Skip to content

Commit 275e23d

Browse files
TMHSDigitalclaude
andcommitted
feat(smoke): every catalog row re-proves a falsifier on every smoke run
Falsifiers were proven once, by hand, in a PR body; nothing re-ran them, so a check that lost its ability to fail stayed green forever. Harness: - catalog rows carry `falsifiers`: {"args": [...], "expect_exit": N}, or {"args": [...], "expect_sidecar_fail": true} where the post-exit sidecar check owns the contract (exit-pre-sidecar). - run_example.py --expect-exit N / --expect-sidecar-fail; protocol.classify passes a falsifier only on exactly N (not 0, not another check's code, not 1, never 77; a legal skip stays a skip). - run_catalog.py runs each row's falsifiers after its happy path (all rows, all failures listed; --no-falsifiers for local iteration). - summarize.py tables falsifiers separately; a falsifier PASS never counts toward the "at least one example passed" rule; any falsifier FAIL is red. - check_smoke_catalog.py validates the schema (unknown keys rejected so a typo cannot silently disable a falsifier) and requires >=1 falsifier per row. - blender-smoke.yml: weekly cron on 5.2 also runs the full showcase sweep (check_falsifier_targets.py --run); job timeout 45 -> 75 min. Catalog: 142/142 rows seeded from README falsifier tables (78), exit-table rows naming the flag (56) and 8 picked by hand. Four examples only had a render-path visual falsify, so each gained a check-path flag reusing the same defect: lightmap-uv-channel --overlap-islands (7), vertex-color-ao --uniform-hemisphere (3), socket-attach-points --skip-mpi (3), modular-kit-snap --seam-gap (5). tests/smoke/check_extension_template.py now exits 3 (register) / 4 (unregister) and takes --skip-unregister (4). READMEs, CLAUDE.md, CONTRIBUTING and docs/new-example-prompt.md document the field; gallery pages regenerated for the four edited scripts. Proof (local, .scratch/blender-5.2.1-windows-x64, 5.2.1 LTS): - all 142 seeded falsifiers observed exiting exactly as declared (one, the sidecar falsifier, exits 0 and fails the sidecar check as declared). - run_catalog on a 4-row sub-catalog: 4 happy PASS + 4 falsifier PASS. - probe: anvil declared 8 instead of 9 -> "[FAIL] anvil [falsifier --skip-decimate]: falsifier exited 9, expected 8", exit 1. - test_harness: 33 tests OK (exact-exit, exit-0, other-code, crash, forbidden codes, legal skip, sidecar falsifier, catalog wiring). Closes #320 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c35d441 commit 275e23d

24 files changed

Lines changed: 626 additions & 254 deletions

File tree

‎.github/workflows/blender-smoke.yml‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ jobs:
9696
needs: resolve-matrix
9797
if: needs.resolve-matrix.result == 'success'
9898
runs-on: ubuntu-latest
99-
timeout-minutes: 45
99+
timeout-minutes: 75 # happy paths + one falsifier per catalog row (~2x the old run)
100100
strategy:
101101
fail-fast: false
102102
matrix:
@@ -346,6 +346,15 @@ jobs:
346346
--xvfb \
347347
--catalog tests/smoke/catalog.json
348348
349+
- name: Full showcase falsifier sweep (weekly, 5.2)
350+
# The catalog runs one falsifier per row on every smoke run. The weekly
351+
# cron also runs every falsifier in every showcase README table and
352+
# asserts each lands on the exit it declares (~5 min).
353+
if: ${{ github.event_name == 'schedule' && matrix.series == '5.2' }}
354+
run: |
355+
set -euo pipefail
356+
python3 tests/check_falsifier_targets.py --run "$BLENDER"
357+
349358
- name: Smoke summary
350359
if: ${{ always() && env.BLENDER != '' }}
351360
run: |

‎CLAUDE.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,16 @@ The host runner is `tests/smoke/run_example.py`. Shipped examples are listed in
176176
- **Post-exit sidecar:** opt-in `--expect-sidecar PATH`. The example writes
177177
`$BDT_SMOKE_SIDECAR` (set by the runner). The harness asserts after Blender
178178
exits. Not a gallery still.
179+
- **Falsifiers in CI:** every catalog row carries `falsifiers`, at least one
180+
`{"args": [...], "expect_exit": N}` (N not 0, 1 or 77) or, for a contract
181+
the sidecar check owns, `{"args": [...], "expect_sidecar_fail": true}`.
182+
`run_catalog.py` runs each after the row's happy path; it passes only on
183+
exactly that exit. `tests/check_smoke_catalog.py` rejects a row without one
184+
and any unknown key. The weekly cron also runs the full showcase sweep
185+
(`tests/check_falsifier_targets.py --run`).
179186
- **Summary:** `tests/smoke/summarize.py` prints passed/skipped/failed. Zero
180-
PASSes makes the job red even if every example skipped cleanly.
187+
PASSes makes the job red even if every example skipped cleanly. Falsifier
188+
runs are tabled separately and never count toward that PASS.
181189

182190
## Example-Run Process
183191

‎CONTRIBUTING.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,9 @@ budget conformance, never an API contract.
114114
exit-code table, and a falsifier that breaks one pipeline stage so a
115115
**named** budget fails.
116116
2. List the directory in `.cursor-plugin/plugin.json` `"showcase"` and add a
117-
`tests/smoke/catalog.json` row. The runner takes opaque script paths.
117+
`tests/smoke/catalog.json` row with at least one entry in `falsifiers`
118+
(`{"args": ["--your-flag"], "expect_exit": N}`, matching the README
119+
falsifier table). The runner takes opaque script paths.
118120
3. Add a `showcase/gallery.json` `pieces[]` entry, including an `alt`: one
119121
sentence saying what the still shows (the generator fails without it).
120122
Render a still, and run `python scripts/build_gallery.py`. Do not hand-edit `docs/gallery/` HTML.

‎docs/gallery/lightmap-uv-channel/index.html‎

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -72,20 +72,21 @@ <h1>Lightmap UV Channel</h1>
7272
<p><strong>Render as proof:</strong> the cart beside its enlarged UV1 atlas display — a slate face in a walnut frame on a plinth with a brass <code>UV1 LIGHTMAP</code> plate, a faint quarter grid marking the [0,1] square the bounds check holds — the Bed&#x27;s packed lightmap built from live UV data, so a change in the atlas moves the board geometry. The falsification variant (<code>--falsify</code>) translates the second-largest island onto the largest: a big emissive-red island visibly stacked over the atlas (15 SAT hits in the check probe). The render path also gates the asset itself through <code>examples/gallery_asset_quality.py</code> (naming, material variation, edge treatment — exit 11): 24 named parts, 5 materials, right-angle share 0.138. Wood, canvas and iron carry procedural grain, weave and pitting over Object coordinates; no check reads a material.</p>
7373
<h2 id="run">Run<a class="anchor" href="#run" aria-label="Link to this section">#</a></h2>
7474
<pre><code>blender --background --python lightmap_uv_channel.py --
75+
blender --background --python lightmap_uv_channel.py -- --overlap-islands # falsifier: overlap scan reports the dragged island; exits 7
7576
blender --background --python lightmap_uv_channel.py -- --output atlas.png
7677
blender --background --python lightmap_uv_channel.py -- --falsify overlap.png</code></pre>
7778
<h2 id="exit-codes">Exit codes<a class="anchor" href="#exit-codes" aria-label="Link to this section">#</a></h2>
7879
<p>Per-script sequential checks. <code>9</code> is a valid check code; there is no rule against it. <code>10</code> is the shared framing helper. <code>11</code> is the shared asset-quality helper.</p>
79-
<div class="table-wrap"><table><thead><tr><th scope="col">Code</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td>0</td><td>Success</td></tr><tr><td>1</td><td>Uncaught exception (FATAL wrapper)</td></tr><tr><td>2</td><td>argparse / usage</td></tr><tr><td>3</td><td>UV layer names ≠ UV0 / UV1</td></tr><tr><td>4</td><td>UVMap / UV1 render/clone flags wrong after re-assert</td></tr><tr><td>5</td><td>Channel 0 (UV0) touched by the UV1 unwrap</td></tr><tr><td>6</td><td>UV1 loops outside <code>[0,1]</code></td></tr><tr><td>7</td><td>SAT overlap self-test failed, or UV1 triangle pairs overlap</td></tr><tr><td>8</td><td>Min island distance below margin</td></tr><tr><td>9</td><td>Part is not watertight</td></tr><tr><td>10</td><td>Gallery framing violation; also <code>--output</code> produced no file</td></tr><tr><td>11</td><td>Gallery asset-quality violation</td></tr></tbody></table></div>
80-
<p>The <code>blender-smoke</code> workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the <code>needs-5.1</code> PR label, or manual dispatch). Smoke does not pass <code>--output</code> or <code>--falsify</code>.</p>
80+
<div class="table-wrap"><table><thead><tr><th scope="col">Code</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td>0</td><td>Success</td></tr><tr><td>1</td><td>Uncaught exception (FATAL wrapper)</td></tr><tr><td>2</td><td>argparse / usage</td></tr><tr><td>3</td><td>UV layer names ≠ UV0 / UV1</td></tr><tr><td>4</td><td>UVMap / UV1 render/clone flags wrong after re-assert</td></tr><tr><td>5</td><td>Channel 0 (UV0) touched by the UV1 unwrap</td></tr><tr><td>6</td><td>UV1 loops outside <code>[0,1]</code></td></tr><tr><td>7</td><td>SAT overlap self-test failed, or UV1 triangle pairs overlap (<code>--overlap-islands</code> lands here)</td></tr><tr><td>8</td><td>Min island distance below margin</td></tr><tr><td>9</td><td>Part is not watertight</td></tr><tr><td>10</td><td>Gallery framing violation; also <code>--output</code> produced no file</td></tr><tr><td>11</td><td>Gallery asset-quality violation</td></tr></tbody></table></div>
81+
<p>The <code>blender-smoke</code> workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the <code>needs-5.1</code> PR label, or manual dispatch). Smoke does not pass <code>--output</code> or <code>--falsify</code>. Its catalog falsifier is <code>--overlap-islands</code> (expect exit 7): the same island drag as <code>--falsify</code>, applied before the SAT scan.</p>
8182
</section>
8283
<section class="detail-section src" id="source">
8384
<h2>Source</h2>
8485
<div class="src-meta">
8586
<code>examples/lightmap-uv-channel/lightmap_uv_channel.py</code>
86-
<span>1019 lines &middot; <a href="https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/lightmap-uv-channel/lightmap_uv_channel.py">View on GitHub &rarr;</a></span>
87+
<span>1030 lines &middot; <a href="https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/lightmap-uv-channel/lightmap_uv_channel.py">View on GitHub &rarr;</a></span>
8788
</div>
88-
<div class="code" data-lines="1019">
89+
<div class="code" data-lines="1030">
8990
<pre class="gutter" aria-hidden="true">1
9091
2
9192
3
@@ -1104,7 +1105,18 @@ <h2>Source</h2>
11041105
1016
11051106
1017
11061107
1018
1107-
1019</pre><pre class="code-lines"><span class="s">&quot;&quot;&quot;Lightmap UV channel — the second UV layer engines need for baked lighting.
1108+
1019
1109+
1020
1110+
1021
1111+
1022
1112+
1023
1113+
1024
1114+
1025
1115+
1026
1116+
1027
1117+
1028
1118+
1029
1119+
1030</pre><pre class="code-lines"><span class="s">&quot;&quot;&quot;Lightmap UV channel — the second UV layer engines need for baked lighting.
11081120

11091121
Witnesses the two-channel UV contract a bake pipeline depends on, on an
11101122
asset built to be reused (a market cart: bed, axle, two wheels, four posts,
@@ -1680,7 +1692,7 @@ <h2>Source</h2>
16801692
<span class="c"># Check</span>
16811693
<span class="c"># ---------------------------------------------------------------------------</span>
16821694

1683-
<span class="k">def</span> check():
1695+
<span class="k">def</span> check(overlap=<span class="k">False</span>):
16841696
meshes = build_cart_meshes()
16851697
fails = []
16861698

@@ -1721,6 +1733,8 @@ <h2>Source</h2>
17211733
ob = bpy.data.objects.new(me.name, me)
17221734
bpy.context.collection.objects.link(ob)
17231735
author_uv1(ob)
1736+
<span class="k">if</span> overlap <span class="k">and</span> suffix == <span class="s">&quot;Bed&quot;</span>:
1737+
drag_second_island(me) <span class="c"># falsifier: the SAT scan must report it</span>
17241738

17251739
<span class="c"># re-fetch by name after CustomData-reallocating ops (see header)</span>
17261740
layers = me.uv_layers
@@ -1881,6 +1895,23 @@ <h2>Source</h2>
18811895
<span class="k">return</span> floor, wall
18821896

18831897

1898+
<span class="k">def</span> drag_second_island(me):
1899+
<span class="s">&quot;&quot;&quot;Translate the mesh&#x27;s second-largest UV1 face onto the largest one&#x27;s
1900+
anchor, shape intact. Shared by --overlap-islands (check path, exit 7)
1901+
and --falsify (render path). Returns the dragged polygon index.&quot;&quot;&quot;</span>
1902+
layer = me.uv_layers[LAYER1]
1903+
polys = sorted(me.polygons, key=<span class="k">lambda</span> p: p.area, reverse=<span class="k">True</span>)
1904+
big, second = polys[<span class="n">0</span>], polys[<span class="n">1</span>]
1905+
anchor = tuple(layer.data[big.loop_indices[<span class="n">0</span>]].uv)
1906+
first = second.loop_indices[<span class="n">0</span>]
1907+
du = (anchor[<span class="n">0</span>] - layer.data[first].uv[<span class="n">0</span>],
1908+
anchor[<span class="n">1</span>] - layer.data[first].uv[<span class="n">1</span>])
1909+
<span class="k">for</span> li <span class="k">in</span> second.loop_indices:
1910+
u, v = layer.data[li].uv
1911+
layer.data[li].uv = (u + du[<span class="n">0</span>], v + du[<span class="n">1</span>])
1912+
<span class="k">return</span> second.index
1913+
1914+
18841915
<span class="k">def</span> render_still(path, engine, falsify=<span class="k">False</span>):
18851916
<span class="s">&quot;&quot;&quot;The cart beside its UV1 atlas board — island polygons built from the
18861917
LIVE packed UVs, so a change in the atlas moves the board. Falsified:
@@ -1931,18 +1962,7 @@ <h2>Source</h2>
19311962
<span class="c"># emissive red on the board (tiny-strip overlaps would not read)</span>
19321963
dragged = set()
19331964
<span class="k">if</span> falsify:
1934-
bed = meshes[<span class="s">&quot;Bed&quot;</span>]
1935-
layer = bed.uv_layers[LAYER1]
1936-
polys = sorted(bed.polygons, key=<span class="k">lambda</span> p: p.area, reverse=<span class="k">True</span>)
1937-
big, second = polys[<span class="n">0</span>], polys[<span class="n">1</span>]
1938-
anchor = tuple(layer.data[big.loop_indices[<span class="n">0</span>]].uv)
1939-
dragged.add(second.index)
1940-
first = second.loop_indices[<span class="n">0</span>]
1941-
du = (anchor[<span class="n">0</span>] - layer.data[first].uv[<span class="n">0</span>],
1942-
anchor[<span class="n">1</span>] - layer.data[first].uv[<span class="n">1</span>])
1943-
<span class="k">for</span> li <span class="k">in</span> second.loop_indices:
1944-
u, v = layer.data[li].uv
1945-
layer.data[li].uv = (u + du[<span class="n">0</span>], v + du[<span class="n">1</span>])
1965+
dragged.add(drag_second_island(meshes[<span class="s">&quot;Bed&quot;</span>]))
19461966

19471967
<span class="c"># atlas board: the Bed&#x27;s live UV1 as flat island polygons mapped onto the</span>
19481968
<span class="c"># board face — a change in the packed atlas moves the board geometry</span>
@@ -2087,6 +2107,9 @@ <h2>Source</h2>
20872107
argv = sys.argv[sys.argv.index(<span class="s">&quot;--&quot;</span>) + <span class="n">1</span>:] <span class="k">if</span> <span class="s">&quot;--&quot;</span> <span class="k">in</span> sys.argv <span class="k">else</span> []
20882108
p = argparse.ArgumentParser()
20892109
p.add_argument(<span class="s">&quot;--output&quot;</span>, default=<span class="k">None</span>, help=<span class="s">&quot;optional: render a still PNG here&quot;</span>)
2110+
p.add_argument(<span class="s">&quot;--overlap-islands&quot;</span>, action=<span class="s">&quot;store_true&quot;</span>,
2111+
help=<span class="s">&quot;falsifier: drag the Bed&#x27;s second-largest UV1 island onto &quot;</span>
2112+
<span class="s">&quot;the largest before the overlap scan (exit 7)&quot;</span>)
20902113
p.add_argument(<span class="s">&quot;--falsify&quot;</span>, default=<span class="k">None</span>,
20912114
help=<span class="s">&quot;optional: render the overlapping-atlas variant here&quot;</span>)
20922115
p.add_argument(<span class="s">&quot;--engine&quot;</span>, default=<span class="s">&quot;eevee&quot;</span>, choices=(<span class="s">&quot;eevee&quot;</span>, <span class="s">&quot;cycles&quot;</span>),
@@ -2095,7 +2118,7 @@ <h2>Source</h2>
20952118

20962119
print(<span class="s">f&quot;</span><span class="s">binary version: </span>{bpy.app.version}<span class="s"> (</span>{bpy.app.version_string}<span class="s">)</span><span class="s">&quot;</span>)
20972120
bpy.ops.wm.read_factory_settings(use_empty=<span class="k">True</span>)
2098-
code = check()
2121+
code = check(overlap=args.overlap_islands)
20992122
<span class="k">if</span> code:
21002123
<span class="k">return</span> code
21012124

0 commit comments

Comments
 (0)