From d17bd4e876e774b9a51a1f08270ff1b08d8e6353 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sun, 2 Aug 2026 13:13:41 +0300 Subject: [PATCH] =?UTF-8?q?fix(generator):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D0=BB=D0=B8=D0=BD=D0=B8=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=D0=9A=D0=BE=D0=B4=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=D0=B0=20=E2=80=94=20=D0=B7=D1=91=D1=80=D0=BD=D0=B0,=20?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D0=B2=D0=B0=D1=80=D1=8C,=20=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=BE=D0=B6=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - третья линия ревью (Кодекс, другое семейство моделей) нашла дыру в стороже неизменности когорты, отставший словарь и тест, который сторожил не тот адрес дерева зёрен. - Что: - когорта отдаётся видом на замороженный массив: флаг только для чтения вызывающий мог снять и испортить память, которой пользуются все дни окна. Граница защиты названа в докстроке — от случайности, не от умысла. - CONTEXT.md и комментарий `RETURN_TAIL_DAYS`: окно активности — от первого визита человека, общее на обе куки (спека это уже говорила, словарь отстал). - сторож предыстории сверял день −N с днём N, а сталкиваются −N и N−1; тем же классом слепоты страдали сторожа независимости состава и дня и различия компонентов — все три переписаны на сверку со всем куском адресов, куда подпоток мог бы попасть. - Проверка: - make test (297), make lint, make typecheck; - батарея из 17 мутантов по plan/world/seeds — выживших нет; гоняется с PYTHONDONTWRITEBYTECODE=1: цикл правки и отката внутри одной секунды оставлял устаревший .pyc, и тесты шли по старому байт-коду. - Отклонено с доводом: - перепроверка настаивала, что сторож неизменности не закрыт: через `.base` вида владелец данных размораживается. Верно фактически, но закрывающего состояния у находки нет — владелец памяти в numpy размораживается всегда, а копия когорты на каждый вызов меняет 2 мс на 16 МиБ копирования и убивает смысл запоминания. Co-Authored-By: Claude Opus 5 (1M context) --- CONTEXT.md | 4 +- generator/src/clickstream_generator/plan.py | 14 ++++- generator/src/clickstream_generator/world.py | 7 +-- generator/tests/test_plan.py | 9 ++++ generator/tests/test_seeds.py | 57 +++++++++++++++----- 5 files changed, 71 insertions(+), 20 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 2c056fe..18a786c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -40,8 +40,8 @@ _Избегать_: состояние мира горизонтом и не совпадает с дневной. **Хвост возвратов**: -Окно активности куки, отсчитанное от её первого визита; дольше окна -кука не возвращается. +Окно активности, отсчитанное от первого визита человека и общее на обе +его куки; дольше окна кука не возвращается. **Предыстория**: Когорты плана с первым визитом до D0; событий не порождают. diff --git a/generator/src/clickstream_generator/plan.py b/generator/src/clickstream_generator/plan.py index ff31e08..d66d5dd 100644 --- a/generator/src/clickstream_generator/plan.py +++ b/generator/src/clickstream_generator/plan.py @@ -64,11 +64,23 @@ class Cohort: pair_order_days: NDArray[np.int64] def __post_init__(self) -> None: - """Когорта запоминается, поэтому массивы отдаются только на чтение.""" + """Когорта запоминается, поэтому массивы отдаются только на чтение. + + Не флагом на самом массиве, а видом на замороженный: флаг + вызывающий снял бы и сам, а испорченную когорту получили бы потом + все дни окна. Вид данных не копирует — платы за это нет. + + Граница у защиты честная: полной неприкосновенности numpy не даёт — + добравшись до владельца данных через `.base`, разморозить можно + что угодно. Это защита от случайной записи и короткого пути, но не + от умысла; умысел закрывался бы копией когорты на каждый вызов — + 16 МиБ на день вместо двух миллисекунд. + """ for field in fields(self): value = getattr(self, field.name) if isinstance(value, np.ndarray): value.flags.writeable = False + object.__setattr__(self, field.name, value[...]) @property def pairs(self) -> int: diff --git a/generator/src/clickstream_generator/world.py b/generator/src/clickstream_generator/world.py index f74afbc..584f04a 100644 --- a/generator/src/clickstream_generator/world.py +++ b/generator/src/clickstream_generator/world.py @@ -43,9 +43,10 @@ ONE_SHOT_PERCENT = 75 # притоке 3 800 (спека, разделы 5 и 9). RETURN_COUNT_WEIGHTS = (25, 20, 15, 12, 9, 7, 5, 4, 2, 1) -# Хвост возвратов: окно активности куки от её первого визита. За краем окна -# кука не возвращается. Оно же — глубина предыстории: столько когорт живёт -# до D0, чтобы дневная аудитория была на полке с самого первого дня. +# Хвост возвратов: окно активности человека от его первого визита, общее на +# обе его куки. За краем окна кука не возвращается. Оно же — глубина +# предыстории: столько когорт живёт до D0, чтобы дневная аудитория была на +# полке с самого первого дня. RETURN_TAIL_DAYS = 90 # Профиль возвратов по дням от первого визита: почти всё в первую неделю, diff --git a/generator/tests/test_plan.py b/generator/tests/test_plan.py index 7dc1671..f25ba79 100644 --- a/generator/tests/test_plan.py +++ b/generator/tests/test_plan.py @@ -56,6 +56,15 @@ def test_counters_are_a_pure_function_of_the_seed(): assert first == plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) +def test_a_remembered_cohort_cannot_be_spoiled_from_outside(): + """Когорту помнят и раздают всем дням окна: править её нельзя никак.""" + cohort = plan.cohort(CANONICAL_SEED, 0) + with pytest.raises(ValueError): + cohort.client_id[0] = 42 + with pytest.raises(ValueError): + cohort.client_id.flags.writeable = True + + def test_another_seed_is_another_world(): ours = plan.cohort(CANONICAL_SEED, 3) theirs = plan.cohort(CANONICAL_SEED + 1, 3) diff --git a/generator/tests/test_seeds.py b/generator/tests/test_seeds.py index 55eb390..2cd8be6 100644 --- a/generator/tests/test_seeds.py +++ b/generator/tests/test_seeds.py @@ -55,25 +55,54 @@ def test_cohorts_of_different_days_are_independent(): def test_prehistory_does_not_collide_with_the_axis(): - """Дни до D0 отрицательны, позиция в дереве — нет: своя ветвь.""" - for depth in range(1, 5): - assert first_draws(cohort_stream(CANONICAL_SEED, -depth)) != first_draws( - cohort_stream(CANONICAL_SEED, depth) - ) + """Дни до D0 отрицательны, позиция в дереве — нет: своя ветвь. + + Сравнивать день −N с днём N мало: слейся эти ветви, столкнулись бы −N + и N−1 — глубину предыстория считает от единицы, а ось дни от нуля. + Поэтому каждый день предыстории сверяется со всем началом оси. + """ + axis = {tuple(first_draws(cohort_stream(CANONICAL_SEED, day))) for day in range(6)} + for depth in range(1, 6): + prehistoric = tuple(first_draws(cohort_stream(CANONICAL_SEED, -depth))) + assert prehistoric not in axis def test_day_components_do_not_share_randomness(): - draws = [ - first_draws(day_stream(CANONICAL_SEED, 3, component)) for component in Component - ] - assert len({tuple(draw) for draw in draws}) == len(Component) + """Четыре подпотока дня из спеки — и они четыре разных. - -def test_composition_and_day_are_separate_streams(): - """Состав мира ветвится сам по себе — день-функция его не сдвигает.""" - assert first_draws(cohort_stream(CANONICAL_SEED, 3)) != first_draws( - day_stream(CANONICAL_SEED, 3, Component.TRAFFIC) + Компоненты перечислены поимённо, а не обходом `Component`: слейся два + имени в одно значение, обход молча стал бы короче, и тест сверял бы + сам себя. + """ + components = ( + Component.TRAFFIC, + Component.COMMERCE, + Component.DISCREPANCIES, + Component.LATECOMERS, ) + assert len({int(component) for component in components}) == 4 + draws = { + tuple(first_draws(day_stream(CANONICAL_SEED, 3, component))) + for component in components + } + assert len(draws) == 4 + + +def test_composition_and_day_never_share_a_stream(): + """Состав мира ветвится сам по себе — день-функция его не сдвигает. + + Сверять день N с составом дня N мало: слейся эти ветви, столкнулись бы + состав дня K и K-й компонент дня 0 — номер дня в одном адресе стоит + там же, где номер компонента в другом. Поэтому каждый компонент + сверяется со всем куском состава, куда он мог бы попасть. + """ + composition = { + tuple(first_draws(cohort_stream(CANONICAL_SEED, day))) for day in range(-5, 6) + } + for day in range(5): + for component in Component: + stream = first_draws(day_stream(CANONICAL_SEED, day, component)) + assert tuple(stream) not in composition def test_another_seed_is_another_world():