feat(generator): добавлена команда слепков заказов

- Зачем:
  - задача #92 добавляет второй источник и показывает слепок как чистую функцию зерна и дня.
- Что:
  - добавлены окно, состояние на границе суток и канонические байты с настоящими миллисекундами.
  - добавлены команда snapshot, сдвиг D → D−1 и переиспользование проигранных дней.
  - опись дополнена хешами слепков и поведенческими тестами.
- Проверка:
  - в generator выполнены make lint, make typecheck и make test: 427 тестов.
This commit is contained in:
2026-08-18 17:12:49 +03:00
parent c11da3b252
commit 8e21381e13
11 changed files with 740 additions and 46 deletions
@@ -16,6 +16,11 @@
(раздел 5): это разные машины разной природы, и сложенные в одно число они
перестают что-либо говорить. Сериализация считается частью генерации: она
рождает те самые байты, которые сторожит опись.
**Слепки заказов идут третьим ходом того же проигрывателя** и живут по правилу
сдвига: прогон дня D отправляет слепок дня D−1 — ночная выгрузка бэкенда за
вчера. Правило записано здесь одно и целиком, потому что оно и есть разница
между двумя источниками: трекер шлёт сегодняшний день, бэкенд — вчерашний.
"""
import logging
@@ -26,6 +31,7 @@ import numpy as np
from numpy.typing import NDArray
from clickstream_generator import day as day_module
from clickstream_generator import orders as orders_module
from clickstream_generator import serialize
from clickstream_generator.sinks import Sink
@@ -103,6 +109,70 @@ def play(
)
def play_snapshots(sink: Sink, seed: int, first_day: int, days: int = 1) -> None:
"""Отправить слепки прогонов `days` дней подряд начиная с `first_day`.
Прогон дня D отправляет слепок дня D−1: содержимое слепка — чистая функция
зерна и дня, от момента отправки оно не зависит, а сдвиг делает живой день
обычным. На старте оси вчера нет, поэтому прогон дня 0 не отправляет
ничего.
Слепок собирается переигровкой дней своего окна: заказы дня — производная
всей воронки, дешёвого пути к ним нет. Окна соседних слепков перекрываются
почти целиком, и внутри одного прогона день играется один раз — иначе
стартовый диапазон стоил бы полусотни проигрышей вместо восьми. Между
прогонами не остаётся ничего: кэш на томе был бы состоянием, которого у
проигрывателя нет.
"""
played: dict[int, orders_module.Orders] = {}
sent = 0
generated = 0.0
delivered = 0.0
for number in range(first_day, first_day + days):
taken = number - 1
if taken < 0:
continue
clock = time.monotonic()
window = [
_orders_of(played, seed, born) for born in orders_module.window(taken)
]
payloads = serialize.orders(window, taken)
spent = time.monotonic() - clock
generated += spent
log.info(
"слепок дня %d: заказов %d, генерация %.1f с", taken, len(payloads), spent
)
clock = time.monotonic()
_send(sink, payloads)
sink.flush()
spent = time.monotonic() - clock
delivered += spent
sent += len(payloads)
log.info(
"слепок дня %d: отправлено %d, доставка %.1f с", taken, len(payloads), spent
)
log.info(
"итого отправлено %d заказов: генерация %.1f с, доставка %.1f с",
sent,
generated,
delivered,
)
def _orders_of(
played: dict[int, orders_module.Orders], seed: int, number: int
) -> orders_module.Orders:
"""Заказы дня `number`, сыгранного один раз на весь прогон."""
if number not in played:
played[number] = day_module.stream(seed, number).orders
return played[number]
def _send(sink: Sink, payloads: list[bytes]) -> None:
"""Пакетно: подряд и без пауз."""
for payload in payloads: