diff --git a/.scratch/feature-data-generator/issues/05-intensity-and-flow-calibration.md b/.scratch/feature-data-generator/issues/05-intensity-and-flow-calibration.md index a8773f2..4c7f9c6 100644 --- a/.scratch/feature-data-generator/issues/05-intensity-and-flow-calibration.md +++ b/.scratch/feature-data-generator/issues/05-intensity-and-flow-calibration.md @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml index 9943fe9..78fc632 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 8c1b1a1..f50bab0 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -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` | ### Топик истории diff --git a/generator/README.md b/generator/README.md index e1facbc..0213449 100644 --- a/generator/README.md +++ b/generator/README.md @@ -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 diff --git a/generator/generator.py b/generator/generator.py index 38238e1..ad460a1 100644 --- a/generator/generator.py +++ b/generator/generator.py @@ -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", diff --git a/generator/src/clickstream_generator/config.py b/generator/src/clickstream_generator/config.py index 3353662..a260196 100644 --- a/generator/src/clickstream_generator/config.py +++ b/generator/src/clickstream_generator/config.py @@ -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")) diff --git a/generator/src/clickstream_generator/generation.py b/generator/src/clickstream_generator/generation.py index a69383f..ec60b7a 100644 --- a/generator/src/clickstream_generator/generation.py +++ b/generator/src/clickstream_generator/generation.py @@ -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: diff --git a/generator/src/clickstream_generator/runtime.py b/generator/src/clickstream_generator/runtime.py index 16a66ef..0f77c10 100644 --- a/generator/src/clickstream_generator/runtime.py +++ b/generator/src/clickstream_generator/runtime.py @@ -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, diff --git a/generator/tests/conftest.py b/generator/tests/conftest.py index 250c8e4..a90b071 100644 --- a/generator/tests/conftest.py +++ b/generator/tests/conftest.py @@ -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, diff --git a/generator/tests/test_config.py b/generator/tests/test_config.py index 4746668..da4721f 100644 --- a/generator/tests/test_config.py +++ b/generator/tests/test_config.py @@ -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, diff --git a/generator/tests/test_generation.py b/generator/tests/test_generation.py index d59bf62..fcdb4e4 100644 --- a/generator/tests/test_generation.py +++ b/generator/tests/test_generation.py @@ -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})"