Skip to content

Keep counties out of the dominant-city rollup - #10

Merged
sebschlo merged 5 commits into
masterfrom
fix/dominant-city-excludes-county
Aug 20, 2026
Merged

sebschlo merged 5 commits into
masterfrom
fix/dominant-city-excludes-county

Conversation

@sebschlo

Copy link
Copy Markdown
Owner

Problem

promoteLocalityParentsByRegionCompetition rolls a parent geohash cell up to the place that dominates its children, so a metro reads as one city instead of a patchwork of suburbs. The candidate set comes from isCityPlacetypeCode, which counts county (PLACETYPE_CODES.county = 3) as city-like — and selectDominantLocalityId then ranks purely on population.

A county almost always outpopulates every town inside it, so it wins that ranking and the rollup does two things at once: the parent cell takes the county's name, and suppressMinorLocalities deletes the child cells of every minor locality under it. Points that had their own town's cell fall back to the parent and answer with a county name where a locality name is expected.

The dominant-city rollup exists to make a metro read as one city. A county is not a city.

This is what PR #9 hit from the other side: with per-cell ownership slightly redistributed, dr8t (Rochester NY) rolled up to Monroe County (748,482) and Brockport (8,366) and Greece (14,519) started answering "Monroe". That report is what prompted this change, but the behaviour predates it — on master today, Rochester, NH (29,752) answers "York", a county in a different state.

Change

selectDominantLocalityId still ranks every competitor for the parent cell on population, unchanged — a major county in the group still blocks a rollup exactly as before. What changes is the verdict when the winner is a placetype that may not name a metro: the rollup is abandoned, and every child keeps the cell it owns.

It deliberately does not fall through to the runner-up city. That city lost the same population competition; promoting it would name the parent cell after a place the comparator already rejected, which is the "muni renamed to the nearest big city" failure in a different costume.

Everything else about counties is untouched:

  • a county still wins cells through comparePlacesForHash on its own merits (placetype rank, population, distance);
  • a county incumbent still protects a parent cell from a takeover (isCityPlacetypeCode at the two incumbent checks);
  • a major county child is still never suppressed by a city rollup.

New --dominant-city-placetypes (default locality,localadmin; WOF_DOMINANT_CITY_PLACETYPES for the WOF wrapper). Passing locality,localadmin,county reproduces current builds exactly. A placetype that is not city-like at all (region) is rejected rather than silently ignored, because accepting it would look like it did something.

localadmin stays eligible: where it is included at all (--include-localadmin true, off by default) it is the municipality that carries the city's name — the Guatemala City case. These builds do not include localadmin, so the slice cannot separate it from locality; excluding it would be an untested guess.

Measurements

Slice: WOF_COUNTRIES=us,mx,nl,de,at,ch,cz, WOF_DOWNLOAD=0 against the pinned ref lockfile, WOF_MAX_PRECISION=5, WOF_COUNTY_MAX_PRECISION=5, WOF_MIN_POPULATION=5000, all seven countries in one batch. Same inputs, same binary, only WOF_DOMINANT_CITY_PLACETYPES differs. Answers come from geocoder.reverse through src/index.js, so they are what a caller sees, not what the table holds.

Label quality — every locality centroid in the slice (13,163 of them)

county eligible (today) county excluded (this PR)
answered by a county name 487 1
resolves to the place itself 9,075 9,490 (+415)
resolves to a different country 31 28
stopped reading as a county — 486
newly reading as a county — 0

Nothing regressed into a county label, and three of the wrong-country cases went away as a side effect -- a town answering with a foreign county's name is both errors at once (Somerton AZ read Mexicali, Rosarito BC read Tijuana, the county).

A sample of what changes, at each town's own centroid:

Town Before After
Santa Maria (CA) Santa Barbara [county] Santa Maria
Bullhead City (AZ) Mohave [county] Bullhead City
South Lake Tahoe (CA) El Dorado [county] South Lake Tahoe
San Luis Obispo (CA) San Luis Obispo [county] San Luis Obispo [the locality]
Somerton (AZ) Mexicali [county, MX] Somerton
Rosarito (MX) Tijuana [county] Rosarito

Cost

county eligible county excluded delta
places 18,615 18,615 0
lookup rows 467,389 494,068 +26,679 (+5.71%)
file bytes 24,305,664 25,591,808 +1,286,144 (+5.29%)

The rows come back because a rollup that no longer happens is a set of child cells that no longer gets deleted. Extrapolating the ratio to the 24.5 MB world asset puts it near 25.8 MB — well inside the 50 MB ceiling.

Where it could have read worse, and what actually happens

A rollup does not only rename the parent cell, it creates that row when no polygon covered the parent cell outright. Skipping the rollup therefore removes 875 precision-4 rows (817 vanish, 58 fall back to a region owner). Those rows answer only for points that no precision-5 cell covers, so they are exactly where this change could degrade a label.

Probing the centre and four quarter points of all 875 changed cells (4,375 points, both builds):

points
identical answer 3,232
county -> locality (finer) 453
county -> a different county 677
county -> region (coarser) 13
no answer at all after 0

Not one point loses an answer, and the coarser cases are 13 out of 4,375 (0.3%) against 453 that get finer. The Hawaii cells are typical of the win: 8e8w answered "Maui" for every point in it and now answers Kihei, Lahaina and Kahului.

The reported case, stacked on #9

The Brockport/Greece report came from a build with --home-cell-priority on, so the same pair was built again on feat/home-cell-ownership with this commit cherry-picked. Same slice, same recipe.

Town (own centroid) county eligible county excluded
Brockport (NY, 8,366) Monroe [county] Brockport
Greece (NY, 14,519) Monroe [county] Greece

The rest of that pair matches the master numbers closely — county-named localities 485 -> 1, +473 resolving to themselves, 0 newly county, +5.37% file size — so the two changes are independent: #9 decides which place owns a cell, this decides whether a county may name a whole parent cell.

On master today Brockport and Greece already answer correctly, which is why the fix has to be measured on both branches: the county rollup is the standing bug, and #9 only changed which parent cells it happened to reach.

Tests

Four specs in spec/boundary_builder_spec.js, on the Rochester shape: a county that outpopulates every town in it (748,482), two towns holding child cells of their own (8,366 and 14,519), and a region as the fallback owner of the parent cell.

  • a dominant county does not take the parent cell, and neither does the runner-up town — the parent stays with the region and both towns keep their cells;
  • --dominant-city-placetypes locality,localadmin,county reproduces the old rollup on the same fixture (parent reads as the county, both towns lose every cell), which is also what proves the fixture reproduces the bug;
  • a genuine dominant city still rolls up and still suppresses minor localities when a county owns a cell beside it — that spec builds the fixture twice, once with the rollup out of reach, so it fails if the hamlet and county never owned cells to begin with;
  • a placetype that cannot carry a city label (region) is rejected rather than silently ignored.

Red/green: against the previous builder the first two fail as Expected $[0].place_id = 8002 to equal 8001 (the parent cell reading as the county) plus Expected 0 to be greater than 0 for both towns, and the third passes unchanged. With the change: 39 specs, 0 failures.

Downstream note

Citylap bundles a database built from this script whose build id ends in majorrollup. A rebuild with this default would grow the 24.5 MB asset by roughly 5% and change the label for the cells listed above; existing stored rows are unaffected, since the app never re-geocodes history.

Note: this branch adds a bullet to the ### Unreleased CHANGELOG section, so it will conflict trivially with whichever of #4-#9 merges first.

A parent cell rolled up to its dominant city could pick a county, because
isCityPlacetypeCode() treats county as city-like. The metro then reads as
its county's name and the minor localities under it lose their own cells.

Rank candidates as before, but refuse to roll a parent cell up to a
placetype that may not name a metro. --dominant-city-placetypes (default
locality,localadmin) selects the eligible set; adding county restores the
previous behaviour.
Three specs on the Rochester shape: a county that outpopulates the towns
inside it, two towns holding their own child cells, a region as fallback.
Against the previous builder the first two fail (the parent cell reads
8002, the county, and both towns lose every cell), and the third one --
a genuine city rollup with a county owning a cell beside it -- passes
unchanged.
@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: 100ecd6121

ℹ️ 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
The rollup has two ways to hand a parent geohash cell to a child: winning
the dominant-city competition, and being the only city-like owner in that
cell. Only the first consulted --dominant-city-placetypes, so a county
that owned a parent cell alone and cleared --parent-locality-min-share
still took the cell under the default locality,localadmin -- the exact
outcome the option exists to prevent.

Both paths now go through localityMayTakeOverParent(), which pairs the
placetype check with the child-share threshold so the two cannot drift
apart. selectDominantLocalityId() drops its own copy of the placetype
check and goes back to being a pure population ranker; a candidate
rejected by the gate is still never replaced by the runner-up.

Measured on the PR's slice (us,mx,nl,de,at,ch,cz at precision 5, counties
included, min population 5000): 18 parent cells were leaking through the
single-candidate path -- 9 in MX, 7 in AT, 2 in US (cells in New
Hampshire reading "Grafton" and "Oxford") -- and now fall back to their
region. Lookup rows 494,068 -> 494,550 and file bytes 25,591,808 ->
25,600,000, so the rule costs +5.33% over a county-eligible build rather
than the +5.29% measured before. Probing the centre and four quarter
points of all 18 cells: 77 of 90 answers unchanged, 13 county -> region,
none lost. A build with county opted back in is byte-identical to before
this commit.
@sebschlo

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: 2b44cf4623

ℹ️ 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 70dc771 into master Aug 20, 2026
2 checks passed
@sebschlo
sebschlo deleted the fix/dominant-city-excludes-county branch August 20, 2026 17:00
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