Fill: honour the half-spacing edge rule when packing plops #76

Merged
steve merged 7 commits from fix/fill-edge-spacing into main 2026-07-21 18:28:18 +00:00
Owner

Closes #75.

Filling a bed with garlic left the outer row too far from the edge — and the corner plop, as reported, "doesn't have to be a full diameter from the edge when you plant". Correct on both counts.

Two defects, one cause

hexCenters anchored the lattice one radius inside the region's min corner, then kept any candidate whose centre landed in the region. On a 122 × 244 cm (4 × 8 ft) bed of garlic — 15 cm spacing, so plop radius 22.5, pitch 45:

BEFORE — 15 plops
   row 0: 3 plops | W gap   +0.0 cm | E gap  -13.0 cm
   row 1: 2 plops | W gap  +22.5 cm | E gap   +9.5 cm
   row 2: 3 plops | W gap   +0.0 cm | E gap  -13.0 cm
   ** worst overhang outside the bed: 13.0 cm
  1. Staggered rows started a full pitch in. Offset by radius, so the first centre landed at MinX + 2·radius — a bare strip a whole plop wide down the west side of every other row. This is the "full diameter" in the report.
  2. The slack all piled up on the far edge, where plops hung 13 cm outside the bed. Nothing clips them, so they drew over the bed outline.

The rule

Spacing describes two plants competing for the same soil, light and water. A bed edge is not a competitor, so the outer row owes it half the spacing — the half it would otherwise share with a neighbour.

That is the arithmetic inside every square-foot-gardening chart: 4 per square is 6" apart and 3" from the square's edge; 9 per square is 4" apart and 2" from the edge. Garlic at 9 per square goes in 2" from the frame, not 6".

The wrinkle specific to pansy: a plop is a clump, not a plant. defaultPlopRadius is 1.5 × spacing, so a plop is three spacings across and its plants sit out to its rim. Keeping the whole circle inside the bed insets the outer row by a full 1.5 spacings — three times what the rule allows. So the clump may cross the edge by up to half a spacing, which puts its outermost plants exactly the half-spacing from the edge the rule asks for. Capped there and nowhere near the full radius: a clump mostly outside the bed is a drawing of plants in the path.

After

AFTER — 15 plops
   row 0: 3 plops | W gap   -6.5 cm | E gap   -6.5 cm
   row 1: 2 plops | W gap  +16.0 cm | E gap  +16.0 cm
   row 2: 3 plops | W gap   -6.5 cm | E gap   -6.5 cm
   ** worst overhang outside the bed: 6.5 cm

Same density, now symmetric, and the overhang is deliberate and inside the 7.5 cm half-spacing budget rather than an accidental 13 cm on one side only.

The stagger falls out of the centring for free: an offset row holds one fewer plop, and centring that run puts it exactly half a pitch off its neighbours — so there is no separate stagger term to get wrong.

Notes for review

  • TestFillRegionDeterministicPacking went from 4 plops to 3 in a 60 × 60 bed. The fourth was centred on the east edge with half of it outside, far past the half-spacing budget. That is the fix working. The test now also asserts nothing overhangs a bed its lattice fits inside.
  • New TestHexCentersEdgeInset pins the rule directly: no plop overhangs by more than half a spacing, opposite margins match, and the staggered row is offset by half a pitch rather than a whole one. It fails on the old code.
  • TestHexCentersTinyRegion covers a region too small for the inset — one plop in the middle beats refusing to plant.
  • Region.contains is now dead and removed; the lattice is bounded by construction, so the loop is count-bounded instead of condition-bounded.
  • fitAxis's epsilon is deliberate: a 60 cm span at a 30 cm step must give 2 positions, not 1 because the division landed on 0.9999999.

Docs updated in the same commit: a decision line in DESIGN.md, and a CLAUDE.md convention noting that a plop is a clump — reasoning about fills as if one plop were one plant is exactly how this bug happened.

GOWORK=off go test ./... green; gofmt -l internal/ clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ

Closes #75. Filling a bed with garlic left the outer row too far from the edge — and the corner plop, as reported, "doesn't have to be a full diameter from the edge when you plant". Correct on both counts. ## Two defects, one cause `hexCenters` anchored the lattice one radius inside the region's **min corner**, then kept any candidate whose *centre* landed in the region. On a 122 × 244 cm (4 × 8 ft) bed of garlic — 15 cm spacing, so plop radius 22.5, pitch 45: ``` BEFORE — 15 plops row 0: 3 plops | W gap +0.0 cm | E gap -13.0 cm row 1: 2 plops | W gap +22.5 cm | E gap +9.5 cm row 2: 3 plops | W gap +0.0 cm | E gap -13.0 cm ** worst overhang outside the bed: 13.0 cm ``` 1. **Staggered rows started a full pitch in.** Offset by `radius`, so the first centre landed at `MinX + 2·radius` — a bare strip a whole plop wide down the west side of every other row. This is the "full diameter" in the report. 2. **The slack all piled up on the far edge**, where plops hung 13 cm outside the bed. Nothing clips them, so they drew over the bed outline. ## The rule Spacing describes two plants competing for the same soil, light and water. A bed edge is not a competitor, so the outer row owes it **half the spacing** — the half it would otherwise share with a neighbour. That is the arithmetic inside every square-foot-gardening chart: 4 per square is 6" apart and **3" from the square's edge**; 9 per square is 4" apart and 2" from the edge. [Garlic at 9 per square](https://squarefootgardening.org/2020/11/square-foot-garlic/) goes in 2" from the frame, not 6". The wrinkle specific to pansy: **a plop is a clump, not a plant.** `defaultPlopRadius` is `1.5 × spacing`, so a plop is three spacings across and its plants sit out to its rim. Keeping the whole circle inside the bed insets the outer row by a full 1.5 spacings — three times what the rule allows. So the clump may cross the edge by up to half a spacing, which puts its *outermost plants* exactly the half-spacing from the edge the rule asks for. Capped there and nowhere near the full radius: a clump mostly outside the bed is a drawing of plants in the path. ## After ``` AFTER — 15 plops row 0: 3 plops | W gap -6.5 cm | E gap -6.5 cm row 1: 2 plops | W gap +16.0 cm | E gap +16.0 cm row 2: 3 plops | W gap -6.5 cm | E gap -6.5 cm ** worst overhang outside the bed: 6.5 cm ``` Same density, now symmetric, and the overhang is deliberate and inside the 7.5 cm half-spacing budget rather than an accidental 13 cm on one side only. The stagger falls out of the centring for free: an offset row holds one fewer plop, and centring *that* run puts it exactly half a pitch off its neighbours — so there is no separate stagger term to get wrong. ## Notes for review - **`TestFillRegionDeterministicPacking` went from 4 plops to 3** in a 60 × 60 bed. The fourth was centred *on* the east edge with half of it outside, far past the half-spacing budget. That is the fix working. The test now also asserts nothing overhangs a bed its lattice fits inside. - **New `TestHexCentersEdgeInset`** pins the rule directly: no plop overhangs by more than half a spacing, opposite margins match, and the staggered row is offset by half a pitch rather than a whole one. It fails on the old code. - **`TestHexCentersTinyRegion`** covers a region too small for the inset — one plop in the middle beats refusing to plant. - `Region.contains` is now dead and removed; the lattice is bounded by construction, so the loop is count-bounded instead of condition-bounded. - `fitAxis`'s epsilon is deliberate: a 60 cm span at a 30 cm step must give 2 positions, not 1 because the division landed on 0.9999999. Docs updated in the same commit: a decision line in DESIGN.md, and a CLAUDE.md convention noting that a plop is a clump — reasoning about fills as if one plop were one plant is exactly how this bug happened. `GOWORK=off go test ./...` green; `gofmt -l internal/` clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
steve added 1 commit 2026-07-21 14:11:37 +00:00
Fill: honour the half-spacing edge rule when packing plops (#75)
Build image / build-and-push (push) Successful in 7s
Gadfly review (reusable) / review (pull_request) Successful in 10m7s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m8s
45da4b15e2
Filling a bed left the outer row too far from the edge, and staggered rows
worse still. Two defects, both from anchoring the lattice at the region's min
corner:

- Odd rows offset by `radius` started at `MinX + 2·radius`, leaving a bare
  strip a whole plop wide down one side of every other row.
- All the leftover slack piled up on the far edge, where plops hung 13cm
  outside the bed on a 4×8ft garlic bed. Nothing clips them, so they drew
  over the bed outline.

Spacing is a constraint between neighbouring plants competing for the same
soil, light and water. A bed edge is not a competitor, so the outer row owes
it half the spacing — the arithmetic inside every square-foot-gardening chart
(4/square = 6" apart, 3" from the square's edge).

The wrinkle: a plop is a CLUMP, not a plant. defaultPlopRadius is 1.5×spacing,
so keeping the whole circle inside the bed insets the outer row by 1.5
spacings, three times what the rule allows. So centre the lattice and set the
minimum centre-inset to `radius - spacing/2`: the clump may cross the edge by
up to half a spacing, putting its outermost plants exactly the half-spacing
from the edge the rule asks for. Capped there — a clump mostly outside the bed
would be a drawing of plants in the path.

Same bed, same 15 plops, now symmetric with a deliberate 6.5cm overhang inside
the 7.5cm budget instead of an accidental 13cm on one side only. The stagger
falls out of the centring for free: an offset row holds one fewer plop, and
centring that run puts it exactly half a pitch off its neighbours.

TestFillRegionDeterministicPacking expected 4 plops in a 60×60 bed; the fourth
was centred ON the east edge with half of it outside, well past the budget.
It is 3 now — the fix working, not a regression in it.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ

🪰 Gadfly — live review status

5/5 reviewers finished · updated 2026-07-21 18:14:23Z

claude-code/sonnet · claude-code — done

  • security — No material issues found
  • correctness — No material issues found
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — No material issues found

glm-5.2:cloud · ollama-cloud — done

  • security — No material issues found
  • correctness — No material issues found
  • maintainability — No material issues found
  • performance — No material issues found
  • error-handling — No material issues found

kimi-k2.6:cloud · ollama-cloud — done

  • security — No material issues found
  • correctness — No material issues found
  • maintainability — No material issues found
  • performance — No material issues found
  • error-handling — No material issues found

opencode/glm-5.2:cloud · opencode — done

  • security — No material issues found
  • correctness — No material issues found
  • maintainability — No material issues found
  • performance — No material issues found
  • error-handling — No material issues found

opencode/kimi-k2.6:cloud · opencode — done

  • security — No material issues found
  • correctness — No material issues found
  • maintainability — No material issues found
  • performance — No material issues found
  • ⚠️ error-handling — could not complete

Live status board. Findings are posted in each model's own comment. Advisory only — does not block merge.

<!-- gadfly-status-board --> ## 🪰 Gadfly — live review status 5/5 reviewers finished · updated 2026-07-21 18:14:23Z #### `claude-code/sonnet` · claude-code — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — No material issues found #### `glm-5.2:cloud` · ollama-cloud — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **maintainability** — No material issues found - ✅ **performance** — No material issues found - ✅ **error-handling** — No material issues found #### `kimi-k2.6:cloud` · ollama-cloud — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **maintainability** — No material issues found - ✅ **performance** — No material issues found - ✅ **error-handling** — No material issues found #### `opencode/glm-5.2:cloud` · opencode — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **maintainability** — No material issues found - ✅ **performance** — No material issues found - ✅ **error-handling** — No material issues found #### `opencode/kimi-k2.6:cloud` · opencode — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **maintainability** — No material issues found - ✅ **performance** — No material issues found - ⚠️ **error-handling** — could not complete <sub>Live status board. Findings are posted in each model's own comment. Advisory only — does not block merge.</sub>
steve added 1 commit 2026-07-21 14:13:34 +00:00
Fill: plant nothing for a region that misses the object entirely
Build image / build-and-push (push) Successful in 8s
3af0d08779
clampTo INVERTS a region lying wholly outside the object — Max clamps below
Min — rather than emptying it. The old loop-until-past-MaxX form handled that
for free by never entering the loop. Counting positions up front does not:
a region 500cm east of a bed with ±50cm local bounds produced 4 plops at
x=275, a couple of metres off the bed.

Caught by removing the guard and watching the new test fail, not by assuming
it would.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ

🪰 Gadfly review — consensus across 5 models

Verdict: Minor issues · 2 findings (0 with multi-model agreement)

2 single-model findings (lower confidence)
Finding Where Model Lens
🟡 Half-spacing rule explanation duplicated near-verbatim across CLAUDE.md, DESIGN.md, and two doc comments internal/service/ops.go:192 claude-code/sonnet maintainability
fillLoaded doc comment omits the new non-finite-region validation it performs internal/service/ops.go:116 claude-code/sonnet maintainability
Per-model detail
claude-code/sonnet (claude-code) — Minor issues

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

VERDICT: No material issues found

I reviewed this diff strictly through the security lens (authn/authz, injection, SSRF, deserialization, secrets, input validation, unsafe untrusted-data handling, insecure defaults), verifying against the checked-out code rather than the diff alone.

Findings from that verification:

  • Authorization is unchanged and correct. FillRegion still calls s.objectForRole(ctx, actorID, objectID, roleEditor) before doing anything (internal/service/ops.go:109), and visiblePlant still gates plant access. This diff only touches geometry math inside fillLoaded/hexCenters, not the ACL path.
  • The new isFinite check closes a real gap, not opens one. internal/service/ops.go:145-151 rejects NaN/±Inf region bounds before they reach clampTo/fitAxis. I confirmed isFinite (internal/service/objects.go:382) is the same helper already used elsewhere (e.g. spacing validation), so this is consistent, not a new/weaker check.
  • No DoS regression from the new counting math. I traced the bound chain: radius is floored at 15 cm by defaultPlopRadius even under a caller-supplied spacingOverride (itself clamped to [0.1, 10000] at ops.go:130), and region is clamped to the object's real bounds via clampTo, which are validated at creation to [1, 10000] cm (validDimensionCM, gardens.go:286-288). So worst-case rows/cols in fitAxis are on the order of a few hundred — total is computed from a closed-form product (ops.go:~236) and checked against maxFillPlops before the pts slice is ever allocated, so there's no path to unbounded allocation or int overflow via a malicious region/spacing combination.
  • spacingOverride is still range- and finiteness-checked exactly as before (ops.go:129-133) — this diff doesn't touch that validation, just consumes spacing in the new inset calculation.
  • No SQL, shell, template, or deserialization surfaces are touched by this diff — it's pure in-memory float geometry in the service layer. Doc-only changes (CLAUDE.md, DESIGN.md) contain no secrets or sensitive content.

Nothing here introduces or weakens an authz check, injects untrusted data into a dangerous sink, or degrades the existing resource-exhaustion guard — if anything the new finiteness check and pre-allocation counting improve robustness against malformed/adversarial input.

🎯 Correctness — No material issues found

VERDICT: No material issues found

I independently re-derived the geometry from first principles rather than trusting the PR's stated numbers, and it checks out:

  • Half-spacing overhang budget: inset := max(0, radius - max(0, spacing)/2) (internal/service/ops.go:240). Re-deriving: overhang = radius − distance-to-edge, and we want max overhang ≤ spacing/2, so minimum allowed distance-to-edge = radius − spacing/2. That's exactly inset. Cross-checked against the square-foot-gardening convention cited in the PR (4/square → 12"/2 cols = 6" apart, 3" from edge = half of 6"; 9/square → 12"/3 = 4" apart, 2" from edge = half of 4") — the "half spacing" rule is dimensionally correct, not an invented constant.
  • fitAxis count/centering (ops.go:288): hand-computed n and start for all three scenarios in the diff (4ft×8ft garlic bed radius 22.5/spacing 15/pitch 45; the 90×90 "exact fit" case; the 60×60 bed at radius 15/spacing 10) and got exactly the values asserted in TestHexCentersEdgeInset and TestFillRegionDeterministicPacking (3 cols at x=-45,-22.5,... for the garlic case; 3 total plops for the 60×60 bed). The stagger-by-half-pitch and symmetric margins both fall out correctly.
  • Total-vs-built consistency: total := (rows+1)/2*cols + rows/2*staggered (ops.go:252) matches what the build loop actually emits in every case I traced, including the cols == 1 degenerate path (where staggered silently equals cols, so parity no longer matters — verified this stays self-consistent with the loop's own cols>1 guard at ops.go:264).
  • Guard ordering: the new NaN/Inf check (ops.go:147) runs before clampTo/hexCenters, and correctly catches what the PR says it catches — region.empty() can't detect NaN (comparisons are false both ways) and Region.clampTo would otherwise pass Inf straight through. Verified isFinite (objects.go:382) and that plant.SpacingCM/spacingOverride are separately validated finite elsewhere (plants.go:319, ops.go:130), so radius/spacing feeding hexCenters are always sane.
  • Traced TestFillRegionOutsideObjectPlantsNothing: clampTo inverting the region (MinX=500 > MaxX=50) is correctly caught by Region.empty() before any lattice math runs.

I could not find a case where the counted total diverges from the built lattice, where the inset allows overhang beyond spacing/2, or where a degenerate (zero-width, tiny, or single-column/row) region produces a wrong point. This is a well-verified change.

🧹 Code cleanliness & maintainability — Minor issues

Both findings hold up against the actual code. Here's the verified review:

VERDICT: Minor issues

  • internal/service/ops.go:100-107 (FillRegion) and ops.go:192-223 (hexCenters) doc comments restate the same "half-spacing rule" explanation that's also written out nearly verbatim in CLAUDE.md:88-93 and DESIGN.md:10 — the phrasing ("competing for the same soil, light and water", "4 per square is 6" apart... 3" from the edge") is copy-pasted across all four locations, not just topically related. Four separate copies of the same square-foot-gardening arithmetic means a future correction to the inset formula has four places to update, which cuts against CLAUDE.md's own stated principle to keep docs in sync with the same commit. A single source of truth (e.g., keep the long version in hexCenters and have CLAUDE.md/DESIGN.md link to it by name) would reduce the sync burden.
  • internal/service/ops.go:116-119fillLoaded's doc comment ("clamps the region..., refuses fills over maxFillPlops, and inserts the whole batch...") wasn't updated to mention the new non-finite-region rejection added in this diff (ops.go:140-150), so the comment undercounts what the function guards against.

Everything else here is clean: Region.contains was correctly deleted as genuinely dead code once the lattice became count-bounded, hexCenters's new signature is updated consistently at both call sites (ops.go:153, ops_test.go:99,176 — grepped, no stale callers), fitAxis is a well-named, single-purpose extraction, and the new tests follow the existing file's structure/helpers rather than reinventing them.

Performance — No material issues found

VERDICT: No material issues found

Reviewed through the performance lens only (efficiency regressions, hot-loop work, allocations, unbounded growth).

What the diff actually changes in hexCenters/fitAxis is, if anything, a performance improvement over the prior code:

  • The old hexCenters built the entire lattice unconditionally, then fillLoaded compared len(centers) > maxFillPlops — so an oversized fill request paid for allocating and populating the whole (rejected) slice before being refused. The new version (internal/service/ops.go:242-255) computes rows, cols, and total analytically via fitAxis (closed-form arithmetic, not iteration) and only allocates pts := make([]localPoint, 0, total) (internal/service/ops.go:257) after confirming total <= limit. Verified fitAxis (internal/service/ops.go:288-298) is O(1) — a math.Floor and a division, no loop — so the up-front count check adds negligible cost regardless of how large rows*cols would have been.
  • The build loop itself (internal/service/ops.go:258-270) is a straightforward double loop bounded by rows/cols, which are themselves bounded by the total > limit (maxFillPlops = 5000) check before the loop runs — same asymptotic shape as the old for y ... for x ... scan, just centered instead of corner-anchored. No new quadratic behavior introduced.
  • pts is pre-sized with the exact capacity (total), so no incremental slice regrowth during append, an improvement over an unsized var pts []localPoint in the old version.
  • The new finite-check on region bounds (internal/service/ops.go:147-150) is four isFinite comparisons — O(1), no measurable cost.

I checked fillLoaded's existing-plop loop (internal/service/ops.go:158-171, calling coveredByExisting at internal/service/ops.go:302-309) — this is O(n²) over up to maxFillPlops candidates since existing grows by one on every accepted candidate. That's unchanged by this diff (no +/- markers touch it in the provided diff, and confirmed by reading the current file), so it's pre-existing behavior outside the scope of this PR's changes, not a regression introduced here.

No N+1 queries, blocking calls, or unbounded growth introduced by this diff; the DB write is still a single batched CreatePlantings call, unmodified.

🧯 Error handling & edge cases — No material issues found

VERDICT: No material issues found

I read through internal/service/ops.go end-to-end (the diff's only production code change) and traced every guard added/changed in this PR:

  • NaN/Inf on the region (ops.go:147-150): all four bounds are checked with isFinite before clampTo/hexCenters ever see them. Confirmed isFinite (objects.go:382) correctly excludes both NaN and ±Inf, and confirmed the new test TestFillRegionRejectsNonFiniteRegion exercises both a NaN bound and a +Inf bound and asserts no non-finite coordinates are ever persisted.
  • Empty/inverted region from clampTo (ops.go:47-49, 231-233): clampTo expresses "no overlap" by inverting Min/Max, and hexCenters now checks r.empty() before calling fitAxis, avoiding a negative-length span. Verified fitAxis is only ever called with length >= 0 as a result. Confirmed via TestFillRegionOutsideObjectPlantsNothing.
  • Degenerate/tiny region (fitAxis, ops.go:288-297): when length < 2*inset, returns (1, length/2) rather than a negative/zero position count — always n >= 1, so cols/rows in hexCenters can never be 0, so staggered is never negative. Traced this through the arithmetic for total in several cases (rows=1, cols=1) and it stays consistent with what the build loop actually produces.
  • Count-before-build overflow/DoS guard (ops.go:252-255): total is computed from closed-form row/col counts before any allocation, and checked against limit before make([]localPoint, 0, total). I checked the practical bounds that feed this (maxPlantSpacingCM = 10,000 cm, validDimensionCM capping object dimensions, defaultPlopRadius floor of 15 cm) and confirmed rows*cols stays in the low hundreds of thousands at worst — nowhere near int overflow, so the "total goes negative and slips past the > limit check into a negative-capacity make panic" scenario I initially suspected isn't reachable.
  • Empty batch behavior: when a fill produces zero plops (region entirely outside the object, or fully covered by existing plops), CreatePlantings no-ops on an empty slice (internal/store/plantings.go:241-243) and record() early-returns on len(changes) == 0 (internal/service/revisions.go:194-196), so there's no spurious "Planted 0 X" history entry — verified this is pre-existing, unmodified behavior that still holds correctly with the new empty-region path.

The tests added (TestHexCentersEdgeInset, TestHexCentersTinyRegion, TestFillRegionRejectsNonFiniteRegion, TestFillRegionOutsideObjectPlantsNothing) directly target these edge cases and match what the code does.

I didn't find an ignored error, missing cleanup, reachable panic, or unhandled boundary condition introduced by this diff. This is a solidly-defended change from the error-handling/edge-case angle.

glm-5.2:cloud (ollama-cloud) — No material issues found

Verdict: No material issues found — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

VERDICT: No material issues found

Through the security lens (authn/authz, injection, input validation, untrusted-data handling, insecure defaults) this change is clean:

  • Authz preserved. FillRegion and FillNamedRegion both still gate on objectForRole(..., roleEditor) (verified at internal/service/ops.go:109 and :315), so the geometric rewrite changes no privilege boundary. A viewer still gets ErrForbidden (existing test at ops_test.go:364).
  • Caller-supplied region is bounded by construction. region.clampTo(o.WidthCM/2, o.HeightCM/2) (ops.go:152) intersects the arbitrary-float region against the loaded object's local bounds, so a caller cannot site plops outside the object it was authorized to edit. The new empty() guard plus fitAxis's tiny-region branch mean a region missing the object plants nothing rather than placing a plop off-bed — confirmed by TestFillRegionOutsideObjectPlantsNothing.
  • Non-finite input now rejected explicitly. The added check at ops.go:147-150 turns NaN/±Inf regions into ErrInvalidInput before they reach the store. Previously NaN could surface as an opaque NOT-NULL constraint error and +Inf as a silent zero-plop success; both are input-validation holes closed here. isFinite is defined at internal/service/objects.go:382.
  • No tainted-data path to SQL/templates/commands. The only values persisted are c.x, c.y, radius, all derived from validated, finite inputs (radius is checked finite/positive at ops.go:136; spacing from a validated plant row or a bounds-checked override at ops.go:130). No string interpolation into queries; writes go through s.store.CreatePlantings.
  • No overflow/injection in fitAxis. With radius>0 and finite region bounds, step is positive and the int(math.Floor(...)) conversion operates on a finite value; total > maxFillPlops (ops.go:253) caps any pathological region before allocation, so a hostile huge-but-finite region can't drive resource exhaustion here.

No security findings to report.

🎯 Correctness — No material issues found

Verdict: No material issues found

I re-derived the geometry from first principles for all the load-bearing cases, and it holds up.

  • 60×60 bed, spacing 10 (radius 15, pitch 30, rowH ≈25.98, inset 10): fitAxis gives cols=2 (x0=15), rows=2 (y0≈17.01). total = (2+1)/2*2 + 2/2*1 = 2 + 1 = 3 ✓. Row 0 (even) at x=±15, row 1 (odd, staggered=1) at x=0 — 3 plops, matching the updated TestFillRegionDeterministicPacking and its "fits inside" assertion (plop edges reach ±27.99 < 30). Verified by reading ops.go:224-272 and ops_test.go:247-282.

  • Garlic 122×244, radius 22.5, spacing 15 (pitch 45, rowH ≈38.97, inset 15): x-axis: (122−30)/45 = 2.044 → cols=3, x0=16 → first centre at −61+16 = −45 ✓ (wantRowStarts[0]). y-axis: (244−30)/38.97 = 5.49 → rows=6, y0≈24.57. Odd row first centre at −61+16+22.5 = −22.5 ✓ (wantRowStarts[1]). total = 7/2*3 + 6/2*2 = 9 + 6 = 15 ✓. Outer plop overhang = 45+22.5−61 = 6.5 ≤ 7.5 (spacing/2) ✓. Matches the PR's "AFTER" table exactly.

  • Exact-fit 90×90, radius 15, spacing 10 (inset 10): cols=3 (x0=15 → first centre −30), rows=3; odd row first centre −15 ✓. Overhang 0 ≤ 5 ✓.

  • Stagger semantics: the odd-row offset is pitch/2 (half a pitch), not a whole one (ops.go:265), and I verified algebraically that both the even and odd rows centre on MinX + length/2, so opposite margins match — the property TestHexCentersEdgeInset asserts.

  • Count-before-build consistency: the total formula (rows+1)/2*cols + rows/2*staggered exactly matches what the build loop emits (even rows emit cols, odd rows emit staggered), including the cols==1 degenerate case where staggered stays cols. Verified at ops.go:248-271. The total > limit → (nil, total) path and the caller's total > maxFillPlops refusal (ops.go:153-155) are consistent.

  • fitAxis epsilon: eps=1e-9 only lifts a result that is FP-noise-below an integer; it cannot inflate a genuinely-non-integer quotient, and when the quotient is exactly integer k, floor(k+eps)=k is unchanged. No off-by-one risk. Verified at ops.go:295-297.

  • empty()/inverted-region guard: clampTo inverts a non-overlapping region (Max < Min), and hexCenters's r.empty() check (ops.go:231) catches it before fitAxis sites a plop off the bed — matching TestFillRegionOutsideObjectPlantsNothing. contains is fully removed with no remaining callers (grep confirms).

  • Non-finite input: the explicit isFinite check (ops.go:147-150) precedes clampTo; isFinite is defined in objects.go:382 in the same package. Covers the NaN/Inf cases the test exercises.

One design note that is not a bug: when a region is too narrow for 2*inset on one axis, fitAxis takes the "tiny" path (n=1, start=length/2) and the lone plop can overhang by more than half a spacing (since length/2 < inset). This is deliberate per the PR ("one plop in the middle beats refusing to plant") and isolated in TestHexCentersTinyRegion; TestHexCentersEdgeInset uses dimensions that avoid that path. The overhang-budget assertion is therefore a property of normal-sized regions, not a universal invariant — which is consistent with the stated semantics.

🧹 Code cleanliness & maintainability — No material issues found

Verdict: No material issues found

Through the cleanliness/maintainability lens I verified the change against the checked-out code and found nothing material.

  • Region.contains removal is clean: grep across internal finds no remaining callers, and the lattice is now count-bounded (fitAxisfor row < rows), so the old condition loop is genuinely replaced, not just papered over (internal/service/ops.go:258).
  • empty() is used exactly where it should be (hexCenters at ops.go:231), and its doc explains the inverted-region convention that clampTo relies on — a good "name once" trade versus bare MaxX < MinX checks. The clampTo doc correctly points to it.
  • fitAxis is single-purpose, well-named, and its step<=0 guard is explicitly documented as currently-unreachable-but-deliberate (ops.go:283); that matches the codebase's habit of prose comments over leaving traps for readers.
  • The total pre-count and the build loop both encode the stagger pattern, which is a mild duplication risk, but the exact-count is load-bearing (it gates the early-return-before-allocating), well-commented at ops.go:245, and TestHexCentersEdgeInset asserts total == len(pts) so the two can't silently drift.
  • No dead/duplicated helpers: rect, clampTo, empty, fitAxis, hexCenters all have live callers (grep confirmed). New test helpers (seedFillBed, reuse of seedOwnPlant) follow the existing seeding pattern; the small repeated garden-creation boilerplate matches TestFillRegionCappedForHugeArea already in the file, so it's not a new inconsistency.
  • Imports added to ops_test.go (math, sort) are both used; isFinite resolves to the existing helper in objects.go:382.

The long prose doc on hexCenters is stylistically heavy, but it's consistent with this file's existing commentary style (the file header itself is several lines of prose), so I don't flag it as a maintainability regression.

Performance — No material issues found

No material issues found

Through the performance lens, this change is a net improvement, not a regression:

  • Count-before-build avoids the wasteful allocation. hexCenters now computes total via closed-form arithmetic (fitAxis) and returns (nil, total) when total > maxFillPlops (ops.go:253-255). The old code built the entire lattice into a growing slice and only afterwards checked len(centers) > maxFillPlops — so a fill large enough to be refused allocated (and grew) the whole lattice just to throw it away. For a pathologically large region this was unbounded work before the guard fired; now it's O(1) arithmetic. Verified at ops.go:224-255.
  • Exact pre-sizing. pts := make([]localPoint, 0, total) (ops.go:257) reserves exactly the count that will be appended, since total is derived to match the build loop exactly (odd/even row split: (rows+1)/2*cols + rows/2*staggered). The old append loop relied on slice growth with reallocation. The comment explicitly notes the old rows*cols would have over-reserved by ~12%; this avoids that too.
  • No new hot-path work introduced. coveredByExisting is still a per-candidate linear scan over existing (ops.go:164-167), but that's pre-existing behavior unchanged by this PR — the per-fill cost remains O(centers × existing), which is inherent to the de-duplication requirement and not worsened here.

No N+1, no unbounded growth, no avoidable quadratic behavior introduced. The only loop added (ops.go:258-270) is the necessary O(total) construction of exactly the points returned.

🧯 Error handling & edge cases — No material issues found

VERDICT: No material issues found

Through the error-handling & edge-case lens, the diff is unusually well defended. I verified each unhappy path against the checked-out code:

  • Non-finite region (ops.go:147-150): all four bounds are checked with isFinite (defined at objects.go:382) before clampTo, so NaN/Inf can no longer reach fitAxis's float→int conversion or slip through empty() (which compares false on NaN). Confirmed.
  • Inverted/empty region (ops.go:231-233, 47-49): clampTo expresses "no overlap" by inverting (Max below Min); the new empty() catches both axes, and hexCenters returns (nil, 0) before computing any geometry. TestFillRegionOutsideObjectPlantsNothing exercises this (region east of bed → MinX=500, MaxX=50).
  • Tiny region (fitAxis, ops.go:289-291): length < 2*inset (and the unreachable-but-defensive step <= 0) returns a single centered plop rather than dividing by a non-positive step (±Inf → garbage int). TestHexCentersTinyRegion covers centered and off-centre cases; the off-centre case correctly distinguishes "middle of region" from "(0,0)".
  • Count-before-allocate (ops.go:252-255): total is computed exactly (even rows × cols + odd rows × staggered, matching the build loop at 258-270), so len(pts) == total and the total > limit refusal path needs no allocation. I re-derived the count: even rows in 0..rows-1 = (rows+1)/2, odd rows = rows/2; the build loop assigns cols to even rows and staggered (= cols-1 when cols>1) to odd rows — exact match, no off-by-one.
  • Integer overflow / float→int: reachable inputs are bounded. clampTo restricts the region to object bounds (≤ maxGardenCM = 10_000cm, so length ≤ 20_000) before hexCenters sees it, and radius = max(1.5*spacing, 15) (≥15, pitch ≥30) keeps rows/cols small; the int(math.Floor(...)) in fitAxis can't overflow on these magnitudes. A pre-clampTo Inf-length (e.g. MaxX=1e308, MinX=-1e308) is defused because clampTo clamps both ends to ±halfW, yielding an inverted (empty) region caught by empty().
  • Single-column stagger (ops.go:264-266): cols > 1 guard prevents a staggered = cols-1 = 0 row from emitting zero plops asymmetrically; n stays cols.
  • No swallowed errors / missing cleanup: fillLoaded still returns CreatePlantings's error; the non-finite and total > limit paths return ErrInvalidInput with no partial side effects (the early returns precede any store writes).

Nothing in my lane is materially wrong.

kimi-k2.6:cloud (ollama-cloud) — No material issues found

Verdict: No material issues found — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

No material issues found

The security-relevant changes in this PR are defensive improvements, not regressions:

  • Non-finite region validation added (ops.go:147-150): NaN and ±Inf in caller-supplied Region coordinates now return ErrInvalidInput before reaching geometry calculations or the store layer. The PR description accurately notes that without this check, NaN survives clamping (since NaN comparisons are always false), empty() cannot detect it, and fitAxis happily centers on it — leading to an opaque SQLite error or silent zero-plop success.
  • Inverted/empty region guard preserved (ops.go:231-233): clampTo inverts the region when there's no overlap; empty() catches this. TestFillRegionOutsideObjectPlantsNothing verifies no plants are created for regions completely outside the object.
  • DoS bounding intact: maxFillPlops (5000) and the pre-allocation count in hexCenters prevent runaway lattice generation.

No new authn/authz gaps, injection vectors, unsafe deserialization, or secret leakage introduced. The spacingOverride path already validates finite values and range bounds (ops.go:130), and radius is re-validated at ops.go:136 before use.

🎯 Correctness — No material issues found

No material issues found

  • I traced fitAxis and hexCenters against the test cases (122×244 garlic bed, 90×90 exact fit, 60×60 deterministic packing, tiny regions) and the arithmetic checks out: the centered lattice produces symmetric margins, the stagger offset is exactly half a pitch, and the radius − spacing/2 inset caps overhang at the half-spacing budget.
  • The total pre-count formula (rows+1)/2*cols + rows/2*staggered correctly accounts for staggered rows having one fewer plop, and the capacity pre-allocation matches exactly.
  • The epsilon (1e-9) in fitAxis safely guards against floating-point drift on exact divisions without materially over-counting: the worst-case "false extra position" would be ~1e-9·step from the inset boundary, negligible at garden scale.
  • The non-finite region guard, empty-region early return, and clamped-outside-object behavior are all correct and covered by tests.
🧹 Code cleanliness & maintainability — No material issues found

No material issues found.

The diff is well-structured: dead code (Region.contains) is removed, the new empty() helper is appropriately extracted with a clear comment explaining why it exists, fitAxis cleanly separates the geometric counting from the lattice construction, and the extensive hexCenters godoc serves as a guard against the exact regression being fixed. Tests follow existing table-driven patterns and the new helpers (seedFillBed) are reused properly.

Performance — No material issues found

No material issues found

From the performance lens, this change is actually an improvement rather than a regression:

  • Pre-counting avoids wasteful allocation: hexCenters now computes the exact lattice count before allocating, and returns (nil, total) when total > limit. The old code built the entire slice first, then checked len(centers) > maxFillPlops — so a maliciously large region would allocate thousands of localPoint structs only to throw them away.
  • Exact capacity eliminates reallocations: make([]localPoint, 0, total) with an exact count means zero slice growth copies. The old code used an unbounded var pts []localPoint with repeated append, which could reallocate multiple times for large fills.
  • Bounded loops replace condition-bounded iteration: The old for y := r.MinY + radius; y <= r.MaxY+eps pattern iterated and then filtered with r.contains(x, y). The new count-and-center approach eliminates the per-point contains call entirely; points are bounded by construction.

No new allocations are introduced on hot paths, and the isFinite checks on region bounds are O(1) and run once per fill. The fitAxis helper uses a small constant amount of float arithmetic — negligible compared to the allocations it avoids.

🧯 Error handling & edge cases — No material issues found

No material issues found

I investigated the change thoroughly through the error-handling and edge-cases lens. Here is what I checked and why it is clean:

  • Non-finite region bounds: fillLoaded now explicitly validates region.MinX/Y and region.MaxX/Y with isFinite before any downstream geometry work. Verified by reading ops.go:147-150 and the matching test TestFillRegionRejectsNonFiniteRegion at ops_test.go:189.
  • Inverted / empty regions: Region.empty() correctly catches clampTo's inverted-output case (Max < Min). hexCenters returns (nil, 0) immediately. Verified by reading ops.go:47-49, ops.go:231-233, and the test TestFillRegionOutsideObjectPlantsNothing at ops_test.go:217.
  • Tiny regions: fitAxis returns (1, length/2) when the span is smaller than 2·inset, so hexCenters plants one plop in the middle rather than returning nothing or placing something off-bed. Verified by reading ops.go:289-291 and the test TestHexCentersTinyRegion at ops_test.go:166.
  • Exact count before allocation: hexCenters computes total analytically ((rows+1)/2*cols + rows/2*staggered) and compares against limit before allocating. The formula exactly matches the nested loop body, so len(pts) == total is guaranteed; the test TestHexCentersEdgeInset asserts this at ops_test.go:105.
  • fitAxis epsilon: eps = 1e-9 is added to prevent an exact fit from under-counting due to floating-point error (e.g. 60 cm span / 30 cm step). It is small enough that any over-count it might theoretically cause would shift a centre by sub-nanometre distances, irrelevant at garden-bed scale.
  • inset never negative: inset := math.Max(0, radius-math.Max(0, spacing)/2) clamps to zero, preventing centres from being placed outside the region.
  • radius <= 0 guard: hexCenters returns early, and fillLoaded already validates radius > 0.

Nothing in this diff ignores, swallows, or mishandles an error path, and the new edge-case guards (NaN/Inf, empty region, tiny region) are correct and well-tested.

opencode/glm-5.2:cloud (opencode) — No material issues found

Verdict: No material issues found — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

VERDICT: No material issues found

I reviewed the change through the security lens (authn/authz, injection, input validation, DoS bounds, unsafe handling of untrusted data). The change is, if anything, a net improvement to input validation:

  • Authz intact. FillRegion/FillNamedRegion still call objectForRole(ctx, actorID, objectID, roleEditor) before fillLoaded (ops.go:109, ops.go:315); visiblePlant still masks cross-user plant ownership as ErrInvalidInput (plantings.go:217-228). The diff doesn't touch any permission gate.
  • New non-finite input rejection (ops.go:147-150). isFinite exists (objects.go:382) and is now applied to the caller-supplied Region before clampTo/hexCenters. I traced the taint: math.Max(NaN, …) propagates NaN, so without this guard NaN would reach CreatePlantings; +Inf would silently plant nothing. Both are now ErrInvalidInput. Verified the test TestFillRegionRejectsNonFiniteRegion covers both NaN and +Inf.
  • DoS bound tightened. hexCenters now computes total before allocating (ops.go:252-255) and returns (nil, total) over limit, so an oversized fill is refused without building the lattice. FillRegion's maxFillPlops cap (5000, ops.go:23) still fires. fitAxis guards step<=0 to avoid ±Inf-to-int garbage even though the only caller makes it unreachable — defensive, good.
  • No injection surface. No SQL changed; region coordinates still flow to CreatePlantings (parameterized store layer). No path/template/command injection introduced.
  • Clamp-to-object still enforced. region.clampTo(o.WidthCM/2, o.HeightCM/2) (ops.go:152) precedes lattice construction, so a region cannot plant outside the authorized object; Region.empty() (ops.go:45-47) makes a non-overlapping (inverted-by-clampTo) region plant nothing rather than at an off-bed point — covered by TestFillRegionOutsideObjectPlantsNothing.

Minor non-security observation (not a finding): the NaN/Inf check runs after visiblePlant's DB read, so an editor sending a malformed region still costs one plant lookup before rejection. That's an ordering nit, not a vulnerability.

No security findings to report.

🎯 Correctness — No material issues found

Now let me verify the math. Let me work through the garlic example carefully.

For the garlic bed: w=122, h=244, radius=22.5, spacing=15.

  • pitch = 45, rowH = 45*sqrt(3)/2 ≈ 38.97
  • inset = max(0, 22.5 - 15/2) = max(0, 22.5-7.5) = 15
  • X span = 122. fitAxis(122, 45, 15): length=122 >= 215=30. n = floor((122-30)/45 + eps) + 1 = floor(92/45) + 1 = floor(2.044) + 1 = 2+1 = 3 cols. x0 = (122 - 245)/2 = (122-90)/2 = 16. So cols=3, x0=16.
  • Y span = 244. fitAxis(244, 38.97, 15): n = floor((244-30)/38.97) + 1 = floor(214/38.97) + 1 = floor(5.49) + 1 = 5+1 = 6 rows. y0 = (244 - 5*38.97)/2 = (244 - 194.85)/2 = 49.15/2 = 24.57.

Wait, the PR says 3 rows and 15 plops. Let me recompute. 244/38.97 = 6.26, so 6 rows possible. But the PR example shows only 3 rows... Hmm, the PR example "AFTER" shows row 0, 1, 2 — 3 rows. Let me reconsider.

Actually wait, the PR example might be a truncated illustration. Let me re-read: "AFTER — 15 plops / row 0: 3 plops / row 1: 2 plops / row 2: 3 plops". That's only 3 rows = 8 plops, not 15. Hmm, the BEFORE also says 15 plops with 3 rows. That doesn't add up either (3+2+3=8).

Actually maybe rows are listed selectively or the example is illustrative of the symmetry, not exhaustive. Let me not fixate on the PR prose; let me verify the actual code logic produces sensible results. Let me check the test assertions for the "4ft bed of garlic" case which uses wantRowStarts [-45, -22.5].

For r = rect(-61, -122, 61, 122).

  • x0 = 16 (computed above, offset from MinX). First row start x = -61 + 16 = -45. ✓ matches wantRowStarts[0]=-45.
  • Row 1 (staggered): x = -61 + 16 + 45/2 = -61+16+22.5 = -22.5. ✓ matches wantRowStarts[1]=-22.5.

Good. Now let me verify the overhang. Row 0 start x=-45, radius=22.5. West edge of plop = -45-22.5 = -67.5. Region MinX=-61. Overhang = -61 - (-67.5) = 6.5. Budget = spacing/2 = 7.5. 6.5 ≤ 7.5 ✓.

Now let me check the overhang for the last plop in row 0. 3 cols: x = -45, 0, 45. Last plop east edge = 45+22.5 = 67.5. MaxX=61. Overhang = 67.5-61 = 6.5 ≤ 7.5 ✓. Symmetric. Good.

Now let me check the deterministic packing test: 60×60 bed, radius 15, spacing 10 (from seedFillBed with plant spacing 10).

  • pitch = 30, rowH = 30*sqrt(3)/2 ≈ 25.98
  • inset = max(0, 15 - 5) = 10
  • X span=60: fitAxis(60, 30, 10): length=60 >= 20. n = floor((60-20)/30 + eps)+1 = floor(40/30)+1 = floor(1.333)+1 = 1+1 = 2 cols. x0 = (60 - 1*30)/2 = 15. So cols=2, x0=15.
  • Y span=60: fitAxis(60, 25.98, 10): n = floor((60-20)/25.98)+1 = floor(40/25.98)+1 = floor(1.539)+1 = 1+1 = 2 rows. y0 = (60 - 1*25.98)/2 = 17.01.

So rows=2, cols=2, staggered=1. total = (2+1)/22 + 2/21 = 12 + 11 = 3. ✓ matches expected 3 plops.

Row 0: 2 plops at x=-30+15=-15, -15+30=15. y=-30+17.01=-12.99.

Wait, region is rect(-30,-30,30,30). MinX=-30, x0=15. Row 0 x = -30+15 = -15, then +30 = 15. y = -30 + 17.01 = -12.99.
Row 1 (staggered, cols>1): n=staggered=1, x = -30+15+15 = 0. y = -30+17.01+25.98 = 12.99.

So plops: (-15,-12.99), (15,-12.99), (0,12.99). 3 plops. ✓

Overhang check: plop at (15,-12.99), radius 15. East edge = 30. MaxX=30. Overhang=0. West of (-15): -30, MinX=-30, overhang 0. South: y=-12.99-15=-27.99, MinY=-30, no overhang. North: 12.99+15=27.99 ≤30. Good, fits exactly. The test asserts no overhang. ✓

Now let me check the tiny region test. rect(-5,-5,5,5), radius 15, spacing 10.

  • pitch=30, rowH≈25.98, inset = max(0, 15-5)=10.
  • fitAxis(10, 30, 10): length=10 < 2*inset=20. Returns (1, 10/2=5). cols=1, x0=5.
  • fitAxis(10, 25.98, 10): length=10 < 20. Returns (1, 5). rows=1, y0=5.
  • staggered: cols=1, so stays 1 (cols>1 false). total = (1+1)/21 + 1/21... wait. (rows+1)/2cols + rows/2staggered = (2)/21 + (1)/21... integer division: (1+1)/2 = 1, rows/2 = 0. total = 11 + 01 = 1.
  • Row 0: n=cols=1, x=-5+5=0, y=-5+5=0. Plop at (0,0). ✓

Off-corner: rect(20,-40,30,-30). length x = 10, y = 10. Same as above but MinX=20, MinY=-40.

  • cols=1, x0=5. rows=1, y0=5. Row 0: x=20+5=25, y=-40+5=-35. ✓ matches wantX=25, wantY=-35.

Now let me check the non-finite rejection and outside-object tests reference helpers that exist.

Let me check seedFillBed, seedUser, seedOwnPlant, newTestService, openConfig exist.Let me verify the test helpers exist.
Now let me verify the agent tool that calls FillRegion/FillNamedRegion still passes the right args, since the signature of fillLoaded is unchanged but hexCenters changed signature. Let me check if any other caller of hexCenters exists outside the file. Already grepped — only ops.go and ops_test.go. Good.

Let me check the agent tool adapters to make sure nothing else broke.
Let me check the TestFillRegionCappedForHugeArea test still works correctly — it relies on the over-limit path returning total > maxFillPlops.
Let me verify nothing else referenced the removed Region.contains method.
I verified the math from first principles against the checked-out code and tests. Let me confirm one more edge — the derivedCount and store path aren't affected.
The change is clean. I verified the math from first principles against the checked-out code, all tests reference existing helpers, no callers of the removed Region.contains remain, and the hexCenters signature change is confined to ops.go + ops_test.go. One observation worth surfacing on the +Inf rejection path: the test asserts err == ErrInvalidInput for MaxY = math.Inf(1) and the new guard catches it. But there's a subtle gap — clampTo already happens after the finite check, so the check is correctly placed.

Let me double-check one thing about the fitAxis epsilon interaction with very large beds, since the cap test uses a 6000×6000 bed with radius 15.

For bed 6000×6000, radius 15, spacing 10: pitch=30, rowH≈25.98, inset=10. fitAxis(6000, 30, 10): n = floor((6000-20)/30)+1 = floor(199.33)+1 = 199+1 = 200 cols. fitAxis(6000, 25.98, 10): n = floor(5980/25.98)+1 = floor(230.17)+1 = 230+1 = 231 rows. total = (231+1)/2200 + 231/2199 = 116200 + 115199 = 23200 + 22885 = 46085 > 5000. Correctly capped. ✓

Everything verifies. The change is correct.

Verdict: No material issues found

I reviewed through the Correctness lens and re-derived the geometry from first principles against the checked-out internal/service/ops.go:

  • Half-spacing rule: For garlic (w=122, h=244, radius=22.5, spacing=15): inset = max(0, 22.5 - 7.5) = 15. fitAxis(122, 45, 15) → cols=3, x0=16. First-row start x = -61+16 = -45 (matches wantRowStarts[0]=-45); staggered row start x = -61+16+22.5 = -22.5 (matches wantRowStarts[1]=-22.5). Overhang = 45+22.5-61 = 6.5 cm, under the 7.5 cm half-spacing budget on both edges, symmetric. ✓
  • Deterministic packing test (60×60, radius 15, spacing 10): fitAxis(60, 30, 10) → cols=2, x0=15; fitAxis(60, 25.98, 10) → rows=2, y0≈17.01. total = (2+1)/2*2 + 2/2*1 = 3. Plops: (-15,-12.99), (15,-12.99), (0,12.99). No overhang (edges land exactly at ±30). Matches the asserted 3. ✓
  • Tiny region fallback (fitAxis length < 2*inset → 1 plop centered): verified both origin-centered rect(-5,-5,5,5)→(0,0) and off-centre rect(20,-40,30,-30)→(25,-35). ✓
  • Cap test (6000×6000): 200 cols × 231 rows → total = 46 085 > 5000, refused via the pre-build count path without allocating. ✓
  • Non-finite guard: placed before clampTo, so NaN/Inf in the region are rejected with ErrInvalidInput rather than leaking into SQLite or producing a silent zero-plop success. ✓
  • Outside-object region: clampTo inverts to MinX=500, MaxX=50empty() returns true → 0 plops. ✓
  • Dead code: grep confirms no remaining callers of the removed Region.contains. The hexCenters signature change is confined to ops.go and ops_test.go; agent tools and API call the unchanged FillRegion/FillNamedRegion. ✓

The new total-count formula (rows+1)/2*cols + rows/2*staggered correctly partitions rows into even-indexed (full cols) and odd-indexed (staggered = cols-1 when cols>1), and the build loop matches it exactly (verified by re-deriving both halves for the three test cases above). fitAxis's epsilon is the right magnitude — large enough to recover an exact fit lost to float error, small enough not to add a spurious position. I found nothing materially wrong in this lens.

🧹 Code cleanliness & maintainability — No material issues found

VERDICT: No material issues found

I reviewed the diff through the cleanliness/maintainability lens and verified the key claims against the checked-out code.

  • Region.contains is genuinely dead after removal — grep for \.contains( finds no remaining callers in the repo. Good cleanup; the count-bounded loop replacing it is clearer.
  • empty(), clampTo, isFinite, hexCenters, fitAxis are each used and resolve correctly (isFinite lives in objects.go:382, reused here rather than redefined — no duplication).
  • The hexCenters total-derivation matches what the build loop produces (odd rows get cols, even rows get staggered = cols-1 when cols > 1), and the capacity hint is exact rather than an over-reserve — the stated reason for the up-front count holds.
  • The fitAxis step <= 0 guard is explicitly documented as unreachable-but-defensive; that is a deliberate, well-explained choice, not dead code to flag.
  • Names are clear (inset, staggered, pitch, rowH), the function is short and flat, and the reassignment of n, x inside the row loop reads cleanly.

One trivial observation (not a finding): the "plop is a clump, half-spacing at the edge" rationale now appears in three places — CLAUDE.md, DESIGN.md, and the hexCenters doc comment. Each serves a distinct audience (convention / decision / regression guard), and the PR consciously updates all three in one commit, so I would not call this churn-worthy. The clampTo comment's phrasing "comes back empty — see empty()" is slightly loose (clampTo inverts; empty() detects the inversion), but the cross-reference makes it unambiguous.

Performance — No material issues found

No material issues found.

Reviewed through the performance lens. The change is geometry, and on the efficiency axis it is neutral-to-positive:

  • Count-before-build is an improvement, not a regression. The old hexCenters allocated the full lattice, returned it, and only then did fillLoaded check len(centers) > maxFillPlops (ops.go:152-154 in the old form). The new hexCenters(r, radius, spacing, maxFillPlops) derives total arithmetically ((rows+1)/2*cols + rows/2*staggered) and returns (nil, total) over the limit without ever allocating the slice (ops.go:252-255). A refused fill of 5000+ candidates no longer pays for the points it's about to throw away.
  • The capacity reservation is exact. I verified the total formula against the build loop for several row/col counts (rows=2/cols=3 → 5; rows=3/cols=3 → 8; rows=1/cols=N → N; cols=1 → rows). make([]localPoint, 0, total) neither over-reserves (the comment's "~12%" note about rows*cols) nor under-reserves (which would trigger slice regrowth). No realloc on the hot path.
  • No new loops over unbounded collections. fitAxis is O(1); hexCenters' build loop is O(total), bounded by maxFillPlops. coveredByExisting and the per-candidate existing = append(...) are pre-existing and unchanged by this diff.
  • The non-finite guard (ops.go:147-150) is four isFinite calls on the request path — negligible, and it prevents the downstream work that NaN/+Inf would otherwise do.
🧯 Error handling & edge cases — No material issues found

I'll examine the actual code to verify edge cases and error handling.
Now let me look at the test for the non-finite region case to verify the test claim about Inf being silent:
Now let me verify the math.Max(0, spacing)/2 for negative spacing - does the existing validation reject negatives?
Looking at the error-handling and edge-case lens, I've traced the new code paths carefully. Let me verify a couple of suspicions against the actual code.
I've traced all the new/changed error paths and edge cases in the diff against the actual code. Let me record my findings.
VERDICT: No material issues found

Through the error-handling and edge-case lens, I verified the new unhappy paths and they hold up. Notes on what I checked:

  • Non-finite region bounds (ops.go:147-150): the new isFinite guard runs after radius validation but before clampTo, so NaN/Inf bounds are rejected as ErrInvalidInput before they can reach the inverted-region guard or fitAxis. I confirmed isFinite is defined at objects.go:382 and rejects both NaN and ±Inf. The PR description's claim that "+Inf would otherwise silently plant nothing" is consistent with the old hexCenters form (the loop never advances past r.MaxY+eps when MaxY is +Inf, and r.contains/empty can't see it). The new clampTo keeps Inf (math.Min(+Inf, halfW)=halfW), so without the guard fitAxis would compute length=200 and return valid positions — the guard is doing real work. ✓

  • Inverted-region / outside-bed case (ops.go:231, 47-49): empty() is correctly checked first inside hexCenters, so a region clamped to MinX>MaxX (object missed entirely) plants nothing rather than one plop. TestFillRegionOutsideObjectPlantsNothing pins this. ✓

  • Tiny region fallback (fitAxis:289-291): when length < 2*inset, fitAxis returns (1, length/2) — one plop centered. I verified this never produces a negative start and is bounded since length is Max-Min ≥ 0 after the empty() guard. ✓

  • radius <= 0 and step <= 0 guards: hexCenters returns (nil, 0) for radius <= 0 (so total > limit is never true spuriously), and fitAxis's step <= 0 half is unreachable but defensively guarded against ±Inf from division. ✓

  • Negative spacing: math.Max(0, spacing)/2 at ops.go:240 clamps it, but it's also a dead branch since plant.SpacingCM is validated ≥ minPlantSpacingCM (0.1) at plants.go:319 and the override at ops.go:130. The defensive clamp is harmless. ✓

  • Empty-batch path (fillLoaded with len(centers)==0): CreatePlantings explicitly no-ops on empty input (plantings.go:241-243), record early-returns on len(changes)==0 (revisions.go:194), so an all-covered-by-existing or zero-center fill returns (nil/empty, nil) cleanly with no spurious history entry. ✓

  • Over-limit path (hexCenters returns (nil, total) with total > limit): caller refuses with ErrInvalidInput before allocating the lattice — matches the stated intent. ✓

No swallowed errors, missing defers, nil-deref, or boundary issues introduced by this change within my lane.

opencode/kimi-k2.6:cloud (opencode) — No material issues found

Verdict: No material issues found · ⚠️ 1/5 lens(es) errored — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

Verdict: No material issues found

Security lens review:

I traced the changed code through internal/service/ops.go and the test suite, focusing on input validation, bounds checking, and potential misuse of untrusted data.

  • Non-finite region validation (ops.go:147–150): The PR adds an explicit isFinite check on all four region bounds before clamping. Without this, NaN survived clamping and the inverted-region guard (NaN compares false both ways), eventually reaching the store as a NULL constraint failure. +Inf produced a silent zero-plop success. This is a correct hardening of the input surface.

  • Region.empty() guard (ops.go:231–233): A region that misses the object entirely (after clampTo inverts it) now returns zero plops. Verified by TestFillRegionOutsideObjectPlantsNothing. Prevents planting outside the authorized object boundary.

  • maxFillPlops enforcement unchanged: The new hexCenters pre-counts positions and returns (nil, total) when total > limit, avoiding allocation for oversized fills. The caller (fillLoaded) still returns ErrInvalidInput when over the cap.

  • No injection vectors introduced: The diff does not touch SQL construction. All store interactions go through typed domain.Planting structs and existing store methods.

  • Spacing/radius validation chain intact: spacingOverride is finite-and-bounded checked; the derived radius is finite-and-positive checked before use. Even if a stored plant.SpacingCM were corrupt, defaultPlopRadiusradius <= 0 catches it.

Nothing in this diff introduces, widens, or bypasses an authn/authz, injection, or unsafe-data path. The changes are purely geometric and actually tighten one validation boundary.

🎯 Correctness — No material issues found

Verdict: No material issues found

Reviewed through the correctness lens. The core geometry change is sound:

  • fitAxis centering logic verified: Re-derived for the exact-fit case (60 cm span, 30 cm pitch, 15 cm inset) → n=2, start=15, giving centers at x=±15 relative to the span's start. Correct. For the garlic case (122 cm span, 45 cm pitch, 15 cm inset) → n=3, start=16, giving centers at 16, 61, 106, which from origin at –61 are –45, 0, +45. Correct.

  • Half-spacing edge rule verified: inset = radius – spacing/2. For garlic (r=22.5, s=15), inset=15. Outer plop at ±45 has rim at ±67.5; bed edge at ±61; overhang = 6.5 cm, within the 7.5 cm half-spacing budget. Correct.

  • Stagger offset verified: Odd rows get staggered = cols-1 and start at x0 + pitch/2, so the first staggered center is offset by exactly half a pitch from the first non-staggered center. For the garlic case: non-staggered starts at –45, staggered starts at –22.5 (which is –45 + 22.5). Correct.

  • total arithmetic verified: Integer formula (rows+1)/2*cols + rows/2*staggered is exact for the alternating-row pattern, not an upper bound. Tested with 3-row and 4-row examples manually.

  • clampTo + empty() interaction verified: A region wholly outside the object inverts to Max < Min, which empty() catches, causing hexCenters to return nil, 0. The new TestFillRegionOutsideObjectPlantsNothing covers this.

  • Non-finite guard verified: isFinite check on all four region bounds before clamping prevents NaN/+Inf from silently corrupting the lattice or causing misleading errors downstream.

No logic bugs, arithmetic errors, or semantic/domain correctness issues introduced by this change.

🧹 Code cleanliness & maintainability — No material issues found

No material issues found.

The code is clean and well-structured:

  • Region.empty() and fitAxis() are small, focused helpers with clear names and explicit documentation for non-obvious behavior (why empty() exists, why fitAxis keeps its step <= 0 guard).
  • hexCenters is reorganized into bounded counting + bounded allocation, replacing the old condition-bounded loop. The comments are extensive but warranted given the geometric subtlety and the #75 history.
  • Tests follow existing patterns (seedFillBed, seedOwnPlant) and add targeted coverage for edge cases (TinyRegion, NonFiniteRegion, OutsideObjectPlantsNothing).
  • No dead or duplicated code, no confusing names, no overly long functions.
Performance — No material issues found

No material issues found

The PR improves allocation efficiency rather than regressing it:

  • hexCenters now computes an exact count before allocating, replacing the old unbounded condition-based loop that grew a slice through repeated append. The new make([]localPoint, 0, total) pre-sizes the backing array to exact capacity, eliminating all reallocations.
  • The removed Region.contains per-point check is now satisfied by construction via fitAxis, saving a comparison on every lattice point.
  • The added isFinite guard and Region.empty() check are O(1) and run once per fill.

No hot-path work is added, no N+1 patterns introduced, and no quadratic behavior is new. The counting-before-building logic is a straightforward win.

🧯 Error handling & edge cases⚠️ could not complete

⚠️ This reviewer failed to complete: opencode run context deadline exceeded

Automated adversarial review by Gadfly — consensus across the model swarm. Advisory only — does not block merge.

<!-- gadfly-consensus --> ## 🪰 Gadfly review — consensus across 5 models **Verdict: Minor issues** · 2 findings (0 with multi-model agreement) <details><summary>2 single-model findings (lower confidence)</summary> | | Finding | Where | Model | Lens | |--|--|--|--|--| | 🟡 | Half-spacing rule explanation duplicated near-verbatim across CLAUDE.md, DESIGN.md, and two doc comments | `internal/service/ops.go:192` | claude-code/sonnet | maintainability | | ⚪ | fillLoaded doc comment omits the new non-finite-region validation it performs | `internal/service/ops.go:116` | claude-code/sonnet | maintainability | </details> <details><summary>Per-model detail</summary> <details><summary><b>claude-code/sonnet</b> (claude-code) — Minor issues</summary> **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> **VERDICT: No material issues found** I reviewed this diff strictly through the security lens (authn/authz, injection, SSRF, deserialization, secrets, input validation, unsafe untrusted-data handling, insecure defaults), verifying against the checked-out code rather than the diff alone. Findings from that verification: - **Authorization is unchanged and correct.** `FillRegion` still calls `s.objectForRole(ctx, actorID, objectID, roleEditor)` before doing anything (`internal/service/ops.go:109`), and `visiblePlant` still gates plant access. This diff only touches geometry math inside `fillLoaded`/`hexCenters`, not the ACL path. - **The new `isFinite` check closes a real gap, not opens one.** `internal/service/ops.go:145-151` rejects NaN/±Inf region bounds before they reach `clampTo`/`fitAxis`. I confirmed `isFinite` (`internal/service/objects.go:382`) is the same helper already used elsewhere (e.g. spacing validation), so this is consistent, not a new/weaker check. - **No DoS regression from the new counting math.** I traced the bound chain: `radius` is floored at 15 cm by `defaultPlopRadius` even under a caller-supplied `spacingOverride` (itself clamped to `[0.1, 10000]` at `ops.go:130`), and `region` is clamped to the *object's* real bounds via `clampTo`, which are validated at creation to `[1, 10000]` cm (`validDimensionCM`, `gardens.go:286-288`). So worst-case `rows`/`cols` in `fitAxis` are on the order of a few hundred — `total` is computed from a closed-form product (`ops.go:~236`) and checked against `maxFillPlops` *before* the `pts` slice is ever allocated, so there's no path to unbounded allocation or int overflow via a malicious region/spacing combination. - **`spacingOverride` is still range- and finiteness-checked** exactly as before (`ops.go:129-133`) — this diff doesn't touch that validation, just consumes `spacing` in the new `inset` calculation. - No SQL, shell, template, or deserialization surfaces are touched by this diff — it's pure in-memory float geometry in the service layer. Doc-only changes (`CLAUDE.md`, `DESIGN.md`) contain no secrets or sensitive content. Nothing here introduces or weakens an authz check, injects untrusted data into a dangerous sink, or degrades the existing resource-exhaustion guard — if anything the new finiteness check and pre-allocation counting improve robustness against malformed/adversarial input. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> **VERDICT: No material issues found** I independently re-derived the geometry from first principles rather than trusting the PR's stated numbers, and it checks out: - **Half-spacing overhang budget**: `inset := max(0, radius - max(0, spacing)/2)` (`internal/service/ops.go:240`). Re-deriving: overhang = radius − distance-to-edge, and we want max overhang ≤ spacing/2, so minimum allowed distance-to-edge = radius − spacing/2. That's exactly `inset`. Cross-checked against the square-foot-gardening convention cited in the PR (4/square → 12"/2 cols = 6" apart, 3" from edge = half of 6"; 9/square → 12"/3 = 4" apart, 2" from edge = half of 4") — the "half spacing" rule is dimensionally correct, not an invented constant. - **`fitAxis` count/centering** (`ops.go:288`): hand-computed `n` and `start` for all three scenarios in the diff (4ft×8ft garlic bed radius 22.5/spacing 15/pitch 45; the 90×90 "exact fit" case; the 60×60 bed at radius 15/spacing 10) and got exactly the values asserted in `TestHexCentersEdgeInset` and `TestFillRegionDeterministicPacking` (3 cols at x=-45,-22.5,... for the garlic case; 3 total plops for the 60×60 bed). The stagger-by-half-pitch and symmetric margins both fall out correctly. - **Total-vs-built consistency**: `total := (rows+1)/2*cols + rows/2*staggered` (`ops.go:252`) matches what the build loop actually emits in every case I traced, including the `cols == 1` degenerate path (where `staggered` silently equals `cols`, so parity no longer matters — verified this stays self-consistent with the loop's own `cols>1` guard at `ops.go:264`). - **Guard ordering**: the new NaN/Inf check (`ops.go:147`) runs before `clampTo`/`hexCenters`, and correctly catches what the PR says it catches — `region.empty()` can't detect NaN (comparisons are false both ways) and `Region.clampTo` would otherwise pass Inf straight through. Verified `isFinite` (`objects.go:382`) and that `plant.SpacingCM`/`spacingOverride` are separately validated finite elsewhere (`plants.go:319`, `ops.go:130`), so `radius`/`spacing` feeding `hexCenters` are always sane. - Traced `TestFillRegionOutsideObjectPlantsNothing`: `clampTo` inverting the region (`MinX=500 > MaxX=50`) is correctly caught by `Region.empty()` before any lattice math runs. I could not find a case where the counted `total` diverges from the built lattice, where the inset allows overhang beyond spacing/2, or where a degenerate (zero-width, tiny, or single-column/row) region produces a wrong point. This is a well-verified change. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> Both findings hold up against the actual code. Here's the verified review: VERDICT: Minor issues - `internal/service/ops.go:100-107` (`FillRegion`) and `ops.go:192-223` (`hexCenters`) doc comments restate the same "half-spacing rule" explanation that's also written out nearly verbatim in `CLAUDE.md:88-93` and `DESIGN.md:10` — the phrasing ("competing for the same soil, light and water", "4 per square is 6\" apart... 3\" from the edge") is copy-pasted across all four locations, not just topically related. Four separate copies of the same square-foot-gardening arithmetic means a future correction to the inset formula has four places to update, which cuts against CLAUDE.md's own stated principle to keep docs in sync with the same commit. A single source of truth (e.g., keep the long version in `hexCenters` and have `CLAUDE.md`/`DESIGN.md` link to it by name) would reduce the sync burden. - `internal/service/ops.go:116-119` — `fillLoaded`'s doc comment ("clamps the region..., refuses fills over maxFillPlops, and inserts the whole batch...") wasn't updated to mention the new non-finite-region rejection added in this diff (`ops.go:140-150`), so the comment undercounts what the function guards against. Everything else here is clean: `Region.contains` was correctly deleted as genuinely dead code once the lattice became count-bounded, `hexCenters`'s new signature is updated consistently at both call sites (`ops.go:153`, `ops_test.go:99,176` — grepped, no stale callers), `fitAxis` is a well-named, single-purpose extraction, and the new tests follow the existing file's structure/helpers rather than reinventing them. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> **VERDICT: No material issues found** Reviewed through the performance lens only (efficiency regressions, hot-loop work, allocations, unbounded growth). What the diff actually changes in `hexCenters`/`fitAxis` is, if anything, a performance *improvement* over the prior code: - The old `hexCenters` built the entire lattice unconditionally, then `fillLoaded` compared `len(centers) > maxFillPlops` — so an oversized fill request paid for allocating and populating the whole (rejected) slice before being refused. The new version (`internal/service/ops.go:242-255`) computes `rows`, `cols`, and `total` analytically via `fitAxis` (closed-form arithmetic, not iteration) and only allocates `pts := make([]localPoint, 0, total)` (`internal/service/ops.go:257`) after confirming `total <= limit`. Verified `fitAxis` (`internal/service/ops.go:288-298`) is O(1) — a `math.Floor` and a division, no loop — so the up-front count check adds negligible cost regardless of how large `rows*cols` would have been. - The build loop itself (`internal/service/ops.go:258-270`) is a straightforward double loop bounded by `rows`/`cols`, which are themselves bounded by the `total > limit` (`maxFillPlops = 5000`) check before the loop runs — same asymptotic shape as the old `for y ... for x ...` scan, just centered instead of corner-anchored. No new quadratic behavior introduced. - `pts` is pre-sized with the exact capacity (`total`), so no incremental slice regrowth during `append`, an improvement over an unsized `var pts []localPoint` in the old version. - The new finite-check on `region` bounds (`internal/service/ops.go:147-150`) is four `isFinite` comparisons — O(1), no measurable cost. I checked `fillLoaded`'s existing-plop loop (`internal/service/ops.go:158-171`, calling `coveredByExisting` at `internal/service/ops.go:302-309`) — this is O(n²) over up to `maxFillPlops` candidates since `existing` grows by one on every accepted candidate. That's unchanged by this diff (no `+`/`-` markers touch it in the provided diff, and confirmed by reading the current file), so it's pre-existing behavior outside the scope of this PR's changes, not a regression introduced here. No N+1 queries, blocking calls, or unbounded growth introduced by this diff; the DB write is still a single batched `CreatePlantings` call, unmodified. </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> ## VERDICT: No material issues found I read through `internal/service/ops.go` end-to-end (the diff's only production code change) and traced every guard added/changed in this PR: - **NaN/Inf on the region** (`ops.go:147-150`): all four bounds are checked with `isFinite` before `clampTo`/`hexCenters` ever see them. Confirmed `isFinite` (objects.go:382) correctly excludes both NaN and ±Inf, and confirmed the new test `TestFillRegionRejectsNonFiniteRegion` exercises both a NaN bound and a +Inf bound and asserts no non-finite coordinates are ever persisted. - **Empty/inverted region from `clampTo`** (`ops.go:47-49, 231-233`): `clampTo` expresses "no overlap" by inverting Min/Max, and `hexCenters` now checks `r.empty()` before calling `fitAxis`, avoiding a negative-length span. Verified `fitAxis` is only ever called with `length >= 0` as a result. Confirmed via `TestFillRegionOutsideObjectPlantsNothing`. - **Degenerate/tiny region** (`fitAxis`, `ops.go:288-297`): when `length < 2*inset`, returns `(1, length/2)` rather than a negative/zero position count — always `n >= 1`, so `cols`/`rows` in `hexCenters` can never be 0, so `staggered` is never negative. Traced this through the arithmetic for `total` in several cases (rows=1, cols=1) and it stays consistent with what the build loop actually produces. - **Count-before-build overflow/DoS guard** (`ops.go:252-255`): `total` is computed from closed-form row/col counts before any allocation, and checked against `limit` before `make([]localPoint, 0, total)`. I checked the practical bounds that feed this (`maxPlantSpacingCM` = 10,000 cm, `validDimensionCM` capping object dimensions, `defaultPlopRadius` floor of 15 cm) and confirmed `rows*cols` stays in the low hundreds of thousands at worst — nowhere near `int` overflow, so the "total goes negative and slips past the `> limit` check into a negative-capacity `make` panic" scenario I initially suspected isn't reachable. - **Empty batch behavior**: when a fill produces zero plops (region entirely outside the object, or fully covered by existing plops), `CreatePlantings` no-ops on an empty slice (`internal/store/plantings.go:241-243`) and `record()` early-returns on `len(changes) == 0` (`internal/service/revisions.go:194-196`), so there's no spurious "Planted 0 X" history entry — verified this is pre-existing, unmodified behavior that still holds correctly with the new empty-region path. The tests added (`TestHexCentersEdgeInset`, `TestHexCentersTinyRegion`, `TestFillRegionRejectsNonFiniteRegion`, `TestFillRegionOutsideObjectPlantsNothing`) directly target these edge cases and match what the code does. I didn't find an ignored error, missing cleanup, reachable panic, or unhandled boundary condition introduced by this diff. This is a solidly-defended change from the error-handling/edge-case angle. </details> </details> <details><summary><b>glm-5.2:cloud</b> (ollama-cloud) — No material issues found</summary> **Verdict: No material issues found** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> VERDICT: No material issues found Through the security lens (authn/authz, injection, input validation, untrusted-data handling, insecure defaults) this change is clean: - **Authz preserved.** `FillRegion` and `FillNamedRegion` both still gate on `objectForRole(..., roleEditor)` (verified at `internal/service/ops.go:109` and `:315`), so the geometric rewrite changes no privilege boundary. A viewer still gets `ErrForbidden` (existing test at `ops_test.go:364`). - **Caller-supplied region is bounded by construction.** `region.clampTo(o.WidthCM/2, o.HeightCM/2)` (ops.go:152) intersects the arbitrary-float region against the loaded object's local bounds, so a caller cannot site plops outside the object it was authorized to edit. The new `empty()` guard plus `fitAxis`'s tiny-region branch mean a region missing the object plants nothing rather than placing a plop off-bed — confirmed by `TestFillRegionOutsideObjectPlantsNothing`. - **Non-finite input now rejected explicitly.** The added check at ops.go:147-150 turns NaN/±Inf regions into `ErrInvalidInput` before they reach the store. Previously NaN could surface as an opaque NOT-NULL constraint error and +Inf as a silent zero-plop success; both are input-validation holes closed here. `isFinite` is defined at `internal/service/objects.go:382`. - **No tainted-data path to SQL/templates/commands.** The only values persisted are `c.x, c.y, radius`, all derived from validated, finite inputs (`radius` is checked finite/positive at ops.go:136; `spacing` from a validated plant row or a bounds-checked override at ops.go:130). No string interpolation into queries; writes go through `s.store.CreatePlantings`. - **No overflow/injection in `fitAxis`.** With radius>0 and finite region bounds, `step` is positive and the `int(math.Floor(...))` conversion operates on a finite value; `total > maxFillPlops` (ops.go:253) caps any pathological region before allocation, so a hostile huge-but-finite region can't drive resource exhaustion here. No security findings to report. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> **Verdict: No material issues found** I re-derived the geometry from first principles for all the load-bearing cases, and it holds up. - **60×60 bed, spacing 10 (radius 15, pitch 30, rowH ≈25.98, inset 10):** `fitAxis` gives cols=2 (x0=15), rows=2 (y0≈17.01). `total = (2+1)/2*2 + 2/2*1 = 2 + 1 = 3` ✓. Row 0 (even) at x=±15, row 1 (odd, staggered=1) at x=0 — 3 plops, matching the updated `TestFillRegionDeterministicPacking` and its "fits inside" assertion (plop edges reach ±27.99 < 30). Verified by reading `ops.go:224-272` and `ops_test.go:247-282`. - **Garlic 122×244, radius 22.5, spacing 15 (pitch 45, rowH ≈38.97, inset 15):** x-axis: (122−30)/45 = 2.044 → cols=3, x0=16 → first centre at −61+16 = −45 ✓ (`wantRowStarts[0]`). y-axis: (244−30)/38.97 = 5.49 → rows=6, y0≈24.57. Odd row first centre at −61+16+22.5 = −22.5 ✓ (`wantRowStarts[1]`). `total = 7/2*3 + 6/2*2 = 9 + 6 = 15` ✓. Outer plop overhang = 45+22.5−61 = 6.5 ≤ 7.5 (spacing/2) ✓. Matches the PR's "AFTER" table exactly. - **Exact-fit 90×90, radius 15, spacing 10 (inset 10):** cols=3 (x0=15 → first centre −30), rows=3; odd row first centre −15 ✓. Overhang 0 ≤ 5 ✓. - **Stagger semantics:** the odd-row offset is `pitch/2` (half a pitch), not a whole one (`ops.go:265`), and I verified algebraically that *both* the even and odd rows centre on `MinX + length/2`, so opposite margins match — the property `TestHexCentersEdgeInset` asserts. - **Count-before-build consistency:** the `total` formula `(rows+1)/2*cols + rows/2*staggered` exactly matches what the build loop emits (even rows emit `cols`, odd rows emit `staggered`), including the `cols==1` degenerate case where `staggered` stays `cols`. Verified at `ops.go:248-271`. The `total > limit → (nil, total)` path and the caller's `total > maxFillPlops` refusal (`ops.go:153-155`) are consistent. - **`fitAxis` epsilon:** `eps=1e-9` only lifts a result that is FP-noise-below an integer; it cannot inflate a genuinely-non-integer quotient, and when the quotient is exactly integer `k`, `floor(k+eps)=k` is unchanged. No off-by-one risk. Verified at `ops.go:295-297`. - **`empty()`/inverted-region guard:** `clampTo` inverts a non-overlapping region (Max < Min), and `hexCenters`'s `r.empty()` check (`ops.go:231`) catches it before `fitAxis` sites a plop off the bed — matching `TestFillRegionOutsideObjectPlantsNothing`. `contains` is fully removed with no remaining callers (grep confirms). - **Non-finite input:** the explicit `isFinite` check (`ops.go:147-150`) precedes `clampTo`; `isFinite` is defined in `objects.go:382` in the same package. Covers the NaN/Inf cases the test exercises. One design note that is *not* a bug: when a region is too narrow for `2*inset` on one axis, `fitAxis` takes the "tiny" path (`n=1, start=length/2`) and the lone plop can overhang by *more* than half a spacing (since `length/2 < inset`). This is deliberate per the PR ("one plop in the middle beats refusing to plant") and isolated in `TestHexCentersTinyRegion`; `TestHexCentersEdgeInset` uses dimensions that avoid that path. The overhang-budget assertion is therefore a property of normal-sized regions, not a universal invariant — which is consistent with the stated semantics. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — No material issues found</summary> Verdict: No material issues found Through the cleanliness/maintainability lens I verified the change against the checked-out code and found nothing material. - `Region.contains` removal is clean: grep across `internal` finds no remaining callers, and the lattice is now count-bounded (`fitAxis` → `for row < rows`), so the old condition loop is genuinely replaced, not just papered over (`internal/service/ops.go:258`). - `empty()` is used exactly where it should be (`hexCenters` at `ops.go:231`), and its doc explains the inverted-region convention that `clampTo` relies on — a good "name once" trade versus bare `MaxX < MinX` checks. The `clampTo` doc correctly points to it. - `fitAxis` is single-purpose, well-named, and its `step<=0` guard is explicitly documented as currently-unreachable-but-deliberate (`ops.go:283`); that matches the codebase's habit of prose comments over leaving traps for readers. - The `total` pre-count and the build loop both encode the stagger pattern, which is a mild duplication risk, but the exact-count is load-bearing (it gates the early-return-before-allocating), well-commented at `ops.go:245`, and `TestHexCentersEdgeInset` asserts `total == len(pts)` so the two can't silently drift. - No dead/duplicated helpers: `rect`, `clampTo`, `empty`, `fitAxis`, `hexCenters` all have live callers (`grep` confirmed). New test helpers (`seedFillBed`, reuse of `seedOwnPlant`) follow the existing seeding pattern; the small repeated garden-creation boilerplate matches `TestFillRegionCappedForHugeArea` already in the file, so it's not a new inconsistency. - Imports added to `ops_test.go` (`math`, `sort`) are both used; `isFinite` resolves to the existing helper in `objects.go:382`. The long prose doc on `hexCenters` is stylistically heavy, but it's consistent with this file's existing commentary style (the file header itself is several lines of prose), so I don't flag it as a maintainability regression. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> No material issues found Through the performance lens, this change is a net improvement, not a regression: - **Count-before-build avoids the wasteful allocation.** `hexCenters` now computes `total` via closed-form arithmetic (`fitAxis`) and returns `(nil, total)` when `total > maxFillPlops` (`ops.go:253-255`). The old code built the entire lattice into a growing slice and only afterwards checked `len(centers) > maxFillPlops` — so a fill large enough to be refused allocated (and grew) the whole lattice just to throw it away. For a pathologically large region this was unbounded work before the guard fired; now it's O(1) arithmetic. Verified at `ops.go:224-255`. - **Exact pre-sizing.** `pts := make([]localPoint, 0, total)` (`ops.go:257`) reserves exactly the count that will be appended, since `total` is derived to match the build loop exactly (odd/even row split: `(rows+1)/2*cols + rows/2*staggered`). The old append loop relied on slice growth with reallocation. The comment explicitly notes the old `rows*cols` would have over-reserved by ~12%; this avoids that too. - **No new hot-path work introduced.** `coveredByExisting` is still a per-candidate linear scan over `existing` (`ops.go:164-167`), but that's pre-existing behavior unchanged by this PR — the per-fill cost remains O(centers × existing), which is inherent to the de-duplication requirement and not worsened here. No N+1, no unbounded growth, no avoidable quadratic behavior introduced. The only loop added (`ops.go:258-270`) is the necessary O(total) construction of exactly the points returned. </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> **VERDICT: No material issues found** Through the error-handling & edge-case lens, the diff is unusually well defended. I verified each unhappy path against the checked-out code: - **Non-finite region** (`ops.go:147-150`): all four bounds are checked with `isFinite` (defined at `objects.go:382`) *before* `clampTo`, so NaN/Inf can no longer reach `fitAxis`'s float→int conversion or slip through `empty()` (which compares false on NaN). Confirmed. - **Inverted/empty region** (`ops.go:231-233`, `47-49`): `clampTo` expresses "no overlap" by inverting (Max below Min); the new `empty()` catches both axes, and `hexCenters` returns `(nil, 0)` before computing any geometry. `TestFillRegionOutsideObjectPlantsNothing` exercises this (region east of bed → `MinX=500, MaxX=50`). - **Tiny region** (`fitAxis`, `ops.go:289-291`): `length < 2*inset` (and the unreachable-but-defensive `step <= 0`) returns a single centered plop rather than dividing by a non-positive step (±Inf → garbage int). `TestHexCentersTinyRegion` covers centered and off-centre cases; the off-centre case correctly distinguishes "middle of region" from "(0,0)". - **Count-before-allocate** (`ops.go:252-255`): `total` is computed exactly (even rows × `cols` + odd rows × `staggered`, matching the build loop at `258-270`), so `len(pts) == total` and the `total > limit` refusal path needs no allocation. I re-derived the count: even rows in `0..rows-1` = `(rows+1)/2`, odd rows = `rows/2`; the build loop assigns `cols` to even rows and `staggered` (= `cols-1` when `cols>1`) to odd rows — exact match, no off-by-one. - **Integer overflow / float→int**: reachable inputs are bounded. `clampTo` restricts the region to object bounds (≤ `maxGardenCM` = 10_000cm, so length ≤ 20_000) before `hexCenters` sees it, and `radius = max(1.5*spacing, 15)` (≥15, pitch ≥30) keeps `rows`/`cols` small; the `int(math.Floor(...))` in `fitAxis` can't overflow on these magnitudes. A pre-`clampTo` Inf-length (e.g. `MaxX=1e308, MinX=-1e308`) is defused because `clampTo` clamps both ends to ±halfW, yielding an inverted (empty) region caught by `empty()`. - **Single-column stagger** (`ops.go:264-266`): `cols > 1` guard prevents a `staggered = cols-1 = 0` row from emitting zero plops asymmetrically; `n` stays `cols`. - **No swallowed errors / missing cleanup**: `fillLoaded` still returns `CreatePlantings`'s error; the non-finite and `total > limit` paths return `ErrInvalidInput` with no partial side effects (the early returns precede any store writes). Nothing in my lane is materially wrong. </details> </details> <details><summary><b>kimi-k2.6:cloud</b> (ollama-cloud) — No material issues found</summary> **Verdict: No material issues found** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> **No material issues found** The security-relevant changes in this PR are defensive improvements, not regressions: - **Non-finite region validation added** (`ops.go:147-150`): NaN and ±Inf in caller-supplied `Region` coordinates now return `ErrInvalidInput` before reaching geometry calculations or the store layer. The PR description accurately notes that without this check, NaN survives clamping (since NaN comparisons are always false), `empty()` cannot detect it, and `fitAxis` happily centers on it — leading to an opaque SQLite error or silent zero-plop success. - **Inverted/empty region guard preserved** (`ops.go:231-233`): `clampTo` inverts the region when there's no overlap; `empty()` catches this. `TestFillRegionOutsideObjectPlantsNothing` verifies no plants are created for regions completely outside the object. - **DoS bounding intact**: `maxFillPlops` (5000) and the pre-allocation count in `hexCenters` prevent runaway lattice generation. No new authn/authz gaps, injection vectors, unsafe deserialization, or secret leakage introduced. The `spacingOverride` path already validates finite values and range bounds (`ops.go:130`), and `radius` is re-validated at `ops.go:136` before use. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> No material issues found - I traced `fitAxis` and `hexCenters` against the test cases (122×244 garlic bed, 90×90 exact fit, 60×60 deterministic packing, tiny regions) and the arithmetic checks out: the centered lattice produces symmetric margins, the stagger offset is exactly half a pitch, and the `radius − spacing/2` inset caps overhang at the half-spacing budget. - The `total` pre-count formula `(rows+1)/2*cols + rows/2*staggered` correctly accounts for staggered rows having one fewer plop, and the capacity pre-allocation matches exactly. - The epsilon (`1e-9`) in `fitAxis` safely guards against floating-point drift on exact divisions without materially over-counting: the worst-case "false extra position" would be ~1e-9·step from the inset boundary, negligible at garden scale. - The non-finite region guard, empty-region early return, and clamped-outside-object behavior are all correct and covered by tests. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — No material issues found</summary> No material issues found. The diff is well-structured: dead code (`Region.contains`) is removed, the new `empty()` helper is appropriately extracted with a clear comment explaining why it exists, `fitAxis` cleanly separates the geometric counting from the lattice construction, and the extensive `hexCenters` godoc serves as a guard against the exact regression being fixed. Tests follow existing table-driven patterns and the new helpers (`seedFillBed`) are reused properly. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> **No material issues found** From the performance lens, this change is actually an improvement rather than a regression: - **Pre-counting avoids wasteful allocation**: `hexCenters` now computes the exact lattice count *before* allocating, and returns `(nil, total)` when `total > limit`. The old code built the entire slice first, then checked `len(centers) > maxFillPlops` — so a maliciously large region would allocate thousands of `localPoint` structs only to throw them away. - **Exact capacity eliminates reallocations**: `make([]localPoint, 0, total)` with an exact count means zero slice growth copies. The old code used an unbounded `var pts []localPoint` with repeated `append`, which could reallocate multiple times for large fills. - **Bounded loops replace condition-bounded iteration**: The old `for y := r.MinY + radius; y <= r.MaxY+eps` pattern iterated and then filtered with `r.contains(x, y)`. The new count-and-center approach eliminates the per-point `contains` call entirely; points are bounded by construction. No new allocations are introduced on hot paths, and the `isFinite` checks on region bounds are O(1) and run once per fill. The `fitAxis` helper uses a small constant amount of float arithmetic — negligible compared to the allocations it avoids. </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> **No material issues found** I investigated the change thoroughly through the error-handling and edge-cases lens. Here is what I checked and why it is clean: - **Non-finite region bounds:** `fillLoaded` now explicitly validates `region.MinX/Y` and `region.MaxX/Y` with `isFinite` before any downstream geometry work. Verified by reading `ops.go:147-150` and the matching test `TestFillRegionRejectsNonFiniteRegion` at `ops_test.go:189`. - **Inverted / empty regions:** `Region.empty()` correctly catches `clampTo`'s inverted-output case (Max < Min). `hexCenters` returns `(nil, 0)` immediately. Verified by reading `ops.go:47-49`, `ops.go:231-233`, and the test `TestFillRegionOutsideObjectPlantsNothing` at `ops_test.go:217`. - **Tiny regions:** `fitAxis` returns `(1, length/2)` when the span is smaller than `2·inset`, so `hexCenters` plants one plop in the middle rather than returning nothing or placing something off-bed. Verified by reading `ops.go:289-291` and the test `TestHexCentersTinyRegion` at `ops_test.go:166`. - **Exact count before allocation:** `hexCenters` computes `total` analytically (`(rows+1)/2*cols + rows/2*staggered`) and compares against `limit` before allocating. The formula exactly matches the nested loop body, so `len(pts) == total` is guaranteed; the test `TestHexCentersEdgeInset` asserts this at `ops_test.go:105`. - **`fitAxis` epsilon:** `eps = 1e-9` is added to prevent an exact fit from under-counting due to floating-point error (e.g. 60 cm span / 30 cm step). It is small enough that any over-count it might theoretically cause would shift a centre by sub-nanometre distances, irrelevant at garden-bed scale. - **`inset` never negative:** `inset := math.Max(0, radius-math.Max(0, spacing)/2)` clamps to zero, preventing centres from being placed outside the region. - **`radius <= 0` guard:** `hexCenters` returns early, and `fillLoaded` already validates `radius > 0`. Nothing in this diff ignores, swallows, or mishandles an error path, and the new edge-case guards (NaN/Inf, empty region, tiny region) are correct and well-tested. </details> </details> <details><summary><b>opencode/glm-5.2:cloud</b> (opencode) — No material issues found</summary> **Verdict: No material issues found** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> VERDICT: No material issues found I reviewed the change through the security lens (authn/authz, injection, input validation, DoS bounds, unsafe handling of untrusted data). The change is, if anything, a net improvement to input validation: - **Authz intact.** `FillRegion`/`FillNamedRegion` still call `objectForRole(ctx, actorID, objectID, roleEditor)` before `fillLoaded` (`ops.go:109`, `ops.go:315`); `visiblePlant` still masks cross-user plant ownership as `ErrInvalidInput` (`plantings.go:217-228`). The diff doesn't touch any permission gate. - **New non-finite input rejection (`ops.go:147-150`).** `isFinite` exists (`objects.go:382`) and is now applied to the caller-supplied `Region` *before* `clampTo`/`hexCenters`. I traced the taint: `math.Max(NaN, …)` propagates NaN, so without this guard NaN would reach `CreatePlantings`; +Inf would silently plant nothing. Both are now `ErrInvalidInput`. Verified the test `TestFillRegionRejectsNonFiniteRegion` covers both NaN and +Inf. - **DoS bound tightened.** `hexCenters` now computes `total` *before* allocating (`ops.go:252-255`) and returns `(nil, total)` over `limit`, so an oversized fill is refused without building the lattice. `FillRegion`'s `maxFillPlops` cap (5000, `ops.go:23`) still fires. `fitAxis` guards `step<=0` to avoid ±Inf-to-int garbage even though the only caller makes it unreachable — defensive, good. - **No injection surface.** No SQL changed; region coordinates still flow to `CreatePlantings` (parameterized store layer). No path/template/command injection introduced. - **Clamp-to-object still enforced.** `region.clampTo(o.WidthCM/2, o.HeightCM/2)` (`ops.go:152`) precedes lattice construction, so a region cannot plant outside the authorized object; `Region.empty()` (`ops.go:45-47`) makes a non-overlapping (inverted-by-clampTo) region plant nothing rather than at an off-bed point — covered by `TestFillRegionOutsideObjectPlantsNothing`. Minor non-security observation (not a finding): the NaN/Inf check runs *after* `visiblePlant`'s DB read, so an editor sending a malformed region still costs one plant lookup before rejection. That's an ordering nit, not a vulnerability. No security findings to report. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> Now let me verify the math. Let me work through the garlic example carefully. For the garlic bed: w=122, h=244, radius=22.5, spacing=15. - pitch = 45, rowH = 45*sqrt(3)/2 ≈ 38.97 - inset = max(0, 22.5 - 15/2) = max(0, 22.5-7.5) = 15 - X span = 122. fitAxis(122, 45, 15): length=122 >= 2*15=30. n = floor((122-30)/45 + eps) + 1 = floor(92/45) + 1 = floor(2.044) + 1 = 2+1 = 3 cols. x0 = (122 - 2*45)/2 = (122-90)/2 = 16. So cols=3, x0=16. - Y span = 244. fitAxis(244, 38.97, 15): n = floor((244-30)/38.97) + 1 = floor(214/38.97) + 1 = floor(5.49) + 1 = 5+1 = 6 rows. y0 = (244 - 5*38.97)/2 = (244 - 194.85)/2 = 49.15/2 = 24.57. Wait, the PR says 3 rows and 15 plops. Let me recompute. 244/38.97 = 6.26, so 6 rows possible. But the PR example shows only 3 rows... Hmm, the PR example "AFTER" shows row 0, 1, 2 — 3 rows. Let me reconsider. Actually wait, the PR example might be a truncated illustration. Let me re-read: "AFTER — 15 plops / row 0: 3 plops / row 1: 2 plops / row 2: 3 plops". That's only 3 rows = 8 plops, not 15. Hmm, the BEFORE also says 15 plops with 3 rows. That doesn't add up either (3+2+3=8). Actually maybe rows are listed selectively or the example is illustrative of the symmetry, not exhaustive. Let me not fixate on the PR prose; let me verify the actual code logic produces sensible results. Let me check the test assertions for the "4ft bed of garlic" case which uses wantRowStarts [-45, -22.5]. For r = rect(-61, -122, 61, 122). - x0 = 16 (computed above, offset from MinX). First row start x = -61 + 16 = -45. ✓ matches wantRowStarts[0]=-45. - Row 1 (staggered): x = -61 + 16 + 45/2 = -61+16+22.5 = -22.5. ✓ matches wantRowStarts[1]=-22.5. Good. Now let me verify the overhang. Row 0 start x=-45, radius=22.5. West edge of plop = -45-22.5 = -67.5. Region MinX=-61. Overhang = -61 - (-67.5) = 6.5. Budget = spacing/2 = 7.5. 6.5 ≤ 7.5 ✓. Now let me check the overhang for the last plop in row 0. 3 cols: x = -45, 0, 45. Last plop east edge = 45+22.5 = 67.5. MaxX=61. Overhang = 67.5-61 = 6.5 ≤ 7.5 ✓. Symmetric. Good. Now let me check the deterministic packing test: 60×60 bed, radius 15, spacing 10 (from seedFillBed with plant spacing 10). - pitch = 30, rowH = 30*sqrt(3)/2 ≈ 25.98 - inset = max(0, 15 - 5) = 10 - X span=60: fitAxis(60, 30, 10): length=60 >= 20. n = floor((60-20)/30 + eps)+1 = floor(40/30)+1 = floor(1.333)+1 = 1+1 = 2 cols. x0 = (60 - 1*30)/2 = 15. So cols=2, x0=15. - Y span=60: fitAxis(60, 25.98, 10): n = floor((60-20)/25.98)+1 = floor(40/25.98)+1 = floor(1.539)+1 = 1+1 = 2 rows. y0 = (60 - 1*25.98)/2 = 17.01. So rows=2, cols=2, staggered=1. total = (2+1)/2*2 + 2/2*1 = 1*2 + 1*1 = 3. ✓ matches expected 3 plops. Row 0: 2 plops at x=-30+15=-15, -15+30=15. y=-30+17.01=-12.99. Wait, region is rect(-30,-30,30,30). MinX=-30, x0=15. Row 0 x = -30+15 = -15, then +30 = 15. y = -30 + 17.01 = -12.99. Row 1 (staggered, cols>1): n=staggered=1, x = -30+15+15 = 0. y = -30+17.01+25.98 = 12.99. So plops: (-15,-12.99), (15,-12.99), (0,12.99). 3 plops. ✓ Overhang check: plop at (15,-12.99), radius 15. East edge = 30. MaxX=30. Overhang=0. West of (-15): -30, MinX=-30, overhang 0. South: y=-12.99-15=-27.99, MinY=-30, no overhang. North: 12.99+15=27.99 ≤30. Good, fits exactly. The test asserts no overhang. ✓ Now let me check the tiny region test. rect(-5,-5,5,5), radius 15, spacing 10. - pitch=30, rowH≈25.98, inset = max(0, 15-5)=10. - fitAxis(10, 30, 10): length=10 < 2*inset=20. Returns (1, 10/2=5). cols=1, x0=5. - fitAxis(10, 25.98, 10): length=10 < 20. Returns (1, 5). rows=1, y0=5. - staggered: cols=1, so stays 1 (cols>1 false). total = (1+1)/2*1 + 1/2*1... wait. (rows+1)/2*cols + rows/2*staggered = (2)/2*1 + (1)/2*1... integer division: (1+1)/2 = 1, rows/2 = 0. total = 1*1 + 0*1 = 1. - Row 0: n=cols=1, x=-5+5=0, y=-5+5=0. Plop at (0,0). ✓ Off-corner: rect(20,-40,30,-30). length x = 10, y = 10. Same as above but MinX=20, MinY=-40. - cols=1, x0=5. rows=1, y0=5. Row 0: x=20+5=25, y=-40+5=-35. ✓ matches wantX=25, wantY=-35. Now let me check the non-finite rejection and outside-object tests reference helpers that exist. Let me check seedFillBed, seedUser, seedOwnPlant, newTestService, openConfig exist.Let me verify the test helpers exist. Now let me verify the agent tool that calls FillRegion/FillNamedRegion still passes the right args, since the signature of `fillLoaded` is unchanged but `hexCenters` changed signature. Let me check if any other caller of hexCenters exists outside the file. Already grepped — only ops.go and ops_test.go. Good. Let me check the agent tool adapters to make sure nothing else broke. Let me check the `TestFillRegionCappedForHugeArea` test still works correctly — it relies on the over-limit path returning total > maxFillPlops. Let me verify nothing else referenced the removed `Region.contains` method. I verified the math from first principles against the checked-out code and tests. Let me confirm one more edge — the `derivedCount` and store path aren't affected. The change is clean. I verified the math from first principles against the checked-out code, all tests reference existing helpers, no callers of the removed `Region.contains` remain, and the `hexCenters` signature change is confined to `ops.go` + `ops_test.go`. One observation worth surfacing on the `+Inf` rejection path: the test asserts `err == ErrInvalidInput` for `MaxY = math.Inf(1)` and the new guard catches it. But there's a subtle gap — `clampTo` already happens *after* the finite check, so the check is correctly placed. Let me double-check one thing about the `fitAxis` epsilon interaction with very large beds, since the cap test uses a 6000×6000 bed with radius 15. For bed 6000×6000, radius 15, spacing 10: pitch=30, rowH≈25.98, inset=10. fitAxis(6000, 30, 10): n = floor((6000-20)/30)+1 = floor(199.33)+1 = 199+1 = 200 cols. fitAxis(6000, 25.98, 10): n = floor(5980/25.98)+1 = floor(230.17)+1 = 230+1 = 231 rows. total = (231+1)/2*200 + 231/2*199 = 116*200 + 115*199 = 23200 + 22885 = 46085 > 5000. Correctly capped. ✓ Everything verifies. The change is correct. ## Verdict: No material issues found I reviewed through the **Correctness** lens and re-derived the geometry from first principles against the checked-out `internal/service/ops.go`: - **Half-spacing rule**: For garlic (w=122, h=244, radius=22.5, spacing=15): `inset = max(0, 22.5 - 7.5) = 15`. `fitAxis(122, 45, 15)` → cols=3, x0=16. First-row start x = -61+16 = -45 (matches `wantRowStarts[0]=-45`); staggered row start x = -61+16+22.5 = -22.5 (matches `wantRowStarts[1]=-22.5`). Overhang = 45+22.5-61 = 6.5 cm, under the 7.5 cm half-spacing budget on both edges, symmetric. ✓ - **Deterministic packing test** (60×60, radius 15, spacing 10): `fitAxis(60, 30, 10)` → cols=2, x0=15; `fitAxis(60, 25.98, 10)` → rows=2, y0≈17.01. `total = (2+1)/2*2 + 2/2*1 = 3`. Plops: (-15,-12.99), (15,-12.99), (0,12.99). No overhang (edges land exactly at ±30). Matches the asserted 3. ✓ - **Tiny region fallback** (`fitAxis` `length < 2*inset` → 1 plop centered): verified both origin-centered `rect(-5,-5,5,5)`→(0,0) and off-centre `rect(20,-40,30,-30)`→(25,-35). ✓ - **Cap test** (6000×6000): 200 cols × 231 rows → total = 46 085 > 5000, refused via the pre-build count path without allocating. ✓ - **Non-finite guard**: placed before `clampTo`, so NaN/Inf in the region are rejected with `ErrInvalidInput` rather than leaking into SQLite or producing a silent zero-plop success. ✓ - **Outside-object region**: `clampTo` inverts to `MinX=500, MaxX=50` → `empty()` returns true → 0 plops. ✓ - **Dead code**: `grep` confirms no remaining callers of the removed `Region.contains`. The `hexCenters` signature change is confined to `ops.go` and `ops_test.go`; agent tools and API call the unchanged `FillRegion`/`FillNamedRegion`. ✓ The new total-count formula `(rows+1)/2*cols + rows/2*staggered` correctly partitions rows into even-indexed (full `cols`) and odd-indexed (`staggered = cols-1` when cols>1), and the build loop matches it exactly (verified by re-deriving both halves for the three test cases above). `fitAxis`'s epsilon is the right magnitude — large enough to recover an exact fit lost to float error, small enough not to add a spurious position. I found nothing materially wrong in this lens. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — No material issues found</summary> VERDICT: No material issues found I reviewed the diff through the cleanliness/maintainability lens and verified the key claims against the checked-out code. - `Region.contains` is genuinely dead after removal — `grep` for `\.contains(` finds no remaining callers in the repo. Good cleanup; the count-bounded loop replacing it is clearer. - `empty()`, `clampTo`, `isFinite`, `hexCenters`, `fitAxis` are each used and resolve correctly (`isFinite` lives in `objects.go:382`, reused here rather than redefined — no duplication). - The `hexCenters` total-derivation matches what the build loop produces (odd rows get `cols`, even rows get `staggered = cols-1` when `cols > 1`), and the capacity hint is exact rather than an over-reserve — the stated reason for the up-front count holds. - The `fitAxis` `step <= 0` guard is explicitly documented as unreachable-but-defensive; that is a deliberate, well-explained choice, not dead code to flag. - Names are clear (`inset`, `staggered`, `pitch`, `rowH`), the function is short and flat, and the reassignment of `n, x` inside the row loop reads cleanly. One trivial observation (not a finding): the "plop is a clump, half-spacing at the edge" rationale now appears in three places — `CLAUDE.md`, `DESIGN.md`, and the `hexCenters` doc comment. Each serves a distinct audience (convention / decision / regression guard), and the PR consciously updates all three in one commit, so I would not call this churn-worthy. The `clampTo` comment's phrasing "comes back empty — see empty()" is slightly loose (clampTo inverts; `empty()` detects the inversion), but the cross-reference makes it unambiguous. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> No material issues found. Reviewed through the performance lens. The change is geometry, and on the efficiency axis it is neutral-to-positive: - **Count-before-build is an improvement, not a regression.** The old `hexCenters` allocated the full lattice, returned it, and only then did `fillLoaded` check `len(centers) > maxFillPlops` (ops.go:152-154 in the old form). The new `hexCenters(r, radius, spacing, maxFillPlops)` derives `total` arithmetically (`(rows+1)/2*cols + rows/2*staggered`) and returns `(nil, total)` over the limit without ever allocating the slice (ops.go:252-255). A refused fill of 5000+ candidates no longer pays for the points it's about to throw away. - **The capacity reservation is exact.** I verified the `total` formula against the build loop for several row/col counts (rows=2/cols=3 → 5; rows=3/cols=3 → 8; rows=1/cols=N → N; cols=1 → rows). `make([]localPoint, 0, total)` neither over-reserves (the comment's "~12%" note about `rows*cols`) nor under-reserves (which would trigger slice regrowth). No realloc on the hot path. - **No new loops over unbounded collections.** `fitAxis` is O(1); `hexCenters`' build loop is O(total), bounded by `maxFillPlops`. `coveredByExisting` and the per-candidate `existing = append(...)` are pre-existing and unchanged by this diff. - The non-finite guard (ops.go:147-150) is four `isFinite` calls on the request path — negligible, and it prevents the downstream work that NaN/+Inf would otherwise do. </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> I'll examine the actual code to verify edge cases and error handling. Now let me look at the test for the non-finite region case to verify the test claim about Inf being silent: Now let me verify the `math.Max(0, spacing)/2` for negative spacing - does the existing validation reject negatives? Looking at the error-handling and edge-case lens, I've traced the new code paths carefully. Let me verify a couple of suspicions against the actual code. I've traced all the new/changed error paths and edge cases in the diff against the actual code. Let me record my findings. **VERDICT: No material issues found** Through the error-handling and edge-case lens, I verified the new unhappy paths and they hold up. Notes on what I checked: - **Non-finite region bounds** (`ops.go:147-150`): the new `isFinite` guard runs *after* `radius` validation but *before* `clampTo`, so NaN/Inf bounds are rejected as `ErrInvalidInput` before they can reach the inverted-region guard or `fitAxis`. I confirmed `isFinite` is defined at `objects.go:382` and rejects both NaN and ±Inf. The PR description's claim that "+Inf would otherwise silently plant nothing" is consistent with the old `hexCenters` form (the loop never advances past `r.MaxY+eps` when `MaxY` is +Inf, and `r.contains`/`empty` can't see it). The new `clampTo` keeps Inf (`math.Min(+Inf, halfW)=halfW`), so without the guard `fitAxis` would compute `length=200` and return valid positions — the guard is doing real work. ✓ - **Inverted-region / outside-bed case** (`ops.go:231`, `47-49`): `empty()` is correctly checked first inside `hexCenters`, so a region clamped to `MinX>MaxX` (object missed entirely) plants nothing rather than one plop. `TestFillRegionOutsideObjectPlantsNothing` pins this. ✓ - **Tiny region fallback** (`fitAxis:289-291`): when `length < 2*inset`, `fitAxis` returns `(1, length/2)` — one plop centered. I verified this never produces a negative `start` and is bounded since `length` is `Max-Min ≥ 0` after the `empty()` guard. ✓ - **`radius <= 0` and `step <= 0` guards**: `hexCenters` returns `(nil, 0)` for `radius <= 0` (so `total > limit` is never true spuriously), and `fitAxis`'s `step <= 0` half is unreachable but defensively guarded against ±Inf from division. ✓ - **Negative `spacing`**: `math.Max(0, spacing)/2` at `ops.go:240` clamps it, but it's also a dead branch since `plant.SpacingCM` is validated `≥ minPlantSpacingCM (0.1)` at `plants.go:319` and the override at `ops.go:130`. The defensive clamp is harmless. ✓ - **Empty-batch path** (`fillLoaded` with `len(centers)==0`): `CreatePlantings` explicitly no-ops on empty input (`plantings.go:241-243`), `record` early-returns on `len(changes)==0` (`revisions.go:194`), so an all-covered-by-existing or zero-center fill returns `(nil/empty, nil)` cleanly with no spurious history entry. ✓ - **Over-limit path** (`hexCenters` returns `(nil, total)` with `total > limit`): caller refuses with `ErrInvalidInput` before allocating the lattice — matches the stated intent. ✓ No swallowed errors, missing defers, nil-deref, or boundary issues introduced by this change within my lane. </details> </details> <details><summary><b>opencode/kimi-k2.6:cloud</b> (opencode) — No material issues found</summary> **Verdict: No material issues found · ⚠️ 1/5 lens(es) errored** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> **Verdict: No material issues found** **Security lens review:** I traced the changed code through `internal/service/ops.go` and the test suite, focusing on input validation, bounds checking, and potential misuse of untrusted data. - **Non-finite region validation** (ops.go:147–150): The PR *adds* an explicit `isFinite` check on all four region bounds before clamping. Without this, `NaN` survived clamping and the inverted-region guard (NaN compares false both ways), eventually reaching the store as a NULL constraint failure. `+Inf` produced a silent zero-plop success. This is a correct hardening of the input surface. - **`Region.empty()` guard** (ops.go:231–233): A region that misses the object entirely (after `clampTo` inverts it) now returns zero plops. Verified by `TestFillRegionOutsideObjectPlantsNothing`. Prevents planting outside the authorized object boundary. - **`maxFillPlops` enforcement unchanged**: The new `hexCenters` pre-counts positions and returns `(nil, total)` when `total > limit`, avoiding allocation for oversized fills. The caller (`fillLoaded`) still returns `ErrInvalidInput` when over the cap. - **No injection vectors introduced**: The diff does not touch SQL construction. All store interactions go through typed `domain.Planting` structs and existing store methods. - **Spacing/radius validation chain intact**: `spacingOverride` is finite-and-bounded checked; the derived `radius` is finite-and-positive checked before use. Even if a stored `plant.SpacingCM` were corrupt, `defaultPlopRadius` → `radius <= 0` catches it. Nothing in this diff introduces, widens, or bypasses an authn/authz, injection, or unsafe-data path. The changes are purely geometric and actually tighten one validation boundary. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> **Verdict: No material issues found** Reviewed through the correctness lens. The core geometry change is sound: - **`fitAxis` centering logic verified**: Re-derived for the exact-fit case (60 cm span, 30 cm pitch, 15 cm inset) → `n=2`, `start=15`, giving centers at `x=±15` relative to the span's start. Correct. For the garlic case (122 cm span, 45 cm pitch, 15 cm inset) → `n=3`, `start=16`, giving centers at 16, 61, 106, which from origin at –61 are –45, 0, +45. Correct. - **Half-spacing edge rule verified**: `inset = radius – spacing/2`. For garlic (r=22.5, s=15), inset=15. Outer plop at ±45 has rim at ±67.5; bed edge at ±61; overhang = 6.5 cm, within the 7.5 cm half-spacing budget. Correct. - **Stagger offset verified**: Odd rows get `staggered = cols-1` and start at `x0 + pitch/2`, so the first staggered center is offset by exactly half a pitch from the first non-staggered center. For the garlic case: non-staggered starts at –45, staggered starts at –22.5 (which is –45 + 22.5). Correct. - **`total` arithmetic verified**: Integer formula `(rows+1)/2*cols + rows/2*staggered` is exact for the alternating-row pattern, not an upper bound. Tested with 3-row and 4-row examples manually. - **`clampTo` + `empty()` interaction verified**: A region wholly outside the object inverts to `Max < Min`, which `empty()` catches, causing `hexCenters` to return `nil, 0`. The new `TestFillRegionOutsideObjectPlantsNothing` covers this. - **Non-finite guard verified**: `isFinite` check on all four region bounds before clamping prevents NaN/+Inf from silently corrupting the lattice or causing misleading errors downstream. No logic bugs, arithmetic errors, or semantic/domain correctness issues introduced by this change. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — No material issues found</summary> No material issues found. The code is clean and well-structured: - `Region.empty()` and `fitAxis()` are small, focused helpers with clear names and explicit documentation for non-obvious behavior (why `empty()` exists, why `fitAxis` keeps its `step <= 0` guard). - `hexCenters` is reorganized into bounded counting + bounded allocation, replacing the old condition-bounded loop. The comments are extensive but warranted given the geometric subtlety and the `#75` history. - Tests follow existing patterns (`seedFillBed`, `seedOwnPlant`) and add targeted coverage for edge cases (`TinyRegion`, `NonFiniteRegion`, `OutsideObjectPlantsNothing`). - No dead or duplicated code, no confusing names, no overly long functions. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> **No material issues found** The PR improves allocation efficiency rather than regressing it: - `hexCenters` now computes an exact count before allocating, replacing the old unbounded condition-based loop that grew a slice through repeated `append`. The new `make([]localPoint, 0, total)` pre-sizes the backing array to exact capacity, eliminating all reallocations. - The removed `Region.contains` per-point check is now satisfied by construction via `fitAxis`, saving a comparison on every lattice point. - The added `isFinite` guard and `Region.empty()` check are O(1) and run once per fill. No hot-path work is added, no N+1 patterns introduced, and no quadratic behavior is new. The counting-before-building logic is a straightforward win. </details> <details><summary><b>🧯 Error handling & edge cases</b> — ⚠️ could not complete</summary> ⚠️ This reviewer failed to complete: opencode run context deadline exceeded </details> </details> </details> <sub>Automated adversarial review by Gadfly — consensus across the model swarm. Advisory only — does not block merge.</sub>
steve added 1 commit 2026-07-21 14:23:24 +00:00
Address Gadfly findings on #76
Build image / build-and-push (push) Successful in 5s
f8929a19a8
- clampTo's doc justified itself by stopping hexCenters "looping forever",
  which stopped being true when hexCenters became count-bounded. Say what it
  actually does now, and note the inversion the new guard relies on.
- Trim the changelog prose from hexCenters' doc down to the one line that
  earns its keep: don't re-anchor at the min corner, and why.
- Rename a test local from `max` so it stops shadowing the builtin.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
steve added 1 commit 2026-07-21 16:13:51 +00:00
Address second round of Gadfly findings on #76
Build image / build-and-push (push) Successful in 5s
70ff970672
- FillRegion's doc still said "half-pitch inset", left over from the first
  draft of the fix; the inset is radius - spacing/2. Two docs on the same
  function disagreeing is worse than either being terse.
- Reject non-finite region bounds. They survive clamping and the inverted-
  region guard (NaN compares false both ways). Nothing corrupt reached the
  table — SQLite stores NaN as NULL and NOT NULL refuses it — but NaN
  surfaced as a raw store error and +Inf as a silent zero-plop success.
- TestHexCentersTinyRegion used a region symmetric about the origin, so it
  could not distinguish "the middle of the region" from "the origin" and
  would have passed for an implementation that just returned (0,0). Added an
  off-centre case.

Not taken: the finding that `make(..., rows*cols)` over-allocates ~12%
because staggered rows hold cols-1. True, but the slice is capped at
maxFillPlops (5000) and the exact count needs a ceil/floor split for no
measurable gain.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
Author
Owner

Re-review please — three commits have landed since the last pass.

Round 1 findings, all taken:

  • clampTo's doc justified itself by stopping hexCenters "looping forever", which this PR made untrue by turning the loop count-bounded. Rewritten, and it now documents the inversion the new guard depends on.
  • Trimmed the changelog prose from hexCenters' doc to the one line worth keeping: don't re-anchor at the min corner, and why.
  • Renamed a test local that shadowed builtin max.

Round 2 findings, taken:

  • FillRegion's doc still said "half-pitch inset" — left over from the first draft of this fix, and contradicting hexCenters' own doc. The inset is radius - spacing/2.
  • Non-finite region bounds are now rejected. The finding called for a finiteness check and was right that there wasn't one, though the impact differs from the report: there is no +Inf panic, and nothing corrupt reached the table (SQLite stores NaN as NULL, so NOT NULL refuses it). What actually happened was NaN surfacing as a raw store error and +Inf as a silent zero-plop success — both lying about what went wrong. Now ErrInvalidInput.
  • TestHexCentersTinyRegion used a region symmetric about the origin, so it couldn't tell "the middle of the region" from "the origin" and would have passed for an implementation that just returned (0,0). Added an off-centre case.

Not taken: make(..., rows*cols) over-allocating ~12% because staggered rows hold cols-1. True, but the slice is capped at maxFillPlops (5000) and an exact count needs a ceil/floor split for no measurable gain.

Also worth flagging for its own sake: I found a bug of my own here that the first review never saw. Rewriting the loop as a counted lattice broke a case the old form got for free — clampTo inverts a region lying wholly outside the object rather than emptying it, and the new code planted 4 plops 225cm off the bed. Guarded and tested. It was in commit 2, and since this workflow doesn't trigger on synchronize, it would have merged unreviewed.

Re-review please — three commits have landed since the last pass. **Round 1 findings, all taken:** - `clampTo`'s doc justified itself by stopping `hexCenters` "looping forever", which this PR made untrue by turning the loop count-bounded. Rewritten, and it now documents the inversion the new guard depends on. - Trimmed the changelog prose from `hexCenters`' doc to the one line worth keeping: don't re-anchor at the min corner, and why. - Renamed a test local that shadowed builtin `max`. **Round 2 findings, taken:** - `FillRegion`'s doc still said "half-pitch inset" — left over from the first draft of this fix, and contradicting `hexCenters`' own doc. The inset is `radius - spacing/2`. - **Non-finite region bounds are now rejected.** The finding called for a finiteness check and was right that there wasn't one, though the impact differs from the report: there is no `+Inf` panic, and nothing corrupt reached the table (SQLite stores NaN as NULL, so `NOT NULL` refuses it). What actually happened was NaN surfacing as a raw store error and `+Inf` as a silent zero-plop success — both lying about what went wrong. Now `ErrInvalidInput`. - `TestHexCentersTinyRegion` used a region symmetric about the origin, so it couldn't tell "the middle of the region" from "the origin" and would have passed for an implementation that just returned `(0,0)`. Added an off-centre case. **Not taken:** `make(..., rows*cols)` over-allocating ~12% because staggered rows hold `cols-1`. True, but the slice is capped at `maxFillPlops` (5000) and an exact count needs a ceil/floor split for no measurable gain. Also worth flagging for its own sake: I found a bug of my own here that the first review never saw. Rewriting the loop as a counted lattice broke a case the old form got for free — `clampTo` *inverts* a region lying wholly outside the object rather than emptying it, and the new code planted 4 plops 225cm off the bed. Guarded and tested. It was in commit 2, and since this workflow doesn't trigger on `synchronize`, it would have merged unreviewed.
Author
Owner

@gadfly review

(My previous comment was meant to trigger this and didn't — it lacked the trigger phrase, so the run skipped and still reported success. Everything above still stands: three commits have landed since the last real pass, and the findings I took and declined are itemised in the comment before this one.)

@gadfly review (My previous comment was meant to trigger this and didn't — it lacked the trigger phrase, so the run skipped and still reported success. Everything above still stands: three commits have landed since the last real pass, and the findings I took and declined are itemised in the comment before this one.)
steve added 1 commit 2026-07-21 17:26:00 +00:00
Address third round of Gadfly findings on #76
Build image / build-and-push (push) Successful in 7s
28af101634
- hexCenters now derives its exact point count BEFORE building anything and
  returns it alongside the points, refusing over the cap without allocating.
  Previously it materialised the whole lattice and fillLoaded checked len()
  afterwards — so the "too large" path paid for the thing it was rejecting.
  This also makes the preallocation exact, which subsumes the earlier
  over-allocation finding I'd declined: staggered rows hold cols-1, so
  rows*cols over-reserved by ~12%.

- Region.empty() names the invariant that clampTo expresses "no overlap" by
  INVERTING the region rather than zeroing it. A bare `MaxX < MinX` at each
  call site was spreading a non-obvious convention across three functions.

The count is now load-bearing (it gates the cap), so the test asserts it
matches what actually gets built.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
Author
Owner

@gadfly review

Round 3 findings, both taken — and the first one subsumes a finding I'd declined earlier, which is a fair result:

  • hexCenters pre-allocating rows*cols before the cap check. Now it derives the exact count before building anything and returns it alongside the points, so an oversized fill is refused without allocating the lattice it's rejecting. Previously the "too large" path paid in full for the thing it was about to throw away. Making the count exact also fixes the ~12% over-allocation from staggered rows holding cols-1 — the round-2 finding I declined as not worth churn. It was worth it once there was a real reason to compute the count anyway.
  • clampTo encoding "empty" as an inverted region. Named it: Region.empty(). A bare MaxX < MinX was spreading a convention nobody would guess across three functions.

The count is load-bearing now (it gates the cap), so TestHexCentersEdgeInset asserts the reported total matches what actually gets built — otherwise the two could drift and the cap would guard a number nothing produces.

Nothing declined this round.

@gadfly review Round 3 findings, both taken — and the first one subsumes a finding I'd declined earlier, which is a fair result: - **`hexCenters` pre-allocating `rows*cols` before the cap check.** Now it derives the exact count *before* building anything and returns it alongside the points, so an oversized fill is refused without allocating the lattice it's rejecting. Previously the "too large" path paid in full for the thing it was about to throw away. Making the count exact also fixes the ~12% over-allocation from staggered rows holding `cols-1` — the round-2 finding I declined as not worth churn. It was worth it once there was a real reason to compute the count anyway. - **`clampTo` encoding "empty" as an inverted region.** Named it: `Region.empty()`. A bare `MaxX < MinX` was spreading a convention nobody would guess across three functions. The count is load-bearing now (it gates the cap), so `TestHexCentersEdgeInset` asserts the reported total matches what actually gets built — otherwise the two could drift and the cap would guard a number nothing produces. Nothing declined this round.
steve added 1 commit 2026-07-21 18:03:42 +00:00
Address fourth round of Gadfly findings on #76
Build image / build-and-push (push) Successful in 6s
958b90ebc6
- Split hexCenters' doc: the count/limit contract had run straight on from
  the #75 anti-regression paragraph with no separator, so its opening "It"
  read as referring to the wrong thing.
- Write the stagger as pitch/2 rather than radius. Same value, but the intent
  is "half a pitch" and only incidentally "one radius".
- fitAxis's step<=0 guard is unreachable from its only caller. Kept, and now
  says so: a helper this small shouldn't need its caller read to be shown
  safe, and the failure mode without it is ±Inf into an int conversion.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
Author
Owner

@gadfly review

Round 4, all three taken:

  • Doc paragraphs merged without a separator. The count/limit contract ran straight on from the #75 anti-regression paragraph, so its opening "It" pointed at the wrong subject. Split into its own section.
  • Stagger written as radius rather than pitch/2. Same value — pitch is 2*radius, and 2*r/2 is exact in IEEE754 — but the intent is "half a pitch" and only incidentally "one radius". Now says what it means.
  • fitAxis's step<=0 branch is unreachable from its only caller, which is correct. Kept rather than deleted, and the comment now says so explicitly: without it a non-positive step gives ±Inf into an int conversion, and a helper this small shouldn't require reading its caller to be shown safe. Flagging the reasoning here so it isn't re-raised as an oversight next round.

This is intended as the last pass. The substantive findings across four rounds were the finiteness guard and the pre-allocation-before-cap; rounds 3 and 4 have otherwise been comment prose, which is a reasonable place to stop.

@gadfly review Round 4, all three taken: - **Doc paragraphs merged without a separator.** The count/limit contract ran straight on from the #75 anti-regression paragraph, so its opening "It" pointed at the wrong subject. Split into its own section. - **Stagger written as `radius` rather than `pitch/2`.** Same value — `pitch` is `2*radius`, and `2*r/2` is exact in IEEE754 — but the intent is "half a pitch" and only incidentally "one radius". Now says what it means. - **`fitAxis`'s `step<=0` branch is unreachable** from its only caller, which is correct. Kept rather than deleted, and the comment now says so explicitly: without it a non-positive step gives ±Inf into an int conversion, and a helper this small shouldn't require reading its caller to be shown safe. Flagging the reasoning here so it isn't re-raised as an oversight next round. This is intended as the last pass. The substantive findings across four rounds were the finiteness guard and the pre-allocation-before-cap; rounds 3 and 4 have otherwise been comment prose, which is a reasonable place to stop.
gitea-actions bot reviewed 2026-07-21 18:14:23 +00:00
gitea-actions bot left a comment

🪰 Gadfly consensus review — 1 inline finding on changed lines. See the consensus comment for the full ranked summary.

Advisory only — does not block merge.

<!-- gadfly-inline-review --> 🪰 **Gadfly consensus review** — 1 inline finding on changed lines. See the consensus comment for the full ranked summary. <sub>Advisory only — does not block merge.</sub>
@@ -174,3 +192,1 @@
// standard hexagonal packing at a 2×radius pitch. The lattice is anchored one
// radius inside the region's min corner so the first plop sits inside it.
func hexCenters(r Region, radius float64) []localPoint {
// hexCenters returns hex-packed lattice centers filling a region: rows radius·√3

🟡 Half-spacing rule explanation duplicated near-verbatim across CLAUDE.md, DESIGN.md, and two doc comments

maintainability · flagged by 1 model

🪰 Gadfly · advisory

🟡 **Half-spacing rule explanation duplicated near-verbatim across CLAUDE.md, DESIGN.md, and two doc comments** _maintainability · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
steve added 1 commit 2026-07-21 18:27:59 +00:00
Address fifth round of Gadfly findings on #76
Build image / build-and-push (push) Successful in 5s
07d598cffd
- fillLoaded's doc listed what it does and omitted the non-finite-region
  rejection this PR added to it.
- Trim the half-spacing rule's restatement in DESIGN.md to the decision and a
  pointer. The rule, the square-foot arithmetic and the failure mode are
  written out once, in hexCenters, rather than near-verbatim in four places.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
steve merged commit 437c535cd1 into main 2026-07-21 18:28:18 +00:00
Sign in to join this conversation.