Skip to content

Commit d4aaafa

Browse files
derek73claude
andcommitted
test: make the pack registry the gate contract (design note, option C)
The non-interference gate, rotator lists, and branch-coverage test now iterate the registered packs (_REGISTRY -> _PACKS/_PACKED/_ROTATORS dicts) instead of hand-wiring each pack by name: the per-pack gate tests collapse into one parametrized test, the combined test derives its predicate by OR-ing every registered DEVIATES, and marker regexes are discovered as the pack module's re.Pattern attributes (anchoring read from the pattern's own shape). A contract meta-test requires every registered pack to declare DEVIATES and ship a rotator list, so pack #3 fails structurally until it does -- accepted option C of the 2026-07-18 DEVIATES design note, keeping Locale pure data. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent cf46e3c commit d4aaafa

1 file changed

Lines changed: 86 additions & 60 deletions

File tree

tests/v2/test_locales.py

Lines changed: 86 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"""The locale pack layer (locales spec §2-3): lazy access, the two
22
2.0.0 packs, composition, and the non-interference gate."""
33
import functools
4+
import importlib
45
import json
56
import re
67
from collections.abc import Callable, Iterable
78
from pathlib import Path
9+
from types import ModuleType
810

911
import pytest
1012

@@ -25,13 +27,26 @@
2527
if line.strip()
2628
]
2729

30+
# The registry is the pack contract (design note 2026-07-18, option C):
31+
# the DEVIATES requirement, the rotator lists, and the non-interference
32+
# gate below all iterate the registered packs, so adding a pack is one
33+
# registry entry plus one rotator list -- the contract meta-test fails
34+
# until both exist, instead of a human remembering to extend N tests.
35+
def _pack_modules() -> dict[str, ModuleType]:
36+
out: dict[str, ModuleType] = {}
37+
for modname, attr in locales._REGISTRY.values():
38+
module = importlib.import_module(modname)
39+
out[getattr(module, attr).code] = module
40+
return out
41+
42+
43+
_PACKS = _pack_modules()
44+
2845
# shared across the gate and rotator tests: the default-parser baseline
2946
# is identical everywhere (only the packed side varies per test), so
3047
# parse each name once for the whole module instead of once per test
3148
_DEFAULT_PARSER = Parser()
32-
_RU_PACKED = parser_for(locales.RU)
33-
_TR_AZ_PACKED = parser_for(locales.TR_AZ)
34-
_COMBINED_PACKED = parser_for(locales.RU, locales.TR_AZ)
49+
_PACKED = {code: parser_for(locales.get(code)) for code in _PACKS}
3550

3651

3752
@functools.cache
@@ -114,7 +129,7 @@ def test_locales_unknown_attribute() -> None:
114129

115130

116131
def test_ru_plus_tr_az_unions_patronymic_rules() -> None:
117-
p = _COMBINED_PACKED
132+
p = parser_for(locales.RU, locales.TR_AZ)
118133
assert p.policy.patronymic_rules == frozenset(
119134
{PatronymicRule.EAST_SLAVIC, PatronymicRule.TURKIC})
120135
# both rules live: one name from each pack's case segment
@@ -146,7 +161,7 @@ def test_pack_preserves_custom_base_policy() -> None:
146161
def test_parser_for_results_chain_as_bases() -> None:
147162
# parser_for's result is itself a valid base= -- packs applied in
148163
# two steps accumulate exactly like one call with both packs
149-
chained = parser_for(locales.TR_AZ, base=_RU_PACKED)
164+
chained = parser_for(locales.TR_AZ, base=_PACKED["ru"])
150165
assert chained.policy.patronymic_rules == frozenset(
151166
{PatronymicRule.EAST_SLAVIC, PatronymicRule.TURKIC})
152167
assert chained.parse("Сидоров Иван Петрович").given == "Иван"
@@ -199,14 +214,15 @@ def _default_corpus() -> list[str]:
199214
if c.locale is None and c.policy is None]
200215

201216

202-
# Synthetic rotator names: one per alternation branch of each pack's
203-
# marker regexes (both scripts), in the shape that makes the rule fire
204-
# (RU: family-first, patronymic last, plain middle; TR_AZ: a standalone
205-
# marker token). They serve the gate's POSITIVE side -- without them the
206-
# shared corpus exercises TR_AZ with a single name, leaving the gate
207-
# nearly vacuous (review finding, 2026-07-17). Branch named per row so a
208-
# regex edit that orphans a branch is visible here.
209-
_RU_ROTATORS = [
217+
# Synthetic rotator names, keyed by pack code: one per alternation
218+
# branch of each pack's marker regexes (both scripts), in the shape that
219+
# makes the rule fire (RU: family-first, patronymic last, plain middle;
220+
# TR_AZ: a standalone marker token). They serve the gate's POSITIVE side
221+
# -- without them the shared corpus exercises TR_AZ with a single name,
222+
# leaving the gate nearly vacuous (review finding, 2026-07-17). Branch
223+
# named per row, enforced by the branch-coverage test below.
224+
_ROTATORS: dict[str, list[str]] = {}
225+
_ROTATORS["ru"] = [
210226
"Sidorov Ivan Petrovich", # ovich
211227
"Sidorova Anna Petrovna", # ovna
212228
"Karpov Oleg Sergeevich", # evich
@@ -228,7 +244,7 @@ def _default_corpus() -> list[str]:
228244
"Орлов Семён Фомич", # фомич
229245
"Зайцев Андрей Фокич", # фокич
230246
]
231-
_TR_AZ_ROTATORS = [
247+
_ROTATORS["tr_az"] = [
232248
"Aliyev Ali Vali oglu", # oglu
233249
"Aliyev Rashad Vali oğlu", # oğlu
234250
"Mammadov Elchin Hasan ogly", # ogly
@@ -257,6 +273,20 @@ def _default_corpus() -> list[str]:
257273
]
258274

259275

276+
def test_registry_is_the_pack_contract() -> None:
277+
# spec §2 authoring requirement 3, enforced structurally (design
278+
# note 2026-07-18, option C): every registered pack module must
279+
# declare its deviation surface, and every pack must feed the gate's
280+
# positive side -- a new pack fails HERE until it ships both, rather
281+
# than a reviewer remembering to extend the gate by hand.
282+
assert set(_PACKS) == set(locales.available())
283+
for code, module in _PACKS.items():
284+
assert callable(getattr(module, "DEVIATES", None)), (
285+
f"pack {code!r} does not declare DEVIATES")
286+
assert set(_ROTATORS) == set(_PACKS), (
287+
"every pack needs a rotator list (and only registered packs)")
288+
289+
260290
def _alternation_branches(pattern: str) -> list[str]:
261291
"""Split a marker regex of the shape '(a|b|...)$' / '^(a|b|...)$'
262292
into its alternatives. The packs' character classes contain no '|',
@@ -268,64 +298,60 @@ def _alternation_branches(pattern: str) -> list[str]:
268298
return inner[1:-1].split("|")
269299

270300

271-
@pytest.mark.parametrize("regex, rotators, anchored", [
272-
(_ru._EAST_SLAVIC, _RU_ROTATORS, False),
273-
(_ru._EAST_SLAVIC_CYR, _RU_ROTATORS, False),
274-
(_tr_az._TURKIC, _TR_AZ_ROTATORS, True),
275-
(_tr_az._TURKIC_CYR, _TR_AZ_ROTATORS, True),
276-
], ids=["east-slavic", "east-slavic-cyr", "turkic", "turkic-cyr"])
277-
def test_rotators_cover_every_marker_branch(
278-
regex: re.Pattern, rotators: list[str], anchored: bool) -> None:
301+
@pytest.mark.parametrize("code", sorted(_PACKS))
302+
def test_rotators_cover_every_marker_branch(code: str) -> None:
279303
# The rotator lists' per-row branch comments are a human convention;
280304
# this enforces them mechanically: every alternation branch of every
281-
# marker regex must be hit by some rotator token, so a regex edit
282-
# that adds a branch (kept in sync with _post_rules by the pattern-
283-
# equality test above) fails HERE until a rotator name covers it.
284-
tokens = [tok for name in rotators for tok in name.split()]
285-
for branch in _alternation_branches(regex.pattern):
286-
shape = f"^({branch})$" if anchored else f"({branch})$"
287-
branch_re = re.compile(shape, re.I)
288-
assert any(branch_re.search(tok) for tok in tokens), (
289-
f"no rotator name exercises marker branch {branch!r}")
290-
291-
292-
@pytest.mark.parametrize("name", _RU_ROTATORS)
293-
def test_ru_rotator_deviates_and_declares(name: str) -> None:
305+
# marker regex a pack defines (any module-level re.Pattern) must be
306+
# hit by some rotator token, so a regex edit that adds a branch
307+
# (kept in sync with _post_rules by the pattern-equality test above)
308+
# fails HERE until a rotator name covers it. Anchoring follows the
309+
# pattern's own shape: '^(...)$' = whole-token marker, '(...)$' =
310+
# token ending.
311+
tokens = [tok for name in _ROTATORS[code] for tok in name.split()]
312+
patterns = [v for v in vars(_PACKS[code]).values()
313+
if isinstance(v, re.Pattern)]
314+
assert patterns, f"pack {code!r} defines no marker regexes"
315+
for regex in patterns:
316+
for branch in _alternation_branches(regex.pattern):
317+
anchored = regex.pattern.startswith("^")
318+
branch_re = re.compile(
319+
f"^({branch})$" if anchored else f"({branch})$", re.I)
320+
assert any(branch_re.search(tok) for tok in tokens), (
321+
f"no {code!r} rotator exercises marker branch {branch!r}")
322+
323+
324+
@pytest.mark.parametrize("code, name", [
325+
(code, name) for code in sorted(_ROTATORS)
326+
for name in _ROTATORS[code]])
327+
def test_rotator_deviates_and_declares(code: str, name: str) -> None:
294328
# every branch of the pack's marker regexes both FIRES (packed parse
295329
# differs from default) and is DECLARED (DEVIATES says so) -- the
296330
# per-name proof behind the gate's declared-count floor below
297-
assert _RU_PACKED.parse(name).as_dict() != _default_parse(name)
298-
assert _ru.DEVIATES(name)
299-
331+
assert _PACKED[code].parse(name).as_dict() != _default_parse(name)
332+
assert _PACKS[code].DEVIATES(name)
300333

301-
@pytest.mark.parametrize("name", _TR_AZ_ROTATORS)
302-
def test_tr_az_rotator_deviates_and_declares(name: str) -> None:
303-
assert _TR_AZ_PACKED.parse(name).as_dict() != _default_parse(name)
304-
assert _tr_az.DEVIATES(name)
305334

306-
307-
def test_non_interference_ru() -> None:
308-
corpus = _default_corpus() + _RU_ROTATORS
309-
declared = _assert_non_interference(_RU_PACKED, _ru.DEVIATES, corpus)
310-
# the positive side: every synthetic rotator (plus the corpus's own
311-
# east-slavic names) must actually flow through the declared branch
312-
assert declared >= len(_RU_ROTATORS)
313-
314-
315-
def test_non_interference_tr_az() -> None:
316-
corpus = _default_corpus() + _TR_AZ_ROTATORS
335+
@pytest.mark.parametrize("code", sorted(_PACKS))
336+
def test_non_interference_each_pack(code: str) -> None:
337+
corpus = _default_corpus() + _ROTATORS[code]
317338
declared = _assert_non_interference(
318-
_TR_AZ_PACKED, _tr_az.DEVIATES, corpus)
319-
assert declared >= len(_TR_AZ_ROTATORS)
339+
_PACKED[code], _PACKS[code].DEVIATES, corpus)
340+
# the positive side: every synthetic rotator (plus the corpus's own
341+
# in-scope names) must actually flow through the declared branch
342+
assert declared >= len(_ROTATORS[code])
320343

321344

322-
def test_non_interference_combined() -> None:
323-
corpus = _default_corpus() + _RU_ROTATORS + _TR_AZ_ROTATORS
345+
def test_non_interference_all_packs_combined() -> None:
346+
all_rotators = [n for code in sorted(_ROTATORS)
347+
for n in _ROTATORS[code]]
348+
corpus = _default_corpus() + all_rotators
349+
packed = parser_for(*(locales.get(code) for code in sorted(_PACKS)))
324350
declared = _assert_non_interference(
325-
_COMBINED_PACKED,
326-
lambda n: _ru.DEVIATES(n) or _tr_az.DEVIATES(n),
351+
packed,
352+
lambda n: any(m.DEVIATES(n) for m in _PACKS.values()),
327353
corpus)
328-
assert declared >= len(_RU_ROTATORS) + len(_TR_AZ_ROTATORS)
354+
assert declared >= len(all_rotators)
329355

330356

331357
# -- #269: non-Latin default vocabulary (Cyrillic, Greek, Arabic, Hebrew) --

0 commit comments

Comments
 (0)