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
@@ -29,6 +29,11 @@
его место занимает −1, а не ноль, — иначе «оплатили в секунду рождения» было
бы не отличить от «не оплатили вовсе».
**Состояние на границе суток** — чтение готовой судьбы, а не накопление:
слепок дня D учитывает моменты не позже границы D|D+1 и по ним называет
статус. Отсюда «дыхание» окна — заказ, оплаченный назавтра, стоит в сегодняшнем
слепке как `created`, а завтра как `paid`.
**Дельта суммы — вычеркнутая позиция**: товара не оказалось в наличии, и
заказ приезжает на строку короче клиентской корзины, а `items_total` меньше
ровно на её полную стоимость. Момента у дельты нет — склад собрал заказ до
@@ -79,6 +84,10 @@ class Orders:
order_id: tuple[str, ...]
# Пользователь магазина: тот же человек, что стоит за купившей кукой.
user_id: NDArray[np.uint64]
# Когда строка заказа создана в базе источника: секунда той самой покупки
# — в модели строка создаётся синхронно с ней — и миллисекунда часов базы.
# От этого момента отсчитываются и моменты судьбы.
created_at: NDArray[np.datetime64]
# Позиции заказа: номера товаров каталога и штуки, ячейка на заказ. У
# заказа с дельтой позиций на одну меньше, чем в корзине клиента.
product: tuple[NDArray[np.int64], ...]
@@ -104,11 +113,13 @@ def of_day(seed: int, day: int, purchases: Purchases) -> Orders:
delivery = _delivery(rng, len(purchases))
outcome, paid_after, cancelled_after = _fate(rng, len(purchases))
product, quantity, items_total = _delta(rng, purchases)
created_at = _created_at(rng, purchases)
discount = _discount(purchases)
return Orders(
day=day,
order_id=purchases.order_id,
user_id=purchases.person_id,
created_at=created_at,
product=product,
quantity=quantity,
items_total=items_total,
@@ -121,6 +132,49 @@ def of_day(seed: int, day: int, purchases: Purchases) -> Orders:
)
def window(day: int) -> range:
"""Дни рождения, чьи заказы несёт слепок дня `day`.
Окно изменяемости — константа мира; у начала оси оно усекается само, а не
сторожем: дней до D0 попросту нет.
"""
return range(max(0, day - world.ORDER_WINDOW_DAYS + 1), day + 1)
def at_boundary(rows: Orders, day: int) -> tuple[list[str], NDArray[np.datetime64]]:
"""Статус заказов и момент их последнего изменения на границе `day`|`day+1`.
Судьба решена при рождении, поэтому слепок её только читает: момент позже
границы для него ещё не случился. Отмена перевешивает оплату — у дороги
«оплачен и отменён» она поздняя, и заказ на границе уже отменён.
`updated_at` — поздний учтённый момент, а без единого заказ показывает своё
рождение: строку с тех пор никто не трогал.
"""
edge = _boundary(day)
paid = rows.created_at + rows.paid_after.astype("timedelta64[s]")
cancelled = rows.created_at + rows.cancelled_after.astype("timedelta64[s]")
# Момента, которого у исхода нет, в данных нет вовсе: там −1, и без маски
# он прикинулся бы моментом за секунду до рождения.
got_paid = (rows.paid_after >= 0) & (paid <= edge)
got_cancelled = (rows.cancelled_after >= 0) & (cancelled <= edge)
status = np.where(got_cancelled, "cancelled", np.where(got_paid, "paid", "created"))
updated = np.where(
got_cancelled, cancelled, np.where(got_paid, paid, rows.created_at)
)
return status.tolist(), updated
def _boundary(day: int) -> np.datetime64:
"""Граница суток `day`|`day+1` абсолютной меткой: полночь пояса счётчика.
Модельные сутки считаются в поясе счётчика, а моменты заказа — метки UTC:
между ними ровно смещение пояса.
"""
midnight = np.datetime64(world.ORIGIN, "s") + np.timedelta64(day + 1, "D")
return midnight - np.timedelta64(world.COUNTER_TIMEZONE_MINUTES, "m")
def _delivery(rng: np.random.Generator, orders: int) -> NDArray[np.int64]:
"""Стоимость доставки каждого заказа дня — броском по таблице весов."""
return _DELIVERY_PRICE[pick(rng, _DELIVERY_CUMULATIVE, orders)]
@@ -147,6 +201,22 @@ def _fate(
return outcome, paid, cancelled
def _created_at(
rng: np.random.Generator, purchases: Purchases
) -> NDArray[np.datetime64]:
"""Момент создания строки заказа: секунда покупки и миллисекунда часов базы.
Секунда приходит из события — строка создаётся синхронно с покупкой, и
сдвигать её значило бы подделывать аудит источника. Миллисекунду трекер не
видит вовсе: у него своё разрешение, у базы своё, и три дописанных нуля
выдали бы секундную модель за миллисекундную (исследование формата слепка).
"""
millisecond = rng.integers(0, 1000, len(purchases))
return purchases.moment.astype("datetime64[ms]") + millisecond.astype(
"timedelta64[ms]"
)
def _moment(rng: np.random.Generator, orders: int) -> NDArray[np.int64]:
"""Момент внутри окна: час по таблице весов и равномерная секунда в нём.