From 0028a0547b390d9681f9b5fb9cae4f8efad434dc Mon Sep 17 00:00:00 2001 From: BarujaFe1 Date: Fri, 21 Aug 2026 15:35:52 -0300 Subject: [PATCH 1/4] fix(api): four-state decision engine, coherent Newcombe CI, honest edges (P0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direction (P0): the engine decided with abs(diff) > MPE, labeling a significantly worse B as 'Vencedor' (reproduced: A=60% vs B=58%, n=10k/group, p=0.004, diff=-2pp). Now: Melhora (p+mpe), Regressão (p=alpha_adj or undefined test). Adds direction field and public 'regressao' scenario. CI (P1): Newcombe interval via confint_proportions_2indep(compare=diff, method=newcomb, alpha=alpha_adj) for pB-pA; response exposes ci_level and ci_method; UI can show the real level (e.g. 98.33%). Decision and interval are now family-wise coherent (before: decision at alpha_adj but Wald CI at nominal alpha labeled 95%). Pooled score test + unpooled CI documented as a method choice in docs/STATISTICAL_METHOD.md. Edge contracts: structured 400 for non-object JSON bodies (was 500); strict integer validation rejecting bool/fractional/NaN/Inf/negative counts (was silent int() truncation); degenerate cases (0% vs 0%, 100% vs 100%) return test_defined=false, p_value=null and warnings (was NaN masked as p=1.0) with the Newcombe CI still reported; uplift=null when pA=0 (was fake 0.0); sample-size guards (positive MDE, finite inputs, <=100M per group); normal-approximation warning when min(successes, failures) < 5 per arm; GET /api/assumptions and assumptions in /api/demo. --- api-server/api/index.py | 294 ++++++++++++++++++++++++++++++---------- 1 file changed, 222 insertions(+), 72 deletions(-) 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, } ) From a9229b7155cc6f2b1ff931ed3b9430bd56ba94dd Mon Sep 17 00:00:00 2001 From: BarujaFe1 Date: Fri, 21 Aug 2026 15:35:52 -0300 Subject: [PATCH 2/4] test(api): golden suite G1-G13 (47 tests) G1 equality, G2 melhora, G3 regression guard (historical 60% vs 58% never Melhora), G4 weak effect, G5 Bonferroni flip, G6 CI vs statsmodels reference in 5 families (normal, Bonferroni, unbalanced, near-0, near-1) + ci_level, G7 non-object bodies 400, G8 float/NaN counts 400, G9 bool 400, G10/G11 degenerate without fake p, G12 baseline-zero uplift null, G13 MDE guards. Plus closed-form Cohen's h sample-size, power monotonicity, alpha sensitivity, scenario coverage and legacy validations. --- api-server/api/tests/test_api.py | 520 ++++++++++++++++++++++++------- 1 file changed, 399 insertions(+), 121 deletions(-) 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" From 0659b16e14d24e3ecc8c9e488cf31ba37767ab32 Mon Sep 17 00:00:00 2001 From: BarujaFe1 Date: Fri, 21 Aug 2026 15:36:07 -0300 Subject: [PATCH 3/4] feat(ui): four-state result panel with real CI level, direction and warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status colors for Melhora/Regressão/Efeito Fraco/Inconclusivo; direction chip (favor/against B); p-value shown as 'indefinido' when test_defined is false; uplift shows 'Não definido (baseline zero)' on null; CI displays the actual ci_level (e.g. 98.33%) and method; warnings block; assumptions details fetched from /api/assumptions; alpha input on Plan tab; scenario chips updated to the five public scenarios (melhora, regressao, efeito_fraco, inconclusivo, zero_conversoes). --- frontend/app/page.tsx | 86 ++++++++++++++++++++++++++++++++++++------- frontend/lib/types.ts | 21 +++++++++-- 2 files changed, 89 insertions(+), 18 deletions(-) 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 + +
    + {assumptions.map((a) => ( +
  • {a}
  • + ))} +
+
+ )} +
-
p-valor: {analysis.p_value.toFixed(4)}
+
+ p-valor:{' '} + {analysis.p_value === null || analysis.p_value === undefined + ? indefinido (teste degenerado) + : analysis.p_value.toFixed(4)} +
Alpha ajustado: {analysis.alpha_ajustado.toFixed(4)}
-
Δ absoluto: {(((analysis.absolute_diff ?? (analysis.conversion_b - analysis.conversion_a))) * 100).toFixed(2)} pp
-
Uplift: {(analysis.uplift * 100).toFixed(2)}%
+
Δ absoluto (B − A): {(((analysis.absolute_diff ?? (analysis.conversion_b - analysis.conversion_a))) * 100).toFixed(2)} pp
+
+ Uplift:{' '} + {analysis.uplift === null || analysis.uplift === undefined + ? Não definido (baseline zero) + : `${(analysis.uplift * 100).toFixed(2)}%`} +
IC{ciLevel ? ` ${ciLevel}` : ''}:{' '} [{analysis.ci_low.toFixed(4)}, {analysis.ci_high.toFixed(4)}] + {analysis.ci_method ? ({analysis.ci_method}) : null}
+ {analysis.warnings && analysis.warnings.length > 0 && ( +
+ {analysis.warnings.map((w) => ( +

+ {w} +

+ ))} +
+ )} {analysis.next_steps && analysis.next_steps.length > 0 && (

Próximos passos

diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index c57cead..b6a0b5f 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -1,17 +1,22 @@ export interface AnalysisResult { - p_value: number; + test_defined: boolean; + p_value: number | null; + warnings?: string[]; alpha_ajustado: number; alpha?: number; n_comparisons?: number; mpe?: number; - uplift: number; + uplift: number | null; absolute_diff?: number; conversion_a: number; conversion_b: number; ci_low: number; ci_high: number; + ci_level?: number; + ci_method?: string; significant: boolean; practically_significant?: boolean; + direction: 'positive' | 'negative' | 'neutral'; status: string; interpretation: string; next_steps?: string[]; @@ -34,13 +39,21 @@ export interface Scenario { } export const STATUS_COLOR: Record = { - Vencedor: '#16a34a', + Melhora: '#16a34a', + Regressão: '#dc2626', 'Efeito Fraco': '#d97706', Inconclusivo: '#64748b', }; +export const DIRECTION_LABEL: Record = { + positive: 'Efeito a favor de B', + negative: 'Efeito contra B', + neutral: 'Sem direção conclusiva', +}; + export const SCENARIO_ORDER = [ - 'vencedor', + 'melhora', + 'regressao', 'efeito_fraco', 'inconclusivo', 'zero_conversoes', From 8b818f1001b02e66cd305d83f3e8bd31fa1e7fe1 Mon Sep 17 00:00:00 2001 From: BarujaFe1 Date: Fri, 21 Aug 2026 15:36:08 -0300 Subject: [PATCH 4/4] docs: statistical methodology, updated contracts, drift cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New docs/STATISTICAL_METHOD.md (four states, pooled score test vs Newcombe CI rationale, Bonferroni-coherent intervals, degenerate contract, uplift baseline-zero, absolute MDE semantics, assumptions, language policy). api-contract.md rewritten for the new response. architecture-guardrails.md rewritten to the current dual-project Flask architecture (was stale FastAPI single-project with English labels). README switched to 4-state wording and canonical statlab-ab.vercel.app URL. TESTING.md documents the 47-test golden suite. DEMO_SCRIPT.md gains the Regressão step. TECHNICAL_DECISIONS.md adds ADR-006/007. CHANGELOG entry. Historical snapshots (AUDIT_REPORT, HANDOFF, PORTFOLIO_HANDOFF, PLAN, SUMMARY, portfolio-*) now carry an explicit banner. --- CHANGELOG.md | 50 +++++++++++ PLAN.md | 7 ++ README.md | 42 +++++---- SUMMARY.md | 7 ++ docs/ARCHITECTURE.md | 7 +- docs/AUDIT_REPORT.md | 7 ++ docs/DEMO_SCRIPT.md | 34 +++++--- docs/HANDOFF.md | 7 ++ docs/PORTFOLIO_HANDOFF.md | 7 ++ docs/STATISTICAL_METHOD.md | 147 ++++++++++++++++++++++++++++++++ docs/TECHNICAL_DECISIONS.md | 39 ++++++++- docs/TESTING.md | 37 +++++--- docs/api-contract.md | 124 ++++++++++++++++++++++----- docs/architecture-guardrails.md | 69 +++++++++------ docs/capabilities.md | 15 ++-- portfolio-integration-prompt.md | 7 ++ portfolio-project-handoff.md | 7 ++ scripts/capture_screens.mjs | 48 +++++++---- 18 files changed, 541 insertions(+), 120 deletions(-) create mode 100644 docs/STATISTICAL_METHOD.md 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.

PT-BR @@ -31,7 +31,7 @@

- 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
Plan sample size
Analyze — Winner
Analyze — Winner
Analyze — Melhora
Analyze — Melhora
Weak effect
Weak effect
Scenarios
Scenarios
Analyze — Regressão
Analyze — Regressão
Weak effect
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/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/portfolio-integration-prompt.md b/portfolio-integration-prompt.md index 1116878..da941fc 100644 --- a/portfolio-integration-prompt.md +++ b/portfolio-integration-prompt.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. + # Texto de integração — StatLab Experiments no portfólio **Destino:** https://barujafe.vercel.app/ diff --git a/portfolio-project-handoff.md b/portfolio-project-handoff.md index 9997a2f..3983511 100644 --- a/portfolio-project-handoff.md +++ b/portfolio-project-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-project-handoff.md Documento de handoff para integração do **StatLab Experiments** no portfólio [barujafe.vercel.app](https://barujafe.vercel.app/). diff --git a/scripts/capture_screens.mjs b/scripts/capture_screens.mjs index 52ed807..7c29fd1 100644 --- a/scripts/capture_screens.mjs +++ b/scripts/capture_screens.mjs @@ -1,4 +1,4 @@ -import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'; +import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -7,32 +7,44 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const outDir = path.join(__dirname, '..', 'docs', 'screenshots'); fs.mkdirSync(outDir, { recursive: true }); +const BASE_URL = process.env.BASE_URL || 'https://statlab-ab.vercel.app'; + const browser = await chromium.launch({ channel: 'chrome', headless: true }); const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); -await page.goto('https://statlab-ab.vercel.app/', { waitUntil: 'networkidle', timeout: 60000 }); +await page.goto(`${BASE_URL}/`, { waitUntil: 'networkidle', timeout: 60000 }); + +async function clickScenario(name) { + const chip = page.getByRole('button', { name }).first(); + if (await chip.count()) { + await chip.click(); + await page.waitForTimeout(3000); + return true; + } + return false; +} -const winnerChip = page.getByRole('button', { name: /Vencedor/i }).first(); -if (await winnerChip.count()) { - await winnerChip.click(); -} else { - await page.getByRole('button', { name: /Começar com cenário Vencedor|Demo padrão/i }).first().click(); +// 02 — Melhora (demo padrão; fallback via botão de demonstração) +if (!(await clickScenario(/Melhora/i))) { + await page.getByRole('button', { name: /Começar com cenário Melhora|Demo padrão/i }).first().click(); + await page.waitForTimeout(3500); } -await page.waitForTimeout(3500); -await page.screenshot({ path: path.join(outDir, '02-analyze-vencedor.png'), fullPage: false }); +await page.screenshot({ path: path.join(outDir, '02-analyze-melhora.png'), fullPage: false }); + +// 03 — Regressão (guard do P0: B significativamente pior) +await clickScenario(/Regressão/i); +await page.screenshot({ path: path.join(outDir, '03-analyze-regressao.png'), fullPage: false }); +// 04 — Efeito Fraco +await clickScenario(/Efeito Fraco/i); +await page.screenshot({ path: path.join(outDir, '04-analyze-efeito-fraco.png'), fullPage: false }); + +// 01 — Planejar await page.getByRole('tab', { name: 'Planejar' }).click(); await page.getByRole('button', { name: /Calcular/i }).click(); await page.waitForTimeout(2500); await page.screenshot({ path: path.join(outDir, '01-plan-sample-size.png'), fullPage: false }); -const weak = page.getByRole('button', { name: /Efeito Fraco/i }).first(); -if (await weak.count()) { - await page.getByRole('tab', { name: 'Analisar' }).click(); - await weak.click(); - await page.waitForTimeout(3000); - await page.screenshot({ path: path.join(outDir, '03-analyze-efeito-fraco.png'), fullPage: false }); -} - -await page.screenshot({ path: path.join(outDir, '04-scenarios-chips.png'), fullPage: true }); +// 05 — chips de cenários +await page.screenshot({ path: path.join(outDir, '05-scenarios-chips.png'), fullPage: true }); await browser.close(); console.log('screenshots ok ->', outDir);