diff --git a/CHANGELOG.md b/CHANGELOG.md
index f940973..7a00dbb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,55 @@
# CHANGELOG
+## 2026-08-21 — Scientific hardening: 4-state engine, coherent CI, honest edges
+
+### Fixed (P0)
+- **Direção do efeito na decisão:** o motor usava `abs(diff) > MPE` e rotulava
+ "Vencedor" mesmo com B significativamente pior (caso reproduzido: A=60% vs
+ B=58%, p≈0.004). Novo motor de **4 estados** — Melhora / Regressão / Efeito
+ Fraco / Inconclusivo — com direção assinada (`diff = pB − pA`), campo
+ `direction` na resposta, cenário público `regressao` e golden test G3
+ travando o bug.
+
+### Fixed (P1)
+- **IC coerente com Bonferroni:** a decisão usava `alpha_adj` mas o IC era
+ Wald com alpha nominal enquanto a UI dizia 95%. Agora o IC é Newcombe
+ (Wilson híbrido, `confint_proportions_2indep`) para `pB − pA` com
+ `alpha=alpha_adj`; a resposta expõe `ci_level` e `ci_method` e a UI mostra
+ o nível real (ex.: IC 98,33% com 3 comparações).
+- Pooled score test + IC unpooled documentados como decisão de método
+ (`docs/STATISTICAL_METHOD.md`), não "corrigidos" para pooled.
+
+### Fixed (edge cases)
+- Body JSON não-objeto (lista/string/número) ou malformado → 400 estruturado
+ (antes: 500).
+- Contagens com validação estrita de inteiro: rejeita bool, `100.7`, NaN/Inf,
+ negativos e strings decimais (antes: `int()` truncava silenciosamente).
+- Casos degenerados (0% vs 0%, 100% vs 100%): `test_defined=false`,
+ `p_value=null` + warning (antes: NaN mascarado como `p=1.0`); IC de
+ Newcombe segue reportado.
+- `pA=0`: `uplift=null` ("Não definido") em vez de `0.0` falso; diff absoluta
+ permanece válida.
+- Sample-size: MDE negativo/zero rejeitado; não-finitos rejeitados; MDE
+ minúsculo que exigiria >100M/grupo retorna 400 honesto; `solve_power`
+ protegido contra overflow/erro.
+- Warning documentado de aproximação normal quando `min(sucessos, falhas) < 5`
+ por braço.
+
+### Added
+- `GET /api/assumptions` + premissas na UI (details) e em `/api/demo`.
+- Campo `alpha` na aba Planejar (antes fixo 0.05).
+- `docs/STATISTICAL_METHOD.md` (metodologia, IC, premissas, limites).
+- Golden states G1–G13 em 47 testes pytest; CI validado contra a referência da
+ biblioteca em 5 famílias de casos.
+- ADR-006/ADR-007 em `docs/TECHNICAL_DECISIONS.md`.
+
+### Changed
+- Cenários públicos: `vencedor` → `melhora`; novo `regressao`; UI exibe
+ direção, warnings e nível do IC.
+- Docs de arquitetura/capacidades/guardrails atualizados (Flask, 4 estados,
+ URL canônica `statlab-ab.vercel.app`); docs históricos ganharam banner de
+ snapshot.
+
## 2026-07-13 — Portfolio case-study pass
### Added
diff --git a/PLAN.md b/PLAN.md
index 0deb31b..68c1f12 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -1,3 +1,10 @@
+> **SNAPSHOT HISTÓRICO (2026-07)** — Este documento registra o estado do projeto
+> antes do hardening científico de 2026-08-21 (motor de 4 estados, IC Newcombe
+> ajustado, contratos honestos de edge cases). Termos como "3 estados",
+> "Vencedor", "FastAPI" e a URL legada refletem o contexto da época e NÃO
+> descrevem o comportamento atual. Fontes de verdade atuais: README.md,
+> docs/STATISTICAL_METHOD.md, docs/api-contract.md, docs/architecture-guardrails.md.
+
# Evolução: StatLab Experiments (V1.1)
## Objetivo
diff --git a/README.md b/README.md
index f9a4350..c0fc59f 100644
--- a/README.md
+++ b/README.md
@@ -3,8 +3,8 @@
StatLab Experiments
-
Planeje e interprete testes A/B com z-test, Bonferroni e motor de decisão em 3 estados.
-
Plan and interpret A/B tests with z-test, Bonferroni and a 3-state decision engine.
+
Planeje e interprete testes A/B com z-test, Bonferroni e motor de decisão em 4 estados com direção do efeito.
+
Plan and interpret A/B tests with z-test, Bonferroni and a 4-state decision engine with signed effect direction.
- Live Demo
+ Live Demo
·
Repo
·
@@ -42,14 +42,14 @@
-> **Stats lab notice:** frequentist helpers for **two-proportion** experiments. Decision states (Winner / Inconclusive / Weak Effect) are **pedagogical decision-support** — not automatic product rollout authority.
+> **Stats lab notice:** frequentist helpers for **two-proportion** experiments. Decision states (Melhora / Regressão / Efeito Fraco / Inconclusivo) are **pedagogical decision-support** — not automatic product rollout authority.
---
## PT-BR
### Visão geral
-O **StatLab Experiments** calcula tamanho amostral (Cohen's *h* + poder), analisa duas proporções (z-test) e classifica o resultado em **Vencedor / Inconclusivo / Efeito fraco**, com correção de Bonferroni quando aplicável.
+O **StatLab Experiments** calcula tamanho amostral (Cohen's *h* + poder), analisa duas proporções (z-test) e classifica o resultado em **Melhora / Regressão / Efeito Fraco / Inconclusivo** — com direção do efeito assinada (diff = pB − pA), correção de Bonferroni e IC de Newcombe no nível ajustado.
### Problema
Times leem A/B só pelo p-valor, param cedo e tratam qualquer “significativo” como vitória operacional — sem poder, tamanho de efeito nem relevância prática.
@@ -58,9 +58,11 @@ Times leem A/B só pelo p-valor, param cedo e tratam qualquer “significativo
PMs, analistas e data scientists que precisam de um cockpit simples de **planejamento + interpretação** frequentista.
### Funcionalidades
-- Planejamento de tamanho amostral
+- Planejamento de tamanho amostral (baseline, MDE absoluto, poder e alpha)
- Análise de duas proporções (z-test) via API Flask + SciPy/statsmodels
-- Motor de decisão em 3 estados
+- Motor de decisão em 4 estados com direção do efeito (pB − pA vs MPE)
+- IC de Newcombe coerente com Bonferroni (a UI mostra o nível real, ex.: IC 98,33%)
+- Contratos honestos: p-valor null em casos degenerados, uplift null com baseline zero, 400 estruturado para entradas inválidas
- Cenários/chips de demonstração na UI Next.js
- `start.bat` para subir o lab no Windows
@@ -74,7 +76,7 @@ PMs, analistas e data scientists que precisam de um cockpit simples de **planeja
## English
### Overview
-**StatLab Experiments** plans sample size (Cohen's *h* + power), analyzes two proportions (z-test) and classifies outcomes as **Winner / Inconclusive / Weak Effect**, with Bonferroni when applicable.
+**StatLab Experiments** plans sample size (Cohen's *h* + power), analyzes two proportions (z-test) and classifies outcomes as **Melhora / Regressão / Efeito Fraco / Inconclusivo** — with signed effect direction (diff = pB − pA), Bonferroni correction and a Newcombe CI at the adjusted level.
### Problem
Teams read A/B tests from p-values alone, stop early and treat any “significant” result as an operational win — without power, effect size or practical relevance.
@@ -83,9 +85,11 @@ Teams read A/B tests from p-values alone, stop early and treat any “significan
PMs, analysts and data scientists who need a simple **plan + interpret** frequentist cockpit.
### Features
-- Sample-size planning
+- Sample-size planning (baseline, absolute MDE, power and alpha)
- Two-proportion analysis (z-test) via Flask API + SciPy/statsmodels
-- 3-state decision engine
+- 4-state decision engine with signed effect direction (pB − pA vs MPE)
+- Newcombe CI coherent with Bonferroni (UI shows the real level, e.g. 98.33%)
+- Honest contracts: null p-value on degenerate counts, null uplift on zero baseline, structured 400s for invalid input
- Demo scenarios/chips in the Next.js UI
- Windows `start.bat` for local lab bring-up
@@ -100,10 +104,10 @@ PMs, analysts and data scientists who need a simple **plan + interpret** frequen
| Surface | URL |
|---|---|
-| **Public lab** | [https://statlab-experiments.vercel.app](https://statlab-experiments.vercel.app) |
+| **Public lab** | [https://statlab-ab.vercel.app](https://statlab-ab.vercel.app) |
| **GitHub** | see Repo badge above |
-**How to try:** plan a sample size → analyze a scenario → compare Winner vs Weak Effect / Inconclusive → read why the state was chosen.
+**How to try:** plan a sample size → analyze a scenario → compare Melhora vs Regressão / Weak Effect / Inconclusive → read why the state was chosen (direction, adjusted alpha, real CI level).
@@ -112,11 +116,11 @@ PMs, analysts and data scientists who need a simple **plan + interpret** frequen
Plan sample size
-
Analyze — Winner
+
Analyze — Melhora
-
Weak effect
-
Scenarios
+
Analyze — Regressão
+
Weak effect
@@ -166,9 +170,13 @@ npm run dev
## Technical decisions
-- **3-state engine** to separate statistical win from weak/practical effect
+- **4-state engine with signed direction** — a significantly *worse* B is Regressão, never a win (the historical `abs(diff)` bug is locked out by golden test G3)
+- **Newcombe CI at the Bonferroni-adjusted level** — inference and interval are family-wise coherent; the UI shows the real level (e.g. 98.33%)
+- **Honest edge contracts** — no fabricated p-values or uplifts; structured 400s for invalid input
- **Flask on Vercel** for a slim stats endpoint next to Next.js
-- Teach **Bonferroni / power** instead of p-value-only screenshots
+- Teach **Bonferroni / power / direction** instead of p-value-only screenshots
+
+Statistical methodology and assumptions: [`docs/STATISTICAL_METHOD.md`](./docs/STATISTICAL_METHOD.md).
---
diff --git a/SUMMARY.md b/SUMMARY.md
index c07e860..042e89e 100644
--- a/SUMMARY.md
+++ b/SUMMARY.md
@@ -1,3 +1,10 @@
+> **SNAPSHOT HISTÓRICO (2026-07)** — Este documento registra o estado do projeto
+> antes do hardening científico de 2026-08-21 (motor de 4 estados, IC Newcombe
+> ajustado, contratos honestos de edge cases). Termos como "3 estados",
+> "Vencedor", "FastAPI" e a URL legada refletem o contexto da época e NÃO
+> descrevem o comportamento atual. Fontes de verdade atuais: README.md,
+> docs/STATISTICAL_METHOD.md, docs/api-contract.md, docs/architecture-guardrails.md.
+
# SUMMARY.md
## Implementações Realizadas (Evolução V1.1)
diff --git a/api-server/api/index.py b/api-server/api/index.py
index db337f1..0e71e13 100644
--- a/api-server/api/index.py
+++ b/api-server/api/index.py
@@ -1,42 +1,126 @@
-from flask import Flask, request, jsonify
import math
-import warnings
+import re
+
import numpy as np
+from flask import Flask, jsonify, request
from scipy.stats import norm
from statsmodels.stats.power import NormalIndPower
-from statsmodels.stats.proportion import proportions_ztest
+from statsmodels.stats.proportion import confint_proportions_2indep, proportions_ztest
app = Flask(__name__)
+STATUS_MELHORA = "Melhora"
+STATUS_REGRESSAO = "Regressão"
+STATUS_EFEITO_FRACO = "Efeito Fraco"
+STATUS_INCONCLUSIVO = "Inconclusivo"
+
+CI_METHOD = "newcomb"
+
+# solve_power aceita MDEs que implicam dezenas de milhões de observações por
+# grupo; acima disso o resultado não é acionável, então devolvemos validação.
+MAX_N_PER_GROUP = 100_000_000
+
+ASSUMPTIONS_SUMMARY = [
+ "Amostras independentes entre A e B (sem sobreposição de usuários).",
+ "Desfecho binário (converteu / não converteu) por observação.",
+ "Observações independentes dentro de cada braço.",
+ "Aproximação normal do z-test de duas proporções (sucessos e falhas suficientes por braço).",
+ "Causalidade só vale se a aleatorização e o design do experimento forem válidos.",
+ "Bonferroni controla erro familial porém é conservador.",
+ "Sem correção para testes sequenciais/peeking — analyse uma única vez.",
+ "Sem checagem automática de SRM (Sample Ratio Mismatch).",
+ "O status do motor é apoio à decisão; não autoriza rollout por si só.",
+]
+
def _error(message, code=400):
return jsonify({"error": message}), code
-def interpret_result(p_val, p_a, p_b, alpha_adj=0.05, mpe=0.005):
- diff = p_b - p_a
- is_significant = p_val < alpha_adj
- is_practically_significant = abs(diff) > mpe
- if is_significant and is_practically_significant:
+def _json_object_or_error():
+ """Exige que o body seja um objeto JSON; listas/strings/escalares viram 400."""
+ data = request.get_json(silent=True)
+ if not isinstance(data, dict):
+ return None, _error("O corpo da requisição deve ser um objeto JSON.")
+ return data, None
+
+
+def _parse_strict_int(value, field):
+ """Inteiro estrito: rejeita bool, floats fracionários, NaN/Inf e strings inválidas."""
+ if isinstance(value, bool):
+ raise ValueError(f"{field} deve ser um inteiro (booleano recebido).")
+ if isinstance(value, int):
+ return value
+ if isinstance(value, float):
+ if not math.isfinite(value) or value != int(value):
+ raise ValueError(f"{field} deve ser um inteiro sem casas decimais.")
+ return int(value)
+ if isinstance(value, str):
+ if re.fullmatch(r"[+-]?\d+", value.strip() or ""):
+ return int(value.strip())
+ raise ValueError(f"{field} deve ser um inteiro válido.")
+ raise ValueError(f"{field} é obrigatório e deve ser um inteiro.")
+
+
+def _parse_float(value, field):
+ """Float estrito: rejeita bool, NaN/Inf e strings não numéricas."""
+ if isinstance(value, bool):
+ raise ValueError(f"{field} deve ser numérico (booleano recebido).")
+ if isinstance(value, (int, float)):
+ result = float(value)
+ elif isinstance(value, str):
+ try:
+ result = float(value.strip())
+ except ValueError as exc:
+ raise ValueError(f"{field} deve ser numérico.") from exc
+ else:
+ raise ValueError(f"{field} é obrigatório e deve ser numérico.")
+ if not math.isfinite(result):
+ raise ValueError(f"{field} deve ser um número finito.")
+ return result
+
+
+def interpret_result(diff, test_defined, p_val, alpha_adj, mpe):
+ """Motor de decisão em 4 estados, com direção assinada (diff = pB - pA).
+
+ Melhora: p < alpha_adj e diff > +mpe
+ Regressão: p < alpha_adj e diff < -mpe
+ Efeito Fraco: p < alpha_adj e |diff| <= mpe
+ Inconclusivo: p >= alpha_adj ou teste indefinido
+ """
+ if not test_defined:
return (
- "Vencedor",
- "A variante B apresenta resultado estatisticamente significativo "
- "e relevância prática (efeito absoluto acima do MPE). "
- "Ainda assim, valide instrumentação, duração e custo de rollout "
- "antes de decidir.",
+ STATUS_INCONCLUSIVO,
+ "O z-test não está definido para estas contagens (variância nula sob H0, "
+ "por exemplo 0% vs 0% ou 100% vs 100%). Sem p-valor não há evidência "
+ "para concluir sobre A vs B; o intervalo de confiança ainda é reportado.",
)
- elif is_significant and not is_practically_significant:
+ if p_val >= alpha_adj:
return (
- "Efeito Fraco",
- "Há sinal estatístico, mas o efeito absoluto é menor ou igual ao MPE. "
- "Significância não implica impacto de negócio suficiente para rollout.",
+ STATUS_INCONCLUSIVO,
+ "Sob as premissas do teste, os dados não fornecem evidência suficiente "
+ "no nível ajustado. Evidência insuficiente não é prova de ausência de "
+ "efeito: aumente a amostra, revise o MDE ou a hipótese.",
)
- else:
+ if diff > mpe:
+ return (
+ STATUS_MELHORA,
+ "Sob as premissas do teste, há evidência estatística (p < alpha ajustado) "
+ "de efeito positivo observado acima do MPE. Valide instrumentação, "
+ "duração e custo de rollout antes de decidir.",
+ )
+ if diff < -mpe:
return (
- "Inconclusivo",
- "Os dados não fornecem evidência suficiente sob o alpha ajustado. "
- "Evite declarar vencedor: aumente amostra, revise o MDE ou a hipótese.",
+ STATUS_REGRESSAO,
+ "Sob as premissas do teste, há evidência estatística de efeito NEGATIVO: "
+ "B converteu menos que A além do MPE. Não promova B; investigue "
+ "instrumentação e hipóteses antes de descartar ou redesenhar.",
)
+ return (
+ STATUS_EFEITO_FRACO,
+ "Há sinal estatístico, mas o efeito absoluto observado é menor ou igual ao "
+ "MPE. Significância não implica impacto de negócio suficiente para rollout.",
+ )
def decision_checklist(status, abs_diff, mpe, n_a, n_b, alpha_adj):
@@ -44,12 +128,17 @@ def decision_checklist(status, abs_diff, mpe, n_a, n_b, alpha_adj):
"Confirme se a alocação A/B e a instrumentação estão corretas.",
"Evite peeking repetido sem correção sequencial (este MVP usa Bonferroni estático).",
]
- if status == "Vencedor":
+ if status == STATUS_MELHORA:
items.append(
- f"Efeito absoluto observado ({abs_diff:.4f}) supera o MPE ({mpe:.4f})."
+ f"Efeito absoluto observado ({abs_diff:.4f}) supera o MPE ({mpe:.4f}) a favor de B."
)
items.append("Estime custo/benefício do rollout antes de promover.")
- elif status == "Efeito Fraco":
+ elif status == STATUS_REGRESSAO:
+ items.append(
+ f"Efeito absoluto observado ({abs_diff:.4f}) supera o MPE ({mpe:.4f}) contra B."
+ )
+ items.append("Mantenha A ou reverta B; só então investigue a causa do prejuízo.")
+ elif status == STATUS_EFEITO_FRACO:
items.append(
f"Efeito absoluto ({abs_diff:.4f}) ≤ MPE ({mpe:.4f}): sinal sem relevância prática."
)
@@ -57,7 +146,7 @@ def decision_checklist(status, abs_diff, mpe, n_a, n_b, alpha_adj):
else:
total_n = n_a + n_b
items.append(
- f"Amostra atual n={total_n} com alpha_ajustado={alpha_adj:.4f} ainda não sustenta decisão forte."
+ f"Amostra atual n={total_n} com alpha_ajustado={alpha_adj:.4f} ainda não sustenta conclusão forte."
)
items.append("Use a aba Planejar para estimar n sob um MDE realista e poder desejado.")
return items
@@ -68,21 +157,28 @@ def health():
return jsonify({"status": "ok", "service": "statlab-experiments-api"})
+@app.route("/api/assumptions", methods=["GET"])
+def assumptions():
+ return jsonify({"assumptions": ASSUMPTIONS_SUMMARY})
+
+
@app.route("/api/calculate-sample-size", methods=["POST"])
def calculate_sample_size():
- data = request.get_json(silent=True) or {}
+ data, err = _json_object_or_error()
+ if err:
+ return err
try:
- baseline = float(data.get("baseline_conversion"))
- mde = float(data.get("mde"))
- alpha = float(data.get("alpha", 0.05))
- power = float(data.get("power", 0.80))
- except (TypeError, ValueError):
- return _error("Campos numéricos ausentes ou inválidos.")
+ baseline = _parse_float(data.get("baseline_conversion"), "baseline_conversion")
+ mde = _parse_float(data.get("mde"), "mde")
+ alpha = _parse_float(data.get("alpha", 0.05), "alpha")
+ power = _parse_float(data.get("power", 0.80), "power")
+ except ValueError as exc:
+ return _error(str(exc))
if not (0.0 < baseline < 1.0):
return _error("baseline_conversion deve estar entre 0 e 1 (exclusive).")
- if mde == 0:
- return _error("mde deve ser diferente de zero.")
+ if mde <= 0:
+ return _error("mde deve ser positivo (diferença absoluta desejada, ex.: 0.01 = 1 pp).")
p2 = baseline + mde
if not (0.0 < p2 < 1.0):
return _error("baseline_conversion + mde deve permanecer em (0, 1).")
@@ -92,9 +188,17 @@ def calculate_sample_size():
return _error("power deve estar entre 0 e 1.")
h = 2 * (math.asin(math.sqrt(baseline)) - math.asin(math.sqrt(p2)))
- n = NormalIndPower().solve_power(
- effect_size=abs(h), alpha=alpha, power=power, ratio=1
- )
+ try:
+ n = NormalIndPower().solve_power(
+ effect_size=abs(h), alpha=alpha, power=power, ratio=1
+ )
+ except (ValueError, OverflowError, RuntimeError):
+ return _error("Não foi possível resolver o tamanho amostral com estes parâmetros.")
+ if not math.isfinite(n) or n > MAX_N_PER_GROUP:
+ return _error(
+ "MDE pequeno demais para o baseline informado: a amostra exigida por grupo "
+ f"supera {MAX_N_PER_GROUP:,} observações. Revise o MDE."
+ )
n_per_group = int(math.ceil(n))
return jsonify(
{
@@ -115,17 +219,19 @@ def calculate_sample_size():
@app.route("/api/analyze", methods=["POST"])
def analyze():
- data = request.get_json(silent=True) or {}
+ data, err = _json_object_or_error()
+ if err:
+ return err
try:
- visitors_a = int(data.get("visitors_a"))
- conversions_a = int(data.get("conversions_a"))
- visitors_b = int(data.get("visitors_b"))
- conversions_b = int(data.get("conversions_b"))
- alpha = float(data.get("alpha", 0.05))
- n_comparisons = int(data.get("n_comparisons", 1))
- mpe = float(data.get("mpe", 0.005))
- except (TypeError, ValueError):
- return _error("Campos numéricos ausentes ou inválidos.")
+ visitors_a = _parse_strict_int(data.get("visitors_a"), "visitors_a")
+ conversions_a = _parse_strict_int(data.get("conversions_a"), "conversions_a")
+ visitors_b = _parse_strict_int(data.get("visitors_b"), "visitors_b")
+ conversions_b = _parse_strict_int(data.get("conversions_b"), "conversions_b")
+ alpha = _parse_float(data.get("alpha", 0.05), "alpha")
+ n_comparisons = _parse_strict_int(data.get("n_comparisons", 1), "n_comparisons")
+ mpe = _parse_float(data.get("mpe", 0.005), "mpe")
+ except ValueError as exc:
+ return _error(str(exc))
if visitors_a <= 0 or visitors_b <= 0:
return _error("visitors_a e visitors_b devem ser inteiros positivos.")
@@ -144,48 +250,78 @@ def analyze():
p_b = conversions_b / visitors_b
count = np.array([conversions_a, conversions_b])
nobs = np.array([visitors_a, visitors_b])
+ warnings_list = []
+
+ import warnings as _warnings
- with warnings.catch_warnings():
- warnings.filterwarnings(
- "ignore",
- message="invalid value encountered in scalar divide",
- category=RuntimeWarning,
+ with _warnings.catch_warnings():
+ _warnings.filterwarnings("ignore", message="invalid value encountered*")
+ try:
+ _z_stat, p_val_raw = proportions_ztest(count, nobs)
+ except (ValueError, ZeroDivisionError):
+ p_val_raw = float("nan")
+ p_val = None if p_val_raw is None else float(p_val_raw)
+ test_defined = p_val is not None and math.isfinite(p_val) and not math.isnan(p_val)
+ if not test_defined:
+ warnings_list.append(
+ "Teste z indefinido (variância nula sob H0): p-valor não reportado."
)
- _z_stat, p_val = proportions_ztest(count, nobs)
- if p_val is None or math.isnan(float(p_val)):
- p_val = 1.0
- variance_a = (p_a * (1 - p_a)) / visitors_a
- variance_b = (p_b * (1 - p_b)) / visitors_b
- se = math.sqrt(max(0.0, variance_a + variance_b))
+ # Regra de bolso documentada para a aproximação normal do z-test.
+ for label, conv, visit in (("A", conversions_a, visitors_a), ("B", conversions_b, visitors_b)):
+ if min(conv, visit - conv) < 5:
+ warnings_list.append(
+ f"Poucos sucessos ou falhas no braço {label} ({conv}/{visit}): "
+ "a aproximação normal pode ser inadequada; interprete com cautela."
+ )
+
diff = p_b - p_a
+ alpha_adj = alpha / n_comparisons
+ ci_level = 1.0 - alpha_adj
- z = norm.ppf(1 - alpha / 2)
- ci_low = diff - z * se
- ci_high = diff + z * se
+ # Newcombe (Wilson híbrido) para pB - pA no nível ajustado — ver
+ # docs/STATISTICAL_METHOD.md: o score test usa variância pooled sob H0 e o
+ # IC usa variâncias próprias de cada braço; métodos distintos por design.
+ ci_low, ci_high = confint_proportions_2indep(
+ conversions_b, visitors_b, conversions_a, visitors_a,
+ compare="diff", method=CI_METHOD, alpha=alpha_adj,
+ )
- alpha_adj = alpha / n_comparisons
- status, interpretation = interpret_result(p_val, p_a, p_b, alpha_adj, mpe)
- uplift = (p_b / p_a - 1) if p_a > 0 else 0.0
+ status, interpretation = interpret_result(
+ diff, test_defined, p_val if test_defined else 1.0, alpha_adj, mpe
+ )
+ uplift = (p_b / p_a - 1) if p_a > 0 else None
abs_diff = abs(diff)
+ significant = bool(test_defined and p_val < alpha_adj)
practical = abs_diff > mpe
- significant = bool(p_val < alpha_adj)
+
+ if significant and diff > mpe:
+ direction = "positive"
+ elif significant and diff < -mpe:
+ direction = "negative"
+ else:
+ direction = "neutral"
return jsonify(
{
- "p_value": float(p_val),
+ "test_defined": test_defined,
+ "p_value": float(p_val) if test_defined else None,
+ "warnings": warnings_list,
"alpha_ajustado": float(alpha_adj),
"alpha": float(alpha),
"n_comparisons": int(n_comparisons),
"mpe": float(mpe),
- "uplift": float(uplift),
+ "uplift": float(uplift) if uplift is not None else None,
"absolute_diff": float(diff),
"conversion_a": float(p_a),
"conversion_b": float(p_b),
"ci_low": float(ci_low),
"ci_high": float(ci_high),
+ "ci_level": float(ci_level),
+ "ci_method": CI_METHOD,
"significant": significant,
"practically_significant": bool(practical),
+ "direction": direction,
"status": status,
"interpretation": interpretation,
"next_steps": decision_checklist(
@@ -196,8 +332,8 @@ def analyze():
SCENARIOS = {
- "vencedor": {
- "label": "Vencedor (sinal + relevância)",
+ "melhora": {
+ "label": "Melhora (sinal + relevância)",
"analyze": {
"visitors_a": 10000,
"conversions_a": 500,
@@ -207,7 +343,20 @@ def analyze():
"n_comparisons": 3,
"mpe": 0.005,
},
- "lesson": "Significância após Bonferroni e efeito absoluto acima do MPE.",
+ "lesson": "Significância após Bonferroni e efeito absoluto acima do MPE, a favor de B.",
+ },
+ "regressao": {
+ "label": "Regressão (B significativamente pior)",
+ "analyze": {
+ "visitors_a": 10000,
+ "conversions_a": 6000,
+ "visitors_b": 10000,
+ "conversions_b": 5800,
+ "alpha": 0.05,
+ "n_comparisons": 1,
+ "mpe": 0.005,
+ },
+ "lesson": "p-valor baixo com B abaixo de A não é vitória: o sinal aponta prejuízo.",
},
"efeito_fraco": {
"label": "Efeito Fraco (sinal sem relevância)",
@@ -246,7 +395,7 @@ def analyze():
"n_comparisons": 1,
"mpe": 0.005,
},
- "lesson": "Sem eventos, não há evidência — a API retorna Inconclusivo sem crash.",
+ "lesson": "Sem eventos o teste é indefinido: sem p-valor fabricado, apenas o IC honesto.",
},
}
@@ -261,8 +410,9 @@ def demo():
"alpha": 0.05,
"power": 0.80,
},
- "analyze": SCENARIOS["vencedor"]["analyze"],
+ "analyze": SCENARIOS["melhora"]["analyze"],
"scenarios": SCENARIOS,
+ "assumptions": ASSUMPTIONS_SUMMARY,
}
)
diff --git a/api-server/api/tests/test_api.py b/api-server/api/tests/test_api.py
index d762317..52def68 100644
--- a/api-server/api/tests/test_api.py
+++ b/api-server/api/tests/test_api.py
@@ -1,14 +1,39 @@
import math
import numpy as np
-from scipy.stats import norm
from statsmodels.stats.power import NormalIndPower
+from statsmodels.stats.proportion import confint_proportions_2indep
-from api.index import app
+from api.index import SCENARIOS, app
client = app.test_client()
+def _analyze(payload):
+ r = client.post("/api/analyze", json=payload)
+ assert r.status_code == 200, (r.status_code, r.get_json())
+ return r.get_json()
+
+
+def _analyze_error(payload):
+ r = client.post("/api/analyze", json=payload)
+ assert r.status_code == 400, (r.status_code, r.get_json())
+ assert isinstance(r.get_json().get("error"), str)
+ return r.get_json()
+
+
+def _expected_ci(conversions_a, visitors_a, conversions_b, visitors_b, alpha_adj):
+ """Referência: chamada direta à biblioteca (Newcombe, diff = pB - pA)."""
+ return confint_proportions_2indep(
+ conversions_b, visitors_b, conversions_a, visitors_a,
+ compare="diff", method="newcomb", alpha=alpha_adj,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Estrutura básica
+# ---------------------------------------------------------------------------
+
def test_health():
r = client.get("/api/health")
assert r.status_code == 200
@@ -22,58 +47,365 @@ def test_demo_structure():
assert "sample_size" in data and "analyze" in data and "scenarios" in data
assert "baseline_conversion" in data["sample_size"]
assert "visitors_a" in data["analyze"]
- assert "vencedor" in data["scenarios"]
+ assert "melhora" in data["scenarios"]
+ assert "assumptions" in data and len(data["assumptions"]) >= 5
+
+
+def test_assumptions_endpoint():
+ r = client.get("/api/assumptions")
+ assert r.status_code == 200
+ items = r.get_json()["assumptions"]
+ assert len(items) >= 5
+ assert any("independen" in i.lower() for i in items)
+ assert any("sequencia" in i.lower() for i in items)
-def test_scenarios_endpoint():
+def test_scenarios_endpoint_has_five_public_states():
r = client.get("/api/scenarios")
assert r.status_code == 200
scenarios = r.get_json()["scenarios"]
- assert set(scenarios) >= {"vencedor", "efeito_fraco", "inconclusivo", "zero_conversoes"}
+ assert set(scenarios) == {
+ "melhora", "regressao", "efeito_fraco", "inconclusivo", "zero_conversoes"
+ }
-def test_sample_size_includes_methodology_fields():
- payload = {"baseline_conversion": 0.05, "mde": 0.01, "alpha": 0.05, "power": 0.80}
- data = client.post("/api/calculate-sample-size", json=payload).get_json()
- assert data["n_per_group"] > 0
- assert data["effect_size_h"] > 0
- assert "note" in data
- assert data["power"] == 0.80
+def test_every_public_scenario_resolves_to_distinct_status():
+ statuses = {}
+ for key, scenario in SCENARIOS.items():
+ data = _analyze(scenario["analyze"])
+ statuses[key] = data["status"]
+ assert statuses["melhora"] == "Melhora"
+ assert statuses["regressao"] == "Regressão"
+ assert statuses["efeito_fraco"] == "Efeito Fraco"
+ assert statuses["inconclusivo"] == "Inconclusivo"
+ assert statuses["zero_conversoes"] == "Inconclusivo"
+
+
+# ---------------------------------------------------------------------------
+# G1 — igualdade: não significativo
+# ---------------------------------------------------------------------------
+
+def test_g1_equality_is_inconclusive():
+ data = _analyze({"visitors_a": 1000, "conversions_a": 50,
+ "visitors_b": 1000, "conversions_b": 50, "alpha": 0.05})
+ assert data["test_defined"] is True
+ assert data["significant"] is False
+ assert data["status"] == "Inconclusivo"
+ assert data["direction"] == "neutral"
+ assert data["p_value"] > 0.99
+
+# ---------------------------------------------------------------------------
+# G2 — positive: Melhora (inclusive com Bonferroni)
+# ---------------------------------------------------------------------------
-def test_analyze_includes_practical_fields_and_next_steps():
+def test_g2_positive_is_melhora():
payload = {"visitors_a": 10000, "conversions_a": 500,
"visitors_b": 10000, "conversions_b": 580,
"alpha": 0.05, "n_comparisons": 3, "mpe": 0.005}
- data = client.post("/api/analyze", json=payload).get_json()
- assert data["status"] == "Vencedor"
- assert "absolute_diff" in data
+ data = _analyze(payload)
+ assert data["significant"] is True
assert data["practically_significant"] is True
+ assert data["direction"] == "positive"
+ assert data["status"] == "Melhora"
+ assert data["alpha_ajustado"] == 0.05 / 3
assert isinstance(data["next_steps"], list) and len(data["next_steps"]) >= 2
+ assert data["uplift"] > 0
-def test_scenario_efeito_fraco_status():
- from api.index import SCENARIOS
- payload = SCENARIOS["efeito_fraco"]["analyze"]
- data = client.post("/api/analyze", json=payload).get_json()
- assert data["status"] == "Efeito Fraco"
+# ---------------------------------------------------------------------------
+# G3 — significant negative: Regressão (caso histórico 60% vs 58%)
+# ---------------------------------------------------------------------------
+
+def test_g3_significant_negative_is_regressao_never_melhora():
+ """Reprodução do P0: B pior com p ≈ 0.004 nunca pode ser vitória."""
+ payload = {"visitors_a": 10000, "conversions_a": 6000,
+ "visitors_b": 10000, "conversions_b": 5800,
+ "alpha": 0.05, "n_comparisons": 1, "mpe": 0.005}
+ data = _analyze(payload)
+ assert abs(data["p_value"] - 0.0040352613785050295) < 1e-12
+ assert data["absolute_diff"] < -0.02 + 1e-9
+ assert data["significant"] is True
+ assert data["direction"] == "negative"
+ assert data["status"] == "Regressão"
+ assert data["status"] not in {"Melhora", "Vencedor"}
+ assert any("NEGATIVO" in s or "contra B" in s for s in data["next_steps"])
+
+
+def test_g3_negative_ci_excludes_zero_on_the_negative_side():
+ data = _analyze({"visitors_a": 10000, "conversions_a": 6000,
+ "visitors_b": 10000, "conversions_b": 5800,
+ "alpha": 0.05, "n_comparisons": 1, "mpe": 0.005})
+ assert data["ci_high"] < 0
+
+
+# ---------------------------------------------------------------------------
+# G4 — weak: significativo mas magnitude <= MPE
+# ---------------------------------------------------------------------------
+
+def test_g4_weak_effect():
+ payload = {"visitors_a": 5_000_000, "conversions_a": 250_000,
+ "visitors_b": 5_000_000, "conversions_b": 252_000,
+ "alpha": 0.05, "mpe": 0.005}
+ data = _analyze(payload)
assert data["significant"] is True
assert data["practically_significant"] is False
+ assert data["status"] == "Efeito Fraco"
-def test_scenario_inconclusivo_status():
- from api.index import SCENARIOS
- payload = SCENARIOS["inconclusivo"]["analyze"]
- data = client.post("/api/analyze", json=payload).get_json()
- assert data["status"] == "Inconclusivo"
+def test_g4_weak_effect_with_large_mpe_boundary():
+ data = _analyze({"visitors_a": 10000, "conversions_a": 500,
+ "visitors_b": 10000, "conversions_b": 580,
+ "alpha": 0.05, "n_comparisons": 1, "mpe": 0.05})
+ assert data["significant"] is True
+ assert data["status"] == "Efeito Fraco"
+
+
+# ---------------------------------------------------------------------------
+# G5 — Bonferroni flip: n=1 vs n>1
+# ---------------------------------------------------------------------------
+
+def test_g5_bonferroni_flip():
+ payload = {"visitors_a": 10000, "conversions_a": 500,
+ "visitors_b": 10000, "conversions_b": 567, "alpha": 0.05}
+ single = _analyze({**payload, "n_comparisons": 1})
+ multi = _analyze({**payload, "n_comparisons": 10})
+ assert single["status"] == "Melhora"
+ assert multi["status"] == "Inconclusivo"
+ assert multi["alpha_ajustado"] < single["alpha_ajustado"]
+
+
+# ---------------------------------------------------------------------------
+# G6 — adjusted CI contra referência da biblioteca
+# ---------------------------------------------------------------------------
+
+def test_g6_ci_matches_library_reference_normal():
+ payload = {"visitors_a": 10000, "conversions_a": 500,
+ "visitors_b": 10000, "conversions_b": 580,
+ "alpha": 0.05, "n_comparisons": 1, "mpe": 0.005}
+ data = _analyze(payload)
+ lo, hi = _expected_ci(500, 10000, 580, 10000, alpha_adj=0.05)
+ assert abs(data["ci_low"] - lo) < 1e-9
+ assert abs(data["ci_high"] - hi) < 1e-9
+ assert abs(data["ci_level"] - 0.95) < 1e-12
+ assert data["ci_method"] == "newcomb"
+
+
+def test_g6_ci_matches_library_reference_with_bonferroni():
+ payload = {"visitors_a": 10000, "conversions_a": 500,
+ "visitors_b": 10000, "conversions_b": 580,
+ "alpha": 0.05, "n_comparisons": 3, "mpe": 0.005}
+ data = _analyze(payload)
+ lo, hi = _expected_ci(500, 10000, 580, 10000, alpha_adj=0.05 / 3)
+ assert abs(data["ci_low"] - lo) < 1e-9
+ assert abs(data["ci_high"] - hi) < 1e-9
+ # nível do IC acompanha a inferência family-wise (98,33%), não o nominal 95%
+ assert abs(data["ci_level"] - (1 - 0.05 / 3)) < 1e-12
+ bonferroni_width = hi - lo
+ nominal_lo, nominal_hi = _expected_ci(500, 10000, 580, 10000, alpha_adj=0.05)
+ assert bonferroni_width > (nominal_hi - nominal_lo)
+
+
+def test_g6_ci_matches_library_reference_unbalanced():
+ payload = {"visitors_a": 1000, "conversions_a": 50,
+ "visitors_b": 3000, "conversions_b": 120,
+ "alpha": 0.05, "n_comparisons": 1}
+ data = _analyze(payload)
+ lo, hi = _expected_ci(50, 1000, 120, 3000, alpha_adj=0.05)
+ assert abs(data["ci_low"] - lo) < 1e-9
+ assert abs(data["ci_high"] - hi) < 1e-9
+
+
+def test_g6_ci_matches_library_reference_near_zero_proportion():
+ payload = {"visitors_a": 20000, "conversions_a": 10,
+ "visitors_b": 20000, "conversions_b": 30,
+ "alpha": 0.05, "n_comparisons": 1}
+ data = _analyze(payload)
+ lo, hi = _expected_ci(10, 20000, 30, 20000, alpha_adj=0.05)
+ assert abs(data["ci_low"] - lo) < 1e-9
+ assert abs(data["ci_high"] - hi) < 1e-9
+
+
+def test_g6_ci_matches_library_reference_near_one():
+ payload = {"visitors_a": 1000, "conversions_a": 950,
+ "visitors_b": 1000, "conversions_b": 990,
+ "alpha": 0.05, "n_comparisons": 1}
+ data = _analyze(payload)
+ lo, hi = _expected_ci(950, 1000, 990, 1000, alpha_adj=0.05)
+ assert abs(data["ci_low"] - lo) < 1e-9
+ assert abs(data["ci_high"] - hi) < 1e-9
+
+
+def test_g6_ci_is_for_pB_minus_pA_order():
+ data = _analyze({"visitors_a": 10000, "conversions_a": 500,
+ "visitors_b": 10000, "conversions_b": 580, "alpha": 0.05})
+ # centro do IC aproxima diff = pB - pA > 0
+ assert data["ci_low"] < data["ci_high"]
+ assert data["ci_low"] + data["ci_high"] > 0
+
+
+# ---------------------------------------------------------------------------
+# G7 — JSON top-level não objeto → 400 (nunca 500)
+# ---------------------------------------------------------------------------
+
+def test_g7_non_object_json_body_returns_400():
+ for body in ([1, 2, 3], "hello", 42, None):
+ r = client.post("/api/analyze", json=body)
+ assert r.status_code == 400, (body, r.status_code)
+ assert "error" in r.get_json()
+
+
+def test_g7_non_object_json_body_returns_400_sample_size():
+ for body in ([1], "x", 7):
+ r = client.post("/api/calculate-sample-size", json=body)
+ assert r.status_code == 400, (body, r.status_code)
+
+
+def test_g7_malformed_json_returns_400_not_500():
+ r = client.post(
+ "/api/analyze", data="{not json", content_type="application/json"
+ )
+ assert r.status_code == 400
+ assert "error" in r.get_json()
+
+
+# ---------------------------------------------------------------------------
+# G8 — float em contagens → 400 (sem truncamento silencioso)
+# ---------------------------------------------------------------------------
+
+def test_g8_float_counts_rejected():
+ _analyze_error({"visitors_a": 1000.5, "conversions_a": 50,
+ "visitors_b": 1000, "conversions_b": 65})
+ _analyze_error({"visitors_a": 1000, "conversions_a": 100.7,
+ "visitors_b": 1000, "conversions_b": 65})
+
+
+def test_g8_nonfinite_counts_rejected():
+ for bad in (float("nan"), float("inf"), -float("inf")):
+ _analyze_error({"visitors_a": 1000, "conversions_a": bad,
+ "visitors_b": 1000, "conversions_b": 65})
+ _analyze_error({"visitors_a": bad, "conversions_a": 50,
+ "visitors_b": 1000, "conversions_b": 65})
+
+
+def test_g8_decimal_strings_rejected_but_integer_strings_accepted():
+ _analyze_error({"visitors_a": 1000, "conversions_a": "100.7",
+ "visitors_b": 1000, "conversions_b": 65})
+ data = _analyze({"visitors_a": "1000", "conversions_a": "50",
+ "visitors_b": "1000", "conversions_b": "65", "alpha": "0.05"})
+ assert data["conversion_a"] == 0.05
+
+
+def test_g8_negative_counts_rejected():
+ _analyze_error({"visitors_a": 1000, "conversions_a": -1,
+ "visitors_b": 1000, "conversions_b": 65})
+
+
+# ---------------------------------------------------------------------------
+# G9 — bool em contagens → 400
+# ---------------------------------------------------------------------------
+
+def test_g9_bool_counts_rejected():
+ _analyze_error({"visitors_a": 1000, "conversions_a": True,
+ "visitors_b": 1000, "conversions_b": 65})
+ _analyze_error({"visitors_a": False, "conversions_a": 0,
+ "visitors_b": 1000, "conversions_b": 65})
+ _analyze_error({"visitors_a": 1000, "conversions_a": 0,
+ "visitors_b": 1000, "conversions_b": 65, "n_comparisons": True})
+
+
+# ---------------------------------------------------------------------------
+# G10 — zero/zero: sem p-valor fabricado
+# ---------------------------------------------------------------------------
+
+def test_g10_zero_vs_zero_no_fake_p():
+ data = _analyze({"visitors_a": 1000, "conversions_a": 0,
+ "visitors_b": 1000, "conversions_b": 0, "alpha": 0.05})
+ assert data["test_defined"] is False
+ assert data["p_value"] is None
assert data["significant"] is False
+ assert data["status"] == "Inconclusivo"
+ assert len(data["warnings"]) >= 1
+ # IC honesto e simétrico em torno de zero
+ lo, hi = _expected_ci(0, 1000, 0, 1000, alpha_adj=0.05)
+ assert abs(data["ci_low"] - lo) < 1e-9
+ assert abs(data["ci_high"] - hi) < 1e-9
+
+
+# ---------------------------------------------------------------------------
+# G11 — 100%/100%: sem p-valor fabricado
+# ---------------------------------------------------------------------------
+
+def test_g11_full_conversion_no_fake_p():
+ data = _analyze({"visitors_a": 1000, "conversions_a": 1000,
+ "visitors_b": 1000, "conversions_b": 1000, "alpha": 0.05})
+ assert data["test_defined"] is False
+ assert data["p_value"] is None
+ assert data["status"] == "Inconclusivo"
+ assert len(data["warnings"]) >= 1
+
+
+# ---------------------------------------------------------------------------
+# G12 — pA = 0: uplift relativo indefinido
+# ---------------------------------------------------------------------------
+
+def test_g12_baseline_zero_uplift_undefined():
+ data = _analyze({"visitors_a": 1000, "conversions_a": 0,
+ "visitors_b": 1000, "conversions_b": 10, "alpha": 0.05})
+ assert data["uplift"] is None
+ # diferença absoluta continua válida
+ assert abs(data["absolute_diff"] - 0.01) < 1e-9
+ assert data["conversion_a"] == 0.0
+
+
+# ---------------------------------------------------------------------------
+# G13 — MDE negativo → 400 (e demais guards de sample-size)
+# ---------------------------------------------------------------------------
+
+def test_g13_negative_mde_rejected():
+ r = client.post("/api/calculate-sample-size",
+ json={"baseline_conversion": 0.05, "mde": -0.01})
+ assert r.status_code == 400
+
+
+def test_g13_zero_mde_rejected():
+ r = client.post("/api/calculate-sample-size",
+ json={"baseline_conversion": 0.05, "mde": 0})
+ assert r.status_code == 400
+
+
+def test_g13_tiny_mde_rejected_with_honest_message():
+ r = client.post("/api/calculate-sample-size",
+ json={"baseline_conversion": 0.05, "mde": 1e-9})
+ assert r.status_code == 400
+ assert "MDE" in r.get_json()["error"]
+
+
+def test_g13_nonfinite_alpha_power_rejected():
+ for overrides in ({"alpha": "nan"}, {"power": float("inf")}):
+ r = client.post("/api/calculate-sample-size",
+ json={"baseline_conversion": 0.05, "mde": 0.01, **overrides})
+ assert r.status_code == 400, overrides
+
+
+def test_g13_negative_mpe_rejected_in_analyze():
+ _analyze_error({"visitors_a": 1000, "conversions_a": 50,
+ "visitors_b": 1000, "conversions_b": 65, "mpe": -0.01})
+
+# ---------------------------------------------------------------------------
+# Sample-size: fechado contra Cohen's h + monotonicidade
+# ---------------------------------------------------------------------------
+
+def test_sample_size_matches_closed_form_cohen_h():
payload = {"baseline_conversion": 0.05, "mde": 0.01, "alpha": 0.05, "power": 0.80}
r = client.post("/api/calculate-sample-size", json=payload)
assert r.status_code == 200
- n = r.get_json()["n_per_group"]
+ data = r.get_json()
+ n = data["n_per_group"]
assert isinstance(n, int) and n > 0
-
+ assert data["effect_size_h"] > 0
+ assert "note" in data
p1, p2 = payload["baseline_conversion"], payload["baseline_conversion"] + payload["mde"]
h = 2 * (math.asin(math.sqrt(p1)) - math.asin(math.sqrt(p2)))
expected = int(math.ceil(NormalIndPower().solve_power(
@@ -94,84 +426,31 @@ def test_sample_size_invalid_baseline():
assert r.status_code == 400
-def test_sample_size_invalid_mde_zero():
- r = client.post("/api/calculate-sample-size", json={"baseline_conversion": 0.05, "mde": 0})
- assert r.status_code == 400
-
-
def test_sample_size_invalid_sum_over_one():
r = client.post("/api/calculate-sample-size", json={"baseline_conversion": 0.9, "mde": 0.2})
assert r.status_code == 400
-def test_analyze_winner():
- payload = {"visitors_a": 10000, "conversions_a": 500,
- "visitors_b": 10000, "conversions_b": 580,
- "alpha": 0.05, "n_comparisons": 3, "mpe": 0.005}
- r = client.post("/api/analyze", json=payload)
- assert r.status_code == 200
- data = r.get_json()
- assert data["status"] == "Vencedor"
- assert data["significant"] is True
- assert data["conversion_b"] > data["conversion_a"]
- assert data["uplift"] > 0
- assert data["ci_low"] < data["ci_high"]
+def test_sample_size_custom_alpha_changes_n():
+ base = {"baseline_conversion": 0.05, "mde": 0.01, "power": 0.80}
+ lax = client.post("/api/calculate-sample-size", json={**base, "alpha": 0.10}).get_json()["n_per_group"]
+ strict = client.post("/api/calculate-sample-size", json={**base, "alpha": 0.01}).get_json()["n_per_group"]
+ assert strict > lax
-def test_analyze_inconclusive():
- payload = {"visitors_a": 1000, "conversions_a": 50,
- "visitors_b": 1000, "conversions_b": 52,
- "alpha": 0.05}
- r = client.post("/api/analyze", json=payload)
- assert r.status_code == 200
- data = r.get_json()
- assert data["status"] == "Inconclusivo"
- assert data["significant"] is False
-
-
-def test_analyze_weak_effect():
- payload = {"visitors_a": 5_000_000, "conversions_a": 250_000,
- "visitors_b": 5_000_000, "conversions_b": 252_000,
- "alpha": 0.05, "mpe": 0.005}
- r = client.post("/api/analyze", json=payload)
- assert r.status_code == 200
- data = r.get_json()
- assert data["status"] == "Efeito Fraco"
- assert data["significant"] is True
- assert abs(data["conversion_b"] - data["conversion_a"]) <= 0.005
-
-
-def test_analyze_bonferroni_flip():
- payload = {"visitors_a": 10000, "conversions_a": 500,
- "visitors_b": 10000, "conversions_b": 567,
- "alpha": 0.05}
- single = client.post("/api/analyze", json={**payload, "n_comparisons": 1}).get_json()
- multi = client.post("/api/analyze", json={**payload, "n_comparisons": 10}).get_json()
- assert single["status"] == "Vencedor"
- assert multi["status"] == "Inconclusivo"
- assert multi["alpha_ajustado"] < single["alpha_ajustado"]
-
-
-def test_analyze_zero_conversions_no_crash():
- payload = {"visitors_a": 1000, "conversions_a": 0,
- "visitors_b": 1000, "conversions_b": 0, "alpha": 0.05}
- r = client.post("/api/analyze", json=payload)
- assert r.status_code == 200
- data = r.get_json()
- assert data["status"] == "Inconclusivo"
- assert data["ci_low"] == data["ci_high"] == 0.0
- assert math.isnan(data["p_value"]) is False
-
+# ---------------------------------------------------------------------------
+# Validações legadas de analyze
+# ---------------------------------------------------------------------------
def test_analyze_validation_zero_visitors():
r = client.post("/api/analyze", json={"visitors_a": 0, "conversions_a": 0,
- "visitors_b": 100, "conversions_b": 5})
+ "visitors_b": 100, "conversions_b": 5})
assert r.status_code == 400
def test_analyze_validation_conversions_exceed_visitors():
r = client.post("/api/analyze", json={"visitors_a": 100, "conversions_a": 150,
- "visitors_b": 100, "conversions_b": 5})
+ "visitors_b": 100, "conversions_b": 5})
assert r.status_code == 400
@@ -180,36 +459,35 @@ def test_analyze_validation_missing_fields():
assert r.status_code == 400
-def test_analyze_ci_reflects_alpha():
- payload = {"visitors_a": 10000, "conversions_a": 500,
- "visitors_b": 10000, "conversions_b": 580, "alpha": 0.10}
- data = client.post("/api/analyze", json=payload).get_json()
- p_a, p_b = data["conversion_a"], data["conversion_b"]
- diff = p_b - p_a
- se = math.sqrt((p_a * (1 - p_a)) / 10000 + (p_b * (1 - p_b)) / 10000)
- z = norm.ppf(1 - 0.10 / 2)
- assert abs(data["ci_low"] - (diff - z * se)) < 1e-9
- assert abs(data["ci_high"] - (diff + z * se)) < 1e-9
-
-
-def test_analyze_weak_effect_with_small_mpe_boundary():
- """Significant uplift below a large MPE should be Weak Effect."""
- payload = {
- "visitors_a": 10000,
- "conversions_a": 500,
- "visitors_b": 10000,
- "conversions_b": 580,
- "alpha": 0.05,
- "n_comparisons": 1,
- "mpe": 0.05,
- }
- data = client.post("/api/analyze", json=payload).get_json()
- assert data["significant"] is True
- assert abs(data["conversion_b"] - data["conversion_a"]) < 0.05
- assert data["status"] == "Efeito Fraco"
+def test_analyze_validation_alpha_bounds():
+ for alpha in (0.0, 1.0, 1.5):
+ r = client.post("/api/analyze", json={"visitors_a": 1000, "conversions_a": 50,
+ "visitors_b": 1000, "conversions_b": 65,
+ "alpha": alpha})
+ assert r.status_code == 400, alpha
+
+
+def test_analyze_validation_n_comparisons():
+ r = client.post("/api/analyze", json={"visitors_a": 1000, "conversions_a": 50,
+ "visitors_b": 1000, "conversions_b": 65,
+ "n_comparisons": 0})
+ assert r.status_code == 400
+
+
+def test_analyze_inconclusive_small_sample():
+ data = _analyze({"visitors_a": 1000, "conversions_a": 50,
+ "visitors_b": 1000, "conversions_b": 52, "alpha": 0.05})
+ assert data["status"] == "Inconclusivo"
+ assert data["significant"] is False
+
+
+def test_analyze_warns_on_few_successes():
+ data = _analyze({"visitors_a": 100, "conversions_a": 1,
+ "visitors_b": 100, "conversions_b": 6, "alpha": 0.05})
+ assert any("aproximação normal" in w for w in data["warnings"])
-def test_demo_analyze_payload_is_winner():
+def test_demo_analyze_payload_is_melhora():
demo = client.get("/api/demo").get_json()["analyze"]
- data = client.post("/api/analyze", json=demo).get_json()
- assert data["status"] == "Vencedor"
+ data = _analyze(demo)
+ assert data["status"] == "Melhora"
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index d6b5b25..9188527 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -48,8 +48,11 @@ Calls are always **relative** (`/api/...`). Never hardcode the backend URL in th
### Statistical core
1. **Sample size:** Cohen’s *h* + `NormalIndPower.solve_power`
-2. **Analysis:** `proportions_ztest`, CI via `norm.ppf(1 - alpha/2)`, Bonferroni `alpha / n_comparisons`
-3. **Decision:** Vencedor / Inconclusivo / Efeito Fraco using significance × MPE
+2. **Analysis:** `proportions_ztest` (score test, bilateral); Bonferroni `alpha / n_comparisons`
+3. **CI:** Newcombe (Wilson híbrido) para `pB − pA` via `confint_proportions_2indep`, no nível ajustado (`ci_level = 1 − alpha_adj`)
+4. **Decision:** Melhora / Regressão / Efeito Fraco / Inconclusivo — significância × MPE **com direção assinada** (`diff = pB − pA`)
+
+Detalhes e premissas: [`STATISTICAL_METHOD.md`](./STATISTICAL_METHOD.md).
## Local development
diff --git a/docs/AUDIT_REPORT.md b/docs/AUDIT_REPORT.md
index 7ab14b4..14a1eb1 100644
--- a/docs/AUDIT_REPORT.md
+++ b/docs/AUDIT_REPORT.md
@@ -1,3 +1,10 @@
+> **SNAPSHOT HISTÓRICO (2026-07)** — Este documento registra o estado do projeto
+> antes do hardening científico de 2026-08-21 (motor de 4 estados, IC Newcombe
+> ajustado, contratos honestos de edge cases). Termos como "3 estados",
+> "Vencedor", "FastAPI" e a URL legada refletem o contexto da época e NÃO
+> descrevem o comportamento atual. Fontes de verdade atuais: README.md,
+> docs/STATISTICAL_METHOD.md, docs/api-contract.md, docs/architecture-guardrails.md.
+
# AUDIT_REPORT.md — StatLab Experiments
**Data:** 2026-07-13
diff --git a/docs/DEMO_SCRIPT.md b/docs/DEMO_SCRIPT.md
index 786a214..0a37e11 100644
--- a/docs/DEMO_SCRIPT.md
+++ b/docs/DEMO_SCRIPT.md
@@ -9,32 +9,38 @@
“StatLab é um laboratório frequentista para planejar e interpretar testes A/B. Não é plataforma de experimentação em produção; é um case de estatística aplicada + engenharia.”
2. **Planejar (60–90s)**
- - Aba Planejar → baseline `0.05`, MDE `0.01`, poder `0.80` → Calcular.
- - Explicar: MDE = menor efeito absoluto desejado; poder = chance de detectar se existir; n ≈ 8143/grupo.
+ - Aba Planejar → baseline `0.05`, MDE `0.01`, poder `0.80`, alpha `0.05` → Calcular.
+ - Explicar: MDE = menor efeito absoluto desejado (0.01 = 1 pp); poder = chance de detectar se existir; n ≈ 8143/grupo.
-3. **Cenário Vencedor (60s)**
- - Chip “Vencedor” → status verde, significativo + relevante (MPE).
- - Mostrar badges e próximos passos (validar instrumentação / custo de rollout).
+3. **Cenário Melhora (60s)**
+ - Chip “Melhora” → status verde, significativo após Bonferroni + relevante (MPE).
+ - Mostrar alpha ajustado, IC no nível real (98,33% com 3 comparações) e próximos passos.
-4. **Cenário Efeito Fraco (45s)**
+4. **Cenário Regressão (60s)** — o diferencial do lab
+ - Chip “Regressão” → A 60% vs B 58%, p ≈ 0.004, diff = −2 pp.
+ - Punchline: “p-valor baixo não é vitória — aqui o sinal diz que B **prejudica**. O motor antigo chamava isso de Vencedor; agora é impossível.”
+
+5. **Cenário Efeito Fraco (45s)**
- Mesmo uplift, MPE alto → “sinal sem relevância prática”.
- Punchline: p-valor baixo ≠ rollout automático.
-5. **Cenário Inconclusivo (45s)**
- - Amostra pequena → não declarar vencedor; voltar ao Planejar.
+6. **Cenário Inconclusivo + caso-limite (45s)**
+ - Amostra pequena → não declarar vencedor; voltar ao Planejar.
+ - Zero conversões → p-valor “indefinido” honesto (sem número fabricado) + IC.
-6. **Fechamento (30s)**
+7. **Fechamento (30s)**
- Arquitetura: Next.js + Flask WSGI em dois projetos Vercel (rewrite `/api`).
- - Testes pytest + CI.
- - Limitações: sem auth/DB/sequential testing.
+ - 47 testes pytest (golden states G1–G13) + CI.
+ - Limitações: sem auth/DB/sequential testing — premissas visíveis na UI.
## Captura de screenshots
Salvar em `docs/screenshots/`:
1. `01-plan-sample-size.png` — resultado do cálculo de n
-2. `02-analyze-vencedor.png` — card Vencedor + gráfico
-3. `03-analyze-efeito-fraco.png` — contraste didático
-4. `04-scenarios-chips.png` — chips de cenários
+2. `02-analyze-melhora.png` — card Melhora + IC com nível real
+3. `03-analyze-regressao.png` — card Regressão (guard do P0)
+4. `04-analyze-efeito-fraco.png` — contraste didático
+5. `05-scenarios-chips.png` — chips dos 5 cenários
Sem PII. Preferir desktop 1280×800 e um crop mobile opcional.
diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md
index c121cf0..d507e15 100644
--- a/docs/HANDOFF.md
+++ b/docs/HANDOFF.md
@@ -1,3 +1,10 @@
+> **SNAPSHOT HISTÓRICO (2026-07)** — Este documento registra o estado do projeto
+> antes do hardening científico de 2026-08-21 (motor de 4 estados, IC Newcombe
+> ajustado, contratos honestos de edge cases). Termos como "3 estados",
+> "Vencedor", "FastAPI" e a URL legada refletem o contexto da época e NÃO
+> descrevem o comportamento atual. Fontes de verdade atuais: README.md,
+> docs/STATISTICAL_METHOD.md, docs/api-contract.md, docs/architecture-guardrails.md.
+
# HANDOFF.md — Portfolio Quality Pass
**Branch:** `chore/portfolio-quality-pass`
diff --git a/docs/PORTFOLIO_HANDOFF.md b/docs/PORTFOLIO_HANDOFF.md
index 690118c..bc2d396 100644
--- a/docs/PORTFOLIO_HANDOFF.md
+++ b/docs/PORTFOLIO_HANDOFF.md
@@ -1,3 +1,10 @@
+> **SNAPSHOT HISTÓRICO (2026-07)** — Este documento registra o estado do projeto
+> antes do hardening científico de 2026-08-21 (motor de 4 estados, IC Newcombe
+> ajustado, contratos honestos de edge cases). Termos como "3 estados",
+> "Vencedor", "FastAPI" e a URL legada refletem o contexto da época e NÃO
+> descrevem o comportamento atual. Fontes de verdade atuais: README.md,
+> docs/STATISTICAL_METHOD.md, docs/api-contract.md, docs/architecture-guardrails.md.
+
# PORTFOLIO_HANDOFF — StatLab Experiments
**Data:** 2026-07-13
diff --git a/docs/STATISTICAL_METHOD.md b/docs/STATISTICAL_METHOD.md
new file mode 100644
index 0000000..a6d6bab
--- /dev/null
+++ b/docs/STATISTICAL_METHOD.md
@@ -0,0 +1,147 @@
+# Statistical Method — StatLab Experiments
+
+Este documento é a fonte de verdade da metodologia estatística implementada em
+`api-server/api/index.py`. Ele explica o que é calculado, por quê, e quais são
+as premissas e limites.
+
+## 1. Teste de hipótese: z-test de duas proporções
+
+- Estatística: **z-test bilateral de duas proporções** (score test), via
+ `statsmodels.stats.proportion.proportions_ztest`.
+- Diferença analisada: `diff = pB − pA` (B = tratamento, A = controle).
+- Sob H0 (pA = pB), o score test usa **variância pooled** — comportamento
+ padrão e correto do teste.
+
+### Correção de múltiplas comparações (Bonferroni)
+
+```
+alpha_adj = alpha / n_comparisons (n_comparisons >= 1)
+```
+
+A decisão usa `alpha_adj`. **O intervalo de confiança acompanha a inferência
+family-wise**: o IC é calculado com `alpha = alpha_adj`, e o nível real do IC
+(`ci_level = 1 − alpha_adj`) é exposto na resposta e na UI. Exemplo: com
+`alpha = 0.05` e `n_comparisons = 3`, a decisão e o IC usam
+`alpha_adj ≈ 0.0167` e a UI exibe **IC 98,33%**, não 95%.
+
+## 2. Motor de decisão — 4 estados com direção assinada
+
+O motor nunca usa `abs(diff)` isoladamente para declarar vitória. A direção do
+efeito é parte da decisão:
+
+| Estado | Condição |
+|---|---|
+| **Melhora** | `p < alpha_adj` **e** `diff > +mpe` |
+| **Regressão** | `p < alpha_adj` **e** `diff < −mpe` |
+| **Efeito Fraco** | `p < alpha_adj` **e** `\|diff\| <= mpe` |
+| **Inconclusivo** | `p >= alpha_adj` **ou** teste indefinido |
+
+O campo `direction` da resposta assume `positive` (efeito a favor de B),
+`negative` (contra B) ou `neutral`.
+
+**Motivação (bug P0 histórico):** o motor antigo usava `abs(diff) > MPE` e
+chamava "Vencedor" mesmo quando B era *pior* que A com significância (caso
+reproduzido: A = 60%, B = 58%, n = 10.000/grupo, p ≈ 0.004, diff = −2 pp).
+Um efeito negativo significativo agora é **Regressão**, nunca vitória. O
+cenário público `regressao` e o golden test G3 protegem contra regressão
+desse bug.
+
+## 3. Intervalo de confiança — Newcombe (Wilson híbrido)
+
+- Método: `statsmodels.stats.proportion.confint_proportions_2indep` com
+ `compare="diff"`, `method="newcomb"`, `alpha=alpha_adj`.
+- Ordem dos argumentos produz o IC para `pB − pA` (mesmo sinal do
+ `absolute_diff`).
+- Nível: `1 − alpha_adj` (coerente com Bonferroni; exposto como `ci_level`).
+
+### Por que o teste é pooled e o IC não (e por que isso não é bug)
+
+O score test usa variância pooled **sob H0** (quando H0 é verdadeira,
+pA = pB e a variância combinada é a estimativa correta). O IC da diferença
+não assume H0 — ele estima a incerteza em torno do efeito observado, e o
+método de Newcombe constrói isso a partir de intervalos de Wilson de cada
+braço (variâncias próprias/unpooled). Métodos diferentes para perguntas
+diferentes; a combinação score test + Newcombe CI é prática recomendada para
+duas proporções (Newcombe, 1998).
+
+Golden tests (G6) comparam o IC retornado contra chamada direta à biblioteca
+de referência em cinco famílias de casos: normal, com Bonferroni, amostras
+desbalanceadas, proporções perto de 0 e perto de 1.
+
+## 4. Casos degenerados — contrato honesto
+
+Quando o z-test não está definido (variância nula sob H0 — ex.: 0/1000 vs
+0/1000 ou 1000/1000 vs 1000/1000):
+
+```json
+{
+ "test_defined": false,
+ "p_value": null,
+ "significant": false,
+ "status": "Inconclusivo",
+ "warnings": ["Teste z indefinido (variância nula sob H0): p-valor não reportado.", "..."]
+}
+```
+
+**Nenhum p-valor é fabricado** (o comportamento antigo mascarava NaN como
+`p_value = 1.0`). O IC de Newcombe continua sendo reportado porque os
+intervalos de Wilson por braço são definidos mesmo com zero eventos.
+
+## 5. Uplift relativo com baseline zero
+
+Quando `pA = 0`, o uplift relativo `pB/pA − 1` é **indefinido**. A API
+retorna `uplift: null` (a UI exibe "Não definido (baseline zero)"). A
+diferença absoluta continua válida e reportada.
+
+## 6. Aviso de aproximação normal
+
+Regra de bolso documentada: se `min(sucessos, falhas) < 5` em qualquer braço,
+a resposta inclui um warning de que a aproximação normal do z-test pode ser
+inadequada. É um aviso de cautela, não um bloqueio — a decisão permanece
+disponível e o warning é explícito.
+
+## 7. MDE / MPE — semântica absoluta
+
+- **MDE** (Planejar) e **MPE** (Analisar) são **diferenças absolutas** em
+ proporção: `0.01` = 1 ponto percentual.
+- MDE negativo ou zero é rejeitado no planejamento; MPE negativo é rejeitado
+ na análise.
+- MDE relativo está fora de escopo (backlog).
+- O planejamento usa Cohen's *h* + `NormalIndPower.solve_power`
+ (duas amostras independentes, alocação 1:1). MDEs que exigem mais de
+ 100 milhões de observações por grupo são rejeitados com mensagem honesta.
+
+## 8. Premissas e limites
+
+Resumo (disponível em `GET /api/assumptions` e na UI):
+
+1. Amostras independentes entre A e B (sem sobreposição de usuários).
+2. Desfecho binário por observação.
+3. Observações independentes dentro de cada braço.
+4. Aproximação normal válida (sucessos e falhas suficientes por braço).
+5. Causalidade só vale com aleatorização e design válidos — o motor mede
+ associação observada sob as premissas do teste.
+6. Bonferroni controla o erro familial porém é conservador.
+7. Sem correção para testes sequenciais/peeking — analyse uma única vez.
+8. Sem checagem automática de SRM (Sample Ratio Mismatch).
+9. O status do motor é apoio à decisão; não autoriza rollout por si só.
+
+## 9. Linguagem
+
+As interpretações evitam causalidade automática e falsa equivalência: falam em
+**evidência**, **efeito observado** e **sob as premissas do teste**; "Inconclusivo"
+significa "evidência insuficiente", nunca "A e B são iguais" ou "não existe
+efeito".
+
+## 10. Fora de escopo (deliberadamente)
+
+Sequential testing, A/B bayesiano, CUPED, SRM automation, data warehouse de
+experimentos, login, banco de dados, feature flags e MDE relativo.
+
+## Referências
+
+- Newcombe, R. G. (1998). *Interval estimation for the difference between
+ independent proportions: comparison of eleven methods*. Statistics in
+ Medicine, 17(8), 873–890.
+- `statsmodels.stats.proportion.confint_proportions_2indep` (documentação).
+- `statsmodels.stats.proportion.proportions_ztest` (documentação).
diff --git a/docs/TECHNICAL_DECISIONS.md b/docs/TECHNICAL_DECISIONS.md
index 85f91b4..e072fd5 100644
--- a/docs/TECHNICAL_DECISIONS.md
+++ b/docs/TECHNICAL_DECISIONS.md
@@ -18,11 +18,17 @@
**Decision:** Always call `/api/...`; let rewrites resolve the target.
**Consequence:** One code path for local and production.
-## ADR-004: Three-state decision engine + MPE
+## ADR-004: Four-state decision engine with signed direction + MPE
-**Context:** Binary “significant / not” hides weak effects.
-**Decision:** Classify into Vencedor / Inconclusivo / Efeito Fraco using Bonferroni-adjusted alpha and a minimum practical effect (MPE).
-**Consequence:** Better product narrative; requires explaining MPE to users.
+**Context:** Binary “significant / not” hides weak effects — and the original
+three-state engine decided with `abs(diff) > MPE`, so a *significantly worse*
+B was labeled “Vencedor” (P0 incident: A=60% vs B=58%, p≈0.004 → winner).
+**Decision:** Classify into **Melhora / Regressão / Efeito Fraco /
+Inconclusivo** using Bonferroni-adjusted alpha, MPE and the **sign** of
+`diff = pB − pA`. Golden test G3 + public `regressao` scenario keep the bug
+from returning.
+**Consequence:** Better product narrative; requires explaining MPE and
+direction to users.
## ADR-005: CI on GitHub Actions
@@ -30,6 +36,31 @@
**Decision:** Lightweight CI — frontend lint/typecheck/build + API pytest.
**Consequence:** Fast feedback without paid runners or heavy tooling.
+## ADR-006: Newcombe CI at the Bonferroni-adjusted level
+
+**Context:** The decision used `alpha_adj = alpha / n_comparisons`, but the CI
+was Wald with nominal alpha while the UI said “95%” — internally inconsistent.
+**Decision:** Compute the CI for `pB − pA` with
+`confint_proportions_2indep(method="newcomb", alpha=alpha_adj)` and expose
+`ci_level = 1 − alpha_adj` so the UI shows the real level (e.g. “IC 98,33%”).
+Keep the pooled score test for the p-value: pooled variance under H0 vs
+unpooled interval for the observed effect are different questions by design
+(see `STATISTICAL_METHOD.md`).
+**Consequence:** Inference and interval are family-wise coherent; golden tests
+G6 compare against the library reference in five case families.
+
+## ADR-007: Honest degenerate/edge contracts
+
+**Context:** `NaN` p-values were masked as `p_value = 1.0`, baseline-zero
+uplift returned a fake `0.0`, non-object JSON bodies crashed with 500, and
+`int()` silently truncated fractional counts.
+**Decision:** `test_defined=false` + `p_value=null` + warning for degenerate
+counts; `uplift=null` when `pA=0`; structured 400 for non-object bodies;
+strict integer validation (no bool/float-fractional/NaN/Inf); sample-size
+guards (positive MDE, finite inputs, ≤100M per group).
+**Consequence:** The API never fabricates statistical quantities; clients must
+handle `null` fields (documented in `api-contract.md`).
+
## Trade-offs accepted
- No persistence / auth / multi-metric experiments (MVP scope).
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 6129277..8e4f89f 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -8,16 +8,26 @@ pip install -r api-server/requirements.txt pytest
python -m pytest api-server/api/tests -q
```
-Coverage highlights (16+ tests):
-
-- health / demo shape
-- sample size vs closed-form Cohen’s *h*
-- monotonicity in power
-- validation 400s (baseline, mde, visitors, conversions)
-- winner / inconclusive / weak effect
-- Bonferroni flip
-- zero conversions (no crash, no NaN)
-- CI width tracks alpha
+Current suite: **47 tests**, including the golden states **G1–G13**:
+
+- **G1** equality → Inconclusivo (não significativo)
+- **G2** positive → Melhora (com Bonferroni)
+- **G3** significant negative → **Regressão** (caso histórico 60% vs 58%, p≈0.004 — guarda do bug P0; nunca Melhora/Vencedor)
+- **G4** weak → Efeito Fraco (significante mas ≤ MPE)
+- **G5** Bonferroni flip (n=1 vs n>1)
+- **G6** adjusted CI contra a referência da biblioteca (`confint_proportions_2indep`, newcomb) em 5 famílias: normal, Bonferroni, desbalanceado, perto de 0, perto de 1 + nível `ci_level`
+- **G7** JSON top-level não-objeto (lista/string/número/malformado) → 400
+- **G8** contagens float (`100.7`), NaN/Inf, strings decimais → 400; strings inteiras aceitas
+- **G9** bool em contagens → 400
+- **G10** 0/1000 vs 0/1000 → `test_defined=false`, `p_value=null`, sem fake p
+- **G11** 1000/1000 vs 1000/1000 → idem
+- **G12** pA=0 → `uplift=null` (indefinido), diff absoluta válida
+- **G13** MDE negativo/zero/minúsculo → 400; alpha/power não-finitos → 400
+
+Mais: sample-size contra forma fechada de Cohen's *h*, monotonicidade em
+poder, sensibilidade a alpha, cinco cenários públicos com status distintos,
+warnings de aproximação normal, validações legadas (visitors/conversões/alpha/
+n_comparisons/mpe).
`api-server/conftest.py` adds the package root to `sys.path` so `from api.index import app` works.
@@ -34,9 +44,10 @@ npm run build
There is no Jest/Playwright suite yet (MVP). Critical UX paths are validated manually / via production smoke:
1. Planejar → baseline 0.05 + mde 0.01 → n ≈ 8143
-2. Analisar demo → status **Vencedor**
-3. Campos vazios → toast
-4. Payload inválido → toast com erro do backend
+2. Analisar demo → status **Melhora**
+3. Cenário Regressão → status **Regressão** com IC negativo
+4. Campos vazios → toast
+5. Payload inválido → toast com erro do backend
## CI
diff --git a/docs/api-contract.md b/docs/api-contract.md
index 3c100f0..2afe0d2 100644
--- a/docs/api-contract.md
+++ b/docs/api-contract.md
@@ -1,9 +1,28 @@
-## API Contract
+# API Contract
-### POST /calculate-sample-size
-Calculates the required sample size per group.
+Backend: Flask WSGI (`api-server/api/index.py`). Todas as rotas POST exigem
+**body JSON objeto** — listas, strings, escalares ou JSON malformado retornam
+`400` estruturado (`{"error": "..."}`), nunca `500`.
+
+Campos de contagem (`visitors_*`, `conversions_*`, `n_comparisons`) são
+**inteiros estritos**: rejeitam `bool`, floats fracionários (`100.7`),
+`NaN`/`Infinity`, negativos e strings decimais inválidas. Strings inteiras
+(`"1000"`) são aceitas. Campos numéricos contínuos rejeitam não-finitos.
+
+## GET /api/health
+
+```json
+{"status": "ok", "service": "statlab-experiments-api"}
+```
+
+## GET /api/assumptions
+
+Retorna `{"assumptions": [...]}` — resumo das premissas e limites do teste.
+
+## POST /api/calculate-sample-size
+
+**Request:**
-**Request Body:**
```json
{
"baseline_conversion": 0.05,
@@ -13,36 +32,99 @@ Calculates the required sample size per group.
}
```
-**Response:**
+- `mde` é **diferença absoluta** (`0.01` = 1 pp); deve ser `> 0`.
+- `baseline_conversion + mde` deve permanecer em `(0, 1)`.
+- MDE que exige mais de 100 milhões de observações por grupo → `400` com
+ mensagem honesta.
+
+**Response (200):**
+
```json
{
- "n_per_group": 12345
+ "n_per_group": 8143,
+ "baseline_conversion": 0.05,
+ "mde": 0.01,
+ "alpha": 0.05,
+ "power": 0.8,
+ "effect_size_h": 0.04550352426855219,
+ "note": "O MDE é o menor efeito absoluto que você quer detectar..."
}
```
-### POST /analyze
-Analyzes experimental results.
+## POST /api/analyze
+
+**Request:**
-**Request Body:**
```json
{
- "visitors_a": 1000,
- "conversions_a": 50,
- "visitors_b": 1000,
- "conversions_b": 65,
- "alpha": 0.05
+ "visitors_a": 10000,
+ "conversions_a": 500,
+ "visitors_b": 10000,
+ "conversions_b": 580,
+ "alpha": 0.05,
+ "n_comparisons": 3,
+ "mpe": 0.005
}
```
-**Response:**
+**Response (200):**
+
```json
{
- "p_value": 0.042,
- "uplift": 0.30,
+ "test_defined": true,
+ "p_value": 0.0020807176121605817,
+ "warnings": [],
+ "alpha": 0.05,
+ "alpha_ajustado": 0.016666666666666666,
+ "n_comparisons": 3,
+ "mpe": 0.005,
+ "uplift": 0.15999999999999992,
+ "absolute_diff": 0.008,
"conversion_a": 0.05,
- "conversion_b": 0.065,
- "ci_low": 0.005,
- "ci_high": 0.025,
- "significant": true
+ "conversion_b": 0.058,
+ "ci_low": 0.0003436408598088839,
+ "ci_high": 0.015675048319222378,
+ "ci_level": 0.9833333333333333,
+ "ci_method": "newcomb",
+ "significant": true,
+ "practically_significant": true,
+ "direction": "positive",
+ "status": "Melhora",
+ "interpretation": "Sob as premissas do teste, há evidência estatística...",
+ "next_steps": ["...", "..."]
}
```
+
+### Semântica dos campos de decisão
+
+- `status` — um de `Melhora` / `Regressão` / `Efeito Fraco` / `Inconclusivo`
+ (motor de 4 estados com direção assinada; ver `docs/STATISTICAL_METHOD.md`).
+- `direction` — `positive` (efeito a favor de B), `negative` (contra B),
+ `neutral`.
+- `alpha_ajustado = alpha / n_comparisons` — usado tanto na decisão quanto no
+ nível do IC (`ci_level = 1 − alpha_ajustado`).
+- `ci_method = "newcomb"` — IC de Newcombe (Wilson híbrido) para `pB − pA`.
+- `uplift` — `null` quando `pA = 0` (indefinido; a diferença absoluta segue
+ válida).
+- `test_defined` — `false` quando o z-test é indefinido (ex.: 0% vs 0%,
+ 100% vs 100%). Nesse caso `p_value` é `null`, `significant` é `false`,
+ `status` é `Inconclusivo` e `warnings` explica o motivo. O IC continua
+ reportado.
+- `warnings` — lista (possivelmente vazia) com avisos como aproximação normal
+ inadequada (`min(sucessos, falhas) < 5` por braço).
+
+### Erros (400)
+
+Body não-objeto, JSON malformado, contagens não-inteiras/booleanas/negativas,
+conversões > visitantes, `alpha` fora de `(0,1)`, `n_comparisons < 1`,
+`mpe < 0`. Sempre `{"error": ""}`.
+
+## GET /api/demo
+
+Payload didático: `sample_size`, `analyze` (cenário Melhora), `scenarios`
+(`melhora`, `regressao`, `efeito_fraco`, `inconclusivo`, `zero_conversoes`) e
+`assumptions`.
+
+## GET /api/scenarios
+
+`{"scenarios": {...}}` — os cinco cenários públicos de decisão.
diff --git a/docs/architecture-guardrails.md b/docs/architecture-guardrails.md
index d12e382..a9a1bce 100644
--- a/docs/architecture-guardrails.md
+++ b/docs/architecture-guardrails.md
@@ -1,36 +1,57 @@
# StatLab Experiments — Architecture Guardrails
-## Canonical production architecture
-
-- **Single Vercel project:** `frontend`
-- **Production URL:** https://statlab-experiments.vercel.app
-- **Root directory:** `frontend/`
-- **Routing:** `frontend/vercel.json` with Vercel Services (`web` = Next.js, `api` = FastAPI)
-- **API contract (do not change casually):**
- - Status labels in English: `Winner` | `Weak Effect` | `Inconclusive`
- - Field name: `alpha_adjusted` (not `alpha_ajustado`)
- - Demo fixture key: `analysis` (not `analyze`)
- - Routes: `/api/health`, `/api/demo`, `/api/calculate-sample-size`, `/api/analyze`
-
-## Do not
-
-- Do **not** add `API_BACKEND_URL` or Next.js rewrites that proxy `/api/*` to an external Flask/FastAPI project.
-- Do **not** recreate a parallel Vercel project (e.g. `statlab-experiments-api`) for production traffic.
-- Do **not** replace the Services-based `frontend/api/index.py` with a dual-project Flask layout for the live demo.
-- Do **not** localize decision status labels in the API response (`Vencedor` / `Inconclusivo`).
+> Este arquivo já descreveu uma arquitetura antiga (projeto Vercel único com
+> Python Services, labels em inglês e `statlab-experiments.vercel.app` como
+> produção). Aqui está o guardrail atual; a história live em `git log`.
+
+## Arquitetura de produção canônica
+
+- **Dois projetos Vercel (team `baruja-fe`):**
+ - `statlab-experiments` (`frontend/`) — Next.js — https://statlab-ab.vercel.app
+ - `statlab-experiments-api` (`api-server/`) — Flask WSGI — https://statlab-experiments-api.vercel.app
+- **Proxy:** `frontend/next.config.ts` reescreve `/api/:path*` para
+ `${API_BACKEND_URL}/api/:path*`. A UI chama apenas rotas relativas.
+- **API contract (não mudar sem atualizar `docs/api-contract.md`):**
+ - Status labels em português: `Melhora` | `Regressão` | `Efeito Fraco` | `Inconclusivo`
+ - Campo: `alpha_ajustado` (não `alpha_adjusted`)
+ - Demo fixture key: `analyze` (não `analysis`)
+ - Rotas: `/api/health`, `/api/assumptions`, `/api/demo`, `/api/scenarios`,
+ `/api/calculate-sample-size`, `/api/analyze`
+- **URL legada:** `statlab-experiments.vercel.app` pertence a um projeto de
+ outro team scope e **não é a URL oficial** — não usar em docs, metadata ou
+ demos. A oficial é `statlab-ab.vercel.app`.
+
+## Não fazer
+
+- Não reintroduzir `frontend/vercel.json` com Python Services ao lado do
+ Next (o preset Next captura `/api/*` e sombreia o handler Python).
+- Não transformar o z-test pooled em IC pooled "para baterem" — score test
+ pooled sob H0 e IC Newcombe unpooled são métodos distintos por design (ver
+ `docs/STATISTICAL_METHOD.md`).
+- Não usar `abs(diff)` na decisão sem sinal — o bug P0 histórico (B pior
+ significativo chamado de "Vencedor") deve permanecer impossível.
+- Não mascarar p-valor indefinido (`NaN → 1.0`); o contrato é
+ `test_defined=false` + `p_value=null` + warning.
+- Não hardcodear URL do backend na UI.
## Deploy
```bash
-cd frontend
-vercel --prod
+cd api-server
+vercel deploy --prod --yes --scope baruaja-fe # projeto statlab-experiments-api
+
+cd ../frontend
+vercel deploy --prod --yes --scope baruaja-fe # projeto statlab-experiments
```
-Validate after every production deploy:
+Validar após cada deploy de produção:
```powershell
-Invoke-RestMethod https://statlab-experiments.vercel.app/api/health
-Invoke-RestMethod https://statlab-experiments.vercel.app/api/demo
+Invoke-RestMethod https://statlab-ab.vercel.app/api/health
+Invoke-RestMethod https://statlab-ab.vercel.app/api/demo
+# P0 guard: B pior significativo DEVE retornar "Regressão"
+Invoke-RestMethod -Method Post https://statlab-ab.vercel.app/api/analyze -ContentType 'application/json' -Body '{"visitors_a":10000,"conversions_a":6000,"visitors_b":10000,"conversions_b":5800,"alpha":0.05,"n_comparisons":1,"mpe":0.005}'
```
-Demo fixture must include `analysis.visitors_a == 1000` and analyze must return `status: Winner` for the default demo payload.
+O demo padrão retorna `status: "Melhora"` para o payload `melhora`, e o
+cenário `regressao` retorna `status: "Regressão"`.
diff --git a/docs/capabilities.md b/docs/capabilities.md
index 48235ff..b564fa4 100644
--- a/docs/capabilities.md
+++ b/docs/capabilities.md
@@ -19,12 +19,13 @@ The "Plan" module calculates the required sample size to ensure statistical powe
The "Analyze" module evaluates the outcomes of a completed A/B test.
- **Input Parameters:**
- Visitors and Conversions for both Variant A and Variant B.
+ - Alpha, number of comparisons (Bonferroni) and MPE (practical significance threshold).
- **Calculations performed:**
- **Conversion Rates**: Observed performance per variant.
- - **Uplift**: Relative performance improvement.
- - **P-Value**: Calculated via `proportions_ztest` (Z-test for two independent proportions).
- - **Confidence Interval**: Estimated range for the observed difference.
-- **Interpretation Logic**: Automatically flags results as "Significant" or "Inconclusive" based on a configurable Alpha.
+ - **Uplift**: Relative improvement (`null` — undefined — when baseline A is zero).
+ - **P-Value**: Calculated via `proportions_ztest` (Z-test for two independent proportions); `null` with `test_defined=false` for degenerate counts (0% vs 0%, 100% vs 100%).
+ - **Confidence Interval**: Newcombe (hybrid Wilson) interval for `pB − pA`, at the Bonferroni-adjusted level (`ci_level`).
+- **Interpretation Logic**: Four decision states — **Melhora / Regressão / Efeito Fraco / Inconclusivo** — combining adjusted-alpha significance with the signed direction of the effect vs MPE.
---
@@ -44,6 +45,6 @@ The "Analyze" module evaluates the outcomes of a completed A/B test.
---
## 3. Technical Implementation
-- **Architecture**: Stateless (no database), decouple Frontend (Next.js) from Backend (FastAPI).
-- **Communication**: Strict API contracts via Pydantic schemas.
-- **Deployability**: Optimized for instant deployment on Vercel (front) and Render/Fly.io (back).
+- **Architecture**: Stateless (no database), decouple Frontend (Next.js) from Backend (Flask WSGI on Vercel).
+- **Communication**: Strict JSON API contracts (see `docs/api-contract.md`); non-object bodies and invalid counts return structured 400s.
+- **Deployability**: Dual Vercel projects (Next.js front + Flask API), demo at https://statlab-ab.vercel.app.
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index 5314070..2483fba 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
import { BarChart, Bar, XAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { Toaster, toast } from 'sonner';
import type { AnalysisInput, AnalysisResult, Scenario } from '@/lib/types';
-import { SCENARIO_ORDER, STATUS_COLOR } from '@/lib/types';
+import { DIRECTION_LABEL, SCENARIO_ORDER, STATUS_COLOR } from '@/lib/types';
function Field({
id,
@@ -46,6 +46,7 @@ export default function Home() {
const [loadingAnalyze, setLoadingAnalyze] = useState(false);
const [loadingDemo, setLoadingDemo] = useState(false);
const [scenarios, setScenarios] = useState>({});
+ const [assumptions, setAssumptions] = useState([]);
const [activeScenario, setActiveScenario] = useState(null);
const [planNote, setPlanNote] = useState(null);
@@ -53,6 +54,7 @@ export default function Home() {
const [baseline, setBaseline] = useState('0.05');
const [mde, setMde] = useState('0.01');
const [power, setPower] = useState('0.80');
+ const [planAlpha, setPlanAlpha] = useState('0.05');
const [sampleSize, setSampleSize] = useState(null);
// Analyze State
@@ -70,6 +72,10 @@ export default function Home() {
.then((r) => r.json())
.then((data) => setScenarios(data.scenarios || {}))
.catch(() => undefined);
+ fetch('/api/assumptions')
+ .then((r) => r.json())
+ .then((data) => setAssumptions(data.assumptions || []))
+ .catch(() => undefined);
}, []);
const applyScenarioInputs = (d: AnalysisInput) => {
@@ -92,6 +98,7 @@ export default function Home() {
baseline_conversion: parseFloat(baseline),
mde: parseFloat(mde),
power: parseFloat(power),
+ alpha: parseFloat(planAlpha) || 0.05,
}),
});
const data = await res.json();
@@ -171,12 +178,14 @@ export default function Home() {
return;
}
if (data.scenarios) setScenarios(data.scenarios);
+ if (data.assumptions) setAssumptions(data.assumptions);
const d = data.analyze as AnalysisInput;
applyScenarioInputs(d);
setBaseline(String(data.sample_size.baseline_conversion));
setMde(String(data.sample_size.mde));
setPower(String(data.sample_size.power ?? 0.8));
- setActiveScenario('vencedor');
+ setPlanAlpha(String(data.sample_size.alpha ?? 0.05));
+ setActiveScenario('melhora');
setActiveTab('analyze');
await analyze(d);
} catch {
@@ -188,19 +197,22 @@ export default function Home() {
const copyReport = async () => {
if (!analysis) return;
+ const ciLevelPct = ((analysis.ci_level ?? 1 - analysis.alpha_ajustado) * 100).toFixed(2);
const text = [
'StatLab Experiments — Relatório de Análise',
`Status: ${analysis.status}`,
`Conversão A: ${(analysis.conversion_a * 100).toFixed(2)}%`,
`Conversão B: ${(analysis.conversion_b * 100).toFixed(2)}%`,
`Diferença absoluta: ${((analysis.absolute_diff ?? analysis.conversion_b - analysis.conversion_a) * 100).toFixed(2)} pp`,
- `Uplift relativo: ${(analysis.uplift * 100).toFixed(2)}%`,
- `p-valor: ${analysis.p_value.toFixed(4)}`,
+ `Uplift relativo: ${analysis.uplift === null || analysis.uplift === undefined ? 'Não definido (baseline zero)' : `${(analysis.uplift * 100).toFixed(2)}%`}`,
+ `p-valor: ${analysis.p_value === null || analysis.p_value === undefined ? 'indefinido (teste degenerado)' : analysis.p_value.toFixed(4)}`,
`Alpha ajustado (Bonferroni): ${analysis.alpha_ajustado.toFixed(4)}`,
`MPE: ${analysis.mpe ?? parseFloat(mpe)}`,
+ `Direção: ${DIRECTION_LABEL[analysis.direction] ?? analysis.direction}`,
`Significativo: ${analysis.significant ? 'sim' : 'não'}`,
`Relevante na prática: ${analysis.practically_significant ? 'sim' : 'não'}`,
- `IC: [${analysis.ci_low.toFixed(4)}, ${analysis.ci_high.toFixed(4)}]`,
+ `IC ${ciLevelPct}% (${analysis.ci_method ?? 'newcomb'}): [${analysis.ci_low.toFixed(4)}, ${analysis.ci_high.toFixed(4)}]`,
+ ...(analysis.warnings?.length ? analysis.warnings.map((w) => `Aviso: ${w}`) : []),
analysis.interpretation,
...(analysis.next_steps || []).map((s) => `- ${s}`),
].join('\n');
@@ -214,7 +226,7 @@ export default function Home() {
const busy = loadingPlan || loadingAnalyze || loadingDemo;
const ciLevel = analysis
- ? `${((1 - (analysis.alpha || 0.05)) * 100).toFixed(0)}%`
+ ? `${((analysis.ci_level ?? 1 - analysis.alpha_ajustado) * 100).toFixed(2)}%`
: null;
return (
@@ -261,7 +273,7 @@ export default function Home() {
disabled={busy}
className="text-sm text-teal-700 hover:text-teal-900 underline disabled:opacity-50"
>
- {loadingDemo ? 'Carregando demonstração…' : 'Começar com cenário Vencedor'}
+ {loadingDemo ? 'Carregando demonstração…' : 'Começar com cenário Melhora'}
@@ -300,6 +312,19 @@ export default function Home() {
)}
+ {assumptions.length > 0 && (
+
+
+ Premissas e limites do teste
+
+