Causation
skills/reading-experimentsreading-experiments
Runs the trust gate on a finished experiment before the result is believed: sample ratio mismatch, exposure counts, always-valid or sequential bounds, guardrails read before the primary metric, the pre-registered segment rather than the most flattering one, CUPED variance reduction, and empirical Bayes shrinkage against the prior store. Use when analysing or reviewing A/B test results, when a test looks like a winner, when someone reports a lift, or when deciding whether to ship on an experiment readout.
$ git clone https://github.com/0trm/gallop $ cp -r gallop/skills/reading-experiments .claude/skills/
Ships with
Whether the result is a result. Five of the six steps in an experiment can hand back a wrong number without the readout looking any different, so the reading order is fixed and the checks come before the number. Read against the pre-registered plan; if no plan exists, say so first, because everything below weakens without one.
The mechanical checks are scripted. Given assignment/exposure counts and unit-level data, run them in one pass:
python skills/reading-experiments/scripts/run_checks.py \
--counts counts.csv --data units.csv --y metric --x pre_metric --arm arm \
[--store priors.jsonl --metric activation_rate --unit pp]
Or individually via python -m gallop.trust|variance|sequential|shrink.
1 · The trust gate, before any effect
Two checks, and a failure at either stops the reading. Details and the cause taxonomy: reference/trust-gate.md.
- Sample ratio mismatch. Chi-square on assignment counts against the
intended split (
gallop.trust.srm, alpha 0.001). A 0.4% imbalance at a million units looks like nothing on a dashboard and is a broken test. On SRM: stop, do not analyse, work the cause taxonomy. There is no correction; a biased assignment is not a smaller sample, it is a different population per arm. - Exposure. Did the variant reach anyone, at the same rate in both
arms? (
gallop.trust.exposure_check). Differential exposure is a trigger bug and invalidates the comparison. Uniform under-exposure dilutes the ITT estimate toward zero by a known factor; report both the diluted and scaled numbers, labelled.
2 · Guardrails before the primary
Read the guardrail metrics first, in the plan's order. A win on the primary with a guardrail breach is a rollback, not a trade-off discussion, because the trade was already made when the guardrail was registered. A rollback triggered by a guardrail is the system working and still gets a readout.
3 · The effect, under the licence you actually have
- If the design was fixed-horizon and nobody looked early: the plain interval stands.
- If anyone peeked without a sequential design: the nominal p-value is
broken (daily peeking roughly triples the false positive rate) and the
reported effect is selected on noise. Recompute against always-valid
bounds (
python -m gallop.sequential bound) and label the result as salvaged, not as designed. - If the design was sequential: read against the pre-declared boundary
(
boundfor always-valid,obffor the fixed schedule). A day-three crossing of an always-valid bound is a licensed stop.
Apply CUPED if a pre-period covariate exists
(gallop.variance.cuped): the se falls by sqrt(1 - rho²), often the
difference between a conclusive and an inconclusive read. Validity rule
and failure modes: reference/variance-reduction.md.
A null is an interval, not a zero. "No difference" when the MDE was 1.4pp means "we could not have seen less than 1.4pp". Report the interval and the MDE every time; an underpowered null read as a kill is as wrong as a noise spike read as a win.
4 · Segments: the one you registered
Read the pre-registered segment. Do not read the other eleven the platform offers; with eleven cuts at alpha 0.05, finding a "significant" segment is the expected outcome under a true null. Anything interesting in an unregistered cut is a hypothesis for the next test, filed as one.
5 · Shrink toward what this metric actually does
Raw winners are inflated: conditioning on crossing a threshold selects the
draws noise helped. Shrink the estimate toward the prior store's
distribution for this metric (gallop.shrink.from_store); the shrunk
number is the planning number and the one that gets written back. Mechanics
and when shrinkage is not available:
reference/shrinkage.md.
6 · Time: novelty and decay
If the daily lift trends down across the run (regress daily lift on day; a negative slope with a positive intercept), the effect is novelty and the horizon-end average overstates the long run. A decay slower than the horizon is invisible to any test you can afford; the only instrument for that is a long-term holdback, so recommend one when the decision is expensive and the mechanism is plausibly novelty-driven.
The verdict
One of four, stated plainly with the decision rule beside it:
- Trustworthy and clears the rule → ship; hand to
writing-readouts. - Trustworthy and misses the rule → kill or iterate; hand to
writing-readouts(nulls are filed with the same care as wins). - Trust gate failed → no result exists. Diagnose, fix, rerun. Also filed: a broken test is a lesson about the pipeline.
- Salvage (peeked without licence, diluted exposure): a labelled, weaker number with its caveat attached in the same sentence, and a note in the readout that the next design fixes the licence.
Every verdict flows to writing-readouts; the prior store only stays
honest if losses and broken tests are written back as faithfully as wins.
reference shrinkage.md
Empirical Bayes shrinkage
The winner's-curse correction, and the moment the prior store pays for itself.
Why raw winners are inflated
A result is reported because it crossed a threshold. Conditioning on crossing selects the realisations noise pushed up, so the expected reported effect exceeds the true one, and the excess grows as power falls. An underpowered test that "wins big" is the most inflated object in the building: with 20% power, a just-significant result overstates the true effect by roughly a factor of two or more. The sign is usually right; the magnitude is not, which is worse for planning than a clean false positive.
The model
Normal-normal. The prior is what this metric has actually done, estimated from the store by method of moments:
mu = mean of past effects
tau² = max( var(past effects) − mean(past se²), 0 )
Posterior mean = w·effect + (1−w)·mu, with w = tau²/(tau² + se²).
python -m gallop.shrink eb --effect 0.0031 --se 0.0012 \
--store priors.jsonl --metric activation_rate --unit pp
Reading the output:
- w near 1: the test was precise relative to how much true effects vary; the data mostly stands.
- w near 0: the test was noisy relative to the metric's history; the estimate collapses toward the prior mean. This is not the method being timid, it is the observation carrying little information.
- tau² = 0: past effects are indistinguishable from noise around their mean; the model is saying this metric has never produced a distinguishable effect, and the new result shrinks all the way to mu. That verdict is itself worth surfacing in the readout.
The shrunk number is the planning number: what to forecast from, what to write into the prior store, what to tell the roadmap. The raw number and interval are still reported beside it, labelled.
Preconditions, checked by the tooling
- At least 3 past effects for the metric (
gallop.shrinkrefuses fewer; below ~8 the prior itself is noisy, so say so). - One unit. Effects in
ppdo not pool with effects inrelative;from_storerefuses mixed units rather than averaging them. - Comparable readouts. The store should hold effects from the same
metric under comparable designs; a store polluted with one 10x outlier
from a pricing change will inflate tau² and under-shrink.
supersedesexists for corrections; use it.
When there is no store
First tests at a company have no prior. Options, in honesty order: borrow the published base rate (most experiments do nothing; the honest prior mean is roughly zero, so at minimum discount the raw estimate mentally and say the readout is unshrunk); or start the store with this readout and accept that shrinkage begins from test four. What is not available is treating the raw number as a forecast: that is the exact error the correction exists for.
reference trust-gate.md
The trust gate
Two mechanical checks that run before any effect is read. Both exist because their failure modes produce readouts indistinguishable from healthy ones.
Sample ratio mismatch
The check: chi-square goodness of fit of assignment counts against the intended split.
python -m gallop.trust srm --counts counts.csv # columns: arm, assigned
Alpha is 0.001, not 0.05, deliberately. The check runs on every experiment ever analysed; at 0.05 it would fire on one healthy test in twenty, be ignored within a quarter, and protect nothing. At 0.001 a firing means the test is broken, with high confidence.
Why there is no correction. An SRM means units left (or never entered) one arm non-randomly. The remaining populations differ in whatever made those units leave, which is usually correlated with the outcome. Reweighting cannot recover a comparison that randomisation no longer underwrites.
The cause taxonomy (Fabijan et al.), worked in order, stopping at the first that fits:
- Assignment – bucketing bug, hash collision, a variant filter upstream.
- Execution – one variant errors or times out; its events never arrive.
- Logging – telemetry differs by variant; a slower page fires fewer beacons.
- Experiment definition – the ramp changed mid-flight and the expected ratio was not updated. (The one benign cause: fix the expectation, not the test.)
- Triggering – the trigger condition is itself affected by treatment.
- Interference – units move between variants or share state.
- Filtering – a bot or outlier filter removes units at different rates by variant.
Check daily during the run, not once at the end: an SRM caught on day two costs two days; caught at readout it costs the whole test.
Exposure versus eligibility
The check: exposure counts per arm against assignment counts.
python -m gallop.trust exposure --counts counts.csv # columns: arm, assigned, exposed
Two distinct failures:
Differential exposure (the exposed counts fail an SRM at the assignment split): the arms trigger unequally, which is a bug in the trigger or the surface, and the comparison is invalid outright. Treat exactly like an SRM.
Uniform dilution (both arms under-exposed at the same rate r): the intention-to-treat estimate is attenuated by roughly r. Two honest readings:
- ITT as measured, labelled "diluted by exposure at r".
- The exposed-only comparison, if exposure is logged symmetrically in both arms (control fires the event on seeing the control surface). If only the treatment arm logs exposure, the exposed subsets are not comparable and scaling ITT by 1/r is the safer statement.
The deeper fix belongs to design: log exposure, not eligibility, and alert on event volume so a dark platform or country is visible within a day. A test that "ran fine" with a 60% exposure rate was a test on a 40% smaller effect than anyone thought they were testing.
reference variance-reduction.md
Variance reduction at readout
CUPED
Deng, Xu, Kohavi & Walker (2013). Adjust the metric by a pre-experiment covariate that treatment cannot have touched:
Y_adj = Y - theta (X - mean(X)), theta = Cov(Y,X) / Var(X)
Var(Y_adj) = Var(Y)(1 - rho²): the entire benefit is the squared pre/post correlation, so measure rho before promising anyone a number. At rho 0.7 the required sample halves; at rho 0.2 the gain is cosmetic.
python -m gallop.variance cuped --data units.csv --y metric --x pre_metric --arm arm
The validity rule, in one sentence: the covariate must be fully determined before the first unit was exposed, and defined identically for every unit, including units with no history (impute a constant, usually the mean; never drop them).
Failure modes, in order of danger:
- Contaminated covariate. X computed over a window that overlaps the experiment, or otherwise touched by treatment. The only failure that biases the estimate rather than weakening it: CUPED then subtracts part of the true effect. If the covariate window's end is not provably before first exposure, do not use it.
- Per-arm theta. Theta and mean(X) must be estimated pooled across
arms. Estimated per arm, each theta fits its own arm's noise and most of
the variance reduction is thrown away (
gallop.variancepools; a hand-rolled version often does not). - Missing pre-period. New users have no history. Coverage c of the covariate cuts the reduction by roughly c²; with 40% coverage, expect ~16% of the nominal gain. Report the effective rho, not the rho among covered users.
The natural covariate is the same metric over an equal-length pre-period. Anything pre-treatment and correlated works; the same metric is usually the most correlated thing available.
The alternatives
Post-stratification on a discrete covariate (platform, country, tenure): weight each stratum by its pooled share. Wins over CUPED only when the covariate is genuinely categorical or must be fixed before launch.
Triggered analysis: restrict to units that could have been affected at all. Not a variance trick on the same population; it changes the estimand to the effect on the triggered subset. Legitimate and often what the decision wants, but say which population the number is about.
What is not on the list: collecting more traffic. At readout the sample is what it is; the levers above are the only ones left, which is why the covariate is planned at design time.