Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4e8c0ac
docs(unspsc): corrected research report (review fixes)
Sep 30, 2026
055e791
docs(unspsc): implementation plan (Momus OKAY)
Sep 30, 2026
510b706
feat(unspsc): scaffold UNSPSC capability skeleton
Sep 30, 2026
d033efa
feat(unspsc): notation facet record (Task 1)
Sep 30, 2026
74dd66b
feat(unspsc): contract + factory flags (Task 2)
Sep 30, 2026
d55bb25
feat(unspsc): codeset snapshot + regen tool (Task 3)
Sep 30, 2026
ddf64eb
fix(unspsc): strict pyright types in regen tool
Sep 30, 2026
a18ffff
fix(unspsc): cast snapshot lists for strict pyright
Sep 30, 2026
32076dd
feat(unspsc): grammar + structure/padding rules (Tasks 4-5)
Sep 30, 2026
f75cf86
feat(unspsc): codeset-membership LOOKUP_TABLE (Task 6)
Sep 30, 2026
6180026
feat(unspsc): BFI-suffix gated PARSER (Task 7)
Sep 30, 2026
085dbc8
feat(unspsc): wiring + format_value seam (Task 8)
Sep 30, 2026
a2b0b18
feat(unspsc): resolution-state integration map (Task 9)
Sep 30, 2026
66d65b7
feat(unspsc): drop segmented, property gates (Task 10)
Sep 30, 2026
4ba3312
fix(unspsc): segmented rejection test after unoffer
Sep 30, 2026
6f694aa
docs(unspsc): registry completeness (Task 11)
Sep 30, 2026
81721d5
fix(unspsc): CLI dispatch + suppress_common_words factory flag
Sep 30, 2026
c9a81f9
test(unspsc): 29th-capability registration completeness (Task 12)
Sep 30, 2026
299bffd
fix(unspsc): oracle + thermo-nuclear review findings (all 5)
Sep 30, 2026
7b13eb6
fix(ci): import sort + UNSPSC regen drift check
Sep 30, 2026
6e23075
fix(unspsc): review findings — counts, left-hyphen guard, test root
Sep 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ jobs:
- name: Check generated Language data drift
run: uv run python tools/regenerate_language_data.py --check

- name: Check generated UNSPSC codeset data drift
run: uv run python tools/regenerate_unspsc_data.py --check

- name: Verify no generated data drift (git diff)
run: git diff --exit-code

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **UNSPSC — Product/service codes:** new capability with one grammar (`unspsc_recognition`: bare 8-digit, zero-padded parents, 6-digit class alias, `UNSPSC`-labelled, `UNSPSC000.` MDM IDs, 10-digit +BFI, ASCII-only, longest-first 10/8/6) and four rules backed by the UNGM UNSPSC Code Structure article (`Section 1-hierarchy-structure` — 6/8/10 lanes, PARSER; `Section 2-level-padding` — `00`-pair lattice, PARSER), the UNDP codeset (UNGM live export 2026-09-30, 13,482 live stems plus 8 synthesized family ancestors (ancestor-walk only); `Section 3-codeset-membership` — stem plus live ancestors, always-active LOOKUP_TABLE; partial — UNDP v26.0801 XLSX gated 403) and the UNECE Classification Guidelines v2.04 (`Section 4-business-function-suffix` — informative-only, PARSER gated by `include_business_function=True`, default on; gating drops the suffix candidate, not the verdict). Canonical form is the 8-digit zero-padded wire stem; offered output formats `labeled` and `native` (both re-enter as fixed points; pair-hyphenated `segmented` display deliberately unoffered — hyphens never re-enter by design). Space/dot/hyphen-grouped, truncated 2/4-digit, and 7/9/11-digit runs → `MISSING`; mid-zero padding, unissued stems, Wikidata-cited-but-unexported codes (e.g. `11101803`) → `INVALID` under the pinned snapshot; two distinct codes in one call → `MultipleMentionsError`.

- **Domain — Hostnames:** new capability with two grammars (`ascii_hostname`: LDH shapes, case-neutral, trailing-dot runs; `idn_hostname`: any script with reserved-ASCII excluded, ASCII overlap dedups by span) and five rules backed by RFC 1034 (`Section-3.1-name-syntax` — ≥2 labels, no empty label, PARSER), RFC 1035 (`Section-2.3.4-label-length` — 63 octets/label, 253 chars/name post-encode, PARSER), UTS #46 (`UTS46-statuses` — statuses with STD3 rules, D13 hyphen checks, ACE round trip, PARSER; shipped table 15.1.0), RFC 5893 (`Section-2-bidi-context` — §2 six conditions plus ContextJ join-control rejection, PARSER), and the IANA Root Zone Database v2026092300 (`root-zone-membership` — encoded-TLD membership ANDed with the other four, LOOKUP_TABLE; no corroboration → `INVALID`). Canonical form is the lowercase ASCII A-label dotted name with no trailing dot; offered output format `unicode` (per-label Punycode decode, re-enters as a fixed point). Single labels (`localhost`) and undelegated TLDs → `INVALID`; containers (`user@example.com`, URLs, `*.example.com`) → `MISSING` (boundary kills); two distinct hostnames in one call → `MultipleMentionsError`.

- **Timezone — Wave-2 first-cut table expansion (#170):** two legacy Links (`Canada/Eastern` → `America/Toronto`, `US/PACIFIC` → `America/Los_Angeles`, both live in tzdb `backward`) and one fixed zone (`Etc/GMT-8`, live in `etcetera`, POSIX sign kept as authored). Previously `MISSING`, now `SUCCESS` under the default contract. Data tables + guide rows only — no grammar-logic, contract, engine, or canonical-form change; the subset contract (unlisted keys → `MISSING`) and the short-caps carve rule are unchanged.
Expand Down
14 changes: 11 additions & 3 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ Capability-defined intermediate representation that Grammars must produce:
- **Timezone:** `TimezoneNotation(key, family, compact)` — `key` is the as-written mention, `family` discriminates `"name"` / `"abbreviation"`, `compact` is the lookup key (rules restore canonical case and resolve Links)
- **UtcOffset:** `UtcOffsetNotation(compact)` — `compact` is the grammar-normalized extended `+HH:MM` form (`Z` → `+00:00`); rules own range validation (`-12:00..+14:00`, `-00:00` refused)
- **UUID:** `UUIDNotation(compact, hyphenated, urn, version)` — `compact` is the 32-hex lowercase form, `hyphenated` the `8-4-4-4-12` canonical, `urn` the `urn:uuid:` carrier, `version` the version nibble (informative, never gates)
- **UNSPSC:** `UNSPSCNotation(digits, level, function, native_length)` — `digits` is the 8-digit zero-padded stem (6-digit class alias padded `+"00"` by the grammar; 10-digit input keeps its 8-digit stem), `level` is `"segment"` / `"family"` / `"class"` / `"commodity"` from trailing-`00` pairs (no authority lookup), `function` the 2-digit BFI suffix or `""`, `native_length` the spelled digit length (6/8/10, retained as the `native` format facet); grammar never validates membership — rules own it
- **Phone / URL:** capability-defined shapes for address / number / URI components

### Notation Type Example
Expand All @@ -78,7 +79,7 @@ class EmailNotation:

## The Capabilities

Paxman ships twenty-eight built-in capabilities (28 in `paxman/capabilities/__init__.py` and `paxman/api/bootstrap.py:_SHIPPED`, alphabetical by registry name), each wired to an authoritative specification:
Paxman ships twenty-nine built-in capabilities (29 in `paxman/capabilities/__init__.py` and `paxman/api/bootstrap.py:_SHIPPED`, alphabetical by registry name), each wired to an authoritative specification:

| Capability | Domain | Authorities |
|------------|--------|-------------|
Expand Down Expand Up @@ -107,6 +108,7 @@ Paxman ships twenty-eight built-in capabilities (28 in `paxman/capabilities/__in
| **Phone** | Phone numbers | ITU-T E.164, RFC 3966, NANP |
| **SI Unit** | SI unit expressions | BIPM SI Brochure, ISO 80000-1 |
| **Timezone** | Timezone identifiers | IANA Time Zone Database |
| **UNSPSC** | Product/service codes | UNDP UNSPSC Code Structure (UNGM), UNDP Codeset (UNGM live export 2026-09-30), UNECE BFI Guidelines v2.04 |
| **URL** | URLs | WHATWG URL Standard |
| **UtcOffset** | UTC offsets | ISO 8601, RFC 3339 |
| **UUID** | UUIDs | IETF RFC 9562 |
Expand Down Expand Up @@ -836,7 +838,7 @@ paxman/
├── __main__.py # python -m paxman entry point
├── api/
│ ├── __init__.py
│ ├── bootstrap.py # _SHIPPED (28 capabilities, alphabetical; paxman/capabilities/__init__.py exports 28), register_all_shipped(), list_shipped_capabilities()
│ ├── bootstrap.py # _SHIPPED (29 capabilities, alphabetical; paxman/capabilities/__init__.py exports 29), register_all_shipped(), list_shipped_capabilities()
│ └── canonicalize.py # Public canonicalize() function → run_capability()
├── shared_data/
│ └── currency_snapshot.json # CLDR v47 + ISO 4217 snapshot → Currency + Money data via tools/regenerate_currency_data.py
Expand Down Expand Up @@ -1048,6 +1050,12 @@ paxman/
│ ├── grammar/data/ # grandfathered_tags, english_names, localized_names, script_names, region_names
│ ├── rules/ # bcp47_rfc5646, iso_639_1/2/3/5, iana_language_subtag_registry, cldr_language_display_name
│ └── rules/data/ # iana_language_subtags, iso_639 tables, english_display_names
├── UNSPSC/ # grammar/ (1) + rules/ (3) + rules/data/ — UNGM structure, UNDP codeset (UNGM live export), UNECE BFI
│ ├── capability.py # UNSPSCCapability
│ ├── contract.py # UNSPSCContract (unspsc/labeled/native output formats; include_business_function)
│ ├── notation.py # UNSPSCNotation (digits, level, function, native_length)
│ ├── grammar/ # unspsc_recognition
│ └── rules/ # undp_unspsc_structure_ed2025, undp_unspsc_codeset_ed2026, unece_bfi_ed2005 (+ data/unspsc_codeset)
└── LEI/ # grammar/ (1) + rules/ (2) — ISO 17442-1:2020, GLEIF LOU prefix list
├── capability.py # LEICapability
├── contract.py # LEIContract
Expand Down Expand Up @@ -1089,7 +1097,7 @@ tests/
│ ├── test_capability_contract.py# CapabilityContract (output_format policy, defaults)
│ ├── test_capability.py # Capability ABC
│ ├── test_capability_surface.py # Surface homogeneity across capabilities
│ ├── test_capability_exports.py # __init__ export completeness (28 capabilities)
│ ├── test_capability_exports.py # __init__ export completeness (29 capabilities)
│ ├── test_version_stamp.py # VersionStamp
│ ├── test_discovery.py # Registry register/freeze/reset
│ ├── test_errors.py # Exception hierarchy
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ If multiple specifications disagree on the canonical value, the status is `AMBIG

## Capabilities

Paxman ships with twenty-eight built-in capabilities (28 in `paxman/capabilities/__init__.py` and `paxman/api/bootstrap.py:_SHIPPED`, alphabetical by registry name):
Paxman ships with twenty-nine built-in capabilities (29 in `paxman/capabilities/__init__.py` and `paxman/api/bootstrap.py:_SHIPPED`, alphabetical by registry name):

| Capability | Domain | Grammars | Rules | Description |
|---|---|---|---|---|
Expand Down Expand Up @@ -95,6 +95,7 @@ Paxman ships with twenty-eight built-in capabilities (28 in `paxman/capabilities
| **Phone** | Phone numbers | 4 (e164, tel_uri, international_00, national) | 5 | ITU-T E.164, RFC 3966, NANP |
| **SI Unit** | SI unit expressions | 3 (symbol, name, compound) | 7 | BIPM SI Brochure, ISO 80000-1 |
| **Timezone** | Timezone identifiers | 2 (timezone_name, timezone_abbreviation) | 4 | IANA Time Zone Database |
| **UNSPSC** | Product/service codes | 1 (unspsc) | 4 | UNDP UNSPSC (UNGM structure, UNGM live-export snapshot, UNECE BFI) |
| **URL** | URLs | 1 (absolute_uri) | 1 | WHATWG URL Standard |
| **UtcOffset** | UTC offsets | 1 (utc_offset) | 1 | ISO 8601-1:2019, RFC 3339 |
| **UUID** | UUIDs | 1 (uuid) | 1 | IETF RFC 9562 |
Expand Down Expand Up @@ -574,7 +575,7 @@ result = paxman.canonicalize("2026-01-15", contract)

## Community Extensions

Paxman ships with twenty-eight built-in capabilities, but a capability is closed for modification yet open for extension: you can add recognition and validation without touching the library. Register a `Grammar` subclass and the `Rule` subclass that validates it, then opt a contract into them by naming the grammar in `extra_grammars`:
Paxman ships with twenty-nine built-in capabilities, but a capability is closed for modification yet open for extension: you can add recognition and validation without touching the library. Register a `Grammar` subclass and the `Rule` subclass that validates it, then opt a contract into them by naming the grammar in `extra_grammars`:

```python
import re
Expand Down
9 changes: 9 additions & 0 deletions benchmarks/baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,15 @@
"p95_ms": 142.99965801183134,
"min_ms": 58.47020004875958,
"max_ms": 155.29442497063428
},
{
"capability": "unspsc",
"iterations": 100,
"mean_ms": 0.11675417306832969,
"p50_ms": 0.10525202378630638,
"p95_ms": 0.16783701721578836,
"min_ms": 0.10288890916854143,
"max_ms": 0.2312560100108385
}
],
"iterations": 100
Expand Down
23 changes: 23 additions & 0 deletions benchmarks/scenarios.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,23 @@ def _isni_contract() -> object:
return ISNI.create_contract()


def _unspsc_register() -> None:
from paxman.capabilities import UNSPSC
from paxman.core.discovery import get_capability, register_capability
from paxman.core.errors import CapabilityError

try:
get_capability("unspsc")
except CapabilityError:
register_capability(UNSPSC())


def _unspsc_contract() -> object:
from paxman.capabilities import UNSPSC

return UNSPSC.create_contract()


def _language_register() -> None:
from paxman.capabilities import Language
from paxman.core.discovery import get_capability, register_capability
Expand Down Expand Up @@ -602,6 +619,12 @@ def _si_text(size: int) -> str:
"register": _isni_register,
"contract_factory": _isni_contract,
},
{
"capability": "unspsc",
"text": "UNSPSC 44103103",
"register": _unspsc_register,
"contract_factory": _unspsc_contract,
},
{
"capability": "issn",
"text": "0317-8471",
Expand Down
5 changes: 3 additions & 2 deletions docs/development/MILESTONE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

> **Purpose:** Roadmap guidance for capabilities to bring into Paxman, ordered by priority.
> This table is a planning aid — details need not be exactly accurate in terms of implementation.
> 28 capabilities are shipped (BIC, ChemicalElement, Coordinates, Country, CreditCard, Currency, Date, DOI, Domain, Email, GTIN, IBAN, IP, ISBN, ISIN, ISNI, ISSN, Language, LEI, MacAddress, Money, ORCID, Phone, SIUnit, Timezone, URL, UtcOffset, UUID) and are listed in §1 only — they are excluded from the future roadmap in §2.
> 29 capabilities are shipped (BIC, ChemicalElement, Coordinates, Country, CreditCard, Currency, Date, DOI, Domain, Email, GTIN, IBAN, IP, ISBN, ISIN, ISNI, ISSN, Language, LEI, MacAddress, Money, ORCID, Phone, SIUnit, Timezone, UNSPSC, URL, UtcOffset, UUID) and are listed in §1 only — they are excluded from the future roadmap in §2.

---

## 1. Delivered Capabilities (28 shipped)
## 1. Delivered Capabilities (29 shipped)

Verified against `paxman/capabilities/__init__.py` + `paxman/api/bootstrap.py:_SHIPPED` (alphabetical bootstrap order) and `paxman/capabilities/<Name>/rules/` provenance constants. Examples were re-checked through `canonicalize()` on 2026-09-22.

Expand Down Expand Up @@ -40,6 +40,7 @@ Verified against `paxman/capabilities/__init__.py` + `paxman/api/bootstrap.py:_S
| 26 | **GTIN** | GS1 General Specifications 26.0 | "590 1234 12345 7" → "05901234123457" | `research/2026-09-22-gtin-canonicalization.md`, `plans/2026-09-22-gtin-capability.md` |
| 27 | **LEI** | ISO 17442-1:2020, GLEIF LOU prefix list | "5493000IBP32UQZ0KL24" → "5493000IBP32UQZ0KL24" | `research/2026-09-22-lei-canonicalization.md`, `plans/2026-09-22-lei-capability.md` |
| 28 | **ISNI** | ISO 27729:2024 (MOD 11-2) | "ISNI 0000 0001 2103 2683" → "0000 0001 2103 2683" | `research/2026-09-26-isni-canonicalization.md`, `plans/2026-09-26-isni-capability.md` |
| 29 | **UNSPSC** | UNDP UNSPSC (UNGM structure, UNGM live-export snapshot, UNECE BFI) | "UNSPSC 44103103" → "44103103" | `research/2026-09-30-unspsc-canonicalization.md`, `plans/2026-09-30-unspsc-capability.md` |

Notes on §1:

Expand Down
Loading
Loading