From 164dfdf7896886dd21df1640f30bd265ea985d58 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 11:23:31 +0000 Subject: [PATCH 1/3] fix(engine): several ranks/PIBs on one line no longer corrupt each other (v3.0.14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Masks were substituted into the partially masked line with str.replace(original, mask, 1); when the mask of one rank contained the form of another rank on the same line, the second substitution hit the freshly inserted mask ("рядового ... солдата" -> "старшого рядового", second rank left unmasked, unmask restored the wrong rank). Replacements are now numbered placeholders in the working copy of the line and are substituted once at the end. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XT6iUWaQgahXDB9TWX9Bq7 Generated-With: Claude Code 2.1.42 --- CHANGELOG.md | 17 +++++ data_masking.py | 2 +- datamasking/_version.py | 2 +- datamasking/masking/engine.py | 35 +++++++---- tests/test_same_line_replacement.py | 98 +++++++++++++++++++++++++++++ 5 files changed, 139 insertions(+), 15 deletions(-) create mode 100644 tests/test_same_line_replacement.py diff --git a/CHANGELOG.md b/CHANGELOG.md index af86905..897ba75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ 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.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/data_masking.py b/data_masking.py index 1583e0e..5bb7b23 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.14" from datamasking.masking.constants import ( __version__, __author__, __contact__, __phone__, __license__, __year__, diff --git a/datamasking/_version.py b/datamasking/_version.py index 7379aa5..4d7cfa0 100644 --- a/datamasking/_version.py +++ b/datamasking/_version.py @@ -9,4 +9,4 @@ (і не тягнучи faker під час збірки). """ -__version__ = "3.0.13" +__version__ = "3.0.14" 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/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 From 9858ec29575cd881252635092d593a0f53b1d0e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 11:24:48 +0000 Subject: [PATCH 2/3] fix(surname): seed the synthetic stem from the lower-cased stem (v3.0.15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit МАЗУРЕНКА / Мазуренка / Мазуренко (one person in upper case, title case and different grammatical cases) got three unrelated masks because the seed was taken from the surface form. All forms now share one synthetic stem; ending and letter case are applied on top. Unmask is mapping-driven and unaffected. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XT6iUWaQgahXDB9TWX9Bq7 Generated-With: Claude Code 2.1.42 --- CHANGELOG.md | 14 +++++++++++++ data_masking.py | 2 +- datamasking/_version.py | 2 +- datamasking/masking/surname.py | 12 ++++++++--- tests/test_surname_prefix.py | 38 ++++++++++++++++++++++++++++++++++ 5 files changed, 63 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 897ba75..5b48355 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ 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.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 diff --git a/data_masking.py b/data_masking.py index 5bb7b23..0bf9ed8 100644 --- a/data_masking.py +++ b/data_masking.py @@ -31,7 +31,7 @@ # Re-exports from masking package for backward compatibility # ============================================================================ -__version__ = "3.0.14" +__version__ = "3.0.15" from datamasking.masking.constants import ( __version__, __author__, __contact__, __phone__, __license__, __year__, diff --git a/datamasking/_version.py b/datamasking/_version.py index 4d7cfa0..277ea7a 100644 --- a/datamasking/_version.py +++ b/datamasking/_version.py @@ -9,4 +9,4 @@ (і не тягнучи faker під час збірки). """ -__version__ = "3.0.14" +__version__ = "3.0.15" diff --git a/datamasking/masking/surname.py b/datamasking/masking/surname.py index dc7a5da..4be0842 100644 --- a/datamasking/masking/surname.py +++ b/datamasking/masking/surname.py @@ -20,7 +20,9 @@ «Грицова Марія»). 4. Перевірки: маска ≠ оригінал, не містить оригінал/його основу, не збігається з жодною вже виданою маскою чи вже відомим оригіналом - (колізія зламала б unmask). Детерміновано від seed(оригінал). + (колізія зламала б unmask). Детерміновано від seed(основа в нижньому + регістрі): усі відмінкові форми й регістри одного прізвища дістають + одну синтетичну основу (МАЗУРЕНКА / Мазуренко / Мазуренку). """ import random @@ -263,12 +265,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/tests/test_surname_prefix.py b/tests/test_surname_prefix.py index 209bacc..9c3c8d6 100644 --- a/tests/test_surname_prefix.py +++ b/tests/test_surname_prefix.py @@ -130,6 +130,44 @@ 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" From c6db24ed6e788fa65a13cd021a506129b8b4bffb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 11:27:25 +0000 Subject: [PATCH 3/3] feat(surname): count the preserved ending against the "half of the surname" limit (v3.0.16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the prefix was capped at half of the word, so with a long ending most of the original stayed visible (Мазуренка -> Мазиденка kept 7 of 9 letters). Prefix + preserved ending are now limited to half of the base form of the surname; the budget is computed from the base form so all grammatical cases keep the same prefix. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XT6iUWaQgahXDB9TWX9Bq7 Generated-With: Claude Code 2.1.42 --- CHANGELOG.md | 18 ++++++++++ CLAUDE.md | 10 +++--- data_masking.py | 2 +- datamasking/_version.py | 2 +- datamasking/extras/config.py | 8 +++-- datamasking/masking/constants.py | 3 +- datamasking/masking/surname.py | 19 +++++++---- docs/README.md | 4 +-- docs/README_UK.md | 4 +-- tests/test_surname_prefix.py | 58 ++++++++++++++++++++------------ 10 files changed, 86 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b48355..c46e7ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ 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 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 0bf9ed8..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.15" +__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 277ea7a..4901d8c 100644 --- a/datamasking/_version.py +++ b/datamasking/_version.py @@ -9,4 +9,4 @@ (і не тягнучи faker під час збірки). """ -__version__ = "3.0.15" +__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/surname.py b/datamasking/masking/surname.py index 4be0842..aba0eec 100644 --- a/datamasking/masking/surname.py +++ b/datamasking/masking/surname.py @@ -212,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: 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_surname_prefix.py b/tests/test_surname_prefix.py index 9c3c8d6..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 ["Петренко", "Іванов", "Ґудзь", "Ткач", "Коваль-Сидоренко"]) @@ -169,7 +183,7 @@ def test_within_one_document(self): 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) @@ -190,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): @@ -200,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: @@ -214,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")