docs(generator): путеводитель по генератору и живой проход make demo

- Зачем:
  - спека отвечает «почему», а жанра «как пройти устройство по коду»
    не было (#48); черновик в generator/README.md ждал выверки.
- Что:
  - новый набор docs/architecture/generator/: индекс с целевой картиной
    и mermaid-схемой плюс шесть поддокументов по темам тикета.
  - демо-модуль и цель make demo: канонический D0, все стадии цепочки
    на одном экране; тест-сторож проверяет стадии без прибитых чисел.
  - раздел «Как это работает» в generator/README.md сжат до ссылки
    на путеводитель, черновая врезка снята.
- Проверка:
  - из generator/: make lint, make typecheck, make test (430 passed),
    make demo; два слепых ревью и узкая перепроверка — находки закрыты.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
ddadminandClaude Fable 5 committed 2026-08-28 06:00:36 -04:00
1 parent 33aef73d0e
commit 0c466c082e
11 files changed
+392 -26

No files matched your search

+6 -1
View File
@@ -1,4 +1,4 @@
.PHONY: lint typecheck test docs inventory
.PHONY: lint typecheck test demo docs inventory
# --- Проверки ---
@@ -12,6 +12,11 @@ typecheck:
test:
uv run pytest
# --- Пример из путеводителя ---
demo:
uv run python -m clickstream_generator.demo
# --- Сборка из кода ---
docs:
+6 -25
View File
@@ -13,31 +13,11 @@
## Как это работает
> Раздел черновой. По сути он верен, но на понятность читателем со стороны не
> выверен: пишет его тот, кто держит устройство генератора в голове, а это
> худший судья понятности. Выверка — вместе с путеводителем, тикет #48.
Цепочка одна и всегда та же: корневое зерно → подпотоки по позиции в дереве
→ когорта дня (кто впервые пришёл, сколько раз вернётся, чей паспорт какой)
→ аудитория дня, то есть когорта плюс возвраты когорт окна → визиты этих кук
→ строки событий. Мир при этом ленив: когорта считается по требованию, а до
D0 живёт предыстория, поэтому любой день собирается сам по себе — прожитая
история для него не нужна и на него не влияет.
Главное правило для того, кто придёт следующим: **внутри подпотока порядок
бросков — часть контракта.** Броски раздаёт один генератор подряд, и k-й
бросок достаётся тому, кто спросил k-м. Приписать новый бросок в конец
функции безопасно: у прежних он ничего не отнимает. Вставить в середину —
значит сдвинуть все броски после него, а с ними и весь мир: события того же
дня станут другими, счётчики канонического мира разойдутся с описью, и
поймается это не ошибкой, а красным чеком. Ровно поэтому паспорта кук в
`plan.cohort` брошены последними, а личность человека приписана уже за ними:
куки, пары и паспорта остались теми же до байта.
Случайность целочисленная — только диапазоны и выбор по целым весам: готовые
распределения numpy расходятся между версиями и архитектурами, а обещано
побайтовое совпадение (спека, раздел 2). Отсюда `weights.py` вместо
`rng.choice` с вероятностями.
Корневое зерно ветвится на подпотоки, из них складываются когорты, аудитория
дня, визиты, строки событий и заказы. План поднимает состав мира по требованию,
а день-функция проживает один день без накопленного состояния. Код этой
цепочки, ее схема и запуск D0 собраны в
[путеводителе](../docs/architecture/generator/README.md).
## Что где лежит
@@ -104,6 +84,7 @@ D0 живёт предыстория, поэтому любой день соб
- `make test` — тесты генератора;
- `make lint` — ruff: проверка и формат;
- `make typecheck` — ty: проверка типов;
- `make demo` — пройти стадии канонического D0 на одном экране;
- `make docs` — пересобрать описание выгрузки;
- `make inventory` — пересобрать опись мира.
@@ -0,0 +1,62 @@
"""Короткий проход по цепочке генератора на каноническом дне D0."""
from collections import Counter
import numpy as np
from clickstream_generator import day, plan, seeds, world
DAY = 0
def _number(value: int) -> str:
"""Разделяет разряды числа для чтения с экрана."""
return f"{value:,}".replace(",", " ")
def render() -> str:
"""Собирает D0 и показывает его стадии без числового эталона."""
seed = seeds.CANONICAL_SEED
cohort = plan.cohort(seed, DAY)
audience = plan.audience(seed, DAY)
events = day.stream(seed, DAY)
_, rows_per_visit = np.unique(events.columns["VisitID"], return_counts=True)
event_types = Counter(str(value) for value in events.columns["EventType"])
smallest_visit = int(rows_per_visit.min())
largest_visit = int(rows_per_visit.max())
return "\n".join(
(
f"Зерно: {seed}",
"Подпотоки D0: состав мира → когорта; день → трафик, торговля, заказы",
(
f"Когорта D0: людей — {_number(cohort.people)}; "
f"кук — {_number(len(cohort.client_id))}"
),
(
f"Аудитория D0: кук — {_number(len(audience.client_id))}; "
f"когорты D-{world.RETURN_TAIL_DAYS}...D0"
),
(
f"Визиты по VisitID: групп — {_number(len(rows_per_visit))}; "
f"строк в группе — {smallest_visit}–{largest_visit}"
),
(
f"Строки событий: {_number(len(events))} "
f"(pageview — {_number(event_types['pageview'])}; "
f"add_to_cart — {_number(event_types['add_to_cart'])}; "
f"purchase — {_number(event_types['purchase'])}); "
f"заказов — {_number(len(events.orders))}"
),
)
)
def main() -> None:
"""Печатает проход по D0."""
print(render())
if __name__ == "__main__":
main()
+32
View File
@@ -0,0 +1,32 @@
"""Запускаемый путеводитель остается коротким и проходит всю цепочку."""
import subprocess
from pathlib import Path
def test_demo_runs_and_shows_the_whole_chain() -> None:
"""Сторожит команду и стадии, а числа мира оставляет описи."""
finished = subprocess.run(
["make", "--silent", "demo"],
check=False,
capture_output=True,
cwd=Path(__file__).parents[1],
text=True,
)
assert finished.returncode == 0, finished.stderr
lines = finished.stdout.splitlines()
assert [line.partition(":")[0] for line in lines] == [
"Зерно",
"Подпотоки D0",
"Когорта D0",
"Аудитория D0",
"Визиты по VisitID",
"Строки событий",
]
assert "когорты D-" in lines[3] and "...D0" in lines[3]
assert "строк в группе" in lines[4]
assert all(
label in lines[5]
for label in ("pageview", "add_to_cart", "purchase", "заказов")
)