Skip to content

setupAB: correct isControl and carry edge fields on multi-arm tests - #332

Closed
MO-Thibault wants to merge 1 commit into
masterfrom
setupab-multiarm
Closed

setupAB: correct isControl and carry edge fields on multi-arm tests#332
MO-Thibault wants to merge 1 commit into
masterfrom
setupab-multiarm

Conversation

@MO-Thibault

Copy link
Copy Markdown
Contributor

Summary

setupAB cannot express a split test with more than two arms. Three separate defects, all of which surface the moment a third variant exists. The More than two variants section of abTestAssignment.md already documents this as a supported shape, so today that documentation is wrong.

The concrete case: chess is running a three-arm test — production at 50%, skip1p at 45% carrying skipMatchers: ['1p'], and a 5% holdout — and the wrapper has to hand-roll the whole assignment because setupAB cannot hold it.

isControl treated every non-treatment arm as a holdout

const isControl = selected.id !== treatmentId;
if (isControl) { localStorage.removeItem("OPTABLE_RESOLVED"); /* + targeting cache */ }

A middle arm that still resolves EIDs was classified as control and had its cache cleared on every page load. That is wrong twice over: it corrupts the measurement, and because the session guard (OPTABLE_TARGETING_DONE, sessionStorage) survives the wipe, the user serves an empty cache for the rest of the session rather than re-resolving.

Now selected.id === controlId.

This is not a behaviour change for any current caller. With two variants the two predicates are the same test. All 17 templated bundles in optable-solutions pass exactly [{id:'production'},{id:'test',trafficPercentage:5}], and the documented custom-name example already passes controlId and treatmentId together.

skipMatchers was stripped off the variant

fillTrafficPercentages rebuilt each variant as {id, trafficPercentage}, discarding skipMatchers, skipResolvers and matcher_override. Those fields are the reason an arm would differ at the edge at all, and ABTestConfig already declares them.

Variants are now spread through, so the assigned variant can go straight into InitConfig.abTests — which is the only way skip_matchers reaches the edge, since targeting.ts reads it off determineABTest(config.abTests) and a top-level skipMatchers is inert.

The sticky read returned the stored copy, not the current config

Editing an arm's skipMatchers, or its weight, applied only to newly assigned users; anyone already holding that arm kept whatever was serialized at assignment time. The read now resolves the stored id against the current variant list and returns that entry, so config is the single source of truth for everything except the id. Unknown ids are still discarded.

New optableSplitTest flag

optableControlGroup only reaches the two arms named by controlId and treatmentId, so there was no way to hold a middle arm for QA. ?optableSplitTest=<id> forces a variant by id and takes precedence over optableControlGroup. An id absent from variants is ignored and assignment proceeds normally, so a stale flag cannot invent an arm.

Test plan

  • 12 new cases in abTestAssignment.test.ts: three-arm assignment, cache retention for a middle arm, control still clearing its cache, skipMatchers passthrough and absence, config re-read over the stored copy, and the full optableSplitTest precedence chain. Existing cases are unmodified.
  • pnpm test — 387 pass, 24 suites.
  • pnpm build-lib and prettier --check lib/ clean.
  • Distribution sanity outside the suite, 500k draws through the real setupAB with the chess config: 50.01% / 44.98% / 5.01%, zero null selections; skipMatchers present only on skip1p, isControl true only on the holdout.
  • Consumer check: the chess bundle built against this branch and driven in headless Chrome with the edge stubbed, 44 assertions passing, including that the middle arm keeps its third-party EIDs across a page load and sends skip_matchers=1p.

Docs updated: abTestAssignment.md gains a section on variants that change what the edge resolves, states the isControl rule for multi-arm tests, and documents optableSplitTest; flags.md gains the new flag.

Release

Consumers need this on npm before they can use a third arm. Optable/optable-solutions#775 is waiting on it.

setupAB could not express a split test with more than two arms, in three
separate ways. All three surface together the moment a third arm exists.

isControl was `selected.id !== treatmentId`, so any arm that is neither the
named treatment nor the control was reported as a holdout, and had
OPTABLE_RESOLVED and the SDK targeting cache cleared. For an arm that still
resolves EIDs that is wrong twice over: it corrupts the measurement, and
because the session guard survives the wipe the user serves an empty cache
for the rest of the session. It is now `selected.id === controlId`, which is
the same predicate for two arms and the intended one for more.

fillTrafficPercentages rebuilt each variant as {id, trafficPercentage},
dropping skipMatchers, skipResolvers and matcher_override. Those fields are
the reason an arm would differ at the edge at all, and ABTestConfig already
declares them. Variants are now spread through, so the selected variant can
be handed straight to InitConfig.abTests.

The sticky read returned the object stored in localStorage. Editing an arm's
skipMatchers or its weight then applied only to newly assigned users. It now
resolves the stored id against the current variant list and returns that
entry, so config is the single source of truth for everything but the id.

Adds optableSplitTest, which forces a variant by id. optableControlGroup only
reaches the two arms named by controlId and treatmentId, so it cannot hold a
middle arm for QA. An unknown id falls through to normal assignment.

Behaviour is unchanged for every two-arm caller: with variants production and
test, `id === 'test'` and `id !== 'production'` are the same test.

Tests: 12 new cases covering three-arm assignment, cache retention for a
middle arm, skipMatchers passthrough, config re-read over the stored copy,
and the optableSplitTest precedence chain. 387 pass.
@MO-Thibault
MO-Thibault requested review from a team as code owners September 2, 2026 21:32
@MO-Thibault MO-Thibault closed this Sep 2, 2026
@MO-Thibault
MO-Thibault deleted the setupab-multiarm branch September 2, 2026 21:38
@MO-Thibault

Copy link
Copy Markdown
Contributor Author

Unblocking note: Optable/optable-solutions#775 no longer waits on this. Chess now layers its third arm inside the production variant, which setupAB already treats as enabled, so it runs on 0.59.0 as published.

This is still worth landing on its own merits — it is what makes a genuine third variant work, stops skipMatchers being stripped off the variant, and lets optableSplitTest reach a middle arm. But there is no release pressure behind it now, so it can go at review pace.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant