Paxman is a canonicalization authority resolver. It takes ambiguous human input and returns what authoritative specifications say that input means, with full provenance.
For a deeper understanding of the system, see ARCHITECTURE.md.
pip install paxmanLatest development snapshot (unreleased dev branch, installable from GitHub instead of PyPI):
pip install --force-reinstall git+https://github.com/nexusnv/paxman-python.git@devor with uv:
uv add git+https://github.com/nexusnv/paxman-python.git@devimport paxman
from paxman.capabilities import Email
from paxman.core.domain import Resolution
paxman.register_all_shipped() # once, before first use
# Create a contract and canonicalize
contract = Email.create_contract()
result = paxman.canonicalize("Contact user@Example.com", contract)
# Check the result
if result.status == Resolution.SUCCESS:
print(result.canonicalized_value) # "user@example.com"To register only what you need, call paxman.register_capability(Email()) per capability.
Registration and threading: Registration — single (register_capability) or bootstrap (paxman.register_all_shipped()) — must complete from a single thread before the first canonicalize() call; the registry then freezes and reads are safe from any thread; registering later raises CapabilityError.
When you call paxman.canonicalize(), the system:
- Recognizes — grammars scan your input text and find patterns that match known formats
- Validates — rules check those patterns against authoritative specifications (like RFCs)
- Resolves — if exactly one canonical value emerges, it is returned with full provenance
If multiple specifications disagree on the canonical value, the status is AMBIGUOUS. If nothing is recognized, the status is MISSING. If something is recognized but no specification validates it, the status is INVALID.
MISSING vs INVALID:
MISSINGmeans no grammar found the pattern;INVALIDmeans a grammar found it but no rule accepted it.
Paxman ships with twenty built-in capabilities (20 in paxman/capabilities/__init__.py and paxman/api/bootstrap.py:_SHIPPED, alphabetical by registry name):
| Capability | Domain | Grammars | Rules | Description |
|---|---|---|---|---|
| BIC | Business identifier codes | 1 (bic) | 1 | ISO 9362:2022, ISO 3166-1 (country codes plus XK) |
| Coordinates | WGS 84 coordinates | 1 (coordinates) | 4 | ISO 6709:2022, RFC 5870, RFC 7946 |
| Country | Country codes/names | 4 (alpha2, alpha3, numeric, name) | 6 | ISO 3166, CLDR |
| Currency | Currency identifiers | 3 (code, symbol, word) | 3 | ISO 4217, CLDR |
| Date | Dates | 1 (date) | 3 | ISO 8601-1:2019 §5.2.1.1, derived conventions (US/European locale) |
| Element | Chemical elements | 1 (element) | 2 | IUPAC Red Book 2005, IUPAC Periodic Table 04 May 2022 |
| Email addresses | 3 (standard, obfuscated, localhost) | 2 | RFC 5322, RFC 6761 | |
| IBAN | Bank account numbers | 1 (iban) | 1 | ISO 13616, SWIFT Registry, MOD 97-10 |
| IP | IP addresses | 2 (ipv4, ipv6) | 2 | RFC 791, RFC 5952 |
| ISBN | ISBNs | 2 (isbn13, isbn10) | 4 | ISO 2108, ISBN Users' Manual, ISBN Range Message |
| ISSN | Serial identifiers | 1 (issn) | 1 | ISO 3297:2022 |
| Language | Language identifiers | 4 (bcp47_tag, language_code, language_name, language_description) | 11 | ISO 639, IANA Language Subtag Registry, BCP 47 RFC 5646, CLDR |
| MacAddress | MAC addresses | 1 (mac_address) | 1 | IEEE Std 802-2024 |
| Money | Money amounts | 3 (code, symbol, word) | 3 | ISO 4217, CLDR |
| ORCID | Researcher identifiers | 1 (orcid) | 2 | ISO 27729:2024, MOD 11-2 |
| Phone | Phone numbers | 4 (e164, tel_uri, international_00, national) | 5 | ITU-T E.164, RFC 3966, NANP |
| SI Unit | SI unit expressions | 3 (symbol, name, compound) | 7 | BIPM SI Brochure, ISO 80000-1 |
| Timezone | Timezone identifiers | 2 (timezone_name, timezone_abbreviation) | 4 | IANA Time Zone Database |
| URL | URLs | 1 (absolute_uri) | 1 | WHATWG URL Standard |
| UtcOffset | UTC offsets | 1 (utc_offset) | 1 | ISO 8601-1:2019, RFC 3339 |
Note: Table generated from
paxman/api/bootstrap.py:_SHIPPED(alphabetical by registry name). To regenerate, runuv run python tools/generate_readme_table.py.
Recognizes standard, obfuscated (user at domain dot com), and localhost email addresses.
from paxman.capabilities import Email
register_capability(Email())
# Standard email
contract = Email.create_contract()
result = paxman.canonicalize("user@Example.COM", contract)
# → "user@example.com"
# Enable obfuscated recognition
contract = Email.create_contract(include_obfuscated=True)
result = paxman.canonicalize("Contact user at example dot com", contract)
# → "user@example.com"
# Exclude localhost validation
contract = Email.create_contract(excluded_rules=["Section 6.3-localhost"])
result = paxman.canonicalize("admin@localhost", contract)Recognizes dates in ISO 8601 (YYYY-MM-DD), slash-ISO (YYYY/MM/DD), US (MM/DD/YYYY), and European (DD/MM/YYYY) formats.
from paxman.capabilities import Date
register_capability(Date())
# ISO format (unambiguous)
contract = Date.create_contract()
result = paxman.canonicalize("2026-01-15", contract)
# → "2026-01-15"
# Slash-ISO format (4-digit year first)
contract = Date.create_contract()
result = paxman.canonicalize("2026/01/15", contract)
# → "2026-01-15"
# US/European format (potentially ambiguous)
contract = Date.create_contract()
result = paxman.canonicalize("01/02/2026", contract)
# → Status: AMBIGUOUS (US: 2026-01-02, European: 2026-02-01)
# Pin to specific rules
contract = Date.create_contract(pinned_rules=["Section 4.3.1-calendar-date"])
result = paxman.canonicalize("2026-01-15", contract)Recognizes country representations as alpha-2, alpha-3, numeric codes, or country names.
from paxman.capabilities import Country
register_capability(Country())
# Alpha-2 code
contract = Country.create_contract()
result = paxman.canonicalize("US", contract)
# → "US"
# Country name
contract = Country.create_contract()
result = paxman.canonicalize("United States", contract)
# → "US"
# Enable localized names (CLDR multilingual)
contract = Country.create_contract(include_localized=True)
result = paxman.canonicalize("Alemania", contract)
# → "DE"
# Enable historical/deprecated names (ISO 3166-3)
contract = Country.create_contract(include_historical=True)
result = paxman.canonicalize("Burma", contract)
# → "BU"Localized names are validated by the CLDR rule, so an enabled resolution like Alemania → DE carries Unicode/CLDR provenance. Recognizing a localized token is not ISO validation: without include_localized, recognized localized input (e.g., Alemania) is INVALID — recognized, but no authority rule runs — rather than being resolved via ISO. Historical names resolve through ISO 3166-3 and return the historical entity's own former code (Burma → BU), not a successor state's code.
Recognizes ISO 4217 alpha-3 codes, CLDR currency symbols, and CLDR currency display-name words as standalone identifiers, canonicalizing to the uppercase alpha-3 code. Identifier-only: amounts are the Money capability's domain (USD 500 resolves via its USD span; amount-glued tokens like US$5 are not recognized at all).
Note: Bare symbols like
$areINVALIDby default.$is shared by 29 currencies whose CLDR symbol is$— without disambiguation Paxman cannot pick one. Opt in withdefault_currencyto resolve the bare symbol only when it is one of that symbol's own candidates.
from paxman.capabilities import Currency
register_capability(Currency())
# Lowercase code folds to uppercase alpha-3
contract = Currency.create_contract()
result = paxman.canonicalize("usd", contract)
# → "USD"
# Lowercase CLDR display-name word resolves to its code
contract = Currency.create_contract()
result = paxman.canonicalize("euro", contract)
# → "EUR"
# Shared bare symbol is unresolved by default: INVALID (recognized, no authority)
contract = Currency.create_contract()
result = paxman.canonicalize("$", contract)
# → Status: INVALID
# Opt in: default_currency resolves the shared bare symbol only when the
# code is one of that symbol's own candidates ("$" -> the 29 codes whose
# CLDR symbol is "$")
contract = Currency.create_contract(default_currency="USD")
result = paxman.canonicalize("$", contract)
# → "USD"
# A valid code that is NOT a candidate of "$" (MYR's symbol is RM) is INVALID
contract = Currency.create_contract(default_currency="MYR")
result = paxman.canonicalize("$", contract)
# → Status: INVALIDRecognizes International Bank Account Numbers with check-digit and country validation, canonicalizing to electronic compact form.
from paxman.capabilities import IBAN
register_capability(IBAN())
# Bare IBAN (compact)
contract = IBAN.create_contract()
result = paxman.canonicalize("GB82WEST12345698765432", contract)
# → "GB82WEST12345698765432"
# Paper IBAN with spaces
contract = IBAN.create_contract()
result = paxman.canonicalize("GB82 WEST 1234 5698 7654 32", contract)
# → "GB82WEST12345698765432"
# Paper presentation (spaced groups of four)
contract = IBAN.create_contract(output_format="paper")
result = paxman.canonicalize("GB82WEST12345698765432", contract)
# → "GB82 WEST 1234 5698 7654 32"
# Invalid country or bad MOD 97 check
contract = IBAN.create_contract()
result = paxman.canonicalize("ZZ82WEST12345698765432", contract)
# → Status: INVALIDRecognizes IPv4 and IPv6 addresses (including IPv6 mixed with embedded IPv4 per RFC 4291 §2.2 / RFC 5952 §5) with canonical normalization.
from paxman.capabilities import IP
register_capability(IP())
# IPv4 — leading zeros stripped
contract = IP.create_contract()
result = paxman.canonicalize("010.020.030.040", contract)
# → "10.20.30.40"
result = paxman.canonicalize("192.168.1.1", contract)
# → "192.168.1.1"
# IPv6 (canonical form per RFC 5952)
contract = IP.create_contract()
result = paxman.canonicalize("2001:0db8:0000:0000:0000:0000:0000:0001", contract)
# → "2001:db8::1"
# Mixed IPv6 with embedded IPv4 (RFC 4291 §2.2)
contract = IP.create_contract()
result = paxman.canonicalize("::ffff:192.0.2.1", contract)
# → AMBIGUOUS ['::ffff:192.0.2.1', '192.0.2.1'] — trailing IPv4 also emitted via \b (prefer the IPv6 value)
# Disable IPv6 recognition
contract = IP.create_contract(include_ipv6=False)
result = paxman.canonicalize("2001:db8::1", contract)
# → Status: MISSINGRecognizes ISBN-10 and ISBN-13 identifiers with check-digit validation, canonicalizing legacy ISBN-10 input to ISBN-13.
from paxman.capabilities import ISBN
register_capability(ISBN())
# Bare ISBN-13
contract = ISBN.create_contract()
result = paxman.canonicalize("9780306406157", contract)
# → "9780306406157"
# Legacy ISBN-10 converts to ISBN-13
contract = ISBN.create_contract()
result = paxman.canonicalize("0306406152", contract)
# → "9780306406157"
# Range Message hyphenation (presentation only)
contract = ISBN.create_contract(output_format="hyphenated")
result = paxman.canonicalize("9780110002224", contract)
# → "978-0-11-000222-4"
# Enable registrant-range provenance (ISBN Range Message authority)
contract = ISBN.create_contract(include_range_validation=True)
result = paxman.canonicalize("9780110002224", contract)
# → Status: SUCCESS (Section 4-registrant-range provenance)
# Disable ISBN-10 recognition
contract = ISBN.create_contract(include_isbn10=False)
result = paxman.canonicalize("0306406152", contract)
# → Status: MISSINGRecognizes ISO 4217 codes, CLDR currency symbols, and CLDR currency names adjacent to amounts, canonicalizing to CODE + amount padded to ISO 4217 minor units.
Note: Bare symbols like
$500areINVALIDby default. The dollar sign is shared by many currencies — without disambiguation Paxman cannot pick one. Opt in withdollar_sign_currency.
from paxman.capabilities import Money
register_capability(Money())
# Code + amount
contract = Money.create_contract()
result = paxman.canonicalize("USD500", contract)
# → "USD 500.00"
# Bare $ is unresolved by default: INVALID (recognized, no authority)
contract = Money.create_contract()
result = paxman.canonicalize("$500", contract)
# → Status: INVALID
# Opt in: bare $ resolves via dollar_sign_currency (only to one of the
# symbol's own CLDR candidates, like Currency's default_currency)
contract = Money.create_contract(dollar_sign_currency="USD")
result = paxman.canonicalize("$500", contract)
# → "USD 500.00"
# European comma-decimal: last separator is the decimal point
contract = Money.create_contract()
result = paxman.canonicalize("1.000,50 EUR", contract)
# → "EUR 1000.50"
# Compact rendering removes the code/amount separator space
contract = Money.create_contract(output_format="compact")
result = paxman.canonicalize("USD500", contract)
# → "USD500.00"Recognizes ORCID iDs (ISNI-compatible identifiers, ISO 27729:2024) with MOD 11-2 check-digit validation, canonicalizing to the hyphenated form.
import paxman
from paxman.capabilities import ORCID
paxman.register_capability(ORCID())
# Bare hyphenated iD
contract = ORCID.create_contract()
result = paxman.canonicalize("0000-0002-1825-0097", contract)
# → "0000-0002-1825-0097"
# URI input resolves to the same canonical value
result = paxman.canonicalize("https://orcid.org/0000-0002-1694-233X", contract)
# → "0000-0002-1694-233X"
# Render as the storage URI
contract = ORCID.create_contract(output_format="uri")
result = paxman.canonicalize("0000-0002-1825-0097", contract)
# → "https://orcid.org/0000-0002-1825-0097"
# Bad check digit is INVALID
result = paxman.canonicalize("0000-0002-1825-0098", ORCID.create_contract())
# → Status: INVALIDRecognizes international (E.164, 00-prefix), tel-URI, and NANP national phone numbers.
from paxman.capabilities import Phone
register_capability(Phone())
# International number
contract = Phone.create_contract()
result = paxman.canonicalize("+1 555 123 4567", contract)
# → "+15551234567"
# National number (requires default_country)
contract = Phone.create_contract(default_country="US")
result = paxman.canonicalize("(555) 234-5678", contract)
# → "+15552345678"
# Output as RFC 3966 tel-URI
contract = Phone.create_contract(output_format="rfc3966")
result = paxman.canonicalize("+15551234567", contract)
# → "tel:+15551234567"Recognizes absolute URIs and IRIs, canonicalizing them per the WHATWG URL Standard (lowercased scheme/host, default-port removal, dot-segment resolution, UTS #46 IDNA for internationalized hosts, byte-preserving percent-encoding).
from paxman.capabilities import URL
register_capability(URL())
# Absolute URI canonicalization (milestone)
contract = URL.create_contract()
result = paxman.canonicalize("HTTPS://Example.COM:443/path/../other", contract)
# → "https://example.com/other"
# Opaque (non-special) scheme — verbatim
contract = URL.create_contract()
result = paxman.canonicalize("mailto:user@example.com", contract)
# → "mailto:user@example.com"
# IDN host canonicalizes via UTS #46 (IDNA)
contract = URL.create_contract()
result = paxman.canonicalize("http://münchen.de", contract)
# → "http://xn--mnchen-3ya.de/"Recognizes SI unit expressions: symbols, names, and product/quotient compounds, canonicalizing to the canonical symbol form with BIPM SI Brochure (9th ed.) and ISO 80000-1 provenance. Identity-only: no quantities, no magnitudes, no name-compounds ("metre per second" does not resolve as a compound — its words are recognized separately, yielding AMBIGUOUS; "25°C" is MISSING).
from paxman.capabilities import SIUnit
register_capability(SIUnit())
# Unit name resolves to its canonical symbol
contract = SIUnit.create_contract()
result = paxman.canonicalize("Kilogram", contract)
# → "kg"
# Prefixed name resolves to the prefixed symbol
contract = SIUnit.create_contract()
result = paxman.canonicalize("megahertz", contract)
# → "MHz"
# Compound expression canonicalizes to the symbol form
contract = SIUnit.create_contract()
result = paxman.canonicalize("m/s²", contract)
# → "m/s2"
# Spoken word-prefix form, merged only when opted in
contract = SIUnit.create_contract(allow_split_word_prefixes=True)
result = paxman.canonicalize("kilo gram", contract)
# → "kg"
# Symbol-prefix spacing is always rejected (no flag)
contract = SIUnit.create_contract()
result = paxman.canonicalize("k g", contract)
# → Status: INVALID
# Multi-solidus preserved only when opted in
contract = SIUnit.create_contract(allow_multi_solidus=True)
result = paxman.canonicalize("kg/m/s", contract)
# → "kg/m/s"Every capability provides a create_contract() factory method with common and capability-specific parameters.
| Parameter | Type | Description |
|---|---|---|
excluded_rules |
Sequence[str] |
Rule names to exclude from validation |
pinned_rules |
Sequence[str] |
Pin to specific rules (overrides excluded_rules) |
year |
int |
Temporal filter — only rules with publication_year ≤ year run |
extra_grammars |
tuple[str, ...] |
Community grammar names to opt in (appended after shipped grammars) |
suppress_common_words |
bool |
Suppress common-word noise on scan/prose (e.g. to → Tonga), except when the whole input is the word (A0 whole-input exemption, #122); default False (ADR-0009 §16) |
| Capability | Parameter | Type | Description |
|---|---|---|---|
include_obfuscated |
bool |
Enable "user at domain dot com" recognition | |
include_localhost |
bool |
Enable localhost email recognition (default: True) |
|
| Date | output_format |
str |
Output format (e.g., "ISO", "US") |
| Date | two_digit_base_year |
int |
Base year for 2-digit years (e.g., 2000 → "26" = 2026) |
| Country | include_localized |
bool |
Enable CLDR multilingual name recognition |
| Country | include_historical |
bool |
Enable deprecated/historical country name recognition |
| Country | output_format |
str |
Output format ("alpha2" default, "alpha3", "numeric", "name") |
| Currency | default_currency |
str | None |
ISO 4217 alpha-3 code resolving a shared bare symbol (e.g. "$"), valid only when it is one of that symbol's own candidate codes; None (default) or a non-candidate code makes them INVALID |
| IP | include_ipv6 |
bool |
Enable IPv6 recognition (default: True) |
| ISBN | include_isbn10 |
bool |
Enable ISBN-10 recognition (default: True) |
| ISBN | include_range_validation |
bool |
Enable ISBN Range Message registrant-range provenance |
| ISBN | output_format |
str |
Output format ("isbn13" default, "hyphenated") |
| Money | precision |
str |
Over-precision amount handling: "strict" (reject), "truncate", "round" (default: "strict") |
| Money | dollar_sign_currency |
str | None |
ISO 4217 alpha-3 code resolving bare/shared symbols (opt-in), valid only when it is one of that symbol's own candidate codes; None (default) or a non-candidate code makes them INVALID |
| Money | output_format |
str |
Output format ("code_amount" default, "compact") |
| Phone | default_country |
str |
ISO 3166-1 alpha-2 country code to resolve national numbers (e.g., "US") |
| Phone | output_format |
str |
Output format ("e164" default, "rfc3966", "split") |
| SIUnit | allow_split_word_prefixes |
bool |
Merge a word prefix split from its unit by whitespace (e.g. "kilo gram" → "kg") when True; default False rejects the spoken form (→ INVALID) |
| SIUnit | allow_multi_solidus |
bool |
Preserve the legacy accept-multi-solidus behavior (e.g. "kg/m/s") when True; default False rejects more than one top-level solidus (→ INVALID) per ISO 80000-1 §6.6.2 |
| Timezone | include_systemv |
bool |
Enable SystemV zone validation (EST5EDT and kin; recognized but INVALID when off) |
| UtcOffset | output_format |
str |
Output format ("extended" default, "basic") |
from paxman.capabilities import Email
# Pin to specific rules — only these run
contract = Email.create_contract(pinned_rules=["Section 3.4.1-addr-spec"])
result = paxman.canonicalize("user@example.com", contract)
# Pin + year filter — both apply
contract = Email.create_contract(
pinned_rules=["Section 3.4.1-addr-spec", "Section 6.3-localhost"], year=2010
)
# Exclude specific rules
contract = Email.create_contract(excluded_rules=["Section 6.3-localhost"])
result = paxman.canonicalize("admin@localhost", contract)from paxman.capabilities import Date
# Only rules published on or before 2019
contract = Date.create_contract(year=2019)
result = paxman.canonicalize("2026-01-15", contract)Paxman ships with twenty built-in capabilities, but a capability is closed for modification yet open for extension: you can add recognition and validation without touching the library. Register a Grammar subclass and the Rule subclass that validates it, then opt a contract into them by naming the grammar in extra_grammars:
import re
from datetime import datetime
import paxman
from paxman.capabilities import Date
from paxman.capabilities.Date.notation import DateNotation
from paxman.core.contract import Contract
from paxman.core.discovery import register_capability
from paxman.core.domain import (
Grammar,
Provenance,
RecognitionMatch,
Rule,
RuleStrategy,
)
register_capability(Date())
class DotDateGrammar(Grammar[DateNotation]):
"""Recognize YYYY.MM.DD dates with dot separators."""
name = "dot_date_recognition"
semantics = "dot_date_recognition"
_PATTERN = re.compile(r"\b(\d{4})\.(\d{2})\.(\d{2})\b")
def recognize(self, text: str) -> list[RecognitionMatch[DateNotation]]:
"""Return span-bearing matches for dot-separated dates."""
return [
RecognitionMatch(
notation=DateNotation(N1=m.group(1), N2=m.group(2), N3=m.group(3)),
start=m.start(),
end=m.end(),
raw_text=m.group(0),
)
for m in self._PATTERN.finditer(text)
]
class DotDateRule(Rule[DateNotation]):
"""Validate dot-separated dates, normalizing to ISO YYYY-MM-DD."""
name = "dot_date_rule"
strategy = RuleStrategy.PARSER
provenance = Provenance(
authority="ISO",
specification_name="ISO 8601",
kind="specification",
reference_url="https://www.iso.org/standard/70907.html",
version="2019",
lifecycle="active",
publication_year=2019,
)
citation = "Section 4.3.1 (calendar date)"
target_semantics = frozenset({"dot_date_recognition"})
requires_features = frozenset()
def matches(self, notation: DateNotation, contract: Contract) -> bool:
"""Accept valid calendar dates."""
try:
datetime(int(notation.N1), int(notation.N2), int(notation.N3))
return True
except ValueError:
return False
def normalize(self, notation: DateNotation, contract: Contract) -> str:
"""Normalize YYYY.MM.DD to ISO YYYY-MM-DD."""
return f"{int(notation.N1):04d}-{int(notation.N2):02d}-{int(notation.N3):02d}"
paxman.register_grammar("date", DotDateGrammar)
paxman.register_rule("date", DotDateRule)
contract = Date.create_contract(extra_grammars=("dot_date_recognition",))
result = paxman.canonicalize("2024.01.01", contract)
print(result.canonicalized_value) # → "2024-01-01"Rules of the seam:
- Register before the first
canonicalize()call — the extension registries freeze with the capability registry. - Opt-in only — a registered grammar runs only when named in
extra_grammars(available on everycreate_contractfactory), and a registered rule runs only when the contract'sextra_grammarsresolve to one of itstarget_semanticsids; un-named grammars and un-opted rules never affect results. - Unknown
extra_grammarsnames are silently skipped for grammar activation, so a contract naming an uninstalled grammar still runs identically (deterministically); the unknown name is kept as-is as the semantics key for rule activation. A name that is not a grammar name but matches a known semantics id therefore activates that semantics's rules without opting in a grammar — those rules fire only on recognitions carrying that semantics (fail-fastContractErrorapplies only to ids no grammar claims). - Names must be unique in the composed set — a community grammar colliding with a shipped name fails fast with
CapabilityError. - Community rules declare
target_semanticsand activate only when the contract'sextra_grammarsresolve to one of those ids; a rule opted in via an id that no grammar claims fails fast withContractError, while a rule that is not opted in stays inert regardless of any dangling targets.
| Status | Meaning |
|---|---|
MISSING |
No patterns recognized in the input |
INVALID |
Recognized, but no specification validates it |
SUCCESS |
Single canonical value resolved |
AMBIGUOUS |
Multiple specifications disagree on the canonical value |
MISSING vs INVALID: If the grammar cannot find the pattern, the result is
MISSING. If a grammar finds it but no rule accepts it, the result isINVALID. Example:bad@.comisMISSING(no grammar matches), while999.999.999.999as IP isINVALID(IPv4 grammar matched, RFC 791 rejected).
Paxman extracts and canonicalizes entities embedded in messy human text, preserving provenance and span for each mention:
import paxman
from paxman.capabilities import Country, Email, Money, URL
from paxman.core.domain import Resolution
paxman.register_all_shipped()
text = "Invoice of 1.000,50 EUR due 01/02/2026 — contact billing@example.com. See https://Example.COM:443/path/../other"
for Cap in (Money, Email, URL, Country):
contract = Cap.create_contract()
result = paxman.canonicalize(text, contract)
if result.status == Resolution.SUCCESS:
print(f"{Cap.name}: {result.canonicalized_value!r} at {result.span}")
for prov in result.candidates[0].provenance:
print(
f" via {prov.authority}: {prov.specification_name} ({prov.citation})"
)
# Money: 'EUR 1000.50' at (11, 23) via ISO: ISO 4217
# Email: 'billing@example.com' at (49, 68) via IETF: RFC 5322
# URL: 'https://example.com/other' at (74, 111) via WHATWG: URL StandardFor inputs with multiple mentions of the same capability, split the text first — see docs/recipes/segmentation.md. The CLI offers the same extraction without code: python -m paxman email "Contact billing@example.com".
For scan on prose, short-code noise (to→Tonga) can dominate: use the off-by-default
suppress_common_words gate (ADR-0009 §16) — Country.create_contract(suppress_common_words=True) /
paxman scan --suppress-common-words "Ship to the United States of America, total 45.50 USD, weight 3.5 kg" keeps the name mention while USD remains for currency. Bare canonicalize("to") stays SUCCESS "TO" with the flag off and with it on (A0 whole-input exemption, #122 — a suppressible hit covering the trimmed whole input is never suppressed), while scan() prose still drops embedded to. See docs/user/migration.md for the full 0.2.0 suppression note and the 0.4.0 exemption.
Every resolved value carries provenance — the authoritative specification that validates it:
from paxman.capabilities import Email
contract = Email.create_contract()
result = paxman.canonicalize("user@example.com", contract)
for candidate in result.candidates:
for prov in candidate.provenance:
print(f"{prov.authority}: {prov.specification_name}")
# "IETF: RFC 5322"Every Candidate and the top-level ExecutionResult expose the half-open
[start, end) character range the recognition occupied in the input, so callers
can locate exactly where a canonicalized entity sat:
from paxman.capabilities import Email
contract = Email.create_contract()
result = paxman.canonicalize("user@example.com", contract)
print(result.span) # (0, 16)
for candidate in result.candidates:
print(candidate.span) # (0, 16)
print(candidate.recognition_rule) # "standard_recognition"result.span is the source span of the single resolved value on SUCCESS; it
is None for MISSING, INVALID, or AMBIGUOUS, since those cases have no
single resolved entity. For AMBIGUOUS results, locate each mention via the
per-candidate Candidate.span.
Paxman raises typed exceptions for different failure modes:
from paxman.core.errors import (
CapabilityError, # Unknown capability or registry frozen
ContractError, # Malformed contract configuration
RecognitionError, # Grammar failed during recognition
ValidationError, # Rule failed during validation
)
try:
result = paxman.canonicalize("input", contract)
except CapabilityError as e:
print(f"Capability error: {e}")
except ContractError as e:
print(f"Contract error: {e}")
except RecognitionError as e:
print(f"Recognition failed in {e.rule}: {e}")
except ValidationError as e:
print(f"Validation failed in {e.rule}: {e}")Paxman resolves one mention per canonicalize() call; MultipleMentionsError occurs only when distinct recognized mentions in one slice resolve to different canonical values — identical canonical values still coalesce to SUCCESS. For the caller-owned split-then-canonicalize pattern, see docs/recipes/segmentation.md.
- ARCHITECTURE.md — architectural principles and design decisions
- HOW_TO_ADD_NEW_CAPABILITY.md — guide to adding new domain capabilities. To add a new capability, start from the
tools/new_capability.pyscaffolder (see HOW_TO_ADD_NEW_CAPABILITY.md, Step 0) which generates the full skeleton. - CONTRIBUTING.md — development setup and contribution guidelines