Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on:
branches: [main]
types: [opened, synchronize, reopened]
push:
branches: [main]
branches: [main, "feature/**", "refactor/**"]

concurrency:
group: ci-${{ github.ref }}
Expand Down
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,9 @@ In contrast to contracts, Capabilities are defined as Abstract Base Classes (`Ca

Capabilities do not hold state. They are factories that produce grammars and rules on demand. The engine queries a capability for its available grammars and rules, then filters them based on the contract configuration. This design keeps capabilities lightweight and makes the filtering logic centralized in the engine.

### Notation Bridging
### Typed Notation

Each capability defines a typed Notation (a data class with named fields) that provides domain-specific structure. However, the Rule and Grammar abstract interfaces operate on a generic list-of-strings representation. Capabilities bridge this gap by providing a conversion method from the typed notation to the generic form. This gives type safety at the capability level while maintaining a uniform interface for the engine.
Each capability defines a frozen dataclass Notation with one `str` field per recognized component (e.g., `DateNotation` carries `N1`, `N2`, `N3`). The concrete notation type is the sole type parameter threaded end to end: `Grammar[NotationT]` recognizes it and `Rule[NotationT]` validates it, so the engine and every rule operate on the fully typed object rather than a positional `list[str]`. Rules read notation fields by name (e.g., `notation.N1`); there is no generic list alias and no conversion bridge between the typed notation and a list form.

### Contract Parameters

Expand Down
6 changes: 0 additions & 6 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,6 @@ Capability-defined intermediate representation that Grammars must produce:
- **SIUnit:** `SIUnitNotation(text, shape)` — `shape` is `"symbol"` / `"name"` / `"compound"` / `"split_word_prefix"` / `"split_symbol_prefix"`; `text` is the unit expression as written (symbols keep exact casing, names are grammar-folded to lowercase, compounds keep the written form)
- **IP / Phone / URL:** capability-defined shapes for address / number / URI components

**Note:** Capabilities define Notation using frozen dataclasses for type safety and immutability. The `as_list()` method bridges the typed notation to the generic `list[str]` interface.

### Notation Type Example
```python
from dataclasses import dataclass
Expand All @@ -56,10 +54,6 @@ from dataclasses import dataclass
class EmailNotation:
local_part: str
domain_part: str

def as_list(self) -> list[str]:
"""Convert to list[str] for generic Rule interface."""
return [self.local_part, self.domain_part]
```

---
Expand Down
26 changes: 16 additions & 10 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,26 @@ Paxman requires Python 3.11 or later. To set up a local development environment:
```bash
# Clone the repository
git clone <repository-url>
cd paxman-alternative
cd paxman-python

# Create and activate a virtual environment using uv
uv venv
source .venv/bin/activate
# Install the package with all dev tooling (pytest, hypothesis, ruff,
# pyright, import-linter) into a managed virtual environment
uv sync --all-extras
```

# Install the package in editable mode with dev dependencies
uv pip install -e ".[dev]"
All dev dependencies are listed in `pyproject.toml` under `[dependency-groups] dev` and include pytest, hypothesis, ruff, pyright, and import-linter.

# Install the dependency-group dev tools (import-linter)
uv pip install -e ".[dev]" --group dev
```
---

## Before You Write Code

Before implementing a capability or changing recognition or validation logic, read the contributor guides in the repository root:

- [HOW_TO_ADD_NEW_CAPABILITY.md](HOW_TO_ADD_NEW_CAPABILITY.md) — end-to-end walkthrough of adding a self-contained capability (notation, contract, capability, grammar, rules, presentation).
- [HOW_TO_ADD_NEW_GRAMMAR.md](HOW_TO_ADD_NEW_GRAMMAR.md) — conventions for writing grammars that recognize input without validating it.
- [TESTING_STRATEGY.md](TESTING_STRATEGY.md) — the project's TDD and testing expectations, including purity scans and registry hygiene.

All dev dependencies are listed in `pyproject.toml` under `[project.optional-dependencies] dev` and include pytest, hypothesis, ruff, pyright, and import-linter.
These guides document the contribution patterns; the code under `paxman/` remains the source of truth.

---

Expand Down
42 changes: 18 additions & 24 deletions HOW_TO_ADD_NEW_CAPABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,13 @@ Create `paxman/capabilities/YourDomain/notation.py`:

1. Import `dataclass` from `dataclasses`
2. Define a frozen dataclass with `@dataclass(frozen=True)` and one field per component of your notation
3. Add an `as_list()` method that returns the fields as a plain list of strings (in order) — an optional helper for bridging to generic interfaces

The engine passes typed notation objects to rules, not lists. Rules access fields by name (e.g., `notation.field_name`). The `as_list()` method is a convenience helper, not an engine requirement.
The engine passes typed notation objects to rules, not lists. Rules access fields by name (e.g., `notation.field_name`).

**Rules for Notation:**

- Every field must be a `str` type
- The dataclass must be frozen (immutable)
- The `as_list()` method must return fields in a consistent, documented order
- Rules access notation fields by name (e.g., `notation.field_name`), not by list position

**Example patterns:**
Expand All @@ -128,7 +126,7 @@ class YourDomainNotation:
field_two: str
```

This is optional. Use it when your notation will be instantiated many times (e.g., processing bulk input). The `as_list()` method works the same way.
This is optional. Use it when your notation will be instantiated many times (e.g., processing bulk input).

---

Expand Down Expand Up @@ -362,12 +360,11 @@ Create `paxman/capabilities/YourDomain/capability.py`:
1. Import `Capability` from `paxman.core.capability`
2. Import your grammars and rules
3. Define a class that extends `Capability`
4. Set `name` to a lowercase identifier (e.g., "yourdomain") — this is the name users pass to the contract
5. Set `version` to a semantic version string (e.g., "1.0.0")
6. Implement `get_grammars()` — return a list of grammar instances
7. Implement `get_rules()` — return a list of rule instances
8. Define a `create_contract()` static method that returns a default contract
9. Implement `format_value(value, output_format, notation)` — the single presentation seam. The engine calls it immediately after `Rule.normalize()` and before candidate deduplication and status determination. Return the value unchanged for the default format; only an explicitly offered alternative triggers conversion (e.g., Date `"US"` rendering, Country `alpha3`/`numeric`/`name` conversion, Phone RFC 3966/national rendering). A capability with no alternative formats (e.g., Email, IP) inherits the identity implementation from `Capability` and does not override it. Rules never implement presentation — see Step 5d and the presentational-only invariant in Step 7.
4. Set `name` to a lowercase identifier (e.g., "yourdomain") — this is the name users pass to the contract
5. Implement `get_grammars()` — return a list of grammar instances
6. Implement `get_rules()` — return a list of rule instances
7. Define a `create_contract()` static method that returns a default contract
8. Implement `format_value(value, output_format, notation)` — the single presentation seam. The engine calls it immediately after `Rule.normalize()` and before candidate deduplication and status determination. Return the value unchanged for the default format; only an explicitly offered alternative triggers conversion (e.g., Date `"US"` rendering, Country `alpha3`/`numeric`/`name` conversion, Phone RFC 3966/national rendering). A capability with no alternative formats (e.g., Email, IP) inherits the identity implementation from `Capability` and does not override it. Rules never implement presentation — see Step 5d and the presentational-only invariant in Step 7.

---

Expand Down Expand Up @@ -746,20 +743,17 @@ Two sections in one file:

1. `test_creates_with_fields` — verify field access
2. `test_is_frozen` — verify immutability (assigning raises error)
3. `test_as_list_returns_correct` — verify list conversion
4. `test_as_list_preserves_order` — verify field order matches list order
5. `test_equality` — verify value equality
6. `test_hashable` — verify it can be used in sets or as dict keys
3. `test_equality` — verify value equality
4. `test_hashable` — verify it can be used in sets or as dict keys

**Capability wiring tests:**

1. `test_is_capability_subclass` — verify isinstance check
2. `test_name` — verify name matches expected value
3. `test_version` — verify version matches expected value
4. `test_get_grammars_returns_all` — verify grammar count
5. `test_get_rules_returns_all` — verify rule count
6. `test_grammar_name` — verify grammar names follow convention
7. `test_rule_name` — verify rule names follow convention
1. `test_is_capability_subclass` — verify isinstance check
2. `test_name` — verify name matches expected value
3. `test_get_grammars_returns_all` — verify grammar count
4. `test_get_rules_returns_all` — verify rule count
5. `test_grammar_name` — verify grammar names follow convention
6. `test_rule_name` — verify rule names follow convention

### 10d: Integration Tests

Expand Down Expand Up @@ -917,7 +911,7 @@ Rule names follow the pattern `Section {X.Y.Z}-{description}` (e.g., `Section 3.

### Pattern: Notation Fields Are Typed

Rules access notation fields by name (e.g., `notation.field_name`), not by list position. Your `as_list()` method is an optional helper for bridging to generic interfaces.
Rules access notation fields by name (e.g., `notation.field_name`), not by list position.

### Pitfall: Grammar Regex Must Be Compiled Once

Expand Down Expand Up @@ -1064,7 +1058,7 @@ Semantics to rely on:

Use this checklist to verify your capability is complete:

- [ ] Notation is a frozen dataclass with `as_list()` method
- [ ] Notation is a frozen dataclass with one `str` field per component
- [ ] Each grammar extends `Grammar[YourDomainNotation]` and implements `recognize(text) -> list[RecognitionMatch[YourDomainNotation]]`
- [ ] Each rule extends `Rule[YourDomainNotation]` and implements `matches(notation, contract) -> bool` and `normalize(notation, contract) -> str`
- [ ] Each rule declares `target_semantics` (non-empty `frozenset[str]`) and `requires_features` (`frozenset()` when the rule always runs)
Expand All @@ -1088,7 +1082,7 @@ Use this checklist to verify your capability is complete:
- [ ] Grammar tests cover happy path, edge cases, multiple matches, and empty input
- [ ] Rule tests cover valid input, invalid input, normalization, provenance, and naming
- [ ] Notation tests cover creation, immutability, and equality
- [ ] Capability tests cover subclass check, name, version, grammar count, and rule count
- [ ] Capability tests cover subclass check, name, grammar count, and rule count
- [ ] Integration tests use the `_clean_registry` fixture
- [ ] End-to-end tests exercise the public API
- [ ] `pyright --strict` passes with zero errors
Expand Down
4 changes: 2 additions & 2 deletions HOW_TO_ADD_NEW_GRAMMAR.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ class TestSlashISODateGrammar:
grammar = SlashISODateGrammar()
result = grammar.recognize("2026/07/26")
assert len(result) == 1
assert result[0].notation.as_list() == ["2026", "07", "26"]
assert result[0].notation == DateNotation(N1="2026", N2="07", N3="26")

def test_does_not_match_us_or_european_order(self) -> None:
"""A 2-digit-first slash date is not a slash-ISO date."""
Expand All @@ -76,7 +76,7 @@ class TestSlashISODateGrammar:
assert result[0].start == 2
assert result[0].end == 12
assert result[0].raw_text == "2026/07/26"
assert result[0].notation.as_list() == ["2026", "07", "26"]
assert result[0].notation == DateNotation(N1="2026", N2="07", N3="26")

def test_grammar_name(self) -> None:
grammar = SlashISODateGrammar()
Expand Down
2 changes: 1 addition & 1 deletion TESTING_STRATEGY.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ For the Email capability, this means three classes of tests:

**Rule tests** confirm that each validation rule's `matches()` method accepts valid notation and rejects invalid notation. They verify that `normalize()` produces the expected canonical string. They check that rule metadata (name, strategy, provenance attributes, citation) is set correctly. For example, `Section341AddrSpec` lowercases the email during normalization, while `Section63localhost` preserves case.

**Capability tests** verify that the capability class itself satisfies the `Capability` ABC, that it returns the correct number and type of grammars and rules from `get_grammars()` and `get_rules()`, and that its notation type (`EmailNotation`) is immutable, equality-comparable, hashable, and bridges to the generic `list[str]` representation via `as_list()`.
**Capability tests** verify that the capability class itself satisfies the `Capability` ABC, that it returns the correct number and type of grammars and rules from `get_grammars()` and `get_rules()`, and that its notation type (`EmailNotation`) is an immutable, equality-comparable, hashable frozen dataclass with one `str` field per component.

### Integration Tests

Expand Down
2 changes: 1 addition & 1 deletion paxman/capabilities/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
├── <Name>/ # one self-contained package per capability
│ ├── __init__.py # exports Capability, Contract, Notation
│ ├── __init__.py # exports Capability, Contract, <Name>Notation
│ ├── notation.py # frozen slots dataclass — the intermediate token
│ ├── contract.py # frozen CapabilityContract subclass (NO slots)
│ ├── capability.py # Capability[NotationT] subclass — wiring + format_value()
Expand Down
1 change: 0 additions & 1 deletion paxman/capabilities/Country/capability.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ class CountryCapability(Capability[CountryNotation]):
"""

name = "country"
version = "1.0.0"

def get_grammars(self) -> list[Grammar[CountryNotation]]:
"""Return all grammar instances.
Expand Down
8 changes: 0 additions & 8 deletions paxman/capabilities/Country/notation.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,3 @@ class CountryNotation:

shape: str
value: str

def as_list(self) -> list[str]:
"""Bridge to generic list[str] interface.

Returns:
[shape, value] — shape first for consistent ordering.
"""
return [self.shape, self.value]
1 change: 0 additions & 1 deletion paxman/capabilities/Currency/capability.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ class CurrencyCapability(Capability[CurrencyNotation]):
"""

name = "currency"
version = "1.0.0"

def get_grammars(self) -> list[Grammar[CurrencyNotation]]:
"""Return all grammar instances.
Expand Down
4 changes: 0 additions & 4 deletions paxman/capabilities/Currency/notation.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,3 @@ def __post_init__(self) -> None:
raise ValueError(
f"invalid shape {self.shape!r}; expected one of {sorted(_VALID_SHAPES)}"
)

def as_list(self) -> list[str]:
"""Flatten the notation for structural equality checks."""
return [self.text, self.shape]
1 change: 0 additions & 1 deletion paxman/capabilities/Date/capability.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ class DateCapability(Capability[DateNotation]):
"""Date canonicalization capability."""

name = "date"
version = "1.0.0"

def get_grammars(self) -> list[Grammar[DateNotation]]:
return [
Expand Down
4 changes: 0 additions & 4 deletions paxman/capabilities/Date/notation.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,3 @@ class DateNotation:
N1: str
N2: str
N3: str

def as_list(self) -> list[str]:
"""Convert to list[str] for generic Rule interface."""
return [self.N1, self.N2, self.N3]
1 change: 0 additions & 1 deletion paxman/capabilities/Email/capability.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ class EmailCapability(Capability[EmailNotation]):
"""Email canonicalization capability."""

name = "email"
version = "1.0.0"

def get_grammars(self) -> list[Grammar[EmailNotation]]:
return [
Expand Down
4 changes: 0 additions & 4 deletions paxman/capabilities/Email/notation.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,3 @@ class EmailNotation:

local_part: str
domain_part: str

def as_list(self) -> list[str]:
"""Convert to list[str] for generic Rule interface."""
return [self.local_part, self.domain_part]
1 change: 0 additions & 1 deletion paxman/capabilities/IP/capability.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ class IPCapability(Capability[IPNotation]):
"""IP canonicalization capability."""

name = "ip"
version = "1.0.0"

def get_grammars(self) -> list[Grammar[IPNotation]]:
return [
Expand Down
4 changes: 0 additions & 4 deletions paxman/capabilities/IP/notation.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,3 @@ class IPNotation:
"""IP address notation: a single address string."""

address: str

def as_list(self) -> list[str]:
"""Convert to list[str] for generic Rule interface."""
return [self.address]
1 change: 0 additions & 1 deletion paxman/capabilities/ISBN/capability.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ class ISBNCapability(Capability[ISBNNotation]):
"""

name = "isbn"
version = "1.0.0"

def get_grammars(self) -> list[Grammar[ISBNNotation]]:
return [ISBN13RecognitionGrammar(), ISBN10RecognitionGrammar()]
Expand Down
4 changes: 0 additions & 4 deletions paxman/capabilities/ISBN/notation.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,3 @@ class ISBNNotation:

shape: Literal["isbn10", "isbn13"]
digits: str

def as_list(self) -> list[str]:
"""Convert to list[str] for the generic Rule interface."""
return [self.shape, self.digits]
1 change: 0 additions & 1 deletion paxman/capabilities/Money/capability.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ class MoneyCapability(Capability[MoneyNotation]):
"""

name = "money"
version = "1.0.0"

def get_grammars(self) -> list[Grammar[MoneyNotation]]:
"""Return all grammar instances.
Expand Down
13 changes: 0 additions & 13 deletions paxman/capabilities/Money/notation.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,3 @@ def __post_init__(self) -> None:
raise ValueError(f"invalid currency_shape: {self.currency_shape!r}")
if self.amount_shape and self.amount_shape not in _VALID_AMOUNT_SHAPES:
raise ValueError(f"invalid amount_shape: {self.amount_shape!r}")

def as_list(self) -> list[str]:
"""Bridge to generic list[str] interface.

Returns:
[currency_part, amount_part, currency_shape, amount_shape].
"""
return [
self.currency_part,
self.amount_part,
self.currency_shape,
self.amount_shape,
]
1 change: 0 additions & 1 deletion paxman/capabilities/Phone/capability.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ class PhoneCapability(Capability[PhoneNotation]):
"""

name = "phone"
version = "1.0.0"

def get_grammars(self) -> list[Grammar[PhoneNotation]]:
"""Return all grammar instances.
Expand Down
8 changes: 0 additions & 8 deletions paxman/capabilities/Phone/notation.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,3 @@ class PhoneNotation:
shape: str
value: str
extension: str = ""

def as_list(self) -> list[str]:
"""Bridge to generic list[str] interface.

Returns:
[shape, value, extension] — shape first for consistent ordering.
"""
return [self.shape, self.value, self.extension]
1 change: 0 additions & 1 deletion paxman/capabilities/SIUnit/capability.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ class SIUnitCapability(Capability[SIUnitNotation]):
"""

name = "si_unit"
version = "1.0.0"

def get_grammars(self) -> list[Grammar[SIUnitNotation]]:
"""Return all grammar instances.
Expand Down
7 changes: 2 additions & 5 deletions paxman/capabilities/SIUnit/notation.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ class SIUnitNotation:
Attributes:
text: The unit text. Symbols keep their exact casing; names are
grammar-folded to lowercase; compounds keep the written form.
shape: One of "symbol", "name", "compound", "split_word_prefix", "split_symbol_prefix".
shape: One of "symbol", "name", "compound", "split_word_prefix",
"split_symbol_prefix".
"""

text: str
Expand All @@ -43,7 +44,3 @@ def __post_init__(self) -> None:
raise ValueError(
f"invalid shape {self.shape!r}; expected one of {sorted(_VALID_SHAPES)}"
)

def as_list(self) -> list[str]:
"""Flatten the notation for structural equality checks."""
return [self.text, self.shape]
1 change: 0 additions & 1 deletion paxman/capabilities/URL/capability.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ class URLCapability(Capability[URLNotation]):
"""

name = "url"
version = "1.0.0"

def get_grammars(self) -> list[Grammar[URLNotation]]:
return [
Expand Down
Loading
Loading