@mailwoman/codex 7.1.0 → 7.2.1

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 (57) hide show
  1. package/address-system-conventions.ts +68 -0
  2. package/au/delivery-service.ts +179 -0
  3. package/au/index.ts +15 -0
  4. package/au/level-designator.ts +209 -0
  5. package/au/postcode.ts +51 -0
  6. package/au/state.ts +35 -0
  7. package/ca/index.ts +12 -0
  8. package/ca/postal-code.ts +121 -0
  9. package/ca/province.ts +99 -0
  10. package/ca/street-type.ts +167 -0
  11. package/country/codes.ts +534 -0
  12. package/country/country.ts +125 -0
  13. package/country/index.ts +14 -0
  14. package/country/names.ts +274 -0
  15. package/country/official-languages.ts +397 -0
  16. package/country/reference-data.ts +267 -0
  17. package/country/reference.ts +47 -0
  18. package/de/bundesland.ts +102 -0
  19. package/de/index.ts +12 -0
  20. package/de/postleitzahl.ts +91 -0
  21. package/de/street-type.ts +83 -0
  22. package/fr/cedex.ts +56 -0
  23. package/fr/code-postal.ts +105 -0
  24. package/fr/departement.ts +142 -0
  25. package/fr/index.ts +14 -0
  26. package/fr/region.ts +93 -0
  27. package/fr/voie.ts +98 -0
  28. package/gb/country.ts +74 -0
  29. package/gb/index.ts +14 -0
  30. package/gb/postcode-area.ts +107 -0
  31. package/gb/postcode.ts +109 -0
  32. package/gb/street-type.ts +90 -0
  33. package/index.ts +38 -0
  34. package/jp/address-unit.ts +87 -0
  35. package/jp/index.ts +13 -0
  36. package/jp/postal-code.ts +93 -0
  37. package/jp/prefecture.ts +173 -0
  38. package/level-semantics.ts +623 -0
  39. package/nz/delivery-service.ts +211 -0
  40. package/nz/index.ts +12 -0
  41. package/nz/postcode.ts +42 -0
  42. package/package.json +13 -5
  43. package/postcode-systems.ts +68 -0
  44. package/tools/build-country-surface-lexicon.ts +166 -0
  45. package/tools/export-country-surfaces.ts +46 -0
  46. package/tools/generate-country-reference.ts +153 -0
  47. package/tools/generate-official-languages.ts +188 -0
  48. package/tools/index.ts +12 -0
  49. package/us/floor-designator.ts +119 -0
  50. package/us/index.ts +19 -0
  51. package/us/military-address.ts +199 -0
  52. package/us/po-box.ts +82 -0
  53. package/us/state.ts +156 -0
  54. package/us/street-directional.ts +220 -0
  55. package/us/street-suffix.ts +345 -0
  56. package/us/unit-designator.ts +223 -0
  57. package/us/zipcode.ts +212 -0
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Per-address-system parsing conventions (#478's rules-as-constraints slice, started as the #511
7
+ * Tier A corrective). The model's locale head detects WHICH address system a string belongs to;
8
+ * this table says what is and isn't grammatical in that system, so the decoder can obey the
9
+ * detection instead of merely being nudged by it.
10
+ *
11
+ * Every row is a provenance-carrying claim about a national addressing convention — the same
12
+ * provenance-first discipline as the rest of the codex. Add rows with a source, not from vibes;
13
+ * an absent row means "no constraints known", never "no constraints exist".
14
+ *
15
+ * First consumer: `@mailwoman/neural`'s decoder applies `forbiddenTags` as a hard emission mask
16
+ * before Viterbi and treats `postcodePattern` as the system's canonical shape for the snap-only
17
+ * postcode repair pass. Both triggered only when the system is detected confidently (or supplied
18
+ * by the caller) — an undetected system parses exactly as before.
19
+ */
20
+
21
+ import { CODE_POSTAL_PATTERN } from "./fr/code-postal.ts"
22
+ import type { SystemCode } from "./postcode-systems.ts"
23
+
24
+ export interface AddressSystemConventions {
25
+ /**
26
+ * Component tags that are NOT grammatical in this address system (names from the core `ComponentTag` union; codex
27
+ * stays dependency-free so they are plain strings here).
28
+ */
29
+ readonly forbiddenTags?: readonly string[]
30
+ /**
31
+ * The system's canonical postcode shape. A decoded postcode span that is a strict sub-match of a pattern-valid string
32
+ * in the raw text is shape-INVALID for this system and eligible for the snap-only repair (extend/clip to the valid
33
+ * match — never invent a span).
34
+ */
35
+ readonly postcodePattern?: RegExp
36
+ }
37
+
38
+ export const ADDRESS_SYSTEM_CONVENTIONS: Partial<Record<SystemCode, AddressSystemConventions>> = {
39
+ /**
40
+ * France (La Poste / AFNOR NF Z 10-011): the street TYPE is a LEADING particle of the street name ("Rue de Rivoli",
41
+ * "Avenue des Champs-Élysées", "Cours Lafayette") and is labeled `street_prefix` — French addresses DO carry a
42
+ * street_prefix, just never a trailing USPS-style street_suffix (the libpostal French dictionaries have no trailing
43
+ * street-suffix class; Pub-28's suffix decomposition has no French counterpart).
44
+ *
45
+ * Provenance / why this is NOT a blanket prefix+suffix forbid (#719, 2026-06-18): an earlier model mis-tagged the
46
+ * leading "Rue" as a US-style `street_suffix` (RUE is a Pub-28 suffix variant) — the 2026-06-10 v1.1.0 gate — so #511
47
+ * forbade BOTH affix tags to stop that leakage. That forbid was correct for THAT model but became a live production
48
+ * bug for the current one: the shipped model (v1.5.0) emits the FR `street_prefix` correctly, but the conventions
49
+ * mask was a hard −1e9 on every B-/I-street_prefix emission, so the detected-FR parse could never KEEP a prefix — it
50
+ * destroyed `street_prefix` wholesale (measured on data/eval/external/ fr-street-prefix-real.jsonl at
51
+ * conventions=auto: F1 0.0 with the forbid on → 80.0 with it off; the larger real-FR eval reported the same collapse,
52
+ * ~96 → ~0.6). We keep ONLY `street_suffix` forbidden: the current model with the forbid OFF shows zero FR
53
+ * street_suffix leakage (fp=0 on that same slice) and FR has no trailing street suffix, so the constraint costs
54
+ * nothing while still guarding against any future suffix mis-tag. Postcode: exactly five digits (NF Z 10-011; see
55
+ * fr/code-postal).
56
+ */
57
+ fr: {
58
+ forbiddenTags: ["street_suffix"],
59
+ postcodePattern: CODE_POSTAL_PATTERN,
60
+ },
61
+ }
62
+
63
+ /** Look up conventions for a system. Absent row = no constraints KNOWN (parse unconstrained). */
64
+ export function conventionsForSystem(system: SystemCode | null | undefined): AddressSystemConventions | null {
65
+ if (!system) return null
66
+
67
+ return ADDRESS_SYSTEM_CONVENTIONS[system] ?? null
68
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Australia Post delivery-service designators (Postal Delivery Types) — the Commonwealth po_box
7
+ * vocabulary the US-only `us/po-box.ts` cannot see: `GPO Box 2890`, `Locked Bag 1797`, `Private
8
+ * Bag 7`, plus the rural/community legacy tail (`RMB 4600`, `RSD`, `CMB`).
9
+ *
10
+ * Sourcing (accessed 2026-06-11; the underlying urban/rural addressing standard is AS/NZS 4819,
11
+ * which governs street addressing — the delivery-service designators below are Australia Post's
12
+ * own, from its addressing guidance):
13
+ *
14
+ * - The complete Postal Delivery Type table comes verbatim from Australia Post's barcode addressing
15
+ * booklet ("Hints and tips to get a higher address match rate", SAP 8838883): CARE OF POST
16
+ * OFFICE→CARE PO, COMMUNITY MAIL AGENT→CMA, COMMUNITY MAIL BAG→CMB, GENERAL POST OFFICE
17
+ * BOX→GPO BOX, LOCKED MAIL BAG SERVICE→LOCKED BAG, MAIL SERVICE→MS, POST OFFICE BOX→PO BOX,
18
+ * POSTE RESTANTE→CARE PO, PRIVATE MAIL BAG SERVICE→PRIVATE BAG, ROADSIDE DELIVERY→RSD,
19
+ * ROADSIDE MAIL BAG→RMB, ROADSIDE MAIL BOX→RMB, ROADSIDE MAIL SERVICE→RMS, COMMUNITY POSTAL
20
+ * AGENT→CPA. The same booklet states: "With the exception of Care of Post Office, Community
21
+ * Mail Agent, Community Postal Agent, and Community Mail Bag, all Postal Delivery Types must
22
+ * have an associated number for a match to occur. e.g. PO Box 112", and "'PRIVATE BOX' is not
23
+ * a valid type" (so PRIVATE BOX is deliberately NOT in this table).
24
+ * - Which designators are CURRENT retail products (vs. AMAS-recognized legacy forms) comes from the
25
+ * live auspost.com.au pages: the addressing guidelines ("Line 2 should contain the street
26
+ * number and name, or PO Box or Locked Bag number"), the Correct Addressing brochure (SAP
27
+ * 8833878, Nov 2022 — `GPO Box 123 / SYDNEY NSW 2000` example), the personal "PO Boxes and
28
+ * Private Bags" page (Private Bag: "If you live in a rural or remote area of Australia, you
29
+ * can manage your mail securely with a Private Bag"), and the business "PO Boxes and Locked
30
+ * Bags" page (GPO Box: "Lease a single GPO Box, or the same box number in each capital city
31
+ * with our Common Box service"; Common Box numbers run 9800–9999). PO Box, GPO Box, Locked
32
+ * Bag, and Private Bag appear on those current pages; the rural/community types (RSD, RMB,
33
+ * RMS, MS, CMB, CMA, CPA, Care PO) appear ONLY in the AMAS table and are flagged `legacy` —
34
+ * the parser must still recognize them on older addresses.
35
+ *
36
+ * @see {@link https://auspost.com.au/content/dam/auspost_corp/media/documents/Barcode_hints_tips.pdf Australia Post barcode addressing booklet (Postal Delivery Type table)}
37
+ * @see {@link https://auspost.com.au/sending/guidelines/addressing-guidelines Australia Post addressing guidelines}
38
+ * @see {@link https://auspost.com.au/content/dam/auspost_corp/media/documents/correct-addressing.pdf Australia Post Correct Addressing brochure (Nov 2022)}
39
+ * @see {@link https://auspost.com.au/receiving/manage-your-mail/po-boxes-and-private-bags Australia Post — PO Boxes and Private Bags}
40
+ * @see {@link https://auspost.com.au/business/business-admin/po-boxes-and-locked-bags Australia Post — business PO Boxes, GPO Boxes and Locked Bags}
41
+ */
42
+
43
+ /** One Postal Delivery Type row from the Australia Post AMAS abbreviation table. */
44
+ export interface AuDeliveryServiceDesignator {
45
+ /** The full Postal Delivery Type name, verbatim from the table (uppercase as published). */
46
+ name: string
47
+ /** The standard abbreviation — the surface form written on mail ("GPO BOX", "LOCKED BAG"). */
48
+ abbreviation: string
49
+ /**
50
+ * Whether the designator "must have an associated number for a match to occur" (AMAS rule; exceptions are Care of
51
+ * Post Office, Community Mail Agent, Community Postal Agent, and Community Mail Bag).
52
+ */
53
+ requiresNumber: boolean
54
+ /**
55
+ * True when the designator is recognized by the AMAS Postal Delivery Type table but absent from every current
56
+ * auspost.com.au addressing/product page (accessed 2026-06-11) — the rural and community forms superseded by rural
57
+ * street addressing under AS/NZS 4819. The parser must still RECOGNIZE these on old addresses; synthesis should
58
+ * weight them low.
59
+ */
60
+ legacy: boolean
61
+ }
62
+
63
+ /**
64
+ * The verbatim Postal Delivery Type table (see the module header for the per-row provenance). Multiple names can share
65
+ * an abbreviation (ROADSIDE MAIL BAG and ROADSIDE MAIL BOX are both RMB; POSTE RESTANTE is addressed as CARE PO).
66
+ */
67
+ export const AU_DELIVERY_SERVICE_DESIGNATORS = [
68
+ { name: "GENERAL POST OFFICE BOX", abbreviation: "GPO BOX", requiresNumber: true, legacy: false },
69
+ { name: "POST OFFICE BOX", abbreviation: "PO BOX", requiresNumber: true, legacy: false },
70
+ { name: "LOCKED MAIL BAG SERVICE", abbreviation: "LOCKED BAG", requiresNumber: true, legacy: false },
71
+ { name: "PRIVATE MAIL BAG SERVICE", abbreviation: "PRIVATE BAG", requiresNumber: true, legacy: false },
72
+ { name: "COMMUNITY MAIL BAG", abbreviation: "CMB", requiresNumber: false, legacy: true },
73
+ { name: "COMMUNITY MAIL AGENT", abbreviation: "CMA", requiresNumber: false, legacy: true },
74
+ { name: "COMMUNITY POSTAL AGENT", abbreviation: "CPA", requiresNumber: false, legacy: true },
75
+ { name: "CARE OF POST OFFICE", abbreviation: "CARE PO", requiresNumber: false, legacy: true },
76
+ { name: "POSTE RESTANTE", abbreviation: "CARE PO", requiresNumber: false, legacy: true },
77
+ { name: "MAIL SERVICE", abbreviation: "MS", requiresNumber: true, legacy: true },
78
+ { name: "ROADSIDE DELIVERY", abbreviation: "RSD", requiresNumber: true, legacy: true },
79
+ { name: "ROADSIDE MAIL BAG", abbreviation: "RMB", requiresNumber: true, legacy: true },
80
+ { name: "ROADSIDE MAIL BOX", abbreviation: "RMB", requiresNumber: true, legacy: true },
81
+ { name: "ROADSIDE MAIL SERVICE", abbreviation: "RMS", requiresNumber: true, legacy: true },
82
+ ] as const satisfies readonly AuDeliveryServiceDesignator[]
83
+
84
+ /** A canonical Australia Post Postal Delivery Type abbreviation. */
85
+ export type AuDeliveryServiceAbbreviation = (typeof AU_DELIVERY_SERVICE_DESIGNATORS)[number]["abbreviation"]
86
+
87
+ /**
88
+ * Per-designator surface patterns (designator phrase only, no anchor, no id). Ordered longest / most-specific first so
89
+ * the matcher prefers "GPO Box" over "PO Box" and "RMS" over "MS". Each pattern tolerates the punctuation AMAS tells
90
+ * mailers to strip ("the full stops and commas in R.M.B and P.O.") — recognition must accept what deliverable mail
91
+ * actually carries.
92
+ *
93
+ * MS is special-cased in {@link matchAuDeliveryService}: its identifier must start with a digit so the bare two-letter
94
+ * designator cannot swallow an honorific ("Ms Smith").
95
+ */
96
+ const DESIGNATOR_PATTERNS: ReadonlyArray<readonly [AuDeliveryServiceAbbreviation, string]> = [
97
+ ["GPO BOX", String.raw`general\s+post\s+office\s+box|g\.?\s*p\.?\s*o\.?\s*box`],
98
+ ["PO BOX", String.raw`post\s+office\s+box|p\.?\s*o\.?\s*box`],
99
+ ["LOCKED BAG", String.raw`locked\s+(?:mail\s+)?bag(?:\s+service)?`],
100
+ ["PRIVATE BAG", String.raw`private\s+(?:mail\s+)?bag(?:\s+service)?`],
101
+ ["CARE PO", String.raw`care\s+of\s+post\s+office|poste\s+restante|care\s+po`],
102
+ ["CMB", String.raw`community\s+mail\s+bag|cmb`],
103
+ ["CMA", String.raw`community\s+mail\s+agent|cma`],
104
+ ["CPA", String.raw`community\s+postal\s+agent|cpa`],
105
+ ["RSD", String.raw`roadside\s+delivery|r\.?\s*s\.?\s*d\.?`],
106
+ ["RMB", String.raw`roadside\s+mail\s+(?:bag|box)|r\.?\s*m\.?\s*b\.?`],
107
+ ["RMS", String.raw`roadside\s+mail\s+service|rms`],
108
+ ["MS", String.raw`mail\s+service|ms`],
109
+ ]
110
+
111
+ const DESIGNATOR_INFO = new Map<AuDeliveryServiceAbbreviation, { requiresNumber: boolean; legacy: boolean }>(
112
+ AU_DELIVERY_SERVICE_DESIGNATORS.map((d) => [d.abbreviation, { requiresNumber: d.requiresNumber, legacy: d.legacy }])
113
+ )
114
+
115
+ // One anchored regex per designator: phrase + (required|optional) identifier. The id shape matches
116
+ // the US slice ([\dA-Za-z][\dA-Za-z-]*); MS additionally requires a digit-leading id (see above).
117
+ const MATCHERS: ReadonlyArray<{ abbreviation: AuDeliveryServiceAbbreviation; re: RegExp }> = DESIGNATOR_PATTERNS.map(
118
+ ([abbreviation, src]) => {
119
+ const { requiresNumber } = DESIGNATOR_INFO.get(abbreviation)!
120
+ const id = abbreviation === "MS" ? String.raw`(\d[\dA-Za-z-]*)` : String.raw`([\dA-Za-z][\dA-Za-z-]*)`
121
+ const tail = requiresNumber ? String.raw`\s*#?\s*${id}` : String.raw`(?:\s*#?\s*${id})?`
122
+
123
+ return { abbreviation, re: new RegExp(String.raw`^\s*(${src})${tail}\s*$`, "i") }
124
+ }
125
+ )
126
+
127
+ /** Result of an AU delivery-service parse. */
128
+ export interface AuDeliveryServiceMatch {
129
+ /** The designator phrase as it appeared ("G.P.O. Box", "Locked Bag"). */
130
+ matched: string
131
+ /** The canonical Postal Delivery Type abbreviation ("GPO BOX", "LOCKED BAG"). */
132
+ designator: AuDeliveryServiceAbbreviation
133
+ /** The delivery-service number when present ("9999", "4600"). */
134
+ id?: string
135
+ /** True when the designator is an AMAS-only legacy form (see the table). */
136
+ legacy: boolean
137
+ }
138
+
139
+ /**
140
+ * If `input` is a standalone Australia Post delivery-service phrase ("GPO Box 2890", "Locked Bag 1797", "RMB 4600",
141
+ * bare "CMB"), return the canonical designator, the id, and the legacy flag. Null otherwise — including for "Private
142
+ * Box", which Australia Post explicitly calls out as not a valid type.
143
+ */
144
+ export function matchAuDeliveryService(input: unknown): AuDeliveryServiceMatch | null {
145
+ if (typeof input !== "string") return null
146
+
147
+ for (const { abbreviation, re } of MATCHERS) {
148
+ const m = re.exec(input)
149
+
150
+ if (!m) continue
151
+ const info = DESIGNATOR_INFO.get(abbreviation)!
152
+
153
+ return {
154
+ matched: m[1]!.trim(),
155
+ designator: abbreviation,
156
+ ...(m[2] ? { id: m[2] } : {}),
157
+ legacy: info.legacy,
158
+ }
159
+ }
160
+
161
+ return null
162
+ }
163
+
164
+ /** Type-predicate: does the input look like a standalone AU delivery-service address line? */
165
+ export function isAuDeliveryService(input: unknown): boolean {
166
+ return matchAuDeliveryService(input) !== null
167
+ }
168
+
169
+ /**
170
+ * Normalize a recognized delivery-service phrase to the canonical AMAS form (`"g.p.o. box 123"` → `"GPO BOX 123"`).
171
+ * Returns the input unchanged if it isn't one.
172
+ */
173
+ export function normalizeAuDeliveryService(input: string): string {
174
+ const m = matchAuDeliveryService(input)
175
+
176
+ if (!m) return input
177
+
178
+ return m.id ? `${m.designator} ${m.id.toUpperCase()}` : m.designator
179
+ }
package/au/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The Australian address system (Australia Post / ISO 3166-2:AU; street addressing per AS/NZS
7
+ * 4819): delivery-service designators (GPO Box, Locked Bag, Private Bag, the rural legacy tail),
8
+ * AMAS / AS 4590.1 floor/level designators (Level 3, Ground Floor, Mezzanine), states and
9
+ * territories, and the 4-digit postcode.
10
+ */
11
+
12
+ export * from "./delivery-service.ts"
13
+ export * from "./level-designator.ts"
14
+ export * from "./postcode.ts"
15
+ export * from "./state.ts"
@@ -0,0 +1,209 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Australia Post AMAS floor/level designators — the sub-premise vocabulary that names a FLOOR of a
7
+ * building rather than a numbered unit on that floor: `Level 3`, `L 12`, `Ground Floor`,
8
+ * `Mezzanine`.
9
+ *
10
+ * Sourcing (accessed 2026-06-12):
11
+ *
12
+ * - **Australia Post AMAS** (Address Matching Approval System) defines the Level Designator type and
13
+ * its approved abbreviation. The verbatim AMAS description: "LEVEL" is the full word; "L" is
14
+ * the approved abbreviation. The standard phrase format is `LEVEL <number>`, e.g. "LEVEL 3"
15
+ * or "L 3" (abbreviation always uppercase in AMAS output). Australia Post's own barcode
16
+ * addressing booklet (SAP 8838883) lists "LEVEL" and "L" as the level type; the Correct
17
+ * Addressing brochure (SAP 8833878, Nov 2022) gives the example `LEVEL 3 / 60 MARGARET ST /
18
+ * SYDNEY NSW 2000`. The AMAS Data Extract Format document (v4.2) confirms "LEVEL" → "L" as
19
+ * the sole level abbreviation pair.
20
+ * - **Ground floor** is treated by AMAS as `LEVEL G` (with the identifier "G"). Australia Post's
21
+ * addressing guidelines state that ground floor should be written as "LEVEL G"; the full word
22
+ * "GROUND" is a recognized alias for the identifier, not a separate designator type.
23
+ * - **Mezzanine**, **Lower Ground**, and **Upper Ground** appear in AS 4590.1-2017 (the Australian
24
+ * Standard for interchange of client information) as recognized level-type values alongside
25
+ * LEVEL and GROUND. AS 4590.1-2017 Table 3 "Level type": B (Basement), G (Ground), MEZZANINE
26
+ * (M), LG (Lower Ground), UG (Upper Ground), L (Level), OD (Observation Deck), P (Parking /
27
+ * Podium), RT (Rooftop). These are the values a Geocoded National Address File (GNAF) record
28
+ * may carry in the LEVEL_TYPE_CODE column, which mirrors the AP AMAS level-type vocabulary.
29
+ * - "LVL" and "LG" appear as widely-recognized surface variants in real AU addresses (Open Addresses
30
+ * AU export, accessed via OpenAddresses) though AS 4590.1-2017 and AMAS canonicalize to "L"
31
+ * and "LG" respectively; the variants are included in {@link AU_LEVEL_DESIGNATOR_VARIANTS} so
32
+ * the parser can RECOGNIZE them without synthesizing them.
33
+ *
34
+ * @see {@link https://auspost.com.au/sending/guidelines/addressing-guidelines Australia Post addressing guidelines}
35
+ * @see {@link https://auspost.com.au/content/dam/auspost_corp/media/documents/correct-addressing.pdf Australia Post Correct Addressing brochure (Nov 2022)}
36
+ * @see {@link https://auspost.com.au/content/dam/auspost_corp/media/documents/Barcode_hints_tips.pdf Australia Post barcode addressing booklet}
37
+ * @see {@link https://www.iso.org/standard/67840.html AS 4590.1-2017 — Interchange of client information}
38
+ */
39
+
40
+ /**
41
+ * One AMAS / AS 4590.1 level-type row.
42
+ *
43
+ * The `type` is the AS 4590.1 LEVEL_TYPE_CODE value (what GNAF and AMAS use internally); the `abbreviation` is the
44
+ * approved surface form used in formatted mail; the `requiresNumber` flag distinguishes designators that take a floor
45
+ * identifier from standalone ones.
46
+ */
47
+ export interface AuLevelDesignator {
48
+ /** AS 4590.1 LEVEL_TYPE_CODE (the GNAF / AMAS internal code). */
49
+ code: string
50
+ /** Full descriptive name (AMAS table label). */
51
+ name: string
52
+ /** The approved AMAS surface abbreviation written on mail ("L", "B", "M"). */
53
+ abbreviation: string
54
+ /**
55
+ * True when the designator takes a numeric or alphanumeric floor identifier after it (`LEVEL 3`, `BASEMENT 2`). False
56
+ * for standalone types (`GROUND`, `MEZZANINE`, `ROOFTOP`) that name a specific well-known floor by vocabulary alone.
57
+ */
58
+ requiresNumber: boolean
59
+ }
60
+
61
+ /**
62
+ * AMAS / AS 4590.1-2017 level-type table (Table 3). Verbatim codes; see the module header for provenance. Ordered with
63
+ * the most-common forms first for match priority.
64
+ */
65
+ export const AU_LEVEL_DESIGNATORS = [
66
+ { code: "L", name: "LEVEL", abbreviation: "L", requiresNumber: true },
67
+ { code: "G", name: "GROUND", abbreviation: "G", requiresNumber: false },
68
+ { code: "B", name: "BASEMENT", abbreviation: "B", requiresNumber: true },
69
+ { code: "M", name: "MEZZANINE", abbreviation: "M", requiresNumber: false },
70
+ { code: "LG", name: "LOWER GROUND", abbreviation: "LG", requiresNumber: false },
71
+ { code: "UG", name: "UPPER GROUND", abbreviation: "UG", requiresNumber: false },
72
+ { code: "OD", name: "OBSERVATION DECK", abbreviation: "OD", requiresNumber: false },
73
+ { code: "P", name: "PARKING", abbreviation: "P", requiresNumber: true },
74
+ { code: "RT", name: "ROOFTOP", abbreviation: "RT", requiresNumber: false },
75
+ ] as const satisfies readonly AuLevelDesignator[]
76
+
77
+ /** A canonical AS 4590.1 LEVEL_TYPE_CODE. */
78
+ export type AuLevelCode = (typeof AU_LEVEL_DESIGNATORS)[number]["code"]
79
+
80
+ /**
81
+ * Recognized surface variants for each AMAS level code — the canonical code/abbreviation pair PLUS additional forms
82
+ * found in real AU addresses (Open Addresses export) that the parser must RECOGNIZE but the synthesis layer should not
83
+ * favor over the canonical form.
84
+ *
85
+ * Synthesis uses only the first element (the AMAS canonical surface). Recognition accepts all.
86
+ */
87
+ export const AU_LEVEL_DESIGNATOR_VARIANTS: Readonly<Record<AuLevelCode, readonly string[]>> = {
88
+ L: ["L", "LEVEL", "LVL", "LEVL", "LEV"],
89
+ G: ["G", "GROUND", "GRD", "GF", "GROUND FLOOR"],
90
+ B: ["B", "BASEMENT", "BSMT", "LOWER LEVEL"],
91
+ M: ["M", "MEZZANINE", "MEZZ", "MEZZANINE LEVEL"],
92
+ LG: ["LG", "LOWER GROUND", "LOWER GROUND FLOOR"],
93
+ UG: ["UG", "UPPER GROUND", "UPPER GROUND FLOOR"],
94
+ OD: ["OD", "OBSERVATION DECK"],
95
+ P: ["P", "PARKING", "PODIUM"],
96
+ RT: ["RT", "ROOFTOP", "ROOF"],
97
+ }
98
+
99
+ /**
100
+ * Inverse lookup: every variant (abbreviation or surface form) → the canonical AMAS code. Lowercase-keyed for
101
+ * case-insensitive matching (`"level"` → `"L"`, `"bsmt"` → `"B"`).
102
+ */
103
+ export const AU_LEVEL_DESIGNATOR_LOOKUP: ReadonlyMap<string, AuLevelCode> = (() => {
104
+ // Structural integrity check: every code must have at least one non-empty variant. Throw at
105
+ // module load time so a malformed table entry fails loud rather than silently producing an empty
106
+ // lexicon (the "builder must round-trip loud" rule from the task contract).
107
+ for (const { code } of AU_LEVEL_DESIGNATORS) {
108
+ const variants = AU_LEVEL_DESIGNATOR_VARIANTS[code]
109
+
110
+ if (!variants || variants.length === 0) {
111
+ throw new Error(`[codex/au/level-designator] code "${code}" has no variants in AU_LEVEL_DESIGNATOR_VARIANTS`)
112
+ }
113
+
114
+ for (const v of variants) {
115
+ if (!v || !v.trim()) {
116
+ throw new Error(
117
+ `[codex/au/level-designator] code "${code}" has an empty or blank variant in AU_LEVEL_DESIGNATOR_VARIANTS`
118
+ )
119
+ }
120
+ }
121
+ }
122
+ const out = new Map<string, AuLevelCode>()
123
+
124
+ for (const { code } of AU_LEVEL_DESIGNATORS) {
125
+ for (const variant of AU_LEVEL_DESIGNATOR_VARIANTS[code]) {
126
+ const key = variant.toLowerCase()
127
+
128
+ if (!out.has(key)) {
129
+ out.set(key, code)
130
+ }
131
+ }
132
+ }
133
+
134
+ return out
135
+ })()
136
+
137
+ /** Result of an AU level designator parse. */
138
+ export interface AuLevelDesignatorMatch {
139
+ /** The code as it appeared in the input ("Level", "L", "lvl"). */
140
+ matched: string
141
+ /** The canonical AS 4590.1 LEVEL_TYPE_CODE ("L", "B", "M"). */
142
+ code: AuLevelCode
143
+ /** The floor identifier when present ("3", "G", "B2"). */
144
+ identifier?: string
145
+ }
146
+
147
+ // One regex per level code. Multi-word variants ("LOWER GROUND", "GROUND FLOOR") are matched
148
+ // before their shorter constituents by ordering the variant list longest-first within each code.
149
+ const LEVEL_MATCHERS: ReadonlyArray<{ code: AuLevelCode; requiresNumber: boolean; re: RegExp }> = (() => {
150
+ const rows: Array<{ code: AuLevelCode; requiresNumber: boolean; re: RegExp }> = []
151
+
152
+ for (const { code, requiresNumber } of AU_LEVEL_DESIGNATORS) {
153
+ const variants = [...AU_LEVEL_DESIGNATOR_VARIANTS[code]]
154
+ .sort((a, b) => b.length - a.length)
155
+ .map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, String.raw`\s+`))
156
+ const alts = variants.join("|")
157
+ // Identifier: optional alphanumeric (B2, 12, G). requiresNumber=true → identifier required.
158
+ const tail = requiresNumber
159
+ ? String.raw`\s+([A-Za-z]?\d[\dA-Za-z-]*|\d[\dA-Za-z-]*)`
160
+ : String.raw`(?:\s+([A-Za-z]?\d[\dA-Za-z-]*|\d[\dA-Za-z-]*))?`
161
+ rows.push({ code, requiresNumber, re: new RegExp(String.raw`^\s*(${alts})${tail}\s*$`, "i") })
162
+ }
163
+
164
+ return rows
165
+ })()
166
+
167
+ /**
168
+ * If `input` is a standalone AU level designator phrase ("Level 3", "L 12", "Ground Floor", "Mezzanine", "B 2"), return
169
+ * the canonical code and identifier. Null otherwise. Malformed entries (a requires-number designator with no
170
+ * identifier, e.g. bare "Level") return null — the builder throws loudly when a row in a table violates this
171
+ * constraint.
172
+ */
173
+ export function matchAuLevelDesignator(input: unknown): AuLevelDesignatorMatch | null {
174
+ if (typeof input !== "string") return null
175
+
176
+ for (const { code, re } of LEVEL_MATCHERS) {
177
+ const m = re.exec(input)
178
+
179
+ if (!m) continue
180
+
181
+ return {
182
+ matched: m[1]!.trim(),
183
+ code,
184
+ ...(m[2] ? { identifier: m[2] } : {}),
185
+ }
186
+ }
187
+
188
+ return null
189
+ }
190
+
191
+ /** Type-predicate: does the input look like a standalone AU level designator phrase? */
192
+ export function isAuLevelDesignator(input: unknown): boolean {
193
+ return matchAuLevelDesignator(input) !== null
194
+ }
195
+
196
+ /**
197
+ * Normalize a recognized level phrase to the AMAS canonical form (`"level 3"` → `"L 3"`, `"ground floor"` → `"G"`).
198
+ * Returns the input unchanged if it isn't a level designator phrase. Throws if a row in {@link AU_LEVEL_DESIGNATORS} is
199
+ * malformed (requires-number entry with no abbreviation or empty name) — the builder must surface structural defects
200
+ * loudly.
201
+ */
202
+ export function normalizeAuLevelDesignator(input: string): string {
203
+ const m = matchAuLevelDesignator(input)
204
+
205
+ if (!m) return input
206
+ const row = AU_LEVEL_DESIGNATORS.find((r) => r.code === m.code)!
207
+
208
+ return m.identifier ? `${row.abbreviation} ${m.identifier.toUpperCase()}` : row.abbreviation
209
+ }
package/au/postcode.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Australian postcodes: four digits, written at the end of the last address line after the locality
7
+ * and state (`SYDNEY NSW 2000`). Sourcing (accessed 2026-06-11):
8
+ *
9
+ * - Australia Post's Correct Addressing brochure (SAP 8833878, Nov 2022) — every example carries a
10
+ * 4-digit postcode and the brochure references envelopes "with preprinted four postcode
11
+ * squares".
12
+ * - The barcode addressing booklet documents the coarse first-digit → state prior: "if the Postcode
13
+ * falls in the range 3000-3999 the State abbreviation will be VIC; 4000-4999 will be QLD,
14
+ * etc. … Exceptions to this include ACT Postcodes and Postcodes located on State borders."
15
+ * Because the booklet only enumerates VIC and QLD and flags exceptions, this module
16
+ * deliberately does NOT ship a full first-digit → state table — the shape is the contract,
17
+ * the geographic prior is the gazetteer's job.
18
+ *
19
+ * Note the shape collides with New Zealand's (also 4 digits) — `candidateSystemsForPostcode`
20
+ * returns both, and that ambiguity is by design (shape test, not membership test).
21
+ * @see {@link https://auspost.com.au/content/dam/auspost_corp/media/documents/correct-addressing.pdf Australia Post Correct Addressing brochure (Nov 2022)}
22
+ * @see {@link https://auspost.com.au/content/dam/auspost_corp/media/documents/Barcode_hints_tips.pdf Australia Post barcode addressing booklet}
23
+ */
24
+
25
+ import type { Tagged } from "type-fest"
26
+
27
+ /**
28
+ * An Australian postcode: four digits.
29
+ *
30
+ * @category Postal
31
+ * @type string
32
+ * @title Australian postcode
33
+ * @pattern ^\d{4}$
34
+ */
35
+ export type AuPostcode = Tagged<string, "AuPostcode">
36
+
37
+ /** The AU postcode shape: exactly four digits. */
38
+ export const AU_POSTCODE_PATTERN = /^\d{4}$/
39
+
40
+ /** Normalize a postcode surface form (trim only — AU has no country-prefix courtesy form). */
41
+ export function normalizeAuPostcode(raw: unknown): AuPostcode | null {
42
+ if (typeof raw !== "string") return null
43
+ const s = raw.trim()
44
+
45
+ return AU_POSTCODE_PATTERN.test(s) ? (s as AuPostcode) : null
46
+ }
47
+
48
+ /** Type-predicate for a (normalized) Australian postcode. */
49
+ export function isAuPostcode(input: unknown): input is AuPostcode {
50
+ return typeof input === "string" && AU_POSTCODE_PATTERN.test(input)
51
+ }
package/au/state.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Australian states and territories (ISO 3166-2:AU). Australia Post's addressing guidelines put the
7
+ * state abbreviation on the last line between the locality and the 4-digit postcode ("Line 3
8
+ * should contain the locality or suburb, state and postcode and be written in capital letters" —
9
+ * addressing guidelines, accessed 2026-06-11), e.g. `SYDNEY NSW 2000`, `BUNBURY WA 6230`, `EUROA
10
+ * VIC 3664`. The abbreviation set is the ISO 3166-2:AU subdivision codes, which are the same
11
+ * codes Australia Post's own examples use (NSW, VIC, WA, QLD, ACT appear across the addressing
12
+ * guidelines and the barcode booklet).
13
+ * @see {@link https://auspost.com.au/sending/guidelines/addressing-guidelines Australia Post addressing guidelines}
14
+ * @see {@link https://www.iso.org/obp/ui/#iso:code:3166:AU ISO 3166-2:AU}
15
+ */
16
+
17
+ /** State/territory abbreviation → full name (ISO 3166-2:AU subdivision set). */
18
+ export const AU_STATE_ABBREVIATIONS = {
19
+ ACT: "Australian Capital Territory",
20
+ NSW: "New South Wales",
21
+ NT: "Northern Territory",
22
+ QLD: "Queensland",
23
+ SA: "South Australia",
24
+ TAS: "Tasmania",
25
+ VIC: "Victoria",
26
+ WA: "Western Australia",
27
+ } as const satisfies Record<string, string>
28
+
29
+ /** An Australian state/territory abbreviation as written on the last address line. */
30
+ export type AuStateAbbreviation = keyof typeof AU_STATE_ABBREVIATIONS
31
+
32
+ /** Type-predicate for an AU state/territory abbreviation (case-insensitive). */
33
+ export function isAuStateAbbreviation(input: unknown): input is AuStateAbbreviation {
34
+ return typeof input === "string" && Object.hasOwn(AU_STATE_ABBREVIATIONS, input.toUpperCase())
35
+ }
package/ca/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The Canadian address system (Canada Post / ISO 3166-2:CA): bilingual street types, alphanumeric
7
+ * postal codes, and the provinces and territories.
8
+ */
9
+
10
+ export * from "./postal-code.ts"
11
+ export * from "./province.ts"
12
+ export * from "./street-type.ts"