Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
pageTypesFromPath,
toFacetPath,
} from "../../client";
import { resolveExperimentForTarget } from "@decocms/blocks/sdk/experiments";
import { pickSku, toProduct } from "../../utils/transform";
import type { Product as ProductVTEX, Sort } from "../../utils/types";

Expand Down Expand Up @@ -334,6 +335,44 @@ function isValidPLPPath(path: string): boolean {
return true;
}

/** The collection facet a curated PLP queries, and what an arm replaces. */
const COLLECTION_FACET = "productClusterIds";

/**
* Serve this visitor's ranking arm, when an experiment targets this PLP.
*
* Every curated PLP resolves to exactly one `productClusterIds` value, and each
* ranking model gets its own precomputed collection, so serving a variant is a
* value swap — no ranking math at request time.
*
* The arm **replaces** the value rather than adding a second facet. Two
* collections OR'd together would be neither model's ranking, and since the
* filter-chip and pagination hrefs below are built from this same array, an
* extra facet would also leak into every link on the page — where a control-arm
* visitor opening a shared link would inherit the other arm's collection while
* still being tagged `control`.
*
* Scoped by target, so it is inert for every PLP the control plane has not
* published an experiment for: no assignment is recorded and the facets are
* returned untouched. Sites with no experiments never even reach a KV read
* beyond the one memoised per request.
*/
async function applyPlpRankingExperiment(facets: SelectedFacet[]): Promise<SelectedFacet[]> {
const index = facets.findIndex((f) => f.key === COLLECTION_FACET);
if (index < 0) return facets;

const variant = await resolveExperimentForTarget<{ collectionId?: string }>(
"plp_ranking",
facets[index].value,
);
const collectionId = variant?.payload?.collectionId;
if (!collectionId) return facets;

const swapped = [...facets];
swapped[index] = { key: COLLECTION_FACET, value: collectionId };
return swapped;
}

/**
* Mirrors the original deco-cx/apps PLP loader:
*
Expand Down Expand Up @@ -414,7 +453,24 @@ export default async function vtexProductListingPage(props: PLPProps): Promise<a
return null;
}

const facetPath = toFacetPath(facets);
// 1b. PLP ranking experiment. Runs after every facet source has been
// merged (CMS props, URL `filter.*`, VTEX `map`, page types), so it sees
// the final set.
//
// The result is used for the SEARCH ONLY, never for the page's own
// outbound links: `toFilter` and the pagination loop below serialise
// `filter.<key>=<value>` from `facets`, so swapping in place would put
// the arm's collection into every chip and pagination href. A control
// visitor opening such a link would then have `filter.…=412` appended
// from the URL (the dedupe matches on key AND value, so it does not
// collapse), get the control arm swapped in alongside it, and query
// BOTH collections while still tagged `control` — the exact leak the
// in-place swap exists to avoid, just one request later. It would also
// let crawlers index pagination URLs pinned to an arm's collection,
// which render empty once that arm is retired.
const queryFacets = await applyPlpRankingExperiment(facets);

const facetPath = toFacetPath(queryFacets);
const config = getVtexConfig();
const locale = config.locale ?? "pt-BR";

Expand Down
157 changes: 157 additions & 0 deletions packages/blocks/src/sdk/experiments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type ExperimentConfig,
pickWeightedVariant,
readExperimentConfig,
resolveExperimentForTarget,
resolveExperimentVariant,
weightsFingerprint,
} from "./experiments";
Expand Down Expand Up @@ -326,3 +327,159 @@ describe("readExperimentConfig", () => {
expect(await readExperimentConfig(broken, "www.farmrio.com")).toBeNull();
});
});

describe("resolveExperimentForTarget", () => {
/** Two PLPs of the same kind, each with its own arms — the real fleet shape. */
const TARGETED: ExperimentConfig<Payload> = {
version: 1,
experiments: [
{
key: "plp-ranking",
targetKind: "plp_ranking",
target: "2258",
variants: [
{ id: "control", weight: 0, payload: { collectionId: "2258" } },
{ id: "model-b", weight: 100, payload: { collectionId: "412" } },
],
},
{
key: "bazar-ranking",
targetKind: "plp_ranking",
target: "2259",
variants: [
{ id: "control", weight: 0, payload: { collectionId: "2259" } },
{ id: "model-b", weight: 100, payload: { collectionId: "777" } },
],
},
],
};

it("resolves the experiment that targets this surface, not another's", async () => {
const a = await resolveExperimentForTarget<Payload>("plp_ranking", "2258", {
config: TARGETED,
assignments: [],
});
expect(a?.payload.collectionId).toBe("412");
expect(a?.experimentKey).toBe("plp-ranking");

const b = await resolveExperimentForTarget<Payload>("plp_ranking", "2259", {
config: TARGETED,
assignments: [],
});
// The whole point: /bazar must never inherit /produtos' arm.
expect(b?.payload.collectionId).toBe("777");
expect(b?.experimentKey).toBe("bazar-ranking");
});

it("returns null and records nothing for an untargeted surface", async () => {
const assignments: StoredFlag[] = [];
const variant = await resolveExperimentForTarget<Payload>("plp_ranking", "9999", {
config: TARGETED,
assignments,
});
expect(variant).toBeNull();
// An exposure that cannot be affected by the treatment would bias the
// analysis, so an untargeted PLP must not be enrolled at all.
expect(assignments).toEqual([]);
});

it("does not match on kind alone", async () => {
expect(
await resolveExperimentForTarget<Payload>("banner", "2258", {
config: TARGETED,
assignments: [],
}),
).toBeNull();
});

it("ignores untargeted experiments, so contract-1 docs without a target still work by key", async () => {
expect(
await resolveExperimentForTarget<Payload>("plp_ranking", "139", {
config: CONFIG,
assignments: [],
}),
).toBeNull();
const byKey = await resolveExperimentVariant<Payload>("plp-ranking", {
config: CONFIG,
assignments: [],
random: () => 0.99,
});
expect(byKey?.variantId).toBe("model-b");
});

it("keys the cookie on the experiment key, never on the target", async () => {
const assignments: StoredFlag[] = [];
await resolveExperimentForTarget<Payload>("plp_ranking", "2258", {
config: TARGETED,
assignments,
});
// Analytics groups by cohort, not by surface; a target in the cookie would
// force a splitByChar before every GROUP BY.
expect(assignments.map((f) => f.name)).toEqual(["plp-ranking"]);
});
});

describe("duplicate keys", () => {
const kv = (value: unknown) => ({ get: async () => value as never });

it("keeps the first and drops a repeated key, rather than thrashing the cookie", async () => {
// Two experiments sharing a key have different weight vectors, so their
// fingerprints differ; every hop between their surfaces would look like a
// ramp change, re-roll the visitor and rewrite deco_segment — corrupting
// the assignment and changing __abf on every navigation.
const config = await readExperimentConfig<Payload>(
kv({
version: 1,
experiments: [
{
key: "plp-ranking",
targetKind: "plp_ranking",
target: "2258",
variants: [{ id: "a", weight: 100, payload: { collectionId: "1" } }],
},
{
key: "plp-ranking",
targetKind: "plp_ranking",
target: "2259",
variants: [{ id: "b", weight: 100, payload: { collectionId: "2" } }],
},
],
}),
"www.farmrio.com",
);
expect(config?.experiments).toHaveLength(1);
expect(config?.experiments[0].target).toBe("2258");

// The dropped one is inert rather than colliding.
expect(
await resolveExperimentForTarget<Payload>("plp_ranking", "2259", {
config,
assignments: [],
}),
).toBeNull();
});

it("leaves distinct keys alone", async () => {
const config = await readExperimentConfig<Payload>(
kv({
version: 1,
experiments: [
{
key: "plp-ranking",
targetKind: "plp_ranking",
target: "2258",
variants: [{ id: "a", weight: 100, payload: { collectionId: "1" } }],
},
{
key: "bazar-ranking",
targetKind: "plp_ranking",
target: "2259",
variants: [{ id: "b", weight: 100, payload: { collectionId: "2" } }],
},
],
}),
"www.farmrio.com",
);
expect(config?.experiments).toHaveLength(2);
});
});
99 changes: 95 additions & 4 deletions packages/blocks/src/sdk/experiments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,28 @@ export interface ExperimentVariant<P = unknown> {

/** An active experiment. */
export interface ExperimentDefinition<P = unknown> {
/**
* Stable, human-readable identity for the cohort — what analytics groups by.
* Never encodes the target: gluing the two into one string forces a
* `splitByChar` before every `GROUP BY`, the same objection contract 4
* raises against gluing experiment and variant.
*/
key: string;
/**
* What kind of surface this experiment targets, e.g. `"plp_ranking"`.
* Mirrors the control plane's `experiments.target_kind`.
*/
targetKind?: string;
/**
* Which specific surface, e.g. the VTEX collection id a PLP queries.
* Mirrors the control plane's target id.
*
* Without this the runtime is handed an experiment with no way to know which
* of a site's PLPs it belongs to, which forces callers to hardcode a page —
* and a hardcoded page applies one PLP's arm to every other PLP sharing the
* loader, serving the wrong catalogue.
*/
target?: string;
variants: ExperimentVariant<P>[];
}

Expand Down Expand Up @@ -191,12 +212,37 @@ export async function readExperimentConfig<P = unknown>(
if (!kv) return null;
try {
const config = await kv.get<ExperimentConfig<P>>(hostname, "json");
return Array.isArray(config?.experiments) ? config : null;
if (!Array.isArray(config?.experiments)) return null;
return { ...config, experiments: dedupeByKey(config.experiments) };
} catch {
return null;
}
}

/**
* Drop experiments repeating a `key` already seen, keeping the first.
*
* Target scoping makes several concurrent experiments per site normal, which
* makes a key collision newly plausible — and its failure mode is severe and
* silent. `deco_segment` stores one entry per name, so two experiments sharing
* a key with different weight vectors have different fingerprints: each hop
* between their surfaces looks like a ramp change, re-rolls the visitor, and
* rewrites the cookie. That thrashes the assignment the analysis depends on
* AND changes `__abf` on every navigation, so nothing caches.
*
* Losing one arm of a mis-published pair is bad; corrupting every assignment
* on the site is worse, so the collision is contained here rather than left to
* surface as unexplained cache misses.
*/
function dedupeByKey<P>(experiments: ExperimentDefinition<P>[]): ExperimentDefinition<P>[] {
const seen = new Set<string>();
return experiments.filter((e) => {
if (!e?.key || seen.has(e.key)) return false;
seen.add(e.key);
return true;
});
}

/**
* Explicit inputs, for tests and for callers outside a request context.
* Omitted fields fall back to the ambient {@link RequestContext} + Workers env.
Expand Down Expand Up @@ -243,13 +289,53 @@ export async function resolveExperimentVariant<P = unknown>(
ctx: ExperimentContext<P> = {},
): Promise<ResolvedVariant<P> | null> {
const config = ctx.config !== undefined ? ctx.config : await loadConfig<P>(ctx);
const experiment = config?.experiments.find((e) => e.key === experimentKey);
return decide(
config?.experiments.find((e) => e.key === experimentKey),
ctx,
);
}

/**
* Resolve the experiment targeting one specific surface.
*
* The key answers "which cohort"; this answers "which of the site's PLPs".
* A site has many surfaces of the same kind — FARM Rio alone has ~953
* collection-driven PLPs — and an arm's payload is precomputed for exactly
* one of them, so looking an experiment up by key alone would apply one page's
* arm to all of them.
*
* Returns null when no active experiment targets `(targetKind, target)`,
* which is the overwhelmingly common case: an untargeted surface records no
* assignment and behaves exactly as it does today. Exposure therefore always
* means "could actually be affected", which is what the analysis requires.
*/
export async function resolveExperimentForTarget<P = unknown>(
targetKind: string,
target: string,
ctx: ExperimentContext<P> = {},
): Promise<ResolvedVariant<P> | null> {
const config = ctx.config !== undefined ? ctx.config : await loadConfig<P>(ctx);
return decide(
config?.experiments.find((e) => e.targetKind === targetKind && e.target === target),
ctx,
);
}

/**
* The assignment itself, shared by both lookups so they cannot drift.
* Always keyed on `experiment.key`, never on the target — the cookie and the
* analytics join both identify the cohort, not the surface.
*/
function decide<P>(
experiment: ExperimentDefinition<P> | undefined,
ctx: ExperimentContext<P>,
): ResolvedVariant<P> | null {
if (!experiment?.variants?.length) return null;

const stored = parseSegmentCookie(ctx.segmentCookie ?? ambientSegmentCookie());
const random = ctx.random ?? Math.random;
const { value: variantId, isFresh } = stickyDecide<string>({
name: experimentKey,
name: experiment.key,
fingerprint: weightsFingerprint(experiment.variants),
recorded: ctx.assignments ?? ambientAssignments(),
stored,
Expand All @@ -260,7 +346,12 @@ export async function resolveExperimentVariant<P = unknown>(
});

const variant = experiment.variants.find((v) => v.id === variantId) ?? experiment.variants[0];
return { experimentKey, variantId: variant.id, payload: variant.payload, isFresh };
return {
experimentKey: experiment.key,
variantId: variant.id,
payload: variant.payload,
isFresh,
};
}

// ---------------------------------------------------------------------------
Expand Down