Skip to content

feat(blocks): generic sticky variant-resolution primitive (experiments) - #494

Open
hugo-ccabral wants to merge 1 commit into
mainfrom
feat/experiments-variant-primitive
Open

feat(blocks): generic sticky variant-resolution primitive (experiments)#494
hugo-ccabral wants to merge 1 commit into
mainfrom
feat/experiments-variant-primitive

Conversation

@hugo-ccabral

@hugo-ccabral hugo-ccabral commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Rollout item B of the PLP ranking experiments design
(04_engineering/platform/experiments/README.md in context).

Companion doc PR publishing the signature for item C: decocms/context#683

What this does

Extracts the sticky-assignment / self-healing re-roll core out of
cms/resolve.ts's evaluateVariantRule into sdk/experiments.ts as
stickyDecide, so it can be driven by a variant list from any source. The
CMS multivariate path now calls the same function, so the two assignment paths
cannot drift.

On top of it, resolveExperimentVariant resolves an N-way weighted experiment
from control-plane config in KV (frozen contract 1), returning contract 2's
ResolvedVariant.

The signature item C consumes

import { resolveExperimentVariant } from "@decocms/blocks/sdk/experiments";

resolveExperimentVariant<P>(experimentKey: string, ctx?: ExperimentContext<P>)
  : Promise<ResolvedVariant<P> | null>

One argument in a loader. The KV binding (EXPERIMENTS_KV), request
hostname, and deco_segment cookie all come from the ambient RequestContext
that workerEntry.ts already installs; the KV read is memoised per request, so
N loaders cost one kv.get. ctx exists for tests — config, kv,
hostname, segmentCookie, assignments, random, all optional.

Every failure mode (binding absent, key unset, malformed document, KV outage)
returns null, never a thrown request. Callers behave exactly as today on
null.

Two things the design assumed were already solved

The design's "Key insight #2" claimed the CMS multivariate engine already had
N-way weighted variants and a reusable cookie. Reading the code, neither held.
Both are solved here; both are corrected in the doc PR.

  1. The CMS engine was never N-way weighted. It lists N variants each gated
    by its own independent boolean matcher, evaluated first-match-wins — there
    is no weighted draw over a variant list, and StoredFlag.value was a
    boolean. The sticky/re-roll policy did extract cleanly; the weighted draw
    (pickWeightedVariant) and the weight-vector fingerprint
    (weightsFingerprint) are net-new.

  2. deco_segment could not physically hold a variant id. active /
    inactiveDrawn are boolean buckets. String-valued decisions now ride in an
    exp map — deliberately the shape Deco Analytics consumes, making that
    migration a read rather than a translation. The map is omitted when empty,
    so cookies stay byte-identical for sites with no experiment (an added empty
    map would re-issue every visitor's cookie on deploy and, via
    segmentCacheToken, cold-start every edge cache entry).

The non-obvious fix

persistFlags ran before the section loaders. An experiment is resolved
inside a loader (that is where item C calls it), so the assignment would never
have reached the cookie and every visitor would re-roll on every request. Moved
to after the loaders, and it now merges experiment assignments through the same
writer — one deco_segment, one Set-Cookie.

Item C would have hit this as "stickiness doesn't work" with no obvious cause.

Cache safety

segmentCacheToken folds experiment assignments in on the same footing as
boolean flags, so the existing __abf cache-key split in workerEntry.ts
covers them with no change. Without that, two visitors on different variants
would share cached HTML. There is a test for exactly that.

CMS multivariate keeps its own trafficToPct fingerprint — swapping it for the
experiment weight hash would re-roll every live visitor on the next deploy.

Tests

26 new tests in packages/blocks/src/sdk/experiments.test.ts:

  • first assignment (fresh visitor, isFresh, weight-proportional routing)
  • stickiness across requests, including adversarial RNG that would have drawn
    the other arm, and legacy fingerprint-less deco_segment cookies
  • re-roll once then stick across a 95/5 → 90/10 ramp, asserting the new
    fingerprint is written so the re-roll cannot repeat
  • a visitor holding a variant retired mid-flight is re-rolled onto a live arm
  • cookie coexistence with a CMS boolean flag; cache-key separation
  • readExperimentConfig failure modes

Blocks suite: baseline on origin/main is 27 failed | 667 passed; with
this branch 27 failed | 693 passed. Same 27 failures, all
this runtime has no URLPattern Web API (needs Node 24+, environmental).

The 2 workerEntry.test.ts draft-preview failures also reproduce on clean
origin/main — both verified by stashing, neither is from this branch.

tsc --noEmit clean. biome check clean on all touched files.

Not in scope

sdk/abTesting.ts is untouched — it is a binary migration-period proxy and a
dead end for this. Only its kv.get<T>(key, "json") shape is borrowed.

Nothing in farmrio-storefront, control-plane, or ai-farmrio.

🤖 Generated with Claude Code


Summary by cubic

Introduces a generic sticky variant resolver and an N-way weighted experiment API. Aligns CMS boolean matcher stickiness with experiments and fixes cookie persistence so assignments reach deco_segment and stick across requests.

  • Review notes

    • Adds @decocms/blocks/sdk/experiments export with stickyDecide (extracted from cms/resolve.ts) and resolveExperimentVariant(experimentKey, ctx?) -> Promise<ResolvedVariant | null>.
    • Moves persistence: cmsRoute now writes deco_segment after loaders and merges takeExperimentAssignments(); prevents per-request re-rolls.
    • Extends deco_segment schema in sdk/flags.ts with exp map { experimentKey: variantId } (omitted when empty). segmentCacheToken includes variant ids; existing analytics remain unchanged.
    • CMS boolean matcher path now uses stickyDecide with its own trafficToPct fingerprint; behavior for binary flags is unchanged.
    • Failure modes (missing EXPERIMENTS_KV, unset/malformed config, KV errors) return null; callers follow the current path on null.
    • Adds focused tests for first assignment, stickiness, single re-roll on weight changes, retired variants, cookie coexistence, cache-key separation, and config reads.
  • Rollout

    • No action for sites without experiments; cookies remain byte-identical.
    • To run an experiment, call resolveExperimentVariant from a loader and branch on variantId/payload. Do not set cookies; the framework persists assignments.
    • Ensure EXPERIMENTS_KV is bound and serves per-hostname config; changing weights will re-roll each visitor once, then stick.

Written for commit efbcccc. Summary will update on new commits.

Review in cubic

Rollout item B of the PLP ranking experiments design
(04_engineering/platform/experiments/README.md in `context`).

Extracts the sticky-assignment / self-healing re-roll core out of
`cms/resolve.ts`'s `evaluateVariantRule` into `sdk/experiments.ts` as
`stickyDecide`, so it can be driven by a variant list from any source. The CMS
multivariate path now calls the same function and keeps its own `trafficToPct`
fingerprint, so live visitors are not re-rolled.

On top of it, `resolveExperimentVariant` resolves an N-way weighted experiment
from control-plane config in KV (frozen contract 1), returning contract 2's
`ResolvedVariant`.

Two gaps the design assumed were already solved, and are solved here:

- The CMS engine is not N-way weighted. It is first-match-wins over independent
  boolean matchers, each with its own `traffic`. The weighted draw
  (`pickWeightedVariant`) and the weight-vector fingerprint
  (`weightsFingerprint`) are new.
- `deco_segment` could not hold a variant id — `active`/`inactiveDrawn` are
  boolean buckets. String-valued decisions now ride in an `exp` map, which is
  deliberately the shape Deco Analytics consumes. The map is omitted when empty,
  so cookies stay byte-identical for sites with no experiment, and
  `segmentCacheToken` folds it into `__abf` so two variants never share cached
  HTML.

`persistFlags` moves to after the section loaders: an experiment is resolved
inside a loader, so persisting before them dropped the assignment and re-rolled
the visitor on every request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hugo-ccabral
hugo-ccabral requested a review from a team August 20, 2026 14:40
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.

2 participants