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

- Зачем:
  - тикет #38: состав мира должен быть чистой функцией зерна, а счётчики
    будущего манифеста — известны до генерации хоть одного события.
- Что:
  - `world.py` — конфигурация мира одним модулем чистых данных: приток,
    недельная волна, профиль возвратов, доли покупателей и пар, D0.
  - `seeds.py` — иерархия подпотоков на `SeedSequence`: состав мира
    (ось и предыстория) отдельно от дней и их компонентов.
  - `plan.py` — ленивый план состава: когорта дня, аудитория дня из
    окна возвратов, гарантированные заказы пар, счётчики горизонта.
    Случайность — только целыми числами.
  - спека, раздел 1: вторая кука пары рождается по затухающему профилю
    возвратов; раздел 9: измеренные числа канонического мира, оценка
    накопленной аудитории поправлена с ≈60 до 68 тыс.
  - CONTEXT.md: термин «когорта дня»; README генератора — новые модули.
- Проверка:
  - make test (295 тестов), make lint, make typecheck;
  - тесты проверены мутациями: 12 подмен в плане и конфигурации,
    каждая роняет ровно свой тест.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 12:20:48 +03:00
co-authored by Claude Opus 5
parent 68a35bfdcb
commit abac6afe18
9 changed files with 807 additions and 3 deletions
@@ -0,0 +1,72 @@
"""Иерархия зёрен: откуда любая часть мира берёт свою случайность.
Дерево подпотоков (спека генератора, раздел 2):
корневое зерно
├── состав мира
│ ├── ось → номер дня: когорта этого дня
│ └── предыстория → глубина: когорта дня до D0
└── дни
└── номер дня → трафик, торговля, расхождения, опоздания
Механизм — `numpy.random.SeedSequence`: потомок полностью определяется парой
(зерно, позиция в дереве), а не порядком вычислений. Сверено через Context7
по документации numpy (2026-08-01) и проверено тестом: `spawn_key`, выписанный
руками, даёт тот же подпоток, что цепочка `spawn`. На этом держатся три
обещания: параллельный прогон равен последовательному, день N+1 не трогает
дни 1…N, правка одного компонента меняет только его часть снимка.
Позиция в дереве — неотрицательные целые, а дни предыстории отрицательны;
поэтому у предыстории своя ветвь, а не общий ряд с осью.
"""
from collections.abc import Sequence
from enum import IntEnum
import numpy as np
# Канонический зерно эталонного мира — константа репозитория; манифест хранит
# его в паспорте мира. Свои зёрна менти крутит без гарантий манифеста.
CANONICAL_SEED = 20260601
class Component(IntEnum):
"""Подпотоки внутри дня; порядок объявления — позиция в дереве."""
TRAFFIC = 0
COMMERCE = 1
DISCREPANCIES = 2
LATECOMERS = 3
class _Branch(IntEnum):
"""Две ветви корня: постоянный состав мира и проживание дней."""
COMPOSITION = 0
DAYS = 1
class _Era(IntEnum):
"""Две ветви состава: ось событий и предыстория до D0."""
AXIS = 0
PREHISTORY = 1
def _stream(seed: int, position: Sequence[int]) -> np.random.Generator:
"""Подпоток на позиции `position` дерева зерна `seed`."""
sequence = np.random.SeedSequence(entropy=seed, spawn_key=position)
return np.random.Generator(np.random.PCG64(sequence))
def cohort_stream(seed: int, day: int) -> np.random.Generator:
"""Случайность когорты дня `day`; отрицательный день — предыстория."""
era, index = (_Era.AXIS, day) if day >= 0 else (_Era.PREHISTORY, -day - 1)
return _stream(seed, (_Branch.COMPOSITION, era, index))
def day_stream(seed: int, day: int, component: Component) -> np.random.Generator:
"""Случайность одного компонента дня `day` — дня оси, не предыстории."""
if day < 0:
raise ValueError(f"события начинаются в D0: дня {day} на оси нет")
return _stream(seed, (_Branch.DAYS, day, component))