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
@@ -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,