#!/usr/bin/env python3
"""
Строит tools/lag_model/model.json — коэффициенты калькулятора нагрузки на тик (/tools/lag).

Только stdlib. Запуск:  python tools/lag_model/fit.py
Каждая цифра ниже — либо считается из data/*.csv, либо взята из таблицы в журналах сервера
(VPS-Server-Minecraft/*.md, источник указан рядом). Ничего не подгоняется «на глаз»:
неизвестное записано как kind="assumed" и показывается на странице как допущение.

Единицы: мс на тик (MSPT). Бюджет тика = 50 мс (20 TPS).
Интервалы [lo, hi] — огибающая по арифметике интервалов (гарантия при принятых допущениях),
НЕ статистический доверительный интервал; mid — геометрическое среднее lo и hi (если не сказано иное).
"""
import csv, json, math, pathlib, statistics as st
from datetime import date

HERE = pathlib.Path(__file__).parent
T975 = {1: 12.706, 2: 4.303, 3: 3.182, 4: 2.776, 5: 2.571, 6: 2.447, 7: 2.365}  # t-квантиль 97.5%, df=1..7


def geo(lo, hi):
    return math.sqrt(lo * hi)


def param(lo, hi, mid=None, **meta):
    mid = geo(lo, hi) if mid is None else mid
    assert 0 <= lo <= mid <= hi, (lo, mid, hi)
    return dict(lo=lo, mid=mid, hi=hi, **meta)


# ---------------------------------------------------------------- 1. стенд: база с форслоадом (bench.csv)
def stand_base():
    cyc = {}
    for r in csv.DictReader(open(HERE / 'data' / 'bench.csv')):
        if r['phase'] == 'baseline':
            cyc.setdefault(r['cycle'], []).append(float(r['avg_ms']))
    means = [st.mean(v) for v in cyc.values()]
    n = len(means)
    m, se = st.mean(means), st.stdev(means) / math.sqrt(n)
    return dict(mean=m, half=T975[n - 1] * se, cycles=n, cycle_means=means)


# ---------------------------------------------------------------- 2. присутствие блоков Create (простой)
# STAGING: wipe/AB_RESULTS_20261005.md («Нагрузочный тест»), база пустого мира 2,1 мс. Блоков, тик мс.
PRESENCE_BASE = 2.1
PRESENCE = [(231, 2.95), (1050, 2.85), (1308, 2.83), (99, 2.65), (255, 2.62), (159, 2.38), (3, 1.77)]
# Повторный раунд (wipe/DEVBLOG_2026-10-wipe.md §8), база раунда не указана — только проверка, не обучение.
PRESENCE_ROUND2 = [(1398, 3.35), (231, 3.23)]


def presence_fit():
    x = [a for a, _ in PRESENCE]
    d = [t - PRESENCE_BASE for _, t in PRESENCE]
    n = len(x)
    b = sum(a * c for a, c in zip(x, d)) / sum(a * a for a in x)          # МНК через начало координат
    s = math.sqrt(sum((c - b * a) ** 2 for a, c in zip(x, d)) / (n - 1))  # остаточное СКО, мс
    se = s / math.sqrt(sum(a * a for a in x))
    h = T975[n - 1] * se
    # то же с свободным членом — проверка, что наклон вообще отличим от нуля
    mx, my = st.mean(x), st.mean([t for _, t in PRESENCE])
    sxx = sum((a - mx) ** 2 for a in x)
    b1 = sum((a - mx) * (t - my) for a, (_, t) in zip(x, PRESENCE)) / sxx
    res1 = [t - (my + b1 * (a - mx)) for a, (_, t) in zip(x, PRESENCE)]
    s1 = math.sqrt(sum(r * r for r in res1) / (n - 2))
    h1 = T975[n - 2] * s1 / math.sqrt(sxx)
    return dict(b=b, lo=max(b - h, 0.0), hi=b + h, resid_sd=s, n=n,
                slope_free=b1, slope_free_lo=b1 - h1, slope_free_hi=b1 + h1)


# ---------------------------------------------------------------- 3. активный корабль Sable (поля Сезона 0)
# TPS_INVESTIGATION.md, «Хроническая нагрузка»: Rapier3D.step() ≈ 30–35 % выборок главного потока.
SHARE = (0.30, 0.35)
# Снимок A (11.08 15:31–15:34 UTC): [report] shiplod total 154–181, frozen 11–15; MSPT «примерно 50–80».
SNAP_A = dict(active=(154 - 15, 181 - 11), mspt=(50.0, 80.0))
# Снимок B (11.08 17:06–17:08 UTC): total 87–100, frozen 13–14 %; «15–20 tps» → MSPT 1000/20..1000/15.
SNAP_B = dict(active=(87 * (1 - 0.14), 100 * (1 - 0.13)), mspt=(50.0, 1000 / 15))


def ship_c(snap):
    return (SHARE[0] * snap['mspt'][0] / snap['active'][1],
            SHARE[1] * snap['mspt'][1] / snap['active'][0])


def ship_fit():
    a, b = ship_c(SNAP_A), ship_c(SNAP_B)
    lo, hi = min(a[0], b[0]), max(a[1], b[1])
    # проверка «вне выборки»: c по снимку A предсказывает Rapier-мс снимка B
    pred = (a[0] * SNAP_B['active'][0], a[1] * SNAP_B['active'][1])
    obs = (SHARE[0] * SNAP_B['mspt'][0], SHARE[1] * SNAP_B['mspt'][1])
    overlap = (max(pred[0], obs[0]), min(pred[1], obs[1]))
    # согласованность с Сезоном 1: Sable 3–5 % тика при 30–38 мс (LAUNCH_REPORT §4) → сколько активных кораблей
    s1_ms = (0.03 * 30, 0.05 * 38)
    return dict(A=a, B=b, lo=lo, hi=hi, pred_B=pred, obs_B=obs, overlap=overlap,
                s1_ms=s1_ms, s1_ships=(s1_ms[0] / hi, s1_ms[1] / lo))


# ---------------------------------------------------------------- 4. автосейв кораблей
# TPS_INVESTIGATION.md: Sable saveAll() «стабильные ~70–150 мс»; число кораблей в те сессии — по снимкам
# [report] выше (87–181). Порядок величины, уверенность низкая.
AUTOSAVE_MS = (70.0, 150.0)
AUTOSAVE_SHIPS = (87, 181)


def build():
    base = stand_base()
    pr = presence_fit()
    sh = ship_fit()

    # --- форслоад-чанк: остаток базы стенда (14,2 мс при 0 игроков, 3600 forceload-чанков, 22 корабля, 1431 машина)
    # R = M_stand − M_empty − 1431·β − 22·c_ship; границы — по худшему сочетанию.
    N_CH, N_SHIPS, N_MACH = 3600, 22, 1431
    r_hi = (base['mean'] + base['half']) - (PRESENCE_BASE - pr['resid_sd']) - N_MACH * pr['lo'] - 0.0
    r_lo = (base['mean'] - base['half']) - (PRESENCE_BASE + pr['resid_sd']) - N_MACH * pr['hi'] - N_SHIPS * sh['hi']
    assert r_lo > 0, 'остаток не положителен — модель базы разошлась с данными'
    chunk = param(r_lo / N_CH, r_hi / N_CH)

    P = {
        'ship_active': param(sh['lo'], sh['hi'], kind='derived', conf='низкая', unit='мс/тик на корабль',
            src='TPS_INVESTIGATION.md: доля Rapier3D.step 30–35 %, снимки [report] shiplod 11.08 (A, B)',
            note='Доля выборок стека × MSPT ÷ число активных кораблей. Размер корабля не учтён: данных нет.'),
        # Ниже — допущения: прямого замера нет. Центральные значения заданы явно.
        'ship_cruise_factor': param(0.25, 1.0, mid=0.5, kind='assumed', conf='нет данных', unit='доля от активного',
            src='shiplod: cruiseIntervalTicks 3 (адаптивно 4) → нижняя граница 1/4; верхняя — «экономии нет»',
            note='Не измерено на проде/стенде.'),
        'ship_frozen_factor': param(0.0, 1.0, mid=0.1, kind='assumed', conf='нет данных', unit='доля от активного',
            src='shiplod: catch-up раз в ≤30 тиков → 1/30; верхняя граница — «экономии нет»',
            note='Самый важный пробел: сколько стоит припаркованный корабль, не замерено.'),
        'ship_autosave': param(AUTOSAVE_MS[0] / AUTOSAVE_SHIPS[1], AUTOSAVE_MS[1] / AUTOSAVE_SHIPS[0],
            kind='derived', conf='низкая', unit='мс в пик автосейва на корабль',
            src='TPS_INVESTIGATION.md: saveAll 70–150 мс; число кораблей 87–181 (снимки shiplod)',
            note='Раз в ~5–7 минут, разовый пик, а не постоянная нагрузка. Знаменатель — порядок величины.'),
        'create_block': param(max(pr['lo'], 1e-5), pr['hi'], mid=pr['b'], kind='measured', conf='средняя', unit='мс/тик на блок (простой)',
            src='AB_RESULTS_20261005.md «Нагрузочный тест», n=7, МНК через начало координат, 95 % ДИ (t, df=6)',
            note='Блоки без питания и сетей: нижняя оценка. Работающие установки дороже, данных нет.'),
        'forced_chunk': param(chunk['lo'], chunk['hi'], kind='derived', conf='низкая', unit='мс/тик на чанк',
            src='bench.csv (baseline, 3 цикла): остаток базы стенда за вычетом пустого мира, машин и кораблей',
            note='Огибающая по худшему сочетанию. Не включает автосейв чанков: знаменатель в логах не определён.'),
    }

    checks = []

    def chk(name, ok, detail):
        checks.append(dict(name=name, ok=bool(ok), detail=detail))

    chk('Наклон Create отличим от нуля (модель со свободным членом)?', pr['slope_free_lo'] > 0,
        'b=%.5f [%.5f; %.5f] мс/блок — доверительный интервал включает 0, поэтому берём модель через начало '
        'координат и трактуем результат как верхнюю границу' % (pr['slope_free'], pr['slope_free_lo'], pr['slope_free_hi']))
    for n, t in PRESENCE_ROUND2:
        lo = PRESENCE_BASE - pr['resid_sd'] + n * pr['lo']
        hi = PRESENCE_BASE + pr['resid_sd'] + n * pr['hi']
        chk('Повторный раунд: %d блоков → %.2f мс' % (n, t), lo <= t <= hi,
            'предсказание [%.2f; %.2f] мс' % (lo, hi))
    chk('Корабль: c по снимку A предсказывает снимок B', sh['overlap'][0] <= sh['overlap'][1],
        'предсказано %.1f–%.1f мс, наблюдалось %.1f–%.1f мс, пересечение %.1f–%.1f' %
        (sh['pred_B'] + sh['obs_B'] + sh['overlap']))

    return dict(
        generated=str(date.today()),
        budget=dict(tick_ms=50.0, day_load_lo=30.0, day_load_hi=38.0,
                    src='LAUNCH_REPORT_2026-10-07.md §4: при 14–25 игроках средний тик 30–38 мс; на 50+ игроках TPS ≈ 18'),
        params=P,
        evidence=dict(stand_base=base, presence=pr, ship=sh, stand_counts=dict(chunks=N_CH, ships=N_SHIPS, machines=N_MACH),
                      presence_points=PRESENCE),
        checks=checks,
    )


if __name__ == '__main__':
    import sys
    sys.stdout.reconfigure(encoding='utf-8')
    m = build()
    for k, p in m['params'].items():
        assert 0 <= p['lo'] <= p['mid'] <= p['hi']
        print('%-20s lo=%.5f mid=%.5f hi=%.5f  [%s]' % (k, p['lo'], p['mid'], p['hi'], p['kind']))
    for c in m['checks']:
        print(('OK  ' if c['ok'] else 'WARN'), c['name'], '—', c['detail'])
    (HERE / 'model.json').write_text(json.dumps(m, ensure_ascii=False, indent=1), encoding='utf-8')
    print('written', HERE / 'model.json')
