Experiment Outcome Summary
Part of AI Engineering: Design an A/B test with cost guardrails
Experiment Outcome Summary
A learning app runs an A/B test of a staged-hint feature built on a language model. Each learner is randomly assigned to control (the current hints) or treatment (staged hints). After every learner's observation window has closed, an upstream job writes exactly one record per assigned learner: whether the predefined outcome occurred, and the learner's total attributable model cost, including retries and failed attempts (0 for a learner who never asked for a hint). Costs are whole numbers of microunits, millionths of one declared currency unit, so no floating-point money is ever summed.
Write the descriptive summary an analyst reads before any statistical test. It validates the records, then reports each arm's success rate and costs and the difference between the arms. It does not estimate uncertainty or decide whether to ship.
Implement summarizeVariants(rows).
Input
rows is an array of records. Records from the two arms arrive mixed together, in any order.
| JavaScript key | Python key | Meaning |
|---|---|---|
userId | user_id | Pseudonymous ID of one assigned learner |
variant | variant | "control" or "treatment", the assigned arm |
success | success | true if the outcome occurred, false if the window closed without it |
costMicrounits | cost_microunits | The learner's total attributable cost |
An outcome that was never observed (a lost event, for example) is invalid input. It must never be counted as false. The variant field may hold any value, even an array; anything other than the two exact strings is rejected. Keys outside this table are ignored.
Validation
Check the input, then each record in order, and stop at the first failure. Within one record, run the checks in the order below. Throw an Error with exactly the message shown:
| Order | Check | Message |
|---|---|---|
| 1 | rows is a non-empty array | Expected nonempty user records |
| 2 | The record is an object, not null and not an array | Record must be an object |
| 3 | userId is a string that is not empty or whitespace only, and no earlier record in either arm used it | Missing or duplicate user ID |
| 4 | variant is exactly "control" or "treatment" | Unknown variant |
| 5 | success is a boolean | Outcome must be observed boolean |
| 6 | costMicrounits is an integer of at least 0 (not a boolean, not a string) | Cost must be nonnegative integer |
| 7 | After every record passes, both arms have at least one learner | Both variants need observations |
IDs are compared exactly as given, with no trimming or case folding: "t1" and "T1" are different learners. The variant match is exact and case-sensitive.
Report
Return an object with exactly these keys:
| JavaScript key | Python key | Value |
|---|---|---|
control | control | Arm summary for control |
treatment | treatment | Arm summary for treatment |
absoluteLiftPercentagePoints | absolute_lift_percentage_points | 100 * (treatment rate - control rate) |
relativeLift | relative_lift | (treatment rate - control rate) / control rate as a fraction (0.5 means 50%), or null when the control rate is 0 |
Each arm summary has exactly these keys:
| JavaScript key | Python key | Value |
|---|---|---|
users | users | Number of records in the arm |
successRate | success_rate | successes / users |
meanCostMicrounits | mean_cost_microunits | total cost / users |
costPerSuccessMicrounits | cost_per_success_microunits | total cost / successes, or null when the arm has no successes |
- Mean cost divides by every learner in the arm. Cost per success divides the same total by successes only, so it still includes what failed learners cost.
- A ratio with a zero denominator is
null(Nonein Python), never0,NaN, orInfinity. - Do not round. The tests round floats to 4 decimal places before comparing.
Example 1
summarizeVariants([
{ userId: 'c1', variant: 'control', success: true, costMicrounits: 150 },
{ userId: 't1', variant: 'treatment', success: true, costMicrounits: 60 },
{ userId: 'c2', variant: 'control', success: false, costMicrounits: 50 },
{ userId: 't2', variant: 'treatment', success: true, costMicrounits: 90 },
{ userId: 't3', variant: 'treatment', success: false, costMicrounits: 60 },
{ userId: 't4', variant: 'treatment', success: true, costMicrounits: 90 },
]);
// {
// control: { users: 2, successRate: 0.5, meanCostMicrounits: 100, costPerSuccessMicrounits: 200 },
// treatment: { users: 4, successRate: 0.75, meanCostMicrounits: 75, costPerSuccessMicrounits: 100 },
// absoluteLiftPercentagePoints: 25,
// relativeLift: 0.5,
// }Control succeeds for 1 of 2 learners and treatment for 3 of 4. The difference is 0.25, which is 25 percentage points, and 0.25 / 0.5 = 0.5 relative to control. Treatment spends 300 microunits in total: 75 per assigned learner and 100 per success.
Example 2
summarizeVariants([
{ userId: 'c1', variant: 'control', success: 1, costMicrounits: 100 },
{ userId: 't1', variant: 'treatment', success: true, costMicrounits: 80 },
]);
// throws Error: Outcome must be observed boolean1 is a number, not a boolean. Coercing it would hide an upstream encoding bug, and coercing a missing outcome to false would quietly lower one arm's success rate.
Fixtures
The tests compare the whole summary for these two fixtures. Each cell lists learner (success, cost in microunits).
| Fixture | Control | Treatment |
|---|---|---|
| A | u1 (true, 100), u2 (false, 300) | u3 (true, 250), u4 (true, 350) |
| B | u5 (false, 100), u6 (false, 100) | u7 (true, 200), u8 (false, 0) |
They also run these validation rows. Each one changes fixture A, whose rows are u1, u2, u3, u4 in that order:
| Change to fixture A |
|---|
u3's user ID becomes "u1" |
| u4's user ID is three spaces |
u3's variant is "Treatment" |
u2's success is null |
u2's success is the number 1 |
u1's cost is true |
u1's cost is -1 |
u1's cost is 1.5 |
u2's variant is "beta" and its success is null |
| u3 and u4 are removed |
| All four rows are removed |
The string "u9" is appended as a fifth row |
Constraints
- At most 100,000 records.
- Valid costs are at most 1,000,000,000 microunits per learner, so every total stays an exact integer.
- User IDs are at most 64 characters.
Expected Complexity
- Time: O(n) for n records.
- Space: O(n) for the set of seen user IDs.
Python
Implement summarize_variants(rows). rows must be a list and each record a dict with the Python keys above. Return a dict with the Python keys, use None for null, and raise ValueError with the same messages in the same order. success must be a bool. The cost must be an int that is not a bool (Python treats True as the integer 1), and a float such as 100.0 is rejected even though it has no fractional part.
0 views
Solution
Hints
