diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55e946f3..104dbe68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,16 @@ jobs: - name: Import lint run: uv run import-linter lint + - name: Check generated Currency/Money data drift + run: uv run python tools/regenerate_currency_data.py --check + + - name: Check generated SIUnit data drift + run: uv run python tools/regenerate_si_prefix_data.py --check + + - name: Benchmark (informational, non-blocking) + continue-on-error: true + run: uv run python -m benchmarks.harness --iterations 50 --output /tmp/bench.json && cat /tmp/bench.json + - name: Tests with coverage run: uv run pytest --cov=paxman --cov-report=term-missing --cov-report=html --tb=short -q diff --git a/HOW_TO_ADD_NEW_CAPABILITY.md b/HOW_TO_ADD_NEW_CAPABILITY.md index bd576549..4203de05 100644 --- a/HOW_TO_ADD_NEW_CAPABILITY.md +++ b/HOW_TO_ADD_NEW_CAPABILITY.md @@ -438,6 +438,8 @@ Every contract and rule in the codebase follows the same structural rules. The ` **1. Every contract MUST inherit `CapabilityContract`** +Subclass `CapabilityContract` (never `Contract` directly). `Contract` is engine-internal since ADR-0007. + Import it from `paxman.core.contract` (defined in `paxman/core/capability_contract.py`). Your contract is a frozen dataclass subclass: ```python diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 00000000..0317c588 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,11 @@ +# Benchmarks (Item 7, W5) + +One scenario per capability, deterministic per (input, contract, library snapshot). + +Run: + uv run python -m benchmarks.harness --iterations 200 + uv run python -m benchmarks.harness --output bench.json + uv run python -m benchmarks.harness --update-baseline # refresh committed baseline.json + +CI runs 50 iterations informational (non-blocking). Baseline is tracked but not gated. +Add a new capability: add one entry to `benchmarks/scenarios.py` with `register` + `contract_factory`. diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/benchmarks/baseline.json b/benchmarks/baseline.json new file mode 100644 index 00000000..cf12a42e --- /dev/null +++ b/benchmarks/baseline.json @@ -0,0 +1,95 @@ +{ + "scenarios": [ + { + "capability": "country", + "iterations": 100, + "mean_ms": 0.27881579706445336, + "p50_ms": 0.20324101205915213, + "p95_ms": 0.31342904549092054, + "min_ms": 0.15853706281632185, + "max_ms": 4.520129994489253 + }, + { + "capability": "currency", + "iterations": 100, + "mean_ms": 0.1070823660120368, + "p50_ms": 0.10521896183490753, + "p95_ms": 0.12719491496682167, + "min_ms": 0.090339919552207, + "max_ms": 0.15212292782962322 + }, + { + "capability": "date", + "iterations": 100, + "mean_ms": 0.11676306487061083, + "p50_ms": 0.0926790526136756, + "p95_ms": 0.17091899644583464, + "min_ms": 0.0784930307418108, + "max_ms": 0.26655092369765043 + }, + { + "capability": "email", + "iterations": 100, + "mean_ms": 0.09656466194428504, + "p50_ms": 0.09762297850102186, + "p95_ms": 0.11605804320424795, + "min_ms": 0.060654012486338615, + "max_ms": 0.17720204778015614 + }, + { + "capability": "ip", + "iterations": 100, + "mean_ms": 0.13068879838101566, + "p50_ms": 0.14457793440669775, + "p95_ms": 0.17728202510625124, + "min_ms": 0.08774595335125923, + "max_ms": 0.27406506706029177 + }, + { + "capability": "isbn", + "iterations": 100, + "mean_ms": 0.13500795583240688, + "p50_ms": 0.13043393846601248, + "p95_ms": 0.18328602891415358, + "min_ms": 0.0971390400081873, + "max_ms": 0.20166800823062658 + }, + { + "capability": "money", + "iterations": 100, + "mean_ms": 0.20503543317317963, + "p50_ms": 0.20394206512719393, + "p95_ms": 0.246229930780828, + "min_ms": 0.11839496437460184, + "max_ms": 0.5321740172803402 + }, + { + "capability": "phone", + "iterations": 100, + "mean_ms": 0.18060301197692752, + "p50_ms": 0.18282991368323565, + "p95_ms": 0.21669501438736916, + "min_ms": 0.10399601887911558, + "max_ms": 0.46293798368424177 + }, + { + "capability": "si_unit", + "iterations": 100, + "mean_ms": 0.12988552218303084, + "p50_ms": 0.12476707343012094, + "p95_ms": 0.15376601368188858, + "min_ms": 0.11326710227876902, + "max_ms": 0.2440139651298523 + }, + { + "capability": "url", + "iterations": 100, + "mean_ms": 0.23713358445093036, + "p50_ms": 0.2435100032016635, + "p95_ms": 0.27114199474453926, + "min_ms": 0.14298106543719769, + "max_ms": 0.47093897592276335 + } + ], + "iterations": 100 +} diff --git a/benchmarks/harness.py b/benchmarks/harness.py new file mode 100644 index 00000000..d00805d7 --- /dev/null +++ b/benchmarks/harness.py @@ -0,0 +1,97 @@ +"""Minimal benchmark harness — one scenario per capability (W5, Item 7). + +No external deps. Deterministic per contract+input+library snapshot. +Measures wall-clock via time.perf_counter, reports mean/p50/p95 per scenario. +Used in CI as non-blocking signal and locally via --update-baseline. + +Usage: + uv run python -m benchmarks.harness --iterations 200 + uv run python -m benchmarks.harness --output /tmp/bench.json + uv run python -m benchmarks.harness --update-baseline +""" + +from __future__ import annotations + +import argparse +import json +import statistics +import sys +import time +from pathlib import Path + +from benchmarks.scenarios import SCENARIOS + +ROOT = Path(__file__).resolve().parents[1] +BASELINE = ROOT / "benchmarks" / "baseline.json" + + +def run_once( + capability: str, text: str, iterations: int = 100 +) -> dict[str, float | str | int]: + from paxman import canonicalize + from paxman.core.discovery import reset_registry + + if iterations <= 0: + raise ValueError(f"iterations must be a positive integer, got {iterations!r}") + + # Import capability lazily to respect PEP 562 (Item 8) + # — harness must not hide import cost + # Scenarios carry a factory: lambda: (contract, text) + scenario = next(s for s in SCENARIOS if s["capability"] == capability) + durations: list[float] = [] + for _ in range(iterations): + reset_registry() + # Register only the needed capability for this scenario + # (isolates import cost if lazy) + scenario["register"]() + contract = scenario["contract_factory"]() + start = time.perf_counter() + canonicalize(text, contract) + durations.append((time.perf_counter() - start) * 1000) + # reset_registry cleans for next iteration + reset_registry() + durations.sort() + p50 = durations[len(durations) // 2] + p95 = durations[int(len(durations) * 0.95)] + return { + "capability": capability, + "iterations": iterations, + "mean_ms": statistics.mean(durations), + "p50_ms": p50, + "p95_ms": p95, + "min_ms": min(durations), + "max_ms": max(durations), + } + + +def main(argv: list[str] | None = None) -> None: + parser = argparse.ArgumentParser(description="Paxman benchmark harness (Item 7)") + parser.add_argument("--iterations", type=int, default=100) + parser.add_argument("--output", type=str, default=None) + parser.add_argument("--update-baseline", action="store_true") + args = parser.parse_args(argv) + + results: list[dict[str, float | str | int]] = [] + for scenario in SCENARIOS: + results.append( + run_once( + scenario["capability"], scenario["text"], iterations=args.iterations + ) + ) + + payload: dict[str, object] = {"scenarios": results, "iterations": args.iterations} + if args.output: + Path(args.output).write_text( + json.dumps(payload, indent=2) + "\n", encoding="utf-8" + ) + print(f"wrote {args.output}") + elif args.update_baseline: + BASELINE.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + print(f"wrote {BASELINE}") + else: + json.dump(payload, sys.stdout, indent=2) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/scenarios.py b/benchmarks/scenarios.py new file mode 100644 index 00000000..c10ded8c --- /dev/null +++ b/benchmarks/scenarios.py @@ -0,0 +1,241 @@ +"""Benchmark scenarios — one deterministic input per capability (Item 7). + +Inputs chosen to exercise the hot path: recognition + validation + formatting. +No network, no clock, no randomness — deterministic per library snapshot. +""" + +from __future__ import annotations + + +def _country_register() -> None: + from paxman.capabilities import Country + from paxman.core.discovery import get_capability, register_capability + from paxman.core.errors import CapabilityError + + try: + get_capability("country") + except CapabilityError: + register_capability(Country()) + + +def _country_contract() -> object: + from paxman.capabilities import Country + + return Country.create_contract() + + +def _currency_register() -> None: + from paxman.capabilities import Currency + from paxman.core.discovery import get_capability, register_capability + from paxman.core.errors import CapabilityError + + try: + get_capability("currency") + except CapabilityError: + register_capability(Currency()) + + +def _currency_contract() -> object: + from paxman.capabilities import Currency + + return Currency.create_contract() + + +def _date_register() -> None: + from paxman.capabilities import Date + from paxman.core.discovery import get_capability, register_capability + from paxman.core.errors import CapabilityError + + try: + get_capability("date") + except CapabilityError: + register_capability(Date()) + + +def _date_contract() -> object: + from paxman.capabilities import Date + + return Date.create_contract() + + +def _email_register() -> None: + from paxman.capabilities import Email + from paxman.core.discovery import get_capability, register_capability + from paxman.core.errors import CapabilityError + + try: + get_capability("email") + except CapabilityError: + register_capability(Email()) + + +def _email_contract() -> object: + from paxman.capabilities import Email + + return Email.create_contract() + + +def _ip_register() -> None: + from paxman.capabilities import IP + from paxman.core.discovery import get_capability, register_capability + from paxman.core.errors import CapabilityError + + try: + get_capability("ip") + except CapabilityError: + register_capability(IP()) + + +def _ip_contract() -> object: + from paxman.capabilities import IP + + return IP.create_contract() + + +def _isbn_register() -> None: + from paxman.capabilities import ISBN + from paxman.core.discovery import get_capability, register_capability + from paxman.core.errors import CapabilityError + + try: + get_capability("isbn") + except CapabilityError: + register_capability(ISBN()) + + +def _isbn_contract() -> object: + from paxman.capabilities import ISBN + + return ISBN.create_contract() + + +def _money_register() -> None: + from paxman.capabilities import Money + from paxman.core.discovery import get_capability, register_capability + from paxman.core.errors import CapabilityError + + try: + get_capability("money") + except CapabilityError: + register_capability(Money()) + + +def _money_contract() -> object: + from paxman.capabilities import Money + + return Money.create_contract() + + +def _phone_register() -> None: + from paxman.capabilities import Phone + from paxman.core.discovery import get_capability, register_capability + from paxman.core.errors import CapabilityError + + try: + get_capability("phone") + except CapabilityError: + register_capability(Phone()) + + +def _phone_contract() -> object: + from paxman.capabilities import Phone + + return Phone.create_contract(default_country="US") + + +def _si_unit_register() -> None: + from paxman.capabilities import SIUnit + from paxman.core.discovery import get_capability, register_capability + from paxman.core.errors import CapabilityError + + try: + get_capability("si_unit") + except CapabilityError: + register_capability(SIUnit()) + + +def _si_unit_contract() -> object: + from paxman.capabilities import SIUnit + + return SIUnit.create_contract() + + +def _url_register() -> None: + from paxman.capabilities import URL + from paxman.core.discovery import get_capability, register_capability + from paxman.core.errors import CapabilityError + + try: + get_capability("url") + except CapabilityError: + register_capability(URL()) + + +def _url_contract() -> object: + from paxman.capabilities import URL + + return URL.create_contract() + + +SCENARIOS: list[dict] = [ + { + "capability": "country", + "text": "United States", + "register": _country_register, + "contract_factory": _country_contract, + }, + { + "capability": "currency", + "text": "USD", + "register": _currency_register, + "contract_factory": _currency_contract, + }, + { + "capability": "date", + "text": "2026-01-15", + "register": _date_register, + "contract_factory": _date_contract, + }, + { + "capability": "email", + "text": "user@example.com", + "register": _email_register, + "contract_factory": _email_contract, + }, + { + "capability": "ip", + "text": "192.168.1.1", + "register": _ip_register, + "contract_factory": _ip_contract, + }, + { + "capability": "isbn", + "text": "9780306406157", + "register": _isbn_register, + "contract_factory": _isbn_contract, + }, + { + "capability": "money", + "text": "USD 500.00", + "register": _money_register, + "contract_factory": _money_contract, + }, + { + "capability": "phone", + "text": "+1 555 123 4567", + "register": _phone_register, + "contract_factory": _phone_contract, + }, + { + "capability": "si_unit", + "text": "kg", + "register": _si_unit_register, + "contract_factory": _si_unit_contract, + }, + { + "capability": "url", + "text": "https://example.com/path", + "register": _url_register, + "contract_factory": _url_contract, + }, +] diff --git a/docs/adr/0007-contract-surface-unification.md b/docs/adr/0007-contract-surface-unification.md new file mode 100644 index 00000000..dab18558 --- /dev/null +++ b/docs/adr/0007-contract-surface-unification.md @@ -0,0 +1,40 @@ +# ADR-0007: Contract Surface Unification — CapabilityContract as Single Source of Truth + +## Status + +Accepted + +## Context + +`paxman/core/contract.py` defines `Contract` as a `@runtime_checkable` Protocol (intended for duck-typed user contracts). `paxman/core/capability_contract.py` defines `CapabilityContract` as a frozen dataclass ABC that every shipped contract MUST inherit (homogeneity mandate, `capabilities/AGENTS.md`). The 2026-08-17 architecture review (W1) found drift: + +- `Contract` omits `extra_grammars`; engine probes via `getattr(contract, "extra_grammars", ())` in `orchestrator._recognize` and `_activated_rules`. A duck-typed `Contract` silently loses the extension seam. +- `Contract.output_format: str | None` vs `CapabilityContract.__post_init__` always resolves to concrete `str`. +- `ContractFactory` docstring says "five capability classes" — there are ten. + +Dual truth is a compat hazard; the ABC has won in practice (all 10 shipped contracts inherit it). + +## Decision + +- `CapabilityContract` is the **only sanctioned public contract base**. Shipped and community contracts MUST inherit it. +- `Contract` Protocol is demoted to **engine-internal** (`paxman/core/_engine_contract.py` or retained as private re-export, not exported from `paxman.core.__init__` or `paxman.__init__`). It exists only for internal structural typing of the engine boundary. +- Engine centralizes `extra_grammars` access in the `_extra_grammars_of` helper which uses `getattr` to detect a missing field and raises `ContractError` (never raw `AttributeError`), preserving fail-fast semantics. +- `ContractFactory` docstring corrected to ten; `capability_name` contract field typed as concrete `str` post-`__post_init__`. +- Breaking change is budgeted at 0.x per M12; provide a one-minor deprecation shim if needed (`Contract = CapabilityContract` alias with DeprecationWarning gated by env var, removed at 0.3.0). + +## Alternatives Considered + +1. **Fix Protocol to match ABC** (add `extra_grammars`, fix `output_format: str`): keeps two definitions in sync forever — drift will recur, no user value (no shipped duck-typed contract exists). +2. **Keep Protocol as public, ABC as convenience**: same dual-sync cost plus weaker homogeneity enforcement (`__init_subclass__` checks bypassed for duck types). + +## Consequences + +- One source of truth; drift eliminated; `getattr` probes deleted. +- Engine type hints narrow to `CapabilityContract`; `pyright` strict passes without `getattr` fallback. +- Third-party duck-typed contracts without inheritance fail fast with actionable `ContractError` instead of silent extension-seam loss. +- `import-linter` layers unchanged; no new dependencies. + +## References + +- `docs/reports/2026-08-17-architecture-review.md` W1, §9 item 5, M9 +- `paxman/core/capability_contract.py`, `paxman/core/contract.py`, `paxman/engine/orchestrator.py` diff --git a/ocs/development/plans/2026-08-19-mid-term-recommendations.md b/ocs/development/plans/2026-08-19-mid-term-recommendations.md new file mode 100644 index 00000000..e78e9580 --- /dev/null +++ b/ocs/development/plans/2026-08-19-mid-term-recommendations.md @@ -0,0 +1,1551 @@ +# Mid-Term Recommendations Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver all four Mid-Term structural-debt items from `docs/reports/2026-08-17-architecture-review.md` §9 (items 5–8) without architectural surgery — resolving Protocol-vs-ABC drift, standing up the shared-vocabulary data pipeline for Currency/Money, adding a minimal benchmark harness, and adopting PEP 562 lazy exports for `paxman.capabilities`. + +**Architecture:** Four independent verticals that converge on determinism + isolation invariants: (5) ADR-driven contract unification with `CapabilityContract` as single source of truth and removal of `getattr` probes; (6) snapshot-driven regeneration `tools/regenerate_currency_data.py` that writes *into* per-capability tables preserving import-linter isolation; (7) pure-Python `benchmarks/` harness with one scenario per capability and JSON baseline tracking; (8) `PEP 562 __getattr__/__dir__` in `paxman/capabilities/__init__.py` with eager `__all__` preserved for `pyright`/`ruff`. + +**Tech Stack:** Python 3.11+, `uv`, `hatchling`, `pyright` (strict), `ruff`, `import-linter`, `pytest` + `pytest-cov` (95% per-package gates), `time.perf_counter` (bench), `argparse` + `pathlib` (tools), `importlib.metadata` (version stamp) + +--- + +## File Structure + +Before tasks, lock decomposition. Files grouped by responsibility; each file does one thing. + +**New files:** +- `docs/adr/0007-contract-surface-unification.md` — ADR for item 5 (Protocol vs ABC decision) +- `paxman/core/_engine_contract.py` — engine-internal Protocol (demoted `Contract`), not publicly exported (optional, see Task 3 variant) +- `paxman/shared_data/currency_snapshot.json` + `paxman/shared_data/README.md` — shared CLDR/ISO 4217 snapshot (source of truth for item 6) +- `tools/regenerate_currency_data.py` — regenerates Currency + Money tables from shared snapshot (item 6) +- `benchmarks/__init__.py` — package marker +- `benchmarks/harness.py` — runner, timing, JSON output +- `benchmarks/scenarios.py` — one deterministic scenario per capability (10 entries) +- `benchmarks/baseline.json` — committed baseline (updated via `--update-baseline`) +- `tests/unit/test_contract_surface.py` — contract surface unification tests (item 5) +- `tests/unit/test_capability_lazy_import.py` — PEP 562 lazy-import tests (item 8) +- `tests/unit/test_currency_data_regeneration.py` — drift guard for item 6 (mirrors `tests/capabilities/si_unit/test_data.py`) +- `tests/benchmarks/test_harness.py` — harness smoke tests (item 7) + +**Modified files:** +- `paxman/core/contract.py` — shrink to `resolve_output_format` only or re-export shim (item 5) +- `paxman/core/capability_contract.py` — docstring/typing polish, satisfies internal Protocol structurally (item 5) +- `paxman/core/__init__.py` — stop re-exporting `Contract` publicly; export `CapabilityContract` only (item 5) +- `paxman/core/capability.py` — update `ContractFactory` docstring (5→10) +- `paxman/engine/orchestrator.py` — `_recognize`, `_activated_rules`, `_filter_rules` remove `getattr(contract, "extra_grammars", ())`; direct `contract.extra_grammars`; type to `CapabilityContract` (item 5) +- `paxman/capabilities/__init__.py` — PEP 562 lazy `__getattr__` (item 8) +- `paxman/capabilities/Currency/grammar/data/currency_symbols.py` — becomes GENERATED (item 6) +- `paxman/capabilities/Currency/grammar/data/currency_words.py` — becomes GENERATED (item 6) +- `paxman/capabilities/Currency/rules/data/cldr_currencies.py` — becomes GENERATED (item 6) +- `paxman/capabilities/Currency/rules/data/iso4217_list_one.py` — becomes GENERATED (item 6) +- `paxman/capabilities/Money/grammar/data/currency_symbols.py` — becomes GENERATED (item 6) +- `paxman/capabilities/Money/grammar/data/currency_words.py` — becomes GENERATED (item 6) +- `paxman/capabilities/Money/rules/data/cldr_currencies.py` — becomes GENERATED (item 6) +- `paxman/capabilities/Money/rules/data/iso4217_list_one.py` — becomes GENERATED (item 6) +- `.github/workflows/ci.yml` — add bench + drift check jobs (items 6, 7) +- `pyproject.toml` — add `[tool.pytest.ini_options] markers` entry `benchmark`, add `benchmarks` to coverage `omit` or explicit per-package handling (item 7) +- `HOW_TO_ADD_NEW_CAPABILITY.md` — note shared snapshot workflow (item 6) +- `paxman/capabilities/AGENTS.md` — update data-placement rules for generated tables (item 6) + +**Untouched (enforced invariants):** +- `paxman/core/domain.py` — no change (Rule/Grammar ABCs stay) +- `paxman/core/discovery.py`, `paxman/core/extensions.py` — no import added (layer discipline intact) +- No sibling imports introduced; `import-linter` layers `api → engine → capabilities → core` remain enforced. + +--- + +## Task Overview + +| Task | Vertical | Effort | Deliverable | +|------|----------|--------|-------------| +| 1 | Item 5 — Contract ADR | 15 min | `docs/adr/0007-*.md` accepted | +| 2 | Item 5 — Failing surface tests | 20 min | `tests/unit/test_contract_surface.py` RED | +| 3 | Item 5 — Engine + core unification | 40 min | `getattr` probes removed, `Contract` demoted, pyright clean | +| 4 | Item 5 — Docs + export polish | 15 min | `__init__.py`, `capability.py` docstring, `HOW_TO` note | +| 5 | Item 6 — Snapshot audit + scaffold | 20 min | `paxman/shared_data/` + snapshot JSON | +| 6 | Item 6 — Regeneration tool | 45 min | `tools/regenerate_currency_data.py` with `--check` | +| 7 | Item 6 — Migrate Currency tables to GENERATED | 25 min | 4 Currency data files regenerated, tests GREEN | +| 8 | Item 6 — Migrate Money tables + import-linter guard | 25 min | 4 Money data files regenerated, `import-linter lint` GREEN | +| 9 | Item 6 — Drift tests + CI wiring | 20 min | `test_currency_data_regeneration.py` + CI job | +| 10 | Item 7 — Benchmark harness | 30 min | `benchmarks/harness.py` runnable via `uv run python -m benchmarks.harness` | +| 11 | Item 7 — Scenarios (10 capabilities) | 30 min | `benchmarks/scenarios.py` deterministic, property-checked | +| 12 | Item 7 — Baseline + CI | 20 min | `benchmarks/baseline.json` + CI `benchmark` job (non-blocking) | +| 13 | Item 7 — Docs | 10 min | `benchmarks/README.md` + `ARCHITECTURE.md` note | +| 14 | Item 8 — Lazy-import failing tests | 20 min | `tests/unit/test_capability_lazy_import.py` RED | +| 15 | Item 8 — PEP 562 `__getattr__` | 30 min | `paxman/capabilities/__init__.py` lazy, backward compatible | +| 16 | Item 8 — Type + lint + perf verification | 25 min | `pyright`, `ruff`, import-time micro-bench GREEN | +| 17 | Final — Cross-vertical verification gate | 20 min | Full `ruff + pyright + import-linter + pytest + coverage` GREEN | + +--- + +### Task 1: Item 5 — Write ADR 0007 (Contract Surface Unification) + +**Files:** +- Create: `docs/adr/0007-contract-surface-unification.md` +- Read: `docs/adr/0003-semantic-affinity-routing.md`, `paxman/core/contract.py`, `paxman/core/capability_contract.py`, `paxman/engine/orchestrator.py:146-158` + +- [ ] **Step 1: Create ADR file with Accepted status** + +Create `docs/adr/0007-contract-surface-unification.md` with exact content: + +```markdown +# ADR-0007: Contract Surface Unification — CapabilityContract as Single Source of Truth + +## Status + +Accepted + +## Context + +`paxman/core/contract.py` defines `Contract` as a `@runtime_checkable` Protocol (intended for duck-typed user contracts). `paxman/core/capability_contract.py` defines `CapabilityContract` as a frozen dataclass ABC that every shipped contract MUST inherit (homogeneity mandate, `capabilities/AGENTS.md`). The 2026-08-17 architecture review (W1) found drift: + +- `Contract` omits `extra_grammars`; engine probes via `getattr(contract, "extra_grammars", ())` in `orchestrator._recognize` and `_activated_rules`. A duck-typed `Contract` silently loses the extension seam. +- `Contract.output_format: str | None` vs `CapabilityContract.__post_init__` always resolves to concrete `str`. +- `ContractFactory` docstring says "five capability classes" — there are ten. + +Dual truth is a compat hazard; the ABC has won in practice (all 10 shipped contracts inherit it). + +## Decision + +- `CapabilityContract` is the **only sanctioned public contract base**. Shipped and community contracts MUST inherit it. +- `Contract` Protocol is demoted to **engine-internal** (`paxman/core/_engine_contract.py` or retained as private re-export, not exported from `paxman.core.__init__` or `paxman.__init__`). It exists only for internal structural typing of the engine boundary. +- Engine removes all `getattr(contract, "extra_grammars", ())` probes; accesses `contract.extra_grammars` directly (fail-fast `AttributeError` → `ContractError` wrapping if violated). +- `ContractFactory` docstring corrected to ten; `capability_name` contract field typed as concrete `str` post-`__post_init__`. +- Breaking change is budgeted at 0.x per M12; provide a one-minor deprecation shim if needed (`Contract = CapabilityContract` alias with DeprecationWarning gated by env var, removed at 0.3.0). + +## Alternatives Considered + +1. **Fix Protocol to match ABC** (add `extra_grammars`, fix `output_format: str`): keeps two definitions in sync forever — drift will recur, no user value (no shipped duck-typed contract exists). +2. **Keep Protocol as public, ABC as convenience**: same dual-sync cost plus weaker homogeneity enforcement (`__init_subclass__` checks bypassed for duck types). + +## Consequences + +- One source of truth; drift eliminated; `getattr` probes deleted. +- Engine type hints narrow to `CapabilityContract`; `pyright` strict passes without `getattr` fallback. +- Third-party duck-typed contracts without inheritance fail fast with actionable `ContractError` instead of silent extension-seam loss. +- `import-linter` layers unchanged; no new dependencies. + +## References + +- `docs/reports/2026-08-17-architecture-review.md` W1, §9 item 5, M9 +- `paxman/core/capability_contract.py`, `paxman/core/contract.py`, `paxman/engine/orchestrator.py` +``` + +- [ ] **Step 2: Verify ADR renders and links** + +Run: `uv run python -c "from pathlib import Path; print(Path('docs/adr/0007-contract-surface-unification.md').read_text()[:200])"` +Expected: prints header, no exception + +- [ ] **Step 3: Commit** + +```bash +git add docs/adr/0007-contract-surface-unification.md +git commit -m "docs(adr): add ADR-0007 contract surface unification (Item 5)" +``` + +--- + +### Task 2: Item 5 — Write Failing Surface Tests (TDD Red) + +**Files:** +- Create: `tests/unit/test_contract_surface.py` +- Read: `tests/unit/test_capability_contract.py`, `tests/unit/test_contract.py`, `paxman/core/__init__.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/unit/test_contract_surface.py`: + +```python +"""Item 5 — contract surface unification: CapabilityContract is the single source of truth. + +These tests drive Task 3: they will FAIL until the engine removes getattr probes +and Contract is demoted from public exports. +""" + +from __future__ import annotations + +import importlib + +import pytest + + +def test_contract_not_exported_from_core_public_api() -> None: + """After unification, `Contract` must NOT be exported from `paxman.core`.""" + import paxman.core as core + + assert not hasattr(core, "Contract") or core.Contract.__module__.endswith( + "_engine_contract" + ), "Contract must not be publicly re-exported from paxman.core" + + +def test_capability_contract_is_only_public_base() -> None: + """Every shipped contract must inherit CapabilityContract (homogeneity mandate).""" + from paxman.capabilities.Country.contract import CountryContract + from paxman.capabilities.Currency.contract import CurrencyContract + from paxman.capabilities.Date.contract import DateContract + from paxman.capabilities.Email.contract import EmailContract + from paxman.capabilities.IP.contract import IPContract + from paxman.capabilities.ISBN.contract import ISBNContract + from paxman.capabilities.Money.contract import MoneyContract + from paxman.capabilities.Phone.contract import PhoneContract + from paxman.capabilities.SIUnit.contract import SIUnitContract + from paxman.capabilities.URL.contract import URLContract + from paxman.core.capability_contract import CapabilityContract + + for cls in [ + CountryContract, + CurrencyContract, + DateContract, + EmailContract, + IPContract, + ISBNContract, + MoneyContract, + PhoneContract, + SIUnitContract, + URLContract, + ]: + assert issubclass(cls, CapabilityContract), f"{cls.__name__} must inherit CapabilityContract" + + +def test_engine_requires_extra_grammars_attribute() -> None: + """Engine must access contract.extra_grammars directly (no getattr fallback).""" + from dataclasses import dataclass + + from paxman.core.capability_contract import CapabilityContract + from paxman.engine.orchestrator import _recognize # type: ignore[attr-defined] + + # Build a minimal contract-like object WITHOUT extra_grammars — should fail fast + # (after fix, engine does NOT use getattr(... , ()); it accesses directly) + @dataclass(frozen=True) + class _BadContract: + capability_name: str = "email" + active_grammars = None + excluded_rules: tuple[str, ...] = () + pinned_rules: tuple[str, ...] | None = None + year: int | None = None + output_format: str | None = None + # NOTE: no extra_grammars attribute at all + + # The engine should not silently succeed via getattr fallback + import inspect + + src = inspect.getsource(_recognize) + assert 'getattr(contract, "extra_grammars"' not in src, "getattr probe must be removed from _recognize" + + src2 = inspect.getsource(importlib.import_module("paxman.engine.orchestrator")) + assert 'getattr(contract, "extra_grammars"' not in src2, "all getattr probes for extra_grammars must be removed" + + +def test_contract_factory_docstring_mentions_ten() -> None: + """ContractFactory docstring must say ten, not five.""" + from paxman.core.capability import ContractFactory + + assert ContractFactory.__doc__ is not None + assert "five" not in ContractFactory.__doc__.lower(), "stale 'five' must be fixed" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/unit/test_contract_surface.py -v` +Expected: FAIL — `test_contract_not_exported_from_core_public_api` (Contract still exported) and `test_engine_requires_extra_grammars_attribute` (getattr still present) + +- [ ] **Step 3: Commit the failing test (TDD Red)** + +```bash +git add tests/unit/test_contract_surface.py +git commit -m "test(core): add failing contract surface unification tests (Item 5 RED)" +``` + +--- + +### Task 3: Item 5 — Implement Engine + Core Unification (TDD Green) + +**Files:** +- Modify: `paxman/core/contract.py` +- Modify: `paxman/core/capability_contract.py` +- Modify: `paxman/core/__init__.py` +- Modify: `paxman/engine/orchestrator.py` +- Modify: `paxman/core/domain.py` (if it imports Contract for typing — narrow to CapabilityContract where needed) +- Create (optional): `paxman/core/_engine_contract.py` + +- [ ] **Step 1: Demote Contract Protocol to engine-internal** + +Option A (minimal diff, recommended): keep `paxman/core/contract.py` but stop re-exporting `Contract` publicly; keep `Contract` as internal Protocol used only by engine typing. Add header comment. + +Edit `paxman/core/contract.py`: add module docstring note at top: + +```python +"""Contract protocol — ENGINE-INTERNAL since ADR-0007. + +Public contracts MUST inherit `CapabilityContract`. This Protocol is retained +only for internal structural typing of the engine boundary and is NOT part of +the public API. Do not import it from `paxman.core` — import `CapabilityContract`. +""" +``` + +No functional change to `Contract` class itself in this task (keep for internal use). The public-export cut happens in `paxman/core/__init__.py`. + +If you create `paxman/core/_engine_contract.py`, move `Contract` there and make `paxman/core/contract.py` a shim that re-exports both `Contract` (deprecated) and `resolve_output_format`. For this plan, keep single file to minimize churn. + +- [ ] **Step 2: Remove public re-export of Contract** + +Edit `paxman/core/__init__.py`: remove `Contract` from imports and `__all__`. Ensure `CapabilityContract` remains exported. + +Before: +```python +from paxman.core.contract import Contract +... +__all__ = ["Capability", "Contract", ...] +``` + +After: +```python +from paxman.core.capability_contract import CapabilityContract +... +__all__ = ["Capability", "CapabilityContract", ...] # Contract intentionally not exported +``` + +Add `from paxman.core.contract import Contract as _Contract # internal, not exported` if engine still needs it via `paxman.core.contract` (not via `paxman.core`). + +- [ ] **Step 3: Remove getattr probes in orchestrator** + +Edit `paxman/engine/orchestrator.py`: + +Change `_recognize` line 147: +```python +# Before: +extra_grammars = getattr(contract, "extra_grammars", ()) +# After: +extra_grammars = contract.extra_grammars # type: ignore[attr-defined] # CapabilityContract always has this (ADR-0007) +``` + +Change `_activated_rules` line 494: +```python +# Before: +extra_grammars = set(getattr(contract, "extra_grammars", ())) +# After: +extra_grammars = set(contract.extra_grammars) # type: ignore[attr-defined] +``` + +Change `_filter_rules` inspection: it already uses `hasattr` for `requires_features` — keep that (that's feature-gating, not contract surface). Do NOT change `_filter_rules` feature logic. + +Update type hints at top of `orchestrator.py`: + +```python +from paxman.core.capability_contract import CapabilityContract + +# Change ExecutionResult.contract and run_capability signature to prefer CapabilityContract +# Keep Contract as narrow internal alias if needed for backward compat: +from paxman.core.contract import Contract as _Contract # internal +``` + +But to keep change minimal and pyright-clean, change signatures to `CapabilityContract`: + +```python +@dataclass(frozen=True) +class ExecutionResult: + status: Resolution + canonicalized_value: str | None + candidates: tuple[Candidate, ...] + contract: CapabilityContract + version_stamp: VersionStamp + span: tuple[int, int] | None = None + +def run_capability(text: str, contract: CapabilityContract) -> ExecutionResult: +``` + +Ensure `RecognizedRep.contract` typing in `paxman/core/domain.py` remains `Contract` or widen to `CapabilityContract` — check `domain.py` import; if it imports `Contract`, change to `CapabilityContract` or keep `Contract` as internal Protocol and update import path to `paxman.core._engine_contract` if you created it. Keep one Protocol type to avoid dual. + +Simplest: leave `domain.py` importing from `paxman.core.contract` (internal) — still works because `CapabilityContract` structurally satisfies it. No change to `domain.py` needed if you kept `Contract` in `contract.py`. + +- [ ] **Step 4: Fix ContractFactory docstring** + +Edit `paxman/core/capability.py` line 71 docstring: + +```python +# Before: "Every capability exposes..." (no mention of count) or "five" +# Ensure it says ten if it mentions count, or remove count entirely and say "every shipped capability class satisfies it" +``` + +Change docstring to: + +```python +@runtime_checkable +class ContractFactory(Protocol): + """Factory protocol for capability contract creation. + + Every shipped capability class (ten as of 0.2.0) satisfies it by declaring + ``create_contract`` with the unanimous common parameter block. + """ +``` + +- [ ] **Step 5: Run tests to verify GREEN** + +Run: `uv run pytest tests/unit/test_contract_surface.py -v` +Expected: PASS (3/3 or 4/4 depending on count) + +Run: `uv run pyright` +Expected: PASS (0 errors). If `contract.extra_grammars` triggers `reportAttributeAccessIssue`, add `# type: ignore[attr-defined]` or narrow `contract` param to `CapabilityContract` so pyright sees the attribute. + +Run: `uv run import-linter lint` +Expected: PASS (no new layer violations) + +- [ ] **Step 6: Commit** + +```bash +git add paxman/core/contract.py paxman/core/__init__.py paxman/engine/orchestrator.py paxman/core/capability.py tests/unit/test_contract_surface.py +git commit -m "refactor(core): unify contract surface — CapabilityContract single source (ADR-0007, Item 5)" +``` + +--- + +### Task 4: Item 5 — Docs + Export Polish + +**Files:** +- Modify: `paxman/api/__init__.py` (if it re-exports Contract) +- Modify: `HOW_TO_ADD_NEW_CAPABILITY.md` (contract section) +- Modify: `paxman/capabilities/AGENTS.md` (contract line) + +- [ ] **Step 1: Ensure public API does not re-export Contract** + +Check `paxman/__init__.py` and `paxman/api/__init__.py`: ensure `Contract` not in `__all__`. If found, remove. + +- [ ] **Step 2: Update HOW_TO note** + +In `HOW_TO_ADD_NEW_CAPABILITY.md`, in the Contract section, ensure it says: + +> Subclass `CapabilityContract` (never `Contract` directly). `Contract` is engine-internal since ADR-0007. + +No code, just one paragraph edit. + +- [ ] **Step 3: Run doc-sensitive tests** + +Run: `uv run pytest tests/unit/test_capability_exports.py tests/unit/test_capability_surface.py -v` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add paxman/__init__.py paxman/api/__init__.py HOW_TO_ADD_NEW_CAPABILITY.md paxman/capabilities/AGENTS.md +git commit -m "docs(core): polish contract exports and HOWTO for ADR-0007 (Item 5)" +``` + +--- + +### Task 5: Item 6 — Shared Snapshot Audit + Scaffold + +**Files:** +- Create: `paxman/shared_data/README.md` +- Create: `paxman/shared_data/currency_snapshot.json` +- Read: `paxman/capabilities/Currency/rules/data/cldr_currencies.py`, `paxman/capabilities/Money/rules/data/cldr_currencies.py`, `paxman/capabilities/Currency/rules/data/iso4217_list_one.py`, `paxman/capabilities/Money/rules/data/iso4217_list_one.py`, `paxman/capabilities/Currency/grammar/data/currency_symbols.py`, `paxman/capabilities/Currency/grammar/data/currency_words.py`, `paxman/capabilities/Money/grammar/data/currency_symbols.py`, `paxman/capabilities/Money/grammar/data/currency_words.py` + +- [ ] **Step 1: Audit duplication** + +Run: + +```bash +diff -u paxman/capabilities/Currency/rules/data/cldr_currencies.py paxman/capabilities/Money/rules/data/cldr_currencies.py | head -n 120 +diff -u paxman/capabilities/Currency/grammar/data/currency_symbols.py paxman/capabilities/Money/grammar/data/currency_symbols.py | head -n 80 +wc -l paxman/capabilities/Currency/rules/data/*.py paxman/capabilities/Money/rules/data/*.py +``` + +Record findings in commit message; the snapshot must be the union of both with provenance fields. + +- [ ] **Step 2: Create shared_data directory and README** + +Create `paxman/shared_data/README.md`: + +```markdown +# Shared Vocabulary Snapshot — Currency / Money + +Source of truth for CLDR currency data and ISO 4217 list-one. + +- Authority: Unicode CLDR v47 (en + es) and ISO 4217 (2015 list-one snapshot). +- Canonical file: `currency_snapshot.json` (JSON, UTF-8, sorted keys). +- Generated outputs: `paxman/capabilities/Currency/{grammar,rules}/data/*` and `paxman/capabilities/Money/{grammar,rules}/data/*` via `tools/regenerate_currency_data.py`. +- Edit workflow: update snapshot JSON (with citation), then `uv run python tools/regenerate_currency_data.py` and `--check` in CI. + +Mandate M8: Sibling imports remain banned. Shared vocabularies regenerate into per-capability tables, never imported across capabilities. +``` + +- [ ] **Step 3: Create snapshot JSON (union of current data)** + +Structure `paxman/shared_data/currency_snapshot.json`: + +```json +{ + "_meta": { + "authority": "Unicode CLDR v47 (en + es) + ISO 4217:2015 list-one", + "generated_by": "manual union of Currency/Money tables as of 2026-08-19", + "citation": "CLDR common/main/en.xml, common/main/es.xml; ISO 4217 list-one 2015" + }, + "iso4217": [ + {"alpha3": "USD", "numeric": "840", "minor_units": 2, "name": "US Dollar"}, + {"alpha3": "EUR", "numeric": "978", "minor_units": 2, "name": "Euro"} + ], + "cldr_currencies": { + "USD": {"symbol": "$", "display_name_en": "US Dollar", "display_name_es": "dólar estadounidense"}, + "EUR": {"symbol": "€", "display_name_en": "Euro", "display_name_es": "euro"} + }, + "symbol_to_codes": { + "$": ["USD", "AUD", "CAD"], + "€": ["EUR"] + } +} +``` + +Populate by extracting from the 4 existing files: write a one-off script `tools/_extract_currency_snapshot.py` (delete after) that imports both Currency and Money data modules, merges dicts, sorts keys, writes JSON. Do NOT hand-edit ISO tables by eye. + +Example extractor (run once, then delete): + +```python +import json, pathlib +from paxman.capabilities.Currency.rules.data.cldr_currencies import SYMBOL_TO_CODES as A +from paxman.capabilities.Money.rules.data.cldr_currencies import SYMBOL_TO_CODES as B +merged = {**A, **B} # union, Money wins on collision — inspect diff manually +path = pathlib.Path("paxman/shared_data/currency_snapshot.json") +path.write_text(json.dumps({"symbol_to_codes": {k: sorted(v) for k,v in sorted(merged.items())}}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") +``` + +- [ ] **Step 4: Commit snapshot scaffold** + +```bash +git add paxman/shared_data/README.md paxman/shared_data/currency_snapshot.json +git commit -m "feat(shared-data): scaffold shared CLDR/ISO4217 snapshot for Currency+Money (Item 6)" +``` + +--- + +### Task 6: Item 6 — Regeneration Tool + +**Files:** +- Create: `tools/regenerate_currency_data.py` +- Read: `tools/regenerate_si_prefix_data.py` (template for `--check`, `_GENERATED_HEADER`, deterministic emit), `tools/regenerate_isbn_range_data.py` + +- [ ] **Step 1: Write the failing test for the tool (RED)** + +Create `tests/unit/test_currency_data_regeneration.py` (mirrors `tests/capabilities/si_unit/test_data.py` pattern): + +```python +"""Drift guard for Currency/Money generated tables (Item 6, M8).""" + +from __future__ import annotations + +import subprocess +import sys + + +def test_currency_data_not_drifted() -> None: + proc = subprocess.run( + [sys.executable, "tools/regenerate_currency_data.py", "--check"], + capture_output=True, + text=True, + ) + assert proc.returncode == 0, f"DRIFT: {proc.stderr or proc.stdout}\nRun: uv run python tools/regenerate_currency_data.py" +``` + +Run: `uv run pytest tests/unit/test_currency_data_regeneration.py -v` +Expected: FAIL (tool does not exist yet) + +- [ ] **Step 2: Implement tool** + +Create `tools/regenerate_currency_data.py`: + +```python +"""Regenerate Currency + Money data modules from shared snapshot. + +Source: paxman/shared_data/currency_snapshot.json (CLDR v47 + ISO 4217). +Outputs (8 files, deterministic, longest-first ordering for tokens): +- paxman/capabilities/Currency/grammar/data/currency_symbols.py +- paxman/capabilities/Currency/grammar/data/currency_words.py +- paxman/capabilities/Currency/rules/data/cldr_currencies.py +- paxman/capabilities/Currency/rules/data/iso4217_list_one.py +- paxman/capabilities/Money/grammar/data/currency_symbols.py +- paxman/capabilities/Money/grammar/data/currency_words.py +- paxman/capabilities/Money/rules/data/cldr_currencies.py +- paxman/capabilities/Money/rules/data/iso4217_list_one.py + +Regeneration preserves import-linter isolation: each capability gets its own +local table; sibling imports remain banned (M8). + +Usage: + uv run python tools/regenerate_currency_data.py + uv run python tools/regenerate_currency_data.py --check +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SNAPSHOT = ROOT / "paxman" / "shared_data" / "currency_snapshot.json" + +_GENERATED_HEADER = "GENERATED by tools/regenerate_currency_data.py — do not edit by hand." + +# Per-capability output paths (deterministic order) +OUTPUTS = [ + ROOT / "paxman" / "capabilities" / "Currency" / "grammar" / "data" / "currency_symbols.py", + ROOT / "paxman" / "capabilities" / "Currency" / "grammar" / "data" / "currency_words.py", + ROOT / "paxman" / "capabilities" / "Currency" / "rules" / "data" / "cldr_currencies.py", + ROOT / "paxman" / "capabilities" / "Currency" / "rules" / "data" / "iso4217_list_one.py", + ROOT / "paxman" / "capabilities" / "Money" / "grammar" / "data" / "currency_symbols.py", + ROOT / "paxman" / "capabilities" / "Money" / "grammar" / "data" / "currency_words.py", + ROOT / "paxman" / "capabilities" / "Money" / "rules" / "data" / "cldr_currencies.py", + ROOT / "paxman" / "capabilities" / "Money" / "rules" / "data" / "iso4217_list_one.py", +] + +# ... helpers: _load_snapshot(), _emit_currency_symbols(), _emit_currency_words(), +# _emit_cldr_currencies(), _emit_iso4217_list_one(), _emit_module(doc, assignment) + +def _emit_module(docstring: str, assignment: str) -> str: + return '"""' + _GENERATED_HEADER + "\n\n" + docstring + '"""\n\nfrom __future__ import annotations\n\n' + assignment + "\n" + +def _load_snapshot() -> dict: + return json.loads(SNAPSHOT.read_text(encoding="utf-8")) + +def _symbol_tokens(snapshot: dict) -> tuple[str, ...]: + # From snapshot["symbol_to_codes"] keys, qualified before bare, longest-first + symbols = list(snapshot.get("symbol_to_codes", {}).keys()) + def _rank(s: str) -> tuple[int, int, str]: + qualified = 0 if any(c.isascii() and c.isalpha() for c in s) else 1 + return (qualified, -len(s), s) + return tuple(sorted(symbols, key=_rank)) + +def _word_tokens(snapshot: dict) -> tuple[str, ...]: + # From cldr display names + ISO names, longest-first + words: set[str] = set() + for v in snapshot.get("cldr_currencies", {}).values(): + words.add(v.get("display_name_en", "").lower()) + words.add(v.get("display_name_es", "").lower()) + words.discard("") + return tuple(sorted(words, key=lambda w: (-len(w), w))) + +def _modules() -> list[tuple[Path, str]]: + snap = _load_snapshot() + symbols = _symbol_tokens(snap) + words = _word_tokens(snap) + # Emit grammar tokens + sym_assign = "SYMBOL_TOKENS: tuple[str, ...] = (\n" + "".join(f' "{s}",\n' for s in symbols) + ")" + word_assign = "CURRENCY_WORDS: tuple[str, ...] = (\n" + "".join(f' "{w}",\n' for w in words) + ")" if words else 'CURRENCY_WORDS: tuple[str, ...] = ()' + # Emit rule tables: reproduce current module shape exactly so tests stay green + # For brevity in plan, emit minimal correct shape; refine during implementation to match byte-for-byte expected structure + cldr_assign = f"SYMBOL_TO_CODES: dict[str, list[str]] = {snap.get('symbol_to_codes', {})!r}" + iso_assign = f"ISO4217_LIST_ONE: list[dict] = {snap.get('iso4217', [])!r}" + # Build 8 modules: Currency + Money share same content per M8 (regenerate into per-capability tables) + return [ + (OUTPUTS[0], _emit_module("Currency symbol tokens (CLDR).", sym_assign)), + (OUTPUTS[1], _emit_module("Currency word tokens (CLDR).", word_assign)), + (OUTPUTS[2], _emit_module("CLDR symbol->codes mapping.", cldr_assign)), + (OUTPUTS[3], _emit_module("ISO 4217 list-one.", iso_assign)), + (OUTPUTS[4], _emit_module("Currency symbol tokens (CLDR) — Money copy.", sym_assign)), + (OUTPUTS[5], _emit_module("Currency word tokens (CLDR) — Money copy.", word_assign)), + (OUTPUTS[6], _emit_module("CLDR symbol->codes mapping — Money copy.", cldr_assign)), + (OUTPUTS[7], _emit_module("ISO 4217 list-one — Money copy.", iso_assign)), + ] + +def main() -> None: + parser = argparse.ArgumentParser(description="Regenerate Currency/Money data modules.") + parser.add_argument("--check", action="store_true", help="check drift, exit non-zero if drifted") + args = parser.parse_args() + mods = _modules() + if args.check: + drifted = [p for p, t in mods if not p.exists() or p.read_text(encoding="utf-8") != t] + if drifted: + for p in drifted: + print(f"DRIFT: {p.relative_to(ROOT)}", file=sys.stderr) + raise SystemExit(1) + print("all Currency/Money generated data modules are up to date") + return + for p, t in mods: + p.write_text(t, encoding="utf-8") + print(f"wrote {p.relative_to(ROOT)}") + +if __name__ == "__main__": + main() +``` + +Key: keep `SYMBOL_TOKENS` ordering identical to current `currency_symbols.py` (qualified + longest-first) so existing grammar tests stay green without behavior change. Copy existing emit logic verbatim in implementation; stub above is schematic — flesh out to match file headers line-for-line before marking task done. + +- [ ] **Step 3: Run tool and verify it creates files** + +Run: `uv run python tools/regenerate_currency_data.py` +Expected: `wrote paxman/capabilities/Currency/grammar/data/currency_symbols.py` x8 + +Run: `uv run python tools/regenerate_currency_data.py --check` +Expected: `all Currency/Money generated data modules are up to date` + +- [ ] **Step 4: Run drift test to verify GREEN** + +Run: `uv run pytest tests/unit/test_currency_data_regeneration.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add tools/regenerate_currency_data.py tests/unit/test_currency_data_regeneration.py +git commit -m "feat(tools): add regenerate_currency_data with --check (Item 6)" +``` + +--- + +### Task 7: Item 6 — Migrate Currency Tables to GENERATED + +**Files:** +- Modify: `paxman/capabilities/Currency/grammar/data/currency_symbols.py` +- Modify: `paxman/capabilities/Currency/grammar/data/currency_words.py` +- Modify: `paxman/capabilities/Currency/rules/data/cldr_currencies.py` +- Modify: `paxman/capabilities/Currency/rules/data/iso4217_list_one.py` + +- [ ] **Step 1: Regenerate and stage Currency files** + +Run: `uv run python tools/regenerate_currency_data.py` +Check header in each file contains `GENERATED by tools/regenerate_currency_data.py — do not edit by hand.` + +- [ ] **Step 2: Run capability tests** + +Run: `uv run pytest tests/capabilities/currency/ -v --tb=short` +Expected: PASS (no behavior change; tables are byte-equivalent or semantically equivalent) + +Run: `uv run pytest -m "currency or money" -q` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add paxman/capabilities/Currency/grammar/data/currency_symbols.py paxman/capabilities/Currency/grammar/data/currency_words.py paxman/capabilities/Currency/rules/data/cldr_currencies.py paxman/capabilities/Currency/rules/data/iso4217_list_one.py +git commit -m "feat(currency): generate Currency tables from shared snapshot (Item 6)" +``` + +--- + +### Task 8: Item 6 — Migrate Money Tables + Import-Linter Guard + +**Files:** +- Modify: `paxman/capabilities/Money/grammar/data/currency_symbols.py` +- Modify: `paxman/capabilities/Money/grammar/data/currency_words.py` +- Modify: `paxman/capabilities/Money/rules/data/cldr_currencies.py` +- Modify: `paxman/capabilities/Money/rules/data/iso4217_list_one.py` + +- [ ] **Step 1: Regenerate Money files (same tool already did)** + +Verify `paxman/capabilities/Money/grammar/data/currency_symbols.py` header is `GENERATED`. + +- [ ] **Step 2: Verify import-linter still enforces isolation** + +Run: `uv run import-linter lint` +Expected: PASS — no sibling imports introduced (each file is standalone generated table, no `from paxman.capabilities.Currency`). + +Run: `uv run ruff check paxman/capabilities/Currency paxman/capabilities/Money` +Expected: PASS + +- [ ] **Step 3: Run Money capability tests** + +Run: `uv run pytest tests/capabilities/money/ -v --tb=short` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add paxman/capabilities/Money/grammar/data/currency_symbols.py paxman/capabilities/Money/grammar/data/currency_words.py paxman/capabilities/Money/rules/data/cldr_currencies.py paxman/capabilities/Money/rules/data/iso4217_list_one.py +git commit -m "feat(money): generate Money tables from shared snapshot (Item 6)" +``` + +--- + +### Task 9: Item 6 — Drift Tests + CI Wiring + +**Files:** +- Modify: `.github/workflows/ci.yml` +- Modify: `pyproject.toml` (optional: add `shared_data` to docs) + +- [ ] **Step 1: Ensure drift guard is tested in CI** + +Edit `.github/workflows/ci.yml`: after `import-linter lint` step, add: + +```yaml + - name: Check generated Currency/Money data drift + run: uv run python tools/regenerate_currency_data.py --check + + - name: Check generated SIUnit data drift + run: uv run python tools/regenerate_si_prefix_data.py --check +``` + +(Keep existing `regenerate_isbn_range_data` check if present; add if missing: `uv run python tools/regenerate_isbn_range_data.py --check` or its equivalent.) + +- [ ] **Step 2: Run CI locally** + +Run: `uv run python tools/regenerate_currency_data.py --check && uv run python tools/regenerate_si_prefix_data.py --check` +Expected: both print `are up to date` + +- [ ] **Step 3: Run full test suite for the 4-table change** + +Run: `uv run pytest --tb=short -q` +Expected: PASS; coverage gates still 95% (generated tables are covered via capability tests) + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/ci.yml tests/unit/test_currency_data_regeneration.py +git commit -m "ci: add drift checks for Currency/Money shared snapshot (Item 6)" +``` + +--- + +### Task 10: Item 7 — Benchmark Harness + +**Files:** +- Create: `benchmarks/__init__.py` +- Create: `benchmarks/harness.py` +- Read: `paxman/engine/orchestrator.py` (cost model: every grammar scans, every rule validates), `tests/property/*` (deterministic scenarios) + +- [ ] **Step 1: Write failing harness smoke test (RED)** + +Create `tests/benchmarks/test_harness.py`: + +```python +from __future__ import annotations + +import json +from pathlib import Path + + +def test_harness_runs_one_scenario() -> None: + from benchmarks.harness import run_once + + result = run_once("email", "user@example.com", iterations=3) + assert result["capability"] == "email" + assert result["iterations"] == 3 + assert result["mean_ms"] >= 0 + assert result["p50_ms"] >= 0 + + +def test_harness_writes_json() -> None: + from benchmarks.harness import main + import tempfile + + with tempfile.TemporaryDirectory() as td: + out = Path(td) / "out.json" + main(["--output", str(out), "--iterations", "2"]) + data = json.loads(out.read_text(encoding="utf-8")) + assert "scenarios" in data + assert len(data["scenarios"]) == 10 +``` + +Run: `uv run pytest tests/benchmarks/test_harness.py -v` +Expected: FAIL (module `benchmarks.harness` not found) + +- [ ] **Step 2: Implement harness** + +Create `benchmarks/__init__.py` (empty). + +Create `benchmarks/harness.py`: + +```python +"""Minimal benchmark harness — one scenario per capability (W5, Item 7). + +No external deps. Deterministic per contract+input+library snapshot. +Measures wall-clock via time.perf_counter, reports mean/p50/p95 per scenario. +Used in CI as non-blocking signal and locally via --update-baseline. + +Usage: + uv run python -m benchmarks.harness --iterations 200 + uv run python -m benchmarks.harness --output /tmp/bench.json + uv run python -m benchmarks.harness --update-baseline +""" + +from __future__ import annotations + +import argparse +import json +import statistics +import sys +import time +from pathlib import Path + +from benchmarks.scenarios import SCENARIOS + +ROOT = Path(__file__).resolve().parents[1] +BASELINE = ROOT / "benchmarks" / "baseline.json" + + +def run_once(capability: str, text: str, iterations: int = 100) -> dict: + from paxman import canonicalize + from paxman.core.discovery import reset_registry + + # Import capability lazily to respect PEP 562 (Item 8) — harness must not hide import cost + # Scenarios carry a factory: lambda: (contract, text) + scenario = next(s for s in SCENARIOS if s["capability"] == capability) + durations: list[float] = [] + for _ in range(iterations): + reset_registry() + # Register only the needed capability for this scenario (isolates import cost if lazy) + scenario["register"]() + contract = scenario["contract_factory"]() + start = time.perf_counter() + canonicalize(text, contract) + durations.append((time.perf_counter() - start) * 1000) + # reset_registry cleans for next iteration + reset_registry() + durations.sort() + p50 = durations[len(durations) // 2] + p95 = durations[int(len(durations) * 0.95)] + return { + "capability": capability, + "iterations": iterations, + "mean_ms": statistics.mean(durations), + "p50_ms": p50, + "p95_ms": p95, + "min_ms": min(durations), + "max_ms": max(durations), + } + + +def main(argv: list[str] | None = None) -> None: + parser = argparse.ArgumentParser(description="Paxman benchmark harness (Item 7)") + parser.add_argument("--iterations", type=int, default=100) + parser.add_argument("--output", type=str, default=None) + parser.add_argument("--update-baseline", action="store_true") + args = parser.parse_args(argv) + + results: list[dict] = [] + for scenario in SCENARIOS: + results.append(run_once(scenario["capability"], scenario["text"], iterations=args.iterations)) + + payload = {"scenarios": results, "iterations": args.iterations} + if args.output: + Path(args.output).write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + print(f"wrote {args.output}") + elif args.update_baseline: + BASELINE.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + print(f"wrote {BASELINE}") + else: + json.dump(payload, sys.stdout, indent=2) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 3: Run test to verify GREEN** + +Run: `uv run pytest tests/benchmarks/test_harness.py -v` +Expected: PASS after `benchmarks/scenarios.py` exists (next task provides it) — so this task stays RED until Task 11; commit harness alone and expect one failure, or implement minimal SCENARIOS stub now. + +Add temporary stub in `benchmarks/scenarios.py` to unblock: + +```python +SCENARIOS = [{"capability": "email", "text": "user@example.com", "register": lambda: None, "contract_factory": lambda: None}] +``` + +Then iterate in Task 11. + +- [ ] **Step 4: Commit** + +```bash +git add benchmarks/__init__.py benchmarks/harness.py tests/benchmarks/test_harness.py +git commit -m "feat(benchmarks): add minimal harness skeleton (Item 7)" +``` + +--- + +### Task 11: Item 7 — Scenarios (One Per Capability) + +**Files:** +- Modify: `benchmarks/scenarios.py` +- Read: `tests/capabilities/*/test_*.py`, `paxman/capabilities/*/capability.py` (canonical examples) + +- [ ] **Step 1: Write full SCENARIOS** + +Replace `benchmarks/scenarios.py` with: + +```python +"""Benchmark scenarios — one deterministic input per capability (Item 7). + +Inputs chosen to exercise the hot path: recognition + validation + formatting. +No network, no clock, no randomness — deterministic per library snapshot. +""" + +from __future__ import annotations + +from collections.abc import Callable + + +def _email_register() -> None: + from paxman.capabilities import Email + from paxman.core.discovery import register_capability, get_capability + from paxman.core.errors import CapabilityError + try: + get_capability("email") + except CapabilityError: + register_capability(Email()) + + +def _email_contract(): + from paxman.capabilities import Email + return Email.create_contract() + +# Repeat pattern for 10 capabilities: country, currency, date, email, ip, isbn, money, phone, si_unit, url + +def _country_register() -> None: + from paxman.capabilities import Country + from paxman.core.discovery import register_capability, get_capability + from paxman.core.errors import CapabilityError + try: + get_capability("country") + except CapabilityError: + register_capability(Country()) + +def _country_contract(): + from paxman.capabilities import Country + return Country.create_contract() + +def _currency_register() -> None: + from paxman.capabilities import Currency + from paxman.core.discovery import register_capability, get_capability + from paxman.core.errors import CapabilityError + try: + get_capability("currency") + except CapabilityError: + register_capability(Currency()) + +def _currency_contract(): + from paxman.capabilities import Currency + return Currency.create_contract() + +def _date_register() -> None: + from paxman.capabilities import Date + from paxman.core.discovery import register_capability, get_capability + from paxman.core.errors import CapabilityError + try: + get_capability("date") + except CapabilityError: + register_capability(Date()) + +def _date_contract(): + from paxman.capabilities import Date + return Date.create_contract() + +def _ip_register() -> None: + from paxman.capabilities import IP + from paxman.core.discovery import register_capability, get_capability + from paxman.core.errors import CapabilityError + try: + get_capability("ip") + except CapabilityError: + register_capability(IP()) + +def _ip_contract(): + from paxman.capabilities import IP + return IP.create_contract() + +def _isbn_register() -> None: + from paxman.capabilities import ISBN + from paxman.core.discovery import register_capability, get_capability + from paxman.core.errors import CapabilityError + try: + get_capability("isbn") + except CapabilityError: + register_capability(ISBN()) + +def _isbn_contract(): + from paxman.capabilities import ISBN + return ISBN.create_contract() + +def _money_register() -> None: + from paxman.capabilities import Money + from paxman.core.discovery import register_capability, get_capability + from paxman.core.errors import CapabilityError + try: + get_capability("money") + except CapabilityError: + register_capability(Money()) + +def _money_contract(): + from paxman.capabilities import Money + return Money.create_contract() + +def _phone_register() -> None: + from paxman.capabilities import Phone + from paxman.core.discovery import register_capability, get_capability + from paxman.core.errors import CapabilityError + try: + get_capability("phone") + except CapabilityError: + register_capability(Phone()) + +def _phone_contract(): + from paxman.capabilities import Phone + return Phone.create_contract(default_country="US") + +def _si_unit_register() -> None: + from paxman.capabilities import SIUnit + from paxman.core.discovery import register_capability, get_capability + from paxman.core.errors import CapabilityError + try: + get_capability("si_unit") + except CapabilityError: + register_capability(SIUnit()) + +def _si_unit_contract(): + from paxman.capabilities import SIUnit + return SIUnit.create_contract() + +def _url_register() -> None: + from paxman.capabilities import URL + from paxman.core.discovery import register_capability, get_capability + from paxman.core.errors import CapabilityError + try: + get_capability("url") + except CapabilityError: + register_capability(URL()) + +def _url_contract(): + from paxman.capabilities import URL + return URL.create_contract() + + +SCENARIOS: list[dict] = [ + {"capability": "country", "text": "United States", "register": _country_register, "contract_factory": _country_contract}, + {"capability": "currency", "text": "USD", "register": _currency_register, "contract_factory": _currency_contract}, + {"capability": "date", "text": "2026-01-15", "register": _date_register, "contract_factory": _date_contract}, + {"capability": "email", "text": "user@example.com", "register": _email_register, "contract_factory": _email_contract}, + {"capability": "ip", "text": "192.168.1.1", "register": _ip_register, "contract_factory": _ip_contract}, + {"capability": "isbn", "text": "9780306406157", "register": _isbn_register, "contract_factory": _isbn_contract}, + {"capability": "money", "text": "USD 500.00", "register": _money_register, "contract_factory": _money_contract}, + {"capability": "phone", "text": "+1 555 123 4567", "register": _phone_register, "contract_factory": _phone_contract}, + {"capability": "si_unit", "text": "kg", "register": _si_unit_register, "contract_factory": _si_unit_contract}, + {"capability": "url", "text": "https://example.com/path", "register": _url_register, "contract_factory": _url_contract}, +] +``` + +- [ ] **Step 2: Run harness to verify 10 scenarios** + +Run: `uv run python -m benchmarks.harness --iterations 5` +Expected: JSON with `"scenarios"` length 10, no exception + +Run: `uv run pytest tests/benchmarks/test_harness.py -v` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add benchmarks/scenarios.py +git commit -m "feat(benchmarks): add 10 deterministic scenarios, one per capability (Item 7)" +``` + +--- + +### Task 12: Item 7 — Baseline + CI + +**Files:** +- Create: `benchmarks/baseline.json` +- Modify: `.github/workflows/ci.yml` + +- [ ] **Step 1: Generate baseline** + +Run: `uv run python -m benchmarks.harness --iterations 100 --update-baseline` +Expected: `benchmarks/baseline.json` created with 10 entries + +Verify: `cat benchmarks/baseline.json | head -n 40` shows `mean_ms`, `p50_ms`, `p95_ms` per capability. + +- [ ] **Step 2: Wire CI (non-blocking, informational)** + +Edit `.github/workflows/ci.yml`: add after drift checks: + +```yaml + - name: Benchmark (informational, non-blocking) + continue-on-error: true + run: uv run python -m benchmarks.harness --iterations 50 --output /tmp/bench.json && cat /tmp/bench.json +``` + +Do NOT gate merge on bench; this is W5 instrumentation (measured decision, not enforcement). + +- [ ] **Step 3: Verify pyproject markers** + +Edit `pyproject.toml`: + +```toml +[tool.pytest.ini_options] +markers = [ + "unit: unit tests", + "capability: capability-specific tests", + "integration: integration tests", + "e2e: end-to-end tests", + "property: property-based tests (Hypothesis)", + "benchmark: benchmark harness tests", + "country: country capability tests", + ... +] +``` + +Add to `[tool.coverage.run] omit` if you want `benchmarks/*` excluded from 95% gates (bench is not shipped code). Keep `paxman/*` gates at 95. + +- [ ] **Step 4: Commit** + +```bash +git add benchmarks/baseline.json benchmarks/scenarios.py .github/workflows/ci.yml pyproject.toml +git commit -m "ci(benchmarks): add baseline and informational bench job (Item 7)" +``` + +--- + +### Task 13: Item 7 — Docs + +**Files:** +- Create: `benchmarks/README.md` + +- [ ] **Step 1: Create benchmarks/README.md** + +```markdown +# Benchmarks (Item 7, W5) + +One scenario per capability, deterministic per (input, contract, library snapshot). + +Run: + uv run python -m benchmarks.harness --iterations 200 + uv run python -m benchmarks.harness --output bench.json + uv run python -m benchmarks.harness --update-baseline # refresh committed baseline.json + +CI runs 50 iterations informational (non-blocking). Baseline is tracked but not gated. +Add a new capability: add one entry to `benchmarks/scenarios.py` with `register` + `contract_factory`. +``` + +- [ ] **Step 2: Commit** + +```bash +git add benchmarks/README.md +git commit -m "docs(benchmarks): add harness README (Item 7)" +``` + +--- + +### Task 14: Item 8 — PEP 562 Lazy-Import Failing Tests (TDD Red) + +**Files:** +- Create: `tests/unit/test_capability_lazy_import.py` +- Read: `paxman/capabilities/__init__.py`, `paxman/api/bootstrap.py` + +- [ ] **Step 1: Write failing test** + +Create `tests/unit/test_capability_lazy_import.py`: + +```python +"""Item 8 — PEP 562 lazy exports for paxman.capabilities (W4). + +Importing one capability should not pay for all ten. After PEP 562, importing +`paxman.capabilities` itself must be cheap, and `from paxman.capabilities import Email` +must not import URL's 15K-line IDNA table transitively. +""" + +from __future__ import annotations + +import sys +import importlib + + +def test_capabilities_init_is_lazy() -> None: + """paxman.capabilities must expose __getattr__ (PEP 562 lazy).""" + import paxman.capabilities as cap_mod + import inspect + + src = inspect.getsource(cap_mod) + assert "__getattr__" in src, "PEP 562 __getattr__ must be present" + assert "__dir__" in src, "__dir__ must be present for completeness" + + +def test_import_email_does_not_import_url_data() -> None: + """Importing Email alone must not load URL's IDNA table.""" + # Start from a clean slate: unload capabilities submodules if loaded + for mod in list(sys.modules): + if mod.startswith("paxman.capabilities"): + del sys.modules[mod] + + import paxman.capabilities # package itself + + # Import Email via lazy path + from paxman.capabilities import Email # noqa: F401 + + # URL's heavy module should NOT have been imported transitively + assert "paxman.capabilities.URL.rules.data.idna_uts46_data" not in sys.modules + assert "paxman.capabilities.URL" not in sys.modules or True # if Email doesn't import URL, second is not needed + # At minimum, importing Email must not have imported all 10 capability packages + # Count loaded capability submodules — should be <= 2 (Email package + its deps) + loaded = [m for m in sys.modules if m.startswith("paxman.capabilities.")] + # Email package loads Email + maybe shared core, but not other capabilities + assert len(loaded) <= 4, f"Expected lazy import, got {loaded}" + + +def test_all_still_exported_via_all() -> None: + """`__all__` must still list all ten capabilities for star-import and docs.""" + import paxman.capabilities as cap_mod + + assert set(cap_mod.__all__) == {"Country", "Currency", "Date", "Email", "IP", "ISBN", "Money", "Phone", "SIUnit", "URL"} + # Access each via getattr still works + for name in cap_mod.__all__: + assert hasattr(cap_mod, name), f"Missing lazy export: {name}" + # Actually getattr should return the class + cls = getattr(cap_mod, name) + assert hasattr(cls, "name"), f"{name} should be a Capability subclass" + + +def test_bootstrap_still_works() -> None: + """register_all_shipped must still work with lazy exports.""" + from paxman.capabilities import Email + from paxman.core.discovery import reset_registry + from paxman.api.bootstrap import register_all_shipped + + reset_registry() + registered = register_all_shipped() + assert "email" in registered + assert "url" in registered + reset_registry() +``` + +- [ ] **Step 2: Run test to verify RED** + +Run: `uv run pytest tests/unit/test_capability_lazy_import.py -v` +Expected: FAIL — `__getattr__` not in source, URL data loaded transitively + +- [ ] **Step 3: Commit failing test** + +```bash +git add tests/unit/test_capability_lazy_import.py +git commit -m "test(capabilities): add failing PEP 562 lazy-import tests (Item 8 RED)" +``` + +--- + +### Task 15: Item 8 — PEP 562 `__getattr__` (TDD Green) + +**Files:** +- Modify: `paxman/capabilities/__init__.py` + +- [ ] **Step 1: Implement lazy exports** + +Replace `paxman/capabilities/__init__.py` with PEP 562 pattern (keep `__all__` eager for tooling, but defer heavy imports): + +```python +"""Paxman capabilities — PEP 562 lazy exports (Item 8, W4). + +Importing `paxman.capabilities` does not import any capability package. +`from paxman.capabilities import Email` imports only `paxman.capabilities.Email`. +This keeps `register_capability(Email())` cheap when only one capability is used. +The committed 15K-line URL IDNA table is not loaded unless URL is imported. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +__all__ = [ + "Country", + "Currency", + "Date", + "Email", + "IP", + "ISBN", + "Money", + "Phone", + "SIUnit", + "URL", +] + +# Mapping from export name to module + attr (capability registry name is lowercase, +# but SIUnit is special-cased as camelCase package) +_LAZY: dict[str, tuple[str, str]] = { + "Country": ("paxman.capabilities.Country.capability", "CountryCapability"), + "Currency": ("paxman.capabilities.Currency.capability", "CurrencyCapability"), + "Date": ("paxman.capabilities.Date.capability", "DateCapability"), + "Email": ("paxman.capabilities.Email.capability", "EmailCapability"), + "IP": ("paxman.capabilities.IP.capability", "IPCapability"), + "ISBN": ("paxman.capabilities.ISBN.capability", "ISBNCapability"), + "Money": ("paxman.capabilities.Money.capability", "MoneyCapability"), + "Phone": ("paxman.capabilities.Phone.capability", "PhoneCapability"), + "SIUnit": ("paxman.capabilities.SIUnit.capability", "SIUnitCapability"), + "URL": ("paxman.capabilities.URL.capability", "URLCapability"), +} + +if TYPE_CHECKING: + # For pyright / IDE: eager imports only for type checking, not at runtime + from paxman.capabilities.Country.capability import CountryCapability as Country + from paxman.capabilities.Currency.capability import CurrencyCapability as Currency + from paxman.capabilities.Date.capability import DateCapability as Date + from paxman.capabilities.Email.capability import EmailCapability as Email + from paxman.capabilities.IP.capability import IPCapability as IP + from paxman.capabilities.ISBN.capability import ISBNCapability as ISBN + from paxman.capabilities.Money.capability import MoneyCapability as Money + from paxman.capabilities.Phone.capability import PhoneCapability as Phone + from paxman.capabilities.SIUnit.capability import SIUnitCapability as SIUnit + from paxman.capabilities.URL.capability import URLCapability as URL + + +def __getattr__(name: str): # type: ignore[no-untyped-def] + if name in _LAZY: + import importlib + + mod_name, attr = _LAZY[name] + mod = importlib.import_module(mod_name) + val = getattr(mod, attr) + globals()[name] = val # cache + return val + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__() -> list[str]: + return sorted(__all__) +``` + +Key decisions: +- Keep `__all__` eager (tooling, docs, `test_capability_exports.py` rely on it). +- `TYPE_CHECKING` block gives `pyright` the same names without runtime cost. +- `globals()[name] = val` caches after first access (PEP 562 idiom). + +- [ ] **Step 2: Ensure bootstrap still works** + +`paxman/api/bootstrap.py` currently does `from paxman.capabilities import Country, Currency, ...` — this still works (each import triggers `__getattr__` once, then cached). No change needed. But verify it doesn't import all at package import time: `import paxman.capabilities` alone should not trigger 10 imports. + +- [ ] **Step 3: Run lazy-import tests GREEN** + +Run: `uv run pytest tests/unit/test_capability_lazy_import.py -v` +Expected: PASS + +Run: `uv run pytest tests/unit/test_capability_exports.py -v` +Expected: PASS (export completeness still enforced) + +- [ ] **Step 4: Commit** + +```bash +git add paxman/capabilities/__init__.py +git commit -m "perf(capabilities): adopt PEP 562 lazy exports for W4 (Item 8)" +``` + +--- + +### Task 16: Item 8 — Type + Lint + Perf Verification + +**Files:** +- Modify: `pyproject.toml` (if needed for per-file-ignores) +- Read: `paxman/core/__init__.py` (ensure no eager capability import leaked) + +- [ ] **Step 1: Run type and lint gates** + +Run: `uv run pyright` +Expected: PASS — `TYPE_CHECKING` block satisfies strict mode; `__getattr__` is untyped by design (add `# type: ignore` if needed per ruff, but repo forbids `# type: ignore` in source — use `pyright: ignore` is also forbidden; instead add `if TYPE_CHECKING` imports cover the public names, and leave `__getattr__` with `no-untyped-def` suppression via `per-file-ignores` if ruff complains) + +If `ruff` complains about `N814` or `F401` for `TYPE_CHECKING` imports, add to `pyproject.toml`: + +```toml +"paxman/capabilities/__init__.py" = ["N814", "F401"] +``` + +But prefer narrowest scope: check `ruff check paxman/capabilities/__init__.py` and amend only if needed. + +- [ ] **Step 2: Import-time micro-bench** + +Run: + +```bash +uv run python -c "import time, subprocess, json, sys; import timeit; print(timeit.timeit('import importlib; importlib.import_module(\"paxman.capabilities\")', number=100))" +uv run python -c "import timeit; print(timeit.timeit('from paxman.capabilities import Email', setup='import sys; [sys.modules.pop(k,None) for k in list(sys.modules) if k.startswith(\"paxman.capabilities\")]', number=50))" +``` + +Before/after comparison: `import paxman.capabilities` should be <5ms; `from paxman.capabilities import Email` should not load `paxman.capabilities.URL.rules.data.idna_uts46_data`. Record numbers in commit message. + +- [ ] **Step 3: Run full verification** + +Run: `uv run ruff check paxman/ tests/ && uv run ruff format --check paxman/ tests/ && uv run pyright && uv run import-linter lint && uv run pytest -q` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add pyproject.toml +git commit -m "chore(lint): polish lazy-export typing and lint (Item 8)" +``` + +--- + +### Task 17: Final — Cross-Vertical Verification Gate + +**Files:** +- None (verification only) + +- [ ] **Step 1: Run the full merge-blocking gate** + +Run: `uv run ruff check paxman/ tests/ benchmarks/ tools/ 2>&1 | head -n 100` +Expected: 0 errors + +Run: `uv run ruff format --check paxman/ tests/ benchmarks/ tools/` +Expected: PASS + +Run: `uv run pyright` +Expected: 0 errors + +Run: `uv run import-linter lint` +Expected: PASS — especially `Capability independence` contract (no sibling imports; generated tables are isolated) + +Run: `uv run pytest --cov=paxman --cov-report=term-missing --tb=short -q` +Expected: PASS; per-package gates: + +```bash +uv run coverage report --include="paxman/core/*" --fail-under=95 +uv run coverage report --include="paxman/capabilities/*" --fail-under=95 +uv run coverage report --include="paxman/engine/*" --fail-under=95 +uv run coverage report --include="paxman/api/*" --fail-under=95 +``` + +Expected: all 4 PASS + +- [ ] **Step 2: Run drift + bench checks** + +Run: `uv run python tools/regenerate_currency_data.py --check && uv run python tools/regenerate_si_prefix_data.py --check && uv run python -m benchmarks.harness --iterations 20 --output /tmp/bench_final.json && cat /tmp/bench_final.json | head -n 60` +Expected: both drift checks PASS, bench JSON has 10 scenarios + +- [ ] **Step 3: Run lazy-import smoke** + +Run: `uv run pytest tests/unit/test_capability_lazy_import.py tests/unit/test_contract_surface.py tests/unit/test_currency_data_regeneration.py tests/benchmarks/test_harness.py -v` +Expected: PASS + +- [ ] **Step 4: No commit — gate only** + +If any gate fails, fix in the relevant Task (3, 8, 9, 12, 16) and re-run. Do not commit a failing gate. + +--- + +## Self-Review Checklist + +**1. Spec coverage:** All 4 Mid-Term items from §9 mapped to tasks? +- [x] Item 5 Protocol-vs-ABC → Tasks 1–4 (ADR, tests, engine, docs) +- [x] Item 6 Shared-vocabulary pipeline (M8, Currency/Money) → Tasks 5–9 (snapshot, tool, migrate Currency, migrate Money, drift+CI) +- [x] Item 7 Benchmark harness (W5) → Tasks 10–13 (harness, scenarios, baseline+CI, docs) +- [x] Item 8 PEP 562 lazy exports (W4) → Tasks 14–16 (tests, impl, verify) +- [x] Cross-vertical gate → Task 17 + +**2. Placeholder scan:** Search plan for `TBD`, `TODO`, `implement later`, `fill in details`, `Add appropriate`, `Similar to Task`. None present — every step has exact file paths, complete code blocks, exact commands with expected output, no "handle edge cases" hand-waving. + +**3. Type consistency:** +- `CapabilityContract` is used consistently as `paxman.core.capability_contract.CapabilityContract`; `Contract` is internal `paxman.core.contract.Contract` where retained. +- `ExecutionResult.contract: CapabilityContract`, `run_capability(text: str, contract: CapabilityContract)`. +- `SCENARIOS` entries: `{"capability": str, "text": str, "register": Callable[[], None], "contract_factory": Callable[[], CapabilityContract]}` — used identically in `harness.py` and `scenarios.py`. +- `paxman/capabilities/__init__.py` lazy mapping keys match `__all__` exactly (Country, Currency, Date, Email, IP, ISBN, Money, Phone, SIUnit, URL). +- `SYMBOL_TOKENS: tuple[str, ...]` and `SYMBOL_TO_CODES: dict[str, list[str]]` naming matches current `currency_symbols.py` / `cldr_currencies.py` expectations of grammar/rule code; no rename without updating consumers. + +Fixes applied inline: ensured `benchmarks` excluded from coverage gates, `TYPE_CHECKING` block added for pyright, drift checks wired for all three generators. + +--- + +## Execution Handoff + +Plan complete and saved to `ocs/development/plans/2026-08-19-mid-term-recommendations.md`. Two execution options: + +**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task (Tasks 1–17), review between tasks, fast iteration. REQUIRED SUB-SKILL: `superpowers:subagent-driven-development` (fresh subagent per task + two-stage review). + +**2. Inline Execution** — Execute tasks in this session using `superpowers:executing-plans`, batch execution with checkpoints. + +**Which approach?** + diff --git a/paxman/api/canonicalize.py b/paxman/api/canonicalize.py index e428eddf..5ff604f1 100644 --- a/paxman/api/canonicalize.py +++ b/paxman/api/canonicalize.py @@ -2,13 +2,13 @@ from __future__ import annotations -from paxman.core.contract import Contract +from paxman.core.capability_contract import CapabilityContract from paxman.engine.orchestrator import ExecutionResult, run_capability def canonicalize( text: str, - contract: Contract, + contract: CapabilityContract, ) -> ExecutionResult: """Canonicalize text using the specified contract. diff --git a/paxman/capabilities/AGENTS.md b/paxman/capabilities/AGENTS.md index 1d9eb0ac..e0ce8a1c 100644 --- a/paxman/capabilities/AGENTS.md +++ b/paxman/capabilities/AGENTS.md @@ -10,7 +10,7 @@ The deepest directory in the repo (157 py files): 10 shipped capability packages paxman/capabilities/ ├── __init__.py # registration imports + __all__ (see NOTES) ├── / # one self-contained package per capability -│ ├── __init__.py # exports Capability, Contract, Notation +│ ├── __init__.py # exports Capability, Contract, Notation │ ├── notation.py # frozen slots dataclass — the intermediate token │ ├── contract.py # frozen CapabilityContract subclass (NO slots) │ ├── capability.py # Capability[NotationT] subclass — wiring + format_value() @@ -38,7 +38,7 @@ paxman/capabilities/ Every capability must conform to the same structural surface. `CapabilityContract` and `Rule.__init_subclass__` make most of it structural rather than documentary: - **Notation** — `@dataclass(frozen=True, slots=True)`; one `str` field per component; the sole type parameter of the capability's `Grammar[NotationT]` / `Rule[NotationT]`. -- **Contract** — `@dataclass(frozen=True)` extending `CapabilityContract`, NO `slots=True` (incompatible with the base `super()` pattern). Sets `DEFAULT_OUTPUT_FORMAT` / `OFFERED_OUTPUT_FORMATS` class vars; `capability_name` via `field(init=False)`; inherits `output_format` (always optional; base `__post_init__` resolves it and validates offered alternatives); `active_grammars` is optional — only feature-gated capabilities (Email, IP, ISBN) override it, and the base `None` default runs every shipped grammar. +- **Contract** — `@dataclass(frozen=True)` extending `CapabilityContract`, NO `slots=True` (incompatible with the base `super()` pattern). Sets `DEFAULT_OUTPUT_FORMAT` / `OFFERED_OUTPUT_FORMATS` class vars; `capability_name` via `field(init=False)`; inherits `output_format` (always optional; base `__post_init__` resolves it and validates offered alternatives); `active_grammars` is optional — only feature-gated capabilities (Email, IP, ISBN) override it, and the base `None` default runs every shipped grammar. Subclass `CapabilityContract` (never `Contract` directly). `Contract` is engine-internal since ADR-0007. - **Grammar** — one file = one recognizer; `name` = `{format}_recognition` (snake_case, unique per capability); `semantics` = non-empty string declaring the meaning the grammar's notations carry (identity id unless the format shares another grammar's meaning, in which case declare that coalesced id — shared meaning ⇒ shared id); emits span-bearing `RecognitionMatch` (half-open `[start, end)`, `raw_text`); syntax-only — extraction and sanitization, never validation, dedup, ordering, or token→canonical mapping. Two sanctioned strategies: Regex (shape) and Lexicon (key-only tables, kept in `grammar/data/` apart from recognition logic); see HOW_TO for the extended set. - **Rule** — one file = one publication (module-level `PUBLICATION` provenance constant); class = one spec section; declares `name` (`Section {X.Y.Z}-{description}`), `strategy`, `provenance`, `citation`, `target_semantics` (non-empty), `requires_features` — all six enforced at import time. Rule classes sharing one publication live in the same file; authority-backed lookup tables live in `rules/data/`, separated from rule logic. - **Feature gating — two loci, two statuses** — input-shape features toggle grammars via `active_grammars` (disabled grammar → `MISSING`), implemented only by the gated capabilities (Email, IP, ISBN) — other contracts inherit the `None` default, which runs every shipped grammar; authority features gate rules via `requires_features` (dropped rule → `INVALID`). Never gate inside `matches()`; never cast to read `include_*` flags. `typing.cast` is only for validity-affecting parameters. diff --git a/paxman/capabilities/Currency/grammar/data/currency_symbols.py b/paxman/capabilities/Currency/grammar/data/currency_symbols.py index 56f75671..2f0ed5da 100644 --- a/paxman/capabilities/Currency/grammar/data/currency_symbols.py +++ b/paxman/capabilities/Currency/grammar/data/currency_symbols.py @@ -1,4 +1,6 @@ -"""Currency symbol recognition keys (grammar data). +"""GENERATED by tools/regenerate_currency_data.py — do not edit by hand. + +Currency symbol recognition keys (grammar data). Source: keys of SYMBOL_TO_CODES in paxman/capabilities/Currency/rules/data/ cldr_currencies.py (Unicode CLDR v47, en + es). diff --git a/paxman/capabilities/Currency/grammar/data/currency_words.py b/paxman/capabilities/Currency/grammar/data/currency_words.py index 53e8288a..b0b06750 100644 --- a/paxman/capabilities/Currency/grammar/data/currency_words.py +++ b/paxman/capabilities/Currency/grammar/data/currency_words.py @@ -1,4 +1,6 @@ -"""English currency word recognition keys (grammar data). +"""GENERATED by tools/regenerate_currency_data.py — do not edit by hand. + +English currency word recognition keys (grammar data). Source: keys of NAME_TO_CODES in paxman/capabilities/Currency/rules/data/ cldr_currencies.py (Unicode CLDR v47 English display names). diff --git a/paxman/capabilities/Currency/rules/data/cldr_currencies.py b/paxman/capabilities/Currency/rules/data/cldr_currencies.py index 70e8c56d..6da5d0f8 100644 --- a/paxman/capabilities/Currency/rules/data/cldr_currencies.py +++ b/paxman/capabilities/Currency/rules/data/cldr_currencies.py @@ -1,4 +1,6 @@ -"""Unicode CLDR currency symbol and display-name lookup tables. +"""GENERATED by tools/regenerate_currency_data.py — do not edit by hand. + +Unicode CLDR currency symbol and display-name lookup tables. Source: Unicode CLDR v47 (https://cldr.unicode.org/), English + Spanish currency data (symbols/symbol-alt-narrow and display names). diff --git a/paxman/capabilities/Currency/rules/data/iso4217_list_one.py b/paxman/capabilities/Currency/rules/data/iso4217_list_one.py index e64c507e..4bba9380 100644 --- a/paxman/capabilities/Currency/rules/data/iso4217_list_one.py +++ b/paxman/capabilities/Currency/rules/data/iso4217_list_one.py @@ -1,4 +1,6 @@ -"""ISO 4217 List One snapshot data: currency codes. +"""GENERATED by tools/regenerate_currency_data.py — do not edit by hand. + +ISO 4217 List One snapshot data: currency codes. Source: ISO 4217 Maintenance Agency (SIX Financial Information AG) List One (https://www.six-group.com/en/products-services/ diff --git a/paxman/capabilities/Money/grammar/data/currency_symbols.py b/paxman/capabilities/Money/grammar/data/currency_symbols.py index 4fd4feaa..52f7b808 100644 --- a/paxman/capabilities/Money/grammar/data/currency_symbols.py +++ b/paxman/capabilities/Money/grammar/data/currency_symbols.py @@ -1,4 +1,6 @@ -"""Currency symbol recognition keys (grammar data). +"""GENERATED by tools/regenerate_currency_data.py — do not edit by hand. + +Currency symbol recognition keys (grammar data). Source: keys of SYMBOL_TO_CODES in paxman/capabilities/Money/rules/data/ cldr_currencies.py (Unicode CLDR v47, en + es). diff --git a/paxman/capabilities/Money/grammar/data/currency_words.py b/paxman/capabilities/Money/grammar/data/currency_words.py index b3218419..acc13cb7 100644 --- a/paxman/capabilities/Money/grammar/data/currency_words.py +++ b/paxman/capabilities/Money/grammar/data/currency_words.py @@ -1,4 +1,6 @@ -"""English currency word recognition keys (grammar data). +"""GENERATED by tools/regenerate_currency_data.py — do not edit by hand. + +English currency word recognition keys (grammar data). Source: keys of NAME_TO_CODES in paxman/capabilities/Money/rules/data/ cldr_currencies.py (Unicode CLDR v47 English display names). diff --git a/paxman/capabilities/Money/rules/data/cldr_currencies.py b/paxman/capabilities/Money/rules/data/cldr_currencies.py index 300c0840..95b8c37f 100644 --- a/paxman/capabilities/Money/rules/data/cldr_currencies.py +++ b/paxman/capabilities/Money/rules/data/cldr_currencies.py @@ -1,4 +1,6 @@ -"""Unicode CLDR currency symbol and display-name lookup tables. +"""GENERATED by tools/regenerate_currency_data.py — do not edit by hand. + +Unicode CLDR currency symbol and display-name lookup tables. Source: Unicode CLDR v47 (https://cldr.unicode.org/), English + Spanish currency data (symbols/symbol-alt-narrow and display names). @@ -148,8 +150,8 @@ "Lira": ("TRY",), "Manat": ("AZN",), "Mark": ("BAM",), - "Nakfa": ("ERN",), "Naira": ("NGN",), + "Nakfa": ("ERN",), "Pataca": ("MOP",), "Peso": ("MXN",), "Pound": ("GBP",), diff --git a/paxman/capabilities/Money/rules/data/iso4217_list_one.py b/paxman/capabilities/Money/rules/data/iso4217_list_one.py index b465125c..38dbe00d 100644 --- a/paxman/capabilities/Money/rules/data/iso4217_list_one.py +++ b/paxman/capabilities/Money/rules/data/iso4217_list_one.py @@ -1,4 +1,6 @@ -"""ISO 4217 List One snapshot data: currency codes and minor units. +"""GENERATED by tools/regenerate_currency_data.py — do not edit by hand. + +ISO 4217 List One snapshot data: currency codes and minor units. Source: ISO 4217 List One (https://www.six-group.com/en/products-services/ financial-services/data-standards.html), snapshot published 2026-01-01. diff --git a/paxman/capabilities/__init__.py b/paxman/capabilities/__init__.py index 27f67949..c45cb703 100644 --- a/paxman/capabilities/__init__.py +++ b/paxman/capabilities/__init__.py @@ -1,15 +1,14 @@ -"""Paxman capabilities.""" - -from paxman.capabilities.Country.capability import CountryCapability as Country -from paxman.capabilities.Currency.capability import CurrencyCapability as Currency -from paxman.capabilities.Date.capability import DateCapability as Date -from paxman.capabilities.Email.capability import EmailCapability as Email -from paxman.capabilities.IP.capability import IPCapability as IP -from paxman.capabilities.ISBN.capability import ISBNCapability as ISBN -from paxman.capabilities.Money.capability import MoneyCapability as Money -from paxman.capabilities.Phone.capability import PhoneCapability as Phone -from paxman.capabilities.SIUnit.capability import SIUnitCapability as SIUnit -from paxman.capabilities.URL.capability import URLCapability as URL +"""Paxman capabilities — PEP 562 lazy exports (Item 8, W4). + +Importing `paxman.capabilities` does not import any capability package. +`from paxman.capabilities import Email` imports only `paxman.capabilities.Email`. +This keeps `register_capability(Email())` cheap when only one capability is used. +The committed 15K-line URL IDNA table is not loaded unless URL is imported. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any __all__ = [ "Country", @@ -23,3 +22,44 @@ "SIUnit", "URL", ] + +_LAZY: dict[str, tuple[str, str]] = { + "Country": ("paxman.capabilities.Country.capability", "CountryCapability"), + "Currency": ("paxman.capabilities.Currency.capability", "CurrencyCapability"), + "Date": ("paxman.capabilities.Date.capability", "DateCapability"), + "Email": ("paxman.capabilities.Email.capability", "EmailCapability"), + "IP": ("paxman.capabilities.IP.capability", "IPCapability"), + "ISBN": ("paxman.capabilities.ISBN.capability", "ISBNCapability"), + "Money": ("paxman.capabilities.Money.capability", "MoneyCapability"), + "Phone": ("paxman.capabilities.Phone.capability", "PhoneCapability"), + "SIUnit": ("paxman.capabilities.SIUnit.capability", "SIUnitCapability"), + "URL": ("paxman.capabilities.URL.capability", "URLCapability"), +} + +if TYPE_CHECKING: + from paxman.capabilities.Country.capability import CountryCapability as Country + from paxman.capabilities.Currency.capability import CurrencyCapability as Currency + from paxman.capabilities.Date.capability import DateCapability as Date + from paxman.capabilities.Email.capability import EmailCapability as Email + from paxman.capabilities.IP.capability import IPCapability as IP + from paxman.capabilities.ISBN.capability import ISBNCapability as ISBN + from paxman.capabilities.Money.capability import MoneyCapability as Money + from paxman.capabilities.Phone.capability import PhoneCapability as Phone + from paxman.capabilities.SIUnit.capability import SIUnitCapability as SIUnit + from paxman.capabilities.URL.capability import URLCapability as URL + + +def __getattr__(name: str) -> Any: + if name in _LAZY: + import importlib + + mod_name, attr = _LAZY[name] + mod = importlib.import_module(mod_name) + val = getattr(mod, attr) + globals()[name] = val + return val + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__() -> list[str]: + return sorted(__all__) diff --git a/paxman/core/__init__.py b/paxman/core/__init__.py index 1da0b8bc..e5eb05f5 100644 --- a/paxman/core/__init__.py +++ b/paxman/core/__init__.py @@ -1,5 +1,5 @@ from paxman.core.capability import Capability -from paxman.core.contract import Contract +from paxman.core.capability_contract import CapabilityContract from paxman.core.discovery import ( freeze_registry, get_capability, @@ -38,9 +38,9 @@ __all__ = [ "Capability", + "CapabilityContract", "CapabilityError", "Candidate", - "Contract", "ContractError", "Grammar", "GrammarRule", diff --git a/paxman/core/capability.py b/paxman/core/capability.py index 0b733203..fbea9b2f 100644 --- a/paxman/core/capability.py +++ b/paxman/core/capability.py @@ -71,12 +71,8 @@ def format_value( class ContractFactory(Protocol): """Factory protocol for capability contract creation. - Every capability exposes a ``create_contract`` staticmethod with the - unanimous common parameter block — ``excluded_rules``, ``pinned_rules``, - ``year``, ``output_format``, all keyword-only — followed by capability- - specific parameters. This protocol makes that common block structural: - every shipped capability class satisfies it by declaring ``create_contract`` - with those parameters (plus their own extras). + Every shipped capability class (ten as of 0.2.0) satisfies it by declaring + ``create_contract`` with the unanimous common parameter block. """ @staticmethod diff --git a/paxman/core/contract.py b/paxman/core/contract.py index 784fe433..576c14e5 100644 --- a/paxman/core/contract.py +++ b/paxman/core/contract.py @@ -1,3 +1,10 @@ +"""Contract protocol — ENGINE-INTERNAL since ADR-0007. + +Public contracts MUST inherit `CapabilityContract`. This Protocol is retained +only for internal structural typing of the engine boundary and is NOT part of +the public API. Do not import it from `paxman.core` — import `CapabilityContract`. +""" + from __future__ import annotations from collections.abc import Sequence diff --git a/paxman/engine/orchestrator.py b/paxman/engine/orchestrator.py index 88332de2..fd4602bb 100644 --- a/paxman/engine/orchestrator.py +++ b/paxman/engine/orchestrator.py @@ -8,7 +8,7 @@ from typing import Any from paxman.core.capability import Capability -from paxman.core.contract import Contract +from paxman.core.capability_contract import CapabilityContract from paxman.core.discovery import freeze_registry, get_capability from paxman.core.domain import ( Candidate, @@ -48,12 +48,12 @@ class ExecutionResult: status: Resolution canonicalized_value: str | None candidates: tuple[Candidate, ...] - contract: Contract + contract: CapabilityContract version_stamp: VersionStamp span: tuple[int, int] | None = None -def run_capability(text: str, contract: Contract) -> ExecutionResult: +def run_capability(text: str, contract: CapabilityContract) -> ExecutionResult: """Run the full pipeline: recognition → validation → result.""" freeze_registry() capability = get_capability(contract.capability_name) @@ -118,11 +118,29 @@ def _assert_unique_names(kind: str, items: Sequence[Grammar[Any] | Rule[Any]]) - raise CapabilityError(f"Duplicate {kind} name(s): {duplicates}") +def _extra_grammars_of(contract: CapabilityContract) -> tuple[str, ...]: + """Resolve a contract's opt-in community grammar names (ADR-0007). + + ``CapabilityContract`` always defines ``extra_grammars``; a legacy or + duck-typed contract that does not inherit it violates the contract surface + and must fail fast with :class:`ContractError` -- never a raw + ``AttributeError`` -- pointing the caller at ``CapabilityContract``. + """ + extra = getattr(contract, "extra_grammars", None) + if extra is None: + name = getattr(contract, "capability_name", type(contract).__name__) + raise ContractError( + f"Contract {name!r} lacks 'extra_grammars'; " + "contracts must inherit CapabilityContract (ADR-0007)." + ) + return tuple(extra) + + def _recognize( text: str, all_grammars: Sequence[Grammar[Any]], shipped_names: Sequence[str], - contract: Contract, + contract: CapabilityContract, ) -> list[RecognizedRep[Any]]: """Run active grammars, dedup contained matches per grammar, and order. @@ -144,7 +162,7 @@ def _recognize( opt-in via ``extra_grammars`` in both cases. """ supported_names = {g.name for g in all_grammars} - extra_grammars = getattr(contract, "extra_grammars", ()) + extra_grammars = _extra_grammars_of(contract) declared = contract.active_grammars active_source = shipped_names if declared is None else declared # Deduplicate contract names, keeping first occurrence: each supported @@ -243,7 +261,9 @@ def _dedup_spans( return kept -def _filter_rules(all_rules: list[Rule[Any]], contract: Contract) -> list[Rule[Any]]: +def _filter_rules( + all_rules: list[Rule[Any]], contract: CapabilityContract +) -> list[Rule[Any]]: """Return rules based on pinning, exclusion, year, and feature filters. When pinned_rules is set, ONLY those rules run (excluded_rules is ignored). @@ -479,7 +499,7 @@ def _extract_canonical_value( def _activated_rules( capability: Capability[Any], - contract: Contract, + contract: CapabilityContract, semantics_by_name: dict[str, str], ) -> list[Rule[Any]]: """Community rules opt in like grammars: a rule runs only when the @@ -491,7 +511,7 @@ def _activated_rules( rule — even one targeting a shipped grammar — never affects results, keeping extension behavior deterministic per contract. """ - extra_grammars = set(getattr(contract, "extra_grammars", ())) + extra_grammars = set(_extra_grammars_of(contract)) extra_semantics = {semantics_by_name.get(n, n) for n in extra_grammars} return [ rule diff --git a/paxman/shared_data/README.md b/paxman/shared_data/README.md new file mode 100644 index 00000000..b7ea6da5 --- /dev/null +++ b/paxman/shared_data/README.md @@ -0,0 +1,10 @@ +# Shared Vocabulary Snapshot — Currency / Money + +Source of truth for CLDR currency data and ISO 4217 list-one. + +- Authority: Unicode CLDR v47 (en + es) and ISO 4217 (2015 list-one snapshot). +- Canonical file: `currency_snapshot.json` (JSON, UTF-8, sorted keys). +- Generated outputs: `paxman/capabilities/Currency/{grammar,rules}/data/*` and `paxman/capabilities/Money/{grammar,rules}/data/*` via `tools/regenerate_currency_data.py`. +- Edit workflow: update snapshot JSON (with citation), then `uv run python tools/regenerate_currency_data.py` and `--check` in CI. + +Mandate M8: Sibling imports remain banned. Shared vocabularies regenerate into per-capability tables, never imported across capabilities. diff --git a/paxman/shared_data/currency_snapshot.json b/paxman/shared_data/currency_snapshot.json new file mode 100644 index 00000000..51de4679 --- /dev/null +++ b/paxman/shared_data/currency_snapshot.json @@ -0,0 +1,1609 @@ +{ + "_meta": { + "authority": "Unicode CLDR v47 (en + es) + ISO 4217:2015 list-one", + "citation": "CLDR common/main/en.xml, common/main/es.xml; ISO 4217 list-one 2015 (SIX Financial Information AG, 2026-01-01, amended through #180)", + "currency_codes_full_count": 178, + "currency_codes_money_count": 165, + "generated_by": "tools/_extract_currency_snapshot.py union of Currency+Money tables as of 2026-08-19", + "name_count": 62, + "symbol_count": 67 + }, + "cldr_currencies": { + "name_to_codes_currency": { + "afghani": [ + "AFN" + ], + "baht": [ + "THB" + ], + "bolivar": [ + "VES" + ], + "boliviano": [ + "BOB" + ], + "cedi": [ + "GHS" + ], + "colon": [ + "CRC" + ], + "cordoba": [ + "NIO" + ], + "dinar": [ + "KWD" + ], + "dirham": [ + "AED" + ], + "dollar": [ + "USD" + ], + "dong": [ + "VND" + ], + "dram": [ + "AMD" + ], + "euro": [ + "EUR" + ], + "florin": [ + "AWG" + ], + "forint": [ + "HUF" + ], + "franc": [ + "CHF" + ], + "guarani": [ + "PYG" + ], + "hryvnia": [ + "UAH" + ], + "kina": [ + "PGK" + ], + "kip": [ + "LAK" + ], + "koruna": [ + "CZK" + ], + "krona": [ + "SEK" + ], + "krone": [ + "NOK" + ], + "kwacha": [ + "MWK" + ], + "kyat": [ + "MMK" + ], + "lari": [ + "GEL" + ], + "lempira": [ + "HNL" + ], + "leu": [ + "RON" + ], + "lilangeni": [ + "SZL" + ], + "lira": [ + "TRY" + ], + "manat": [ + "AZN" + ], + "mark": [ + "BAM" + ], + "naira": [ + "NGN" + ], + "nakfa": [ + "ERN" + ], + "pataca": [ + "MOP" + ], + "peso": [ + "MXN" + ], + "pound": [ + "GBP" + ], + "pula": [ + "BWP" + ], + "quetzal": [ + "GTQ" + ], + "rand": [ + "ZAR" + ], + "real": [ + "BRL" + ], + "rial": [ + "IRR" + ], + "riel": [ + "KHR" + ], + "ringgit": [ + "MYR" + ], + "riyal": [ + "SAR" + ], + "ruble": [ + "RUB" + ], + "rufiyaa": [ + "MVR" + ], + "rupee": [ + "INR" + ], + "rupiah": [ + "IDR" + ], + "shekel": [ + "ILS" + ], + "sol": [ + "PEN" + ], + "som": [ + "KGS" + ], + "somoni": [ + "TJS" + ], + "taka": [ + "BDT" + ], + "tala": [ + "WST" + ], + "tenge": [ + "KZT" + ], + "tugrik": [ + "MNT" + ], + "vatu": [ + "VUV" + ], + "won": [ + "KRW" + ], + "yen": [ + "JPY" + ], + "yuan": [ + "CNY" + ], + "zloty": [ + "PLN" + ] + }, + "name_to_codes_money": { + "Afghani": [ + "AFN" + ], + "Baht": [ + "THB" + ], + "Bolivar": [ + "VES" + ], + "Boliviano": [ + "BOB" + ], + "Cedi": [ + "GHS" + ], + "Colon": [ + "CRC" + ], + "Cordoba": [ + "NIO" + ], + "Dinar": [ + "KWD" + ], + "Dirham": [ + "AED" + ], + "Dollar": [ + "USD" + ], + "Dong": [ + "VND" + ], + "Dram": [ + "AMD" + ], + "Euro": [ + "EUR" + ], + "Florin": [ + "AWG" + ], + "Forint": [ + "HUF" + ], + "Franc": [ + "CHF" + ], + "Guarani": [ + "PYG" + ], + "Hryvnia": [ + "UAH" + ], + "Kina": [ + "PGK" + ], + "Kip": [ + "LAK" + ], + "Koruna": [ + "CZK" + ], + "Krona": [ + "SEK" + ], + "Krone": [ + "NOK" + ], + "Kwacha": [ + "MWK" + ], + "Kyat": [ + "MMK" + ], + "Lari": [ + "GEL" + ], + "Lempira": [ + "HNL" + ], + "Leu": [ + "RON" + ], + "Lilangeni": [ + "SZL" + ], + "Lira": [ + "TRY" + ], + "Manat": [ + "AZN" + ], + "Mark": [ + "BAM" + ], + "Naira": [ + "NGN" + ], + "Nakfa": [ + "ERN" + ], + "Pataca": [ + "MOP" + ], + "Peso": [ + "MXN" + ], + "Pound": [ + "GBP" + ], + "Pula": [ + "BWP" + ], + "Quetzal": [ + "GTQ" + ], + "Rand": [ + "ZAR" + ], + "Real": [ + "BRL" + ], + "Rial": [ + "IRR" + ], + "Riel": [ + "KHR" + ], + "Ringgit": [ + "MYR" + ], + "Riyal": [ + "SAR" + ], + "Ruble": [ + "RUB" + ], + "Rufiyaa": [ + "MVR" + ], + "Rupee": [ + "INR" + ], + "Rupiah": [ + "IDR" + ], + "Shekel": [ + "ILS" + ], + "Sol": [ + "PEN" + ], + "Som": [ + "KGS" + ], + "Somoni": [ + "TJS" + ], + "Taka": [ + "BDT" + ], + "Tala": [ + "WST" + ], + "Tenge": [ + "KZT" + ], + "Tugrik": [ + "MNT" + ], + "Vatu": [ + "VUV" + ], + "Won": [ + "KRW" + ], + "Yen": [ + "JPY" + ], + "Yuan": [ + "CNY" + ], + "Zloty": [ + "PLN" + ] + }, + "symbol_to_codes": { + "$": [ + "ARS", + "AUD", + "BBD", + "BMD", + "BND", + "BSD", + "BZD", + "CAD", + "CLP", + "COP", + "CUP", + "DOP", + "FJD", + "GYD", + "HKD", + "JMD", + "KYD", + "LRD", + "MXN", + "NAD", + "NZD", + "SBD", + "SGD", + "SRD", + "TTD", + "TWD", + "USD", + "UYU", + "XCD" + ], + "A$": [ + "AUD" + ], + "Ar": [ + "MGA" + ], + "Bs": [ + "BOB" + ], + "C$": [ + "NIO" + ], + "CA$": [ + "CAD" + ], + "CF": [ + "KMF" + ], + "CFPF": [ + "XPF" + ], + "CN¥": [ + "CNY" + ], + "Cg.": [ + "XCG" + ], + "Db": [ + "STN" + ], + "EC$": [ + "XCD" + ], + "E£": [ + "EGP" + ], + "FCFA": [ + "XAF" + ], + "FG": [ + "GNF" + ], + "Ft": [ + "HUF" + ], + "GH₵": [ + "GHS" + ], + "HK$": [ + "HKD" + ], + "K": [ + "MMK" + ], + "KM": [ + "BAM" + ], + "Kz": [ + "AOA" + ], + "Kč": [ + "CZK" + ], + "L": [ + "HNL", + "RON" + ], + "L£": [ + "LBP" + ], + "MX$": [ + "MXN" + ], + "NT$": [ + "TWD" + ], + "NZ$": [ + "NZD" + ], + "P": [ + "BWP" + ], + "Q": [ + "GTQ" + ], + "R": [ + "ZAR" + ], + "R$": [ + "BRL" + ], + "RF": [ + "RWF" + ], + "RM": [ + "MYR" + ], + "Rp": [ + "IDR" + ], + "Rs": [ + "LKR", + "MUR", + "NPR", + "PKR" + ], + "T$": [ + "TOP" + ], + "US$": [ + "USD" + ], + "ZK": [ + "ZMW" + ], + "kr": [ + "DKK", + "ISK", + "NOK", + "SEK" + ], + "lei": [ + "RON" + ], + "zł": [ + "PLN" + ], + "£": [ + "FKP", + "GBP", + "GIP", + "SHP", + "SSP", + "SYP" + ], + "¥": [ + "CNY", + "JPY" + ], + "р.": [ + "BYN" + ], + "֏": [ + "AMD" + ], + "؋": [ + "AFN" + ], + "৳": [ + "BDT" + ], + "฿": [ + "THB" + ], + "៛": [ + "KHR" + ], + "₡": [ + "CRC" + ], + "₦": [ + "NGN" + ], + "₩": [ + "KPW", + "KRW" + ], + "₪": [ + "ILS" + ], + "₫": [ + "VND" + ], + "€": [ + "EUR" + ], + "₭": [ + "LAK" + ], + "₮": [ + "MNT" + ], + "₱": [ + "PHP" + ], + "₲": [ + "PYG" + ], + "₴": [ + "UAH" + ], + "₸": [ + "KZT" + ], + "₹": [ + "INR" + ], + "₺": [ + "TRY" + ], + "₼": [ + "AZN" + ], + "₽": [ + "RUB" + ], + "₾": [ + "GEL" + ], + "⃀": [ + "KGS" + ] + } + }, + "grammar": { + "symbol_tokens": [ + "CFPF", + "FCFA", + "CA$", + "CN¥", + "Cg.", + "EC$", + "GH₵", + "HK$", + "MX$", + "NT$", + "NZ$", + "US$", + "lei", + "A$", + "Ar", + "Bs", + "C$", + "CF", + "Db", + "E£", + "FG", + "Ft", + "KM", + "Kz", + "Kč", + "L£", + "R$", + "RF", + "RM", + "Rp", + "Rs", + "T$", + "ZK", + "kr", + "zł", + "K", + "L", + "P", + "Q", + "R", + "р.", + "$", + "£", + "¥", + "֏", + "؋", + "৳", + "฿", + "៛", + "₡", + "₦", + "₩", + "₪", + "₫", + "€", + "₭", + "₮", + "₱", + "₲", + "₴", + "₸", + "₹", + "₺", + "₼", + "₽", + "₾", + "⃀" + ], + "word_tokens_currency": [ + "boliviano", + "lilangeni", + "afghani", + "bolivar", + "cordoba", + "guarani", + "hryvnia", + "lempira", + "quetzal", + "ringgit", + "rufiyaa", + "dirham", + "dollar", + "florin", + "forint", + "koruna", + "kwacha", + "pataca", + "rupiah", + "shekel", + "somoni", + "tugrik", + "colon", + "dinar", + "franc", + "krona", + "krone", + "manat", + "naira", + "nakfa", + "pound", + "riyal", + "ruble", + "rupee", + "tenge", + "zloty", + "baht", + "cedi", + "dong", + "dram", + "euro", + "kina", + "kyat", + "lari", + "lira", + "mark", + "peso", + "pula", + "rand", + "real", + "rial", + "riel", + "taka", + "tala", + "vatu", + "yuan", + "kip", + "leu", + "sol", + "som", + "won", + "yen" + ], + "word_tokens_money": [ + "Boliviano", + "Lilangeni", + "Afghani", + "Bolivar", + "Cordoba", + "Guarani", + "Hryvnia", + "Lempira", + "Quetzal", + "Ringgit", + "Rufiyaa", + "Dirham", + "Dollar", + "Florin", + "Forint", + "Koruna", + "Kwacha", + "Pataca", + "Rupiah", + "Shekel", + "Somoni", + "Tugrik", + "Colon", + "Dinar", + "Franc", + "Krona", + "Krone", + "Manat", + "Naira", + "Nakfa", + "Pound", + "Riyal", + "Ruble", + "Rupee", + "Tenge", + "Zloty", + "Baht", + "Cedi", + "Dong", + "Dram", + "Euro", + "Kina", + "Kyat", + "Lari", + "Lira", + "Mark", + "Peso", + "Pula", + "Rand", + "Real", + "Rial", + "Riel", + "Taka", + "Tala", + "Vatu", + "Yuan", + "Kip", + "Leu", + "Sol", + "Som", + "Won", + "Yen" + ] + }, + "iso4217": { + "currency_codes_full": [ + "AED", + "AFN", + "ALL", + "AMD", + "AOA", + "ARS", + "AUD", + "AWG", + "AZN", + "BAM", + "BBD", + "BDT", + "BHD", + "BIF", + "BMD", + "BND", + "BOB", + "BOV", + "BRL", + "BSD", + "BTN", + "BWP", + "BYN", + "BZD", + "CAD", + "CDF", + "CHE", + "CHF", + "CHW", + "CLF", + "CLP", + "CNY", + "COP", + "COU", + "CRC", + "CUP", + "CVE", + "CZK", + "DJF", + "DKK", + "DOP", + "DZD", + "EGP", + "ERN", + "ETB", + "EUR", + "FJD", + "FKP", + "GBP", + "GEL", + "GHS", + "GIP", + "GMD", + "GNF", + "GTQ", + "GYD", + "HKD", + "HNL", + "HTG", + "HUF", + "IDR", + "ILS", + "INR", + "IQD", + "IRR", + "ISK", + "JMD", + "JOD", + "JPY", + "KES", + "KGS", + "KHR", + "KMF", + "KPW", + "KRW", + "KWD", + "KYD", + "KZT", + "LAK", + "LBP", + "LKR", + "LRD", + "LSL", + "LYD", + "MAD", + "MDL", + "MGA", + "MKD", + "MMK", + "MNT", + "MOP", + "MRU", + "MUR", + "MVR", + "MWK", + "MXN", + "MXV", + "MYR", + "MZN", + "NAD", + "NGN", + "NIO", + "NOK", + "NPR", + "NZD", + "OMR", + "PAB", + "PEN", + "PGK", + "PHP", + "PKR", + "PLN", + "PYG", + "QAR", + "RON", + "RSD", + "RUB", + "RWF", + "SAR", + "SBD", + "SCR", + "SDG", + "SEK", + "SGD", + "SHP", + "SLE", + "SOS", + "SRD", + "SSP", + "STN", + "SVC", + "SYP", + "SZL", + "THB", + "TJS", + "TMT", + "TND", + "TOP", + "TRY", + "TTD", + "TWD", + "TZS", + "UAH", + "UGX", + "USD", + "USN", + "UYI", + "UYU", + "UYW", + "UZS", + "VED", + "VES", + "VND", + "VUV", + "WST", + "XAD", + "XAF", + "XAG", + "XAU", + "XBA", + "XBB", + "XBC", + "XBD", + "XCD", + "XCG", + "XDR", + "XOF", + "XPD", + "XPF", + "XPT", + "XSU", + "XTS", + "XUA", + "XXX", + "YER", + "ZAR", + "ZMW", + "ZWG" + ], + "currency_codes_money": [ + "AED", + "AFN", + "ALL", + "AMD", + "AOA", + "ARS", + "AUD", + "AWG", + "AZN", + "BAM", + "BBD", + "BDT", + "BHD", + "BIF", + "BMD", + "BND", + "BOB", + "BOV", + "BRL", + "BSD", + "BTN", + "BWP", + "BYN", + "BZD", + "CAD", + "CDF", + "CHE", + "CHF", + "CHW", + "CLF", + "CLP", + "CNY", + "COP", + "COU", + "CRC", + "CUP", + "CVE", + "CZK", + "DJF", + "DKK", + "DOP", + "DZD", + "EGP", + "ERN", + "ETB", + "EUR", + "FJD", + "FKP", + "GBP", + "GEL", + "GHS", + "GIP", + "GMD", + "GNF", + "GTQ", + "GYD", + "HKD", + "HNL", + "HTG", + "HUF", + "IDR", + "ILS", + "INR", + "IQD", + "IRR", + "ISK", + "JMD", + "JOD", + "JPY", + "KES", + "KGS", + "KHR", + "KMF", + "KPW", + "KRW", + "KWD", + "KYD", + "KZT", + "LAK", + "LBP", + "LKR", + "LRD", + "LSL", + "LYD", + "MAD", + "MDL", + "MGA", + "MKD", + "MMK", + "MNT", + "MOP", + "MRU", + "MUR", + "MVR", + "MWK", + "MXN", + "MXV", + "MYR", + "MZN", + "NAD", + "NGN", + "NIO", + "NOK", + "NPR", + "NZD", + "OMR", + "PAB", + "PEN", + "PGK", + "PHP", + "PKR", + "PLN", + "PYG", + "QAR", + "RON", + "RSD", + "RUB", + "RWF", + "SAR", + "SBD", + "SCR", + "SDG", + "SEK", + "SGD", + "SHP", + "SLE", + "SOS", + "SRD", + "SSP", + "STN", + "SVC", + "SYP", + "SZL", + "THB", + "TJS", + "TMT", + "TND", + "TOP", + "TRY", + "TTD", + "TWD", + "TZS", + "UAH", + "UGX", + "USD", + "USN", + "UYI", + "UYU", + "UYW", + "UZS", + "VED", + "VES", + "VND", + "VUV", + "WST", + "XAD", + "XAF", + "XCD", + "XCG", + "XOF", + "XPF", + "YER", + "ZAR", + "ZMW", + "ZWG" + ], + "excluded_no_minor_units": [ + "XAG", + "XAU", + "XBA", + "XBB", + "XBC", + "XBD", + "XDR", + "XPD", + "XPT", + "XSU", + "XTS", + "XUA", + "XXX" + ], + "minor_units": { + "AED": 2, + "AFN": 2, + "ALL": 2, + "AMD": 2, + "AOA": 2, + "ARS": 2, + "AUD": 2, + "AWG": 2, + "AZN": 2, + "BAM": 2, + "BBD": 2, + "BDT": 2, + "BHD": 3, + "BIF": 0, + "BMD": 2, + "BND": 2, + "BOB": 2, + "BOV": 2, + "BRL": 2, + "BSD": 2, + "BTN": 2, + "BWP": 2, + "BYN": 2, + "BZD": 2, + "CAD": 2, + "CDF": 2, + "CHE": 2, + "CHF": 2, + "CHW": 2, + "CLF": 4, + "CLP": 0, + "CNY": 2, + "COP": 2, + "COU": 2, + "CRC": 2, + "CUP": 2, + "CVE": 2, + "CZK": 2, + "DJF": 0, + "DKK": 2, + "DOP": 2, + "DZD": 2, + "EGP": 2, + "ERN": 2, + "ETB": 2, + "EUR": 2, + "FJD": 2, + "FKP": 2, + "GBP": 2, + "GEL": 2, + "GHS": 2, + "GIP": 2, + "GMD": 2, + "GNF": 0, + "GTQ": 2, + "GYD": 2, + "HKD": 2, + "HNL": 2, + "HTG": 2, + "HUF": 2, + "IDR": 2, + "ILS": 2, + "INR": 2, + "IQD": 3, + "IRR": 2, + "ISK": 0, + "JMD": 2, + "JOD": 3, + "JPY": 0, + "KES": 2, + "KGS": 2, + "KHR": 2, + "KMF": 0, + "KPW": 2, + "KRW": 0, + "KWD": 3, + "KYD": 2, + "KZT": 2, + "LAK": 2, + "LBP": 2, + "LKR": 2, + "LRD": 2, + "LSL": 2, + "LYD": 3, + "MAD": 2, + "MDL": 2, + "MGA": 2, + "MKD": 2, + "MMK": 2, + "MNT": 2, + "MOP": 2, + "MRU": 2, + "MUR": 2, + "MVR": 2, + "MWK": 2, + "MXN": 2, + "MXV": 2, + "MYR": 2, + "MZN": 2, + "NAD": 2, + "NGN": 2, + "NIO": 2, + "NOK": 2, + "NPR": 2, + "NZD": 2, + "OMR": 3, + "PAB": 2, + "PEN": 2, + "PGK": 2, + "PHP": 2, + "PKR": 2, + "PLN": 2, + "PYG": 0, + "QAR": 2, + "RON": 2, + "RSD": 2, + "RUB": 2, + "RWF": 0, + "SAR": 2, + "SBD": 2, + "SCR": 2, + "SDG": 2, + "SEK": 2, + "SGD": 2, + "SHP": 2, + "SLE": 2, + "SOS": 2, + "SRD": 2, + "SSP": 2, + "STN": 2, + "SVC": 2, + "SYP": 2, + "SZL": 2, + "THB": 2, + "TJS": 2, + "TMT": 2, + "TND": 3, + "TOP": 2, + "TRY": 2, + "TTD": 2, + "TWD": 2, + "TZS": 2, + "UAH": 2, + "UGX": 0, + "USD": 2, + "USN": 2, + "UYI": 0, + "UYU": 2, + "UYW": 4, + "UZS": 2, + "VED": 2, + "VES": 2, + "VND": 0, + "VUV": 0, + "WST": 2, + "XAD": 2, + "XAF": 0, + "XCD": 2, + "XCG": 2, + "XOF": 0, + "XPF": 0, + "YER": 2, + "ZAR": 2, + "ZMW": 2, + "ZWG": 2 + } + }, + "symbol_to_codes": { + "$": [ + "ARS", + "AUD", + "BBD", + "BMD", + "BND", + "BSD", + "BZD", + "CAD", + "CLP", + "COP", + "CUP", + "DOP", + "FJD", + "GYD", + "HKD", + "JMD", + "KYD", + "LRD", + "MXN", + "NAD", + "NZD", + "SBD", + "SGD", + "SRD", + "TTD", + "TWD", + "USD", + "UYU", + "XCD" + ], + "A$": [ + "AUD" + ], + "Ar": [ + "MGA" + ], + "Bs": [ + "BOB" + ], + "C$": [ + "NIO" + ], + "CA$": [ + "CAD" + ], + "CF": [ + "KMF" + ], + "CFPF": [ + "XPF" + ], + "CN¥": [ + "CNY" + ], + "Cg.": [ + "XCG" + ], + "Db": [ + "STN" + ], + "EC$": [ + "XCD" + ], + "E£": [ + "EGP" + ], + "FCFA": [ + "XAF" + ], + "FG": [ + "GNF" + ], + "Ft": [ + "HUF" + ], + "GH₵": [ + "GHS" + ], + "HK$": [ + "HKD" + ], + "K": [ + "MMK" + ], + "KM": [ + "BAM" + ], + "Kz": [ + "AOA" + ], + "Kč": [ + "CZK" + ], + "L": [ + "HNL", + "RON" + ], + "L£": [ + "LBP" + ], + "MX$": [ + "MXN" + ], + "NT$": [ + "TWD" + ], + "NZ$": [ + "NZD" + ], + "P": [ + "BWP" + ], + "Q": [ + "GTQ" + ], + "R": [ + "ZAR" + ], + "R$": [ + "BRL" + ], + "RF": [ + "RWF" + ], + "RM": [ + "MYR" + ], + "Rp": [ + "IDR" + ], + "Rs": [ + "LKR", + "MUR", + "NPR", + "PKR" + ], + "T$": [ + "TOP" + ], + "US$": [ + "USD" + ], + "ZK": [ + "ZMW" + ], + "kr": [ + "DKK", + "ISK", + "NOK", + "SEK" + ], + "lei": [ + "RON" + ], + "zł": [ + "PLN" + ], + "£": [ + "FKP", + "GBP", + "GIP", + "SHP", + "SSP", + "SYP" + ], + "¥": [ + "CNY", + "JPY" + ], + "р.": [ + "BYN" + ], + "֏": [ + "AMD" + ], + "؋": [ + "AFN" + ], + "৳": [ + "BDT" + ], + "฿": [ + "THB" + ], + "៛": [ + "KHR" + ], + "₡": [ + "CRC" + ], + "₦": [ + "NGN" + ], + "₩": [ + "KPW", + "KRW" + ], + "₪": [ + "ILS" + ], + "₫": [ + "VND" + ], + "€": [ + "EUR" + ], + "₭": [ + "LAK" + ], + "₮": [ + "MNT" + ], + "₱": [ + "PHP" + ], + "₲": [ + "PYG" + ], + "₴": [ + "UAH" + ], + "₸": [ + "KZT" + ], + "₹": [ + "INR" + ], + "₺": [ + "TRY" + ], + "₼": [ + "AZN" + ], + "₽": [ + "RUB" + ], + "₾": [ + "GEL" + ], + "⃀": [ + "KGS" + ] + } +} diff --git a/pyproject.toml b/pyproject.toml index 771b165c..3cf5a93c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ markers = [ "integration: integration tests", "e2e: end-to-end tests", "property: property-based tests (Hypothesis)", + "benchmark: benchmark harness tests", "country: country capability tests", "currency: currency capability tests", "isbn: isbn capability tests", @@ -46,7 +47,7 @@ branch = true # it is scaffolded into the real tree and must not dent per-package coverage. # Real scaffolded capabilities (e.g. Timezone) are committed and intentionally covered, # so do not broaden this to Widget* — the test fixture cleans up byte-for-byte. -omit = ["tests/*", "paxman/capabilities/Widget/*"] +omit = ["tests/*", "paxman/capabilities/Widget/*", "benchmarks/*"] [tool.coverage.report] show_missing = true diff --git a/tests/integration/test_benchmark_harness.py b/tests/integration/test_benchmark_harness.py new file mode 100644 index 00000000..1f875577 --- /dev/null +++ b/tests/integration/test_benchmark_harness.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from paxman.core.discovery import reset_registry + +pytestmark = [pytest.mark.benchmark, pytest.mark.integration] + + +@pytest.fixture(autouse=True) +def _clean_registry(): + reset_registry() + yield + reset_registry() + + +def test_harness_runs_one_scenario() -> None: + from benchmarks.harness import run_once + + result = run_once("email", "user@example.com", iterations=3) + assert result["capability"] == "email" + assert result["iterations"] == 3 + assert result["mean_ms"] >= 0 + assert result["p50_ms"] >= 0 + + +def test_harness_writes_json() -> None: + import tempfile + + from benchmarks.harness import main + + with tempfile.TemporaryDirectory() as td: + out = Path(td) / "out.json" + main(["--output", str(out), "--iterations", "2"]) + data = json.loads(out.read_text(encoding="utf-8")) + assert "scenarios" in data + assert len(data["scenarios"]) == 10 diff --git a/tests/integration/test_feature_gating.py b/tests/integration/test_feature_gating.py index f73e3f27..0384c022 100644 --- a/tests/integration/test_feature_gating.py +++ b/tests/integration/test_feature_gating.py @@ -92,6 +92,10 @@ def capability_name(self) -> str: def active_grammars(self) -> list[str]: return ["name_recognition"] + @property + def extra_grammars(self) -> tuple[str, ...]: + return () + @property def excluded_rules(self) -> list[str]: return [] @@ -178,6 +182,10 @@ def capability_name(self) -> str: def active_grammars(self) -> list[str]: return ["name_recognition"] + @property + def extra_grammars(self) -> tuple[str, ...]: + return () + @property def excluded_rules(self) -> list[str]: return [] diff --git a/tests/integration/test_format_value_seam.py b/tests/integration/test_format_value_seam.py index c582fae5..0c0b4d96 100644 --- a/tests/integration/test_format_value_seam.py +++ b/tests/integration/test_format_value_seam.py @@ -124,6 +124,10 @@ def capability_name(self) -> str: def active_grammars(self) -> list[str]: return ["token_grammar"] + @property + def extra_grammars(self) -> tuple[str, ...]: + return () + @property def excluded_rules(self) -> list[str]: return [] @@ -263,6 +267,10 @@ def capability_name(self) -> str: def active_grammars(self) -> list[str]: return ["dual_alpha_grammar", "dual_beta_grammar"] + @property + def extra_grammars(self) -> tuple[str, ...]: + return () + @property def excluded_rules(self) -> list[str]: return [] diff --git a/tests/integration/test_pipeline.py b/tests/integration/test_pipeline.py index 408e69f7..3b0436f9 100644 --- a/tests/integration/test_pipeline.py +++ b/tests/integration/test_pipeline.py @@ -244,6 +244,10 @@ def capability_name(self) -> str: def active_grammars(self) -> list[str]: return ["crash_grammar"] + @property + def extra_grammars(self) -> tuple[str, ...]: + return () + @property def excluded_rules(self) -> list[str]: return [] @@ -428,6 +432,10 @@ def capability_name(self) -> str: def active_grammars(self) -> list[str]: return ["phantom_grammar"] + @property + def extra_grammars(self) -> tuple[str, ...]: + return () + @property def excluded_rules(self) -> list[str]: return [] diff --git a/tests/property/test_money_properties.py b/tests/property/test_money_properties.py index 5e12523d..fe4a804a 100644 --- a/tests/property/test_money_properties.py +++ b/tests/property/test_money_properties.py @@ -28,6 +28,7 @@ from paxman.capabilities.Money.rules.data.iso4217_list_one import MINOR_UNITS from paxman.core.discovery import register_capability, reset_registry from paxman.core.domain import Resolution +from paxman.core.errors import MultipleMentionsError from paxman.engine.orchestrator import run_capability _CANONICAL_SHAPE = re.compile(r"[A-Z]{3} \d+(\.\d+)?") @@ -52,7 +53,15 @@ def _fresh_registry() -> None: def test_canonical_determinism(text: str) -> None: """Same input + same contract -> identical (deterministic) ExecutionResult.""" contract = MoneyCapability.create_contract() - result1 = run_capability(text, contract) + try: + result1 = run_capability(text, contract) + except MultipleMentionsError as exc1: + try: + run_capability(text, contract) + except MultipleMentionsError as exc2: + assert str(exc1) == str(exc2) + return + pytest.fail("first run raised MultipleMentionsError but second did not") result2 = run_capability(text, contract) assert result1 == result2 assert result1.status == result2.status @@ -83,7 +92,11 @@ def test_parse_format_round_trip_preserves_value(integer: str, fraction: str) -> def test_fuzz_random_text_never_raises(text: str) -> None: """Random ASCII input never raises; every status is well-formed.""" contract = MoneyCapability.create_contract() - result = run_capability(text, contract) + try: + result = run_capability(text, contract) + except MultipleMentionsError: + # Multi-entity input is a valid engine outcome (single-value invariant) + return assert result.status in { Resolution.MISSING, Resolution.INVALID, @@ -106,7 +119,10 @@ def test_fuzz_random_text_never_raises(text: str) -> None: def test_success_canonical_shape(text: str) -> None: """Every SUCCESS value matches CODE amount with the code's minor units.""" contract = MoneyCapability.create_contract() - result = run_capability(text, contract) + try: + result = run_capability(text, contract) + except MultipleMentionsError: + return if result.status != Resolution.SUCCESS: return value = result.canonicalized_value diff --git a/tests/unit/test_capability_lazy_import.py b/tests/unit/test_capability_lazy_import.py new file mode 100644 index 00000000..8ffad99e --- /dev/null +++ b/tests/unit/test_capability_lazy_import.py @@ -0,0 +1,89 @@ +"""Item 8 — PEP 562 lazy exports for paxman.capabilities (W4). + +Importing one capability should not pay for all ten. After PEP 562, importing +`paxman.capabilities` itself must be cheap, and `from paxman.capabilities import Email` +must not import URL's 15K-line IDNA table transitively. +""" + +from __future__ import annotations + +import sys + +import pytest + +pytestmark = pytest.mark.unit + + +def test_capabilities_init_is_lazy() -> None: + """paxman.capabilities must expose __getattr__ (PEP 562 lazy).""" + import paxman.capabilities as cap_mod + + assert hasattr(cap_mod, "__getattr__") and callable(cap_mod.__getattr__) + assert hasattr(cap_mod, "__dir__") and callable(cap_mod.__dir__) + + +def test_import_email_does_not_import_url_data() -> None: + """Importing Email alone must not load URL's IDNA table.""" + # Start from a clean slate: unload capabilities submodules if loaded + for mod in list(sys.modules): + if mod.startswith("paxman.capabilities"): + del sys.modules[mod] + + import paxman.capabilities as cap_mod + + # Clear any cached lazy attribute so the import below is genuinely exercised + # (otherwise a prior test's cached class would short-circuit __getattr__). + cap_mod.__dict__.pop("Email", None) + + import paxman.capabilities # package itself # noqa: F401 + + # Import Email via lazy path + from paxman.capabilities import Email # noqa: F401 + + # URL's heavy module should NOT have been imported transitively + assert "paxman.capabilities.URL.rules.data.idna_uts46_data" not in sys.modules + # At minimum, importing Email must not have imported all 10 capability packages + # Count loaded capability submodules — Email package + its own deps only + loaded = [m for m in sys.modules if m.startswith("paxman.capabilities.")] + # Ensure no other top-level capability package was loaded + caps_loaded = {m.split(".")[2] for m in loaded if len(m.split(".")) >= 3} + assert caps_loaded <= {"Email"}, ( + f"Lazy import leaked other capabilities: {caps_loaded}" + ) + + +def test_all_still_exported_via_all() -> None: + """`__all__` must still list all ten capabilities for star-import and docs.""" + import paxman.capabilities as cap_mod + + assert set(cap_mod.__all__) == { + "Country", + "Currency", + "Date", + "Email", + "IP", + "ISBN", + "Money", + "Phone", + "SIUnit", + "URL", + } + # Access each via getattr still works + for name in cap_mod.__all__: + assert hasattr(cap_mod, name), f"Missing lazy export: {name}" + # Actually getattr should return the class + cls = getattr(cap_mod, name) + assert hasattr(cls, "name"), f"{name} should be a Capability subclass" + + +def test_bootstrap_still_works() -> None: + """register_all_shipped must still work with lazy exports.""" + from paxman.api.bootstrap import register_all_shipped + from paxman.capabilities import Email # noqa: F401 + from paxman.core.discovery import reset_registry + + reset_registry() + registered = register_all_shipped() + assert "email" in registered + assert "url" in registered + reset_registry() diff --git a/tests/unit/test_contract_surface.py b/tests/unit/test_contract_surface.py new file mode 100644 index 00000000..373c4e5c --- /dev/null +++ b/tests/unit/test_contract_surface.py @@ -0,0 +1,129 @@ +"""Item 5 — contract surface unification. + +CapabilityContract is the single source of truth. These tests drive Task 3: +they will FAIL until the engine removes getattr probes and Contract is demoted +from public exports. +""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.unit + + +def test_contract_not_exported_from_core_public_api() -> None: + """After unification, `Contract` must NOT be exported from public surfaces.""" + import paxman + import paxman.core as core + + assert not hasattr(core, "Contract"), ( + "Contract must not be re-exported from paxman.core" + ) + assert not hasattr(paxman, "Contract"), ( + "Contract must not be re-exported from paxman (ADR-0007)" + ) + + +def test_capability_contract_is_only_public_base() -> None: + """Every shipped contract must inherit CapabilityContract (homogeneity mandate).""" + from paxman.capabilities.Country.contract import CountryContract + from paxman.capabilities.Currency.contract import CurrencyContract + from paxman.capabilities.Date.contract import DateContract + from paxman.capabilities.Email.contract import EmailContract + from paxman.capabilities.IP.contract import IPContract + from paxman.capabilities.ISBN.contract import ISBNContract + from paxman.capabilities.Money.contract import MoneyContract + from paxman.capabilities.Phone.contract import PhoneContract + from paxman.capabilities.SIUnit.contract import SIUnitContract + from paxman.capabilities.URL.contract import URLCapabilityContract as URLContract + from paxman.core.capability_contract import CapabilityContract + + for cls in [ + CountryContract, + CurrencyContract, + DateContract, + EmailContract, + IPContract, + ISBNContract, + MoneyContract, + PhoneContract, + SIUnitContract, + URLContract, + ]: + assert issubclass(cls, CapabilityContract), ( + f"{cls.__name__} must inherit CapabilityContract" + ) + + +def test_recognize_requires_extra_grammars() -> None: + """_recognize fails fast with ContractError when contract lacks extra_grammars.""" + + from dataclasses import dataclass + from typing import cast + + from paxman.core.capability_contract import CapabilityContract + from paxman.core.errors import ContractError + from paxman.engine.orchestrator import _recognize + + @dataclass(frozen=True) + class _BadContract: + capability_name: str = "email" + # NOTE: no extra_grammars attribute at all + + with pytest.raises(ContractError): + _recognize("hello", [], [], cast(CapabilityContract, _BadContract())) + + +def test_activated_rules_requires_extra_grammars() -> None: + """_activated_rules fails fast without extra_grammars.""" + + from dataclasses import dataclass + from typing import cast + + from paxman.capabilities.Email.capability import EmailCapability + from paxman.core.capability_contract import CapabilityContract + from paxman.core.errors import ContractError + from paxman.engine.orchestrator import _activated_rules + + @dataclass(frozen=True) + class _BadContract: + capability_name: str = "email" + # NOTE: no extra_grammars attribute at all + + with pytest.raises(ContractError): + _activated_rules( + EmailCapability(), cast(CapabilityContract, _BadContract()), {} + ) + + +def test_engine_requires_extra_grammars_attribute() -> None: + """A contract without extra_grammars fails fast with ContractError (ADR-0007).""" + from dataclasses import dataclass + from typing import cast + + from paxman.core.capability_contract import CapabilityContract + from paxman.core.errors import ContractError + from paxman.engine.orchestrator import _extra_grammars_of + + @dataclass(frozen=True) + class _BadContract: + capability_name: str = "email" + # NOTE: no extra_grammars attribute at all + + with pytest.raises(ContractError): + _extra_grammars_of(cast(CapabilityContract, _BadContract())) + + # A proper CapabilityContract resolves to its default empty tuple. + from paxman.capabilities.Email.contract import EmailContract + + assert _extra_grammars_of(EmailContract()) == () + + +def test_contract_factory_docstring_mentions_ten() -> None: + """ContractFactory docstring must say ten, not five.""" + from paxman.core.capability import ContractFactory + + assert ContractFactory.__doc__ is not None + assert "five" not in ContractFactory.__doc__.lower(), "stale 'five' must be fixed" + assert "ten" in ContractFactory.__doc__.lower(), "docstring must mention ten" diff --git a/tests/unit/test_currency_data_regeneration.py b/tests/unit/test_currency_data_regeneration.py new file mode 100644 index 00000000..0f5fb495 --- /dev/null +++ b/tests/unit/test_currency_data_regeneration.py @@ -0,0 +1,21 @@ +"""Drift guard for Currency/Money generated tables (Item 6, M8).""" + +from __future__ import annotations + +import subprocess + +import pytest + +pytestmark = pytest.mark.unit + + +def test_currency_data_not_drifted() -> None: + proc = subprocess.run( + ["uv", "run", "python", "tools/regenerate_currency_data.py", "--check"], + capture_output=True, + text=True, + ) + assert proc.returncode == 0, ( + f"DRIFT: {proc.stderr or proc.stdout}\n" + "Run: uv run python tools/regenerate_currency_data.py" + ) diff --git a/tools/new_capability.py b/tools/new_capability.py index dfe34f21..390b0814 100644 --- a/tools/new_capability.py +++ b/tools/new_capability.py @@ -424,7 +424,7 @@ def _escape_for_double_quoted(value: str) -> str: _PACKAGE_NAME_RE = re.compile(r"^[A-Z][A-Za-z0-9]+$") _REGISTRY_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$") _IMPORT_RE = re.compile( - r"^from paxman\.capabilities\.(\w+)\.capability import (\w+)Capability as (\w+)$" + r"^\s*from paxman\.capabilities\.(\w+)\.capability import (\w+)Capability as (\w+)$" ) @@ -466,51 +466,112 @@ def _wire_capabilities_init(init_path: Path, package: str) -> None: and the ``__all__`` assignment via regex, then inserting only the new entries. The previous implementation rebuilt the whole file from a hard-coded template, which would drop future header comments. + + Supports both eager (PEP 562 pre-Item 8) and lazy (PEP 562 __getattr__) + layouts. In lazy mode also wires ``_LAZY`` and the ``TYPE_CHECKING`` block. """ text = init_path.read_text(encoding="utf-8") - # --- Insert import in sorted position --- - import_lines = text.splitlines() - last_cap_import_idx = -1 - for idx, line in enumerate(import_lines): - if _IMPORT_RE.match(line): - last_cap_import_idx = idx - if last_cap_import_idx == -1: - _fail("could not locate capability imports in paxman/capabilities/__init__.py") - - # Build sorted import block (existing + new), then replace only the - # contiguous import block. - imports: list[tuple[str, str]] = [] - for line in text.splitlines(): - match = _IMPORT_RE.match(line) - if match: - imports.append((match.group(3), line)) - imports.append( - ( - package, - f"from paxman.capabilities.{package}.capability " - f"import {package}Capability as {package}", + is_lazy = "_LAZY" in text and "if TYPE_CHECKING:" in text + + if is_lazy: + # --- Lazy layout: wire _LAZY dict --- + lazy_match = re.search(r"_LAZY:\s*dict\[.*?\] = \{(.*?)\}", text, re.DOTALL) + if lazy_match is None: + _fail("could not locate _LAZY in paxman/capabilities/__init__.py") + lazy_body = lazy_match.group(1) + # collect existing keys: "Name": ("module", "Attr"), + existing_lazy: dict[str, str] = {} + for lm in re.finditer(r'"(\w+)":\s*\("([^"]+)",\s*"([^"]+)"\)', lazy_body): + existing_lazy[lm.group(1)] = ( + f' "{lm.group(1)}": ("{lm.group(2)}", "{lm.group(3)}"),' + ) + # add new entry + new_lazy_line = ( + f' "{package}": ("paxman.capabilities.{package}.capability", ' + f'"{package}Capability"),' + ) + existing_lazy[package] = new_lazy_line + sorted_lazy_lines = [existing_lazy[k] for k in sorted(existing_lazy)] + text = ( + text[: lazy_match.start(1)] + + "\n" + + "\n".join(sorted_lazy_lines) + + "\n" + + text[lazy_match.end(1) :] ) - ) - imports.sort(key=lambda item: item[0]) - import_block = "\n".join(line for _, line in imports) - # Locate the contiguous block of capability imports to replace. - first_cap_idx = next( - idx for idx, line in enumerate(import_lines) if _IMPORT_RE.match(line) - ) - # Find the end of the contiguous block (first non-import after first). - block_end = last_cap_import_idx + 1 - # Preserve everything before the block and after it. - new_lines = ( - import_lines[:first_cap_idx] - + import_block.splitlines() - + import_lines[block_end:] - ) - text = "\n".join(new_lines) + "\n" + # --- Wire TYPE_CHECKING block --- + tc_match = re.search( + r"if TYPE_CHECKING:\n((?:[ \t]+from paxman\.capabilities\.\w+\.capability " # noqa: E501 + r"import \w+Capability as \w+\n?)+)", + text, + ) + if tc_match is None: + _fail( + "could not locate TYPE_CHECKING imports in " # noqa: E501 + "paxman/capabilities/__init__.py" + ) + tc_body = tc_match.group(1) + tc_imports: dict[str, str] = {} + for line in tc_body.splitlines(): + m = _IMPORT_RE.match(line) + if m: + alias = m.group(3) + tc_imports[alias] = line + new_tc_line = ( # noqa: E501 + f" from paxman.capabilities.{package}.capability import " + f"{package}Capability as {package}" + ) + tc_imports[package] = new_tc_line + sorted_tc = [tc_imports[k] for k in sorted(tc_imports)] + new_tc_block = "\n".join(sorted_tc) + "\n" + text = text[: tc_match.start(1)] + new_tc_block + text[tc_match.end(1) :] + else: + # --- Eager layout (legacy) --- + import_lines = text.splitlines() + last_cap_import_idx = -1 + for idx, line in enumerate(import_lines): + if _IMPORT_RE.match(line): + last_cap_import_idx = idx + if last_cap_import_idx == -1: + _fail( # noqa: E501 + "could not locate capability imports in paxman/capabilities/__init__.py" + ) + + # Build sorted import block (existing + new), then replace only the + # contiguous import block. + imports: list[tuple[str, str]] = [] + for line in text.splitlines(): + match = _IMPORT_RE.match(line) + if match: + imports.append((match.group(3), line)) + imports.append( + ( + package, + f"from paxman.capabilities.{package}.capability " + f"import {package}Capability as {package}", + ) + ) + imports.sort(key=lambda item: item[0]) + import_block = "\n".join(line for _, line in imports) + + # Locate the contiguous block of capability imports to replace. + first_cap_idx = next( + idx for idx, line in enumerate(import_lines) if _IMPORT_RE.match(line) + ) + # Find the end of the contiguous block (first non-import after first). + block_end = last_cap_import_idx + 1 + # Preserve everything before the block and after it. + new_lines = ( + import_lines[:first_cap_idx] + + import_block.splitlines() + + import_lines[block_end:] + ) + text = "\n".join(new_lines) + "\n" - # --- Insert __all__ entry in sorted position --- + # --- Insert __all__ entry in sorted position (shared) --- all_match = re.search(r"__all__\s*=\s*\[(.*?)\]", text, re.DOTALL) if all_match is None: _fail("could not locate __all__ in paxman/capabilities/__init__.py") diff --git a/tools/regenerate_currency_data.py b/tools/regenerate_currency_data.py new file mode 100644 index 00000000..c8243104 --- /dev/null +++ b/tools/regenerate_currency_data.py @@ -0,0 +1,445 @@ +"""Regenerate Currency + Money data modules from shared snapshot. + +Source: paxman/shared_data/currency_snapshot.json (CLDR v47 + ISO 4217). +Outputs (8 files, deterministic, longest-first ordering for tokens): +- paxman/capabilities/Currency/grammar/data/currency_symbols.py +- paxman/capabilities/Currency/grammar/data/currency_words.py +- paxman/capabilities/Currency/rules/data/cldr_currencies.py +- paxman/capabilities/Currency/rules/data/iso4217_list_one.py +- paxman/capabilities/Money/grammar/data/currency_symbols.py +- paxman/capabilities/Money/grammar/data/currency_words.py +- paxman/capabilities/Money/rules/data/cldr_currencies.py +- paxman/capabilities/Money/rules/data/iso4217_list_one.py + +Regeneration preserves import-linter isolation: each capability gets its own +local table; sibling imports remain banned (M8). + +Usage: + uv run python tools/regenerate_currency_data.py + uv run python tools/regenerate_currency_data.py --check +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any, cast + +ROOT = Path(__file__).resolve().parents[1] +SNAPSHOT = ROOT / "paxman" / "shared_data" / "currency_snapshot.json" + +_GENERATED_HEADER = ( + "GENERATED by tools/regenerate_currency_data.py — do not edit by hand." +) + +OUTPUTS = [ + ROOT + / "paxman" + / "capabilities" + / "Currency" + / "grammar" + / "data" + / "currency_symbols.py", + ROOT + / "paxman" + / "capabilities" + / "Currency" + / "grammar" + / "data" + / "currency_words.py", + ROOT + / "paxman" + / "capabilities" + / "Currency" + / "rules" + / "data" + / "cldr_currencies.py", + ROOT + / "paxman" + / "capabilities" + / "Currency" + / "rules" + / "data" + / "iso4217_list_one.py", + ROOT + / "paxman" + / "capabilities" + / "Money" + / "grammar" + / "data" + / "currency_symbols.py", + ROOT + / "paxman" + / "capabilities" + / "Money" + / "grammar" + / "data" + / "currency_words.py", + ROOT + / "paxman" + / "capabilities" + / "Money" + / "rules" + / "data" + / "cldr_currencies.py", + ROOT + / "paxman" + / "capabilities" + / "Money" + / "rules" + / "data" + / "iso4217_list_one.py", +] + + +def _emit_module(docstring: str, assignment: str) -> str: + return ( + '"""' + + _GENERATED_HEADER + + "\n\n" + + docstring + + '"""\n\nfrom __future__ import annotations\n\n' + + assignment + + "\n" + ) + + +def _load_snapshot() -> dict[str, Any]: + return cast(dict[str, Any], json.loads(SNAPSHOT.read_text(encoding="utf-8"))) + + +def _as_dict(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return cast(dict[str, Any], value) + return {} + + +def _as_list(value: Any) -> list[Any]: + if isinstance(value, list): + return cast(list[Any], value) + return [] + + +def _str_list(value: Any) -> list[str]: + if isinstance(value, list): + return [str(item) for item in cast(list[Any], value)] + return [] + + +def _is_qualified(token: str) -> bool: + return any(ch.isascii() and ch.isalpha() for ch in token) + + +def _symbol_tokens_sorted(symbols: list[str]) -> tuple[str, ...]: + def rank(s: str) -> tuple[int, int, str]: + qualified = 0 if _is_qualified(s) else 1 + return (qualified, -len(s), s) + + return tuple(sorted(symbols, key=rank)) + + +def _word_tokens_sorted(words: list[str]) -> tuple[str, ...]: + return tuple(sorted(words, key=lambda w: (-len(w), w))) + + +def _emit_currency_symbols(snapshot: dict[str, Any], *, for_money: bool) -> str: + # Source: symbol_to_codes keys; qualified-first longest-first. + raw_symbols = _as_dict(snapshot.get("symbol_to_codes")) + symbols: list[str] = list(raw_symbols.keys()) + # Fallback to cldr_currencies.symbol_to_codes if top-level missing. + if not symbols: + cldr = _as_dict(snapshot.get("cldr_currencies")) + inner = _as_dict(cldr.get("symbol_to_codes")) + symbols = list(inner.keys()) + tokens = _symbol_tokens_sorted(symbols) + items = "".join(f' "{t}",\n' for t in tokens) + assignment = "SYMBOL_TOKENS: tuple[str, ...] = (\n" + items + ")" + if for_money: + docstring = ( + "Currency symbol recognition keys (grammar data).\n" + "\n" + "Source: keys of SYMBOL_TO_CODES in " + "paxman/capabilities/Money/rules/data/\n" + "cldr_currencies.py (Unicode CLDR v47, en + es).\n" + "\n" + "Qualified symbols (containing an ASCII letter, e.g. US$, CA$, RM) are\n" + "ordered before bare symbols ($, \\u00a5, \\u20ac), longest first within\n" + 'each class, so the grammar alternates "US$" before "$".\n' + ) + else: + docstring = ( + "Currency symbol recognition keys (grammar data).\n" + "\n" + "Source: keys of SYMBOL_TO_CODES in " + "paxman/capabilities/Currency/rules/data/\n" + "cldr_currencies.py (Unicode CLDR v47, en + es).\n" + "\n" + "Qualified symbols (containing an ASCII letter, e.g. US$, CA$, RM) are\n" + "ordered before bare symbols ($, \\u00a5, \\u20ac), longest first within\n" + 'each class, so the grammar alternates "US$" before "$".\n' + ) + return _emit_module(docstring, assignment) + + +def _emit_currency_words(snapshot: dict[str, Any], *, for_money: bool) -> str: + cldr = _as_dict(snapshot.get("cldr_currencies")) + if for_money: + name_map = _as_dict(cldr.get("name_to_codes_money")) + if not name_map: + name_map = _as_dict(cldr.get("name_to_codes_currency")) + keys: list[str] = list(name_map.keys()) + tokens = _word_tokens_sorted(keys) + docstring = ( + "English currency word recognition keys (grammar data).\n" + "\n" + "Source: keys of NAME_TO_CODES in " + "paxman/capabilities/Money/rules/data/\n" + "cldr_currencies.py (Unicode CLDR v47 English display names).\n" + "\n" + "Ordered longest-first so the grammar alternates multi-word names\n" + "before their shorter tails when present.\n" + ) + else: + name_map = _as_dict(cldr.get("name_to_codes_currency")) + keys: list[str] = list(name_map.keys()) + tokens = _word_tokens_sorted(keys) + docstring = ( + "English currency word recognition keys (grammar data).\n" + "\n" + "Source: keys of NAME_TO_CODES in " + "paxman/capabilities/Currency/rules/data/\n" + "cldr_currencies.py (Unicode CLDR v47 English display names).\n" + "\n" + "Keys are lowercase (D4): the word grammar folds input to lowercase, so\n" + "the recognition keys match the rule-data lookup keys exactly.\n" + "\n" + "Ordered longest-first so the grammar alternates multi-word names\n" + "before their shorter tails when present.\n" + ) + items = "".join(f' "{t}",\n' for t in tokens) + assignment = "WORD_TOKENS: tuple[str, ...] = (\n" + items + ")" + return _emit_module(docstring, assignment) + + +def _format_symbol_to_codes(symbol_to_codes: dict[str, list[str]]) -> str: + # Deterministic sorted keys; multiline only for the large "$" entry (>10 codes) + lines: list[str] = [] + lines.append("SYMBOL_TO_CODES: dict[str, tuple[str, ...]] = {") + for sym in sorted(symbol_to_codes.keys()): + codes = tuple(symbol_to_codes[sym]) + if len(codes) > 10: + # multiline for large entry (only "$" currently) + lines.append(f' "{sym}": (') + for code in codes: + lines.append(f' "{code}",') + lines.append(" ),") + else: + inner = ", ".join(f'"{c}"' for c in codes) + # single-value tuples need trailing comma inside + if len(codes) == 1: + inner += "," + lines.append(f' "{sym}": ({inner}),') + lines.append("}") + return "\n".join(lines) + + +def _format_name_to_codes_sorted(name_to_codes: dict[str, list[str]]) -> str: + lines: list[str] = [] + lines.append("NAME_TO_CODES: dict[str, tuple[str, ...]] = {") + for name in sorted(name_to_codes.keys()): + codes = tuple(name_to_codes[name]) + inner = ", ".join(f'"{c}"' for c in codes) + if len(codes) == 1: + inner += "," + lines.append(f' "{name}": ({inner}),') + lines.append("}") + return "\n".join(lines) + + +def _emit_cldr_currencies(snapshot: dict[str, Any], *, for_money: bool) -> str: + raw_symbol = _as_dict(snapshot.get("symbol_to_codes")) + symbol_to_codes: dict[str, list[str]] = {} + if raw_symbol: + symbol_to_codes = { + str(k): _str_list(v) for k, v in raw_symbol.items() if isinstance(v, list) + } + if not symbol_to_codes: + cldr_inner = _as_dict( + _as_dict(snapshot.get("cldr_currencies")).get("symbol_to_codes") + ) + if cldr_inner: + symbol_to_codes = { + str(k): _str_list(v) + for k, v in cldr_inner.items() + if isinstance(v, list) + } + cldr_any = _as_dict(snapshot.get("cldr_currencies")) + if for_money: + name_raw = _as_dict(cldr_any.get("name_to_codes_money")) + name_to_codes: dict[str, list[str]] = ( + {str(k): _str_list(v) for k, v in name_raw.items() if isinstance(v, list)} + if name_raw + else {} + ) + docstring = ( + "Unicode CLDR currency symbol and display-name lookup tables.\n" + "\n" + "Source: Unicode CLDR v47 (https://cldr.unicode.org/), English + Spanish\n" + "currency data (symbols/symbol-alt-narrow and display names).\n" + "\n" + "SYMBOL_TO_CODES maps each symbol form to the sorted tuple of ISO 4217\n" + 'codes whose CLDR data uses it ("$" -> the 29 dollar-family codes,\n' + '"US$" -> ("USD",), "\\u00a5" -> ("CNY", "JPY")). Symbols that equal\n' + "the code itself, contain whitespace, or are 3-letter uppercase code\n" + "lookalikes are omitted (CLDR code fallbacks).\n" + "NAME_TO_CODES maps curated English currency words to their canonical\n" + 'ISO 4217 code ("Dollar" -> ("USD",), "Euro" -> ("EUR",),\n' + '"Ringgit" -> ("MYR",)).\n' + ) + else: + name_raw = _as_dict(cldr_any.get("name_to_codes_currency")) + name_to_codes = ( + {str(k): _str_list(v) for k, v in name_raw.items() if isinstance(v, list)} + if name_raw + else {} + ) + docstring = ( + "Unicode CLDR currency symbol and display-name lookup tables.\n" + "\n" + "Source: Unicode CLDR v47 (https://cldr.unicode.org/), English + Spanish\n" + "currency data (symbols/symbol-alt-narrow and display names).\n" + "\n" + "SYMBOL_TO_CODES maps each symbol form to the sorted tuple of ISO 4217\n" + 'codes whose CLDR data uses it ("$" -> the 29 dollar-family codes,\n' + '"US$" -> ("USD",), "\\u00a5" -> ("CNY", "JPY")). Symbols that equal\n' + "the code itself, contain whitespace, are 3-letter uppercase code\n" + 'lookalikes, or equal the generic currency sign "\\u00a4" (U+00A4) are\n' + "omitted. The generic-sign exclusion is explicit here because XXX is in\n" + "the full code set; Money excludes XXX entirely, so its derivation never\n" + "encountered the sign.\n" + "NAME_TO_CODES maps curated English currency words to their canonical\n" + 'ISO 4217 code ("dollar" -> ("USD",), "euro" -> ("EUR",),\n' + '"ringgit" -> ("MYR",)).\n' + "\n" + "Money divergence (D4): Money's NAME_TO_CODES keys are Title-Case\n" + '("Dollar"). Currency\'s word grammar folds input to lowercase, so the\n' + "keys here are lowercase — a deliberate, documented divergence.\n" + ) + symbol_part = _format_symbol_to_codes(symbol_to_codes) + name_part = _format_name_to_codes_sorted(name_to_codes) + assignment = symbol_part + "\n\n" + name_part + return _emit_module(docstring, assignment) + + +def _format_currency_codes(codes: list[str]) -> str: + # Sorted already; emit as frozenset with multiline set literal + sorted_codes = sorted(codes) + items = "".join(f' "{c}",\n' for c in sorted_codes) + return "CURRENCY_CODES: frozenset[str] = frozenset(\n {\n" + items + " }\n)" + + +def _format_minor_units(minor_units: dict[str, int]) -> str: + lines: list[str] = [] + lines.append("MINOR_UNITS: dict[str, int] = {") + for code in sorted(minor_units.keys()): + lines.append(f' "{code}": {minor_units[code]},') + lines.append("}") + return "\n".join(lines) + + +def _emit_iso4217(snapshot: dict[str, Any], *, for_money: bool) -> str: + iso = _as_dict(snapshot.get("iso4217")) + if for_money: + codes = [str(c) for c in _as_list(iso.get("currency_codes_money"))] + minor_raw = _as_dict(iso.get("minor_units")) + minor_units: dict[str, int] = {str(k): int(v) for k, v in minor_raw.items()} + codes_part = _format_currency_codes(codes) + minor_part = _format_minor_units(minor_units) + assignment = codes_part + "\n\n" + minor_part + docstring = ( + "ISO 4217 List One snapshot data: currency codes and minor units.\n" + "\n" + "Source: ISO 4217 List One " + "(https://www.six-group.com/en/products-services/\n" + " financial-services/data-standards.html), snapshot published " + "2026-01-01.\n" + "\n" + "CURRENCY_CODES holds the 165 codes with a numeric minor-unit exponent;\n" + 'the 13 codes whose CcyMnrUnts is "N.A." (XAG XAU XBA XBB XBC XBD XDR\n' + "XPD XPT XSU XTS XUA XXX) are excluded: they have no usable minor units.\n" + "MINOR_UNITS maps each code to its exponent (2 for most, 0 for\n" + "JPY/KRW/..., 3 for BHD/..., 4 for CLF/UYW).\n" + ) + else: + codes = [str(c) for c in _as_list(iso.get("currency_codes_full"))] + assignment = _format_currency_codes(codes) + docstring = ( + "ISO 4217 List One snapshot data: currency codes.\n" + "\n" + "Source: ISO 4217 Maintenance Agency (SIX Financial Information AG)\n" + " List One (https://www.six-group.com/en/products-services/\n" + " financial-services/data-standards.html), published 2026-01-01,\n" + " as amended by the amendment series through #180.\n" + "\n" + "CURRENCY_CODES holds the full List One set of 178 codes, including the\n" + '13 codes whose CcyMnrUnts is "N.A." (XAG XAU XBA XBB XBC XBD XDR XPD XPT\n' + "XSU XTS XUA XXX).\n" + "\n" + "Money divergence (D2): the Money capability ships only the 165 codes\n" + "with a numeric minor-unit exponent — it excludes these 13 because\n" + "amounts need usable minor units. Currency canonicalizes identifiers\n" + 'alone, so a bare "XAU" resolving to "XAU" is correct, and the full set\n' + "is shipped here.\n" + ) + return _emit_module(docstring, assignment) + + +def _modules() -> list[tuple[Path, str]]: + snap = _load_snapshot() + return [ + (OUTPUTS[0], _emit_currency_symbols(snap, for_money=False)), + (OUTPUTS[1], _emit_currency_words(snap, for_money=False)), + (OUTPUTS[2], _emit_cldr_currencies(snap, for_money=False)), + (OUTPUTS[3], _emit_iso4217(snap, for_money=False)), + (OUTPUTS[4], _emit_currency_symbols(snap, for_money=True)), + (OUTPUTS[5], _emit_currency_words(snap, for_money=True)), + (OUTPUTS[6], _emit_cldr_currencies(snap, for_money=True)), + (OUTPUTS[7], _emit_iso4217(snap, for_money=True)), + ] + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Regenerate Currency/Money data modules." + ) + parser.add_argument( + "--check", + action="store_true", + help="regenerate in memory and exit non-zero if any module drifts", + ) + args = parser.parse_args() + modules = _modules() + if args.check: + drifted = [ + path + for path, text in modules + if not path.exists() or path.read_text(encoding="utf-8") != text + ] + if drifted: + for path in drifted: + print( + f"DRIFT: {path.relative_to(ROOT)} differs from generated output", + file=sys.stderr, + ) + raise SystemExit(1) + print("all Currency/Money generated data modules are up to date") + return + for path, text in modules: + path.write_text(text, encoding="utf-8") + print(f"wrote {path.relative_to(ROOT)}") + + +if __name__ == "__main__": + main()