@chrono-meta/fh-gate 2.15.1 → 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 (134) 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 +88 -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 +13 -3
  8. package/README.ja.md +51 -7
  9. package/README.ko.md +48 -7
  10. package/README.md +43 -6
  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 +77 -10
  17. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +18 -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 +35 -1
  21. package/knowledge/shared/harness-core/ship_readiness_gate.md +19 -2
  22. package/knowledge/shared/learnings/subagent_invocations_log.yaml +489 -3
  23. package/package.json +48 -2
  24. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  25. package/plugins/fh-commons/skills/preprep/README.md +4 -1
  26. package/plugins/fh-commons/skills/preprep/SKILL.md +99 -3
  27. package/plugins/fh-commons/skills/preprep/diagram_from_json.py +154 -0
  28. package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_negative.pptx +0 -0
  29. package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_positive.pptx +0 -0
  30. package/plugins/fh-commons/skills/preprep/fixtures/mk_slide_fixtures.py +179 -0
  31. package/plugins/fh-commons/skills/preprep/interslide_deps.py +98 -9
  32. package/plugins/fh-commons/skills/preprep/lane_adjacent_dup.py +4 -1
  33. package/plugins/fh-commons/skills/preprep/lane_diagram.py +95 -0
  34. package/plugins/fh-commons/skills/preprep/lane_geometry.py +181 -0
  35. package/plugins/fh-commons/skills/preprep/lane_promise.py +4 -1
  36. package/plugins/fh-commons/skills/preprep/lane_slide_refs.py +134 -0
  37. package/plugins/fh-commons/skills/preprep/lane_slide_relations.py +300 -0
  38. package/plugins/fh-commons/skills/preprep/preprep.py +129 -8
  39. package/plugins/fh-commons/skills/preprep/preprep_wire.py +210 -0
  40. package/plugins/fh-commons/skills/preprep/presentation_checklist.md +4 -0
  41. package/plugins/fh-commons/skills/preprep/surfaces.example.yaml +18 -0
  42. package/plugins/fh-commons/skills/preprep/test_preprep_lanes_rp.py +184 -0
  43. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  44. package/plugins/fh-meta/CHANGELOG.md +67 -0
  45. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +87 -2
  46. package/plugins/fh-meta/skills/frontier-digest/SKILL.md +2 -2
  47. package/plugins/fh-meta/skills/frontier-digest/SKILL_detail.md +25 -5
  48. package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL.md +11 -0
  49. package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL_detail.md +3 -0
  50. package/plugins/fh-qp/.claude-plugin/plugin.json +22 -0
  51. package/plugins/fh-qp/README.md +71 -0
  52. package/plugins/fh-qp/fixtures/evidence_known_clean.txt +3 -0
  53. package/plugins/fh-qp/fixtures/evidence_known_dirty.txt +6 -0
  54. package/plugins/fh-qp/fixtures/reach_known_partial.tsv +4 -0
  55. package/plugins/fh-qp/fixtures/reach_known_reached.tsv +4 -0
  56. package/plugins/fh-qp/fixtures/reach_known_wall.tsv +3 -0
  57. package/plugins/fh-qp/fixtures/verdicts_known_bad_branch.tsv +2 -0
  58. package/plugins/fh-qp/fixtures/verdicts_known_bad_vacuous_machine.tsv +2 -0
  59. package/plugins/fh-qp/fixtures/verdicts_known_good.tsv +4 -0
  60. package/plugins/fh-qp/fixtures/verdicts_verify_only.tsv +3 -0
  61. package/plugins/fh-qp/qp_profile.example.yaml +29 -0
  62. package/plugins/fh-qp/scripts/qp_tools.sh +216 -0
  63. package/plugins/fh-qp/skills/qp/SKILL.md +65 -0
  64. package/plugins/fh-qp/skills/qp-plan/SKILL.md +42 -0
  65. package/plugins/fh-qp/skills/qp-regress/SKILL.md +45 -0
  66. package/plugins/fh-qp/skills/qp-run/SKILL.md +49 -0
  67. package/scripts/backtick_guard.sh +194 -0
  68. package/scripts/chamber_run.sh +14 -5
  69. package/scripts/com.forge-harness.live-eval.plist +84 -0
  70. package/scripts/compaction_probe.sh +9 -35
  71. package/scripts/context_continuity_score.sh +49 -7
  72. package/scripts/directional_diff_gate.sh +14 -2
  73. package/scripts/fh-gate.sh +3 -3
  74. package/scripts/files_manifest_shipping_check.sh +19 -0
  75. package/scripts/frontier_digest_autopilot.sh +4 -1
  76. package/scripts/gate_pathspec_check.sh +1 -1
  77. package/scripts/map_postprocess.py +90 -0
  78. package/scripts/outbound_query_guard.sh +131 -0
  79. package/scripts/outbound_query_hook.sh +373 -0
  80. package/scripts/package_coverage_check.sh +79 -18
  81. package/scripts/pipe_verdict_guard.sh +41 -1
  82. package/scripts/probe_live_eval.sh +240 -0
  83. package/scripts/probe_live_eval_lib.py +579 -0
  84. package/scripts/proposal_hook.sh +192 -0
  85. package/scripts/public_surface_scan_files.sh +11 -2
  86. package/scripts/push_zone_check.sh +78 -0
  87. package/scripts/residency_closure_scan.py +252 -0
  88. package/scripts/revert_probe.sh +250 -0
  89. package/scripts/selfcheck.sh +106 -3
  90. package/scripts/session_close_check.sh +100 -0
  91. package/scripts/sim_isolated_run.sh +195 -9
  92. package/scripts/test_action_yml_lanes.sh +97 -0
  93. package/scripts/test_backtick_guard_lanes.sh +115 -0
  94. package/scripts/test_degrade_scan_shell_probes.sh +7 -7
  95. package/scripts/test_fh_qp_lanes.sh +105 -0
  96. package/scripts/test_files_manifest_shipping_lanes.sh +5 -5
  97. package/scripts/test_gate_two_verdicts_lanes.sh +139 -0
  98. package/scripts/test_heavy_classifier_lanes.sh +1 -1
  99. package/scripts/test_lane_runner_lanes.sh +59 -33
  100. package/scripts/test_map_postprocess_lanes.sh +143 -0
  101. package/scripts/test_mapped_tracks_lanes.sh +1 -1
  102. package/scripts/test_marker_affected_lanes.sh +93 -0
  103. package/scripts/test_marker_crossfamily_lanes.sh +90 -6
  104. package/scripts/test_marker_oracle_lanes.sh +136 -0
  105. package/scripts/test_marker_soul_check_lanes.sh +24 -0
  106. package/scripts/test_node_check_lanes.sh +34 -34
  107. package/scripts/test_outbound_query_hook_lanes.sh +433 -0
  108. package/scripts/test_outbound_query_lanes.sh +87 -0
  109. package/scripts/test_package_coverage_lanes.sh +53 -27
  110. package/scripts/test_pipe_verdict_guard_lanes.sh +31 -5
  111. package/scripts/test_precommit_pointer_index_lanes.sh +33 -0
  112. package/scripts/test_preprep_diagram_lanes.sh +87 -0
  113. package/scripts/test_preprep_drift_anchor.sh +16 -7
  114. package/scripts/test_preprep_drift_anchor_lanes.sh +23 -0
  115. package/scripts/test_preprep_slide_refs_lanes.sh +169 -0
  116. package/scripts/test_probe_live_eval_lanes.sh +437 -0
  117. package/scripts/test_proposal_hook_lanes.sh +57 -0
  118. package/scripts/test_push_zone_lanes.sh +304 -0
  119. package/scripts/test_residency_closure_lanes.sh +70 -0
  120. package/scripts/test_revert_probe_lanes.sh +146 -0
  121. package/scripts/test_session_close_lanes.sh +3 -5
  122. package/scripts/test_sim_isolated_run_lanes.sh +136 -0
  123. package/scripts/test_utterance_intake_lanes.sh +414 -0
  124. package/scripts/test_worktree_reclaim_lanes.sh +70 -0
  125. package/scripts/transcript_utterances.py +222 -0
  126. package/scripts/utterance_intake.sh +424 -0
  127. package/scripts/utterance_landing_check.sh +2 -2
  128. package/scripts/validate_yaml.sh +27 -0
  129. package/scripts/worktree_reclaim.sh +95 -0
  130. package/templates/.git-hooks/pre-commit +380 -5
  131. package/templates/.git-hooks/pre-push +91 -0
  132. package/templates/RED_TEAM_REPORT.md +49 -0
  133. package/templates/settings.PreToolUse.snippet.json +101 -1
  134. package/plugins/fh-commons/README.md +0 -38
@@ -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)
@@ -22,18 +22,46 @@ usage: interslide_deps.py <draft.md> <builder.py> [--all]
22
22
  import sys, re, ast, os
23
23
 
24
24
  # 어휘는 **실물에서 뽑았다**(pre-자립화 S9 원문). 지어내지 않았다.
25
- SCREEN_REUSE = ('그대로', '같은 화면', '이어서', '화면 유지', '왼쪽 그대로', '오른쪽 그대로')
25
+ # 🟥 2026-09-04 수리(신호 §4) 바깥 자리에 있던 **맨몸 「그대로」를 뺐다**. 121장 실물 코퍼스에서
26
+ # 「경고가 떠도 그대로 승인한다」·「제출본 그대로 머지」처럼 **평범한 부사**로 훨씬 더 자주 쓰여,
27
+ # L8 UNRESOLVED 9건 중 다수가 이 낱말 하나가 만들었다(손검증 → 전부 오탐). 맨몸을 빼고
28
+ # **화면에 결박된 표기만** 남긴다 — 「그대로」 앞에 화면을 가리키는 말이 없으면 화면 재사용이
29
+ # 아니라 그냥 부사다. [[feedback_regex_bind_not_add.md]] 와 같은 원리(결박, 어휘 추가가 아니라).
30
+ SCREEN_REUSE = ('같은 화면', '이어서', '화면 유지',
31
+ '화면 그대로', '이 화면 그대로', '왼쪽 그대로', '오른쪽 그대로')
26
32
  DIRECTION = ('오른쪽', '왼쪽', '위에서', '아래', '옆')
27
33
  VISUAL_VERB = ('보셨', '보신', '보시는', '보시면', '보였')
28
34
  RECALL = ('방금', '아까', '조금 전', '앞서', '앞에서', '지금까지')
29
35
  SOFT_ONLY = ('말씀드린', '말씀드렸', '설명드린', '그 세', '앞의')
30
36
 
31
- def units(src):
32
- """(sid, 헤딩, 본문) 순서대로. 🚫 은퇴 표기 절은 «죽은 것»으로 표시해 같이 낸다."""
37
+ _DEFAULT_UNIT_PATTERN = r'^### (S[0-9]+(?:-[a-z])?)\s*·'
38
+
39
+
40
+ def units(src, pattern=None):
41
+ """(sid, 헤딩, 본문) 순서대로. 🚫 은퇴 표기 절은 «죽은 것»으로 표시해 같이 낸다.
42
+
43
+ 🟥 2026-09-04 수리(신호 §3-1) — 초판은 절 헤딩 정규식을 **여기 하드코딩**했다. 같은
44
+ `surfaces.yaml unit_pattern` 을 `preprep.py manuscript_ids()` 는 이미 읽는데 이 함수는
45
+ 안 읽어서, «절을 무엇으로 보나»에 대한 관대함이 **두 곳에서 따로 갈렸다**
46
+ ([[feedback_divergent_leniency_duplicate_normalizers]]). `pattern` 인자로 받는다 —
47
+ 미지정이면 이 코퍼스의 기존 패턴으로 하위호환.
48
+ 🟥 정규식을 다시 짓지 않는다 — `manuscript_ids()`(preprep.py)와 **같은 head/slice 기법**
49
+ (헤딩 위치를 전부 찾고 다음 헤딩 시작 전까지를 본문으로 슬라이스)을 쓴다. lookahead 로
50
+ "다음 헤딩" 을 다시 정의하면 그 자체가 세 번째 갈래가 된다.
51
+ `pattern`: 절 헤딩을 여는 정규식, **캡처 그룹 하나**(절 id)만 가진다
52
+ (예: r'^###\\s+(S[0-9]+(?:-[a-z])?)\\s*·').
53
+ """
54
+ pat = pattern or _DEFAULT_UNIT_PATTERN
33
55
  out = []
34
- for m in re.finditer(r'^### (S[0-9]+(?:-[a-z])?)\s*·([^\n]*)\n(.*?)(?=^### |\Z)', src, re.M | re.S):
35
- retired = any(t in m.group(2) for t in ('🚫', '제거됨', '폐기'))
36
- out.append((m.group(1), m.group(2).strip(), m.group(3), retired))
56
+ heads = list(re.finditer(pat + r'([^\n]*)', src, re.M))
57
+ for i, m in enumerate(heads):
58
+ end = heads[i + 1].start() if i + 1 < len(heads) else len(src)
59
+ head_line = m.group(2)
60
+ body = src[m.end():end]
61
+ if body.startswith('\n'):
62
+ body = body[1:] # 헤딩 줄 자신의 개행 — 옛 구현은 본문에 안 담았다(동치 유지)
63
+ retired = any(t in head_line for t in ('🚫', '제거됨', '폐기'))
64
+ out.append((m.group(1), head_line.strip(), body, retired))
37
65
  return out
38
66
 
39
67
  # 🟥 런 #14 에서 이미 한 번 겪은 결함이 여기서 재현됐다: 원고의 **주석 블록**도 `>` 라서
@@ -107,7 +135,12 @@ def referents(line):
107
135
  t = re.sub(DEICTIC_PREFIX, '', m.group(1).strip(' .,·「」«»')).strip()
108
136
  # 지시어만 벗기면 남는 말이 너무 짧거나 흔하면 대상이 못 된다
109
137
  if len(t) >= 2: cands.append(t)
110
- for m in re.finditer(r'[그저]\s*([가-힣A-Za-z][가-힣A-Za-z0-9 ]{1,12})', line):
138
+ # 🟥 2026-09-04 수리(신호 §4) — 초판은 `[그저]` **「저」도 지시어**로 잡았다. 한국어
139
+ # 존댓말 1인칭 「저희」가 「저 + 희…」로 물려 대상어가 «희 하네스의 활용 구조입» 같은
140
+ # 파편이 됐다(실측: S25). 「저」는 지시어(저것 · 저 사람)와 1인칭 겸양어(저 · 저희)를
141
+ # **같은 글자로 겸하는 다의어**라 이 정규식 자리에서 원리적으로 갈리지 않는다 — 지시어
142
+ # 쪽만 원하면 「그」만 남기고 「저」는 뺀다(놓치는 지시어보다 존댓말 오탐이 더 흔하다).
143
+ for m in re.finditer(r'그\s*([가-힣A-Za-z][가-힣A-Za-z0-9 ]{1,12})', line):
111
144
  cands.append(m.group(1).strip())
112
145
  return [c for c in dict.fromkeys(cands) if c not in COMMON and len(c) >= 2]
113
146
 
@@ -141,15 +174,16 @@ def wired_ids(builder):
141
174
  return {v[0] for v in ast.literal_eval(src[start:i+1]).values() if v[0]}
142
175
  raise RuntimeError('NOTE_MAP 괄호 불균형')
143
176
 
144
- def analyze(draft, builder, show_all=False):
177
+ def analyze(draft, builder, show_all=False, pattern=None):
145
178
  """계산부 — 출력하지 않는다. 레인 배선이 이걸 부른다(main 은 이걸 «찍기만» 한다).
146
179
 
147
180
  🟥 로직을 복제하지 않으려고 뺀 것이다. CLI 와 레인이 각자 계산하면
148
181
  관대함이 갈려서 한쪽만 통과하는 입력이 조용히 생긴다.
182
+ `pattern`: 원고 표면의 `unit_pattern` 선언(있으면) — units() 로 그대로 전달한다.
149
183
  반환: dict(error, n_units, n_retired, n_wired, edges, broken, unresolved)
150
184
  """
151
185
  try:
152
- us = units(open(draft, encoding='utf-8').read())
186
+ us = units(open(draft, encoding='utf-8').read(), pattern=pattern)
153
187
  wired = wired_ids(builder)
154
188
  except Exception as e:
155
189
  return {'error': f'{e}', 'edges': [], 'broken': [], 'unresolved': []}
@@ -188,7 +222,62 @@ def analyze(draft, builder, show_all=False):
188
222
  'n_wired': len(wired), 'edges': edges, 'broken': broken, 'unresolved': unresolved}
189
223
 
190
224
 
225
+ def selftest():
226
+ """known-pair — 신호 §3-1·§4 수리가 실제로 짝을 가르는지. 안 가르면 장식이다."""
227
+ ok = True
228
+
229
+ # ── L8 §4-a: 「저」를 지시어 정규식에서 뺐다 — 존댓말 「저희」오탐 재발 방지 ──────────
230
+ cases_ref = [
231
+ ('known-positive(존댓말 「저희」 — 지시 대상이면 안 된다)',
232
+ '저희 하네스의 활용 구조입니다 . 보시는 것처럼 세 층입니다 .', False),
233
+ ('known-negative(진짜 지시어 「그」 — 여전히 잡혀야 한다)',
234
+ '그 하네스는 이렇게 동작합니다 .', True),
235
+ ]
236
+ for name, line, want_hit in cases_ref:
237
+ got = referents(line)
238
+ hit = bool(got) == want_hit
239
+ ok &= hit
240
+ print(f' {"PASS" if hit else "FAIL"} referents {name}: {got}')
241
+
242
+ # ── L8 §4-b: SCREEN_REUSE 맨몸 「그대로」를 뺐다 — 화면 결박 표기만 남는다 ──────────
243
+ cases_scr = [
244
+ ('known-positive(평범한 부사 — 화면 재사용 아니다)', '경고가 떠도 그대로 승인한다', False),
245
+ ('known-positive(다른 문맥의 부사)', '제출본 그대로 머지', False),
246
+ ('known-negative(화면 결박 — 여전히 잡혀야 한다)', '왼쪽 그대로', True),
247
+ ('known-negative(화면 결박 변형)', '이 화면 그대로 두면 됩니다', True),
248
+ ]
249
+ for name, line, want_hit in cases_scr:
250
+ got = any(c in line for c in SCREEN_REUSE)
251
+ hit = got == want_hit
252
+ ok &= hit
253
+ print(f' {"PASS" if hit else "FAIL"} SCREEN_REUSE {name}: hit={got}')
254
+
255
+ # ── §3-1: units() 가 pattern 인자를 받고, 미지정 시 하위호환 ──────────────────────
256
+ src_default = '### S1 · 제목\n🗣\n> 본문\n\n### S2 · 둘째\n🗣\n> 본문2\n'
257
+ default_ids = [u[0] for u in units(src_default)]
258
+ ok3 = default_ids == ['S1', 'S2']
259
+ ok &= ok3
260
+ print(f' {"PASS" if ok3 else "FAIL"} units() 기본 패턴 하위호환: {default_ids}')
261
+
262
+ src_custom = '### U1 · 제목\n🗣\n> 본문\n\n### U2 · 둘째\n🗣\n> 본문2\n'
263
+ custom_ids = [u[0] for u in units(src_custom, pattern=r'^###\s+(U[0-9]+)\s*·')]
264
+ ok4 = custom_ids == ['U1', 'U2']
265
+ ok &= ok4
266
+ print(f' {"PASS" if ok4 else "FAIL"} units() 커스텀 unit_pattern 수용: {custom_ids}')
267
+ # 기본 패턴으로는 U1/U2 코퍼스를 못 읽어야 한다 — «항상 통과»하는 계기가 아님을 확인
268
+ default_on_custom = units(src_custom)
269
+ ok5 = default_on_custom == []
270
+ ok &= ok5
271
+ print(f' {"PASS" if ok5 else "FAIL"} 기본 패턴은 U-접두를 못 읽는다(계기가 진짜 pattern 을 씀): '
272
+ f'{len(default_on_custom)}건')
273
+
274
+ print('interslide_deps SELFTEST:', 'PASS' if ok else 'FAIL — 이 계기의 출력을 쓰지 마라')
275
+ return 0 if ok else 1
276
+
277
+
191
278
  def main():
279
+ if '--self-test' in sys.argv:
280
+ return selftest()
192
281
  if len(sys.argv) < 3: print(__doc__); return 2
193
282
  draft, builder = sys.argv[1], sys.argv[2]
194
283
  show_all = '--all' in sys.argv
@@ -47,7 +47,10 @@ def scan(cfg, texts, surf_meta, mod):
47
47
  findings, notes = [], []
48
48
  for sid in sorted(spoken):
49
49
  text = texts[sid][0]
50
- us = [u for u in mod.units(text) if not u[3]] # 은퇴 제외
50
+ # 🟥 2026-09-04 units() pattern 받는다. 표면의 unit_pattern 선언을 넘긴다
51
+ # (interslide_deps 신호 §3-1 과 같은 수리 — 두 곳이 각자 관대함을 갖지 않게 한다).
52
+ _pat = surf_meta.get(sid, {}).get('unit_pattern')
53
+ us = [u for u in mod.units(text, pattern=_pat) if not u[3]] # 은퇴 절 제외
51
54
  if len(us) < 2:
52
55
  notes.append(f'L10 adjacent-dup : {sid} — 절 {len(us)}개, 인접 쌍 없음 (0 아님)')
53
56
  continue
@@ -0,0 +1,95 @@
1
+ """L12 diagram — **도해가 «지금 JSON» 에서 검증을 거쳐 구워졌나.**
2
+
3
+ ## 존재 근거 (2026-09-05, archify × preprep 결합 실측)
4
+
5
+ 도해를 타입 있는 JSON → 외부 렌더러(archify) → PNG → pptx 로 넣는 경로가 뚫렸다. 그 경로의 사각은
6
+ 셋이고, 셋 다 «PNG 가 있다»로는 안 보인다:
7
+ ① JSON 을 고쳤는데 PNG 는 옛 것(재생성 안 함) — 빌드는 성공, 그림은 거짓
8
+ ② validate 를 못 통과한 JSON 을 손으로 렌더해 넣음 — 겹침·라벨 마스킹이 화면에
9
+ ③ 픽셀은 충분한데 viewBox 가 넓어 글자가 12pt 로 떨어짐 — «해상도 OK» 인데 못 읽는다
10
+
11
+ ## 무엇을 재고 무엇을 안 재나
12
+
13
+ - 잰다 — `kind: diagram_source` 표면마다 `render:` PNG 옆의 **굽기 영수증**(`*.receipt.json`,
14
+ `diagram_from_json.py` 가 쓴다)을 읽어: 영수증 존재 · JSON sha 일치 · validate ok(showcase) ·
15
+ PNG 실제 폭(IHDR 을 직접 읽는다 — 영수증 자기신고가 아니다) ≥ min_px · viewBox 폭 ≤ max ·
16
+ pad ≥ min.
17
+ - 안 잰다 — 그림이 «좋은가». 그건 §방법론 ⓒ «도해를 건드렸으면 렌더한다» 의 사람 몫이다.
18
+ - 🟥 영수증이 없는 PNG(손으로 넣은 그림)는 **UNMEASURED 로 낸다** — 0 이 아니고, 통과도 아니다.
19
+ 이 레인은 «생성기를 거친 그림»만 본다. 사각은 좁아지지 좁아지지 닫히지 않는다(SKILL.md §갈라 적는 것).
20
+
21
+ ## 차단인 이유
22
+ 선언된 표면에 한해 «지문이 다르다 / validate 가 아니다 / 폭이 모자란다» 는 애매하지 않은 사실이다.
23
+ 오탐의 최저비용 무마는 «다시 굽는 것»이라 원고를 망가뜨리는 방향이 아니다.
24
+ """
25
+ import hashlib, json, os, struct
26
+
27
+
28
+ def _sha(p):
29
+ h = hashlib.sha256(); h.update(open(p, 'rb').read()); return h.hexdigest()
30
+
31
+
32
+ def _png_w(p):
33
+ with open(p, 'rb') as f: head = f.read(24)
34
+ if head[:8] != b'\x89PNG\r\n\x1a\n': raise ValueError('PNG 아님')
35
+ return struct.unpack('>II', head[16:24])[0]
36
+
37
+
38
+ def _resolve(root, p):
39
+ return os.path.normpath(os.path.join(root, os.path.expanduser(p)))
40
+
41
+
42
+ def scan(cfg, root):
43
+ """(findings, notes). findings 튜플 = (sid, kind, ref, lit, why) — preprep.py 의 USE 계열."""
44
+ surfs = [s for s in cfg.get('surfaces', []) if s.get('kind') == 'diagram_source']
45
+ if not surfs:
46
+ return [], ['L12 diagram : diagram_source 표면 없음 — NOT_CONFIGURED (0 아님)']
47
+ spec = cfg.get('diagram') or {}
48
+ min_px = int(spec.get('min_px', 3840)); max_vb = float(spec.get('max_viewbox_w', 720)); min_pad = float(spec.get('min_pad', 0.02))
49
+ findings, notes = [], []
50
+ n_ok = n_unm = 0
51
+ for s in surfs:
52
+ sid = s['id']; jp = _resolve(root, s['path'])
53
+ if not os.path.exists(jp):
54
+ notes.append(f'L12 diagram : {sid} JSON 없음({jp}) — UNMEASURED (0 아님)'); n_unm += 1; continue
55
+ rp_png = s.get('render')
56
+ if not rp_png:
57
+ notes.append(f'L12 diagram : {sid} render(PNG) 미선언 — UNMEASURED (0 아님)'); n_unm += 1; continue
58
+ png = _resolve(root, rp_png); rec = png[:-4] + '.receipt.json' if png.endswith('.png') else png + '.receipt.json'
59
+ if not os.path.exists(png):
60
+ findings.append((sid, 'diagram', 'render', os.path.basename(png), 'L12 선언된 PNG 가 디스크에 없다 — 굽지 않았거나 경로가 틀렸다')); continue
61
+ if not os.path.exists(rec):
62
+ notes.append(f'L12 diagram : {sid} 굽기 영수증 없음({os.path.basename(rec)}) — 손으로 넣은 그림은 이 레인이 못 본다. UNMEASURED (0 아님)'); n_unm += 1; continue
63
+ try: r = json.load(open(rec, encoding='utf-8'))
64
+ except Exception as e:
65
+ findings.append((sid, 'diagram', 'receipt', os.path.basename(rec), f'L12 영수증 파싱 실패({type(e).__name__}) — 채널이 깨졌다')); continue
66
+ bad = False
67
+ if r.get('spec_sha256') != _sha(jp):
68
+ findings.append((sid, 'diagram', 'stale', os.path.basename(png), 'L12 JSON 지문이 영수증과 다르다 — JSON 을 고친 뒤 다시 굽지 않았다')); bad = True
69
+ if not (r.get('validate') or {}).get('ok') or (r.get('validate') or {}).get('profile') != 'showcase':
70
+ findings.append((sid, 'diagram', 'validate', os.path.basename(png), 'L12 showcase validate 통과 기록이 없다 — 검증 안 된 그림')); bad = True
71
+ try: w = _png_w(png)
72
+ except Exception as e:
73
+ findings.append((sid, 'diagram', 'png', os.path.basename(png), f'L12 PNG 헤더 읽기 실패({e})')); continue
74
+ if w < min_px:
75
+ # 자른(crop) PNG 는 캔버스보다 좁다 — 영수증의 png_size 가 아니라 «crop 전 캔버스»가 하한을 넘겼는지 본다.
76
+ # 🟥 캔버스 폭은 영수증 자기신고다. IHDR 실측(w)과 함께 둘 다 적어 감사자가 가르게 한다.
77
+ canvas_w = None
78
+ cv = r.get('canvas')
79
+ if isinstance(cv, str) and 'x' in cv.lower() and cv.lower() != 'auto':
80
+ canvas_w = int(cv.lower().split('x')[0])
81
+ elif r.get('crop'):
82
+ canvas_w = min_px if r.get('png_size') else None # auto 캔버스는 스크립트가 하한 이상으로만 만든다
83
+ if not (r.get('crop') and canvas_w and canvas_w >= min_px):
84
+ findings.append((sid, 'diagram', 'resolution', os.path.basename(png), f'L12 PNG 폭 {w} < 하한 {min_px} (장표 2×)')); bad = True
85
+ else:
86
+ notes.append(f'L12 diagram : {sid} 자른 PNG 폭 {w}(IHDR) — 캔버스 {canvas_w}(영수증 자기신고) 로 하한 판정')
87
+ vbw = r.get('viewbox_w')
88
+ if vbw is None or float(vbw) > max_vb:
89
+ findings.append((sid, 'diagram', 'viewbox', os.path.basename(png), f'L12 viewBox 폭 {vbw} > 상한 {max_vb:.0f} — 글자 배율 미달(라벨≈{12*1728/float(vbw or 1):.0f}pt)')); bad = True
90
+ if float(r.get('pad', 0)) < min_pad:
91
+ findings.append((sid, 'diagram', 'pad', os.path.basename(png), f'L12 여백 {r.get("pad")} < 하한 {min_pad}')); bad = True
92
+ if not bad: n_ok += 1
93
+ notes.append(f'L12 diagram : 표면 {len(surfs)} · 통과 {n_ok} · UNMEASURED {n_unm} · 발견 {len(findings)} '
94
+ f'(하한 {min_px}px · viewBox≤{max_vb:.0f} · pad≥{min_pad})')
95
+ return findings, notes
@@ -0,0 +1,181 @@
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
+ import zipfile, re, os, collections
39
+
40
+ EMU_PT = 12700
41
+
42
+
43
+ def shapes(z, sn):
44
+ """한 슬라이드의 도형을 {이름: (x,y,cx,cy,text)} 로. 이름 중복은 대조 못 하니 뺀다."""
45
+ x = z.read('ppt/slides/slide%d.xml' % sn).decode('utf-8')
46
+ out = {}
47
+ for m in re.finditer(r'<p:(sp|cxnSp)>.*?</p:\1>', x, re.S):
48
+ b = m.group(0)
49
+ nm = re.search(r'name="([^"]*)"', b)
50
+ o = re.search(r'<a:off x="(-?\d+)" y="(-?\d+)"/><a:ext cx="(-?\d+)" cy="(-?\d+)"', b)
51
+ if not (nm and o):
52
+ continue
53
+ t = re.sub(r'\s+', ' ', ' '.join(re.findall(r'<a:t>([^<]*)</a:t>', b))).strip()
54
+ out.setdefault(nm.group(1), []).append(
55
+ (int(o.group(1)), int(o.group(2)), int(o.group(3)), int(o.group(4)), t))
56
+ return {k: v[0] for k, v in out.items() if len(v) == 1}
57
+
58
+
59
+ def _order(z):
60
+ rels = dict(re.findall(r'Id="([^"]+)"[^>]*Target="slides/slide(\d+)\.xml"',
61
+ z.read('ppt/_rels/presentation.xml.rels').decode('utf-8')))
62
+ lst = re.search(r'<p:sldIdLst>.*?</p:sldIdLst>',
63
+ z.read('ppt/presentation.xml').decode('utf-8'), re.S).group(0)
64
+ return [int(rels[r]) for r in re.findall(r'<p:sldId id="\d+" r:id="([^"]+)"/>', lst)]
65
+
66
+
67
+ def load_slides(path):
68
+ z = zipfile.ZipFile(path)
69
+ return [shapes(z, sn) for sn in _order(z)]
70
+
71
+
72
+ def p1_lines(S, JIT):
73
+ """build-jitter 후보. 반환: (lines, compared) — compared=0 이면 대조 대상이 아예 없었다는
74
+ 뜻이라 «튐 0건»과 «UNMEASURED」를 갈라야 한다."""
75
+ lines, compared = [], 0
76
+ for i in range(1, len(S)):
77
+ for nm, cur in S[i].items():
78
+ prv = S[i - 1].get(nm)
79
+ if not prv:
80
+ continue
81
+ compared += 1
82
+ d = [cur[k] - prv[k] for k in range(4)]
83
+ mx = max(abs(v) for v in d)
84
+ if 0 < mx <= JIT:
85
+ lab = ['x', 'y', 'cx', 'cy']
86
+ moved = ' · '.join(f'{lab[k]} {d[k]:+d}' for k in range(4) if d[k])
87
+ lines.append(f" {i:>3}p→{i+1:<3}p {nm[:16]:16} {moved} ({mx/EMU_PT:.2f}pt) "
88
+ f"«{cur[4][:26]}»")
89
+ return lines, compared
90
+
91
+
92
+ def p3_lines(S, ALN):
93
+ """adjacency 후보 — A 오른쪽 끝 ↔ B 왼쪽 끝, 세로로 겹치는 띠에 있을 때만."""
94
+ lines = []
95
+ for i, sh in enumerate(S):
96
+ items = [(nm, v) for nm, v in sh.items() if v[2] > 0 and v[3] > 0]
97
+ for a in range(len(items)):
98
+ for b in range(len(items)):
99
+ if a == b:
100
+ continue
101
+ na, va = items[a]
102
+ nb, vb = items[b]
103
+ oy = min(va[1] + va[3], vb[1] + vb[3]) - max(va[1], vb[1])
104
+ if oy <= 0:
105
+ continue
106
+ gap = vb[0] - (va[0] + va[2]) # A 오른쪽 → B 왼쪽
107
+ if 0 < abs(gap) <= ALN:
108
+ kind = '겹침' if gap < 0 else '틈'
109
+ lines.append(f" {i+1:>3}p {na[:14]:14} 오른끝 → {nb[:14]:14} 왼끝 "
110
+ f"{kind} {abs(gap):>6} EMU ({abs(gap)/EMU_PT:.2f}pt)")
111
+ return lines
112
+
113
+
114
+ def collect_lines(path, JIT, ALN):
115
+ """(lines, compared) — P1+P3 합친 후보 줄과, P1 이 실제로 대조한 도형-쌍 수."""
116
+ S = load_slides(path)
117
+ l1, compared = p1_lines(S, JIT)
118
+ l3 = p3_lines(S, ALN)
119
+ return l1 + l3, compared
120
+
121
+
122
+ def delta(base_path, cur_path, JIT, ALN):
123
+ """편집 전/후 델타 — 절대 목록이 아니라 **차이**로 읽는 용법(정확했던 것). P3 는 SYS 를 안 쓰니
124
+ 양쪽에 같은 규칙을 그대로 적용하면 된다(계기 자기결함이 P2 전용이라 여기 안 옮는다)."""
125
+ bl, cmp_b = collect_lines(base_path, JIT, ALN)
126
+ cur, cmp_c = collect_lines(cur_path, JIT, ALN)
127
+ b = set(bl)
128
+ new = [l for l in cur if l not in b]
129
+ gone = [l for l in bl if l not in set(cur)]
130
+ return new, gone, cmp_b, cmp_c
131
+
132
+
133
+ def _resolve(root, p):
134
+ return os.path.normpath(os.path.join(root, os.path.expanduser(p)))
135
+
136
+
137
+ def scan(cfg, root):
138
+ """preprep.py 가 부르는 레인 진입점. (findings, notes) — 🟥 **advisory 고정**:
139
+ 호출자는 findings 를 버리고 notes 만 취한다(L8·L11 관례)."""
140
+ deck_s = (cfg.get('surfaces_by_id') or {}).get('built_deck')
141
+ if not deck_s:
142
+ return [], ['P1/P3 geometry : built_deck 미선언 — NOT_CONFIGURED (0 아님)']
143
+ deck = _resolve(root, deck_s['path'])
144
+ if not os.path.exists(deck):
145
+ return [], [f'P1/P3 geometry : built_deck 실물 없음({deck}) — UNMEASURED (0 아님)']
146
+ spec = cfg.get('geometry') or {}
147
+ JIT = int(spec.get('jitter_emu', 200000))
148
+ ALN = int(spec.get('align_emu', 63500))
149
+ base_p = spec.get('baseline')
150
+ notes = []
151
+
152
+ if base_p:
153
+ base = _resolve(root, base_p)
154
+ if not os.path.exists(base):
155
+ return [], [f'P1/P3 geometry : baseline 실물 없음({base}) — UNMEASURED (0 아님)']
156
+ try:
157
+ new, gone, cmp_b, cmp_c = delta(base, deck, JIT, ALN)
158
+ except Exception as e:
159
+ return [], [f'P1/P3 geometry : 계기 오류({type(e).__name__}: {e}) — UNMEASURED (0 아님)']
160
+ notes.append(f'P1/P3 geometry(델타) : 기준 {os.path.basename(base)} 대비 '
161
+ f'새 어긋남 {len(new)}건 · 사라진 어긋남 {len(gone)}건 🟥 advisory '
162
+ f'(P1 대조된 도형-쌍: 기준 {cmp_b} · 현재 {cmp_c}'
163
+ + ('' if cmp_b and cmp_c else ' — 🟥 0건이면 UNMEASURED, 「튐 없음」이 아니다') + ')')
164
+ for l in new:
165
+ notes.append(' + ' + l.strip())
166
+ for l in gone:
167
+ notes.append(' - ' + l.strip())
168
+ return [], notes
169
+
170
+ try:
171
+ lines, compared = collect_lines(deck, JIT, ALN)
172
+ except Exception as e:
173
+ return [], [f'P1/P3 geometry : 계기 오류({type(e).__name__}: {e}) — UNMEASURED (0 아님)']
174
+ notes.append(f'P1/P3 geometry(목록) : 후보 {len(lines)}건 — 🟥 절대 목록으로 읽지 마라, '
175
+ f'P3 기저 오탐이 코퍼스마다 수십 건일 수 있다(원 코퍼스 실측 76건). '
176
+ f'`geometry.baseline` 을 쓰는 편집-델타 용법이 정확하다 '
177
+ f'(P1 대조된 도형-쌍 {compared}'
178
+ + ('' if compared else ' — 🟥 UNMEASURED, 「튐 없음」이 아니다') + ')')
179
+ for l in lines:
180
+ notes.append(l)
181
+ return [], notes
@@ -74,7 +74,10 @@ def scan(texts, surf_meta, max_report=8, mod=None):
74
74
  text = texts[sid][0]
75
75
  if mod is not None:
76
76
  speech = []
77
- for _uid, _h, body, _r in mod.units(text):
77
+ # 🟥 2026-09-04 units() 이제 pattern 을 받는다. 이 표면의 surfaces.yaml
78
+ # unit_pattern 선언을 그대로 넘긴다(interslide_deps 신호 §3-1 과 같은 수리).
79
+ _pat = surf_meta.get(sid, {}).get('unit_pattern')
80
+ for _uid, _h, body, _r in mod.units(text, pattern=_pat):
78
81
  _sc, sp = mod.blocks(body)
79
82
  speech.append(sp if isinstance(sp, str) else '\n'.join(sp))
80
83
  text = '\n'.join(speech) or text