Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion data_masking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__,
Expand Down
2 changes: 1 addition & 1 deletion datamasking/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@
(і не тягнучи faker під час збірки).
"""

__version__ = "3.0.13"
__version__ = "3.0.16"
8 changes: 5 additions & 3 deletions datamasking/extras/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion datamasking/masking/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
35 changes: 22 additions & 13 deletions datamasking/masking/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
# «Іван ПЕТРЕНКО» (прізвище виділене капсом) → ім'я перше.
Expand All @@ -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)
Expand Down
31 changes: 22 additions & 9 deletions datamasking/masking/surname.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
«Грицова Марія»).
4. Перевірки: маска ≠ оригінал, не містить оригінал/його основу,
не збігається з жодною вже виданою маскою чи вже відомим оригіналом
(колізія зламала б unmask). Детерміновано від seed(оригінал).
(колізія зламала б unmask). Детерміновано від seed(основа в нижньому
регістрі): усі відмінкові форми й регістри одного прізвища дістають
одну синтетичну основу (МАЗУРЕНКА / Мазуренко / Мазуренку).
"""

import random
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
Loading
Loading