@mailwoman/geographic-model 0.0.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 (51) hide show
  1. package/README.md +155 -0
  2. package/artifact.ts +198 -0
  3. package/compile.ts +350 -0
  4. package/data/geographic-model.json +172 -0
  5. package/data/model/concepts.json +114 -0
  6. package/data/model/mappings.json +18 -0
  7. package/data/model/model.json +3 -0
  8. package/data/model/relations.json +14 -0
  9. package/index.ts +51 -0
  10. package/load.ts +396 -0
  11. package/lookup.ts +129 -0
  12. package/out/artifact.d.ts +106 -0
  13. package/out/artifact.d.ts.map +1 -0
  14. package/out/artifact.js +135 -0
  15. package/out/artifact.js.map +1 -0
  16. package/out/compile.d.ts +84 -0
  17. package/out/compile.d.ts.map +1 -0
  18. package/out/compile.js +259 -0
  19. package/out/compile.js.map +1 -0
  20. package/out/index.d.ts +51 -0
  21. package/out/index.d.ts.map +1 -0
  22. package/out/index.js +51 -0
  23. package/out/index.js.map +1 -0
  24. package/out/load.d.ts +122 -0
  25. package/out/load.d.ts.map +1 -0
  26. package/out/load.js +269 -0
  27. package/out/load.js.map +1 -0
  28. package/out/lookup.d.ts +64 -0
  29. package/out/lookup.d.ts.map +1 -0
  30. package/out/lookup.js +68 -0
  31. package/out/lookup.js.map +1 -0
  32. package/out/schema.d.ts +366 -0
  33. package/out/schema.d.ts.map +1 -0
  34. package/out/schema.js +166 -0
  35. package/out/schema.js.map +1 -0
  36. package/out/scripts/build-artifact.d.ts +51 -0
  37. package/out/scripts/build-artifact.d.ts.map +1 -0
  38. package/out/scripts/build-artifact.js +78 -0
  39. package/out/scripts/build-artifact.js.map +1 -0
  40. package/out/validate.d.ts +67 -0
  41. package/out/validate.d.ts.map +1 -0
  42. package/out/validate.js +465 -0
  43. package/out/validate.js.map +1 -0
  44. package/out/validation-issues.d.ts +84 -0
  45. package/out/validation-issues.d.ts.map +1 -0
  46. package/out/validation-issues.js +190 -0
  47. package/out/validation-issues.js.map +1 -0
  48. package/package.json +120 -0
  49. package/schema.ts +399 -0
  50. package/validate.ts +845 -0
  51. package/validation-issues.ts +305 -0
package/index.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `@mailwoman/geographic-model` — the world-semantic layer: stable concepts beyond the POI
7
+ * vocabulary, relation definitions, activities and affordances, rule modality, source
8
+ * observations, derived facts, derivation provenance, deterministic compilation and validation,
9
+ * and mappings from external vocabularies into world concepts.
10
+ *
11
+ * The public surface is the authored-record schema (`./schema.ts`), its deterministic validator
12
+ * (`./validate.ts`), the compiler that turns a validated document into the runtime artifact
13
+ * (`./compile.ts`), the artifact's shape and canonical bytes (`./artifact.ts`), and the lookups a
14
+ * runtime consumer reads it through (`./lookup.ts`). All five are re-exported here and all five are
15
+ * reachable as curated subpaths.
16
+ *
17
+ * The authoring loader (`./load.ts`, the `./load` subpath) is deliberately NOT re-exported here. It
18
+ * is the only module in the package that touches a filesystem, and it belongs to the build step that
19
+ * produces an artifact rather than to the consumers that read one. The first authored document
20
+ * arrives with #1927; this entry point carries no data.
21
+ *
22
+ * Four things this package must never hold, each owned elsewhere and each a rule the review
23
+ * applies rather than a preference:
24
+ *
25
+ * 1. **Ranking policy.** No weights, boosts, penalties, or candidate-ordering API — not as a
26
+ * function, not as a type. Candidate ordering belongs to `@mailwoman/resolver`, and the decode
27
+ * objective to `@mailwoman/neural` plus `@mailwoman/core/decoder`. Knowledge here creates
28
+ * observations; it never overrides learned interpretation.
29
+ * 2. **A second POI vocabulary.** External and curated POI categories, their containment
30
+ * hierarchy, the Overture-leaf translation, the query-phrase lexicon and the brand table all
31
+ * belong to `@mailwoman/poi-taxonomy`. This package maps INTO those identifiers.
32
+ * 3. **A second coverage register.** Dataset identity and coverage epistemics belong to
33
+ * `@mailwoman/core/layers`. An expected-but-absent observation becomes negative evidence only
34
+ * where `supportsExclusion` permits it there.
35
+ * 4. **Empirical affordance statistics.** #1683 fits those. This package owns the stable
36
+ * activity and affordance identifiers they are fitted against, and nothing numeric about them.
37
+ *
38
+ * `@mailwoman/core` must not depend on this package. Core ships the pipeline contract and roughly
39
+ * 9 MB of reference data to every consumer, so a world-semantics dependency there is one every
40
+ * drop-in API inherits without asking for it. Reversing that direction is an explicit amendment to
41
+ * the boundary record, not a convenience during implementation.
42
+ *
43
+ * Boundary record: `docs/superpowers/specs/2026-08-26-geographic-model-boundaries.md` (#1917).
44
+ * Program parent: #1916.
45
+ */
46
+
47
+ export * from "./artifact.ts"
48
+ export * from "./compile.ts"
49
+ export * from "./lookup.ts"
50
+ export * from "./schema.ts"
51
+ export * from "./validate.ts"
package/load.ts ADDED
@@ -0,0 +1,396 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The authoring loader: a directory of JSON files in, one {@link GeographicModelDocument} out.
7
+ *
8
+ * **The filesystem layout is authoring convenience and carries no meaning.** A concept means the
9
+ * same thing whichever file it was written in, and a file may hold any subset of the tables. What a
10
+ * directory does carry is one manifest — `model.json`, holding the document's `version` — because a
11
+ * version assembled from whichever fragment happened to declare one is a version nobody chose.
12
+ *
13
+ * Two properties make the loader safe to build an artifact from:
14
+ *
15
+ * 1. **Enumeration order cannot reach the output.** {@link mergeGeographicModelFiles} sorts the
16
+ * files it was handed before reading any of them, so the merged tables are a function of the file
17
+ * NAMES and their contents. `readdir` order, and therefore the filesystem, is out of the answer.
18
+ * 2. **Every issue names the file it came from.** The document validator addresses a record by its
19
+ * position in the merged table (`$.concepts[7].kind`), which is the one thing an author cannot
20
+ * see; the loader keeps a per-record origin and re-addresses each issue to its source file. A
21
+ * duplicate identifier names both files — the one that claimed it and the one that claimed it
22
+ * first — because "already used" is unactionable without the other half.
23
+ *
24
+ * Validation itself is delegated whole to `./validate.ts`. What the loader checks on its own is only
25
+ * what the validator cannot see: whether a file parses, whether it is an object, and whether the
26
+ * keys it uses are tables.
27
+ */
28
+
29
+ import { readdirSync, readFileSync } from "node:fs"
30
+ import { resolve } from "node:path"
31
+
32
+ import { compareIdentifiers } from "./artifact.ts"
33
+ import type { GeographicModelDocument } from "./schema.ts"
34
+ import { validateGeographicModelDocument } from "./validate.ts"
35
+ import {
36
+ add,
37
+ checkFieldNames,
38
+ isPlainObject,
39
+ readArray,
40
+ readString,
41
+ type ValidationIssue,
42
+ ValidationIssueCode,
43
+ } from "./validation-issues.ts"
44
+
45
+ /**
46
+ * The manifest every model directory carries: the document's `version`, and nothing else.
47
+ */
48
+ export const MODEL_MANIFEST_FILENAME = "model.json"
49
+
50
+ /**
51
+ * The keys a source file may use. They are the document's tables, minus the manifest's `version`.
52
+ */
53
+ const TABLE_FIELDS = ["relations", "concepts", "mappings", "observations", "derivedFacts"] as const
54
+
55
+ type TableField = (typeof TABLE_FIELDS)[number]
56
+
57
+ const MANIFEST_FIELDS = ["version"] as const
58
+
59
+ /**
60
+ * The document path a record occupies, e.g. `$.concepts[7]` or `$.concepts[7].assertions[1]`. Group 3 is present only
61
+ * for an assertion, which is the one record that nests.
62
+ */
63
+ const RECORD_PATH_PATTERN = /^\$\.([A-Za-z]+)\[(\d+)\](?:\.assertions\[(\d+)\])?/u
64
+
65
+ /**
66
+ * Every way loading can fail. The document validator's whole vocabulary, plus the one failure only a loader meets: a
67
+ * file that is not JSON at all.
68
+ */
69
+ export const LoadIssueCode = {
70
+ ...ValidationIssueCode,
71
+ /**
72
+ * A source file could not be parsed as JSON. Emitted by the loader alone; the document validator is handed values,
73
+ * never text.
74
+ */
75
+ MalformedJSON: "malformed_json",
76
+ } as const
77
+
78
+ export type LoadIssueCode = (typeof LoadIssueCode)[keyof typeof LoadIssueCode]
79
+
80
+ /**
81
+ * One violation, addressed to the file an author can open.
82
+ */
83
+ export interface SourcedIssue {
84
+ /**
85
+ * The source file, relative to the model directory, with `/` separators on every platform.
86
+ */
87
+ file: string
88
+ /**
89
+ * The JSONPath-style address into the MERGED document, kept so a reader can find the record in the table the
90
+ * validator saw.
91
+ */
92
+ path: string
93
+ code: LoadIssueCode
94
+ message: string
95
+ /**
96
+ * For a duplicate identifier: the file that claimed it first.
97
+ */
98
+ otherFile?: string
99
+ }
100
+
101
+ /**
102
+ * One authoring file: its path relative to the model directory, and its text.
103
+ */
104
+ export interface GeographicModelSourceFile {
105
+ path: string
106
+ text: string
107
+ }
108
+
109
+ /**
110
+ * Render every issue as one line, `file:path: message [code]`, in the order the loader produced them.
111
+ */
112
+ export function formatSourcedIssues(issues: readonly SourcedIssue[]): string {
113
+ return issues
114
+ .map((issue) => {
115
+ const claimant = issue.otherFile ? ` — first claimed in ${issue.otherFile}` : ""
116
+
117
+ return `${issue.file}:${issue.path}: ${issue.message}${claimant} [${issue.code}]`
118
+ })
119
+ .join("\n")
120
+ }
121
+
122
+ /**
123
+ * Thrown when a model directory does not load. Carries every issue, and states them all in its message, so a caller
124
+ * that only prints `error.message` still sees the whole list.
125
+ */
126
+ export class GeographicModelLoadError extends Error {
127
+ readonly issues: readonly SourcedIssue[]
128
+
129
+ constructor(issues: readonly SourcedIssue[]) {
130
+ super(`geographic-model source does not load (${issues.length} issues)\n${formatSourcedIssues(issues)}`)
131
+
132
+ this.name = "GeographicModelLoadError"
133
+ this.issues = issues
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Where one record came from, kept so a validation issue addressed to the merged table can be re-addressed to a file.
139
+ */
140
+ interface RecordOrigin {
141
+ file: string
142
+ /**
143
+ * The table the record was appended to, or `assertions` for one nested inside a concept — the namespace its
144
+ * identifier is unique within.
145
+ */
146
+ table: string
147
+ id?: string
148
+ }
149
+
150
+ interface MergeState {
151
+ issues: SourcedIssue[]
152
+ tables: Record<TableField, unknown[]>
153
+ origins: Map<string, RecordOrigin>
154
+ /**
155
+ * Table → identifier → the file that used it first. The validator reports the SECOND claimant, so this is what names
156
+ * the other half of the pair.
157
+ */
158
+ firstClaims: Map<string, Map<string, string>>
159
+ version?: string
160
+ }
161
+
162
+ function sourced(file: string, issues: readonly ValidationIssue[]): SourcedIssue[] {
163
+ return issues.map((issue) => ({ file, path: issue.path, code: issue.code, message: issue.message }))
164
+ }
165
+
166
+ /**
167
+ * Parse one source file, or report why it could not be parsed.
168
+ *
169
+ * The house wrapper lives in `@mailwoman/core/objects`, and this package takes no dependency on `@mailwoman/core` — the
170
+ * boundary record keeps world semantics out of core, and a build-time loader is not the reason to reverse it. The
171
+ * parser's own message is also the useful half of the report here, which a wrapper returning a fallback discards.
172
+ */
173
+ function readSourceJSON(file: GeographicModelSourceFile, issues: SourcedIssue[]): unknown {
174
+ try {
175
+ // oxlint-disable-next-line no-restricted-properties -- see the note above.
176
+ return JSON.parse(file.text)
177
+ } catch (error) {
178
+ issues.push({
179
+ file: file.path,
180
+ path: "$",
181
+ code: LoadIssueCode.MalformedJSON,
182
+ message: error instanceof Error ? error.message : String(error),
183
+ })
184
+
185
+ return undefined
186
+ }
187
+ }
188
+
189
+ function claim(state: MergeState, table: string, id: string | undefined, file: string): void {
190
+ if (id === undefined) return
191
+
192
+ const claims = state.firstClaims.get(table) ?? new Map<string, string>()
193
+
194
+ state.firstClaims.set(table, claims)
195
+
196
+ if (!claims.has(id)) {
197
+ claims.set(id, file)
198
+ }
199
+ }
200
+
201
+ function recordID(entry: unknown): string | undefined {
202
+ if (!isPlainObject(entry)) return undefined
203
+
204
+ return typeof entry.id === "string" ? entry.id : undefined
205
+ }
206
+
207
+ function readManifestFile(state: MergeState, file: GeographicModelSourceFile, value: unknown): void {
208
+ const issues: ValidationIssue[] = []
209
+
210
+ if (!isPlainObject(value)) {
211
+ add(issues, "$", ValidationIssueCode.WrongType, `\`${MODEL_MANIFEST_FILENAME}\` must be an object`)
212
+ } else {
213
+ checkFieldNames(issues, "$", value, MANIFEST_FIELDS)
214
+
215
+ state.version = readString(issues, "$", value, "version", true)
216
+ }
217
+
218
+ state.issues.push(...sourced(file.path, issues))
219
+ }
220
+
221
+ /**
222
+ * Append one file's tables to the merged document, recording where every record came from.
223
+ */
224
+ function readTableFile(state: MergeState, file: GeographicModelSourceFile, value: unknown): void {
225
+ const issues: ValidationIssue[] = []
226
+
227
+ if (!isPlainObject(value)) {
228
+ add(issues, "$", ValidationIssueCode.WrongType, "a geographic-model source file must be an object")
229
+
230
+ state.issues.push(...sourced(file.path, issues))
231
+
232
+ return
233
+ }
234
+
235
+ // `version` is admitted to the field check and then refused on its own, so the report names where a version belongs
236
+ // instead of only saying the field is unknown here.
237
+ checkFieldNames(issues, "$", value, [...TABLE_FIELDS, ...MANIFEST_FIELDS])
238
+
239
+ if ("version" in value) {
240
+ add(
241
+ issues,
242
+ "$.version",
243
+ ValidationIssueCode.UnknownField,
244
+ `the document's \`version\` is authored in \`${MODEL_MANIFEST_FILENAME}\`, not in a table file`
245
+ )
246
+ }
247
+
248
+ for (const table of TABLE_FIELDS) {
249
+ if (!(table in value)) continue
250
+
251
+ for (const entry of readArray(issues, "$", value, table, false) ?? []) {
252
+ const index = state.tables[table].length
253
+ const id = recordID(entry)
254
+ const documentPath = `${table}[${index}]`
255
+
256
+ state.tables[table].push(entry)
257
+ state.origins.set(documentPath, { file: file.path, table, id })
258
+ claim(state, table, id, file.path)
259
+
260
+ if (table !== "concepts" || !isPlainObject(entry) || !Array.isArray(entry.assertions)) continue
261
+
262
+ for (const [position, assertion] of entry.assertions.entries()) {
263
+ const assertionID = recordID(assertion)
264
+
265
+ state.origins.set(`${documentPath}.assertions[${position}]`, {
266
+ file: file.path,
267
+ table: "assertions",
268
+ id: assertionID,
269
+ })
270
+
271
+ claim(state, "assertions", assertionID, file.path)
272
+ }
273
+ }
274
+ }
275
+
276
+ state.issues.push(...sourced(file.path, issues))
277
+ }
278
+
279
+ /**
280
+ * Re-address one validation issue from its position in the merged document to the file the record was authored in.
281
+ */
282
+ function attribute(state: MergeState, issue: ValidationIssue): SourcedIssue {
283
+ const match = RECORD_PATH_PATTERN.exec(issue.path)
284
+ const nested = match?.[3]
285
+ const key = match ? `${match[1]}[${match[2]}]${nested ? `.assertions[${nested}]` : ""}` : undefined
286
+ const origin = key ? state.origins.get(key) : undefined
287
+
288
+ // A document-level issue — `$.version`, or the root itself — is about the manifest, which is the only file that
289
+ // contributes anything outside a table.
290
+ const file = origin?.file ?? MODEL_MANIFEST_FILENAME
291
+
292
+ const claimant =
293
+ issue.code === ValidationIssueCode.DuplicateID && origin?.id
294
+ ? state.firstClaims.get(origin.table)?.get(origin.id)
295
+ : undefined
296
+
297
+ return {
298
+ file,
299
+ path: issue.path,
300
+ code: issue.code,
301
+ message: issue.message,
302
+ ...(claimant ? { otherFile: claimant } : {}),
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Merge authoring files into one document, and validate the result.
308
+ *
309
+ * The files are sorted by path before anything is read, so any enumeration order produces the same tables in the same
310
+ * order. Throws {@link GeographicModelLoadError} with every issue, each addressed to its source file; returns nothing
311
+ * partial.
312
+ */
313
+ export function mergeGeographicModelFiles(files: readonly GeographicModelSourceFile[]): GeographicModelDocument {
314
+ const state: MergeState = {
315
+ issues: [],
316
+ tables: { relations: [], concepts: [], mappings: [], observations: [], derivedFacts: [] },
317
+ origins: new Map(),
318
+ firstClaims: new Map(),
319
+ }
320
+
321
+ const ordered = files.toSorted((left, right) => compareIdentifiers(left.path, right.path))
322
+ const manifest = ordered.find((file) => file.path === MODEL_MANIFEST_FILENAME)
323
+
324
+ if (!manifest) {
325
+ state.issues.push({
326
+ file: MODEL_MANIFEST_FILENAME,
327
+ path: "$",
328
+ code: LoadIssueCode.MissingField,
329
+ message: `a model directory carries \`${MODEL_MANIFEST_FILENAME}\`, holding the document's \`version\``,
330
+ })
331
+ }
332
+
333
+ for (const file of ordered) {
334
+ const value = readSourceJSON(file, state.issues)
335
+
336
+ if (value === undefined) continue
337
+
338
+ if (file === manifest) {
339
+ readManifestFile(state, file, value)
340
+
341
+ continue
342
+ }
343
+
344
+ readTableFile(state, file, value)
345
+ }
346
+
347
+ // A record issue cannot be addressed to a file that failed to parse, so the structural pass reports alone.
348
+ if (state.issues.length) throw new GeographicModelLoadError(state.issues)
349
+
350
+ const result = validateGeographicModelDocument({ version: state.version, ...state.tables })
351
+
352
+ if (!result.ok) {
353
+ throw new GeographicModelLoadError(result.issues.map((issue) => attribute(state, issue)))
354
+ }
355
+
356
+ return result.document
357
+ }
358
+
359
+ /**
360
+ * Every `*.json` file under `root`, relative to it, in code-point order.
361
+ *
362
+ * Directory entries are sorted at each level rather than taken as `readdir` returns them, so the list is a property of
363
+ * the tree and not of the filesystem that stored it. Symbolic links are not followed: a model directory is source, and
364
+ * a link out of it is a record whose home nobody can state.
365
+ */
366
+ function listSourceFiles(root: string, prefix = ""): string[] {
367
+ const entries = readdirSync(resolve(root, prefix), { withFileTypes: true })
368
+ const found: string[] = []
369
+
370
+ for (const entry of entries.toSorted((left, right) => compareIdentifiers(left.name, right.name))) {
371
+ const path = prefix ? `${prefix}/${entry.name}` : entry.name
372
+
373
+ if (entry.isDirectory()) {
374
+ found.push(...listSourceFiles(root, path))
375
+
376
+ continue
377
+ }
378
+
379
+ if (entry.isFile() && entry.name.endsWith(".json")) {
380
+ found.push(path)
381
+ }
382
+ }
383
+
384
+ return found
385
+ }
386
+
387
+ /**
388
+ * Load a model directory: read every `*.json` file under it, merge them, and validate the result.
389
+ *
390
+ * Throws {@link GeographicModelLoadError} with every issue, each addressed to its source file.
391
+ */
392
+ export function loadGeographicModelDirectory(root: string): GeographicModelDocument {
393
+ const files = listSourceFiles(root).map((path) => ({ path, text: readFileSync(resolve(root, path), "utf8") }))
394
+
395
+ return mergeGeographicModelFiles(files)
396
+ }
package/lookup.ts ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The read surface over a compiled artifact: every question answered by one map probe, none by
7
+ * walking the graph.
8
+ *
9
+ * The shape is deliberate. `@mailwoman/geographic-model` compiles authored records into an artifact
10
+ * precisely so a runtime consumer never traverses anything — so the index exposes lookups
11
+ * (`concept`, `ancestorsOf`, `derivedFactsAbout`, `conceptsForExternalID`) and no walk, no cursor,
12
+ * and no query language.
13
+ *
14
+ * Two absences are distinguished everywhere, because they are different answers. A lookup for a
15
+ * concept the artifact does not carry returns `undefined` — the model cannot speak to it. A lookup
16
+ * for a concept it does carry, which nothing was derived about, returns an empty list — the model
17
+ * carries it and states nothing. A reader that collapsed the two would report "no ancestors" for a
18
+ * concept it had never heard of.
19
+ */
20
+
21
+ import type { POICategoryID } from "@mailwoman/poi-taxonomy/types"
22
+
23
+ import { type CompiledGeographicModel, compareIdentifiers } from "./artifact.ts"
24
+ import type {
25
+ ConceptID,
26
+ ConceptRecord,
27
+ DerivedFactRecord,
28
+ ExternalVocabulary,
29
+ RelationID,
30
+ RelationRecord,
31
+ } from "./schema.ts"
32
+
33
+ /**
34
+ * Lookups over one compiled artifact.
35
+ */
36
+ export interface GeographicModelIndex {
37
+ /**
38
+ * The artifact these lookups read, so a consumer holding the index never has to carry both.
39
+ */
40
+ readonly model: CompiledGeographicModel
41
+ /**
42
+ * One concept record, or `undefined` when the artifact does not carry it.
43
+ */
44
+ concept(id: ConceptID): ConceptRecord | undefined
45
+ /**
46
+ * One relation definition, or `undefined` when the artifact does not carry it.
47
+ */
48
+ relation(id: RelationID): RelationRecord | undefined
49
+ /**
50
+ * Every concept this one is a kind of, transitively, in code-point order. Empty for a concept that is a kind of
51
+ * nothing; `undefined` for a concept the artifact does not carry.
52
+ */
53
+ ancestorsOf(id: ConceptID): readonly ConceptID[] | undefined
54
+ /**
55
+ * Every derived fact whose subject is this concept, in artifact order. Empty for a concept nothing was derived about;
56
+ * `undefined` for a concept the artifact does not carry.
57
+ */
58
+ derivedFactsAbout(id: ConceptID): readonly DerivedFactRecord[] | undefined
59
+ /**
60
+ * The concepts a mapping translates this external identifier into, in code-point order.
61
+ *
62
+ * An empty list is a true negative rather than an unread answer: the artifact carries every mapping the document
63
+ * authored, so nothing having declared this identifier is the whole of what there is to know about it.
64
+ */
65
+ conceptsForExternalID(vocabulary: ExternalVocabulary, externalID: POICategoryID): readonly ConceptID[]
66
+ }
67
+
68
+ function externalKey(vocabulary: ExternalVocabulary, externalID: string): string {
69
+ return `${vocabulary}\u0000${externalID}`
70
+ }
71
+
72
+ /**
73
+ * Index a compiled artifact for reading.
74
+ *
75
+ * Every table is walked once here so that no table is ever walked again. Nothing is copied — the records handed back
76
+ * are the artifact's own.
77
+ */
78
+ export function createGeographicModelIndex(model: CompiledGeographicModel): GeographicModelIndex {
79
+ const concepts = new Map<string, ConceptRecord>(model.concepts.map((concept) => [String(concept.id), concept]))
80
+ const relations = new Map<string, RelationRecord>(model.relations.map((relation) => [String(relation.id), relation]))
81
+
82
+ const ancestors = new Map<string, readonly ConceptID[]>(
83
+ model.inheritanceClosure.map((entry) => [String(entry.concept), entry.ancestors])
84
+ )
85
+
86
+ const facts = new Map<string, DerivedFactRecord[]>()
87
+ const external = new Map<string, ConceptID[]>()
88
+
89
+ for (const fact of model.derivedFacts) {
90
+ const subject = String(fact.subject)
91
+ const existing = facts.get(subject)
92
+
93
+ if (existing) {
94
+ existing.push(fact)
95
+
96
+ continue
97
+ }
98
+
99
+ facts.set(subject, [fact])
100
+ }
101
+
102
+ for (const mapping of model.mappings) {
103
+ const key = externalKey(mapping.vocabulary, String(mapping.externalID))
104
+ const existing = external.get(key)
105
+
106
+ if (!existing) {
107
+ external.set(key, [mapping.concept])
108
+
109
+ continue
110
+ }
111
+
112
+ if (!existing.includes(mapping.concept)) {
113
+ existing.push(mapping.concept)
114
+ }
115
+ }
116
+
117
+ return {
118
+ model,
119
+ concept: (id) => concepts.get(String(id)),
120
+ relation: (id) => relations.get(String(id)),
121
+ ancestorsOf: (id) => ancestors.get(String(id)),
122
+ derivedFactsAbout: (id) => (concepts.has(String(id)) ? (facts.get(String(id)) ?? []) : undefined),
123
+ conceptsForExternalID: (vocabulary, externalID) => {
124
+ const matches = external.get(externalKey(vocabulary, String(externalID)))
125
+
126
+ return matches ? matches.toSorted(compareIdentifiers) : []
127
+ },
128
+ }
129
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The compiled runtime artifact: its shape, its canonical bytes, and the reader that turns those
7
+ * bytes back into a typed value.
8
+ *
9
+ * A runtime consumer reads THIS and never the authored records. That is the whole point of the
10
+ * artifact, and it is why the shape carries every table plus the materialized `isA` closure: a
11
+ * consumer that had to walk `concepts[].isA` to answer a question about one concept would be doing
12
+ * query-time traversal, which the boundary record excludes.
13
+ *
14
+ * Two determinism rules define the bytes, and both are stated so a reader can check them:
15
+ *
16
+ * 1. **Every object's keys are emitted in code-point order**, at every depth. A rule that
17
+ * canonicalizes by itself beats a hand-kept field order, which drifts the first time the schema
18
+ * gains a field.
19
+ * 2. **Every table is ordered by identifier**, under {@link compareIdentifiers} — code point, never
20
+ * `localeCompare`, whose answer depends on the machine's collation. Arrays INSIDE a record keep
21
+ * the order they were authored in; the compiler writes the tables, so it orders those.
22
+ *
23
+ * Nothing here records when compilation ran. `modelVersion` is the authored document's own version,
24
+ * so two builds of one document are byte-identical, and a clock in the artifact would make every
25
+ * regenerate a diff.
26
+ *
27
+ * Boundary record: `docs/superpowers/specs/2026-08-26-geographic-model-boundaries.md` (#1917).
28
+ */
29
+ import type { ConceptID, ConceptRecord, DerivedFactRecord, ExternalMappingRecord, RelationRecord, SourceObservationRecord } from "./schema.ts";
30
+ /**
31
+ * The artifact FORMAT version — incremented by hand when the compiled shape changes, so a reader meeting an artifact it
32
+ * was not written for says so instead of quietly reading fields that moved.
33
+ *
34
+ * It is not data about the world, which is why a number is allowed here and nowhere in `./schema.ts`.
35
+ */
36
+ export declare const ARTIFACT_SCHEMA_VERSION = 1;
37
+ /**
38
+ * One concept's transitive `isA` ancestors, materialized so a consumer answers "what is this a kind of" with a lookup.
39
+ *
40
+ * Every concept gets an entry, including one with no ancestors at all: an entry with an empty list says the concept was
41
+ * compiled and is a kind of nothing, and a missing entry would say the same thing while also being what an absent
42
+ * concept looks like.
43
+ */
44
+ export interface InheritanceClosureEntry {
45
+ concept: ConceptID;
46
+ ancestors: readonly ConceptID[];
47
+ }
48
+ /**
49
+ * The whole compiled artifact.
50
+ *
51
+ * The five authored tables travel unchanged — the compiler orders them and rewrites no record — beside the two tables
52
+ * it computes: {@link InheritanceClosureEntry} per concept, and the derived facts the closure materializes.
53
+ */
54
+ export interface CompiledGeographicModel {
55
+ /**
56
+ * The format version of this artifact — {@link ARTIFACT_SCHEMA_VERSION} at the time it was written.
57
+ */
58
+ schemaVersion: number;
59
+ /**
60
+ * The authored document's own `version`. Never a build date.
61
+ */
62
+ modelVersion: string;
63
+ relations: readonly RelationRecord[];
64
+ concepts: readonly ConceptRecord[];
65
+ mappings: readonly ExternalMappingRecord[];
66
+ observations: readonly SourceObservationRecord[];
67
+ inheritanceClosure: readonly InheritanceClosureEntry[];
68
+ /**
69
+ * The authored derived facts, plus every fact the compiler's derivations produced. Each names the procedure that
70
+ * wrote it and every record that procedure read.
71
+ */
72
+ derivedFacts: readonly DerivedFactRecord[];
73
+ }
74
+ /**
75
+ * The artifact's stated order: UTF-16 code point, ascending.
76
+ *
77
+ * `String.prototype.localeCompare` is the trap this exists to avoid — its answer depends on the machine's collation, so
78
+ * an artifact ordered with it is reproducible only on the machine that built it.
79
+ */
80
+ export declare function compareIdentifiers(left: string, right: string): number;
81
+ /**
82
+ * The artifact's canonical bytes: tab-indented, one trailing newline, keys in code-point order.
83
+ *
84
+ * Tab indentation and the trailing newline match the repository's other committed JSON tables (`taxonomy.json`,
85
+ * `brands.json`). A committed copy of these bytes is the generator's output run through `oxfmt`, which inlines short
86
+ * arrays — so a freshness check compares the PARSED artifact against a fresh compile, and a byte comparison compares
87
+ * two compiles.
88
+ */
89
+ export declare function serializeCompiledModel(model: CompiledGeographicModel): string;
90
+ /**
91
+ * Thrown when a value cannot be read as a compiled artifact.
92
+ */
93
+ export declare class GeographicModelArtifactError extends Error {
94
+ constructor(message: string);
95
+ }
96
+ /**
97
+ * Read a parsed artifact — the value `JSON.parse` produced from the compiled bytes — as a
98
+ * {@link CompiledGeographicModel}.
99
+ *
100
+ * It checks the format version and the presence of every table, and does NOT re-validate the records. An artifact is
101
+ * generated from a document that `parseGeographicModelDocument` already accepted; re-checking every record here would
102
+ * be a second validator, and the version check is what catches the failure this reader can actually meet — an artifact
103
+ * written by a different compiler.
104
+ */
105
+ export declare function parseCompiledGeographicModel(input: unknown): CompiledGeographicModel;
106
+ //# sourceMappingURL=artifact.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"artifact.d.ts","sourceRoot":"","sources":["../artifact.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,OAAO,KAAK,EACX,SAAS,EACT,aAAa,EACb,iBAAiB,EACjB,qBAAqB,EACrB,cAAc,EACd,uBAAuB,EACvB,MAAM,aAAa,CAAA;AAGpB;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC;;;;;;GAMG;AACH,MAAM,WAAW,uBAAuB;IACvC,OAAO,EAAE,SAAS,CAAA;IAClB,SAAS,EAAE,SAAS,SAAS,EAAE,CAAA;CAC/B;AAED;;;;;GAKG;AACH,MAAM,WAAW,uBAAuB;IACvC;;OAEG;IACH,aAAa,EAAE,MAAM,CAAA;IACrB;;OAEG;IACH,YAAY,EAAE,MAAM,CAAA;IACpB,SAAS,EAAE,SAAS,cAAc,EAAE,CAAA;IACpC,QAAQ,EAAE,SAAS,aAAa,EAAE,CAAA;IAClC,QAAQ,EAAE,SAAS,qBAAqB,EAAE,CAAA;IAC1C,YAAY,EAAE,SAAS,uBAAuB,EAAE,CAAA;IAChD,kBAAkB,EAAE,SAAS,uBAAuB,EAAE,CAAA;IACtD;;;OAGG;IACH,YAAY,EAAE,SAAS,iBAAiB,EAAE,CAAA;CAC1C;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAMtE;AAwBD;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,uBAAuB,GAAG,MAAM,CAE7E;AAED;;GAEG;AACH,qBAAa,4BAA6B,SAAQ,KAAK;gBAC1C,OAAO,EAAE,MAAM;CAK3B;AAuCD;;;;;;;;GAQG;AACH,wBAAgB,4BAA4B,CAAC,KAAK,EAAE,OAAO,GAAG,uBAAuB,CAMpF"}