A/B Test Analysis skill for Codex
SRM check first, intervals over p-values. Codex checks an experiment's health before its result: SRM, cross-arm contamination and analysis unit come first. It then picks the right test for each metric, reports effects as confidence intervals, corrects for multiple metrics, refuses to bless peeked results, and ends with a ship recommendation. A standard-library script handles SRM, two-proportion tests and sample size offline.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- ab-test-analysis Installs to ~/.agents/skills/ab-test-analysis/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install ab-test-analysis The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project ab-test-analysis Installs to .agents/skills/ab-test-analysis/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/ab-test-analysis.zipunzip -o ab-test-analysis.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) ab-test-analysis PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $ab-test-analysis, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Analyses randomised A/B and multivariate experiments correctly - sample ratio mismatch (SRM) check first, then two-proportion z-tests, Welch t-tests, delta-method ratio metrics, CUPED variance reduction, confidence intervals, power and minimum detectable effect, guardrail metrics and multiple-testing control. Use when reading out an experiment, a test result looks too good, a team wants to stop early, designing sample size for a planned test, or reviewing an experiment readout before a ship decision.
Use it when
- An experiment has finished and needs a readout and recommendation
- Results look too good, or the split between arms looks uneven
- Planning sample size, MDE and duration for a new test
- Reviewing someone else's experiment analysis before a ship decision
Use something else when
- You are designing variants, copy and hypotheses for a marketing test: use ab-testing
- You are setting up product event tracking for experiments: use analytics-tracking-plan
How to check the result
Arm totals recomputed from raw exposure and event tables match the platform, the SRM test passes, and an A/A run through the same code shows no effect.
The mistake it prevents
Stopping the test the first day the p-value dips below 0.05, which makes the reported significance meaningless.
A trustworthy readout checks the experiment’s health before its result, reports the effect as an interval rather than a p-value alone, analyses only the metrics and window that were planned, and states the ship recommendation with the risk it carries. If the assignment is broken, there is no result to report.
Before computing anything
- Find the plan: hypothesis, primary metric, guardrails, unit of randomisation (user, session, account), planned sample size or duration, and alpha. If none was written, say so in the report; post-hoc choices weaken every conclusion.
- Confirm the analysis unit matches the randomisation unit. Randomised by user but analysed per session or pageview means observations are correlated and naive standard errors are too small; aggregate to the user, or use the delta method.
- Read
AGENTS.mdand the data source (warehouse table, PostHog, Statsig, GrowthBook, Optimizely export). Use the platform’s exposure table, not a reconstruction. - Use only users exposed to the experiment (triggered), from exposure time onward.
Workflow
- [ ] 1. SRM check on assignment counts
- [ ] 2. Pre-period balance and exposure sanity (dates, platforms, duplicates across arms)
- [ ] 3. Primary metric: effect, CI, p-value at the planned horizon
- [ ] 4. Guardrails: did anything get worse beyond its threshold?
- [ ] 5. Secondary metrics and pre-registered segments, corrected for multiplicity
- [ ] 6. Practical significance vs the MDE and cost of shipping
- [ ] 7. Recommendation with caveats
Step 1: sample ratio mismatch
python3 scripts/ab_stats.py srm --observed 50900 49100 # 50/50 split expected
python3 scripts/ab_stats.py srm --observed 33100 33500 33400 --expected 1 1 1
The script runs a chi-square goodness-of-fit test and exits 1 when p < 0.001. On SRM, stop and investigate before reading any metric: bot filtering applied unevenly, redirects dropping users in one arm, assignment logged after a crash-prone code path, caching, or users counted in both arms. Report SRM as the headline finding.
Also check: users appearing in more than one arm (exclude them and report the count) and exposure starting on the same date in all arms.
Step 3: choose the test
| Metric | Test | Notes |
|---|---|---|
| Conversion (user converted yes/no) | Two-proportion z-test | scripts/ab_stats.py proportions |
| Mean per user (revenue, sessions) | Welch’s t-test | Heavy tails: winsorise at p99.9 pre-registered, or bootstrap |
| Ratio of sums (CTR = clicks/views, AOV = revenue/orders) | Delta method or bootstrap by user | Denominator varies per user; a plain t-test on per-event rows is wrong |
| Count with many zeros | Welch t on per-user values | Fine at large n; bootstrap for small n |
| Time-to-event | Survival analysis | Out of scope here; ask for a statistician |
python3 scripts/ab_stats.py proportions --control 1200 50000 --treatment 1290 50100
The script prints both rates, absolute difference with CI, relative lift with a delta-method CI, z and two-sided p. It uses only the standard library, so it runs in a sandbox without installs.
For continuous metrics in Python (needs scipy, which may require an install):
import numpy as np
from scipy import stats
def welch(control: np.ndarray, treatment: np.ndarray, alpha: float = 0.05):
res = stats.ttest_ind(treatment, control, equal_var=False)
diff = treatment.mean() - control.mean()
se = np.sqrt(treatment.var(ddof=1) / len(treatment) + control.var(ddof=1) / len(control))
z = stats.norm.ppf(1 - alpha / 2)
return diff, (diff - z * se, diff + z * se), res.pvalue
Delta method for a ratio metric R = sum(Y)/sum(X), per user i with n users in an arm:
def ratio_var(y: np.ndarray, x: np.ndarray) -> float:
n = len(y)
my, mx = y.mean(), x.mean()
vy, vx = y.var(ddof=1), x.var(ddof=1)
cov = np.cov(y, x, ddof=1)[0, 1]
return (vy / mx**2 - 2 * my * cov / mx**3 + my**2 * vx / mx**4) / n
The difference between arms then has variance ratio_var(t) + ratio_var(c).
CUPED
When a pre-experiment covariate exists (same metric in the 2-4 weeks before exposure), CUPED cuts variance, often by 20-50% for engagement metrics:
theta = np.cov(y_all, x_pre_all, ddof=1)[0, 1] / x_pre_all.var(ddof=1) # pooled across arms
y_adj = y - theta * (x_pre - x_pre_all.mean())
Run the same test on y_adj. Estimate theta on both arms pooled, and use a covariate
measured strictly before exposure, otherwise the treatment leaks into the adjustment.
Power and duration (design time)
python3 scripts/ab_stats.py sample-size --baseline 0.04 --mde 0.05 --relative
- Choose the MDE from what is worth shipping, not from what the traffic allows.
- Run whole weeks (at least 1-2 full weekly cycles) to cover day-of-week effects.
- If the required sample exceeds available traffic, say so: options are a bigger MDE, a more sensitive metric, CUPED, or not running the test.
Hard rules
- No peeking. Looking at p-values daily and stopping at the first p < 0.05 inflates false positives several-fold. Analyse at the planned horizon, or use a method built for continuous monitoring (sequential tests, always-valid p-values) that the platform supports. If someone stopped early, say the p-value is not valid as reported.
- Multiple metrics and segments. Primary metric at full alpha. For secondary metrics apply Holm or Benjamini-Hochberg; label unplanned segment results “exploratory”.
- Novelty and primacy. Plot the daily treatment effect; an effect that decays over the first week will not persist.
- Non-significant is not “no effect”. Report the CI: “between -0.4% and +1.1%” tells the reader whether a meaningful effect is ruled out.
- Interference. Marketplaces, social features and shared budgets break the independence assumption; flag it and suggest cluster or switchback designs.
- Do not give personalised investment or financial advice from revenue experiments; the readout is about the product decision.
Interpretation table
| Primary result | Guardrails | Recommendation |
|---|---|---|
| CI excludes 0, effect >= MDE | All within thresholds | Ship |
| CI excludes 0, effect < MDE | Fine | Ship if cost is low; effect smaller than planned |
| CI includes 0, narrow around 0 | Fine | No meaningful effect; decide on other grounds |
| CI includes 0, wide | Any | Underpowered; extend or redesign, do not call it neutral |
| Any | A guardrail degraded beyond threshold | Do not ship without an explicit trade-off decision |
| SRM detected | - | No conclusion; fix assignment and rerun |
Verification
- Recompute the primary metric’s arm totals from raw exposure and event tables and match the platform’s numbers within rounding; a mismatch means the metric definitions differ.
- Run an A/A comparison (or the pre-period) through the same code: it should show no effect in about 95% of reruns or splits.
- Confirm the analysis window and filters match the plan exactly.
Readout template
# Experiment: <name> (<start> to <end>, <n> days)
**Hypothesis:** <change> increases <primary metric>
**Unit:** user, 50/50, triggered on checkout page view
**Health:** SRM p = 0.41 (ok); 112 users in both arms excluded; no exposure gaps
| Metric | Control | Treatment | Diff (95% CI) | p |
|---|---|---|---|---|
| Checkout conversion (primary) | 2.400% | 2.575% | +0.17 pts (-0.02, +0.37) | 0.076 |
| Revenue per user (guardrail) | 1.82 | 1.84 | +0.02 (-0.05, +0.09) | 0.58 |
**Interpretation:** effect is positive but the CI includes zero; the test reached 65% of its planned sample.
**Recommendation:** extend to the planned 2 weeks; do not ship on this readout.
**Caveats:** novelty effect visible days 1-3; mobile segment result is exploratory (not pre-registered). What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "A/B Test Analysis"
short_description: "SRM check first, intervals over p-values"
default_prompt: "$ab-test-analysis Read out this checkout experiment and tell me whether we should ship" scripts/ab_stats.py
#!/usr/bin/env python3
"""Standard-library A/B test helpers: SRM check, two-proportion test, sample size.
Usage:
python3 ab_stats.py srm --observed 50310 49690 [--expected 0.5 0.5]
python3 ab_stats.py proportions --control 1200 50000 --treatment 1290 50100 [--alpha 0.05]
python3 ab_stats.py sample-size --baseline 0.04 --mde 0.05 --relative [--alpha 0.05] [--power 0.8]
Exit codes: 0 ok, 1 SRM detected (srm subcommand), 2 bad input.
"""
from __future__ import annotations
import argparse
import math
import sys
from statistics import NormalDist
N = NormalDist()
def _gamma_q(s: float, x: float) -> float:
"""Regularised upper incomplete gamma Q(s, x)."""
if x <= 0:
return 1.0
if x < s + 1: # series for P, then Q = 1 - P
term = total = 1.0 / s
a = s
for _ in range(10_000):
a += 1
term *= x / a
total += term
if abs(term) < abs(total) * 1e-15:
break
return max(0.0, 1.0 - total * math.exp(-x + s * math.log(x) - math.lgamma(s)))
# continued fraction (modified Lentz)
tiny = 1e-300
b = x + 1 - s
c = 1 / tiny
d = 1 / b
h = d
for i in range(1, 10_000):
an = -i * (i - s)
b += 2
d = an * d + b
d = tiny if abs(d) < tiny else d
c = b + an / c
c = tiny if abs(c) < tiny else c
d = 1 / d
delta = d * c
h *= delta
if abs(delta - 1) < 1e-15:
break
return math.exp(-x + s * math.log(x) - math.lgamma(s)) * h
def chi2_sf(stat: float, df: int) -> float:
return _gamma_q(df / 2.0, stat / 2.0)
def cmd_srm(a: argparse.Namespace) -> int:
obs = a.observed
k = len(obs)
exp_w = a.expected or [1.0 / k] * k
if len(exp_w) != k or k < 2:
print("error: need >= 2 observed counts and matching expected shares", file=sys.stderr)
return 2
total = sum(obs)
wsum = sum(exp_w)
expected = [total * w / wsum for w in exp_w]
stat = sum((o - e) ** 2 / e for o, e in zip(obs, expected))
p = chi2_sf(stat, k - 1)
for i, (o, e) in enumerate(zip(obs, expected)):
print(f"arm {i}: observed {o:>12,} expected {e:>14,.1f} share {o / total:.4%}")
print(f"chi2 = {stat:.3f}, df = {k - 1}, p = {p:.3g}")
if p < a.threshold:
print(f"SRM DETECTED (p < {a.threshold}): do not trust effect estimates until explained")
return 1
print(f"no SRM at threshold {a.threshold}")
return 0
def cmd_proportions(a: argparse.Namespace) -> int:
xc, nc = a.control
xt, nt = a.treatment
if min(nc, nt) <= 0 or not (0 <= xc <= nc and 0 <= xt <= nt):
print("error: counts must satisfy 0 <= conversions <= users, users > 0", file=sys.stderr)
return 2
pc, pt = xc / nc, xt / nt
pooled = (xc + xt) / (nc + nt)
se_pooled = math.sqrt(pooled * (1 - pooled) * (1 / nc + 1 / nt))
z = (pt - pc) / se_pooled if se_pooled > 0 else 0.0
p_value = 2 * (1 - N.cdf(abs(z)))
se_diff = math.sqrt(pc * (1 - pc) / nc + pt * (1 - pt) / nt)
zcrit = N.inv_cdf(1 - a.alpha / 2)
lo, hi = (pt - pc) - zcrit * se_diff, (pt - pc) + zcrit * se_diff
print(f"control {xc:,}/{nc:,} = {pc:.4%}")
print(f"treatment {xt:,}/{nt:,} = {pt:.4%}")
print(f"absolute diff = {pt - pc:+.4%} {100 * (1 - a.alpha):.0f}% CI [{lo:+.4%}, {hi:+.4%}]")
if pc > 0:
# delta-method CI on relative lift (log ratio)
se_log = math.sqrt((1 - pc) / (pc * nc) + (1 - pt) / (pt * nt)) if pt > 0 else float("nan")
ratio = pt / pc
rlo = math.exp(math.log(ratio) - zcrit * se_log) - 1 if pt > 0 else float("nan")
rhi = math.exp(math.log(ratio) + zcrit * se_log) - 1 if pt > 0 else float("nan")
print(f"relative lift = {ratio - 1:+.2%} CI [{rlo:+.2%}, {rhi:+.2%}]")
print(f"z = {z:.3f}, two-sided p = {p_value:.4g}, alpha = {a.alpha}")
return 0
def cmd_sample_size(a: argparse.Namespace) -> int:
p1 = a.baseline
p2 = p1 * (1 + a.mde) if a.relative else p1 + a.mde
if not (0 < p1 < 1 and 0 < p2 < 1):
print("error: baseline and baseline+effect must be in (0, 1)", file=sys.stderr)
return 2
za = N.inv_cdf(1 - a.alpha / 2)
zb = N.inv_cdf(a.power)
pbar = (p1 + p2) / 2
n = (za * math.sqrt(2 * pbar * (1 - pbar)) + zb * math.sqrt(p1 * (1 - p1) + p2 * (1 - p2))) ** 2 / (p2 - p1) ** 2
n = math.ceil(n)
print(f"baseline {p1:.4%} -> {p2:.4%} (absolute {p2 - p1:+.4%}, relative {p2 / p1 - 1:+.2%})")
print(f"alpha {a.alpha} two-sided, power {a.power}")
print(f"users per arm: {n:,} (total for 2 arms: {2 * n:,})")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("srm", help="sample ratio mismatch chi-square test")
s.add_argument("--observed", type=int, nargs="+", required=True)
s.add_argument("--expected", type=float, nargs="+", help="expected shares, default equal")
s.add_argument("--threshold", type=float, default=0.001)
s.set_defaults(fn=cmd_srm)
p = sub.add_parser("proportions", help="two-proportion z-test with CIs")
p.add_argument("--control", type=int, nargs=2, metavar=("CONVERSIONS", "USERS"), required=True)
p.add_argument("--treatment", type=int, nargs=2, metavar=("CONVERSIONS", "USERS"), required=True)
p.add_argument("--alpha", type=float, default=0.05)
p.set_defaults(fn=cmd_proportions)
z = sub.add_parser("sample-size", help="users per arm for a conversion metric")
z.add_argument("--baseline", type=float, required=True)
z.add_argument("--mde", type=float, required=True, help="minimum detectable effect")
z.add_argument("--relative", action="store_true", help="interpret mde as relative lift")
z.add_argument("--alpha", type=float, default=0.05)
z.add_argument("--power", type=float, default=0.8)
z.set_defaults(fn=cmd_sample_size)
args = ap.parse_args()
return args.fn(args)
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.