What this solves
Most A/B tests confuse “which variant won” with “did the engine actually help vs doing nothing?” A holdout group answers the second question: a known percentage of traffic is held out entirely and gets zero offers from the flow. Comparing engaged-rate across variant × (in-experiment vs holdout) gives you causal uplift, not just relative ranking.Why this works
The platform gives you three complementary mechanisms:- Champion / Challenger on the Score node —
championChallenger.{champion, challengers[]}routes each customer to one scoring model via a deterministic hash ofcustomerId, so the same customer always lands in the same variant. The assignment is persisted (see Step 3). - Per-flow holdout — a flow-level
experimentconfig block ({ enabled: true, holdoutPercent: N }). Held-out customers get zero offers and the trace carriestraceSummary.holdout: true. This is a true causal holdout — the customer sees nothing from this flow, so you can measure lift against “did nothing.” - Always-on control group — an independent ~2% slice (deterministic per
customer × day) that still receives the qualified, policy-filtered offer set but with randomized scores/ranking instead of the model’s ranking. Surfaced ascontrolGroup: trueon every response.
Step 1 — Reserve a holdout on the flow
The holdout lives on the flow config as anexperiment block — not on tenant settings. The Controlled Experiment (Holdout) flow template ships it pre-wired; the shape is:
traceSummary.holdout: true — they get no offers from this flow. holdoutPercent is expressed as a percentage of traffic.
Step 2 — Configure champion/challenger on the Score node
experimentId) so the same customer stays in the same variant across sessions until you change the configuration.
Step 3 — Capture the variant on each decision
The recommend response includes:experimentVariant is the model key the customer was routed to by champion/challenger (null when no experiment is configured on the flow). controlGroup: true marks the always-on ~2% control slice (randomized ranking) — it is independent of the champion/challenger split and of the per-flow holdout. The decision_traces.experimentAssignment JSONB persists the assigned variant as { "variant": "bayesian-v2" } for later analysis.
Step 4 — Measure uplift
GET /api/v1/experiments/{id}/results computes z-tested uplift between the treatment population and the holdout (__holdout__ variant assignments). The path segment accepts the experiment’s UUID or its name:
treatment and holdout conversion rates, uplift.{absolute, relative}, significance.{zScore, pValue, isSignificant}, and Wilson confidence intervals. The z-test / p-value math lives in platform/src/lib/experimentation/uplift.ts.
Gotchas
- Holdout is per-flow, not tenant-wide. It lives on the flow’s
experiment.holdoutPercent, so different flows can hold out different shares. The always-on control group, by contrast, is tenant-wide: it readstenant.settings.controlGroupPercentand defaults to 2% when unset. (That default is what applies unless the value is seeded directly on the tenant record —controlGroupPercentis not on thePUT /api/v1/tenant-settingsallowlist.) - Variant assignment is persistent. The same customer always sees the same variant — a DB-backed assignment (
variant_assignments, 30-day TTL) keyed byexperimentId, falling back to a deterministic hash on a miss. autoPromoteon the Experiment resource (when enabled) automatically promotes a winning challenger to champion once it clearspromoteThresholdforpromoteAfterDays. Combine withfour-eyesapproval for governance.