@chrono-meta/fh-gate 3.2.0 → 3.5.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 (106) hide show
  1. package/.claude/registry/agent_cards.json +1 -1
  2. package/.claude/rules/fh_4axis_gate.md +25 -0
  3. package/.claude-plugin/marketplace.json +9 -3
  4. package/AGENTS.md +2 -2
  5. package/CATALOG.md +4 -4
  6. package/CHEATSHEET.md +1 -1
  7. package/CLAUDE.md +4 -4
  8. package/README.md +2 -2
  9. package/docs/OUTPUT_EVIDENCE.md +1 -1
  10. package/docs/STANDARDS_ALIGNMENT.md +1 -1
  11. package/docs/codex-compat.md +2 -2
  12. package/knowledge/shared/harness-core/agents_md_runtime_details.md +2 -2
  13. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +30 -3
  14. package/knowledge/shared/harness-core/iso_ai_standards_crosswalk.md +1 -1
  15. package/knowledge/shared/harness-core/skill_quality_rubric.md +1 -1
  16. package/knowledge/shared/learnings/subagent_invocations_log.yaml +120 -4
  17. package/knowledge/shared/rules/modes_and_value.md +2 -2
  18. package/package.json +9 -1
  19. package/plugins/fh-commons/.claude-plugin/plugin.json +2 -2
  20. package/plugins/fh-commons/README.md +38 -0
  21. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  22. package/plugins/fh-meta/CHANGELOG.md +117 -0
  23. package/plugins/fh-meta/skills/agent-composer/SKILL.md +2 -2
  24. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +40 -0
  25. package/plugins/fh-meta/skills/frontier-digest/SKILL.md +1 -1
  26. package/plugins/fh-meta/skills/frontier-digest/SKILL_detail.md +43 -5
  27. package/plugins/fh-meta/skills/{hub-cc-pr-reviewer → harness-pr-reviewer}/SKILL.md +75 -3
  28. package/plugins/fh-meta/skills/{hub-cc-pr-reviewer → harness-pr-reviewer}/SKILL_detail.md +2 -2
  29. package/plugins/fh-meta/skills/harvest-loop/SKILL_detail.md +2 -2
  30. package/plugins/fh-meta/skills/install-doctor/SKILL.md +1 -1
  31. package/plugins/fh-meta/skills/install-wizard/SKILL.md +1 -1
  32. package/plugins/fh-meta/skills/meta-prompt-builder/SKILL.md +1 -1
  33. package/plugins/fh-meta/skills/pipeline-conductor/SKILL.md +1 -1
  34. package/plugins/fh-meta/skills/plugin-recommender/SKILL.md +1 -1
  35. package/plugins/fh-meta/skills/sim-conductor/SKILL.md +1 -1
  36. package/plugins/fh-preprep/.claude-plugin/plugin.json +22 -0
  37. package/plugins/fh-preprep/README.md +24 -0
  38. package/plugins/{fh-commons → fh-preprep}/skills/preprep/SKILL.md +75 -3
  39. package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/mk_slide_fixtures.py +152 -1
  40. package/plugins/fh-preprep/skills/preprep/lane_attr_consistency.py +370 -0
  41. package/plugins/fh-preprep/skills/preprep/lane_geometry.py +339 -0
  42. package/plugins/fh-preprep/skills/preprep/lane_screen_parity.py +232 -0
  43. package/plugins/fh-preprep/skills/preprep/oox.py +305 -0
  44. package/plugins/{fh-commons → fh-preprep}/skills/preprep/preprep.py +52 -4
  45. package/plugins/fh-preprep/skills/preprep/safe_install.py +369 -0
  46. package/plugins/{fh-commons → fh-preprep}/skills/preprep/surfaces.example.yaml +29 -0
  47. package/plugins/fh-preprep/skills/preprep/test_preprep_lanes_rp.py +1417 -0
  48. package/plugins/fh-preprep/skills/preprep/usage_ledger.py +191 -0
  49. package/plugins/fh-qp/.claude-plugin/plugin.json +1 -1
  50. package/scripts/fh-run.sh +13 -0
  51. package/scripts/field_canon_preload.sh +1 -1
  52. package/scripts/finding_fleet.sh +391 -13
  53. package/scripts/finding_pipeline.sh +370 -11
  54. package/scripts/finding_verifier.sh +31 -2
  55. package/scripts/finding_verify.py +198 -13
  56. package/scripts/frontier_digest_autopilot.sh +3 -3
  57. package/scripts/gate_shape_scan.sh +16 -2
  58. package/scripts/selfcheck.sh +12 -8
  59. package/scripts/test_fh_run_resolver_lanes.sh +116 -0
  60. package/scripts/test_files_manifest_shipping_lanes.sh +31 -0
  61. package/scripts/test_finding_pipeline_lanes.sh +1556 -4
  62. package/scripts/test_gate_shape_scan_lanes.sh +11 -0
  63. package/scripts/test_marker_crossfamily_lanes.sh +75 -3
  64. package/scripts/test_marker_standpoint_lanes.sh +31 -6
  65. package/scripts/test_preprep_adjacent_dup_lanes.sh +1 -1
  66. package/scripts/test_preprep_diagram_lanes.sh +1 -1
  67. package/scripts/test_preprep_drift_anchor.sh +27 -12
  68. package/scripts/test_preprep_drift_anchor_lanes.sh +4 -2
  69. package/scripts/test_preprep_font_lanes.sh +7 -7
  70. package/scripts/test_preprep_progression_lanes.sh +1 -1
  71. package/scripts/test_preprep_promise_lanes.sh +1 -1
  72. package/scripts/test_preprep_retired_lanes.sh +45 -2
  73. package/scripts/test_preprep_slide_refs_lanes.sh +5 -5
  74. package/scripts/test_usage_ledger_lanes.sh +243 -0
  75. package/scripts/usage_report.py +211 -0
  76. package/templates/.git-hooks/pre-commit +152 -6
  77. package/templates/local_fh_context.md +1 -1
  78. package/templates/regression_guard.sh +1 -1
  79. package/plugins/fh-commons/skills/preprep/lane_geometry.py +0 -181
  80. package/plugins/fh-commons/skills/preprep/test_preprep_lanes_rp.py +0 -184
  81. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/README.md +0 -0
  82. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/canon_terms.example.yaml +0 -0
  83. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/diagram_from_json.py +0 -0
  84. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/adjdup_known_negative.md +0 -0
  85. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/adjdup_known_positive.md +0 -0
  86. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/fixture_R3_negative.pptx +0 -0
  87. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/fixture_R3_positive.pptx +0 -0
  88. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/font_revert_probe.py +0 -0
  89. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/progression_known_negative.md +0 -0
  90. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/progression_known_positive.md +0 -0
  91. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/promise_known_negative.md +0 -0
  92. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/promise_known_positive.md +0 -0
  93. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/interslide_deps.py +0 -0
  94. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/jargon_terms.example.yaml +0 -0
  95. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_adjacent_dup.py +0 -0
  96. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_diagram.py +0 -0
  97. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_font.py +0 -0
  98. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_progression.py +0 -0
  99. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_promise.py +0 -0
  100. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_slide_refs.py +0 -0
  101. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_slide_relations.py +0 -0
  102. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/ooxml/c1_baseline.txt +0 -0
  103. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/ooxml/gate.py +0 -0
  104. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/preprep_wire.py +0 -0
  105. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/presentation_checklist.md +0 -0
  106. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/test_lane_font.py +0 -0
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env python3
2
+ """P1·P3 — 도형 배치 검사 둘. the private companion store's origin script (lane_geometry.py(2026-09-04,
3
+ 36p 사고에서 역산)를 preprep 관례로 이식한 것 — 로직은 그대로다.
4
+ 정본: `tracks-meta/fh_signal_2026-09-04_preprep-evolution.md` §2.
5
+
6
+ P1 build-jitter 같은 이름의 도형이 «연속한 두 장»에서 조금 다른 자리에 있다
7
+ → 넘길 때 튄다. 실사고: 노란 게이트 막대가 직전 장 대비 35,000 EMU 밀려
8
+ 회색 연결선과 겹쳤다. 큰 차이는 «의도한 이동»이라 제외한다.
9
+ P3 adjacency 한 도형의 «오른쪽 끝»과 다른 도형의 «왼쪽 끝»이 맞닿으려다 어긋났다
10
+ → 실제 결함이 이 형태였다(회색 스텁 끝 ↔ 막대 시작, 35,000 겹침).
11
+
12
+ 🟥 **P2(near-miss, 같은 종류 모서리끼리만 대조)는 미포함이다 — 채택하지 않는다.** 실사고가
13
+ 못 잡히는 이유가 설계 근거였다: 실제 결함은 「회색 스텁 오른쪽 끝 ↔ 막대 왼쪽 끝」이라는
14
+ **다른 종류** 모서리의 인접 관계인데, P2 는 왼↔왼·오른↔오른만 봐서 구조적으로 못 잡는다.
15
+ 그 관계는 P3 가 이미 덮는다. 새 레인을 짤 때 «같은 종류끼리»가 기본값이라는 것을 의심하라.
16
+
17
+ 🟥 둘 다 advisory. 임계는 사람이 정한다(기본 JIT=200,000 EMU · ALN=63,500 EMU=5pt ·
18
+ 1pt=12,700 EMU). `surfaces.yaml` 의 `geometry.jitter_emu` / `geometry.align_emu` 로 바꾼다.
19
+ 🟥 «차이 0» 은 정상이고 «차이 큼» 도 정상이다. 이 검사가 보는 것은 그 사이뿐이다.
20
+
21
+ ## 🟥 목록으로 읽지 마라 — 델타로 읽어라
22
+
23
+ 실측: P3 는 원 코퍼스에서 기저 오탐이 76건이다(아이콘 무리 내부 · 맵 라벨의 «의도된» 겹침).
24
+ 그 목록에서 진짜를 골라낼 방법이 없다. 그런데 편집 전/후를 대조하면 **차이가 정확히 그
25
+ 한 줄**이었다. ⇒ 절대 목록은 회귀 도구로 못 쓰고 **편집 전후 델타는 정확하다.**
26
+ `surfaces.yaml` 의 `geometry.baseline`(이전 판 pptx 경로)이 있으면 이 레인은 델타 모드로 돈다
27
+ — 「지금 몇 건이냐」가 아니라 «내가 방금 뭘 어긋냈냐»를 묻는 회귀 도구다.
28
+
29
+ ## 🟥 P1 이 «비교 안 함»을 «튐 없음»으로 속일 수 있는 자리 (계기 결함, 미리 적는다)
30
+
31
+ `shapes()` 는 **한 장 안에서 이름이 겹치는 도형을 통째로 버린다**(`len(v) == 1` 만 남긴다) —
32
+ 겹치는 이름끼리는 대조할 대상을 하나로 못 정하기 때문이다. python-pptx 기본 이름
33
+ (`TextBox 1` 류)은 장마다 새로 매겨져 **이름이 우연히 안 겹칠 수 있고**, 대조 자체가 0건이
34
+ 되는 입력에서도 「후보 0건」이 똑같이 출력된다 — 그 0 은 **「튐 없음」이 아니라 「비교 안 함」**
35
+ 이다(이 저장소가 이름 붙인 「레인이 초록인 이유는 셋」의 ②). ⇒ `scan()` 은 **비교된 도형-쌍
36
+ 수**를 항상 같이 낸다 — 0 이면 UNMEASURED 로 읽어야 한다는 뜻이다.
37
+
38
+ ## 🟥 이름만으로는 «같은 도형»이 아니다 — 다만 **버리는 게 아니라 신뢰도를 갈라야 한다**
39
+
40
+ 위 절이 «대조 못 한 것»(0건)을 걱정했다면 이 절은 그 반대 — **틀린 것끼리 대조한 것**이다.
41
+ 서로 다른 도해가 `a-b1` · `TextBox 208xx` 같은 **범용 이름을 재사용**하므로, 이름이 같다고
42
+ 같은 도형이 아니다. 실측(120장 한국어 덱, 2026-09-10): 이름이 맞은 **824 쌍** 중
43
+
44
+ 이름+글자 일치 425 (52%) ← 같은 도형이라 말할 근거가 둘
45
+ 이름만 같음 65 ( 8%) ← 다른 내용이 같은 이름을 입었다. **대조 자체가 틀렸다** → 제외
46
+ 양쪽 글자 없음 334 (40%) ← 막대·선·아이콘. 결박할 근거가 **이름뿐**이다
47
+
48
+ 🟥 **그래서 처방이 「이름 AND 글자로 좁힌다」가 아니다 — 그건 한 번 틀렸다.** 2026-09-10 에
49
+ 그렇게 좁혀 놓고 원적 사건 재현 프로브를 돌렸더니 **P1 이 존재하는 이유였던 그 결함이
50
+ 사라졌다**: 35p→36p 의 게이트 막대 `s5017`(x −35,000 EMU, 회색 연결선과 겹침)은 **글자가 없는
51
+ 도형**이라 좁힌 결박이 통째로 잘라냈다. 오탐을 없애면서 자기 known-positive 를 같이 죽인 것이다
52
+ ([[feedback_deletion_beats_repair_dead_filter]] 의 반대 방향 — 이번엔 필터가 진짜를 죽였다).
53
+
54
+ ⇒ **셋을 신뢰도로 가른다. 버리는 것은 하나뿐이다.**
55
+
56
+ bound 이름+글자 일치 → 후보로 낸다 (근거 둘)
57
+ name_only 양쪽 글자 없음 → **후보로 내되 «이름만»이라고 이름표를 붙인다** (근거 하나)
58
+ mismatch 글자가 다름 → 제외. 이것만이 실제로 틀린 결박이다
59
+
60
+ `compared=824` 라는 한 숫자를 안 쓰는 이유가 이것이다 — 그 숫자는 「824 쌍을 봤다」로 읽히지만
61
+ 셋의 성격이 전부 다르다. 🟥 **`bound` 든 `name_only` 든 0 이면 「튐 없음」이 아니라 UNMEASURED 다.**
62
+
63
+ ## 🟥 그리고 이 레인은 «의도된 연출»과 «사고»를 못 가른다 — 판단은 사람이 선언한다
64
+
65
+ 실사고(2026-09-10): 49p→50p 에서 격리 막대가 10.24pt 두꺼워지는 것은 「격리가 세지는」
66
+ **연출**이었는데 이 레인이 튐으로 냈고, 그것을 «고쳤다가» 운영자 지적으로 되돌렸다.
67
+ 🟥 **휴리스틱으로 못 가른다** — 임계의 전제가 「큰 변화는 의도다」인데 그 연출은 임계(15.7pt)
68
+ 바로 아래에 있었다. ⇒ 기계는 «선언되었나»만 보고 «옳은가»는 안 본다(CLAUDE.md
69
+ §Mechanization Boundary — 채널은 굳히고 판단은 안 굳힌다).
70
+
71
+ `surfaces.yaml` 의 `geometry.intended` 에 선언한다:
72
+
73
+ geometry:
74
+ intended:
75
+ - slides: [49, 50]
76
+ shapes: ["s7215", "s7217"]
77
+ attrs: [x, cx] # 🟥 «바뀐 속성 집합»까지 정확히 일치해야 면제된다
78
+ why: "격리 막대 얇음→두꺼움. 「격리가 세지는」 연출"
79
+
80
+ 🟥 **면제는 도형이 아니라 «그 변화»에 걸린다.** 이름만으로 면제하면 그 도형이 나중에 진짜로
81
+ 튈 때 조용히 삼킨다 — 이 저장소가 이름 붙인 「ACK 를 손실 내용에 결박하라」와 같은 자리다.
82
+ 선언한 것 말고 **다른 속성이 바뀌면 그대로 뜬다.** `why` 가 비면 면제가 아니라 **오류**다.
83
+ 🟥 그리고 면제된 것은 **«면제됨»으로 출력한다** — 조용히 사라지면 다음 감사자가 그 연출을
84
+ 다시 «발견»한다(§방법론 ⓕ 「알고도 남긴 것」).
85
+ """
86
+ import zipfile, re, os, collections, html
87
+
88
+ EMU_PT = 12700
89
+
90
+
91
+ import oox as _oox
92
+
93
+
94
+ def shapes(z, sn):
95
+ """한 슬라이드의 도형을 {이름: (x,y,cx,cy,text,flip)} 로. 이름 중복은 대조 못 하니 뺀다.
96
+ 🟥 2026-09-11: 정규식 독자를 oox(트리 독자)로 교체 — 그룹 변환(이동·flip)을 합성한 절대 좌표, 속성 순서·접두·엔티티 무관."""
97
+ out = {}
98
+ walked = _oox.walk_slide(z, sn)
99
+ # R9 A9(codex): 이름 중복은 «장 위의 모든 도형» 에서 센다 — xfrm 없는 자리표시자와 겹치는 이름을 걸러낸 뒤 세면 결박이 그 이름을 «유일» 로 오인한다
100
+ dup = {n_ for n_, c_ in collections.Counter(s_['name'] for s_ in walked if s_['name'] is not None).items() if c_ > 1}
101
+ for s in walked:
102
+ if s['kind'] not in ('sp', 'cxnSp') or s['name'] is None or s['x'] is None or s['name'] in dup:
103
+ continue
104
+ # R8 B11(+R7 rot): 회전·배율 미정의 그룹 아래 도형은 좌표를 «값» 으로 쓰지 않는다 — 7번째 칸에 이름을 남긴다
105
+ unm = 'rot' if s.get('rot_unmeasured') else ('geo' if s.get('geo_unmeasured') else None)
106
+ out.setdefault(s['name'], []).append((s['x'], s['y'], s['cx'], s['cy'], _oox.shape_text(s['paras']), s['flip'], unm))
107
+ return {k: v[0] for k, v in out.items() if len(v) == 1}
108
+
109
+
110
+ def _order(z):
111
+ return _oox.slide_order(z)
112
+
113
+
114
+ def load_slides(path):
115
+ z = zipfile.ZipFile(path)
116
+ return [shapes(z, sn) for sn in _order(z)]
117
+
118
+
119
+ LAB = ['x', 'y', 'cx', 'cy']
120
+ DECLARABLE = LAB + ['flip'] # R8 A5: 뒤집힘도 선언으로 면제할 수 있어야 한다 — 채널 없는 후보는 영원한 후보다
121
+
122
+
123
+ def _intent_index(intended):
124
+ """`geometry.intended` 선언을 조회용으로 편다.
125
+
126
+ 반환: ({(prev_slide, shape_name): [(frozenset(attrs), why), …]}, errors)
127
+ 🟥 `why` 가 비었거나 필드가 빠진 항목은 **면제하지 않고 오류로 낸다** — 사유 없는 면제는
128
+ 이 채널이 존재하는 이유(누가·왜 남겼나)를 지운다."""
129
+ idx, errs = collections.defaultdict(list), []
130
+ for n, e in enumerate(intended or [], 1):
131
+ if not isinstance(e, dict):
132
+ errs.append(f'geometry.intended[{n}] : 매핑이 아니다 — 면제 안 함')
133
+ continue
134
+ sl, sh = e.get('slides') or [], e.get('shapes') or []
135
+ if not isinstance(sh, list) or not isinstance(sl, list) or not isinstance(e.get('attrs') or [], list): # R8 B9: attrs 도
136
+ errs.append(f'geometry.intended[{n}] : shapes·slides 는 목록이어야 한다 (문자열 "ab" 는 a·b 두 도형으로 읽힌다) — 면제 안 함') # R3 A9
137
+ continue
138
+ at, why = e.get('attrs') or [], (e.get('why') or '').strip()
139
+ if len(sl) != 2 or not sh or not at:
140
+ errs.append(f'geometry.intended[{n}] : slides(2개)·shapes·attrs 가 모두 있어야 한다 — 면제 안 함')
141
+ continue
142
+ # 🟥 면제는 «그 변화» 에 걸린다 — 변화는 i→i+1 사이에 있으므로 두 끝점이 모두 정수이고
143
+ # 연속이어야 한다. [1, 99] 나 [1, "garbage"] 가 1→2 의 변화를 면제하던 구멍(codex 09-11).
144
+ if not all(isinstance(v, int) and not isinstance(v, bool) for v in sl) or sl[1] != sl[0] + 1:
145
+ errs.append(f'geometry.intended[{n}] : slides 는 연속한 두 정수 [i, i+1] 이어야 한다 (받은 값 {sl!r}) — 면제 안 함')
146
+ continue
147
+ bad = [a for a in at if a not in DECLARABLE]
148
+ if bad:
149
+ errs.append(f'geometry.intended[{n}] : attrs 에 모르는 이름 {bad} (x·y·cx·cy·flip 뿐) — 면제 안 함')
150
+ continue
151
+ if not why:
152
+ errs.append(f'geometry.intended[{n}] : 🟥 why 가 비었다 — 사유 없는 면제는 오류다, 면제 안 함')
153
+ continue
154
+ for name in sh:
155
+ idx[(int(sl[0]), name)].append((frozenset(at), why, n))
156
+ return idx, errs
157
+
158
+
159
+ def p1_lines(S, JIT, intended=None):
160
+ """build-jitter 후보.
161
+
162
+ 반환: (lines, stats, suppressed, errors)
163
+ stats = {'bound', 'name_only', 'mismatch'} — 🟥 셋을 한 숫자로 접지 않는다.
164
+ `bound`/`name_only` 가 둘 다 0 이면 «튐 없음»이 아니라 UNMEASURED 다.
165
+ lines = 후보. `name_only` 결박은 줄 끝에 **[이름만]** 이 붙는다 — 성격이 다르니
166
+ 같은 목록에 두되 같은 것처럼 읽히면 안 된다.
167
+ suppressed = `geometry.intended` 로 면제된 줄 (조용히 버리지 않고 같이 낸다)"""
168
+ idx, errors = _intent_index(intended)
169
+ declared = {n for v in idx.values() for _a, _w, n in v}
170
+ hits = collections.Counter()
171
+ lines, suppressed = [], []
172
+ stats = collections.Counter()
173
+ for i in range(1, len(S)):
174
+ for nm, cur in S[i].items():
175
+ prv = S[i - 1].get(nm)
176
+ if not prv:
177
+ continue
178
+ # 🟥 이름은 결박의 «필요조건»이다. 글자는 근거를 하나 더 얹을 뿐이고,
179
+ # 글자가 없다고 도형이 없는 게 아니다 — 잘라내면 원적 사건이 죽는다(위 절).
180
+ if cur[4] != prv[4]:
181
+ stats['mismatch'] += 1 # 다른 내용이 같은 이름을 입었다 → 결박 자체가 틀렸다
182
+ continue
183
+ if (len(cur) > 6 and cur[6]) or (len(prv) > 6 and prv[6]):
184
+ stats['unmeasured'] += 1 # R8 B11: 회전/배율 미정의 그룹 아래 — 좌표가 값이 아니다. 0 으로 접지 않는다
185
+ continue
186
+ weak = not cur[4] # 양쪽 다 글자 없음 → 근거가 이름 하나뿐
187
+ stats['name_only' if weak else 'bound'] += 1
188
+ # 🟥 뒤집힘은 «근사»가 아니다 — 임계와 무관하게 낸다. 상자가 한 EMU도 안 움직여도
189
+ # 화면에서는 화살표가 반대를 가리킨다.
190
+ flip_changed = cur[5] != prv[5]
191
+ d = [cur[k] - prv[k] for k in range(4)]
192
+ mx = max(abs(v) for v in d)
193
+ jitter = 0 < mx <= JIT
194
+ moved_set = frozenset(LAB[k] for k in range(4) if d[k]) if jitter else frozenset()
195
+ # R8 A5: 면제 조회 키 = «이 쌍에서 실제로 바뀐 것» — 뒤집힘 + 튐 속성. 선언은 그 집합과 정확히 같아야 한다
196
+ changed = moved_set | (frozenset(['flip']) if flip_changed else frozenset())
197
+ hit = next(((w, n) for a, w, n in idx.get((i, nm), []) if a == changed), None) if changed else None
198
+ if flip_changed:
199
+ fl = (f" {i:>3}p→{i+1:<3}p {nm} 🟥 뒤집힘 "
200
+ f"«{prv[5] or '없음'}» → «{cur[5] or '없음'}» "
201
+ f"— 자리는 그대로여도 방향이 바뀐다 "
202
+ + ('[이름만 결박]' if weak else f'«{cur[4][:20]}»'))
203
+ stats['flipped'] += 1
204
+ (suppressed if hit is not None else lines).append(fl + (f" ← 선언된 연출: {hit[0]}" if hit is not None else ''))
205
+ if not jitter:
206
+ if hit is not None:
207
+ hits[hit[1]] += 1
208
+ continue
209
+ moved = ' · '.join(f'{LAB[k]} {d[k]:+d}' for k in range(4) if d[k])
210
+ line = (f" {i:>3}p→{i+1:<3}p {nm} {moved} ({mx/EMU_PT:.2f}pt) " # R3 A8: 줄이 곧 신원 — 이름을 자르면 델타가 두 도형을 합친다
211
+ + ('[이름만 결박 — 글자 없는 도형]' if weak else f'«{cur[4][:26]}»'))
212
+ if hit is not None:
213
+ suppressed.append(line + f" ← 선언된 연출: {hit[0]}")
214
+ hits[hit[1]] += 1
215
+ else:
216
+ lines.append(line)
217
+ # 🟥 아무것도 안 거는 선언은 «알고 남긴 것» 이 아니라 «알던 것이 틀린 것» 이다 — 출력이 같으면
218
+ # 구분이 안 된다(덱 세션 실측 09-11: 유령 후보에 why 를 붙여 선언해 둔 것이 수리 뒤 죽어 있었다).
219
+ for n in sorted(declared):
220
+ if hits.get(n, 0) == 0:
221
+ errors.append(f'geometry.intended[{n}] : 🟥 죽은 선언 — 이 덱에서 아무 변화도 면제하지 않는다(가리키는 변화가 없다). 지우거나 고쳐라')
222
+ return lines, dict(stats), suppressed, errors
223
+
224
+
225
+ def p3_lines(S, ALN):
226
+ """adjacency 후보 — A 오른쪽 끝 ↔ B 왼쪽 끝, 세로로 겹치는 띠에 있을 때만."""
227
+ lines = []
228
+ for i, sh in enumerate(S):
229
+ items = [(nm, v) for nm, v in sh.items() if v[2] > 0 and v[3] > 0 and not (len(v) > 6 and v[6])] # R8 B11
230
+ for a in range(len(items)):
231
+ for b in range(len(items)):
232
+ if a == b:
233
+ continue
234
+ na, va = items[a]
235
+ nb, vb = items[b]
236
+ oy = min(va[1] + va[3], vb[1] + vb[3]) - max(va[1], vb[1])
237
+ if oy <= 0:
238
+ continue
239
+ gap = vb[0] - (va[0] + va[2]) # A 오른쪽 → B 왼쪽
240
+ if 0 < abs(gap) <= ALN:
241
+ kind = '겹침' if gap < 0 else '틈'
242
+ lines.append(f" {i+1:>3}p {na} 오른끝 → {nb} 왼끝 "
243
+ f"{kind} {abs(gap):>6} EMU ({abs(gap)/EMU_PT:.2f}pt)")
244
+ return lines
245
+
246
+
247
+ def collect_lines(path, JIT, ALN, intended=None):
248
+ """(lines, stats, suppressed, errors) — P1+P3 합친 후보 줄과 P1 의 결박 내역."""
249
+ S = load_slides(path)
250
+ l1, stats, suppressed, errors = p1_lines(S, JIT, intended)
251
+ l3 = p3_lines(S, ALN)
252
+ return l1 + l3, stats, suppressed, errors
253
+
254
+
255
+ def _fmt_stats(stats):
256
+ """🟥 세 숫자를 한 줄로 묶되 **합치지는 않는다** — 성격이 셋 다 다르다."""
257
+ b, w, m = (stats.get('bound', 0), stats.get('name_only', 0), stats.get('mismatch', 0))
258
+ s = f"이름+글자 {b}쌍 · 이름만(글자 없는 도형) {w}쌍 · 글자 불일치 {m}쌍 제외"
259
+ if stats.get('flipped'):
260
+ s += f" · 🟥 뒤집힘 {stats['flipped']}건"
261
+ if stats.get('unmeasured'):
262
+ s += f" · ⬜ 회전/배율 미정의 그룹 아래 {stats['unmeasured']}쌍 UNMEASURED"
263
+ if not (b or w):
264
+ s += ' — 🟥 결박 0 이면 「튐 없음」이 아니라 UNMEASURED 다'
265
+ return s
266
+
267
+
268
+ def delta(base_path, cur_path, JIT, ALN, intended=None):
269
+ """편집 전/후 델타 — 절대 목록이 아니라 **차이**로 읽는 용법(정확했던 것). P3 는 SYS 를 안 쓰니
270
+ 양쪽에 같은 규칙을 그대로 적용하면 된다(계기 자기결함이 P2 전용이라 여기 안 옮는다)."""
271
+ bl, st_b, sup_b, _err_b = collect_lines(base_path, JIT, ALN, intended)
272
+ cur, st_c, sup_c, err_c = collect_lines(cur_path, JIT, ALN, intended)
273
+ b = set(bl)
274
+ new = [l for l in cur if l not in b]
275
+ gone = [l for l in bl if l not in set(cur)]
276
+ # R2 A6: 선언 오류(죽은 선언 포함)는 «현재본» 의 것과 같이 나가야 한다 — 기준본 것을 내던 것은 다른 덱의 답
277
+ return new, gone, st_b, st_c, sup_c, err_c
278
+
279
+
280
+ def _resolve(root, p):
281
+ return os.path.normpath(os.path.join(root, os.path.expanduser(p)))
282
+
283
+
284
+ def scan(cfg, root):
285
+ """preprep.py 가 부르는 레인 진입점. (findings, notes) — 🟥 **advisory 고정**:
286
+ 호출자는 findings 를 버리고 notes 만 취한다(L8·L11 관례)."""
287
+ deck_s = (cfg.get('surfaces_by_id') or {}).get('built_deck')
288
+ if not deck_s:
289
+ return [], ['P1/P3 geometry : built_deck 미선언 — NOT_CONFIGURED (0 아님)']
290
+ deck = _resolve(root, deck_s['path'])
291
+ if not os.path.exists(deck):
292
+ return [], [f'P1/P3 geometry : built_deck 실물 없음({deck}) — UNMEASURED (0 아님)']
293
+ spec = cfg.get('geometry') or {}
294
+ JIT = int(spec.get('jitter_emu', 200000))
295
+ ALN = int(spec.get('align_emu', 63500))
296
+ base_p = spec.get('baseline')
297
+ intended = spec.get('intended') or []
298
+ notes = []
299
+
300
+ def _tail(suppressed, errors):
301
+ """면제·선언 오류는 **항상 같이 낸다** — 조용히 사라진 면제가 §방법론 ⓕ 의 사고다."""
302
+ out = []
303
+ for e in errors:
304
+ out.append(' 🟥 ' + e)
305
+ for l in suppressed:
306
+ out.append(' ~ (면제)' + l.strip())
307
+ if suppressed:
308
+ out.append(f' ~ 선언된 연출 {len(suppressed)}건을 면제했다 — 「없다」가 아니라 「알고 남겼다」')
309
+ return out
310
+
311
+ if base_p:
312
+ base = _resolve(root, base_p)
313
+ if not os.path.exists(base):
314
+ return [], [f'P1/P3 geometry : baseline 실물 없음({base}) — UNMEASURED (0 아님)']
315
+ try:
316
+ new, gone, st_b, st_c, sup, err = delta(base, deck, JIT, ALN, intended)
317
+ except Exception as e:
318
+ return [], [f'P1/P3 geometry : 계기 오류({type(e).__name__}: {e}) — UNMEASURED (0 아님)']
319
+ notes.append(f'P1/P3 geometry(델타) : 기준 {os.path.basename(base)} 대비 '
320
+ f'새 어긋남 {len(new)}건 · 사라진 어긋남 {len(gone)}건 🟥 advisory')
321
+ notes.append(f' P1 결박(기준) : {_fmt_stats(st_b)}')
322
+ notes.append(f' P1 결박(현재) : {_fmt_stats(st_c)}')
323
+ for l in new:
324
+ notes.append(' + ' + l.strip())
325
+ for l in gone:
326
+ notes.append(' - ' + l.strip())
327
+ return [], notes + _tail(sup, err)
328
+
329
+ try:
330
+ lines, stats, sup, err = collect_lines(deck, JIT, ALN, intended)
331
+ except Exception as e:
332
+ return [], [f'P1/P3 geometry : 계기 오류({type(e).__name__}: {e}) — UNMEASURED (0 아님)']
333
+ notes.append(f'P1/P3 geometry(목록) : 후보 {len(lines)}건 — 🟥 절대 목록으로 읽지 마라, '
334
+ f'P3 기저 오탐이 코퍼스마다 수십 건일 수 있다(원 코퍼스 실측 76건). '
335
+ f'`geometry.baseline` 을 쓰는 편집-델타 용법이 정확하다')
336
+ notes.append(f' P1 결박 : {_fmt_stats(stats)}')
337
+ for l in lines:
338
+ notes.append(l)
339
+ return [], notes + _tail(sup, err)
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env python3
2
+ """L14 screen-parity — **원고의 화면 블록(🖥)과 덱 화면이 갈렸나.**
3
+
4
+ ## 왜 이 레인이 없었나, 그리고 왜 그게 이상한가 (2026-09-10)
5
+
6
+ 이 하네스의 정체는 «표면 사이»다 — SKILL 첫 절이 그렇게 적는다. 그런데 **가장 큰 쌍이
7
+ 비어 있었다**: `surfaces.yaml` 이 원고를 «화면 문자열의 정본»(`canonical_for: [화면 문자열]`)
8
+ 이라고 선언해 놓고, **그 선언을 집행하는 레인이 하나도 없었다.**
9
+
10
+ 실사고(같은 날): 운영자가 PowerPoint 에서 직접 **7곳**을 고쳤다 — 라벨 두 종류를 지우고
11
+ 세 구간의 문장을 손봤다. **`preprep` 은 `rc=0` 을 냈다.** 덱은 새 문장을, 원고는 옛 문장을
12
+ 들고 있는 채로 통과한 것이다.
13
+ 🟥 「선언은 있는데 소비처가 0」 — 이 저장소가 이름 붙인 반쪽 외부화 그대로다.
14
+
15
+ ## 무엇을 재나
16
+
17
+ 원고 단위 i 의 🖥 블록 줄들 ↔ 덱 i 번째 장의 화면 문자열들
18
+
19
+ 공백을 지우고 대조해서 **짝이 없는 줄**을 낸다. 🟥 그리고 «없는 줄»과 «문구만 다른 줄»을
20
+ **갈라서** 낸다 — 합치면 계기가 거짓말한다:
21
+
22
+ 🗑/➕ 짝 없음 한쪽에만 있고 비슷한 상대가 아예 없다 → 진짜 드리프트 후보
23
+ ✏️ 문구 다름 비슷한 상대가 있다(기본 유사도 0.60) → 손질했는데 한쪽만 반영
24
+
25
+ ## 🟥 advisory 고정이고, 그 이유가 실측에 있다
26
+
27
+ 이 덱(120장, 손질을 여러 차례 거친 상태)에서 **짝 없음 53줄 · 문구 다름 38줄**이 뜬다.
28
+ 원고 🖥 블록은 화면의 «모든» 도형을 적는 자리가 아니라 사람이 적는 요약이라, 아이콘 라벨·
29
+ 보조 문구가 구조적으로 빠진다. ⇒ **후보 0 을 목표로 삼으면 오독이다.** L8·L11 과 같은
30
+ 성격이고, 종료코드에 태우면 최저비용 무마가 «원고를 화면에 맞춰 기계적으로 덮어쓰는 것»이
31
+ 되어 정본이 산출물을 따라가는 역전이 난다.
32
+
33
+ ⚠️ **매핑은 순서다** — 원고 i 번째 단위 ↔ 덱 i 번째 장. 개수가 다르면 통째로 **UNMEASURED**
34
+ 로 내고 아무것도 대조하지 않는다(어긋난 채 짝지으면 전량 오탐이 된다).
35
+ """
36
+ import html, re, os, zipfile, difflib, collections
37
+
38
+ def manuscript_screens(path):
39
+ """(순서대로의 [(단위id, [화면줄…])], 총 단위 수)"""
40
+ s = open(path, encoding='utf-8').read()
41
+ out = []
42
+ for blk in re.split(r'^###\s+', s, flags=re.M)[1:]:
43
+ head = blk.split('\n', 1)[0] # R8 B12: 단위 id 는 «제목 줄» 안에서만 — '·' 가 없으면 블록 전체가 id 가 됐다
44
+ uid = re.split(r'\s*·', head, 1)[0].strip() or '(제목 없음)'
45
+ # 🖥 는 U+1F5A5 뒤에 변이 선택자(U+FE0F)나 공백이 붙어 올 수 있다 — 그 변이를 못 읽으면
46
+ # «단위 0» 이 되고 «어긋남 0» 으로 접힌다(codex 09-11)
47
+ m = re.search(r'🖥\ufe0f?[ \t]*\n(.*?)(?=\n🗣|\Z)', blk, re.S)
48
+ lines = [l.strip() for l in (m.group(1).split('\n') if m else []) if l.strip()]
49
+ out.append((uid, lines))
50
+ return out
51
+
52
+
53
+ def deck_screens(path):
54
+ """장별 [문단 텍스트 …] (평평하게). 🟥 deprecated — 이 결과를 compare() 에 «deck_groups 없이» 넘기면
55
+ 조용히 1단계로 돌아 다문단 도형마다 오탐이 난다(덱 세션 실측 09-11: 279 vs 48). 새 호출자는
56
+ deck_screens_grouped() 를 쓰고 compare(..., deck_groups=groups) 로 불러라."""
57
+ return [[t for shp in sl for t in shp] for sl in deck_screens_grouped(path)]
58
+
59
+
60
+ def deck_screens_grouped(path):
61
+ """장별 [[도형의 문단 …] …] — 🟥 원고 🖥 는 «도형 단위» 로 쓰이고 덱은 «문단 단위» 로 읽힌다.
62
+ 알갱이가 다른 채 짝지으면 다문단 도형마다 1+N 건 오탐이 난다(실측 47 → 279). 그래서 도형 경계를
63
+ 같이 넘겨 compare 가 두 단계로 맞춘다. 2026-09-11: 정규식 독자 → oox(트리). 표 셀 문단 포함."""
64
+ import oox as _oox
65
+ z = zipfile.ZipFile(path)
66
+ out = []
67
+ for sn in _oox.slide_order(z):
68
+ shapes = []
69
+ for s in _oox.walk_slide(z, sn):
70
+ paras = _oox.para_texts(s['paras'])
71
+ if paras:
72
+ shapes.append(paras)
73
+ out.append(shapes)
74
+ return out
75
+
76
+
77
+ def _norm(t):
78
+ return re.sub(r'\s', '', t)
79
+
80
+
81
+ def compare(man, deck, threshold=0.60, skip=(), deck_groups=None):
82
+ """(absent, wording, stats) — 🟥 둘을 합치지 않는다.
83
+ deck_groups 가 있으면 2단계: ① 문단 단위로 맞추고 ② 남은 원고 줄을 «같은 도형의 남은 문단 join»
84
+ 과 한 번 더 맞춘 뒤에야 짝 없음으로 낸다(원고는 도형 단위로 쓰인 면이다 — 덱 세션 실측 09-11)."""
85
+ absent, wording = [], []
86
+ joined_hits = 0
87
+ if deck_groups is None:
88
+ # 🟥 같은 얼굴로 다른 동작을 내지 않는다 — 1단계로 도는 것은 stats 에도, stderr 에도 남긴다
89
+ import sys
90
+ print('lane_screen_parity.compare: deck_groups 없음 — 1단계(문단 평면)만 돈다. '
91
+ '다문단 도형은 오탐이 난다. deck_screens_grouped() 를 넘겨라', file=sys.stderr)
92
+ for i, (uid, mlines) in enumerate(man):
93
+ if uid in skip:
94
+ continue
95
+ M = [_norm(t) for t in mlines]
96
+ D = [_norm(t) for t in deck[i]]
97
+ # 🟥 다중성을 보존한다 — 원고에 «Title» 이 둘이고 덱에 하나면 하나가 짝이 없다(집합은 그걸 지운다)
98
+ # R2 B12: 다중성은 Counter 로 — 같은 문단이 둘이면 둘 다 세고, 둘 다 빼야 한다(list.remove 는 크래시)
99
+ pool = collections.Counter(t for t in D if t)
100
+ M = [t for t in M if t]
101
+ # ⓪ R3 B13: «도형 전체 join 이 원고 줄과 정확히 같은» 짝을 먼저 소비한다 — 문단 단위가 먼저 먹으면
102
+ # (원고 AB·ABX ↔ 도형 [AB,X]·[A,B]) 옳은 배정이 막힌다.
103
+ consumed_shapes = set() # R4 A3: ⓪ 에서 통째로 소비된 도형은 ② 가 다시 빌리지 못한다
104
+ if deck_groups is not None:
105
+ for si, paras in enumerate(deck_groups[i]):
106
+ ps = [_norm(p) for p in paras if _norm(p)]
107
+ if len(ps) < 2 or not all(pool[p] > 0 for p in ps):
108
+ continue
109
+ j = ''.join(ps)
110
+ if j in M:
111
+ M.remove(j)
112
+ for p in ps:
113
+ pool[p] -= 1
114
+ joined_hits += 1
115
+ consumed_shapes.add(si)
116
+ only_m = []
117
+ for t in M:
118
+ if pool[t] > 0:
119
+ pool[t] -= 1
120
+ else:
121
+ only_m.append(t)
122
+ # ② 도형 단위 재대조 — 남은 원고 줄이 «어느 도형의 «아직 짝 없는» 문단을 이어 붙인 것» 이면 짝이다
123
+ # (R2 B11: 1단계에서 일부가 맞았어도 나머지를 잇는다). 🟥 R2 A5: 정확 일치만 «조용히» 사라진다 —
124
+ # 유사 일치는 문구 차이로 남긴다(맞춘 것과 다른 것을 같은 얼굴로 내지 않는다).
125
+ if deck_groups is not None and only_m:
126
+ for si, paras in enumerate(deck_groups[i]):
127
+ if si in consumed_shapes:
128
+ continue
129
+ avail = collections.Counter(pool)
130
+ ps = []
131
+ for p in (_norm(p) for p in paras):
132
+ if p and avail[p] > 0:
133
+ avail[p] -= 1
134
+ ps.append(p)
135
+ if len(ps) < 2:
136
+ continue
137
+ j = ''.join(ps)
138
+ hit = next((t for t in only_m if t == j), None)
139
+ fuzzy = None
140
+ if hit is None:
141
+ cand = max(((difflib.SequenceMatcher(None, t, j).ratio(), t) for t in only_m), default=(0.0, None))
142
+ if cand[0] >= threshold:
143
+ hit, fuzzy = cand[1], cand[0]
144
+ if hit is not None:
145
+ only_m.remove(hit)
146
+ for p in ps:
147
+ pool[p] -= 1
148
+ joined_hits += 1
149
+ consumed_shapes.add(si)
150
+ if fuzzy is not None:
151
+ wording.append((i + 1, uid, hit, j, fuzzy))
152
+ for t in only_m:
153
+ best = max(((difflib.SequenceMatcher(None, t, c).ratio(), c) for c in pool.elements()),
154
+ default=(0.0, None))
155
+ if best[0] >= threshold:
156
+ pool[best[1]] -= 1
157
+ wording.append((i + 1, uid, t, best[1], best[0]))
158
+ else:
159
+ absent.append((i + 1, uid, '원고에만', t))
160
+ for t in pool.elements():
161
+ absent.append((i + 1, uid, '화면에만', t))
162
+ slides = {a[0] for a in absent} | {w[0] for w in wording}
163
+ return absent, wording, {'units': len(man), 'slides_touched': len(slides), 'joined': joined_hits,
164
+ 'stage': 2 if deck_groups is not None else 1}
165
+
166
+
167
+ def _resolve(root, p):
168
+ return os.path.normpath(os.path.join(root, os.path.expanduser(p)))
169
+
170
+
171
+ def scan(cfg, root):
172
+ """preprep.py 진입점. 🟥 **advisory 고정** — findings 를 안 낸다."""
173
+ by_id = cfg.get('surfaces_by_id') or {}
174
+ deck_s = by_id.get('built_deck')
175
+ if not deck_s:
176
+ return [], ['L14 screen-parity : built_deck 미선언 — NOT_CONFIGURED (0 아님)']
177
+ # 원고 표면 = interslide.manuscript 가 가리키는 것 (없으면 화면 정본을 선언한 표면)
178
+ mid = ((cfg.get('interslide') or {}).get('manuscript')
179
+ or next((k for k, v in by_id.items()
180
+ if '화면 문자열' in (v.get('canonical_for') or [])), None))
181
+ if not mid or mid not in by_id:
182
+ return [], ['L14 screen-parity : 화면 문자열의 정본 표면이 선언되지 않았다 '
183
+ '— NOT_CONFIGURED (0 아님). `interslide.manuscript` 또는 '
184
+ "`canonical_for: [화면 문자열]` 로 지정해라"]
185
+ mp, dp = _resolve(root, by_id[mid]['path']), _resolve(root, deck_s['path'])
186
+ for p, lab in ((mp, '원고'), (dp, '덱')):
187
+ if not os.path.exists(p):
188
+ return [], ['L14 screen-parity : %s 실물 없음(%s) — UNMEASURED (0 아님)' % (lab, p)]
189
+ spec = cfg.get('screen_parity') or {}
190
+ try:
191
+ man = manuscript_screens(mp)
192
+ groups = deck_screens_grouped(dp)
193
+ deck = [[t for shp in sl for t in shp] for sl in groups]
194
+ except Exception as e:
195
+ return [], ['L14 screen-parity : 계기 오류(%s: %s) — UNMEASURED (0 아님)'
196
+ % (type(e).__name__, e)]
197
+ # 🟥 어긋난 채 짝지으면 전량 오탐이다. 개수가 다르면 아무것도 대조하지 않는다.
198
+ if len(man) != len(deck):
199
+ return [], ['L14 screen-parity : 원고 단위 %d개 ↔ 덱 %d장 — 개수가 달라 순서 매핑을 '
200
+ '못 세운다. UNMEASURED (0 아님, 「어긋남 없음」 아님)' % (len(man), len(deck))]
201
+ absent, wording, st = compare(man, deck,
202
+ float(spec.get('wording_threshold', 0.60)),
203
+ set(spec.get('skip_units') or ()), deck_groups=groups)
204
+ notes = ['L14 screen-parity : 짝 없는 줄 %d · 문구만 다른 줄 %d '
205
+ '(단위 %d · 어긋난 장 %d · 도형 join 으로 맞춘 원고 줄 %d) — 🟥 advisory. 원고 🖥 는 화면의 «모든» 도형을 적는 '
206
+ '자리가 아니라 사람이 적는 요약이라 후보 0 이 목표가 아니다'
207
+ % (len(absent), len(wording), st['units'], st['slides_touched'], st.get('joined', 0))]
208
+ for sn, uid, side, t in absent[:int(spec.get('max_show', 24))]:
209
+ notes.append(' 🗑 %3dp %-7s %s : «%s»' % (sn, uid, side, t[:50]))
210
+ if len(absent) > int(spec.get('max_show', 24)):
211
+ notes.append(' … 짝 없는 줄 %d개 더' % (len(absent) - int(spec.get('max_show', 24))))
212
+ for sn, uid, a, b, r in wording[:int(spec.get('max_show_wording', 8))]:
213
+ notes.append(' ✏️ %3dp %-7s 원고 «%s»' % (sn, uid, a[:44]))
214
+ notes.append(' %s 화면 «%s» (닮음 %.2f)' % (' ' * 12, b[:44], r))
215
+ if len(wording) > int(spec.get('max_show_wording', 8)):
216
+ notes.append(' … 문구만 다른 줄 %d개 더' % (len(wording) - int(spec.get('max_show_wording', 8))))
217
+ return [], notes
218
+
219
+
220
+ if __name__ == '__main__':
221
+ import sys
222
+ man = manuscript_screens(sys.argv[1])
223
+ deck = deck_screens(sys.argv[2])
224
+ if len(man) != len(deck):
225
+ print('🟥 단위 %d ↔ 장 %d — UNMEASURED' % (len(man), len(deck)))
226
+ raise SystemExit(2)
227
+ a, w, st = compare(man, deck)
228
+ print('짝 없음 %d · 문구 다름 %d · %s' % (len(a), len(w), st))
229
+ for sn, uid, side, t in a:
230
+ print(' 🗑 %3dp %-7s %s «%s»' % (sn, uid, side, t[:56]))
231
+ for sn, uid, x, y, r in w:
232
+ print(' ✏️ %3dp %-7s %.2f\n 원고 «%s»\n 화면 «%s»' % (sn, uid, r, x[:56], y[:56]))