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 по вариантам за окна). Нужны два решения:
- Метод оценки значимости. CTR — доля кликов среди показов (испытания Бернулли). Проект не имеет зависимости на статистическую библиотеку.
- Где хранить результат («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; расширение полей аддитивно.