gap: the CBS note points at place_labels.population as the cheaper answer, and it is a per-kind constant #246

Closed
opened 2026-08-31 02:38:46 +00:00 by viberfox-agent · 0 comments
Collaborator

Found while QA-ing what #192 shipped (#245).

What I did

#192 landed as documentation only: docs/notes/cbs-neighbourhood-statistics.md plus a
docs/direction.md row moved from "frontier" to "checked and not taken". So the deliverable a
reader meets is the note, and the one thing it hands forward is the first bullet of its
Pointers section — the cheaper answer to the same question, explicitly reserved for a future
ticket:

The vector tile already carries a population figure, and nothing reads it.
The place_labels layer — 21 features on the Groningen z14 fixture, properties
kind / name / population — is listed as used for nothing in
vector-tile-unused-layers.md. Verified: it is in the bytes map_geometry
already fetches, so it costs no request and no extra decode, and it is global rather
than Dutch-only. Not verified: what its population actually resolves to per
feature, or whether it is usable at cell resolution. That is its own ticket off that
note, not this one.

The commit body puts it more strongly: it points at a figure "already in every vector tile and
read by nothing, which is the cheaper answer to the same question".

The note names the unverified number, so I verified it — decoded place_labels out of the three
MVT fixtures in the tree, and checked the result against the Shortbread 1.0 schema.

What happened

population is not a population. It is the OSM population=* tag where the place is
tagged
, and a documented per-kind constant everywhere else. From the schema
(https://shortbread-tiles.org/schema/1.0/, layer place_labels):

population integer — value of OSM population=* tag, else defaults (see below)

with defaults city 100,000 / town 5,000 / village 100 / hamlet 50 / suburb 1,000 /
quarter 500 / neighbourhood 100 / isolated_dwelling 5 / farm 5 / island 0 / locality 0.
The same section says the layer's features "are sorted by population in descending order" — in
the schema it is a label-ranking weight, and it is doing that job.

On the very fixture the note cites, 21 of 21 features are the constant:

fixture kind n distinct values values
groningen_z14 quarter 6 1 500 ×6
groningen_z14 neighbourhood 15 1 100 ×15

Every one of those matches the schema default exactly. At city-cell resolution the field is
kind re-encoded as an integer, carrying no demographic information at all: it says every
quarter in Groningen holds 500 people and every neighbourhood 100. CBS, over the same ground,
measures 4,745 residents in Binnenstad-Noord alone.

It is not uniformly empty — the field is real where OSM carries the tag, which is mostly
settlements at low zoom. From the two z10 fixtures:

fixture kind n distinct note
lowzoom_10_534_336 city 1 1 Osnabrück 167,730 — real
lowzoom_10_534_336 town 8 8 50,438 … 9,461 — all real
lowzoom_10_534_336 suburb 22 4 19 of 22 are exactly 1,000 (the default)
lowzoom_10_534_336 village 26 12 15 of 26 are exactly 100 (the default)
lowzoom_10_526_334 village 29 29 all real
lowzoom_10_526_334 hamlet 8 1 50 ×8 (the default)

So the split is by tagging, not by zoom: named settlements are usually tagged, and the
sub-settlement classes a city cell is made of — quarter, neighbourhood, suburb — mostly are not.

Reproduce (no repo change; a throwaway protobuf reader over the checked-in fixtures):

crates/geo/tests/fixtures/groningen_z14.mvt        → place_labels, 21 features
  quarter       ×6   population == 500  (schema default for quarter)
  neighbourhood ×15  population == 100  (schema default for neighbourhood)

What a reader would expect instead

The Pointers section exists so the check is not repeated, and this bullet is the only surviving
record of the idea — no ticket was ever filed off it (nothing in the tracker mentions
place_labels, population or the unused-layers note). So the next frontier refill, or the
next person asking "how many people are actually there", meets this pointer and nothing else.

Followed as written, it leads to building a neighbourhood-varying occupancy or a "who lives
here" readout on a field that, in a Dutch city cell, is constant per class. That is the failure
the same note invokes value 1 to prevent, three paragraphs earlier and about this exact
question:

Turning a residence count into a street-presence count is an inference wearing a
measurement's clothes, which is the one thing value 1 exists to stop.

A constant wearing a measurement's clothes is the same error one step further along, and the
note recommends it as the cheaper option.

Two smaller things in the same bullet, both minor next to the above:

  • "costs no extra decode" is not quite right by the sibling note's own correction —
    TileData::features is a lazy per-layer OnceCell, and vector-tile-unused-layers.md says
    an unread layer "cost[s] nothing at all", which means reading place_labels is a decode
    that is not currently paid. It is a small one (21 point features); the claim is just stronger
    than the mechanism.
  • "it is global rather than Dutch-only" is correct, and I confirmed it — the German z10
    fixture carries the same layer with real values for Osnabrück and its towns.

Where the seam is

  • docs/notes/cbs-neighbourhood-statistics.md, Pointers section, first bullet (the
    *Verified:* / **Not verified:** pair) — the "not verified" half is now measured, and the
    answer removes the pointer rather than qualifying it.
  • docs/notes/vector-tile-unused-layers.md:31 — the row `place_labels` | 21 | **nothing** (`kind`, `name`, `population`) is what the bullet leans on. The property list is right;
    what it does not say is that population is a schema default at this zoom. That table is
    where a reader checks "what else is in the tile", so the qualifier belongs there too.
  • Nothing in crates/ reads the layer, so there is no code to change — the fix is to the two
    notes, and the decision of whether the idea survives at all.

What I would suggest

Correct the bullet to say what the field is: real where OSM tagged it (settlements at low
zoom), the Shortbread per-kind default otherwise, and therefore not an answer to "how
many people are in this block" in a city cell. It may still be worth a line as a label-ranking
input — sorting which place names to draw first is exactly what it is for — but that is a
different feature from the one the pointer currently implies, and it should not be recorded as
the cheap substitute for CBS.

Worth noting the CBS decision itself is unaffected: I reproduced every measured number in that
note against the live PDOK service and they all hold to the byte. This is only about the thing
it hands forward.

Found while QA-ing what #192 shipped (#245). ## What I did #192 landed as documentation only: `docs/notes/cbs-neighbourhood-statistics.md` plus a `docs/direction.md` row moved from "frontier" to "checked and not taken". So the deliverable a reader meets is the note, and the one thing it hands *forward* is the first bullet of its Pointers section — the cheaper answer to the same question, explicitly reserved for a future ticket: > **The vector tile already carries a population figure, and nothing reads it.** > The `place_labels` layer — 21 features on the Groningen z14 fixture, properties > `kind` / `name` / `population` — is listed as used for *nothing* in > [vector-tile-unused-layers.md](…). *Verified:* it is in the bytes `map_geometry` > already fetches, so it costs no request and no extra decode, and it is global rather > than Dutch-only. **Not verified:** what its `population` actually resolves to per > feature, or whether it is usable at cell resolution. That is its own ticket off that > note, not this one. The commit body puts it more strongly: it points at a figure "already in every vector tile and read by nothing, **which is the cheaper answer to the same question**". The note names the unverified number, so I verified it — decoded `place_labels` out of the three MVT fixtures in the tree, and checked the result against the Shortbread 1.0 schema. ## What happened `population` is not a population. It is the OSM `population=*` tag **where the place is tagged**, and a documented per-`kind` constant everywhere else. From the schema (<https://shortbread-tiles.org/schema/1.0/>, layer `place_labels`): > `population` integer — value of OSM `population=*` tag, **else defaults (see below)** with defaults city 100,000 / town 5,000 / village 100 / hamlet 50 / **suburb 1,000** / **quarter 500** / **neighbourhood 100** / isolated_dwelling 5 / farm 5 / island 0 / locality 0. The same section says the layer's features "are sorted by population in descending order" — in the schema it is a label-ranking weight, and it is doing that job. On the very fixture the note cites, **21 of 21 features are the constant**: | fixture | kind | n | distinct values | values | |---|---|---|---|---| | `groningen_z14` | quarter | 6 | **1** | 500 ×6 | | `groningen_z14` | neighbourhood | 15 | **1** | 100 ×15 | Every one of those matches the schema default exactly. At city-cell resolution the field is `kind` re-encoded as an integer, carrying no demographic information at all: it says every quarter in Groningen holds 500 people and every neighbourhood 100. CBS, over the same ground, measures 4,745 residents in Binnenstad-Noord alone. It is not uniformly empty — the field is real where OSM carries the tag, which is mostly settlements at low zoom. From the two z10 fixtures: | fixture | kind | n | distinct | note | |---|---|---|---|---| | `lowzoom_10_534_336` | city | 1 | 1 | Osnabrück 167,730 — real | | `lowzoom_10_534_336` | town | 8 | 8 | 50,438 … 9,461 — all real | | `lowzoom_10_534_336` | suburb | 22 | 4 | **19 of 22 are exactly 1,000** (the default) | | `lowzoom_10_534_336` | village | 26 | 12 | **15 of 26 are exactly 100** (the default) | | `lowzoom_10_526_334` | village | 29 | 29 | all real | | `lowzoom_10_526_334` | hamlet | 8 | 1 | 50 ×8 (the default) | So the split is by tagging, not by zoom: named settlements are usually tagged, and the sub-settlement classes a city cell is made of — quarter, neighbourhood, suburb — mostly are not. Reproduce (no repo change; a throwaway protobuf reader over the checked-in fixtures): ``` crates/geo/tests/fixtures/groningen_z14.mvt → place_labels, 21 features quarter ×6 population == 500 (schema default for quarter) neighbourhood ×15 population == 100 (schema default for neighbourhood) ``` ## What a reader would expect instead The Pointers section exists so the check is not repeated, and this bullet is the only surviving record of the idea — **no ticket was ever filed off it** (nothing in the tracker mentions `place_labels`, `population` or the unused-layers note). So the next frontier refill, or the next person asking "how many people are actually there", meets this pointer and nothing else. Followed as written, it leads to building a neighbourhood-varying occupancy or a "who lives here" readout on a field that, in a Dutch city cell, is constant per class. That is the failure the same note invokes **value 1** to prevent, three paragraphs earlier and about this exact question: > Turning a residence count into a street-presence count is an inference wearing a > measurement's clothes, which is the one thing value 1 exists to stop. A constant wearing a measurement's clothes is the same error one step further along, and the note recommends it as the cheaper option. Two smaller things in the same bullet, both minor next to the above: - "costs **no extra decode**" is not quite right by the sibling note's own correction — `TileData::features` is a lazy per-layer `OnceCell`, and `vector-tile-unused-layers.md` says an unread layer "cost[s] nothing at all", which means reading `place_labels` *is* a decode that is not currently paid. It is a small one (21 point features); the claim is just stronger than the mechanism. - "it is **global** rather than Dutch-only" is correct, and I confirmed it — the German z10 fixture carries the same layer with real values for Osnabrück and its towns. ## Where the seam is - `docs/notes/cbs-neighbourhood-statistics.md`, Pointers section, first bullet (the `*Verified:* / **Not verified:**` pair) — the "not verified" half is now measured, and the answer removes the pointer rather than qualifying it. - `docs/notes/vector-tile-unused-layers.md:31` — the row `` `place_labels` | 21 | **nothing** (`kind`, `name`, `population`) `` is what the bullet leans on. The property list is right; what it does not say is that `population` is a schema default at this zoom. That table is where a reader checks "what else is in the tile", so the qualifier belongs there too. - Nothing in `crates/` reads the layer, so there is no code to change — the fix is to the two notes, and the decision of whether the idea survives at all. ## What I would suggest Correct the bullet to say what the field is: real where OSM tagged it (settlements at low zoom), the Shortbread per-`kind` default otherwise, and therefore **not** an answer to "how many people are in this block" in a city cell. It may still be worth a line as a *label-ranking* input — sorting which place names to draw first is exactly what it is for — but that is a different feature from the one the pointer currently implies, and it should not be recorded as the cheap substitute for CBS. Worth noting the CBS decision itself is unaffected: I reproduced every measured number in that note against the live PDOK service and they all hold to the byte. This is only about the thing it hands forward.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jeroen/cartopolis#246
No description provided.