feat(generator): откалиброван поток steady-stream генератора

- Зачем:
  - поток генератора должен соответствовать модели интенсивности и профилю сида перед реализацией состояния версии 2.
- Что:
  - событийный бюджет тика переведён в рождения визитов через ожидаемую среднюю длину визита.
  - дефолты интенсивности и обычный docker-compose запуск синхронизированы с целевыми 30 событиями в минуту.
  - добавлены статистические проверки длины визита, воронки, новых пользователей, межсессионных пауз и долгого окна потока.
  - обновлены README, OPERATIONS и карточка задачи 05.
- Проверка:
  - uv run --with-requirements generator/requirements.txt pytest generator/tests -q.
  - git diff --check.
This commit is contained in:
Dmitry Dementiev
2026-06-11 17:17:34 +03:00
parent d7c5a56675
commit 8f1e997cc9
11 changed files with 345 additions and 65 deletions
@@ -1,4 +1,4 @@
Status: ready-for-agent Status: ready-for-human
# Интенсивность и калибровка потока # Интенсивность и калибровка потока
@@ -19,16 +19,16 @@ Status: ready-for-agent
## Acceptance criteria ## Acceptance criteria
- [ ] Средняя интенсивность событий за длинное окно соответствует - [x] Средняя интенсивность событий за длинное окно соответствует
`GEN_LAMBDA_BASE_PER_MIN * часовой коэффициент` с разумным отклонением. `GEN_LAMBDA_BASE_PER_MIN * часовой коэффициент` с разумным отклонением.
- [ ] Рождения визитов рассчитываются из бюджета событий и средней длины визита, - [x] Рождения визитов рассчитываются из бюджета событий и средней длины визита,
а не из старых границ `GEN_MIN/MAX_EVENTS_PER_TICK` в прежнем смысле. а не из старых границ `GEN_MIN/MAX_EVENTS_PER_TICK` в прежнем смысле.
- [ ] Доля новых пользователей за длинное окно близка к `GEN_P_NEW_USER`. - [x] Доля новых пользователей за длинное окно близка к `GEN_P_NEW_USER`.
- [ ] Межсессионные паузы одного пользователя не меньше кулдауна, а среднее на - [x] Межсессионные паузы одного пользователя не меньше кулдауна, а среднее на
дефолтах находится в районе расчёта из спеки. дефолтах находится в районе расчёта из спеки.
- [ ] Распределение длины визита на симуляции сопоставимо с сидом: медиана около - [x] Распределение длины визита на симуляции сопоставимо с сидом: медиана около
10, среднее около 10, максимум не выше потолка. 10, среднее около 10, максимум не выше потолка.
- [ ] Воронка по шагам `/home -> товары -> /cart -> /payment -> /confirmation` - [x] Воронка по шагам `/home -> товары -> /cart -> /payment -> /confirmation`
монотонно затухает на длинной симуляции. монотонно затухает на длинной симуляции.
## Blocked by ## Blocked by
@@ -37,3 +37,19 @@ Status: ready-for-agent
## Comments ## Comments
2026-06-11: реализована калибровка потока через `TickStreamGenerator`: событийный
бюджет тика копится как бюджет рождений визитов через ожидаемую среднюю длину
визита (~10 событий), а фактические события выходят по запланированным паузам
активных визитов. Дефолт `GEN_LAMBDA_BASE_PER_MIN` в коде снижен до 30
событий/мин и синхронизирован с обычным запуском через `docker-compose.yml`;
`GEN_MIN/MAX_EVENTS_PER_TICK` теперь ограничивают событийный бюджет тика до
пересчёта в рождения визитов.
Калибровка таблицы переходов и пауз проверена тестами: средняя интенсивность
длинного окна близка к целевому бюджету, доля новых пользователей близка к
`GEN_P_NEW_USER`, межсессионные паузы не меньше кулдауна и имеют часовой
масштаб около формульного ориентира из спеки, длина визита сопоставима с сидом,
доля `/confirmation` остаётся около 25%, воронка монотонно затухает.
Проверка: `uv run --with-requirements generator/requirements.txt pytest
generator/tests -q` — 96 passed.
+2 -2
View File
@@ -293,9 +293,9 @@ services:
KAFKA_BOOTSTRAP_SERVERS: kafka:29092 KAFKA_BOOTSTRAP_SERVERS: kafka:29092
# Режим steady-stream: короткие тики 1-10 сек (по умолчанию 5) # Режим steady-stream: короткие тики 1-10 сек (по умолчанию 5)
GEN_TICK_SECONDS: "5" GEN_TICK_SECONDS: "5"
GEN_LAMBDA_BASE_PER_MIN: "200" GEN_LAMBDA_BASE_PER_MIN: "30"
GEN_JITTER_PCT: "20" GEN_JITTER_PCT: "20"
GEN_MIN_EVENTS_PER_TICK: "5" GEN_MIN_EVENTS_PER_TICK: "1"
GEN_MAX_EVENTS_PER_TICK: "50" GEN_MAX_EVENTS_PER_TICK: "50"
GEN_DATA_DIR: /data GEN_DATA_DIR: /data
GEN_ENABLED: "true" GEN_ENABLED: "true"
+3 -3
View File
@@ -93,10 +93,10 @@ make generator-logs
| Переменная | Описание | По умолчанию | | Переменная | Описание | По умолчанию |
|------------|----------|--------------| |------------|----------|--------------|
| `GEN_TICK_SECONDS` | Интервал между тиками | `5` | | `GEN_TICK_SECONDS` | Интервал между тиками | `5` |
| `GEN_LAMBDA_BASE_PER_MIN` | Базовая интенсивность (событий/мин) | `200` | | `GEN_LAMBDA_BASE_PER_MIN` | Базовая интенсивность (событий/мин) | `30` |
| `GEN_JITTER_PCT` | Процент вариативности | `20` | | `GEN_JITTER_PCT` | Процент вариативности | `20` |
| `GEN_MIN_EVENTS_PER_TICK` | Минимум событий за тик | `5` | | `GEN_MIN_EVENTS_PER_TICK` | Минимальный событийный бюджет тика | `1` |
| `GEN_MAX_EVENTS_PER_TICK` | Максимум событий за тик | `50` | | `GEN_MAX_EVENTS_PER_TICK` | Максимальный событийный бюджет тика | `50` |
### Топик истории ### Топик истории
+10 -5
View File
@@ -39,8 +39,8 @@ generator-service -> Kafka topics -> (потребители отдельно)
- Публикуем постепенно, **короткими тиками** (по умолчанию каждые 5 секунд) - Публикуем постепенно, **короткими тиками** (по умолчанию каждые 5 секунд)
- На каждом тике отправляем небольшую порцию сообщений - На каждом тике отправляем небольшую порцию сообщений
- Держим целевую интенсивность `events/min` без крупных минутных batch: - Держим целевую интенсивность `events/min` без крупных минутных batch:
рассчитанный событийный бюджет тика планирует новые визиты и списывается по рассчитанный событийный бюджет тика копится как бюджет рождения визитов через
фактической длине этих визитов, а события выходят позже по своим ожидаемую среднюю длину визита, а события выходят позже по своим
запланированным меткам времени запланированным меткам времени
- Распределяем события по 4 топикам: - Распределяем события по 4 топикам:
- `browser_events` - `browser_events`
@@ -63,10 +63,10 @@ generator-service -> Kafka topics -> (потребители отдельно)
|------------|----------|--------------| |------------|----------|--------------|
| `KAFKA_BOOTSTRAP_SERVERS` | Адрес Kafka | `kafka:29092` | | `KAFKA_BOOTSTRAP_SERVERS` | Адрес Kafka | `kafka:29092` |
| `GEN_TICK_SECONDS` | Интервал между тиками | `5` (1-10 сек рекомендуется) | | `GEN_TICK_SECONDS` | Интервал между тиками | `5` (1-10 сек рекомендуется) |
| `GEN_LAMBDA_BASE_PER_MIN` | Базовая интенсивность (событий/мин) | `200` | | `GEN_LAMBDA_BASE_PER_MIN` | Базовая интенсивность (событий/мин) | `30` |
| `GEN_JITTER_PCT` | Процент вариативности | `20` | | `GEN_JITTER_PCT` | Процент вариативности | `20` |
| `GEN_MIN_EVENTS_PER_TICK` | Минимум событий за тик | `5` | | `GEN_MIN_EVENTS_PER_TICK` | Минимальный событийный бюджет тика | `1` |
| `GEN_MAX_EVENTS_PER_TICK` | Максимум событий за тик | `50` | | `GEN_MAX_EVENTS_PER_TICK` | Максимальный событийный бюджет тика | `50` |
| `GEN_MAX_SESSION_EVENTS` | Потолок длины одного визита, защита от петель | `30` | | `GEN_MAX_SESSION_EVENTS` | Потолок длины одного визита, защита от петель | `30` |
| `GEN_MAX_ACTIVE_SESSIONS` | Потолок одновременных активных визитов | `200` | | `GEN_MAX_ACTIVE_SESSIONS` | Потолок одновременных активных визитов | `200` |
| `GEN_POPULATION_MAX` | Потолок активной популяции пользователей | `300` | | `GEN_POPULATION_MAX` | Потолок активной популяции пользователей | `300` |
@@ -88,6 +88,11 @@ GEN_MIN_EVENTS_PER_TICK=50
GEN_MAX_EVENTS_PER_TICK=500 GEN_MAX_EVENTS_PER_TICK=500
``` ```
`GEN_MIN_EVENTS_PER_TICK` и `GEN_MAX_EVENTS_PER_TICK` задают нижнюю и верхнюю
границы событийного бюджета тика. Этот бюджет сначала превращается в рождения
визитов через среднюю длину визита, поэтому фактическое число отправленных
событий в конкретном тике может отличаться.
## Управление через Makefile ## Управление через Makefile
```bash ```bash
+2 -1
View File
@@ -17,7 +17,7 @@ if str(SRC_DIR) not in sys.path:
from clickstream_generator.config import Config from clickstream_generator.config import Config
from clickstream_generator.dictionary import EventDictionary from clickstream_generator.dictionary import EventDictionary
from clickstream_generator.generation import EventGenerator from clickstream_generator.generation import EXPECTED_VISIT_EVENTS, EventGenerator
from clickstream_generator.intensity import calculate_events_count, hour_factor from clickstream_generator.intensity import calculate_events_count, hour_factor
from clickstream_generator.kafka_io import ( from clickstream_generator.kafka_io import (
BatchRecord, BatchRecord,
@@ -50,6 +50,7 @@ __all__ = [
"Config", "Config",
"EventDictionary", "EventDictionary",
"EventGenerator", "EventGenerator",
"EXPECTED_VISIT_EVENTS",
"GeneratorService", "GeneratorService",
"GeneratorState", "GeneratorState",
"KafkaBatchHistory", "KafkaBatchHistory",
@@ -16,13 +16,13 @@ class Config:
default_factory=lambda: int(os.getenv("GEN_TICK_SECONDS", "5")) default_factory=lambda: int(os.getenv("GEN_TICK_SECONDS", "5"))
) )
lambda_base_per_min: int = field( lambda_base_per_min: int = field(
default_factory=lambda: int(os.getenv("GEN_LAMBDA_BASE_PER_MIN", "200")) default_factory=lambda: int(os.getenv("GEN_LAMBDA_BASE_PER_MIN", "30"))
) )
jitter_pct: int = field( jitter_pct: int = field(
default_factory=lambda: int(os.getenv("GEN_JITTER_PCT", "20")) default_factory=lambda: int(os.getenv("GEN_JITTER_PCT", "20"))
) )
min_events_per_tick: int = field( min_events_per_tick: int = field(
default_factory=lambda: int(os.getenv("GEN_MIN_EVENTS_PER_TICK", "5")) default_factory=lambda: int(os.getenv("GEN_MIN_EVENTS_PER_TICK", "1"))
) )
max_events_per_tick: int = field( max_events_per_tick: int = field(
default_factory=lambda: int(os.getenv("GEN_MAX_EVENTS_PER_TICK", "50")) default_factory=lambda: int(os.getenv("GEN_MAX_EVENTS_PER_TICK", "50"))
@@ -22,51 +22,57 @@ PAGE_START_DISTRIBUTION = [
PAGE_TRANSITIONS = { PAGE_TRANSITIONS = {
"/home": [ "/home": [
("/home", 0.40), ("/home", 0.41),
("/product_a", 0.28), ("/product_a", 0.28),
("/product_b", 0.18), ("/product_b", 0.18),
("/cart", 0.04), ("/cart", 0.04),
(None, 0.10),
],
"/product_a": [
("/home", 0.30),
("/product_a", 0.16),
("/product_b", 0.18),
("/cart", 0.27),
(None, 0.09), (None, 0.09),
], ],
"/product_b": [ "/product_a": [
("/home", 0.32), ("/home", 0.34),
("/product_a", 0.15), ("/product_a", 0.13),
("/product_b", 0.18), ("/product_b", 0.18),
("/cart", 0.27), ("/cart", 0.27),
(None, 0.08), (None, 0.08),
], ],
"/product_b": [
("/home", 0.36),
("/product_a", 0.15),
("/product_b", 0.15),
("/cart", 0.27),
(None, 0.07),
],
"/cart": [ "/cart": [
("/home", 0.20), ("/home", 0.20),
("/product_a", 0.12), ("/product_a", 0.12),
("/product_b", 0.10), ("/product_b", 0.10),
("/cart", 0.10), ("/cart", 0.11),
("/payment", 0.42), ("/payment", 0.42),
(None, 0.06), (None, 0.05),
], ],
"/payment": [ "/payment": [
("/home", 0.12), ("/home", 0.12),
("/cart", 0.24), ("/cart", 0.24),
("/payment", 0.12), ("/payment", 0.14),
("/confirmation", 0.38), ("/confirmation", 0.38),
(None, 0.14), (None, 0.12),
], ],
"/confirmation": [ "/confirmation": [
("/home", 0.35), ("/home", 0.37),
("/product_a", 0.15), ("/product_a", 0.15),
("/product_b", 0.10), ("/product_b", 0.10),
("/confirmation", 0.05), ("/confirmation", 0.05),
(None, 0.35), (None, 0.33),
], ],
} }
# Калибровочный ориентир из профиля сида: средний визит около 10 событий.
# Тиковый слой использует это число, чтобы переводить событийный бюджет в
# рождения визитов; тесты ниже защищают фактическую длину визита от дрейфа.
EXPECTED_VISIT_EVENTS = 10.0
class EventGenerator: class EventGenerator:
"""Генератор одного связанного визита.""" """Генератор одного связанного визита."""
@@ -118,7 +124,7 @@ class EventGenerator:
def _visit_pause_seconds(self) -> float: def _visit_pause_seconds(self) -> float:
"""Разыгрывает паузу между событиями визита.""" """Разыгрывает паузу между событиями визита."""
pause = self.rng.lognormvariate(math.log(45.0), 1.0) pause = self.rng.lognormvariate(math.log(20.0), 0.9)
return max(1.0, min(pause, 29 * 60.0)) return max(1.0, min(pause, 29 * 60.0))
def _hour_factor(self) -> float: def _hour_factor(self) -> float:
@@ -4,7 +4,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from weakref import WeakKeyDictionary from weakref import WeakKeyDictionary
from clickstream_generator.generation import EventGenerator from clickstream_generator.generation import EXPECTED_VISIT_EVENTS, EventGenerator
TOPICS = ("browser_events", "location_events", "device_events", "geo_events") TOPICS = ("browser_events", "location_events", "device_events", "geo_events")
@@ -136,7 +136,7 @@ class TickStreamGenerator:
self.generator = generator self.generator = generator
self.active_visits: list[ActiveVisit] = [] self.active_visits: list[ActiveVisit] = []
self.population = UserPopulation(generator) self.population = UserPopulation(generator)
self._pending_event_budget = 0 self._pending_visit_births = 0.0
@property @property
def population_size(self) -> int: def population_size(self) -> int:
@@ -165,18 +165,18 @@ class TickStreamGenerator:
def _birth_visits(self, event_budget: int, tick_time: datetime) -> None: def _birth_visits(self, event_budget: int, tick_time: datetime) -> None:
if len(self.active_visits) >= self.generator.config.max_active_sessions: if len(self.active_visits) >= self.generator.config.max_active_sessions:
self._pending_event_budget = 0 self._pending_visit_births = 0.0
return return
self._pending_event_budget += max(0, event_budget) self._pending_visit_births += max(0, event_budget) / EXPECTED_VISIT_EVENTS
while ( while (
self._pending_event_budget > 0 self._pending_visit_births >= 1.0
and len(self.active_visits) < self.generator.config.max_active_sessions and len(self.active_visits) < self.generator.config.max_active_sessions
): ):
user = self.population.choose_for_visit(tick_time) user = self.population.choose_for_visit(tick_time)
if user is None: if user is None:
self._pending_event_budget = 0 self._pending_visit_births = 0.0
return return
visit_batch = self.generator.generate_batch( visit_batch = self.generator.generate_batch(
@@ -196,10 +196,10 @@ class TickStreamGenerator:
self.active_visits.append( self.active_visits.append(
ActiveVisit(batch=visit_batch, timestamps=timestamps, user=user) ActiveVisit(batch=visit_batch, timestamps=timestamps, user=user)
) )
self._pending_event_budget -= len(timestamps) self._pending_visit_births -= 1.0
if len(self.active_visits) >= self.generator.config.max_active_sessions: if len(self.active_visits) >= self.generator.config.max_active_sessions:
self._pending_event_budget = 0 self._pending_visit_births = 0.0
def _release_due_events( def _release_due_events(
self, self,
+2 -2
View File
@@ -33,9 +33,9 @@ def base_config(data_dir):
return Config( return Config(
kafka_bootstrap_servers="localhost:9092", kafka_bootstrap_servers="localhost:9092",
tick_seconds=5, tick_seconds=5,
lambda_base_per_min=200, lambda_base_per_min=30,
jitter_pct=20, jitter_pct=20,
min_events_per_tick=5, min_events_per_tick=1,
max_events_per_tick=50, max_events_per_tick=50,
data_dir=data_dir, data_dir=data_dir,
seed=42, seed=42,
+14 -3
View File
@@ -56,7 +56,7 @@ class TestConfigValidation:
def test_valid_config_passes(self, base_config): def test_valid_config_passes(self, base_config):
"""Валидная конфигурация создается без ошибок.""" """Валидная конфигурация создается без ошибок."""
assert base_config.tick_seconds == 5 assert base_config.tick_seconds == 5
assert base_config.lambda_base_per_min == 200 assert base_config.lambda_base_per_min == 30
assert base_config.jitter_pct == 20 assert base_config.jitter_pct == 20
assert base_config.max_active_sessions < base_config.population_max assert base_config.max_active_sessions < base_config.population_max
@@ -64,6 +64,17 @@ class TestConfigValidation:
class TestConfigDefaults: class TestConfigDefaults:
"""Тесты значений по умолчанию.""" """Тесты значений по умолчанию."""
def test_default_lambda_base_per_min_is_calibrated_to_demo_scale(
self, monkeypatch, data_dir
):
"""По умолчанию целевая интенсивность равна 30 событиям в минуту."""
monkeypatch.setenv("GEN_DATA_DIR", str(data_dir))
monkeypatch.delenv("GEN_LAMBDA_BASE_PER_MIN", raising=False)
config = Config()
assert config.lambda_base_per_min == 30
def test_default_tick_seconds_is_5(self, data_dir): def test_default_tick_seconds_is_5(self, data_dir):
"""По умолчанию tick_seconds = 5 (rev5).""" """По умолчанию tick_seconds = 5 (rev5)."""
import os import os
@@ -75,9 +86,9 @@ class TestConfigDefaults:
config = Config( config = Config(
kafka_bootstrap_servers="localhost:9092", kafka_bootstrap_servers="localhost:9092",
tick_seconds=int(os.getenv("GEN_TICK_SECONDS", "5")), tick_seconds=int(os.getenv("GEN_TICK_SECONDS", "5")),
lambda_base_per_min=200, lambda_base_per_min=30,
jitter_pct=20, jitter_pct=20,
min_events_per_tick=5, min_events_per_tick=1,
max_events_per_tick=50, max_events_per_tick=50,
data_dir=data_dir, data_dir=data_dir,
seed=None, seed=None,
+255 -14
View File
@@ -2,12 +2,20 @@
Тесты генерации событий. Тесты генерации событий.
""" """
import random
import uuid import uuid
from dataclasses import replace from dataclasses import replace
from datetime import datetime, timedelta from datetime import datetime, timedelta
import pytest import pytest
from generator import EventGenerator, EventDictionary, TickStreamGenerator, generate_tick_batch from generator import (
EventGenerator,
EventDictionary,
EXPECTED_VISIT_EVENTS,
TickStreamGenerator,
calculate_events_count,
generate_tick_batch,
)
ALLOWED_PAGE_PATHS = { ALLOWED_PAGE_PATHS = {
@@ -143,7 +151,7 @@ class TestEventGeneration:
def test_tick_stream_keeps_long_window_intensity_near_event_budget( def test_tick_stream_keeps_long_window_intensity_near_event_budget(
self, event_dictionary, base_config self, event_dictionary, base_config
): ):
"""Длинное окно не разгоняется от событийного бюджета к бюджету визитов.""" """Длинное окно держит среднюю интенсивность около событийного бюджета."""
config = replace( config = replace(
base_config, base_config,
tick_seconds=5, tick_seconds=5,
@@ -167,8 +175,35 @@ class TestEventGeneration:
total_events += len(batch["browser_events"]) total_events += len(batch["browser_events"])
events_per_minute = total_events / (ticks_count * config.tick_seconds / 60) events_per_minute = total_events / (ticks_count * config.tick_seconds / 60)
expected_events_per_minute = 17 * 60 / config.tick_seconds
assert events_per_minute < 300 assert events_per_minute == pytest.approx(expected_events_per_minute, rel=0.10)
def test_tick_stream_births_visits_from_average_visit_length_budget(
self, event_dictionary, base_config
):
"""Новый визит рождается после накопления бюджета средней длины визита."""
config = replace(
base_config,
tick_seconds=60,
max_active_sessions=100,
population_max=101,
)
generator = EventGenerator(event_dictionary, config)
stream = TickStreamGenerator(generator)
tick_at = datetime(2026, 6, 11, 12, 0)
partial_budget_tick = stream.generate_tick(
event_budget=9,
tick_started_at=tick_at,
)
completed_budget_tick = stream.generate_tick(
event_budget=1,
tick_started_at=tick_at + timedelta(seconds=config.tick_seconds),
)
assert partial_budget_tick["browser_events"] == []
assert len(completed_budget_tick["browser_events"]) == 1
def test_tick_stream_keeps_active_visit_between_ticks(self, event_dictionary, base_config): def test_tick_stream_keeps_active_visit_between_ticks(self, event_dictionary, base_config):
"""Один визит может выпускать события в нескольких последовательных тиках.""" """Один визит может выпускать события в нескольких последовательных тиках."""
@@ -177,7 +212,7 @@ class TestEventGeneration:
stream = TickStreamGenerator(generator) stream = TickStreamGenerator(generator)
first_tick_at = datetime(2026, 6, 11, 12, 0) first_tick_at = datetime(2026, 6, 11, 12, 0)
first_tick = stream.generate_tick(event_budget=1, tick_started_at=first_tick_at) first_tick = stream.generate_tick(event_budget=10, tick_started_at=first_tick_at)
second_tick = stream.generate_tick( second_tick = stream.generate_tick(
event_budget=0, event_budget=0,
tick_started_at=first_tick_at + timedelta(seconds=config.tick_seconds), tick_started_at=first_tick_at + timedelta(seconds=config.tick_seconds),
@@ -202,7 +237,7 @@ class TestEventGeneration:
stream = TickStreamGenerator(generator) stream = TickStreamGenerator(generator)
tick_at = datetime(2026, 6, 11, 12, 0) tick_at = datetime(2026, 6, 11, 12, 0)
first_tick = stream.generate_tick(event_budget=1, tick_started_at=tick_at) first_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at)
same_time_tick = stream.generate_tick(event_budget=0, tick_started_at=tick_at) same_time_tick = stream.generate_tick(event_budget=0, tick_started_at=tick_at)
assert len(first_tick["browser_events"]) == 1 assert len(first_tick["browser_events"]) == 1
@@ -218,7 +253,7 @@ class TestEventGeneration:
stream = TickStreamGenerator(generator) stream = TickStreamGenerator(generator)
tick_at = datetime(2026, 6, 11, 12, 0) tick_at = datetime(2026, 6, 11, 12, 0)
first_tick = stream.generate_tick(event_budget=1, tick_started_at=tick_at) first_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at)
final_tick = stream.generate_tick( final_tick = stream.generate_tick(
event_budget=0, event_budget=0,
tick_started_at=tick_at + timedelta(hours=1), tick_started_at=tick_at + timedelta(hours=1),
@@ -247,8 +282,8 @@ class TestEventGeneration:
stream = TickStreamGenerator(generator) stream = TickStreamGenerator(generator)
tick_at = datetime(2026, 6, 11, 12, 0) tick_at = datetime(2026, 6, 11, 12, 0)
first_tick = stream.generate_tick(event_budget=5, tick_started_at=tick_at) first_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at)
blocked_tick = stream.generate_tick(event_budget=5, tick_started_at=tick_at) blocked_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at)
final_tick = stream.generate_tick( final_tick = stream.generate_tick(
event_budget=0, event_budget=0,
tick_started_at=tick_at + timedelta(hours=1), tick_started_at=tick_at + timedelta(hours=1),
@@ -305,6 +340,55 @@ class TestEventGeneration:
assert repeated_users assert repeated_users
assert all(len(user_ids) == 1 for user_ids in users_by_session.values()) assert all(len(user_ids) == 1 for user_ids in users_by_session.values())
def test_tick_stream_new_user_share_matches_config_after_warmup(
self, event_dictionary, base_config
):
"""Доля новых пользователей в потоке близка к GEN_P_NEW_USER."""
config = replace(
base_config,
tick_seconds=60,
jitter_pct=0,
min_events_per_tick=30,
max_events_per_tick=30,
lambda_base_per_min=30,
max_active_sessions=200,
population_max=300,
p_new_user=0.15,
min_return_minutes=30,
)
generator = EventGenerator(event_dictionary, config)
stream = TickStreamGenerator(generator)
tick_at = datetime(2026, 6, 11, 12, 0)
warmup_ticks = 240
measure_ticks = 480
seen_users = set()
seen_sessions = set()
total_sessions = 0
new_user_sessions = 0
for tick_index in range(warmup_ticks + measure_ticks):
batch = stream.generate_tick(
event_budget=30,
tick_started_at=tick_at + timedelta(seconds=60 * tick_index),
)
users_by_session = {
event["click_id"]: event["user_domain_id"]
for event in batch["device_events"]
}
for click_id, user_id in users_by_session.items():
if click_id in seen_sessions:
continue
seen_sessions.add(click_id)
if tick_index >= warmup_ticks:
total_sessions += 1
if user_id not in seen_users:
new_user_sessions += 1
seen_users.add(user_id)
new_user_share = new_user_sessions / total_sessions
assert new_user_share == pytest.approx(config.p_new_user, rel=0.35)
def test_tick_stream_replays_same_flow_with_same_seed( def test_tick_stream_replays_same_flow_with_same_seed(
self, event_dictionary, base_config self, event_dictionary, base_config
): ):
@@ -409,6 +493,81 @@ class TestEventGeneration:
minutes=config.min_return_minutes minutes=config.min_return_minutes
) )
def test_returning_user_pause_average_matches_model_scale(
self, event_dictionary, base_config
):
"""Средняя пауза между визитами одного пользователя имеет часовой масштаб."""
config = replace(
base_config,
tick_seconds=60,
jitter_pct=0,
min_events_per_tick=30,
max_events_per_tick=30,
lambda_base_per_min=30,
max_active_sessions=200,
population_max=300,
p_new_user=0.15,
min_return_minutes=30,
)
generator = EventGenerator(event_dictionary, config)
stream = TickStreamGenerator(generator)
batches = _run_stream_batches(
stream=stream,
tick_at=datetime(2026, 6, 11, 12, 0),
ticks_count=16 * 60,
event_budget=30,
tick_seconds=config.tick_seconds,
)
users_by_session = {}
times_by_session = {}
for batch in batches:
for device_event in batch["device_events"]:
users_by_session.setdefault(
device_event["click_id"],
device_event["user_domain_id"],
)
for browser_event in batch["browser_events"]:
times_by_session.setdefault(browser_event["click_id"], []).append(
datetime.fromisoformat(
browser_event["event_timestamp"].replace(" ", "T")
)
)
sessions_by_user = {}
for click_id, timestamps in times_by_session.items():
sessions_by_user.setdefault(users_by_session[click_id], []).append(
(min(timestamps), max(timestamps))
)
pauses_minutes = []
visit_durations_minutes = []
for sessions in sessions_by_user.values():
ordered_sessions = sorted(sessions)
visit_durations_minutes.extend(
(finished_at - started_at).total_seconds() / 60
for started_at, finished_at in ordered_sessions
)
for previous, current in zip(ordered_sessions, ordered_sessions[1:]):
pauses_minutes.append((current[0] - previous[1]).total_seconds() / 60)
mean_pause_minutes = sum(pauses_minutes) / len(pauses_minutes)
mean_visit_duration_minutes = (
sum(visit_durations_minutes) / len(visit_durations_minutes)
)
expected_pause_minutes = (
config.population_max
/ (
config.lambda_base_per_min
/ EXPECTED_VISIT_EVENTS
* (1 - config.p_new_user)
)
- mean_visit_duration_minutes
)
assert min(pauses_minutes) >= config.min_return_minutes
assert mean_pause_minutes == pytest.approx(expected_pause_minutes, rel=0.30)
def test_visit_cooldown_starts_from_planned_last_event_time( def test_visit_cooldown_starts_from_planned_last_event_time(
self, event_dictionary, base_config self, event_dictionary, base_config
): ):
@@ -426,7 +585,7 @@ class TestEventGeneration:
stream = TickStreamGenerator(generator) stream = TickStreamGenerator(generator)
tick_at = datetime(2026, 6, 11, 12, 0) tick_at = datetime(2026, 6, 11, 12, 0)
first_tick = stream.generate_tick(event_budget=1, tick_started_at=tick_at) first_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at)
user_id = first_tick["device_events"][0]["user_domain_id"] user_id = first_tick["device_events"][0]["user_domain_id"]
planned_visit_end = stream.active_visits[0].timestamps[-1] planned_visit_end = stream.active_visits[0].timestamps[-1]
@@ -459,13 +618,13 @@ class TestEventGeneration:
stream = TickStreamGenerator(generator) stream = TickStreamGenerator(generator)
tick_at = datetime(2026, 6, 11, 12, 0) tick_at = datetime(2026, 6, 11, 12, 0)
first_tick = stream.generate_tick(event_budget=5, tick_started_at=tick_at) first_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at)
second_tick = stream.generate_tick( second_tick = stream.generate_tick(
event_budget=5, event_budget=10,
tick_started_at=tick_at + timedelta(hours=1), tick_started_at=tick_at + timedelta(hours=1),
) )
third_tick = stream.generate_tick( third_tick = stream.generate_tick(
event_budget=5, event_budget=10,
tick_started_at=tick_at + timedelta(hours=2), tick_started_at=tick_at + timedelta(hours=2),
) )
@@ -622,6 +781,21 @@ class TestEventGeneration:
assert max(len(visit) for visit in visits) <= 4 assert max(len(visit) for visit in visits) <= 4
assert any(len(visit) == 4 for visit in visits) assert any(len(visit) == 4 for visit in visits)
def test_visit_length_distribution_matches_seed_scale(
self, event_dictionary, base_config
):
"""Длина визита сопоставима с сидом: медиана и среднее около 10."""
generator = EventGenerator(event_dictionary, base_config)
visits = _page_path_visits(generator, 2000)
visit_lengths = sorted(len(visit) for visit in visits)
median_length = visit_lengths[len(visit_lengths) // 2]
mean_length = sum(visit_lengths) / len(visit_lengths)
assert 8 <= median_length <= 12
assert mean_length == pytest.approx(EXPECTED_VISIT_EVENTS, rel=0.10)
assert max(visit_lengths) <= base_config.max_session_events
def test_visit_pauses_stay_below_session_timeout_scale(self, event_dictionary, base_config): def test_visit_pauses_stay_below_session_timeout_scale(self, event_dictionary, base_config):
"""Паузы внутри визита остаются меньше 30 минут, p95 — единицы минут.""" """Паузы внутри визита остаются меньше 30 минут, p95 — единицы минут."""
generator = EventGenerator(event_dictionary, base_config) generator = EventGenerator(event_dictionary, base_config)
@@ -651,6 +825,23 @@ class TestEventGeneration:
assert 0.20 <= confirmation_share <= 0.30 assert 0.20 <= confirmation_share <= 0.30
def test_visit_funnel_monotonically_fades(self, event_dictionary, base_config):
"""Воронка по визитам монотонно затухает от главной до подтверждения."""
generator = EventGenerator(event_dictionary, base_config)
visits = _page_path_visits(generator, 2000)
home_visits = sum("/home" in visit for visit in visits)
product_visits = sum(
"/product_a" in visit or "/product_b" in visit
for visit in visits
)
cart_visits = sum("/cart" in visit for visit in visits)
payment_visits = sum("/payment" in visit for visit in visits)
confirmation_visits = sum("/confirmation" in visit for visit in visits)
assert home_visits >= product_visits >= cart_visits
assert cart_visits >= payment_visits >= confirmation_visits
def test_visit_can_continue_after_confirmation(self, event_dictionary, base_config): def test_visit_can_continue_after_confirmation(self, event_dictionary, base_config):
"""/confirmation не обязан быть последним событием визита.""" """/confirmation не обязан быть последним событием визита."""
generator = EventGenerator(event_dictionary, base_config) generator = EventGenerator(event_dictionary, base_config)
@@ -697,6 +888,51 @@ class TestEventGeneration:
class TestPoissonDistribution: class TestPoissonDistribution:
"""Тесты статистической модели.""" """Тесты статистической модели."""
def test_event_budget_mean_follows_lambda_and_hour_factor(
self, base_config, monkeypatch
):
"""Средний событийный бюджет следует λ и часовому коэффициенту."""
monkeypatch.setattr(
"clickstream_generator.intensity.hour_factor",
lambda: 1.2,
)
config = replace(
base_config,
tick_seconds=60,
lambda_base_per_min=30,
jitter_pct=0,
min_events_per_tick=1,
max_events_per_tick=100,
)
rng = random.Random(config.seed)
samples = [calculate_events_count(config, rng) for _ in range(1000)]
mean_budget = sum(samples) / len(samples)
assert mean_budget == pytest.approx(30 * 1.2, rel=0.15)
def test_default_tick_budget_floor_does_not_outgrow_target_lambda(
self, base_config, monkeypatch
):
"""Дефолтная нижняя граница бюджета не разгоняет lambda=30 на тике 5 секунд."""
monkeypatch.setattr(
"clickstream_generator.intensity.hour_factor",
lambda: 1.0,
)
config = replace(
base_config,
tick_seconds=5,
lambda_base_per_min=30,
jitter_pct=0,
max_events_per_tick=50,
)
rng = random.Random(config.seed)
samples = [calculate_events_count(config, rng) for _ in range(1000)]
events_per_minute = sum(samples) / len(samples) * 60 / config.tick_seconds
assert events_per_minute == pytest.approx(config.lambda_base_per_min, rel=0.20)
def test_calculate_events_respects_bounds(self, event_dictionary, base_config): def test_calculate_events_respects_bounds(self, event_dictionary, base_config):
"""Расчет количества событий уважает границы.""" """Расчет количества событий уважает границы."""
generator = EventGenerator(event_dictionary, base_config) generator = EventGenerator(event_dictionary, base_config)
@@ -725,14 +961,19 @@ class TestPoissonDistribution:
def test_mean_is_reasonable(self, event_dictionary, base_config): def test_mean_is_reasonable(self, event_dictionary, base_config):
"""Среднее значение в разумных пределах.""" """Среднее значение в разумных пределах."""
generator = EventGenerator(event_dictionary, base_config) config = replace(
base_config,
jitter_pct=0,
min_events_per_tick=1,
)
generator = EventGenerator(event_dictionary, config)
samples = [generator._calculate_events_count() for _ in range(500)] samples = [generator._calculate_events_count() for _ in range(500)]
mean = sum(samples) / len(samples) mean = sum(samples) / len(samples)
# Ожидаем: lambda_base * tick_seconds / 60 * hour_factor # Ожидаем: lambda_base * tick_seconds / 60 * hour_factor
# hour_factor обычно 0.7-1.2 # hour_factor обычно 0.7-1.2
expected_base = base_config.lambda_base_per_min * base_config.tick_seconds / 60.0 expected_base = config.lambda_base_per_min * config.tick_seconds / 60.0
# Допустимое отклонение до 50% # Допустимое отклонение до 50%
assert mean > expected_base * 0.5, f"Mean {mean} too low (expected ~{expected_base})" assert mean > expected_base * 0.5, f"Mean {mean} too low (expected ~{expected_base})"