feat(generator): эталонный мир в git и import по умолчанию

- Зачем:
  - менти собирал мир генерацией (минуты и десятки минут CPU); теперь
    готовый трёхдневный мир загружается импортом за ~2 минуты, и числа
    у всех менти совпадают число-в-число (issue #3).
- Что:
  - артефакт data/startup_history/reference-world.json.xz в git:
    3 модельных дня daily-wave, ~850 МБ JSON → 32 МБ xz;
  - чтение и запись артефакта понимают .xz потоково (lzma); пустое поле
    artifact_path в пульте и make startup-history-import читают эталон;
  - длительность профиля daily-wave стала 3d — в тон эталонному миру;
  - предпроверка чистого стенда ставит зависимости генератора через uv;
    экспорт и импорт разведены отдельными переменными Makefile;
  - доки и runbook обновлены; новые контрактные тесты: xz round-trip
    и дефолтные пути артефакта.
- Проверка:
  - make test (210 + 31) и make lint зелёные; импорт на чистом стенде
    за 2м03с, manifest совпал (280437 событий), Superset-проверка
    зелёная; независимое ревью — APPROVED.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 18:49:02 +03:00
co-authored by Claude Fable 5
parent b87dde79b7
commit 10ee1a1cb6
21 changed files with 165 additions and 35 deletions
+1 -1
View File
@@ -126,7 +126,7 @@ make generated-history-analytics
проверяет Superset metadata. Файлы `data/*.jsonl` при этом не грузятся в Kafka:
они пока используются только как фактура для генератора.
По умолчанию используется учебный профиль `daily-wave`: 2 суток с суточной
По умолчанию используется учебный профиль `daily-wave`: 3 суток с суточной
волной. В live он идёт с `GEN_MODEL_TIME_SPEED=60` и
`GEN_TICK_SECONDS=1`: модельные сутки проходят примерно за 24 настенные
минуты. Плоский профиль `ci` на 6 часов остаётся служебным для автоматических
@@ -335,12 +335,13 @@ def target_dag_trigger_error(dag_id: str, dag_model) -> str | None:
def default_artifact_path(operation: str) -> str:
"""Возвращает путь артефакта по умолчанию в общем томе data."""
filename = (
"startup-history-import.json"
if operation == "import"
else "startup-history.json"
)
return str(Path(AIRFLOW_DATA_DIR) / filename)
if operation == "import":
return str(
Path(AIRFLOW_DATA_DIR)
/ "startup_history"
/ "reference-world.json.xz"
)
return str(Path(AIRFLOW_DATA_DIR) / "startup-history.json")
@contextmanager
@@ -47,7 +47,7 @@ PROFILES = {
},
),
"daily-wave": LaunchProfile(
duration="2d",
duration="3d",
env={
"GEN_SEED": "4242",
"GEN_MODEL_T0": "2026-01-01T00:00:00+00:00",
@@ -5,6 +5,7 @@ from __future__ import annotations
import argparse
import hashlib
import json
import lzma
import logging
from copy import deepcopy
from datetime import datetime, timezone
@@ -353,6 +354,15 @@ def write_startup_history_artifact(path: str | Path, artifact: dict) -> None:
"""Пишет артефакт в JSON-файл."""
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
if target.suffix == ".xz":
with lzma.open(
target,
"wt",
encoding="utf-8",
preset=9 | lzma.PRESET_EXTREME,
) as output:
json.dump(artifact, output, ensure_ascii=True, indent=2)
return
target.write_text(
json.dumps(artifact, ensure_ascii=True, indent=2),
encoding="utf-8",
@@ -361,7 +371,11 @@ def write_startup_history_artifact(path: str | Path, artifact: dict) -> None:
def load_startup_history_artifact(path: str | Path) -> dict:
"""Читает артефакт из JSON-файла."""
return json.loads(Path(path).read_text(encoding="utf-8"))
source = Path(path)
if source.suffix == ".xz":
with lzma.open(source, "rt", encoding="utf-8") as input_file:
return json.load(input_file)
return json.loads(source.read_text(encoding="utf-8"))
def validate_startup_history_artifact(
+13 -1
View File
@@ -10,6 +10,18 @@ from types import SimpleNamespace
import pytest
def test_default_artifact_paths_keep_import_and_backfill_separate():
"""Import читает эталонный мир, а backfill пишет в прежний файл."""
from clickstream_generator.airflow_control import default_artifact_path
assert default_artifact_path("import") == (
"/opt/airflow/data/startup_history/reference-world.json.xz"
)
assert default_artifact_path("backfill") == (
"/opt/airflow/data/startup-history.json"
)
def test_build_control_env_uses_profile_duration_and_airflow_data_dir():
"""Пульт строит env для backfill из профиля и каталога Airflow."""
from clickstream_generator.airflow_control import build_control_env
@@ -47,7 +59,7 @@ def test_import_env_requires_artifact_path_and_uses_backfill_contract():
assert env["GEN_RUN_MODE"] == "backfill"
assert env["GEN_STATE_RESET"] == "true"
assert env["GEN_HISTORY_DURATION"] == "2d"
assert env["GEN_HISTORY_DURATION"] == "3d"
def test_next_day_env_uses_world_settings_and_current_manifest_boundary():
@@ -247,6 +247,22 @@ def test_console_generator_paths_precheck_clean_stand_before_writes():
)
def test_reference_artifact_is_import_default_only():
"""Экспорт не может по умолчанию перезаписать эталонный мир."""
makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
export_script = (
REPO_ROOT / "scripts" / "export_startup_history_artifact.sh"
).read_text(encoding="utf-8")
import_script = (
REPO_ROOT / "scripts" / "import_startup_history_artifact.sh"
).read_text(encoding="utf-8")
assert "STARTUP_HISTORY_EXPORT_ARTIFACT" in makefile
assert "STARTUP_HISTORY_IMPORT_ARTIFACT" in makefile
assert "/tmp/clickstream-startup-history.json" in export_script
assert "data/startup_history/reference-world.json.xz" in import_script
def test_console_continue_checks_state_or_clean_stand_before_live_start():
"""Continue не стартует новый live-мир на грязном стенде без state."""
run_generator = (REPO_ROOT / "scripts" / "run_generator.sh").read_text(
+3 -3
View File
@@ -26,15 +26,15 @@ def test_duration_2d_sets_model_t_end_from_t0():
assert env["GEN_MODEL_T_END"] == "2026-01-03T00:00:00+00:00"
def test_daily_wave_profile_uses_two_days_by_default():
def test_daily_wave_profile_uses_three_days_by_default():
"""Профиль daily-wave даёт историю с суточной волной."""
from clickstream_generator.launch import build_launch_env
env = build_launch_env("backfill", profile_name="daily-wave")
assert env["GEN_LAUNCH_PROFILE"] == "daily-wave"
assert env["GEN_HISTORY_DURATION"] == "2d"
assert env["GEN_MODEL_T_END"] == "2026-01-03T00:00:00+00:00"
assert env["GEN_HISTORY_DURATION"] == "3d"
assert env["GEN_MODEL_T_END"] == "2026-01-04T00:00:00+00:00"
assert env["GEN_MODEL_TIME_SPEED"] == "60"
assert env["GEN_TICK_SECONDS"] == "1"
@@ -4,12 +4,55 @@
from dataclasses import replace
from datetime import datetime, timezone
import json
import lzma
import pytest
from generator import GeneratorState
def test_plain_json_roundtrip_keeps_existing_serialization(tmp_path):
"""Обычный JSON по-прежнему пишется без сжатия и читается обратно."""
from clickstream_generator.startup_history_artifact import (
load_startup_history_artifact,
write_startup_history_artifact,
)
artifact = {"text": "мир", "nested": {"value": 42}}
path = tmp_path / "artifact.json"
write_startup_history_artifact(path, artifact)
assert path.read_text(encoding="utf-8") == json.dumps(
artifact,
ensure_ascii=True,
indent=2,
)
assert load_startup_history_artifact(path) == artifact
def test_xz_artifact_roundtrip(tmp_path):
"""XZ-артефакт потоково пишется и читается без изменения данных."""
from clickstream_generator.startup_history_artifact import (
load_startup_history_artifact,
write_startup_history_artifact,
)
artifact = {"text": "мир", "nested": {"value": 42}}
path = tmp_path / "artifact.json.xz"
write_startup_history_artifact(path, artifact)
assert load_startup_history_artifact(path) == artifact
with lzma.open(path, "rt", encoding="utf-8") as input_file:
assert input_file.read() == json.dumps(
artifact,
ensure_ascii=True,
indent=2,
)
def _state() -> GeneratorState:
import random