@gradial/aci 0.1.52-rc.21 → 0.1.52-rc.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -5
- package/dist/astro/index.js +4 -0
- package/dist/audience-data-models.d.ts +12 -0
- package/dist/audience-data-models.js +3 -0
- package/dist/cli/mintlify-render.d.mts +1 -0
- package/dist/cli/mintlify-render.mjs +8 -0
- package/dist/collect/aci.js +1 -1
- package/dist/collect/aci.js.sha256 +1 -1
- package/dist/collect/api.d.ts +3 -0
- package/dist/collect/api.js +19 -0
- package/dist/collect/assignments.d.ts +7 -9
- package/dist/collect/assignments.js +23 -32
- package/dist/collect/batcher.d.ts +3 -2
- package/dist/collect/consent/envelope.d.ts +7 -1
- package/dist/collect/consent/envelope.js +3 -1
- package/dist/collect/consent/mapping.d.ts +2 -0
- package/dist/collect/consent/mapping.js +1 -0
- package/dist/collect/consenthold.d.ts +38 -0
- package/dist/collect/consenthold.js +82 -0
- package/dist/collect/declarative.js +7 -5
- package/dist/collect/gapbuffer.d.ts +13 -4
- package/dist/collect/gapbuffer.js +10 -4
- package/dist/collect/index.js +66 -19
- package/dist/collect/pageviews.d.ts +11 -11
- package/dist/collect/pageviews.js +27 -22
- package/dist/consent-banner/index.d.ts +17 -0
- package/dist/consent-banner/index.js +23 -0
- package/dist/content/provider.d.ts +8 -0
- package/dist/index.d.ts +1 -1
- package/dist/internal/domain/edge/identity.d.ts +5 -0
- package/dist/internal/domain/edge/identity.js +49 -13
- package/dist/internal/domain/edge/mimic_token.d.ts +1 -0
- package/dist/internal/domain/edge/mimic_token.js +74 -7
- package/dist/internal/domain/edge/personalization-core.d.ts +8 -33
- package/dist/internal/domain/edge/personalization-core.js +151 -195
- package/dist/internal/domain/edge/personalization_index.d.ts +17 -0
- package/dist/internal/domain/edge/personalization_index.js +56 -0
- package/dist/internal/domain/edge/record/codec.d.ts +1 -30
- package/dist/internal/domain/edge/record/codec.js +1 -156
- package/dist/internal/domain/edge/resolve.js +55 -38
- package/dist/internal/domain/edge/types.d.ts +41 -32
- package/dist/internal/domain/edge/variant_key.d.ts +3 -0
- package/dist/internal/domain/edge/variant_key.js +44 -0
- package/dist/internal/platform/collectionstore/errors.d.ts +13 -0
- package/dist/internal/platform/collectionstore/errors.js +28 -0
- package/dist/mintlify/blocks.d.ts +6 -0
- package/dist/mintlify/blocks.js +198 -0
- package/dist/mintlify/config.d.ts +3 -0
- package/dist/mintlify/config.js +77 -0
- package/dist/mintlify/index.d.ts +3 -0
- package/dist/mintlify/index.js +2 -0
- package/dist/mintlify/json.d.ts +8 -0
- package/dist/mintlify/json.js +37 -0
- package/dist/mintlify/materialize.d.ts +12 -0
- package/dist/mintlify/materialize.js +159 -0
- package/dist/mintlify/types.d.ts +69 -0
- package/dist/mintlify/types.js +1 -0
- package/dist/mintlify/validate.d.ts +7 -0
- package/dist/mintlify/validate.js +252 -0
- package/dist/next/assignments-navigation.d.ts +4 -0
- package/dist/next/assignments-navigation.js +18 -0
- package/dist/next/assignments-script.d.ts +3 -2
- package/dist/next/assignments-script.js +5 -5
- package/dist/next/error-seat.js +3 -0
- package/dist/next/index.d.ts +1 -0
- package/dist/next/index.js +3 -0
- package/dist/next/render.js +8 -1
- package/dist/react/VideoPlayer.d.ts +1 -1
- package/dist/react/index.d.ts +1 -1
- package/dist/registry.d.ts +4 -2
- package/dist/testing/conformance/checks.d.ts +2 -2
- package/dist/testing/conformance/checks.js +2 -0
- package/dist/testing/conformance/index.d.ts +1 -0
- package/dist/testing/conformance/index.js +1 -0
- package/dist/testing/conformance/next-stubs.d.ts +6 -0
- package/dist/testing/conformance/next-stubs.js +9 -0
- package/dist/testing/conformance/preset.d.ts +3 -2
- package/dist/testing/conformance/preset.js +12 -8
- package/dist/testing/conformance/root-identity.d.ts +31 -0
- package/dist/testing/conformance/root-identity.js +380 -0
- package/dist/testing/conformance/stubs/next-image.d.ts +7 -0
- package/dist/testing/conformance/stubs/next-image.js +5 -0
- package/dist/testing/conformance/stubs/next-link.d.ts +7 -0
- package/dist/testing/conformance/stubs/next-link.js +4 -0
- package/dist/testing/conformance/types.d.ts +2 -2
- package/dist/testing/index.js +2 -2
- package/dist/types/component.d.ts +9 -0
- package/package.json +11 -7
- package/src/cli/mintlify-render.mjs +9 -0
- package/src/types/component.ts +10 -0
- package/templates/astro/starter-content/src/components/chrome/Footer.astro +5 -3
- package/templates/astro/starter-content/src/components/chrome/Header.astro +5 -3
- package/templates/astro/starter-content/src/components/elements/Button.astro +5 -2
- package/templates/astro/starter-content/src/components/elements/ButtonBlock.astro +5 -3
- package/templates/astro/starter-content/src/components/sections/ContainerSection.astro +4 -3
- package/templates/astro/starter-content/src/components/sections/FeatureCard.astro +4 -3
- package/templates/astro/starter-content/src/components/sections/FeatureGrid.astro +4 -3
- package/templates/astro/starter-content/src/components/sections/HomeHero.astro +5 -3
- package/templates/astro/starter-content/src/components/sections/PersonalizationChoice.astro +4 -3
- package/templates/astro/starter-content/src/components/sections/StatsBar.astro +4 -3
- package/templates/astro/starter-content/src/fixtures/sections.ts +1 -1
- package/templates/astro/starter-content/src/stories/CompositionStoryFrame.astro +7 -2
- package/templates/astro/template/package.json.tmpl +4 -4
- package/templates/common/.agents/skills/aci/SKILL.md +50 -0
- package/templates/common/.agents/skills/authoring/SKILL.md +4 -3
- package/templates/common/.agents/skills/authoring/references/block-registry.md +15 -0
- package/templates/common/.agents/skills/migration/references/selector-map.md +1 -1
- package/templates/common/.content/schemas/site.schema.json +11 -1
- package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000101}/asset.json +1 -1
- package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000102}/asset.json +1 -1
- package/templates/common/starter-content/.content/pages/404/_index.json +1 -1
- package/templates/common/starter-content/.content/pages/{home/_index.json → _index.json} +2 -2
- package/templates/common/starter-content/.content/pages/about/_index.json +1 -1
- package/templates/common/starter-content/.content/pages/error/_index.json +1 -1
- package/templates/common/starter-content/.content/patterns/components/button.json +2 -2
- package/templates/common/starter-content/.content/patterns/components/container.json +2 -2
- package/templates/common/starter-content/.content/patterns/components/feature_card.json +2 -2
- package/templates/common/starter-content/.content/patterns/components/feature_grid.json +2 -2
- package/templates/common/starter-content/.content/patterns/components/home_hero.json +2 -2
- package/templates/common/starter-content/.content/patterns/components/site-footer.json +2 -2
- package/templates/common/starter-content/.content/patterns/components/site-navigation.json +2 -2
- package/templates/common/starter-content/.content/patterns/components/stats_bar.json +2 -2
- package/templates/common/starter-content/.content/patterns/layouts/default.json +2 -2
- package/templates/common/starter-content/.content/schemas/site.schema.json +11 -1
- package/templates/nextjs/starter-content/src/components/chrome/Footer.tsx +4 -3
- package/templates/nextjs/starter-content/src/components/chrome/Header.tsx +4 -3
- package/templates/nextjs/starter-content/src/components/elements/Button.tsx +4 -3
- package/templates/nextjs/starter-content/src/components/elements/ButtonBlock.tsx +4 -3
- package/templates/nextjs/starter-content/src/components/sections/ContainerSection.tsx +4 -2
- package/templates/nextjs/starter-content/src/components/sections/FeatureCard.tsx +4 -3
- package/templates/nextjs/starter-content/src/components/sections/FeatureGrid.tsx +4 -2
- package/templates/nextjs/starter-content/src/components/sections/HomeHero.tsx +4 -3
- package/templates/nextjs/starter-content/src/components/sections/PersonalizationChoice.tsx +4 -2
- package/templates/nextjs/starter-content/src/components/sections/StatsBar.tsx +4 -3
- package/templates/astro/starter-content/src/render/RenderPage.astro +0 -67
- package/templates/common/starter-content/.content/patterns/components/personalization_choice.json +0 -42
- package/templates/nextjs/starter-content/src/components/elements/AtomicBlock.tsx +0 -13
- /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000101}/editorial.json +0 -0
- /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000101}/governance.json +0 -0
- /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000101}/versions/v0001.json +0 -0
- /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000102}/editorial.json +0 -0
- /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000102}/governance.json +0 -0
- /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000102}/versions/v0001.json +0 -0
|
@@ -1,89 +1,182 @@
|
|
|
1
1
|
// Runtime-neutral personalization selection. The backend owns this logic
|
|
2
|
-
// and pushes compiled artifacts to each host (
|
|
2
|
+
// and pushes compiled artifacts to each host (Lambda@Edge, Vercel
|
|
3
3
|
// middleware, Cloudflare Worker). Keep this file free of platform APIs.
|
|
4
4
|
//
|
|
5
5
|
// This is the pure decision algorithm — no I/O, no cookies, no identity
|
|
6
6
|
// minting. The caller resolves identity and claims, then calls this.
|
|
7
7
|
import { variantValuesFromKey } from './record/codec.js';
|
|
8
|
-
|
|
8
|
+
import { canonicalVariantKey } from './variant_key.js';
|
|
9
|
+
import { personalizationSelection } from './personalization_index.js';
|
|
10
|
+
export { preparePersonalizationRoute } from './personalization_index.js';
|
|
11
|
+
export class PersonalizationResolutionError extends Error {
|
|
12
|
+
variantKey;
|
|
13
|
+
code = 'personalization.tuple_missing';
|
|
14
|
+
constructor(variantKey) {
|
|
15
|
+
super(`materialized personalization tuple ${variantKey} is absent from the route projection`);
|
|
16
|
+
this.variantKey = variantKey;
|
|
17
|
+
this.name = 'PersonalizationResolutionError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function resolvePersonalizationDecision(request, route, claims, experiments, identity, forcedArms) {
|
|
9
21
|
if (!route)
|
|
10
|
-
return { variant: null,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
return { variant: null, experiments: [] };
|
|
23
|
+
const selection = personalizationSelection(route, experiments);
|
|
24
|
+
const legacyExperimentExclusions = selection.legacyExperimentExclusions;
|
|
25
|
+
const authored = selectRuleVariant(request, selection, claims);
|
|
26
|
+
const values = { ...(authored ? authored.variantValues || variantValuesFromKey(authored.variantKey) : {}) };
|
|
27
|
+
// The leg selectRuleVariant chose is the selected-mode predicate's only
|
|
28
|
+
// input (ADR 0050, edge-functions I-16): "" when no leg won — an organic
|
|
29
|
+
// visit admits no selected-mode experiment.
|
|
30
|
+
const selectedLegAudienceId = String(values.audience || '');
|
|
31
|
+
const assignments = [];
|
|
32
|
+
const rejected = [];
|
|
33
|
+
const attempted = { ...values };
|
|
34
|
+
// Experiment-arm forcing (audience-mimic design/09, edge-functions I-17):
|
|
35
|
+
// an arms-bearing mimic token runs the experiment tier with the token's
|
|
36
|
+
// assertion replacing the dice for each experiment the request selects
|
|
37
|
+
// through the ordinary gate. Selection, eligibility, and arm-rule
|
|
38
|
+
// evaluation are the ordinary path — only allocation is replaced. No
|
|
39
|
+
// identity exists under mimic, so experiments the token does not name are
|
|
40
|
+
// skipped, and unselected experiments' forces are inert.
|
|
41
|
+
const forcing = !!forcedArms && Object.keys(forcedArms).length > 0;
|
|
42
|
+
if ((identity?.usable || forcing) && experiments) {
|
|
43
|
+
// The immutable route projection is the authority for scope. Missing
|
|
44
|
+
// qualified entries are corruption, never evidence to omit an experiment.
|
|
45
|
+
for (const item of selection.experiments) {
|
|
46
|
+
const id = item.id;
|
|
47
|
+
if (!experimentEligible(item, claims, selectedLegAudienceId))
|
|
25
48
|
continue;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
// leg applies (ADR 0040). A rejection fires the onRuleRejected sink
|
|
31
|
-
// inside evaluateRule; the candidate is skipped — never served, and
|
|
32
|
-
// never left behind as a stale experiment decision.
|
|
33
|
-
if (candidate.rule && !evaluateRule(request, candidate.rule, claims, candidate.variantKey))
|
|
49
|
+
const assignment = forcing
|
|
50
|
+
? forceExperimentArm(item, forcedArms)
|
|
51
|
+
: assignExperiment(item, identity);
|
|
52
|
+
if (!assignment)
|
|
34
53
|
continue;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
54
|
+
attempted[`experiment.${id}`] = assignment.armId;
|
|
55
|
+
if (item.eligibilityMode !== 'selected') {
|
|
56
|
+
const candidate = selection.arms[id]?.[assignment.armId];
|
|
57
|
+
// Under forcing an absent single-factor entry is publish/mint skew,
|
|
58
|
+
// not corruption (D-12 forbids the error page on the live origin):
|
|
59
|
+
// the factor degrades to absent and the plain mimic view renders.
|
|
60
|
+
if (!candidate) {
|
|
61
|
+
if (!forcing)
|
|
62
|
+
throw new PersonalizationResolutionError(assignment.variantKey);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const factorEvents = [];
|
|
66
|
+
const factorRequest = { ...request, onRuleRejected: (event) => factorEvents.push(event) };
|
|
67
|
+
if (candidate.rule && !evaluateRule(factorRequest, candidate.rule, claims, assignment.variantKey)) {
|
|
68
|
+
if (!factorEvents.length)
|
|
69
|
+
factorEvents.push({ variantKey: assignment.variantKey, diagnosticCode: 'expression.not_matched', diagnosticPath: 'expression', diagnosticCount: 1 });
|
|
70
|
+
for (const event of factorEvents)
|
|
71
|
+
rejected.push({ ...event, experimentId: id, armId: assignment.armId });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// A selected-mode experiment carries no arm rules (experiment I-18) and
|
|
76
|
+
// its arms materialize only composed with the selected leg, so there is
|
|
77
|
+
// no single-factor arm entry to look up; the composed-tuple resolution
|
|
78
|
+
// below stays the fail-closed corruption guard.
|
|
79
|
+
assignments.push(assignment);
|
|
80
|
+
values[`experiment.${id}`] = assignment.armId;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const attemptedKey = canonicalVariantKey(attempted);
|
|
84
|
+
for (const event of rejected)
|
|
85
|
+
request.onRuleRejected?.({ ...event, variantKey: attemptedKey });
|
|
86
|
+
let variant = authored;
|
|
87
|
+
if (assignments.length) {
|
|
88
|
+
const key = canonicalVariantKey(values);
|
|
89
|
+
const selected = selection.tuples[key];
|
|
90
|
+
// Under forcing a forced tuple absent from the projection (publish/mint
|
|
91
|
+
// skew) degrades every forced factor to absent — the plain mimic view.
|
|
92
|
+
// The fail-closed named error applies only to non-mimic traffic (I-17).
|
|
93
|
+
if (!selected && !forcing)
|
|
94
|
+
throw new PersonalizationResolutionError(key);
|
|
95
|
+
if (selected) {
|
|
96
|
+
variant = { ...selected, variantKey: key };
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
assignments.length = 0;
|
|
100
|
+
for (const name of Object.keys(values)) {
|
|
101
|
+
if (name.startsWith('experiment.'))
|
|
102
|
+
delete values[name];
|
|
38
103
|
}
|
|
39
|
-
break;
|
|
40
104
|
}
|
|
41
105
|
}
|
|
42
|
-
return { variant:
|
|
106
|
+
return { variant, experiments: assignments, ...(legacyExperimentExclusions ? { legacyExperimentExclusions } : {}) };
|
|
43
107
|
}
|
|
44
|
-
|
|
45
|
-
|
|
108
|
+
// forceExperimentArm resolves the token's named arm for one selected
|
|
109
|
+
// experiment instead of bucketing (design/09). Null when the token does not
|
|
110
|
+
// name this experiment (unforced experiments never run under mimic — no
|
|
111
|
+
// identity exists) or names an arm the active projection does not carry
|
|
112
|
+
// (skew degrades to the plain mimic view, never an error).
|
|
113
|
+
function forceExperimentArm(experiment, forcedArms) {
|
|
114
|
+
const armId = forcedArms[experiment.id];
|
|
115
|
+
if (armId === undefined)
|
|
46
116
|
return null;
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
117
|
+
const arm = (experiment.arms || []).find((candidate) => candidate.id === armId);
|
|
118
|
+
if (!arm)
|
|
119
|
+
return null;
|
|
120
|
+
return {
|
|
121
|
+
experimentId: experiment.id,
|
|
122
|
+
armId,
|
|
123
|
+
allocationVersion: String(experiment.allocationVersion || ''),
|
|
124
|
+
variantKey: `experiment.${experiment.id}=${armId}`,
|
|
125
|
+
// The dice never rolled; -1 marks the forced origin in the decision
|
|
126
|
+
// record where a bucket would otherwise sit.
|
|
127
|
+
bucket: -1,
|
|
128
|
+
forced: true,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function selectRuleVariant(request, selection, claims) {
|
|
132
|
+
let best = null;
|
|
133
|
+
for (const candidate of selection.authored) {
|
|
134
|
+
const { variant, values, key } = candidate;
|
|
135
|
+
if (candidate.experiments.some((item) => !experimentEligible(item, claims, String(values.audience || ''))))
|
|
57
136
|
continue;
|
|
58
137
|
if (!requestDimensionsMatch(request, values))
|
|
59
138
|
continue;
|
|
60
139
|
const membershipMatch = (values.audience && claims && (claims.aud || []).includes(values.audience))
|
|
61
140
|
|| (values.segment && claims && (claims.seg || []).includes(values.segment));
|
|
62
|
-
const ruleMatch = variant.rule ? evaluateRule(request, variant.rule, claims,
|
|
63
|
-
if (membershipMatch || ruleMatch)
|
|
64
|
-
|
|
141
|
+
const ruleMatch = variant.rule ? evaluateRule(request, variant.rule, claims, key) : false;
|
|
142
|
+
if (!best && (membershipMatch || ruleMatch))
|
|
143
|
+
best = { ...variant, variantKey: key, variantValues: values };
|
|
65
144
|
}
|
|
66
|
-
|
|
67
|
-
|| String(left.variantKey || '').localeCompare(String(right.variantKey || '')));
|
|
68
|
-
return matches[0] || null;
|
|
145
|
+
return best;
|
|
69
146
|
}
|
|
70
147
|
// The eligibility gate (audience-scoped-experiments ASE-F4/F5): an
|
|
71
148
|
// experiment declaring eligibilityAudienceId is skipped entirely — before
|
|
72
149
|
// bucketing or arm candidacy — unless the verified claims carry the ID.
|
|
73
150
|
// Ungated experiments return true, preserving byte-identical behavior.
|
|
74
|
-
|
|
151
|
+
//
|
|
152
|
+
// ADR 0050 (edge-functions I-16): an experiment whose projection carries
|
|
153
|
+
// eligibilityMode "selected" is gated by the selected-leg predicate INSTEAD
|
|
154
|
+
// of the claims gate — it is a factor only when the leg the request selected
|
|
155
|
+
// carries audience=<eligibilityAudienceId>. Claims are never consulted for
|
|
156
|
+
// it: a rule-backed audience cannot enter claims, so the claims gate would
|
|
157
|
+
// hold the experiment dormant, and the selected leg is the fact the
|
|
158
|
+
// planner's sum built this route's tuples for. Out of selection there is no
|
|
159
|
+
// bucket, assignment, exposure, or rule-rejection record.
|
|
160
|
+
function experimentEligible(item, claims, selectedLegAudienceId) {
|
|
161
|
+
if (item.eligibilityMode === 'selected')
|
|
162
|
+
return selectedModeFactorOf(item, selectedLegAudienceId);
|
|
75
163
|
const audienceID = String(item.eligibilityAudienceId || '').trim();
|
|
76
164
|
if (!audienceID)
|
|
77
165
|
return true;
|
|
78
166
|
return !!claims && (claims.aud || []).includes(audienceID);
|
|
79
167
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
168
|
+
// selectedModeFactorOf is the decision core's spelling of the planner's
|
|
169
|
+
// leg-membership predicate (ADR 0050) — one name, one rule, two runtimes:
|
|
170
|
+
// experiment.SelectedModeFactorOf in Go, applied by Assign and by the
|
|
171
|
+
// contentbuild planner's leg factors, candidates, and Lookup. A
|
|
172
|
+
// selected-mode experiment with an unresolved eligibility audience is a
|
|
173
|
+
// factor of no leg, base included. Both inputs are canonical by variant-key
|
|
174
|
+
// construction (the record codec and the strict document decode pre-trim
|
|
175
|
+
// them), so this comparison needs no normalization — the Go twin's TrimSpace
|
|
176
|
+
// is defensive only and never changes the outcome.
|
|
177
|
+
function selectedModeFactorOf(item, legAudienceId) {
|
|
178
|
+
const audienceID = String(item.eligibilityAudienceId || '').trim();
|
|
179
|
+
return audienceID !== '' && audienceID === legAudienceId;
|
|
87
180
|
}
|
|
88
181
|
function assignExperiment(experiment, identity) {
|
|
89
182
|
const version = String(experiment.allocationVersion || '').trim();
|
|
@@ -106,25 +199,20 @@ function assignExperiment(experiment, identity) {
|
|
|
106
199
|
experimentId: experiment.id,
|
|
107
200
|
armId: arm.id,
|
|
108
201
|
allocationVersion: version,
|
|
109
|
-
variantKey: arm.
|
|
202
|
+
variantKey: `experiment.${experiment.id}=${arm.id}`,
|
|
110
203
|
bucket,
|
|
111
204
|
};
|
|
112
205
|
}
|
|
113
206
|
}
|
|
114
207
|
return null;
|
|
115
208
|
}
|
|
116
|
-
function betterVariant(candidatePriority, candidateKey, bestPriority, bestKey) {
|
|
117
|
-
if (candidatePriority !== bestPriority)
|
|
118
|
-
return candidatePriority > bestPriority;
|
|
119
|
-
return String(candidateKey || '').localeCompare(String(bestKey || '')) < 0;
|
|
120
|
-
}
|
|
121
209
|
function requestDimensionsMatch(request, values) {
|
|
122
210
|
for (const rawKey in values) {
|
|
123
211
|
const rawWant = values[rawKey];
|
|
124
212
|
const key = String(rawKey).trim();
|
|
125
213
|
const normalized = key.toLowerCase();
|
|
126
214
|
const want = String(rawWant).trim();
|
|
127
|
-
if (!key || !want || ['audience', 'segment'
|
|
215
|
+
if (!key || !want || ['audience', 'segment'].includes(normalized) || normalized.startsWith('experiment.'))
|
|
128
216
|
continue;
|
|
129
217
|
const candidates = (request.query[key] || []).concat(request.query['aci_' + key] || [], request.headers['x-aci-' + normalized] || '', request.headers['x-gradial-' + normalized] || '', request.cookies[key] || '', request.cookies['aci_' + key] || '');
|
|
130
218
|
if (normalized === 'locale') {
|
|
@@ -783,137 +871,6 @@ function utf8(value) {
|
|
|
783
871
|
}
|
|
784
872
|
return new Uint8Array(out);
|
|
785
873
|
}
|
|
786
|
-
// ── Normalized two-phase model: classify → decide ─────────────────────
|
|
787
|
-
//
|
|
788
|
-
// Phase 1 (classify): evaluate all audience/segment rules page-agnostically
|
|
789
|
-
// and assign experiment arms. Returns a Classification that is used by
|
|
790
|
-
// Phase 2 for cheap set-membership lookups per route.
|
|
791
|
-
//
|
|
792
|
-
// Phase 2 (decide): for a given route record, check which variants match
|
|
793
|
-
// the classification (set membership + priority). Audience/segment rules
|
|
794
|
-
// were evaluated in Phase 1; an experiment arm variant's own rule is
|
|
795
|
-
// evaluated here at commit time, because a bucket assignment is candidacy,
|
|
796
|
-
// not entitlement (ADR 0040).
|
|
797
|
-
//
|
|
798
|
-
// The per-route record stays small: just variant keys (e.g.
|
|
799
|
-
// "audience=trade") + priorities. Rules live in the classification data,
|
|
800
|
-
// evaluated once per request.
|
|
801
|
-
//
|
|
802
|
-
/**
|
|
803
|
-
* Phase 1: Classify the visitor against all release-level rules.
|
|
804
|
-
*
|
|
805
|
-
* @param ctx The request context (cookies, headers, geo, query)
|
|
806
|
-
* @param data Release-level classification data (audiences, segments, experiments)
|
|
807
|
-
* @param identity Visitor identity (for experiment bucketing). Pass undefined
|
|
808
|
-
* if no identity or privacy denied.
|
|
809
|
-
* @param claims The verified claims the caller already holds (eligibility
|
|
810
|
-
* gate input, ASE-F4). Undefined/null means "no claims" —
|
|
811
|
-
* gated experiments are skipped.
|
|
812
|
-
* @returns Classification — matched audiences, segments, and experiment arm assignments
|
|
813
|
-
*/
|
|
814
|
-
export function classify(ctx, data, identity, claims) {
|
|
815
|
-
// Evaluate audience rules. Claims feed the audience.memberships fact
|
|
816
|
-
// (ADR 0039) exactly as they do in the request-scoped resolver path.
|
|
817
|
-
const matchedAudiences = [];
|
|
818
|
-
for (const key in data.audiences) {
|
|
819
|
-
const def = data.audiences[key];
|
|
820
|
-
if (def.rule && evaluateRule(ctx, def.rule, claims, key)) {
|
|
821
|
-
matchedAudiences.push(key);
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
// Evaluate segment rules
|
|
825
|
-
const matchedSegments = [];
|
|
826
|
-
for (const key in data.segments) {
|
|
827
|
-
const def = data.segments[key];
|
|
828
|
-
if (def.rule && evaluateRule(ctx, def.rule, claims, key)) {
|
|
829
|
-
matchedSegments.push(key);
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
// Assign experiment arms (only for identified visitors)
|
|
833
|
-
const experimentArms = {};
|
|
834
|
-
if (identity && identity.usable) {
|
|
835
|
-
for (const expId in data.experiments) {
|
|
836
|
-
const exp = data.experiments[expId];
|
|
837
|
-
if (exp.status !== 'running')
|
|
838
|
-
continue;
|
|
839
|
-
if (exp.kind !== 'ab' && exp.kind !== 'abn')
|
|
840
|
-
continue;
|
|
841
|
-
if (!experimentEligible(exp, claims))
|
|
842
|
-
continue;
|
|
843
|
-
const assignment = assignExperiment(exp, identity);
|
|
844
|
-
if (assignment) {
|
|
845
|
-
experimentArms[expId] = assignment;
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
return { matchedAudiences, matchedSegments, experimentArms };
|
|
850
|
-
}
|
|
851
|
-
/**
|
|
852
|
-
* Phase 2: Decide the winning variant for a specific route.
|
|
853
|
-
*
|
|
854
|
-
* Uses the classification from Phase 1 for cheap set-membership lookups.
|
|
855
|
-
* Audience/segment rules were already evaluated in Phase 1; the one rule
|
|
856
|
-
* evaluated here is an experiment arm variant's own rule, because the arm
|
|
857
|
-
* assignment is candidacy, not entitlement (ADR 0040).
|
|
858
|
-
*
|
|
859
|
-
* @param record The route entry (path, dispatch, variants)
|
|
860
|
-
* @param classification The visitor's classification from Phase 1
|
|
861
|
-
* @param ctx The request context (signal facts + rejection sink)
|
|
862
|
-
* @param claims The verified claims the caller already holds — the
|
|
863
|
-
* same object passed to classify() — so an arm variant's
|
|
864
|
-
* audience.memberships rule resolves identically to the
|
|
865
|
-
* resolver path and fails closed without it (ADR 0039).
|
|
866
|
-
* @returns The decision — winning variant + experiment assignment, or nulls
|
|
867
|
-
*/
|
|
868
|
-
export function decide(record, classification, ctx, claims) {
|
|
869
|
-
if (!record.variants)
|
|
870
|
-
return { variant: null, experiment: null };
|
|
871
|
-
let bestVariant = null;
|
|
872
|
-
let bestExperiment = null;
|
|
873
|
-
for (const key in record.variants) {
|
|
874
|
-
const variant = record.variants[key];
|
|
875
|
-
if (!variant.variantKey)
|
|
876
|
-
variant.variantKey = key;
|
|
877
|
-
const values = variant.variantValues || variantValuesFromKey(key);
|
|
878
|
-
// Check if this variant references a paused experiment — skip if so
|
|
879
|
-
const experimentID = String(values.experiment || '').trim();
|
|
880
|
-
if (experimentID && !classification.experimentArms[experimentID])
|
|
881
|
-
continue;
|
|
882
|
-
let matched = false;
|
|
883
|
-
// Audience membership
|
|
884
|
-
if (values.audience && classification.matchedAudiences.includes(values.audience)) {
|
|
885
|
-
matched = true;
|
|
886
|
-
}
|
|
887
|
-
// Segment membership
|
|
888
|
-
if (!matched && values.segment && classification.matchedSegments.includes(values.segment)) {
|
|
889
|
-
matched = true;
|
|
890
|
-
}
|
|
891
|
-
// Experiment arm assignment. The arm is a candidate only — committing it
|
|
892
|
-
// inside the win block prevents a losing arm variant from leaving a
|
|
893
|
-
// stale experiment decision behind when another variant wins.
|
|
894
|
-
let armMatch = null;
|
|
895
|
-
if (!matched && experimentID) {
|
|
896
|
-
const arm = classification.experimentArms[experimentID];
|
|
897
|
-
if (arm && arm.variantKey === variant.variantKey) {
|
|
898
|
-
// Same ADR 0040 posture as the resolver's experiment leg: the arm
|
|
899
|
-
// assignment is candidacy only. A rule rejected at serve time (or
|
|
900
|
-
// unmatched) fires the onRuleRejected sink inside evaluateRule and
|
|
901
|
-
// skips the variant — never served, no stale experiment decision.
|
|
902
|
-
if (!variant.rule || evaluateRule(ctx, variant.rule, claims, variant.variantKey)) {
|
|
903
|
-
matched = true;
|
|
904
|
-
armMatch = arm;
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
if (matched) {
|
|
909
|
-
if (!bestVariant || betterVariant(variant.priority || 0, variant.variantKey, bestVariant.priority || 0, bestVariant.variantKey)) {
|
|
910
|
-
bestVariant = variant;
|
|
911
|
-
bestExperiment = armMatch;
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
return { variant: bestVariant, experiment: bestExperiment };
|
|
916
|
-
}
|
|
917
874
|
// ── Derived request device class (ADR 0037) ──────────────────────────
|
|
918
875
|
//
|
|
919
876
|
// device.class is the one request fact that is neither transcribed from the
|
|
@@ -993,7 +950,6 @@ function deviceHeader(headers, key) {
|
|
|
993
950
|
// envelope may carry up to 64 conditions and a route many candidate variants,
|
|
994
951
|
// each building its own resolver — without this, one page view could lowercase
|
|
995
952
|
// and scan the user agent hundreds of times. Keyed on the context object, so
|
|
996
|
-
// classify()/decide() reuse it too.
|
|
997
953
|
const DEVICE_CLASS_CACHE = new WeakMap();
|
|
998
954
|
function cachedDeviceClass(request) {
|
|
999
955
|
const hit = DEVICE_CLASS_CACHE.get(request);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PersonalizationExperiment, PersonalizationRoute, PersonalizationVariant } from './types.js';
|
|
2
|
+
export interface AuthoredCandidate {
|
|
3
|
+
readonly key: string;
|
|
4
|
+
readonly variant: PersonalizationVariant;
|
|
5
|
+
readonly values: Record<string, string>;
|
|
6
|
+
readonly experiments: readonly PersonalizationExperiment[];
|
|
7
|
+
}
|
|
8
|
+
export interface PersonalizationSelection {
|
|
9
|
+
readonly sourceExperiments: Record<string, PersonalizationExperiment> | undefined;
|
|
10
|
+
readonly authored: readonly AuthoredCandidate[];
|
|
11
|
+
readonly experiments: readonly PersonalizationExperiment[];
|
|
12
|
+
readonly arms: Readonly<Record<string, Readonly<Record<string, PersonalizationVariant>>>>;
|
|
13
|
+
readonly tuples: Readonly<Record<string, PersonalizationVariant>>;
|
|
14
|
+
readonly legacyExperimentExclusions: number;
|
|
15
|
+
}
|
|
16
|
+
export declare function preparePersonalizationRoute(route: PersonalizationRoute, experiments?: Record<string, PersonalizationExperiment>): PersonalizationRoute;
|
|
17
|
+
export declare function personalizationSelection(route: PersonalizationRoute, experiments?: Record<string, PersonalizationExperiment>): PersonalizationSelection;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { variantValuesFromKey } from './record/codec.js';
|
|
2
|
+
import { compareUTF8 } from './variant_key.js';
|
|
3
|
+
const preparedSelection = Symbol('prepared personalization selection');
|
|
4
|
+
// Call once when an immutable partition is decoded. The derived index is a
|
|
5
|
+
// non-enumerable symbol property, so it cannot alter any serialized record.
|
|
6
|
+
export function preparePersonalizationRoute(route, experiments) {
|
|
7
|
+
const existing = route[preparedSelection];
|
|
8
|
+
if (existing && existing.sourceExperiments === experiments)
|
|
9
|
+
return route;
|
|
10
|
+
return Object.freeze(Object.defineProperty({ ...route }, preparedSelection, { value: deriveSelection(route, experiments) }));
|
|
11
|
+
}
|
|
12
|
+
export function personalizationSelection(route, experiments) {
|
|
13
|
+
return preparePersonalizationRoute(route, experiments)[preparedSelection];
|
|
14
|
+
}
|
|
15
|
+
function deriveSelection(route, experiments) {
|
|
16
|
+
const authored = [];
|
|
17
|
+
const arms = Object.create(null);
|
|
18
|
+
const legacy = new Set();
|
|
19
|
+
const qualified = new Set();
|
|
20
|
+
for (const [key, variant] of Object.entries(route.variants || {})) {
|
|
21
|
+
const values = Object.freeze({ ...(variant.variantValues || variantValuesFromKey(key)) });
|
|
22
|
+
// DEBT(edge-functions): ADR-0045 bounded pair reader and counter. Remove
|
|
23
|
+
// only when every retained release in every tenant compiles under the
|
|
24
|
+
// post-removal compiler; pair entries then become malformed.
|
|
25
|
+
if (isPairVariant(key, values)) {
|
|
26
|
+
const id = values.experiment || key.split(';').find((part) => part.startsWith('experiment='))?.slice('experiment='.length);
|
|
27
|
+
if (id && experiments?.[id])
|
|
28
|
+
legacy.add(id);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const names = Object.keys(values);
|
|
32
|
+
const ids = names.filter((name) => name.startsWith('experiment.')).map((name) => name.slice('experiment.'.length));
|
|
33
|
+
for (const id of ids)
|
|
34
|
+
qualified.add(id);
|
|
35
|
+
if (ids.length === 1 && names.length === 1) {
|
|
36
|
+
const id = ids[0];
|
|
37
|
+
(arms[id] ||= Object.create(null))[values[`experiment.${id}`]] = variant;
|
|
38
|
+
}
|
|
39
|
+
const factors = ids.map((id) => experiments?.[id]);
|
|
40
|
+
if (factors.some((item) => !item || item.kind !== 'xt' || item.status !== 'running'))
|
|
41
|
+
continue;
|
|
42
|
+
authored.push(Object.freeze({ key, variant, values, experiments: Object.freeze(factors) }));
|
|
43
|
+
}
|
|
44
|
+
for (const id of qualified)
|
|
45
|
+
legacy.delete(id);
|
|
46
|
+
authored.sort((left, right) => (right.variant.priority || 0) - (left.variant.priority || 0) || compareUTF8(left.key, right.key));
|
|
47
|
+
const scoped = Object.keys(experiments || {}).sort(compareUTF8).map((id) => experiments[id])
|
|
48
|
+
.filter((item) => (item.kind === 'ab' || item.kind === 'abn') && item.status === 'running' && !legacy.has(item.id));
|
|
49
|
+
for (const entries of Object.values(arms))
|
|
50
|
+
Object.freeze(entries);
|
|
51
|
+
return Object.freeze({ sourceExperiments: experiments, authored: Object.freeze(authored), experiments: Object.freeze(scoped), arms: Object.freeze(arms), tuples: route.variants || {}, legacyExperimentExclusions: legacy.size });
|
|
52
|
+
}
|
|
53
|
+
function isPairVariant(key, values) {
|
|
54
|
+
const names = [...key.split(';').map((part) => part.split('=')[0]), ...Object.keys(values)];
|
|
55
|
+
return names.some((name) => name.toLowerCase() === 'experiment' || name.toLowerCase() === 'experimentarm');
|
|
56
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EdgeRouteEntry
|
|
1
|
+
import type { EdgeRouteEntry } from '../types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Unique sentinel object returned by expand functions on failure.
|
|
4
4
|
* Callers check `result === INVALID`. Never throws.
|
|
@@ -37,35 +37,6 @@ export interface CompactRouteRecord {
|
|
|
37
37
|
}
|
|
38
38
|
export declare function projectRouteRecord(entry: EdgeRouteEntry): CompactRouteRecord;
|
|
39
39
|
export declare function expandRouteRecord(raw: unknown): EdgeRouteEntry | Invalid;
|
|
40
|
-
export interface CompactClassificationData {
|
|
41
|
-
au?: Record<string, {
|
|
42
|
-
r?: CompactRule;
|
|
43
|
-
}>;
|
|
44
|
-
sg?: Record<string, {
|
|
45
|
-
r?: CompactRule;
|
|
46
|
-
}>;
|
|
47
|
-
x?: Record<string, CompactExperiment>;
|
|
48
|
-
}
|
|
49
|
-
interface CompactExperimentArm {
|
|
50
|
-
id: string;
|
|
51
|
-
ab?: number;
|
|
52
|
-
vk?: string;
|
|
53
|
-
ro?: string;
|
|
54
|
-
}
|
|
55
|
-
interface CompactExperiment {
|
|
56
|
-
id: string;
|
|
57
|
-
st?: string;
|
|
58
|
-
k?: string;
|
|
59
|
-
av?: string;
|
|
60
|
-
sl?: string;
|
|
61
|
-
pr?: number;
|
|
62
|
-
ar?: CompactExperimentArm[];
|
|
63
|
-
dn?: string;
|
|
64
|
-
pg?: string;
|
|
65
|
-
gl?: string[];
|
|
66
|
-
}
|
|
67
|
-
export declare function projectClassificationData(data: ClassificationData): CompactClassificationData;
|
|
68
|
-
export declare function expandClassificationData(raw: unknown): ClassificationData | Invalid;
|
|
69
40
|
export declare const MARKER = "{\"s\":1,\"e\":1}";
|
|
70
41
|
export declare function isMarker(raw: string): boolean;
|
|
71
42
|
export declare function canonicalSerialize(value: unknown): string;
|