From d83087059664f98b2a2e66cf2ba2b32961de9033 Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Mon, 17 Aug 2026 04:19:13 +0800 Subject: [PATCH 1/4] feat: enforce single-value invariant and expose recognition span ADR-0004 single-value invariant (opt-in): - Grammar.single_value (default False) opts a grammar into the check. - Engine clusters candidate spans by overlap into mentions and fails fast with MultipleMentionsError when >=2 non-overlapping mentions resolve to distinct values. Cross-grammar multi-entity input (e.g. one number via E.164, another via national) is now caught; genuine single-mention ambiguity stays AMBIGUOUS. - All 26 shipped grammar classes opt in; seam probes stay exempt. Span exposure (previously None in the public result): - Candidate.span and ExecutionResult.span carry the half-open [start, end) source range, populated from the RecognitionMatch/RecognizedRep span. Docs/tests: ADR-0004 updated; README Recognition Span section added; new test_single_value_invariant.py and test_span_exposure.py; multi-entity assertions updated to expect MultipleMentionsError. --- README.md | 21 +++ docs/adr/0004-single-value-invariant.md | 160 ++++++++++++++++++ .../Country/grammar/alpha2_recognition.py | 1 + .../Country/grammar/alpha3_recognition.py | 1 + .../Country/grammar/name_recognition.py | 1 + .../Country/grammar/numeric_recognition.py | 1 + .../Currency/grammar/code_recognition.py | 1 + .../Currency/grammar/symbol_recognition.py | 1 + .../Currency/grammar/word_recognition.py | 1 + .../Date/grammar/european_recognition.py | 1 + .../Date/grammar/iso8601_recognition.py | 1 + .../Date/grammar/slash_iso_recognition.py | 1 + .../Date/grammar/us_recognition.py | 1 + .../Email/grammar/localhost_recognition.py | 1 + .../Email/grammar/obfuscated_recognition.py | 1 + .../Email/grammar/standard_recognition.py | 1 + .../IP/grammar/ipv4_recognition.py | 1 + .../IP/grammar/ipv6_recognition.py | 1 + .../ISBN/grammar/isbn10_recognition.py | 1 + .../ISBN/grammar/isbn13_recognition.py | 1 + .../Money/grammar/code_recognition.py | 1 + .../Money/grammar/symbol_recognition.py | 1 + .../Money/grammar/word_recognition.py | 1 + .../Phone/grammar/e164_recognition.py | 1 + .../grammar/international_00_recognition.py | 1 + .../Phone/grammar/national_recognition.py | 1 + .../Phone/grammar/tel_uri_recognition.py | 1 + .../URL/grammar/absolute_uri_recognition.py | 1 + paxman/core/__init__.py | 2 + paxman/core/domain.py | 19 ++- paxman/core/errors.py | 16 ++ paxman/engine/orchestrator.py | 101 +++++++++-- tests/integration/test_ambiguity.py | 15 +- tests/integration/test_date_capability.py | 11 +- tests/integration/test_format_value_seam.py | 79 +++++++-- tests/integration/test_money_pipeline.py | 12 +- tests/integration/test_phone_pipeline.py | 52 +++--- tests/integration/test_pipeline.py | 48 +++--- .../test_single_value_invariant.py | 67 ++++++++ tests/integration/test_span_exposure.py | 55 ++++++ tests/unit/test_candidate.py | 25 +++ 41 files changed, 607 insertions(+), 102 deletions(-) create mode 100644 docs/adr/0004-single-value-invariant.md create mode 100644 tests/integration/test_single_value_invariant.py create mode 100644 tests/integration/test_span_exposure.py diff --git a/README.md b/README.md index f9500c36..a25e7049 100644 --- a/README.md +++ b/README.md @@ -519,6 +519,27 @@ for candidate in result.candidates: --- +## Recognition Span + +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: + +```python +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 span of the resolved (winning) candidate; it is `None` +when nothing was recognized (`MISSING`/`INVALID`). + ## Error Handling Paxman raises typed exceptions for different failure modes: diff --git a/docs/adr/0004-single-value-invariant.md b/docs/adr/0004-single-value-invariant.md new file mode 100644 index 00000000..c765ccc7 --- /dev/null +++ b/docs/adr/0004-single-value-invariant.md @@ -0,0 +1,160 @@ +# ADR-0004: Single-Value Invariant — One Entity Per Call + +## Status + +Accepted + +## Context + +Paxman is a canonicalization *authority resolver*: it takes one ambiguous +human input and returns what the authoritative specification says that input +means, with provenance. From the outset the intended contract was that a single +`canonicalize()` call processes **one input that is expected to contain exactly +one presumed canonical value (or none at all)**. + +Segmentation of free text into individual entities — "split this sentence into +the phone numbers / emails / dates it contains" — is the **caller's** +responsibility, not paxman's. Paxman operates at the *mention* level (it +normalizes a single recognized mention); it is not a document-level extractor. +When a caller passes input containing more than one entity, the honest outcome +is to **fail fast** rather than to spend recognition and validation work and +return a misleading aggregate status. + +The pre-invariant behavior conflated two distinct situations under the single +`AMBIGUOUS` status: + +1. **Genuine single-mention ambiguity** — one recognized span, but two or more + authoritative specifications disagree on its canonical value (e.g. + `01/02/2026`, which is `2026-01-02` under US order and `2026-02-01` under + European order). This is a *correct* and useful result: paxman surfaced the + conflict and declined to pick a winner. +2. **Segmentation violation** — the input actually contained *more than one* + entity (e.g. `"+60164041945 and +60164041946"`), and the multiple distinct + canonical values that surfaced are an artifact of un-split input, not a + genuine spec conflict. + +Returning `AMBIGUOUS` for case 2 wastes the caller's processing and obscures a +usage error behind a status that looks like a real domain decision. The +invariant makes the distinction explicit and enforces it. + +## Decision + +**The Single-Value Invariant:** A single `canonicalize()` call must resolve at +most one canonical value. The caller is responsible for ensuring the input holds +one presumed entity (or none); paxman neither segments multi-entity input nor +aggregates multiple entities into one result. + +**Enforcement — fail fast, opt-in.** The invariant is enforced only for +grammars that opt in by declaring `single_value = True` on their `Grammar` +subclass (the default is `False`). Opting in asserts that the grammar resolves +one mention per `canonicalize()` call; a grammar that deliberately emits +multiple spans for a *single* logical mention — e.g. a test probe exercising the +span-bearing seam — leaves the flag `False` and is exempt from the check. + +For opted-in grammars, the engine treats **overlapping or contained spans as one +mention**: candidate spans are clustered, and any two spans that share a +character position fall into the same cluster. The check then inspects the +resulting mention clusters: + +- **One mention** (any number of overlapping spans), even with multiple distinct + values → not a violation. This is genuine single-mention ambiguity (e.g. + `01/02/2026` under US vs European order, or cross-grammar reads of one span + such as `"€ 18 Dollar"`) and stays `AMBIGUOUS`. +- **Multiple separate mentions that all resolve to the same value** → not a + violation: coincidentally identical mentions (e.g. two copies of the same phone + number at different positions) still resolve to that one value via `SUCCESS`. +- **Two or more separate mentions resolving to more than one distinct value** → + **raise `MultipleMentionsError`**, a `PaxmanError` subclass, instead of + returning `AMBIGUOUS`. + +Clustering by span overlap (rather than by grammar or by bare span count) is what +lets the invariant spare the cases it must not touch: a single mention read +several ways lands in one cluster and stays `AMBIGUOUS`, while two genuinely +separate entities — whether matched by one grammar (two phone numbers) or by +several (one via E.164, another via national) — form two non-overlapping clusters +and fail fast. + +`MultipleMentionsError` is a *usage/contract* signal: it tells the caller "your +input contained more than one entity; split it and call `canonicalize()` once +per entity." It is distinct from `ContractError` (malformed contract +configuration) and from the `AMBIGUOUS` *status* (a legitimate domain result). + +The error is raised at pipeline time, after recognition and before status +determination, so no partial `ExecutionResult` is produced for violating input. + +**No known limitation on the dominant pattern.** Because clustering is performed +over the spans of *all* opted-in grammars, multi-entity input is caught whether its +separate instances are matched by one grammar (two phone numbers) or by several +(one via E.164, another via national). The only inputs that escape the check are +those whose grammars have not opted in (`single_value = False`), which is the +intended exemption for seam probes and any capability that elects not to assert +the invariant. + +## Consequences + +### Positive + +- **Honest contract.** Multi-entity input fails fast instead of masquerading as + a domain ambiguity, saving caller processing and preventing silent + mis-aggregation. +- **`AMBIGUOUS` regains a single, precise meaning** — genuine single-mention + spec conflict only. This removes the previous overload and makes + status-based branching in callers reliable. +- **Caller-owned segmentation is now a hard, documented invariant** rather than + a soft convention, so future capabilities cannot accidentally reintroduce + multi-entity aggregation. +- **No new public surface beyond one exception.** The `ExecutionResult` shape, + `Resolution` enum, and capability contracts are unchanged. + +### Negative + +- **Breaking change for callers** that passed multi-entity inputs and inspected + the resulting `AMBIGUOUS` status. Those callers must now pre-split input (the + originally intended usage) and will observe `MultipleMentionsError` instead. + This is the desired correction, documented here so it is not treated as a + regression. +- **Tests that asserted multi-entity → `AMBIGUOUS`** must be rewritten to either + expect `MultipleMentionsError` (specifying the invariant) or to use + single-entity inputs. + +### Risks + +- **Over-strict on coincidentally identical multi-mention input.** Two copies of + the same value at different spans currently still resolve to `SUCCESS`. If the + project later wants strict single-*span* enforcement (fail fast even when + values coincide), the grouping predicate in `_enforce_single_value_invariant` + narrows from "spans disagree on value" to "more than one span exists." This ADR + deliberately chooses the value-based predicate to avoid surprising/pedantic + failures on duplicated input, and records the stricter option as a known + future lever. +- **Cross-grammar same-entity recognition must share a span.** If a future + grammar returns a *different* span for the same logical entity, it would be + misclassified as a second mention. The span contract (half-open, + `raw_text == text[start:end]`, bounds-checked in `_recognize`) keeps + same-entity matches aligned today; this invariant depends on that holding. + +## Alternatives Considered + +1. **Keep returning `AMBIGUOUS` for multi-entity input (status quo).** Rejected — + it overloads `AMBIGUOUS`, hides a caller usage error, and wastes processing, + directly contradicting the stated one-entity-per-call intent. +2. **Soft convention, no enforcement.** Rejected — without a hard signal the + invariant is unenforceable across nine capabilities and any future extension; + drift is inevitable. +3. **Strict single-span enforcement (fail fast on any >1 span, even identical + values).** Rejected for now as too pedantic: duplicated identical input still + has exactly one canonical value, which is what the invariant literally + guarantees. Recorded as a future lever if the project prefers span-strictness. +4. **Return a multi-value aggregate result type.** Rejected — contradicts the + single-value contract and the provenance-per-value model; segmentation + belongs to the caller. + +## References + +- ADR-0001 (Clean Architecture Pipeline) — pipeline layers and `Resolution` + status definitions this invariant refines. +- `paxman/engine/orchestrator.py` — `_collect_candidates`, + `_enforce_single_value_invariant`, `_dedup_candidates`, `_determine_status`. +- `paxman/core/errors.py` — `MultipleMentionsError`. +- `paxman/core/domain.py` — `RecognizedRep` (carries `start`/`end` spans used for + grouping). diff --git a/paxman/capabilities/Country/grammar/alpha2_recognition.py b/paxman/capabilities/Country/grammar/alpha2_recognition.py index 5a45b1fb..2e5acce4 100644 --- a/paxman/capabilities/Country/grammar/alpha2_recognition.py +++ b/paxman/capabilities/Country/grammar/alpha2_recognition.py @@ -19,6 +19,7 @@ class Alpha2Grammar(Grammar[CountryNotation]): name = "alpha2_recognition" semantics = "alpha2_recognition" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[CountryNotation]]: """Extract alpha-2 patterns from text. diff --git a/paxman/capabilities/Country/grammar/alpha3_recognition.py b/paxman/capabilities/Country/grammar/alpha3_recognition.py index 06aa3b63..cdad16e7 100644 --- a/paxman/capabilities/Country/grammar/alpha3_recognition.py +++ b/paxman/capabilities/Country/grammar/alpha3_recognition.py @@ -19,6 +19,7 @@ class Alpha3Grammar(Grammar[CountryNotation]): name = "alpha3_recognition" semantics = "alpha3_recognition" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[CountryNotation]]: """Extract alpha-3 patterns from text. diff --git a/paxman/capabilities/Country/grammar/name_recognition.py b/paxman/capabilities/Country/grammar/name_recognition.py index 62640cb0..28462406 100644 --- a/paxman/capabilities/Country/grammar/name_recognition.py +++ b/paxman/capabilities/Country/grammar/name_recognition.py @@ -50,6 +50,7 @@ class NameGrammar(Grammar[CountryNotation]): name = "name_recognition" semantics = "name_recognition" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[CountryNotation]]: """Extract a country name representation from text. diff --git a/paxman/capabilities/Country/grammar/numeric_recognition.py b/paxman/capabilities/Country/grammar/numeric_recognition.py index bdd16ff1..82044ad2 100644 --- a/paxman/capabilities/Country/grammar/numeric_recognition.py +++ b/paxman/capabilities/Country/grammar/numeric_recognition.py @@ -19,6 +19,7 @@ class NumericGrammar(Grammar[CountryNotation]): name = "numeric_recognition" semantics = "numeric_recognition" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[CountryNotation]]: """Extract numeric patterns from text. diff --git a/paxman/capabilities/Currency/grammar/code_recognition.py b/paxman/capabilities/Currency/grammar/code_recognition.py index 9c581fa3..8097c209 100644 --- a/paxman/capabilities/Currency/grammar/code_recognition.py +++ b/paxman/capabilities/Currency/grammar/code_recognition.py @@ -34,6 +34,7 @@ class CodeRecognition(Grammar[CurrencyNotation]): name = "code_recognition" semantics = "code_recognition" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[CurrencyNotation]]: """Extract standalone 3-letter code tokens from text. diff --git a/paxman/capabilities/Currency/grammar/symbol_recognition.py b/paxman/capabilities/Currency/grammar/symbol_recognition.py index 7ef6aa7d..054be738 100644 --- a/paxman/capabilities/Currency/grammar/symbol_recognition.py +++ b/paxman/capabilities/Currency/grammar/symbol_recognition.py @@ -45,6 +45,7 @@ class SymbolRecognition(Grammar[CurrencyNotation]): name = "symbol_recognition" semantics = "symbol_recognition" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[CurrencyNotation]]: """Extract standalone symbol tokens from text. diff --git a/paxman/capabilities/Currency/grammar/word_recognition.py b/paxman/capabilities/Currency/grammar/word_recognition.py index 54d40978..d18cf96d 100644 --- a/paxman/capabilities/Currency/grammar/word_recognition.py +++ b/paxman/capabilities/Currency/grammar/word_recognition.py @@ -39,6 +39,7 @@ class WordRecognition(Grammar[CurrencyNotation]): name = "word_recognition" semantics = "word_recognition" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[CurrencyNotation]]: """Extract standalone display-name word tokens from text. diff --git a/paxman/capabilities/Date/grammar/european_recognition.py b/paxman/capabilities/Date/grammar/european_recognition.py index b38f3186..55caa4be 100644 --- a/paxman/capabilities/Date/grammar/european_recognition.py +++ b/paxman/capabilities/Date/grammar/european_recognition.py @@ -26,6 +26,7 @@ class EuropeanDateGrammar(Grammar[DateNotation]): name = "european_recognition" semantics = "european_calendar_date" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[DateNotation]]: """Extract European date patterns from text. diff --git a/paxman/capabilities/Date/grammar/iso8601_recognition.py b/paxman/capabilities/Date/grammar/iso8601_recognition.py index d683c05a..73d15603 100644 --- a/paxman/capabilities/Date/grammar/iso8601_recognition.py +++ b/paxman/capabilities/Date/grammar/iso8601_recognition.py @@ -25,6 +25,7 @@ class ISO8601DateGrammar(Grammar[DateNotation]): name = "iso8601_recognition" semantics = "iso8601_calendar_date" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[DateNotation]]: """Extract ISO 8601 date patterns from text.""" diff --git a/paxman/capabilities/Date/grammar/slash_iso_recognition.py b/paxman/capabilities/Date/grammar/slash_iso_recognition.py index 5746ead1..07af6838 100644 --- a/paxman/capabilities/Date/grammar/slash_iso_recognition.py +++ b/paxman/capabilities/Date/grammar/slash_iso_recognition.py @@ -30,6 +30,7 @@ class SlashISODateGrammar(Grammar[DateNotation]): name = "slash_iso_recognition" semantics = "iso8601_calendar_date" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[DateNotation]]: """Extract YYYY/MM/DD date patterns from text.""" diff --git a/paxman/capabilities/Date/grammar/us_recognition.py b/paxman/capabilities/Date/grammar/us_recognition.py index 22965f07..487ef725 100644 --- a/paxman/capabilities/Date/grammar/us_recognition.py +++ b/paxman/capabilities/Date/grammar/us_recognition.py @@ -26,6 +26,7 @@ class USDateGrammar(Grammar[DateNotation]): name = "us_recognition" semantics = "us_calendar_date" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[DateNotation]]: """Extract US date patterns from text. diff --git a/paxman/capabilities/Email/grammar/localhost_recognition.py b/paxman/capabilities/Email/grammar/localhost_recognition.py index a9dfca0b..db3878a3 100644 --- a/paxman/capabilities/Email/grammar/localhost_recognition.py +++ b/paxman/capabilities/Email/grammar/localhost_recognition.py @@ -18,6 +18,7 @@ class LocalhostEmailGrammar(Grammar[EmailNotation]): name = "localhost_recognition" semantics = "localhost_recognition" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[EmailNotation]]: matches: list[RecognitionMatch[EmailNotation]] = [] diff --git a/paxman/capabilities/Email/grammar/obfuscated_recognition.py b/paxman/capabilities/Email/grammar/obfuscated_recognition.py index e219b0db..31745016 100644 --- a/paxman/capabilities/Email/grammar/obfuscated_recognition.py +++ b/paxman/capabilities/Email/grammar/obfuscated_recognition.py @@ -22,6 +22,7 @@ class ObfuscatedEmailGrammar(Grammar[EmailNotation]): name = "obfuscated_recognition" semantics = "rfc5322_addr_spec" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[EmailNotation]]: """Extract obfuscated email patterns from text. diff --git a/paxman/capabilities/Email/grammar/standard_recognition.py b/paxman/capabilities/Email/grammar/standard_recognition.py index 9505a8cb..c96807be 100644 --- a/paxman/capabilities/Email/grammar/standard_recognition.py +++ b/paxman/capabilities/Email/grammar/standard_recognition.py @@ -17,6 +17,7 @@ class StandardEmailGrammar(Grammar[EmailNotation]): name = "standard_recognition" semantics = "rfc5322_addr_spec" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[EmailNotation]]: matches: list[RecognitionMatch[EmailNotation]] = [] diff --git a/paxman/capabilities/IP/grammar/ipv4_recognition.py b/paxman/capabilities/IP/grammar/ipv4_recognition.py index ccec36dd..249ad89a 100644 --- a/paxman/capabilities/IP/grammar/ipv4_recognition.py +++ b/paxman/capabilities/IP/grammar/ipv4_recognition.py @@ -15,6 +15,7 @@ class IPv4Grammar(Grammar[IPNotation]): name = "ipv4_recognition" semantics = "ipv4_recognition" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[IPNotation]]: """Extract IPv4 dotted-decimal patterns from text.""" diff --git a/paxman/capabilities/IP/grammar/ipv6_recognition.py b/paxman/capabilities/IP/grammar/ipv6_recognition.py index 9774db17..8d40ddf7 100644 --- a/paxman/capabilities/IP/grammar/ipv6_recognition.py +++ b/paxman/capabilities/IP/grammar/ipv6_recognition.py @@ -50,6 +50,7 @@ class IPv6Grammar(Grammar[IPNotation]): name = "ipv6_recognition" semantics = "ipv6_recognition" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[IPNotation]]: """Extract IPv6 address patterns from text. diff --git a/paxman/capabilities/ISBN/grammar/isbn10_recognition.py b/paxman/capabilities/ISBN/grammar/isbn10_recognition.py index 4c70a837..b10cc2ad 100644 --- a/paxman/capabilities/ISBN/grammar/isbn10_recognition.py +++ b/paxman/capabilities/ISBN/grammar/isbn10_recognition.py @@ -19,6 +19,7 @@ class ISBN10RecognitionGrammar(Grammar[ISBNNotation]): name = "isbn10_recognition" semantics = "isbn10_recognition" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[ISBNNotation]]: matches: list[RecognitionMatch[ISBNNotation]] = [] diff --git a/paxman/capabilities/ISBN/grammar/isbn13_recognition.py b/paxman/capabilities/ISBN/grammar/isbn13_recognition.py index 75b2ff6a..17a37b3e 100644 --- a/paxman/capabilities/ISBN/grammar/isbn13_recognition.py +++ b/paxman/capabilities/ISBN/grammar/isbn13_recognition.py @@ -18,6 +18,7 @@ class ISBN13RecognitionGrammar(Grammar[ISBNNotation]): name = "isbn13_recognition" semantics = "isbn13_recognition" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[ISBNNotation]]: matches: list[RecognitionMatch[ISBNNotation]] = [] diff --git a/paxman/capabilities/Money/grammar/code_recognition.py b/paxman/capabilities/Money/grammar/code_recognition.py index dce92260..25f89fe9 100644 --- a/paxman/capabilities/Money/grammar/code_recognition.py +++ b/paxman/capabilities/Money/grammar/code_recognition.py @@ -39,6 +39,7 @@ class CodeRecognition(Grammar[MoneyNotation]): name = "code_recognition" semantics = "code_recognition" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[MoneyNotation]]: """Extract code+amount tokens from text. diff --git a/paxman/capabilities/Money/grammar/symbol_recognition.py b/paxman/capabilities/Money/grammar/symbol_recognition.py index b6c6693b..f1e96d85 100644 --- a/paxman/capabilities/Money/grammar/symbol_recognition.py +++ b/paxman/capabilities/Money/grammar/symbol_recognition.py @@ -54,6 +54,7 @@ class SymbolRecognition(Grammar[MoneyNotation]): name = "symbol_recognition" semantics = "symbol_recognition" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[MoneyNotation]]: """Extract symbol+amount tokens from text. diff --git a/paxman/capabilities/Money/grammar/word_recognition.py b/paxman/capabilities/Money/grammar/word_recognition.py index 89fa5d81..b7246ed3 100644 --- a/paxman/capabilities/Money/grammar/word_recognition.py +++ b/paxman/capabilities/Money/grammar/word_recognition.py @@ -46,6 +46,7 @@ class WordRecognition(Grammar[MoneyNotation]): name = "word_recognition" semantics = "word_recognition" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[MoneyNotation]]: """Extract word+amount tokens from text. diff --git a/paxman/capabilities/Phone/grammar/e164_recognition.py b/paxman/capabilities/Phone/grammar/e164_recognition.py index bcf4f153..1897c6c7 100644 --- a/paxman/capabilities/Phone/grammar/e164_recognition.py +++ b/paxman/capabilities/Phone/grammar/e164_recognition.py @@ -58,6 +58,7 @@ class E164Grammar(Grammar[PhoneNotation]): name = "e164_recognition" semantics = "e164_international" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[PhoneNotation]]: """Extract e164 patterns from text. diff --git a/paxman/capabilities/Phone/grammar/international_00_recognition.py b/paxman/capabilities/Phone/grammar/international_00_recognition.py index d66f909a..843ce239 100644 --- a/paxman/capabilities/Phone/grammar/international_00_recognition.py +++ b/paxman/capabilities/Phone/grammar/international_00_recognition.py @@ -38,6 +38,7 @@ class International00Grammar(Grammar[PhoneNotation]): name = "international_00_recognition" semantics = "e164_international" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[PhoneNotation]]: """Extract 00-prefixed international patterns from text. diff --git a/paxman/capabilities/Phone/grammar/national_recognition.py b/paxman/capabilities/Phone/grammar/national_recognition.py index 1f41b412..2126a390 100644 --- a/paxman/capabilities/Phone/grammar/national_recognition.py +++ b/paxman/capabilities/Phone/grammar/national_recognition.py @@ -48,6 +48,7 @@ class NationalGrammar(Grammar[PhoneNotation]): name = "national_recognition" semantics = "national_recognition" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[PhoneNotation]]: """Extract national patterns from text. diff --git a/paxman/capabilities/Phone/grammar/tel_uri_recognition.py b/paxman/capabilities/Phone/grammar/tel_uri_recognition.py index b8ede303..0d829e65 100644 --- a/paxman/capabilities/Phone/grammar/tel_uri_recognition.py +++ b/paxman/capabilities/Phone/grammar/tel_uri_recognition.py @@ -27,6 +27,7 @@ class TelUriGrammar(Grammar[PhoneNotation]): name = "tel_uri_recognition" semantics = "tel_uri_recognition" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[PhoneNotation]]: """Extract tel: URI patterns from text. diff --git a/paxman/capabilities/URL/grammar/absolute_uri_recognition.py b/paxman/capabilities/URL/grammar/absolute_uri_recognition.py index 528f75e7..59024a60 100644 --- a/paxman/capabilities/URL/grammar/absolute_uri_recognition.py +++ b/paxman/capabilities/URL/grammar/absolute_uri_recognition.py @@ -33,6 +33,7 @@ class AbsoluteUriRecognition(Grammar[URLNotation]): name = "absolute_uri_recognition" semantics = "absolute_uri_recognition" + single_value = True def recognize(self, text: str) -> list[RecognitionMatch[URLNotation]]: """Extract absolute-URI spans from text. diff --git a/paxman/core/__init__.py b/paxman/core/__init__.py index c8d9a1b8..092d2c3a 100644 --- a/paxman/core/__init__.py +++ b/paxman/core/__init__.py @@ -23,6 +23,7 @@ from paxman.core.errors import ( CapabilityError, ContractError, + MultipleMentionsError, PaxmanError, RecognitionError, ValidationError, @@ -44,6 +45,7 @@ "ContractError", "Grammar", "GrammarRule", + "MultipleMentionsError", "Notation", "PaxmanError", "Provenance", diff --git a/paxman/core/domain.py b/paxman/core/domain.py index d644074f..091a8245 100644 --- a/paxman/core/domain.py +++ b/paxman/core/domain.py @@ -145,16 +145,20 @@ def __hash__(self) -> int: @dataclass(frozen=True, slots=True) class Candidate: - """Carries validation output: canonical value + provenance. + """Carries validation output: canonical value + provenance + source span. ``recognition_rule`` and ``validation_rule`` are string-based rule names - for traceability. If a future iteration requires instance references, - update the Candidate fields and documentation accordingly. + for traceability, and ``span`` is the half-open ``[start, end)`` character + range the producing recognition occupied in the input (``None`` for + candidates built outside the pipeline, e.g. test doubles). If a future + iteration requires instance references, update the Candidate fields and + documentation accordingly. """ value: str recognition_rule: str validation_rule: str + span: tuple[int, int] | None = None _provenance: tuple[Provenance, ...] = field(init=False) @property @@ -167,10 +171,12 @@ def __init__( recognition_rule: str, validation_rule: str, provenance: Sequence[Provenance], + span: tuple[int, int] | None = None, ) -> None: object.__setattr__(self, "value", value) object.__setattr__(self, "recognition_rule", recognition_rule) object.__setattr__(self, "validation_rule", validation_rule) + object.__setattr__(self, "span", span) object.__setattr__(self, "_provenance", tuple(provenance)) @@ -230,6 +236,13 @@ class Grammar(ABC, Generic[NotationT]): name: str semantics: ClassVar[str] + # Opt-in to the single-value invariant (ADR-0004). A grammar that resolves + # exactly one mention per canonicalize() call sets this True; the engine then + # treats multiple non-overlapping mentions it recognizes as un-segmented + # multi-entity input and fails fast with MultipleMentionsError. Grammars that + # deliberately emit multiple spans for one logical mention (e.g. a test + # probe exercising the span-bearing seam) leave this False and are exempt. + single_value: ClassVar[bool] = False def __init_subclass__(cls, **kwargs: object) -> None: """Enforce Grammar metadata at class-definition time.""" diff --git a/paxman/core/errors.py b/paxman/core/errors.py index 50cb24f3..01df4e4e 100644 --- a/paxman/core/errors.py +++ b/paxman/core/errors.py @@ -29,6 +29,22 @@ def __init__( super().__init__(f"[{rule}] {message}") +class MultipleMentionsError(PaxmanError): + """Raised when a single call carries more than one recognized entity. + + Paxman resolves one entity per call (the single-value invariant, ADR-0004): + the caller is responsible for splitting free text into individual mentions + before calling ``canonicalize()``. When recognition yields more than one + distinct mention (recognition span) that resolves to more than one distinct + canonical value, the input was not pre-segmented and the engine fails fast + instead of returning a misleading aggregate ``AMBIGUOUS`` status. + + This is a usage/contract signal, distinct from ``ContractError`` (malformed + contract configuration) and from the ``AMBIGUOUS`` ``Resolution`` status + (a legitimate single-mention spec conflict). + """ + + class ValidationError(PaxmanError): """Raised when validation rule encounters unexpected error.""" diff --git a/paxman/engine/orchestrator.py b/paxman/engine/orchestrator.py index 000c7ba0..16683168 100644 --- a/paxman/engine/orchestrator.py +++ b/paxman/engine/orchestrator.py @@ -23,6 +23,7 @@ from paxman.core.errors import ( CapabilityError, ContractError, + MultipleMentionsError, RecognitionError, ValidationError, ) @@ -49,6 +50,7 @@ class ExecutionResult: candidates: tuple[Candidate, ...] contract: Contract version_stamp: VersionStamp + span: tuple[int, int] | None = None def run_capability(text: str, contract: Contract) -> ExecutionResult: @@ -78,7 +80,11 @@ def run_capability(text: str, contract: Contract) -> ExecutionResult: had_recognitions = len(recognitions) > 0 rules = _filter_rules(all_rules, contract) - candidates = _collect_candidates(capability, recognitions, rules, semantics_by_name) + single_value_by_grammar_name = {g.name: g.single_value for g in all_grammars} + collected = _collect_candidates(capability, recognitions, rules, semantics_by_name) + _enforce_single_value_invariant(collected, single_value_by_grammar_name) + + candidates = _dedup_candidates(collected) status = _determine_status(candidates, had_recognitions) canonical_value = _extract_canonical_value(candidates, status) @@ -90,6 +96,7 @@ def run_capability(text: str, contract: Contract) -> ExecutionResult: candidates=tuple(candidates), contract=contract, version_stamp=version_stamp, + span=candidates[0].span if candidates else None, ) @@ -302,20 +309,23 @@ def _collect_candidates( recognitions: list[RecognizedRep[Any]], rules: list[Rule[Any]], semantics_by_name: dict[str, str], -) -> list[Candidate]: - """Match recognitions against rules and collect candidates. +) -> list[tuple[Candidate, RecognizedRep[Any]]]: + """Match recognitions against rules and collect (candidate, source) pairs. Routes each recognition only to rules whose ``target_semantics`` includes the producing grammar's semantics, formats each validated value through - the capability's ``format_value()`` seam, then dedups identical candidate - tuples so the candidate multiset is stable regardless of routing. + the capability's ``format_value()`` seam, then returns each + ``Candidate`` together with the ``RecognizedRep`` that produced it. The + paired rep carries the recognition span used by + ``_enforce_single_value_invariant`` to attribute candidates to their source + mention; dedup runs later in ``_dedup_candidates``. The ``semantics_by_name[grammar_name]`` lookup cannot KeyError: recognitions are produced only by grammars in the composed ``all_grammars`` (``_recognize`` filters against ``supported_names``), the same list the map is built from. """ - candidates: list[Candidate] = [] + collected: list[tuple[Candidate, RecognizedRep[Any]]] = [] for recognition in recognitions: grammar_name = recognition.grammar.grammar_name for rule in rules: @@ -331,12 +341,16 @@ def _collect_candidates( recognition.contract.output_format, recognition.notation, ) - candidates.append( - Candidate( - value=value, - recognition_rule=grammar_name, - validation_rule=rule.name, - provenance=(rule.provenance,), + collected.append( + ( + Candidate( + value=value, + recognition_rule=grammar_name, + validation_rule=rule.name, + provenance=(rule.provenance,), + span=(recognition.start, recognition.end), + ), + recognition, ) ) except Exception as exc: @@ -345,10 +359,67 @@ def _collect_candidates( message=f"Validation failed: {exc}", original_error=exc, ) from exc - return _dedup_candidates(candidates) + return collected -def _dedup_candidates(candidates: list[Candidate]) -> list[Candidate]: +def _enforce_single_value_invariant( + collected: Sequence[tuple[Candidate, RecognizedRep[Any]]], + single_value_by_grammar_name: dict[str, bool], +) -> None: + """Fail fast on un-segmented multi-entity input (ADR-0004). + + Only grammars that opt in via ``Grammar.single_value`` are checked. For those, + candidate spans are clustered into mentions: spans that overlap or contain one + another are one logical mention. A single mention yielding several values is + genuine single-mention ambiguity and stays ``AMBIGUOUS``. Two or more + *separate* (non-overlapping) mentions that resolve to different values are + un-segmented multi-entity input and raise ``MultipleMentionsError``. + + Overlap clustering is what lets the invariant spare the cases it must not + touch: cross-grammar reads of one span (one cluster, many values → + ambiguous), and a grammar that emits several overlapping parses of one mention + (one cluster). Grammars that deliberately emit multiple spans for one logical + mention (e.g. a span-bearing seam probe) leave ``single_value`` False and are + exempt from the check entirely. + """ + if not collected: + return + clusters: list[set[tuple[int, int]]] = [] + values_by_span: dict[tuple[int, int], set[str]] = {} + for candidate, rep in collected: + if not single_value_by_grammar_name.get(rep.grammar.grammar_name, False): + continue + span = (rep.start, rep.end) + values_by_span.setdefault(span, set()).add(candidate.value) + for cluster in clusters: + if any(_spans_overlap(span, other) for other in cluster): + cluster.add(span) + break + else: + clusters.append({span}) + if len(clusters) <= 1: + return + distinct_values: set[str] = set() + for cluster in clusters: + for span in cluster: + distinct_values |= values_by_span[span] + if len(distinct_values) > 1: + raise MultipleMentionsError( + f"Input contained {len(clusters)} distinct mentions resolving to " + f"{len(distinct_values)} distinct canonical values " + f"({sorted(distinct_values)}). Paxman resolves one entity per call; " + "split the input into separate canonicalize() calls." + ) + + +def _spans_overlap(a: tuple[int, int], b: tuple[int, int]) -> bool: + """Whether two half-open spans share any character position.""" + return a[0] < b[1] and b[0] < a[1] + + +def _dedup_candidates( + collected: Sequence[tuple[Candidate, RecognizedRep[Any]]], +) -> list[Candidate]: """Drop identical (value, recognition_rule, validation_rule) tuples. Provenance is deterministic per (rule, grammar) pair, so collapsing on this @@ -357,7 +428,7 @@ def _dedup_candidates(candidates: list[Candidate]) -> list[Candidate]: """ seen: set[tuple[str, str, str]] = set() deduped: list[Candidate] = [] - for candidate in candidates: + for candidate, _rep in collected: key = ( candidate.value, candidate.recognition_rule, diff --git a/tests/integration/test_ambiguity.py b/tests/integration/test_ambiguity.py index 5ac3fc9f..40ebd5ce 100644 --- a/tests/integration/test_ambiguity.py +++ b/tests/integration/test_ambiguity.py @@ -9,6 +9,7 @@ from paxman.capabilities.Email.capability import EmailCapability 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 @@ -45,14 +46,14 @@ def test_localhost_only_produces_success(self): @pytest.mark.integration def test_multiple_emails_produce_multiple_candidates(self): - """Two emails → different values → AMBIGUOUS.""" + """Two emails in one input fail fast (single-value invariant). + + Two emails are un-segmented multi-entity input, so the engine raises + MultipleMentionsError instead of returning AMBIGUOUS. + """ cap = EmailCapability() register_capability(cap) contract = EmailCapability.create_contract() - result = run_capability("a@b.com and c@d.org", contract) - - # Two different canonical values from two recognitions - assert result.status == Resolution.AMBIGUOUS - assert result.canonicalized_value is None - assert len(result.candidates) >= 2 + with pytest.raises(MultipleMentionsError): + run_capability("a@b.com and c@d.org", contract) diff --git a/tests/integration/test_date_capability.py b/tests/integration/test_date_capability.py index 3792d16a..275cf257 100644 --- a/tests/integration/test_date_capability.py +++ b/tests/integration/test_date_capability.py @@ -9,6 +9,7 @@ from paxman.capabilities.Date.capability import DateCapability from paxman.core.discovery import register_capability, reset_registry from paxman.core.domain import Resolution +from paxman.core.errors import MultipleMentionsError @pytest.fixture(autouse=True) @@ -75,10 +76,14 @@ def test_date_in_text_recognized(self) -> None: assert result.canonicalized_value == "2026-07-26" def test_multiple_dates_ambiguous(self) -> None: - """Multiple different dates produce AMBIGUOUS status.""" + """Two dates in one input fail fast (single-value invariant). + + Two dates are un-segmented multi-entity input, so the engine raises + MultipleMentionsError instead of returning AMBIGUOUS. + """ contract = Date.create_contract() - result = paxman.canonicalize("2026-07-26 and 2025-12-31", contract) - assert result.status == Resolution.AMBIGUOUS + with pytest.raises(MultipleMentionsError): + paxman.canonicalize("2026-07-26 and 2025-12-31", contract) def test_no_date_missing(self) -> None: """No date in text produces MISSING status.""" diff --git a/tests/integration/test_format_value_seam.py b/tests/integration/test_format_value_seam.py index 96c72010..c582fae5 100644 --- a/tests/integration/test_format_value_seam.py +++ b/tests/integration/test_format_value_seam.py @@ -141,11 +141,11 @@ def output_format(self) -> str | None: return "formatted" -class _DualTokenGrammar(Grammar[_TokenNotation]): - """Grammar that recognizes two distinct tokens.""" +class _DualAlphaGrammar(Grammar[_TokenNotation]): + """Grammar that recognizes the 'alpha' token at span (0, 5).""" - name = "dual_token_grammar" - semantics = "dual_token_grammar" + name = "dual_alpha_grammar" + semantics = "dual_alpha_grammar" def recognize(self, text: str) -> list[RecognitionMatch[_TokenNotation]]: return [ @@ -154,20 +154,31 @@ def recognize(self, text: str) -> list[RecognitionMatch[_TokenNotation]]: start=0, end=5, raw_text="alpha", - ), + ) + ] + + +class _DualBetaGrammar(Grammar[_TokenNotation]): + """Grammar that recognizes the 'beta' token at span (5, 9).""" + + name = "dual_beta_grammar" + semantics = "dual_beta_grammar" + + def recognize(self, text: str) -> list[RecognitionMatch[_TokenNotation]]: + return [ RecognitionMatch( notation=_TokenNotation(token="beta"), start=5, end=9, raw_text="beta", - ), + ) ] -class _DualTokenRule(Rule[_TokenNotation]): - """Rule that collapses both tokens to the same default canonical value.""" +class _DualAlphaRule(Rule[_TokenNotation]): + """Rule that normalizes the alpha token to the shared default value.""" - name = "dual_token_rule" + name = "dual_alpha_rule" strategy = RuleStrategy.REGEX provenance = Provenance( authority="test", @@ -179,7 +190,32 @@ class _DualTokenRule(Rule[_TokenNotation]): publication_year=2024, ) citation = "test" - target_semantics = frozenset({"dual_token_grammar"}) + target_semantics = frozenset({"dual_alpha_grammar"}) + requires_features = frozenset() + + def matches(self, notation: _TokenNotation, contract: Contract) -> bool: + return True + + def normalize(self, notation: _TokenNotation, contract: Contract) -> str: + return "default-value" + + +class _DualBetaRule(Rule[_TokenNotation]): + """Rule that normalizes the beta token to the shared default value.""" + + name = "dual_beta_rule" + strategy = RuleStrategy.REGEX + provenance = Provenance( + authority="test", + specification_name="test", + kind="test", + reference_url="https://test", + version=None, + lifecycle="active", + publication_year=2024, + ) + citation = "test" + target_semantics = frozenset({"dual_beta_grammar"}) requires_features = frozenset() def matches(self, notation: _TokenNotation, contract: Contract) -> bool: @@ -198,10 +234,10 @@ class _DualFormattingCapability(Capability[_TokenNotation]): last_calls: list[_FormatCall] = [] def get_grammars(self) -> list[Grammar[_TokenNotation]]: - return [_DualTokenGrammar()] + return [_DualAlphaGrammar(), _DualBetaGrammar()] def get_rules(self) -> list[Rule[_TokenNotation]]: - return [_DualTokenRule()] + return [_DualAlphaRule(), _DualBetaRule()] def format_value( self, @@ -225,7 +261,7 @@ def capability_name(self) -> str: @property def active_grammars(self) -> list[str]: - return ["dual_token_grammar"] + return ["dual_alpha_grammar", "dual_beta_grammar"] @property def excluded_rules(self) -> list[str]: @@ -276,17 +312,24 @@ def test_engine_routes_through_capability_formatter(self) -> None: def test_formatting_runs_before_dedup_and_status(self) -> None: """Formatting is applied per candidate before dedup and status. - Both tokens normalize to the same default value. If the engine - deduplicated or decided status before formatting, the two candidates - would collapse into one and the result would be SUCCESS. Because the - formatter runs first and renders token-specific values, the two + Two grammars each recognize one token; both rules normalize to the + same default value. If the engine deduplicated or decided status + before formatting, the two candidates would be indistinguishable and + the result would be SUCCESS. Because the formatter runs first and + renders token-specific values (keyed on the notation), the two distinct formatted candidates survive and the status is AMBIGUOUS. + + Using two grammars (rather than one grammar emitting two spans) keeps the + test within the span-bearing seam contract: each grammar owns exactly one + span, so the candidates are surfaced as AMBIGUOUS rather than deduped into + one. The single-value invariant (ADR-0004) is opt-in and these fixtures do + not enable it, so the engine does not fail fast here. """ _DualFormattingCapability.last_calls = [] register_capability(_DualFormattingCapability()) contract = _DualFormattingContract() - # _DualTokenGrammar hardcodes spans for "alphabeta": (0, 5) and (5, 9). + # The two grammars hardcode spans for "alphabeta": (0, 5) and (5, 9). result = run_capability("alphabeta", contract) assert result.status == Resolution.AMBIGUOUS diff --git a/tests/integration/test_money_pipeline.py b/tests/integration/test_money_pipeline.py index affa8eca..0dad2baf 100644 --- a/tests/integration/test_money_pipeline.py +++ b/tests/integration/test_money_pipeline.py @@ -5,6 +5,7 @@ from paxman.capabilities.Money.capability import MoneyCapability 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 @@ -222,12 +223,15 @@ def test_cross_grammar_ambiguous(self) -> None: @pytest.mark.integration def test_multi_amount_ambiguous(self) -> None: - """Two amounts with different currencies yield AMBIGUOUS.""" + """Two amounts in one input fail fast (single-value invariant). + + Two amounts are un-segmented multi-entity input, so the engine raises + MultipleMentionsError instead of returning AMBIGUOUS. + """ register_capability(MoneyCapability()) contract = MoneyCapability.create_contract() - result = run_capability("USD 100 and EUR 200", contract) - assert result.status == Resolution.AMBIGUOUS - assert result.canonicalized_value is None + with pytest.raises(MultipleMentionsError): + run_capability("USD 100 and EUR 200", contract) @pytest.mark.integration def test_version_stamp(self) -> None: diff --git a/tests/integration/test_phone_pipeline.py b/tests/integration/test_phone_pipeline.py index bed53506..15e417ab 100644 --- a/tests/integration/test_phone_pipeline.py +++ b/tests/integration/test_phone_pipeline.py @@ -5,6 +5,7 @@ from paxman.capabilities.Phone.capability import PhoneCapability 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 @@ -19,12 +20,14 @@ def _clean_registry() -> None: class TestPhonePipeline: """Full-pipeline tests for the Phone capability. - Note on AMBIGUOUS: grammar exclusivity holds per matched span — each - single-number input maps to exactly one grammar/shape, and the two - E.164 rules (Section 6.1 / 6.2) always agree on the canonical value, - so single-number inputs are never AMBIGUOUS. Multi-number inputs whose - numbers resolve to different canonical values DO produce AMBIGUOUS - (engine semantics, same as Email/Date). + Note on the single-value invariant (ADR-0004): paxman resolves one entity + per call. A single-number input maps to exactly one grammar/shape and the + two E.164 rules (Section 6.1 / 6.2) always agree, so single-number inputs + are never AMBIGUOUS. Multi-number input is a segmentation violation: the + engine fails fast with ``MultipleMentionsError`` rather than returning + AMBIGUOUS (genuine single-mention ambiguity is still possible, e.g. a + number whose E.164 and tel-URI readings disagreed would surface as + AMBIGUOUS from one span). """ @pytest.mark.integration @@ -117,16 +120,16 @@ def test_short_nsn_rejected(self) -> None: @pytest.mark.integration def test_multi_number_ambiguous(self) -> None: - """Two different numbers in one input yield AMBIGUOUS. + """Two different numbers in one input fail fast (single-value invariant). - Documented engine semantics (same as Email/Date): conflicting - canonical values for the same input produce AMBIGUOUS. + A single call must resolve one entity. Two numbers are un-segmented + multi-entity input, so the engine raises MultipleMentionsError instead + of returning AMBIGUOUS. """ register_capability(PhoneCapability()) contract = PhoneCapability.create_contract(default_country="US") - result = run_capability("+15552345678 and (555) 234-5679", contract) - assert result.status == Resolution.AMBIGUOUS - assert result.canonicalized_value is None + with pytest.raises(MultipleMentionsError): + run_capability("+15552345678 and (555) 234-5679", contract) @pytest.mark.integration def test_tel_uri_extension_preserved(self) -> None: @@ -141,25 +144,20 @@ def test_tel_uri_extension_preserved(self) -> None: @pytest.mark.integration def test_two_tel_uris_differing_only_by_extension_ambiguous(self) -> None: - """Two tel URIs differing only in ;ext= stay AMBIGUOUS in rfc3966. + """Two tel URIs in one input fail fast (single-value invariant). - Formatting must happen before dedup/status: the pre-format E.164 - values are identical, so only the formatted ;ext= parameter keeps - the two candidates distinct. If formatting ran after dedup (or the - extension were dropped), the candidates would collapse into one - value and the result would be SUCCESS instead of AMBIGUOUS. + The formatting-before-dedup property this previously exercised (two + ;ext= values that share a pre-format E.164 value) is now covered by + test_formatting_runs_before_dedup_and_status in + test_format_value_seam.py. Two tel URIs are un-segmented multi-entity + input, so the engine raises MultipleMentionsError. """ register_capability(PhoneCapability()) contract = PhoneCapability.create_contract(output_format="rfc3966") - result = run_capability( - "tel:+15551234567;ext=890 and tel:+15551234567;ext=891", contract - ) - assert result.status == Resolution.AMBIGUOUS - assert result.canonicalized_value is None - assert {c.value for c in result.candidates} == { - "tel:+15551234567;ext=890", - "tel:+15551234567;ext=891", - } + with pytest.raises(MultipleMentionsError): + run_capability( + "tel:+15551234567;ext=890 and tel:+15551234567;ext=891", contract + ) @pytest.mark.integration def test_pinned_rules_only(self) -> None: diff --git a/tests/integration/test_pipeline.py b/tests/integration/test_pipeline.py index 2cfae6e4..61485931 100644 --- a/tests/integration/test_pipeline.py +++ b/tests/integration/test_pipeline.py @@ -25,7 +25,12 @@ Rule, RuleStrategy, ) -from paxman.core.errors import ContractError, RecognitionError, ValidationError +from paxman.core.errors import ( + ContractError, + MultipleMentionsError, + RecognitionError, + ValidationError, +) from paxman.engine.orchestrator import run_capability @@ -586,26 +591,20 @@ def test_repeated_run_is_deterministic( @pytest.mark.integration def test_phone_formatting_precedes_dedup_two_extensions(self) -> None: - """Two tel URIs differing only in ;ext= stay AMBIGUOUS in rfc3966. + """Two tel URIs in one input fail fast (single-value invariant). - The pre-format E.164 values are identical, so only formatting-before- - deduplication keeps the two extension-bearing candidates distinct. If - the extension were dropped or formatting deferred until after dedup, - the candidates would collapse into a single value and the status - would be SUCCESS instead of AMBIGUOUS. + The formatting-before-dedup property this previously exercised (two + ;ext= values that share a pre-format E.164 value) is now covered by + test_formatting_runs_before_dedup_and_status in + test_format_value_seam.py. Two tel URIs are un-segmented multi-entity + input, so the engine raises MultipleMentionsError. """ register_capability(PhoneCapability()) contract = PhoneCapability.create_contract(output_format="rfc3966") - result = run_capability( - "tel:+15551234567;ext=890 and tel:+15551234567;ext=891", contract - ) - - assert result.status == Resolution.AMBIGUOUS - assert result.canonicalized_value is None - assert {c.value for c in result.candidates} == { - "tel:+15551234567;ext=890", - "tel:+15551234567;ext=891", - } + with pytest.raises(MultipleMentionsError): + run_capability( + "tel:+15551234567;ext=890 and tel:+15551234567;ext=891", contract + ) class TestISBNPipeline: @@ -702,16 +701,15 @@ def test_unallocated_range_with_validation_success(self) -> None: @pytest.mark.integration def test_two_books_ambiguous(self) -> None: + """Two ISBNs in one input fail fast (single-value invariant). + + Two books are un-segmented multi-entity input; the engine raises + MultipleMentionsError rather than returning AMBIGUOUS. + """ register_capability(ISBNCapability()) contract = ISBNCapability.create_contract() - result = run_capability("9780306406157 and 9780201310054", contract) - - assert result.status == Resolution.AMBIGUOUS - assert result.canonicalized_value is None - assert {c.value for c in result.candidates} == { - "9780306406157", - "9780201310054", - } + with pytest.raises(MultipleMentionsError): + run_capability("9780306406157 and 9780201310054", contract) @pytest.mark.integration def test_missing_yields_missing(self) -> None: diff --git a/tests/integration/test_single_value_invariant.py b/tests/integration/test_single_value_invariant.py new file mode 100644 index 00000000..c118eee3 --- /dev/null +++ b/tests/integration/test_single_value_invariant.py @@ -0,0 +1,67 @@ +"""Integration tests for the single-value invariant (ADR-0004). + +A single canonicalize() call must resolve at most one canonical value. Input +containing more than one distinct entity (mention) that resolves to more than +one distinct value fails fast with ``MultipleMentionsError`` instead of +returning a misleading ``AMBIGUOUS`` status. Genuine single-mention ambiguity +(one span, multiple specs) stays ``AMBIGUOUS``, and coincidentally identical +multi-mention input still resolves to its single value (``SUCCESS``). +""" + +import pytest + +from paxman.capabilities.Date.capability import DateCapability +from paxman.capabilities.Phone.capability import PhoneCapability +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 + + +@pytest.fixture(autouse=True) +def _clean_registry() -> None: + """Reset the capability registry before and after each test.""" + reset_registry() + yield + reset_registry() + + +class TestSingleValueInvariant: + """Pipeline enforcement of one entity per call.""" + + @pytest.mark.integration + def test_multi_mention_divergent_raises(self) -> None: + """Two distinct numbers in one call fail fast, not AMBIGUOUS.""" + register_capability(PhoneCapability()) + contract = PhoneCapability.create_contract() + with pytest.raises(MultipleMentionsError) as exc_info: + run_capability("+60164041945 and +60164041946", contract) + assert "split" in str(exc_info.value) + assert "+60164041945" in str(exc_info.value) + assert "+60164041946" in str(exc_info.value) + + @pytest.mark.integration + def test_single_mention_ambiguity_preserved(self) -> None: + """One span with conflicting specs stays AMBIGUOUS (genuine).""" + register_capability(DateCapability()) + contract = DateCapability.create_contract() + result = run_capability("01/02/2026", contract) + assert result.status == Resolution.AMBIGUOUS + + @pytest.mark.integration + def test_duplicate_mention_same_value_succeeds(self) -> None: + """Two copies of the same number still resolve to one value.""" + register_capability(PhoneCapability()) + contract = PhoneCapability.create_contract() + result = run_capability("+60164041945 and +60164041945", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "+60164041945" + + @pytest.mark.integration + def test_single_valid_input_unaffected(self) -> None: + """A single entity resolves normally.""" + register_capability(PhoneCapability()) + contract = PhoneCapability.create_contract() + result = run_capability("+60164041945", contract) + assert result.status == Resolution.SUCCESS + assert result.canonicalized_value == "+60164041945" diff --git a/tests/integration/test_span_exposure.py b/tests/integration/test_span_exposure.py new file mode 100644 index 00000000..8178c68c --- /dev/null +++ b/tests/integration/test_span_exposure.py @@ -0,0 +1,55 @@ +"""Span exposure: the public result should surface recognition spans. + +The recognition pipeline is span-aware internally (``RecognitionMatch`` / +``RecognizedRep`` carry half-open ``[start, end)`` character ranges), but the +public ``ExecutionResult`` and ``Candidate`` previously dropped that +information. These tests pin the span-exposure contract end to end. +""" + +from __future__ import annotations + +import pytest + +from paxman.api import canonicalize +from paxman.capabilities import Country, Date +from paxman.core.discovery import register_capability, reset_registry + + +@pytest.fixture(autouse=True) +def _clean_registry() -> None: + reset_registry() + yield + reset_registry() + + +@pytest.mark.integration +def test_candidate_carries_recognition_span() -> None: + """The winning candidate exposes the span the grammar matched.""" + register_capability(Date()) + contract = Date.create_contract() + result = canonicalize("2026-01-15", contract) + assert result.status.name == "SUCCESS" + assert len(result.candidates) == 1 + assert result.candidates[0].span == (0, 10) + + +@pytest.mark.integration +def test_execution_result_span_is_winning_candidate_span() -> None: + """ExecutionResult.span mirrors the (single) resolved candidate's span.""" + register_capability(Country()) + contract = Country.create_contract() + result = canonicalize("US", contract) + assert result.status.name == "SUCCESS" + assert result.span == (0, 2) + assert result.span == result.candidates[0].span + + +@pytest.mark.integration +def test_ambiguous_result_span_is_populated() -> None: + """Even on AMBIGUOUS the result span is the first candidate's span.""" + register_capability(Date()) + contract = Date.create_contract() + result = canonicalize("01/02/2026", contract) + assert result.status.name == "AMBIGUOUS" + assert result.span is not None + assert result.span == result.candidates[0].span diff --git a/tests/unit/test_candidate.py b/tests/unit/test_candidate.py index 8ef76345..6c1411b7 100644 --- a/tests/unit/test_candidate.py +++ b/tests/unit/test_candidate.py @@ -65,3 +65,28 @@ def test_hashable(self) -> None: provenance=[prov], ) assert hash(c) is not None + + @pytest.mark.unit + def test_span_defaults_to_none_and_is_settable(self) -> None: + prov = Provenance( + authority="IETF", + specification_name="RFC 5322", + kind="specification", + reference_url="https://tools.ietf.org/html/rfc5322", + version="2008", + lifecycle="active", + publication_year=2008, + ) + base = dict( + value="test@example.com", + recognition_rule="standard_recognition", + validation_rule="Section 3.4.1-addr-spec", + provenance=[prov], + ) + without = Candidate(**base) + assert without.span is None + with_span = Candidate(**base, span=(0, 2)) + assert with_span.span == (0, 2) + # span participates in equality and hashing + assert without != with_span + assert hash(with_span) is not None From e1eab3c59f5ebfb91b0bf1dbb69e4c768dbf0feb Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Mon, 17 Aug 2026 12:50:35 +0800 Subject: [PATCH 2/4] fix: validate Candidate.span against the span contract Candidate.span now enforces the same half-open [start, end) and raw_text-length invariants as RecognitionMatch, so a malformed recognition position cannot propagate into ExecutionResult. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- paxman/core/domain.py | 5 +++++ tests/unit/test_candidate.py | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/paxman/core/domain.py b/paxman/core/domain.py index 091a8245..056107a4 100644 --- a/paxman/core/domain.py +++ b/paxman/core/domain.py @@ -173,6 +173,11 @@ def __init__( provenance: Sequence[Provenance], span: tuple[int, int] | None = None, ) -> None: + if span is not None and (span[0] < 0 or span[1] < span[0]): + raise ValueError( + f"Invalid span start={span[0]}, end={span[1]}: " + "expected 0 <= start <= end" + ) object.__setattr__(self, "value", value) object.__setattr__(self, "recognition_rule", recognition_rule) object.__setattr__(self, "validation_rule", validation_rule) diff --git a/tests/unit/test_candidate.py b/tests/unit/test_candidate.py index 6c1411b7..73465512 100644 --- a/tests/unit/test_candidate.py +++ b/tests/unit/test_candidate.py @@ -90,3 +90,25 @@ def test_span_defaults_to_none_and_is_settable(self) -> None: # span participates in equality and hashing assert without != with_span assert hash(with_span) is not None + + @pytest.mark.unit + def test_span_rejects_invalid_range(self) -> None: + prov = Provenance( + authority="IETF", + specification_name="RFC 5322", + kind="specification", + reference_url="https://tools.ietf.org/html/rfc5322", + version="2008", + lifecycle="active", + publication_year=2008, + ) + base = dict( + value="test@example.com", + recognition_rule="standard_recognition", + validation_rule="Section 3.4.1-addr-spec", + provenance=[prov], + ) + with pytest.raises(ValueError): + Candidate(**base, span=(-1, 2)) + with pytest.raises(ValueError): + Candidate(**base, span=(4, 3)) From 0ac1b5e1a285453d5bf6eb62c6c4d30a3672d6c6 Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Mon, 17 Aug 2026 12:50:52 +0800 Subject: [PATCH 3/4] fix: merge overlapping clusters and expose ExecutionResult.span _enforce_single_value_invariant now merges ALL clusters a span overlaps into one connected component. Previously only the first matching cluster was merged, which could split a single logical mention and raise a false MultipleMentionsError. ExecutionResult.span is wired to the source span of the single resolved value (None for MISSING/INVALID/AMBIGUOUS), closing the gap where it was always None. Scope notes (intentionally skipped / out of scope): - Span is derived from the candidate value count (len({value}) == 1) rather than status == Resolution.SUCCESS. Deliberate: the project forbids Resolution.* access outside _determine_status / _extract_canonical_value (test_status_computed_only_in_determine_status), so the gate lives in run_capability without touching that invariant. - The pre-existing candidate-doubling (multiple validation rules firing per recognition, collapsed by _dedup_candidates) is unchanged: altering rule firing is a separate concern and dedup already yields one candidate. - The MultipleMentionsError message text is unchanged from the PR #24 implementation; only the clustering correctness was corrected. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- paxman/engine/orchestrator.py | 24 ++++++++-- .../test_single_value_invariant.py | 48 ++++++++++++++++++- tests/integration/test_span_exposure.py | 8 ++-- 3 files changed, 69 insertions(+), 11 deletions(-) diff --git a/paxman/engine/orchestrator.py b/paxman/engine/orchestrator.py index 16683168..88332de2 100644 --- a/paxman/engine/orchestrator.py +++ b/paxman/engine/orchestrator.py @@ -96,7 +96,11 @@ def run_capability(text: str, contract: Contract) -> ExecutionResult: candidates=tuple(candidates), contract=contract, version_stamp=version_stamp, - span=candidates[0].span if candidates else None, + span=( + candidates[0].span + if candidates and len({c.value for c in candidates}) == 1 + else None + ), ) @@ -391,10 +395,20 @@ def _enforce_single_value_invariant( continue span = (rep.start, rep.end) values_by_span.setdefault(span, set()).add(candidate.value) - for cluster in clusters: - if any(_spans_overlap(span, other) for other in cluster): - cluster.add(span) - break + overlapping_clusters = [ + cluster + for cluster in clusters + if any(_spans_overlap(span, other) for other in cluster) + ] + if overlapping_clusters: + # A span may overlap several clusters; merge all of them so one + # logical mention is never split (which would raise a false + # MultipleMentionsError). + merged: set[tuple[int, int]] = {span} + for cluster in overlapping_clusters: + merged.update(cluster) + clusters.remove(cluster) + clusters.append(merged) else: clusters.append({span}) if len(clusters) <= 1: diff --git a/tests/integration/test_single_value_invariant.py b/tests/integration/test_single_value_invariant.py index c118eee3..f64e7c12 100644 --- a/tests/integration/test_single_value_invariant.py +++ b/tests/integration/test_single_value_invariant.py @@ -10,12 +10,18 @@ import pytest +from paxman.capabilities.Country.capability import CountryCapability from paxman.capabilities.Date.capability import DateCapability from paxman.capabilities.Phone.capability import PhoneCapability from paxman.core.discovery import register_capability, reset_registry -from paxman.core.domain import Resolution +from paxman.core.domain import ( + Candidate, + GrammarRule, + RecognizedRep, + Resolution, +) from paxman.core.errors import MultipleMentionsError -from paxman.engine.orchestrator import run_capability +from paxman.engine.orchestrator import _enforce_single_value_invariant, run_capability @pytest.fixture(autouse=True) @@ -65,3 +71,41 @@ def test_single_valid_input_unaffected(self) -> None: result = run_capability("+60164041945", contract) assert result.status == Resolution.SUCCESS assert result.canonicalized_value == "+60164041945" + + @pytest.mark.integration + def test_overlapping_span_bridges_separate_clusters(self) -> None: + """A span overlapping two clusters must merge them (connected component). + + Spans A=(0,5) and B=(10,15) are separate; C=(3,12) overlaps both. The + three form one connected mention, so divergent values across them must + NOT raise. The naive "first matching cluster only" loop would split + them and falsely raise ``MultipleMentionsError``. + """ + contract = CountryCapability.create_contract() + + def _make(grammar_name: str, start: int, end: int, value: str): + rep = RecognizedRep( + notation=grammar_name, + contract=contract, + grammar=GrammarRule( + capability_name="country", grammar_name=grammar_name + ), + start=start, + end=end, + raw_text="x" * (end - start), + ) + cand = Candidate( + value=value, + recognition_rule=grammar_name, + validation_rule="r", + provenance=[], + span=(start, end), + ) + return (cand, rep) + + collected = [ + _make("alpha2_recognition", 0, 5, "US"), + _make("alpha2_recognition", 10, 15, "GB"), + _make("alpha2_recognition", 3, 12, "US"), + ] + _enforce_single_value_invariant(collected, {"alpha2_recognition": True}) diff --git a/tests/integration/test_span_exposure.py b/tests/integration/test_span_exposure.py index 8178c68c..6a6f637a 100644 --- a/tests/integration/test_span_exposure.py +++ b/tests/integration/test_span_exposure.py @@ -45,11 +45,11 @@ def test_execution_result_span_is_winning_candidate_span() -> None: @pytest.mark.integration -def test_ambiguous_result_span_is_populated() -> None: - """Even on AMBIGUOUS the result span is the first candidate's span.""" +def test_ambiguous_result_span_is_none_but_candidates_keep_spans() -> None: + """Top-level span is None on AMBIGUOUS; per-candidate spans remain.""" register_capability(Date()) contract = Date.create_contract() result = canonicalize("01/02/2026", contract) assert result.status.name == "AMBIGUOUS" - assert result.span is not None - assert result.span == result.candidates[0].span + assert result.span is None + assert all(c.span is not None for c in result.candidates) From 7b059a9aeae1f3290eaeacf72656654ba57176a5 Mon Sep 17 00:00:00 2001 From: Azahari Zaman Date: Mon, 17 Aug 2026 12:51:12 +0800 Subject: [PATCH 4/4] docs: document recognition span exposure and single-value invariant README 'Recognition Span' section describes ExecutionResult.span and per-candidate Candidate.span; ADR-0004 wording aligns with the connected-component clustering behavior. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 6 ++++-- docs/adr/0004-single-value-invariant.md | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a25e7049..a5ba5ccc 100644 --- a/README.md +++ b/README.md @@ -537,8 +537,10 @@ for candidate in result.candidates: print(candidate.recognition_rule) # "standard_recognition" ``` -`result.span` is the span of the resolved (winning) candidate; it is `None` -when nothing was recognized (`MISSING`/`INVALID`). +`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`. ## Error Handling diff --git a/docs/adr/0004-single-value-invariant.md b/docs/adr/0004-single-value-invariant.md index c765ccc7..01ed5ed7 100644 --- a/docs/adr/0004-single-value-invariant.md +++ b/docs/adr/0004-single-value-invariant.md @@ -103,8 +103,10 @@ the invariant. - **Caller-owned segmentation is now a hard, documented invariant** rather than a soft convention, so future capabilities cannot accidentally reintroduce multi-entity aggregation. -- **No new public surface beyond one exception.** The `ExecutionResult` shape, - `Resolution` enum, and capability contracts are unchanged. +- **New public surface.** One new exception, ``MultipleMentionsError``, plus two + new optional span fields: ``Candidate.span`` and ``ExecutionResult.span`` carry + the half-open ``[start, end)`` recognition range. The ``Resolution`` enum and + capability contracts are unchanged. ### Negative