claudecode-omc 5.6.6 → 5.6.8

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 (179) hide show
  1. package/.local/skills/THIRD_PARTY_LICENSES/AvdLee-SwiftUI-Agent-Skill.LICENSE +21 -0
  2. package/.local/skills/THIRD_PARTY_LICENSES/Dimillian-Skills.LICENSE +21 -0
  3. package/.local/skills/THIRD_PARTY_LICENSES/README.md +36 -0
  4. package/.local/skills/THIRD_PARTY_LICENSES/twostraws-swiftui-agent-skill.LICENSE +21 -0
  5. package/.local/skills/h5-to-swiftui/SKILL.md +201 -0
  6. package/.local/skills/h5-to-swiftui/assets/calibration/README.md +176 -0
  7. package/.local/skills/h5-to-swiftui/assets/calibration/h5-twin/index.html +52 -0
  8. package/.local/skills/h5-to-swiftui/assets/calibration/h5-twin/style.css +133 -0
  9. package/.local/skills/h5-to-swiftui/assets/calibration/swiftui-twin/Package.swift +26 -0
  10. package/.local/skills/h5-to-swiftui/assets/calibration/swiftui-twin/Sources/CalibrationScreen/CalibrationScreen.swift +142 -0
  11. package/.local/skills/h5-to-swiftui/assets/calibration/swiftui-twin-divergent/Package.swift +32 -0
  12. package/.local/skills/h5-to-swiftui/assets/calibration/swiftui-twin-divergent/Sources/CalibrationScreenDivergent/CalibrationScreenDivergent.swift +122 -0
  13. package/.local/skills/h5-to-swiftui/assets/calibration/tokens.json +42 -0
  14. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/index.html +14 -0
  15. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/package.json +20 -0
  16. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/public/api/articles/001.json +96 -0
  17. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/public/api/articles/index.json +89 -0
  18. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/App.jsx +22 -0
  19. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/App.module.css +11 -0
  20. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/ArticleCard.jsx +53 -0
  21. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/ArticleCard.module.css +139 -0
  22. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/NavBar.jsx +37 -0
  23. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/NavBar.module.css +72 -0
  24. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/TagCloud.jsx +30 -0
  25. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/TagCloud.module.css +50 -0
  26. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/TrendChart.jsx +159 -0
  27. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/components/TrendChart.module.css +21 -0
  28. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/main.jsx +12 -0
  29. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/screens/ArticleScreen.jsx +182 -0
  30. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/screens/ArticleScreen.module.css +294 -0
  31. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/screens/FeedScreen.jsx +147 -0
  32. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/screens/FeedScreen.module.css +161 -0
  33. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/styles/global.css +50 -0
  34. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/src/styles/tokens.css +103 -0
  35. package/.local/skills/h5-to-swiftui/assets/sample-h5-react/vite.config.js +6 -0
  36. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/data/tasks.js +67 -0
  37. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/index.html +26 -0
  38. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/router.js +73 -0
  39. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/screens/detail.js +164 -0
  40. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/screens/home.js +53 -0
  41. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/screens/list.js +87 -0
  42. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/styles/app.css +342 -0
  43. package/.local/skills/h5-to-swiftui/assets/sample-h5-vanilla/styles/tokens.css +68 -0
  44. package/.local/skills/h5-to-swiftui/references/css-to-swiftui-map.md +205 -0
  45. package/.local/skills/h5-to-swiftui/references/design-token-extraction.md +209 -0
  46. package/.local/skills/h5-to-swiftui/references/high-risk-triage.md +209 -0
  47. package/.local/skills/h5-to-swiftui/references/render-equivalence-calibration.md +193 -0
  48. package/.local/skills/h5-to-swiftui/references/stack-detection.md +160 -0
  49. package/.local/skills/h5-to-swiftui/references/visual-diff-loop-protocol.md +365 -0
  50. package/.local/skills/h5-to-swiftui/scripts/_calib-consts.mjs +150 -0
  51. package/.local/skills/h5-to-swiftui/scripts/_imglib.mjs +547 -0
  52. package/.local/skills/h5-to-swiftui/scripts/_provenance.mjs +123 -0
  53. package/.local/skills/h5-to-swiftui/scripts/calibrate-render.mjs +625 -0
  54. package/.local/skills/h5-to-swiftui/scripts/capture-reference.mjs +386 -0
  55. package/.local/skills/h5-to-swiftui/scripts/detect-stack.mjs +305 -0
  56. package/.local/skills/h5-to-swiftui/scripts/evaluate-convergence.mjs +1093 -0
  57. package/.local/skills/h5-to-swiftui/scripts/extract-tokens.mjs +600 -0
  58. package/.local/skills/h5-to-swiftui/scripts/mark-overlay.mjs +379 -0
  59. package/.local/skills/h5-to-swiftui/scripts/pixel-diff.mjs +530 -0
  60. package/.local/skills/h5-to-swiftui/scripts/sim-screenshot.sh +544 -0
  61. package/.local/skills/ios-debugger-agent/SKILL.md +51 -0
  62. package/.local/skills/ios-debugger-agent/agents/openai.yaml +4 -0
  63. package/.local/skills/swift-concurrency-expert/SKILL.md +105 -0
  64. package/.local/skills/swift-concurrency-expert/agents/openai.yaml +4 -0
  65. package/.local/skills/swift-concurrency-expert/references/approachable-concurrency.md +63 -0
  66. package/.local/skills/swift-concurrency-expert/references/swift-6-2-concurrency.md +272 -0
  67. package/.local/skills/swift-concurrency-expert/references/swiftui-concurrency-tour-wwdc.md +33 -0
  68. package/.local/skills/swiftui-expert-skill/SKILL.md +162 -0
  69. package/.local/skills/swiftui-expert-skill/references/accessibility-patterns.md +215 -0
  70. package/.local/skills/swiftui-expert-skill/references/animation-advanced.md +403 -0
  71. package/.local/skills/swiftui-expert-skill/references/animation-basics.md +284 -0
  72. package/.local/skills/swiftui-expert-skill/references/animation-transitions.md +326 -0
  73. package/.local/skills/swiftui-expert-skill/references/charts-accessibility.md +135 -0
  74. package/.local/skills/swiftui-expert-skill/references/charts.md +602 -0
  75. package/.local/skills/swiftui-expert-skill/references/focus-patterns.md +299 -0
  76. package/.local/skills/swiftui-expert-skill/references/image-optimization.md +203 -0
  77. package/.local/skills/swiftui-expert-skill/references/latest-apis.md +488 -0
  78. package/.local/skills/swiftui-expert-skill/references/layout-best-practices.md +266 -0
  79. package/.local/skills/swiftui-expert-skill/references/liquid-glass.md +423 -0
  80. package/.local/skills/swiftui-expert-skill/references/list-patterns.md +446 -0
  81. package/.local/skills/swiftui-expert-skill/references/macos-scenes.md +318 -0
  82. package/.local/skills/swiftui-expert-skill/references/macos-views.md +357 -0
  83. package/.local/skills/swiftui-expert-skill/references/macos-window-styling.md +303 -0
  84. package/.local/skills/swiftui-expert-skill/references/performance-patterns.md +403 -0
  85. package/.local/skills/swiftui-expert-skill/references/scroll-patterns.md +293 -0
  86. package/.local/skills/swiftui-expert-skill/references/sheet-navigation-patterns.md +363 -0
  87. package/.local/skills/swiftui-expert-skill/references/state-management.md +388 -0
  88. package/.local/skills/swiftui-expert-skill/references/text-patterns.md +32 -0
  89. package/.local/skills/swiftui-expert-skill/references/trace-analysis.md +295 -0
  90. package/.local/skills/swiftui-expert-skill/references/trace-recording.md +134 -0
  91. package/.local/skills/swiftui-expert-skill/references/view-structure.md +780 -0
  92. package/.local/skills/swiftui-expert-skill/scripts/__pycache__/analyze_trace.cpython-313.pyc +0 -0
  93. package/.local/skills/swiftui-expert-skill/scripts/__pycache__/record_trace.cpython-313.pyc +0 -0
  94. package/.local/skills/swiftui-expert-skill/scripts/analyze_trace.py +301 -0
  95. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__init__.py +1 -0
  96. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__pycache__/__init__.cpython-313.pyc +0 -0
  97. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__pycache__/causes.cpython-313.pyc +0 -0
  98. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__pycache__/correlate.cpython-313.pyc +0 -0
  99. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__pycache__/events.cpython-313.pyc +0 -0
  100. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__pycache__/hangs.cpython-313.pyc +0 -0
  101. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__pycache__/hitches.cpython-313.pyc +0 -0
  102. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__pycache__/summary.cpython-313.pyc +0 -0
  103. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__pycache__/swiftui.cpython-313.pyc +0 -0
  104. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__pycache__/time_profiler.cpython-313.pyc +0 -0
  105. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__pycache__/xctrace.cpython-313.pyc +0 -0
  106. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/__pycache__/xml_utils.cpython-313.pyc +0 -0
  107. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/causes.py +187 -0
  108. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/correlate.py +179 -0
  109. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/events.py +291 -0
  110. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/hangs.py +108 -0
  111. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/hitches.py +145 -0
  112. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/summary.py +243 -0
  113. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/swiftui.py +195 -0
  114. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/time_profiler.py +135 -0
  115. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/xctrace.py +117 -0
  116. package/.local/skills/swiftui-expert-skill/scripts/instruments_parser/xml_utils.py +224 -0
  117. package/.local/skills/swiftui-expert-skill/scripts/record_trace.py +252 -0
  118. package/.local/skills/swiftui-liquid-glass/SKILL.md +90 -0
  119. package/.local/skills/swiftui-liquid-glass/agents/openai.yaml +4 -0
  120. package/.local/skills/swiftui-liquid-glass/references/liquid-glass.md +280 -0
  121. package/.local/skills/swiftui-performance-audit/SKILL.md +106 -0
  122. package/.local/skills/swiftui-performance-audit/agents/openai.yaml +4 -0
  123. package/.local/skills/swiftui-performance-audit/references/code-smells.md +150 -0
  124. package/.local/skills/swiftui-performance-audit/references/demystify-swiftui-performance-wwdc23.md +46 -0
  125. package/.local/skills/swiftui-performance-audit/references/optimizing-swiftui-performance-instruments.md +29 -0
  126. package/.local/skills/swiftui-performance-audit/references/profiling-intake.md +44 -0
  127. package/.local/skills/swiftui-performance-audit/references/report-template.md +47 -0
  128. package/.local/skills/swiftui-performance-audit/references/understanding-hangs-in-your-app.md +33 -0
  129. package/.local/skills/swiftui-performance-audit/references/understanding-improving-swiftui-performance.md +52 -0
  130. package/.local/skills/swiftui-pro/SKILL.md +108 -0
  131. package/.local/skills/swiftui-pro/agents/openai.yaml +10 -0
  132. package/.local/skills/swiftui-pro/assets/swiftui-pro-icon.png +0 -0
  133. package/.local/skills/swiftui-pro/assets/swiftui-pro-icon.svg +29 -0
  134. package/.local/skills/swiftui-pro/references/accessibility.md +13 -0
  135. package/.local/skills/swiftui-pro/references/api.md +39 -0
  136. package/.local/skills/swiftui-pro/references/data.md +43 -0
  137. package/.local/skills/swiftui-pro/references/design.md +32 -0
  138. package/.local/skills/swiftui-pro/references/hygiene.md +9 -0
  139. package/.local/skills/swiftui-pro/references/navigation.md +14 -0
  140. package/.local/skills/swiftui-pro/references/performance.md +46 -0
  141. package/.local/skills/swiftui-pro/references/swift.md +56 -0
  142. package/.local/skills/swiftui-pro/references/views.md +36 -0
  143. package/.local/skills/swiftui-ui-patterns/SKILL.md +95 -0
  144. package/.local/skills/swiftui-ui-patterns/agents/openai.yaml +4 -0
  145. package/.local/skills/swiftui-ui-patterns/references/app-wiring.md +201 -0
  146. package/.local/skills/swiftui-ui-patterns/references/async-state.md +96 -0
  147. package/.local/skills/swiftui-ui-patterns/references/components-index.md +50 -0
  148. package/.local/skills/swiftui-ui-patterns/references/controls.md +57 -0
  149. package/.local/skills/swiftui-ui-patterns/references/deeplinks.md +66 -0
  150. package/.local/skills/swiftui-ui-patterns/references/focus.md +90 -0
  151. package/.local/skills/swiftui-ui-patterns/references/form.md +97 -0
  152. package/.local/skills/swiftui-ui-patterns/references/grids.md +71 -0
  153. package/.local/skills/swiftui-ui-patterns/references/haptics.md +71 -0
  154. package/.local/skills/swiftui-ui-patterns/references/input-toolbar.md +51 -0
  155. package/.local/skills/swiftui-ui-patterns/references/lightweight-clients.md +93 -0
  156. package/.local/skills/swiftui-ui-patterns/references/list.md +86 -0
  157. package/.local/skills/swiftui-ui-patterns/references/loading-placeholders.md +38 -0
  158. package/.local/skills/swiftui-ui-patterns/references/macos-settings.md +71 -0
  159. package/.local/skills/swiftui-ui-patterns/references/matched-transitions.md +59 -0
  160. package/.local/skills/swiftui-ui-patterns/references/media.md +73 -0
  161. package/.local/skills/swiftui-ui-patterns/references/menu-bar.md +101 -0
  162. package/.local/skills/swiftui-ui-patterns/references/navigationstack.md +159 -0
  163. package/.local/skills/swiftui-ui-patterns/references/overlay.md +45 -0
  164. package/.local/skills/swiftui-ui-patterns/references/performance.md +62 -0
  165. package/.local/skills/swiftui-ui-patterns/references/previews.md +48 -0
  166. package/.local/skills/swiftui-ui-patterns/references/scroll-reveal.md +133 -0
  167. package/.local/skills/swiftui-ui-patterns/references/scrollview.md +87 -0
  168. package/.local/skills/swiftui-ui-patterns/references/searchable.md +71 -0
  169. package/.local/skills/swiftui-ui-patterns/references/sheets.md +155 -0
  170. package/.local/skills/swiftui-ui-patterns/references/split-views.md +72 -0
  171. package/.local/skills/swiftui-ui-patterns/references/tabview.md +114 -0
  172. package/.local/skills/swiftui-ui-patterns/references/theming.md +71 -0
  173. package/.local/skills/swiftui-ui-patterns/references/title-menus.md +93 -0
  174. package/.local/skills/swiftui-ui-patterns/references/top-bar.md +49 -0
  175. package/.local/skills/swiftui-view-refactor/SKILL.md +202 -0
  176. package/.local/skills/swiftui-view-refactor/agents/openai.yaml +4 -0
  177. package/.local/skills/swiftui-view-refactor/references/mv-patterns.md +161 -0
  178. package/bundled/manifest.json +1 -1
  179. package/package.json +1 -1
@@ -0,0 +1,625 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * calibrate-render.mjs — Stage 2.5: Render-equivalence calibration
4
+ *
5
+ * Usage:
6
+ * node calibrate-render.mjs <calibration-dir> --device "iPhone 15 Pro"
7
+ * [--out calibration.json]
8
+ * [--ref <h5.png>] [--gen <swift.png>]
9
+ * [--sim-runtime <runtime>]
10
+ * [--browser <browser-id>]
11
+ * [--model-id <model>]
12
+ *
13
+ * Input resolution (in order of precedence):
14
+ * 1. Explicit --ref and --gen flags.
15
+ * 2. Auto-find in <calibration-dir>: first file matching *ref*.png as the H5
16
+ * reference, first file matching *gen*.png as the SwiftUI render.
17
+ * If neither path resolves, exits 1 with a clear error.
18
+ *
19
+ * Normalization pipeline (render-equivalence-calibration.md):
20
+ * 1. Crop simulator content rect: status-bar and home-indicator insets from
21
+ * the pinned device DEVICES table (below).
22
+ * 2. Resample BOTH sides to common logical raster (logical_pt × render_scale)
23
+ * using resampleLanczos3 — identical filter, both sides.
24
+ * 3. p3ToSrgb on the simulator (gen) side.
25
+ *
26
+ * Measurements:
27
+ * floor.ssim_global — whole-image SSIM after normalization
28
+ * floor.ssim_nontext — SSIM over non-text ROI (whole image if no bbox map)
29
+ * floor.deltaE_p95 — 95th-pct CIEDE2000 ΔE
30
+ * floor.text_iou — null (no bbox map provided at calibration time)
31
+ *
32
+ * Sanity bound (no-fake spine):
33
+ * If ssim_nontext < 0.95 (or text_iou < 0.9 when non-null), the toolchain
34
+ * is unmeasurable. Writes blocked.json next to --out and exits 1.
35
+ * Never emits calibration.json against an unproven metric.
36
+ *
37
+ * ADDITIONAL flat-image guard: SSIM is insensitive to a uniform mean shift
38
+ * on near-zero-variance (effectively flat) images — a clearly-divergent
39
+ * solid pair can still score ~0.95. If BOTH normalized images are
40
+ * effectively flat (luma variance below FLAT_VARIANCE_MAX on each side),
41
+ * the SSIM floor is unreliable ⇒ toolchain is treated as NOT MEASURABLE
42
+ * (blocked.json + exit 1), never a falsely-high floor. Bundled calibration
43
+ * content MUST be textured (text + multi-color region), see
44
+ * assets/calibration/README.md.
45
+ *
46
+ * Structured gate (consumed by evaluate-convergence.mjs WITHOUT a string
47
+ * parser): `gate.converged` / `gate.close` are numeric threshold objects
48
+ * computed from the measured floor (nontext floor-0.005, deltaE floor+0.4,
49
+ * text_iou floor-0.03; close = 2x band). A human-readable `gate_explain`
50
+ * string is kept for humans only — it is NEVER evaluated by code.
51
+ *
52
+ * Calibration honesty + provenance binding:
53
+ * calibration.twin_hashes = SHA-256 of the input ref + gen PNGs
54
+ * (non-security provenance metadata only — see twin_hashes note below).
55
+ * calibration.calibration_source = the bundled twin dirs this calibration
56
+ * was run against (relative to the skill root) + a deterministic
57
+ * SHA-256 source-tree hash of EACH bundled tree's SOURCE FILES
58
+ * (excluding build output/dotfiles). evaluate-convergence.mjs recomputes
59
+ * these source-tree hashes from the actual bundled assets and FAILS
60
+ * CLOSED on a mismatch — this binds the IDENTITY of the bundled twin
61
+ * source files. It does NOT bind the measured `floor` *value*: the
62
+ * grader separately asserts the `floor` satisfies this script's OWN
63
+ * sanity envelope (it writes blocked.json — not calibration.json — below
64
+ * ssim_nontext 0.95 / non-null text_iou 0.9), but a floor *within* that
65
+ * envelope yet looser than the true measured one is a disclosed
66
+ * irreducible residual (the grader cannot re-render to re-measure it).
67
+ *
68
+ * Outputs:
69
+ * calibration.json — schema h5-to-swiftui/calibration@1
70
+ * blocked.json — written instead when sanity bound fails (schema h5-to-swiftui/blocked@1)
71
+ *
72
+ * Exit codes:
73
+ * 0 — calibration.json written
74
+ * 1 — fatal error (bad args, file missing, sanity bound failed)
75
+ * 2 — pngjs not installed (actionable hint printed)
76
+ *
77
+ * Examples:
78
+ * node calibrate-render.mjs assets/calibration --device "iPhone 15 Pro"
79
+ * node calibrate-render.mjs assets/calibration --device "iPhone 15 Pro" --out dist/calibration.json
80
+ * node calibrate-render.mjs assets/calibration --device "iPhone 15 Pro" \
81
+ * --ref assets/calibration/h5-twin.png --gen assets/calibration/swiftui-twin.png
82
+ */
83
+
84
+ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
85
+ import { resolve, join, dirname } from 'node:path';
86
+ import { fileURLToPath } from 'node:url';
87
+
88
+ import { sourceTreeHash, sha256File } from './_provenance.mjs';
89
+ // Single source of truth for the sanity-envelope constants. The grader
90
+ // (evaluate-convergence.mjs) imports the SAME module, so the floor it
91
+ // asserts is identical to the floor this script refuses to emit below.
92
+ import { SSIM_NONTEXT_MIN, TEXT_IOU_MIN } from './_calib-consts.mjs';
93
+
94
+ import {
95
+ requirePng,
96
+ loadPng,
97
+ ssimWindow,
98
+ ciede2000Region,
99
+ resampleLanczos3,
100
+ p3ToSrgb,
101
+ cropRect,
102
+ quantile,
103
+ } from './_imglib.mjs';
104
+
105
+ // ── Device table (must match capture-reference.mjs DEVICES) ──────────────────
106
+ //
107
+ // Fields:
108
+ // logical_w, logical_h — logical point dimensions (pt)
109
+ // status_bar_pt — top inset (status bar + notch/island) in pt
110
+ // home_indicator_pt — bottom inset (home indicator) in pt
111
+ // render_scale — physical pixels per logical pt (@Nx)
112
+ //
113
+ // content_rect_pt = [0, status_bar_pt, logical_w, logical_h - status_bar_pt - home_indicator_pt]
114
+ // safe_area_offset_pt = [0, status_bar_pt]
115
+
116
+ const DEVICES = {
117
+ 'iPhone 15 Pro': {
118
+ logical_w: 393, logical_h: 852,
119
+ status_bar_pt: 59, home_indicator_pt: 34,
120
+ render_scale: 3,
121
+ },
122
+ 'iPhone 14 Pro': {
123
+ logical_w: 393, logical_h: 852,
124
+ status_bar_pt: 59, home_indicator_pt: 34,
125
+ render_scale: 3,
126
+ },
127
+ 'iPhone 15': {
128
+ logical_w: 390, logical_h: 844,
129
+ status_bar_pt: 47, home_indicator_pt: 34,
130
+ render_scale: 3,
131
+ },
132
+ 'iPhone SE': {
133
+ logical_w: 375, logical_h: 667,
134
+ status_bar_pt: 20, home_indicator_pt: 0,
135
+ render_scale: 2,
136
+ },
137
+ };
138
+
139
+ // ── CLI ───────────────────────────────────────────────────────────────────────
140
+
141
+ const args = process.argv.slice(2);
142
+
143
+ if (args.includes('--help') || args.includes('-h')) {
144
+ console.log(`calibrate-render.mjs — Stage 2.5 render-equivalence calibration
145
+
146
+ Usage:
147
+ node calibrate-render.mjs <calibration-dir> --device "iPhone 15 Pro"
148
+ [--out calibration.json]
149
+ [--ref <h5.png>] [--gen <swift.png>]
150
+ [--sim-runtime <runtime>]
151
+ [--browser <browser-id>]
152
+ [--model-id <model>]
153
+
154
+ Arguments:
155
+ <calibration-dir> Directory holding the known-correct H5/SwiftUI pair
156
+ --device <name> Target device (required); must match a known profile
157
+ --out <path> Output path for calibration.json (default: calibration.json
158
+ inside <calibration-dir>)
159
+ --ref <path> Explicit path to H5/Playwright reference PNG
160
+ --gen <path> Explicit path to SwiftUI simulator render PNG
161
+ --sim-runtime <runtime> Simulator runtime string to pin in output (e.g. "iOS 17.5")
162
+ --browser <id> Browser build string to pin (e.g. "chromium-1180")
163
+ --model-id <id> Model identifier to pin (e.g. "claude-sonnet-4-6")
164
+
165
+ Input resolution (precedence):
166
+ 1. --ref / --gen flags if provided
167
+ 2. Auto-find in <calibration-dir>: first *ref*.png = H5 side,
168
+ first *gen*.png = SwiftUI side (alphabetical within each glob)
169
+
170
+ Supported devices:
171
+ "iPhone 15 Pro" 393×852 pt status_bar=59pt home=34pt @3x
172
+ "iPhone 14 Pro" 393×852 pt status_bar=59pt home=34pt @3x
173
+ "iPhone 15" 390×844 pt status_bar=47pt home=34pt @3x
174
+ "iPhone SE" 375×667 pt status_bar=20pt home=0pt @2x
175
+
176
+ Normalization pipeline:
177
+ 1. Crop simulator content rect via device safe-area insets
178
+ 2. Resample BOTH sides to logical_pt × render_scale with Lanczos3
179
+ 3. P3→sRGB (relative colorimetric) on the simulator side
180
+
181
+ Sanity bound:
182
+ If ssim_nontext < 0.95 (or text_iou < 0.9 when non-null), the toolchain is
183
+ unmeasurable. Writes blocked.json and exits 1. Never grades against an
184
+ unproven metric.
185
+ Flat-image guard: if BOTH normalized images are effectively flat (near-zero
186
+ luma variance), SSIM is unreliable for a mean shift ⇒ also blocked.json.
187
+
188
+ Structured gate (no string DSL):
189
+ gate.converged / gate.close are numeric threshold objects, evaluated by
190
+ evaluate-convergence.mjs WITHOUT a parser:
191
+ converged: { ssim_nontext_min, deltaE_p95_max, text_iou_min,
192
+ require_judge_yes: true }
193
+ close: { ...2x band... , require_judge_equiv: true }
194
+ gate_explain is a human-only string; code never evaluates it.
195
+
196
+ Calibration honesty + provenance binding:
197
+ twin_hashes = SHA-256 of the input ref + gen PNGs (non-security
198
+ provenance metadata; the security binding is
199
+ calibration_source).
200
+ calibration_source = bundled twin dirs (relative to skill root) +
201
+ deterministic SHA-256 source-tree hash of each tree's
202
+ SOURCE FILES (excluding build output/dotfiles).
203
+ evaluate-convergence.mjs recomputes these from the
204
+ shipped assets and FAILS CLOSED on a mismatch — this
205
+ binds the twin source IDENTITY, NOT the measured floor
206
+ value. The grader additionally asserts the floor is
207
+ within this script's own sanity envelope; a floor
208
+ within that envelope but looser than the true measured
209
+ one is a disclosed irreducible residual (grader cannot
210
+ re-render to re-measure it).
211
+
212
+ Outputs:
213
+ calibration.json schema: h5-to-swiftui/calibration@1
214
+ blocked.json written instead when sanity bound fails
215
+
216
+ Exit codes:
217
+ 0 calibration.json written
218
+ 1 fatal error / sanity bound failed
219
+ 2 pngjs not installed
220
+
221
+ Examples:
222
+ node calibrate-render.mjs assets/calibration --device "iPhone 15 Pro"
223
+ node calibrate-render.mjs assets/calibration --device "iPhone 15 Pro" \\
224
+ --out dist/calibration.json --sim-runtime "iOS 17.5 (21F79)"
225
+ `);
226
+ process.exit(0);
227
+ }
228
+
229
+ // Positional: first non-flag arg
230
+ let calibDir = null;
231
+ for (const a of args) {
232
+ if (!a.startsWith('--')) { calibDir = a; break; }
233
+ }
234
+
235
+ if (!calibDir) {
236
+ console.error('Error: <calibration-dir> is required.\nRun with --help for usage.');
237
+ process.exit(1);
238
+ }
239
+
240
+ calibDir = resolve(calibDir);
241
+
242
+ if (!existsSync(calibDir)) {
243
+ console.error(`Error: calibration-dir not found: ${calibDir}`);
244
+ process.exit(1);
245
+ }
246
+
247
+ function getFlag(flag, fallback = null) {
248
+ const idx = args.indexOf(flag);
249
+ if (idx === -1) return fallback;
250
+ const val = args[idx + 1];
251
+ if (val === undefined || val.startsWith('--')) {
252
+ console.error(`Error: ${flag} requires an argument.`);
253
+ process.exit(1);
254
+ }
255
+ return val;
256
+ }
257
+
258
+ const deviceName = getFlag('--device');
259
+ const outFlagRaw = getFlag('--out');
260
+ const refFlagRaw = getFlag('--ref');
261
+ const genFlagRaw = getFlag('--gen');
262
+ const simRuntime = getFlag('--sim-runtime', 'unknown');
263
+ const browserStr = getFlag('--browser', 'unknown');
264
+ const modelId = getFlag('--model-id', 'unknown');
265
+
266
+ if (!deviceName) {
267
+ console.error('Error: --device is required.\nRun with --help for usage.');
268
+ process.exit(1);
269
+ }
270
+
271
+ const device = DEVICES[deviceName];
272
+ if (!device) {
273
+ const known = Object.keys(DEVICES).map(d => `"${d}"`).join(', ');
274
+ console.error(`Error: unknown device "${deviceName}".\nSupported: ${known}`);
275
+ process.exit(1);
276
+ }
277
+
278
+ // Resolve output path
279
+ const defaultOut = join(calibDir, 'calibration.json');
280
+ const outPath = outFlagRaw ? resolve(outFlagRaw) : defaultOut;
281
+ const blockedPath = join(dirname(outPath), 'blocked.json');
282
+
283
+ // ── Resolve input PNGs ────────────────────────────────────────────────────────
284
+
285
+ function findPngGlob(dir, pattern) {
286
+ // pattern is a substring to match (e.g. 'ref', 'gen')
287
+ try {
288
+ const files = readdirSync(dir)
289
+ .filter(f => f.endsWith('.png') && f.toLowerCase().includes(pattern))
290
+ .sort();
291
+ return files.length > 0 ? join(dir, files[0]) : null;
292
+ } catch {
293
+ return null;
294
+ }
295
+ }
296
+
297
+ let refPath = refFlagRaw ? resolve(refFlagRaw) : findPngGlob(calibDir, 'ref');
298
+ let genPath = genFlagRaw ? resolve(genFlagRaw) : findPngGlob(calibDir, 'gen');
299
+
300
+ if (!refPath) {
301
+ console.error(
302
+ 'Error: could not find H5 reference PNG.\n' +
303
+ 'Either pass --ref <path> or place a file matching *ref*.png in <calibration-dir>.'
304
+ );
305
+ process.exit(1);
306
+ }
307
+
308
+ if (!genPath) {
309
+ console.error(
310
+ 'Error: could not find SwiftUI render PNG.\n' +
311
+ 'Either pass --gen <path> or place a file matching *gen*.png in <calibration-dir>.'
312
+ );
313
+ process.exit(1);
314
+ }
315
+
316
+ if (!existsSync(refPath)) {
317
+ console.error(`Error: --ref file not found: ${refPath}`);
318
+ process.exit(1);
319
+ }
320
+
321
+ if (!existsSync(genPath)) {
322
+ console.error(`Error: --gen file not found: ${genPath}`);
323
+ process.exit(1);
324
+ }
325
+
326
+ console.error(`Device: ${deviceName}`);
327
+ console.error(`H5 ref: ${refPath}`);
328
+ console.error(`SwiftUI gen:${genPath}`);
329
+ console.error(`Output: ${outPath}`);
330
+
331
+ // ── Load PNG dependency early for clear error ────────────────────────────────
332
+
333
+ await requirePng();
334
+
335
+ // ── Load images ───────────────────────────────────────────────────────────────
336
+
337
+ let imgRef, imgGen;
338
+ try {
339
+ [imgRef, imgGen] = await Promise.all([loadPng(refPath), loadPng(genPath)]);
340
+ } catch (e) {
341
+ console.error(`Error: failed to load image: ${e.message}`);
342
+ process.exit(1);
343
+ }
344
+
345
+ console.error(`H5 ref size: ${imgRef.width}×${imgRef.height}`);
346
+ console.error(`SwiftUI size: ${imgGen.width}×${imgGen.height}`);
347
+
348
+ // ── Normalization pipeline ────────────────────────────────────────────────────
349
+
350
+ // Derive content rect from device safe-area insets
351
+ const contentX = 0;
352
+ const contentY = device.status_bar_pt; // pt
353
+ const contentW = device.logical_w; // pt
354
+ const contentH = device.logical_h - device.status_bar_pt - device.home_indicator_pt; // pt
355
+
356
+ // Physical pixel dimensions for the crop on the simulator side
357
+ const cropX_px = Math.round(contentX * device.render_scale);
358
+ const cropY_px = Math.round(contentY * device.render_scale);
359
+ const cropW_px = Math.round(contentW * device.render_scale);
360
+ const cropH_px = Math.round(contentH * device.render_scale);
361
+
362
+ // Common logical raster target (logical pt × render_scale = physical px of content)
363
+ const targetW = cropW_px;
364
+ const targetH = cropH_px;
365
+
366
+ console.error(`Content rect (pt): [${contentX}, ${contentY}, ${contentW}, ${contentH}]`);
367
+ console.error(`Crop (px): [${cropX_px}, ${cropY_px}, ${cropW_px}, ${cropH_px}]`);
368
+ console.error(`Resample target: ${targetW}×${targetH}`);
369
+
370
+ // Step 1: Crop simulator side to content rect
371
+ // Only crop if the simulator image is taller than the content crop (has chrome)
372
+ let imgGenNorm = imgGen;
373
+ if (imgGen.height >= cropY_px + cropH_px && imgGen.width >= cropX_px + cropW_px) {
374
+ imgGenNorm = cropRect(imgGen, cropX_px, cropY_px, cropW_px, cropH_px);
375
+ console.error(`Cropped simulator side: ${imgGenNorm.width}×${imgGenNorm.height}`);
376
+ } else {
377
+ console.error(
378
+ `Warning: simulator image (${imgGen.width}×${imgGen.height}) is smaller than the ` +
379
+ `expected crop [${cropX_px},${cropY_px},${cropW_px},${cropH_px}] — skipping crop.`
380
+ );
381
+ }
382
+
383
+ // Step 2: Resample both sides to common logical raster (Lanczos3, identical filter)
384
+ const imgRefNorm = resampleLanczos3(imgRef, targetW, targetH);
385
+ imgGenNorm = resampleLanczos3(imgGenNorm, targetW, targetH);
386
+ console.error(`After resample — ref: ${imgRefNorm.width}×${imgRefNorm.height}, gen: ${imgGenNorm.width}×${imgGenNorm.height}`);
387
+
388
+ // Step 3: P3→sRGB on simulator (gen) side
389
+ p3ToSrgb(imgGenNorm);
390
+ console.error('P3→sRGB conversion applied to simulator side.');
391
+
392
+ // ── Measurements ─────────────────────────────────────────────────────────────
393
+
394
+ // ssim_global: whole-image SSIM
395
+ const ssim_global = ssimWindow(imgRefNorm, imgGenNorm, undefined, 8);
396
+
397
+ // ssim_nontext: without a bbox map we treat the whole image as non-text.
398
+ // The text_iou is null; we document this in the output.
399
+ const ssim_nontext = ssimWindow(imgRefNorm, imgGenNorm, undefined, 8);
400
+
401
+ // deltaE_p95: 95th-percentile CIEDE2000 ΔE over the whole image
402
+ const { p95: deltaE_p95 } = ciede2000Region(imgRefNorm, imgGenNorm);
403
+
404
+ // text_iou: null — no bbox map at calibration time
405
+ const text_iou = null;
406
+
407
+ // Luma variance per side (BT.601). A near-zero variance means the image is
408
+ // effectively flat — SSIM cannot distinguish a uniform mean shift there, so
409
+ // the floor it yields would be unreliable.
410
+ function lumaVariance(img) {
411
+ const d = img.data;
412
+ const n = img.width * img.height;
413
+ if (n === 0) return 0;
414
+ let sum = 0;
415
+ for (let i = 0; i < d.length; i += 4) {
416
+ sum += 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
417
+ }
418
+ const mean = sum / n;
419
+ let varSum = 0;
420
+ for (let i = 0; i < d.length; i += 4) {
421
+ const l = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
422
+ varSum += (l - mean) * (l - mean);
423
+ }
424
+ return varSum / n;
425
+ }
426
+
427
+ // Variance threshold below which a side is "effectively flat" (luma units²).
428
+ // ~9 ≈ a 3-luma-level stddev: anything below this has essentially no structure.
429
+ const FLAT_VARIANCE_MAX = 9;
430
+ const varRef = lumaVariance(imgRefNorm);
431
+ const varGen = lumaVariance(imgGenNorm);
432
+ const bothFlat = varRef < FLAT_VARIANCE_MAX && varGen < FLAT_VARIANCE_MAX;
433
+
434
+ console.error(`\nMeasured floor:`);
435
+ console.error(` ssim_global = ${ssim_global.toFixed(4)}`);
436
+ console.error(` ssim_nontext = ${ssim_nontext.toFixed(4)} (whole image, no bbox map)`);
437
+ console.error(` deltaE_p95 = ${deltaE_p95.toFixed(4)}`);
438
+ console.error(` text_iou = null (no bbox map provided)`);
439
+ console.error(` luma_variance = ref ${varRef.toFixed(2)}, gen ${varGen.toFixed(2)} ` +
440
+ `(flat threshold ${FLAT_VARIANCE_MAX})`);
441
+
442
+ // ── Sanity bound ──────────────────────────────────────────────────────────────
443
+ //
444
+ // If the known-correct pair cannot beat conservative bounds, the toolchain is
445
+ // not measurable. Write blocked.json and exit 1. Never fake convergence.
446
+
447
+ // SSIM_NONTEXT_MIN (0.95) and TEXT_IOU_MIN (0.9) are imported from
448
+ // ./_calib-consts.mjs — the SINGLE source of truth shared with
449
+ // evaluate-convergence.mjs. The numeric values are unchanged (byte-identical
450
+ // behavior): this script still writes blocked.json below these bounds.
451
+ const ssimFails = ssim_nontext < SSIM_NONTEXT_MIN;
452
+ // text_iou check is skipped when null (no bbox map)
453
+ const textIouFails = text_iou !== null && text_iou < TEXT_IOU_MIN;
454
+ // Flat-image guard: SSIM is insensitive to a uniform mean shift on
455
+ // near-zero-variance images, so a falsely-high floor is possible. If BOTH
456
+ // sides are flat, the floor is unreliable ⇒ not measurable.
457
+ const flatFails = bothFlat;
458
+
459
+ if (ssimFails || textIouFails || flatFails) {
460
+ const reasons = [];
461
+ if (ssimFails) reasons.push(`ssim_nontext=${ssim_nontext.toFixed(4)} < ${SSIM_NONTEXT_MIN}`);
462
+ if (textIouFails) reasons.push(`text_iou=${text_iou.toFixed(4)} < ${TEXT_IOU_MIN}`);
463
+ if (flatFails) reasons.push(
464
+ `both images effectively flat (luma variance ref=${varRef.toFixed(2)}, ` +
465
+ `gen=${varGen.toFixed(2)} < ${FLAT_VARIANCE_MAX}) — SSIM floor unreliable; ` +
466
+ `calibration content must be textured`);
467
+
468
+ const blocked = {
469
+ schema: 'h5-to-swiftui/blocked@1',
470
+ stage: '2.5',
471
+ reason: flatFails && !ssimFails && !textIouFails
472
+ ? 'toolchain not measurable (flat calibration content)'
473
+ : 'toolchain not measurable',
474
+ detail: reasons.join('; '),
475
+ measured: {
476
+ ssim_global: Math.round(ssim_global * 10000) / 10000,
477
+ ssim_nontext: Math.round(ssim_nontext * 10000) / 10000,
478
+ deltaE_p95: Math.round(deltaE_p95 * 10000) / 10000,
479
+ text_iou,
480
+ luma_variance: {
481
+ ref: Math.round(varRef * 100) / 100,
482
+ gen: Math.round(varGen * 100) / 100,
483
+ flat_threshold: FLAT_VARIANCE_MAX,
484
+ },
485
+ },
486
+ device: deviceName,
487
+ measured_at: new Date().toISOString(),
488
+ };
489
+
490
+ mkdirSync(dirname(blockedPath), { recursive: true });
491
+ writeFileSync(blockedPath, JSON.stringify(blocked, null, 2) + '\n', 'utf8');
492
+
493
+ console.error(`\nSANITY BOUND FAILED: ${reasons.join('; ')}`);
494
+ console.error(`Toolchain is not measurable. Wrote: ${blockedPath}`);
495
+ console.error('calibration.json was NOT written. Fix the toolchain and re-run.');
496
+ process.exit(1);
497
+ }
498
+
499
+ // ── Twin hashes (calibration honesty) ─────────────────────────────────────────
500
+ // SHA-256 of the raw input PNGs so a downstream reader can detect a
501
+ // degraded/swapped twin before trusting the floor.
502
+ const twinHashes = {
503
+ ref_png: refPath,
504
+ ref_sha256: sha256File(refPath),
505
+ gen_png: genPath,
506
+ gen_sha256: sha256File(genPath),
507
+ };
508
+
509
+ // ── Calibration source provenance (binds the bundled twin SOURCE IDENTITY) ────
510
+ // Skill root = parent of this script's directory (scripts/ -> skill root).
511
+ // Resolved from the script's own location so it is correct from ANY cwd.
512
+ const SKILL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
513
+ const REL_H5_TWIN = 'assets/calibration/h5-twin';
514
+ const REL_SWIFTUI_TWIN = 'assets/calibration/swiftui-twin';
515
+ const calibrationSource = {
516
+ // POSIX-relative bundled twin dirs this calibration must derive from.
517
+ h5_twin_dir: REL_H5_TWIN,
518
+ swiftui_twin_dir: REL_SWIFTUI_TWIN,
519
+ // Deterministic SHA-256 content hash of each shipped source tree's SOURCE
520
+ // FILES (excluding build output/dotfiles). The consumer recomputes these
521
+ // from the actual bundled assets and fails closed on a mismatch — this
522
+ // binds the IDENTITY of the bundled twin source files. It does NOT bind
523
+ // the measured `floor` *value*: keeping these (public, unmodified) hashes
524
+ // while writing a loose floor is caught only down to this script's own
525
+ // sanity envelope (the grader's FIX A check); a floor within that envelope
526
+ // yet looser than the true measured one is a disclosed irreducible
527
+ // residual (the grader cannot re-render to re-measure it).
528
+ h5_twin_source_sha256: sourceTreeHash(join(SKILL_ROOT, REL_H5_TWIN)),
529
+ swiftui_twin_source_sha256: sourceTreeHash(join(SKILL_ROOT, REL_SWIFTUI_TWIN)),
530
+ source_tree_hash_algo: 'sha256/sorted-relpath+filebytes/v1',
531
+ };
532
+
533
+ // ── Emit calibration.json ─────────────────────────────────────────────────────
534
+
535
+ // Structured numeric gate — evaluate-convergence.mjs reads these WITHOUT a
536
+ // string parser. Tolerances applied to the measured floor:
537
+ // ssim_nontext_min = floor - 0.005 (looser by 0.005)
538
+ // deltaE_p95_max = floor + 0.4 (looser by 0.4)
539
+ // text_iou_min = floor - 0.03 (looser by 0.03; null ⇒ skipped)
540
+ // `close` is a 2× band of each delta from the floor.
541
+ const round4 = (v) => Math.round(v * 10000) / 10000;
542
+
543
+ const floorSsimNontext = round4(ssim_nontext);
544
+ const floorDeltaE = round4(deltaE_p95);
545
+ const floorTextIou = text_iou; // null when no bbox map
546
+
547
+ const D_SSIM = 0.005;
548
+ const D_DELTAE = 0.4;
549
+ const D_IOU = 0.03;
550
+
551
+ const gateConverged = {
552
+ ssim_nontext_min: round4(floorSsimNontext - D_SSIM),
553
+ deltaE_p95_max: round4(floorDeltaE + D_DELTAE),
554
+ text_iou_min: floorTextIou === null ? null : round4(floorTextIou - D_IOU),
555
+ require_judge_yes: true,
556
+ };
557
+ const gateClose = {
558
+ ssim_nontext_min: round4(floorSsimNontext - 2 * D_SSIM),
559
+ deltaE_p95_max: round4(floorDeltaE + 2 * D_DELTAE),
560
+ text_iou_min: floorTextIou === null ? null : round4(floorTextIou - 2 * D_IOU),
561
+ require_judge_equiv: true,
562
+ };
563
+ const gateExplain =
564
+ `converged := ssim_nontext >= ${gateConverged.ssim_nontext_min} ` +
565
+ `AND deltaE_p95 <= ${gateConverged.deltaE_p95_max} ` +
566
+ (gateConverged.text_iou_min === null
567
+ ? 'AND (text_iou gate skipped: no bbox map at calibration) '
568
+ : `AND text_iou >= ${gateConverged.text_iou_min} `) +
569
+ `AND judge=YES. close := same metrics at the 2x band ` +
570
+ `(ssim>=${gateClose.ssim_nontext_min}, deltaE<=${gateClose.deltaE_p95_max}` +
571
+ (gateClose.text_iou_min === null ? '' : `, text_iou>=${gateClose.text_iou_min}`) +
572
+ `) AND judge=visually-equivalent-residual-subperceptual. ` +
573
+ `Human-readable only — code reads gate.converged / gate.close numerically.`;
574
+
575
+ const calibration = {
576
+ schema: 'h5-to-swiftui/calibration@1',
577
+ pinned: {
578
+ sim_runtime: simRuntime,
579
+ device: deviceName,
580
+ logical_size: [device.logical_w, device.logical_h],
581
+ render_scale: device.render_scale,
582
+ browser: browserStr,
583
+ model_id: modelId,
584
+ temperature: 0,
585
+ },
586
+ transform: {
587
+ dom_to_screen_scale: 1.0,
588
+ safe_area_offset_pt: [0, device.status_bar_pt],
589
+ content_rect_pt: [contentX, contentY, contentW, contentH],
590
+ resample_filter: 'lanczos3',
591
+ color: 'p3->srgb-relative',
592
+ },
593
+ twin_hashes: twinHashes,
594
+ calibration_source: calibrationSource,
595
+ floor: {
596
+ ssim_global: round4(ssim_global),
597
+ ssim_nontext: floorSsimNontext,
598
+ deltaE_p95: floorDeltaE,
599
+ text_iou: floorTextIou,
600
+ luma_variance: {
601
+ ref: Math.round(varRef * 100) / 100,
602
+ gen: Math.round(varGen * 100) / 100,
603
+ flat_threshold: FLAT_VARIANCE_MAX,
604
+ },
605
+ _text_iou_note: text_iou === null
606
+ ? 'No bbox map provided at calibration time; text_iou is null. ' +
607
+ 'Stage 5 will skip the text_iou gate for this calibration.'
608
+ : undefined,
609
+ },
610
+ gate: {
611
+ converged: gateConverged,
612
+ close: gateClose,
613
+ gate_explain: gateExplain,
614
+ },
615
+ measured_at: new Date().toISOString(),
616
+ };
617
+
618
+ // Remove undefined keys (e.g. _text_iou_note when text_iou is not null)
619
+ const cleanCalib = JSON.parse(JSON.stringify(calibration));
620
+
621
+ mkdirSync(dirname(outPath), { recursive: true });
622
+ writeFileSync(outPath, JSON.stringify(cleanCalib, null, 2) + '\n', 'utf8');
623
+
624
+ console.error(`\nCalibration complete. Wrote: ${outPath}`);
625
+ process.exit(0);