@mailwoman/corpus 10.5.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 (232) 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 +14 -2
  27. package/lib/gb/county.ts +59 -0
  28. package/lib/gb/os-open/{open-names.ts → 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 +10 -2
  94. package/out/gb/adapters/os-open/adapter.d.ts.map +1 -1
  95. package/out/gb/adapters/os-open/adapter.js +13 -2
  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 → open/names.d.ts} +18 -1
  102. package/out/gb/os-open/open/names.d.ts.map +1 -0
  103. package/out/gb/os-open/{open-names.js → open/names.js} +20 -1
  104. package/out/gb/os-open/open/names.js.map +1 -0
  105. package/out/gb/os-open/{open-roads.d.ts → open/roads.d.ts} +1 -1
  106. package/out/gb/os-open/open/roads.d.ts.map +1 -0
  107. package/out/gb/os-open/{open-roads.js → open/roads.js} +1 -1
  108. package/out/gb/os-open/open/roads.js.map +1 -0
  109. package/out/gu/adapters/bsp/adapter.d.ts +83 -0
  110. package/out/gu/adapters/bsp/adapter.d.ts.map +1 -0
  111. package/out/gu/adapters/bsp/adapter.js +346 -0
  112. package/out/gu/adapters/bsp/adapter.js.map +1 -0
  113. package/out/im/adapters/land-registry/adapter.d.ts +68 -0
  114. package/out/im/adapters/land-registry/adapter.d.ts.map +1 -0
  115. package/out/im/adapters/land-registry/adapter.js +261 -0
  116. package/out/im/adapters/land-registry/adapter.js.map +1 -0
  117. package/out/international/recipes/locale.d.ts +24 -4
  118. package/out/international/recipes/locale.d.ts.map +1 -1
  119. package/out/international/recipes/locale.js +107 -15
  120. package/out/international/recipes/locale.js.map +1 -1
  121. package/out/li/adapters/llv/adapter.d.ts +69 -0
  122. package/out/li/adapters/llv/adapter.d.ts.map +1 -0
  123. package/out/li/adapters/llv/adapter.js +181 -0
  124. package/out/li/adapters/llv/adapter.js.map +1 -0
  125. package/out/recipes/bare/postcode.d.ts.map +1 -1
  126. package/out/recipes/bare/postcode.js +9 -30
  127. package/out/recipes/bare/postcode.js.map +1 -1
  128. package/out/recipes/country-balanced.d.ts.map +1 -1
  129. package/out/recipes/country-balanced.js +82 -73
  130. package/out/recipes/country-balanced.js.map +1 -1
  131. package/out/recipes/intersection.d.ts +3 -2
  132. package/out/recipes/intersection.d.ts.map +1 -1
  133. package/out/recipes/intersection.js +20 -26
  134. package/out/recipes/intersection.js.map +1 -1
  135. package/out/recipes/po/box/cedex/recipe.d.ts.map +1 -1
  136. package/out/recipes/po/box/cedex/recipe.js +5 -4
  137. package/out/recipes/po/box/cedex/recipe.js.map +1 -1
  138. package/out/recipes/po/box/cedex/sources.d.ts +2 -45
  139. package/out/recipes/po/box/cedex/sources.d.ts.map +1 -1
  140. package/out/recipes/po/box/cedex/sources.js +0 -20
  141. package/out/recipes/po/box/cedex/sources.js.map +1 -1
  142. package/out/recipes/scaffold.d.ts +15 -2
  143. package/out/recipes/scaffold.d.ts.map +1 -1
  144. package/out/recipes/scaffold.js +21 -1
  145. package/out/recipes/scaffold.js.map +1 -1
  146. package/out/recipes/sources.d.ts.map +1 -1
  147. package/out/recipes/sources.js +112 -1
  148. package/out/recipes/sources.js.map +1 -1
  149. package/out/recipes/street/affix.d.ts.map +1 -1
  150. package/out/recipes/street/affix.js +8 -20
  151. package/out/recipes/street/affix.js.map +1 -1
  152. package/out/recipes/sub/venue/context.d.ts +14 -3
  153. package/out/recipes/sub/venue/context.d.ts.map +1 -1
  154. package/out/recipes/sub/venue/context.js +29 -9
  155. package/out/recipes/sub/venue/context.js.map +1 -1
  156. package/out/recipes/sub/venue/sources.d.ts +41 -8
  157. package/out/recipes/sub/venue/sources.d.ts.map +1 -1
  158. package/out/recipes/sub/venue/sources.js +26 -7
  159. package/out/recipes/sub/venue/sources.js.map +1 -1
  160. package/out/recipes/sub/venue.d.ts.map +1 -1
  161. package/out/recipes/sub/venue.js +35 -14
  162. package/out/recipes/sub/venue.js.map +1 -1
  163. package/out/recipes/unit.d.ts +4 -0
  164. package/out/recipes/unit.d.ts.map +1 -1
  165. package/out/recipes/unit.js +7 -6
  166. package/out/recipes/unit.js.map +1 -1
  167. package/out/registers.d.ts +33 -0
  168. package/out/registers.d.ts.map +1 -1
  169. package/out/registers.js +33 -0
  170. package/out/registers.js.map +1 -1
  171. package/out/source-register/corpus-inputs.d.ts +240 -0
  172. package/out/source-register/corpus-inputs.d.ts.map +1 -0
  173. package/out/source-register/corpus-inputs.js +275 -0
  174. package/out/source-register/corpus-inputs.js.map +1 -0
  175. package/out/source-register/effective-manifest.d.ts +2 -0
  176. package/out/source-register/effective-manifest.d.ts.map +1 -1
  177. package/out/source-register/effective-manifest.js +33 -4
  178. package/out/source-register/effective-manifest.js.map +1 -1
  179. package/out/source-register/types.d.ts +21 -1
  180. package/out/source-register/types.d.ts.map +1 -1
  181. package/out/source-register/types.js +20 -0
  182. package/out/source-register/types.js.map +1 -1
  183. package/out/south-asia/recipes/register.js +1 -1
  184. package/out/south-asia/recipes/register.js.map +1 -1
  185. package/out/tools/overlay/manifest.d.ts +7 -0
  186. package/out/tools/overlay/manifest.d.ts.map +1 -1
  187. package/out/tools/overlay/manifest.js +72 -1
  188. package/out/tools/overlay/manifest.js.map +1 -1
  189. package/out/tools/source-register/build.d.ts +9 -0
  190. package/out/tools/source-register/build.d.ts.map +1 -1
  191. package/out/tools/source-register/build.js +25 -3
  192. package/out/tools/source-register/build.js.map +1 -1
  193. package/out/tools/variety-sample.d.ts +196 -0
  194. package/out/tools/variety-sample.d.ts.map +1 -0
  195. package/out/tools/variety-sample.js +417 -0
  196. package/out/tools/variety-sample.js.map +1 -0
  197. package/out/us/adapters/usgov/nad/adapter.d.ts +80 -0
  198. package/out/us/adapters/usgov/nad/adapter.d.ts.map +1 -1
  199. package/out/us/adapters/usgov/nad/adapter.js +27 -8
  200. package/out/us/adapters/usgov/nad/adapter.js.map +1 -1
  201. package/out/us/recipes/skeleton-sources.d.ts +92 -0
  202. package/out/us/recipes/skeleton-sources.d.ts.map +1 -0
  203. package/out/us/recipes/skeleton-sources.js +121 -0
  204. package/out/us/recipes/skeleton-sources.js.map +1 -0
  205. package/out/us/tools/nad-skeletons.d.ts +120 -0
  206. package/out/us/tools/nad-skeletons.d.ts.map +1 -0
  207. package/out/us/tools/nad-skeletons.js +262 -0
  208. package/out/us/tools/nad-skeletons.js.map +1 -0
  209. package/out/uy/adapters/ide/adapter.d.ts +64 -0
  210. package/out/uy/adapters/ide/adapter.d.ts.map +1 -0
  211. package/out/uy/adapters/ide/adapter.js +206 -0
  212. package/out/uy/adapters/ide/adapter.js.map +1 -0
  213. package/out/ve/recipes/reviewed-postcode-tail.d.ts +6 -2
  214. package/out/ve/recipes/reviewed-postcode-tail.d.ts.map +1 -1
  215. package/out/ve/recipes/reviewed-postcode-tail.js +16 -5
  216. package/out/ve/recipes/reviewed-postcode-tail.js.map +1 -1
  217. package/package.json +14 -14
  218. package/lib/recipes/unit/sources.ts +0 -44
  219. package/lib/tools/fetch/ppd.ts +0 -121
  220. package/out/gb/os-open/open-names.d.ts.map +0 -1
  221. package/out/gb/os-open/open-names.js.map +0 -1
  222. package/out/gb/os-open/open-roads.d.ts.map +0 -1
  223. package/out/gb/os-open/open-roads.js.map +0 -1
  224. package/out/recipes/unit/sources.d.ts +0 -28
  225. package/out/recipes/unit/sources.d.ts.map +0 -1
  226. package/out/recipes/unit/sources.js +0 -30
  227. package/out/recipes/unit/sources.js.map +0 -1
  228. package/out/tools/fetch/ppd.d.ts +0 -15
  229. package/out/tools/fetch/ppd.d.ts.map +0 -1
  230. package/out/tools/fetch/ppd.js +0 -86
  231. package/out/tools/fetch/ppd.js.map +0 -1
  232. /package/lib/gb/os-open/{open-roads.ts → open/roads.ts} +0 -0
@@ -0,0 +1,528 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Joins a corpus source id to the register sources whose data its rows are built from.
7
+ *
8
+ * A corpus source id is the `source` value stored on every corpus row, keyed by a training config's
9
+ * `source_weights` and listed in a training manifest. The register scopes a source to one publisher in one
10
+ * jurisdiction and records its license decision. This table is the one record joining the two. Before it, a source whose
11
+ * register row was refused or never read could feed a corpus build and a training run: HM Land Registry Price Paid
12
+ * Data reached `rendered-gb` and `spliced-sub-venue` while its row read `unchecked` (#2635).
13
+ *
14
+ * The table is `packages/corpus/data/corpus-source-inputs.json`. A derived or synthetic source lists every input
15
+ * whose data reaches one of its rows. An input with no register row gets one in `source-additions.json`, under an
16
+ * `unchecked` decision, so the gap reads as a gap rather than as an absence.
17
+ */
18
+
19
+ import { readLocalJSONFile } from "@mailwoman/core/fs/readers"
20
+ import { stringifyJSON } from "@mailwoman/core/json"
21
+ import { resolveModulePath } from "@mailwoman/core/module/resolvers"
22
+ import type { PathBuilderLike } from "path-ts"
23
+
24
+ import { permissionFor, readAddressSourceRegister } from "#source-register/register"
25
+ import {
26
+ LicenseReviewState,
27
+ OperationPermission,
28
+ SourceOperation,
29
+ type AddressSourceRegister,
30
+ type LicenseDecision,
31
+ } from "#source-register/types"
32
+
33
+ /**
34
+ * The operations a register decision must permit before a source's rows may
35
+ * be built into a corpus or trained on.
36
+ *
37
+ * Both need adaptation: normalizing an address into labeled components is a derived work,
38
+ * and fitting a model on it is another.
39
+ */
40
+ export const TRAINING_OPERATIONS: readonly SourceOperation[] = [SourceOperation.Transform, SourceOperation.Train]
41
+
42
+ /**
43
+ * One register source whose data reaches a corpus source's rows.
44
+ */
45
+ export interface CorpusSourceInput {
46
+ /**
47
+ * The register's `sourceID`.
48
+ */
49
+ registerSource: string
50
+ /**
51
+ * The `licenseID` that register source points at, repeated so a reviewer reads the decision key here.
52
+ *
53
+ * {@linkcode auditCorpusSourceInputs} refuses a value that disagrees with the register.
54
+ */
55
+ license: string
56
+ /**
57
+ * What the corpus source reads from it: the file, the fields and the component they fill.
58
+ */
59
+ reads: string
60
+ }
61
+
62
+ /**
63
+ * Everything one corpus source's rows are built from.
64
+ */
65
+ export interface CorpusSourceMapping {
66
+ /**
67
+ * Other spellings rows of this source are stored under, such as the retired `synth-*` spelling.
68
+ */
69
+ aliases?: readonly string[]
70
+ /**
71
+ * Every register source whose data reaches a row.
72
+ */
73
+ inputs: readonly CorpusSourceInput[]
74
+ /**
75
+ * Inputs written in this repository, which need no register row: a hand-written word list, a template.
76
+ */
77
+ authored?: readonly string[]
78
+ note?: string
79
+ /**
80
+ * What the source read in corpora built before its producer changed inputs under the same id.
81
+ *
82
+ * `inputs` describes what a build reads today.
83
+ * The build and training checks read it.
84
+ *
85
+ * A corpus already built keeps the rows it was built from, so the release audit reads the
86
+ * entry naming that corpus: `spliced-sub-venue` kept its name when its GB skeletons moved from
87
+ * Price Paid Data to OS OpenData, and the published model trained on the Price Paid Data rows.
88
+ */
89
+ history?: readonly CorpusSourceHistory[]
90
+ }
91
+
92
+ /**
93
+ * The inputs one source read in the corpora named.
94
+ */
95
+ export interface CorpusSourceHistory {
96
+ corpora: readonly string[]
97
+ inputs: readonly CorpusSourceInput[]
98
+ because: string
99
+ }
100
+
101
+ /**
102
+ * The committed table.
103
+ */
104
+ export interface CorpusSourceInputsFile {
105
+ sources: Readonly<Record<string, CorpusSourceMapping>>
106
+ }
107
+
108
+ /**
109
+ * The table indexed for lookup under every spelling it records.
110
+ */
111
+ export interface CorpusSourceInputs {
112
+ readonly file: CorpusSourceInputsFile
113
+ /**
114
+ * The mapping a spelling resolves to, with the id it is filed under.
115
+ */
116
+ resolve(source: string): { id: string; mapping: CorpusSourceMapping } | null
117
+ }
118
+
119
+ /**
120
+ * Resolves the committed table through the package's `./data/*` export.
121
+ */
122
+ export function corpusSourceInputsPath(): string {
123
+ return resolveModulePath("@mailwoman/corpus/data/corpus-source-inputs.json")
124
+ }
125
+
126
+ /**
127
+ * Indexes a table by id and alias.
128
+ *
129
+ * @throws When two entries claim one spelling, because a lookup would then answer for whichever came last.
130
+ */
131
+ export function indexCorpusSourceInputs(file: CorpusSourceInputsFile): CorpusSourceInputs {
132
+ const bySpelling = new Map<string, string>()
133
+
134
+ for (const [id, mapping] of Object.entries(file.sources)) {
135
+ for (const spelling of [id, ...(mapping.aliases ?? [])]) {
136
+ const claimed = bySpelling.get(spelling)
137
+
138
+ if (claimed && claimed !== id) {
139
+ throw new Error(`corpus source spelling ${stringifyJSON(spelling)} is claimed by both ${claimed} and ${id}`)
140
+ }
141
+
142
+ bySpelling.set(spelling, id)
143
+ }
144
+ }
145
+
146
+ return {
147
+ file,
148
+ resolve(source) {
149
+ const id = bySpelling.get(source)
150
+
151
+ return id ? { id, mapping: file.sources[id]! } : null
152
+ },
153
+ }
154
+ }
155
+
156
+ /**
157
+ * One message per structural problem in the table, read against the register.
158
+ */
159
+ export function auditCorpusSourceInputs(file: CorpusSourceInputsFile, register: AddressSourceRegister): string[] {
160
+ const problems: string[] = []
161
+ const sources = new Map(register.sources.map((source) => [source.sourceID, source]))
162
+
163
+ for (const [id, mapping] of Object.entries(file.sources)) {
164
+ const named = stringifyJSON(id)
165
+
166
+ if (!mapping.inputs.length && !mapping.authored?.length) {
167
+ problems.push(`corpus source ${named} lists no input, so nothing records what its rows are built from`)
168
+ }
169
+
170
+ for (const entry of mapping.history ?? []) {
171
+ if (!entry.corpora.length || !entry.because) {
172
+ problems.push(`corpus source ${named} has a history entry naming no corpus or giving no reason`)
173
+ }
174
+ }
175
+
176
+ for (const input of [...mapping.inputs, ...(mapping.history ?? []).flatMap((entry) => entry.inputs)]) {
177
+ const row = sources.get(input.registerSource)
178
+
179
+ if (!row) {
180
+ problems.push(
181
+ `corpus source ${named} reads register source ${stringifyJSON(input.registerSource)}, which the register ` +
182
+ "does not carry. Add the row to `source-additions.json` and regenerate the register."
183
+ )
184
+
185
+ continue
186
+ }
187
+
188
+ if (row.license !== input.license) {
189
+ problems.push(
190
+ `corpus source ${named} records license ${stringifyJSON(input.license)} for ${input.registerSource}, ` +
191
+ `and the register points that source at ${stringifyJSON(row.license)}`
192
+ )
193
+ }
194
+
195
+ if (!input.reads) {
196
+ problems.push(`corpus source ${named} reads ${input.registerSource} and does not say what it reads`)
197
+ }
198
+ }
199
+ }
200
+
201
+ return problems
202
+ }
203
+
204
+ /**
205
+ * Reads the committed table and checks it against the register.
206
+ *
207
+ * @throws When the file does not parse or the audit reports a problem.
208
+ */
209
+ export async function readCorpusSourceInputs(
210
+ register: AddressSourceRegister,
211
+ path?: PathBuilderLike
212
+ ): Promise<CorpusSourceInputs> {
213
+ const resolved = path ?? corpusSourceInputsPath()
214
+ const file = await readLocalJSONFile<CorpusSourceInputsFile>(resolved)
215
+ const problems = auditCorpusSourceInputs(file, register)
216
+
217
+ if (problems.length) {
218
+ throw new Error(`${resolved} failed its audit against the register:\n ${problems.join("\n ")}`)
219
+ }
220
+
221
+ return indexCorpusSourceInputs(file)
222
+ }
223
+
224
+ /**
225
+ * Why one corpus source lacks the permission {@linkcode TRAINING_OPERATIONS} need.
226
+ */
227
+ export interface SourceRightsGap {
228
+ /**
229
+ * The corpus source id as the caller named it.
230
+ */
231
+ source: string
232
+ /**
233
+ * The register source lacking the permission, or `null` when the table has no entry for the corpus source.
234
+ */
235
+ registerSource: string | null
236
+ licenseID: string | null
237
+ /**
238
+ * The decision's review state, or `null` when there is no decision to read.
239
+ */
240
+ state: LicenseDecision["state"] | null
241
+ /**
242
+ * The operations the decision does not permit.
243
+ */
244
+ missing: SourceOperation[]
245
+ because: string
246
+ }
247
+
248
+ /**
249
+ * Every reason a corpus source's rows may not be built into a corpus or trained on.
250
+ *
251
+ * An empty array is the only answer a caller may read as permission.
252
+ * A source the table does not map answers one gap whose `registerSource` is `null`,
253
+ * because an unmapped source is one nobody has traced to its publishers.
254
+ *
255
+ * `corpusVersion` names a corpus already built, so a source whose inputs changed under
256
+ * the same id is read against what that corpus's rows were built from.
257
+ * A build or training check omits it and reads today's inputs.
258
+ */
259
+ export function sourceRightsGaps(
260
+ source: string,
261
+ inputs: CorpusSourceInputs,
262
+ register: AddressSourceRegister,
263
+ corpusVersion?: string
264
+ ): SourceRightsGap[] {
265
+ const resolved = inputs.resolve(source)
266
+
267
+ if (!resolved) {
268
+ return [
269
+ {
270
+ source,
271
+ registerSource: null,
272
+ licenseID: null,
273
+ state: null,
274
+ missing: [...TRAINING_OPERATIONS],
275
+ because:
276
+ "no entry in packages/corpus/data/corpus-source-inputs.json says which register sources its rows are " +
277
+ "built from",
278
+ },
279
+ ]
280
+ }
281
+
282
+ const decisions = new Map(register.licenses.map((decision) => [decision.licenseID, decision]))
283
+ const rows = new Map(register.sources.map((row) => [row.sourceID, row]))
284
+ const gaps: SourceRightsGap[] = []
285
+
286
+ const former = corpusVersion
287
+ ? resolved.mapping.history?.find((entry) => entry.corpora.includes(corpusVersion))
288
+ : undefined
289
+
290
+ for (const input of former?.inputs ?? resolved.mapping.inputs) {
291
+ const row = rows.get(input.registerSource)
292
+ const decision = row ? decisions.get(row.license) : undefined
293
+
294
+ if (!row || !decision) {
295
+ gaps.push({
296
+ source,
297
+ registerSource: input.registerSource,
298
+ licenseID: row?.license ?? null,
299
+ state: null,
300
+ missing: [...TRAINING_OPERATIONS],
301
+ because: row
302
+ ? `the register declares no decision ${stringifyJSON(row.license)}`
303
+ : "the register has no such row",
304
+ })
305
+
306
+ continue
307
+ }
308
+
309
+ const readings = TRAINING_OPERATIONS.map((operation) => ({
310
+ operation,
311
+ reading: permissionFor(decision, operation),
312
+ }))
313
+
314
+ const missing = readings.filter(({ reading }) => reading.permission !== OperationPermission.Permitted)
315
+
316
+ if (!missing.length) continue
317
+
318
+ gaps.push({
319
+ source,
320
+ registerSource: row.sourceID,
321
+ licenseID: decision.licenseID,
322
+ state: decision.state,
323
+ missing: missing.map(({ operation }) => operation),
324
+ because:
325
+ decision.state === LicenseReviewState.Refused
326
+ ? `the decision is refused: ${decision.refusedBecause}`
327
+ : missing[0]!.reading.because,
328
+ })
329
+ }
330
+
331
+ return gaps
332
+ }
333
+
334
+ /**
335
+ * One line naming the corpus source, the register row and the missing permission.
336
+ */
337
+ export function describeSourceRightsGap(gap: SourceRightsGap): string {
338
+ const missing = gap.missing.join(" and ")
339
+
340
+ if (!gap.registerSource) {
341
+ return `corpus source ${gap.source} is unmapped and so lacks a permission for ${missing}: ${gap.because}`
342
+ }
343
+
344
+ return (
345
+ `corpus source ${gap.source} reads register source ${gap.registerSource} ` +
346
+ `(license ${gap.licenseID ?? "none"}, ${gap.state ?? "no decision"}), which does not permit ${missing}: ${gap.because}`
347
+ )
348
+ }
349
+
350
+ /**
351
+ * Raised when a build or training run would admit a source lacking the permission.
352
+ */
353
+ export class SourceRightsError extends Error {
354
+ readonly gaps: readonly SourceRightsGap[]
355
+
356
+ constructor(context: string, gaps: readonly SourceRightsGap[]) {
357
+ super(
358
+ `${context} refuses ${new Set(gaps.map((gap) => gap.source)).size} corpus source(s) whose register rows ` +
359
+ `lack an elected decision permitting ${TRAINING_OPERATIONS.join(" and ")}:\n ` +
360
+ gaps.map(describeSourceRightsGap).join("\n ") +
361
+ "\nRecord the decision in packages/corpus/data/license-decisions.json, map the source in " +
362
+ "packages/corpus/data/corpus-source-inputs.json, or pass --unshippable-reason <why> for a local " +
363
+ "experiment, which marks the output as not shippable."
364
+ )
365
+
366
+ this.name = "SourceRightsError"
367
+ this.gaps = gaps
368
+ }
369
+ }
370
+
371
+ /**
372
+ * What a checked output records about the sources it admitted.
373
+ *
374
+ * Its field names are snake_case because it is written into corpus manifests,
375
+ * which a Python reader also reads.
376
+ */
377
+ export interface SourceRightsRecord {
378
+ /**
379
+ * False when an override admitted a source lacking the permission.
380
+ */
381
+ shippable: boolean
382
+ unshippable_reason: string | null
383
+ /**
384
+ * Every corpus source the check read, sorted.
385
+ */
386
+ sources_checked: string[]
387
+ gaps: SourceRightsGap[]
388
+ /**
389
+ * Inputs whose corpus sources could not be read, so the check could not judge them,
390
+ * such as a base corpus whose files name no source and whose manifest carries no record.
391
+ */
392
+ unverified: string[]
393
+ }
394
+
395
+ /**
396
+ * Checks corpus sources one at a time as an output admits them.
397
+ */
398
+ export interface SourceRightsAdmission {
399
+ /**
400
+ * Admits a source or throws {@linkcode SourceRightsError}.
401
+ *
402
+ * Under an override it logs each gap once and returns them instead.
403
+ */
404
+ admit(source: string): readonly SourceRightsGap[]
405
+ /**
406
+ * Admits several sources at once, so a refusal names every one of them.
407
+ */
408
+ admitAll(sources: Iterable<string>): readonly SourceRightsGap[]
409
+ /**
410
+ * Refuses an input whose sources cannot be read, or records it under an override.
411
+ *
412
+ * An input the check cannot read stays unverified rather than clear.
413
+ */
414
+ admitUnverified(description: string): void
415
+ record(): SourceRightsRecord
416
+ }
417
+
418
+ /**
419
+ * Options for {@linkcode createSourceRightsAdmission}.
420
+ */
421
+ export interface SourceRightsAdmissionOptions {
422
+ /**
423
+ * Names the output in a refusal, as `corpus build v0.7.7` or `recipe sub-venue`.
424
+ */
425
+ context: string
426
+ /**
427
+ * Why a local experiment admits sources lacking the permission.
428
+ *
429
+ * Setting it marks the output unshippable rather than relaxing the rule.
430
+ */
431
+ unshippableReason?: string | null
432
+ log?: (line: string) => void
433
+ }
434
+
435
+ /**
436
+ * Builds the admission check over an already-read register and table.
437
+ *
438
+ * @throws When `unshippableReason` is set and blank, because an override must say why.
439
+ */
440
+ export function createSourceRightsAdmission(
441
+ register: AddressSourceRegister,
442
+ inputs: CorpusSourceInputs,
443
+ options: SourceRightsAdmissionOptions
444
+ ): SourceRightsAdmission {
445
+ const reason = options.unshippableReason ?? null
446
+
447
+ if (reason !== null && !reason.trim()) {
448
+ throw new Error(`${options.context}: --unshippable-reason must say why the output is not shippable`)
449
+ }
450
+
451
+ const log = options.log ?? ((line: string) => console.error(line))
452
+ const checked = new Map<string, readonly SourceRightsGap[]>()
453
+
454
+ const admitAll = (sources: Iterable<string>): readonly SourceRightsGap[] => {
455
+ const fresh: SourceRightsGap[] = []
456
+ const all: SourceRightsGap[] = []
457
+
458
+ for (const source of sources) {
459
+ const known = checked.get(source)
460
+
461
+ if (known) {
462
+ all.push(...known)
463
+
464
+ continue
465
+ }
466
+
467
+ const gaps = sourceRightsGaps(source, inputs, register)
468
+
469
+ fresh.push(...gaps)
470
+ all.push(...gaps)
471
+
472
+ if (!gaps.length || reason !== null) {
473
+ checked.set(source, gaps)
474
+ }
475
+ }
476
+
477
+ if (!fresh.length) return all
478
+
479
+ if (reason === null) throw new SourceRightsError(options.context, fresh)
480
+
481
+ for (const gap of fresh) {
482
+ log(`UNSHIPPABLE ${options.context}: ${describeSourceRightsGap(gap)} (override: ${reason})`)
483
+ }
484
+
485
+ return all
486
+ }
487
+
488
+ const unverified: string[] = []
489
+
490
+ return {
491
+ admit: (source) => admitAll([source]),
492
+ admitAll,
493
+ admitUnverified: (description) => {
494
+ if (reason === null) {
495
+ throw new Error(
496
+ `${options.context} cannot check the corpus sources of ${description}, and an input it cannot read is ` +
497
+ "not one it found clear. Pass --unshippable-reason <why> for a local experiment, which marks the " +
498
+ "output as not shippable."
499
+ )
500
+ }
501
+
502
+ log(`UNSHIPPABLE ${options.context}: the corpus sources of ${description} were not checked (override: ${reason})`)
503
+ unverified.push(description)
504
+ },
505
+ record: () => {
506
+ const gaps = [...checked.values()].flat()
507
+ const shippable = gaps.length === 0 && unverified.length === 0
508
+
509
+ return {
510
+ shippable,
511
+ unshippable_reason: shippable ? null : reason,
512
+ sources_checked: [...checked.keys()].toSorted(),
513
+ gaps,
514
+ unverified: [...unverified],
515
+ }
516
+ },
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Reads the committed register and table and builds the admission check over them.
522
+ */
523
+ export async function openSourceRightsAdmission(options: SourceRightsAdmissionOptions): Promise<SourceRightsAdmission> {
524
+ const register = await readAddressSourceRegister()
525
+ const inputs = await readCorpusSourceInputs(register)
526
+
527
+ return createSourceRightsAdmission(register, inputs, options)
528
+ }
@@ -28,6 +28,7 @@ import { LicenseObligation, summarizeLicense } from "@mailwoman/core/license/obl
28
28
  import type { PathBuilder } from "path-ts"
29
29
 
30
30
  import { OSM_ATTRIBUTION, OSM_LICENSE } from "#adapters/osm/adapter"
31
+ import { currentSourceName } from "#recipes/sources"
31
32
  import type { TrainingManifest } from "#source-register/training-manifest"
32
33
 
33
34
  /**
@@ -178,12 +179,45 @@ const ATTRIBUTION_STATEMENTS: Readonly<Record<string, string>> = {
178
179
  [OSM_LICENSE]: OSM_ATTRIBUTION,
179
180
  }
180
181
 
182
+ /**
183
+ * Recognized licenses carried by contents of a source whose rows state one free-text
184
+ * license, keyed by the source's current spelling.
185
+ *
186
+ * The derivation groups sources by the license their rows state.
187
+ * The `sub-venue` recipe states one license over three publications.
188
+ *
189
+ * That license is free text, and the OpenStreetMap venue names it carries are under ODbL 1.0.
190
+ * Grouping by that text alone leaves the source out of the ODbL group,
191
+ * the group that carries the recorded OpenStreetMap statement.
192
+ *
193
+ * The source keeps its own group as well, because that text also credits the
194
+ * publishers of the address skeletons.
195
+ */
196
+ const CONTENT_LICENSES: Readonly<Record<string, readonly string[]>> = {
197
+ "spliced-sub-venue": [OSM_LICENSE],
198
+ }
199
+
200
+ /**
201
+ * The licenses a source owes attribution under: the one its rows state and those its contents carry.
202
+ */
203
+ function attributedLicenses(record: EffectiveSourceRecord): string[] {
204
+ const current = currentSourceName(record.source) ?? record.source
205
+
206
+ return [record.license, ...(CONTENT_LICENSES[current] ?? [])].filter((license) => {
207
+ const summary = summarizeLicense(license)
208
+
209
+ return !summary.recognized || summary.obligations.includes(LicenseObligation.Attribution)
210
+ })
211
+ }
212
+
181
213
  /**
182
214
  * The attributions owed by the sources that reached the trainer, grouped by license.
183
215
  *
184
216
  * Only `trainingSources` are read, so a source the corpus holds and the run never drew owes no attribution.
185
217
  * A license is included when `summarizeLicense` records an attribution obligation for it, or
186
218
  * when it is unrecognized, since an unrecognized license's obligations are unknown rather than absent.
219
+ *
220
+ * A source joins the group of every license in {@linkcode CONTENT_LICENSES} beside its own.
187
221
  */
188
222
  export function requiredAttributions(sources: readonly EffectiveSourceRecord[]): RequiredAttribution[] {
189
223
  const byLicense = new Map<string, string[]>()
@@ -191,11 +225,9 @@ export function requiredAttributions(sources: readonly EffectiveSourceRecord[]):
191
225
  for (const record of sources) {
192
226
  if (record.emittedRows <= 0) continue
193
227
 
194
- const summary = summarizeLicense(record.license)
195
-
196
- if (summary.recognized && !summary.obligations.includes(LicenseObligation.Attribution)) continue
197
-
198
- byLicense.set(record.license, [...(byLicense.get(record.license) ?? []), record.source])
228
+ for (const license of attributedLicenses(record)) {
229
+ byLicense.set(license, [...(byLicense.get(license) ?? []), record.source])
230
+ }
199
231
  }
200
232
 
201
233
  return [...byLicense]
@@ -124,8 +124,21 @@ export const REGISTER_SECTORS = [
124
124
  "environment-industry",
125
125
  "finance",
126
126
  "food-pharma",
127
+ /**
128
+ * Text a language model generated, which a corpus source reads as rows.
129
+ */
130
+ "generated-text",
131
+ /**
132
+ * A gazetteer, map or address collection that compiles many publishers' data under its own
133
+ * terms, such as OpenStreetMap, Overture Maps, Who's On First, GeoNames or OpenAddresses.
134
+ */
135
+ "geodata-aggregate",
127
136
  "health",
128
137
  "mixed-regulatory",
138
+ /**
139
+ * A postal operator's or postal union's publication, such as a postcode file or an addressing standard.
140
+ */
141
+ "postal",
129
142
  "procurement-grants",
130
143
  "professional-licensing",
131
144
  "property-building",
@@ -184,6 +197,13 @@ export const ResearchPass = {
184
197
  * It located each authority and did not read its terms.
185
198
  */
186
199
  DecoderExposureSurvey: "2026-10-03-decoder-exposure",
200
+ /**
201
+ * The mapping of each corpus source to the publications its rows are built from.
202
+ *
203
+ * It added a row for every publication a corpus source reads that no earlier pass recorded,
204
+ * and it read no terms, so each such row's decision stays `unchecked`.
205
+ */
206
+ CorpusInputMapping: "2026-10-17-corpus-input-mapping",
187
207
  } as const
188
208
 
189
209
  /**
@@ -151,7 +151,7 @@ function makeRecipe(name: string, country: "PK" | "BD", locale: string, descript
151
151
 
152
152
  if (opts.count !== undefined && emitted >= opts.count) break
153
153
 
154
- const components = componentsForOSMRow(tuple as Parameters<typeof componentsForOSMRow>[0])
154
+ const components = componentsForOSMRow(tuple as Parameters<typeof componentsForOSMRow>[0], country)
155
155
  const rendering = components ? renderSouthAsiaRegister(components, random) : null
156
156
 
157
157
  if (!rendering) {