11"""The locale pack layer (locales spec §2-3): lazy access, the two
222.0.0 packs, composition, and the non-interference gate."""
33import functools
4+ import importlib
45import json
56import re
67from collections .abc import Callable , Iterable
78from pathlib import Path
9+ from types import ModuleType
810
911import pytest
1012
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
116131def 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:
146161def 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+
260290def _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