diff --git a/CHANGELOG.md b/CHANGELOG.md index af86905..c46e7ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,55 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [3.0.16] - 2026-09 + +### Changed — surname prefix rule +- The "at most half of the surname" limit now counts the preserved ending + as well as the prefix. Before, only the prefix was capped, so with a long + ending most of the original stayed visible (`Мазуренка → Мазиденка`: + 7 of 9 letters unchanged, `Іванов → Іва…ов`: 5 of 6). Now prefix + + ending ≤ half of the base (nominative) form of the surname, and long + endings shorten the prefix: `Коваль → 3`, `Ґудзь → 2`, `Іванов → 1`, + `Кравчук → 1`, `Бондаренко → 1`, `Петренко → 0` (`-енко` alone is half + of the word, so the stem is fully synthetic). The budget is computed from + the base form, so all grammatical cases of one surname keep the same + prefix (`Іванов / Іванова / Івановим → 1`). +- `masking_rules.surname_prefix_length` still sets the upper bound (0 = + fully synthetic). Docs, config template and comments updated. +- Tests in `tests/test_surname_prefix.py` updated to the new table, plus + `test_prefix_plus_ending_at_most_half`. + +## [3.0.15] - 2026-09 + +### Fixed — surname masks +- The synthetic surname stem is now seeded from the lower-cased stem of the + original instead of its surface form. Before, `МАЗУРЕНКА`, `Мазуренка` + and `Мазуренко` (one person: upper case in the header, title case in the + body, different grammatical cases) got three unrelated masks + (`МАЗИДЕНКА` / `Мазісниченка` / `МАЗАНЕНКО`). Now all case and + grammatical-case forms share one synthetic stem and differ only by the + preserved ending and letter case (`МАЗІЖЕНКА` / `Мазіженка` / + `Мазіженко`). Unmask is unaffected (it relies on the mapping only); + masks of existing mapping files stay valid. +- Tests: `TestCaseAndFormConsistency` in `tests/test_surname_prefix.py`. + +## [3.0.14] - 2026-09 + +### Fixed — masking engine +- Several ranks / names on one line no longer corrupt each other. Masks were + substituted with `line.replace(original, mask, 1)` on the partially masked + line, so when the mask of one rank contained the form of another rank on + the same line (`рядовий → старший солдат`, `солдат → рядовий`) the second + substitution hit the freshly inserted mask: + `рядового МАЗУРЕНКА та солдата КОВАЛЕНКА` became + `старшого рядового МАЗИДЕНКА та солдата КОВИЛЕНКА` (a rank that does not + exist, the second rank left unmasked, and unmask restoring the wrong rank). + Replacements are now collected as numbered placeholders in the working + copy of the line and substituted once at the end, so a mask can never be + matched by a later replacement. Same fix for full-name (PIB) replacements. +- Tests: `tests/test_same_line_replacement.py` (cross-masked ranks on one + line, several PIBs per line, round-trip through unmask). + ## [3.0.13] - 2026-09 ### Changed — tooling diff --git a/CLAUDE.md b/CLAUDE.md index 9124fda..7e1b435 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,10 +68,12 @@ pip install -e '.[full]' && pip install -r requirements-dev.txt ## Інваріанти, які перевіряють тести — не ламати -- Маска прізвища: перші N символів оригіналу (N = `SURNAME_PREFIX_LENGTH`, - не більше половини слова, хоча б один символ основи змінюється) + синтетична - основа + закінчення; **ніколи** не містить оригінал, його основу чи слово - документа; детермінована від seed(оригінал). +- Маска прізвища: перші N символів оригіналу (N = `SURNAME_PREFIX_LENGTH`; + префікс + збережене закінчення ≤ половини базової форми прізвища, хоча б + один символ основи змінюється) + синтетична основа + закінчення; **ніколи** + не містить оригінал, його основу чи слово документа; детермінована від + seed(основа в нижньому регістрі) — усі регістри й відмінки одного прізвища + дають одну синтетичну основу. - Імена/по батькові ніколи не мапляться самі на себе. - `--encrypt` пише лише `.enc` (plaintext mapping не створюється), mapping — атомарно з правами 0600. diff --git a/data_masking.py b/data_masking.py index 1583e0e..e34b988 100644 --- a/data_masking.py +++ b/data_masking.py @@ -31,7 +31,7 @@ # Re-exports from masking package for backward compatibility # ============================================================================ -__version__ = "3.0.13" +__version__ = "3.0.16" from datamasking.masking.constants import ( __version__, __author__, __contact__, __phone__, __license__, __year__, diff --git a/datamasking/_version.py b/datamasking/_version.py index 7379aa5..4901d8c 100644 --- a/datamasking/_version.py +++ b/datamasking/_version.py @@ -9,4 +9,4 @@ (і не тягнучи faker під час збірки). """ -__version__ = "3.0.13" +__version__ = "3.0.16" diff --git a/datamasking/extras/config.py b/datamasking/extras/config.py index d151d1a..16a6d94 100644 --- a/datamasking/extras/config.py +++ b/datamasking/extras/config.py @@ -85,7 +85,8 @@ class MaskingRulesConfig: enable_orders: bool = True enable_br_numbers: bool = True # Скільки перших символів оригінального прізвища зберігати в масці - # (0 = не зберігати; для коротких прізвищ — не більше половини слова) + # (0 = не зберігати; разом зі збереженим закінченням — не більше половини + # прізвища: Коваль → 3, Іванов → 1, Петренко → 0) surname_prefix_length: int = 3 # Tuning parameters rank_shift_options: List[int] = field(default_factory=lambda: [-2, -1, 1, 2]) @@ -512,8 +513,9 @@ def generate_default_config(output_path: str = "config.yaml") -> str: # -------------------------------------------------------------------------- masking_rules: # How many leading characters of the ORIGINAL surname to keep in its mask - # (0 = none). Short surnames keep at most half of the word: - # Петренко -> Пет…енко, Ґудзь -> Ґу… ENV: DATA_MASKING_SURNAME_PREFIX_LENGTH + # (0 = none). Prefix plus the preserved ending never exceed half of the + # surname: Коваль -> Ков…, Іванов -> І…ов, Петренко -> …енко (the ending + # alone is half the word). ENV: DATA_MASKING_SURNAME_PREFIX_LENGTH surname_prefix_length: 3 # Military ranks (with declension and case preservation) diff --git a/datamasking/masking/constants.py b/datamasking/masking/constants.py index 6ef652a..b73597c 100644 --- a/datamasking/masking/constants.py +++ b/datamasking/masking/constants.py @@ -44,7 +44,8 @@ fake_uk_fallback = fake_uk # Скільки перших символів оригінального прізвища зберігати в масці -# (0 = не зберігати). Для коротких прізвищ — не більше половини слова. +# (0 = не зберігати). Разом зі збереженим закінченням — не більше половини +# прізвища (Коваль → 3, Іванов → 1, Петренко → 0; див. surname.prefix_length_for). # Конфіг: masking_rules.surname_prefix_length / DATA_MASKING_SURNAME_PREFIX_LENGTH SURNAME_PREFIX_LENGTH = 3 diff --git a/datamasking/masking/engine.py b/datamasking/masking/engine.py index 56b4a81..b86c8a3 100644 --- a/datamasking/masking/engine.py +++ b/datamasking/masking/engine.py @@ -414,25 +414,32 @@ def _add_skip(item) -> None: continue iteration = 0 + # Заміни збираються як нумеровані плейсхолдери в РОБОЧІЙ копії рядка, + # а не підставляються одразу в final_line через str.replace(x, mask, 1): + # так «перше входження» могло влучити в уже вставлену маску. Приклад: + # «рядового МАЗУРЕНКА та солдата КОВАЛЕНКА» → «рядового»→«старшого + # солдата», далі «солдата»→«рядового» замінювало «солдата» всередині + # щойно вставленого «старшого солдата» → «старшого рядового» (такого + # звання немає), а справжнє «солдата» лишалось відкритим. current_line_for_parsing = line - final_line = line + placeholders: List[Tuple[str, str]] = [] + + def _hold(kind: str, value: str) -> str: + token = f"___{kind}_MASKED_{len(placeholders) + 1}___" + placeholders.append((token, value)) + return token while iteration < 10: rank, pib, identifier = parse_hybrid_line(current_line_for_parsing) if not pib: break # ПІБ має бути дослівно в рядку — інакше заміна не спрацює, а # mask_* уже запишуть сміття в mapping і цикл крутитиметься вхолосту - if pib not in final_line or pib not in current_line_for_parsing: + if pib not in current_line_for_parsing: break - if rank and not pib: - current_line_for_parsing = current_line_for_parsing.replace(rank, "___SKIP_RANK___", 1) - iteration += 1 - continue if rank and _cfg.MASK_RANKS: masked_rank_val = mask_rank_preserve_case(rank, masking_dict, instance_counters) - final_line = final_line.replace(rank, masked_rank_val, 1) - current_line_for_parsing = current_line_for_parsing.replace(rank, "___RANK_MASKED___", 1) + current_line_for_parsing = current_line_for_parsing.replace(rank, _hold("RANK", masked_rank_val), 1) if pib and _cfg.MASK_NAMES: parts = pib.split() @@ -447,7 +454,7 @@ def _add_skip(item) -> None: if isinstance(info, dict) and "masked_as" in info } if any(p.lower() in already_masked for p in parts[:2]): - current_line_for_parsing = current_line_for_parsing.replace(pib, "___PIB_MASKED___", 1) + current_line_for_parsing = current_line_for_parsing.replace(pib, _hold("PIB", pib), 1) iteration += 1 continue # «Іван ПЕТРЕНКО» (прізвище виділене капсом) → ім'я перше. @@ -471,15 +478,17 @@ def _add_skip(item) -> None: masked_patronymic = mask_patronymic(patronymic, gender, masking_dict, instance_counters) masked_pib_str += f" {masked_patronymic}" - final_line = final_line.replace(pib, masked_pib_str, 1) - current_line_for_parsing = current_line_for_parsing.replace(pib, "___PIB_MASKED___", 1) + current_line_for_parsing = current_line_for_parsing.replace(pib, _hold("PIB", masked_pib_str), 1) elif len(parts) == 1 and rank: # Звання + лише прізвище («рядовий Іванов прибув») — # раніше такий ПІБ узагалі не маскувався masked_surname = mask_surname(parts[0], masking_dict, instance_counters) - final_line = final_line.replace(pib, masked_surname, 1) - current_line_for_parsing = current_line_for_parsing.replace(pib, "___PIB_MASKED___", 1) + current_line_for_parsing = current_line_for_parsing.replace(pib, _hold("PIB", masked_surname), 1) iteration += 1 + + final_line = current_line_for_parsing + for token, value in placeholders: + final_line = final_line.replace(token, value, 1) masked_lines.append(final_line) text = '\n'.join(masked_lines) diff --git a/datamasking/masking/surname.py b/datamasking/masking/surname.py index dc7a5da..aba0eec 100644 --- a/datamasking/masking/surname.py +++ b/datamasking/masking/surname.py @@ -20,7 +20,9 @@ «Грицова Марія»). 4. Перевірки: маска ≠ оригінал, не містить оригінал/його основу, не збігається з жодною вже виданою маскою чи вже відомим оригіналом - (колізія зламала б unmask). Детерміновано від seed(оригінал). + (колізія зламала б unmask). Детерміновано від seed(основа в нижньому + регістрі): усі відмінкові форми й регістри одного прізвища дістають + одну синтетичну основу (МАЗУРЕНКА / Мазуренко / Мазуренку). """ import random @@ -210,18 +212,25 @@ def _random_stem(seed: int, target_len: int, prefix: str = "") -> str: def prefix_length_for(original: str, configured: Optional[int] = None) -> int: """Скільки перших символів оригіналу лишити в масці. - Правило (ТЗ): N з конфігу (SURNAME_PREFIX_LENGTH, типово 3), але для - коротких прізвищ — не більше половини слова: Петренко → 3, Ґудзь → 2, - Ткач → 2. Префікс не залежить від того, де починається закінчення, але - не заходить у нього (інакше закінчення не відновити граматично). + Правило (ТЗ): N з конфігу (SURNAME_PREFIX_LENGTH, типово 3), але з + оригіналу в масці лишається НЕ БІЛЬШЕ ПОЛОВИНИ прізвища — і префікс, + і збережене закінчення разом (v3.0.16; раніше закінчення не рахувалось, + і в «Мазуренка → Мазиденка» збігались 7 із 9 літер). Половина береться + від базової (називної) форми — основа + родинне закінчення, — щоб + префікс не залежав від відмінка (Іванов / Іванова / Івановим → 1). + + Коваль → 3, Ґудзь → 2, Ткач → 2, Іванов → 1, Кравчук → 1, + Бондаренко → 1, Петренко → 0 (закінчення «енко» уже половина слова). """ n = _cfg.SURNAME_PREFIX_LENGTH if configured is None else configured if n <= 0: return 0 - stem, _ending, _family = split_surname(original) + stem, _ending, family = split_surname(original) + base_len = len(stem) + len(family) + budget = base_len // 2 - len(family) # Хоча б один символ основи має змінитись (Лис-енко: основа «лис» — префікс # 2, не 3), інакше маска містить усю основу і no-leak відкидає всі спроби - return max(0, min(n, len(original) // 2, len(stem) - 1)) + return max(0, min(n, budget, len(stem) - 1)) def _leaks(masked: str, original: str, stem: str) -> bool: @@ -263,12 +272,16 @@ def synthesize_surname(original: str, forbidden: Optional[Set[str]] = None, """ forbidden = {f.lower() for f in (forbidden or set())} | _document_vocab stem, ending, family = split_surname(original) - base_seed = get_deterministic_seed(original) + # Seed — від основи в нижньому регістрі, а не від поверхневої форми: + # «рядового МАЗУРЕНКА» у шапці й «Мазуренко І.П.» у тексті — одна людина, + # тож МАЗУРЕНКА / Мазуренка / Мазуренко / Мазуренку мають діставати одну + # синтетичну основу (закінчення й регістр накладаються окремо) + base_seed = get_deterministic_seed(stem) prefix = original.lower()[:prefix_length_for(original, prefix_length)] last = "" for attempt in range(_ATTEMPTS): - seed = base_seed if attempt == 0 else get_deterministic_seed(f"{original}\x00{attempt}") + seed = base_seed if attempt == 0 else get_deterministic_seed(f"{stem}\x00{attempt}") new_stem = _pick_stem(seed, len(stem), family, ending, forbidden={stem}, prefix=prefix) masked = new_stem + ending last = masked diff --git a/docs/README.md b/docs/README.md index 2490056..37bbc78 100644 --- a/docs/README.md +++ b/docs/README.md @@ -185,8 +185,8 @@ Unmask правильно відновить обидва входження "Капітану на пенсії" → "Майору на пенсії" (давальний зберігається!) ``` -### Surname masks (v3.0.8) -A surname mask keeps the **first 3 characters** of the original (at most half of the word for short surnames), the rest is synthetic; the grammatical ending is preserved: `Петренку → Петаченку`, `Ґудзь → Ґузій`. Configure with `masking_rules.surname_prefix_length` (0 = fully synthetic) and the faker dictionaries with `system.faker_locale` (default `uk_UA`; grammar stays Ukrainian). +### Surname masks (v3.0.8, rule refined in v3.0.16) +A surname mask keeps up to the **first 3 characters** of the original, the rest is synthetic, and the grammatical ending is preserved. The prefix and the preserved ending together never exceed **half of the surname**, so long endings shorten the prefix: `Коваль → Ковар`, `Іванов → Іщенов`, `Ґудзь → Ґубко`, `Петренку → Єрченку` (the `-енко` ending is already half of the word). All letter-case and grammatical-case forms of one surname share one synthetic stem (`МАЗУРЕНКА / Мазуренко → ТЕЛІЖЕНКА / Теліженко`). Configure with `masking_rules.surname_prefix_length` (0 = fully synthetic) and the faker dictionaries with `system.faker_locale` (default `uk_UA`; grammar stays Ukrainian). ### Case Preservation ``` diff --git a/docs/README_UK.md b/docs/README_UK.md index 0c20c6e..f0c23d7 100644 --- a/docs/README_UK.md +++ b/docs/README_UK.md @@ -185,8 +185,8 @@ Unmask правильно відновить обидва входження "Капітану на пенсії" → "Майору на пенсії" (давальний зберігається!) ``` -### Маски прізвищ (v3.0.8) -Маска прізвища зберігає **перші 3 символи** оригіналу (для коротких — не більше половини слова), решта синтетична; відмінкове закінчення зберігається: `Петренку → Петаченку`, `Ґудзь → Ґузій`. Налаштування: `masking_rules.surname_prefix_length` (0 = повністю синтетична) та словники faker через `system.faker_locale` (типово `uk_UA`; морфологія лишається українською). +### Маски прізвищ (v3.0.8, правило уточнено у v3.0.16) +Маска прізвища зберігає до **перших 3 символів** оригіналу, решта синтетична, відмінкове закінчення зберігається. Префікс разом зі збереженим закінченням ніколи не перевищують **половини прізвища**, тож довгі закінчення скорочують префікс: `Коваль → Ковар`, `Іванов → Іщенов`, `Ґудзь → Ґубко`, `Петренку → Єрченку` (закінчення `-енко` — уже половина слова). Усі регістри й відмінкові форми одного прізвища дістають одну синтетичну основу (`МАЗУРЕНКА / Мазуренко → ТЕЛІЖЕНКА / Теліженко`). Налаштування: `masking_rules.surname_prefix_length` (0 = повністю синтетична) та словники faker через `system.faker_locale` (типово `uk_UA`; морфологія лишається українською). ### Збереження регістру ``` diff --git a/tests/test_same_line_replacement.py b/tests/test_same_line_replacement.py new file mode 100644 index 0000000..11a90d8 --- /dev/null +++ b/tests/test_same_line_replacement.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Кілька звань/ПІБ в одному рядку: заміни не мають влучати одна в одну (v3.0.14). + +До 3.0.14 рушій підставляв маску через final_line.replace(rank, mask, 1) — +«перше входження» в уже частково замаскованому рядку. Коли маска одного +звання містила форму іншого звання з того ж рядка (рядовий → старший солдат, +солдат → рядовий), друга заміна псувала першу: + + довідках №273 рядового МАЗУРЕНКА та солдата КОВАЛЕНКА + → довідках №857 старшого рядового МАЗИДЕНКА та солдата КОВИЛЕНКА + +«старшого рядового» — неіснуюче звання, «солдата» лишилось відкритим, +а unmask повертав «старшого солдата» замість «рядового». +""" +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from datamasking.masking.mask_military import get_rank_in_case # noqa: E402 +from datamasking.unmasking.engine import unmask_text_v2 # noqa: E402 +from datamasking.unmasking.helpers import check_mapping_version # noqa: E402 +from tests.test_initials import mask # noqa: E402 + + +def _roundtrip(text: str): + masked, md = mask(text) + restored, _ = unmask_text_v2(masked, md, check_mapping_version(md)) + return masked, md, restored + + +class TestCrossMaskedRanksOnOneLine: + LINE = "довідках №273 рядового МАЗУРЕНКА та солдата КОВАЛЕНКА" + + def test_masks_are_crossed_in_this_fixture(self): + # Фікстура має сенс лише поки маски перехрещуються: маска «рядовий» + # містить «солдат», а маска «солдат» — «рядовий». Якщо ієрархія/зсув + # зміняться, тест треба перебудувати + _, md, _ = _roundtrip(self.LINE) + ranks = md["mappings"]["rank"] + assert "солдат" in ranks["рядовий"]["masked_as"] + assert "рядовий" in ranks["солдат"]["masked_as"] + + def test_each_rank_gets_its_own_mask(self): + masked, md, _ = _roundtrip(self.LINE) + ranks = md["mappings"]["rank"] + first = get_rank_in_case(ranks["рядовий"]["masked_as"], "genitive") + second = get_rank_in_case(ranks["солдат"]["masked_as"], "genitive") + words = masked.split() + assert words[2:4] == first.split(), masked + assert words[6:7] == second.split(), masked + assert "старшого рядового" not in masked + assert "солдата КОВ" not in masked # друге звання не лишилось відкритим + + def test_no_original_leaks(self): + masked, _, _ = _roundtrip(self.LINE) + for leak in ["МАЗУРЕНКА", "КОВАЛЕНКА", "№273"]: + assert leak not in masked + + def test_roundtrip(self): + _, _, restored = _roundtrip(self.LINE) + assert restored == self.LINE + + def test_reverse_order_roundtrip(self): + line = "рапорт солдата КОВАЛЕНКА щодо рядового МАЗУРЕНКА" + masked, _, restored = _roundtrip(line) + assert "старшого рядового" not in masked + assert restored == line + + +class TestManyItemsOnOneLine: + @pytest.mark.parametrize("line", [ + "рядовий Іванов Іван Іванович, солдат Петров Петро Петрович, старший солдат Сидоров Сидір Сидорович", + "рядового Мазуренка та солдата Коваленка і старшого солдата Бондаренка", + "капітан Петренко Іван Іванович та капітан Іванов Петро Петрович", + "старший солдат Ткач Олег Ігорович; рядовий Ґудзь Ігор Олегович", + ]) + def test_roundtrip_and_no_leak(self, line): + masked, md, restored = _roundtrip(line) + assert restored == line + for original in md["mappings"]["surname"]: + assert original not in masked + + def test_same_rank_twice(self): + line = "рядовий Іванов Іван Іванович та рядовий Петров Петро Петрович" + masked, md, restored = _roundtrip(line) + assert restored == line + masked_rank = md["mappings"]["rank"]["рядовий"]["masked_as"] + assert masked.count(masked_rank) == 2 + + def test_no_placeholder_survives(self): + for line in ["рядовий Іванов Іван Іванович та солдат Петров", "капітан Петренко Іван"]: + masked, _, _ = _roundtrip(line) + assert "___" not in masked diff --git a/tests/test_surname_prefix.py b/tests/test_surname_prefix.py index 209bacc..683380b 100644 --- a/tests/test_surname_prefix.py +++ b/tests/test_surname_prefix.py @@ -5,9 +5,11 @@ Правила: - маска зберігає перші N символів оригіналу (N = masking_rules.surname_prefix_length, - типово 3; 0 = вимкнено), для коротких прізвищ — не більше половини слова; - - префікс не залежить від відмінкового закінчення, але закінчення й далі - зберігається; оригінал у масці не з'являється; + типово 3; 0 = вимкнено); префікс разом зі збереженим закінченням — не + більше половини базової форми прізвища (v3.0.16), тож довгі закінчення + скорочують префікс: Коваль → 3, Іванов → 1, Петренко → 0; + - префікс однаковий для всіх відмінкових форм; закінчення зберігається; + оригінал у масці не з'являється; - system.faker_locale перемикає словники faker (морфологія лишається uk). """ import sys @@ -47,17 +49,28 @@ def sm(word: str) -> str: class TestPrefixLength: @pytest.mark.parametrize("word,expected", [ - ("Петренко", 3), ("Іванов", 3), ("Іванова", 3), ("Сидоренко", 3), - ("Ґудзь", 2), ("Ткач", 2), ("Коваль", 3), ("Рак", 1), ("Шамрай", 3), - ("Петренку", 3), ("Ковальського", 3), + ("Коваль", 3), ("Шамрай", 3), ("Ґудзь", 2), ("Ткач", 2), ("Рак", 1), + ("Іванов", 1), ("Іванова", 1), ("Івановим", 1), ("Кравчук", 1), ("Кравчуком", 1), + ("Мельник", 1), ("Коломієць", 1), ("Бондаренко", 1), + ("Петренко", 0), ("Петренку", 0), ("Сидоренко", 0), ("Лисенко", 0), ("Ковальського", 0), ]) def test_default_three_capped_at_half(self, word, expected): assert prefix_length_for(word) == expected + @pytest.mark.parametrize("word", [ + "Коваль", "Шамрай", "Ґудзь", "Ткач", "Рак", "Іванов", "Іванова", "Кравчук", + "Мельник", "Коломієць", "Бондаренко", "Петренко", "Ковальського", "Мазуренка", + ]) + def test_prefix_plus_ending_at_most_half(self, word): + stem, _ending, family = split_surname(word) + p = prefix_length_for(word, 10) + assert p + len(family) <= (len(stem) + len(family)) // 2 or p == 0 + def test_configured_two(self): - assert prefix_length_for("Петренко", 2) == 2 + assert prefix_length_for("Коваль", 2) == 2 assert prefix_length_for("Ткач", 2) == 2 assert prefix_length_for("Рак", 2) == 1 + assert prefix_length_for("Іванов", 2) == 1 def test_zero_disables(self): assert prefix_length_for("Петренко", 0) == 0 @@ -94,13 +107,14 @@ def test_ending_still_preserved(self): assert sm(word).lower().endswith(ending) def test_hyphenated_each_part_keeps_prefix(self): - m = sm("Петренко-Іванова") + m = sm("Коваль-Іванова") a, b = m.split("-") - assert a.lower().startswith("пет") and b.lower().startswith("іва") + assert a.lower().startswith("ков") and b.lower().startswith("і") + assert "коваль" not in m.lower() and "іванова" not in m.lower() def test_case_preserved(self): - assert sm("ІВАНОВ").startswith("ІВА") and sm("ІВАНОВ").isupper() - assert sm("Іванов").startswith("Іва") + assert sm("КОВАЛЬ").startswith("КОВ") and sm("КОВАЛЬ").isupper() + assert sm("Коваль").startswith("Ков") def test_pronounceable_joint(self): # На стику префікса й хвоста не буває двох голосних чи трьох приголосних @@ -118,8 +132,8 @@ def test_prefix_zero_gives_fully_synthetic(self): def test_prefix_two(self): _cfg.SURNAME_PREFIX_LENGTH = 2 - assert sm("Петренко").lower().startswith("пе") - assert not sm("Петренко").lower().startswith("пет") or True # третя літера може випадково збігтись + assert sm("Коваль").lower().startswith("ко") + assert sm("Шамрай").lower().startswith("ша") def test_roundtrip_document(self): text = "\n".join(f"капітан {w} Іван Іванович" for w in ["Петренко", "Іванов", "Ґудзь", "Ткач", "Коваль-Сидоренко"]) @@ -130,8 +144,46 @@ def test_roundtrip_document(self): assert w not in m +class TestCaseAndFormConsistency: + """Одна людина — одна синтетична основа (v3.0.15): seed від основи в нижньому + регістрі, тож регістр (МАЗУРЕНКА / Мазуренка) і відмінок (Мазуренко / + Мазуренку) не дають різних «людей» у замаскованому документі.""" + + def test_case_variants_share_mask(self): + assert sm("МАЗУРЕНКА").lower() == sm("Мазуренка").lower() == sm("мазуренка") + assert sm("КОВАЛЬ").lower() == sm("Коваль").lower() + + def test_case_variants_keep_their_case(self): + assert sm("МАЗУРЕНКА").isupper() + assert sm("Мазуренка").istitle() + + @pytest.mark.parametrize("forms", [ + ["Мазуренко", "Мазуренка", "Мазуренку", "Мазуренком", "МАЗУРЕНКО"], + ["Іванов", "Іванова", "Іванову", "ІВАНОВИМ"], + ["Ковальський", "Ковальського", "Ковальському"], + ["Кравчук", "Кравчука", "Кравчуком"], + ]) + def test_case_forms_share_synthetic_stem(self, forms): + stems = {split_surname(sm(f))[0] for f in forms} + assert len(stems) == 1, stems + # і закінчення кожної форми збережено + for f in forms: + assert sm(f).lower().endswith(split_surname(f)[1]) + + def test_within_one_document(self): + text = "довідках №273 рядового МАЗУРЕНКА\nкапітан Мазуренко Іван Іванович\nрапорт Мазуренку Івану" + masked, md = mask(text) + masks = {k: v["masked_as"] for k, v in md["mappings"]["surname"].items()} + assert set(masks) >= {"МАЗУРЕНКА", "Мазуренко"} + stems = {split_surname(m)[0] for m in masks.values()} + assert len(stems) == 1, masks + assert masks["МАЗУРЕНКА"].isupper() and masks["Мазуренко"].istitle() + r, _ = unmask_text_v2(masked, md, check_mapping_version(md)) + assert r == text + + class TestConfigWiring: - SAMPLE = "капітан Петренко Іван Сергійович\nсержант Бондаренко Марія Іванівна\n" + SAMPLE = "капітан Коваль Іван Сергійович\nсержант Бондаренко Марія Іванівна\n" def _run(self, tmp_path, monkeypatch, extra_args=(), yaml_text=None, env=None): monkeypatch.chdir(tmp_path) @@ -152,7 +204,7 @@ def test_prefix_from_yaml(self, tmp_path, monkeypatch): def test_prefix_from_env(self, tmp_path, monkeypatch): rc, out = self._run(tmp_path, monkeypatch, env={"DATA_MASKING_SURNAME_PREFIX_LENGTH": "2"}) assert rc == 0 and _cfg.SURNAME_PREFIX_LENGTH == 2 - assert "капітан Пе" in out or "Пе" in out.split()[1] + assert out.splitlines()[0].split()[1].startswith("Ко") @needs_yaml def test_negative_prefix_rejected(self, tmp_path, monkeypatch): @@ -162,8 +214,8 @@ def test_negative_prefix_rejected(self, tmp_path, monkeypatch): def test_default_prefix_visible_in_cli_output(self, tmp_path, monkeypatch): rc, out = self._run(tmp_path, monkeypatch) assert rc == 0 - assert out.splitlines()[0].split()[1].startswith("Пет") - assert "Петренко" not in out + assert out.splitlines()[0].split()[1].startswith("Ков") + assert "Коваль" not in out class TestFakerLocale: @@ -176,14 +228,14 @@ def test_unknown_locale_rejected(self): assert _cfg.FAKER_LOCALE == "uk_UA" def test_switching_locale_changes_masks_deterministically(self): - a1 = synthesize_surname("Петренко") + a1 = synthesize_surname("Коваль") _cfg.set_faker_locale("ru_RU") - b1 = synthesize_surname("Петренко") - b2 = synthesize_surname("Петренко") + b1 = synthesize_surname("Коваль") + b2 = synthesize_surname("Коваль") assert b1 == b2 # детерміновано в межах локалі - assert b1.startswith("пет") and "петренко" not in b1 # префікс і no-leak діють + assert b1.startswith("ков") and "коваль" not in b1 # префікс і no-leak діють _cfg.set_faker_locale("uk_UA") - assert synthesize_surname("Петренко") == a1 + assert synthesize_surname("Коваль") == a1 def test_locale_without_patronymics_falls_back(self): _cfg.set_faker_locale("en_US")