Skip to content

Persist whether a stored centroid lies inside its own geometry - #11

Merged
sebschlo merged 1 commit into
masterfrom
fix/persist-centroid-validity
Aug 20, 2026
Merged

sebschlo merged 1 commit into
masterfrom
fix/persist-centroid-validity

Conversation

@sebschlo

@sebschlo sebschlo commented Aug 20, 2026 •

Copy link
Copy Markdown
Owner

Follow-up to #9, which was merged while Codex's last review round was still open. This closes the one finding from that round that is live on real data.

Single-purpose by design: the cross-batch shadowing detector that used to be in this PR has moved to a follow-up so this branch carries only the change that alters real output. Nothing here depends on it.

The bug

normalizePlace() refuses a home-cell claim to a centroid that falls outside its own shape — the bounding-box fallback for a concave, holed, or multipart polygon can easily land in a notch or a hole. That decision lived only in memory.

cellCandidateFromCompactRow() rebuilds an --append incumbent from its stored latitude/longitude alone, so every batch after the one that wrote the place handed the claim straight back. A challenger genuinely centred in a contested cell could then lose it to a point that is not inside the incumbent at all — the exact inversion the home-cell rule exists to prevent.

This is not hypothetical. Instrumenting the gate over real Who's On First data: 105 of 18,644 places in the seven-country slice (52 of 3,354 in a ch/at/nl/cz probe) have a centroid outside their own polygon. The world database is built as 26 --append batches, so 25 of them see those incumbents through this path.

The fix

Store the decision in a nullable compact_places.centroid_inside and read it back on append.

  • NULL means "not recorded" and keeps the pre-upgrade behaviour of trusting the coordinates, so existing databases are unchanged until they are rebuilt.
  • Older schemas gain the column in place via ALTER TABLE ... ADD COLUMN, alongside population/area.
  • Readers never select it.

Catalogued as schema generation 5 in COMPATIBILITY.md, with its own permanent reader fixture in spec/reader_compatibility_spec.js pinning all three states a row can carry — 1, 0, and the NULL every pre-upgrade row keeps.

Validation

Red-green: does not restore a home claim for a centroid outside its own geometry. A horseshoe whose bbox midpoint lands in its slot, and a town in the next country genuinely centred in the cell that midpoint falls in — a cell the horseshoe really does clip, so it legitimately emits it. The two are written in separate --append batches. Ignoring the stored flag fails with Expected 9001 to equal 9002 / Expected 'US' to equal 'MX'.

Full suite green: 220 specs, 0 failures.

Closes the review thread at #9 (comment).

Normalization refuses a home-cell claim to a centroid that falls outside
its own shape - the bounding-box fallback for a concave, holed, or
multipart polygon can easily land in a notch or a hole. That decision
lived only in memory. cellCandidateFromCompactRow() rebuilds an append
incumbent from its stored coordinates, so every batch after the one that
wrote the place handed the claim straight back, and a challenger
genuinely centred in a contested cell could lose it to a point that is
not inside the incumbent at all.

The decision is now stored in a nullable compact_places.centroid_inside
and read back on append. NULL means "not recorded" and keeps the
pre-upgrade behaviour of trusting the coordinates, so existing databases
are unchanged until they are rebuilt; older schemas gain the column in
place with ALTER TABLE. Readers never select it. Catalogued as schema
generation 5 in COMPATIBILITY.md with its own permanent reader fixture,
which pins all three states a row can carry - 1, 0 and the NULL every
pre-upgrade row keeps.

This is live on real data, not a hypothetical: 52 of 3,354 places in
ch/at/nl/cz have a centroid outside their own polygon (32 CH, 18 NL, 1 AT,
1 CZ), and the world database is built as 26 --append batches, so 25 of
them see those incumbents through this path.

Red-green spec: a horseshoe whose bbox midpoint lands in its slot, and a
town in the next country genuinely centred in the cell that midpoint
falls in - a cell the horseshoe really does clip, so it legitimately
emits it. Ignoring the stored flag fails with Expected 9001 to equal
9002 / Expected 'US' to equal 'MX'.
@sebschlo

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5f70570bb5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/generate_boundary_index.js
@sebschlo

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c029da5b13

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/generate_boundary_index.js Outdated
@sebschlo

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dc8ba6f0f5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/generate_boundary_index.js Outdated
if (!answeredAt) continue

for (var finer = answeredAt + 1; finer <= deepest; finer++) {
var hash = place.homeGeohash.slice(0, finer)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Encode descendant candidates beyond precision 12

When an existing database contains geohashes deeper than 12—supported because the builder and reader accept --max-precision values above 12—homeGeohash remains capped at HOME_CELL_PRECISION, so slicing it here repeatedly produces the same 12-character hash instead of the required descendants. Even after the stored-depth fix, this fresh evidence means an append whose home cell is shadowed at precision 13 or deeper can still report zero; generate hashes beyond the cached length as ownsHomeCell() already does.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #12 — the detector moved there so #11 stays single-purpose. Hashes past the cached home geohash are now encoded from the centroid as ownsHomeCell() does, rather than sliced out of a 12-character string. No spec: it is unreachable at any precision this project builds at (the world build runs --max-precision 5, and the cache is 12), so a test would only assert against a configuration nothing produces — but the cap is a builder option, so the code is wrong wherever it is raised and worth correcting.

🤖 Addressed by Claude Code

Comment thread scripts/generate_boundary_index.js Outdated
})

return { rows: kept, skipped: skipped }
var shadowed = await countHomeCellsShadowedByFinerRows(db, kept, placeById, batchPlaceIds, homeCellPriority)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scan descendants for candidates that lose their coarse row

When an appended place emits a coarse home cell that an existing co-centred, more populous place wins at the exact hash, kept no longer contains any row for the challenger, so the descendant scan skips it entirely. A finer persisted row can still be an invalid shadow—for example, its owner is not centred in that finer cell while the challenger is—and the within-batch nested reconciliation would give that finer cell to the challenger, but this counter reports zero. Preserve the original emitted rows for determining which home paths need scanning rather than deriving them only from exact-conflict winners.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real, and fixed in #12 — the detector moved there so #11 stays single-purpose. Scan paths no longer come from the conflict-resolution winners: every place with a valid home cell is scanned whether or not it kept a row, with a red-green spec whose challenger owns no row at all after the append.

🤖 Addressed by Claude Code

Comment thread scripts/generate_boundary_index.js Outdated
Comment on lines +1838 to +1840
var incumbent = cellCandidateFromCompactRow(existing)
if (comparePlacesForHash(candidate.place, incumbent, candidate.hash, hashCenterCache, true) < 0) {
shadowed += 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compare only the deepest stored owner on each home path

When earlier append batches have left multiple nested rows along the same centroid path, this loop independently counts every finer row that the challenger outranks. Runtime lookup uses only the longest matching prefix, so a shallower, worse-ranked owner can increment the counter even when a deeper owner correctly outranks the challenger; multiple worse-ranked rows also inflate one shadowed home cell into several. Group candidates by appended place and evaluate only its deepest surviving stored descendant so the reported condition matches the lookup result.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real, and fixed in #12 — the detector moved there so #11 stays single-purpose. Only the deepest owner on each centroid path is judged now, and the count is per place rather than per row, with a red-green spec putting a weak precision-4 owner under a precision-5 owner that correctly outranks the challenger.

🤖 Addressed by Claude Code

@sebschlo
sebschlo force-pushed the fix/persist-centroid-validity branch from dc8ba6f to 5f70570 Compare August 20, 2026 18:52
@sebschlo

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: 5f70570bb5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@sebschlo
sebschlo merged commit fb00431 into master Aug 20, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant