Skip to content

feat(hexmap): uniform object spreader (L7) - #130

Merged
bal7hazar merged 2 commits into
mainfrom
feat/hexmap-spreader
Sep 27, 2026
Merged

bal7hazar merged 2 commits into
mainfrom
feat/hexmap-spreader

Conversation

@bal7hazar

@bal7hazar bal7hazar commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Lot L7 of origami_hexmap: Spreader::generate(grid, width, height, count, seed) returns a bitmap with exactly count bits set, all on walkable tiles, chosen uniformly and deterministically for a seed. It works on any grid, open edge tiles included.

The second commit follows up audit A2. Finding 1: the first version's rejection fix-up had no iteration bound. With 2 walkable tiles on a 10x25 board it cost 1.33M gas on average and 9.3M at worst. It is replaced by a bounded algorithm.

Algorithm: radix select on random keys.

  • Complement trick: it chooses k = min(count, walkable - count) tiles.
  • Each level splits the current class with one Poseidon word: one AND and one popcount. About log2(n / 8) levels, whatever the seed.
  • A class of at most 8 tiles is finished in one step: a uniform subset drawn from a 511-entry table of masks, deposited in at most 8 steps.
  • Small counts are picked one by one when a measured cost rule says it is cheaper:
    • at most 2 rejection trials when the set covers at least 30 % of the board,
    • otherwise a loop-free select from SWAR byte counts, kept up to date across removals,
    • or a walk on sets of at most 8 tiles.
  • Every loop has a stated worst-case bound.
  • Boards of at most 128 bits run on one u128 limb.

Uniformity. The algorithm itself is exact: permutation-invariant. The only deviations come from the random sources, and they are quantified in the doc of generate with tests checking the bounds (audit findings 2 and 3):

  • Key words: below 2^-51 in total.
  • Rng draws: 2^-24 per 128-bit pool, one pool per call in practice.

Gas (per call, over 256 seeds)

Case This PR: mean / max First version: mean / max
CAVE_17X14, 20 objects 191k / 241k 177k / 465k
CAVE_17X14, 60 objects 199k / 255k 201k / 536k
MAZE_17X14, 20 objects 181k / 251k 187k / 575k
30 % density 17x14, 20 objects 173k / 230k 198k / 533k
2 tiles on 10x25, 1 object 64k / 68k 1331k / 9265k
5 tiles on 17x14, 2 objects 76k / 83k 1134k / 3969k

The targets were a max under 300k for 20 objects on CAVE_17X14, and no case above 3x its mean. Both are met: no row goes above 1.84x its mean.

The cost of bounding: small counts on dense boards are dearer on average. Count 1 costs +14k to +30k, and count 5 on EMPTY/CAVE 17x14 costs about +65k.

The benchmarks are snforge fuzz tests, so CI checks the max over all seeds against the budget. The full tables (every fixture and count, mean and max), the losers, the microbenchmarks and the budgets are in the GAS.md L7 section.

Tests

26 unit tests:

  • Exact grids for fixed seeds, with the printouts in comments.
  • Invariants and extreme dimensions.
  • Panics with their exact messages.
  • Select, walk and byte counts agree after every removal.
  • The subset table is complete and consistent.
  • Bias bounds.
  • 9 statistical tests (180 to 1000 seeds each). They cover u128/u256 radix, the complement, dense and sparse picks, the subset table, and the sparse grids from the audit. I checked that they catch a bias: a mutation of the table draw fails 3 of them.

🤖 Generated with Claude Code

bal7hazar and others added 2 commits September 27, 2026 14:42
Spreader::generate picks exactly `count` walkable tiles, uniformly and
deterministically for a seed, on any grid (open edges included).

Hash-and-mask with an exact fix-up: two Bernoulli mask rounds built from
Poseidon words (density in 1/16, then in 1/32 on the deficit), rejection
sampling for the last tiles, uniform trim on overshoot, complement trick
above half of the walkable tiles, and a u128 path on boards of at most
128 bits. 20 objects on CAVE_17X14 cost 170k (target 300k); an open 19x13
board with 35 objects costs 181k against 13.65M for origami_map.

Selection sampling, Floyd rank selection, plain rejection and a one-round
mask are kept test-only in bench_spreader.cairo with their budgets; the
measurements are in GAS.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Audit A2 finding 1: the rejection fix-up of the first spreader had no
iteration bound (2 walkable tiles on a 10x25 board: 1.33M gas on average,
9.3M at worst over 256 seeds).

Spreader::generate now runs a radix select on random keys: one Poseidon
word per level splits the current class with one AND and one popcount,
and a class of at most 8 tiles is finished by a uniform subset from a
511-entry table deposited in at most 8 steps. Small counts are picked
one by one when a measured cost rule says it is cheaper: at most 2
rejection trials, then a loop-free select from SWAR byte counts kept up
to date across removals, or a walk on sets of at most 8 tiles. Every
loop has a stated bound.

Over 256 seeds, 20 objects on CAVE_17X14 cost 191k mean, 241k max (was
177k / 465k), the sparse grids at most 83k, and no case exceeds 1.84x
its mean. Findings 2 and 3 (random source biases) are quantified in the
doc of generate and in GAS.md, with tests checking the bounds: below
2^-51 for the key words, 2^-24 per Rng pool.

Benchmarks are fuzz tests reporting mean and max; statistical tests
cover the sparse grids and every path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bal7hazar
bal7hazar merged commit 9b8c155 into main Sep 27, 2026
9 checks passed
@bal7hazar
bal7hazar deleted the feat/hexmap-spreader branch September 27, 2026 17:20
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.

1 participant