@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/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# @mailwoman/geographic-model
|
|
2
|
+
|
|
3
|
+
The **world-semantic layer**: stable geographic concepts, the relations between them, mappings from external vocabularies into those concepts, source observations, derived facts, and the provenance of every one of them — authored as records, compiled deterministically into runtime artifacts.
|
|
4
|
+
|
|
5
|
+
> **Status: one authored proposition.** The record types and their deterministic validator are here (#1925), along with the loader and compiler that turn authored files into a runtime artifact (#1926) and the first authored document — `pharmacy affords obtain_medication` (#1927). Nothing consumes the artifact at runtime: no resolver integration, no ordering change, no POI behavior change. Whether the proposition is worth anything to a user is what #1928's pre-registered probe measures, and #1930 records the decision.
|
|
6
|
+
|
|
7
|
+
The ownership boundary is fixed by the record at [`docs/superpowers/specs/2026-08-26-geographic-model-boundaries.md`](../../docs/superpowers/specs/2026-08-26-geographic-model-boundaries.md) (#1917), under program parent #1916. That document is authoritative for everything below; this README is the package-local summary.
|
|
8
|
+
|
|
9
|
+
## What this package owns
|
|
10
|
+
|
|
11
|
+
- Stable concepts beyond the POI vocabulary, and the identifiers other packages refer to them by.
|
|
12
|
+
- Relation definitions, activities, affordances, and rule modality — the first of which is one proposition: `pharmacy affords obtain_medication`.
|
|
13
|
+
- Mappings from external vocabularies (`@mailwoman/poi-taxonomy` category identifiers, and later others) into world concepts.
|
|
14
|
+
- Source observations, kept separate from derived facts.
|
|
15
|
+
- Derivation provenance on every mapping and every derived fact.
|
|
16
|
+
- Deterministic compilation of the authored records into runtime artifacts, and the validation that refuses a record set which does not compile.
|
|
17
|
+
|
|
18
|
+
## The schema
|
|
19
|
+
|
|
20
|
+
One document holds six tables, and all six are required — a hand-authored file writes `"derivedFacts": []` rather than leaving the table out, because an absent table and an empty table are different claims.
|
|
21
|
+
|
|
22
|
+
| Table | Record | What it is |
|
|
23
|
+
| -------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
|
24
|
+
| `relations` | `RelationRecord` | Vocabulary: what a relation means, which concept kinds may stand on each side, whether it is transitive or symmetric, and its inverse |
|
|
25
|
+
| `concepts` | `ConceptRecord` + `RelationAssertion[]` | Authored semantics: a concept, its broader concepts, and the claims a curator states about it |
|
|
26
|
+
| `mappings` | `ExternalMappingRecord` | Translation into an external vocabulary — today `@mailwoman/poi-taxonomy` category identifiers |
|
|
27
|
+
| `observations` | `SourceObservationRecord` | What a named external source states, recorded in this vocabulary and kept out of the concept table |
|
|
28
|
+
| `derivedFacts` | `DerivedFactRecord` | What a named procedure computed, with every record it read listed as an input. Written by the compiler, never by hand |
|
|
29
|
+
|
|
30
|
+
Three properties hold by construction:
|
|
31
|
+
|
|
32
|
+
- **No numeric field exists anywhere.** Not a strength, not a confidence, not a count. `Modality` is an ordinal vocabulary of words (`necessary`, `prohibited`, `strongly_expected`, … `strongly_unusual`) and the package exports no order over it, because a number on an authored relationship is a ranking weight whatever it is called.
|
|
33
|
+
- **Authored, observed, and derived are three types, not three uses of one type.** Their identifiers carry separate brands, so one is not assignable where another is expected, and they live in separate tables so a curation decision has to be made deliberately rather than by a record sitting in a convenient place.
|
|
34
|
+
- **A derived fact carries its provenance structurally.** It has no `source` field: its `derivation` plus its `inputs` are the provenance, and every input carries source provenance in turn. A source string can be copied onto a record that did not come from it; an input list either resolves or the document does not validate.
|
|
35
|
+
|
|
36
|
+
Identifiers are branded through `type-fest`'s `Tagged` and converted explicitly — `toConceptID`, `toRelationID`, `toRuleID`, `toMappingID`, `toObservationID`, `toDerivedFactID` — the same idiom as `toPOICategoryID` in `@mailwoman/poi-taxonomy`. The brands are compile-time only; the strings survive JSON untouched.
|
|
37
|
+
|
|
38
|
+
## The validator
|
|
39
|
+
|
|
40
|
+
`validateGeographicModelDocument(input)` returns the whole document or every reason it is not one. `parseGeographicModelDocument(input)` is the throwing form, and its `GeographicModelValidationError` states every violation in `error.message` as well as on `error.issues`, so a caller that only prints the message still sees all of them.
|
|
41
|
+
|
|
42
|
+
It reports **every** violation, each addressed by a JSONPath-style location such as `$.concepts[0].assertions[1].modality`. It never returns a partial document: a validator that quietly drops the records it could not read is a validator whose output is indistinguishable from a world that does not contain them.
|
|
43
|
+
|
|
44
|
+
Two passes, both of which always run. **Shape** covers field presence and types, closed-vocabulary membership, and unknown keys — with a field whose name announces ranking policy (`score`, `boost`, `penalty`, `rankWeight`, `relevanceWeight`, `affinityWeight`, and anything else matching the same fragments) reported under its own code rather than as an anonymous stray field. **Whole-table references** covers duplicate identifiers, `isA` self-reference and cycles, relation and concept resolution, relation domain and range kinds, inverse reciprocity, and derivation inputs.
|
|
45
|
+
|
|
46
|
+
It is plain deterministic TypeScript with no I/O and no dependencies beyond the two type imports: no reasoner, no query engine, no schema library.
|
|
47
|
+
|
|
48
|
+
## The loader
|
|
49
|
+
|
|
50
|
+
`loadGeographicModelDirectory(root)` (the `./load` subpath, the one module here that touches a filesystem) reads every `*.json` file under a directory and merges them into one document.
|
|
51
|
+
|
|
52
|
+
**The layout is authoring convenience and carries no meaning.** A concept means the same thing whichever file it was written in, and a file may hold any subset of the tables. One file is special: `model.json`, holding the document's `version` — a version assembled from whichever fragment happened to declare one is a version nobody chose.
|
|
53
|
+
|
|
54
|
+
Two properties make it safe to build an artifact from:
|
|
55
|
+
|
|
56
|
+
- **Enumeration order cannot reach the output.** The files are sorted by path before any of them is read, so the merged tables are a function of the file names and their contents, never of `readdir` order. `mergeGeographicModelFiles(files)` states the same property without a filesystem, which is how it is tested: any order in, one document out.
|
|
57
|
+
- **Every issue names the file it came from.** The validator addresses a record by its position in the merged table (`$.concepts[7].kind`) — the one address an author cannot see — so the loader keeps a per-record origin and re-addresses each issue. A duplicate identifier names **both** files, the one that claimed it and the one that claimed it first, because "already used" is unactionable without the other half.
|
|
58
|
+
|
|
59
|
+
What the loader checks on its own is only what the validator cannot see: whether a file parses, whether it is an object, and whether the keys it uses are tables. Everything else is delegated whole.
|
|
60
|
+
|
|
61
|
+
## The compiler
|
|
62
|
+
|
|
63
|
+
`compileGeographicModel(input)` validates by delegation — `parseGeographicModelDocument` decides whether a document is well formed, and throws with every violation before a byte is computed — then compiles it into a `CompiledGeographicModel`. There is no second validator, and no partial artifact: a compile produces the whole thing or produces nothing.
|
|
64
|
+
|
|
65
|
+
**`isA` alone defines semantic inheritance**, and two derivations follow from it:
|
|
66
|
+
|
|
67
|
+
| In the artifact | What it is |
|
|
68
|
+
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
69
|
+
| `inheritanceClosure` | Every concept's transitive `isA` ancestors, deduplicated and ordered. One entry per concept, empty list included — a concept that is a kind of nothing says so |
|
|
70
|
+
| `derivedFacts` | Every ancestor's assertions materialized onto its descendants as `DerivedFactRecord`s, naming the derivation and every record it read |
|
|
71
|
+
|
|
72
|
+
Materializing the assertions is what makes the artifact answer a question rather than point at one: the closure alone would tell a consumer which concepts to go and read, which is the traversal it was supposed to be spared. A descendant that states the same relation and target itself inherits nothing for that pair — the authored record is the more specific one, which is what `isA` means.
|
|
73
|
+
|
|
74
|
+
A relation declaring `transitive` or `inverse` is **not** closed over. Those fields say what the relation means; materializing them is a reasoning step no executable need has asked for, and general reasoning is excluded from this package. The day one is needed it arrives as its own named derivation beside this one.
|
|
75
|
+
|
|
76
|
+
Compilation refuses two things the validator cannot see, both about records the compiler is about to write, and both reported with every offending record named: an inherited assertion whose subject kind the relation does not accept, and two derived facts claiming one identifier.
|
|
77
|
+
|
|
78
|
+
## The artifact
|
|
79
|
+
|
|
80
|
+
`serializeCompiledModel(model)` produces the canonical bytes. Two rules define them:
|
|
81
|
+
|
|
82
|
+
- **Every object's keys are emitted in code-point order**, at every depth. A rule that canonicalizes by itself beats a hand-kept field order, which drifts the first time the schema gains a field.
|
|
83
|
+
- **Every table is ordered by identifier**, under `compareIdentifiers` — code point, never `localeCompare`, whose answer depends on the machine's collation. Arrays inside a record keep the order they were authored in.
|
|
84
|
+
|
|
85
|
+
Nothing records when compilation ran: `modelVersion` is the authored document's own version, so two builds of one document are byte-identical and a regenerate is a diff only when the records changed. `schemaVersion` is the artifact FORMAT version, and `parseCompiledGeographicModel` refuses an artifact declaring another one rather than reading fields that may have moved.
|
|
86
|
+
|
|
87
|
+
A committed artifact is these bytes run through `oxfmt`, which inlines short arrays — the same convention `taxonomy.json` follows. So a freshness check compares the **parsed** artifact against a fresh compile, and a byte comparison compares two compiles.
|
|
88
|
+
|
|
89
|
+
`createGeographicModelIndex(model)` (the `./lookup` subpath) is the read surface: `concept`, `relation`, `ancestorsOf`, `derivedFactsAbout`, `conceptsForExternalID`. Lookups only — no walk, no cursor, no query language, because removing query-time traversal is the reason the artifact exists. Two absences stay distinguishable throughout: a concept the artifact does not carry answers `undefined`, and a concept it carries with nothing derived about it answers an empty list.
|
|
90
|
+
|
|
91
|
+
## The authored slice
|
|
92
|
+
|
|
93
|
+
One proposition, frozen by §4 of the boundary record and authored under [`data/model/`](./data/model/):
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
place
|
|
97
|
+
establishment isA place
|
|
98
|
+
healthcare_facility isA establishment
|
|
99
|
+
pharmacy isA healthcare_facility affords obtain_medication (necessary)
|
|
100
|
+
activity
|
|
101
|
+
obtain_medication isA activity
|
|
102
|
+
|
|
103
|
+
affords establishment → activity, hard, not transitive, not symmetric
|
|
104
|
+
poi-taxonomy pharmacy → the pharmacy concept
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Every concept, assertion and mapping carries `provenance` naming the boundary record and #1927 — the two authorities this slice has. `RelationRecord` carries none, because a relation is vocabulary rather than a claim: it says what `affords` means, and stands behind nothing in particular.
|
|
108
|
+
|
|
109
|
+
Deliberately absent, and each absence is a statement rather than an omission: no source observations, no hand-authored derived facts, no `countries` scope, and no second establishment class. `isA` inheritance materializes nothing here, because the one assertion sits on `pharmacy` and `pharmacy` has no descendants.
|
|
110
|
+
|
|
111
|
+
[`data/geographic-model.json`](./data/geographic-model.json) is the committed compilation of those records. **Do not hand-edit it** — regenerate:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
node packages/geographic-model/scripts/build-artifact.ts && npx oxfmt packages/geographic-model/data/geographic-model.json
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
[`data/PROVENANCE.md`](./data/PROVENANCE.md) records what each file states, where the external category id was read from, and why the freshness check compares parsed values. `test/unit/pharmacy-slice.test.ts` asserts all of it against the committed artifact, and names that command when the artifact goes stale.
|
|
118
|
+
|
|
119
|
+
## What this package must never own
|
|
120
|
+
|
|
121
|
+
Each of these is owned elsewhere, and naming the owner is what keeps a second copy from growing here.
|
|
122
|
+
|
|
123
|
+
| Not here | Owner |
|
|
124
|
+
| ------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- |
|
|
125
|
+
| Relevance weights, boosts, penalties, any candidate-ordering API — even a type | `@mailwoman/resolver` (ordering), `@mailwoman/neural` + `@mailwoman/core/decoder` (the decode objective) |
|
|
126
|
+
| POI categories, their containment hierarchy, the Overture-leaf translation, the query-phrase lexicon, brands | `@mailwoman/poi-taxonomy` |
|
|
127
|
+
| Dataset identity and coverage epistemics | `@mailwoman/core/layers` |
|
|
128
|
+
| Empirical, spatial activity-affordance statistics | #1683 — it fits numbers against the identifiers owned here |
|
|
129
|
+
|
|
130
|
+
Two rules follow from that table and are enforced rather than assumed:
|
|
131
|
+
|
|
132
|
+
- **`@mailwoman/core` must not depend on `@mailwoman/geographic-model`.** Core ships the pipeline contract and roughly 9 MB of reference data to every consumer, so a world-semantics dependency there is one every drop-in API inherits without asking for it. [`test/unit/boundaries.test.ts`](./test/unit/boundaries.test.ts) reads core's manifest and fails on the day that changes.
|
|
133
|
+
- **The public surface carries no ranking policy.** The same test reads whatever the entry point exports and refuses a binding whose name announces a boost, a penalty, a weight, a rank, a score, or an ordering.
|
|
134
|
+
|
|
135
|
+
The operating rule for the whole boundary is one sentence, verbatim from the record: **knowledge creates observations; it never overrides learned interpretation.** A record here may create a fact, an anomaly, a contradiction, or a coverage-qualified absence. It may not create an imperative.
|
|
136
|
+
|
|
137
|
+
Architecturally excluded for the life of the program, not merely deferred: an OWL/DL reasoner, a SPARQL endpoint, a triplestore, a general-purpose knowledge-graph service, and any query-time traversal of the authoring JSON. Authored records are source material compiled into artifacts — artifacts, not a service.
|
|
138
|
+
|
|
139
|
+
## Why this is not in the release list
|
|
140
|
+
|
|
141
|
+
`@mailwoman/geographic-model` is a public, publishable-shaped workspace, and it is deliberately absent from `.release-it.json`'s workspace list. The reason is mechanical rather than editorial: **npm Trusted Publishing cannot create a package that does not exist yet.** A brand-new `@mailwoman/*` name returns `E404` from OIDC, so it needs a one-time manual first publish plus a Trusted Publisher configuration — `scripts/bless-package.ts`, an interactive second-factor step an operator runs — before CI can ever publish it. Adding the name to the release list ahead of that blessing does not publish the package; it fails the next coordinated release at this workspace. `RELEASING.md`'s "Adding a NEW package: it can't be first-published from CI" is the full account.
|
|
142
|
+
|
|
143
|
+
The absence is therefore recorded, not silent: `SANCTIONED_RELEASE_ABSENCES` in [`scripts/release-stage.ts`](../../scripts/release-stage.ts) carries it with that reason, and `checkReleaseListIdentity` fails on any absence missing from that record. The version stays at `0.0.0` — nothing bumps a workspace outside the release list, and `0.0.0` reads as never published, which is the true statement.
|
|
144
|
+
|
|
145
|
+
When the package is blessed and its Trusted Publisher is on file, the change is three edits in one commit: add `packages/geographic-model` to `.release-it.json`, remove its entry from `SANCTIONED_RELEASE_ABSENCES`, and update the arithmetic in `AGENTS.md` and `scripts/release-stage.test.ts`.
|
|
146
|
+
|
|
147
|
+
## Layout
|
|
148
|
+
|
|
149
|
+
Source lives at the workspace root, as it does in every workspace except `packages/corpus/` and `docs/`. Tests live under `test/unit/` and reach the package through its package name, never a relative path — the contract `scripts/verify-test-contract.ts` enforces.
|
|
150
|
+
|
|
151
|
+
The manifest's `files` array already declares `data/**/*.json`. Authored records land there, and a glob written now cannot be the one a later publish forgets: `**/*.ts` does not cover JSON, and a data file absent from `files` is a package that installs without the data it exists to carry.
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
AGPL-3.0-only OR LicenseRef-Commercial. See the repository root for details.
|
package/artifact.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
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
|
+
|
|
30
|
+
import type {
|
|
31
|
+
ConceptID,
|
|
32
|
+
ConceptRecord,
|
|
33
|
+
DerivedFactRecord,
|
|
34
|
+
ExternalMappingRecord,
|
|
35
|
+
RelationRecord,
|
|
36
|
+
SourceObservationRecord,
|
|
37
|
+
} from "./schema.ts"
|
|
38
|
+
import { isPlainObject } from "./validation-issues.ts"
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The artifact FORMAT version — incremented by hand when the compiled shape changes, so a reader meeting an artifact it
|
|
42
|
+
* was not written for says so instead of quietly reading fields that moved.
|
|
43
|
+
*
|
|
44
|
+
* It is not data about the world, which is why a number is allowed here and nowhere in `./schema.ts`.
|
|
45
|
+
*/
|
|
46
|
+
export const ARTIFACT_SCHEMA_VERSION = 1
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One concept's transitive `isA` ancestors, materialized so a consumer answers "what is this a kind of" with a lookup.
|
|
50
|
+
*
|
|
51
|
+
* Every concept gets an entry, including one with no ancestors at all: an entry with an empty list says the concept was
|
|
52
|
+
* compiled and is a kind of nothing, and a missing entry would say the same thing while also being what an absent
|
|
53
|
+
* concept looks like.
|
|
54
|
+
*/
|
|
55
|
+
export interface InheritanceClosureEntry {
|
|
56
|
+
concept: ConceptID
|
|
57
|
+
ancestors: readonly ConceptID[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The whole compiled artifact.
|
|
62
|
+
*
|
|
63
|
+
* The five authored tables travel unchanged — the compiler orders them and rewrites no record — beside the two tables
|
|
64
|
+
* it computes: {@link InheritanceClosureEntry} per concept, and the derived facts the closure materializes.
|
|
65
|
+
*/
|
|
66
|
+
export interface CompiledGeographicModel {
|
|
67
|
+
/**
|
|
68
|
+
* The format version of this artifact — {@link ARTIFACT_SCHEMA_VERSION} at the time it was written.
|
|
69
|
+
*/
|
|
70
|
+
schemaVersion: number
|
|
71
|
+
/**
|
|
72
|
+
* The authored document's own `version`. Never a build date.
|
|
73
|
+
*/
|
|
74
|
+
modelVersion: string
|
|
75
|
+
relations: readonly RelationRecord[]
|
|
76
|
+
concepts: readonly ConceptRecord[]
|
|
77
|
+
mappings: readonly ExternalMappingRecord[]
|
|
78
|
+
observations: readonly SourceObservationRecord[]
|
|
79
|
+
inheritanceClosure: readonly InheritanceClosureEntry[]
|
|
80
|
+
/**
|
|
81
|
+
* The authored derived facts, plus every fact the compiler's derivations produced. Each names the procedure that
|
|
82
|
+
* wrote it and every record that procedure read.
|
|
83
|
+
*/
|
|
84
|
+
derivedFacts: readonly DerivedFactRecord[]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The artifact's stated order: UTF-16 code point, ascending.
|
|
89
|
+
*
|
|
90
|
+
* `String.prototype.localeCompare` is the trap this exists to avoid — its answer depends on the machine's collation, so
|
|
91
|
+
* an artifact ordered with it is reproducible only on the machine that built it.
|
|
92
|
+
*/
|
|
93
|
+
export function compareIdentifiers(left: string, right: string): number {
|
|
94
|
+
if (left < right) return -1
|
|
95
|
+
|
|
96
|
+
if (left > right) return 1
|
|
97
|
+
|
|
98
|
+
return 0
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Rebuild `value` with every object's keys in code-point order, at every depth. Arrays keep their order — ordering
|
|
103
|
+
* those is the compiler's job, and doing it here would silently reorder authored data.
|
|
104
|
+
*/
|
|
105
|
+
function canonicalize(value: unknown): unknown {
|
|
106
|
+
if (Array.isArray(value)) return value.map(canonicalize)
|
|
107
|
+
|
|
108
|
+
if (!isPlainObject(value)) return value
|
|
109
|
+
|
|
110
|
+
const canonical: Record<string, unknown> = {}
|
|
111
|
+
|
|
112
|
+
for (const key of Object.keys(value).toSorted(compareIdentifiers)) {
|
|
113
|
+
const entry = value[key]
|
|
114
|
+
|
|
115
|
+
if (entry === undefined) continue
|
|
116
|
+
|
|
117
|
+
canonical[key] = canonicalize(entry)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return canonical
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The artifact's canonical bytes: tab-indented, one trailing newline, keys in code-point order.
|
|
125
|
+
*
|
|
126
|
+
* Tab indentation and the trailing newline match the repository's other committed JSON tables (`taxonomy.json`,
|
|
127
|
+
* `brands.json`). A committed copy of these bytes is the generator's output run through `oxfmt`, which inlines short
|
|
128
|
+
* arrays — so a freshness check compares the PARSED artifact against a fresh compile, and a byte comparison compares
|
|
129
|
+
* two compiles.
|
|
130
|
+
*/
|
|
131
|
+
export function serializeCompiledModel(model: CompiledGeographicModel): string {
|
|
132
|
+
return `${JSON.stringify(canonicalize(model), null, "\t")}\n`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Thrown when a value cannot be read as a compiled artifact.
|
|
137
|
+
*/
|
|
138
|
+
export class GeographicModelArtifactError extends Error {
|
|
139
|
+
constructor(message: string) {
|
|
140
|
+
super(message)
|
|
141
|
+
|
|
142
|
+
this.name = "GeographicModelArtifactError"
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The tables every artifact carries. A reader that finds one missing refuses rather than answering every question about
|
|
148
|
+
* that table with an empty list.
|
|
149
|
+
*/
|
|
150
|
+
const ARTIFACT_TABLES = [
|
|
151
|
+
"concepts",
|
|
152
|
+
"derivedFacts",
|
|
153
|
+
"inheritanceClosure",
|
|
154
|
+
"mappings",
|
|
155
|
+
"observations",
|
|
156
|
+
"relations",
|
|
157
|
+
] as const
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Why `value` is not a compiled artifact, or nothing when it is one.
|
|
161
|
+
*
|
|
162
|
+
* Kept separate from {@link parseCompiledGeographicModel} so the reader casts the value it was HANDED, still typed
|
|
163
|
+
* `unknown`, rather than a narrowed shape it would have to launder through a second cast to widen again.
|
|
164
|
+
*/
|
|
165
|
+
function artifactProblem(value: unknown): string | undefined {
|
|
166
|
+
if (!isPlainObject(value)) return "a compiled geographic model must be an object"
|
|
167
|
+
|
|
168
|
+
if (value.schemaVersion !== ARTIFACT_SCHEMA_VERSION) {
|
|
169
|
+
return `this reader understands artifact schema version ${ARTIFACT_SCHEMA_VERSION}; the artifact declares ${JSON.stringify(value.schemaVersion)}`
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (typeof value.modelVersion !== "string" || !value.modelVersion.trim().length) {
|
|
173
|
+
return "`modelVersion` must be a non-blank string"
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const missing = ARTIFACT_TABLES.filter((table) => !Array.isArray(value[table]))
|
|
177
|
+
|
|
178
|
+
if (!missing.length) return undefined
|
|
179
|
+
|
|
180
|
+
return `the artifact is missing ${missing.map((table) => `\`${table}\``).join(", ")}`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Read a parsed artifact — the value `JSON.parse` produced from the compiled bytes — as a
|
|
185
|
+
* {@link CompiledGeographicModel}.
|
|
186
|
+
*
|
|
187
|
+
* It checks the format version and the presence of every table, and does NOT re-validate the records. An artifact is
|
|
188
|
+
* generated from a document that `parseGeographicModelDocument` already accepted; re-checking every record here would
|
|
189
|
+
* be a second validator, and the version check is what catches the failure this reader can actually meet — an artifact
|
|
190
|
+
* written by a different compiler.
|
|
191
|
+
*/
|
|
192
|
+
export function parseCompiledGeographicModel(input: unknown): CompiledGeographicModel {
|
|
193
|
+
const problem = artifactProblem(input)
|
|
194
|
+
|
|
195
|
+
if (problem) throw new GeographicModelArtifactError(problem)
|
|
196
|
+
|
|
197
|
+
return input as CompiledGeographicModel
|
|
198
|
+
}
|