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
- [ ] Средняя интенсивность событий за длинное окно соответствует
- [x] Средняя интенсивность событий за длинное окно соответствует
`GEN_LAMBDA_BASE_PER_MIN * часовой коэффициент` с разумным отклонением.
- [ ] Рождения визитов рассчитываются из бюджета событий и средней длины визита,
- [x] Рождения визитов рассчитываются из бюджета событий и средней длины визита,
а не из старых границ `GEN_MIN/MAX_EVENTS_PER_TICK` в прежнем смысле.
- [ ] Доля новых пользователей за длинное окно близка к `GEN_P_NEW_USER`.
- [ ] Межсессионные паузы одного пользователя не меньше кулдауна, а среднее на
- [x] Доля новых пользователей за длинное окно близка к `GEN_P_NEW_USER`.
- [x] Межсессионные паузы одного пользователя не меньше кулдауна, а среднее на
дефолтах находится в районе расчёта из спеки.
- [ ] Распределение длины визита на симуляции сопоставимо с сидом: медиана около
- [x] Распределение длины визита на симуляции сопоставимо с сидом: медиана около
10, среднее около 10, максимум не выше потолка.
- [ ] Воронка по шагам `/home -> товары -> /cart -> /payment -> /confirmation`
- [x] Воронка по шагам `/home -> товары -> /cart -> /payment -> /confirmation`
монотонно затухает на длинной симуляции.
## Blocked by
@@ -37,3 +37,19 @@ Status: ready-for-agent
## 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
# Режим steady-stream: короткие тики 1-10 сек (по умолчанию 5)
GEN_TICK_SECONDS: "5"
GEN_LAMBDA_BASE_PER_MIN: "200"
GEN_LAMBDA_BASE_PER_MIN: "30"
GEN_JITTER_PCT: "20"
GEN_MIN_EVENTS_PER_TICK: "5"
GEN_MIN_EVENTS_PER_TICK: "1"
GEN_MAX_EVENTS_PER_TICK: "50"
GEN_DATA_DIR: /data
GEN_ENABLED: "true"
+3 -3
View File
@@ -93,10 +93,10 @@ make generator-logs
| Переменная | Описание | По умолчанию |
|------------|----------|--------------|
| `GEN_TICK_SECONDS` | Интервал между тиками | `5` |
| `GEN_LAMBDA_BASE_PER_MIN` | Базовая интенсивность (событий/мин) | `200` |
| `GEN_LAMBDA_BASE_PER_MIN` | Базовая интенсивность (событий/мин) | `30` |
| `GEN_JITTER_PCT` | Процент вариативности | `20` |
| `GEN_MIN_EVENTS_PER_TICK` | Минимум событий за тик | `5` |
| `GEN_MAX_EVENTS_PER_TICK` | Максимум событий за тик | `50` |
| `GEN_MIN_EVENTS_PER_TICK` | Минимальный событийный бюджет тика | `1` |
| `GEN_MAX_EVENTS_PER_TICK` | Максимальный событийный бюджет тика | `50` |
### Топик истории
+10 -5
View File
@@ -39,8 +39,8 @@ generator-service -> Kafka topics -> (потребители отдельно)
- Публикуем постепенно, **короткими тиками** (по умолчанию каждые 5 секунд)
- На каждом тике отправляем небольшую порцию сообщений
- Держим целевую интенсивность `events/min` без крупных минутных batch:
рассчитанный событийный бюджет тика планирует новые визиты и списывается по
фактической длине этих визитов, а события выходят позже по своим
рассчитанный событийный бюджет тика копится как бюджет рождения визитов через
ожидаемую среднюю длину визита, а события выходят позже по своим
запланированным меткам времени
- Распределяем события по 4 топикам:
- `browser_events`
@@ -63,10 +63,10 @@ generator-service -> Kafka topics -> (потребители отдельно)
|------------|----------|--------------|
| `KAFKA_BOOTSTRAP_SERVERS` | Адрес Kafka | `kafka:29092` |
| `GEN_TICK_SECONDS` | Интервал между тиками | `5` (1-10 сек рекомендуется) |
| `GEN_LAMBDA_BASE_PER_MIN` | Базовая интенсивность (событий/мин) | `200` |
| `GEN_LAMBDA_BASE_PER_MIN` | Базовая интенсивность (событий/мин) | `30` |
| `GEN_JITTER_PCT` | Процент вариативности | `20` |
| `GEN_MIN_EVENTS_PER_TICK` | Минимум событий за тик | `5` |
| `GEN_MAX_EVENTS_PER_TICK` | Максимум событий за тик | `50` |
| `GEN_MIN_EVENTS_PER_TICK` | Минимальный событийный бюджет тика | `1` |
| `GEN_MAX_EVENTS_PER_TICK` | Максимальный событийный бюджет тика | `50` |
| `GEN_MAX_SESSION_EVENTS` | Потолок длины одного визита, защита от петель | `30` |
| `GEN_MAX_ACTIVE_SESSIONS` | Потолок одновременных активных визитов | `200` |
| `GEN_POPULATION_MAX` | Потолок активной популяции пользователей | `300` |
@@ -88,6 +88,11 @@ GEN_MIN_EVENTS_PER_TICK=50
GEN_MAX_EVENTS_PER_TICK=500
```
`GEN_MIN_EVENTS_PER_TICK` и `GEN_MAX_EVENTS_PER_TICK` задают нижнюю и верхнюю
границы событийного бюджета тика. Этот бюджет сначала превращается в рождения
визитов через среднюю длину визита, поэтому фактическое число отправленных
событий в конкретном тике может отличаться.
## Управление через Makefile
```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.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.kafka_io import (
BatchRecord,
@@ -50,6 +50,7 @@ __all__ = [
"Config",
"EventDictionary",
"EventGenerator",
"EXPECTED_VISIT_EVENTS",
"GeneratorService",
"GeneratorState",
"KafkaBatchHistory",
@@ -16,13 +16,13 @@ class Config:
default_factory=lambda: int(os.getenv("GEN_TICK_SECONDS", "5"))
)
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(
default_factory=lambda: int(os.getenv("GEN_JITTER_PCT", "20"))
)
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(
default_factory=lambda: int(os.getenv("GEN_MAX_EVENTS_PER_TICK", "50"))
@@ -22,51 +22,57 @@ PAGE_START_DISTRIBUTION = [
PAGE_TRANSITIONS = {
"/home": [
("/home", 0.40),
("/home", 0.41),
("/product_a", 0.28),
("/product_b", 0.18),
("/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),
],
"/product_b": [
("/home", 0.32),
("/product_a", 0.15),
"/product_a": [
("/home", 0.34),
("/product_a", 0.13),
("/product_b", 0.18),
("/cart", 0.27),
(None, 0.08),
],
"/product_b": [
("/home", 0.36),
("/product_a", 0.15),
("/product_b", 0.15),
("/cart", 0.27),
(None, 0.07),
],
"/cart": [
("/home", 0.20),
("/product_a", 0.12),
("/product_b", 0.10),
("/cart", 0.10),
("/cart", 0.11),
("/payment", 0.42),
(None, 0.06),
(None, 0.05),
],
"/payment": [
("/home", 0.12),
("/cart", 0.24),
("/payment", 0.12),
("/payment", 0.14),
("/confirmation", 0.38),
(None, 0.14),
(None, 0.12),
],
"/confirmation": [
("/home", 0.35),
("/home", 0.37),
("/product_a", 0.15),
("/product_b", 0.10),
("/confirmation", 0.05),
(None, 0.35),
(None, 0.33),
],
}
# Калибровочный ориентир из профиля сида: средний визит около 10 событий.
# Тиковый слой использует это число, чтобы переводить событийный бюджет в
# рождения визитов; тесты ниже защищают фактическую длину визита от дрейфа.
EXPECTED_VISIT_EVENTS = 10.0
class EventGenerator:
"""Генератор одного связанного визита."""
@@ -118,7 +124,7 @@ class EventGenerator:
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))
def _hour_factor(self) -> float:
@@ -4,7 +4,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
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")
@@ -136,7 +136,7 @@ class TickStreamGenerator:
self.generator = generator
self.active_visits: list[ActiveVisit] = []
self.population = UserPopulation(generator)
self._pending_event_budget = 0
self._pending_visit_births = 0.0
@property
def population_size(self) -> int:
@@ -165,18 +165,18 @@ class TickStreamGenerator:
def _birth_visits(self, event_budget: int, tick_time: datetime) -> None:
if len(self.active_visits) >= self.generator.config.max_active_sessions:
self._pending_event_budget = 0
self._pending_visit_births = 0.0
return
self._pending_event_budget += max(0, event_budget)
self._pending_visit_births += max(0, event_budget) / EXPECTED_VISIT_EVENTS
while (
self._pending_event_budget > 0
self._pending_visit_births >= 1.0
and len(self.active_visits) < self.generator.config.max_active_sessions
):
user = self.population.choose_for_visit(tick_time)
if user is None:
self._pending_event_budget = 0
self._pending_visit_births = 0.0
return
visit_batch = self.generator.generate_batch(
@@ -196,10 +196,10 @@ class TickStreamGenerator:
self.active_visits.append(
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:
self._pending_event_budget = 0
self._pending_visit_births = 0.0
def _release_due_events(
self,
+2 -2
View File
@@ -33,9 +33,9 @@ def base_config(data_dir):
return Config(
kafka_bootstrap_servers="localhost:9092",
tick_seconds=5,
lambda_base_per_min=200,
lambda_base_per_min=30,
jitter_pct=20,
min_events_per_tick=5,
min_events_per_tick=1,
max_events_per_tick=50,
data_dir=data_dir,
seed=42,
+14 -3
View File
@@ -56,7 +56,7 @@ class TestConfigValidation:
def test_valid_config_passes(self, base_config):
"""Валидная конфигурация создается без ошибок."""
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.max_active_sessions < base_config.population_max
@@ -64,6 +64,17 @@ class TestConfigValidation:
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):
"""По умолчанию tick_seconds = 5 (rev5)."""
import os
@@ -75,9 +86,9 @@ class TestConfigDefaults:
config = Config(
kafka_bootstrap_servers="localhost:9092",
tick_seconds=int(os.getenv("GEN_TICK_SECONDS", "5")),
lambda_base_per_min=200,
lambda_base_per_min=30,
jitter_pct=20,
min_events_per_tick=5,
min_events_per_tick=1,
max_events_per_tick=50,
data_dir=data_dir,
seed=None,
+255 -14
View File
@@ -2,12 +2,20 @@
Тесты генерации событий.
"""
import random
import uuid
from dataclasses import replace
from datetime import datetime, timedelta
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 = {
@@ -143,7 +151,7 @@ class TestEventGeneration:
def test_tick_stream_keeps_long_window_intensity_near_event_budget(
self, event_dictionary, base_config
):
"""Длинное окно не разгоняется от событийного бюджета к бюджету визитов."""
"""Длинное окно держит среднюю интенсивность около событийного бюджета."""
config = replace(
base_config,
tick_seconds=5,
@@ -167,8 +175,35 @@ class TestEventGeneration:
total_events += len(batch["browser_events"])
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):
"""Один визит может выпускать события в нескольких последовательных тиках."""
@@ -177,7 +212,7 @@ class TestEventGeneration:
stream = TickStreamGenerator(generator)
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(
event_budget=0,
tick_started_at=first_tick_at + timedelta(seconds=config.tick_seconds),
@@ -202,7 +237,7 @@ class TestEventGeneration:
stream = TickStreamGenerator(generator)
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)
assert len(first_tick["browser_events"]) == 1
@@ -218,7 +253,7 @@ class TestEventGeneration:
stream = TickStreamGenerator(generator)
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(
event_budget=0,
tick_started_at=tick_at + timedelta(hours=1),
@@ -247,8 +282,8 @@ class TestEventGeneration:
stream = TickStreamGenerator(generator)
tick_at = datetime(2026, 6, 11, 12, 0)
first_tick = stream.generate_tick(event_budget=5, tick_started_at=tick_at)
blocked_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=10, tick_started_at=tick_at)
final_tick = stream.generate_tick(
event_budget=0,
tick_started_at=tick_at + timedelta(hours=1),
@@ -305,6 +340,55 @@ class TestEventGeneration:
assert repeated_users
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(
self, event_dictionary, base_config
):
@@ -409,6 +493,81 @@ class TestEventGeneration:
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(
self, event_dictionary, base_config
):
@@ -426,7 +585,7 @@ class TestEventGeneration:
stream = TickStreamGenerator(generator)
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"]
planned_visit_end = stream.active_visits[0].timestamps[-1]
@@ -459,13 +618,13 @@ class TestEventGeneration:
stream = TickStreamGenerator(generator)
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(
event_budget=5,
event_budget=10,
tick_started_at=tick_at + timedelta(hours=1),
)
third_tick = stream.generate_tick(
event_budget=5,
event_budget=10,
tick_started_at=tick_at + timedelta(hours=2),
)
@@ -622,6 +781,21 @@ class TestEventGeneration:
assert max(len(visit) for visit in visits) <= 4
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):
"""Паузы внутри визита остаются меньше 30 минут, p95 — единицы минут."""
generator = EventGenerator(event_dictionary, base_config)
@@ -651,6 +825,23 @@ class TestEventGeneration:
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):
"""/confirmation не обязан быть последним событием визита."""
generator = EventGenerator(event_dictionary, base_config)
@@ -697,6 +888,51 @@ class TestEventGeneration:
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):
"""Расчет количества событий уважает границы."""
generator = EventGenerator(event_dictionary, base_config)
@@ -725,14 +961,19 @@ class TestPoissonDistribution:
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)]
mean = sum(samples) / len(samples)
# Ожидаем: lambda_base * tick_seconds / 60 * hour_factor
# 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%
assert mean > expected_base * 0.5, f"Mean {mean} too low (expected ~{expected_base})"