Skip to content

Carry a seed across a region edit only if no append since reached it - #668

Merged
leonardoaraujosantos merged 2 commits into
mainfrom
fix/665-intersect-append-move-stale
Sep 30, 2026
Merged

leonardoaraujosantos merged 2 commits into
mainfrom
fix/665-intersect-append-move-stale

Conversation

@leonardoaraujosantos

@leonardoaraujosantos leonardoaraujosantos commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #665

Why

If you append an Op::Intersect item and then move it with a uniform transform before any refill, the brick cache keeps the un-intersected layer. Re-dirtying the item's whole bound and refilling does not repair it. The document is correct, only the bricks are stale.

Cause (confirmed by instrumenting, not just by reading the code)

The issue suspected the frontier path at the appended ordinal. The actual cause is in touch_region_locked, which all three region fronts share. An append re-stamps no seed, because the append log is what carries a seed across appends. The next region edit then forgot the log and moved every seed its box missed to the new revision. That included seeds still sitting at the revision before the append. A probe showed the stale bricks going from rev 3 to rev 5 with the append at rev 4. From then on the rev == now shortcut returned the pre-append field on every refill. The same happens with no intersect at all: append a local item, then edit something far away.

What lands

  • touch_appended records each append's command_influence_bound, which apply_edit already computes, next to the log. An infinite bound is widened to the whole of space when it is logged.

  • New carried_across_appends: a region edit moves an unreached, clean seed forward only if one of two things holds:

    • the seed was current just before the edit, or
    • the log covers every revision the seed is behind by, and no append in that span reaches the seed's cull region. The region edit builds a tree of range unions over the log once (ReachTree, O(n)), so each seed costs about log(n) box tests. Interior unions only prune and leaves decide, so the answer is exactly a scan's.

    Any other seed stays at its old revision, and its next refill walks it in full.

  • All three region fronts read the log before they forget it.

  • No change to the ABI, the file format or the version.

Measured (issue fixture: sphere r1, cylinder 0.25/1.6 moved to y 0.9, dim 8, voxel 0.02, band 3)

variant main this branch
add at origin, uniform move, refill 1171 surface bricks (the sphere) 192, bit-identical to the reference
the same, refilled twice 1171 192
local append, then move a different item far away, refill the layer 1123 vs 1116 1116, bit-identical
control rows from the issue (built in place, non-uniform move, refill between, two moves, append another item) correct correct

Cost of the check (first far move after N cap dabs, each refilled on its own; filled unit sphere, dim 8, voxel 0.02)

dabs main (unsound) per-seed log scan (first revision of this PR) range-union tree
200 0.21 ms 3.98 ms 0.52 ms
1000 0.21 ms 14.5 ms 0.89 ms
3000 0.30 ms 42.9 ms 1.27 ms

The scan was seeds x appends under cache_mutex_ and grew with the stroke; review replaced it with the tree.

Tests

New file tests/unit/test_c_intersect_append_move.cpp with 11 cases. It covers the issue's table (stale variants plus the correct controls) and adds one case showing that a seed no append reached is still resumed, checked through clay_document_resume_stats. Two more pin the log offset across several appends: a later append that reaches a brick holds its seed back, and an append the brick was refilled after does not.

Mutation check:

  • main's behaviour (always carry the seed forward) fails 4 cases.
  • dropping every seed that is behind fails the resume case: 0 resumed, 144 refilled.
  • carrying seeds forward without the per-append scan fails 4 cases.
  • reading the log from its start fails 2 cases; skipping one entry past the seed's revision fails 4; testing only the first entry fails the two offset cases.

Verification

  • Ran the new file against unfixed main: 4 of the original 9 cases fail (1171 vs 192, 1123 vs 1116, and the resume case's bit comparison).
  • ctest --preset cpu-only with tests and pyclay on: 11/11 passed.
  • openspec validate --all --strict (1.12.0): 65/65 passed.
  • GCC 16 -O2 -Wall -Wextra -Wpedantic -Wshadow -Werror -fno-rtti (full compile, not -fsyntax-only, so the flow-based warnings run) on clay_c.cpp and on the new test: clean.
  • Cognitive complexity (clang-tidy): touch_region_locked 13, carried_across_appends 4, ReachTree::any_in 4, touch_appended 3.
  • tools/release_check.py --skip-slow on the reviewed head: every row passes except device, which is red only because the engine changed since the last recorded device gate (7f6cf38); main carries other engine changes that also expire it. That is a release-time gate.
  • Not run: the device gate and check_bench.

Docs

  • docs/05-claycore-library.md: the seed carry-forward paragraph now states the new rule.
  • New OpenSpec change refill-a-dirtied-brick-against-the-current-document, with an ADDED brick-cache requirement.

A region invalidation advanced every seed its box missed to the new
revision, including seeds still sitting at the revision before an append
that had not been refilled yet. An append re-stamps no seed (the append log
carries it), and the region edit forgets that log, so those seeds became
"current" while holding the pre-append field, and the refill's rev == now
shortcut returned it on every later refill.

Appending an intersect and moving it before a refill hit this: the append
changes the whole layer, the #471 swept move box does not cover it, and the
cache kept the un-intersected sphere (1171 surface bricks against 192) even
after the whole bound was re-dirtied. A local append followed by an edit
elsewhere does the same (1123 against 1116).

The log now records each append's command_influence_bound. A lagging seed is
advanced only when the log covers every revision it lags by and no append in
that span reaches its cull region, with the log's union as an O(1) early
out. All three region fronts read the log before forgetting it. Seeds no
append reached are still carried and resumed.

Fixes #665
The #665 carry check scanned the append log from each lagging seed's
revision, so the first region edit after a stroke cost seeds x appends
under cache_mutex_. Measured on a filled unit sphere (dim 8, voxel 0.02)
with N cap dabs each refilled on its own, the first far move took 3.98 /
14.5 / 42.9 ms at 200 / 1000 / 3000 dabs, against 0.21-0.30 ms on main,
and kept growing with the stroke.

The region edit now builds a binary tree of unions over the log's index
ranges once (ReachTree, O(n)) and each seed asks whether any reach from
its own index on meets its cull region: 0.52 / 0.89 / 1.27 ms. Interior
unions only prune and leaves decide, so the answer is exactly the scan's.
Infinite reaches are widened to the whole of space when logged so no
union can prune them; the separate running union is gone.

Two cases pin the log offset across several appends: a later append that
reaches a brick holds its seed back, and an append the brick was refilled
after does not. Reading the log from its start, skipping one entry, or
testing only the first entry each fail at least one case.
@leonardoaraujosantos
leonardoaraujosantos merged commit f3efaf6 into main Sep 30, 2026
16 checks passed
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.

Appending an intersect item and moving it before a refill leaves the un-intersected layer in the brick cache

1 participant