{
 "nbformat": 4,
 "nbformat_minor": 5,
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.10.0"
  },
  "colab": {
   "provenance": []
  }
 },
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Тест равномерности алгоритма розыгрыша — Рандомайзер.ру\n",
    "\n",
    "Этот notebook проверяет, что алгоритм HMAC-SHA256 с последовательной выборкой без возвращения\n",
    "обеспечивает **равные шансы победы** для каждого участника.\n",
    "\n",
    "## Методология\n",
    "\n",
    "Мы симулируем большое количество независимых розыгрышей с разными случайными seed-ами и\n",
    "проверяем, насколько равномерно распределяются победы между участниками.\n",
    "\n",
    "**Тест 1:** 3 победителя из 10 000 участников, 100 000 итераций  \n",
    "**Тест 2:** 3 победителя из 100 000 участников, 100 000 итераций\n",
    "\n",
    "### Что проверяем\n",
    "- Среднее число побед совпадает с ожиданием K/N\n",
    "- Стандартное отклонение совпадает с теоретическим\n",
    "- χ²-тест: p-value > 0.01 (распределение статистически равномерное)\n",
    "- Позиция в розыгрыше не даёт преимущества (позиции 1, 2, 3 равновероятны)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Установка зависимостей (если нужно)\n",
    "# !pip install tqdm scipy matplotlib numpy"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import hmac\n",
    "import hashlib\n",
    "import json\n",
    "import os\n",
    "import time\n",
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "import matplotlib.gridspec as gridspec\n",
    "from scipy import stats\n",
    "from tqdm.auto import tqdm\n",
    "\n",
    "plt.rcParams['figure.dpi'] = 120\n",
    "plt.rcParams['font.size'] = 11\n",
    "print('Библиотеки загружены.')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Алгоритм розыгрыша\n",
    "\n",
    "Точная копия алгоритма, используемого на Рандомайзер.ру.\n",
    "\n",
    "**Ключевые свойства:**\n",
    "- HMAC-SHA256 — криптографически стойкая псевдослучайная функция\n",
    "- Swap-trick: O(k) операций на итерацию без копирования массива\n",
    "- Выборка без возвращения: коллизии исключены структурно"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def participants_hash(n: int) -> bytes:\n",
    "    \"\"\"SHA-256 от отсортированного JSON списка участников.\"\"\"\n",
    "    ids = list(range(1, n + 1))\n",
    "    return hashlib.sha256(\n",
    "        json.dumps(ids, separators=(',', ':')).encode()\n",
    "    ).digest()\n",
    "\n",
    "\n",
    "def run_simulation(n: int, k: int, iterations: int):\n",
    "    \"\"\"\n",
    "    Симулирует `iterations` розыгрышей: k победителей из n участников.\n",
    "    Каждая итерация использует свежий случайный seed (os.urandom).\n",
    "    Возвращает: counts (побед на участника), pos_counts (по позиции).\n",
    "    \"\"\"\n",
    "    p_hash     = participants_hash(n)\n",
    "    counts     = np.zeros(n, dtype=np.int64)\n",
    "    pos_counts = np.zeros((k, n), dtype=np.int64)\n",
    "    indices    = np.arange(n, dtype=np.int32)\n",
    "\n",
    "    save_idx = np.empty(k, dtype=np.int32)\n",
    "    save_val = np.empty(k, dtype=np.int32)\n",
    "    save_end = np.empty(k, dtype=np.int32)\n",
    "\n",
    "    for _ in tqdm(range(iterations), desc=f'N={n:,} K={k}'):\n",
    "        seed = os.urandom(32)  # новый случайный seed на каждую итерацию\n",
    "\n",
    "        for r in range(k):\n",
    "            active = n - r\n",
    "            h      = hmac.new(seed,\n",
    "                              p_hash + r.to_bytes(4, 'big'),\n",
    "                              hashlib.sha256).digest()\n",
    "            idx    = int.from_bytes(h, 'big') % active\n",
    "            winner = int(indices[idx])\n",
    "\n",
    "            counts[winner]        += 1\n",
    "            pos_counts[r][winner] += 1\n",
    "\n",
    "            save_idx[r] = idx\n",
    "            save_val[r] = indices[idx]\n",
    "            save_end[r] = indices[active - 1]\n",
    "            indices[idx] = indices[active - 1]\n",
    "\n",
    "        # Восстанавливаем indices для следующей итерации\n",
    "        for r in range(k - 1, -1, -1):\n",
    "            active = n - r\n",
    "            indices[save_idx[r]] = save_val[r]\n",
    "            indices[active - 1]  = save_end[r]\n",
    "\n",
    "    return counts, pos_counts\n",
    "\n",
    "\n",
    "print('Функции определены.')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def visualize(counts, pos_counts, n, k, iterations, label):\n",
    "    exp_mean = iterations * k / n\n",
    "    exp_std  = np.sqrt(iterations * (k / n) * (1 - k / n))\n",
    "    chi2, p  = stats.chisquare(counts, f_exp=np.full(n, exp_mean))\n",
    "\n",
    "    fig = plt.figure(figsize=(16, 13))\n",
    "    fig.suptitle(f'{label}  |  N={n:,}  K={k}  iterations={iterations:,}',\n",
    "                 fontsize=13, fontweight='bold')\n",
    "    gs = gridspec.GridSpec(3, 3, figure=fig, hspace=0.45, wspace=0.35)\n",
    "\n",
    "    # 1. Гистограмма числа побед\n",
    "    ax = fig.add_subplot(gs[0, :2])\n",
    "    ax.hist(counts, bins=60, color='steelblue', alpha=0.75, edgecolor='none')\n",
    "    ax.axvline(exp_mean, color='red', ls='--', lw=1.8, label=f'E[x] = {exp_mean:.1f}')\n",
    "    ax.axvline(exp_mean - 3*exp_std, color='orange', ls=':', lw=1.2, label='±3σ')\n",
    "    ax.axvline(exp_mean + 3*exp_std, color='orange', ls=':', lw=1.2)\n",
    "    ax.set_xlabel('Число побед участника')\n",
    "    ax.set_ylabel('Количество участников')\n",
    "    ax.set_title('Распределение числа побед (ожидаем ~нормальное по ЦПТ)')\n",
    "    ax.legend(fontsize=9)\n",
    "\n",
    "    # 2. Статистическая сводка\n",
    "    ax = fig.add_subplot(gs[0, 2])\n",
    "    ax.axis('off')\n",
    "    info = (\n",
    "        f\"  mean     {counts.mean():.2f}\\n\"\n",
    "        f\"  E[x]     {exp_mean:.2f}\\n\\n\"\n",
    "        f\"  std      {counts.std():.2f}\\n\"\n",
    "        f\"  E[σ]     {exp_std:.2f}\\n\\n\"\n",
    "        f\"  min      {counts.min()}\\n\"\n",
    "        f\"  max      {counts.max()}\\n\\n\"\n",
    "        f\"  χ²       {chi2:.1f}\\n\"\n",
    "        f\"  df       {n-1}\\n\"\n",
    "        f\"  p-value  {p:.4f}\\n\\n\"\n",
    "        f\"  {'✓ PASS' if p > 0.01 else '✗ FAIL'} (α=0.01)\"\n",
    "    )\n",
    "    ax.text(0.05, 0.97, info, transform=ax.transAxes, va='top',\n",
    "            fontsize=10, fontfamily='monospace',\n",
    "            bbox=dict(boxstyle='round', facecolor='#f0f4ff', alpha=0.8))\n",
    "    ax.set_title('χ²-тест (H₀: равномерное)')\n",
    "\n",
    "    # 3. Отсортированные счётчики\n",
    "    ax = fig.add_subplot(gs[1, :])\n",
    "    ax.plot(np.sort(counts), color='steelblue', lw=0.6, alpha=0.8)\n",
    "    ax.axhline(exp_mean, color='red', ls='--', lw=1.5, label=f'E[x] = {exp_mean:.0f}')\n",
    "    ax.fill_between(range(n),\n",
    "                    exp_mean - 3*exp_std, exp_mean + 3*exp_std,\n",
    "                    alpha=0.12, color='red', label='±3σ')\n",
    "    ax.set_xlabel('Участник (отсортирован по числу побед)')\n",
    "    ax.set_ylabel('Число побед')\n",
    "    ax.set_title('Отсортированные счётчики — плоская линия = равномерное распределение')\n",
    "    ax.legend(fontsize=9)\n",
    "\n",
    "    # 4. Равномерность по позиции\n",
    "    ax = fig.add_subplot(gs[2, :2])\n",
    "    pmeans = pos_counts.mean(axis=1)\n",
    "    pstds  = pos_counts.std(axis=1)\n",
    "    ax.bar(range(1, k+1), pmeans, yerr=pstds/np.sqrt(n),\n",
    "           color='steelblue', alpha=0.75, capsize=6, edgecolor='white')\n",
    "    ax.axhline(exp_mean/k, color='red', ls='--', lw=1.5,\n",
    "               label=f'Expected = {exp_mean/k:.1f}')\n",
    "    ax.set_xlabel('Позиция в розыгрыше (1 = первый выбранный)')\n",
    "    ax.set_ylabel('Среднее число побед')\n",
    "    ax.set_title('Равномерность по позиции — позиция не даёт преимущества')\n",
    "    ax.legend(fontsize=9)\n",
    "    ax.set_xticks(range(1, k+1))\n",
    "\n",
    "    # 5. Q-Q plot\n",
    "    ax = fig.add_subplot(gs[2, 2])\n",
    "    stats.probplot(counts, dist='norm', plot=ax)\n",
    "    ax.get_lines()[0].set(markersize=1.5, alpha=0.3, color='steelblue')\n",
    "    ax.set_title('Q-Q: счётчики побед vs Нормальное')\n",
    "\n",
    "    plt.tight_layout()\n",
    "    plt.savefig(f'uniformity_N{n}.png', bbox_inches='tight')\n",
    "    plt.show()\n",
    "    print(f'[{label}] χ²={chi2:.1f} df={n-1} p={p:.4f} '\n",
    "          f'mean={counts.mean():.2f} std={counts.std():.2f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Тест 1: N=10 000 участников, K=3 победителя, 100 000 итераций\n",
    "\n",
    "Ожидаемое число побед на участника: **30 ± 5.5**\n",
    "\n",
    "Этот тест проверяет поведение при типичном масштабе крупного розыгрыша."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "N1, K, ITERS = 10_000, 3, 100_000\n",
    "\n",
    "print(f'Тест 1: {ITERS:,} розыгрышей, {K} победителя из {N1:,} участников')\n",
    "print(f'Ожидаемое число побед на участника: {ITERS*K/N1:.0f} ± '\n",
    "      f'{np.sqrt(ITERS*(K/N1)*(1-K/N1)):.1f}\\n')\n",
    "\n",
    "t0 = time.time()\n",
    "counts1, pos1 = run_simulation(N1, K, ITERS)\n",
    "print(f'Время: {time.time()-t0:.1f} сек\\n')\n",
    "\n",
    "visualize(counts1, pos1, N1, K, ITERS, 'Тест 1')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Тест 2: N=100 000 участников, K=3 победителя, 100 000 итераций\n",
    "\n",
    "Ожидаемое число побед на участника: **3 ± 1.7**\n",
    "\n",
    "Стресс-тест для большого числа участников. При малом числе побед на участника\n",
    "χ²-тест особенно важен — визуально оценить равномерность сложнее."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "N2 = 100_000\n",
    "\n",
    "print(f'Тест 2: {ITERS:,} розыгрышей, {K} победителя из {N2:,} участников')\n",
    "print(f'Ожидаемое число побед на участника: {ITERS*K/N2:.1f} ± '\n",
    "      f'{np.sqrt(ITERS*(K/N2)*(1-K/N2)):.2f}\\n')\n",
    "\n",
    "t0 = time.time()\n",
    "counts2, pos2 = run_simulation(N2, K, ITERS)\n",
    "print(f'Время: {time.time()-t0:.1f} сек\\n')\n",
    "\n",
    "visualize(counts2, pos2, N2, K, ITERS, 'Тест 2')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Сравнение нормированных распределений\n",
    "\n",
    "Нормируем счётчики побед на z-score и накладываем на теоретическое N(0,1).\n",
    "Совпадение = распределение равномерное (по Центральной Предельной Теореме)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from scipy.stats import norm\n",
    "\n",
    "fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n",
    "fig.suptitle('Нормированные распределения (z-score) vs N(0,1)', fontweight='bold')\n",
    "\n",
    "for ax, counts, n, label in [\n",
    "    (axes[0], counts1, N1, f'N={N1:,}'),\n",
    "    (axes[1], counts2, N2, f'N={N2:,}'),\n",
    "]:\n",
    "    z = (counts - counts.mean()) / counts.std()\n",
    "    ax.hist(z, bins=50, density=True, color='steelblue', alpha=0.7, edgecolor='none', label='Данные')\n",
    "    x = np.linspace(z.min(), z.max(), 300)\n",
    "    ax.plot(x, norm.pdf(x), 'r-', lw=2, label='N(0,1)')\n",
    "    ax.set_title(label)\n",
    "    ax.set_xlabel('z-score')\n",
    "    ax.legend(fontsize=9)\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig('comparison.png', bbox_inches='tight')\n",
    "plt.show()\n",
    "print('Если кривая данных совпадает с красной линией — распределение равномерное.')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Итог\n",
    "\n",
    "| Тест | N | p-value | Результат |\n",
    "|------|---|---------|----------|\n",
    "| Тест 1 | 10 000 | > 0.01 | ✓ PASS |\n",
    "| Тест 2 | 100 000 | > 0.01 | ✓ PASS |\n",
    "\n",
    "**Алгоритм обеспечивает статистически равномерный выбор победителей.**\n",
    "\n",
    "Скрипт верификации и документация: **randomizer.ru/kak-opredelyaetsya-pobeditel**"
   ]
  }
 ]
}
