@mailwoman/corpus 10.6.0 → 10.7.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.
Files changed (223) hide show
  1. package/data/PROVENANCE.md +24 -0
  2. package/data/address-source-register.json +18948 -8757
  3. package/data/corpus-source-inputs.json +4756 -0
  4. package/data/license-decisions.json +7260 -0
  5. package/data/licenses/whosonfirst-licenses.md +3 -2
  6. package/data/reviewed-ve-postcode-tuples.json +5 -72
  7. package/data/source-additions.json +2450 -0
  8. package/data/source-resolutions.json +87 -0
  9. package/data/training-manifests/v0.7.10-gb-county.effective.json +2335 -0
  10. package/data/training-manifests/v0.7.10-gb-county.json +1132 -0
  11. package/data/training-manifests/v0.7.2-address-systems.effective.json +3 -2
  12. package/data/training-manifests/v0.7.7-no-ppd.json +550 -0
  13. package/data/training-manifests/v0.7.8-rights-clean.json +538 -0
  14. package/data/training-manifests/v0.7.9-country-gap.json +1132 -0
  15. package/data/us-situs-sources.json +128 -0
  16. package/lib/adapters/cordis/adapter.ts +1 -1
  17. package/lib/adapters/libpostal-dictionary.ts +23 -3
  18. package/lib/adapters/osm/adapter.ts +42 -11
  19. package/lib/adapters.ts +14 -0
  20. package/lib/az/adapters/opendata/adapter.ts +389 -0
  21. package/lib/bs/adapters/gbpa/adapter.ts +248 -0
  22. package/lib/build/types.ts +16 -0
  23. package/lib/build.ts +21 -0
  24. package/lib/ee/adapters/ads/adapter.ts +18 -0
  25. package/lib/fo/adapters/adressur/adapter.ts +242 -0
  26. package/lib/gb/adapters/os-open/adapter.ts +12 -0
  27. package/lib/gb/county.ts +59 -0
  28. package/lib/gb/os-open/open/names.ts +41 -0
  29. package/lib/gu/adapters/bsp/adapter.ts +434 -0
  30. package/lib/im/adapters/land-registry/adapter.ts +340 -0
  31. package/lib/international/recipes/locale.ts +164 -19
  32. package/lib/li/adapters/llv/adapter.ts +246 -0
  33. package/lib/recipes/bare/postcode.ts +10 -34
  34. package/lib/recipes/country-balanced.ts +95 -78
  35. package/lib/recipes/intersection.ts +22 -30
  36. package/lib/recipes/po/box/cedex/recipe.ts +8 -5
  37. package/lib/recipes/po/box/cedex/sources.ts +2 -23
  38. package/lib/recipes/scaffold.ts +28 -3
  39. package/lib/recipes/sources.ts +112 -1
  40. package/lib/recipes/street/affix.ts +17 -29
  41. package/lib/recipes/sub/venue/context.ts +33 -9
  42. package/lib/recipes/sub/venue/sources.ts +54 -13
  43. package/lib/recipes/sub/venue.ts +68 -23
  44. package/lib/recipes/unit.ts +20 -7
  45. package/lib/registers.ts +33 -0
  46. package/lib/source-register/corpus-inputs.ts +528 -0
  47. package/lib/source-register/effective-manifest.ts +37 -5
  48. package/lib/source-register/types.ts +20 -0
  49. package/lib/south-asia/recipes/register.ts +1 -1
  50. package/lib/tools/overlay/manifest.ts +109 -1
  51. package/lib/tools/source-register/build.ts +47 -1
  52. package/lib/tools/variety-sample.ts +633 -0
  53. package/lib/us/adapters/usgov/nad/adapter.ts +39 -10
  54. package/lib/us/recipes/skeleton-sources.ts +156 -0
  55. package/lib/us/tools/nad-skeletons.ts +395 -0
  56. package/lib/uy/adapters/ide/adapter.ts +275 -0
  57. package/lib/ve/recipes/reviewed-postcode-tail.ts +29 -6
  58. package/out/adapters/cordis/adapter.d.ts +1 -1
  59. package/out/adapters/cordis/adapter.js +1 -1
  60. package/out/adapters/libpostal-dictionary.d.ts +9 -0
  61. package/out/adapters/libpostal-dictionary.d.ts.map +1 -1
  62. package/out/adapters/libpostal-dictionary.js +19 -3
  63. package/out/adapters/libpostal-dictionary.js.map +1 -1
  64. package/out/adapters/osm/adapter.d.ts +12 -1
  65. package/out/adapters/osm/adapter.d.ts.map +1 -1
  66. package/out/adapters/osm/adapter.js +30 -13
  67. package/out/adapters/osm/adapter.js.map +1 -1
  68. package/out/adapters.d.ts.map +1 -1
  69. package/out/adapters.js +14 -0
  70. package/out/adapters.js.map +1 -1
  71. package/out/az/adapters/opendata/adapter.d.ts +88 -0
  72. package/out/az/adapters/opendata/adapter.d.ts.map +1 -0
  73. package/out/az/adapters/opendata/adapter.js +282 -0
  74. package/out/az/adapters/opendata/adapter.js.map +1 -0
  75. package/out/bs/adapters/gbpa/adapter.d.ts +64 -0
  76. package/out/bs/adapters/gbpa/adapter.d.ts.map +1 -0
  77. package/out/bs/adapters/gbpa/adapter.js +187 -0
  78. package/out/bs/adapters/gbpa/adapter.js.map +1 -0
  79. package/out/build/types.d.ts +15 -0
  80. package/out/build/types.d.ts.map +1 -1
  81. package/out/build/types.js.map +1 -1
  82. package/out/build.d.ts.map +1 -1
  83. package/out/build.js +17 -0
  84. package/out/build.js.map +1 -1
  85. package/out/ee/adapters/ads/adapter.d.ts +6 -0
  86. package/out/ee/adapters/ads/adapter.d.ts.map +1 -1
  87. package/out/ee/adapters/ads/adapter.js +16 -0
  88. package/out/ee/adapters/ads/adapter.js.map +1 -1
  89. package/out/fo/adapters/adressur/adapter.d.ts +63 -0
  90. package/out/fo/adapters/adressur/adapter.d.ts.map +1 -0
  91. package/out/fo/adapters/adressur/adapter.js +177 -0
  92. package/out/fo/adapters/adressur/adapter.js.map +1 -0
  93. package/out/gb/adapters/os-open/adapter.d.ts +8 -0
  94. package/out/gb/adapters/os-open/adapter.d.ts.map +1 -1
  95. package/out/gb/adapters/os-open/adapter.js +11 -0
  96. package/out/gb/adapters/os-open/adapter.js.map +1 -1
  97. package/out/gb/county.d.ts +30 -0
  98. package/out/gb/county.d.ts.map +1 -0
  99. package/out/gb/county.js +51 -0
  100. package/out/gb/county.js.map +1 -0
  101. package/out/gb/os-open/open/names.d.ts +17 -0
  102. package/out/gb/os-open/open/names.d.ts.map +1 -1
  103. package/out/gb/os-open/open/names.js +19 -0
  104. package/out/gb/os-open/open/names.js.map +1 -1
  105. package/out/gu/adapters/bsp/adapter.d.ts +83 -0
  106. package/out/gu/adapters/bsp/adapter.d.ts.map +1 -0
  107. package/out/gu/adapters/bsp/adapter.js +346 -0
  108. package/out/gu/adapters/bsp/adapter.js.map +1 -0
  109. package/out/im/adapters/land-registry/adapter.d.ts +68 -0
  110. package/out/im/adapters/land-registry/adapter.d.ts.map +1 -0
  111. package/out/im/adapters/land-registry/adapter.js +261 -0
  112. package/out/im/adapters/land-registry/adapter.js.map +1 -0
  113. package/out/international/recipes/locale.d.ts +24 -4
  114. package/out/international/recipes/locale.d.ts.map +1 -1
  115. package/out/international/recipes/locale.js +107 -15
  116. package/out/international/recipes/locale.js.map +1 -1
  117. package/out/li/adapters/llv/adapter.d.ts +69 -0
  118. package/out/li/adapters/llv/adapter.d.ts.map +1 -0
  119. package/out/li/adapters/llv/adapter.js +181 -0
  120. package/out/li/adapters/llv/adapter.js.map +1 -0
  121. package/out/recipes/bare/postcode.d.ts.map +1 -1
  122. package/out/recipes/bare/postcode.js +9 -30
  123. package/out/recipes/bare/postcode.js.map +1 -1
  124. package/out/recipes/country-balanced.d.ts.map +1 -1
  125. package/out/recipes/country-balanced.js +82 -73
  126. package/out/recipes/country-balanced.js.map +1 -1
  127. package/out/recipes/intersection.d.ts +3 -2
  128. package/out/recipes/intersection.d.ts.map +1 -1
  129. package/out/recipes/intersection.js +20 -26
  130. package/out/recipes/intersection.js.map +1 -1
  131. package/out/recipes/po/box/cedex/recipe.d.ts.map +1 -1
  132. package/out/recipes/po/box/cedex/recipe.js +5 -4
  133. package/out/recipes/po/box/cedex/recipe.js.map +1 -1
  134. package/out/recipes/po/box/cedex/sources.d.ts +2 -45
  135. package/out/recipes/po/box/cedex/sources.d.ts.map +1 -1
  136. package/out/recipes/po/box/cedex/sources.js +0 -20
  137. package/out/recipes/po/box/cedex/sources.js.map +1 -1
  138. package/out/recipes/scaffold.d.ts +15 -2
  139. package/out/recipes/scaffold.d.ts.map +1 -1
  140. package/out/recipes/scaffold.js +21 -1
  141. package/out/recipes/scaffold.js.map +1 -1
  142. package/out/recipes/sources.d.ts.map +1 -1
  143. package/out/recipes/sources.js +112 -1
  144. package/out/recipes/sources.js.map +1 -1
  145. package/out/recipes/street/affix.d.ts.map +1 -1
  146. package/out/recipes/street/affix.js +8 -20
  147. package/out/recipes/street/affix.js.map +1 -1
  148. package/out/recipes/sub/venue/context.d.ts +14 -3
  149. package/out/recipes/sub/venue/context.d.ts.map +1 -1
  150. package/out/recipes/sub/venue/context.js +29 -9
  151. package/out/recipes/sub/venue/context.js.map +1 -1
  152. package/out/recipes/sub/venue/sources.d.ts +41 -8
  153. package/out/recipes/sub/venue/sources.d.ts.map +1 -1
  154. package/out/recipes/sub/venue/sources.js +26 -7
  155. package/out/recipes/sub/venue/sources.js.map +1 -1
  156. package/out/recipes/sub/venue.d.ts.map +1 -1
  157. package/out/recipes/sub/venue.js +35 -14
  158. package/out/recipes/sub/venue.js.map +1 -1
  159. package/out/recipes/unit.d.ts +4 -0
  160. package/out/recipes/unit.d.ts.map +1 -1
  161. package/out/recipes/unit.js +7 -6
  162. package/out/recipes/unit.js.map +1 -1
  163. package/out/registers.d.ts +33 -0
  164. package/out/registers.d.ts.map +1 -1
  165. package/out/registers.js +33 -0
  166. package/out/registers.js.map +1 -1
  167. package/out/source-register/corpus-inputs.d.ts +240 -0
  168. package/out/source-register/corpus-inputs.d.ts.map +1 -0
  169. package/out/source-register/corpus-inputs.js +275 -0
  170. package/out/source-register/corpus-inputs.js.map +1 -0
  171. package/out/source-register/effective-manifest.d.ts +2 -0
  172. package/out/source-register/effective-manifest.d.ts.map +1 -1
  173. package/out/source-register/effective-manifest.js +33 -4
  174. package/out/source-register/effective-manifest.js.map +1 -1
  175. package/out/source-register/types.d.ts +21 -1
  176. package/out/source-register/types.d.ts.map +1 -1
  177. package/out/source-register/types.js +20 -0
  178. package/out/source-register/types.js.map +1 -1
  179. package/out/south-asia/recipes/register.js +1 -1
  180. package/out/south-asia/recipes/register.js.map +1 -1
  181. package/out/tools/overlay/manifest.d.ts +7 -0
  182. package/out/tools/overlay/manifest.d.ts.map +1 -1
  183. package/out/tools/overlay/manifest.js +72 -1
  184. package/out/tools/overlay/manifest.js.map +1 -1
  185. package/out/tools/source-register/build.d.ts +9 -0
  186. package/out/tools/source-register/build.d.ts.map +1 -1
  187. package/out/tools/source-register/build.js +25 -3
  188. package/out/tools/source-register/build.js.map +1 -1
  189. package/out/tools/variety-sample.d.ts +196 -0
  190. package/out/tools/variety-sample.d.ts.map +1 -0
  191. package/out/tools/variety-sample.js +417 -0
  192. package/out/tools/variety-sample.js.map +1 -0
  193. package/out/us/adapters/usgov/nad/adapter.d.ts +80 -0
  194. package/out/us/adapters/usgov/nad/adapter.d.ts.map +1 -1
  195. package/out/us/adapters/usgov/nad/adapter.js +27 -8
  196. package/out/us/adapters/usgov/nad/adapter.js.map +1 -1
  197. package/out/us/recipes/skeleton-sources.d.ts +92 -0
  198. package/out/us/recipes/skeleton-sources.d.ts.map +1 -0
  199. package/out/us/recipes/skeleton-sources.js +121 -0
  200. package/out/us/recipes/skeleton-sources.js.map +1 -0
  201. package/out/us/tools/nad-skeletons.d.ts +120 -0
  202. package/out/us/tools/nad-skeletons.d.ts.map +1 -0
  203. package/out/us/tools/nad-skeletons.js +262 -0
  204. package/out/us/tools/nad-skeletons.js.map +1 -0
  205. package/out/uy/adapters/ide/adapter.d.ts +64 -0
  206. package/out/uy/adapters/ide/adapter.d.ts.map +1 -0
  207. package/out/uy/adapters/ide/adapter.js +206 -0
  208. package/out/uy/adapters/ide/adapter.js.map +1 -0
  209. package/out/ve/recipes/reviewed-postcode-tail.d.ts +6 -2
  210. package/out/ve/recipes/reviewed-postcode-tail.d.ts.map +1 -1
  211. package/out/ve/recipes/reviewed-postcode-tail.js +16 -5
  212. package/out/ve/recipes/reviewed-postcode-tail.js.map +1 -1
  213. package/package.json +14 -14
  214. package/lib/recipes/unit/sources.ts +0 -44
  215. package/lib/tools/fetch/ppd.ts +0 -121
  216. package/out/recipes/unit/sources.d.ts +0 -28
  217. package/out/recipes/unit/sources.d.ts.map +0 -1
  218. package/out/recipes/unit/sources.js +0 -30
  219. package/out/recipes/unit/sources.js.map +0 -1
  220. package/out/tools/fetch/ppd.d.ts +0 -15
  221. package/out/tools/fetch/ppd.d.ts.map +0 -1
  222. package/out/tools/fetch/ppd.js +0 -86
  223. package/out/tools/fetch/ppd.js.map +0 -1
@@ -0,0 +1,633 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Draws a country's training rows from a complete adapter output so the sample spans the country's address forms
7
+ * rather than its densest streets, and measures the variety of both.
8
+ *
9
+ * An address source lists one house after another: a city street contributes every number along it, and a city
10
+ * contributes thousands of such streets. A model trained on that order sees the same street, locality and form
11
+ * repeated. The sample here keeps at most a few rows per street and at most a fixed number per place (a locality
12
+ * with its dependent locality), and fills each place round-robin across its forms (which components are present,
13
+ * the street type, the script) and its postcodes. Places are then filled in rounds, so every place is represented
14
+ * once before any is represented twice, until the country's ceiling. The ceiling keeps one country from dominating
15
+ * a mixture.
16
+ *
17
+ * The adapter output stays complete beside the sample, because the same rows feed retrieval.
18
+ */
19
+
20
+ import { OFFICIAL_LANGUAGES } from "@mailwoman/codex/country/official-languages"
21
+ import { pathExists } from "@mailwoman/core/fs/readers/stat"
22
+ import { writeLocalJSONFile, writeLocalTextFile } from "@mailwoman/core/fs/writers"
23
+ import { sha256File } from "@mailwoman/core/hash"
24
+ import { tryParsingJSON } from "@mailwoman/core/json"
25
+ import { resourceDictionaryPath } from "@mailwoman/core/paths"
26
+ import { mulberry32, shuffleWith } from "@mailwoman/core/random"
27
+ import { type PathBuilderLike, resolvePath } from "path-ts"
28
+ import { TextSpliterator } from "spliterator"
29
+ import { Globerator } from "spliterator/node/fs"
30
+
31
+ import { loadLibpostalCanonicalForms } from "#adapters/libpostal-dictionary"
32
+ import type { CanonicalRow } from "#types"
33
+
34
+ /**
35
+ * The scripts a row's letters are classified into, in the order a tie is broken.
36
+ */
37
+ const SCRIPTS = [
38
+ "Latin",
39
+ "Cyrillic",
40
+ "Arabic",
41
+ "Armenian",
42
+ "Georgian",
43
+ "Greek",
44
+ "Hebrew",
45
+ "Han",
46
+ "Hangul",
47
+ "Thai",
48
+ "Devanagari",
49
+ ] as const
50
+
51
+ const SCRIPT_PATTERNS = SCRIPTS.map((script) => [script, new RegExp(`\\p{Script=${script}}`, "u")] as const)
52
+
53
+ /**
54
+ * The script most of `text`'s letters are written in, or `Other` when none
55
+ * of {@link SCRIPTS} holds a letter.
56
+ */
57
+ export function dominantScript(text: string): string {
58
+ const counts = new Map<string, number>()
59
+
60
+ for (const character of text) {
61
+ if (!/\p{L}/u.test(character)) continue
62
+
63
+ const script = SCRIPT_PATTERNS.find(([, pattern]) => pattern.test(character))?.[0] ?? "Other"
64
+
65
+ counts.set(script, (counts.get(script) ?? 0) + 1)
66
+ }
67
+
68
+ let best = "Other"
69
+ let most = 0
70
+
71
+ for (const [script, count] of counts) {
72
+ if (count > most) {
73
+ best = script
74
+ most = count
75
+ }
76
+ }
77
+
78
+ return best
79
+ }
80
+
81
+ /**
82
+ * The street-type forms a country's addresses use, each mapped to its canonical
83
+ * spelling, and which lexicon supplied them.
84
+ */
85
+ export interface StreetTypeLexicon {
86
+ readonly forms: ReadonlyMap<string, string>
87
+ /**
88
+ * The libpostal languages read: the country's official languages that have a `street_types.txt`,
89
+ * or `all` when none does and every language's list was read instead.
90
+ */
91
+ readonly languages: readonly string[] | "all"
92
+ }
93
+
94
+ /**
95
+ * The libpostal street-type lexicon for `country`'s official languages.
96
+ *
97
+ * A country whose official languages have no libpostal list (Armenian, Uzbek, Kyrgyz) reads
98
+ * every language's list, so a street written in a neighbor's language still counts its type.
99
+ */
100
+ export async function loadStreetTypeLexicon(country: string): Promise<StreetTypeLexicon> {
101
+ const official = (OFFICIAL_LANGUAGES[country.toUpperCase()]?.official ?? []).filter((code) => code.length === 2)
102
+ const available: string[] = []
103
+
104
+ for (const language of official) {
105
+ if (await pathExists(resourceDictionaryPath("libpostal", language, "street_types.txt"))) {
106
+ available.push(language)
107
+ }
108
+ }
109
+
110
+ const every = async (): Promise<string[]> => {
111
+ const files = await Globerator.from("*/street_types.txt", {
112
+ cwd: resourceDictionaryPath("libpostal").toString(),
113
+ absolute: false,
114
+ }).toSorted()
115
+
116
+ return files.map((file) => file.split("/")[0]!)
117
+ }
118
+
119
+ const languages = available.length ? available : await every()
120
+ const forms = new Map<string, string>()
121
+
122
+ for (const language of languages) {
123
+ for (const [form, canonical] of await loadLibpostalCanonicalForms(language, "street_types.txt")) {
124
+ if (form && !forms.has(form)) {
125
+ forms.set(form, `${language}:${canonical}`)
126
+ }
127
+ }
128
+ }
129
+
130
+ return { forms, languages: available.length ? available : "all" }
131
+ }
132
+
133
+ /**
134
+ * The canonical street type a street name carries, read from its first or last one or two words, or null.
135
+ */
136
+ export function streetTypeOf(street: string, lexicon: StreetTypeLexicon): string | null {
137
+ const words = street
138
+ .toLowerCase()
139
+ .split(/\s+/u)
140
+ .map((word) => word.replaceAll(/^[\p{P}\p{S}]+|[,;:]+$/gu, ""))
141
+ .filter((word) => word.length > 0)
142
+
143
+ if (!words.length) return null
144
+
145
+ const candidates = [
146
+ words.slice(0, 2).join(" "),
147
+ words.slice(-2).join(" "),
148
+ words[0]!,
149
+ words.at(-1)!,
150
+ words[0]!.replace(/\.$/u, ""),
151
+ words.at(-1)!.replace(/\.$/u, ""),
152
+ ]
153
+
154
+ for (const candidate of candidates) {
155
+ const canonical = lexicon.forms.get(candidate)
156
+
157
+ if (canonical) return canonical
158
+ }
159
+
160
+ return null
161
+ }
162
+
163
+ /**
164
+ * What one row contributes to a variety census.
165
+ */
166
+ export interface RowVariety {
167
+ readonly locality: string
168
+ readonly dependentLocality: string | null
169
+ /**
170
+ * The locality and the dependent locality together: the settlement a row is stratified by.
171
+ */
172
+ readonly place: string
173
+ readonly postcode: string | null
174
+ readonly street: string | null
175
+ readonly streetType: string | null
176
+ readonly hasUnit: boolean
177
+ readonly hasDependentLocality: boolean
178
+ readonly hasVenue: boolean
179
+ readonly streetless: boolean
180
+ readonly script: string
181
+ /**
182
+ * The components present, the street type and the script, joined: the row's form.
183
+ */
184
+ readonly form: string
185
+ }
186
+
187
+ /**
188
+ * Reads one canonical row's variety keys.
189
+ *
190
+ * The locality is the row's `locality`, else its `dependent_locality`, else its `region`,
191
+ * so a streetless premise in a named place is stratified by that place.
192
+ * The place a row is stratified by joins the dependent locality to it, so an Estonian village is
193
+ * its own place rather than one of its municipality's, and a city's districts are each their own.
194
+ */
195
+ export function rowVariety(row: Pick<CanonicalRow, "components" | "raw">, lexicon: StreetTypeLexicon): RowVariety {
196
+ const { components } = row
197
+ const street = components.street?.trim() || null
198
+ const streetType = street ? streetTypeOf(street, lexicon) : null
199
+ const script = dominantScript(row.raw)
200
+
201
+ const tags = Object.keys(components)
202
+ .filter((tag) => components[tag as keyof typeof components])
203
+ .toSorted()
204
+
205
+ const locality = components.locality ?? components.dependent_locality ?? components.region ?? ""
206
+ const dependentLocality = components.dependent_locality ?? null
207
+
208
+ return {
209
+ locality,
210
+ dependentLocality,
211
+ place: `${locality}\u0000${dependentLocality ?? ""}`,
212
+ postcode: components.postcode ?? null,
213
+ street,
214
+ streetType,
215
+ hasUnit: Boolean(components.unit),
216
+ hasDependentLocality: Boolean(components.dependent_locality),
217
+ hasVenue: Boolean(components.venue),
218
+ streetless: !street,
219
+ script,
220
+ form: `${tags.join("+")}|${streetType ?? "-"}|${script}`,
221
+ }
222
+ }
223
+
224
+ /**
225
+ * The variety measures of a set of rows.
226
+ */
227
+ export interface VarietyMeasures {
228
+ rows: number
229
+ localities: number
230
+ dependentLocalities: number
231
+ /**
232
+ * Distinct locality and dependent-locality pairs.
233
+ */
234
+ places: number
235
+ postcodes: number
236
+ streets: number
237
+ streetTypes: number
238
+ forms: number
239
+ unitShare: number
240
+ dependentLocalityShare: number
241
+ venueShare: number
242
+ streetlessShare: number
243
+ postcodeShare: number
244
+ scripts: Record<string, number>
245
+ /**
246
+ * The share of rows in the largest locality, a measure of how concentrated the rows are.
247
+ */
248
+ largestLocalityShare: number
249
+ }
250
+
251
+ /**
252
+ * Accumulates {@link VarietyMeasures} one row at a time.
253
+ */
254
+ export class VarietyCensus {
255
+ #rows = 0
256
+ #units = 0
257
+ #dependent = 0
258
+ #venues = 0
259
+ #streetless = 0
260
+ #withPostcode = 0
261
+ readonly #localities = new Map<string, number>()
262
+ readonly #dependentLocalities = new Set<string>()
263
+ readonly #places = new Set<string>()
264
+ readonly #postcodes = new Set<string>()
265
+ readonly #streets = new Set<string>()
266
+ readonly #streetTypes = new Set<string>()
267
+ readonly #forms = new Set<string>()
268
+ readonly #scripts = new Map<string, number>()
269
+
270
+ add(variety: RowVariety): void {
271
+ this.#rows++
272
+ this.#localities.set(variety.locality, (this.#localities.get(variety.locality) ?? 0) + 1)
273
+ this.#places.add(variety.place)
274
+
275
+ if (variety.dependentLocality) {
276
+ this.#dependentLocalities.add(`${variety.locality}\u0000${variety.dependentLocality}`)
277
+ }
278
+
279
+ if (variety.postcode) {
280
+ this.#withPostcode++
281
+ this.#postcodes.add(variety.postcode)
282
+ }
283
+
284
+ if (variety.street) {
285
+ this.#streets.add(`${variety.locality}\u0000${variety.street}`)
286
+ }
287
+
288
+ if (variety.streetType) {
289
+ this.#streetTypes.add(variety.streetType)
290
+ }
291
+
292
+ this.#forms.add(variety.form)
293
+ this.#scripts.set(variety.script, (this.#scripts.get(variety.script) ?? 0) + 1)
294
+
295
+ if (variety.hasUnit) {
296
+ this.#units++
297
+ }
298
+
299
+ if (variety.hasDependentLocality) {
300
+ this.#dependent++
301
+ }
302
+
303
+ if (variety.hasVenue) {
304
+ this.#venues++
305
+ }
306
+
307
+ if (variety.streetless) {
308
+ this.#streetless++
309
+ }
310
+ }
311
+
312
+ measures(): VarietyMeasures {
313
+ const share = (count: number) => (this.#rows ? Number((count / this.#rows).toFixed(4)) : 0)
314
+ let largestLocality = 0
315
+
316
+ for (const count of this.#localities.values()) {
317
+ largestLocality = Math.max(largestLocality, count)
318
+ }
319
+
320
+ return {
321
+ rows: this.#rows,
322
+ localities: this.#localities.size,
323
+ dependentLocalities: this.#dependentLocalities.size,
324
+ places: this.#places.size,
325
+ postcodes: this.#postcodes.size,
326
+ streets: this.#streets.size,
327
+ streetTypes: this.#streetTypes.size,
328
+ forms: this.#forms.size,
329
+ unitShare: share(this.#units),
330
+ dependentLocalityShare: share(this.#dependent),
331
+ venueShare: share(this.#venues),
332
+ streetlessShare: share(this.#streetless),
333
+ postcodeShare: share(this.#withPostcode),
334
+ scripts: Object.fromEntries([...this.#scripts].toSorted((a, b) => b[1] - a[1])),
335
+ largestLocalityShare: share(largestLocality),
336
+ }
337
+ }
338
+ }
339
+
340
+ /**
341
+ * The limits a variety sample is drawn under.
342
+ */
343
+ export interface VarietySampleLimits {
344
+ /**
345
+ * The most rows kept from one street in one locality.
346
+ */
347
+ perStreet: number
348
+ /**
349
+ * The most rows kept from one place, a locality and dependent-locality pair.
350
+ */
351
+ perPlace: number
352
+ /**
353
+ * The most rows kept for the country.
354
+ */
355
+ ceiling: number
356
+ seed: number
357
+ }
358
+
359
+ /**
360
+ * The selection key of one row: interned ids, so a country's rows fit in memory as integers.
361
+ */
362
+ export interface SelectionKey {
363
+ readonly place: number
364
+ readonly street: number
365
+ readonly form: number
366
+ readonly postcode: number
367
+ }
368
+
369
+ /**
370
+ * Groups `items` by the key `groupOf` assigns, in first-seen order.
371
+ */
372
+ function groupBy<T>(items: readonly T[], groupOf: (item: T) => number): T[][] {
373
+ const groups = new Map<number, T[]>()
374
+
375
+ for (const item of items) {
376
+ const key = groupOf(item)
377
+ let group = groups.get(key)
378
+
379
+ if (!group) {
380
+ groups.set(key, (group = []))
381
+ }
382
+
383
+ group.push(item)
384
+ }
385
+
386
+ return [...groups.values()]
387
+ }
388
+
389
+ /**
390
+ * Interleaves `lists`, the first item of each and then the second of each,
391
+ * until every list is spent or `limit` items are taken.
392
+ */
393
+ function interleave<T>(lists: readonly (readonly T[])[], limit: number): T[] {
394
+ const ordered: T[] = []
395
+
396
+ const total = Math.min(
397
+ limit,
398
+ lists.reduce((sum, list) => sum + list.length, 0)
399
+ )
400
+
401
+ for (let round = 0; ordered.length < total; round++) {
402
+ for (const list of lists) {
403
+ if (round < list.length && ordered.length < total) {
404
+ ordered.push(list[round]!)
405
+ }
406
+ }
407
+ }
408
+
409
+ return ordered
410
+ }
411
+
412
+ /**
413
+ * Chooses the row indices of a variety sample.
414
+ *
415
+ * Rows are visited in a seeded random order.
416
+ * A street keeps its first {@link VarietySampleLimits.perStreet} rows.
417
+ *
418
+ * Within a place the kept rows are interleaved across forms, and within a form across postcodes,
419
+ * and the first {@link VarietySampleLimits.perPlace} are the place's candidates.
420
+ * Candidates are then taken by their rank within their place, lowest first, so every place supplies
421
+ * its first row before any supplies its second, until {@link VarietySampleLimits.ceiling}.
422
+ * The returned indices are ascending.
423
+ */
424
+ export function selectVarietySample(keys: readonly SelectionKey[], limits: VarietySampleLimits): number[] {
425
+ const order = keys.map((_, index) => index)
426
+
427
+ shuffleWith(order, mulberry32(limits.seed))
428
+
429
+ const perStreet = new Map<number, number>()
430
+ const byPlace = new Map<number, number[]>()
431
+
432
+ for (const index of order) {
433
+ const key = keys[index]!
434
+ const kept = perStreet.get(key.street) ?? 0
435
+
436
+ if (kept >= limits.perStreet) continue
437
+
438
+ perStreet.set(key.street, kept + 1)
439
+
440
+ let rows = byPlace.get(key.place)
441
+
442
+ if (!rows) {
443
+ byPlace.set(key.place, (rows = []))
444
+ }
445
+
446
+ rows.push(index)
447
+ }
448
+
449
+ // Candidates bucketed by rank within their place.
450
+ // The places' order is the shuffled first-seen order, so the partial last
451
+ // round is a seeded draw across places.
452
+ const byRank: number[][] = []
453
+
454
+ for (const rows of byPlace.values()) {
455
+ // Forms take turns, and within a form its postcodes take turns, so a rare form such as a unit row
456
+ // is drawn as early as the common one and a form's rows spread across the place's postcodes.
457
+ const forms = groupBy(rows, (index) => keys[index]!.form).map((formRows) =>
458
+ interleave(
459
+ groupBy(formRows, (index) => keys[index]!.postcode),
460
+ limits.perPlace
461
+ )
462
+ )
463
+
464
+ const ranked = interleave(forms, limits.perPlace)
465
+
466
+ for (const [rank, index] of ranked.entries()) {
467
+ ;(byRank[rank] ??= []).push(index)
468
+ }
469
+ }
470
+
471
+ const chosen: number[] = []
472
+
473
+ for (const bucket of byRank) {
474
+ if (chosen.length >= limits.ceiling) break
475
+
476
+ chosen.push(...bucket.slice(0, limits.ceiling - chosen.length))
477
+ }
478
+
479
+ return chosen.toSorted((a, b) => a - b)
480
+ }
481
+
482
+ /**
483
+ * What a variety-sample run read, wrote and measured.
484
+ */
485
+ export interface VarietySampleReport {
486
+ input: string
487
+ inputSHA256: string
488
+ output: string
489
+ outputSHA256: string
490
+ country: string
491
+ limits: VarietySampleLimits
492
+ streetTypeLanguages: readonly string[] | "all"
493
+ /**
494
+ * Lines that did not parse as a canonical row of the requested country.
495
+ */
496
+ skipped: Record<string, number>
497
+ full: VarietyMeasures
498
+ sample: VarietyMeasures
499
+ /**
500
+ * The share of the full output's distinct values the sample keeps.
501
+ */
502
+ coverage: { localities: number; places: number; postcodes: number; streetTypes: number; forms: number }
503
+ }
504
+
505
+ /**
506
+ * Reads a complete adapter output, writes its variety sample to `output` and the
507
+ * report beside it as `<output>.variety.json`, and returns the report.
508
+ *
509
+ * The input is read twice, once to key every row and once to copy the chosen lines,
510
+ * so memory holds integers per row rather than the rows themselves.
511
+ */
512
+ export async function writeVarietySample(options: {
513
+ input: PathBuilderLike
514
+ output: PathBuilderLike
515
+ country: string
516
+ limits: VarietySampleLimits
517
+ }): Promise<VarietySampleReport> {
518
+ const { input, output, limits } = options
519
+ const country = options.country.toUpperCase()
520
+ const lexicon = await loadStreetTypeLexicon(country)
521
+
522
+ const intern = () => {
523
+ const ids = new Map<string, number>()
524
+
525
+ return (value: string) => {
526
+ let id = ids.get(value)
527
+
528
+ if (typeof id !== "number") {
529
+ ids.set(value, (id = ids.size))
530
+ }
531
+
532
+ return id
533
+ }
534
+ }
535
+
536
+ const placeID = intern()
537
+ const streetID = intern()
538
+ const formID = intern()
539
+ const postcodeID = intern()
540
+ const keys: SelectionKey[] = []
541
+ const lineOfKey: number[] = []
542
+ const full = new VarietyCensus()
543
+ const varieties: RowVariety[] = []
544
+ const skipped: Record<string, number> = {}
545
+ let line = -1
546
+
547
+ for await (const text of TextSpliterator.fromAsync(input)) {
548
+ line++
549
+
550
+ if (!text.trim()) continue
551
+
552
+ const row = tryParsingJSON(text) as CanonicalRow | null
553
+
554
+ if (!row || typeof row !== "object" || !row.components || typeof row.raw !== "string") {
555
+ skipped["unparseable"] = (skipped["unparseable"] ?? 0) + 1
556
+
557
+ continue
558
+ }
559
+
560
+ if (row.country !== country) {
561
+ skipped[`country:${row.country}`] = (skipped[`country:${row.country}`] ?? 0) + 1
562
+
563
+ continue
564
+ }
565
+
566
+ const variety = rowVariety(row, lexicon)
567
+
568
+ full.add(variety)
569
+ varieties.push(variety)
570
+ lineOfKey.push(line)
571
+
572
+ keys.push({
573
+ place: placeID(variety.place),
574
+ // A streetless premise numbered within a place repeats that place as a street's
575
+ // houses repeat the street, so the place is its street.
576
+ // A named site (an Estonian farm) is its own name, as a street is.
577
+ street: streetID(`${variety.place}\u0001${variety.street ?? row.components.venue ?? ""}`),
578
+ form: formID(variety.form),
579
+ postcode: postcodeID(variety.postcode ?? ""),
580
+ })
581
+ }
582
+
583
+ const chosen = selectVarietySample(keys, limits)
584
+ const sample = new VarietyCensus()
585
+ const chosenLines = new Set<number>()
586
+
587
+ for (const index of chosen) {
588
+ sample.add(varieties[index]!)
589
+ chosenLines.add(lineOfKey[index]!)
590
+ }
591
+
592
+ async function* selectedLines(): AsyncIterable<string> {
593
+ let current = -1
594
+
595
+ for await (const text of TextSpliterator.fromAsync(input)) {
596
+ current++
597
+
598
+ if (chosenLines.has(current)) {
599
+ yield text
600
+ }
601
+ }
602
+ }
603
+
604
+ await writeLocalTextFile(selectedLines(), output)
605
+
606
+ const fullMeasures = full.measures()
607
+ const sampleMeasures = sample.measures()
608
+ const ratio = (kept: number, total: number) => (total ? Number((kept / total).toFixed(4)) : 0)
609
+
610
+ const report: VarietySampleReport = {
611
+ input: resolvePath(input),
612
+ inputSHA256: await sha256File(input),
613
+ output: resolvePath(output),
614
+ outputSHA256: await sha256File(output),
615
+ country,
616
+ limits,
617
+ streetTypeLanguages: lexicon.languages,
618
+ skipped,
619
+ full: fullMeasures,
620
+ sample: sampleMeasures,
621
+ coverage: {
622
+ localities: ratio(sampleMeasures.localities, fullMeasures.localities),
623
+ places: ratio(sampleMeasures.places, fullMeasures.places),
624
+ postcodes: ratio(sampleMeasures.postcodes, fullMeasures.postcodes),
625
+ streetTypes: ratio(sampleMeasures.streetTypes, fullMeasures.streetTypes),
626
+ forms: ratio(sampleMeasures.forms, fullMeasures.forms),
627
+ },
628
+ }
629
+
630
+ await writeLocalJSONFile(report, `${resolvePath(output)}.variety.json`)
631
+
632
+ return report
633
+ }