Перейти к основному содержимому

0038. Анализ A/B-теста: двухдолевой z-тест и снимок результата на ab_tests

Копия ADR из репозитория: backend/docs/adr/0038-abtest-winner-analysis.md. Канонический текст — в репозитории; портал обновляется командой npm run sync:adr.

  • Статус: accepted (2026-07-09)
  • Дата: 2026-07-09
  • Связанные задачи: OP-145 (анализ результатов и выбор победителя); соседи — OP-143 (модель теста/вариантов), OP-144 (сбор метрик CTR), OP-146 (публичное API управления/отчётов)
  • Связанные ADR: —

Контекст и проблема​

ТЗ 5.14 (AC OP-145) требует: сравнить варианты по CTR, оценить статистическую значимость, определить победителя и зафиксировать результат. Метрики уже собраны (OP-144: показы/клики/CTR по вариантам за окна). Нужны два решения:

  1. Метод оценки значимости. CTR — доля кликов среди показов (испытания Бернулли). Проект не имеет зависимости на статистическую библиотеку.
  2. Где хранить результат («winner-storage» развилка). Итог анализа (победитель + разбивка) надо где-то персистить: расширить ab_tests либо завести отдельную сущность результата.

Решение​

Метод — двухдолевой z-тест равенства долей (two-proportion z-test) по агрегированным за весь тест показам/кликам. Лидер — вариант с наибольшим CTR (детерминированный тай-брейк: CTR → показы → метка). Базовая линия сравнения — контрольный вариант, если задан, иначе раннер-ап. Победитель фиксируется только при p-value < alpha (по умолчанию alpha = 0.05, 95% доверия) и строгом превосходстве лидера. p-value считается через стандартную нормальную CDF, выраженную функцией ошибок erf (аппроксимация Abramowitz & Stegun 7.1.26, макс. ошибка ~1.5e-7) — без внешних зависимостей. Ядро вынесено в чистый модуль ab-test-winner.ts (computeAbTestResult/twoProportionZTest/standardNormalCdf), тестируется детерминированно без БД.

Хранение — снимок на самом тесте ab_tests (миграция AddAbTestResult): три nullable-поля — winner_variant_id (победитель), result (jsonb — метод, alpha, z, p-value, признак значимости, причина, разбивка по вариантам), result_computed_at (момент расчёта). Анализ (AbTestAnalysisService.analyze) идёт под pessimistic-write блокировкой строки теста: расчёт, фиксация снимка и переход running → completed атомарны.

Причины предпочесть снимок на ab_tests отдельной сущности результата:

  • Кардинальность 1:1. У теста ровно один финальный результат — это терминальный факт теста, а не коллекция; отдельная таблица дала бы лишний JOIN без выгоды.
  • Снимок, а не пересчёт. result (jsonb) хранит готовую разбивку на момент завершения — аудируемо, не зависит от последующих изменений метрик, читается одной строкой (для отчётов OP-146).
  • Наименьшая инвазивность. Три nullable-колонки к существующей таблице; сущность-агрегат остаётся когерентной. Совпадает с уже применённым в модуле паттерном jsonb (payload варианта).
  • Без DB-FK на победителя. winner_variant_id ссылается на вариант того же теста (валидируется доменным слоем); варианты каскадно удаляются вместе с тестом, поэтому жёсткий FK избыточен и не порождает дрейф метаданных сущности при migration:generate.

Последствия​

  • Положительные: значимость честно оценивается стандартным методом без внешних зависимостей; результат аудируем и читается без пересчёта; завершение теста атомарно с фиксацией итога; ядро статистики — чистое и полностью покрыто юнит-тестами.
  • Trade-offs: z-тест — нормальная аппроксимация, надёжен при достаточных выборках; на малых показах разница честно выходит незначимой (итог insufficient-data/ no-significant-difference, победитель null). Множественные сравнения (>2 вариантов) сведены к «лидер против базовой линии» без поправки на множественность — для карточных тестов с малым числом вариантов это осознанное упрощение MVP.
  • Анализ завершает тест. analyze доступен только для running и всегда переводит в completed (даже при неопределённом победителе) — период закончился, итог зафиксирован. Управление жизненным циклом (start/stop) и отчёты — OP-146.
  • Обратимость: метод инкапсулирован в ab-test-winner.ts; поправка на множественность или иной тест значимости меняются точечно. Формат снимка — в AbTestResult; расширение полей аддитивно.