feat(generator): план состава мира — зерно, приток, двухкуковые пары #46

Merged
ddmitry merged 5 commits from feat/38-world-plan into main 2026-08-02 13:25:58 +03:00
5 changed files with 71 additions and 20 deletions
Showing only changes of commit d17bd4e876 - Show all commits
+2 -2
View File
@@ -40,8 +40,8 @@ _Избегать_: состояние мира
горизонтом и не совпадает с дневной.
**Хвост возвратов**:
Окно активности куки, отсчитанное от её первого визита; дольше окна
кука не возвращается.
Окно активности, отсчитанное от первого визита человека и общее на обе
его куки; дольше окна кука не возвращается.
**Предыстория**:
Когорты плана с первым визитом до D0; событий не порождают.
+13 -1
View File
@@ -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:
+4 -3
View File
@@ -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
# Профиль возвратов по дням от первого визита: почти всё в первую неделю,
+9
View File
@@ -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)
+43 -14
View File
@@ -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
и N1 глубину предыстория считает от единицы, а ось дни от нуля.
Поэтому каждый день предыстории сверяется со всем началом оси.
"""
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():