feat(experiments): target-scoped PLP ranking, so any PLP is testable - #531
feat(experiments): target-scoped PLP ranking, so any PLP is testable#531hugo-ccabral wants to merge 2 commits into
Conversation
Contract 1 published `{key, variants}` with no target, even though the
control plane's `experiments` table has modelled `target_kind` + a target
id from the start. The runtime was therefore handed an experiment with no
way to know which of a site's PLPs it belonged to, and the only way to
scope was to hardcode a page in site code — which applies one PLP's arm
to every other PLP sharing that loader and serves the wrong catalogue.
blocks:
- ExperimentDefinition gains optional `targetKind` + `target`, mirroring
the control-plane schema. Both optional: an experiment without them
still resolves by key exactly as before.
- resolveExperimentForTarget(kind, target) alongside the key lookup. The
assignment itself moves into a shared `decide()` so the two cannot
drift; it stays keyed on `experiment.key`, never the target, so the
cookie and the analytics join identify the cohort and not the surface.
The target is deliberately NOT encoded into the key — that forces a
splitByChar before every GROUP BY, the same objection contract 4 raises
against gluing experiment and variant.
apps-vtex:
- vtexProductListingPage resolves the incoming productClusterIds facet
and swaps it in place when an arm targets that collection. This is the
single entry point every collection-driven PLP already flows through:
1936 of FARM Rio's pages call it straight from their CMS block and
never touch site code, so no site-side change could reach them. It is
also sync-proof, unlike a `.deco` block prop.
- Replaces rather than appends. Two collections OR'd together are neither
model's ranking, and the filter-chip and pagination hrefs are built
from that same array, so an appended facet leaks into every link on the
page — a control-arm visitor opening a shared link would inherit the
other arm's collection while still being tagged `control`.
- Inert for any collection with no published experiment: nothing
recorded, facets returned untouched. Exposure always means "could
actually be affected", which the analysis requires.
31/31 tests pass in blocks (5 new, no pre-existing regression from the
decide() extraction); packages/blocks and packages/apps-vtex both
typecheck clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Verdict: BLOCK — the swap is in-place, but every outbound link on the page is serialised from the swapped array, so the arm's collection escapes into Blocking
Worth fixing
Checked, fine
|
…collisions Review caught the swap leaking into outbound URLs — the exact failure the in-place swap exists to prevent, just one request later. `facets` is not only the search input: `toFilter` and the pagination loop serialise `filter.<key>=<value>` from it, so swapping in place put the arm's collection into every filter chip and pagination href. A control visitor opening such a link had `filter.productClusterIds=412` appended from the URL — the dedupe matches on key AND value, so it did not collapse — then had the control arm swapped in alongside it and queried BOTH collections while still tagged `control`. Crawlers would also have indexed pagination URLs pinned to an arm's collection, rendering empty once that arm retires. The swap now produces a separate `queryFacets` used only for `toFacetPath`; the page's own links keep the original facets. Moved below the page-types fallback too, so it sees the final facet set rather than the pre-fallback one. Also contains key collisions at read time. Target scoping makes several concurrent experiments per site normal, which makes a repeated `key` newly plausible, and its failure mode is severe and silent: deco_segment stores one entry per name, so two experiments sharing a key have different fingerprints, and every hop between their surfaces looks like a ramp change — re-rolling the visitor, rewriting the cookie, and changing __abf on every navigation so nothing caches. Losing one arm of a mis-published pair beats corrupting every assignment on the site. 33/33 tests pass (2 new); blocks and apps-vtex both typecheck clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Triaged all three. Pushed in 1. Facets leaking into the page's own links — fixed, blocking, and you were rightI verified it rather than taking it on faith: That is precisely the leak the PR body claims replacing-not-appending prevents. It just happened one request later, via the link. Embarrassing, and a good catch. Fixed as you suggested: the swap produces a separate Both follow-ons you flagged dissolve with it: nothing pins a crawlable pagination URL to an arm's collection, and the single- 2. Duplicate
|
| page blocks calling the framework PLP loader | 959 of 1035 |
of those, with a hardcoded productClusterIds |
958 |
on regionListingPage |
3 |
on site/loaders/plp.ts |
0 |
So it's 958 of 959, not "953 of 1936". The real figure argues the case harder than the one I printed.
33/33 tests pass; blocks and apps-vtex both typecheck clean.
Makes the PLP ranking A/B capability generic, so any collection-driven PLP can be tested instead of one hand-wired page. Design:
04_engineering/platform/experiments/README.mdincontext(contract 1 updated in a companion PR).The hole this closes
Contract 1 published
{key, variants}with no target — even though the control plane'sexperimentstable has modelledtarget_kind+ a target id from the start. So the runtime received an experiment with no way to know which of a site's PLPs it belonged to.The only way left to scope it was to hardcode a page in site code. That is what the first cut of the consuming PR did, and it is actively wrong: FARM Rio has three CMS pages sharing one loader —
/produtos(2258),/bazar(2259),/produtos/vestido(2247) — each curating a different product set. An arm precomputed for one of them applied to all three serves Bazar's shoppers the main PLP's catalogue.It also could not reach most PLPs at all: 1936 of FARM Rio's pages call
intelligentSearch/productListingPage.tsstraight from their CMS block and never touch site code, 953 of them passing a hardcodedproductClusterIds.What changes
packages/blocks—ExperimentDefinitiongains optionaltargetKind+target, and aresolveExperimentForTarget(kind, target)lookup joins the existing key lookup. The assignment moves into a shareddecide()so the two cannot drift.Both fields are optional, so an experiment without them resolves by key exactly as before — no behaviour change for anything published today.
The assignment stays keyed on
experiment.key, never the target: the cookie and the analytics join identify the cohort, not the surface. The target is deliberately not encoded into the key (plp-ranking:2258) — that forces asplitByCharbefore everyGROUP BY, the same objection contract 4 raises against gluing experiment and variant.packages/apps-vtex—vtexProductListingPageresolves the incomingproductClusterIdsfacet and swaps it in place when an arm targets that collection.control.Why in
apps-vtexand not in each siteIt is the single entry point every collection-driven PLP already flows through, so it reaches the 1936 pages no site-side change can. It is also sync-proof: FARM Rio's
.decoblocks are regenerated from the Fresh site — 143sync: .deco content from farmriocommits landed during one review cycle — so a block-level opt-in prop would simply be overwritten. Every VTEX deco site gets the capability for free.A nice consequence: FARM Rio's region loader picks a Sul-specific collection for PR/RS/SC visitors. Because matching is by target, those visitors simply don't match an experiment aimed at the default collection — regional shoppers are excluded from a ranking test with no special-casing anywhere.
Tests
31 passedinpackages/blocks/src/sdk/experiments.test.ts— 5 new, covering: the right experiment wins per surface (/bazarnever inherits/produtos' arm), an untargeted surface returns null and records no assignment, kind alone doesn't match, untargeted contract-1 documents still resolve by key, and the cookie is keyed on the experiment key rather than the target.No pre-existing test regressed from the
decide()extraction.packages/blocksandpackages/apps-vtexboth typecheck clean.🤖 Generated with Claude Code
Summary by cubic
Makes PLP ranking experiments target-scoped so any collection-driven PLP can be tested instead of one hardwired page. Previously, experiments resolved by key with no target, forcing a hardcoded page that applied one PLP's arm to every PLP sharing the loader.
ExperimentDefinitiongains optionaltargetKindandtarget; experiments without them resolve by key with no behavior change.resolveExperimentForTargetlookup assigns per surface, sharing assignment logic with the existing key lookup.vtexProductListingPageswaps in the assigned collection for the search only, replacing — never appending — theproductClusterIdsfacet; the page's own links keep the original facets.Written for commit 3d5360a. Summary will update on new commits.