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,365 @@
1
+ # Stage 5 — Visual-Diff Convergence Loop Protocol
2
+
3
+ The core mechanism. Operates **per component** (component granularity is the
4
+ primary anti-oscillation defense — a fix to one component cannot break
5
+ another). Consumes `calibration.json` (Stage 2.5) and the per-component
6
+ **snapshot host** (Stage 4 hard output contract).
7
+
8
+ ## One iteration
9
+
10
+ 1. **Render** the component via its snapshot host in the simulator
11
+ (`sim-screenshot.sh`). Normalize per `calibration.json.transform`
12
+ (crop → resample → P3→sRGB) so it co-registers with the Stage-2
13
+ reference crop for that component (`reference/<screen>/<component>.png`).
14
+ 2. **Diff cascade** (`pixel-diff.mjs`):
15
+ - pHash Hamming is recorded as raw data plus a `phash_fast_candidate`
16
+ boolean (≤ 5). **It is necessary-not-sufficient and NEVER a
17
+ short-circuit to `converged`** — `pixel-diff.mjs` does not decide the
18
+ verdict; `evaluate-convergence.mjs` does, and it always requires the
19
+ region gate to pass regardless of pHash.
20
+ - split component into **text regions** vs **non-text regions** using the
21
+ DOM bbox map:
22
+ - text → score by layout-box **IoU** + resolved **token-color ΔE**
23
+ (foreground/background), **never glyph-raster SSIM** (cross-renderer
24
+ glyph rasters are not comparable — Stage 2.5). IoU is the **real**
25
+ `bboxIoU(refBox, genBox)` only when `--gen-bbox-map` supplies the
26
+ generated bbox; otherwise `iou` is **`null`** (never a fabricated
27
+ 1.0), and a `null` iou counts as a gate **FAIL**.
28
+ - non-text → SSIM + CIEDE2000 + AA-tolerant diff mask.
29
+ - **inter-component spacing delta** is `null` unless generated positions
30
+ are supplied via `--gen-bbox-map` (never fabricated `{top:0,leading:0}`).
31
+ - Inputs MUST be **co-registered** (same dimensions) — `pixel-diff.mjs`
32
+ does not normalize and **hard-errors (exit 1)** on a size mismatch;
33
+ pre-normalize via Stage 2.5 `calibration.transform` first.
34
+ 3. **Feedback payload** to the corrector LLM:
35
+ - reference + generated, both with **identical Set-of-Mark** numbered
36
+ overlays (`mark-overlay.mjs`),
37
+ - red diff-mask overlay on the reference,
38
+ - structured JSON delta (schema below),
39
+ - **two-stage critique**: (a) visual/perceptual NL critique, then (b)
40
+ code-level NL→SwiftUI-patch recommendation (separation of modalities).
41
+ 4. **Patch**: corrector emits a **structured per-file diff** (NOT a
42
+ whole-file rewrite — enables reversion control), constrained to the
43
+ `tokens.json` vocabulary; prior-iteration correction history is injected
44
+ ("you changed X which worsened Y; do not revert").
45
+ 5. **Recompile.** Compile-failure branch: revert working tree to the best
46
+ gate-passing iteration, **consume one iteration**; if the cap is hit with
47
+ no buildable+passing iteration ⇒ `needs-human` (never `converged`).
48
+ 6. **Re-measure** (step 2). Retain best **only among gate-passing
49
+ iterations** (monotone-or-fail: a non-passing run is never presented as a
50
+ result).
51
+
52
+ Cap = `--max-iter` (default 3). Diminishing returns past 3 are documented in
53
+ `../../../.omc/conductor/tracks/h5-to-swiftui/research/findings.md` RQ4.
54
+
55
+ ## The verdict is emitted ONLY by `scripts/evaluate-convergence.mjs`
56
+
57
+ `convergence/<component>.json` is **never hand-written** by the orchestrator.
58
+ The orchestrator runs `pixel-diff.mjs` per iteration, records each
59
+ iteration's `built` flag, gathers masks + the independent-judge result, then
60
+ calls `scripts/evaluate-convergence.mjs`, which **mechanically** decides the
61
+ tier and **exits non-zero on any guard violation** (exit 3 =
62
+ needs-human/guard violation, exit 4 = blocked, exit 0 = converged/close) so a
63
+ pipeline cannot ignore a failed gate. Every guard below is enforced *in that
64
+ script's code*, not by prose. The script — not the caller — chooses
65
+ `best_iteration` and computes `gate_passed` per iteration; caller-supplied
66
+ values for those are ignored.
67
+
68
+ Invocation:
69
+
70
+ ```
71
+ node scripts/evaluate-convergence.mjs \
72
+ --iterations iterations.json \ # [{i,diff_json_path,built}, ...]
73
+ --calibration calibration.json \ # STRUCTURED numeric gate (not string DSL)
74
+ --judge judge.json \ # {negative_control,framing,differences,verdict}
75
+ --masks masks.json \ # [{x,y,w,h,reason}] (reason required)
76
+ --component-area 320x140 \ # mask-fraction denominator
77
+ --component ProductCard \
78
+ --out .h5-to-swiftui/convergence/ProductCard.json
79
+ ```
80
+
81
+ ## Trust model & residual (honest disclosure — spec §1.1)
82
+
83
+ The skill's own thesis (§1.1) is *report the residual, do not pretend it is
84
+ zero*. Applied to the grader's **own** trust model:
85
+
86
+ **Mechanically bound (fail-closed, in `evaluate-convergence.mjs` code):**
87
+
88
+ - the structured `gate` is **recomputed from `calib.floor`** and a deviating
89
+ gate is rejected (`gate-floor-mismatch`, exit 1) — this binds the *gate to
90
+ the floor* (a hand-loosened gate is rejected unless the floor is loosened);
91
+ it does not bind the floor's *value*;
92
+ - the **identity of the bundled twin source files** (excluding build
93
+ output/dotfiles) is bound via `calibration_source` source-tree hashes
94
+ recomputed from the actual `assets/calibration/{h5-twin,swiftui-twin}`
95
+ (`calibration-twin-mismatch`, exit 1) — this binds the twin *source
96
+ identity*, NOT the measured `floor` *value* (the real public twin hashes
97
+ can be copied alongside a loose floor);
98
+ - the **`floor` value** is asserted to satisfy `calibrate-render.mjs`'s OWN
99
+ sanity envelope via the shared `scripts/_calib-consts.mjs`
100
+ (`ssim_nontext ≥ 0.95`, non-null `text_iou ≥ 0.9`, metric-valid
101
+ `deltaE_p95`); a floor calibrate-render could not have emitted (it writes
102
+ `blocked.json` below this) is rejected `floor-implausible`, exit 1 — this
103
+ kills the absurd-floor attack but does NOT re-measure the floor;
104
+ - the judge negative control is bound to the **shipped, hash-pinned**
105
+ `assets/calibration/swiftui-twin-divergent` source files (structured +
106
+ rejected + framed; `negative-control-unbound` VOIDs any `YES`, exit 3);
107
+ - pHash never short-circuits; a `null` text IoU is a FAIL; best-of-N is
108
+ monotone-or-fail; the mask budget is verified or the run refuses to pass;
109
+ `calibration.json.schema` is asserted.
110
+
111
+ **Named, irreducible residuals (NOT zero — BOTH stated, neither hidden):** a
112
+ fully zero-trust verdict is impossible because *something must run the
113
+ renders and something must measure the floor*.
114
+
115
+ 1. **The grader cannot re-execute the simulator renders.** It trusts the
116
+ per-iteration `pixel-diff.mjs` JSONs were produced by running the **real
117
+ `pixel-diff.mjs` on real `sim-screenshot.sh` renders** (bounded by
118
+ `sim-screenshot.sh`'s no-fake build/env spine — no simulator / no build ⇒
119
+ `blocked`/`needs-human`, never converged).
120
+ 2. **The grader cannot re-measure the calibration floor.** It asserts the
121
+ supplied `floor` satisfies `calibrate-render.mjs`'s own sanity envelope
122
+ and recomputes the gate from it, but a `floor` *within* that envelope yet
123
+ looser than the TRUE measured floor is trusted (it cannot re-render the
124
+ bundled twins to re-derive the real number). Mitigated by the
125
+ orchestrator's contractual obligation to run the real,
126
+ sanity/flat-image-spined `calibrate-render.mjs` and by the human-readable
127
+ `calibration_provenance` recorded in the convergence artifact.
128
+
129
+ These are the deliberate, documented boundaries: the deliverable is
130
+ **"maximally provenance-bound + honestly disclosed residual"**, explicitly
131
+ *not* "zero-trust". The **whole-assembled-screen trend check remains a
132
+ Stage-7 manual cross-check**, not an automated Stage-5 guard (unchanged;
133
+ still honestly scoped).
134
+
135
+ ## Tiered verdict (honest)
136
+
137
+ `evaluate-convergence.mjs` reads the **structured** `calibration.json.gate`
138
+ (numeric `gate.converged` / `gate.close` objects — a legacy string-DSL gate
139
+ is rejected as un-enforceable) and evaluates it against each iteration's
140
+ `pixel-diff.mjs` output:
141
+
142
+ - **`converged`** — the *script's* chosen best iteration (built AND
143
+ gate-passing) passes the converged gate vs the *measured* floor AND the
144
+ independent judge `YES` with a valid negative control.
145
+ - **`close`** — structural metrics within the `close` band AND judge returns
146
+ `visually-equivalent-residual-subperceptual`. This is an **honest accept**,
147
+ explicitly distinct from faking — "as good as this renderer pair allows".
148
+ - **`needs-human`** — anything else; recorded with full evidence + the
149
+ machine-readable guard reason. **`blocked`** — a `blocked.json` is present
150
+ for the component (never converged).
151
+
152
+ ## Independent judge (anti-collusion)
153
+
154
+ - Separate sub-agent, separate lane (OMC: never self-approve in same
155
+ context). Dispatch via `Agent` (use `verifier` or `qa-tester`), NOT the
156
+ corrector.
157
+ - **Negative control (BOUND to the shipped divergent twin)**: before its
158
+ verdict is trusted, feed the judge the known-divergent pair
159
+ `h5-twin` vs the bundled `assets/calibration/swiftui-twin-divergent`; it
160
+ MUST return a rejection. This is **not** a free `"passed"` string — the old
161
+ bare form is rejected as unbound (it asserts a judge run nothing verified).
162
+ `judge.negative_control` MUST be the structured artifact below, and
163
+ `scripts/evaluate-convergence.mjs` recomputes the divergent twin's
164
+ source-tree hash from the shipped asset and fails closed
165
+ (`negative-control-unbound`, any `YES` VOID, exit 3) unless every field
166
+ matches:
167
+
168
+ ```json
169
+ "negative_control": {
170
+ "stimulus_source_hash": "<sha256 source-tree hash of the bundled assets/calibration/swiftui-twin-divergent>",
171
+ "divergent_pair": "h5-twin vs swiftui-twin-divergent",
172
+ "rejected": true,
173
+ "differences": [ { "desc": "green bg vs light", "severity": "major" }, … ]
174
+ }
175
+ ```
176
+
177
+ `stimulus_source_hash` ≠ the recomputed bundled divergent hash, OR
178
+ `rejected !== true`, OR an empty/unstructured `differences`, OR
179
+ `framing !== "forced-difference-3"` ⇒ the negative control is **unbound**,
180
+ any `YES` is **VOID**, tier forced `needs-human`, exit non-zero
181
+ (`reason: negative-control-unbound`). This ties "the judge really rejected
182
+ the *known-divergent* pair" to the shipped, hash-pinned asset rather than a
183
+ free string.
184
+ - **Adversarial framing**: prompt = *"enumerate the 3 most significant
185
+ visual differences between A and B and rate each severity"*, never
186
+ "do these match?". A `converged` verdict requires all 3 to be
187
+ sub-perceptual / cross-renderer-irreducible. `judge.framing` MUST be
188
+ `"forced-difference-3"` (enforced; absence/mismatch VOIDs `YES`).
189
+
190
+ ## Anti-gaming guards
191
+
192
+ **Enforced in code by `scripts/evaluate-convergence.mjs`** (violation ⇒ tier
193
+ downgrade + non-zero exit + a machine-readable reason — never a silent pass):
194
+
195
+ - **Mask budget**: `sum(mask area) / component area` must be ≤ 0.10; every
196
+ mask must carry a non-empty `reason`. Over budget or a reason-less mask ⇒
197
+ forced `needs-human`, exit 3. If masks are supplied but the component area
198
+ is unknown, the script **refuses to proceed (exit 1)** rather than silently
199
+ pass an unverifiable budget.
200
+ - **Gate eval in code**: the structured `calibration.json` thresholds are
201
+ evaluated per iteration to produce `gate_passed` (the caller cannot supply
202
+ it). A text-region `iou` of `null` is a **FAIL**. pHash never short-circuits.
203
+ - **Monotone-or-fail best-of-N**: `best_iteration` is the script's choice
204
+ among iterations that are **both** `built==true` **and** `gate_passed==true`;
205
+ if none qualify ⇒ `needs-human` (recorded). The caller's `best_iteration`
206
+ is ignored.
207
+ - **Negative-control voids judge (bound to the shipped divergent twin)**:
208
+ `judge.negative_control` MUST be the structured object
209
+ `{stimulus_source_hash, divergent_pair, rejected, differences}` whose
210
+ `stimulus_source_hash` equals the **source-tree hash of the shipped
211
+ `assets/calibration/swiftui-twin-divergent`** recomputed by the grader,
212
+ with `rejected === true`, a non-empty structured `differences`, and
213
+ `judge.framing === "forced-difference-3"`. The legacy bare
214
+ `negative_control:"passed"` string is **rejected as unbound**. Any
215
+ deviation ⇒ any `verdict:"YES"` is **VOID** ⇒ cannot be `converged`
216
+ (downgraded to `needs-human`, recorded `negative_control: failed`,
217
+ `reason: negative-control-unbound`, exit 3).
218
+ - **Calibration provenance (gate ⇐ floor; twin source identity; floor ⇐
219
+ sanity envelope)**: the grader asserts
220
+ `calibration.json.schema == "h5-to-swiftui/calibration@1"`; **recomputes**
221
+ the structured gate from `floor` and rejects a deviating `gate`
222
+ (`gate-floor-mismatch`, exit 1 — binds the gate TO the floor); recomputes
223
+ the bundled `h5-twin`/`swiftui-twin` **source-file** tree hashes from the
224
+ shipped assets and rejects a mismatching `calibration_source`
225
+ (`calibration-twin-mismatch`, exit 1 — binds the twin source IDENTITY, not
226
+ the floor value); and asserts the `floor` *value* satisfies
227
+ `calibrate-render.mjs`'s own sanity envelope via the shared
228
+ `scripts/_calib-consts.mjs` (`floor-implausible`, exit 1 — a floor
229
+ calibrate-render could not have emitted is rejected, killing the
230
+ absurd-floor attack). **Residual:** a `floor` *within* that envelope yet
231
+ looser than the true measured floor is trusted — the grader cannot
232
+ re-render the bundled twins to re-measure it (named residual (2) under
233
+ "Trust model & residual").
234
+ - **Build accounting**: a present `blocked.json` for the component, or no
235
+ built+gate-passing iteration ⇒ `blocked`/`needs-human`, **never**
236
+ `converged`.
237
+
238
+ **Enforced elsewhere / by process (NOT by `evaluate-convergence.mjs`):**
239
+
240
+ - **Idiomatic lint** (Stage 4): a component whose layout is predominantly
241
+ `.position()`/`.offset()` absolute pinning is rejected at rewrite time —
242
+ pixel-pushed WebView-in-SwiftUI, not a native rewrite.
243
+ - **Whole-assembled-screen trend check — Stage-7 manual cross-check, NOT an
244
+ automated Stage-5 guard.** No executable component diffs the assembled
245
+ screen during the loop, so it is **not** advertised as active automation.
246
+ After assembly (Stage 7) a human / separate verification pass compares the
247
+ assembled-screen capture vs the reference so per-component `converged`
248
+ cannot mask a broken composition. This is a documented **known
249
+ limitation**: until that Stage-7 check runs, per-component verdicts are
250
+ authoritative only at component granularity.
251
+ - **Determinism**: artifact header pins sim/browser/model/seed (passed
252
+ through from `calibration.json`); the dry-run runs twice and must yield the
253
+ **same verdict** (not same pixels).
254
+
255
+ ## `pixel-diff.mjs` output schema (`h5-to-swiftui/diff@1`)
256
+
257
+ `phash_hamming` is raw data; `phash_fast_candidate` is necessary-not-
258
+ sufficient (NOT a converged signal — there is **no** `phash_converged`
259
+ field). `iou` is `null` unless `--gen-bbox-map` supplied the generated bbox
260
+ (a `null` iou is a gate FAIL, never an assumed 1.0).
261
+ `inter_component_spacing_delta_pt` is `null` (with an explanatory
262
+ `inter_component_spacing_delta_note`) when generated positions are unknown —
263
+ never fabricated zeros.
264
+
265
+ ```json
266
+ {
267
+ "schema": "h5-to-swiftui/diff@1",
268
+ "component": "ProductCard",
269
+ "phash_hamming": 7,
270
+ "phash_fast_candidate": false,
271
+ "regions": {
272
+ "text": [{"mark": 2, "iou": 0.95, "fg_deltaE": 1.2, "bg_deltaE": 0.6}],
273
+ "nontext": [{"mark": 1, "ssim": 0.991, "deltaE_p95": 1.4}]
274
+ },
275
+ "inter_component_spacing_delta_pt": null,
276
+ "inter_component_spacing_delta_note": "null: generated component positions unknown (--gen-bbox-map absent); reporting null, NOT zeros",
277
+ "diff_mask_png": ".h5-to-swiftui/diff/ProductCard.iter2.mask.png",
278
+ "global_ssim": 0.984,
279
+ "diff_pixel_fraction": 0.0142
280
+ }
281
+ ```
282
+
283
+ When no generated bbox is supplied, a text region is instead:
284
+ `{"mark": 2, "iou": null, "iou_note": "no generated bbox supplied (--gen-bbox-map absent or no match) — IoU is null, NOT assumed 1.0", "fg_deltaE": 1.2, "bg_deltaE": 0.6}`.
285
+
286
+ ## `convergence/<component>.json` schema (`h5-to-swiftui/convergence@1`)
287
+
288
+ Emitted **only** by `scripts/evaluate-convergence.mjs` (pinned-version header
289
+ passed through from `calibration.json`):
290
+
291
+ ```json
292
+ {
293
+ "schema": "h5-to-swiftui/convergence@1",
294
+ "component": "ProductCard",
295
+ "pinned": { "sim_runtime": "...", "browser": "...", "model_id": "...", "temperature": 0 },
296
+ "calibration_floor": { "ssim_nontext": 0.991, "deltaE_p95": 1.6, "text_iou": 0.94 },
297
+ "gate": {
298
+ "converged": {"ssim_nontext_min": 0.986, "deltaE_p95_max": 2.0, "text_iou_min": 0.91, "require_judge_yes": true},
299
+ "close": {"ssim_nontext_min": 0.981, "deltaE_p95_max": 2.4, "text_iou_min": 0.88, "require_judge_equiv": true}
300
+ },
301
+ "iterations": [
302
+ {"i": 1, "diff": {"global_ssim": 0.90, "phash_hamming": 20, "diff_pixel_fraction": 0.2},
303
+ "built": true, "gate_passed": false, "close_band_passed": false,
304
+ "phash_fast_candidate": false, "gate_detail": "nontext mark 1 ssim 0.9 < 0.986"},
305
+ {"i": 2, "diff": {"global_ssim": 0.993, "phash_hamming": 3, "diff_pixel_fraction": 0.02},
306
+ "built": true, "gate_passed": true, "close_band_passed": true,
307
+ "phash_fast_candidate": true, "gate_detail": "all sub-gates passed"},
308
+ {"i": 3, "diff": {"global_ssim": 0.999, "phash_hamming": 2, "diff_pixel_fraction": 0.001},
309
+ "built": false, "gate_passed": false, "close_band_passed": false,
310
+ "phash_fast_candidate": true, "gate_detail": "all sub-gates passed"}
311
+ ],
312
+ "masks": [{"x":0,"y":0,"w":12,"h":12,"reason":"live timestamp"}],
313
+ "mask_fraction": 0.02,
314
+ "mask_budget": 0.10,
315
+ "component_area_px": 44800,
316
+ "component_area_source": "flag 320x140",
317
+ "judge": {
318
+ "negative_control": "passed",
319
+ "negative_control_binding": {
320
+ "expected_divergent_source_sha256": "…64-hex (recomputed from shipped swiftui-twin-divergent)…",
321
+ "claimed_stimulus_source_hash": "…64-hex (from judge.json)…",
322
+ "rejected": true,
323
+ "bound": true,
324
+ "reasons": []
325
+ },
326
+ "framing": "forced-difference-3",
327
+ "differences": [
328
+ {"desc": "1px baseline shift on price label", "severity": "sub-perceptual"},
329
+ {"desc": "shadow blur 0.5pt softer", "severity": "sub-perceptual"},
330
+ {"desc": "—", "severity": "none"}
331
+ ],
332
+ "verdict": "YES",
333
+ "verdict_honored": true
334
+ },
335
+ "calibration_provenance": {
336
+ "schema_ok": true,
337
+ "gate_recomputed_from_floor": true,
338
+ "calibration_source": {
339
+ "h5_twin_source_sha256": "…64-hex…",
340
+ "swiftui_twin_source_sha256": "…64-hex…",
341
+ "verified_against_bundled": true
342
+ }
343
+ },
344
+ "guard_violations": [],
345
+ "best_iteration": 2,
346
+ "tier": "converged",
347
+ "tier_reason": "gate_passed(best i=2) AND judge YES with valid negative control",
348
+ "residual": {"ssim_nontext": 0.993, "deltaE_p95": 1.4, "text_iou": 0.96},
349
+ "evaluated_at": "ISO8601"
350
+ }
351
+ ```
352
+
353
+ `best_iteration` is the script's choice (built AND gate-passing only); a
354
+ non-empty `guard_violations` array and/or a `tier` of `needs-human`/`blocked`
355
+ corresponds to a non-zero process exit (3 / 4) so the verdict cannot be
356
+ silently ignored.
357
+
358
+ ## Stop conditions
359
+
360
+ - `converged` or `close` reached ⇒ accept best gate-passing iteration.
361
+ - Cap hit, no gate-passing+buildable iteration ⇒ `needs-human`.
362
+ - Mask budget exceeded / negative control failed / idiomatic-lint failed ⇒
363
+ `needs-human` regardless of metrics.
364
+ - No simulator or persistent build failure ⇒ `blocked` (Stage 5 skipped for
365
+ that component; counted as `needs-human` in the summary, never success).
@@ -0,0 +1,150 @@
1
+ /**
2
+ * _calib-consts.mjs — Single source of truth for the calibration sanity
3
+ * envelope shared by the PRODUCER (`calibrate-render.mjs`) and the CONSUMER
4
+ * (`evaluate-convergence.mjs`).
5
+ *
6
+ * Why this module exists (audit #3 CRITICAL-1):
7
+ * `calibration_source` binds the *identity* of the bundled twin source
8
+ * files but NOT the *measured floor values*. Without this module an
9
+ * attacker could write an absurdly loose `floor`
10
+ * (e.g. ssim_nontext:0.05, deltaE_p95:200), copy the REAL public bundled
11
+ * twin source hashes into `calibration_source`, let the gate recompute
12
+ * from that absurd floor, and grade visually-broken output as converged
13
+ * with ZERO shipped files altered.
14
+ *
15
+ * `calibrate-render.mjs` ALREADY refuses to EMIT a `floor` that fails its
16
+ * own sanity bound — it writes `blocked.json` instead of
17
+ * `calibration.json`. Therefore a `calibration.json` whose `floor`
18
+ * violates that same bound *could not have been produced by an honest
19
+ * `calibrate-render.mjs` run*. The grader may reject it on
20
+ * consistency grounds. This module hoists the EXACT constants
21
+ * `calibrate-render.mjs` already enforces so producer and consumer use a
22
+ * SINGLE source of truth (mirrors how `_provenance.mjs` shares hashing).
23
+ *
24
+ * Provenance of each bound (NO new lenient constant is invented here — every
25
+ * number is exactly what `calibrate-render.mjs` already enforces, cited):
26
+ *
27
+ * - SSIM_NONTEXT_MIN = 0.95
28
+ * Source: `calibrate-render.mjs` `const SSIM_NONTEXT_MIN = 0.95;`
29
+ * (the ssim_nontext sanity floor it refuses to emit below) and
30
+ * `references/render-equivalence-calibration.md` §"Sanity bound"
31
+ * (`ssim_nontext ≥ 0.95`), itself derived from `findings.md` RQ4
32
+ * ("SSIM ... < 0.92 = fail"; 0.95 is the conservative measurable
33
+ * floor, not the 0.995 same-renderer regression value §1.1 warns
34
+ * against). An honest calibrate-render run NEVER emits
35
+ * floor.ssim_nontext < 0.95.
36
+ *
37
+ * - TEXT_IOU_MIN = 0.9
38
+ * Source: `calibrate-render.mjs` `const TEXT_IOU_MIN = 0.9;` (the
39
+ * text_iou sanity floor it enforces *when text_iou is non-null*;
40
+ * skipped when null because no bbox map exists at calibration time)
41
+ * and `references/render-equivalence-calibration.md` §"Sanity bound"
42
+ * (`text_iou ≥ 0.9`). An honest calibrate-render run NEVER emits a
43
+ * non-null floor.text_iou < 0.9.
44
+ *
45
+ * - deltaE_p95: there is DELIBERATELY no shared maximum here.
46
+ * `calibrate-render.mjs`'s sanity bound (its `ssimFails`/`textIouFails`
47
+ * /`flatFails` block) does NOT bound deltaE_p95 from above — it only
48
+ * gates ssim_nontext, text_iou, and the flat-image variance guard.
49
+ * Inventing a deltaE ceiling here would be a NEW lenient constant with
50
+ * no producer-side counterpart, which the task explicitly forbids
51
+ * ("Derive any envelope numbers ONLY from what calibrate-render
52
+ * already enforces ... do NOT invent a new lenient constant").
53
+ * The only deltaE assertion the consumer may make is the
54
+ * metric-validity one below (finite and ≥ 0) — a CIEDE2000 ΔE is a
55
+ * non-negative real; a negative/NaN/Infinity value could not be a real
56
+ * `ciede2000Region` output. This is a validity check, not a lenient
57
+ * tolerance, so it is principled.
58
+ *
59
+ * No npm dependencies — plain constants + a pure validator.
60
+ */
61
+
62
+ // ── Sanity-envelope constants (verbatim from calibrate-render.mjs) ────────────
63
+ //
64
+ // calibrate-render.mjs line ~430: `const SSIM_NONTEXT_MIN = 0.95;`
65
+ export const SSIM_NONTEXT_MIN = 0.95;
66
+ // calibrate-render.mjs line ~431: `const TEXT_IOU_MIN = 0.9;`
67
+ export const TEXT_IOU_MIN = 0.9;
68
+
69
+ /**
70
+ * Validate a `calibration.json` `floor` against the SAME sanity envelope
71
+ * `calibrate-render.mjs` enforces before it is willing to EMIT a
72
+ * `calibration.json` (vs writing `blocked.json`).
73
+ *
74
+ * Returns { ok:true } or { ok:false, reasons:[...] }. Pure; no I/O.
75
+ *
76
+ * What is asserted (all derived from calibrate-render's own behavior):
77
+ * 1. ssim_nontext is a finite number in [0,1] AND ≥ SSIM_NONTEXT_MIN.
78
+ * (calibrate-render writes blocked.json, not calibration.json, when
79
+ * ssim_nontext < SSIM_NONTEXT_MIN — so a real run never emits below it.)
80
+ * 2. deltaE_p95 is a finite number ≥ 0. (A CIEDE2000 ΔE is a non-negative
81
+ * real; calibrate-render imposes no UPPER deltaE bound, so neither does
82
+ * the grader — only metric validity, not a lenient tolerance.)
83
+ * 3. text_iou is null OR a finite number in [0,1]; when non-null it must be
84
+ * ≥ TEXT_IOU_MIN (calibrate-render's textIouFails gate, skipped on null).
85
+ *
86
+ * A `floor` failing ANY of these could not have been produced by an honest
87
+ * `calibrate-render.mjs` run (it would have written blocked.json), so the
88
+ * grader rejects it as `floor-implausible` rather than recomputing a gate
89
+ * from a fabricated floor.
90
+ */
91
+ export function floorWithinCalibrateEnvelope(floor) {
92
+ const reasons = [];
93
+
94
+ if (!floor || typeof floor !== 'object') {
95
+ return { ok: false, reasons: ['floor is absent or not an object'] };
96
+ }
97
+
98
+ const finite = (v) => typeof v === 'number' && Number.isFinite(v);
99
+
100
+ // 1. ssim_nontext — calibrate-render's SSIM_NONTEXT_MIN sanity floor.
101
+ const s = floor.ssim_nontext;
102
+ if (!finite(s)) {
103
+ reasons.push(
104
+ `floor.ssim_nontext=${JSON.stringify(s)} is not a finite number ` +
105
+ `(SSIM is a real in [0,1]; not a producible calibrate-render output)`);
106
+ } else if (s < 0 || s > 1) {
107
+ reasons.push(
108
+ `floor.ssim_nontext=${s} is outside the valid SSIM range [0,1] ` +
109
+ `(not a producible calibrate-render output)`);
110
+ } else if (s < SSIM_NONTEXT_MIN) {
111
+ reasons.push(
112
+ `floor.ssim_nontext=${s} < calibrate-render sanity minimum ` +
113
+ `${SSIM_NONTEXT_MIN} — calibrate-render writes blocked.json (not ` +
114
+ `calibration.json) below this, so this floor could not be a real run`);
115
+ }
116
+
117
+ // 2. deltaE_p95 — metric validity only (no producer-side upper bound).
118
+ const d = floor.deltaE_p95;
119
+ if (!finite(d)) {
120
+ reasons.push(
121
+ `floor.deltaE_p95=${JSON.stringify(d)} is not a finite number ` +
122
+ `(CIEDE2000 ΔE is a finite non-negative real; not a producible output)`);
123
+ } else if (d < 0) {
124
+ reasons.push(
125
+ `floor.deltaE_p95=${d} is negative — a CIEDE2000 ΔE is non-negative ` +
126
+ `(not a producible calibrate-render output)`);
127
+ }
128
+
129
+ // 3. text_iou — null is valid (no bbox map); when non-null,
130
+ // calibrate-render's TEXT_IOU_MIN sanity floor applies.
131
+ const t = floor.text_iou;
132
+ if (t === null) {
133
+ // valid — calibrate-render emits null when no bbox map exists.
134
+ } else if (!finite(t)) {
135
+ reasons.push(
136
+ `floor.text_iou=${JSON.stringify(t)} is neither null nor a finite ` +
137
+ `number (not a producible calibrate-render output)`);
138
+ } else if (t < 0 || t > 1) {
139
+ reasons.push(
140
+ `floor.text_iou=${t} is outside the valid IoU range [0,1] ` +
141
+ `(not a producible calibrate-render output)`);
142
+ } else if (t < TEXT_IOU_MIN) {
143
+ reasons.push(
144
+ `floor.text_iou=${t} < calibrate-render sanity minimum ` +
145
+ `${TEXT_IOU_MIN} — calibrate-render writes blocked.json below this ` +
146
+ `for a non-null text_iou, so this floor could not be a real run`);
147
+ }
148
+
149
+ return reasons.length === 0 ? { ok: true } : { ok: false, reasons };
150
+ }