From dbf6af1c227e1311dbb38f9d512d720dd41486be Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Fri, 9 Oct 2026 08:31:28 +0800 Subject: [PATCH 1/3] feat(minor-planet): add MinorPlanet capability with registration and docs --- AGENTS.md | 4 +- CONTEXT.md | 14 +- README.md | 5 +- .../2026-10-07-minor-planet-capability.md | 142 ++++ ...nor-planet-designation-canonicalization.md | 762 ++++++++++++++++++ docs/user/api-reference.md | 1 + docs/user/capabilities/index.md | 1 + docs/user/capabilities/minor_planet.md | 60 ++ docs/user/citations.md | 21 +- docs/user/concepts/capabilities.md | 1 + docs/user/glossary.md | 2 + docs/user/migration.md | 4 + paxman/api/bootstrap.py | 2 + paxman/capabilities/AGENTS.md | 4 +- paxman/capabilities/MinorPlanet/__init__.py | 9 + paxman/capabilities/MinorPlanet/capability.py | 115 +++ paxman/capabilities/MinorPlanet/contract.py | 40 + .../MinorPlanet/grammar/__init__.py | 1 + .../grammar/minor_planet_recognition.py | 135 ++++ paxman/capabilities/MinorPlanet/notation.py | 26 + .../MinorPlanet/rules/__init__.py | 1 + .../MinorPlanet/rules/mpc_codec.py | 216 +++++ .../MinorPlanet/rules/mpc_numbering.py | 48 ++ .../rules/mpc_packed_designation.py | 78 ++ .../rules/mpc_unpacked_designation.py | 83 ++ paxman/capabilities/__init__.py | 8 + paxman/cli.py | 4 + tests/AGENTS.md | 2 +- tests/capabilities/minor_planet/__init__.py | 1 + .../minor_planet/test_capability.py | 102 +++ .../capabilities/minor_planet/test_grammar.py | 115 +++ .../minor_planet/test_integration.py | 127 +++ .../minor_planet/test_notation.py | 34 + tests/capabilities/minor_planet/test_rules.py | 380 +++++++++ .../property/test_minor_planet_properties.py | 99 +++ .../test_output_format_preservation.py | 14 + tests/property/test_reentry_invariant.py | 5 + tests/unit/test_api_coverage_fix.py | 2 +- tests/unit/test_bootstrap.py | 3 +- tests/unit/test_capability_exports.py | 14 + tests/unit/test_capability_lazy_import.py | 1 + tests/unit/test_capability_surface.py | 8 + .../test_offered_format_class_declarations.py | 2 + tools/generate_readme_table.py | 5 + 44 files changed, 2688 insertions(+), 13 deletions(-) create mode 100644 docs/development/plans/2026-10-07-minor-planet-capability.md create mode 100644 docs/development/research/2026-10-07-minor-planet-designation-canonicalization.md create mode 100644 docs/user/capabilities/minor_planet.md create mode 100644 paxman/capabilities/MinorPlanet/__init__.py create mode 100644 paxman/capabilities/MinorPlanet/capability.py create mode 100644 paxman/capabilities/MinorPlanet/contract.py create mode 100644 paxman/capabilities/MinorPlanet/grammar/__init__.py create mode 100644 paxman/capabilities/MinorPlanet/grammar/minor_planet_recognition.py create mode 100644 paxman/capabilities/MinorPlanet/notation.py create mode 100644 paxman/capabilities/MinorPlanet/rules/__init__.py create mode 100644 paxman/capabilities/MinorPlanet/rules/mpc_codec.py create mode 100644 paxman/capabilities/MinorPlanet/rules/mpc_numbering.py create mode 100644 paxman/capabilities/MinorPlanet/rules/mpc_packed_designation.py create mode 100644 paxman/capabilities/MinorPlanet/rules/mpc_unpacked_designation.py create mode 100644 tests/capabilities/minor_planet/__init__.py create mode 100644 tests/capabilities/minor_planet/test_capability.py create mode 100644 tests/capabilities/minor_planet/test_grammar.py create mode 100644 tests/capabilities/minor_planet/test_integration.py create mode 100644 tests/capabilities/minor_planet/test_notation.py create mode 100644 tests/capabilities/minor_planet/test_rules.py create mode 100644 tests/property/test_minor_planet_properties.py diff --git a/AGENTS.md b/AGENTS.md index 09f4779e..afaf246e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ **Branch:** chore/v0.2.0-release ## OVERVIEW -Paxman is a Python 3.11+ canonicalization library with a small CLI: takes ambiguous human input, returns what authoritative specs say it means, with full provenance. Deterministic, provenance-first. 28 capabilities (BIC, ChemicalElement, Coordinates, Country, CreditCard, Currency, Date, DOI, Domain, Email, GTIN, IBAN, IP, ISBN, ISIN, ISNI, ISSN, Language, LEI, MacAddress, Money, ORCID, Phone, SI Unit, Timezone, URL, UtcOffset, UUID) — recognition via the Recognition Kernel (ADR-0009) with legacy pipeline stages retained for unmigrated grammars. Toolchain: uv + hatchling, ruff, strict pyright, import-linter, pytest at 95% coverage. +Paxman is a Python 3.11+ canonicalization library with a small CLI: takes ambiguous human input, returns what authoritative specs say it means, with full provenance. Deterministic, provenance-first. 30 capabilities (BIC, ChemicalElement, Coordinates, Country, CreditCard, Currency, Date, DOI, Domain, Email, GTIN, IBAN, IP, ISBN, ISIN, ISNI, ISSN, Language, LEI, MacAddress, MinorPlanet, Money, ORCID, Phone, SI Unit, Timezone, UNSPSC, URL, UtcOffset, UUID) — recognition via the Recognition Kernel (ADR-0009) with legacy pipeline stages retained for unmigrated grammars. Toolchain: uv + hatchling, ruff, strict pyright, import-linter, pytest at 95% coverage. ## STRUCTURE ```text @@ -47,7 +47,7 @@ docs/ # adr/, development/, recipes/, user/ | Symbol | Type | Location | Role | |--------|------|----------|------| | `canonicalize()` | function | `paxman/api/canonicalize.py` | Sole user entry point → `run_capability()` | -| `register_all_shipped()` / `list_shipped_capabilities()` | functions | `paxman/api/bootstrap.py` | One-call registration of the 28 shipped capabilities; deterministic name list | +| `register_all_shipped()` / `list_shipped_capabilities()` | functions | `paxman/api/bootstrap.py` | One-call registration of the 30 shipped capabilities; deterministic name list | | `list_registered_capabilities()` | function | `paxman/core/discovery.py` | Introspection of the live registry | | `register_capability()` | function | `paxman/core/discovery.py` | Registry add; freezes on first run | | `register_grammar()` / `register_rule()` | functions | `paxman/core/extensions.py` | Community extension seam (opt-in via contract `extra_grammars`) | diff --git a/CONTEXT.md b/CONTEXT.md index b741a58a..c8461e70 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -61,6 +61,7 @@ Capability-defined intermediate representation that Grammars must produce: - **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 +- **MinorPlanet:** `MinorPlanetNotation(designation, form, packed)` — `designation` is the syntax-normalized spelled form (unpacked lanes upper/single-space, packed lanes byte-for-byte), `form` the lane id (`provisional` | `packed` | `extended` | `survey` | `survey_packed` | `number` | `packed_number`), `packed` the spelled packed form for packed lanes else `""`; grammar never decodes — rules own coherence via `mpc_codec` - **Phone / URL:** capability-defined shapes for address / number / URI components ### Notation Type Example @@ -79,7 +80,7 @@ class EmailNotation: ## The Capabilities -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: +Paxman ships thirty built-in capabilities (30 in `paxman/capabilities/__init__.py` and `paxman/api/bootstrap.py:_SHIPPED`, alphabetical by registry name), each wired to an authoritative specification: | Capability | Domain | Authorities | |------------|--------|-------------| @@ -103,6 +104,7 @@ Paxman ships twenty-nine built-in capabilities (29 in `paxman/capabilities/__ini | **Language** | Language identifiers | ISO 639-1:2002, ISO 639-2:1998, ISO 639-3:2007, ISO 639-5:2008, BCP 47 RFC 5646, IANA Language Subtag Registry (File-Date 2026-08-08), CLDR (localized, gated) | | **LEI** | Legal entity identifiers | ISO 17442-1:2020, GLEIF LOU prefix list | | **MacAddress** | MAC addresses | IEEE Std 802-2024 | +| **MinorPlanet** | Minor-planet designations | MPC DesDoc, MPC PackedDes, MPC HowNamed | | **Money** | Money amounts | ISO 4217, CLDR | | **ORCID** | Researcher identifiers | ISO 27729:2024, MOD 11-2 | | **Phone** | Phone numbers | ITU-T E.164, RFC 3966, NANP | @@ -838,7 +840,7 @@ paxman/ ├── __main__.py # python -m paxman entry point ├── api/ │ ├── __init__.py -│ ├── bootstrap.py # _SHIPPED (29 capabilities, alphabetical; paxman/capabilities/__init__.py exports 29), register_all_shipped(), list_shipped_capabilities() +│ ├── bootstrap.py # _SHIPPED (30 capabilities, alphabetical; paxman/capabilities/__init__.py exports 30), 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 @@ -1056,6 +1058,12 @@ paxman/ │ ├── 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) + ├── MinorPlanet/ # grammar/ (1) + rules/ (4: 3 publications + codec) — MPC DesDoc, PackedDes, HowNamed + │ ├── capability.py # MinorPlanetCapability + │ ├── contract.py # MinorPlanetContract (designation/packed output formats) + │ ├── notation.py # MinorPlanetNotation (designation, form, packed) + │ ├── grammar/ # minor_planet_recognition + │ └── rules/ # mpc_unpacked_designation, mpc_packed_designation, mpc_numbering (+ mpc_codec) └── LEI/ # grammar/ (1) + rules/ (2) — ISO 17442-1:2020, GLEIF LOU prefix list ├── capability.py # LEICapability ├── contract.py # LEIContract @@ -1097,7 +1105,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 (29 capabilities) +│ ├── test_capability_exports.py # __init__ export completeness (30 capabilities) │ ├── test_version_stamp.py # VersionStamp │ ├── test_discovery.py # Registry register/freeze/reset │ ├── test_errors.py # Exception hierarchy diff --git a/README.md b/README.md index 7fc91bab..128dd57a 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ If multiple specifications disagree on the canonical value, the status is `AMBIG ## Capabilities -Paxman ships with twenty-nine built-in capabilities (29 in `paxman/capabilities/__init__.py` and `paxman/api/bootstrap.py:_SHIPPED`, alphabetical by registry name): +Paxman ships with thirty built-in capabilities (30 in `paxman/capabilities/__init__.py` and `paxman/api/bootstrap.py:_SHIPPED`, alphabetical by registry name): | Capability | Domain | Grammars | Rules | Description | |---|---|---|---|---| @@ -90,6 +90,7 @@ Paxman ships with twenty-nine built-in capabilities (29 in `paxman/capabilities/ | **Language** | Language identifiers | 4 (bcp47_tag, language_code, language_name, language_description) | 11 | ISO 639, IANA Language Subtag Registry, BCP 47 RFC 5646, CLDR | | **LEI** | Legal entity identifiers | 1 (lei) | 2 | ISO 17442-1:2020, GLEIF LOU prefix list | | **MacAddress** | MAC addresses | 1 (mac_address) | 1 | IEEE Std 802-2024 | +| **MinorPlanet** | Minor-planet designations | 1 (minor_planet) | 5 | MPC DesDoc, MPC PackedDes, MPC HowNamed | | **Money** | Money amounts | 3 (code, symbol, word) | 3 | ISO 4217, CLDR | | **ORCID** | Researcher identifiers | 1 (orcid) | 2 | ISO 27729:2024, MOD 11-2 | | **Phone** | Phone numbers | 4 (e164, tel_uri, international_00, national) | 5 | ITU-T E.164, RFC 3966, NANP | @@ -575,7 +576,7 @@ result = paxman.canonicalize("2026-01-15", contract) ## Community Extensions -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`: +Paxman ships with thirty 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 diff --git a/docs/development/plans/2026-10-07-minor-planet-capability.md b/docs/development/plans/2026-10-07-minor-planet-capability.md new file mode 100644 index 00000000..457280e4 --- /dev/null +++ b/docs/development/plans/2026-10-07-minor-planet-capability.md @@ -0,0 +1,142 @@ +# MinorPlanet Capability Implementation Plan (research-driven — no issue) + +> **For workers:** Execute task-by-task via `tdd` + `verification-before-completion`. Review gate: `paxman-momus-review` (plan), `paxman-oracle-review` (after impl). + +**Goal:** Ship a `minor_planet` capability that recognizes unpacked/packed/survey/numbered minor-planet lanes and validates them structure-only (PARSER, no registry) to the unpacked canonical (`1995 XA`, `2040 P-L`, `(433)`), with `packed` as the offered wire encoding. + +**Architecture:** Single `minor_planet_recognition` grammar (lane alternation, one dedup key) + five PARSER rules across three publications (unpacked, packed, numbering) + `MinorPlanetContract` (`designation` default / `packed` offered) + `format_value` seam. Pack/unpack codec lives in rules-owned shared module imported by rules and capability, never the grammar. No registry at v1; valid-vs-issued collapses honestly with a named MPCORB upgrade path. + +**Tech Stack:** Python 3.11+, uv, ruff, strict pyright, import-linter, pytest (markers: unit/capability/integration/property), `paxman/core/grammar` kernel (`RegexMatcher` + `BoundarySpec.WORD` preferred; legacy `PipelineGrammar` + `RegexStage` accepted with note), `paxman/capabilities/MinorPlanet`. + +**References:** `docs/development/research/2026-10-07-minor-planet-designation-canonicalization.md` §§2–13 (fixed 2026-10-07: compilable pattern, `A/` 1-2 letters, spaced-lowercase lanes, phone/ZIP overclaims documented, decode in rules/capability), `HOW_TO_ADD_NEW_CAPABILITY.md` Steps 0–7, `paxman/core/domain.py:243-289` (Rule metadata), `paxman/core/domain.py:291-322` (Grammar metadata), `paxman/engine/orchestrator.py:750-789` (ADR-0012 vacuity), `paxman/engine/orchestrator.py:625-684` (single-value), `paxman/engine/orchestrator.py:882-893` (MISSING/INVALID), `paxman/capabilities/ISSN/capability.py:47-56` (shipped `create_contract` block), `paxman/capabilities/ISSN/contract.py:10-33` (contract shape + ADR-0011 docstring), `paxman/capabilities/GTIN/notation.py:9-22` (notation facet precedent), `docs/adr/0012-candidate-qualification.md:67-75` (vacuity), `docs/adr/0011-output-format-information-preservation.md` (encoding class), `paxman/capabilities/UNSPSC/grammar/unspsc_recognition.py:20-36` (staged-pipeline pattern precedent). + +**Branch:** `feature/minor-planet-capability` (cut from current `feature/unspsc-capability` HEAD; rebase as needed) + +--- + +## File Structure + +- Create via scaffolder then fill: `paxman/capabilities/MinorPlanet/__init__.py`, `paxman/capabilities/MinorPlanet/notation.py`, `paxman/capabilities/MinorPlanet/contract.py`, `paxman/capabilities/MinorPlanet/capability.py`, `paxman/capabilities/MinorPlanet/grammar/__init__.py`, `paxman/capabilities/MinorPlanet/grammar/minor_planet_recognition.py`, `paxman/capabilities/MinorPlanet/rules/__init__.py`, `paxman/capabilities/MinorPlanet/rules/mpc_unpacked_designation.py`, `paxman/capabilities/MinorPlanet/rules/mpc_packed_designation.py`, `paxman/capabilities/MinorPlanet/rules/mpc_numbering.py`, `paxman/capabilities/MinorPlanet/rules/mpc_codec.py` +- Create tests: `tests/capabilities/minor_planet/__init__.py`, `tests/capabilities/minor_planet/test_notation.py`, `tests/capabilities/minor_planet/test_grammar.py`, `tests/capabilities/minor_planet/test_rules.py`, `tests/capabilities/minor_planet/test_capability.py`, `tests/capabilities/minor_planet/test_integration.py` (new; pipeline + determinism vectors) +- Modify (registration + surface): `paxman/capabilities/__init__.py` (`_LAZY` + `TYPE_CHECKING` + `__all__`), `paxman/api/bootstrap.py` (`_SHIPPED` + contract factory branch), `paxman/cli.py` (capability branch), `tools/generate_readme_table.py` (if capability-indexed), `tests/unit/test_capability_surface.py` (`_CAPABILITY_SURFACES` entry, via scaffolder) +- Modify (docs sweep per `tools/new_capability.py` output): `docs/user/capabilities/minor_planet.md` (create), `docs/user/capabilities/index.md`, `docs/user/concepts/capabilities.md`, `docs/user/api-reference.md`, `docs/user/citations.md`, `docs/user/glossary.md`, `docs/user/migration.md`, `README.md` tables, `CONTEXT.md` Notation/table entries, root `AGENTS.md` counts (27 → 28 shipped; verify current number at implementation) +- No `paxman/core` change. No `rules/data/` at v1 (no registry). + +Type lock (used identically in every task below): `MinorPlanetNotation(designation: str, form: str, packed: str)` with `packed` = spelled packed form for packed lanes else `""`; `MinorPlanetContract` with `DEFAULT_OUTPUT_FORMAT = "designation"`, `OFFERED_OUTPUT_FORMATS = frozenset({"packed"})`, `capability_name = "minor_planet"`, shipped common block (`excluded_rules`, `pinned_rules`, `year`, `output_format`, `extra_grammars`, `suppress_common_words`). + +--- + +### Task 1: Scaffold — generate the skeleton + +**Files:** scaffolder output listed above (`paxman/capabilities/MinorPlanet/*`, `tests/capabilities/minor_planet/*`) + +**Goal:** Green skeleton with unanimous surface before any domain fill. + +- [ ] Run `uv run python tools/new_capability.py MinorPlanet --name minor_planet --authority "Minor Planet Center" --spec-name "Unpacked provisional designation definition" --spec-url "https://minorplanetcenter.net/iau/info/DesDoc.html" --publication-year 2026 --spec-version "living document (fetched 2026-10-07)" --default-format designation` → 13 files + `paxman/capabilities/__init__.py` wiring. Verify: `uv run pytest tests/capabilities/minor_planet -q` → PASS (scaffold stubs). Commit: `feat(minor_planet): scaffold capability skeleton`. + +Depends on: nothing (first). + +### Task 2: Notation — frozen+slots facets + +**Files:** `paxman/capabilities/MinorPlanet/notation.py`, `tests/capabilities/minor_planet/test_notation.py` + +**Goal:** `MinorPlanetNotation` with `designation` (syntax-normalized spelled form), `form` (one of `provisional` | `packed` | `extended` | `survey` | `survey_packed` | `number` | `packed_number`), `packed` (spelled packed for packed lanes else `""`). + +- [ ] Failing test first: `test_frozen_slots_hash` + `test_packed_empty_for_unpacked_lane` in `tests/capabilities/minor_planet/test_notation.py` → FAIL. Implement `@dataclass(frozen=True, slots=True)` with three `str` fields and docstring stating the `packed` lane rule. Verify: `uv run pytest tests/capabilities/minor_planet/test_notation.py -v` → PASS. Commit: `feat(minor_planet): shape notation facets`. + +### Task 3: Codec — rules-owned pack/unpack tables (never grammar) + +**Files:** `paxman/capabilities/MinorPlanet/rules/mpc_codec.py` (create; pure functions + tables), `tests/capabilities/minor_planet/test_rules.py` (codec vectors) + +**Goal:** Single source of truth for century `IJK`=18/19/20, cycle letter values A=10…Z=35/a=36…z=61, base-62 decode, tilde minus-620000, survey-pack codes, extended-`_` shape. Grammar never imports this module (governance gate). + +- [ ] Failing tests: `test_tilde_vectors` (`~0000`→620000, `~000z`→620061, `~AZaz`→3140113, `~zzzz`→15396335), `test_cycle_letter_values` (`J`=19→`J3`=193, `f`=41→`f8`=418), `test_pack_unpack_roundtrip` (`J95X00A`↔`1995 XA`, `K07Tf8A`↔`2007 TA418`, `K99AJ3Z`↔`2099 AZ193`, `03202`↔`(3202)`, `A0345`↔`(100345)`, `a0017`↔`(360017)`) → FAIL. Implement `mpc_unpack(spelled) -> str | None` (case-exact, `None` on incoherent) and `mpc_pack(designation) -> str | None` (`None` for the `A/` lane) with no imports from grammar. Verify: `uv run pytest tests/capabilities/minor_planet/test_rules.py -k "tilde or codec or roundtrip" -v` → PASS. Commit: `feat(minor_planet): add rules-owned pack/unpack codec`. + +### Task 4: Contract — shipped common block + ADR-0011 class + +**Files:** `paxman/capabilities/MinorPlanet/contract.py`, `tests/capabilities/minor_planet/test_capability.py` (contract factory asserts) + +**Goal:** `MinorPlanetContract(CapabilityContract)` with `designation` default, `packed` offered, shipped `create_contract` signature from `paxman/capabilities/ISSN/capability.py:47-56`, ADR-0011 class paragraph naming `packed` as an encoding in the same docstring paragraph. + +- [ ] Failing tests: `test_default_is_designation`, `test_packed_offered_reenters`, `test_unknown_format_raises_ContractError` → FAIL. Implement contract per the type lock + `tests/unit/test_offered_format_class_declarations.py` paragraph rule. Verify: `uv run pytest tests/capabilities/minor_planet/test_capability.py -k contract -v` → PASS. Commit: `feat(minor_planet): contract with packed encoding`. + +### Task 5: Grammar — single lane-alternation recognizer + +**Files:** `paxman/capabilities/MinorPlanet/grammar/minor_planet_recognition.py`, `tests/capabilities/minor_planet/test_grammar.py` + +**Goal:** `minor_planet_recognition` / `minor_planet_recognition` semantics, `single_value=True`, kernel `RegexMatcher` + `BoundarySpec.WORD` spelling of the research §4.2 pattern as fixed (distinct `a*` groups for the `A/` 1-2-letter lane, `_NUMBER` `\d{1,8}`, ASCII without `IGNORECASE`); fallback to legacy `PipelineGrammar` + `RegexStage` only with a code comment if the kernel API blocks exact-case lanes. + +- [ ] Failing tests first: positive vector per §2.1 RECOGNIZE row (`1995 XA`, `2007 TA418`, `2003 cp20` fold, `1995_XA`, `A904 OA`, `A/2017 U1`, `J95X00A`, `K07Tf8A`, `_QC0000`, `2040 P-L`/`PLS2040`, `3138 T-1`/`T1S3138`, `(433)`, `(274301)`, `(15396335)`, `03202`, `A0345`, `a0017`, `~000z`, `~AZaz`, `(433) Eros` number-span); negatives (`1995XA` glued, `433` bare, `1892 A` old-style, `C/1995 O1` comet, `Eros` name, `1995 SA₁` subscript, 3-/5-digit years, 6-char packs, X-glued runs); compile-regression test asserting `re.compile` succeeds (guards the duplicate-group failure); span invariants (`raw_text == text[start:end]`, parens inside span, name outside span). Document `(555)`-in-phone and bare-`\d{5}`-as-ZIP as known v1 overclaims in test comments, not guard negatives. Run `uv run pytest tests/capabilities/minor_planet/test_grammar.py -v` → FAIL then PASS after implement. Commit: `feat(minor_planet): lane-alternation grammar`. + +Depends on: Task 2 Notation. + +### Task 6: Rules — unpacked structure + surveys (Sections 1–2) + +**Files:** `paxman/capabilities/MinorPlanet/rules/mpc_unpacked_designation.py`, `tests/capabilities/minor_planet/test_rules.py` + +**Goal:** `Section1UnpackedProvisionalStructure` + `Section2SurveyDesignation`, PARSER, `target_semantics=frozenset({"minor_planet_recognition"})`, `requires_features=frozenset()`, DesDoc `PUBLICATION`, full-conjunction `matches()` (lane gate on `notation.form` + year lane + separator + half-month `[A-HJ-Y]` + second `[A-HJ-Z]` + cycle; survey number + identifier). + +- [ ] Failing tests: valid (`1995 XA`, `2007 TA418`, `A904 OA`, `A/2017 U1`, `1995 XZ` second-letter-Z valid), invalid (`1995 XI`, `1995 IZ`, bad survey identifier), `normalize()` exact unpacked canonical, provenance attrs, `target_semantics` exact set → FAIL then PASS. Verify: `uv run pytest tests/capabilities/minor_planet/test_rules.py -k "Section1 or Section2" -v` → PASS. Commit: `feat(minor_planet): unpacked + survey rules`. + +Depends on: Tasks 2–3. + +### Task 7: Rules — packed coherence + tilde (Sections 3–4) + +**Files:** `paxman/capabilities/MinorPlanet/rules/mpc_packed_designation.py`, `tests/capabilities/minor_planet/test_rules.py` + +**Goal:** `Section3PackedProvisional` + `Section4PackedNumber`, PARSER, PackedDes `PUBLICATION`, full-conjunction `matches()` via `mpc_codec` (century ∈ IJK, half-month set, cycle letter×10+digit, extended shape, tilde arithmetic; packed-number case preserved with `a0017`→360017 vs `A0345`→100345 trap pair). + +- [ ] Failing tests: valid packed vectors from Task 3 plus invalid (`Q95X00A` bad century, `J95I00A` bad half-month), extended shape vectors, case-trap pair asserting distinct normalizations → FAIL then PASS. Verify: `uv run pytest tests/capabilities/minor_planet/test_rules.py -k "Section3 or Section4" -v` → PASS. Commit: `feat(minor_planet): packed coherence rules`. + +Depends on: Tasks 2–3. + +### Task 8: Rules — permanent numbers (Section 5) + +**Files:** `paxman/capabilities/MinorPlanet/rules/mpc_numbering.py`, `tests/capabilities/minor_planet/test_rules.py` + +**Goal:** `Section5PermanentNumber`, PARSER, HowNamed `PUBLICATION`, `^[1-9]\d{0,7}$` on paren contents (no leading zeros, tilde-max headroom). + +- [ ] Failing tests: `(433)`, `(274301)`, `(15396335)` valid; `(0433)`, `(0)` invalid; `(433) Eros` validates on the number lane (name outside notation) → FAIL then PASS. Verify: `uv run pytest tests/capabilities/minor_planet/test_rules.py -k Section5 -v` → PASS. Commit: `feat(minor_planet): permanent-number rule`. + +Depends on: Task 2. + +### Task 9: Capability — wiring + `format_value` seam + +**Files:** `paxman/capabilities/MinorPlanet/capability.py`, `tests/capabilities/minor_planet/test_capability.py` + +**Goal:** `get_grammars()` 1 grammar, `get_rules()` 5 rules, shipped `create_contract()` delegating to Task 4, `format_value()` identity for `designation` and `mpc_pack` render for `packed` (imports codec from `rules/mpc_codec.py`, never grammar tables), `version = "1.0.0"`. + +- [ ] Failing tests: wiring counts, grammar/rule name conventions, `format_value` round-trips (`1995 XA`→`J95X00A`→re-parse→`1995 XA`; lowercase packed lanes case-exact; `A/`-lane `packed` falls back to designation with comment) → FAIL then PASS. Verify: `uv run pytest tests/capabilities/minor_planet/test_capability.py -v` → PASS. Commit: `feat(minor_planet): capability wiring and packed seam`. + +Depends on: Tasks 2–8. + +### Task 10: Registration + cross-layer consistency + purity + +**Files:** `paxman/capabilities/__init__.py`, `paxman/api/bootstrap.py`, `paxman/cli.py`, `tests/unit/test_capability_surface.py`, new consistency asserts in `tests/capabilities/minor_planet/test_capability.py` + +**Goal:** Capability resolvable via `register_all_shipped` and CLI; every rule `target_semantics` produced by the shipped grammar; no `output_format` token in `paxman/capabilities/MinorPlanet/rules/`; grammar imports nothing from `rules/`. + +- [ ] Failing tests: `test_target_semantics_covered`, purity scan (`rg -n output_format paxman/capabilities/MinorPlanet/rules/` → 0 hits), `test_surface_covers_minor_planet` → FAIL then PASS after wiring. Verify: `uv run pytest tests/capabilities/minor_planet/test_capability.py tests/unit/test_capability_surface.py -q` → PASS. Commit: `feat(minor_planet): register and lock consistency`. + +Depends on: Task 9. + +### Task 11: Pipeline + property + docs sweep + gates + +**Files:** `tests/capabilities/minor_planet/test_integration.py`, property tests (extend or add `tests/property/test_minor_planet*.py` per repo layout), docs sweep paths in File Structure + +**Goal:** End-to-end SUCCESS/INVALID/MISSING/AMBIGUOUS per research §§8–9; `_clean_registry` fixture; determinism + `VersionStamp`; `packed`→re-parse preservation-matrix entry (`CLASS_MAP` + injectivity pair in `tests/property/test_output_format_preservation.py`); hypothesis lane-directed generation, round-trips, never-raise on random alphanum, case-fold stability; docs sweep; full gates green. + +- [ ] Failing integration tests first: SUCCESS all lanes incl. packed+unpacked same-value dedup (`J95X00A = 1995 XA` → one value); INVALID (bad letter slots, bad century, leading-zero number); MISSING (glued/bare/old-style/comet/name/subscript); AMBIGUOUS/`MultipleMentionsError` (equation headings, two distinct); overclaims asserted as documented SUCCESS with registry-upgrade comments. Property tests per research §12. Docs sweep per `tools/new_capability.py` step 5. Final verify: `uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run import-linter lint && uv run pytest -q` → all green, coverage ≥95. Commit: `feat(minor_planet): pipeline, properties, docs sweep`. + +Depends on: Tasks 1–10. + +--- + +## Self-Review (done before saving) + +- Spec coverage: every §2.1 RECOGNIZE row has a Task 5 positive vector; every REJECT/DEFER row has a negative; §§5/7 rule map matches Tasks 6–8; §6 contract/capability matches Tasks 4/9; §8/9 resolution map matches Task 11; §12 vectors distributed across Tasks 5–8/11. +- Placeholder scan: clean per `rg` (no placeholder tokens remain in task text). +- Type consistency: one Notation shape, one Contract shape, one codec owner, repeated verbatim in File Structure + Tasks 2–4/9. +- Momus dry-run: branch set, references carry paths:lines, each task names files + test names + `uv run` verify + commit, QA per task. Ready for `paxman-momus-review`. + +Plan saved. Execute task-by-task via `tdd` (failing test first). After impl, run `paxman-momus-review` on this plan file, then `paxman-oracle-review` on the branch diff before PR handoff. diff --git a/docs/development/research/2026-10-07-minor-planet-designation-canonicalization.md b/docs/development/research/2026-10-07-minor-planet-designation-canonicalization.md new file mode 100644 index 00000000..f1fa8e1e --- /dev/null +++ b/docs/development/research/2026-10-07-minor-planet-designation-canonicalization.md @@ -0,0 +1,762 @@ +# Minor Planet Designation Canonicalization Research — paxman-python + +**Date:** 2026-10-07 +**Scope:** Primary-source survey of the IAU Minor Planet Center designation system (unpacked provisional designations, packed designations incl. the extended LSST-era scheme, Palomar-Leiden/Trojan survey designations, permanent numbers, and the naming process), ecosystem parsing practices, and Paxman's grammar/rule/provenance architecture, to ground the design of a future `MinorPlanet` capability. No source code, tests, or configuration were modified. +**Evidence basis:** MPC "New- And Old-Style Minor Planet Designations" (DesDoc, fetched 2026-10-07), MPC "Packed Provisional and Permanent Designations" (PackedDes, fetched 2026-10-07), MPC "How Are Minor Planets Named?" (HowNamed), MPC provisional-designation definition + cometary-designation system + dual-status objects ops-docs pages, MPC designation-identifier API docs, MPC 80-column optical-observation format (OpticalObs), MPC NEO Confirmation Page (live examples), IAU ADES `submit.xsd` (`PermIDType`/`BaseProvIDType`/`OldProvIDType`, fetched 2026-10-07), WGSBN naming rules (snippet-sourced), JPL SBDB API + Horizons Lookup API accepted-input docs, `sbpy.data.Names` parser/packer (reference-quality), `astroquery.mpc` target parser, `rlseaman/MPC_designations` multi-language library + SPECIFICATION.md, `jorbit.utils.mpc`, Project Pluto find_orb pack/unpack + `packed.htm` + `pack.txt` proposal, Rubin/LSST PPDB schema (fields, no validator), Wikidata Property P5736 (mandatory regex `[A-Z0-9][A-Z0-9/ ()-]{0,11}`, fetched 2026-10-07), python-stdnum absence survey, validator.js absence survey, Wikipedia provisional/minor-planet-designation pages (secondary, flagged where sole source), and shipped Paxman capabilities (ISBN, ISSN, GTIN, Country, ISIN) as architectural precedents. Repo state: `feature/unspsc-capability` @ `6e23075` — engine owns per-grammar containment dedup, total recognition ordering, and `Capability.format_value()` presentational seam. +**Conventions grounding this report:** HOW_TO_ADD_NEW_CAPABILITY.md, HOW_TO_ADD_NEW_GRAMMAR.md, ARCHITECTURE.md, and the ISSN research precedent `docs/development/research/2026-08-21-issn-canonicalization.md` plus the IBAN precedent `docs/development/research/2026-08-22-iban-canonicalization.md` and the BIC precedent `docs/development/research/2026-08-23-bic-canonicalization.md`. + +> Note on scope: "astronomy and astrophysics" admits many identifiers (constellation abbreviations, comet designations, Messier/NGC numbers, spectral types). This report covers **minor-planet designations only** (provisional + packed + survey + permanent numbers), the richest strict-grammar surface with a single stable authority (MPC/IAU). Comets, natural satellites, and bare names are surveyed as explicit DEFER/REJECT dispositions, not silent gaps. Registry name: `minor_planet`. Package name: `MinorPlanet`. Export alias: `MinorPlanet`. + +--- + +## Executive Summary + +Minor-planet designations are a strong fit for a Paxman capability: they have an unambiguous canonical form (**unpacked designation, uppercase, single-space**: `YYYY LL[n]` provisionals such as `1995 XA` / `2007 TA418`, `NNNN P-L` / `NNNN T-n` surveys such as `2040 P-L`, parenthesized permanent numbers such as `(433)`), a stable single-authority standard (**IAU Minor Planet Center**, hosted by the Center for Astrophysics | Harvard & Smithsonian and funded by NASA, current system in force since **1925**, packed forms documented without version break), a machine-compact wire encoding (**7-character packed provisionals** `J95X00A`, 5-character packed numbers `03202`/`A0345`/`a0017`/`~000z`, survey packs `PLS2040`, LSST-era extended `_`-prefixed base-62 packs), and a well-understood human-readable presentation (**unpacked with single space on the wire; lowercase and underscore tolerated from resolver/API lanes; packed case is significant** — `a0017` ≠ `A0345`). The domain mirrors Paxman's value proposition for ISBN and UNSPSC: recognizing tolerant human surface (case/underscore/packed variants), validating strictly against authority letter-sets and positional decode coherence, returning one canonical unpacked value with provenance. Minor-planet designations have **no checksum** — validity is pure syntax (year lane + half-month/second letter sets + cycle coherence + packed positional decode), exactly like BIC (structure is all there is) and like UUID (structure-only v1 with no registry). + +Key findings that shape the design: + +1. **Canonical form is the unpacked designation, uppercased, single-spaced** (provisional `1995 XA`; survey `2040 P-L`; number `(433)` with parentheses). The packed form (`J95X00A`) is the same-entity wire encoding, not a distinct identity — exactly the ISBN compact/hyphenated pair, and the offered `packed` format re-enters through the packed branch. Parentheses are load-bearing for numbers: bare `433` is unclaimable in free text (parens required), while JPL's bare-number search lane is resolution, not grammar. +2. **One grammar suffices, with lane-aware alternation.** Unpacked provisional (+ `A8xx`/`A9xx` retrospective lane, `A/`-prefixed lane, underscore lane), packed 7-char (case-sensitive), extended `_` base-62 (case-sensitive), survey unpacked + packed, parenthesized numbers, and packed numbers are disjoint shapes inside one alternation — no cross-grammar containment, no spurious `AMBIGUOUS` (longer-wins is per-grammar only, `orchestrator:_dedup_spans`). Comets get a future second grammar with a distinct semantics id (disjoint namespace), never a coalesced one. +3. **Validation is structure-only at v1, no checksum, no registry.** Level 1: unpacked shape (year lane `A[89]\d{2}|19\d{2}|20\d{2}`, single space/underscore, half-month `[A-HJ-Y]` with I omitted and Z unused, second letter `[A-HJ-Z]` with I omitted, optional cycle digits). Level 2: packed decode coherence (century `IJK` = 18/19/20, cycle letter value A=10…Z=35/a=36…z=61 ×10 + units, tilde base-62 number−620000 arithmetic, survey-pack codes). Level 3: permanent-number shape (`(433)`: positive integer, no leading zeros). All PARSER, always-active; the ADR-0012 vacuity exception covers PARSER-without-LOOKUP exactly as it does ISSN's check-digit rule. "Issued" (actually assigned by MPC) is unknowable without a registry, so v1 collapses valid-vs-issued honestly — a future MPCORB-snapshot LOOKUP_TABLE is the named upgrade path, not silent overclaim. +4. **Case is lane-relative, not global.** Unpacked input folds to uppercase (`2003 cp20` → `2003 CP20`; glued `2003cp20` without separator stays MISSING — JPL resolution liberality, not grammar); packed case is significant and must never be folded (`a0017` = 360017 vs `A0345` = 100345; extended-scheme lowercase carries cycle magnitude). The pattern therefore compiles **without** `re.IGNORECASE`: unpacked classes are explicit `[A-Za-z]`, packed classes are exact-case. One flag decision, enforced by construction. +5. **Provenance is cleanly split** per HOW_TO_ADD_NEW_CAPABILITY.md Step 5 (one file per publication, one `PUBLICATION: Provenance` constant, one `Rule` class per section): MPC unpacked-designation definition (DesDoc) owns provisional structure + survey forms; MPC packed-designation spec (PackedDes) owns packed/extended/tilde decode coherence; MPC naming/numbering process (HowNamed) owns the permanent-number rule. NOT an ISO standard — there is no ISO catalogue entry to cite, and ADES (IAU XML schema with normative `provID` regexes) is corroborating ecosystem evidence, not a rule publication at v1. + +Recommended file layout, rule set, notation, and contract are specified in §6, §10, §11. Open decisions and recommendations are in §13. + +--- + +## 1. Target User + +| Persona | Why they need minor-planet canonicalization | Typical context | +|---------|---------------------------------------------|-----------------| +| **Survey alert-broker engineers** | Normalize `2003 cp20` vs `2003 CP20` vs `K03C20P` to one key for cross-match joins across ZTF/LSST/MPC feeds | Rubin/LSST, ZTF, Pan-STARRS alert pipelines; NEOCP ingestion | +| **Orbit-computation / data engineers** | Roll packed MPCORB columns, unpacked MPEC strings, and API search strings up to one canonical designation with span-bearing provenance | find_orb/digest2-adjacent ETL, MPCORB snapshot joins, observation-archive dedup | +| **Observation planners / follow-up tools** | Validate user-supplied designations at form ingest; reject structurally invalid vs unresolvable input with `MISSING`/`INVALID` semantics | NEO follow-up report forms, telescope target lists, MPChecker-style UX | +| **Catalog / cross-reference librarians** | Pin `1997 RO4` vs `2007 FK34` as distinct designations (same body, different mentions) while expanding packed↔unpacked losslessly | JPL SBDB ↔ MPC ↔ Wikidata (P5736) joins, dual-status bookkeeping | + +**User-visible contract:** The caller supplies raw human text (free-form, possibly containing zero, one, or many designations) and a contract; Paxman returns one canonical designation (or `MISSING`/`INVALID`/`AMBIGUOUS`) with citation. This mirrors ISBN (compact/display same-entity encodings) and UNSPSC (digits-only wire, presentation-only variants) ergonomics, but the canonical default is the **unpacked human-readable form** (`1995 XA`, `(433)`), with `packed` as the offered wire encoding. + +--- + +## 2. Shape of Input (Human Surface) + +### 2.1 Recognition-surface inventory — every distinct written form (MANDATORY) + +Surveyed from MPC DesDoc/PackedDes/HowNamed (spec), ADES `submit.xsd` pattern facets (schema), ecosystem parser strip logic (`sbpy`/`astroquery` regexes, JPL SBDB/Horizons accepted-input docs, MPC identifier-API accepted inputs), and real-world carriers (MPEC strings, MPCORB packed columns, NEOCP temp IDs, db_search equation headings). There is no resolver-URI lane for individual designations (Wikidata P5736 formatter URLs are link-shaped search links, and Horizons is space-sensitive) and no prose-label lane (`MPC:`/`DES:` unattested) — unlike ISBN/ORCID, the carrier set is pure structure, not labels. + +| Form | Example | Attested where | Prevalence | Paxman v1 decision | Grammar mechanism | +|------|---------|----------------|------------|--------------------|-------------------| +| Unpacked provisional | `1995 XA`, `2007 TA418` | DesDoc (canonical definition); MPC db_search headings; sbpy; ADES `BaseProvIDType` | canonical, dominant | RECOGNIZE | main pattern body | +| Lowercase unpacked | `2003 cp20`, `1995 xa` | JPL SBDB (spaced lowercase accepted); general prose | common (APIs, prose) | RECOGNIZE | explicit `[A-Za-z]` classes, fold-upper in notation | +| Underscore separator | `1995_XA` | sbpy + astroquery patterns (`[ _]`); JPL-adjacent lanes | common in code | RECOGNIZE | `[ _]` separator class | +| Retrospective A-prefix | `A904 OA` (= Jul 1904, 2nd half) | DesDoc (pre-1925 extension); ADES `OldProvIDType` (`A[89]\d{2} …`) | rare, official | RECOGNIZE | year-lane alternation `A[89]\d{2}` | +| `A/`-prefixed provisional | `A/2017 U1` | MPC designation-identifier API (accepted as minor-planet input) | rare, official | RECOGNIZE | `A/`-prefix branch | +| Packed 7-char | `J95X00A`, `K07Tf8A` | PackedDes (normative); MPCORB columns; JPL `K03C20P`; Rubin PPDB char fields | canonical wire, dominant in data | RECOGNIZE | case-sensitive branch | +| Extended packed | `_QC0000` (= `2026 CA620`) | MPC ops docs (Oct-2023 newsletter scheme, LSST-era) + sbpy `from_packed` | rare today, growing | RECOGNIZE | `_` + base-62 branch (exact year-letter mapping verified at implementation) | +| Survey unpacked | `2040 P-L`, `3138 T-1` | DesDoc; sbpy; ADES (`\d{4} (P-L\|T-[123])`) | official, closed set | RECOGNIZE | number + identifier branch | +| Survey packed | `PLS2040`, `T1S3138` | PackedDes; sbpy `PLS…/T1S…` | wire form | RECOGNIZE | literal-prefix branch | +| Parenthesized number | `(433)`, `(274301)` | MPC display (`(274301) Wikipedia = …`); WGSBN bulletins | canonical for numbered | RECOGNIZE | parens branch (parens load-bearing) | +| Packed number | `03202`, `A0345`, `a0017`, `K3289`, `~000z` | PackedDes (normative, with base-62 arithmetic); MPCORB columns | wire form | RECOGNIZE | case-sensitive branch (see §4) | +| Trailing-name citation | `(433) Eros`, `(274301) Wikipedia` | MPC headings; WGSBN citations (number+name pairing) | common in prose | RECOGNIZE (number span only; name outside span) | parens branch + word boundary (name is trailing annotation) | +| Glued `1995XA` | — (unattested as valid) | sbpy requires `[ _]`; ADES requires literal space | none valid | REJECT | documented negative test (SBDB liberality is resolution, not grammar) | +| Bare number `433` | JPL search lane only | JPL SBDB `des`/`sstr` (resolution); never MPC display | data/API only | REJECT | documented negative test; parens required (JPL lane deferred) | +| Old-style `1892 A`, `1914 VV`, `1915 a`, SIGMA/Greek | Historical corpora | DesDoc old-style section (superseded 1925) | archival only | REJECT | documented negative test (distinct dead grammar) | +| Comet forms `C/1995 O1`, `1P/Halley`, `1994 P1-B` | MPC comet lists; JPL | live, disjoint namespace | DEFER | named `comet_recognition` second grammar (distinct semantics) | +| Satellite/ring `S/2000 J 11`, `R/…` | JPL discovery tables; MPC NatSats | live, disjoint namespace | REJECT | documented negative (not minor-planet namespace) | +| Interstellar `1I/2017 U1` | MPC/JPL | live, `I/` namespace | DEFER | with comets (not minor-planet lane) | +| Bare names `Eros`, `Chiron` | MPC headings (always number-paired); JPL search | common in prose | REJECT | documented negative (names are not designations; unbounded vocabulary) | +| Subscript cycle `1995 SA₁` | Print typography only | DesDoc ("should be subscript when possible") | print only | REJECT | ASCII-only wire (plain digits in all machine records) | + +A v1 that does NOT recognize bare numbers states that explicitly here AND raises it as Open Decision §13 row 11: JPL's bare-number search lane (`des=4`, `sstr=163693`) is the one commonly seen form left out, because unparenthesized digit runs are unclaimable in free text without a registry — unhandled-at-v1 is a documented scope cut, not a blind spot. + +### 2.2 Wild variants — adversarial mutations of each inventoried form + +Enumerated from DesDoc/PackedDes/HowNamed, ADES XSD facets, NEOCP live examples, and parser regexes; stress-test every §2.1 RECOGNIZE form: + +| # | Category | Example Inputs | Recognition concern | +|---|----------|----------------|---------------------| +| 1 | Canonical unpacked | `1995 XA`, `2007 TA418`, `1992 QB1` | Spec master form; space significant | +| 2 | Lowercase / mixed case | `2003 cp20`, `1995 xa`, `2007 ta418` | Fold-upper in notation (unpacked lane only; glued `2003cp20` without separator stays MISSING — JPL resolution liberality, not grammar) | +| 3 | Underscore separator | `1995_XA`, `2040_P-L` | `[ _]` class; span includes separator | +| 4 | A-prefix retrospective | `A904 OA`, `A801 AA` | `A[89]\d{2}` year lane; same two-letter tail | +| 5 | `A/`-prefixed | `A/2017 U1` | Slash-prefix branch; rare official | +| 6 | High-cycle unpacked | `2008 AA360`, `1998 SQ108` | `\d*` tail; subscript-in-print is plain digits on wire | +| 7 | Packed 7-char | `J95X00A`, `K07Tf8A`, `J95X01L`, `K99AJ3Z` | Case-sensitive; cycle letter×10+digit; never fold case | +| 8 | Packed lowercase trap | `a0017` (= 360017) vs `A0345` (= 100345) | Distinct values; case fold would merge them — must not | +| 9 | Extended packed | `_QC0000`, `_QCzzzz` | `_` + year-letter + half-month + 4×base-62 | +| 10 | Survey both spellings | `2040 P-L` / `PLS2040`, `3138 T-1` / `T1S3138` | Same entity, two encodings (ADR-0011) | +| 11 | Parenthesized number | `(433)`, `(274301)` | Parens load-bearing; span includes parens | +| 12 | Packed number | `03202`, `50000`, `A0345`, `a0017`, `K3289`, `~0000`, `~000z`, `~AZaz`, `~zzzz` | 5-char exact; tilde base-62 arithmetic | +| 13 | Trailing-name citation | `(433) Eros`, `(274301) Wikipedia = 1997 RO4` | Number span only; `=`-equations are multi-mention (segmentation) | +| 14 | Multiple per line | `1997 RO4 = 2007 FK34`, `1995 XA, 1995 XB` | 2+ matches; distinct values → AMBIGUOUS under single_value | +| 15 | Quoted / bracketed | `"1995 XA"`, `[(433)]`, `(see 2007 TA418)` | Inside punctuation; guards must still fire | +| 16 | Over-long / under-long | `199 XA` (3-digit year), `19955 XA` (5-digit), `J95X00` (6-char pack) | Year/branch length guards; never partial | +| 17 | X-glued runs | `X1995 XA`, `1995 XAY`, `QJ95X00A` | Longer alphanum token must not yield inner designation; `(? "1995 XA" + packed = "" # no packed claimed; capability renders mpc_pack(designation) +# Rules: mpc_unpack(spelled) coherence + normalize() -> canonical unpacked. +# Capability.format_value(packed): mpc_pack(value) shared with rules. +``` + +### 2.3 What input is NOT a minor-planet mention + +- Comet designations (`C/1995 O1`, `1P/Halley`, `73P-C`, fragments `-B`) — disjoint namespace, DEFERRED grammar, never MISSING-vs-INVALID confusion (see §9). +- Satellite/ring designations (`S/2000 J 11`, `R/…`, `(87) Sylvia I`) — different authority branch, REJECT. +- Bare names (`Eros`, `Chiron`, `Wikipedia`) — not designations; MISSING (names lane deferred, §13 row 11). +- Bare numbers (`433`, `274301`) — JPL search lane only; MISSING without parens (real designation is `(433)`). +- Old-style provisionals (`1892 A`, `1914 VV`, `1915 a`, `SIGMA 27`, Greek letters) — superseded 1925 system; MISSING. +- Phone parenthesized fragments (`(555) 123-4567` — v1 overclaims the `(555)` area code as a number; caller segments phone contexts, future registry disambiguates), M49 numerics, GTIN runs (packed letters disambiguate; bare `\d{5}` overlaps ZIP/price runs — caller routes per §13 row 8). +- Subscript-cycle print (`1995 SA₁`) — non-ASCII tail; MISSING (ASCII-only wire). +- NEOCP temp IDs (`P22r91d`, `ZTF10Ge`) — per-survey internal IDs, not designations; MISSING. + +### 2.4 Single-mention vs multi-mention input + +Paxman resolves **one mention per `canonicalize()` call** (ARCHITECTURE.md, segmentation recipe; `docs/recipes/segmentation.md` ADR-0004). MPC equation headings (`(274301) Wikipedia = 1997 RO4 = 2007 FK34`) and multi-designation lines (`1995 XA, 1995 XB`) carry 2+ distinct designations → `AMBIGUOUS` or `MultipleMentionsError` with `single_value=True`; identical packed/unpacked spellings of one designation coalesce to `SUCCESS` (same canonical value, ADR-0011 same-entity encodings). + +--- + +## 3. Shape of Notation (Intermediate Representation) + +### 3.1 Recommended notation — canonical unpacked plus packed facet + +```python +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class MinorPlanetNotation: + """Minor-planet notation — grammar-normalized designation form. + + ``designation`` is the canonical unpacked reading (uppercased, + single-spaced: ``1995 XA``; surveys ``2040 P-L``; numbers + parenthesized ``(433)``; ``A/``-prefixed ``A/2017 U1``). + ``form`` is the recognized lane (``provisional`` | ``packed`` | + ``extended`` | ``survey`` | ``survey_packed`` | ``number`` | + ``packed_number``) — a free str, not a Literal (lanes may grow with + future packing schemes; the rules, not the type, own lane truth). + ``packed`` is the spelled packed form, case-EXACT, for packed lanes + (``J95X00A``; ``a0017`` lowercase preserved) and "" for unpacked/`A/` + lanes. Rules own decode coherence (`mpc_unpack`); the capability owns + `mpc_pack` rendering for the offered ``packed`` format, so the grammar + never consults century/base-62 tables. + """ + + designation: str + form: str + packed: str +``` + +**Considered alternative — single field `designation` only:** a bare canonical string would carry the pipeline, since rules could re-derive the lane. However the decomposition is preferred because (1) the MPC spec indexes authority by lane (unpacked definition vs packed spec vs numbering process are three citable publications), (2) the lane (`form` + case-exact `packed`) decides which rule branch validates without re-parsing, and (3) pack/unpack is a total positional transcoding (like ISBN separator-stripping, writ large) — not validation. This mirrors GTIN's `digits + native_length + has_ai` facet pattern (`paxman/capabilities/GTIN/notation.py:20-22`). + +**On the grammar/rule boundary for pack/unpack transcoding (2026-10-07 review fix):** the capabilities governance (`paxman/capabilities/AGENTS.md`) reserves token→canonical mapping to rules, and pack/unpack needs authority tables (century `IJK`, cycle/base-62 values in `PackedDes`). The grammar therefore performs syntax-only normalization (case/space fold, lane dispatch, spelled-form preservation) and never decodes. Validity judgments (letter sets, decode coherence, tilde arithmetic) live in rules; `mpc_pack` rendering lives in the capability's `format_value()`. The notation carries the spelled lane facets; no vocabulary is consulted in the grammar. + +**Invariants the grammar enforces (before rules):** + +- `designation` is the syntax-normalized spelled form (unpacked lanes uppercased, single-spaced, ASCII; packed lanes preserved byte-for-byte, case-exact). +- `form` is one of the seven lane ids, derived purely syntactically from which alternation branch fired. +- `packed` carries the spelled packed form for packed lanes only; unpacked/`A/` lanes carry `""` (no packed mapping claimed in the grammar; the capability renders `packed` from the validated designation). + +### 3.2 Why not carry spaces variants, case, or names in the notation + +Separator variants (`_` vs space), input case, and trailing names (`(433) Eros` — name outside the span) have **no lexical significance** for validity — the canonical unpacked form is upper/space-normalized per DesDoc, and presentation of names is out of scope (names lane deferred). Presentation beyond case/space normalization is `Capability.format_value()` only. + +### 3.3 Why `form` is not a shape discriminator literal + +`form` is a free `str` naming the fired branch, lane-checked by rules (a claimed `packed` notation must decode coherently; a claimed `survey` must carry a valid survey identifier) — mirroring UNSPSC's free-`str` `level` (the evolving spec, not the type, is the versioned truth; here the extended `_` scheme of 2023 proves packing lanes still grow). A `Literal[...]` would freeze the seven lanes into the type while MPC adds schemes on LSST timescales. + +--- + +## 4. Grammar / Recognition Strategy + +### 4.1 Strategy choice — Regex vs Lexicon + +Per HOW_TO_ADD_NEW_GRAMMAR.md, minor-planet designations have distinctive fixed-width structural shapes (year+letters, 7-char packed, survey codes, parenthesized numbers), so **Regex** is correct. Lexicon is wrong: provisionals are an open-ended stream (tens of thousands per half-month in the LSST era), not a finite hand-keyed token table, and recognition must fire before (and without) consulting any registry. + +### 4.2 Reference pattern (adapted from ISBN and ISSN verbatim precedent) + +ISBN-13 precedent (`paxman/capabilities/ISBN/grammar/isbn13_recognition.py:30-40`): staged `PipelineGrammar` + `StandardPre(empty_guard=True)` + `RegexStage` with module-scope pattern, fused label, digit-continuation lookarounds, `single_value=True`. ISSN precedent (`paxman/capabilities/ISSN/grammar/issn_recognition.py:41-60`): one grammar covering hyphenated-vs-compact via optional separator at the canonical position, `LabelMatcher` with `glued_policy`, `BoundarySpec.WORD`, `re.IGNORECASE | re.ASCII`. + +**Proposed minor-planet pattern (single grammar, staged pipeline):** + +```python +import re +from paxman.capabilities.MinorPlanet.notation import MinorPlanetNotation +from paxman.core.grammar import PipelineGrammar, RegexStage, StandardPre + +# No re.IGNORECASE: unpacked lanes fold case explicitly ([A-Za-z] + +# .upper()), packed lanes are case-SIGNIFICANT (a0017 vs A0345). +# Single `year` group: the `A/` prefix is an optional `aprefix` group on the +# same branch (2026-10-07 review fix — separate `_APREFIX + _UNPACKED` +# alternation redefined `year`/`hm`/`second`/`cycle` and `re.compile` +# raised `redefinition of group name 'year'`). +_YEAR = r"(?:A[89]\d{2}|19\d{2}|20\d{2})" +_UNPACKED = ( + r"(?P" + _YEAR + r")[ _]" + r"(?P[A-Za-z])(?P[A-Za-z])(?P\d*)" +) +# `A/` lane allows 1-2 letters per ADES `[ADCPX]/\d{4} [A-Z]{1,2}\d*` +# (e.g. `A/2017 U1` has a single letter + cycle). Distinct group names — +# reusing `year`/`hm` here reintroduced the duplicate-group compile error. +_APREFIX = ( + r"A/(?P" + _YEAR + r")[ _]" + r"(?P[A-Za-z])(?P[A-Za-z])?(?P\d*)" +) +_SURVEY = r"(?P[1-9]\d{0,3})[ _](?P[Pp]-[Ll]|[Tt]-[123])" +_PACKED7 = r"(?P[IJK]\d{2}[A-Z][0-9A-Za-z]\d[A-Z])" +_EXTENDED = r"(?P_[A-Z][A-Z][0-9A-Za-z]{4})" +_SURVEYPACKED = r"(?P(?:PLS|T[123]S)\d{3,4})" +_PACKEDNUM = r"(?P(?:\d{5}(?![ _][A-Za-z])|[A-Za-z]\d{4}|~[0-9A-Za-z]{4}))" +# 8-digit headroom: tilde decodes to 15396335 (2026-10-07 review fix — +# `\d{1,7}` capped below the `~zzzz` max). +_NUMBER = r"\((?P\d{1,8})\)" +_MP_BODY = ( + _APREFIX + r"|" + _UNPACKED + r"|" + _SURVEY + r"|" + + _PACKED7 + r"|" + _EXTENDED + r"|" + _SURVEYPACKED + r"|" + + _PACKEDNUM + r"|" + _NUMBER +) +_MP_PATTERN = r"(? MinorPlanetNotation: + (...) # lane dispatch on named groups; unpack folds upper/single-space + # and preserves the spelled lane. Pack/unpack decode lives in RULES and + # the capability (2026-10-07 review fix per + # paxman/capabilities/AGENTS.md governance): the grammar performs + # syntax-only normalization and never consults century/base-62 tables. + # `packed` facet carries the spelled packed form for packed lanes and + # "" for unpacked/`A/` lanes; rules own `mpc_unpack` coherence and the + # capability owns `mpc_pack` rendering for the offered `packed` format. + + +class MinorPlanetRecognitionGrammar(PipelineGrammar[MinorPlanetNotation]): + """Minor-planet recognition: unpacked/packed/survey/number lanes. + + New-code preference is the kernel `RegexMatcher` + `BoundarySpec.WORD` + spelling of this pattern; legacy `PipelineGrammar` + `RegexStage` below + remains accepted (BIC/UNSPSC precedent). Either way compile ASCII-only + without `re.IGNORECASE`. + """ + + name = "minor_planet_recognition" + semantics = "minor_planet_recognition" + single_value = True + pre = StandardPre[MinorPlanetNotation](empty_guard=True) + regex = RegexStage[MinorPlanetNotation]( + pattern=_MP_PATTERN, notation_fn=_notation, flags=re.ASCII + ) +``` + +*Notes on fidelity vs ISBN/ISSN:* module-scope strings compiled by `RegexStage` (or kernel `RegexMatcher`); longest-alternation discipline (packed-7 before packed-number before bare runs, so `J95X00A` never carves); ASCII-only via `re.ASCII` **without** `re.IGNORECASE` (packed case significance — the one flag decision, enforced by construction); `(?15,500/half-month LSST volumes; not for pre-2010 discoveries (MPC ops docs) | + +**Citation Details Table (for Provenance):** + +| Authority | Spec name | Version | Reference URL | Lifecycle | Publication year | Kind | +|-----------|-----------|---------|---------------|-----------|------------------|------| +| Minor Planet Center | Unpacked provisional designation definition (DesDoc) | Living document, four-level letter tables + cycle/survey rules, fetched 2026-10-07 | https://minorplanetcenter.net/iau/info/DesDoc.html | active | 2026 | specification | +| Minor Planet Center | Packed provisional/permanent spec (PackedDes) | Living document, 7-/5-char layouts + tilde base-62, fetched 2026-10-07 | https://minorplanetcenter.net/iau/info/PackedDes.html | active | 2026 | specification | +| Minor Planet Center | Numbering/naming process (HowNamed) | Living document, WGSBN Bulletin officiality, fetched 2026-10-07 | https://minorplanetcenter.net/iau/info/HowNamed.html | active | 2026 | specification | + +### 5.2 Rule / publication map (one file per publication — HOW_TO_ADD_NEW_CAPABILITY.md §5) + +| Rule file | Module-level PUBLICATION (Provenance) | Rules in file | What it validates | +|-----------|----------------------------------------|---------------|-------------------| +| `rules/mpc_unpacked_designation.py` | authority="Minor Planet Center", specification_name="Unpacked provisional designation definition", kind="specification", reference_url="https://minorplanetcenter.net/iau/info/DesDoc.html", version="living document (fetched 2026-10-07)", lifecycle="active", publication_year=2026 | Section 1-unpacked-provisional-structure, Section 2-survey-designation | Year lane + separator + half-month/second letter sets + cycle digits; survey number + identifier (PARSER) | +| `rules/mpc_packed_designation.py` | authority="Minor Planet Center", specification_name="Packed provisional/permanent spec", kind="specification", reference_url="https://minorplanetcenter.net/iau/info/PackedDes.html", version="living document (fetched 2026-10-07)", lifecycle="active", publication_year=2026 | Section 3-packed-provisional, Section 4-packed-number | 7-char decode coherence (century/cycle/second, incl. extended `_` shape) + 5-char number decode coherence incl. tilde arithmetic (PARSER) | +| `rules/mpc_numbering.py` | authority="Minor Planet Center", specification_name="Numbering/naming process", kind="specification", reference_url="https://minorplanetcenter.net/iau/info/HowNamed.html", version="living document (fetched 2026-10-07)", lifecycle="active", publication_year=2026 | Section 5-permanent-number | Parenthesized positive integer, no leading zeros (PARSER) | + +Each `Rule[MinorPlanetNotation]` subclass declares six enforced metadata attributes at class-definition time (`Rule.__init_subclass__`, `paxman/core/domain.py:253-278`): + +```python +name: str # "Section {N}-{slug}" +strategy: RuleStrategy # PARSER (all five at v1; see §5.4) +provenance: Provenance # == module PUBLICATION +citation: str # spec section anchor +target_semantics: frozenset[str] # {"minor_planet_recognition"} +requires_features: frozenset[str] # frozenset() at v1 (no gated registry) +``` + +### 5.3 What each rule does vs does not own + +- `matches()` validates strictly, never raises, never reads contract `include_*` flags directly (no capability-specific flags ship at v1 — the `include_live_membership` placebo lesson from UNSPSC review applies: no flag without a consuming rule). `normalize()` returns the canonical unpacked `designation`, never reads `output_format` (CI purity scan enforced), identical across rules for candidate dedup. +- `RuleStrategy` choice: PARSER for all five (letter-set membership and positional decode coherence need no authority table). Packed decode is validation of *coherence* (century ∈ IJK, half-month ∈ set, cycle letter×10+digit arithmetic, tilde value ≥ 0), not registry lookup. +- Lane truth lives in the rules, keyed off `notation.form`: Section 1 matches `provisional` only (so packed inputs carry no DesDoc provenance); Section 3 matches `packed`/`extended`/`survey_packed`; Section 5 matches `number`. Full-conjunction-per-rule discipline follows the GTIN Section-2 precedent (`gs1_prefix_ed2026.py:96-109` re-checks length/ASCII/check-digit before prefix lookup) and ISNI's documented rationale — a partial validator would let off-lane garbage resolve SUCCESS under pinned contracts. + +### 5.4 Scope decision (the capability's analogue of IBAN §5.4 / BIC §5.4) + +Whether to ship a registry (issued-designation) LOOKUP_TABLE at v1. Recommendation: **PARSER-only v1, no registry** — like UUID/BIC structure-only posture, not like ISBN's range-message table. Rationale: (1) there is no small authority table to vendor — MPCORB holds hundreds of thousands of designations on a monthly cadence, and provisional designations are a high-churn stream (tens of thousands per half-month in the LSST era); a snapshot would be stale on arrival and enormous beside the 158k-row UNSPSC precedent. (2) Staleness semantics would be actively misleading: an unissued-but-well-formed `2026 ZZ99` resolving SUCCESS-today/INVALID-tomorrow is worse than honest well-formed-only SUCCESS. (3) The ADR-0012 vacuity exception covers PARSER-without-LOOKUP exactly as it does ISSN's check-digit rule. Cost of deferral: `2026 ZZ99`-shaped never-assigned inputs resolve SUCCESS (well-formed) instead of INVALID — disclosed in §8/§9, and the named upgrade is an MPCORB-snapshot `Section 6-mpcorb-membership` LOOKUP_TABLE behind a default-off `include_mpcorb_membership` flag (never ship the flag first — placebo-flag lesson recorded). + +### 5.5 Assignment / registration authority & registry content + +Owner/RA: Minor Planet Center — "single worldwide location" for minor planets, comets, and outer irregular natural satellites (mpc.html); hosted by the Center for Astrophysics | Harvard & Smithsonian, funded by NASA (site footer). Assigning flow: provisional designation on ≥2 nights of unidentified observations → permanent number once the orbit is secure/recoverable → optional name by discoverer proposal judged by WGSBN, official only on WGSBN Bulletin publication (10-year proposal privilege). Record content per designation: packed + unpacked forms, orbit, observations (80-column / ADES), discovery asterisk. Cadence: nightly MPECs; monthly MPCORB snapshots; Bulletins as needed. Search: `https://www.minorplanetcenter.net/db_search` (+ machine `designation-identifier` API returning `packed_*`/`unpacked_*` pairs). Related: JPL SBDB/Horizons (resolution mirrors, not authorities), WGSBN (names), IAU Division F (dynamical context, not cited). + +--- + +## 6. Presentation Seam — Contract & Capability + +### 6.1 Contract (HOW_TO_ADD_NEW_CAPABILITY.md §7) + +Every contract MUST inherit `CapabilityContract` (never `Contract` directly). `@dataclass(frozen=True)` without slots. + +```python +from dataclasses import dataclass, field +from typing import ClassVar +from paxman.core.contract import CapabilityContract + + +@dataclass(frozen=True) +class MinorPlanetContract(CapabilityContract): + """User-facing configuration for the MinorPlanet capability. + + Formats (ADR-0011 classes): ``designation`` is the unpacked canonical + (``1995 XA`` / ``(433)`` / ``2040 P-L``, default); ``packed`` is the + same-entity wire encoding — encoding (``J95X00A`` / ``03202`` / + ``PLS2040``, case-exact, re-enters through the packed branch). + Unpacked/`A/` lanes have no spelled packed form; the capability renders + `packed` from the validated designation, falling back to the + designation only for the `A/` lane which defines no packed mapping. + """ + + DEFAULT_OUTPUT_FORMAT: ClassVar[str] = "designation" + OFFERED_OUTPUT_FORMATS: ClassVar[frozenset[str]] = frozenset({"packed"}) + + capability_name: str = field(default="minor_planet", init=False) + + @staticmethod + def create_contract( + *, + excluded_rules: Sequence[str] | None = None, + pinned_rules: Sequence[str] | None = None, + year: int | None = None, + output_format: str | None = None, + extra_grammars: Sequence[str] | None = None, + suppress_common_words: bool = False, + ) -> MinorPlanetContract: + """Create a MinorPlanet contract (shipped common block at v1).""" + ... +``` + +- `DEFAULT_OUTPUT_FORMAT` concrete string `"designation"`, `OFFERED_OUTPUT_FORMATS` excludes default, resolved via `resolve_output_format`; `create_contract()` uses the shipped keyword-only common block (`Sequence[str] | None`, plus `extra_grammars` and `suppress_common_words` per `paxman/capabilities/ISSN/capability.py:47-56`). +- No capability-specific flags at v1 (placebo-flag lesson). `year` temporal filtering applies naturally (living-doc publications carry `publication_year=2026`; a future dated revision would make `year` meaningful — document, don't gate). +- Presentational-only invariant: rules never see `output_format`; `normalize()` always returns the unpacked `designation`. +- For MinorPlanet, offered formats model the interchange forms: + +| output_format | Value example | Meaning | +|---------------|---------------|---------| +| `designation` (default) | `1995 XA` | Unpacked canonical (upper, single space, parens for numbers) | +| `packed` | `J95X00A` | Same-entity wire encoding (case-exact; re-enters) | + +### 6.2 Capability (HOW_TO_ADD_NEW_CAPABILITY.md §6) + +```python +from collections.abc import Sequence + +from paxman.core.capability import Capability +from paxman.core.domain import Grammar, Rule +from paxman.capabilities.MinorPlanet.contract import MinorPlanetContract +from paxman.capabilities.MinorPlanet.grammar.minor_planet_recognition import ( + MinorPlanetRecognitionGrammar, +) +from paxman.capabilities.MinorPlanet.notation import MinorPlanetNotation +from paxman.capabilities.MinorPlanet.rules.mpc_numbering import ( + Section5PermanentNumber, +) +from paxman.capabilities.MinorPlanet.rules.mpc_packed_designation import ( + Section3PackedProvisional, + Section4PackedNumber, +) +from paxman.capabilities.MinorPlanet.rules.mpc_unpacked_designation import ( + Section1UnpackedProvisionalStructure, + Section2SurveyDesignation, +) + + +class MinorPlanetCapability(Capability[MinorPlanetNotation]): + """Minor-planet capability — wiring + presentation seam.""" + + name = "minor_planet" + version = "1.0.0" + + def get_grammars(self) -> list[Grammar[MinorPlanetNotation]]: + """Return the shipped recognizers.""" + return [MinorPlanetRecognitionGrammar()] + + def get_rules(self) -> list[Rule[MinorPlanetNotation]]: + """Return the shipped validators.""" + return [ + Section1UnpackedProvisionalStructure(), + Section2SurveyDesignation(), + Section3PackedProvisional(), + Section4PackedNumber(), + Section5PermanentNumber(), + ] + + @staticmethod + def create_contract( + *, + excluded_rules: Sequence[str] | None = None, + pinned_rules: Sequence[str] | None = None, + year: int | None = None, + output_format: str | None = None, + extra_grammars: Sequence[str] | None = None, + suppress_common_words: bool = False, + ) -> MinorPlanetContract: + """Create the default MinorPlanet contract (shipped common block).""" + ... + + def format_value( + self, + value: str, + output_format: str | None, + notation: MinorPlanetNotation, + ) -> str: + """Render the canonical designation in the requested format. + + The default ``"designation"`` path is the identity. ``"packed"`` + renders via the shared `mpc_pack` helper owned by rules/capability + (never recomputed from grammar tables); the `A/` lane has no packed + mapping and falls back to the designation. Never affects candidate + identity or provenance. + """ + if output_format == "packed": + return notation.packed or mpc_pack(value) or value + return value +``` + +Registration via `tools/new_capability.py` (see §10.1). + +--- + +## 7. Validation — three levels + +### 7.1 Level 1 Unpacked structure, Level 2 Packed decode coherence, Level 3 Permanent-number shape + +**Level 1 — unpacked structure (`Section1UnpackedProvisionalStructure`, PARSER, always-active).** Year lane `A[89]\d{2}|19\d{2}|20\d{2}`, single space-or-underscore separator, half-month ∈ `[A-HJ-Y]`, second ∈ `[A-HJ-Z]`, cycle `\d*` (lenient on leading zeros — sbpy `[0-9]{0,3}` and ADES `\d*` agree; strictness would invent rejections both validators decline). `A/`-prefixed lane same tail. Worked examples: `1995 XA` ✓; `2007 TA418` ✓ (cycle 418); `A904 OA` ✓ (retrospective lane); `A/2017 U1` ✓; `1995 XI` ✗ (I excluded from half-month); `1995 IZ` ✗ (I excluded everywhere); `1995 XZ` ✓ (Z is a legal *second* letter — the asymmetric exclusion is the classic trap); `1892 A` ✗ never reaches rules (single-letter old-style; grammar needs two letters). + +**Level 2 — packed decode coherence (`Section3PackedProvisional` + `Section4PackedNumber`, PARSER, always-active).** Provisional: century ∈ `{I,J,K}`; half-month ∈ `[A-HJ-Y]`; cycle = two digits (00–99) or letter×10+digit with letter value A=10…Z=35/a=36…z=61 (so `J=19` → `J3`=193, `f=41` → `f8`=418); second ∈ `[A-Z]` (col 7 uppercase — lowercase col-7 is the comet-fragment lane, deferred); extended `_` = year-letter + half-month + 4×base-62 (shape-validated; exact year-letter mapping verified against sbpy/MPC examples at implementation). Number: `\d{5}` any (zero-padded lane); `[A-Za-z]\d{4}` with case preserved (`A0345`→100345 vs `a0017`→360017 — the report's canonical case-trap pair); `~` + 4×base-62 with value = decode − 620000 ≥ 0 (`~000z`→620061; `~AZaz`→3140113; `~zzzz`→15396335 — arithmetic verified against PackedDes). Worked: `J95X00A`→`1995 XA` ✓; `K07Tf8A`→`2007 TA418` ✓; `K99AJ3Z`→`2099 AZ193` ✓; `03202`→`(3202)` ✓; `~0000`→`(620000)` ✓; `Q95X00A` ✗ (Q not a century); `J95I00A` ✗ (I half-month). + +**Level 3 — permanent-number shape (`Section5PermanentNumber`, PARSER, always-active).** Paren contents match `^[1-9]\d{0,7}$` (positive integer, no leading zeros, 8-digit headroom covering the tilde max 15396335). Worked: `(433)` ✓; `(274301)` ✓; `(15396335)` ✓ (tilde-max shape); `(0433)` ✗ (leading zero — MPC never pads unpacked numbers); `(0)` ✗. Names are not validated (lane deferred — `(433) Eros` validates on `(433)`; `Eros` alone never reaches rules). + +### 7.2 What makes a designation "valid" vs "issued" + +- **Valid (well-formed)** — correct lane shape, ASCII, letter-set/decode coherent; always-active PARSERs. This is the v1 verdict ceiling. +- **Issued (actually assigned by MPC)** — valid plus present in MPC records; unknowable at v1 without a registry. A well-formed never-assigned `2026 ZZ99` resolves SUCCESS (well-formed), not INVALID — disclosed honestly, not silently overclaimed. Analogous to UUID (structure-only) and to BIC before its directory rule; the recorded upgrade is an MPCORB-snapshot LOOKUP_TABLE (§13 row 9). +- **Named** — orthogonal to validity; names are never validated at v1 (WGSBN officiality is a publication event, not a string property). + +Like ISBN valid vs allocated, ISSN valid vs issued — except the issued half is explicitly unshipped. + +--- + +## 8. Edge Cases + +| # | Edge case | Expected resolution | Why | +|---|-----------|---------------------|-----| +| 1 | Lowercase unpacked | `2003 cp20` → SUCCESS `2003 CP20` | Unpacked lane folds upper; spaced lowercase attested (glued `2003cp20` → MISSING) | +| 2 | Underscore separator | `1995_XA` → SUCCESS `1995 XA` | `[ _]` class; sbpy + astroquery agree | +| 3 | Packed case preserved | `a0017` → SUCCESS `(360017)`; `A0345` → SUCCESS `(100345)` | Case-sensitive lane; folding would merge distinct values | +| 4 | Extended packed | `_QC0000` → SUCCESS (unpacked extended reading) | Normative LSST-era scheme; shape + base-62 validated | +| 5 | Survey both spellings | `2040 P-L` / `PLS2040` → SUCCESS same canonical | Same entity, two encodings (ADR-0011) | +| 6 | A-prefix retrospective | `A904 OA` → SUCCESS | ADES `OldProvIDType` agrees | +| 7 | `A/`-prefixed | `A/2017 U1` → SUCCESS `A/2017 U1` | MPC identifier-API input contract; packed facet "" | +| 8 | Trailing-name citation | `(433) Eros` → SUCCESS `(433)` (name outside span) | Number span only; mirrors trailing-annotation handling | +| 9 | Over-long year/pack | `19955 XA` / `J95X00` → MISSING | Year-lane / 7-char length guards | +| 10 | Invalid letter slots | `1995 XI` → INVALID | Claimed by grammar (broad letters), rejected by Section 1 | +| 11 | Leading-zero number | `(0433)` → INVALID | Rule rejects; MPC never pads unpacked numbers | +| 12 | Glued `1995XA` | MISSING | No separator; sbpy + ADES require one | +| 13 | Bare number `433` | MISSING | Parens load-bearing (JPL lane deferred) | +| 14 | Old-style `1892 A` | MISSING | Superseded system; two-letter minimum | +| 15 | Comet `C/1995 O1` / fragment | MISSING (at v1) | Disjoint namespace; deferred grammar | +| 16 | Embedded in sentence | `see 1995 XA (Alcathoe)` → SUCCESS with span | Word guards; parenthetical not swallowed | +| 17 | Two distinct in one slice | `1997 RO4 = 2007 FK34` → AMBIGUOUS / MultipleMentionsError | Same body, distinct designations — authorial choice, segmentation | +| 18 | Packed+unpacked same object | `J95X00A = 1995 XA` → SUCCESS `1995 XA` | Identical canonical via both lanes; candidates dedup | + +--- + +## 9. Resolution-State Map (ARCHITECTURE.md Resolution Semantics) + +| Input | Status | Why | +|-------|--------|-----| +| Valid unpacked/packed/survey/number at v1 rules | SUCCESS → unpacked canonical | Lane shape + letter/decode coherence all pass | +| Valid lowercase/underscore/`A/`/trailing-name variants | SUCCESS (same canonical) | Presentation/spelling-only dedup; packed facet case-exact | +| Invalid letter slot / bad half-month / leading-zero number / mid-shape violation | INVALID | Claimed by grammar (broad letter classes), rejected by lane rule; bad-century packs (`Q95X00A`) are MISSING (century is lane-defining in the grammar) | +| Glued / bare-number / old-style / names / subscripts / satellites | MISSING | No grammar claims these shapes at v1 | +| Comet/interstellar (`C/`, `P/`, `I/`, fragments) | MISSING | Deferred namespace; no grammar claims | +| No designation-shaped runs | MISSING | No grammar recognized | +| Two distinct designations in one slice | AMBIGUOUS / MultipleMentionsError | Single-slice ambiguity, use segmentation | +| Well-formed but never assigned (`2026 ZZ99`-shape) | SUCCESS (well-formed) | No registry at v1 — honest collapse, disclosed in §7.2 | +| Packed and unpacked spellings of one designation | SUCCESS (one value) | Same-entity encodings; dedup coalesces | + +--- + +## 10. Scaffolding & Repo Integration + +### 10.1 Generated skeleton (tools/new_capability.py — HOW_TO_ADD_NEW_CAPABILITY.md Step 0) + +```bash +uv run python tools/new_capability.py MinorPlanet --name minor_planet --authority "Minor Planet Center" --spec-name "Unpacked provisional designation definition" --spec-url "https://minorplanetcenter.net/iau/info/DesDoc.html" --publication-year 2026 --spec-version "living document (fetched 2026-10-07)" --default-format designation +``` + +Creates 13 files + one edit: `paxman/capabilities/MinorPlanet/{notation,contract,capability,grammar/*,rules/*}`, tests stubs, `paxman/capabilities/__init__.py` wiring. TODO(scaffold) markers guide replacement. + +> Note: scaffolder single --spec-name covers one provenance. After scaffolding, add the packed-spec file (`rules/mpc_packed_designation.py`) and the numbering file (`rules/mpc_numbering.py`) manually, splitting the placeholder rule, and delete or repurpose the placeholder — no `rules/data/` at v1 (no registry). + +### 10.2 Contract & grammar wiring + +- `get_grammars()` returns `[MinorPlanetRecognitionGrammar()]`; `active_grammars` omitted at v1 (base `None` runs every shipped grammar). +- Grammar carries `name = "minor_planet_recognition"` and non-empty `semantics = "minor_planet_recognition"`; every rule's `target_semantics = frozenset({"minor_planet_recognition"})`. +- No capability-specific contract flags at v1 (placebo-flag lesson: `include_mpcorb_membership` arrives only with its LOOKUP_TABLE). + +### 10.3 Cross-cutting invariants (fail review if violated) + +- No `# type: ignore` / `# noqa` / `# pyright: ignore` in `paxman/` source. +- No cross-capability imports (import only from `paxman.core`, import-linter enforced). +- No `output_format` token in any `paxman/capabilities/*/rules/` module (source-scan) — `packed` lives in `capability.py:format_value()` only. +- `@dataclass(frozen=True, slots=True)` notation; `@dataclass(frozen=True)` without slots contracts. +- Deterministic by construction: same input + contract + library snapshot → same output (no registry at v1, so snapshot = code + packed tables, both static). + +--- + +## 11. Recommended File Layout (mirrors ISSN and ISBN) + +``` +paxman/capabilities/MinorPlanet/ +├── __init__.py +├── capability.py +├── contract.py +├── notation.py +├── grammar/ +│ ├── __init__.py +│ └── minor_planet_recognition.py +└── rules/ + ├── __init__.py + ├── mpc_unpacked_designation.py + ├── mpc_packed_designation.py + └── mpc_numbering.py +``` + +No `rules/data/` at v1 (no registry — differs from ISBN deliberately; like ISNI's v1 which shipped no lookup tables). A future `rules/data/mpcorb_snapshot.py` + `tools/regenerate_mpcorb_data.py` + `paxman/shared_data/mpcorb_snapshot.json` pair follows the Currency/UNSPSC snapshot+regenerate lifecycle when the LOOKUP_TABLE upgrade lands (§13 row 9). + +--- + +## 12. Test Strategy (mirrors HOW_TO_ADD_NEW_CAPABILITY.md and ISSN §9) + +- Grammar tests: pattern compiles (no duplicate group names — regression for the 2026-10-07 `redefinition of group name 'year'` failure); unpacked canonical + high-cycle, lowercase fold, underscore lane, A-prefix retrospective, `A/`-prefix via optional `aprefix` group, packed 7-char (+ lowercase-trap pair `a0017`/`A0345`), extended `_`, survey both spellings, parens number incl. 8-digit tilde-max shape, packed-number lanes (digit/letter/tilde), trailing-name span, MPC-equation multi-match, quoted/bracketed, glued/bare-number/old-style/comet/satellite/name/subscript negatives, 3-/5-digit-year and 6-char-pack negatives, X-glued runs, span invariants (`raw_text` includes parens/label-equivalents, `notation.designation` excludes case/space variance), name/semantics, boundary-guard negatives — plus one positive vector per §2.1 RECOGNIZE form. Document `(555)`-in-phone and bare-`\d{5}`-as-ZIP as known v1 overclaims, not guard negatives. +- Rule tests: Section-1 valid/variant/invalid (year lanes, `XI`/`IZ` invalid, `XZ` valid, cycle leniency), Section-2 survey valid + bad-identifier invalid, Section-3 packed valid + bad-century/invalid-half-month + extended shape + tilde arithmetic vectors (`~000z`→620061, `~AZaz`→3140113), Section-4 packed-number valid + case-trap pair, Section-5 number valid + leading-zero/zero invalid; `normalize()` exact unpacked canonical everywhere; provenance attributes (`authority`, `kind`, `version`, `lifecycle`, `publication_year`); name/strategy conventions (`Section N-*`, all PARSER); `target_semantics == frozenset({"minor_planet_recognition"})`. +- Capability tests: notation frozen/hashable/slots, wiring counts (1 grammar, 5 rules), grammar/rule name conventions, `format_value` round-trips (`designation` identity, `packed` case-exact incl. lowercase lanes, `A/`-lane packed identity), `create_contract` factories. +- Integration: MISSING (glued/bare/old-style/comet/name/subscript), INVALID (bad letter slots, bad century, leading-zero number), SUCCESS (all lanes incl. packed+unpacked same-value dedup), AMBIGUOUS or MultipleMentionsError (equation headings, two distinct), `_clean_registry` fixture, determinism/`VersionStamp`, span-bearing match, dedup. +- Property tests (hypothesis): valid lane-directed generation (year from lane + letters from legal sets + cycle digits → must canonicalize to itself); packed round-trip (`mpc_pack`∘`mpc_unpack` identity on legal lanes); random alphanum strings → INVALID-or-MISSING, never raise; case-fold stability (lower(input) ≡ input post-`normalize()`); `format_value` round-trip (`packed` → re-parse → same canonical); tilde arithmetic spot-checks. +- Consistency test: every `target_semantics` claimed by a rule is produced by the shipped grammar; pack/unpack helpers agree on the §7 worked vectors. +- Presentation purity: `output_format` source scan over `rules/`. +- Real vectors: `1995 XA`, `2007 TA418`, `1992 QB1`, `2003 CP20` (lowercase input), `A904 OA`, `A/2017 U1`, `J95X00A`, `K07Tf8A`, `K99AJ3Z`, `_QC0000`, `2040 P-L` / `PLS2040`, `3138 T-1` / `T1S3138`, `(433)`, `(274301)`, `(433) Eros` (number span), `03202`, `A0345`, `a0017`, `K3289`, `~000z`, `~AZaz`, `1995 XI` (INVALID), `(0433)` (INVALID), `1995XA` (MISSING), `433` (MISSING), `C/1995 O1` (MISSING at v1). + +--- + +## 13. Open Decisions (with recommendations) + +| # | Decision | Recommendation | Rationale | +|---|----------|----------------|-----------| +| 1 | DEFAULT_OUTPUT_FORMAT — `designation` vs `packed` vs `bare` | Default `designation` (unpacked canonical); offer `packed` | Human-readable citable form first (MPC display practice); packed is the wire encoding, presentationally secondary (ADR-0011) | +| 2 | Single grammar vs N grammars | Single `minor_planet_recognition` with lane alternation | Same-entity encodings share one dedup key; avoids cross-grammar containment spurious AMBIGUOUS | +| 3 | Bare names (`Eros`) | REJECT at v1 (MISSING) | Names are not designations (MPC always number-pairs them); vocabulary unbounded without a names snapshot | +| 4 | Comets as second grammar | DEFER to `comet_recognition` with distinct semantics id | Disjoint namespace (C/P/D/X + periodic numbers + fragments); coalescing would merge distinct authorities | +| 5 | Glued `1995XA` | REJECT (MISSING) | sbpy + ADES both require the separator; JPL liberality is resolution, not grammar | +| 6 | Underscore `1995_XA` | RECOGNIZE (`[ _]` class) | sbpy + astroquery agree; JPL-adjacent code lane | +| 7 | Packed case-sensitivity | Enforce exact case (no `re.IGNORECASE`; unpacked classes explicit) | `a0017` vs `A0345` are distinct values; folding merges them — enforced by construction | +| 8 | Bare packed numbers (`03202` in free text) + `(NNN)` area-code overlap | RECOGNIZE with word guards; document both as known v1 overclaims — caller routes ZIP/phone contexts, registry revisits (§13 row 9) | MPCORB-dump lane and `(433)`-shaped numbers need the lanes; guards cannot disambiguate `\d{5}` vs ZIPs or `(555)` vs area codes without membership | +| 9 | Registry LOOKUP_TABLE future | DEFER to MPCORB-snapshot `Section 6` + default-off `include_mpcorb_membership` (flag ships WITH its rule, never before) | Hundreds of thousands of rows on monthly cadence; v1 honest collapse per §7.2 | +| 10 | Label span inclusion | No label lane at v1 (no `MPC:`/`DES:` convention attested) | Differs from ISBN/ORCID deliberately — structure-only carriers | +| 11 | Which alternative written forms does v1 recognize (bare numbers `433`, subscript cycles, old-style systems, satellite `S/` forms)? | REJECT all at v1 with written rationale above; JPL bare-number lane is the documented scope cut | Unparenthesized runs unclaimable without registry; dead systems need distinct grammars; each cut cites §2.1 | + +--- + +## 14. Ambiguity Analysis (Paxman-specific) + +- No inherent designation-vs-designation positional ambiguity — fixed lane shapes eliminate the reordering ambiguity Date exhibits; longest-alternation inside one grammar resolves pack-vs-number nesting, and two distinct designations in one slice are authorial choice (segmentation intended), not lexical ambiguity. +- Same body, distinct designations is not ambiguity — `1997 RO4` and `2007 FK34` (both body 274301) are different canonical values with different provenance rows, exactly as UNSPSC parent-vs-commodity codes share digits but not identity; linkage is MPC orbit knowledge, not syntax, so no rule may merge them. +- Packed vs unpacked is not ambiguity — `J95X00A` and `1995 XA` normalize to one canonical value with one dedup key (ADR-0011 same-entity encodings); the `packed` format restores the spelling, like UNSPSC `native`. +- Case variance is not ambiguity — `2003 cp20` folds to `2003 CP20` deterministically on the unpacked lane, while packed case is preserved deterministically; lane-relative, never a coin flip. +- Staleness is not ambiguity — a designation's meaning is fixed at assignment (unlike UNSPSC code moves); determinism needs no snapshot because there is no registry at v1, and the future registry pins its `Provenance.version` when it lands. + +--- + +## 15. URL Reference (authoritative, fetched 2026-10-07 unless noted) + +| Claim | URL | Kind | +|-------|-----|------| +| Unpacked provisional syntax, letter tables, cycle scheme, surveys, old-style systems, retrospective A-prefix | https://minorplanetcenter.net/iau/info/DesDoc.html | primary | +| Packed provisional/permanent layouts, century codes, cycle letters, survey packs, tilde base-62, comet/satellite columns | https://minorplanetcenter.net/iau/info/PackedDes.html | primary | +| Numbering/naming process, WGSBN Bulletin officiality, 10-year privilege | https://minorplanetcenter.net/iau/info/HowNamed.html | primary | +| Unpacked provisional definition (normative prose mirror) | https://minorplanetcenter.net/mpcops/documentation/provisional-designation-definition/ | primary | +| Cometary designation system (sibling, deferred) | https://docs.minorplanetcenter.net/mpc-ops-docs/designations/cometary-designation-system | primary | +| Dual-status objects (`2060` Chiron, `4015` Wilson-Harrington) | https://docs.minorplanetcenter.net/mpc-ops-docs/designations/dual-status-objects | primary | +| Provisional-designation docs mirror + extended `_` scheme (Oct-2023 newsletter) | https://docs.minorplanetcenter.net/mpc-ops-docs/designations/provisional-designations | primary | +| 80-column optical format (all 80 columns allocated; packed cols 6–12) | https://minorplanetcenter.net/iau/info/OpticalObs.html | primary | +| Designation-identifier API (accepted inputs, packed/unpacked pairs) | https://minorplanetcenter.net/mpcops/documentation/designation-identifier-api/ | primary | +| MPC as single worldwide location (minor planets, comets, outer satellites) | https://minorplanetcenter.net/iau/mpc.html | primary | +| Live equation heading `(274301) Wikipedia = 1997 RO4 = …` | https://www.minorplanetcenter.net/db_search/show_object?object_id=274301 | primary | +| WGSBN naming rules (10-year privilege, Bulletin officiality) | https://www.wgsbn-iau.org/documentation/NamesAndCitations.pdf | primary (snippet-sourced; PDF unfetchable) | +| IAU naming themes page (naming authority context) | https://iauarchive.eso.org/public/themes/naming | primary (snippet-sourced; live page 404) | +| ADES `submit.xsd` (`PermIDType`, `BaseProvIDType`, `OldProvIDType` facets) | https://raw.githubusercontent.com/IAU-ADES/ADES-Master/master/xsd/submit.xsd | primary (schema) | +| ADES format info page | https://minorplanetcenter.net/iau/info/ADES.html | primary | +| JPL SBDB API accepted inputs (`sstr`, `des`) | https://ssd-api.jpl.nasa.gov/doc/sbdb.html | primary (resolution behavior) | +| JPL Horizons Lookup API (`sstr`, space-sensitivity) | https://ssd-api.jpl.nasa.gov/doc/horizons_lookup.html | primary (resolution behavior) | +| JPL planetary satellite discovery table (`S/2000 J11`) | https://ssd.jpl.nasa.gov/sats/discovery.html | primary (sibling namespace) | +| `sbpy.data.Names` parser/packer (reference-quality regex + round-trips) | https://sbpy.readthedocs.io/en/latest/_modules/sbpy/data/names.html | primary | +| `sbpy.data.Names` docs | https://sbpy.readthedocs.io/en/latest/sbpy/data/names.html | primary | +| `astroquery.mpc` target parser (unpacked-only input) | https://astroquery.readthedocs.io/en/latest/_modules/astroquery/mpc/core.html | primary | +| `astroquery.mpc` docs | https://astroquery.readthedocs.io/en/latest/mpc/mpc.html | primary | +| `astroquery.jplhorizons` pass-through + packed case footgun (#3220) | https://github.com/astropy/astroquery/blob/main/astroquery/jplhorizons/core.py | primary | +| `rlseaman/MPC_designations` library + SPECIFICATION.md (closest stdnum analogue) | https://github.com/rlseaman/MPC_designations | primary | +| PyPI `mpc-designation` | https://pypi.org/project/mpc-designation/ | primary | +| `jorbit.utils.mpc` pack/unpack | https://jorbit.readthedocs.io/en/latest/core/misc/mpc.html | primary | +| find_orb `mpc_obs.cpp` (pack/unpack/normalize suite) | https://github.com/Bill-Gray/find_orb/blob/master/mpc_obs.cpp | primary | +| Project Pluto packed-format explainer | https://projectpluto.com/packed.htm | secondary | +| Project Pluto `pack.txt` cycle>620 proposal | https://www.projectpluto.com/pack.txt | secondary | +| MPC NEOCP confirmation page (live temp-ID examples) | https://minorplanetcenter.net/iau/NEO/toconfirm_tabular.html | primary (examples) | +| Rubin PPDB schema (packed/unpacked char fields) | https://sdm-schemas.lsst.io/ppdb.html | primary (fields, not validator) | +| Wikidata P5736 (mandatory `[A-Z0-9][A-Z0-9/ ()-]{0,11}`, examples 2060/18374/37572) | https://www.wikidata.org/wiki/Property:P5736 | primary (constraint mirror) | +| python-stdnum docs index (no MPC/astronomy module; scope statement) | https://arthurdejong.org/python-stdnum/doc/ | primary (negative) | +| validator.js repo (no astronomy validator) | https://github.com/validatorjs/validator.js | primary (negative) | +| Wikipedia provisional designation (lineage + dual-status examples — secondary) | https://en.wikipedia.org/wiki/Provisional_designation_in_astronomy | secondary | +| Wikipedia minor-planet designation (Gould history, JPL-vs-MPC display — secondary) | https://en.wikipedia.org/wiki/Minor-planet_designation | secondary | +| ISSN research precedent (single-grammar) | docs/development/research/2026-08-21-issn-canonicalization.md | primary (repo) | +| IBAN research precedent (checksum-depth complement) | docs/development/research/2026-08-22-iban-canonicalization.md | primary (repo) | +| BIC research precedent (template) | docs/development/research/2026-08-23-bic-canonicalization.md | primary (repo) | +| Paxman scaffolder & conventions | HOW_TO_ADD_NEW_CAPABILITY.md, HOW_TO_ADD_NEW_GRAMMAR.md, ARCHITECTURE.md | primary (repo) | +| Paxman shipped precedent: ISBN grammar + rules + notation | paxman/capabilities/ISBN/grammar/isbn13_recognition.py, paxman/capabilities/ISBN/rules/isbn_range_message_ed2026.py, paxman/capabilities/ISBN/notation.py | primary (repo) | +| Paxman shipped precedent: ISSN grammar + rules + contract | paxman/capabilities/ISSN/grammar/issn_recognition.py, paxman/capabilities/ISSN/rules/iso_3297_ed2022.py, paxman/capabilities/ISSN/contract.py | primary (repo) | +| Paxman shipped precedent: GTIN notation + prefix rule, engine dedup + domain attrs | paxman/capabilities/GTIN/notation.py, paxman/capabilities/GTIN/rules/gs1_prefix_ed2026.py, paxman/engine/orchestrator.py, paxman/core/domain.py | primary (repo) | + +--- + +## 16. Evidence Completion — Resolved + +This report's minor-planet-specific authoritative evidence has been fetched and cited (2026-10-07): + +- [x] Owner + RA pages: MPC (IAU; CfA-hosted, NASA-funded) owns provisional/packed/numbering; UNGM-style search (`db_search`) + machine identifier API; lineage 1892 → 1893 → 1916 → 1925 → surveys → 1994 comet reform → 2023 extended packing (packed-introduction date honestly unknown) +- [x] NOT-an-ISO-standard proved (no catalogue entry; ADES is an IAU XML schema sibling, not a parent) with related-scheme map (comets/satellites/JPL mirrors/WGSBN names) +- [x] Structure: unpacked `YYYY LL[n]` + half-month/second letter tables + cycle/subscript scheme + survey forms + A-prefix retrospective, ASCII, space-significant, ≥8 worked examples across all lanes +- [x] No checksum proved (PackedDes all-position exhaustiveness + 80-column exhaustiveness — two independent sources, zero check algorithms published; third-party pack tools concur) +- [x] Packed decode coherence: century `IJK`, cycle letter×10+digit (A=10…Z=35/a=36…z=61, verified against `K99AJ3Z`/`K07Tf8A`), tilde base-62 minus-620000 (arithmetic verified against `~AZaz`/`~zzzz`), survey packs, extended `_` shape (year-letter mapping flagged for implementation-time verification) +- [x] Case-lane finding: unpacked folds, packed case-significant (`a0017` vs `A0345` trap pair), enforced by omitting `re.IGNORECASE` +- [x] Ecosystem regex consensus: ADES XSD facets + sbpy + astroquery + MPC identifier API + JPL SBDB/Horizons + Wikidata P5736 (plus stdnum/validator.js/astropy confirmed absence, digest2/mpcorbfile consumer-only) +- [x] Recognition-surface inventory complete (§2.1): 19 rows, every spec/schema/≥2-validator form listed with evidence and a RECOGNIZE/DEFER/REJECT disposition — no silently unhandled form +- [x] Wild input shapes validated (§2.2, 18 categories) against DesDoc/PackedDes/HowNamed + ADES + NEOCP + sbpy/astroquery/JPL/MPC-API +- [x] Label scope decision (no label lane — structure-only carriers, deliberate ISBN/ORCID difference) +- [x] Same-entity (packed↔unpacked) vs distinct-identity (linkages, parents-vs-numbers) decisions +- [x] Separator decision (space + underscore RECOGNIZE; glued/dotted/hyphenated REJECT with evidence — ISBN/Phone analogy applied, not assumed) +- [x] Registry scope decision (PARSER-only v1 + named MPCORB LOOKUP_TABLE upgrade; placebo-flag lesson recorded) +- [x] File layout / rule provenance in §5.2 / §11 / §12 frozen for implementation (pending scaffolder invocation per HOW_TO_ADD_NEW_CAPABILITY.md Step 0) + +--- + +## Appendix — What the Shipped ISBN, ISSN, GTIN and Country Capabilities Teach MinorPlanet (verbatim precedent) + +> The following precedent is **verbatim-sourced from the codebase** (explored 2026-10-07 on `feature/unspsc-capability`, not speculative) and anchors the proposal to what Paxman already ships. + +ISBN (`paxman/capabilities/ISBN/grammar/isbn13_recognition.py:30-40`) teaches the single-grammar staged pipeline (`PipelineGrammar` + `StandardPre(empty_guard=True)` + `RegexStage` with module-scope pattern, fused label, lookarounds, `single_value=True`). ISSN (`paxman/capabilities/ISSN/grammar/issn_recognition.py:41-60`) teaches one grammar covering two spellings (optional separator at the canonical position, `LabelMatcher`, `BoundarySpec.WORD`, `re.IGNORECASE | re.ASCII`) — and, by contrast, why MinorPlanet omits `IGNORECASE` (packed case significance). ISSN's rule (`rules/iso_3297_ed2022.py:48-53`, `Section 4-issn-check-digit`, PARSER, `target_semantics`/`requires_features` frozensets) is the direct model for MinorPlanet's five PARSER rules, including the ADR-0012 vacuity posture. GTIN (`notation.py:20-22`) teaches the notation facet pattern (`digits` + `native_length` + trace-only `has_ai`; MinorPlanet analog: `designation` + `form` + case-exact `packed`), and GTIN's Section-2 rule (`rules/gs1_prefix_ed2026.py:96-109`, full shape re-check before prefix lookup) is the model for lane-strict `matches()`. The engine (`paxman/engine/orchestrator.py`: `_dedup_spans` per-grammar longer-wins at `:463-492`, total order at `:431-442`, `_dedup_candidates` on `(value, recognition_rule, validation_rule)` at `:843-847`, `_enforce_single_value_invariant` at `:625-683`, MISSING-vs-INVALID at `:882-889`) teaches that packed/unpacked same-value candidates coalesce and that dropped-rule vs unclaimed-shape produce INVALID vs MISSING. The domain (`paxman/core/domain.py`: six enforced attrs at `:253-278`, `RecognitionMatch` half-open spans at `:78-86`) fixes the rule/span contract. The four architectural lessons for MinorPlanet: + +1. **Grammar strips, rule validates, capability formats.** Case/space normalization and pack/unpack transcoding are syntax (grammar facets); letter-sets and decode coherence are semantics (rules); packed rendering is display (`format_value`). No layer borrows another's job. +2. **One file per provenance, one class per section.** Unpacked-definition + packed-spec + numbering stay in three files with three `PUBLICATION` constants; `Section N-*` naming throughout. +3. **No `output_format` in rules, ever.** `packed` appears only in `capability.py`; `normalize()` always returns the unpacked `designation`. +4. **Single grammar with lane alternation avoids spurious AMBIGUOUS; full-conjunction rules avoid vacuous SUCCESS.** The packed-contains-number nesting is handled inside one pattern; each rule re-checks its full lane so pinned contracts cannot validate off-lane garbage. + +--- + +*Report saved to `docs/development/research/` (this directory) per MILESTONE guidance for MinorPlanet. It mirrors the structure, depth, and provenance discipline of `docs/development/research/2026-08-22-iban-canonicalization.md` and `docs/development/research/2026-08-23-bic-canonicalization.md` and the deeper ISBN precedent. For implementation, start from `tools/new_capability.py` scaffolder per HOW_TO_ADD_NEW_CAPABILITY.md Step 0.* + +*Note: `docs/development/` is ephemeral per `docs/development/AGENTS.md` — not shipped, may drift, may be removed without notice, and must not be referenced by code or shipped docs.* diff --git a/docs/user/api-reference.md b/docs/user/api-reference.md index ac150f22..04ed0fad 100644 --- a/docs/user/api-reference.md +++ b/docs/user/api-reference.md @@ -203,6 +203,7 @@ Current defaults and offered alternatives: | CreditCard | `pan` | `grouped` | | Language | `bcp47` | `alpha2`, `alpha3`, `alpha3-bib`, `name` | | MacAddress | `colon` | `hyphen`, `bare`, `cisco`, `eui64` | +| MinorPlanet | `designation` | `packed` | | ORCID | `orcid` | `uri`, `compact` | | Timezone | `iana` | *(none — single format)* | | UtcOffset | `extended` | `basic` | diff --git a/docs/user/capabilities/index.md b/docs/user/capabilities/index.md index cd61d389..14a87ecd 100644 --- a/docs/user/capabilities/index.md +++ b/docs/user/capabilities/index.md @@ -52,6 +52,7 @@ For the shared concepts behind these pages see [Contracts](../concepts/contracts | `10.1038/nature12345`, `https://doi.org/10.1038/…`, `doi:10.1038/…` | [DOI](doi/) | | `example.com`, `münchen.de`, `XN--MNCHEN-3YA.de` | [Domain](domain/) | | `UNSPSC 44103103`, `441217`, `UNSPSC000.44103103` | [UNSPSC](unspsc/) | +| `1995 XA`, `J95X00A`, `(433)`, `2040 P-L` | [MinorPlanet](minor_planet/) | > The set above reflects the **current release**. New capabilities are added in minor releases — check `paxman.capabilities` or the latest release notes if you don't see what you need. diff --git a/docs/user/capabilities/minor_planet.md b/docs/user/capabilities/minor_planet.md new file mode 100644 index 00000000..4a42392a --- /dev/null +++ b/docs/user/capabilities/minor_planet.md @@ -0,0 +1,60 @@ +--- +title: "MinorPlanet" +--- + +Canonicalizes **one minor-planet mention** per call — an unpacked provisional (`1995 XA`, `2007 TA418`), a retrospective (`A904 OA`) or `A/`-prefixed (`A/2017 U1`) designation, a packed 7-char (`J95X00A`, `K07Tf8A`) or extended `_` (`_QC0000`) form, a survey designation in either spelling (`2040 P-L` / `PLS2040`), a parenthesized number (`(433)`), or a packed number (`03202`, `a0017`, `~000z`) — to the unpacked canonical. + +> **In plain language:** give it `J95X00A`, `1995 XA`, `PLS2040`, or `(433) Eros` and it hands back `1995 XA` / `2040 P-L` / `(433)` with the MPC publication cited. Structure-only at v1 (no registry): a well-formed but never-assigned shape is `SUCCESS`, never a guess about issuance; shapes outside the lanes are `MISSING`. + +--- + +## What it recognizes — and what it does not + +| Recognizes | Does not recognize | +|------------|--------------------| +| Unpacked provisionals (`1995 XA`, `2007 TA418`, `1992 QB1`) | Glued runs (`1995XA`) → `MISSING` (separator required) | +| Spaced lowercase/underscore (`2003 cp20`, `1995 xa`, `1995_XA` → upper/space canonical) | Glued lowercase (`2003cp20`) → `MISSING` (JPL search liberality is resolution, not grammar) | +| Retrospective (`A904 OA`) and `A/`-prefixed (`A/2017 U1`, 1–2 letters) | Old-style systems (`1892 A`, `1914 VV`, `SIGMA 27`) → `MISSING` (superseded 1925) | +| Packed 7-char, case-exact (`J95X00A`, `K07Tf8A`; `a0017` ≠ `A0345`) | Bad-century packs (`Q95X00A`) → `MISSING` (century is lane-defining) | +| Extended `_` packs (`_QC0000`), survey packs (`PLS2040`, `T1S3138`) | Comet/satellite forms (`C/1995 O1`, `S/2000 J 11`, `1I/2017 U1`) → `MISSING` (deferred/disjoint namespaces) | +| Surveys either spelling (`2040 P-L` / `PLS2040`) | Bare numbers (`433`) → `MISSING` (parens load-bearing) | +| Parenthesized numbers (`(433)`, `(274301)`; name outside span in `(433) Eros`) | Bare names (`Eros`) → `MISSING` (names lane deferred) | +| Packed numbers (`03202`, `A0345`, `a0017`, `~000z`, `~AZaz`) | Subscript cycles (`1995 SA₁`) → `MISSING` (ASCII-only wire) | + +Known v1 overclaims (no registry): `(555)`-shaped phone area codes match the number lane and bare 5-digit runs match the packed-number lane. Segment phone/ZIP contexts before calling; the future MPCORB snapshot disambiguates. + +--- + +## Canonical output + +Default `output_format` is `"designation"` (unpacked canonical). + +| `output_format` | Renders | Example | +|-----------------|---------|---------| +| *(default)* `designation` / `None` / `"default"` | Unpacked canonical | `1995 XA` | +| `packed` | Case-exact wire encoding | `J95X00A` | + +Any other value raises `ContractError`. The `packed` rendering re-enters under the default contract (ADR-0010 fixed point); the `A/` lane defines no packed mapping and falls back to the designation. + +```python +from paxman.capabilities import MinorPlanet +import paxman + +paxman.register_all_shipped() +print(paxman.canonicalize("J95X00A", MinorPlanet.create_contract()).canonicalized_value) +print(paxman.canonicalize("(433) Eros", MinorPlanet.create_contract()).canonicalized_value) +print(paxman.canonicalize("1995 XA", MinorPlanet.create_contract(output_format="packed")).canonicalized_value) +``` + +--- + +## Contract + +```python +contract = MinorPlanet.create_contract( + output_format=None, # "designation" (default); "packed" offered + # plus every common field: suppress_common_words / excluded_rules / pinned_rules / year / extra_grammars +) +``` + +No capability-specific flags at v1. `year` filters rules by `publication_year` (all three publications are 2026 living documents). diff --git a/docs/user/citations.md b/docs/user/citations.md index 035712d2..dcbc180d 100644 --- a/docs/user/citations.md +++ b/docs/user/citations.md @@ -2,7 +2,7 @@ title: "Citations" --- -Every validated value in Paxman carries **provenance** — the authority, specification, version, and section that vouches for it. This page is the curated, authority-grouped index of **all cited provenance** across the 28 shipped capabilities. Use it to audit results, write methods sections, or compare Paxman against another system. +Every validated value in Paxman carries **provenance** — the authority, specification, version, and section that vouches for it. This page is the curated, authority-grouped index of **all cited provenance** across the 30 shipped capabilities. Use it to audit results, write methods sections, or compare Paxman against another system. > **How to read provenance in code:** `result.candidates[n].provenance[0]` is a `Provenance` (`authority`, `specification_name`, `version`, `publication_year`, `reference_url`, `kind`, `lifecycle`) and `candidate.validation_rule` is the section citation (e.g. `Section 3.4.1-addr-spec`). See [Provenance](concepts/provenance/) for the object shape. @@ -38,6 +38,7 @@ All entries below are derived from the `PUBLICATION` constants and `citation`/`n | **WHATWG** | URL Standard (Living Standard) | URL | 1 | | **IEEE** | IEEE Std 802-2024 | MacAddress | 1 | | **IUPAC** | IUPAC Periodic Table 04 May 2022, IUPAC Red Book 2005 Ch. IR-3 | ChemicalElement | 2 | +| **Minor Planet Center** | New- And Old-Style Minor Planet Designations (DesDoc); Packed Provisional and Permanent Designations (PackedDes); How Are Minor Planets Named? (HowNamed) | MinorPlanet | 5 | | **UNDP** | UNSPSC Code Structure (UNGM Help Center); UNSPSC Codeset (UNGM live export 2026-09-30) | UNSPSC | 3 | | **UNECE** | Classification Guidelines (Business Function Identifiers) v2.04 | UNSPSC | 1 | | **Derived convention** | US locale — MM/DD/YYYY, European locale — DD/MM/YYYY | Date | 2 | @@ -316,6 +317,24 @@ Specification: **Classification Guidelines (Business Function Identifiers)** — --- +## Minor Planet Center — International Astronomical Union + +Specification: **New- And Old-Style Minor Planet Designations (DesDoc)** — `https://minorplanetcenter.net/iau/info/DesDoc.html` — `kind="specification"`, `version="living document (fetched 2026-10-07)"`, `lifecycle="active"`, `publication_year=2026`. + +Specification: **Packed Provisional and Permanent Designations (PackedDes)** — `https://minorplanetcenter.net/iau/info/PackedDes.html` — `kind="specification"`, `version="living document (fetched 2026-10-07)"`, `lifecycle="active"`, `publication_year=2026`. + +Specification: **How Are Minor Planets Named? (HowNamed)** — `https://minorplanetcenter.net/iau/info/HowNamed.html` — `kind="specification"`, `version="living document (fetched 2026-10-07)"`, `lifecycle="active"`, `publication_year=2026`. + +| Capability | Rule | Citation | +|------------|------|----------| +| MinorPlanet | `Section 1-unpacked-provisional-structure` | Year lane, half-month/second letter sets, cycle digits, `A/` lane | +| MinorPlanet | `Section 2-survey-designation` | Palomar-Leiden / Trojan survey number + identifier | +| MinorPlanet | `Section 3-packed-provisional` | 7-char, survey-pack, and extended decode coherence | +| MinorPlanet | `Section 4-packed-number` | 5-char, letter, and tilde number decode coherence | +| MinorPlanet | `Section 5-permanent-number` | Parenthesized positive integer, no leading zeros | + +--- + ## Derived conventions — Date (non-authoritative) These two Date rules carry `authority="Derived convention"`, `kind="convention"` and no `reference_url`. They document widely-used locale conventions rather than an external specification. diff --git a/docs/user/concepts/capabilities.md b/docs/user/concepts/capabilities.md index a3a10a10..4c492565 100644 --- a/docs/user/concepts/capabilities.md +++ b/docs/user/concepts/capabilities.md @@ -96,6 +96,7 @@ The set below reflects the **current release** and is intentionally not presente | **CreditCard** | Payment card numbers (ISO/IEC 7812-1) | compact 12–19-digit runs, space/hyphen groupings (4-4-4-4, 4-6-5, 14-digit), `PAN` labels | contiguous digits (`"4111111111111111"`); `grouped` offers groups-of-four | | **Domain** | Hostnames (RFC 1034/1035, UTS #46, RFC 5893, IANA Root Zone) | bare FQDNs any case, root dots, U-labels, ACE labels, fullwidth/dot variants, embedded mentions | lowercase A-label (`"xn--mnchen-3ya.de"`); `unicode` offers the U-label rendering | | **UNSPSC** | Product/service codes (UNDP UNSPSC) | bare 8-digit, zero-padded parents, 6-digit aliases, `UNSPSC` labels, `UNSPSC000.` MDM IDs, 10-digit +BFI | 8-digit wire stem (`"44103103"`); `labeled`/`native` offered | +| **MinorPlanet** | Minor-planet designations (MPC) | unpacked provisionals, packed 7-char, extended `_`, surveys both spellings, parenthesized and packed numbers | unpacked canonical (`"1995 XA"`); `packed` offered | > This table is an overview. Each capability's contract documents its specific flags (e.g. `include_localized` for Country, `default_country` for Phone). See [Contracts](contracts/) and the [README](https://github.com/nexusnv/paxman-python#readme) examples for per-capability details; each row is expanded into its own guide under [Capabilities](../capabilities/). diff --git a/docs/user/glossary.md b/docs/user/glossary.md index 9130fbc7..8b44295f 100644 --- a/docs/user/glossary.md +++ b/docs/user/glossary.md @@ -124,6 +124,8 @@ Common terms used throughout Paxman — in the library API, the pipeline, the ca **UNSPSC** — United Nations Standard Products and Services Code (UNDP). Paxman capability with 8-digit wire-stem canonical (`44103103`); `labeled`, `native` offered. See [Citations](citations/). +**MinorPlanet** — Minor-planet designations (IAU Minor Planet Center). Paxman capability with unpacked canonical (`1995 XA`); `packed` offered. See [Citations](citations/). + **LEI** — Legal Entity Identifier (ISO 17442-1:2020, GLEIF LOU prefix list). Paxman capability. See [Citations](citations/). **IETF** — Internet Engineering Task Force. Authority for RFC 5322, RFC 6761, RFC 791, RFC 5952, RFC 3966, BCP 47 RFC 5646. See [Citations](citations/). diff --git a/docs/user/migration.md b/docs/user/migration.md index 68554542..7a57da26 100644 --- a/docs/user/migration.md +++ b/docs/user/migration.md @@ -367,6 +367,10 @@ check digit plus ANNA ISIN Guidelines V25 prefix allowlist) is registered by contracts are byte-identical. See the [ISIN guide](capabilities/isin/) and the [Citations](citations/) ISO/ANNA rows. +### Unreleased — New capability: MinorPlanet (additive) + +`MinorPlanet` (unpacked canonical `1995 XA` / `(433)` / `2040 P-L`, `packed` offered re-encoding, MPC DesDoc + PackedDes + HowNamed structure-only, no registry) is registered by `register_all_shipped()` (now 30 shipped). No migration required — existing contracts are byte-identical. See the [MinorPlanet guide](capabilities/minor_planet/) and the [Citations](citations/) Minor Planet Center rows. + ### Unreleased — New capability: UNSPSC (additive) `UNSPSC` (8-digit wire-stem canonical, `labeled` and `native` offered re-encodings, UNGM structure + UNDP codeset snapshot + UNECE BFI suffix) is registered by `register_all_shipped()` (now 29 shipped). No migration required — existing contracts are byte-identical. See the [UNSPSC guide](capabilities/unspsc/) and the [Citations](citations/) UNDP/UNECE rows. diff --git a/paxman/api/bootstrap.py b/paxman/api/bootstrap.py index 5cc7a1d1..d00e0797 100644 --- a/paxman/api/bootstrap.py +++ b/paxman/api/bootstrap.py @@ -29,6 +29,7 @@ Email, Language, MacAddress, + MinorPlanet, Money, Phone, SIUnit, @@ -62,6 +63,7 @@ Language, LEI, MacAddress, + MinorPlanet, Money, ORCID, Phone, diff --git a/paxman/capabilities/AGENTS.md b/paxman/capabilities/AGENTS.md index daa063ee..66c2218e 100644 --- a/paxman/capabilities/AGENTS.md +++ b/paxman/capabilities/AGENTS.md @@ -1,7 +1,7 @@ # CAPABILITIES KNOWLEDGE BASE ## OVERVIEW -The deepest directory in the repo (367 py files): 27 shipped capability packages (BIC, ChemicalElement, Coordinates, Country, CreditCard, Currency, Date, DOI, Domain, Email, GTIN, IBAN, IP, ISBN, ISIN, ISSN, Language, LEI, MacAddress, Money, ORCID, Phone, SI Unit, Timezone, URL, UtcOffset, UUID), each an independent recognize→validate→resolve mini-system wired into the shared pipeline via `paxman.core`. Each package is self-contained: grammars recognize representations, rules assign meaning with provenance, the contract selects what runs, and `format_value()` renders the result. +The deepest directory in the repo (367 py files): 30 shipped capability packages (BIC, ChemicalElement, Coordinates, Country, CreditCard, Currency, Date, DOI, Domain, Email, GTIN, IBAN, IP, ISBN, ISIN, ISNI, ISSN, Language, LEI, MacAddress, MinorPlanet, Money, ORCID, Phone, SI Unit, Timezone, UNSPSC, URL, UtcOffset, UUID), each an independent recognize→validate→resolve mini-system wired into the shared pipeline via `paxman.core`. Each package is self-contained: grammars recognize representations, rules assign meaning with provenance, the contract selects what runs, and `format_value()` renders the result. **Authoritative spec:** the root `HOW_TO_ADD_NEW_CAPABILITY.md` (62KB — read it before touching this directory). This file is the compact governance reference: intended architecture, hard rules, and known legacy exceptions. Where the two differ, HOW_TO wins. @@ -64,5 +64,5 @@ Every capability must conform to the same structural surface. `CapabilityContrac - When extending an existing capability, check whether the file you're touching is a flagged legacy exception before copying its style; new code follows the intended architecture. ## NOTES -- `__init__.py` exports all twenty-seven shipped capabilities; completeness is enforced by `tests/unit/test_capability_exports.py`. IBAN, ISSN, ISIN, DOI, LEI, and CreditCard are the minimal-surface members (1 grammar + 1–2 rules each). +- `__init__.py` exports all thirty shipped capabilities; completeness is enforced by `tests/unit/test_capability_exports.py`. IBAN, ISSN, ISIN, DOI, LEI, and CreditCard are the minimal-surface members (1 grammar + 1–2 rules each). - Root AGENTS.md is authoritative for pipeline flow, domain objects, and quality gates; this file adds capability-package structure and governance specifics. diff --git a/paxman/capabilities/MinorPlanet/__init__.py b/paxman/capabilities/MinorPlanet/__init__.py new file mode 100644 index 00000000..b2eb7ac2 --- /dev/null +++ b/paxman/capabilities/MinorPlanet/__init__.py @@ -0,0 +1,9 @@ +"""MinorPlanet capability package.""" + +from __future__ import annotations + +from paxman.capabilities.MinorPlanet.capability import MinorPlanetCapability +from paxman.capabilities.MinorPlanet.contract import MinorPlanetContract +from paxman.capabilities.MinorPlanet.notation import MinorPlanetNotation + +__all__ = ["MinorPlanetCapability", "MinorPlanetContract", "MinorPlanetNotation"] diff --git a/paxman/capabilities/MinorPlanet/capability.py b/paxman/capabilities/MinorPlanet/capability.py new file mode 100644 index 00000000..18a7e281 --- /dev/null +++ b/paxman/capabilities/MinorPlanet/capability.py @@ -0,0 +1,115 @@ +"""MinorPlanet capability — wires grammars and rules together.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from paxman.capabilities.MinorPlanet.contract import MinorPlanetContract +from paxman.capabilities.MinorPlanet.grammar.minor_planet_recognition import ( + MinorPlanetRecognitionGrammar, +) +from paxman.capabilities.MinorPlanet.notation import MinorPlanetNotation +from paxman.capabilities.MinorPlanet.rules.mpc_codec import mpc_pack +from paxman.capabilities.MinorPlanet.rules.mpc_numbering import ( + Section5PermanentNumber, +) +from paxman.capabilities.MinorPlanet.rules.mpc_packed_designation import ( + Section3PackedProvisional, + Section4PackedNumber, +) +from paxman.capabilities.MinorPlanet.rules.mpc_unpacked_designation import ( + Section1UnpackedProvisionalStructure, + Section2SurveyDesignation, +) +from paxman.core.capability import Capability +from paxman.core.domain import Grammar, Rule + + +class MinorPlanetCapability(Capability[MinorPlanetNotation]): + """Minor-planet canonicalization capability. + + Recognizes unpacked/packed/survey/numbered minor-planet lanes via + :class:`MinorPlanetRecognitionGrammar` and validates structure-only + (PARSER, no registry) against MPC DesDoc/PackedDes/HowNamed, with full + provenance. Canonical form is the unpacked designation + (``1995 XA`` / ``2040 P-L`` / ``(433)``); ``packed`` is the offered + same-entity wire encoding. + """ + + name = "minor_planet" + version = "1.0.0" + + def get_grammars(self) -> list[Grammar[MinorPlanetNotation]]: + """Return the default grammar instances.""" + return [MinorPlanetRecognitionGrammar()] + + def get_rules(self) -> list[Rule[MinorPlanetNotation]]: + """Return the default validation rule instances.""" + return [ + Section1UnpackedProvisionalStructure(), + Section2SurveyDesignation(), + Section3PackedProvisional(), + Section4PackedNumber(), + Section5PermanentNumber(), + ] + + @staticmethod + def create_contract( + *, + excluded_rules: Sequence[str] | None = None, + pinned_rules: Sequence[str] | None = None, + year: int | None = None, + output_format: str | None = None, + extra_grammars: Sequence[str] | None = None, + suppress_common_words: bool = False, + ) -> MinorPlanetContract: + """Factory method for creating contracts with proper defaults. + + Args: + excluded_rules: Rule names to exclude. + pinned_rules: Pin to specific rules (takes precedence over + excluded_rules). + year: Year for temporal filtering. + output_format: Output format for canonical values. Optional; + None/"default"/"designation" resolve to "designation". + extra_grammars: Community grammar names (opt-in) to run + alongside the shipped grammars, in order (SEAM — the + surface guard's common block ends with this parameter). + suppress_common_words: Suppress common-word spans. + + Returns: + Configured MinorPlanetContract instance. + """ + return MinorPlanetContract( + excluded_rules=tuple(excluded_rules) if excluded_rules else (), + pinned_rules=tuple(pinned_rules) if pinned_rules is not None else None, + year=year, + output_format=output_format, + extra_grammars=tuple(extra_grammars) if extra_grammars else (), + suppress_common_words=suppress_common_words, + ) + + def format_value( + self, + value: str, + output_format: str | None, + notation: MinorPlanetNotation, + ) -> str: + """Render the canonical designation in the requested format. + + The default ``"designation"`` path is the identity. ``"packed"`` + renders via the shared ``mpc_pack`` helper owned by rules/capability + (the ``A/`` lane defines no packed mapping and falls back to the + designation). Never affects candidate identity or provenance. + """ + if output_format == "packed": + try: + rendered = mpc_pack(value) + if rendered is not None: + return rendered + if notation.packed: + return notation.packed + except (TypeError, AttributeError, ValueError): + pass + return value + return value diff --git a/paxman/capabilities/MinorPlanet/contract.py b/paxman/capabilities/MinorPlanet/contract.py new file mode 100644 index 00000000..62110339 --- /dev/null +++ b/paxman/capabilities/MinorPlanet/contract.py @@ -0,0 +1,40 @@ +"""MinorPlanet contract — user-facing configuration.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import ClassVar + +from paxman.core.capability_contract import CapabilityContract + + +@dataclass(frozen=True) +class MinorPlanetContract(CapabilityContract): + """User-facing configuration for the MinorPlanet capability. + + Formats (ADR-0011 classes): ``designation`` is the unpacked canonical + (``1995 XA`` / ``(433)`` / ``2040 P-L``, default); ``packed`` is the + same-entity wire encoding — encoding (``J95X00A`` / ``03202`` / + ``PLS2040``, case-exact, re-enters through the packed branch). + + Attributes: + capability_name: Fixed to "minor_planet" (not user-settable). + output_format: Canonical output format — "designation" by default, + "packed" offered. Optional — None/"default"/"designation" all + resolve to "designation". + excluded_rules: Tuple of rule names to exclude. + pinned_rules: Pin to specific rules (takes precedence over + excluded_rules). + year: Year for temporal filtering. + extra_grammars: Community grammar names (opt-in) to run alongside + the shipped grammars, in order (SEAM — inherited from base). + suppress_common_words: Suppress common-word spans (inherited). + """ + + DEFAULT_OUTPUT_FORMAT: ClassVar[str] = "designation" + OFFERED_OUTPUT_FORMATS: ClassVar[frozenset[str]] = frozenset({"packed"}) + + capability_name: str = field(default="minor_planet", init=False) + + def __post_init__(self) -> None: + super().__post_init__() diff --git a/paxman/capabilities/MinorPlanet/grammar/__init__.py b/paxman/capabilities/MinorPlanet/grammar/__init__.py new file mode 100644 index 00000000..aad74e60 --- /dev/null +++ b/paxman/capabilities/MinorPlanet/grammar/__init__.py @@ -0,0 +1 @@ +"""MinorPlanet recognition grammars.""" diff --git a/paxman/capabilities/MinorPlanet/grammar/minor_planet_recognition.py b/paxman/capabilities/MinorPlanet/grammar/minor_planet_recognition.py new file mode 100644 index 00000000..6fc63cf3 --- /dev/null +++ b/paxman/capabilities/MinorPlanet/grammar/minor_planet_recognition.py @@ -0,0 +1,135 @@ +"""Minor-planet recognition grammar (kernel RegexMatcher). + +Single ``minor_planet_recognition`` grammar with lane alternation: +unpacked provisional (plus retrospective ``A8xx``/``A9xx`` and ``A/`` +1-2-letter lanes, underscore separator), packed 7-char (case-sensitive), +extended ``_`` base-62 (case-sensitive), survey unpacked + packed, +parenthesized numbers, and packed numbers. Syntax only — case/space +normalization and lane dispatch; decode coherence lives in rules +(`rules/mpc_codec.py`), never imported here. +""" + +from __future__ import annotations + +import re + +from paxman.capabilities.MinorPlanet.notation import MinorPlanetNotation +from paxman.core.grammar import ( + AnchorSet, + BoundarySpec, + PipelineGrammar, + StandardPre, +) +from paxman.core.grammar.matchers.regex import RegexMatcher +from paxman.core.grammar.scan_context import ScanContext + +# No re.IGNORECASE: unpacked lanes fold case explicitly ([A-Za-z] + +# .upper()), packed lanes are case-SIGNIFICANT (a0017 vs A0345). +_YEAR = r"(?:A[89]\d{2}|19\d{2}|20\d{2})" +_UNPACKED = ( + r"(?P" + _YEAR + r")[ _]" + r"(?P[A-Za-z])(?P[A-Za-z])(?P\d*)" +) +_APREFIX = ( + r"A/(?P" + _YEAR + r")[ _]" + r"(?P[A-Za-z])(?P[A-Za-z])?(?P\d*)" +) +_SURVEY = r"(?P[1-9]\d{0,3})[ _](?P[Pp]-[Ll]|[Tt]-[123])" +_PACKED7 = r"(?P[IJK]\d{2}[A-Z][0-9A-Za-z]\d[A-Z])" +_EXTENDED = r"(?P_[0-9A-Za-z][A-Z][0-9A-Za-z]{4})" +_SURVEYPACKED = r"(?P(?:PLS|T[123]S)\d{3,4})" +# Bare `\d{5}` must not carve a 5-digit prefix out of an over-long year +# (`19955 XA`): refuse the digit lane when a separator + letter follows. +_PACKEDNUM = r"(?P(?:\d{5}(?![ _][A-Za-z])|[A-Za-z]\d{4}|~[0-9A-Za-z]{4}))" +_NUMBER = r"\((?P\d{1,8})\)" +_MP_BODY = ( + _APREFIX + + r"|" + + _UNPACKED + + r"|" + + _SURVEY + + r"|" + + _PACKED7 + + r"|" + + _EXTENDED + + r"|" + + _SURVEYPACKED + + r"|" + + _PACKEDNUM + + r"|" + + _NUMBER +) +# Kernel spelling: BoundarySpec.WORD owns word edges; the trailing +# (?![-]\d) stays inline (ISBN-13/UNSPSC precedent: no hyphen-digit carve). +_MP_PATTERN = r"(?:" + _MP_BODY + r")(?![-]\d)" +_LANE_RE = re.compile(r"^(?:" + _MP_BODY + r")$", re.ASCII) + + +def _emit(span: tuple[int, int], ctx: ScanContext) -> MinorPlanetNotation: + """Map a matched span to its lane-dispatched notation.""" + raw = ctx.text[span[0] : span[1]] + m = _LANE_RE.match(raw) + if m is None: # pragma: no cover — matcher already matched this span + return MinorPlanetNotation(designation=raw, form="provisional", packed="") + if m.group("ayear") is not None: + designation = ( + f"A/{m.group('ayear')} " + f"{m.group('ahm')}{m.group('asecond') or ''}{m.group('acycle')}" + ).upper() + return MinorPlanetNotation( + designation=designation, form="provisional", packed="" + ) + if m.group("year") is not None: + designation = ( + f"{m.group('year')} {m.group('hm')}{m.group('second')}{m.group('cycle')}" + ).upper() + return MinorPlanetNotation( + designation=designation, form="provisional", packed="" + ) + if m.group("surveynum") is not None: + designation = f"{int(m.group('surveynum'))} {m.group('survey').upper()}" + return MinorPlanetNotation(designation=designation, form="survey", packed="") + if m.group("packed7") is not None: + return MinorPlanetNotation( + designation=m.group("packed7"), form="packed", packed=m.group("packed7") + ) + if m.group("extended") is not None: + return MinorPlanetNotation( + designation=m.group("extended"), + form="extended", + packed=m.group("extended"), + ) + if m.group("surveypacked") is not None: + return MinorPlanetNotation( + designation=m.group("surveypacked"), + form="survey_packed", + packed=m.group("surveypacked"), + ) + if m.group("packednum") is not None: + return MinorPlanetNotation( + designation=m.group("packednum"), + form="packed_number", + packed=m.group("packednum"), + ) + return MinorPlanetNotation(designation=m.group(0), form="number", packed="") + + +_MATCHER = RegexMatcher( + pattern=_MP_PATTERN, + flags=re.ASCII, + boundary=BoundarySpec.WORD, + view=None, + anchors=AnchorSet(), + emit=_emit, +) + + +class MinorPlanetRecognitionGrammar(PipelineGrammar[MinorPlanetNotation]): + """Minor-planet recognition: unpacked/packed/survey/number lanes.""" + + name = "minor_planet_recognition" + semantics = "minor_planet_recognition" + single_value = True + + pre = StandardPre[MinorPlanetNotation](empty_guard=True) + matchers = (_MATCHER,) diff --git a/paxman/capabilities/MinorPlanet/notation.py b/paxman/capabilities/MinorPlanet/notation.py new file mode 100644 index 00000000..aafb3bdf --- /dev/null +++ b/paxman/capabilities/MinorPlanet/notation.py @@ -0,0 +1,26 @@ +"""MinorPlanet notation — grammar-normalized designation form.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class MinorPlanetNotation: + """Minor-planet notation — grammar-normalized designation form. + + ``designation`` is the syntax-normalized spelled form (unpacked lanes + uppercased, single-spaced: ``1995 XA``; surveys ``2040 P-L``; numbers + parenthesized ``(433)``; ``A/``-prefixed ``A/2017 U1``; packed lanes + preserved byte-for-byte, case-exact). + ``form`` is the recognized lane (``provisional`` | ``packed`` | + ``extended`` | ``survey`` | ``survey_packed`` | ``number`` | + ``packed_number``) — a free str, not a Literal; the rules own lane truth. + ``packed`` is the spelled packed form, case-EXACT, for packed lanes + (``J95X00A``; ``a0017`` lowercase preserved) and "" for unpacked/`A/` + lanes. Rules own decode coherence; the capability owns pack rendering. + """ + + designation: str + form: str + packed: str diff --git a/paxman/capabilities/MinorPlanet/rules/__init__.py b/paxman/capabilities/MinorPlanet/rules/__init__.py new file mode 100644 index 00000000..016951da --- /dev/null +++ b/paxman/capabilities/MinorPlanet/rules/__init__.py @@ -0,0 +1 @@ +"""MinorPlanet validation rules.""" diff --git a/paxman/capabilities/MinorPlanet/rules/mpc_codec.py b/paxman/capabilities/MinorPlanet/rules/mpc_codec.py new file mode 100644 index 00000000..497eb9f4 --- /dev/null +++ b/paxman/capabilities/MinorPlanet/rules/mpc_codec.py @@ -0,0 +1,216 @@ +"""MPC pack/unpack codec — rules-owned shared tables (never grammar). + +Mirrors ``sbpy.data.Names.to_packed`` / ``from_packed`` arithmetic against +MPC PackedDes: century ``IJK`` = 18/19/20, cycle letter values +A=10..Z=35/a=36..z=61, tilde base-62 minus-620000, survey packs, and the +LSST-era extended ``_`` scheme. Pure functions returning ``None`` on +incoherent input (never raise); imported by rules and the capability only. +""" + +from __future__ import annotations + +_BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +_NO_I = "ABCDEFGHJKLMNOPQRSTUVWXYZ" +_HALF_MONTH = frozenset("ABCDEFGHJKLMNOPQRSTUVWXY") +_SECOND = frozenset("ABCDEFGHJKLMNOPQRSTUVWXYZ") + +_BASE62_VALUE = {ch: i for i, ch in enumerate(_BASE62)} + + +def _base62_decode(s: str) -> int | None: + """Decode a base-62 run; ``None`` on non-alphabet input.""" + total = 0 + for ch in s: + v = _BASE62_VALUE.get(ch) + if v is None: + return None + total = total * 62 + v + return total + + +def _base62_encode(value: int, width: int) -> str | None: + """Encode ``value`` as zero-padded base-62 of ``width``; ``None`` if wide.""" + if value < 0: + return None + digits = ["0"] * width + for idx in range(width - 1, -1, -1): + digits[idx] = _BASE62[value % 62] + value //= 62 + if value != 0: + return None + return "".join(digits) + + +def mpc_unpack(spelled: str) -> str | None: + """Decode a packed spelling to its canonical unpacked form (case-exact). + + Returns ``None`` for incoherent input (bad century, bad half-month, + lowercase comet-fragment column 7). Caller decides lane by shape. + """ + try: + if len(spelled) == 5 and spelled.isdigit() and spelled.isascii(): + value = int(spelled) + if value > 0: + return f"({value})" + return None + if ( + len(spelled) == 5 + and spelled[0].isalpha() + and spelled[0].isascii() + and spelled[1:].isdigit() + and spelled[1:].isascii() + ): + high = _BASE62_VALUE.get(spelled[0]) + if high is None or not 10 <= high <= 61: + return None + return f"({high}{spelled[1:]})" + if ( + len(spelled) == 5 + and spelled.startswith("~") + and spelled[1:].isascii() + and all(ch in _BASE62_VALUE for ch in spelled[1:]) + ): + tail = _base62_decode(spelled[1:]) + if tail is None: + return None + return f"({620000 + tail})" + if spelled.startswith("PLS") and len(spelled) in (6, 7): + num = spelled[3:] + if num.isdigit() and num.isascii() and 1 <= int(num) <= 9999: + return f"{int(num)} P-L" + return None + if ( + len(spelled) in (6, 7) + and spelled[0] == "T" + and spelled[1] in ("1", "2", "3") + and spelled[2] == "S" + ): + num = spelled[3:] + if num.isdigit() and num.isascii() and 1 <= int(num) <= 9999: + return f"{int(num)} T-{spelled[1]}" + return None + if len(spelled) == 7 and spelled[0] == "_": + year_letter, half, tail = spelled[1], spelled[2], spelled[3:] + if ( + year_letter in _BASE62_VALUE + and half in _HALF_MONTH + and tail.isascii() + and all(ch in _BASE62_VALUE for ch in tail) + ): + base = _base62_decode(tail) + year_code = _BASE62_VALUE.get(year_letter) + if base is None or year_code is None: + return None + obj_num = 15501 + base + second = _NO_I[(obj_num - 1) % 25] + cycle = (obj_num - 1) // 25 + return f"20{year_code:02d} {half}{second}{cycle}" + return None + if ( + len(spelled) == 7 + and spelled[0].isalpha() + and spelled[0].isascii() + and spelled[1:3].isdigit() + and spelled[1:3].isascii() + and spelled[5].isdigit() + and spelled[5].isascii() + ): + century = _BASE62_VALUE.get(spelled[0]) + half, col5, second = spelled[3], spelled[4], spelled[6] + if century not in (18, 19, 20): + return None + if half not in _HALF_MONTH: + return None + if not second.isalpha() or not second.isascii() or not second.isupper(): + return None + cycle_high = _BASE62_VALUE.get(col5) + if cycle_high is None: + return None + cycle = f"{cycle_high}{spelled[5]}".lstrip("0") + return f"{century}{spelled[1:3]} {half}{second}{cycle}" + return None + except (ValueError, IndexError, TypeError): + return None + + +def mpc_pack(designation: str) -> str | None: + """Encode a canonical unpacked designation to its packed form. + + Returns ``None`` when no packed mapping is defined (the ``A/`` lane) + or the value is out of range. Case-exact output. + """ + try: + if designation.startswith("(") and designation.endswith(")"): + inner = designation[1:-1] + if not inner.isdigit() or not inner.isascii(): + return None + n = int(inner) + if n <= 0: + return None + if n < 100000: + return f"{n:05d}" + if n < 620000: + return f"{_BASE62[n // 10000]}{n % 10000:04d}" + if n <= 15396335: + tail = _base62_encode(n - 620000, 4) + return f"~{tail}" if tail is not None else None + return None + if designation.endswith("P-L") or designation[-3:] in ( + "T-1", + "T-2", + "T-3", + ): + head, _, survey = designation.partition(" ") + if not head.isdigit() or not head.isascii(): + return None + n = int(head) + if not 1 <= n <= 9999: + return None + if survey == "P-L": + return f"PLS{n:04d}" + return f"T{survey[-1]}S{n:04d}" + if designation.startswith("A/"): + return None + if ( + len(designation) >= 7 + and designation[:4].isdigit() + and designation[:4].isascii() + and designation[4] == " " + ): + year = designation[:4] + if not ( + year.startswith(("19", "20")) + or (year[0] == "A" and year[1] in "89" and year[2:].isdigit()) + ): + return None + rest = designation[5:] + if len(rest) < 2 or rest[0] not in _HALF_MONTH or rest[1] not in _SECOND: + return None + half, second, num = rest[0], rest[1], rest[2:] + if num and (not num.isdigit() or not num.isascii()): + return None + if num and num[0] == "0": + # Degenerate cycle ("XA0", "TA05"): the packed field would + # collapse the leading zero and fail to re-enter. + return None + packed_year = _BASE62[int(year[:2]) if year[0] != "A" else 10] + year[2:] + # A-prefix retrospective years have no standard packing lane. + if year[0] == "A": + return None + if num == "": + return f"{packed_year}{half}00{second}" + if len(num) == 1: + return f"{packed_year}{half}0{num}{second}" + obj_num = int(num) * 25 + _NO_I.find(second) + 1 + if obj_num < 15501: + return f"{packed_year}{half}{_BASE62[int(num[:-1])]}{num[-1]}{second}" + if obj_num < 14791837: + obj_num -= 15501 + tail = _base62_encode(obj_num, 4) + if tail is None: + return None + return f"_{_BASE62[int(year[2:])]}{half}{tail}" + return None + return None + except (ValueError, IndexError, TypeError): + return None diff --git a/paxman/capabilities/MinorPlanet/rules/mpc_numbering.py b/paxman/capabilities/MinorPlanet/rules/mpc_numbering.py new file mode 100644 index 00000000..155678e1 --- /dev/null +++ b/paxman/capabilities/MinorPlanet/rules/mpc_numbering.py @@ -0,0 +1,48 @@ +"""MPC numbering rule (publication: HowNamed).""" + +from __future__ import annotations + +import re + +from paxman.capabilities.MinorPlanet.notation import MinorPlanetNotation +from paxman.core.contract import Contract +from paxman.core.domain import Provenance, Rule, RuleStrategy + +PUBLICATION = Provenance( + authority="Minor Planet Center", + specification_name="Numbering/naming process", + kind="specification", + reference_url="https://minorplanetcenter.net/iau/info/HowNamed.html", + version="living document (fetched 2026-10-07)", + lifecycle="active", + publication_year=2026, +) + +_NUMBER_RE = re.compile(r"^\([1-9][0-9]{0,7}\)$", re.ASCII) + + +class Section5PermanentNumber(Rule[MinorPlanetNotation]): + """HowNamed Section 5 — parenthesized permanent-number shape.""" + + name = "Section 5-permanent-number" + strategy = RuleStrategy.PARSER + provenance = PUBLICATION + citation = "Section Numbering (parenthesized positive integer, no leading zeros)" + target_semantics = frozenset({"minor_planet_recognition"}) + requires_features = frozenset() + + def matches(self, notation: MinorPlanetNotation, contract: Contract) -> bool: + """Return True for parenthesized positive integers on this lane.""" + try: + if notation.form != "number": + return False + return _NUMBER_RE.match(notation.designation) is not None + except (TypeError, AttributeError): + return False + + def normalize(self, notation: MinorPlanetNotation, contract: Contract) -> str: + """Return the canonical parenthesized number.""" + try: + return notation.designation + except (TypeError, AttributeError): + return "" diff --git a/paxman/capabilities/MinorPlanet/rules/mpc_packed_designation.py b/paxman/capabilities/MinorPlanet/rules/mpc_packed_designation.py new file mode 100644 index 00000000..7752499b --- /dev/null +++ b/paxman/capabilities/MinorPlanet/rules/mpc_packed_designation.py @@ -0,0 +1,78 @@ +"""MPC packed rules (publication: PackedDes).""" + +from __future__ import annotations + +from paxman.capabilities.MinorPlanet.notation import MinorPlanetNotation +from paxman.capabilities.MinorPlanet.rules.mpc_codec import mpc_unpack +from paxman.core.contract import Contract +from paxman.core.domain import Provenance, Rule, RuleStrategy + +PUBLICATION = Provenance( + authority="Minor Planet Center", + specification_name="Packed provisional/permanent spec", + kind="specification", + reference_url="https://minorplanetcenter.net/iau/info/PackedDes.html", + version="living document (fetched 2026-10-07)", + lifecycle="active", + publication_year=2026, +) + + +class Section3PackedProvisional(Rule[MinorPlanetNotation]): + """PackedDes Section 3 — packed/extended/survey-packed decode coherence.""" + + name = "Section 3-packed-provisional" + strategy = RuleStrategy.PARSER + provenance = PUBLICATION + citation = "Sections Provisional Designations (packed 7-char, survey, tilde)" + target_semantics = frozenset({"minor_planet_recognition"}) + requires_features = frozenset() + + def matches(self, notation: MinorPlanetNotation, contract: Contract) -> bool: + """Return True when the packed spelling decodes coherently.""" + try: + if notation.form not in ("packed", "extended", "survey_packed"): + return False + if not notation.packed: + return False + return mpc_unpack(notation.packed) is not None + except (TypeError, AttributeError): + return False + + def normalize(self, notation: MinorPlanetNotation, contract: Contract) -> str: + """Return the canonical unpacked designation.""" + try: + decoded = mpc_unpack(notation.packed) + return decoded if decoded is not None else notation.designation + except (TypeError, AttributeError): + return notation.designation + + +class Section4PackedNumber(Rule[MinorPlanetNotation]): + """PackedDes Section 4 — packed permanent-number decode coherence.""" + + name = "Section 4-packed-number" + strategy = RuleStrategy.PARSER + provenance = PUBLICATION + citation = "Section Permanent Designations (5-char, letter, tilde)" + target_semantics = frozenset({"minor_planet_recognition"}) + requires_features = frozenset() + + def matches(self, notation: MinorPlanetNotation, contract: Contract) -> bool: + """Return True when the packed number decodes coherently.""" + try: + if notation.form != "packed_number": + return False + if not notation.packed: + return False + return mpc_unpack(notation.packed) is not None + except (TypeError, AttributeError): + return False + + def normalize(self, notation: MinorPlanetNotation, contract: Contract) -> str: + """Return the canonical parenthesized number.""" + try: + decoded = mpc_unpack(notation.packed) + return decoded if decoded is not None else notation.designation + except (TypeError, AttributeError): + return notation.designation diff --git a/paxman/capabilities/MinorPlanet/rules/mpc_unpacked_designation.py b/paxman/capabilities/MinorPlanet/rules/mpc_unpacked_designation.py new file mode 100644 index 00000000..d27f3b69 --- /dev/null +++ b/paxman/capabilities/MinorPlanet/rules/mpc_unpacked_designation.py @@ -0,0 +1,83 @@ +"""MPC unpacked + survey rules (publication: DesDoc).""" + +from __future__ import annotations + +import re + +from paxman.capabilities.MinorPlanet.notation import MinorPlanetNotation +from paxman.core.contract import Contract +from paxman.core.domain import Provenance, Rule, RuleStrategy + +PUBLICATION = Provenance( + authority="Minor Planet Center", + specification_name="Unpacked provisional designation definition", + kind="specification", + reference_url="https://minorplanetcenter.net/iau/info/DesDoc.html", + version="living document (fetched 2026-10-07)", + lifecycle="active", + publication_year=2026, +) + +_YEAR = r"(?:A[89]\d{2}|19\d{2}|20\d{2})" +_UNPACKED_RE = re.compile(rf"^(?:{_YEAR}) [A-HJ-Y][A-HJ-Z](?:[1-9]\d*)?$", re.ASCII) +_APREFIX_RE = re.compile(rf"^A/(?:{_YEAR}) [A-HJ-Y][A-HJ-Z]?(?:[1-9]\d*)?$", re.ASCII) +_SURVEY_RE = re.compile(r"^[1-9]\d{0,3} (?:P-L|T-[123])$", re.ASCII) + + +class Section1UnpackedProvisionalStructure(Rule[MinorPlanetNotation]): + """DesDoc Section 1 — unpacked provisional structure.""" + + name = "Section 1-unpacked-provisional-structure" + strategy = RuleStrategy.PARSER + provenance = PUBLICATION + citation = "Sections New-Style Provisional Designations (year/order/cycle)" + target_semantics = frozenset({"minor_planet_recognition"}) + requires_features = frozenset() + + def matches(self, notation: MinorPlanetNotation, contract: Contract) -> bool: + """Return True for coherent unpacked provisionals on this lane.""" + try: + if notation.form != "provisional": + return False + designation = notation.designation + if not isinstance(designation, str): + return False + if designation.startswith("A/"): + return _APREFIX_RE.match(designation) is not None + return _UNPACKED_RE.match(designation) is not None + except (TypeError, AttributeError): + return False + + def normalize(self, notation: MinorPlanetNotation, contract: Contract) -> str: + """Return the canonical unpacked designation.""" + try: + return notation.designation + except (TypeError, AttributeError): + return "" + + +class Section2SurveyDesignation(Rule[MinorPlanetNotation]): + """DesDoc Section 2 — Palomar-Leiden / Trojan survey designations.""" + + name = "Section 2-survey-designation" + strategy = RuleStrategy.PARSER + provenance = PUBLICATION + citation = "Section Survey Designations (number + P-L/T-1/T-2/T-3)" + target_semantics = frozenset({"minor_planet_recognition"}) + requires_features = frozenset() + + def matches(self, notation: MinorPlanetNotation, contract: Contract) -> bool: + """Return True for survey designations on this lane.""" + try: + if notation.form != "survey": + return False + return _SURVEY_RE.match(notation.designation) is not None + except (TypeError, AttributeError): + return False + + def normalize(self, notation: MinorPlanetNotation, contract: Contract) -> str: + """Return the canonical survey designation.""" + try: + return notation.designation + except (TypeError, AttributeError): + return "" diff --git a/paxman/capabilities/__init__.py b/paxman/capabilities/__init__.py index 09d55390..c353a9c9 100644 --- a/paxman/capabilities/__init__.py +++ b/paxman/capabilities/__init__.py @@ -33,6 +33,7 @@ "LEI", "Language", "MacAddress", + "MinorPlanet", "Money", "ORCID", "Phone", @@ -71,6 +72,10 @@ "LEI": ("paxman.capabilities.LEI.capability", "LEICapability"), "Language": ("paxman.capabilities.Language.capability", "LanguageCapability"), "MacAddress": ("paxman.capabilities.MacAddress.capability", "MacAddressCapability"), + "MinorPlanet": ( + "paxman.capabilities.MinorPlanet.capability", + "MinorPlanetCapability", + ), "Money": ("paxman.capabilities.Money.capability", "MoneyCapability"), "ORCID": ("paxman.capabilities.ORCID.capability", "ORCIDCapability"), "Phone": ("paxman.capabilities.Phone.capability", "PhoneCapability"), @@ -111,6 +116,9 @@ from paxman.capabilities.MacAddress.capability import ( MacAddressCapability as MacAddress, ) + from paxman.capabilities.MinorPlanet.capability import ( + MinorPlanetCapability as MinorPlanet, + ) from paxman.capabilities.Money.capability import MoneyCapability as Money from paxman.capabilities.ORCID.capability import ORCIDCapability as ORCID from paxman.capabilities.Phone.capability import PhoneCapability as Phone diff --git a/paxman/cli.py b/paxman/cli.py index cf3f6800..bc318e06 100644 --- a/paxman/cli.py +++ b/paxman/cli.py @@ -218,6 +218,10 @@ def _create_contract( from paxman.capabilities import MacAddress return MacAddress.create_contract(suppress_common_words=suppress_common_words) + if normalized == "minor_planet": + from paxman.capabilities import MinorPlanet + + return MinorPlanet.create_contract(suppress_common_words=suppress_common_words) if normalized == "money": from paxman.capabilities import Money diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 7b68cf04..0a60fefb 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -1,7 +1,7 @@ # TESTS KNOWLEDGE BASE ## OVERVIEW -Tests are layered by scope; directories organize tests, and each module, class, or function explicitly applies the pytest marker for its layer (defined in pyproject `[tool.pytest.ini_options]`). CI runs the union of markers. 5 layers; all 27 shipped capability packages have landed and are covered here. +Tests are layered by scope; directories organize tests, and each module, class, or function explicitly applies the pytest marker for its layer (defined in pyproject `[tool.pytest.ini_options]`). CI runs the union of markers. 5 layers; all 30 shipped capability packages have landed and are covered here. ## STRUCTURE ```text diff --git a/tests/capabilities/minor_planet/__init__.py b/tests/capabilities/minor_planet/__init__.py new file mode 100644 index 00000000..9e40f0fb --- /dev/null +++ b/tests/capabilities/minor_planet/__init__.py @@ -0,0 +1 @@ +"""minor_planet capability tests (scaffold).""" diff --git a/tests/capabilities/minor_planet/test_capability.py b/tests/capabilities/minor_planet/test_capability.py new file mode 100644 index 00000000..343e897d --- /dev/null +++ b/tests/capabilities/minor_planet/test_capability.py @@ -0,0 +1,102 @@ +"""Tests for the MinorPlanet capability wiring.""" + +import pytest + +from paxman.api import canonicalize +from paxman.capabilities.MinorPlanet.capability import MinorPlanetCapability +from paxman.capabilities.MinorPlanet.contract import MinorPlanetContract +from paxman.capabilities.MinorPlanet.notation import MinorPlanetNotation +from paxman.core.discovery import register_capability, reset_registry +from paxman.core.domain import Resolution +from paxman.core.errors import ContractError + + +@pytest.mark.capability +class TestMinorPlanetCapability: + """Capability wiring — grammars, rules, factory.""" + + def setup_method(self) -> None: + self.capability = MinorPlanetCapability() + + def test_metadata(self) -> None: + assert self.capability.name == "minor_planet" + + def test_get_grammars(self) -> None: + grammars = self.capability.get_grammars() + assert [g.name for g in grammars] == ["minor_planet_recognition"] + assert [g.semantics for g in grammars] == ["minor_planet_recognition"] + + def test_get_rules(self) -> None: + names = [r.name for r in self.capability.get_rules()] + assert names == [ + "Section 1-unpacked-provisional-structure", + "Section 2-survey-designation", + "Section 3-packed-provisional", + "Section 4-packed-number", + "Section 5-permanent-number", + ] + + def test_create_contract_defaults(self) -> None: + contract = self.capability.create_contract() + assert isinstance(contract, MinorPlanetContract) + assert contract.output_format == "designation" + + def test_create_contract_packed(self) -> None: + contract = self.capability.create_contract(output_format="packed") + assert contract.output_format == "packed" + + def test_unknown_format_raises(self) -> None: + with pytest.raises(ContractError): + self.capability.create_contract(output_format="bogus") + + def test_target_semantics_covered(self) -> None: + semantics = {g.semantics for g in self.capability.get_grammars()} + for rule in self.capability.get_rules(): + assert set(rule.target_semantics) <= semantics + + def test_format_value_designation_identity(self) -> None: + notation = MinorPlanetNotation( + designation="1995 XA", form="provisional", packed="" + ) + assert ( + self.capability.format_value("1995 XA", "designation", notation) + == "1995 XA" + ) + + def test_format_value_packed(self) -> None: + notation = MinorPlanetNotation( + designation="1995 XA", form="provisional", packed="" + ) + assert self.capability.format_value("1995 XA", "packed", notation) == "J95X00A" + + def test_format_value_packed_case_exact(self) -> None: + notation = MinorPlanetNotation( + designation="(360017)", form="packed_number", packed="a0017" + ) + assert self.capability.format_value("(360017)", "packed", notation) == "a0017" + + def test_format_value_a_lane_fallback(self) -> None: + notation = MinorPlanetNotation( + designation="A/2017 U1", form="provisional", packed="" + ) + assert ( + self.capability.format_value("A/2017 U1", "packed", notation) == "A/2017 U1" + ) + + +@pytest.mark.capability +class TestMinorPlanetCapabilityPipeline: + """End-to-end: scaffold probe resolves to MISSING.""" + + @pytest.fixture(autouse=True) + def _clean_registry(self) -> None: + reset_registry() + yield + reset_registry() + + def test_scaffold_probe_missing(self) -> None: + register_capability(MinorPlanetCapability()) + contract = MinorPlanetCapability.create_contract() + result = canonicalize("scaffold probe", contract) + assert result.status == Resolution.MISSING + assert result.canonicalized_value is None diff --git a/tests/capabilities/minor_planet/test_grammar.py b/tests/capabilities/minor_planet/test_grammar.py new file mode 100644 index 00000000..c51a3a17 --- /dev/null +++ b/tests/capabilities/minor_planet/test_grammar.py @@ -0,0 +1,115 @@ +"""Tests for MinorPlanet recognition grammar.""" + +import re + +import pytest + +from paxman.capabilities.MinorPlanet.grammar.minor_planet_recognition import ( + MinorPlanetRecognitionGrammar, +) +from paxman.core.domain import Grammar + + +@pytest.mark.capability +class TestMinorPlanetRecognition: + """Grammar: minor_planet_recognition.""" + + def setup_method(self) -> None: + self.grammar: Grammar = MinorPlanetRecognitionGrammar() + + def test_semantics(self) -> None: + assert self.grammar.semantics == "minor_planet_recognition" + + def test_single_value_true(self) -> None: + assert self.grammar.single_value is True + + def test_name(self) -> None: + assert self.grammar.name == "minor_planet_recognition" + + def test_pattern_compiles(self) -> None: + from paxman.capabilities.MinorPlanet.grammar import ( + minor_planet_recognition as mod, + ) + + re.compile(mod._MP_PATTERN, re.ASCII) + + @pytest.mark.parametrize( + "text,designation,form", + [ + ("1995 XA", "1995 XA", "provisional"), + ("2007 TA418", "2007 TA418", "provisional"), + ("1992 QB1", "1992 QB1", "provisional"), + ("2003 cp20", "2003 CP20", "provisional"), + ("1995 xa", "1995 XA", "provisional"), + ("1995_XA", "1995 XA", "provisional"), + ("A904 OA", "A904 OA", "provisional"), + ("A/2017 U1", "A/2017 U1", "provisional"), + ("J95X00A", "J95X00A", "packed"), + ("K07Tf8A", "K07Tf8A", "packed"), + ("K99AJ3Z", "K99AJ3Z", "packed"), + ("_QC0000", "_QC0000", "extended"), + ("2040 P-L", "2040 P-L", "survey"), + ("3138 T-1", "3138 T-1", "survey"), + ("PLS2040", "PLS2040", "survey_packed"), + ("T1S3138", "T1S3138", "survey_packed"), + ("(433)", "(433)", "number"), + ("(274301)", "(274301)", "number"), + ("(15396335)", "(15396335)", "number"), + ("03202", "03202", "packed_number"), + ("A0345", "A0345", "packed_number"), + ("a0017", "a0017", "packed_number"), + ("K3289", "K3289", "packed_number"), + ("~000z", "~000z", "packed_number"), + ("~AZaz", "~AZaz", "packed_number"), + ], + ) + def test_recognize_lanes(self, text: str, designation: str, form: str) -> None: + matches = self.grammar.recognize(text) + assert len(matches) == 1 + assert matches[0].notation.designation == designation + assert matches[0].notation.form == form + assert matches[0].raw_text == text + assert matches[0].end - matches[0].start == len(text) + + def test_trailing_name_outside_span(self) -> None: + matches = self.grammar.recognize("(433) Eros") + assert len(matches) == 1 + assert matches[0].raw_text == "(433)" + assert matches[0].notation.designation == "(433)" + + def test_equation_two_mentions(self) -> None: + matches = self.grammar.recognize("1997 RO4 = 2007 FK34") + assert len(matches) == 2 + + @pytest.mark.parametrize( + "text", + [ + "1995XA", + "433", + "1892 A", + "C/1995 O1", + "Eros", + "1995 SA₁", + "199 XA", + "19955 XA", + "J95X00", + ], + ) + def test_negatives(self, text: str) -> None: + assert self.grammar.recognize(text) == [] + + def test_glued_lowercase_missing(self) -> None: + assert self.grammar.recognize("2003cp20") == [] + + def test_phone_area_code_overclaim_documented(self) -> None: + # Known v1 overclaim (research §4.4): area codes match the number + # lane; the future registry disambiguates. Caller segments phones. + matches = self.grammar.recognize("(555) 123-4567") + assert len(matches) == 1 + assert matches[0].raw_text == "(555)" + + def test_bare_five_digit_overclaim_documented(self) -> None: + # Known v1 overclaim: ZIP/price runs match packed_number lane. + matches = self.grammar.recognize("03202") + assert len(matches) == 1 + assert matches[0].notation.form == "packed_number" diff --git a/tests/capabilities/minor_planet/test_integration.py b/tests/capabilities/minor_planet/test_integration.py new file mode 100644 index 00000000..2bf8a17f --- /dev/null +++ b/tests/capabilities/minor_planet/test_integration.py @@ -0,0 +1,127 @@ +"""MinorPlanet pipeline integration tests.""" + +import pytest + +from paxman.api import canonicalize +from paxman.capabilities.MinorPlanet.capability import MinorPlanetCapability +from paxman.core.discovery import register_capability, reset_registry +from paxman.core.domain import Resolution +from paxman.core.errors import MultipleMentionsError + + +@pytest.fixture(autouse=True) +def _clean_registry() -> None: + reset_registry() + yield + reset_registry() + + +@pytest.mark.integration +class TestMinorPlanetPipeline: + """End-to-end resolution states per research §§8–9.""" + + def setup_method(self) -> None: + register_capability(MinorPlanetCapability()) + + @pytest.mark.parametrize( + "text,expected", + [ + ("1995 XA", "1995 XA"), + ("2007 TA418", "2007 TA418"), + ("2003 cp20", "2003 CP20"), + ("1995_XA", "1995 XA"), + ("A904 OA", "A904 OA"), + ("A/2017 U1", "A/2017 U1"), + ("J95X00A", "1995 XA"), + ("K07Tf8A", "2007 TA418"), + ("2040 P-L", "2040 P-L"), + ("PLS2040", "2040 P-L"), + ("3138 T-1", "3138 T-1"), + ("T1S3138", "3138 T-1"), + ("(433)", "(433)"), + ("(274301)", "(274301)"), + ("(433) Eros", "(433)"), + ("03202", "(3202)"), + ("A0345", "(100345)"), + ("a0017", "(360017)"), + ("~000z", "(620061)"), + ("see 1995 XA (Alcathoe)", "1995 XA"), + ], + ) + def test_success(self, text: str, expected: str) -> None: + result = canonicalize(text, MinorPlanetCapability.create_contract()) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == expected + + @pytest.mark.parametrize( + "text", + [ + "1995 XI", + "1995 IZ", + "J95I00A", + "(0433)", + "(0)", + "00000", # packed-number zero: no valid permanent number + "1995 XA0", # degenerate cycle digits (leading zero) + "2007 TA05", + ], + ) + def test_invalid(self, text: str) -> None: + result = canonicalize(text, MinorPlanetCapability.create_contract()) + assert result.status == Resolution.INVALID + + @pytest.mark.parametrize( + "text", + [ + "1995XA", + "433", + "1892 A", + "C/1995 O1", + "Eros", + "1995 SA₁", + "Q95X00A", + "no designations", + ], + ) + def test_missing(self, text: str) -> None: + result = canonicalize(text, MinorPlanetCapability.create_contract()) + assert result.status == Resolution.MISSING + + def test_two_distinct_mentions_raise(self) -> None: + with pytest.raises(MultipleMentionsError): + canonicalize( + "1997 RO4 = 2007 FK34", MinorPlanetCapability.create_contract() + ) + + def test_packed_unpacked_same_value_dedups(self) -> None: + # Same entity, two encodings in one slice: one cluster, one value. + result = canonicalize( + "J95X00A = 1995 XA", MinorPlanetCapability.create_contract() + ) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "1995 XA" + + def test_packed_format_roundtrip(self) -> None: + packed_contract = MinorPlanetCapability.create_contract(output_format="packed") + result = canonicalize("1995 XA", packed_contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "J95X00A" + reentry = canonicalize("J95X00A", MinorPlanetCapability.create_contract()) + assert reentry.status == Resolution.SUCCESS + assert reentry.canonicalized_value == "1995 XA" + + def test_deterministic_version_stamp(self) -> None: + contract = MinorPlanetCapability.create_contract() + first = canonicalize("1995 XA", contract) + second = canonicalize("1995 XA", contract) + assert first.canonicalized_value == second.canonicalized_value + assert first.version_stamp == second.version_stamp + assert first.span == (0, 7) + + def test_known_overclaims_documented(self) -> None: + # v1 overclaims without a registry (research §4.4): phone area + # codes and bare 5-digit runs match shape lanes. The future + # MPCORB LOOKUP_TABLE disambiguates; caller segments these contexts. + phone = canonicalize("(555) 123-4567", MinorPlanetCapability.create_contract()) + assert phone.status == Resolution.SUCCESS + assert phone.canonicalized_value == "(555)" diff --git a/tests/capabilities/minor_planet/test_notation.py b/tests/capabilities/minor_planet/test_notation.py new file mode 100644 index 00000000..bf15d21f --- /dev/null +++ b/tests/capabilities/minor_planet/test_notation.py @@ -0,0 +1,34 @@ +"""Tests for MinorPlanetNotation.""" + +import dataclasses + +import pytest + +from paxman.capabilities.MinorPlanet.notation import MinorPlanetNotation + + +@pytest.mark.capability +class TestMinorPlanetNotation: + """Notation facets: designation + lane + spelled packed.""" + + def test_frozen_slots_hash(self) -> None: + n = MinorPlanetNotation(designation="1995 XA", form="provisional", packed="") + assert n.designation == "1995 XA" + assert n.form == "provisional" + assert n.packed == "" + assert dataclasses.is_dataclass(n) + with pytest.raises(dataclasses.FrozenInstanceError): + n.designation = "other" # type: ignore[misc] + assert hash(n) == hash( + MinorPlanetNotation(designation="1995 XA", form="provisional", packed="") + ) + + def test_packed_empty_for_unpacked_lane(self) -> None: + n = MinorPlanetNotation(designation="1995 XA", form="provisional", packed="") + assert n.packed == "" + + def test_packed_preserved_case_exact(self) -> None: + n = MinorPlanetNotation( + designation="(360017)", form="packed_number", packed="a0017" + ) + assert n.packed == "a0017" diff --git a/tests/capabilities/minor_planet/test_rules.py b/tests/capabilities/minor_planet/test_rules.py new file mode 100644 index 00000000..c41e2708 --- /dev/null +++ b/tests/capabilities/minor_planet/test_rules.py @@ -0,0 +1,380 @@ +"""Tests for MinorPlanet codec + rules.""" + +import pytest + +from paxman.capabilities.MinorPlanet.contract import MinorPlanetContract +from paxman.capabilities.MinorPlanet.notation import MinorPlanetNotation +from paxman.capabilities.MinorPlanet.rules.mpc_codec import ( + mpc_pack, + mpc_unpack, +) +from paxman.capabilities.MinorPlanet.rules.mpc_numbering import ( + Section5PermanentNumber, +) +from paxman.capabilities.MinorPlanet.rules.mpc_packed_designation import ( + Section3PackedProvisional, + Section4PackedNumber, +) +from paxman.capabilities.MinorPlanet.rules.mpc_unpacked_designation import ( + Section1UnpackedProvisionalStructure, + Section2SurveyDesignation, +) +from paxman.core.domain import RuleStrategy + + +def _notation(designation: str, form: str, packed: str = "") -> MinorPlanetNotation: + return MinorPlanetNotation(designation=designation, form=form, packed=packed) + + +@pytest.mark.capability +class TestSection1Unpacked: + """DesDoc provisional structure.""" + + def setup_method(self) -> None: + self.rule = Section1UnpackedProvisionalStructure() + self.contract = MinorPlanetContract() + + def test_metadata(self) -> None: + assert self.rule.name == "Section 1-unpacked-provisional-structure" + assert self.rule.strategy is RuleStrategy.PARSER + assert self.rule.target_semantics == frozenset({"minor_planet_recognition"}) + assert self.rule.requires_features == frozenset() + assert self.rule.provenance.publication_year == 2026 + + @pytest.mark.parametrize( + "designation", + ["1995 XA", "2007 TA418", "A904 OA", "A/2017 U1", "1995 XZ"], + ) + def test_valid(self, designation: str) -> None: + assert ( + self.rule.matches(_notation(designation, "provisional"), self.contract) + is True + ) + + @pytest.mark.parametrize("designation", ["1995 XI", "1995 IZ", "1995 XA1X"]) + def test_invalid_letter_slots(self, designation: str) -> None: + assert ( + self.rule.matches(_notation(designation, "provisional"), self.contract) + is False + ) + + @pytest.mark.parametrize("designation", ["1995 XA0", "2007 TA05"]) + def test_invalid_leading_zero_cycle(self, designation: str) -> None: + assert ( + self.rule.matches(_notation(designation, "provisional"), self.contract) + is False + ) + + def test_off_lane_rejected(self) -> None: + assert ( + self.rule.matches(_notation("J95X00A", "packed", "J95X00A"), self.contract) + is False + ) + + def test_normalize(self) -> None: + assert ( + self.rule.normalize(_notation("1995 XA", "provisional"), self.contract) + == "1995 XA" + ) + + +@pytest.mark.capability +class TestSection2Survey: + """DesDoc survey designations.""" + + def setup_method(self) -> None: + self.rule = Section2SurveyDesignation() + self.contract = MinorPlanetContract() + + def test_metadata(self) -> None: + assert self.rule.name == "Section 2-survey-designation" + assert self.rule.strategy is RuleStrategy.PARSER + assert self.rule.target_semantics == frozenset({"minor_planet_recognition"}) + + @pytest.mark.parametrize("designation", ["2040 P-L", "3138 T-1", "1010 T-2"]) + def test_valid(self, designation: str) -> None: + assert ( + self.rule.matches(_notation(designation, "survey"), self.contract) is True + ) + + def test_invalid_identifier(self) -> None: + assert ( + self.rule.matches(_notation("2040 P-X", "survey"), self.contract) is False + ) + + def test_normalize(self) -> None: + assert ( + self.rule.normalize(_notation("2040 P-L", "survey"), self.contract) + == "2040 P-L" + ) + + +@pytest.mark.capability +class TestSection3Packed: + """PackedDes provisional coherence.""" + + def setup_method(self) -> None: + self.rule = Section3PackedProvisional() + self.contract = MinorPlanetContract() + + def test_metadata(self) -> None: + assert self.rule.name == "Section 3-packed-provisional" + assert self.rule.strategy is RuleStrategy.PARSER + + @pytest.mark.parametrize( + "spelled,canonical", + [ + ("J95X00A", "1995 XA"), + ("K07Tf8A", "2007 TA418"), + ("K99AJ3Z", "2099 AZ193"), + ("PLS2040", "2040 P-L"), + ("T1S3138", "3138 T-1"), + ], + ) + def test_valid(self, spelled: str, canonical: str) -> None: + form = ( + "packed" + if len(spelled) == 7 and spelled[0] != "_" + else ("survey_packed" if spelled[0] in "PT" else "packed") + ) + assert ( + self.rule.matches(_notation(spelled, form, spelled), self.contract) is True + ) + assert ( + self.rule.normalize(_notation(spelled, form, spelled), self.contract) + == canonical + ) + + @pytest.mark.parametrize("spelled", ["Q95X00A", "J95I00A", "J94P01b"]) + def test_invalid(self, spelled: str) -> None: + assert ( + self.rule.matches(_notation(spelled, "packed", spelled), self.contract) + is False + ) + + +@pytest.mark.capability +class TestSection4PackedNumber: + """PackedDes number coherence.""" + + def setup_method(self) -> None: + self.rule = Section4PackedNumber() + self.contract = MinorPlanetContract() + + def test_metadata(self) -> None: + assert self.rule.name == "Section 4-packed-number" + assert self.rule.strategy is RuleStrategy.PARSER + + @pytest.mark.parametrize( + "spelled,canonical", + [ + ("03202", "(3202)"), + ("A0345", "(100345)"), + ("a0017", "(360017)"), + ("~000z", "(620061)"), + ("~AZaz", "(3140113)"), + ], + ) + def test_valid(self, spelled: str, canonical: str) -> None: + assert ( + self.rule.matches( + _notation(spelled, "packed_number", spelled), self.contract + ) + is True + ) + assert ( + self.rule.normalize( + _notation(spelled, "packed_number", spelled), self.contract + ) + == canonical + ) + + def test_case_trap_distinct(self) -> None: + assert self.rule.normalize( + _notation("a0017", "packed_number", "a0017"), self.contract + ) != self.rule.normalize( + _notation("A0345", "packed_number", "A0345"), self.contract + ) + + def test_zero_number_rejected(self) -> None: + assert ( + self.rule.matches( + _notation("00000", "packed_number", "00000"), self.contract + ) + is False + ) + assert mpc_unpack("00000") is None + + +@pytest.mark.capability +class TestSection5Number: + """HowNamed permanent numbers.""" + + def setup_method(self) -> None: + self.rule = Section5PermanentNumber() + self.contract = MinorPlanetContract() + + def test_metadata(self) -> None: + assert self.rule.name == "Section 5-permanent-number" + assert self.rule.strategy is RuleStrategy.PARSER + + @pytest.mark.parametrize("designation", ["(433)", "(274301)", "(15396335)"]) + def test_valid(self, designation: str) -> None: + assert ( + self.rule.matches(_notation(designation, "number"), self.contract) is True + ) + + @pytest.mark.parametrize("designation", ["(0433)", "(0)", "(43a)"]) + def test_invalid(self, designation: str) -> None: + assert ( + self.rule.matches(_notation(designation, "number"), self.contract) is False + ) + + def test_normalize(self) -> None: + assert ( + self.rule.normalize(_notation("(433)", "number"), self.contract) == "(433)" + ) + + +@pytest.mark.capability +class TestMpcCodec: + """Pack/unpack codec — sbpy-parity vectors from PackedDes.""" + + def test_tilde_vectors(self) -> None: + assert mpc_unpack("~0000") == "(620000)" + assert mpc_unpack("~000z") == "(620061)" + assert mpc_unpack("~AZaz") == "(3140113)" + assert mpc_unpack("~zzzz") == "(15396335)" + + def test_packed_number_case_trap(self) -> None: + assert mpc_unpack("A0345") == "(100345)" + assert mpc_unpack("a0017") == "(360017)" + assert mpc_unpack("03202") == "(3202)" + assert mpc_unpack("K3289") == "(203289)" + + def test_packed_provisional_vectors(self) -> None: + assert mpc_unpack("J95X00A") == "1995 XA" + assert mpc_unpack("J95X01L") == "1995 XL1" + assert mpc_unpack("K07Tf8A") == "2007 TA418" + assert mpc_unpack("K99AJ3Z") == "2099 AZ193" + assert mpc_unpack("K08Aa0A") == "2008 AA360" + + def test_survey_packs(self) -> None: + assert mpc_unpack("PLS2040") == "2040 P-L" + assert mpc_unpack("T1S3138") == "3138 T-1" + assert mpc_pack("2040 P-L") == "PLS2040" + assert mpc_pack("3138 T-1") == "T1S3138" + + def test_pack_roundtrip(self) -> None: + assert mpc_pack("1995 XA") == "J95X00A" + assert mpc_pack("2007 TA418") == "K07Tf8A" + assert mpc_pack("(3202)") == "03202" + assert mpc_pack("(100345)") == "A0345" + assert mpc_pack("(360017)") == "a0017" + assert mpc_pack("(620000)") == "~0000" + assert mpc_pack("(620061)") == "~000z" + + def test_pack_none_for_a_lane(self) -> None: + assert mpc_pack("A/2017 U1") is None + + def test_unpack_none_for_incoherent(self) -> None: + assert mpc_unpack("Q95X00A") is None + assert mpc_unpack("J95I00A") is None + assert mpc_unpack("J94P01b") is None + + +@pytest.mark.capability +class TestMpcCodecRobustness: + """Defensive branches: malformed input returns None/False, never raises.""" + + @pytest.mark.parametrize( + "spelled", + [ + "", + "x", + "1234", + "123456", + "~~~~~", + "PLS", + "PLS0000", + "PLS99999", + "T9S1234", + "T1S", + "_", + "_qC0000", + "_QZ0000", + "_QC00", + "_QC00000", + "J95X00a", + "j95X00A", + "K328", + "K32890", + "~000", + "~00000", + "~!!!!", + "(433", + "433)", + "()", + "(0)", + "(-1)", + "A/2017 U1", + "1995 XA", + "2040 P-L", + "00000", + "٤٥٦٧٨", + "A٤٥٦٧", + "123٤5", + ], + ) + def test_unpack_garbage_none_or_shaped(self, spelled: str) -> None: + result = mpc_unpack(spelled) + assert result is None or isinstance(result, str) + + def test_unpack_non_ascii_digits_none(self) -> None: + assert mpc_unpack("٤٥٦٧٨") is None + assert mpc_unpack("A٤٥٦٧") is None + assert mpc_unpack("123٤5") is None + + def test_unpack_zero_number_none(self) -> None: + assert mpc_unpack("00000") is None + + @pytest.mark.parametrize( + "designation", + [ + "", + "hello", + "(0)", + "()", + "(433", + "433)", + "(43a)", + "0 P-L", + "10000 P-L", + "2040 P-X", + "1995 XI", + "1892 XA", + "2100 XA", + "1995 X", + "1995 XA1X", + "(99999999)", + "1995 XA0", + "2007 TA05", + ], + ) + def test_pack_garbage_none(self, designation: str) -> None: + assert mpc_pack(designation) is None + + def test_pack_number_bounds(self) -> None: + assert mpc_pack("(1)") == "00001" + assert mpc_pack("(99999)") == "99999" + assert mpc_pack("(619999)") == "z9999" + assert mpc_pack("(15396335)") == "~zzzz" + assert mpc_pack("(15396336)") is None + assert mpc_pack("(0)") is None + + def test_pack_high_cycle_extended(self) -> None: + packed = mpc_pack("2026 CA1000") + assert packed is not None and packed.startswith("_") + + def test_pack_cycle_beyond_extended_none(self) -> None: + assert mpc_pack("2026 CA99999999") is None diff --git a/tests/property/test_minor_planet_properties.py b/tests/property/test_minor_planet_properties.py new file mode 100644 index 00000000..e1b5956e --- /dev/null +++ b/tests/property/test_minor_planet_properties.py @@ -0,0 +1,99 @@ +"""Hypothesis property tests for the MinorPlanet capability. + +Each property locks an invariant of recognition, validation, or +presentation using an independently derived expectation: + +- an unpacked provisional built from a lane year + legal letter sets + always canonicalizes to itself (upper, single space); +- packed renderings re-enter to the same canonical (mpc_pack/mpc_unpack + round-trip on legal lanes); +- case-fold stability: lowercased spaced input canonicalizes identically; +- random alphanum strings never raise (INVALID-or-MISSING only). +""" + +from __future__ import annotations + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +import paxman +from paxman.capabilities import MinorPlanet +from paxman.capabilities.MinorPlanet.capability import MinorPlanetCapability +from paxman.core.discovery import register_capability, reset_registry +from paxman.core.domain import Resolution + +_CAP = MinorPlanetCapability +_ = MinorPlanet # keep required import used + + +@pytest.fixture(autouse=True) +def _fresh_registry() -> None: + """Reset the registry and register MinorPlanet around each test.""" + + reset_registry() + register_capability(_CAP()) + yield + reset_registry() + + +_HALF_MONTH = "ABCDEFGHJKLMNOPQRSTUVWXY" +_SECOND = "ABCDEFGHJKLMNOPQRSTUVWXYZ" + + +@given( + year=st.sampled_from(["1995", "2007", "2026", "A904"]), + hm=st.sampled_from(list(_HALF_MONTH)), + second=st.sampled_from(list(_SECOND)), + cycle=st.sampled_from(["", "1", "418"]), +) +def test_lane_built_provisional_self_canonicalizes( + year: str, hm: str, second: str, cycle: str +) -> None: + """Lane-built provisionals canonicalize to themselves.""" + text = f"{year} {hm}{second}{cycle}" + result = paxman.canonicalize(text, _CAP.create_contract()) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == text + + +@given( + spelled=st.sampled_from( + ["J95X00A", "K07Tf8A", "03202", "A0345", "a0017", "~000z", "PLS2040"] + ) +) +def test_packed_spellings_reenter(spelled: str) -> None: + """Packed spellings resolve and their canonicals re-enter exactly.""" + first = paxman.canonicalize(spelled, _CAP.create_contract()) + assert first.status == Resolution.SUCCESS + assert first.canonicalized_value is not None + second = paxman.canonicalize(first.canonicalized_value, _CAP.create_contract()) + assert second.status == Resolution.SUCCESS + assert second.canonicalized_value == first.canonicalized_value + + +@given(text=st.sampled_from(["1995 XA", "2007 TA418", "2040 P-L", "3138 T-1", "(433)"])) +def test_case_fold_stability(text: str) -> None: + """Lowercased spaced input canonicalizes identically.""" + upper = paxman.canonicalize(text, _CAP.create_contract()) + lower = paxman.canonicalize(text.lower(), _CAP.create_contract()) + assert upper.status == Resolution.SUCCESS + assert lower.status == Resolution.SUCCESS + assert lower.canonicalized_value == upper.canonicalized_value + + +@given( + text=st.text( + alphabet=st.characters(min_codepoint=32, max_codepoint=126), + max_size=12, + ) +) +def test_random_ascii_never_raises(text: str) -> None: + """Random ASCII never raises; verdicts are INVALID-or-MISSING-or better.""" + result = paxman.canonicalize(text, _CAP.create_contract()) + assert result.status in ( + Resolution.SUCCESS, + Resolution.INVALID, + Resolution.MISSING, + Resolution.AMBIGUOUS, + ) diff --git a/tests/property/test_output_format_preservation.py b/tests/property/test_output_format_preservation.py index b7c1249f..1a8b9950 100644 --- a/tests/property/test_output_format_preservation.py +++ b/tests/property/test_output_format_preservation.py @@ -83,6 +83,7 @@ Domain, Language, MacAddress, + MinorPlanet, Money, Phone, UtcOffset, @@ -216,6 +217,11 @@ def _default_kwargs(row: _ReEntryRow) -> dict[str, object]: ("mac_address", "bare"): "encoding", ("mac_address", "cisco"): "encoding", ("mac_address", "hyphen"): "encoding", + # -- MinorPlanet ----------------------------------------------------------- + # packed: case-exact wire re-encoding of the unpacked canonical; the + # rendering re-enters the default contract onto the same value + # (measured: "J95X00A" -> "1995 XA"). + ("minor_planet", "packed"): "encoding", # -- Money / Phone ------------------------------------------------------- ("money", "compact"): "encoding", ("phone", "rfc3966"): "encoding", @@ -532,6 +538,14 @@ class _InjectivityPair: "", ), _InjectivityPair("unspsc", UNSPSC, "labeled", "44103103", "43211503", ""), + _InjectivityPair( + "minor_planet", + MinorPlanet, + "packed", + "1995 XA", + "1995 XB", + "distinct provisional renderings", + ), _InjectivityPair( "unspsc", UNSPSC, diff --git a/tests/property/test_reentry_invariant.py b/tests/property/test_reentry_invariant.py index cdbebe3e..20845d7f 100644 --- a/tests/property/test_reentry_invariant.py +++ b/tests/property/test_reentry_invariant.py @@ -73,6 +73,7 @@ Email, Language, MacAddress, + MinorPlanet, Money, Phone, SIUnit, @@ -204,6 +205,10 @@ def _row( # MacAddress: tests/capabilities/mac_address/test_capability.py # ::test_format_value_identity_default (colon) _row(MacAddress, "00:1A:2B:3C:4D:5E", "00:1A:2B:3C:4D:5E"), + # MinorPlanet: tests/capabilities/minor_planet/test_capability.py — + # unpacked canonical; the offered packed rendering re-enters through + # the packed branch (J95X00A -> 1995 XA). + _row(MinorPlanet, "1995 XA", "1995 XA"), # Money: tests/capabilities/money/test_capability.py # ::test_code_amount_is_identity (canonical "CODE amount") _row(Money, "45.50 USD", "USD 45.50"), diff --git a/tests/unit/test_api_coverage_fix.py b/tests/unit/test_api_coverage_fix.py index 84c5f933..11e79794 100644 --- a/tests/unit/test_api_coverage_fix.py +++ b/tests/unit/test_api_coverage_fix.py @@ -29,7 +29,7 @@ def test_list_shipped_and_registered(): assert "gtin" in shipped assert "credit_card" in shipped assert "domain" in shipped - assert len(shipped) == 29 + assert len(shipped) == 30 registered = paxman.list_registered_capabilities() assert isinstance(registered, tuple) # after register, registered should equal shipped diff --git a/tests/unit/test_bootstrap.py b/tests/unit/test_bootstrap.py index 1458e646..09755ecc 100644 --- a/tests/unit/test_bootstrap.py +++ b/tests/unit/test_bootstrap.py @@ -47,6 +47,7 @@ def test_registers_all_shipped(_clean_registry) -> None: "language", "lei", "mac_address", + "minor_planet", "money", "orcid", "phone", @@ -74,7 +75,7 @@ def test_preserves_caller_registration(_clean_registry) -> None: register_capability(mine) names = paxman.register_all_shipped() assert "email" not in names - assert len(names) == 28 + assert len(names) == 29 assert get_capability("email") is mine diff --git a/tests/unit/test_capability_exports.py b/tests/unit/test_capability_exports.py index 5a672365..033b473d 100644 --- a/tests/unit/test_capability_exports.py +++ b/tests/unit/test_capability_exports.py @@ -18,6 +18,7 @@ ISSN, Language, MacAddress, + MinorPlanet, Money, ORCID, Phone, @@ -173,6 +174,18 @@ def test_money_capability_name(self) -> None: assert Money.name == "money" +class TestMinorPlanetCapabilityExports: + @pytest.mark.unit + def test_minor_planet_capability_importable(self) -> None: + """MinorPlanet capability is importable from paxman.capabilities.""" + assert MinorPlanet is not None + + @pytest.mark.unit + def test_minor_planet_capability_name(self) -> None: + """MinorPlanet capability has correct name.""" + assert MinorPlanet.name == "minor_planet" + + class TestORCIDCapabilityExports: @pytest.mark.unit def test_orcid_capability_importable(self) -> None: @@ -306,6 +319,7 @@ def test_export_list_contains_all_shipped_names(self) -> None: "LEI", "Language", "MacAddress", + "MinorPlanet", "Money", "ORCID", "Phone", diff --git a/tests/unit/test_capability_lazy_import.py b/tests/unit/test_capability_lazy_import.py index 0c0946b1..9591a113 100644 --- a/tests/unit/test_capability_lazy_import.py +++ b/tests/unit/test_capability_lazy_import.py @@ -77,6 +77,7 @@ def test_all_still_exported_via_all() -> None: "LEI", "Language", "MacAddress", + "MinorPlanet", "Money", "ORCID", "Phone", diff --git a/tests/unit/test_capability_surface.py b/tests/unit/test_capability_surface.py index 78cd1be1..6bc95c06 100644 --- a/tests/unit/test_capability_surface.py +++ b/tests/unit/test_capability_surface.py @@ -54,6 +54,8 @@ from paxman.capabilities.LEI.contract import LEIContract from paxman.capabilities.MacAddress.capability import MacAddressCapability from paxman.capabilities.MacAddress.contract import MacAddressContract +from paxman.capabilities.MinorPlanet.capability import MinorPlanetCapability +from paxman.capabilities.MinorPlanet.contract import MinorPlanetContract from paxman.capabilities.Money.capability import MoneyCapability from paxman.capabilities.Money.contract import MoneyContract from paxman.capabilities.Money.notation import MoneyNotation @@ -178,6 +180,12 @@ "code_amount", id="money", ), + pytest.param( + MinorPlanetCapability, + MinorPlanetContract, + "designation", + id="minor_planet", + ), pytest.param( ISNICapability, ISNIContract, diff --git a/tests/unit/test_offered_format_class_declarations.py b/tests/unit/test_offered_format_class_declarations.py index b76173f4..a489bc49 100644 --- a/tests/unit/test_offered_format_class_declarations.py +++ b/tests/unit/test_offered_format_class_declarations.py @@ -50,6 +50,7 @@ Email, Language, MacAddress, + MinorPlanet, Money, Phone, SIUnit, @@ -81,6 +82,7 @@ "language": Language, "lei": LEI, "mac_address": MacAddress, + "minor_planet": MinorPlanet, "money": Money, "orcid": ORCID, "phone": Phone, diff --git a/tools/generate_readme_table.py b/tools/generate_readme_table.py index f320bbec..712cec26 100644 --- a/tools/generate_readme_table.py +++ b/tools/generate_readme_table.py @@ -72,6 +72,10 @@ "ISO 639, IANA Language Subtag Registry, BCP 47 RFC 5646, CLDR", ), "mac_address": ("MAC addresses", "IEEE Std 802-2024"), + "minor_planet": ( + "Minor-planet designations", + "MPC DesDoc, MPC PackedDes, MPC HowNamed", + ), "money": ("Money amounts", "ISO 4217, CLDR"), "orcid": ("Researcher identifiers", "ISO 27729:2024, MOD 11-2"), "phone": ("Phone numbers", "ITU-T E.164, RFC 3966, NANP"), @@ -107,6 +111,7 @@ "lei": "LEI", "language": "Language", "mac_address": "MacAddress", + "minor_planet": "MinorPlanet", "money": "Money", "orcid": "ORCID", "phone": "Phone", From aada527ee7e6350d91e1e32ff47f9adb88db3885 Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Fri, 9 Oct 2026 08:41:01 +0800 Subject: [PATCH 2/3] fix(minor-planet): early-return A-retrospective years before packed_year computation (TN-01) --- paxman/capabilities/MinorPlanet/rules/mpc_codec.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paxman/capabilities/MinorPlanet/rules/mpc_codec.py b/paxman/capabilities/MinorPlanet/rules/mpc_codec.py index 497eb9f4..360db22a 100644 --- a/paxman/capabilities/MinorPlanet/rules/mpc_codec.py +++ b/paxman/capabilities/MinorPlanet/rules/mpc_codec.py @@ -193,10 +193,10 @@ def mpc_pack(designation: str) -> str | None: # Degenerate cycle ("XA0", "TA05"): the packed field would # collapse the leading zero and fail to re-enter. return None - packed_year = _BASE62[int(year[:2]) if year[0] != "A" else 10] + year[2:] # A-prefix retrospective years have no standard packing lane. if year[0] == "A": return None + packed_year = _BASE62[int(year[:2])] + year[2:] if num == "": return f"{packed_year}{half}00{second}" if len(num) == 1: From e8c376b5777be1f3a8b246d002acaae260557b47 Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Fri, 9 Oct 2026 08:52:54 +0800 Subject: [PATCH 3/3] fix(minor-planet): refuse extended packing for non-20xx years (PR #197 review) mpc_pack encoded the extended _ lane with only the 2-digit year while mpc_unpack hardcodes the 20xx century, so 19xx designations past the 7-char cycle range (e.g. 1950 AA1000 -> _oA02TE) re-entered as 2050. Return None so format_value falls back to the designation; 19xx cycles within 7-char range still pack exactly (century in year letter). --- .../MinorPlanet/rules/mpc_codec.py | 9 +++++++-- .../minor_planet/test_capability.py | 20 +++++++++++++++++++ tests/capabilities/minor_planet/test_rules.py | 11 ++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/paxman/capabilities/MinorPlanet/rules/mpc_codec.py b/paxman/capabilities/MinorPlanet/rules/mpc_codec.py index 360db22a..419b119f 100644 --- a/paxman/capabilities/MinorPlanet/rules/mpc_codec.py +++ b/paxman/capabilities/MinorPlanet/rules/mpc_codec.py @@ -136,8 +136,8 @@ def mpc_unpack(spelled: str) -> str | None: def mpc_pack(designation: str) -> str | None: """Encode a canonical unpacked designation to its packed form. - Returns ``None`` when no packed mapping is defined (the ``A/`` lane) - or the value is out of range. Case-exact output. + Returns ``None`` when no packed mapping is defined (the ``A/`` lane, + non-20xx extended range, or out-of-range value). Case-exact output. """ try: if designation.startswith("(") and designation.endswith(")"): @@ -205,6 +205,11 @@ def mpc_pack(designation: str) -> str | None: if obj_num < 15501: return f"{packed_year}{half}{_BASE62[int(num[:-1])]}{num[-1]}{second}" if obj_num < 14791837: + if not year.startswith("20"): + # Extended `_` lane drops the century (unpack hardcodes + # 20xx): a non-20xx year here would re-enter as 20xx, + # so refuse and let format_value fall back instead. + return None obj_num -= 15501 tail = _base62_encode(obj_num, 4) if tail is None: diff --git a/tests/capabilities/minor_planet/test_capability.py b/tests/capabilities/minor_planet/test_capability.py index 343e897d..7bb473fc 100644 --- a/tests/capabilities/minor_planet/test_capability.py +++ b/tests/capabilities/minor_planet/test_capability.py @@ -83,6 +83,15 @@ def test_format_value_a_lane_fallback(self) -> None: self.capability.format_value("A/2017 U1", "packed", notation) == "A/2017 U1" ) + def test_format_value_packed_non_20xx_extended_fallback(self) -> None: + notation = MinorPlanetNotation( + designation="1950 AA1000", form="provisional", packed="" + ) + assert ( + self.capability.format_value("1950 AA1000", "packed", notation) + == "1950 AA1000" + ) + @pytest.mark.capability class TestMinorPlanetCapabilityPipeline: @@ -100,3 +109,14 @@ def test_scaffold_probe_missing(self) -> None: result = canonicalize("scaffold probe", contract) assert result.status == Resolution.MISSING assert result.canonicalized_value is None + + def test_packed_format_non_20xx_extended_falls_back_and_reenters(self) -> None: + register_capability(MinorPlanetCapability()) + packed = MinorPlanetCapability.create_contract(output_format="packed") + result = canonicalize("1950 AA1000", packed) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "1950 AA1000" + default = MinorPlanetCapability.create_contract() + reentry = canonicalize("1950 AA1000", default) + assert reentry.status == Resolution.SUCCESS + assert reentry.canonicalized_value == "1950 AA1000" diff --git a/tests/capabilities/minor_planet/test_rules.py b/tests/capabilities/minor_planet/test_rules.py index c41e2708..b4cf0e4c 100644 --- a/tests/capabilities/minor_planet/test_rules.py +++ b/tests/capabilities/minor_planet/test_rules.py @@ -277,6 +277,17 @@ def test_pack_roundtrip(self) -> None: def test_pack_none_for_a_lane(self) -> None: assert mpc_pack("A/2017 U1") is None + def test_pack_none_for_non_20xx_extended_range(self) -> None: + # Extended `_` lane drops the century (unpack hardcodes 20xx): + # a 19xx designation past the 7-char cycle range has no packed + # mapping, so pack refuses and format_value falls back. + assert mpc_pack("1950 AA1000") is None + + def test_pack_19xx_small_cycle_still_packed(self) -> None: + # 19xx designations within the 7-char cycle range pack exactly + # (the century survives in the packed year letter). + assert mpc_pack("1950 AA619") == "J50Az9A" + def test_unpack_none_for_incoherent(self) -> None: assert mpc_unpack("Q95X00A") is None assert mpc_unpack("J95I00A") is None