@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/validate.ts ADDED
@@ -0,0 +1,845 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Deterministic validation of a {@link GeographicModelDocument}. Plain TypeScript: no reasoner, no
7
+ * query engine, no schema library, and no I/O — the same input always produces the same issue list.
8
+ *
9
+ * The validator reports EVERY violation it finds. A record set is authored by hand and read by a
10
+ * compiler, so a validator that stops at the first problem hands its author one defect per run. It
11
+ * also never returns a partial document: either the input satisfies every rule and is returned
12
+ * whole, or nothing is returned and every issue is named with the path it was found at. There is no
13
+ * third answer in which some records were dropped quietly, because a dropped record is
14
+ * indistinguishable from a world that does not contain it.
15
+ *
16
+ * Two passes, in this order, and both always run:
17
+ *
18
+ * 1. **Shape.** Field presence, field types, closed-vocabulary membership, and unknown keys. An
19
+ * unknown key whose name announces ranking policy — a boost, a penalty, a weight, a rank, a
20
+ * score — is reported under its own code rather than as an anonymous stray field, because that
21
+ * is the one authoring mistake this package exists to refuse.
22
+ * 2. **Whole-table references.** Duplicate identifiers, `isA` self-reference and cycles, relation
23
+ * and concept resolution, relation domain and range kinds, inverse reciprocity, and derivation
24
+ * inputs. These are answerable only once every table has been read, which is why they are
25
+ * reported after the shape issues rather than interleaved with them.
26
+ *
27
+ * Consumed by #1926's compiler, which validates before it emits, and by #1927's authored document.
28
+ */
29
+
30
+ import {
31
+ ConceptKind,
32
+ ConceptStatus,
33
+ DerivationInputKind,
34
+ ExternalVocabulary,
35
+ type GeographicModelDocument,
36
+ Modality,
37
+ RelationSemantics,
38
+ } from "./schema.ts"
39
+ import {
40
+ add,
41
+ checkFieldNames,
42
+ isPlainObject,
43
+ listVocabulary,
44
+ readArray,
45
+ readBoolean,
46
+ readString,
47
+ readStringArray,
48
+ readVocabularyArray,
49
+ readVocabularyValue,
50
+ type ValidationIssue,
51
+ ValidationIssueCode,
52
+ } from "./validation-issues.ts"
53
+
54
+ export { type ValidationIssue, ValidationIssueCode } from "./validation-issues.ts"
55
+
56
+ /**
57
+ * The whole document, or every reason it is not one.
58
+ */
59
+ export type ValidationResult =
60
+ | { ok: true; document: GeographicModelDocument }
61
+ | { ok: false; issues: ValidationIssue[] }
62
+
63
+ const DOCUMENT_FIELDS = ["version", "relations", "concepts", "mappings", "observations", "derivedFacts"] as const
64
+ const PROVENANCE_FIELDS = ["source", "sourceVersion", "sourceRecord", "sourceURL", "authoredAt", "notes"] as const
65
+ const OPTIONAL_PROVENANCE_FIELDS = ["sourceVersion", "sourceRecord", "sourceURL", "authoredAt", "notes"] as const
66
+
67
+ const RELATION_FIELDS = [
68
+ "id",
69
+ "label",
70
+ "description",
71
+ "domainKinds",
72
+ "rangeKinds",
73
+ "transitive",
74
+ "symmetric",
75
+ "inverse",
76
+ "semantics",
77
+ ] as const
78
+
79
+ const ASSERTION_FIELDS = ["id", "relation", "target", "modality", "countries", "provenance"] as const
80
+ const CONCEPT_FIELDS = ["id", "label", "description", "kind", "isA", "assertions", "provenance", "status"] as const
81
+ const MAPPING_FIELDS = ["id", "concept", "vocabulary", "externalID", "provenance"] as const
82
+ const OBSERVATION_FIELDS = ["id", "subject", "relation", "object", "modality", "countries", "provenance"] as const
83
+
84
+ const DERIVED_FACT_FIELDS = [
85
+ "id",
86
+ "derivation",
87
+ "inputs",
88
+ "subject",
89
+ "relation",
90
+ "object",
91
+ "modality",
92
+ "countries",
93
+ ] as const
94
+
95
+ const DERIVATION_INPUT_FIELDS = ["kind", "id"] as const
96
+
97
+ /**
98
+ * ISO 3166-1 alpha-2, upper case. A lower-case or three-letter value is an authoring mistake that would otherwise scope
99
+ * a claim to a country nothing else in the system names.
100
+ */
101
+ const COUNTRY_PATTERN = /^[A-Z]{2}$/
102
+
103
+ interface AssertionView {
104
+ path: string
105
+ id?: string
106
+ relation?: string
107
+ target?: string
108
+ }
109
+
110
+ interface ConceptView {
111
+ path: string
112
+ id?: string
113
+ kind?: ConceptKind
114
+ isA?: string[]
115
+ assertions: AssertionView[]
116
+ }
117
+
118
+ interface RelationView {
119
+ path: string
120
+ id?: string
121
+ domainKinds?: ConceptKind[]
122
+ rangeKinds?: ConceptKind[]
123
+ transitive?: boolean
124
+ symmetric?: boolean
125
+ inverse?: string
126
+ }
127
+
128
+ interface MappingView {
129
+ path: string
130
+ id?: string
131
+ concept?: string
132
+ }
133
+
134
+ interface TripleView {
135
+ path: string
136
+ id?: string
137
+ subject?: string
138
+ relation?: string
139
+ object?: string
140
+ }
141
+
142
+ interface DerivationInputView {
143
+ path: string
144
+ kind?: DerivationInputKind
145
+ id?: string
146
+ }
147
+
148
+ interface DerivedFactView extends TripleView {
149
+ inputs: DerivationInputView[]
150
+ }
151
+
152
+ interface DocumentView {
153
+ relations: RelationView[]
154
+ concepts: ConceptView[]
155
+ mappings: MappingView[]
156
+ observations: TripleView[]
157
+ derivedFacts: DerivedFactView[]
158
+ }
159
+
160
+ interface ReferenceTables {
161
+ concepts: Map<string, ConceptView>
162
+ relations: Map<string, RelationView>
163
+ assertions: ReadonlyMap<string, unknown>
164
+ mappings: ReadonlyMap<string, unknown>
165
+ observations: ReadonlyMap<string, unknown>
166
+ derivedFacts: ReadonlyMap<string, unknown>
167
+ }
168
+
169
+ function readCountries(issues: ValidationIssue[], path: string, container: Record<string, unknown>): void {
170
+ const values = readStringArray(issues, path, container, "countries", false)
171
+
172
+ if (!values) return
173
+
174
+ for (const [index, value] of values.entries()) {
175
+ if (!COUNTRY_PATTERN.test(value)) {
176
+ add(
177
+ issues,
178
+ `${path}.countries[${index}]`,
179
+ ValidationIssueCode.MalformedCountry,
180
+ `\`${value}\` is not an upper-case ISO 3166-1 alpha-2 code`
181
+ )
182
+ }
183
+ }
184
+ }
185
+
186
+ function readProvenance(issues: ValidationIssue[], path: string, container: Record<string, unknown>): void {
187
+ const value = container.provenance
188
+ const fieldPath = `${path}.provenance`
189
+
190
+ if (value === undefined) {
191
+ add(issues, fieldPath, ValidationIssueCode.MissingField, "`provenance` is required")
192
+
193
+ return
194
+ }
195
+
196
+ if (!isPlainObject(value)) {
197
+ add(issues, fieldPath, ValidationIssueCode.WrongType, "`provenance` must be an object")
198
+
199
+ return
200
+ }
201
+
202
+ checkFieldNames(issues, fieldPath, value, PROVENANCE_FIELDS)
203
+ readString(issues, fieldPath, value, "source", true)
204
+
205
+ for (const key of OPTIONAL_PROVENANCE_FIELDS) {
206
+ readString(issues, fieldPath, value, key, false)
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Read the `label` and `description` an authored record carries.
212
+ */
213
+ function readNarration(issues: ValidationIssue[], path: string, value: Record<string, unknown>): void {
214
+ readString(issues, path, value, "label", true)
215
+ readString(issues, path, value, "description", true)
216
+ }
217
+
218
+ function readRelation(issues: ValidationIssue[], path: string, value: Record<string, unknown>): RelationView {
219
+ const conceptKinds = Object.values(ConceptKind)
220
+
221
+ checkFieldNames(issues, path, value, RELATION_FIELDS)
222
+
223
+ const id = readString(issues, path, value, "id", true)
224
+
225
+ readNarration(issues, path, value)
226
+
227
+ const kindCode = ValidationIssueCode.UnknownConceptKind
228
+ const domainKinds = readVocabularyArray(issues, path, value, "domainKinds", conceptKinds, kindCode)
229
+ const rangeKinds = readVocabularyArray(issues, path, value, "rangeKinds", conceptKinds, kindCode)
230
+ const transitive = readBoolean(issues, path, value, "transitive")
231
+ const symmetric = readBoolean(issues, path, value, "symmetric")
232
+ const inverse = readString(issues, path, value, "inverse", false)
233
+
234
+ readVocabularyValue(
235
+ issues,
236
+ path,
237
+ value,
238
+ "semantics",
239
+ Object.values(RelationSemantics),
240
+ ValidationIssueCode.UnknownRelationSemantics
241
+ )
242
+
243
+ return { path, id, domainKinds, rangeKinds, transitive, symmetric, inverse }
244
+ }
245
+
246
+ function readAssertion(issues: ValidationIssue[], path: string, value: Record<string, unknown>): AssertionView {
247
+ checkFieldNames(issues, path, value, ASSERTION_FIELDS)
248
+
249
+ const id = readString(issues, path, value, "id", true)
250
+ const relation = readString(issues, path, value, "relation", true)
251
+ const target = readString(issues, path, value, "target", true)
252
+
253
+ readVocabularyValue(issues, path, value, "modality", Object.values(Modality), ValidationIssueCode.UnknownModality)
254
+ readCountries(issues, path, value)
255
+ readProvenance(issues, path, value)
256
+
257
+ return { path, id, relation, target }
258
+ }
259
+
260
+ function readConcept(issues: ValidationIssue[], path: string, value: Record<string, unknown>): ConceptView {
261
+ checkFieldNames(issues, path, value, CONCEPT_FIELDS)
262
+
263
+ const id = readString(issues, path, value, "id", true)
264
+
265
+ readNarration(issues, path, value)
266
+
267
+ const kind = readVocabularyValue(
268
+ issues,
269
+ path,
270
+ value,
271
+ "kind",
272
+ Object.values(ConceptKind),
273
+ ValidationIssueCode.UnknownConceptKind
274
+ )
275
+
276
+ const isA = readStringArray(issues, path, value, "isA", true)
277
+ const assertions: AssertionView[] = []
278
+
279
+ for (const [index, entry] of (readArray(issues, path, value, "assertions", true) ?? []).entries()) {
280
+ const entryPath = `${path}.assertions[${index}]`
281
+
282
+ if (!isPlainObject(entry)) {
283
+ add(issues, entryPath, ValidationIssueCode.WrongType, "an assertion must be an object")
284
+
285
+ continue
286
+ }
287
+
288
+ assertions.push(readAssertion(issues, entryPath, entry))
289
+ }
290
+
291
+ readProvenance(issues, path, value)
292
+
293
+ readVocabularyValue(
294
+ issues,
295
+ path,
296
+ value,
297
+ "status",
298
+ Object.values(ConceptStatus),
299
+ ValidationIssueCode.UnknownConceptStatus
300
+ )
301
+
302
+ return { path, id, kind, isA, assertions }
303
+ }
304
+
305
+ function readMapping(issues: ValidationIssue[], path: string, value: Record<string, unknown>): MappingView {
306
+ checkFieldNames(issues, path, value, MAPPING_FIELDS)
307
+
308
+ const id = readString(issues, path, value, "id", true)
309
+ const concept = readString(issues, path, value, "concept", true)
310
+
311
+ readVocabularyValue(
312
+ issues,
313
+ path,
314
+ value,
315
+ "vocabulary",
316
+ Object.values(ExternalVocabulary),
317
+ ValidationIssueCode.UnknownExternalVocabulary
318
+ )
319
+
320
+ readString(issues, path, value, "externalID", true)
321
+ readProvenance(issues, path, value)
322
+
323
+ return { path, id, concept }
324
+ }
325
+
326
+ function readObservation(issues: ValidationIssue[], path: string, value: Record<string, unknown>): TripleView {
327
+ checkFieldNames(issues, path, value, OBSERVATION_FIELDS)
328
+
329
+ const id = readString(issues, path, value, "id", true)
330
+ const subject = readString(issues, path, value, "subject", true)
331
+ const relation = readString(issues, path, value, "relation", true)
332
+ const object = readString(issues, path, value, "object", true)
333
+
334
+ readVocabularyValue(issues, path, value, "modality", Object.values(Modality), ValidationIssueCode.UnknownModality)
335
+ readCountries(issues, path, value)
336
+ readProvenance(issues, path, value)
337
+
338
+ return { path, id, subject, relation, object }
339
+ }
340
+
341
+ function readDerivationInput(
342
+ issues: ValidationIssue[],
343
+ path: string,
344
+ value: Record<string, unknown>
345
+ ): DerivationInputView {
346
+ checkFieldNames(issues, path, value, DERIVATION_INPUT_FIELDS)
347
+
348
+ const kind = readVocabularyValue(
349
+ issues,
350
+ path,
351
+ value,
352
+ "kind",
353
+ Object.values(DerivationInputKind),
354
+ ValidationIssueCode.UnknownDerivationInputKind
355
+ )
356
+
357
+ return { path, kind, id: readString(issues, path, value, "id", true) }
358
+ }
359
+
360
+ function readDerivedFact(issues: ValidationIssue[], path: string, value: Record<string, unknown>): DerivedFactView {
361
+ checkFieldNames(issues, path, value, DERIVED_FACT_FIELDS)
362
+
363
+ const id = readString(issues, path, value, "id", true)
364
+
365
+ readString(issues, path, value, "derivation", true)
366
+
367
+ const entries = readArray(issues, path, value, "inputs", true)
368
+ const inputs: DerivationInputView[] = []
369
+
370
+ if (entries && !entries.length) {
371
+ add(
372
+ issues,
373
+ `${path}.inputs`,
374
+ ValidationIssueCode.EmptyList,
375
+ "a derived fact names the records its derivation read; an empty list is a fact with no provenance"
376
+ )
377
+ }
378
+
379
+ for (const [index, entry] of (entries ?? []).entries()) {
380
+ const entryPath = `${path}.inputs[${index}]`
381
+
382
+ if (!isPlainObject(entry)) {
383
+ add(issues, entryPath, ValidationIssueCode.WrongType, "a derivation input must be an object")
384
+
385
+ continue
386
+ }
387
+
388
+ inputs.push(readDerivationInput(issues, entryPath, entry))
389
+ }
390
+
391
+ const subject = readString(issues, path, value, "subject", true)
392
+ const relation = readString(issues, path, value, "relation", true)
393
+ const object = readString(issues, path, value, "object", true)
394
+
395
+ readVocabularyValue(issues, path, value, "modality", Object.values(Modality), ValidationIssueCode.UnknownModality)
396
+ readCountries(issues, path, value)
397
+
398
+ return { path, id, inputs, subject, relation, object }
399
+ }
400
+
401
+ function readTable<T>(
402
+ issues: ValidationIssue[],
403
+ document: Record<string, unknown>,
404
+ key: string,
405
+ label: string,
406
+ read: (issues: ValidationIssue[], path: string, value: Record<string, unknown>) => T
407
+ ): T[] {
408
+ const entries = readArray(issues, "$", document, key, true)
409
+ const records: T[] = []
410
+
411
+ for (const [index, entry] of (entries ?? []).entries()) {
412
+ const entryPath = `$.${key}[${index}]`
413
+
414
+ if (!isPlainObject(entry)) {
415
+ add(issues, entryPath, ValidationIssueCode.WrongType, `a ${label} must be an object`)
416
+
417
+ continue
418
+ }
419
+
420
+ records.push(read(issues, entryPath, entry))
421
+ }
422
+
423
+ return records
424
+ }
425
+
426
+ /**
427
+ * Index a table by identifier, reporting every record after the first that claims an identifier already taken. The
428
+ * first claimant keeps the identifier, so a duplicate never silently displaces the record other rows resolve against.
429
+ */
430
+ function indexByID<T extends { path: string; id?: string }>(
431
+ issues: ValidationIssue[],
432
+ records: readonly T[],
433
+ label: string
434
+ ): Map<string, T> {
435
+ const index = new Map<string, T>()
436
+
437
+ for (const record of records) {
438
+ if (record.id === undefined) continue
439
+
440
+ if (index.has(record.id)) {
441
+ add(
442
+ issues,
443
+ `${record.path}.id`,
444
+ ValidationIssueCode.DuplicateID,
445
+ `\`${record.id}\` is already used by another ${label}`
446
+ )
447
+
448
+ continue
449
+ }
450
+
451
+ index.set(record.id, record)
452
+ }
453
+
454
+ return index
455
+ }
456
+
457
+ interface EdgeCheck {
458
+ subjectKind?: ConceptKind
459
+ subjectPath: string
460
+ relationID?: string
461
+ relationPath: string
462
+ objectID?: string
463
+ objectPath: string
464
+ }
465
+
466
+ /**
467
+ * Resolve one subject–relation–object edge and check it against the relation's declared domain and range kinds.
468
+ *
469
+ * Shared by authored assertions, source observations, and derived facts. The three differ in who stands behind them and
470
+ * in what provenance they carry, and the structural question asked of them is the same one.
471
+ */
472
+ function checkEdge(issues: ValidationIssue[], edge: EdgeCheck, tables: ReferenceTables): void {
473
+ const relation = edge.relationID === undefined ? undefined : tables.relations.get(edge.relationID)
474
+
475
+ if (edge.relationID !== undefined && !relation) {
476
+ add(
477
+ issues,
478
+ edge.relationPath,
479
+ ValidationIssueCode.UnknownRelation,
480
+ `\`${edge.relationID}\` is not a relation declared in this document`
481
+ )
482
+ }
483
+
484
+ const object = edge.objectID === undefined ? undefined : tables.concepts.get(edge.objectID)
485
+
486
+ if (edge.objectID !== undefined && !object) {
487
+ add(
488
+ issues,
489
+ edge.objectPath,
490
+ ValidationIssueCode.UnknownConcept,
491
+ `\`${edge.objectID}\` is not a concept declared in this document`
492
+ )
493
+ }
494
+
495
+ if (!relation) return
496
+
497
+ if (edge.subjectKind && relation.domainKinds && !relation.domainKinds.includes(edge.subjectKind)) {
498
+ add(
499
+ issues,
500
+ edge.subjectPath,
501
+ ValidationIssueCode.DomainKindMismatch,
502
+ `relation \`${relation.id}\` accepts ${listVocabulary(relation.domainKinds)} on the asserting side, not \`${edge.subjectKind}\``
503
+ )
504
+ }
505
+
506
+ if (object?.kind && relation.rangeKinds && !relation.rangeKinds.includes(object.kind)) {
507
+ add(
508
+ issues,
509
+ edge.objectPath,
510
+ ValidationIssueCode.RangeKindMismatch,
511
+ `relation \`${relation.id}\` accepts ${listVocabulary(relation.rangeKinds)} on the target side, not \`${object.kind}\``
512
+ )
513
+ }
514
+ }
515
+
516
+ function sameKinds(left: readonly ConceptKind[], right: readonly ConceptKind[]): boolean {
517
+ return left.length === right.length && left.every((kind) => right.includes(kind))
518
+ }
519
+
520
+ /**
521
+ * Check one relation's inverse and its transitivity against the kinds it declares.
522
+ */
523
+ function checkRelation(
524
+ issues: ValidationIssue[],
525
+ relation: RelationView,
526
+ index: ReadonlyMap<string, RelationView>
527
+ ): void {
528
+ const inversePath = `${relation.path}.inverse`
529
+
530
+ if (relation.transitive === true && relation.domainKinds && relation.rangeKinds) {
531
+ const chainable = relation.rangeKinds.some((kind) => relation.domainKinds?.includes(kind))
532
+
533
+ if (!chainable) {
534
+ add(
535
+ issues,
536
+ `${relation.path}.transitive`,
537
+ ValidationIssueCode.TransitiveKindsDisjoint,
538
+ "a transitive relation has to be able to chain, so its range kinds and its domain kinds must overlap"
539
+ )
540
+ }
541
+ }
542
+
543
+ if (relation.inverse === undefined) return
544
+
545
+ const inverse = index.get(relation.inverse)
546
+
547
+ if (!inverse) {
548
+ add(
549
+ issues,
550
+ inversePath,
551
+ ValidationIssueCode.UnknownRelation,
552
+ `\`${relation.inverse}\` is not a relation declared in this document`
553
+ )
554
+
555
+ return
556
+ }
557
+
558
+ if (relation.symmetric === true && inverse.id !== relation.id) {
559
+ add(
560
+ issues,
561
+ inversePath,
562
+ ValidationIssueCode.InverseNotReciprocal,
563
+ `\`${relation.id}\` is symmetric, so it is its own inverse; it names \`${relation.inverse}\``
564
+ )
565
+
566
+ return
567
+ }
568
+
569
+ if (inverse.inverse !== relation.id) {
570
+ add(
571
+ issues,
572
+ inversePath,
573
+ ValidationIssueCode.InverseNotReciprocal,
574
+ `\`${relation.inverse}\` does not name \`${relation.id}\` as its own inverse`
575
+ )
576
+ }
577
+
578
+ if (
579
+ inverse.id !== relation.id &&
580
+ relation.domainKinds &&
581
+ relation.rangeKinds &&
582
+ inverse.domainKinds &&
583
+ inverse.rangeKinds &&
584
+ !(sameKinds(relation.domainKinds, inverse.rangeKinds) && sameKinds(relation.rangeKinds, inverse.domainKinds))
585
+ ) {
586
+ add(
587
+ issues,
588
+ inversePath,
589
+ ValidationIssueCode.InverseKindsMismatch,
590
+ `an inverse reads the same edge backwards, so \`${relation.inverse}\` has to declare this relation's range kinds as its domain kinds, and the reverse`
591
+ )
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Follow `isA` upward from one concept and report the trail if it returns to where it started.
597
+ *
598
+ * The direct self-edge is left out of the walk: `checkIsA` already reports that as a self-reference, at the entry that
599
+ * carries it, and a second report saying the same concept cycles through itself tells its author nothing new.
600
+ */
601
+ function findIsACycle(start: ConceptView, concepts: ReadonlyMap<string, ConceptView>): string[] | undefined {
602
+ if (start.id === undefined) return undefined
603
+
604
+ const startID = start.id
605
+ const visited = new Set<string>()
606
+
607
+ const frontier: Array<{ id: string; trail: string[] }> = (start.isA ?? [])
608
+ .filter((parent) => parent !== startID)
609
+ .map((parent) => ({ id: parent, trail: [startID, parent] }))
610
+
611
+ while (frontier.length) {
612
+ const step = frontier.pop()
613
+
614
+ if (!step) break
615
+
616
+ if (step.id === startID) return step.trail
617
+
618
+ if (visited.has(step.id)) continue
619
+
620
+ visited.add(step.id)
621
+
622
+ for (const parent of concepts.get(step.id)?.isA ?? []) {
623
+ frontier.push({ id: parent, trail: [...step.trail, parent] })
624
+ }
625
+ }
626
+
627
+ return undefined
628
+ }
629
+
630
+ function checkIsA(issues: ValidationIssue[], concept: ConceptView, concepts: ReadonlyMap<string, ConceptView>): void {
631
+ if (!concept.isA) return
632
+
633
+ for (const [index, parent] of concept.isA.entries()) {
634
+ const parentPath = `${concept.path}.isA[${index}]`
635
+
636
+ if (parent === concept.id) {
637
+ add(issues, parentPath, ValidationIssueCode.SelfReference, "a concept is not a kind of itself")
638
+
639
+ continue
640
+ }
641
+
642
+ if (!concepts.has(parent)) {
643
+ add(
644
+ issues,
645
+ parentPath,
646
+ ValidationIssueCode.UnknownConcept,
647
+ `\`${parent}\` is not a concept declared in this document`
648
+ )
649
+ }
650
+ }
651
+
652
+ const cycle = findIsACycle(concept, concepts)
653
+
654
+ if (cycle) {
655
+ add(issues, `${concept.path}.isA`, ValidationIssueCode.CyclicIsA, `\`isA\` cycles through ${cycle.join(" → ")}`)
656
+ }
657
+ }
658
+
659
+ function checkDerivationInputs(issues: ValidationIssue[], fact: DerivedFactView, tables: ReferenceTables): void {
660
+ const byKind: Record<DerivationInputKind, ReadonlyMap<string, unknown>> = {
661
+ [DerivationInputKind.Concept]: tables.concepts,
662
+ [DerivationInputKind.Relation]: tables.relations,
663
+ [DerivationInputKind.Assertion]: tables.assertions,
664
+ [DerivationInputKind.Mapping]: tables.mappings,
665
+ [DerivationInputKind.Observation]: tables.observations,
666
+ [DerivationInputKind.DerivedFact]: tables.derivedFacts,
667
+ }
668
+
669
+ for (const input of fact.inputs) {
670
+ if (input.kind === undefined || input.id === undefined) continue
671
+
672
+ if (input.kind === DerivationInputKind.DerivedFact && input.id === fact.id) {
673
+ add(issues, `${input.path}.id`, ValidationIssueCode.SelfReference, "a derived fact is not one of its own inputs")
674
+
675
+ continue
676
+ }
677
+
678
+ if (!byKind[input.kind].has(input.id)) {
679
+ add(
680
+ issues,
681
+ `${input.path}.id`,
682
+ ValidationIssueCode.UnknownDerivationInput,
683
+ `no \`${input.kind}\` record in this document is identified by \`${input.id}\``
684
+ )
685
+ }
686
+ }
687
+ }
688
+
689
+ function checkReferences(issues: ValidationIssue[], view: DocumentView): void {
690
+ const assertions: AssertionView[] = []
691
+
692
+ for (const concept of view.concepts) {
693
+ assertions.push(...concept.assertions)
694
+ }
695
+
696
+ const relations = indexByID(issues, view.relations, "relation")
697
+ const concepts = indexByID(issues, view.concepts, "concept")
698
+
699
+ const tables: ReferenceTables = {
700
+ relations,
701
+ concepts,
702
+ assertions: indexByID(issues, assertions, "assertion"),
703
+ mappings: indexByID(issues, view.mappings, "mapping"),
704
+ observations: indexByID(issues, view.observations, "observation"),
705
+ derivedFacts: indexByID(issues, view.derivedFacts, "derived fact"),
706
+ }
707
+
708
+ for (const relation of view.relations) {
709
+ checkRelation(issues, relation, relations)
710
+ }
711
+
712
+ for (const concept of view.concepts) {
713
+ checkIsA(issues, concept, concepts)
714
+
715
+ for (const assertion of concept.assertions) {
716
+ checkEdge(
717
+ issues,
718
+ {
719
+ subjectKind: concept.kind,
720
+ subjectPath: `${assertion.path}.relation`,
721
+ relationID: assertion.relation,
722
+ relationPath: `${assertion.path}.relation`,
723
+ objectID: assertion.target,
724
+ objectPath: `${assertion.path}.target`,
725
+ },
726
+ tables
727
+ )
728
+ }
729
+ }
730
+
731
+ for (const mapping of view.mappings) {
732
+ if (mapping.concept !== undefined && !concepts.has(mapping.concept)) {
733
+ add(
734
+ issues,
735
+ `${mapping.path}.concept`,
736
+ ValidationIssueCode.UnknownConcept,
737
+ `\`${mapping.concept}\` is not a concept declared in this document`
738
+ )
739
+ }
740
+ }
741
+
742
+ for (const triple of [...view.observations, ...view.derivedFacts]) {
743
+ const subject = triple.subject === undefined ? undefined : concepts.get(triple.subject)
744
+
745
+ if (triple.subject !== undefined && !subject) {
746
+ add(
747
+ issues,
748
+ `${triple.path}.subject`,
749
+ ValidationIssueCode.UnknownConcept,
750
+ `\`${triple.subject}\` is not a concept declared in this document`
751
+ )
752
+ }
753
+
754
+ checkEdge(
755
+ issues,
756
+ {
757
+ subjectKind: subject?.kind,
758
+ subjectPath: `${triple.path}.subject`,
759
+ relationID: triple.relation,
760
+ relationPath: `${triple.path}.relation`,
761
+ objectID: triple.object,
762
+ objectPath: `${triple.path}.object`,
763
+ },
764
+ tables
765
+ )
766
+ }
767
+
768
+ for (const fact of view.derivedFacts) {
769
+ checkDerivationInputs(issues, fact, tables)
770
+ }
771
+ }
772
+
773
+ function collectIssues(input: unknown): ValidationIssue[] {
774
+ const issues: ValidationIssue[] = []
775
+
776
+ if (!isPlainObject(input)) {
777
+ add(issues, "$", ValidationIssueCode.WrongType, "a geographic-model document must be an object")
778
+
779
+ return issues
780
+ }
781
+
782
+ checkFieldNames(issues, "$", input, DOCUMENT_FIELDS)
783
+ readString(issues, "$", input, "version", true)
784
+
785
+ checkReferences(issues, {
786
+ relations: readTable(issues, input, "relations", "relation", readRelation),
787
+ concepts: readTable(issues, input, "concepts", "concept", readConcept),
788
+ mappings: readTable(issues, input, "mappings", "mapping", readMapping),
789
+ observations: readTable(issues, input, "observations", "observation", readObservation),
790
+ derivedFacts: readTable(issues, input, "derivedFacts", "derived fact", readDerivedFact),
791
+ })
792
+
793
+ return issues
794
+ }
795
+
796
+ /**
797
+ * Validate an authored geographic-model document.
798
+ *
799
+ * Returns the document whole, or every reason it is not one. Issues arrive in traversal order — shape issues per record
800
+ * in table order, then whole-table reference issues — so two runs over the same input produce the same list.
801
+ */
802
+ export function validateGeographicModelDocument(input: unknown): ValidationResult {
803
+ const issues = collectIssues(input)
804
+
805
+ if (issues.length) return { ok: false, issues }
806
+
807
+ // A clean input IS the document — the validator reads, it never rewrites. Keeping the assertion in this function,
808
+ // where `input` is still `unknown`, is what makes it a single step rather than a cast through `unknown`.
809
+ return { ok: true, document: input as GeographicModelDocument }
810
+ }
811
+
812
+ /**
813
+ * Render every issue as one line, `path: message [code]`, in the order the validator produced them.
814
+ */
815
+ export function formatValidationIssues(issues: readonly ValidationIssue[]): string {
816
+ return issues.map((issue) => `${issue.path}: ${issue.message} [${issue.code}]`).join("\n")
817
+ }
818
+
819
+ /**
820
+ * Thrown by {@link parseGeographicModelDocument}. Carries the whole issue list, and states the whole issue list in its
821
+ * message, so a caller that only ever prints `error.message` still sees every violation.
822
+ */
823
+ export class GeographicModelValidationError extends Error {
824
+ readonly issues: readonly ValidationIssue[]
825
+
826
+ constructor(issues: readonly ValidationIssue[]) {
827
+ super(`geographic-model document is invalid (${issues.length} issues)\n${formatValidationIssues(issues)}`)
828
+
829
+ this.name = "GeographicModelValidationError"
830
+ this.issues = issues
831
+ }
832
+ }
833
+
834
+ /**
835
+ * Validate and return an authored document, throwing {@link GeographicModelValidationError} with every violation if it
836
+ * does not validate. The throwing form is for callers with no partial-result behavior to offer — a compiler, a build
837
+ * step, a test.
838
+ */
839
+ export function parseGeographicModelDocument(input: unknown): GeographicModelDocument {
840
+ const result = validateGeographicModelDocument(input)
841
+
842
+ if (!result.ok) throw new GeographicModelValidationError(result.issues)
843
+
844
+ return result.document
845
+ }