Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
An on-call engineer stares at a dashboard showing a critical payment service at 7% error rate. The service owner acknowledged the page two minutes ago. The incident assistant suggests an immediate canary rollback in clean prose. That reply can still cite the wrong runbook, take ten seconds to answer, or send the engineer to a human page five minutes later. Should the team ship a query rewrite?
In Decoding Algorithms, you controlled how retrieved runbook passages turned into an answer and logged the decoding policy. Here we focus on an incident assistant and ask a product question: does rewriting its retrieval query help engineers resolve outages? Keep generation and model weights fixed so the experiment isolates that retrieval change.
An A/B test, also called a randomized controlled experiment, assigns incoming incidents randomly to either the existing system (control) or the new system (treatment) and compares their outcomes. A retrieval update earns a production rollout only when rigorous measurement proves it helps without breaking on-call operations.
That measurement supports one of three clear decisions: ship the rewrite, keep investigating, or roll it back.

Test one change at a time
Start with the incoming request that both arms must answer:
The canary is at 7% errors and the owner acked. Can we roll it back?
Your existing pipeline retrieves runbook passages directly from the raw question and generates an answer. The control path keeps that exact behavior.
A candidate treatment first rewrites the request into a retrieval-focused query: canary rollback error-rate policy owner acknowledgement. It can't invent a rollback threshold that wasn't in the request or trusted context. It then sends the retrieved evidence through the same prompt, model, and decoder settings as control. Any measured difference belongs squarely to the rewrite policy, including its latency overhead and network errors.
| Component | Control | Treatment | Why it matters |
|---|---|---|---|
| Query sent to retriever | Original incident message | Rewritten retrieval query | This is the one intentional change. |
| Runbook index snapshot | runbooks-2026-05-01 | runbooks-2026-05-01 | A different index would mix two changes together. |
| Generator and prompt | Same pinned version | Same pinned version | Generation changes can't masquerade as retrieval gains. |
| Decoder receipt | selection=greedy, same output schema | selection=greedy, same output schema | Decoder policy doesn't differ by arm. |
Write a hypothesis that can fail:
Query rewriting will increase the resolved-incident rate while keeping grounded-answer audit failures, p95 latency, and human pages inside their budgets.
Here, p95 latency represents the 95th percentile of first-request completion times, recorded once per enrolled incident. It protects the slower tail rather than hiding it inside an average. Predeclare how timeouts enter the metric; silently dropping unfinished requests could make a failing treatment look faster.
Lock the decision rules before results tempt you to change them:
The brief specifies an MDE, or minimum detectable effect: the smallest lift the planned enrollment can detect with target statistical power. Keep that planning target separate from the later launch threshold.
| Brief field | Locked choice |
|---|---|
| Enrollment moment | First submitted question on an eligible incident |
| Randomization unit | Incident ID |
| Primary metric | Resolved incident rate |
| Resolved definition | Usable-answer mark by 10 minutes after enrollment, with no human page through that deadline |
| Denominator | All enrolled incidents, analyzed in their assigned arm, after the outcome window matures |
| Guardrail: evidence | Upper end of a two-sided approximate 95% interval for grounding-failure increase must stay below +0.5 percentage points |
| Guardrail: speed | p95 latency must not increase by more than 150 ms |
| Guardrail: operations | Human-page rate must not increase by more than 0.5 percentage points |
| MDE for power planning | +2.0 percentage points in resolution rate |
| Launch rule for resolution | Approximate 95% interval lower bound above zero and observed lift of at least +2.0 percentage points |
| Analysis rule | One fixed-horizon look after planned enrollment and all 10-minute windows mature |
The grounding rule asks whether the data rule out a regression as large as its budget. That's a non-inferiority question, which we'll calculate near the end. Speed and human pages remain point-estimate screens in this compact example, not statistical assurances of safety. A deployable brief needs uncertainty rules for those guardrails too; p95 uncertainty requires request-level data, not just the two reported percentiles.
Random assignment supports causal conclusions only when assignment, instrumentation, and stopping rules stay trustworthy. Practical online experiment systems make this pre-launch contract explicit.[1]
Assign an incident once
Why assign by incident rather than by message? An engineer might ask a follow-up after seeing the first answer. If the first message uses treatment and the follow-up uses control, the second outcome depends partly on the first answer. The unit has received both policies, so the contrast is no longer clean.
In statistical causal inference, this principle is formalized as the Stable Unit Treatment Value Assumption (SUTVA). SUTVA requires two conditions:
- No interference between units: Unit 's outcome depends only on unit 's assigned treatment, unaffected by the assignment of unit .
- No hidden variations of treatment: The treatment policy assigned to unit is identical regardless of how unit received it.
When you randomize individual chat messages instead of the entire incident, SUTVA breaks immediately. The LLM's multi-turn conversation memory carries context across turns, exposing a single incident to a mixture of both variants.
SUTVA can fail across incidents through two main paths:
- Behavioral spillover: An on-call engineer sees a treatment recommendation that clarifies a tricky canary rollback procedure, and then uses that learned knowledge 20 minutes later on a control incident.
- Shared compute contention: If the treatment's query rewriter makes extra LLM inference calls or triggers heavy vector database scans, it can exhaust shared GPU worker pools, increasing latency for control incidents hosted on the same infrastructure.
When incidents cluster naturally inside engineering teams, randomize at the cluster level. Intracluster correlation inflates outcome variance by the design effect:
Here represents average cluster size and is the intracluster correlation coefficient. More chat messages within one team can't replace independently randomized teams.[2]
A stable hash gives each enrolled incident the same arm on every request. Include an experiment name and version in the salt so a later experiment can get a fresh assignment. Lock the salt before outcomes arrive, keep incident IDs immutable, and don't let callers choose IDs to obtain a preferred arm. Hashing approximates random allocation here; it doesn't force exactly equal arm sizes.
1import hashlib
2
3EXPERIMENT = "incident-query-rewrite:v1"
4
5def arm_for(incident_id: str) -> str:
6 payload = f"{EXPERIMENT}:{incident_id}".encode()
7 bucket = int.from_bytes(hashlib.sha256(payload).digest()[:8], "big") % 100
8 return "treatment" if bucket < 50 else "control"
9
10incidents = ["inc-014", "inc-014", "inc-001", "inc-021"]
11for incident in incidents:
12 print(incident, arm_for(incident))
13
14assert arm_for("inc-014") == arm_for("inc-014")
15print("repeat assignment is stable")1inc-014 control
2inc-014 control
3inc-001 treatment
4inc-021 treatment
5repeat assignment is stableIf the same incident appears twice in the output, the assigned arm must match twice. In a service, persist the experiment version and arm on each event as well. Hashing makes the choice deterministic; logging makes that choice auditable.
Why should one incident keep the same experiment assignment across all follow-ups?
Answer
Switching variants between messages contaminates the treatment contrast and gives one incident's later behavior exposure to both policies. Assign the stable experimental unit once.
Define an outcome from events
The assistant shouldn't get credit merely because it produced an answer. Resolution requires a usable-answer mark by ten minutes after enrollment and no human page through that deadline. A missing mark counts as unresolved under this definition; broken telemetry is a measurement problem to investigate, not evidence of success.
Keep incidents whose treatment rewrite timed out in the treatment denominator. This intention-to-treat (ITT) comparison estimates the effect of assignment to the rewrite policy, failures included. Comparing only successful rewrites would select incidents using a treatment-affected event and answer a different, biased question. If 5% of treatment rewrites fail with timeouts, dropping them creates survivor bias by retaining only easier queries.
Wait until every enrolled incident has its full follow-up window. A nine-minute-old incident with no page isn't yet a ten-minute success. The rows below are already matured incident summaries, not individual chat messages; solved means a usable mark arrived within the locked window.
The next lab turns raw product events into that metric. It also records two guardrails: whether an audit says the answer is grounded in the retrieved runbook, and the response latency in milliseconds. Read the event rows before looking at the aggregate counts: which page timing should disqualify a usable answer?
1incident_outcomes = [
2 {"arm": "control", "solved": True, "escalation_after_min": None, "grounded": True, "latency_ms": 1080},
3 {"arm": "control", "solved": True, "escalation_after_min": 4, "grounded": True, "latency_ms": 1200},
4 {"arm": "control", "solved": False, "escalation_after_min": 2, "grounded": False, "latency_ms": 980},
5 {"arm": "treatment", "solved": True, "escalation_after_min": None, "grounded": True, "latency_ms": 1120},
6 {"arm": "treatment", "solved": True, "escalation_after_min": 18, "grounded": True, "latency_ms": 1240},
7 {"arm": "treatment", "solved": True, "escalation_after_min": 6, "grounded": True, "latency_ms": 1280},
8]
9# Each fixture incident has already been observed for 20 minutes.
10incident_outcomes = [{**row, "observed_minutes": 20} for row in incident_outcomes]
11
12def resolved(row: dict) -> bool | None:
13 if row["observed_minutes"] < 10:
14 return None # Pending is not an unresolved outcome.
15 escalation_too_soon = row["escalation_after_min"] is not None and row["escalation_after_min"] <= 10
16 return row["solved"] and not escalation_too_soon
17
18for arm in ("control", "treatment"):
19 rows = [row for row in incident_outcomes if row["arm"] == arm]
20 assert all(resolved(row) is not None for row in rows)
21 successes = sum(resolved(row) for row in rows)
22 grounded = sum(row["grounded"] for row in rows)
23 print(f"{arm}: resolved={successes}/{len(rows)} grounded={grounded}/{len(rows)}")
24
25assert resolved({**incident_outcomes[0], "observed_minutes": 9}) is None
26assert not resolved({**incident_outcomes[0], "escalation_after_min": 10})
27print("pending window is not scored; a page at exactly 10 minutes disqualifies")1control: resolved=1/3 grounded=2/3
2treatment: resolved=2/3 grounded=3/3
3pending window is not scored; a page at exactly 10 minutes disqualifiesThe small rows teach the definition, not the launch result. The treatment row with escalation_after_min=18 still counts as resolved because its page arrives outside the locked ten-minute window. That makes the metric contract inspectable and deterministic.
If later pages matter, choose a longer window or track them separately before launch. The metric also needs enough incidents to distinguish an actual lift from chance variation. The next step is to turn these event-level decisions into an effect estimate.

Read lift from counts
After the planned window, start with a compact teaching fixture of 2,000 incidents per arm. The counts are small enough to check by hand. Before reading the table, predict which arm has the higher resolution rate and what could still make that apparent win misleading.
This fixture isn't powered for the locked MDE of +2.0 percentage points. The planning lab later shows you need about 9,246 incidents per arm at 80% power for that MDE.
Keep the roles separate: use the 2,000-row tables to practice lift, intervals, and launch gates; use the powered enrollment estimate when you design a real test for the brief.
| Variant | Incidents | Resolved | Resolution rate | p95 latency | Human pages | Grounded audit failures |
|---|---|---|---|---|---|---|
| Control: raw query | 2,000 | 760 | 38.0% | 1180 ms | 310 | 22 |
| Treatment: rewritten query | 2,000 | 840 | 42.0% | 1260 ms | 306 | 23 |
Before subtracting the rates, name the target quantity. The estimand is the average effect of assignment to the rewrite policy on resolution among eligible incidents, with each incident weighted once. It includes rewrite failures and follow-up behavior. Random assignment lets us estimate that target by subtracting the two assigned-arm rates, under the no-interference and measurement assumptions already stated.
For the grounding counts below, assume every enrolled incident's answer receives the same blinded audit. If only a sample is audited, use the audited denominator and a predeclared sampling design rather than dividing audit failures by all incidents.
The absolute lift is the treatment rate minus the control rate. It answers a practical question: how many additional incidents resolve per 100 enrolled incidents?
The treatment resolves 4.0 additional incidents per 100 enrolled incidents. That's a 4.0 percentage point lift.
The relative lift compares that absolute change with the original baseline. It answers a different question: how large is the change compared with the control rate?
That's a 10.5 percent relative lift. Name which one you report; saying "up 10.5 points" would be wrong because points describe the absolute difference here.
1control_resolved, control_n = 760, 2000
2treatment_resolved, treatment_n = 840, 2000
3
4p_control = control_resolved / control_n
5p_treatment = treatment_resolved / treatment_n
6absolute_lift = p_treatment - p_control
7relative_lift = absolute_lift / p_control
8
9print(f"control rate: {p_control:.1%}")
10print(f"treatment rate: {p_treatment:.1%}")
11print(f"absolute lift: {absolute_lift * 100:.1f} percentage points")
12print(f"relative lift: {relative_lift:.1%}")1control rate: 38.0%
2treatment rate: 42.0%
3absolute lift: 4.0 percentage points
4relative lift: 10.5%Treatment resolves 840 of 2,000 incidents and control resolves 760 of 2,000. What's the clearest user-facing interpretation of absolute lift?
Answer
The rewrite resolved 4.0 additional incidents per 100 enrolled incidents: 42.0% instead of 38.0%.
Put uncertainty around the lift
If you reran the experiment with different incidents, the counts wouldn't be identical. The observed lift is one draw from a larger process, so it needs a measure of how much it could move.
In Hypothesis Tests, Intervals, and pass@k you put a confidence interval around a paired lift. Here the two arms contain independent incidents, so the standard error adds their two Bernoulli variances. For reasonably large binary-outcome arms, a normal-approximation interval is a useful first calculation.
For each arm, is its observed resolution rate and is its number of enrolled incidents. The estimated standard error of the difference is:
A rough 95 percent interval is . The number 1.96 is the standard-normal cutoff that leaves about 2.5 percent in each tail.
Across repeated experiments satisfying these assumptions, this procedure aims to cover the true effect about 95% of the time. The particular interval we calculate isn't a 95% probability statement about a fixed true effect, and it doesn't cover a different population automatically.
1from math import sqrt
2
3control_resolved, control_n = 760, 2000
4treatment_resolved, treatment_n = 840, 2000
5
6p_control = control_resolved / control_n
7p_treatment = treatment_resolved / treatment_n
8diff = p_treatment - p_control
9se = sqrt(
10 p_control * (1 - p_control) / control_n
11 + p_treatment * (1 - p_treatment) / treatment_n
12)
13low = diff - 1.96 * se
14high = diff + 1.96 * se
15
16print(f"estimated lift: {diff * 100:.1f} pp")
17print(f"standard error: {se * 100:.2f} pp")
18print(f"approximate 95% interval: [{low * 100:.1f}, {high * 100:.1f}] pp")1estimated lift: 4.0 pp
2standard error: 1.55 pp
3approximate 95% interval: [1.0, 7.0] ppThe estimate is +4.0 points and this approximation gives an interval of about +1.0 to +7.0 points. Because the lower bound is above zero, this planned analysis gives evidence for a positive treatment effect, assuming assignment and instrumentation are sound.
It doesn't prove every future rollout will gain four points. The interval answers how much the estimate could vary; it still leaves the planning question open: was 2,000 per arm enough to detect the +2.0 point win you said you cared about?
For rare outcomes, heavily clustered incidents, many simultaneous comparisons, or business-critical launches, choose the inference method with a statistician or a mature experimentation platform before running the test.
More rows aren't always more evidence
Suppose a logging join copies every incident outcome onto four message rows. Both arm rates stay unchanged, so the lift still looks right. But treating those copies as independent observations divides the estimated standard error by two. No new incidents were enrolled; the extra precision is fictional.
1from math import sqrt
2
3p_c, p_t, n_incidents = 0.38, 0.42, 2000
4lift = p_t - p_c
5for copies in (1, 4):
6 assumed_n = n_incidents * copies
7 se = sqrt((p_c * (1 - p_c) + p_t * (1 - p_t)) / assumed_n)
8 low, high = lift - 1.96 * se, lift + 1.96 * se
9 label = "unique incidents" if copies == 1 else "duplicated message rows (WRONG)"
10 print(f"{label}: SE={se * 100:.2f} pp, CI=[{low * 100:.1f}, {high * 100:.1f}] pp")1unique incidents: SE=1.55 pp, CI=[1.0, 7.0] pp
2duplicated message rows (WRONG): SE=0.77 pp, CI=[2.5, 5.5] ppDuplicating rows changes the denominator in the formula, not the amount of independent evidence. Aggregate once per randomized incident before computing this interval.
Real within-team correlation is usually less extreme than identical copies, but the same independence issue applies. If teams are randomized, use a team-aware estimator and uncertainty calculation, such as cluster-robust inference or resampling whole teams. Choosing a cluster method doesn't itself fix interference between clusters. The assignment unit, estimand, and analysis must agree.[2]
Plan for a meaningful win
A test with too little traffic may return "uncertain" even when the treatment helps. Before launching, declare a minimum detectable effect (MDE): a true effect size at which you want a specified probability of rejecting the no-effect null. Smaller effects can still be detected, and an effect at the MDE can still be missed.
Power planning balances three competing parameters:
- False positive rate (Type I error, ): The budget for false alarms when there's truly zero effect. Standard practice sets two-sided ().
- False negative rate (Type II error, ): The risk of missing a real effect of size MDE. Standard practice sets .
- Statistical power (): The probability of successfully rejecting the null hypothesis when the true lift equals the MDE. Standard practice targets 80% ().
Hold the observed rates at 38 and 42 percent, then watch what sample size does to the interval:
1from math import sqrt
2
3p_control = 0.38
4p_treatment = 0.42
5lift = p_treatment - p_control
6
7for n in (200, 500, 2000):
8 se = sqrt(p_control * (1 - p_control) / n + p_treatment * (1 - p_treatment) / n)
9 low = lift - 1.96 * se
10 high = lift + 1.96 * se
11 print(f"{n:>4} per arm: [{low * 100:>4.1f}, {high * 100:>4.1f}] pp")1200 per arm: [-5.6, 13.6] pp
2 500 per arm: [-2.1, 10.1] pp
32000 per arm: [ 1.0, 7.0] ppWith 200 or 500 incidents per arm, a good-looking +4.0 point estimate is still compatible with no improvement. At 2,000 per arm, this observed +4.0 pp effect's interval finally excludes zero in the teaching fixture.
That isn't the same as being powered to detect the locked design MDE of +2.0 pp, which needs about 9,246 incidents per arm below. More enrollment narrows uncertainty; it doesn't make treatment better by itself.
The sample size required per arm follows an inverse square relationship with the effect size :
For binary proportions near baseline rate , outcome variance is :
Notice the in the denominator: halving the MDE from +2.0 pp to +1.0 pp quadruples the required sample size.
1from math import ceil
2from statistics import NormalDist
3
4baseline_rate = 0.38
5mde = 0.02
6alpha = 0.05
7target_power = 0.80
8
9normal = NormalDist()
10z_alpha = normal.inv_cdf(1 - alpha / 2)
11z_power = normal.inv_cdf(target_power)
12n_per_arm = ceil(
13 2 * baseline_rate * (1 - baseline_rate) * (z_alpha + z_power) ** 2 / mde**2
14)
15
16print(f"MDE: {mde * 100:.1f} pp")
17print(f"target power: {target_power:.0%}")
18print(f"planning estimate: {n_per_arm:,} incidents per arm")1MDE: 2.0 pp
2target power: 80%
3planning estimate: 9,246 incidents per armThis formula is for planning, not post-result storytelling. Actual platform planning may account for unequal allocation, repeated incidents, variance reduction, multiple metrics, or sequential analysis.
For comparison, the fuller independent-proportions calculation at baseline 0.38, true lift 0.02, equal allocation, and two-sided alpha 0.05 gives about 79.6% power at 9,246 per arm and rounds up to 9,336 per arm for 80%. That small difference explains why the shortcut is useful for sizing the project but shouldn't be mistaken for the final analysis specification.[3]
An 80% power target isn't an 80% chance of satisfying the whole launch gate. If the true lift is exactly +2.0 points, a roughly symmetric estimate falls above the +2.0-point observed-lift threshold only about half the time. Requiring guardrails to pass can reduce launch probability further. Power the actual decision you intend to make, not just its easiest condition.
Use the estimate to decide how much traffic to enroll before the first outcome arrives. Predeclare a minimum calendar duration too when weekdays, on-call rotations, or novelty effects matter. Hitting the sample count during one unusual shift doesn't establish performance across those conditions.
None of that math is trustworthy if the two arms didn't actually receive the traffic you planned.
Check traffic before outcomes
A sample ratio mismatch (SRM) means observed assignment counts disagree suspiciously with the intended split. If the design says 50/50 but your data has 2,350 treatment incidents and 1,650 control incidents, pause before reading resolution lift. Assignment code, event logging, eligibility filters, or bot removal may differ by arm.
This lab computes a chi-square diagnostic for a planned 50/50 allocation. A one-degree-of-freedom value above 10.83 is a deliberately strict alert threshold corresponding to a very small tail probability, about 0.1 percent.
The 0.001 threshold is this lab's choice, not a universal standard. Microsoft's documented platform uses an even stricter 0.0005 threshold. Such a check diagnoses allocation or data-quality problems; it isn't a test of whether treatment helps.[4]
1observed = {"control": 1650, "treatment": 2350}
2expected_each = sum(observed.values()) / 2
3
4chi_square = sum(
5 (count - expected_each) ** 2 / expected_each
6 for count in observed.values()
7)
8alert_threshold = 10.83
9
10print("planned split: 50% / 50%")
11print(f"observed: control={observed['control']}, treatment={observed['treatment']}")
12print(f"chi-square statistic: {chi_square:.1f}")
13print("pause analysis for SRM investigation:", chi_square > alert_threshold)1planned split: 50% / 50%
2observed: control=1650, treatment=2350
3chi-square statistic: 122.5
4pause analysis for SRM investigation: TrueAn SRM alert doesn't identify the bug. It says your comparison hasn't earned trust yet. Stop and diagnose the event path before making a lift claim.
If the first look at lift looks unbelievable, treat instrumentation as the leading hypothesis until you can replay the events.[1]
Check distinct enrolled incident IDs, not message counts. Treatment could legitimately change the number of follow-ups, so expecting a 50/50 split of messages would test the wrong denominator. A clean SRM result is necessary evidence about traffic, not proof that every outcome field is correct.
Don't stop the first time noise looks good
Suppose treatment truly has no effect: both arms resolve 40 percent of incidents. One planned two-sided 5% test should falsely detect a difference about 5% of the time. That includes apparent benefits and apparent harms. It isn't a 5% rate of positive wins alone. Predict what happens when the same cutoff is checked 20 times and the first crossing ends the test.
Inspecting the ordinary interval after every batch gives noise many chances to cross the threshold. By the Law of the Iterated Logarithm, the cumulative path of zero-effect Brownian noise will cross any fixed significance boundary if observed indefinitely. The stopping policy, not the treatment, changes this false-alarm rate.
This A/A simulation, with two identical arms, uses 5,000 repeat experiments, 20 looks, and a fixed seed. Each look adds 100 new incidents per arm by drawing a binomial count, which is the same as 100 independent Bernoulli trials at 40%.
It tests this exact stopping policy, not a universal percentage for every experiment design.
1import random
2
3rng = random.Random(11)
4trials = 5_000
5looks = 20
6batch_size = 100
7true_rate = 0.40
8
9planned_alarms = 0
10peek_alarms = 0
11
12for _ in range(trials):
13 control_successes = 0
14 treatment_successes = 0
15 crossed_early = False
16
17 for look in range(1, looks + 1):
18 control_successes += rng.binomialvariate(batch_size, true_rate)
19 treatment_successes += rng.binomialvariate(batch_size, true_rate)
20 n = look * batch_size
21 p_control = control_successes / n
22 p_treatment = treatment_successes / n
23 se = (p_control * (1 - p_control) / n + p_treatment * (1 - p_treatment) / n) ** 0.5
24 significant = se > 0 and abs(p_treatment - p_control) / se > 1.96
25 crossed_early = crossed_early or significant
26 if look == looks:
27 planned_alarms += significant
28
29 peek_alarms += crossed_early
30
31print(f"planned final look, either direction: {planned_alarms / trials:.1%}")
32print(f"stop at first crossing, either direction: {peek_alarms / trials:.1%}")1planned final look, either direction: 4.6%
2stop at first crossing, either direction: 24.9%
Ordinary fixed-horizon intervals aren't valid for a decision triggered by continuous monitoring. Repeated observation changes the statistical procedure.
Repeated looks aren't the only way to multiply chances. Trying many variants, metrics, or subgroups and reporting whichever interval excludes zero also needs multiplicity control. Predeclare the family of claims and a method such as Bonferroni, or label new subgroup discoveries exploratory and confirm them with fresh data.[5]
Why can't a fixed-horizon experiment stop the first time an ordinary 95% interval excludes zero?
Answer
Repeated unplanned looks give random noise more chances to cross the threshold. Use the planned final look or a sequential method designed for repeated monitoring.
Sequential testing and continuous monitoring
Product teams want dashboards that update in real time and allow early stopping when a treatment is winning convincingly or hurting users. Two principled mathematical frameworks make this possible without inflating false alarms:
Alpha spending functions
Group sequential designs allocate the total false-alarm budget across intermediate looks using an alpha spending function , where represents the information fraction ().
Lan and DeMets introduced flexible spending functions that preserve the overall Type I error regardless of exact look timings. A popular choice is the O'Brien-Fleming spending boundary:
Early in the test (small ), the boundary requires extreme evidence (for example, or ) to stop early. As the test approaches the maximum planned sample size (), the required critical value relaxes toward the standard 1.96 cutoff. This preserves almost the entire nominal power for the final analysis while providing an emergency brake against catastrophic treatment failures.
Mixture sequential probability ratio tests (mSPRT)
When teams demand true continuous monitoring on every event, the mixture Sequential Probability Ratio Test (mSPRT) provides always-valid p-values and confidence sequences.[6]
Under mSPRT, you construct a test statistic by integrating the likelihood ratio across a mixing distribution over possible effect sizes:
Under the null hypothesis , is a non-negative martingale with expectation equal to 1 for all . By Ville's inequality:
Because Ville's inequality holds uniformly across all sample sizes , the resulting confidence sequences never expire. Product managers can inspect the dashboard at any hour and stop the test whenever the always-valid interval excludes zero, with a mathematical guarantee that false alarms stay capped at .
Reduce noise with pre-experiment data
Power doesn't always require more incidents. CUPED (Controlled-experiment Using Pre-Experiment Data) uses a predictive measurement collected before treatment. For a new incident, that could be a frozen summary of its service's resolution history from before the experiment started. It can't be a prior-period outcome of the same not-yet-created incident.
That signal can remove predictable unit-to-unit variation from the outcome.
Let be the experiment outcome and be a pre-experiment covariate. CUPED forms an adjusted metric:
Because treatment assignment is randomized, the expected value of is identical in both arms: . Subtracting leaves the expected treatment difference completely unchanged:
Now compute the variance of the adjusted metric:
Differentiating with respect to and setting to zero yields the optimal coefficient:
Plugging back into the variance equation produces the variance reduction formula:
Here is the Pearson correlation between and . Under optimal adjustment, outcome variance is multiplied by .
Deng, Xu, Kohavi, and Walker introduced this approach and reported about 50% variance reduction on Bing experiments, enough to cut runtime or traffic roughly in half. Using the same metric from a one-week pre-period reduced queries-per-user variance by more than 45%.[7]
This simulation gives each independent unit a historical flag available before treatment, then assigns treatment independently. Treat the flag as a simplified precomputed feature, not an earlier response to the experiment. Predict which estimate should have the smaller standard error.
1import random
2from statistics import covariance, fmean, variance
3
4rng = random.Random(27)
5n = 20_000
6arm = [rng.randrange(2) for _ in range(n)]
7resolved_before = [rng.binomialvariate(1, 0.45) for _ in range(n)]
8resolved_now = [
9 rng.binomialvariate(1, min(1.0, max(0.0, 0.18 + 0.46 * prior + 0.04 * assigned)))
10 for prior, assigned in zip(resolved_before, arm)
11]
12theta = covariance(resolved_now, resolved_before) / variance(resolved_before)
13mean_before = fmean(resolved_before)
14adjusted = [y - theta * (x - mean_before) for y, x in zip(resolved_now, resolved_before)]
15
16def lift_and_se(outcome: list[float]) -> tuple[float, float]:
17 control = [value for value, assigned in zip(outcome, arm) if assigned == 0]
18 treatment = [value for value, assigned in zip(outcome, arm) if assigned == 1]
19 lift = fmean(treatment) - fmean(control)
20 se = (variance(control) / len(control) + variance(treatment) / len(treatment)) ** 0.5
21 return lift, se
22
23raw_lift, raw_se = lift_and_se(resolved_now)
24cuped_lift, cuped_se = lift_and_se(adjusted)
25variance_reduction = 1 - variance(adjusted) / variance(resolved_now)
26
27print(f"raw estimate: lift={raw_lift * 100:.2f} pp, se={raw_se * 100:.2f} pp")
28print(f"CUPED estimate: lift={cuped_lift * 100:.2f} pp, se={cuped_se * 100:.2f} pp")
29print(f"outcome variance reduced: {variance_reduction:.1%}")1raw estimate: lift=4.68 pp, se=0.70 pp
2CUPED estimate: lift=4.39 pp, se=0.61 pp
3outcome variance reduced: 22.1%
In a finite sample, the raw and adjusted estimates won't be numerically identical. The code estimates one common coefficient from pooled experiment data and uses a large-sample plug-in standard error. Predeclare the adjustment; for richer fitted models, use an appropriate regression-adjusted or cross-fitted estimator and standard errors rather than searching for the adjustment with the biggest lift.
Adjusted outcomes can fall outside zero and one. That's acceptable: they're numerical variance-reduction scores, not new Bernoulli labels. Report the adjusted lift and its uncertainty, and check whether the standard error actually improves.
Never adjust for a quantity treatment could affect, such as latency measured after the rewrite is enabled. Conditioning on that post-treatment value creates collider bias or blocks the causal pathway, wrecking the validity of your test.
Bandits versus fixed A/B tests
When should you run a traditional fixed-horizon A/B test, and when should you deploy a multi-armed bandit algorithm?
This choice centers on the classic exploration versus exploitation tradeoff:
- Fixed A/B testing: Enforces equal 50/50 allocation throughout the entire experiment. It prioritizes pure exploration during the trial to maximize statistical power for causal parameter estimation and guardrail verification. Once the launch gate passes, the winning variant receives 100% of traffic (pure exploitation).
- Multi-armed bandits: Adaptively shift traffic toward whichever variant currently looks best, minimizing cumulative regret while the experiment runs.[8]
In Thompson Sampling, each arm maintains a Bayesian posterior over its success probability. For binary resolution, a Beta-Bernoulli conjugate model samples on each request and routes to the arm with the highest draw.
1import random
2
3rng = random.Random(42)
4p_control = 0.38
5p_treatment = 0.42
6
7alpha_c, beta_c = 1, 1
8alpha_t, beta_t = 1, 1
9n_pulls = 2000
10
11for _ in range(n_pulls):
12 sample_c = rng.betavariate(alpha_c, beta_c)
13 sample_t = rng.betavariate(alpha_t, beta_t)
14 if sample_t > sample_c:
15 reward = 1 if rng.random() < p_treatment else 0
16 alpha_t += reward
17 beta_t += 1 - reward
18 else:
19 reward = 1 if rng.random() < p_control else 0
20 alpha_c += reward
21 beta_c += 1 - reward
22
23pulls_c = alpha_c + beta_c - 2
24pulls_t = alpha_t + beta_t - 2
25mean_c = (alpha_c - 1) / pulls_c
26mean_t = (alpha_t - 1) / pulls_t
27
28print(f"control pulls: {pulls_c} ({pulls_c / n_pulls:.1%}), sample mean: {mean_c:.1%}")
29print(f"treatment pulls: {pulls_t} ({pulls_t / n_pulls:.1%}), sample mean: {mean_t:.1%}")
30print(f"allocation ratio: {pulls_t / pulls_c:.2f}x")1control pulls: 463 (23.2%), sample mean: 38.2%
2treatment pulls: 1537 (76.8%), sample mean: 41.7%
3allocation ratio: 3.32xThe bandit routed 76.8% of incidents to the winning treatment arm, resolving more outages during the test. That efficiency carries an inference penalty:
- Adaptive sampling bias: Arms that get lucky early receive more traffic, while arms that encounter early bad luck are starved. The sample mean of the winning variant is biased upward.
- Invalid classical inference: Standard two-sample t-tests and confidence intervals collapse because observations aren't independent and identically distributed.
- Guardrail blindness: Bandits optimize a single scalar reward. If treatment improves resolution by 4% but quietly triples p95 latency or inflates hallucinations, an unconstrained bandit keeps routing more traffic to the slower, hallucinating model.
Deploy bandits for short-lived opportunities where immediate opportunity cost dominates and long-term scientific learning isn't needed: headline testing, holiday marketing campaigns, or transient routing between external API providers. Use fixed A/B tests for core algorithm changes, model migrations, and architectural updates where guardrails and unconfounded causal conclusions are required.
Interleave pairs to slash prompt noise
In conversational AI and incident management, prompts vary wildly in difficulty. Some incidents describe simple permission lockouts; others describe complex distributed deadlocks. In a standard between-subject A/B test (incident A gets Control, incident B gets Treatment), this prompt difficulty variance adds directly to the standard error of the lift.
Interleaved testing solves this problem by evaluating both models on the exact same input.[9] In search ranking, interleaving blends the candidate lists into a single combined presentation. In LLM evaluation, both models generate candidate answers for the same prompt, and an engineer or automated evaluator judges them head-to-head (as popularized in Chatbot Arena).
By turning an independent two-sample test into a paired comparison, the shared prompt variance cancels out:
When both models evaluate the same prompt difficulty, their scores are strongly positively correlated. The covariance term subtracts out prompt difficulty noise, drastically shrinking the standard error.
1import random
2from statistics import fmean, variance
3
4rng = random.Random(42)
5n_evals = 1000
6
7# Base difficulty of the query on a 1-10 quality scale
8query_difficulty = [rng.gauss(5.0, 1.8) for _ in range(n_evals)]
9queries_c = [rng.gauss(5.0, 1.8) for _ in range(n_evals)]
10queries_t = [rng.gauss(5.0, 1.8) for _ in range(n_evals)]
11
12# Independent A/B test (two distinct sets of queries)
13scores_c_indep = [q + rng.gauss(0, 0.6) for q in queries_c]
14scores_t_indep = [q + 0.45 + rng.gauss(0, 0.6) for q in queries_t]
15lift_indep = fmean(scores_t_indep) - fmean(scores_c_indep)
16se_indep = (variance(scores_c_indep) / n_evals + variance(scores_t_indep) / n_evals) ** 0.5
17
18# Interleaved paired test (both models evaluated on the same queries)
19scores_c_paired = [q + rng.gauss(0, 0.6) for q in query_difficulty]
20scores_t_paired = [q + 0.45 + rng.gauss(0, 0.6) for q in query_difficulty]
21diffs = [t - c for t, c in zip(scores_t_paired, scores_c_paired)]
22lift_paired = fmean(diffs)
23se_paired = (variance(diffs) / n_evals) ** 0.5
24var_reduction = 1 - (se_paired / se_indep) ** 2
25
26print(f"independent A/B: lift={lift_indep:.3f}, SE={se_indep:.3f}")
27print(f"interleaved test: lift={lift_paired:.3f}, SE={se_paired:.3f}")
28print(f"variance reduction: {var_reduction:.1%}")1independent A/B: lift=0.570, SE=0.085
2interleaved test: lift=0.443, SE=0.027
3variance reduction: 89.5%Pairing on the same query slashed outcome variance by 89.5%, shrinking standard error from 0.085 down to 0.027. Chapelle et al. demonstrated that interleaved search evaluation can be 100 to 1,000 times more sensitive than traditional A/B testing.[9]
Treat AI evaluation as a stack
An online experiment answers whether the on-call benefits under live use. It shouldn't be the first time you discover that a treatment emits unsupported rollback claims. Check the treatment in layers, each with a different question:
| Layer | Question | Example evidence |
|---|---|---|
| Offline regression set | Does treatment still follow the runbook on known cases? | Hand-labeled rollback prompts, groundedness and refusal checks |
| Online primary outcome | Does it help engineers complete the incident task? | Resolution rate |
| Product guardrails | Did it hurt speed or operations? | p95 latency, human-page rate, cost |
| Integrity receipt | Did we compare the promised systems? | Assignment split, index snapshot, prompt/model/decoder versions |
If you use a large language model as a judge for an offline rubric, treat it as a measured evaluator rather than truth. Pin its model and prompt version, then compare it with human labels on a retained calibration set.
Zheng et al. study LLM judges as approximations to human preference and document position bias, verbosity bias, self-enhancement bias (favoring answers the judge model itself produced), and reasoning limitations.[10]
This lab makes the configuration receipt explicit. A comparison with two different decoder versions is rejected before anybody reads its lift.
1control = {
2 "index": "runbooks-2026-05-01",
3 "generator": "incident-generator-v7",
4 "decoder": {"selection": "greedy", "schema": "incident-answer-v2"},
5}
6treatment = {
7 "index": "runbooks-2026-05-01",
8 "generator": "incident-generator-v7",
9 "decoder": {"selection": "greedy", "schema": "incident-answer-v2"},
10 "new_component": "query-rewrite-v1",
11}
12
13locked_fields = ("index", "generator", "decoder")
14
15def validate_receipt(control, treatment):
16 mismatches = [field for field in locked_fields if control[field] != treatment[field]]
17 if mismatches:
18 raise ValueError(f"unplanned changes: {mismatches}")
19 return mismatches
20
21mismatches = validate_receipt(control, treatment)
22
23print("intentional treatment change:", treatment["new_component"])
24print("unplanned mismatches:", mismatches)
25print("comparison is interpretable:", not mismatches)
26
27bad_treatment = {**treatment, "decoder": {"selection": "greedy", "schema": "incident-answer-v3"}}
28try:
29 validate_receipt(control, bad_treatment)
30except ValueError as error:
31 print("rejected:", error)
32else:
33 raise AssertionError("unplanned decoder change was accepted")1intentional treatment change: query-rewrite-v1
2unplanned mismatches: []
3comparison is interpretable: True
4rejected: unplanned changes: ['decoder']Make the launch decision auditable
Now combine the evidence. The brief requires a positive interval, an observed lift of at least +2.0 points, guardrails inside their budgets, and trustworthy traffic.
This last lab prints each condition instead of burying the call in a slide deck. Like the earlier lift tables, it reuses the compact 2,000-incident teaching fixture so you can check every line by hand.
For the locked MDE of +2.0 points, the fuller independent-arm calculation targets about 9,336 incidents per arm before accounting for other design requirements. The smaller fixture below is arithmetic practice, not permission for an unplanned early look. Predict which guardrail could keep a positive lift from shipping.
1from math import sqrt
2
3report = {
4 "control": {"n": 2000, "resolved": 760, "p95_latency_ms": 1180, "escalated": 310, "grounding_failures": 22},
5 "treatment": {"n": 2000, "resolved": 840, "p95_latency_ms": 1260, "escalated": 306, "grounding_failures": 23},
6 "minimum_ship_lift": 0.02,
7 "srm_alert": False,
8}
9
10if report["srm_alert"]:
11 raise ValueError("Investigate SRM before computing lift")
12
13c = report["control"]
14t = report["treatment"]
15p_c = c["resolved"] / c["n"]
16p_t = t["resolved"] / t["n"]
17lift = p_t - p_c
18se = sqrt(p_c * (1 - p_c) / c["n"] + p_t * (1 - p_t) / t["n"])
19low = lift - 1.96 * se
20
21latency_delta = t["p95_latency_ms"] - c["p95_latency_ms"]
22escalation_delta = t["escalated"] / t["n"] - c["escalated"] / c["n"]
23grounding_delta = t["grounding_failures"] / t["n"] - c["grounding_failures"] / c["n"]
24
25grounding_control = c["grounding_failures"] / c["n"]
26grounding_treatment = t["grounding_failures"] / t["n"]
27grounding_se = sqrt(
28 grounding_control * (1 - grounding_control) / c["n"]
29 + grounding_treatment * (1 - grounding_treatment) / t["n"]
30)
31grounding_upper = grounding_delta + 1.96 * grounding_se
32
33# Evidence rule was declared in the brief, before outcomes were observed.
34checks = {
35 "traffic_integrity": not report["srm_alert"],
36 "positive_lift_interval": low > 0,
37 "observed_lift_threshold": lift >= report["minimum_ship_lift"],
38 "latency_budget": latency_delta <= 150,
39 "escalation_budget": escalation_delta <= 0.005,
40 "grounding_budget": grounding_delta <= 0.005,
41 "grounding_noninferiority": grounding_upper < 0.005,
42}
43
44for name, passed in checks.items():
45 print(f"{name}: {'PASS' if passed else 'FAIL'}")
46print(
47 f"lift={lift * 100:.1f} pp, low_end={low * 100:.1f} pp, "
48 f"ship_threshold={report['minimum_ship_lift'] * 100:.1f} pp, latency_delta={latency_delta} ms, "
49 f"grounding_delta={grounding_delta * 100:.2f} pp, grounding_upper={grounding_upper * 100:.2f} pp"
50)
51print("teaching gate:", "PASS" if all(checks.values()) else "HOLD AND INVESTIGATE")1traffic_integrity: PASS
2positive_lift_interval: PASS
3observed_lift_threshold: PASS
4latency_budget: PASS
5escalation_budget: PASS
6grounding_budget: PASS
7grounding_noninferiority: FAIL
8lift=4.0 pp, low_end=1.0 pp, ship_threshold=2.0 pp, latency_delta=80 ms, grounding_delta=0.05 pp, grounding_upper=0.70 pp
9teaching gate: HOLD AND INVESTIGATE
+2.0 points, but the grounding-failure interval's upper end is +0.70 points against a +0.5-point budget. The gate therefore stays at investigate.This gate separates evidence from business value. positive_lift_interval asks whether the interval's lower bound is above zero. observed_lift_threshold asks whether the point estimate clears the predeclared +2.0 point bar. Those checks answer different questions.
It doesn't prove the true lift exceeds +2.0 points: the interval begins at +1.0. A more conservative brief could require the lower bound to clear +2.0 points and would keep this result under investigation.
Guardrails can look safe on a point estimate while their uncertainty still crosses the budget. Our brief already declared the grounding rule: the upper endpoint must stay below +0.5 points. Using the upper end of a two-sided 95% interval corresponds to an approximate one-sided 97.5% bound, not a one-sided 95% bound. Keep that choice fixed rather than switching cutoffs after seeing the result.
For grounding failures as a rate, the unpooled SE is:
With the teaching fixture, grounding rates are 1.10% for control and 1.15% for treatment, so pp. The unpooled SE is about 0.333 pp, which puts the rough 95% interval at about pp. Its upper end exceeds the +0.5 pp budget, so grounding_noninferiority fails even though the observed change is small.
For these teaching numbers, the lift and point-estimate checks pass, but the grounding rule keeps the decision at HOLD AND INVESTIGATE. Failing to establish non-inferiority doesn't prove that grounding became worse: the interval includes no change too. We lack enough precision to rule out the unacceptable regression.
The rare-event grounding interval is only a normal approximation with 22 and 23 failures. A real launch should use a preselected method suitable for those counts and audit sampling. Even a passing teaching gate wouldn't resolve the omitted latency and human-page uncertainty rules.
Change treatment latency to 1490 and rerun: resolution still improves, but the speed budget also fails. A treatment doesn't earn launch by winning only its favorite metric.
Practice: break the launch gate
Change one field at a time and predict the gate before rerunning it. These are counterfactual exercises, not permission to edit a live experiment's brief after seeing results:
- Change treatment latency from
1260to1490. Which check fails, and why doesn't the resolution lift override it? - Restore latency, then change
minimum_ship_liftfrom0.02to0.05. Which check fails even though the interval still excludes zero? - Restore the threshold, then set
srm_alerttoTrue. Why should analysis pause before anybody argues about the observed lift?
Expected observations
latency_budgetfails because treatment is now310 msslower than control, beyond the locked150 msbudget.observed_lift_thresholdfails because the observed+4.0point lift is below the new+5.0point business threshold. Statistical evidence and launch value answer different questions.- The code raises
ValueError: Investigate SRM before computing lift. SRM can signal broken assignment, filtering, or logging, so the outcome calculation doesn't run. It isn't merely a red badge added after displaying an untrustworthy lift.
Freeze the experiment receipt
Save the assignment unit and hash rule, metric query and outcome window, planned analysis schedule, sample-ratio-mismatch check, effect estimate with uncertainty, guardrails, and final decision. Those artifacts let a reviewer rerun the gate without reconstructing policy from a dashboard. A trustworthy experiment ends as a reproducible decision record, not a screenshot of a favorable number.