@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.
- package/README.md +155 -0
- package/artifact.ts +198 -0
- package/compile.ts +350 -0
- package/data/geographic-model.json +172 -0
- package/data/model/concepts.json +114 -0
- package/data/model/mappings.json +18 -0
- package/data/model/model.json +3 -0
- package/data/model/relations.json +14 -0
- package/index.ts +51 -0
- package/load.ts +396 -0
- package/lookup.ts +129 -0
- package/out/artifact.d.ts +106 -0
- package/out/artifact.d.ts.map +1 -0
- package/out/artifact.js +135 -0
- package/out/artifact.js.map +1 -0
- package/out/compile.d.ts +84 -0
- package/out/compile.d.ts.map +1 -0
- package/out/compile.js +259 -0
- package/out/compile.js.map +1 -0
- package/out/index.d.ts +51 -0
- package/out/index.d.ts.map +1 -0
- package/out/index.js +51 -0
- package/out/index.js.map +1 -0
- package/out/load.d.ts +122 -0
- package/out/load.d.ts.map +1 -0
- package/out/load.js +269 -0
- package/out/load.js.map +1 -0
- package/out/lookup.d.ts +64 -0
- package/out/lookup.d.ts.map +1 -0
- package/out/lookup.js +68 -0
- package/out/lookup.js.map +1 -0
- package/out/schema.d.ts +366 -0
- package/out/schema.d.ts.map +1 -0
- package/out/schema.js +166 -0
- package/out/schema.js.map +1 -0
- package/out/scripts/build-artifact.d.ts +51 -0
- package/out/scripts/build-artifact.d.ts.map +1 -0
- package/out/scripts/build-artifact.js +78 -0
- package/out/scripts/build-artifact.js.map +1 -0
- package/out/validate.d.ts +67 -0
- package/out/validate.d.ts.map +1 -0
- package/out/validate.js +465 -0
- package/out/validate.js.map +1 -0
- package/out/validation-issues.d.ts +84 -0
- package/out/validation-issues.d.ts.map +1 -0
- package/out/validation-issues.js +190 -0
- package/out/validation-issues.js.map +1 -0
- package/package.json +120 -0
- package/schema.ts +399 -0
- package/validate.ts +845 -0
- 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"}
|