fix(generator): правки по линии ревью Кодекса — зёрна, словарь, сторожа
- Зачем:
- третья линия ревью (Кодекс, другое семейство моделей) нашла дыру в
стороже неизменности когорты, отставший словарь и тест, который
сторожил не тот адрес дерева зёрен.
- Что:
- когорта отдаётся видом на замороженный массив: флаг только для
чтения вызывающий мог снять и испортить память, которой пользуются
все дни окна. Граница защиты названа в докстроке — от случайности,
не от умысла.
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
# Профиль возвратов по дням от первого визита: почти всё в первую неделю,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user