diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 44de55a6..9aa04005 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1139,6 +1139,129 @@ forward-refuse). without a bump, and ship here. The bump is the release branch's own commit, `7f6cf38f`. + **0.121.0 through 0.126.0 are additive at the ABI: 32 functions added, none + removed, and no existing struct re-laid out or appended to.** 0.121.0 adds + per-item mirror axes (#673): `clay_item_set_mirror_axes`, + `clay_item_mirror_axes`, `clay_layer_set_node_mirror` (one undo step) and + `clay_layer_node_mirror`, with `CLAY_MIRROR_AXES_INHERIT`. 0.122.0 adds + `clay_item_volume_move_topological_from` (#681), a topological move sampled + straight from a document. 0.123.0 adds `clay_voxel_grid_clone` and + `clay_voxel_get_occupied` (#682), so grid-to-field can leave the interface + thread, and writes the voxel reads' threading down. 0.124.0 adds + `clay_mesh_from_arrays` and its `clay_mesh_arrays` descriptor (#685), keeping + a host's uvs, normals and colours. 0.125.0 adds the opaque + `clay_multires_delta`, its `clay_multires_delta_stats` descriptor and twelve + entry points (#687), the first undo record a host holding a `clay_multires` + has had. 0.126.0 adds the opaque `clay_stroke_tx`, its + `clay_stroke_tx_status` descriptor and twelve entry points (#688): a stroke + session whose six consumers apply only settled stamps, bit-identical to the + whole-path call. #689 amended the untagged multires consumer in place to take a + nullable `clay_multires_delta` record, before any tag carried it. Against v0.120.1: 708 -> 740 `clay_*(` symbols; the only + `-` lines in the `clay.h` diff that are not comments are the + `CLAY_ABI_MINOR` and `CLAY_ABI_PATCH` defines, and no hunk adds a field to a + struct that existed before. The three new structs lead with `struct_size`. + + **AND IT IS A RELEASE A CALLER OBSERVES WITHOUT CALLING ANYTHING NEW**, in + the format and in entry points it already calls, none announced by a version + gate. + + **The scene and `.clayspace` format minor moves 19 -> 20** (#673): one byte + per node record. A default save writes minor 20; minor 19 is still writable, + byte for byte, when no item carries its own axes, and refused with the + blocking layer named when one does. Older documents load with every item + inheriting and evaluate as saved. The crash journal gains the + `SetNodeMirrorCmd` event, so a v0.126.0 journal carrying one is not + replayable by an older build. + + **A crash journal started by a mid-session enable is refused against a + snapshot the document was edited away from** (#675 -- issue #641). It was + accepted and replayed without the pre-enable edits: load, set (0,0,0), + enable, set (1,0,0), replay onto the snapshot gave `CLAY_OK` with (0,0,0) + missing; now `CLAY_ERROR_SNAPSHOT_MISMATCH`, applied 0. The cost is one + encode at enable (2.16 ms on a 1.58 MB voxel document) and a one-time + re-encode when loading an older minor (1.41 -> 3.61 ms at minor 19), which is + every file v0.120.1 wrote (pyclay, `cpu-only` Release, median of 15, host + model not named). + + **A squashed placement is no longer culled out of bricks its field reaches** + (#680 -- part of issue #649, which stays open). The cull widens a + per-axis-scaled item's bound by `(q - 1)(band + pad + w)`, exactly 0 for a + similarity, so unsquashed documents make identical decisions. Item or layer + scaled (4, 1, 1): **652 in-band samples off, worst 0.11 -> 0**; the issue's + rich sweep **35 documents, worst 0.0825 -> 9, worst 0.0170** (Release, Apple + M-series). Unsquashed cull and refill benchmarks cost 1.1-4.0% (same host). + **#649's mechanism A, the chain-pad envelope below 75 contributors, is NOT + fixed** -- the PR and its proposal say so -- and #649 stays open on it. + + **A region edit after an append no longer serves the pre-append field** + (#668 -- issue #665): 1,171 stale surface bricks -> 192, bit-identical to a + rebuild. A seed an append reached is walked in full rather than resumed; the + first far region edit after 200 / 1,000 / 3,000 dabs costs 0.52 / 0.89 / + 1.27 ms against 0.21-0.30 ms for the unsound rule (no host named). + + **`clay_layer_set_transform_bound` pads an intersect operand by the combines + after it** (#676 -- issue #666), as a sum of their supports: 1,152 -> **256** + delta bricks at ten times the extent, and the box WIDENS where smooth + combines follow the operand, because the old one left 35 stale bricks there. + **`clay_layer_node_influence_bound` and `clay_brick_cache_mark_dirty_nodes` + report a relief or incise item's own bound** (#683 -- issue #672): a + 4-sample stroke 2,548 -> **48** bricks. Both are deterministic counts. + + **A Move drag on a mirror plane is applied once** (#669 -- issue #663): lift + 0.2309 -> **0.1458**, the unmirrored value, and + `clay_sdf_move_preview_grab_count` reads 1 where it read 2 (4 on a radial + axis), and 3 for an oblique drag where it read 2. + **`clay_item_volume_move_topological` sub-steps a drag past half its reach** + (#681 -- issue #657): anchor height 0.939 -> 1.116 on the issue's probe; a + one-slice drag is bit-identical. **Mask extrude reaches the requested + thickness** (#667 -- issue #660): a 0.6 wall stopped near 0.11. #667 alone made the field + extrude 5-7x slower on the iPad (the first v0.126.0 gate failed on it); + **#691 restores the cost**: source calls per sample 7 -> 1.009, 10 stamps + 395.5 -> 69.1 ms against 73.1 before #667 (Apple M2 Max), with stored values + moving up to 0.010 cells on smooth fixtures. **The six + mesh frame verbs move along an area-weighted normal** (#677 -- issue #631): + fin tilt 11.47 -> 0.15 degrees, the adaptive stamp 1-8% dearer (arm64 + macOS, `cpu-only` Release). + + **`clay_voxel_to_layer` is one undo step** (#679 -- issue #656), not two. + **The five consolidation calls return `CLAY_ERROR_UNSUPPORTED` for a voxel + or mesh layer** (#684 -- issue #659), where they said "nothing to + consolidate", and `clay_layer_plan_region_merge` fails where it returned an + empty plan. **`clay_document_mesh_combined` exports a document whose only + visible geometry is mesh layers** (#686 -- issue #662), where it refused. + C++ only: `session::History` keeps a held `DynamicSculptor`'s index in step + (#674 -- issue #629), and a mesh cage drag sums only the dragged points + (#655), ~1.7 s -> ~11 ms per 32^3 preview frame in ClaySpace (the host's + numbers). #678 is tests and comments only. + + **None of 0.121.0, 0.122.0, 0.123.0, 0.124.0 or 0.125.0 was ever tagged on + its own.** Each merged with its feature -- #673, #681, #682, #685 and #687 + respectively, and #688 for 0.126.0 -- and v0.126.0 is the tag cut at + whatever the line had reached, exactly as v0.120.0 covered 0.117.0-0.120.0. + No minor in this range was skipped, and nothing was bumped at release time. + + **Four merges carry the already-released 0.120.1 version line and are NOT in + v0.120.1**: #655, #667, #668 and #669 merged after the tag and before #673's + bump, and ship here. The other merges carry the line they landed on: #674-#680 + 0.121.0, #683 and #684 0.123.0, #686 0.124.0. + + **0.126.0's device gate passed on a re-recorded baseline.** The reference + iPad moved to iOS 27.0.1 (24A446) on 2026-10-04 and #690 re-baselined from + v0.120.1's engine (`7f6cf38f`) on that OS, so the gate compares v0.120.1 to + v0.126.0 on the same OS rather than a run against itself. It passed at + `7049e19c`: iPad15,5, 75 cases, 1800 s cooldowns, nominal throughout, canary + steady at x1.18, **median ratio 0.996 against the baseline, none above both + the 1.4 tolerance and the 0.125 ms floor**, largest rise 1.04x. The first run, + at `7406eb1d` before #691, failed: `mask_extrude` x4.97 (26992 ms at 1000 + stamps, budget 8154) and `mask_extract` x6.26, both through #667's extrude; + with #691 they read 1.01x and 1.04x. The run needed Wi-Fi off and an air + conditioner; post-update on-device inference heat-killed four sessions + before that. The four hardware gates are carried as waivers at `4401b354`: + the only kernel-relevant change since `59e42ccf` is + `include/clay/eval/bake_volume.h` (#680, `8d015258`), which passes the band + into the per-brick cull and changes no kernel arithmetic, and + `tests/unit/test_parity.cpp` is unchanged. + **0.113.0 stops decimation breaking a manifold it was given** (#567). meshoptimizer chooses its own collapses and does not apply the link condition collapse_edge refuses on, so clay_document_mesh -- documented as the diff --git a/docs/release-notes/v0.126.0.md b/docs/release-notes/v0.126.0.md new file mode 100644 index 00000000..3b0b082c --- /dev/null +++ b/docs/release-notes/v0.126.0.md @@ -0,0 +1,949 @@ +# claycore v0.126.0 + +A minor release. Compared against **v0.120.1** (published 2026-09-24), the +previous and latest published release. It covers the twenty-two PRs merged since +that tag (#655, #667–#669, #673–#689, #691) and no direct commit. It spans **six ABI +minors, 0.121.0 through 0.126.0**, each bumped by the PR that needed it; none of +0.121.0–0.125.0 was tagged on its own, and nothing was bumped at release time. +**32 C ABI functions were added and none removed; no existing struct was +re-laid out or appended to** (see "The ABI"). **The scene and `.clayspace` +format minor moves from 19 to 20** (#673). + +**Read this if you SAVE `.clayspace` documents for an older build, if you +enable undo after loading and editing a document, if you mesh or brick-refill +documents with a per-axis scale, if you call `clay_layer_set_transform_bound`, +`clay_layer_node_influence_bound` or `clay_brick_cache_mark_dirty_nodes`, if +you consolidate layers or export with `clay_document_mesh_combined`, if you run +mask extrude, a long topological move, a Move drag on a mirror plane, or any +mesh brush that reads the stamp frame (Draw, Clay, Crease, Layer, Flatten, +Scrape).** Each of those answers differently through an entry point a caller +already calls. Hosts that feed live input in pieces, keep multires undo as +whole-hierarchy snapshots, or snapshot voxel grids cell by cell have new calls +for that. + +Unless a line says otherwise, every number here is a count, a field value or a +byte comparison taken on a Mac from a `cpu-only` build. Timings name their host +where the PR or proposal names one, and say so where it does not. **None of them +is a device number**; the iPad gate for this release has not run yet (see "The +gates"). + +--- + +## Everything since v0.120.1, at a glance + +Every PR merged since the last published release, v0.120.1 (2026-09-24). Each +row's number is taken from its section below, which says what it was measured +on. + +| PR | What changed | Measured | +|---|---|---| +| **#688** (issue #670, ABI 0.126.0) | **A stroke session: `clay_stroke_tx_*` resolves a live stroke as samples arrive, and six consumers apply only the stamps no later sample can change.** A gesture fed in pieces is bit-identical to the whole-path call, and one gesture is one undo step. The resolver stops being quadratic. | last append on a 10 s, 240 Hz stroke 216.3 → **18.9 µs** (pyclay, Apple M-series) | +| **#689** (follow-up to #670) | **A session-fed multires gesture records into a `clay_multires_delta`**, amending the untagged 0.126.0 consumer in place; a call after the close no longer re-binds the record. | session record equals the whole-path record; one revert restores every level bit for bit (Mac, `cpu-only`) | +| **#687** (issue #671, ABI 0.125.0) | **A multires gesture can enter a host undo stack** as a `clay_multires_delta`, replacing a whole-hierarchy snapshot per gesture. | encoded record 568,344 B against a 1,202,160 B snapshot at 4 levels | +| **#673** (issue #664, ABI 0.121.0) | **An item can carry its own mirror axes**, so turning symmetry off or changing its axis no longer rewrites what was made under the old one. **Format minor 19 → 20.** | X twin after the layer mirror is turned off: field 0.95 (gone) → **-0.25** (kept) | +| **#680** (part of issue #649) | **A squashed (per-axis scaled) placement is no longer culled out of bricks its field still reaches.** #649 stays open on the one mechanism that remains (see Known limits). | rich sweep 35 documents off, worst 0.0825 → **9, worst 0.0170** | +| **#675** (issue #641) | **A crash journal started by a mid-session enable is refused against a snapshot the document was edited away from**, instead of replaying without the pre-enable edits. | replay `CLAY_OK`, cell missing → **`CLAY_ERROR_SNAPSHOT_MISMATCH`** | +| **#668** (issue #665) | **A region edit after an append no longer leaves the pre-append field in the brick cache.** | stale cache 1,171 surface bricks → **192, bit-identical** | +| **#676** (issue #666) | **An intersect drag is padded by the combines after its operand**, not by the whole layer. Correct where the old pad left stale bricks. | delta bricks at 10x the extent 1,152 → **256** | +| **#683** (issue #672) | **A relief or incise stamp reports its own bound**, not a box 4 × strength wide. | a 4-sample stroke 2,548 → **48** bricks | +| **#669** (issue #663) | **A Move drag on a mirror plane is applied once**, not once per coincident image. | surface lift 0.2309 → **0.1458** (the unmirrored value) | +| **#681** (issue #657, ABI 0.122.0) | **A topological move longer than its reach is sub-stepped instead of folding**, and `clay_item_volume_move_topological_from` samples a document directly. | anchor height 0.939 → **1.116** on the issue's probe | +| **#677** (issue #631) | **A mesh stamp's averaged normal is area-weighted**, so it follows the surface, not the triangulation. | fin tilt 11.47° → **0.15°** | +| **#667** (issue #660) | **Mask extrude reaches the requested wall thickness** instead of stopping at the paint depth. | a 0.6 wall stopped near 0.11; now within 10% | +| **#691** (follow-up to #667) | **Mask extrude costs what it did before #667.** #667 read the source field seven times per sample; the projection now takes its gradient from the sampled lattice and is skipped where the mask cannot change the stored value. | source calls per sample 7 → **1.009**; 10 stamps 395.5 → **69.1 ms** against 73.1 before #667 (Apple M2 Max, not a device number) | +| **#682** (issue #658, ABI 0.123.0) | **A voxel grid can be cloned and bulk-read**, so grid-to-field can leave the interface thread. Threading is now written down. | clone of an 89,048-cell grid **0.074 ms** (Apple M-series) | +| **#685** (issue #661, ABI 0.124.0) | **`clay_mesh_from_arrays` keeps a host's uvs, normals and colours**, replacing an OBJ text round trip. | attributes back bit-exact | +| **#686** (issue #662) | **`clay_document_mesh_combined` exports a document whose only visible geometry is mesh layers.** | refused → a mesh | +| **#684** (issue #659) | **Consolidating a voxel or mesh layer is refused as `CLAY_ERROR_UNSUPPORTED`**, not as "nothing to consolidate". | 24 failing assertions on main → 0 | +| **#679** (issue #656) | **`clay_voxel_to_layer` is one undo step**; one undo no longer leaves an empty layer. | undo depth +2 → **+1** | +| **#655** | **A mesh cage drag costs what was dragged**, not the whole cage. | one 32³ preview frame ~1.7 s → **~11 ms** (ClaySpace, host's numbers) | +| **#674** (issue #629) | **C++ `session::History` keeps a held `DynamicSculptor`'s index in step** through undo, redo and journal replay. C++ only. | 23 live faces missing, 1,333 dead entries → **0 / 0** | +| **#678** (issue #630) | `refit_around_moved_vertices` is kept and now has a test that fails without it. Tests and comments only. | 621 / 198 faces missed with it removed | + +This release PR adds these notes and the `docs/RELEASE.md` entry, and +re-records the four hardware waivers at `4401b354` (see "The gates"). + +--- + +## A stroke session for live input (#688, #689, issue #670, ABI 0.126.0) + +`brush::StrokeTransaction` already re-resolved a path as samples arrived, so +forty samples in one batch and in five batches of eight gave the same stamps. +It was C++ only. Every C stroke call takes a whole path, so a host forwarding a +Pencil gesture in pieces restarted spacing, taper, steady and jitter on every +call, and could not get the end taper right because no piece knew it was the +last. + +**The session.** `clay_stroke_tx_begin(preset)`, `_append(samples, n, +&new_stamps, &revised_from)`, `_end`, `_stamps` (size query, +`CLAY_ERROR_BUFFER_TOO_SMALL` on a short buffer), `_status_get` +(`clay_stroke_tx_status`, leading `struct_size`) and `_destroy`. + +**A consumer applies a stamp only once it is settled**: no later append can +change its position, radius, strength, deposit or rotation. A stamp is settled +when the path has at least two samples, its station lies on the path received +so far, it is outside the end taper at the current length, and the preset has no +start taper. A start taper is a fraction of the whole stroke, so **nothing of a +start-tapered stroke settles before lift**; the header says so rather than +approximating it. Under this rule **a gesture fed in pieces is bit-identical to +the whole-path call**. The price is that applied ink trails the pen: by up to +one spacing, by the end-taper fraction when there is an end taper, and until +lift when there is a start taper. `clay_stroke_tx_stamps` always returns the +stroke as it stands, for a live preview. + +**Six consumers**: `clay_layer_apply_stroke_tx`, `clay_voxel_apply_stroke_tx`, +`clay_mask_apply_stroke_tx`, `clay_mesh_sculptor_apply_stroke_tx`, +`clay_dynamic_sculptor_apply_stroke_tx` and +`clay_multires_sculptor_apply_stroke_tx`. Each applies what has settled since +its last call. The first call binds the session to one target and one brush; a +later call naming a different target, handle, scalar or descriptor value is +refused with `CLAY_ERROR_INVALID_ARGUMENT` and applies nothing (descriptors are +compared field by field after decoding, floats by their bits, not by bytes, +because `clay_mesh_brush_desc` has padding). The call after `_end` applies the +held-back tail and closes the gesture. **One gesture is one undo step**: for +SDF, voxel and mask targets the session holds the document's undo group open; +mesh gestures record into the caller's `clay_mesh_deltas`; adaptive gestures +continue one `clay_dynamic_delta`, checked before stamps are taken so a +`CLAY_ERROR_SNAPSHOT_MISMATCH` loses none. + +**The mesh stroke calls are now gestures.** A grab carries the region it +gathered at its first stamp, a snakehook its anchor, multires begins its level +record once, and deferred normals flush once. `apply_to_mesh`, +`apply_to_multires` and `apply_to_dynamic[_recorded]` are now one +`MeshStrokeGesture` / `MultiresStrokeGesture` / `DynamicStrokeGesture` fed once, +so the whole-path calls and the session run the same code; a test shows a grab +fed one stroke per call lands somewhere else. A sculptor with a declared world +frame keeps a second transaction in its own space, so its stamps are the floats +the whole-path call produces. + +**The resolver was quadratic.** `sample_path` searched for each station's +segment from the start of the path. It now resumes from the previous station's +segment, which finds the same segment because stations and arc length only +increase. Through pyclay, median of 15, Release, **Apple M-series** (each figure +carries about 2 µs of binding overhead): + +| 240 Hz stroke | stamps | last append, before → after | whole `resolve`, before → after | +|---|---:|---:|---:| +| 1 s | 50 | 4.6 → 2.5 µs | 6.3 → 4.0 µs | +| 5 s | 250 | 60.4 → 9.7 µs | 63.4 → 13.1 µs | +| 10 s | 502 | 216.3 → **18.9 µs** | 219.7 → **22.8 µs** | + +pyclay gains `clay.StrokeTransaction` (`append`, `end`, `stamps`, `status`); +the six consumers are C and C++ only, listed in `check_binding_parity.py` as a +C-only follow-up. + +**A session-fed multires gesture records into a `clay_multires_delta`** (#689, +amending the untagged 0.126.0 entry point in place, so no version moved). +`clay_multires_sculptor_apply_stroke_tx` takes a nullable `record` after +`defer_normals`, in the same place as `clay_multires_sculptor_apply_stroke_recorded` +and the mesh and adaptive consumers; null records nothing and stamps exactly as +before. The record is part of the session's binding, so a later call naming a +different record, or null after a record, is refused with +`CLAY_ERROR_INVALID_ARGUMENT`. A 24-sample stroke fed in batches of 4 produces +the same record and positions as the whole-path recorded call, with and +without an active sculpt pass, and one revert restores every level bit for bit +(Mac, `cpu-only`). A different pass made active mid-gesture is refused with +`CLAY_ERROR_SNAPSHOT_MISMATCH` before any stamp is taken, so the session loses +none; a malformed report is likewise refused before stamping. A call with +nothing to apply, including one after the close, leaves the record's binding +alone, so a record from before a level change is still refused on replay. + +## A multires gesture can enter a host undo stack (#687, issue #671, ABI 0.125.0) + +A `clay_multires` is a standalone handle no document layer owns, so +`clay_document_undo` cannot reach it, and `clay_multires_sculpt_layer_stroke_cancel` +is exact only before commit. A host's only undo after commit was a +whole-hierarchy `clay_multires_serialize` per gesture. `clay.h` said twice that +neither engine record crossed the ABI. + +`clay_multires_delta` has the `clay_dynamic_delta` shape: `_create`, +`_destroy`, `_clear`, `_stats_get` (`clay_multires_delta_stats`), `_levels`, +`_revert`, `_apply`, `_serialize`, `_deserialize`. Three ways in: +`clay_multires_sculptor_stamp_recorded`, +`clay_multires_sculptor_apply_stroke_recorded` and +`clay_multires_sculpt_layer_stroke_commit_into`. The shipped `_stamp`, +`_apply_stroke` and `_commit` are unchanged and share one body with their +recorded twins. + +What building it found: + +- **The C++ records accepted a hierarchy they do not describe.** Their `revert` + refused only by entry counts, so a twin built from the same cage, and the same + hierarchy after `remove_highest_level` + `add_level`, were written. The record + now carries the hierarchy's `structure_revision` plus a per-process origin and + refuses anything else with `CLAY_ERROR_SNAPSHOT_MISMATCH` before writing. +- **The plain sculptor's record was half a record.** With an active pass the + sculptor writes the pass, but `apply_to_multires` passed only the base record, + so a recorded stroke came back empty. (#688 added the matching case for a + recorded *stroke*, which the suite had not covered.) +- **A reverted pass marked no patch**: `clay_multires_dirty_block_count` read 0 + while the evaluated revision had moved. A pass replay now evaluates the + display level before returning. + +Cost, 16x16 triangle cage, seven strokes, then one recorded nine-stamp Draw +stroke at the top level (bytes; deterministic): + +| levels | top-level vertices | entries | encoded | resident | whole-hierarchy snapshot | +|---:|---:|---:|---:|---:|---:| +| 2 | 6,273 | 1,109 | 35,544 | 79,172 | 95,816 | +| 3 | 24,833 | 4,445 | 142,296 | 315,812 | 317,104 | +| 4 | 98,817 | 17,759 | 568,344 | 1,262,012 | 1,202,160 | + +The encoding is 37–47% of the snapshot here. **The resident record is not +smaller than the snapshot on this fixture** (it holds slot maps); what changes +is the scaling, since a snapshot carries all detail sculpted so far and a record +carries one gesture. Spill records with `_serialize`. One stamp and forty +stamps on the same spot record identical entry counts and bytes. + +## An item carries its own mirror axes (#673, issue #664, ABI 0.121.0) + +Mirror participation was a bool per item and the axes lived on the layer, so +turning symmetry off, or switching X to Y, changed every item made under the old +axes. Measured through the C ABI on a radius-0.25 lump at (0.6, 0.4, 0) made +under a layer mirror X (field value; -0.25 means a copy is there): + +| layer mirror after the item was made | X twin | Y reflection | +|---|---:|---:| +| X (as made) | -0.25 | 0.55 | +| off, before | **0.95** (twin gone) | 0.55 | +| Y, before | **0.95** | **-0.25** (wrong plane) | +| off, item has own axes X | -0.25 | 0.55 | +| Y, item has own axes X | -0.25 | 0.55 | + +An item now holds `own_mirror_axes`: `CLAY_MIRROR_X|Y|Z`, 0 for none, or +`CLAY_MIRROR_AXES_INHERIT` (255, the default). Own axes replace the layer's for +that item whatever its participation flag says; the flag still decides what an +inheriting item takes and still decides radial participation; the seam and the +mirror planes stay the layer's. Every consumer reads one function: emission, the +geometry and influence bounds, the cull pad, picking's selection bound, +consolidation's patch test and the layer digest. C ABI: +`clay_item_set_mirror_axes` / `clay_item_mirror_axes` on the builder, and +`clay_layer_set_node_mirror` (one undo step, new journal tag) / +`clay_layer_node_mirror` on a placed node; the reader also reports the axes the +item is actually reflected through. Groups are refused. pyclay: +`Layer.add(..., mirror_axes=)`, `Layer.set_node_mirror`, `Layer.node_mirror`. + +**A Move or magnify reaches an own-axes item through its own reflections**, as +`brush/move.h`'s rule already says (the drag images are the copies the compiler +emits). With the layer mirror off, a drag moves both sides of an item that kept +X; an item held at 0 moves only on the touched side. Inheriting items read the +layer's image set unchanged. + +**Format minor 19 → 20**: one byte per node record. See "The formats". + +## A squashed placement is culled by how far its field can reach (#680, part of issue #649) + +A per-axis scale on an item or its layer makes the field a bound on the +distance, short by up to `q = max(s) / min(s)` (the two levels' ratios +multiply). The per-brick cull dropped an item once its bound was more than +band + pad from the brick, an argument that only holds out to `q × (band + +pad)`. **The bound is now widened, not exempted**, by `(q − 1)(band + pad) + +(q − 1)w` where `w` is the bound's own dilation; both terms are exactly 0 for a +similarity, so **a document with no per-axis scale makes identical cull +decisions** (a test pins it). The band becomes an input to the cull: +`scene::CullRegion` gains `band`, `CullIndex::plan(region, band = 0)` widens a +squashed chain's coarse scan, and a plan made for a narrower band is dropped on +a squashed document. + +The C ABI regression cases, origin/main against this PR: an item scaled (4, 1, +1) and a layer scaled (4, 1, 1) each had **652 in-band samples off, worst 0.11**; +both levels at 2, blended and grouped, **66, worst 0.025**; all now 0. On the +issue's probe (`RV_RAWCHECK=1 undo_bound_oracle_probe`, Release, **Apple +M-series**): seeds 5001–6500 rich, **35 documents off, worst 0.0825 → 9, worst +0.0170**; seeds 1–1500 plain, 21 documents, identical lines (none is reached +by this mechanism). + +**Cost on unsquashed documents** (`clay_bench`, Release, Apple M-series, CPU, +interleaved, 3 rounds of 5, median of round medians): `BM_DeepDocCullPlanned2000` +1.015x, `…Planned10000` 1.011x, `BM_DeepDocCull2000` (no plan) 1.031x, +`BM_DeepDocRefillPlanned10000` 1.027x, `BM_CullIndexRebuild` 1.040x. The rounds +agree within about 1%, so these are real. `CullIndex::Entry` grows from 40 to +48 bytes. + +**#649 stays open: the PR and its proposal say mechanism A is not +addressed** — see Known limits. + +## A journal is paired with the document it starts from (#675, issue #641) + +`clay_document_enable_undo` and pyclay's `Document.enable_undo()` seeded the +crash journal with the identity of the bytes the document was last loaded from +or saved to, and edits do not clear it. On a document **edited after load and +before the enable**, a replay onto that snapshot was accepted and the recovered +document silently lacked every pre-enable edit. Through the C ABI: load; set +(0,0,0); enable; set (1,0,0); replay onto the snapshot — before, `CLAY_OK`, +applied 1, (0,0,0) missing; now **`CLAY_ERROR_SNAPSHOT_MISMATCH`, applied 0**. + +Both enable paths now call `io::journal_seed_for`, which encodes the document at +this build's minor without stamping it: unchanged, it seeds the named snapshot; +edited since, it seeds the identity of the bytes a save would write now, so a +save after the enable pairs and no older snapshot does. To keep an older-minor +load pairable, `load_clayspace` re-encodes once at load time when the stream is +not byte-for-byte what this build writes, and records the result. + +Cost, 1.58 MB voxel document, pyclay, `cpu-only` Release, median of 15 (the PR +names no host model): + +| operation | before | after | +|---|---:|---:| +| `enable_undo` on a loaded document | ~0 | 2.16 ms (one encode) | +| `enable_undo`, never serialized | ~0 | ~0 | +| `load_bytes`, this build's minor | 1.41 ms | 1.43 ms | +| `load_bytes`, **minor 19** | 1.41 ms | **3.61 ms** | + +**Every document written by v0.120.1 is minor 19**, so it pays the re-encode on +load until it is saved again. + +## Brick-cache correctness: appends, intersect drags and relief stamps (#668, #676, #683) + +**A region edit no longer carries a seed past an append that reached it** +(#668, issue #665). The cause was `touch_region_locked`, shared by all three +region fronts: an append re-stamps no seed, and the next region edit moved every +seed its box missed to the new revision, including seeds still behind the +append, after which the `rev == now` shortcut served the pre-append field. Each +append now logs its `command_influence_bound`, and a region edit carries a seed +forward only if it was current just before the edit or no logged append in the +span it is behind reaches it (a range-union tree over the log). On the issue's +fixture (sphere r1, cylinder moved to y 0.9, dim 8, voxel 0.02, band 3; counts): + +| variant | main | now | +|---|---|---| +| append at origin, uniform move, refill | 1,171 surface bricks (the sphere) | 192, bit-identical | +| local append, then move another item far away, refill | 1,123 vs 1,116 | 1,116, bit-identical | + +The check costs, for the first far move after N dabs each refilled on its own: +**0.52 / 0.89 / 1.27 ms at 200 / 1,000 / 3,000 dabs**, against 0.21 / 0.21 / +0.30 ms for main's unsound rule (a per-seed scan was 3.98 / 14.5 / 42.9 ms and +was replaced in review). The PR names no host. + +**An intersect drag is padded by what follows the operand** (#676, issue +#666). `clay_layer_set_transform_bound` dilated the swept box by `cull_pad` over +the whole layer, which on a worked form scales with the form. Only the combines +after the operand can read the running value its move changed. Writing the +oracle cases first showed main was **also wrong** for an operand with smooth +combines after it — 35 stale bricks with the operand at the head of 96 smooth +stamps, 17 in the middle, by one fp16 step — and that no single term bounds it. +The pad is now the **sum** of the later combines' supports (proved by induction +in the design), and the reported box is its overlap with the influence union. +Bricks marked by one drag frame (deterministic): + +| fixture | influence union | delta, main | delta, now | +|---|---:|---:|---:| +| 24 stamps, operand last, r 1 | 900 | 540 | **256** | +| same, ten times the cross-section | 15,600 | 1,152 | **256** | +| 96 stamps, operand at head or middle, r 1 | 1,452 | 540 (stale) | 1,452 | +| 96 stamps, operand at head or middle, r √10 | 28,830 | 1,440 (stale) | 28,830 | + +The last two rows cost more than before; main's narrower box left stale bricks. + +**A relief or incise stamp reports its own bound** (#683, issue #672). Every +stamp of such a stroke but the last was widened by 4 × strength on each side, +because the #650 walk read the relief amplitude as a blend support. A relief or +incise combine is `a ∓ k·w(b)` with `w` exactly zero outside its own bound, so +an edit to it cannot change the field anywhere else; `node_reach_bound` now +skips both dilations for such an item. Unit sphere, radius 0.12, strength 0.5, +four samples, default brick configuration (counts): + +| | earlier stamp's x width | bricks over the stroke | one stamp | +|---|---:|---:|---:| +| main, relief or incise | 4.72 | 2,548 | 48 | +| now | 0.72 | **48** | 48 | + +A relief GROUP is still widened, as is any node a relief stroke follows. + +## A Move drag on a mirror plane is applied once (#669, issue #663) + +On the plane, the reflection of a pull along the plane is the drag itself, so a +straddling item got the same grab twice. Unit sphere dragged from (0,1,0) by +(0,0.25,0), radius 0.35, linear ease: lift **0.1458** with no symmetry, +**0.2309** with mirror X on main, **0.1458** now. Both the live path +(`clay_sdf_move_*`) and the held path (`clay_layer_move_surface_regions`) are +fixed. Images whose world centres agree within `1e-4·radius + 1e-6·|coords|` +are one group, which becomes its mean pull as one grab plus one grab per image +for what it adds beyond the mean: an along-plane pull is exactly the unmirrored +grab; an across-plane pull keeps its two opposite grabs (the documented pinch); +an oblique pull is now **3 grabs where it was 2**. A radial drag on its axis +went from 4 grabs to 1. The tolerance leaves a step, measured and documented +(see Known limits). + +## A long topological move is sub-stepped (#681, issue #657, ABI 0.122.0) + +`clay_item_volume_move_topological` sampled through one pull-back +`p − d·w(g(p))`, which stops being one-to-one once `|d|·slope/r` passes 1; past +that the surface under the grip sank and the call still returned `CLAY_OK`. A +drag now runs as the fewest slices that keep `|d/n|·S/r ≤ 0.5`, capped at 64, +where `S` is the curve's steepest secant over one cell; each slice solves its +own geodesic and the volume is still re-sampled once. **With one slice the +result is bit-identical to before.** The issue's probe (unit sphere, cell 0.01, +band 0.67, `|d|` 0.64, r 0.3; host not named for this table): + +| | height at x=0 | at 0.35 | one call | +|---|---:|---:|---:| +| single step (before) | **0.939** | 0.936 | 388–418 ms | +| 5 slices, one call (now) | 1.116 | 1.365 | **361 ms** | +| host workaround, 9 calls of d/9 | 1.096 | 1.335 | 690 ms total | + +The slice count is sized off one cell of the curve, not its supremum: sized off +the supremum an `in_circ` drag of 0.15 went to 64 slices and 13,024 ms; now 8 +slices and 432 ms (Release, **Apple silicon**). **New: +`clay_item_volume_move_topological_from(doc, move, volume, region_min, +region_max, out_item)`** samples a document directly; with no region it samples +the padded bounds grown by `|d|`, so an outward pull is not clipped. +`ease_max_slope` moves to `include/clay/math/ease_slope.h` (C++; +`scene::ease_max_slope` remains as a using-declaration). + +## A mesh stamp's averaged normal is weighted by area (#677, issue #631) + +`resolve_frame` averaged one equal vote per workset vertex, so the frame followed +the triangulation; the mesher's Freudenthal split is chiral, and on a ridge the +two faces' normals nearly cancel, so the small imbalance became a large tilt. +Each normal now votes with `weight × area` (one third of each incident +triangle), on the fixed, multires and adaptive surfaces. Normals, weights and +the centroid are unchanged bit for bit; **only the six frame verbs move — +Draw, Flatten, Clay, Crease, Scrape, Layer**. + +| fixture | before | after | +|---|---:|---:| +| fin, lattice through the faces | 11.47° | 0.15° | +| fin, lattice between the faces | 1.38° | 0.09° | +| fin, adaptive surface | 1.38° | 0.09° | +| unit sphere, pole | 0.90° | < 0.01° | +| torus saddle | 4.28° | < 0.01° | + +The mesh-sculpt golden tables moved on 30 of 80 rows. Cost, interleaved A/B on +**arm64 macOS, `cpu-only` Release**, 6–8 runs of 200 iterations: fixed mesh and +multires within noise; `BM_DynamicStampNoAutomask` **1–8% slower** (about 6 µs +a stamp for one fan walk per kept vertex). + +## Mask extrude reaches the requested thickness (#667, issue #660) + +A painted mask is a thin volume around the surface, and intersecting the +extruded shell with it capped the wall at the paint depth: **a 0.6-unit wall +stopped near 0.11**. The field extrude now reads the mask at the source surface +under each sample, and the voxel extract grows from masked surface cells along +estimated normals for the requested number of layers, keeping source colours. +Tests measure 0.05, 0.1 and 0.6 walls along three normals within 10%, and a +0.6 voxel wall within one voxel of the field extract. Reaches +`clay_document_mask_extrude`, `_cancellable` and `clay_voxel_mask_extrude`. + +**#667 alone made the field extrude 5–7x slower, and #691 takes that back.** +The first v0.126.0 device gate failed on it: on iPad15,5, iOS 27.0.1, against +v0.120.1's engine on the same OS, `mask_extrude` read 51.6 → 288.3 ms at 10 +stamps (5.58x), 374.5 → 2566.1 ms at 100 (6.85x) and 5297 → 26834 ms at 1000 +(5.07x), and `mask_extract` 75.3 → 468.5 ms (6.22x). That run is not this +release's tree and wrote no gate record. The cause was the projection: each +sample called the source once for its distance and six more times for a +central-difference gradient. #691 takes the gradient from the distances already +sampled one cell either side (a source call only for a neighbour outside the +512-brick window, at the same position expression the grid uses), and skips the +projection where a bound on the mask distance shows the shell alone decides the +stored value. Source calls per lattice sample: **7 → 1.009**. On an Apple M2 +Max through the C ABI, 200 samples per arm: 10 stamps 73.1 ms before #667, +395.5 on #667, **69.1** with #691; 100 stamps 515.9, 3405.9, **519.7**. Not a +device number. + +**The gradient change moves stored values slightly.** Against #667's six-tap +formula: 0.0002–0.0034 cells on smooth fixtures, 0.010 on the device shell, +0.10–0.13 at a box edge and 0.21 on an inward wall reaching a sphere's centre, +the last two where the source has no defined gradient. Brick layout is +identical, #667's thickness tests pass unchanged, and the skip itself is +bit-identical to projecting every sample on 19 fixtures. + +## Voxel grids leave the interface thread (#682, issue #658, ABI 0.123.0) + +A voxel layer's grid was reachable only as a borrow of its document, so a host +moving grid-to-field to a worker read every cell of the occupied box through +`clay_voxel_get` (17–46 ms on the interface thread for 5k–100k cells, the +host's numbers in ClaySpaceDesktop#285), active level only, sculpt layers lost. + +**`clay_voxel_grid_clone`** deep-copies an owned or borrowed grid — every +level, the palette, the sculpt layers — into an owned handle. It is a copy of +the grid, **not of the session**: the engine's member-wise copy would carry the +document's undo sink, an open pass capture and the recording flag, so an edit to +the clone would have been written into the source's history; `VoxelGrid::clone()` +drops all three, and a recording sculpt layer arrives closed. +**`clay_voxel_get_occupied`** returns every occupied cell of the active level +and its index, sorted z, y, x, by the size-query pattern. `clay.h` now carries +THREADING notes on these, `clay_item_volume_from_voxels` and +`clay_voxel_to_layer`. Apple M-series, `-O2`, in-process, median of 15: + +| cells | box walk | clone | `get_occupied` | +|---:|---:|---:|---:| +| 4,184 | 0.16 ms | 0.008 ms | 0.31 ms | +| 89,048 | 10.96 ms | 0.074 ms | 4.12 ms | + +In-process a box-walk call is about 10 ns; a host paying FFI per cell pays the +issue's figures. + +## A mesh from arrays, and a mesh-only export (#685, #686) + +**`clay_mesh_from_arrays`** (#685, issue #661, ABI 0.124.0) takes a +`clay_mesh_arrays` descriptor (leading `struct_size`): positions, optional +vertex-aligned normals, colours and uvs, and exactly one of triangle or quad +indices. Attributes are copied bit-exactly; NULL leaves one absent. Before, the +only constructor that kept uvs was the OBJ reader, and a host wrote vertex +text into memory to parse it back. The call does not validate attribute lengths +or values. `clay_mesh_from_triangles` / `_from_quads` are rebuilt on the same +path and behave as before, except that the `clay_last_error` text for a NULL +pointer now names the argument. + +**`clay_document_mesh_combined` exports mesh layers when the field is empty** +(#686, issue #662): every SDF layer hidden, no SDF item, or every surface group +hidden. It meshed the field first and returned that call's refusal. The visible +mesh layers are now placed first; with none, the call still delegates to +`clay_document_mesh` and refuses exactly as it does. Malformed params and real +mesher failures are still returned. + +## Undo and refusals that now say what happened (#679, #684) + +**`clay_voxel_to_layer` is one undo step** (#679, issue #656). It ran two +edits, so one undo removed the volume and left an empty layer standing. The +layer is now built holding its volume item and added by a single `AddLayerCmd`, +which also makes a failed conversion leave the document as it was by +construction. On a 9³-cell grid: undo depth **+2 → +1**, layers after one undo +**1 → 0**. + +**Consolidating a voxel or mesh layer is refused as `CLAY_ERROR_UNSUPPORTED`** +(#684, issue #659), with `clay_last_error` naming "voxel" or "mesh", for +`clay_layer_consolidation_cost`, `clay_layer_consolidate`, `_cancellable`, +`clay_layer_plan_region_merge` and `clay_layer_consolidate_region`. It was +`CLAY_ERROR_INVALID_ARGUMENT` "nothing to consolidate", which artists read as +"already optimised"; `plan_region_merge` succeeded with an empty plan. A missing +layer is `CLAY_ERROR_NOT_FOUND`; representation is checked before protection, +so a locked grid says UNSUPPORTED, not "locked". An empty SDF layer still says +"nothing to consolidate". **`clay_layer_consolidation_advice` and `_state` +deliberately still succeed** on such a layer, as their header has said since +0.86.0. + +## A mesh cage drag costs what was dragged (#655) + +`mesh::Lattice::displacement` summed every control point at every vertex, +`nx·ny·nz` terms whatever had been dragged, and a control point at rest adds +exactly nothing. The cage now keeps the set of points with a non-zero offset +and sums over it; the per-axis Bernstein basis is built in O(n), in double. +ClaySpace measured one preview frame with one corner in hand at **~1.7 s on a +62k-vertex mesh at 32³, now ~11 ms** (3³ and 8³ ~9–10 ms); those are the host's +end-to-end numbers, host machine not stated. `BM_MeshLatticeDrag` on a loaded +machine read 2.9 / 3.7 / 8.8 ms at 3³ / 8³ / 32³. The results agree with the +full sum to float resolution, which is not a bit-identity claim. The SDF lattice +deformer is untouched. + +## C++ only: the dynamic history and the refit (#674, #678) + +**`session::History` undo, redo and journal replay of a `DynamicMesh` step go +through a held `DynamicSculptor`** (#674, issue #629) when the host sets +`History::set_dynamic_sculptor_resolver`; the new +`DynamicSculptor::replay_delta` restores and reindexes. Before, the history +replayed on the bare surface and left the sculptor's BVH stale: 23 live faces +missing and 1,333 dead entries after one undo of a 16-stamp arc on +`cube_sphere(24)`. No C ABI path reached this. + +**`DynamicSculptor::refit_around_moved_vertices` is kept and gated** (#678, +issue #630). `TopologyDelta::decode` never checks that the vertex and face +blocks agree, so a record arriving as bytes (`clay_dynamic_delta_deserialize`) +can name a moved vertex without its faces, and the refit is the only thing that +refits them. Removing it now fails a test (621 faces missed on undo, 198 on +redo). No behaviour change. + +--- + +## What moves under you + +### The formats + +**The scene and `.clayspace` format minor is 20** (#673). Each node record +gains one byte, the item's own mirror axes. **A default save now writes minor +20.** Documents at older minors load with every item inheriting and evaluate as +saved. Writing at minor 19 succeeds, with 19's exact bytes, when no item carries +its own axes, and is **refused below 20, naming the blocking layer**, when one +does (`clay_document_writable_at_minor` asks first). These notes do not claim +what v0.120.1 does with a minor-20 file; it was not exercised. The nine gallery +documents are regenerated at minor 20. + +**Loading an older-minor `.clayspace` re-encodes it once** (#675) so a journal +can pair with it: 1.41 → 3.61 ms on a 1.58 MB minor-19 voxel document. Loads at +this build's minor are unchanged (1.41 → 1.43 ms). + +**The crash journal gains the `SetNodeMirrorCmd` event** (#673). The journal is +session-private and carries no forward-compatibility promise: **a v0.126.0 +journal that recorded `clay_layer_set_node_mirror` is not replayable by an older +build.** Its seed changes too (#675): a journal begun on an edited load is +paired with the bytes a save would write now, not with the loaded file. + +**`clay_multires_delta` has its own serialized form** (#687), versioned; +truncated, trailing and newer-version bytes are refused. No multires or scene +format change. + +**No kernel arithmetic changed.** The one kernel-path file that moved, +`include/clay/eval/bake_volume.h` (#680), passes the band into the per-brick +cull, which changes which items a brick compiles, not what any kernel computes. +The kernels artifact's reference values come from CPU scalar evaluation, which +nothing here touched; the fixture was not regenerated to confirm byte identity. + +### The entry points + +- **Mask extrude walls are as thick as asked** (#667): `clay_document_mask_extrude`, + `_cancellable` and `clay_voxel_mask_extrude` return taller walls wherever the + thickness exceeded the paint depth. Field extrude values also move by up to + 0.010 cells on smooth fixtures and 0.21 where the source has no gradient + (#691). +- **`clay_item_volume_move_topological` answers differently for a drag longer + than about half its reach** (#681): sub-stepped, taller and unfolded. A + one-slice drag is bit-identical. Cost grows about quadratically in the slice + count. +- **A Move drag on a mirror plane or radial axis moves half as far** where it + was doubled (#669), and `clay_sdf_move_preview_grab_count` reports 1 for an + along-plane drag (was 2), 1 on a radial axis at count 4 (was 4), and 3 for an + oblique drag on the plane (was 2). +- **The mesh frame verbs move along a different normal** on asymmetric + triangulations (#677): Draw, Flatten, Clay, Crease, Scrape and Layer, through + `clay_mesh_sculptor_*`, `clay_dynamic_sculptor_*` and + `clay_multires_sculptor_*`. Grab, Smooth and the other verbs are unchanged. +- **A brick build fills differently for a squashed placement** (#680): it now + keeps the item where its field reaches. Unsquashed documents make identical + cull decisions. +- **A brick refill after an append and a region edit returns the current field** + (#668). A seed an append reached is no longer resumed, so that refill walks the + brick in full. +- **`clay_layer_set_transform_bound` reports a different box for an intersect + operand** (#676): the sweep alone when nothing smooth follows it (narrower), + and up to the influence union when smooth combines follow it (wider, and + correct where the old box was not). +- **`clay_layer_node_influence_bound` and `clay_brick_cache_mark_dirty_nodes` + report a relief or incise item's own bound** (#683): 48 bricks where a + 4-sample stroke dirtied 2,548. +- **`clay_document_enable_undo` / `Document.enable_undo()` on an edited load** + (#675) seeds a journal that a replay onto the loaded file refuses with + `CLAY_ERROR_SNAPSHOT_MISMATCH`; it costs one encode (2.16 ms on 1.58 MB). +- **`clay_voxel_to_layer` is one undo step** (#679): `undo_depth` grows by 1. +- **The consolidation calls return `CLAY_ERROR_UNSUPPORTED`** for a voxel or + mesh layer, and `clay_layer_plan_region_merge` fails where it returned an empty + plan (#684). +- **`clay_document_mesh_combined` succeeds** on a document whose field is empty + but has a visible mesh layer (#686). +- **The mesh cage deformer** (C++ `mesh::Lattice`) agrees with the full sum to + float resolution, not bit for bit (#655). +- **`clay_mesh_from_triangles` / `_from_quads`** name the NULL argument in + `clay_last_error` (#685); results are unchanged. +- **C++ only.** `scene::Node::own_mirror_axes` and `SetNodeMirrorCmd` are new + (#673); `scene::CullRegion` gains `band` and a constructor, + `CullIndex::Entry` grows 40 → 48 bytes, `CullIndex::plan` takes a defaulted + `band` (#680); `brush::apply_to_multires` gains a trailing defaulted + `SculptLayerDelta*` (#687); `apply_to_mesh` / `_multires` / `_dynamic` are now + gesture objects fed once (#688); `ease_max_slope` moves to + `include/clay/math/ease_slope.h` (#681); `save_clayspace` is split into + `encode_clayspace` plus the stamp, and `io::journal_seed_for` and + `Document::snapshot_reencoded_id` are new (#675); + `WorkItemReader::normal_at` returns an area and `SculptWorkset` gains `areas` + (#677); `VoxelGrid::clone()` and `occupied_cells()` (#682); + `DynamicSculptor::replay_delta` and `History::set_dynamic_sculptor_resolver` + (#674); `Lattice::dragged_count()` (#655). Recompile, and add a case to any + exhaustive switch over the command kinds. + +--- + +## The ABI, diffed rather than summarised + +`CLAY_ABI_MAJOR/MINOR/PATCH` = 0/126/0. + +`comm` over `clay_*(` in `bindings/c/clay.h` at `v0.120.1` and at `4401b354`: +708 → 740 symbols, **32 added, zero removed.** + +| minor | PR | added | +|---|---|---| +| 0.121.0 | #673 | `clay_item_set_mirror_axes`, `clay_item_mirror_axes`, `clay_layer_set_node_mirror`, `clay_layer_node_mirror`; macro `CLAY_MIRROR_AXES_INHERIT` (255) | +| 0.122.0 | #681 | `clay_item_volume_move_topological_from` | +| 0.123.0 | #682 | `clay_voxel_grid_clone`, `clay_voxel_get_occupied` | +| 0.124.0 | #685 | `clay_mesh_from_arrays`; struct `clay_mesh_arrays` | +| 0.125.0 | #687 | `clay_multires_delta_create`, `_destroy`, `_clear`, `_stats_get`, `_levels`, `_revert`, `_apply`, `_serialize`, `_deserialize`, `clay_multires_sculptor_stamp_recorded`, `clay_multires_sculptor_apply_stroke_recorded`, `clay_multires_sculpt_layer_stroke_commit_into`; opaque `clay_multires_delta`, struct `clay_multires_delta_stats` | +| 0.126.0 | #688 | `clay_stroke_tx_begin`, `_append`, `_end`, `_stamps`, `_status_get`, `_destroy`, `clay_layer_apply_stroke_tx`, `clay_voxel_apply_stroke_tx`, `clay_mask_apply_stroke_tx`, `clay_mesh_sculptor_apply_stroke_tx`, `clay_dynamic_sculptor_apply_stroke_tx`, `clay_multires_sculptor_apply_stroke_tx`; opaque `clay_stroke_tx`, struct `clay_stroke_tx_status` | + +`git diff v0.120.1..4401b354 -- bindings/c/clay.h` is 853 insertions and 41 +deletions. **The only `-` lines that are not comment lines are the +`CLAY_ABI_MINOR 120` and `CLAY_ABI_PATCH 1` defines.** Three hunks sit just +after a `typedef struct` (`clay_consolidation_cost`, `clay_region_merge`, +`clay_topological_move_params`) and all three edit the comment above the next +function, not the struct. **No existing struct was re-laid out and none gained +a field**: the three structs above are new, each with a leading `struct_size`, +and the two handles are opaque. No enum value was added or renumbered. + +The comments added state what moved above: the consolidation refusal, the +sub-stepped move and its 64-slice cap, the mesh-only combined export, the relief +reach, the intersect delta box, the coincident-image grab count, the journal +pairing rule and its cost, and the threading of the voxel reads. + +pyclay gains `Layer.add(mirror_axes=)`, `Layer.set_node_mirror`, +`Layer.node_mirror` and `clay.StrokeTransaction`, all additive. It does not gain +`Mesh.from_arrays`, the voxel clone or the six session consumers; +`check_binding_parity.py` runs pyclay → C, so C-only additions pass it. + +--- + +## The gates behind this release + +**The iPad device gate ran against this release's tree**, on the reference +iPad15,5, and the baseline it was scored against is new: the iPad moved from iOS +27.0 (24A437) to **27.0.1 (24A446)** on 2026-10-04, `check_device_bench.py` +matches `osVersion` exactly, and PR #690 re-recorded `tests/device/baseline.json` +**from v0.120.1's engine (`7f6cf38f`) on 27.0.1** rather than from this tree, so +the `REGRESSION` rows below compare v0.120.1 to v0.126.0 on the same OS instead +of a run against itself. Every PR in this range reported the `device` row red +until this stamp landed. + + +**Device gate: PASSED, scored against the iOS 27.0.1 baseline from #690.** +Recorded in `tests/device/last-gate.json`: claycore `7049e19c` at ABI 0.126.0, +`iPad15,5` on iOS 27.0.1 (24A446), 75 cases, seven cold sessions with **1800 s** +cooldowns after a thermal probe, thermal state `nominal` at both ends of every +case, `treeDirty: false`. The run needed the iPad's Wi-Fi off and the device +under an air conditioner: with Wi-Fi on, post-update on-device inference kept +it near the thermal kill line and four sessions were terminated 36–80 s in +(no JetsamEvent, no crash report) before any run completed. The console is not +reachable on iOS 27.0.1 from the Mac, so no RunningBoard line is quoted. + +- **No `REGRESSION`, `BUDGET` or `GROWTH` failure.** Taking each case's median + across its points, the median ratio against the baseline is **0.996** over all + 75 cases, and **no case is above both the 1.4 tolerance and the 0.125 ms + floor**. The largest rises are `move_drags`, `trim_curve` and `mask_extract` + at 1.04x; nothing else is above 1.03x. +- **The first run of this release failed, and #691 is why it passed.** At + `7406eb1d`, before #691, the same gate read `mask_extrude` at **26992 ms** + p95 at 1000 stamps against the baseline's 5436 (x4.97, budget 8154 ms) and + `mask_extract` at 468 ms against 75 (x6.26); `BUDGET` and `REGRESSION` fired + on both and no stamp was written. With #691, `mask_extrude` reads + **52.4 / 377.9 / 3680 ms** at 10 / 100 / 1000 stamps against 51.6 / 374.5 / + 5297 (1.01x, 1.01x, 0.69x) and `mask_extract` **78.2 ms** against 75.3 + (1.04x). The 1.34x spread the baseline run saw on `mask_extrude`'s 1000-stamp + point (983 ms over 3 passes) was 1.4 ms here. +- **Canary steady** at x1.18 across the run (98.78 → 116.31 ms), inside the + x1.3 tolerance; every case was measured with the canary inside tolerance. 14 + cases needed repeats for an honest p95 (median of 3 passes), led by + `sdf_consolidate` (spread 7.2 ms) and `sdf_stamp_detail_bricks` (5.9 ms). + + +**Which device cases could see this range.** None was built to measure any +change here, so a reading on any of them is a candidate, not a measured claim. + +- In `tests/device/Measure/LatencyCases.swift`: every brick case + (`sdf_stamp_bricks`, `sdf_stroke_bricks`, `sdf_stroke_in_group_bricks`, + `sdf_stroke_smooth_bricks`, `sdf_node_/_group_/_layer_transform_bricks`, + `sdf_stamp_after_drag_bricks`, `sdf_stamp_after_group_drag_bricks`) goes + through #680's band-aware cull, which costs 1.1–4.0% on unsquashed documents + on a Mac; `sdf_stamp_cpu/_metal` and `sdf_stroke_cpu/_metal` compile through + the same `compile_list` test. The transform and stamp-after-drag cases make + region edits on a document built by appends, which is #668's seed rule; a + slower first refill there is a candidate for #668. None of these fixtures uses + relief, incise, an intersect, a mirror or a per-axis scale, so #683, #676, + #669, #673 and #680's widening itself do not reach them. +- Elsewhere in the suite: `mask_extrude` and `mask_extract` run the extrude #667 + rewrote and #691 made cheap again (the failed first run read them 5–7x); + `stroke_resolve` (and any case resolving a preset path) runs #688's resolver; + `dyntopo_stamp`, `_fine` and `_fixed` stamp Clay on the adaptive surface, + which pays #677's area walk (1–8% on a Mac). + +**What those cases read, as candidates and not claims** (median across points, +against the v0.120.1 baseline on the same OS): the brick cases are flat, +`sdf_stamp_bricks` 0.97x, `sdf_stroke_bricks` 1.00x, `sdf_stroke_smooth_bricks` +0.94x, the transform-bricks 0.87–1.03x and the stamp-after-drag cases 0.97x, so +#680's cull and #668's seed rule cost nothing measurable here; `sdf_stamp_cpu` +1.00x and `sdf_stamp_metal` 0.94x. The stroke cases read **faster**: +`sdf_stroke_cpu` 0.81x (0.435 → 0.353 ms), `sdf_stroke_in_group_bricks` 0.81x +(12.40 → 10.07 ms), `sdf_stroke_metal` 0.89x, `stroke_build` 0.84x and +`stroke_resolve` 0.98x — a candidate for #688's resolver, which none of them +was built to measure. `dyntopo_stamp` 0.95x, `_fine` 0.98x, `_fixed` 0.99x +against #677's 1–8% Mac estimate. Two operation-class verbs this range does not +name also read faster, `sdf_relax` 0.67x (0.533 → 0.357 ms) and `sdf_flatten` +0.74x, with `sdf_consolidate` 0.85x (52.1 → 44.2 ms); nothing in this range +touches them, so those are readings of the device, not of the release. + +**The four manual hardware gates are carried as waivers**, re-recorded in +`tests/hardware/manual-gates.json` at `4401b354` (2026-10-04) with the RTX 5060 +runs at `0c3a392c` kept as `prior_run`. Since v0.120.1's waivers at `59e42ccf`, +`git diff 59e42ccf..4401b354 -- include/clay/kernel include/clay/eval backends +cmake/ClayCudaArch.cmake tools/amalgamate_cl.py tools/amalgamate_glsl.py +tests/unit/test_parity.cpp` touches **one file**, `include/clay/eval/bake_volume.h` +(`8d015258`, #680), which passes `grid.band` into the per-brick `CullRegion` and +`CullIndex::plan`. That changes which items a brick compiles, not kernel +arithmetic, and `tests/unit/test_parity.cpp` is unchanged. **This release +therefore does not claim that CUDA, OpenCL or Vulkan ran against its tree**; the +`parity` row compares cpu. + +--- + +## Known limits, stated rather than discovered + +### From this release + +- **#649 stays open on its mechanism A.** #680 fixed the squashed + cull (B), and its PR and proposal both say A — the #335 chain-pad envelope + applied below the 75 contributors it was measured at — is not addressed (seed + 978). After #680 the rich sweep still has **9 documents off the raw field, + worst 0.0170**, and the plain sweep **21, worst 0.0310** (Apple M-series). + Repairing it trades against #335's performance win and needs the iPad gate. + #649 tracks it. +- **`clay_multires_sculpt_layer_stroke_*` and the `*_apply_preset` calls have no + stroke-session form.** A host feeding live input to those still re-segments. +- **#643 stays open: a `clay_mesh_sculptor` stamp on a document mesh layer is + not a document undo step.** The stroke session gives the mesh sculptor a + gesture boundary, but a mesh gesture still records only into the host's + `clay_mesh_deltas`, not into `clay_document_undo`. +- **A stroke session's ink trails the pen**, by up to one spacing, by the + end-taper fraction with an end taper, and until lift with a start taper. +- **A multires record does not survive a level change**, even one later undone, + **nor a snapshot restore into a new handle** (#687); the binding is the + hierarchy's `structure_revision` plus a per-process origin. The resident + record is not smaller than a whole-hierarchy snapshot on the measured fixture. + Level add/remove records were not built. +- **The mirror-plane merge has a tolerance step** (#669): with the drag centre + 1e-5 off the plane the lift is 0.1458, at 1e-4 it is 0.2309 again, falling to + 0.1597 at 0.2. Grab composition is not additive (two 0.125 grabs lift 0.1600, + one 0.25 grab 0.1458), so a continuous rule needs a multi-centre deformer. +- **A topological move's cost is about quadratic in its slice count**, capped + at 64 slices (#681), and stepping converges with the slice count rather than + to one answer (5 / 9 / 17 calls: 1.119 / 1.096 / 1.086 at the anchor). +- **A per-axis mirror choice cannot be written below minor 20**, even where it + equals what the item would inherit (#673): the node writer has no layer + context. +- **The journal still refuses** a document saved at an older minor and enabled + with no edit and no save between, and a non-canonical foreign-writer stream at + this minor (#675). +- **#668's check costs 0.52–1.27 ms** on the first far region edit after + 200–3,000 dabs, against 0.21–0.30 ms for the unsound rule (no host named). +- **#680 costs 1.1–4.0%** on the unsquashed cull and refill benchmarks + (Apple M-series, CPU). +- **#677 costs the adaptive stamp 1–8%** (arm64 macOS). +- **A relief term of `|k|` for nodes BEFORE a relief stroke was not measured**; + those nodes keep the 4k term (#683). +- **`clay_voxel_get_occupied` spends about two thirds of its time sorting** + (1.67 ms without the sort at 89k cells, Apple M-series), and **two readers of + a cold borrowed grid race in its bounds cache**; warm it with + `clay_voxel_bounds` or clone on the interface thread (#682). +- **`clay_mesh_from_arrays` does not validate attribute lengths or values**, and + **pyclay has no `Mesh.from_arrays`** (#685). +- **`TopologyDelta::decode` still does not check that its vertex and face + blocks agree** (#678); the refit covers the consequence, not the cause. +- **A recovered dynamic surface hands out different pool slots** than the + original session, so its next stroke differs from the original's; this + already existed and is documented (#674). +- **Hosts not named.** #667, #669, #673, #676, #679, #683–#687 report counts, + field values or bytes; #668's and #681's main timings and #675's name no host + model; #677 names arm64 macOS; #680, #682 and #688 name Apple M-series; #681's + follow-up names Apple silicon; #655's end-to-end figures are ClaySpace's. This + document repeats what each says and invents nothing. + +### Unchanged from v0.120.1 + +- **Undo's per-brick cost still grows with chain length** (#648): 7.4 → 28.1 µs + per brick from 1 to 160 grabs (Mac M-series, CPU backend), and a radial pose, a + head behind a twist or another whole-item link, an easing not exactly zero at + its rim, an infinite repeat grid, or a morph or hidden group above the item + still report the node's bound. +- **Refilling a baked volume** (#639's second route) was not attempted. +- **A GATED node after the edited one is not modelled** by #653's dilation. +- **`clay_layer_bounds` / `pick::selection_bounds` still report one cell for a + lattice** (#645). +- **#644: the journal's write-path cost on the device is unmeasured**; the only + figures are Linux x86_64 ratios, not device numbers. +- **Still not an undo step:** creating a MASK, and dropping a resolution level. + A voxel sculpt layer's RECORDING state is not saved, and `end_sculpt_layer` is + not a step. +- **`correct-the-undo-scope` has two open tasks** (9 of 11). +- **The mixed-depth multires export has no C, pyclay or Swift entry point** + (`expose-the-mixed-depth-export`, 0 of 8). +- **Complexity flagged, not refactored:** `History::apply_step`, + `History::replay` and `compile_list`, as v0.120.1 reported them; not + re-measured for this release. +- **The mesh Grab change (#624) has no device measurement**; the mesh brushes + are not in the device suite. +- **A fixed mesh has no mitigation for a stretched captured region**, **the + adaptive surface reaches past the chord on a curve** (115.79%), **the carried + region has no upper bound**, and **the collapse protection is measured only on + sphere grab strokes**. +- **There is no faithful SDF Standard**; it needs its own proposal (#633). + **ClayBuildup is unmeasured.** **Relief's stepping bound is poor at stroke + density** (L = 46 over 30 dabs), and **per-dab `move_surface`'s safe step + collapses by 1.5x per dab** (#632), which is structural. +- **Adaptive undo is last-in first-out only and nothing compacts**; + **`serialize` is not the test for "undo restored the surface"**; **a second + sculptor over the same surface is not kept in step**; **`bytes()` is a + budget, not an assertion**; **a recorded stroke accumulates into its + record**. +- **The adaptive stroke calls are not in the device coverage gate.** +- **#531 is not closed and the all-brush 16 ms target is unmet**, and the + parity umbrella **#530** stays open. Nothing in this release claims otherwise. +- **#621 has no uncontended timing; native GPU execution of #613/#616/#621/#623 + was not measured; #616's p95 is a small-sample statistic.** +- **Decimation can still emit a non-manifold mesh at some ratios on some + toolchains** (#575), and **a mesh is not byte-identical across toolchains.** +- **Regional consolidation's local path is narrow by construction.** +- **`clay_stroke_preset`'s `rotate_to_azimuth` and `velocity_*` controls read + channels only `clay_stroke_sample_full` carries**, and **the flat-packed mesh + and multires stroke calls still take `count*5` floats.** +- **Move still builds no stroke preset**, and **the SDF Move brush still moves + less than the displacement asked for.** +- **"`clay_list_backends` reports metal on a real iPad" has not been observed + by this repository** (#45), and **`check_gallery.py` gates a manifest, not an + image.** + +### Changed from v0.120.1 + +- **#649 is narrowed** (#680 fixed the squashed cull). It was "open on two + mechanisms"; one remains, stated above under "From this release". +- **#641 is fixed** (#675). A journal begun on an edited load is now refused + against the stale snapshot rather than replayed without the pre-enable edits; + "save once right after enabling" is no longer required for correctness. +- **#629 is fixed** (#674): the C++ `session::History` path keeps a held + `DynamicSculptor`'s index in step when the host sets the sculptor resolver. +- **`make-voxel-sculpt-layer-creation-a-step` is archived.** +- **#643's entry gains a note**: the stroke session is a gesture boundary for + the mesh sculptor, still not a document undo step. +- **v0.120.1's device gate passed (median ratio 0.998 over 75 cases); this + release's has not run.** The "regression coverage restored" statement applies + to v0.120.1's tree, not to this one, until the pending gate is recorded. +- **The hardware waivers** move from `59e42ccf` to `4401b354`; see "The gates". + +--- + +## Upgrading + +Ordered by how much risk each item carries. + +1. **If you hand `.clayspace` documents to an older build, write at minor 19** + (`clay_document_writable_at_minor` first). A default save is now minor 20, + and a document holding an item with its own mirror axes is refused below 20, + naming the layer (#673). +2. **Re-record goldens and expectations** for mask extrude walls (#667), long + topological moves (#681), Move drags on a mirror plane or radial axis and + `clay_sdf_move_preview_grab_count` there (#669), the six mesh frame verbs + (#677), and brick builds of documents with a per-axis scale (#680). Each now + returns the corrected answer. +3. **If you enable undo after loading and editing, expect a replay onto the + loaded file to be refused** with `CLAY_ERROR_SNAPSHOT_MISMATCH`, and save + after enabling to get a pairable snapshot (#675). Budget one encode at + enable time, and a re-encode when loading minor-19 files. +4. **If you branch on consolidation errors, handle `CLAY_ERROR_UNSUPPORTED`** + for voxel and mesh layers, and do not read a failed + `clay_layer_plan_region_merge` on such a layer as "nothing to absorb" (#684). +5. **If you dirty bricks from `clay_layer_set_transform_bound`, + `clay_layer_node_influence_bound` or `clay_brick_cache_mark_dirty_nodes`, + take the new boxes as they come** (#676, #683). Do not pad or clip them. +6. **Recount undo steps for `clay_voxel_to_layer`**: one, not two (#679). +7. **Drop a transform-and-concat fallback** around + `clay_document_mesh_combined` if you kept one (#686); keeping it is harmless. +8. **Live-input hosts can move onto `clay_stroke_tx_*`** (#688), applying only + settled stamps and drawing the preview from `clay_stroke_tx_stamps`. Pass a + `clay_multires_delta` to the multires consumer to put a gesture on your undo + stack (#689). +9. **Multires hosts can replace per-gesture snapshots with + `clay_multires_delta`** (#687); keep a snapshot across level changes, which a + record does not survive. +10. **Hosts snapshotting voxel grids cell by cell can clone instead** (#682); + warm `clay_voxel_bounds` before a worker reads a borrowed grid. +11. **Hosts building meshes through OBJ text can call `clay_mesh_from_arrays`** + (#685). +12. **C++ callers recompile** (`CullRegion`, `CullIndex::Entry`, + `apply_to_multires`, the new command kind); a C++ `session::History` host + holding a `DynamicSculptor` should set `set_dynamic_sculptor_resolver` + (#674). +13. **Do not replay a v0.126.0 crash journal with an older build** if it + recorded `clay_layer_set_node_mirror`. +14. **Nothing to do for the kernels artifact.** No kernel arithmetic changed. diff --git a/tests/device/last-gate.json b/tests/device/last-gate.json index 09e42f8b..d7dbfbb2 100644 --- a/tests/device/last-gate.json +++ b/tests/device/last-gate.json @@ -1,7 +1,7 @@ { - "abiVersion": "0.120.1", + "abiVersion": "0.126.0", "caseCount": 75, - "claycoreCommit": "7f6cf38ff4ecdbb201a0ffa472aebafc24ba1c91", + "claycoreCommit": "7049e19cbfef97fa12a8d88ba9a85492e278b19e", "deviceModel": "iPad15,5", "osVersion": "Version 27.0.1 (Build 24A446)", "passed": true, diff --git a/tests/hardware/manual-gates.json b/tests/hardware/manual-gates.json index e1b82792..e89b7327 100644 --- a/tests/hardware/manual-gates.json +++ b/tests/hardware/manual-gates.json @@ -5,47 +5,47 @@ "gates": { "cuda-parity": { "status": "waived", - "commit": "59e42ccf9e088234a6cc7da99b54910429c1f9f4", - "date": "2026-09-18", + "commit": "4401b3540a545b3603d1ec6834d6d247d40cd16b", + "date": "2026-10-04", "prior_run": { "commit": "0c3a392cba28a570fe79da2c622e6a28d97c5330", "date": "2026-09-15", "evidence": "RTX 5060 / driver 580.95.05 / nvcc 12.0.140, fresh worktree, configured -DCLAY_BACKEND_CUDA=ON -DCLAY_BACKEND_OPENCL=ON -DCLAY_BACKEND_VULKAN=ON. 'parity: every registered backend matches the scalar reference' reports 565 assertions cpu-only and 1125 with CUDA (+560, 0 failed), OpenCL and Vulkan hidden (OCL_ICD_VENDORS=/nonexistent, VK_DRIVER_FILES=/nonexistent). Every backend adds the same +560, against +539 at e8ca6d59 -- the corpus grew, so the per-backend DIFFERENCE is the comparable quantity and the totals are not. Caveat: PTX JIT to Blackwell only; a cubin for sm_120 needs CUDA >= 12.8 and stays unexercised." }, - "reason": "Not re-run for v0.120.0, and no kernel arithmetic changed in the release. This gate was RUN at 0c3a392cb on RTX 5060 / driver 580.95.05 / nvcc 12.0.140; that record is kept under prior_run. It expired only because 9c5419ec (#618) edited include/clay/kernel/tape.h, and that diff is two trailing comments on ccombine_relief and ccombine_incise, whose values stay 14 and 15: `git diff 0c3a392cb..HEAD -- include/clay/kernel backends` is 12 insertions and 2 deletions in that one file, with no non-comment line changed. release_check's `kernels` row (15 headers byte-identical) says the same thing from the other side. What this release therefore does NOT claim: that CUDA, OpenCL or Vulkan ran against v0.120.0's tree. The cpu `parity` row covers cpu alone." + "reason": "Not re-run for v0.126.0, and no kernel arithmetic changed in the release. The last RUN record is kept under prior_run. Carried from the v0.120.1 waiver at 59e42ccf: `git diff 59e42ccf..4401b354 -- include/clay/kernel include/clay/eval backends cmake/ClayCudaArch.cmake tools/amalgamate_cl.py tools/amalgamate_glsl.py tests/unit/test_parity.cpp` touches one file, include/clay/eval/bake_volume.h (8d015258, #680), which passes grid.band into the per-brick CullRegion and CullIndex::plan. That changes which items a brick's tape compiles on the CPU bake path, not any kernel's arithmetic, and the parity corpus is unchanged. What this release therefore does NOT claim: that CUDA, OpenCL or Vulkan ran against v0.126.0's tree. The cpu `parity` row covers cpu alone." }, "nvcc-build": { "status": "waived", - "commit": "59e42ccf9e088234a6cc7da99b54910429c1f9f4", - "date": "2026-09-18", + "commit": "4401b3540a545b3603d1ec6834d6d247d40cd16b", + "date": "2026-10-04", "prior_run": { "commit": "0c3a392cba28a570fe79da2c622e6a28d97c5330", "date": "2026-09-15", "evidence": "Same build: nvcc 12.0.140 compiled backends/cuda/clay_kernels.cu; cmake/ClayCudaArch.cmake reported 'GPU arch 120-real is newer than this toolkit; building 90-virtual PTX for the driver to JIT', and the RTX 5060 ran it. Self-proving rather than taken from the status line: the compile line in flags.make carries --generate-code=arch=compute_90,code=[compute_90]. CMAKE_CUDA_ARCHITECTURES:STRING=52 in the cache is enable_language(CUDA)'s toolkit default (CLAY_CUDA_ARCH_TOOLKIT_DEFAULT=52), which ClayCudaArch.cmake overrode by setting CUDA_ARCHITECTURES on the TARGET rather than the variable; it never compiled the kernels. It ran through PTX JIT, so the cubin half of the auto-detection is still unexercised at sm_120." }, - "reason": "Not re-run for v0.120.0, and no kernel arithmetic changed in the release. This gate was RUN at 0c3a392cb on nvcc 12.0.140 compiling backends/cuda/clay_kernels.cu, PTX JIT to Blackwell; that record is kept under prior_run. It expired only because 9c5419ec (#618) edited include/clay/kernel/tape.h, and that diff is two trailing comments on ccombine_relief and ccombine_incise, whose values stay 14 and 15: `git diff 0c3a392cb..HEAD -- include/clay/kernel backends` is 12 insertions and 2 deletions in that one file, with no non-comment line changed. release_check's `kernels` row (15 headers byte-identical) says the same thing from the other side. What this release therefore does NOT claim: that CUDA, OpenCL or Vulkan ran against v0.120.0's tree. The cpu `parity` row covers cpu alone." + "reason": "Not re-run for v0.126.0, and no kernel arithmetic changed in the release. The last RUN record is kept under prior_run. Carried from the v0.120.1 waiver at 59e42ccf: `git diff 59e42ccf..4401b354 -- include/clay/kernel include/clay/eval backends cmake/ClayCudaArch.cmake tools/amalgamate_cl.py tools/amalgamate_glsl.py tests/unit/test_parity.cpp` touches one file, include/clay/eval/bake_volume.h (8d015258, #680), which passes grid.band into the per-brick CullRegion and CullIndex::plan. That changes which items a brick's tape compiles on the CPU bake path, not any kernel's arithmetic, and the parity corpus is unchanged. What this release therefore does NOT claim: that CUDA, OpenCL or Vulkan ran against v0.126.0's tree. The cpu `parity` row covers cpu alone." }, "opencl-device": { "status": "waived", - "commit": "59e42ccf9e088234a6cc7da99b54910429c1f9f4", - "date": "2026-09-18", + "commit": "4401b3540a545b3603d1ec6834d6d247d40cd16b", + "date": "2026-10-04", "prior_run": { "commit": "0c3a392cba28a570fe79da2c622e6a28d97c5330", "date": "2026-09-15", "evidence": "Same RTX 5060 host, NVIDIA ICD: 1685 assertions with CUDA and OpenCL registered, +560 over the CUDA-only run, 0 failed. Controlled with OCL_ICD_VENDORS=/nonexistent, verified BEFORE the runs to hide only what it claims (clGetPlatformIDs 1 platform -> 0, /nonexistent -> -1001). CUDA_VISIBLE_DEVICES= is NOT a control here, because it hides NVIDIA's OpenCL platform too -- it also returns -1001." }, - "reason": "Not re-run for v0.120.0, and no kernel arithmetic changed in the release. This gate was RUN at 0c3a392cb on the same RTX 5060 host on the NVIDIA ICD; that record is kept under prior_run. It expired only because 9c5419ec (#618) edited include/clay/kernel/tape.h, and that diff is two trailing comments on ccombine_relief and ccombine_incise, whose values stay 14 and 15: `git diff 0c3a392cb..HEAD -- include/clay/kernel backends` is 12 insertions and 2 deletions in that one file, with no non-comment line changed. release_check's `kernels` row (15 headers byte-identical) says the same thing from the other side. What this release therefore does NOT claim: that CUDA, OpenCL or Vulkan ran against v0.120.0's tree. The cpu `parity` row covers cpu alone." + "reason": "Not re-run for v0.126.0, and no kernel arithmetic changed in the release. The last RUN record is kept under prior_run. Carried from the v0.120.1 waiver at 59e42ccf: `git diff 59e42ccf..4401b354 -- include/clay/kernel include/clay/eval backends cmake/ClayCudaArch.cmake tools/amalgamate_cl.py tools/amalgamate_glsl.py tests/unit/test_parity.cpp` touches one file, include/clay/eval/bake_volume.h (8d015258, #680), which passes grid.band into the per-brick CullRegion and CullIndex::plan. That changes which items a brick's tape compiles on the CPU bake path, not any kernel's arithmetic, and the parity corpus is unchanged. What this release therefore does NOT claim: that CUDA, OpenCL or Vulkan ran against v0.126.0's tree. The cpu `parity` row covers cpu alone." }, "vulkan-device": { "status": "waived", - "commit": "59e42ccf9e088234a6cc7da99b54910429c1f9f4", - "date": "2026-09-18", + "commit": "4401b3540a545b3603d1ec6834d6d247d40cd16b", + "date": "2026-10-04", "prior_run": { "commit": "0c3a392cba28a570fe79da2c622e6a28d97c5330", "date": "2026-09-15", "evidence": "NO LONGER WAIVED. Real silicon, two devices, each pinned with VK_DRIVER_FILES/VK_ICD_FILENAMES to one ICD: NVIDIA GeForce RTX 5060 (driver 580.95.05, Vulkan 1.4.312) and Intel UHD Graphics 770 (Mesa 25.2.8, Vulkan 1.4.318). Each reports 1125 assertions with Vulkan registered, +560 over cpu-only, 0 failed; all four backends together 2245 = 565 + 3x560, 0 failed. SPIR-V built with glslangValidator (glslc absent), loader headers 1.3.275. lavapipe/llvmpipe was present on the host and EXCLUDED by the pinned ICD, so neither run's arithmetic was on the CPU -- which is the whole reason this gate existed as a waiver until now." }, - "reason": "Not re-run for v0.120.0, and no kernel arithmetic changed in the release. This gate was RUN at 0c3a392cb on real silicon, RTX 5060 (580.95.05) and Intel UHD 770 (Mesa 25.2.8), each pinned to one ICD; that record is kept under prior_run. It expired only because 9c5419ec (#618) edited include/clay/kernel/tape.h, and that diff is two trailing comments on ccombine_relief and ccombine_incise, whose values stay 14 and 15: `git diff 0c3a392cb..HEAD -- include/clay/kernel backends` is 12 insertions and 2 deletions in that one file, with no non-comment line changed. release_check's `kernels` row (15 headers byte-identical) says the same thing from the other side. What this release therefore does NOT claim: that CUDA, OpenCL or Vulkan ran against v0.120.0's tree. The cpu `parity` row covers cpu alone." + "reason": "Not re-run for v0.126.0, and no kernel arithmetic changed in the release. The last RUN record is kept under prior_run. Carried from the v0.120.1 waiver at 59e42ccf: `git diff 59e42ccf..4401b354 -- include/clay/kernel include/clay/eval backends cmake/ClayCudaArch.cmake tools/amalgamate_cl.py tools/amalgamate_glsl.py tests/unit/test_parity.cpp` touches one file, include/clay/eval/bake_volume.h (8d015258, #680), which passes grid.band into the per-brick CullRegion and CullIndex::plan. That changes which items a brick's tape compiles on the CPU bake path, not any kernel's arithmetic, and the parity corpus is unchanged. What this release therefore does NOT claim: that CUDA, OpenCL or Vulkan ran against v0.126.0's tree. The cpu `parity` row covers cpu alone." } } }