mailwoman 8.5.0 → 8.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/commands/gazetteer/granularity.tsx +104 -0
- package/commands/gazetteer/pair-index.tsx +22 -1
- package/eval-harness/gauntlet/cases/regression.ts +4 -4
- package/gazetteer-pipeline/admin/ingest-wof.ts +7 -2
- package/gazetteer-pipeline/borough-pairs.ts +0 -0
- package/gazetteer-pipeline/defaults.ts +7 -1
- package/gazetteer-pipeline/granularity-report.ts +161 -0
- package/gazetteer-pipeline/granularity.ts +300 -0
- package/gazetteer-pipeline/placetype-census.ts +73 -4
- package/out/commands/gazetteer/granularity.d.ts +27 -0
- package/out/commands/gazetteer/granularity.d.ts.map +1 -0
- package/out/commands/gazetteer/granularity.js +78 -0
- package/out/commands/gazetteer/granularity.js.map +1 -0
- package/out/commands/gazetteer/pair-index.d.ts.map +1 -1
- package/out/commands/gazetteer/pair-index.js +22 -1
- package/out/commands/gazetteer/pair-index.js.map +1 -1
- package/out/eval-harness/gauntlet/cases/regression.js +4 -4
- package/out/eval-harness/gauntlet/cases/regression.js.map +1 -1
- package/out/gazetteer-pipeline/admin/ingest-wof.d.ts.map +1 -1
- package/out/gazetteer-pipeline/admin/ingest-wof.js +4 -0
- package/out/gazetteer-pipeline/admin/ingest-wof.js.map +1 -1
- package/out/gazetteer-pipeline/borough-pairs.d.ts +6 -0
- package/out/gazetteer-pipeline/borough-pairs.d.ts.map +1 -1
- package/out/gazetteer-pipeline/borough-pairs.js +0 -0
- package/out/gazetteer-pipeline/borough-pairs.js.map +1 -1
- package/out/gazetteer-pipeline/defaults.d.ts +2 -2
- package/out/gazetteer-pipeline/defaults.d.ts.map +1 -1
- package/out/gazetteer-pipeline/defaults.js +7 -1
- package/out/gazetteer-pipeline/defaults.js.map +1 -1
- package/out/gazetteer-pipeline/granularity-report.d.ts +34 -0
- package/out/gazetteer-pipeline/granularity-report.d.ts.map +1 -0
- package/out/gazetteer-pipeline/granularity-report.js +131 -0
- package/out/gazetteer-pipeline/granularity-report.js.map +1 -0
- package/out/gazetteer-pipeline/granularity.d.ts +115 -0
- package/out/gazetteer-pipeline/granularity.d.ts.map +1 -0
- package/out/gazetteer-pipeline/granularity.js +207 -0
- package/out/gazetteer-pipeline/granularity.js.map +1 -0
- package/out/gazetteer-pipeline/placetype-census.d.ts +16 -4
- package/out/gazetteer-pipeline/placetype-census.d.ts.map +1 -1
- package/out/gazetteer-pipeline/placetype-census.js +71 -4
- package/out/gazetteer-pipeline/placetype-census.js.map +1 -1
- package/package.json +25 -25
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `mailwoman gazetteer granularity` — the per-country gazetteer depth scorecard.
|
|
7
|
+
*
|
|
8
|
+
* Answers "where does the admin gazetteer bottom out?" for every country it knows, which nobody
|
|
9
|
+
* had measured: the shipped artifact stocks 9 of WOF's 34 placetypes and carries a
|
|
10
|
+
* `dependent_locality` tier in 11 of 244 countries. Read-only, no network, no model — three
|
|
11
|
+
* grouped queries over `spr`/`ancestors` and a markdown render.
|
|
12
|
+
*
|
|
13
|
+
* The report is a COMMITTED artifact (the `fill-rates.md` precedent), so the source md5 and the
|
|
14
|
+
* parent-coverage floor are pinned in its header. Re-running against a rebuilt gazetteer and
|
|
15
|
+
* diffing the report is the intended workflow.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
19
|
+
import { dirname } from "node:path"
|
|
20
|
+
|
|
21
|
+
import { dataRootPath, md5File } from "@mailwoman/core/utils"
|
|
22
|
+
import { Box, Text } from "ink"
|
|
23
|
+
import zod from "zod"
|
|
24
|
+
|
|
25
|
+
import { type CommandComponent, useCommandTask } from "../../cli-kit/index.ts"
|
|
26
|
+
import { renderGranularityReport } from "../../gazetteer-pipeline/granularity-report.ts"
|
|
27
|
+
import { DEFAULT_COVERAGE_FLOOR, bottomsOutAt, buildGranularityLadder } from "../../gazetteer-pipeline/granularity.ts"
|
|
28
|
+
|
|
29
|
+
const OptionsSchema = zod.object({
|
|
30
|
+
out: zod
|
|
31
|
+
.string()
|
|
32
|
+
.default("docs/articles/evals/coverage/gazetteer-depth-scorecard.md")
|
|
33
|
+
.describe("Output path for the markdown scorecard"),
|
|
34
|
+
source: zod.string().optional().describe("WOF admin DB. Default $MAILWOMAN_DATA_ROOT/wof/admin-global-priority.db"),
|
|
35
|
+
floor: zod
|
|
36
|
+
.number()
|
|
37
|
+
.default(DEFAULT_COVERAGE_FLOOR)
|
|
38
|
+
.describe(
|
|
39
|
+
`Parent-coverage floor for crediting a sub-locality rung. Default ${DEFAULT_COVERAGE_FLOOR} — set low on ` +
|
|
40
|
+
"purpose, to catch thin-but-real tiers rather than certify them."
|
|
41
|
+
),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
export { OptionsSchema as options }
|
|
45
|
+
|
|
46
|
+
const GazetteerGranularity: CommandComponent<typeof OptionsSchema> = ({ options }) => {
|
|
47
|
+
const state = useCommandTask(async () => {
|
|
48
|
+
const sourcePath = options.source ?? String(dataRootPath("wof", "admin-global-priority.db"))
|
|
49
|
+
const rows = buildGranularityLadder(sourcePath)
|
|
50
|
+
|
|
51
|
+
if (!rows.length) {
|
|
52
|
+
throw new Error(`granularity: no countries measured from ${sourcePath} — is this an admin DB?`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const markdown = renderGranularityReport(rows, {
|
|
56
|
+
// Display the portable form; never bake the resolved lab path into a committed artifact.
|
|
57
|
+
sourcePath: "$MAILWOMAN_DATA_ROOT/wof/admin-global-priority.db",
|
|
58
|
+
sourceMD5: await md5File(sourcePath),
|
|
59
|
+
buildDate: new Date().toISOString(),
|
|
60
|
+
floor: options.floor,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
mkdirSync(dirname(options.out), { recursive: true })
|
|
64
|
+
writeFileSync(options.out, markdown)
|
|
65
|
+
|
|
66
|
+
const byBottom = new Map<string, number>()
|
|
67
|
+
|
|
68
|
+
for (const row of rows) {
|
|
69
|
+
const bottom = bottomsOutAt(row, options.floor) ?? "(none)"
|
|
70
|
+
|
|
71
|
+
byBottom.set(bottom, (byBottom.get(bottom) ?? 0) + 1)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const distribution = [...byBottom.entries()]
|
|
75
|
+
.toSorted((a, b) => b[1] - a[1])
|
|
76
|
+
.map(([rung, n]) => ` ${rung}: ${n.toLocaleString()} countries`)
|
|
77
|
+
|
|
78
|
+
return [
|
|
79
|
+
`gazetteer depth scorecard → ${options.out}`,
|
|
80
|
+
`countries measured: ${rows.length.toLocaleString()} (floor ${(options.floor * 100).toFixed(1)}%)`,
|
|
81
|
+
"bottoms out at:",
|
|
82
|
+
...distribution,
|
|
83
|
+
]
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
if (state.status === "error") return <Text color="red">✗ {state.message}</Text>
|
|
87
|
+
|
|
88
|
+
if (state.status === "done") {
|
|
89
|
+
return (
|
|
90
|
+
<Box flexDirection="column">
|
|
91
|
+
{state.result.map((line, i) => (
|
|
92
|
+
<Text key={i} color={i === 0 ? "green" : undefined}>
|
|
93
|
+
{i === 0 ? "✓ " : " "}
|
|
94
|
+
{line}
|
|
95
|
+
</Text>
|
|
96
|
+
))}
|
|
97
|
+
</Box>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export default GazetteerGranularity
|
|
@@ -70,7 +70,7 @@ const RUNG3_PRE_FOLD_CENSUS_LINE_COUNT = 19_431
|
|
|
70
70
|
* tracks the WOF snapshot alone. A snapshot refresh legitimately moves it; re-anchor the constant deliberately, after
|
|
71
71
|
* inspecting the diff, rather than relaxing the gate.
|
|
72
72
|
*/
|
|
73
|
-
const EXPECTED_US_PAIR_COUNT =
|
|
73
|
+
const EXPECTED_US_PAIR_COUNT = 47_878
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
76
|
* Known (child, parent) pairs probed after write as a self-check — PER COUNTRY, keyed by the `--country` code. Probing
|
|
@@ -97,6 +97,27 @@ const PROBE_PAIRS_BY_COUNTRY: Readonly<Record<string, ReadonlyArray<readonly [ci
|
|
|
97
97
|
["Park Slope", "Brooklyn"],
|
|
98
98
|
["Manhattan", "New York"],
|
|
99
99
|
],
|
|
100
|
+
// R10: the IN instance. Indian addresses carry an area/locality line above the city
|
|
101
|
+
// ("Indiranagar, Bengaluru"), which is the dependent-locality slot.
|
|
102
|
+
in: [
|
|
103
|
+
["Indiranagar", "Bangalore"],
|
|
104
|
+
["Mulund East", "Mumbai"],
|
|
105
|
+
],
|
|
106
|
+
// R11: the ES + IT instances. Spanish barrios and Italian quartieri under their municipio/comune.
|
|
107
|
+
es: [
|
|
108
|
+
["Aravaca", "Madrid"],
|
|
109
|
+
["Triana", "Sevilla"],
|
|
110
|
+
],
|
|
111
|
+
it: [
|
|
112
|
+
["Barona", "Milano"],
|
|
113
|
+
["Trastevere", "Roma"],
|
|
114
|
+
],
|
|
115
|
+
// R9: the DE instance. Ortsteile/Stadtteile under their Gemeinde — the line German addresses
|
|
116
|
+
// carry when they carry one at all (the Ortsteil sits above "PLZ Stadt", never inside it).
|
|
117
|
+
de: [
|
|
118
|
+
["Nippes", "Köln"],
|
|
119
|
+
["Schwabing", "München"],
|
|
120
|
+
],
|
|
100
121
|
// R6: the FR instance. These are LIEUX-DITS under their communes (BAN `nom_ld`), not quartiers —
|
|
101
122
|
// see gazetteer-pipeline/lieudit-pairs.ts for why the French source differs from the US one.
|
|
102
123
|
fr: [
|
|
@@ -1221,7 +1221,7 @@ export const REGRESSION_CASES: SeedCase[] = [
|
|
|
1221
1221
|
postcode: "TW6 2GA",
|
|
1222
1222
|
},
|
|
1223
1223
|
addedAt: "2026-08-01",
|
|
1224
|
-
note: 'Sub-venue
|
|
1224
|
+
note: 'Sub-venue. IMPROVED 2026-08-02 by the WOF venue-structure vocabulary: unit="Terminal 5" is now correct (was locality="Terminal" + house_number=5, airport dropped). Residual: the airport reads as locality and Hounslow as street.',
|
|
1225
1225
|
},
|
|
1226
1226
|
{
|
|
1227
1227
|
id: "gb-subvenue-manchester-gate",
|
|
@@ -1237,7 +1237,7 @@ export const REGRESSION_CASES: SeedCase[] = [
|
|
|
1237
1237
|
postcode: "M90 1QX",
|
|
1238
1238
|
},
|
|
1239
1239
|
addedAt: "2026-08-01",
|
|
1240
|
-
note: 'Sub-venue
|
|
1240
|
+
note: 'Sub-venue, TWO nested structural levels. IMPROVED 2026-08-02: unit="Gate 12" is now correct (was locality="Gate" + house_number=12). Residual: only the inner level is captured.',
|
|
1241
1241
|
},
|
|
1242
1242
|
{
|
|
1243
1243
|
id: "gb-subvenue-st-thomas-wing",
|
|
@@ -1254,7 +1254,7 @@ export const REGRESSION_CASES: SeedCase[] = [
|
|
|
1254
1254
|
postcode: "SE1 7EH",
|
|
1255
1255
|
},
|
|
1256
1256
|
addedAt: "2026-08-01",
|
|
1257
|
-
note: "Sub-venue with a TRAILING designator and a directional lead
|
|
1257
|
+
note: "Sub-venue with a TRAILING designator and a directional lead. UNMOVED by the venue-structure vocabulary: the proposer reads a designator that LEADS its identifier ('Wing B'), not one trailing a modifier ('West Wing'). A named residual.",
|
|
1258
1258
|
},
|
|
1259
1259
|
{
|
|
1260
1260
|
id: "us-subvenue-ohare-concourse",
|
|
@@ -1271,7 +1271,7 @@ export const REGRESSION_CASES: SeedCase[] = [
|
|
|
1271
1271
|
postcode: "60666",
|
|
1272
1272
|
},
|
|
1273
1273
|
addedAt: "2026-08-01",
|
|
1274
|
-
note: "Sub-venue with a LETTER rather than a number
|
|
1274
|
+
note: "Sub-venue with a LETTER rather than a number. Still splits into unit='Concourse' + venue='B': the designator is recognized, the bare-letter identifier after it is not. The other named residual.",
|
|
1275
1275
|
},
|
|
1276
1276
|
{
|
|
1277
1277
|
id: "us-subvenue-googleplex-building",
|
|
@@ -13,6 +13,7 @@ import { readFile } from "node:fs/promises"
|
|
|
13
13
|
import type { DatabaseSync } from "node:sqlite"
|
|
14
14
|
|
|
15
15
|
import { isOfficialLanguage } from "@mailwoman/codex/country"
|
|
16
|
+
import type { WOFFeature, WOFProperties } from "@mailwoman/core/resources/whosonfirst"
|
|
16
17
|
import FastGlob from "fast-glob"
|
|
17
18
|
import { asyncParallelIterator } from "spliterator"
|
|
18
19
|
|
|
@@ -60,8 +61,12 @@ interface ParsedFeature {
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
function parseFeature(text: string, placetypes: ReadonlySet<string>): ParsedFeature | null {
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
// Typed against the schema `@mailwoman/core/resources/whosonfirst` already owns (`WOFFeature`/`WOFProperties`,
|
|
65
|
+
// admin.ts) rather than reading `any`. This file used to name all seventeen `wof:`/`geom:`/`edtf:`/`mz:` keys as
|
|
66
|
+
// bare strings, which meant a typo — `wof:superceded_by` — would have compiled, read `undefined`, and silently
|
|
67
|
+
// ingested every superseded record in the corpus. A type-only import: erased at build, zero runtime cost.
|
|
68
|
+
const feature = JSON.parse(text) as WOFFeature
|
|
69
|
+
const props: WOFProperties | undefined = feature.properties
|
|
65
70
|
|
|
66
71
|
if (!props) return null
|
|
67
72
|
|
|
Binary file
|
|
@@ -23,6 +23,12 @@ export const DEFAULT_WOF_PRIORITY_COUNTRIES = [
|
|
|
23
23
|
"ES",
|
|
24
24
|
"FR",
|
|
25
25
|
"GB",
|
|
26
|
+
// Added 2026-08-02 after the granularity probe. `whosonfirst-data-admin-in` carries 189,026 sub-locality nodes —
|
|
27
|
+
// more than Germany's 67,162, the richest tier shipped before this — converting at 98.6% into 186,469 (child,
|
|
28
|
+
// parent) pairs, against Overture-IN's 74,920 nodes. That is 6× the shipped GB pair index, which took eight
|
|
29
|
+
// campaign rungs to assemble. IN moves OUT of DEFAULT_OVERTURE_COUNTRIES in the same change: a country served by
|
|
30
|
+
// both would double up its admin (the #267 warning).
|
|
31
|
+
"IN",
|
|
26
32
|
"IT",
|
|
27
33
|
"JP",
|
|
28
34
|
"KR",
|
|
@@ -72,7 +78,7 @@ export const DEFAULT_OVERTURE_COUNTRIES = [
|
|
|
72
78
|
"ID",
|
|
73
79
|
"IE",
|
|
74
80
|
"IL",
|
|
75
|
-
"IN"
|
|
81
|
+
// "IN" moved to DEFAULT_WOF_PRIORITY_COUNTRIES 2026-08-02 — see the note there.
|
|
76
82
|
"IQ",
|
|
77
83
|
"IR",
|
|
78
84
|
"IS",
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Markdown renderer for the gazetteer depth scorecard — the committed artifact under
|
|
7
|
+
* `docs/articles/evals/coverage/`, following the `fill-rates.md` precedent.
|
|
8
|
+
*
|
|
9
|
+
* The report declares its own limits inline rather than deferring them to a design doc. These
|
|
10
|
+
* numbers will outlive the conversation that produced them, and a reader six months out must be
|
|
11
|
+
* told IN THE FILE that counts are not quality and that the locality rung is self-comparison for
|
|
12
|
+
* the Overture-backfilled countries.
|
|
13
|
+
*
|
|
14
|
+
* Pure: rows in, markdown out, `buildDate` injected by the caller so the output is deterministic
|
|
15
|
+
* under test.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ComponentTag } from "@mailwoman/core/types"
|
|
19
|
+
|
|
20
|
+
import { DEFAULT_WOF_PRIORITY_COUNTRIES } from "./defaults.ts"
|
|
21
|
+
import { LADDER, type CountryGranularity, bottomsOutAt } from "./granularity.ts"
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Which build path supplied a country's rows — the single most important column in this report.
|
|
25
|
+
*
|
|
26
|
+
* A country whose rows are Overture- or GeoNames-sourced had NO WOF GeoJSON repo ingested, so its empty sub-locality
|
|
27
|
+
* rung says nothing whatsoever about WOF's depth there. Only a dozen of the 260 admin repos WOF publishes are in the
|
|
28
|
+
* recipe; the rest of the world arrives via Overture divisions (5 subtypes, none hood-level) or the GeoNames alias
|
|
29
|
+
* fold. Without this column a reader would take "233 countries bottom out at locality" as a finding about WOF rather
|
|
30
|
+
* than about the recipe.
|
|
31
|
+
*
|
|
32
|
+
* Derived from the ARTIFACT (synthetic id ranges on the locality rung), not from the recipe constants. A recipe-derived
|
|
33
|
+
* column silently goes wrong the moment a country is added to `DEFAULT_WOF_PRIORITY_COUNTRIES` and the gazetteer has
|
|
34
|
+
* not been rebuilt yet — it would claim `wof-repo` over rows that are still 100% Overture. Instead the recipe is used
|
|
35
|
+
* only as a CROSS-CHECK: a mismatch renders as `rebuild pending`, which is the honest description of that window.
|
|
36
|
+
*/
|
|
37
|
+
function sourceClass(country: CountryGranularity): string {
|
|
38
|
+
const locality = country.rungs.locality
|
|
39
|
+
const recipeSaysWOF = (DEFAULT_WOF_PRIORITY_COUNTRIES as readonly string[]).includes(country.country)
|
|
40
|
+
|
|
41
|
+
if (!locality?.nodes) return recipeSaysWOF ? "wof-repo (no rows)" : "(no rows)"
|
|
42
|
+
|
|
43
|
+
const synthetic = locality.overtureBackfilled + locality.geonamesBackfilled
|
|
44
|
+
|
|
45
|
+
const observed =
|
|
46
|
+
synthetic === 0 ? "wof-repo" : locality.geonamesBackfilled > locality.overtureBackfilled ? "geonames" : "overture"
|
|
47
|
+
|
|
48
|
+
// The recipe promises WOF rows the artifact does not yet carry: a rebuild has not run since the recipe changed.
|
|
49
|
+
if (recipeSaysWOF && observed !== "wof-repo") return `${observed} (rebuild pending)`
|
|
50
|
+
|
|
51
|
+
return observed
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface GranularityReportMeta {
|
|
55
|
+
/**
|
|
56
|
+
* Display path of the measured DB. Use the `$MAILWOMAN_DATA_ROOT`-relative form, never the resolved lab path.
|
|
57
|
+
*/
|
|
58
|
+
sourcePath: string
|
|
59
|
+
sourceMD5: string
|
|
60
|
+
/**
|
|
61
|
+
* ISO timestamp, injected rather than read from the clock so the renderer stays deterministic.
|
|
62
|
+
*/
|
|
63
|
+
buildDate: string
|
|
64
|
+
floor: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function pct(value: number): string {
|
|
68
|
+
return `${(value * 100).toFixed(1)}%`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One row's cell for a rung: node count, plus the Overture-backfilled share when any of it is backfilled.
|
|
73
|
+
*/
|
|
74
|
+
function rungCell(country: CountryGranularity, rung: ComponentTag): string {
|
|
75
|
+
const measurement = country.rungs[rung]
|
|
76
|
+
|
|
77
|
+
// Absent measurement = never measured. Distinct from a measured zero, which renders as "0".
|
|
78
|
+
if (!measurement) return "—"
|
|
79
|
+
|
|
80
|
+
if (!measurement.nodes) return "0"
|
|
81
|
+
|
|
82
|
+
const synthetic = measurement.overtureBackfilled + measurement.geonamesBackfilled
|
|
83
|
+
|
|
84
|
+
if (!synthetic) return measurement.nodes.toLocaleString()
|
|
85
|
+
|
|
86
|
+
// Label by whichever synthetic source dominates. A single `id >= OVERTURE_ID_BASE` test would call GeoNames rows
|
|
87
|
+
// Overture, which mislabelled every GeoNames-only country in the first run of this report.
|
|
88
|
+
const label = measurement.geonamesBackfilled > measurement.overtureBackfilled ? "gn" : "ovt"
|
|
89
|
+
|
|
90
|
+
return `${measurement.nodes.toLocaleString()} (${pct(synthetic / measurement.nodes)} ${label})`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Render the scorecard.
|
|
95
|
+
*/
|
|
96
|
+
export function renderGranularityReport(rows: CountryGranularity[], meta: GranularityReportMeta): string {
|
|
97
|
+
const header = LADDER.join(" | ")
|
|
98
|
+
const alignment = LADDER.map(() => "--:").join(" | ")
|
|
99
|
+
|
|
100
|
+
const body = rows.map((country) => {
|
|
101
|
+
const bottom = bottomsOutAt(country, meta.floor)
|
|
102
|
+
const cells = LADDER.map((rung) => rungCell(country, rung)).join(" | ")
|
|
103
|
+
|
|
104
|
+
return `| ${country.country} | ${sourceClass(country)} | ${bottom ?? "(none)"} | ${country.localityParents.toLocaleString()} | ${cells} |`
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const reached = rows.filter((country) => bottomsOutAt(country, meta.floor) === "dependent_locality").length
|
|
108
|
+
const fromWOFRepo = rows.filter((country) => sourceClass(country) === "wof-repo").length
|
|
109
|
+
|
|
110
|
+
return `# Gazetteer depth scorecard
|
|
111
|
+
|
|
112
|
+
Generated by \`mailwoman gazetteer granularity\`. Per-country measurement of where the admin
|
|
113
|
+
gazetteer bottoms out — node counts per containment rung, and for the sub-locality rungs the
|
|
114
|
+
parent-coverage share (the fraction of the country's locality-class nodes carrying at least one
|
|
115
|
+
child projecting onto that rung).
|
|
116
|
+
|
|
117
|
+
- **Source:** \`${meta.sourcePath}\` (md5 \`${meta.sourceMD5}\`)
|
|
118
|
+
- **Built:** ${meta.buildDate}
|
|
119
|
+
- **Parent-coverage floor:** ${pct(meta.floor)} — a sub-locality rung counts as reached only above this.
|
|
120
|
+
- **Countries measured:** ${rows.length.toLocaleString()}
|
|
121
|
+
- **Countries built from a cloned WOF repo:** ${fromWOFRepo.toLocaleString()} — the rest came from Overture divisions or the GeoNames alias fold.
|
|
122
|
+
- **Countries reaching \`dependent_locality\`:** ${reached.toLocaleString()}
|
|
123
|
+
|
|
124
|
+
## Read the \`source\` column first
|
|
125
|
+
|
|
126
|
+
**This report measures the shipped artifact, not Who's on First.** WOF publishes 260 per-country
|
|
127
|
+
admin repos; \`DEFAULT_WOF_PRIORITY_COUNTRIES\` names ${DEFAULT_WOF_PRIORITY_COUNTRIES.length} of
|
|
128
|
+
them. Every other country's rows come from Overture divisions — whose \`OVERTURE_DIVISION_SUBTYPES\`
|
|
129
|
+
is \`country\`/\`locality\`/\`region\`/\`county\`/\`localadmin\`, with no hood-level subtype — or from
|
|
130
|
+
the GeoNames alias fold.
|
|
131
|
+
|
|
132
|
+
So a \`source\` of \`overture\` or \`geonames\` with an empty \`dependent_locality\` rung is **not a
|
|
133
|
+
finding about WOF's depth in that country**. It is a country we never asked. The sub-locality tier
|
|
134
|
+
appears in exactly the countries whose repo is ingested, and that correspondence is the recipe, not a
|
|
135
|
+
property of the data.
|
|
136
|
+
|
|
137
|
+
The column is read off the **artifact** (synthetic id ranges), not the recipe. \`rebuild pending\`
|
|
138
|
+
means the recipe now names a country the shipped gazetteer has not been rebuilt for — its rows are
|
|
139
|
+
still Overture or GeoNames, and its rungs say nothing about WOF yet.
|
|
140
|
+
|
|
141
|
+
## What this report does not tell you
|
|
142
|
+
|
|
143
|
+
- **Counts are not quality.** A node count establishes where to look, never that the rows are good.
|
|
144
|
+
- **There is no demand-side grounding below the locality line.** Nothing here proves a sub-locality
|
|
145
|
+
surface appears in real addresses. Overture's \`address_levels\` — the obvious instrument — bottoms
|
|
146
|
+
out at municipality in every country measured, so it cannot see this tier.
|
|
147
|
+
- **The locality rung and above are partly self-comparison.** For the Overture-backfilled country
|
|
148
|
+
set those rows came from Overture, not WOF; the \`ovt\` share in each cell is how much.
|
|
149
|
+
- **An empty rung is coverage, not fact.** Two independent filters produce zeroes here: the country
|
|
150
|
+
recipe above, and \`ADMIN_PLACETYPES\` in \`admin/ingest-wof.ts\`, which allowlists 9 of WOF's 34
|
|
151
|
+
placetypes — so even for a cloned country the build never asked for the other 25. Per the
|
|
152
|
+
**meaning-of-zero** rule a measured-and-empty rung renders as \`0\` and a never-measured rung as
|
|
153
|
+
\`—\`; they are not the same claim.
|
|
154
|
+
|
|
155
|
+
## Ladder
|
|
156
|
+
|
|
157
|
+
| country | source | bottoms out at | locality parents | ${header} |
|
|
158
|
+
| --- | --- | --- | --: | ${alignment} |
|
|
159
|
+
${body.join("\n")}
|
|
160
|
+
`
|
|
161
|
+
}
|