Skip to content

Bound an edit by the smooth combines after it (#650) - #653

Merged
leonardoaraujosantos merged 3 commits into
mainfrom
fix/650-grab-bound-in-a-blended-group
Sep 23, 2026
Merged

leonardoaraujosantos merged 3 commits into
mainfrom
fix/650-grab-bound-in-a-blended-group

Conversation

@leonardoaraujosantos

Copy link
Copy Markdown
Contributor

Why

undo_bound_oracle_probe under RV_RAWBOUND (seed 5128, rich) found the raw field moving by an ulp outside the undo bound plus the band, and the same on main before and after #648. The issue suspected the group blend's support. That's not the cause. The group in that seed is the first root, so its combine never applies (#515).

The cause: the target is the first item of its chain, and the item after it blends smooth (k = 0.297). The first item of a chain has no combine. It is the running value, and so is any item wherever it's the nearest thing. So an edit changes that value far from the node's own box. A hard union leaves that beyond-band difference alone (min() is exact). A smooth combine further down doesn't: csmin(a, b) reads a wherever |a - b| < support, so with b in the band it carries a change in a back into the band out to band + support. node_reach_bound dilated only by the enclosing groups. That made the node bound too tight, and so was #639's head narrowing, which clamps the grab ball into that bound. In the seed, the grab's eased rim changes the node's value by an ulp inside the ball, 0.3 from its surface, and the sibling's blend lifts it into the band.

It isn't an ulp in general. On main (8b7a571) I placed two r = 0.3 spheres 0.3 apart, blended the second smooth at k = 0.3, and moved the first by 0.1:

band samples moved outside box + band worst stale bricks via mark_dirty_nodes
main (root, or inside a blended group) 6,359 0.0438 25
main, second at k = 0.1 8
this PR 0 0 0

What lands

  • node_reach_bound also dilates by the smooth combines after the node. At each level, before the group's own support, it adds the later siblings' cull_pad_terms resolved at full support (CullPadTerms::support_total). A later sibling group contributes only its own combine, because its children start their own chain. This widens clay_layer_node_influence_bound, clay_brick_cache_mark_dirty_nodes, the command path, and the undo bound (through the clay_document_undo_bound reports a grab's whole node, so undoing a Move refills the node and its cost grows with the chain #639 clamp).
  • Nothing widens for a node followed only by hard combines. That covers every node appended last (a stroke's dabs) and every document with no smooth blend: their bounds are bit-identical.
  • ChainDragMemo holds per-chain suffix maxima, filled from the end only as far as a query needs. LayerExtent owns one per query, the drag frontier passes one through its loop, and an undo step's replay shares one across its commands. The replay clears it after any command except a deformer or colour edit.
  • No ABI change. clay.h gets a NOTE on clay_layer_node_influence_bound, docs/05 a section, and the scene-model "Influence bounds" requirement is modified. The change is archived in this PR (2026-09-23-bound-an-edit-by-the-blends-after-it).

What measuring refuted

  • The cull's chain envelope is the wrong resolution. Resolving the later terms with blend_total (the k-multiple fitted to what a cull may drop against fp16) still moved 110 band samples by up to 0.0123 on the fixture above. At full support, 0.
  • The clay_document_undo_bound reports a grab's whole node, so undoing a Move refills the node and its cost grows with the chain #639 head reach doesn't need the term. My first version also dilated the grab ball, and the oracle's refills rose 72% (plain) and 56% (rich). A link that is the identity outside its ball leaves the raw field bit-identical there, and every combine is pointwise, so only the clamp needed the wider bound. I reverted that part; refills now rise 2.5% and 2.8%.
  • The walk runs per node in loops. The walk covers the later siblings, so any loop over a chain's nodes paid it once per node. A drag frontier over 1,428 warps of a 10,000-item smooth layer went from 0.39 to 65 ms. Undoing a 2,134-warp Move over the same count went from 0.85 to 94.6 ms. With the memo these are 0.45 ms and 1.04–1.11 ms.

Oracle, 400 trials each, RV_RAWBOUND

raw-bound violations stale trials bricks refilled
plain, main 0 0 1,397,332
plain, this PR 0 0 1,432,408 (+2.5%)
rich, main 10 (seed 5128) 3 2,394,500
rich, this PR 0 3 2,461,088 (+2.8%)

The three rich stale trials (5111, 5128, 5229) are the brick build disagreeing with the raw field. They are identical on main and belong to #649, which this PR doesn't touch. RV_FORWARD shows 12 stale trials on main and on this branch alike.

Tests

Each new case fails on main and passes here:

  • test_node_reach_bound.cpp:
    • A smooth sibling after a node carries its edit past the node's own box, at the root and inside a group. On main, 857 band-clamped samples move outside the reach.
    • Only a later smooth combine widens the reach, by exactly its support.
    • A smooth sibling after the node's group drags it too.
    • An undo that changes a later sibling's blend between two commands re-reads the drag. This test also fails if the replay never clears its memo: -1.1 vs -2.0.
    • The memoized answer equals the unmemoized walk.
  • test_c_undo_bound_grab_support.cpp: a grab ball past the node's band, on a smooth sibling's surface, is undone and redone, then compared against a rebuild. Main leaves 4 stale bricks per direction.
  • test_c_frontier_resume.cpp (changed): the mirrored-ridge case now resumes the 4 corner bricks the unmirrored control refuses (16/0 against 12/4). The wider reach prepares their prefix seeds, and the bit-exact parity check against a fresh oracle still holds every frame. So split == plain becomes >= / <=, with the reason stated in the test.

Verification

  • ctest --preset cpu-only: 10/10 shards pass. check_test_shards: 2,938 cases.
  • tools/release_check.py --skip-slow: all rows pass, including bindings (imported pyclay), abi, openspec, layering, dialect, kernels and parity, except device. That row is the release-time iPad gate, which reports "engine changed since the gate ran at 704f2d4", as it does for main.
  • openspec validate --all --strict @1.12.0: 62 items pass. check_doc_latency: OK.
  • ABI stays at 0.120.0; no version lines move.

Not covered: a gated node after the edited one lerps the running value, and a lerp of two beyond-band values need not stay beyond band. The intersect surface delta already refuses that case (chain_carries_only_supports); this term doesn't model it. I left the ROADMAP archive count alone so it won't conflict with the three PRs landing alongside this one.

Closes #650

A node is the running value of its chain wherever it is the nearest
thing, so an edit changes that value far from the node's own box. A hard
union leaves the beyond-band difference alone; a smooth combine further
down reads the running value out to its support and carries it back into
the band. node_reach_bound dilated only by the enclosing groups, so the
node bound -- and the #639 undo narrowing clamped into it -- stopped short
of the sibling's fillet. Two r = 0.3 spheres, the second smooth at
k = 0.3, the first moved 0.1: band samples moved by up to 0.044 outside
the box plus the band, 25 stale bricks after mark_dirty_nodes. Seed 5128
of the undo-bound oracle is the same mechanism at one ulp (#650).

node_reach_bound now dilates, at each level before the group's support,
by the later siblings' cull_pad_terms resolved at full support
(CullPadTerms::support_total); the envelope the cull uses left 0.012.
A node followed only by hard combines, including every node appended
last, keeps its bound bit for bit.

The walk is over the later siblings, so loops paid it per node: a drag
frontier over 1,428 warps of a 10,000-item layer went 0.39 -> 65 ms and
undoing a 2,134-warp Move 0.85 -> 94.6 ms. ChainDragMemo holds per-chain
suffix maxima filled from the end; LayerExtent owns one per query, the
drag frontier threads one through its loop, and an undo replay shares one
across its commands, cleared after any command but a deformer or colour
edit (0.45 and 1.04 ms).

The mirrored-ridge frontier test now resumes the four corner bricks the
unmirrored control refuses (16/0 against 12/4): the wider reach prepares
their prefix seeds, and the bit-exact parity check still holds, so the
comparison is >= rather than ==.
The scene-model Influence bounds requirement now states the reach of
the smooth combines after a node, with three scenarios added. The
proposal carries the measurements and what building it found.
The MSVC job failed the surface-drag allocation gate at 6.0625 per item
against 6.0 (194 allocations over 32 items). ChainDragMemo added 13
allocations to every drag: an unordered_map node, and a suffix vector
regrown member by member across the chain. MSVC's unordered_map also
allocates when it is still empty, which is why only that job crossed the
line; libc++ passed at 5.75.

The memo now keeps its chains in a flat list, searched linearly -- a
query touches one or two chains, and an undo step that keeps the memo
across commands keeps it on one -- and reserves each chain's suffix to
its full length on first use. Answers are unchanged: same walk, same
terms, same indexing.

On libc++ the drag now makes 174 allocations, against 184 before this
commit and 171 on main.

The drag gate cannot hold this on every compiler, so a new gate counts
the memo on its own: a 400-member smooth-blended chain asked for every
suffix must cost at most 2 allocations. It fails on the previous memo at
12, on libc++, and requires a nonzero answer so a memo that walked
nothing cannot pass it.

Full unit suite: 2,939 cases, 17,771,052 assertions, 0 failed.
@leonardoaraujosantos
leonardoaraujosantos merged commit b1ce305 into main Sep 23, 2026
16 checks passed
SummerTree pushed a commit to SummerTree/ClayCore that referenced this pull request Oct 7, 2026
Compared against v0.120.0. What moves under a caller: an unconfined
infinite grid reports an unbounded tape.bounds and meshing refuses it
without a region (CyberdyneCorp#645), bounds narrow per operator (CyberdyneCorp#637), the undo
bound reports a grab rather than its node (CyberdyneCorp#648), the node influence
bound widens behind a smooth sibling (CyberdyneCorp#653), the brick build keeps a
grab the cull cannot judge (CyberdyneCorp#652), voxel sculpt-layer operations and
creation are undo steps (CyberdyneCorp#647, CyberdyneCorp#651), and pyclay's multires Layer
brushes gain layer_height (CyberdyneCorp#636, CyberdyneCorp#646).

The ABI section is diffed against the tag: zero symbols added or
removed, no '-' line inside a typedef struct. The device gate result
is a marked placeholder until the iOS 27.0 same-OS run lands.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A grab inside a blended group moves the raw field by an ulp outside the node's influence bound (seed 5128)

1 participant