@mailwoman/geographic-model 0.0.0 → 9.3.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 (50) hide show
  1. package/README.md +34 -78
  2. package/data/geographic-model.json +54 -3
  3. package/data/model/concepts.json +34 -1
  4. package/data/model/mappings.json +14 -0
  5. package/data/model/model.json +1 -1
  6. package/data/model/relations.json +1 -1
  7. package/{artifact.ts → lib/artifact.ts} +8 -11
  8. package/{compile.ts → lib/compile.ts} +3 -3
  9. package/{index.ts → lib/index.ts} +5 -5
  10. package/{load.ts → lib/load.ts} +17 -12
  11. package/{lookup.ts → lib/lookup.ts} +2 -2
  12. package/{schema.ts → lib/schema.ts} +3 -3
  13. package/{validate.ts → lib/validate.ts} +5 -4
  14. package/{validation-issues.ts → lib/validation-issues.ts} +0 -4
  15. package/out/artifact.d.ts +2 -2
  16. package/out/artifact.d.ts.map +1 -1
  17. package/out/artifact.js +6 -10
  18. package/out/artifact.js.map +1 -1
  19. package/out/compile.d.ts +1 -1
  20. package/out/compile.d.ts.map +1 -1
  21. package/out/compile.js +3 -3
  22. package/out/compile.js.map +1 -1
  23. package/out/index.d.ts +5 -5
  24. package/out/index.d.ts.map +1 -1
  25. package/out/index.js +5 -5
  26. package/out/index.js.map +1 -1
  27. package/out/load.d.ts +2 -2
  28. package/out/load.d.ts.map +1 -1
  29. package/out/load.js +15 -10
  30. package/out/load.js.map +1 -1
  31. package/out/lookup.d.ts +2 -2
  32. package/out/lookup.d.ts.map +1 -1
  33. package/out/lookup.js +1 -1
  34. package/out/lookup.js.map +1 -1
  35. package/out/schema.d.ts +3 -3
  36. package/out/schema.d.ts.map +1 -1
  37. package/out/schema.js.map +1 -1
  38. package/out/scripts/build-artifact.d.ts +6 -6
  39. package/out/scripts/build-artifact.d.ts.map +1 -1
  40. package/out/scripts/build-artifact.js +26 -19
  41. package/out/scripts/build-artifact.js.map +1 -1
  42. package/out/validate.d.ts +3 -3
  43. package/out/validate.d.ts.map +1 -1
  44. package/out/validate.js +4 -3
  45. package/out/validate.js.map +1 -1
  46. package/out/validation-issues.d.ts +0 -1
  47. package/out/validation-issues.d.ts.map +1 -1
  48. package/out/validation-issues.js +0 -3
  49. package/out/validation-issues.js.map +1 -1
  50. package/package.json +37 -5
package/README.md CHANGED
@@ -1,21 +1,18 @@
1
1
  # @mailwoman/geographic-model
2
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.
3
+ Authored geographic semantics for the [mailwoman](https://www.npmjs.com/package/mailwoman) geocoder: stable concepts (`pharmacy`, `obtain_medication`), the relations a curator can state between them (`pharmacy affords obtain_medication`), mappings into external vocabularies, source observations, and derived facts every record carrying provenance, and all of it compiled deterministically into a lookup artifact the runtime reads.
4
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.
5
+ The design problem it exists to solve: a geocoder benefits from knowing that a pharmacy is a place where you obtain medication, but the moment that knowledge becomes a ranking rule, authored opinion starts overriding what the models learned from data. This package holds the knowledge in a shape that CANNOT become ranking policy no numeric field exists anywhere in the schema, and the compiled artifact answers lookups, never orderings.
6
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.
7
+ ## Should you install this?
8
8
 
9
- ## What this package owns
9
+ Probably not directly not yet.
10
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.
11
+ - If you want a geocoder, install [`mailwoman`](https://www.npmjs.com/package/mailwoman). It consumes this package where its experiments call for it.
12
+ - **Do not depend on version `0.0.0`.** It exists to establish the package name. The first supported release ships with the next coordinated mailwoman release, and the API is unstable until a `1.x`.
13
+ - What you can evaluate today: the schema, the validator, the compiler, and the committed artifact all shipped as readable TypeScript source and JSON in this repository.
17
14
 
18
- ## The schema
15
+ ## The shape of the data
19
16
 
20
17
  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
18
 
@@ -33,64 +30,31 @@ Three properties hold by construction:
33
30
  - **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
31
  - **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
32
 
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.
33
+ Identifiers are branded through `type-fest`'s `Tagged` and converted explicitly — `toConceptID`, `toRelationID`, `toRuleID`, `toMappingID`, `toObservationID`, `toDerivedFactID`. The brands are compile-time only; the strings survive JSON untouched.
37
34
 
38
35
  ## The validator
39
36
 
40
37
  `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
38
 
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.
39
+ 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 drops the records it could not read, without reporting them, is a validator whose output is indistinguishable from a world that does not contain them.
43
40
 
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.
41
+ 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`, 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
42
 
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.
43
+ It is plain deterministic TypeScript with no I/O: no reasoner, no query engine, no schema library.
47
44
 
48
- ## The loader
45
+ ## The loader, the compiler, and the artifact
49
46
 
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.
47
+ `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. The file layout is authoring convenience and carries no meaning; enumeration order cannot reach the output, and every validation issue names the file it came from.
51
48
 
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.
49
+ `compileGeographicModel(input)` validates by delegation, then compiles. **`isA` alone defines semantic inheritance**: the artifact carries every concept's transitive ancestors and every ancestor's assertions materialized onto descendants as derived facts naming their derivation and inputs. A relation declaring `transitive` or `inverse` is **not** closed over those fields say what the relation means, and general reasoning is excluded from this package for its lifetime.
53
50
 
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.
51
+ `serializeCompiledModel(model)` produces canonical bytes: keys in code-point order at every depth, tables ordered by identifier, and nothing recording when compilation ran — two builds of one document are byte-identical, so a regenerate is a diff only when the records changed.
88
52
 
89
53
  `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
54
 
91
- ## The authored slice
55
+ ## What is authored today
92
56
 
93
- One proposition, frozen by §4 of the boundary record and authored under [`data/model/`](./data/model/):
57
+ The first slice, in [`data/model/`](./data/model/):
94
58
 
95
59
  ```text
96
60
  place
@@ -100,23 +64,21 @@ pharmacy isA healthcare_facility affords obtain_medicatio
100
64
  activity
101
65
  obtain_medication isA activity
102
66
 
103
- affords establishment → activity, hard, not transitive, not symmetric
67
+ affords establishment → activity, not transitive, not symmetric
104
68
  poi-taxonomy pharmacy → the pharmacy concept
105
69
  ```
106
70
 
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:
71
+ [`data/geographic-model.json`](./data/geographic-model.json) is the committed compilation. **Do not hand-edit it** regenerate:
112
72
 
113
73
  ```bash
114
- node packages/geographic-model/scripts/build-artifact.ts && npx oxfmt packages/geographic-model/data/geographic-model.json
74
+ node packages/geographic-model/lib/scripts/build-artifact.ts && npx oxfmt packages/geographic-model/data/geographic-model.json
115
75
  ```
116
76
 
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.
77
+ [`data/PROVENANCE.md`](./data/PROVENANCE.md) records what each file states and where each external category id was read from. A reviewed amendment admitted a first breadth wave — a `drugstore` concept, a US-scoped `affords` assertion on it, and a second `poi-taxonomy` mapping — and all three are authored, under the same provenance discipline. One activity therefore reaches two establishment classes, which is what the schema was shaped to express and what the POI branch searches as a union.
78
+
79
+ Measured, not promised: with this one proposition injected behind an off-by-default flag, activity-phrased queries against the live geocoder moved from 0 of 4 answered to 3 of 4 (a pharmacy 0.41 km from the Denver anchor), with all 6 control queries unchanged. That measurement — pre-registered before the code existed, frozen by hash, decided against committed thresholds — is why the package continues to grow.
118
80
 
119
- ## What this package must never own
81
+ ## Design commitments
120
82
 
121
83
  Each of these is owned elsewhere, and naming the owner is what keeps a second copy from growing here.
122
84
 
@@ -125,30 +87,24 @@ Each of these is owned elsewhere, and naming the owner is what keeps a second co
125
87
  | Relevance weights, boosts, penalties, any candidate-ordering API — even a type | `@mailwoman/resolver` (ordering), `@mailwoman/neural` + `@mailwoman/core/decoder` (the decode objective) |
126
88
  | POI categories, their containment hierarchy, the Overture-leaf translation, the query-phrase lexicon, brands | `@mailwoman/poi-taxonomy` |
127
89
  | 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:
90
+ | Empirical, spatial activity-affordance statistics | Fitted from data elsewhere in the program — against the identifiers owned here |
131
91
 
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.
92
+ Two rules are enforced by tests rather than assumed: `@mailwoman/core` must not depend on this package (core ships the pipeline contract plus ~9 MB of reference data to every consumer, and a world-semantics dependency there is one every drop-in API inherits without asking), and the public surface carries no ranking policy a binding whose name announces a boost, penalty, weight, rank, score, or ordering fails the suite.
134
93
 
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.
94
+ The operating rule for the whole boundary is one sentence: **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
95
 
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.
96
+ Architecturally excluded for the life of the package: 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
97
 
139
- ## Why this is not in the release list
98
+ ## Where the full design lives
140
99
 
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.
100
+ This README is the package-local summary. The authoritative documents are in the mailwoman repository:
142
101
 
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`.
102
+ - The ownership boundary and the frozen first slice: [`docs/superpowers/specs/2026-08-26-geographic-model-boundaries.md`](https://github.com/sister-software/mailwoman/blob/main/docs/superpowers/specs/2026-08-26-geographic-model-boundaries.md)
103
+ - The program that governs growth, with its decision points: [sister-software/mailwoman#1916](https://github.com/sister-software/mailwoman/issues/1916)
146
104
 
147
105
  ## Layout
148
106
 
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.
107
+ Source lives at the workspace root. Tests live under `test/unit/` and reach the package through its package name, never a relative path. The manifest's `files` array declares `data/**/*.json` explicitly — `**/*.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
108
 
153
109
  ## License
154
110
 
@@ -16,6 +16,39 @@
16
16
  },
17
17
  "status": "active"
18
18
  },
19
+ {
20
+ "assertions": [
21
+ {
22
+ "countries": ["US"],
23
+ "id": "drugstore-affords-obtain-medication",
24
+ "modality": "strongly_expected",
25
+ "provenance": {
26
+ "authoredAt": "2026-08-27",
27
+ "notes": "The committed curator statement `{ \"phrase\": \"drugstore\", \"categoryID\": \"pharmacy\", \"locales\": [\"en-US\"] }` says that in en-US this class names the pharmacy category. `strongly_expected` rather than `necessary`: the statement says a US drugstore characteristically dispenses, and neither a locale-scoped synonym nor a row count is a census of dispensing, so a counter-example qualifies this record rather than falsifying it. Scoped to `US` because that is the only country a committed record scopes the class to — CA (369 rows), GB (117) and MX (3) bound a population and assert nothing about what those premises afford, and FR is a measured zero on the shipped layer, so there would be nothing there for the claim to range over.",
28
+ "source": "mailwoman-curated",
29
+ "sourceRecord": "packages/poi-taxonomy/data/curated-overlay.json synonyms[drugstore → pharmacy, locales en-US]",
30
+ "sourceURL": "https://github.com/sister-software/mailwoman/issues/1963",
31
+ "sourceVersion": "poi-taxonomy table 0.4.0"
32
+ },
33
+ "relation": "affords",
34
+ "target": "obtain_medication"
35
+ }
36
+ ],
37
+ "description": "A retail establishment class whose premises characteristically include a counter that dispenses medication to the public.",
38
+ "id": "drugstore",
39
+ "isA": ["establishment"],
40
+ "kind": "establishment",
41
+ "label": "drugstore",
42
+ "provenance": {
43
+ "authoredAt": "2026-08-27",
44
+ "notes": "Admitted by the wave-1 amendment against a measured recall gap, re-taken on the shipped layer before this record was frozen: one `group by category_id, country` pass over `poi.db` at manifest 2026-07-22.0 counts 7,168 rows under `retail > drugstore` against 82,168 under `health_and_medical > pharmacy` — 8.0% of the two leaves overall and 12.9% within the US (6,679 of 51,624), all of them out of reach of a `pharmacy` query because the branches are disjoint. A kind of `establishment` directly and not of `healthcare_facility`: the external hierarchy puts it under `retail`, and `healthcare_facility` is premises that exist to provide healthcare, which retail premises with a dispensing counter do not. The `poi-taxonomy` mapping the amendment admits as W1-3 is authored beside this record in `mappings.json`, so `obtain_medication` reaches two mapped kinds and the POI branch searches both.",
45
+ "source": "mailwoman-curated",
46
+ "sourceRecord": "docs/superpowers/specs/2026-08-26-geographic-model-boundaries.md §4.1 W1-1",
47
+ "sourceURL": "https://github.com/sister-software/mailwoman/issues/1963",
48
+ "sourceVersion": "poi.db layer_manifest 2026-07-22.0"
49
+ },
50
+ "status": "active"
51
+ },
19
52
  {
20
53
  "assertions": [],
21
54
  "description": "A class of premises a person can go to.",
@@ -71,7 +104,7 @@
71
104
  "modality": "necessary",
72
105
  "provenance": {
73
106
  "authoredAt": "2026-08-26",
74
- "notes": "The proposition the first slice exists to state. `necessary` because dispensing medication to the public is what makes premises a pharmacy rather than a neighbouring retail class, which is the same claim `affords` makes by declaring `hard` semantics a counter-example falsifies this record instead of qualifying it. No `countries` scope: the country-conditional part of the question is which OTHER establishment classes afford the activity, and the slice authors no second class.",
107
+ "notes": "The proposition the first slice exists to state. `necessary` because dispensing medication to the public is what makes premises a pharmacy rather than a neighbouring retail class a counter-example falsifies this record instead of qualifying it. The relation is `defeasible`, which says whether `affords` assertions admit exceptions at all and is what lets a `strongly_expected` record sit beside this one coherently; it does not weaken this claim. No `countries` scope: the country-conditional part of the question is which OTHER establishment classes afford the activity, and each of those carries its own scope.",
75
108
  "source": "mailwoman-curated",
76
109
  "sourceRecord": "docs/superpowers/specs/2026-08-26-geographic-model-boundaries.md §4",
77
110
  "sourceURL": "https://github.com/sister-software/mailwoman/issues/1927"
@@ -117,6 +150,10 @@
117
150
  "ancestors": [],
118
151
  "concept": "activity"
119
152
  },
153
+ {
154
+ "ancestors": ["establishment", "place"],
155
+ "concept": "drugstore"
156
+ },
120
157
  {
121
158
  "ancestors": ["place"],
122
159
  "concept": "establishment"
@@ -139,6 +176,20 @@
139
176
  }
140
177
  ],
141
178
  "mappings": [
179
+ {
180
+ "concept": "drugstore",
181
+ "externalID": "drugstore",
182
+ "id": "poi-taxonomy-drugstore",
183
+ "provenance": {
184
+ "authoredAt": "2026-08-28",
185
+ "notes": "W1-3 of the wave-1 amendment, read back from the committed table at version 0.4.0 (Overture schema v1.17.0): `{ id: drugstore, hierarchy: [retail, drugstore], source: overture }`. The category declares no `overtureCategories`, so `resolveOvertureCategories(\"drugstore\")` is the identity `[\"drugstore\"]`, and the leaf is disjoint from `health_and_medical > pharmacy` — which is why 7,168 rows were out of reach of a `pharmacy` query. With this record `obtain_medication` reaches two mapped kinds; that is the SET the POI branch searches, and no field here states a preference between them. The mapping states that the external identifier names this concept and nothing else.",
186
+ "source": "mailwoman-curated",
187
+ "sourceRecord": "packages/poi-taxonomy/data/taxonomy.json categories[drugstore]",
188
+ "sourceURL": "https://github.com/sister-software/mailwoman/issues/1980",
189
+ "sourceVersion": "poi-taxonomy table 0.4.0"
190
+ },
191
+ "vocabulary": "poi-taxonomy"
192
+ },
142
193
  {
143
194
  "concept": "pharmacy",
144
195
  "externalID": "pharmacy",
@@ -154,7 +205,7 @@
154
205
  "vocabulary": "poi-taxonomy"
155
206
  }
156
207
  ],
157
- "modelVersion": "0.1.0",
208
+ "modelVersion": "0.3.0",
158
209
  "observations": [],
159
210
  "relations": [
160
211
  {
@@ -163,7 +214,7 @@
163
214
  "id": "affords",
164
215
  "label": "affords",
165
216
  "rangeKinds": ["activity"],
166
- "semantics": "hard",
217
+ "semantics": "defeasible",
167
218
  "symmetric": false,
168
219
  "transitive": false
169
220
  }
@@ -65,7 +65,7 @@
65
65
  "sourceRecord": "docs/superpowers/specs/2026-08-26-geographic-model-boundaries.md §4",
66
66
  "sourceURL": "https://github.com/sister-software/mailwoman/issues/1927",
67
67
  "authoredAt": "2026-08-26",
68
- "notes": "The proposition the first slice exists to state. `necessary` because dispensing medication to the public is what makes premises a pharmacy rather than a neighbouring retail class, which is the same claim `affords` makes by declaring `hard` semantics a counter-example falsifies this record instead of qualifying it. No `countries` scope: the country-conditional part of the question is which OTHER establishment classes afford the activity, and the slice authors no second class."
68
+ "notes": "The proposition the first slice exists to state. `necessary` because dispensing medication to the public is what makes premises a pharmacy rather than a neighbouring retail class a counter-example falsifies this record instead of qualifying it. The relation is `defeasible`, which says whether `affords` assertions admit exceptions at all and is what lets a `strongly_expected` record sit beside this one coherently; it does not weaken this claim. No `countries` scope: the country-conditional part of the question is which OTHER establishment classes afford the activity, and each of those carries its own scope."
69
69
  }
70
70
  }
71
71
  ],
@@ -78,6 +78,39 @@
78
78
  },
79
79
  "status": "active"
80
80
  },
81
+ {
82
+ "id": "drugstore",
83
+ "label": "drugstore",
84
+ "description": "A retail establishment class whose premises characteristically include a counter that dispenses medication to the public.",
85
+ "kind": "establishment",
86
+ "isA": ["establishment"],
87
+ "assertions": [
88
+ {
89
+ "id": "drugstore-affords-obtain-medication",
90
+ "relation": "affords",
91
+ "target": "obtain_medication",
92
+ "modality": "strongly_expected",
93
+ "countries": ["US"],
94
+ "provenance": {
95
+ "source": "mailwoman-curated",
96
+ "sourceVersion": "poi-taxonomy table 0.4.0",
97
+ "sourceRecord": "packages/poi-taxonomy/data/curated-overlay.json synonyms[drugstore → pharmacy, locales en-US]",
98
+ "sourceURL": "https://github.com/sister-software/mailwoman/issues/1963",
99
+ "authoredAt": "2026-08-27",
100
+ "notes": "The committed curator statement `{ \"phrase\": \"drugstore\", \"categoryID\": \"pharmacy\", \"locales\": [\"en-US\"] }` says that in en-US this class names the pharmacy category. `strongly_expected` rather than `necessary`: the statement says a US drugstore characteristically dispenses, and neither a locale-scoped synonym nor a row count is a census of dispensing, so a counter-example qualifies this record rather than falsifying it. Scoped to `US` because that is the only country a committed record scopes the class to — CA (369 rows), GB (117) and MX (3) bound a population and assert nothing about what those premises afford, and FR is a measured zero on the shipped layer, so there would be nothing there for the claim to range over."
101
+ }
102
+ }
103
+ ],
104
+ "provenance": {
105
+ "source": "mailwoman-curated",
106
+ "sourceVersion": "poi.db layer_manifest 2026-07-22.0",
107
+ "sourceRecord": "docs/superpowers/specs/2026-08-26-geographic-model-boundaries.md §4.1 W1-1",
108
+ "sourceURL": "https://github.com/sister-software/mailwoman/issues/1963",
109
+ "authoredAt": "2026-08-27",
110
+ "notes": "Admitted by the wave-1 amendment against a measured recall gap, re-taken on the shipped layer before this record was frozen: one `group by category_id, country` pass over `poi.db` at manifest 2026-07-22.0 counts 7,168 rows under `retail > drugstore` against 82,168 under `health_and_medical > pharmacy` — 8.0% of the two leaves overall and 12.9% within the US (6,679 of 51,624), all of them out of reach of a `pharmacy` query because the branches are disjoint. A kind of `establishment` directly and not of `healthcare_facility`: the external hierarchy puts it under `retail`, and `healthcare_facility` is premises that exist to provide healthcare, which retail premises with a dispensing counter do not. The `poi-taxonomy` mapping the amendment admits as W1-3 is authored beside this record in `mappings.json`, so `obtain_medication` reaches two mapped kinds and the POI branch searches both."
111
+ },
112
+ "status": "active"
113
+ },
81
114
  {
82
115
  "id": "activity",
83
116
  "label": "activity",
@@ -13,6 +13,20 @@
13
13
  "authoredAt": "2026-08-26",
14
14
  "notes": "Read from the committed table at version 0.4.0 (Overture schema v1.17.0): `{ id: pharmacy, label: Pharmacy, hierarchy: [health_and_medical, pharmacy], basicLabel: Pharmacy, osmTag: amenity=pharmacy, source: overture }`. The category declares no `overtureCategories`, so `resolveOvertureCategories(\"pharmacy\")` is the identity `[\"pharmacy\"]`. The mapping states that the external identifier names this concept, and nothing else — the containment hierarchy, the Overture-leaf translation and the phrase lexicon stay owned by `@mailwoman/poi-taxonomy`."
15
15
  }
16
+ },
17
+ {
18
+ "id": "poi-taxonomy-drugstore",
19
+ "concept": "drugstore",
20
+ "vocabulary": "poi-taxonomy",
21
+ "externalID": "drugstore",
22
+ "provenance": {
23
+ "source": "mailwoman-curated",
24
+ "sourceVersion": "poi-taxonomy table 0.4.0",
25
+ "sourceRecord": "packages/poi-taxonomy/data/taxonomy.json categories[drugstore]",
26
+ "sourceURL": "https://github.com/sister-software/mailwoman/issues/1980",
27
+ "authoredAt": "2026-08-28",
28
+ "notes": "W1-3 of the wave-1 amendment, read back from the committed table at version 0.4.0 (Overture schema v1.17.0): `{ id: drugstore, hierarchy: [retail, drugstore], source: overture }`. The category declares no `overtureCategories`, so `resolveOvertureCategories(\"drugstore\")` is the identity `[\"drugstore\"]`, and the leaf is disjoint from `health_and_medical > pharmacy` — which is why 7,168 rows were out of reach of a `pharmacy` query. With this record `obtain_medication` reaches two mapped kinds; that is the SET the POI branch searches, and no field here states a preference between them. The mapping states that the external identifier names this concept and nothing else."
29
+ }
16
30
  }
17
31
  ]
18
32
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0"
2
+ "version": "0.3.0"
3
3
  }
@@ -8,7 +8,7 @@
8
8
  "rangeKinds": ["activity"],
9
9
  "transitive": false,
10
10
  "symmetric": false,
11
- "semantics": "hard"
11
+ "semantics": "defeasible"
12
12
  }
13
13
  ]
14
14
  }
@@ -27,6 +27,10 @@
27
27
  * Boundary record: `docs/superpowers/specs/2026-08-26-geographic-model-boundaries.md` (#1917).
28
28
  */
29
29
 
30
+ import { prettyJSON } from "@mailwoman/core/json"
31
+ import { isPlainObject } from "@mailwoman/core/objects"
32
+ import { compareByCodePoint } from "@mailwoman/core/strings/compare"
33
+
30
34
  import type {
31
35
  ConceptID,
32
36
  ConceptRecord,
@@ -34,8 +38,7 @@ import type {
34
38
  ExternalMappingRecord,
35
39
  RelationRecord,
36
40
  SourceObservationRecord,
37
- } from "./schema.ts"
38
- import { isPlainObject } from "./validation-issues.ts"
41
+ } from "#schema"
39
42
 
40
43
  /**
41
44
  * The artifact FORMAT version — incremented by hand when the compiled shape changes, so a reader meeting an artifact it
@@ -90,13 +93,7 @@ export interface CompiledGeographicModel {
90
93
  * `String.prototype.localeCompare` is the trap this exists to avoid — its answer depends on the machine's collation, so
91
94
  * an artifact ordered with it is reproducible only on the machine that built it.
92
95
  */
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
- }
96
+ export { compareByCodePoint as compareIdentifiers } from "@mailwoman/core/strings/compare"
100
97
 
101
98
  /**
102
99
  * Rebuild `value` with every object's keys in code-point order, at every depth. Arrays keep their order — ordering
@@ -109,7 +106,7 @@ function canonicalize(value: unknown): unknown {
109
106
 
110
107
  const canonical: Record<string, unknown> = {}
111
108
 
112
- for (const key of Object.keys(value).toSorted(compareIdentifiers)) {
109
+ for (const key of Object.keys(value).toSorted(compareByCodePoint)) {
113
110
  const entry = value[key]
114
111
 
115
112
  if (entry === undefined) continue
@@ -129,7 +126,7 @@ function canonicalize(value: unknown): unknown {
129
126
  * two compiles.
130
127
  */
131
128
  export function serializeCompiledModel(model: CompiledGeographicModel): string {
132
- return `${JSON.stringify(canonicalize(model), null, "\t")}\n`
129
+ return prettyJSON(canonicalize(model))
133
130
  }
134
131
 
135
132
  /**
@@ -36,7 +36,7 @@ import {
36
36
  type CompiledGeographicModel,
37
37
  compareIdentifiers,
38
38
  type InheritanceClosureEntry,
39
- } from "./artifact.ts"
39
+ } from "#artifact"
40
40
  import {
41
41
  type ConceptID,
42
42
  type ConceptRecord,
@@ -48,8 +48,8 @@ import {
48
48
  type RelationRecord,
49
49
  toConceptID,
50
50
  toDerivedFactID,
51
- } from "./schema.ts"
52
- import { parseGeographicModelDocument } from "./validate.ts"
51
+ } from "#schema"
52
+ import { parseGeographicModelDocument } from "#validate"
53
53
 
54
54
  /**
55
55
  * The name a fact derived by `isA` inheritance carries in its `derivation` field. A consumer branches on this rather
@@ -44,8 +44,8 @@
44
44
  * Program parent: #1916.
45
45
  */
46
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"
47
+ export * from "#artifact"
48
+ export * from "#compile"
49
+ export * from "#lookup"
50
+ export * from "#schema"
51
+ export * from "#validate"
@@ -26,21 +26,21 @@
26
26
  * keys it uses are tables.
27
27
  */
28
28
 
29
- import { readdirSync, readFileSync } from "node:fs"
30
- import { resolve } from "node:path"
29
+ import { readDirectoryEntries, readLocalTextFile } from "@mailwoman/core/fs/readers"
30
+ import { isPlainObject } from "@mailwoman/core/objects"
31
+ import { resolvePath } from "path-ts"
31
32
 
32
- import { compareIdentifiers } from "./artifact.ts"
33
- import type { GeographicModelDocument } from "./schema.ts"
34
- import { validateGeographicModelDocument } from "./validate.ts"
33
+ import { compareIdentifiers } from "#artifact"
34
+ import type { GeographicModelDocument } from "#schema"
35
+ import { validateGeographicModelDocument } from "#validate"
35
36
  import {
36
37
  add,
37
38
  checkFieldNames,
38
- isPlainObject,
39
39
  readArray,
40
40
  readString,
41
41
  type ValidationIssue,
42
42
  ValidationIssueCode,
43
- } from "./validation-issues.ts"
43
+ } from "#validation-issues"
44
44
 
45
45
  /**
46
46
  * The manifest every model directory carries: the document's `version`, and nothing else.
@@ -363,15 +363,15 @@ export function mergeGeographicModelFiles(files: readonly GeographicModelSourceF
363
363
  * the tree and not of the filesystem that stored it. Symbolic links are not followed: a model directory is source, and
364
364
  * a link out of it is a record whose home nobody can state.
365
365
  */
366
- function listSourceFiles(root: string, prefix = ""): string[] {
367
- const entries = readdirSync(resolve(root, prefix), { withFileTypes: true })
366
+ async function listSourceFiles(root: string, prefix = ""): Promise<string[]> {
367
+ const entries = await readDirectoryEntries(resolvePath(root, prefix))
368
368
  const found: string[] = []
369
369
 
370
370
  for (const entry of entries.toSorted((left, right) => compareIdentifiers(left.name, right.name))) {
371
371
  const path = prefix ? `${prefix}/${entry.name}` : entry.name
372
372
 
373
373
  if (entry.isDirectory()) {
374
- found.push(...listSourceFiles(root, path))
374
+ found.push(...(await listSourceFiles(root, path)))
375
375
 
376
376
  continue
377
377
  }
@@ -389,8 +389,13 @@ function listSourceFiles(root: string, prefix = ""): string[] {
389
389
  *
390
390
  * Throws {@link GeographicModelLoadError} with every issue, each addressed to its source file.
391
391
  */
392
- export function loadGeographicModelDirectory(root: string): GeographicModelDocument {
393
- const files = listSourceFiles(root).map((path) => ({ path, text: readFileSync(resolve(root, path), "utf8") }))
392
+ export async function loadGeographicModelDirectory(root: string): Promise<GeographicModelDocument> {
393
+ const paths = await listSourceFiles(root)
394
+ const files: GeographicModelSourceFile[] = []
395
+
396
+ for (const path of paths) {
397
+ files.push({ path, text: await readLocalTextFile(resolvePath(root, path)) })
398
+ }
394
399
 
395
400
  return mergeGeographicModelFiles(files)
396
401
  }
@@ -20,7 +20,7 @@
20
20
 
21
21
  import type { POICategoryID } from "@mailwoman/poi-taxonomy/types"
22
22
 
23
- import { type CompiledGeographicModel, compareIdentifiers } from "./artifact.ts"
23
+ import { type CompiledGeographicModel, compareIdentifiers } from "#artifact"
24
24
  import type {
25
25
  ConceptID,
26
26
  ConceptRecord,
@@ -28,7 +28,7 @@ import type {
28
28
  ExternalVocabulary,
29
29
  RelationID,
30
30
  RelationRecord,
31
- } from "./schema.ts"
31
+ } from "#schema"
32
32
 
33
33
  /**
34
34
  * Lookups over one compiled artifact.
@@ -358,9 +358,9 @@ export type DerivationInput =
358
358
  * A fact a named procedure computed from named inputs. Never hand-authored: #1926's compiler writes this table, and the
359
359
  * validator refuses a fact whose derivation is unnamed or whose inputs do not resolve.
360
360
  *
361
- * There is no provenance field. The derivation plus the inputs is the provenance, and it is the stronger kind — a
362
- * source string can be copied onto a record that did not come from it, while an input list either resolves or the
363
- * document does not validate.
361
+ * There is no provenance field. The derivation plus the inputs is the provenance and the stronger kind — a source
362
+ * string can be copied onto a record that did not come from it, while an input list either resolves or the document
363
+ * does not validate.
364
364
  */
365
365
  export interface DerivedFactRecord {
366
366
  id: DerivedFactID
@@ -27,6 +27,8 @@
27
27
  * Consumed by #1926's compiler, which validates before it emits, and by #1927's authored document.
28
28
  */
29
29
 
30
+ import { isPlainObject } from "@mailwoman/core/objects"
31
+
30
32
  import {
31
33
  ConceptKind,
32
34
  ConceptStatus,
@@ -35,11 +37,10 @@ import {
35
37
  type GeographicModelDocument,
36
38
  Modality,
37
39
  RelationSemantics,
38
- } from "./schema.ts"
40
+ } from "#schema"
39
41
  import {
40
42
  add,
41
43
  checkFieldNames,
42
- isPlainObject,
43
44
  listVocabulary,
44
45
  readArray,
45
46
  readBoolean,
@@ -49,9 +50,9 @@ import {
49
50
  readVocabularyValue,
50
51
  type ValidationIssue,
51
52
  ValidationIssueCode,
52
- } from "./validation-issues.ts"
53
+ } from "#validation-issues"
53
54
 
54
- export { type ValidationIssue, ValidationIssueCode } from "./validation-issues.ts"
55
+ export { type ValidationIssue, ValidationIssueCode } from "#validation-issues"
55
56
 
56
57
  /**
57
58
  * The whole document, or every reason it is not one.