@delmaredigital/payload-experiments 0.2.0-rc.7 → 0.3.0-rc.1
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 +129 -11
- package/dist/admin/PreviewLinks.d.ts +2 -1
- package/dist/admin/PreviewLinks.js +24 -16
- package/dist/admin/actions/ConfirmDialog.d.ts +7 -4
- package/dist/admin/actions/ConfirmDialog.js +37 -82
- package/dist/admin/actions/DecideDialog.d.ts +3 -1
- package/dist/admin/actions/DecideDialog.js +30 -13
- package/dist/admin/actions/MakeContentResult.d.ts +13 -2
- package/dist/admin/actions/MakeContentResult.js +115 -16
- package/dist/admin/actions/actions.model.d.ts +2 -4
- package/dist/admin/actions/actions.model.js +2 -3
- package/dist/admin/allocation.model.js +3 -5
- package/dist/admin/api.d.ts +10 -4
- package/dist/admin/api.js +11 -7
- package/dist/admin/copy/actions.d.ts +39 -3
- package/dist/admin/copy/actions.js +40 -4
- package/dist/admin/copy/armSelect.js +5 -5
- package/dist/admin/copy/audience.d.ts +2 -1
- package/dist/admin/copy/audience.js +3 -2
- package/dist/admin/copy/common.d.ts +13 -6
- package/dist/admin/copy/common.js +14 -8
- package/dist/admin/copy/goals.d.ts +21 -6
- package/dist/admin/copy/goals.js +27 -6
- package/dist/admin/copy/readiness.d.ts +19 -0
- package/dist/admin/copy/readiness.js +19 -0
- package/dist/admin/copy/recommendation.d.ts +0 -3
- package/dist/admin/copy/recommendation.js +0 -3
- package/dist/admin/copy/results.d.ts +41 -2
- package/dist/admin/copy/results.js +42 -3
- package/dist/admin/copy/review.d.ts +1 -1
- package/dist/admin/copy/review.js +1 -1
- package/dist/admin/copy/setting.d.ts +2 -0
- package/dist/admin/copy/setting.js +2 -0
- package/dist/admin/copy/surface.d.ts +7 -4
- package/dist/admin/copy/surface.js +9 -4
- package/dist/admin/copy/testPage.d.ts +10 -0
- package/dist/admin/copy/testPage.js +10 -0
- package/dist/admin/format/date.d.ts +2 -0
- package/dist/admin/format/date.js +10 -0
- package/dist/admin/previewLinks.model.d.ts +2 -2
- package/dist/admin/previewLinks.model.js +5 -7
- package/dist/admin/results/CiChart.js +11 -21
- package/dist/admin/results/DataQualityPanel.d.ts +4 -2
- package/dist/admin/results/DataQualityPanel.js +30 -17
- package/dist/admin/results/DataTable.d.ts +16 -0
- package/dist/admin/results/DataTable.js +152 -0
- package/dist/admin/results/StatTile.d.ts +7 -0
- package/dist/admin/results/StatTile.js +22 -0
- package/dist/admin/results/VerdictHero.d.ts +13 -0
- package/dist/admin/results/{VerdictCard.js → VerdictHero.js} +15 -12
- package/dist/admin/results/releasePages.model.d.ts +17 -0
- package/dist/admin/results/releasePages.model.js +10 -0
- package/dist/admin/results/results.css +64 -0
- package/dist/admin/results/results.model.d.ts +5 -1
- package/dist/admin/results/results.model.js +10 -2
- package/dist/admin/surface/ResultsRoute.d.ts +1 -1
- package/dist/admin/surface/ResultsRoute.js +59 -34
- package/dist/admin/surface/SurfaceShell.d.ts +11 -2
- package/dist/admin/surface/SurfaceShell.js +49 -14
- package/dist/admin/surface/TestsHome.d.ts +6 -1
- package/dist/admin/surface/TestsHome.js +183 -112
- package/dist/admin/surface/TestsSurface.js +56 -16
- package/dist/admin/surface/goals/GoalForm.js +341 -308
- package/dist/admin/surface/goals/GoalsList.js +123 -89
- package/dist/admin/surface/goals/data.d.ts +8 -2
- package/dist/admin/surface/goals/data.js +11 -3
- package/dist/admin/surface/goals/goals.css +10 -13
- package/dist/admin/surface/home.model.d.ts +7 -8
- package/dist/admin/surface/home.model.js +23 -12
- package/dist/admin/surface/newTest/NewTest.js +5 -5
- package/dist/admin/surface/pageBlock/parts.js +31 -26
- package/dist/admin/surface/readiness/ActionDialogs.js +43 -14
- package/dist/admin/surface/readiness/Banner.d.ts +3 -0
- package/dist/admin/surface/readiness/Banner.js +58 -33
- package/dist/admin/surface/readiness/SaveStatus.d.ts +1 -0
- package/dist/admin/surface/readiness/SaveStatus.js +6 -3
- package/dist/admin/surface/readiness/readiness.model.d.ts +2 -0
- package/dist/admin/surface/readiness/readiness.model.js +18 -9
- package/dist/admin/surface/readiness/useTransition.d.ts +1 -0
- package/dist/admin/surface/readiness/useTransition.js +2 -1
- package/dist/admin/surface/results/ReleasePages.d.ts +14 -0
- package/dist/admin/surface/results/ReleasePages.js +116 -0
- package/dist/admin/surface/results/ResultsBody.d.ts +11 -1
- package/dist/admin/surface/results/ResultsBody.js +276 -105
- package/dist/admin/surface/results/ResultsHeader.d.ts +10 -0
- package/dist/admin/surface/results/ResultsHeader.js +177 -0
- package/dist/admin/surface/results/recommendation.model.js +2 -6
- package/dist/admin/surface/results/useResults.d.ts +21 -3
- package/dist/admin/surface/results/useResults.js +2 -1
- package/dist/admin/surface/setting/parts.js +75 -88
- package/dist/admin/surface/setting/setting.css +3 -15
- package/dist/admin/surface/surface.css +62 -47
- package/dist/admin/surface/test/AdvancedSection.js +55 -48
- package/dist/admin/surface/test/GoalSection.js +39 -32
- package/dist/admin/surface/test/LengthSection.js +46 -34
- package/dist/admin/surface/test/Rail.d.ts +4 -0
- package/dist/admin/surface/test/Rail.js +83 -0
- package/dist/admin/surface/test/ReadinessBar.d.ts +5 -0
- package/dist/admin/surface/test/ReadinessBar.js +33 -0
- package/dist/admin/surface/test/Section.d.ts +1 -1
- package/dist/admin/surface/test/Section.js +36 -16
- package/dist/admin/surface/test/TargetingSection.js +168 -142
- package/dist/admin/surface/test/TestPage.d.ts +2 -1
- package/dist/admin/surface/test/TestPage.js +146 -68
- package/dist/admin/surface/test/VersionsSection.js +151 -120
- package/dist/admin/surface/test/fields.d.ts +11 -4
- package/dist/admin/surface/test/fields.js +84 -85
- package/dist/admin/surface/test/jumpToSection.d.ts +3 -0
- package/dist/admin/surface/test/jumpToSection.js +10 -0
- package/dist/admin/surface/test/pageParts.js +9 -3
- package/dist/admin/surface/test/testPage.css +81 -14
- package/dist/admin/surface/useTestDocument.js +10 -4
- package/dist/admin/targeting/targeting.model.d.ts +1 -3
- package/dist/admin/targeting/targeting.model.js +3 -8
- package/dist/admin/theme.css +115 -36
- package/dist/admin/theme.d.ts +38 -0
- package/dist/admin/theme.js +38 -1
- package/dist/admin/useApi.d.ts +17 -2
- package/dist/admin/views/ExperimentsRootView.js +12 -2
- package/dist/collections/experiments/config.js +25 -0
- package/dist/collections/experiments/lockHook.js +3 -1
- package/dist/collections/identities.js +6 -1
- package/dist/collections/metrics.js +7 -0
- package/dist/collections/metricsGuards.d.ts +9 -0
- package/dist/collections/metricsGuards.js +33 -0
- package/dist/contentDone.d.ts +4 -0
- package/dist/contentDone.js +5 -0
- package/dist/db/identities.d.ts +8 -7
- package/dist/db/identities.js +9 -6
- package/dist/db/metricUsage.d.ts +19 -0
- package/dist/db/metricUsage.js +54 -0
- package/dist/db/names.d.ts +5 -0
- package/dist/db/names.js +5 -0
- package/dist/db/publishedContent.d.ts +10 -0
- package/dist/db/publishedContent.js +43 -0
- package/dist/db/results.d.ts +2 -0
- package/dist/db/results.js +6 -2
- package/dist/db/snapshots.d.ts +18 -6
- package/dist/db/snapshots.js +23 -7
- package/dist/db/sweep.js +11 -6
- package/dist/endpoints/index.js +19 -1
- package/dist/endpoints/results.js +5 -0
- package/dist/endpoints/sanitizeReadinessDetail.d.ts +1 -0
- package/dist/endpoints/sanitizeReadinessDetail.js +2 -0
- package/dist/endpoints/summary.js +20 -2
- package/dist/endpoints/transitions.d.ts +15 -0
- package/dist/endpoints/transitions.js +36 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +28 -0
- package/dist/exports/index.d.ts +2 -1
- package/dist/exports/index.js +1 -0
- package/dist/exports/puckEditor.d.ts +1 -0
- package/dist/exports/puckEditor.js +1 -0
- package/dist/exports/rsc.d.ts +5 -1
- package/dist/exports/rsc.js +2 -0
- package/dist/hooks/pageGuard.d.ts +5 -0
- package/dist/hooks/pageGuard.js +121 -0
- package/dist/log.js +6 -3
- package/dist/manifest/compile.d.ts +5 -0
- package/dist/manifest/compile.js +5 -1
- package/dist/options.js +23 -0
- package/dist/plugin.js +26 -2
- package/dist/puck/copy.d.ts +1 -1
- package/dist/puck/copy.js +1 -1
- package/dist/puck/editor/editor.chrome.css +235 -0
- package/dist/puck/editor/editor.css +241 -43
- package/dist/results/aggregate.js +4 -3
- package/dist/results/analyse.d.ts +4 -1
- package/dist/results/analyse.js +27 -11
- package/dist/results/cuped.js +3 -2
- package/dist/results/identityCredit.d.ts +15 -7
- package/dist/results/identityCredit.js +32 -33
- package/dist/results/rolloutUnits.js +2 -1
- package/dist/results/snapshot.js +8 -2
- package/dist/results/srm.js +2 -1
- package/dist/sanitizeReadinessDetail.d.ts +7 -0
- package/dist/sanitizeReadinessDetail.js +54 -0
- package/dist/server/linkIdentity.d.ts +10 -1
- package/dist/server/linkIdentity.js +17 -15
- package/dist/server/metadata.d.ts +5 -0
- package/dist/server/metadata.js +9 -1
- package/dist/server/runResults.d.ts +3 -2
- package/dist/server/runResults.js +121 -31
- package/dist/targeting/values.d.ts +4 -0
- package/dist/targeting/values.js +10 -0
- package/dist/transitions/addMetric.js +8 -0
- package/dist/transitions/archive.js +2 -3
- package/dist/transitions/checkStart.d.ts +6 -3
- package/dist/transitions/checkStart.js +15 -0
- package/dist/transitions/contentFields.d.ts +7 -0
- package/dist/transitions/contentFields.js +20 -0
- package/dist/transitions/decide.js +6 -1
- package/dist/transitions/dismissContentEntry.d.ts +8 -0
- package/dist/transitions/dismissContentEntry.js +45 -0
- package/dist/transitions/makeContent.d.ts +5 -2
- package/dist/transitions/makeContent.js +217 -118
- package/dist/transitions/phaseRecord.d.ts +8 -2
- package/dist/transitions/placements.d.ts +1 -0
- package/dist/transitions/placements.js +4 -0
- package/dist/transitions/preflightData.d.ts +1 -1
- package/dist/transitions/preflightData.js +42 -4
- package/dist/transitions/releasePages.d.ts +8 -0
- package/dist/transitions/releasePages.js +26 -0
- package/dist/transitions/restoreContentEntry.d.ts +8 -0
- package/dist/transitions/restoreContentEntry.js +47 -0
- package/dist/transitions/start.js +30 -5
- package/dist/types/contracts.d.ts +14 -0
- package/dist/types/endpoints.d.ts +34 -3
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/reasons.d.ts +3 -0
- package/dist/types/reasons.js +1 -0
- package/dist/types/snapshot.d.ts +6 -1
- package/dist/ui/Banner.d.ts +2 -0
- package/dist/ui/Banner.js +2 -0
- package/dist/ui/Breadcrumb.d.ts +7 -0
- package/dist/ui/Breadcrumb.js +25 -0
- package/dist/ui/Button.d.ts +15 -0
- package/dist/ui/Button.js +32 -0
- package/dist/ui/Card.d.ts +12 -0
- package/dist/ui/Card.js +40 -0
- package/dist/ui/Checkbox.d.ts +2 -0
- package/dist/ui/Checkbox.js +2 -0
- package/dist/{admin/components → ui}/ChipsInput.d.ts +4 -3
- package/dist/{admin/components → ui}/ChipsInput.js +20 -12
- package/dist/ui/Dialog.d.ts +13 -0
- package/dist/ui/Dialog.js +85 -0
- package/dist/ui/EmptyState.d.ts +2 -0
- package/dist/ui/EmptyState.js +2 -0
- package/dist/ui/Feedback.d.ts +24 -0
- package/dist/ui/Feedback.js +40 -0
- package/dist/ui/Inputs.d.ts +34 -0
- package/dist/ui/Inputs.js +225 -0
- package/dist/ui/NumberInput.d.ts +2 -0
- package/dist/ui/NumberInput.js +2 -0
- package/dist/ui/Range.d.ts +2 -0
- package/dist/ui/Range.js +2 -0
- package/dist/ui/SearchInput.d.ts +2 -0
- package/dist/ui/SearchInput.js +2 -0
- package/dist/ui/SectionCard.d.ts +2 -0
- package/dist/ui/SectionCard.js +2 -0
- package/dist/ui/Select.d.ts +2 -0
- package/dist/ui/Select.js +2 -0
- package/dist/ui/StatusChip.d.ts +2 -0
- package/dist/ui/StatusChip.js +2 -0
- package/dist/ui/Tabs.d.ts +14 -0
- package/dist/ui/Tabs.js +54 -0
- package/dist/ui/TextInput.d.ts +2 -0
- package/dist/ui/TextInput.js +2 -0
- package/dist/ui/Textarea.d.ts +2 -0
- package/dist/ui/Textarea.js +2 -0
- package/dist/ui/Toggle.d.ts +2 -0
- package/dist/ui/Toggle.js +2 -0
- package/dist/ui/accent.d.ts +21 -0
- package/dist/ui/accent.js +81 -0
- package/dist/ui/index.d.ts +9 -0
- package/dist/ui/index.js +9 -0
- package/dist/ui/ui.css +92 -0
- package/package.json +1 -1
- package/dist/admin/components/chipsInput.css +0 -11
- package/dist/admin/results/ArmTable.d.ts +0 -8
- package/dist/admin/results/ArmTable.js +0 -113
- package/dist/admin/results/VerdictCard.d.ts +0 -9
- package/dist/admin/surface/goals/GoalsBreadcrumb.d.ts +0 -5
- package/dist/admin/surface/goals/GoalsBreadcrumb.js +0 -47
- package/dist/admin/surface/test/TestHeader.d.ts +0 -4
- package/dist/admin/surface/test/TestHeader.js +0 -70
package/dist/results/analyse.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { XpAnalysisError } from '../errors.js';
|
|
1
2
|
import { DEFAULT_GAUSSIAN_PRIOR, bonferroniAlpha, capValues, createThetaAdjustedStatistics, effectBayesianABTest, sequentialOneSidedTreatmentGreaterTTest, sequentialOneSidedTreatmentLesserTTest, sequentialTwoSidedTTest, statisticMean, statisticVariance } from '@delmaredigital/experiments-stats';
|
|
2
3
|
import { DEFAULT_SEQUENTIAL_TUNING_PARAMETER } from '../constants.js';
|
|
3
4
|
import { timestampMs } from './aggregate.js';
|
|
@@ -18,8 +19,8 @@ const severity = {
|
|
|
18
19
|
*/ export function analysePhase(input) {
|
|
19
20
|
const { phase, units } = input;
|
|
20
21
|
const { analysis } = phase;
|
|
21
|
-
//
|
|
22
|
-
//
|
|
22
|
+
// Compatibility for phases created before Start froze the effective tuning
|
|
23
|
+
// value. Record that effective value in every snapshot, including n = 0.
|
|
23
24
|
const sequentialTuningParameter = analysis.sequentialTuningParameter ?? DEFAULT_SEQUENTIAL_TUNING_PARAMETER;
|
|
24
25
|
const alpha = analysis.alpha ?? 0.05;
|
|
25
26
|
const prior = analysis.prior ?? DEFAULT_GAUSSIAN_PRIOR;
|
|
@@ -30,7 +31,7 @@ const severity = {
|
|
|
30
31
|
const rollout = phase.phaseKind === 'rollout';
|
|
31
32
|
const baselineArm = phase.arms.find((arm)=>arm.kind === (rollout ? 'holdout' : 'control'));
|
|
32
33
|
const winner = rollout ? phase.arms.find((arm)=>arm.key === input.winnerArm && arm.kind !== 'holdout') : undefined;
|
|
33
|
-
if (!baselineArm || rollout && (!winner || phase.arms.length !== 2)) throw new
|
|
34
|
+
if (!baselineArm || rollout && (!winner || phase.arms.length !== 2)) throw new XpAnalysisError('no-baseline');
|
|
34
35
|
const compared = phase.arms.filter((arm)=>rollout ? arm.key === winner.key : arm.kind === 'variant');
|
|
35
36
|
const k = compared.length;
|
|
36
37
|
const alphaAdjusted = analysis.method === 'sequential' ? bonferroniAlpha(alpha, k) : alpha;
|
|
@@ -42,22 +43,22 @@ const severity = {
|
|
|
42
43
|
}));
|
|
43
44
|
const primaryDefinition = definitions.find((metric)=>metric.role === 'primary');
|
|
44
45
|
if (!primaryDefinition || phase.metricDefinitions.filter((metric)=>metric.role === 'primary').length !== 1) {
|
|
45
|
-
throw new
|
|
46
|
+
throw new XpAnalysisError('primary-metric-count');
|
|
46
47
|
}
|
|
47
48
|
if (new Set(phase.metricDefinitions.map((metric)=>String(metric.metricId))).size !== phase.metricDefinitions.length || new Set(input.metricData.map((metric)=>String(metric.metricId))).size !== input.metricData.length) {
|
|
48
|
-
throw new
|
|
49
|
+
throw new XpAnalysisError('duplicate-metric');
|
|
49
50
|
}
|
|
50
51
|
const from = timestampMs(phase.startedAt);
|
|
51
52
|
const now = timestampMs(input.now);
|
|
52
53
|
const to = Math.min(now, phase.endedAt == null ? now : timestampMs(phase.endedAt));
|
|
53
|
-
if (to < from) throw new
|
|
54
|
+
if (to < from) throw new XpAnalysisError('window-before-start');
|
|
54
55
|
const byArm = new Map(phase.arms.map((arm)=>[
|
|
55
56
|
arm.key,
|
|
56
57
|
[]
|
|
57
58
|
]));
|
|
58
59
|
for (const [id, unit] of units){
|
|
59
60
|
const ids = byArm.get(unit.arm);
|
|
60
|
-
if (!ids) throw new
|
|
61
|
+
if (!ids) throw new XpAnalysisError('unknown-arm');
|
|
61
62
|
ids.push(id);
|
|
62
63
|
}
|
|
63
64
|
const perArm = Object.fromEntries([
|
|
@@ -120,17 +121,19 @@ const severity = {
|
|
|
120
121
|
let worstArmLabel = compared[0]?.label ?? '';
|
|
121
122
|
let worstArm = compared[0]?.key ?? '';
|
|
122
123
|
try {
|
|
123
|
-
if (!data || data.unavailable !== undefined)
|
|
124
|
+
if (!data || data.unavailable !== undefined) {
|
|
125
|
+
throw new XpAnalysisError('metric-not-supplied');
|
|
126
|
+
}
|
|
124
127
|
const stats = new Map();
|
|
125
128
|
const cap = kind === 'revenue' ? definition.resolvedCap ?? null : null;
|
|
126
129
|
const useCovariates = analysis.cuped === 'auto' && data.covariates !== undefined && data.covariates.size > 0;
|
|
127
130
|
for (const arm of phase.arms){
|
|
128
131
|
const ids = byArm.get(arm.key);
|
|
129
132
|
const values = ids.map((id)=>data.values.get(id) ?? 0);
|
|
130
|
-
if (!values.every(Number.isFinite)) throw new
|
|
133
|
+
if (!values.every(Number.isFinite)) throw new XpAnalysisError('non-finite-values');
|
|
131
134
|
const capped = capValues(values, cap);
|
|
132
135
|
const rawPre = useCovariates ? ids.map((id)=>data.covariates.get(id) ?? 0) : undefined;
|
|
133
|
-
if (rawPre && !rawPre.every(Number.isFinite)) throw new
|
|
136
|
+
if (rawPre && !rawPre.every(Number.isFinite)) throw new XpAnalysisError('non-finite-covariates');
|
|
134
137
|
const pre = rawPre && capValues(rawPre, cap).values;
|
|
135
138
|
const built = outcomeStatistic(kind, capped.values, pre);
|
|
136
139
|
stats.set(arm.key, built.statistic);
|
|
@@ -257,7 +260,20 @@ const severity = {
|
|
|
257
260
|
}
|
|
258
261
|
}
|
|
259
262
|
} catch (error) {
|
|
260
|
-
|
|
263
|
+
input.onMetricError?.(error, metricId);
|
|
264
|
+
let reason = 'analysis-failed';
|
|
265
|
+
if (error instanceof XpAnalysisError) {
|
|
266
|
+
switch(error.code){
|
|
267
|
+
case 'metric-not-supplied':
|
|
268
|
+
reason = data?.unavailable ?? 'not-supplied';
|
|
269
|
+
break;
|
|
270
|
+
case 'non-finite-values':
|
|
271
|
+
case 'non-finite-covariates':
|
|
272
|
+
reason = error.code;
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
metric.unavailable = reason;
|
|
261
277
|
metric.perArm = [];
|
|
262
278
|
metric.comparisons = [];
|
|
263
279
|
metric.cupedApplied = false;
|
package/dist/results/cuped.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
+
import { XpAnalysisError } from '../errors.js';
|
|
1
2
|
import { buildRegressionAdjustedStatistic, buildStatistic, summarizeUnits, summarizeUnitsWithCovariate } from '@delmaredigital/experiments-stats';
|
|
2
3
|
/** Build candidate CUPED statistics; the stats engine pools each pair, infers theta,
|
|
3
4
|
* and falls back to the post statistic when pre-period variance/covariance is zero.
|
|
4
5
|
* Missing units in a supplied pre-period map are zero-filled by the caller.
|
|
5
6
|
*/ export function outcomeStatistic(kind, values, covariates) {
|
|
6
7
|
if (!values.every(Number.isFinite) || covariates && !covariates.every(Number.isFinite)) {
|
|
7
|
-
throw new
|
|
8
|
+
throw new XpAnalysisError('cuped-non-finite');
|
|
8
9
|
}
|
|
9
10
|
if (kind === 'binomial' && (values.some((value)=>value !== 0 && value !== 1) || covariates?.some((value)=>value !== 0 && value !== 1))) {
|
|
10
|
-
throw new
|
|
11
|
+
throw new XpAnalysisError('cuped-binomial');
|
|
11
12
|
}
|
|
12
13
|
const moments = summarizeUnits(values);
|
|
13
14
|
if (covariates === undefined) return {
|
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
import type { ExposedUnit, MetricEvent, Timestamp } from './aggregate.js';
|
|
2
|
-
|
|
3
|
-
* Spec §8.3: credit once to the earliest exposed linked visitor; conflicts are diagnostic only.
|
|
4
|
-
* Callers must pass user ids through String() for both identities and user-keyed source events.
|
|
5
|
-
*/
|
|
6
|
-
export declare function creditUserEvents(units: ReadonlyMap<string, ExposedUnit>, identities: readonly {
|
|
2
|
+
export interface IdentityLink {
|
|
7
3
|
anonymousId: string;
|
|
8
4
|
userId: string;
|
|
9
|
-
|
|
5
|
+
verifiedAt: Timestamp | null;
|
|
6
|
+
/** Earliest verification across all the user's browsers, exposed or not. */
|
|
7
|
+
ownedAt: Timestamp | null;
|
|
8
|
+
}
|
|
9
|
+
/** Unverified links credit only events before anyone proves account ownership.
|
|
10
|
+
* Never-verified accounts keep crediting without a time limit.
|
|
11
|
+
*/
|
|
12
|
+
export declare function eligibleIdentityLinks<T extends IdentityLink>(links: readonly T[], ts: Timestamp): T[];
|
|
13
|
+
/** Credit each event to the earliest exposed eligible visitor, with a stable ID
|
|
14
|
+
* tie-break. A user counts once if eligible links span arms for a credited event.
|
|
15
|
+
* Callers must normalize user IDs with String().
|
|
16
|
+
*/
|
|
17
|
+
export declare function creditUserEvents(units: ReadonlyMap<string, ExposedUnit>, identities: readonly IdentityLink[], userEvents: readonly {
|
|
10
18
|
userId: string;
|
|
11
19
|
value: number;
|
|
12
20
|
ts: Timestamp;
|
|
13
|
-
}[]): {
|
|
21
|
+
}[], onConflict?: (userId: string) => void): {
|
|
14
22
|
events: MetricEvent[];
|
|
15
23
|
conflicts: number;
|
|
16
24
|
};
|
|
@@ -1,46 +1,45 @@
|
|
|
1
1
|
import { timestampMs } from './aggregate.js';
|
|
2
|
-
/**
|
|
3
|
-
*
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
/** Unverified links credit only events before anyone proves account ownership.
|
|
3
|
+
* Never-verified accounts keep crediting without a time limit.
|
|
4
|
+
*/ export function eligibleIdentityLinks(links, ts) {
|
|
5
|
+
const t = timestampMs(ts);
|
|
6
|
+
return links.filter((link)=>link.verifiedAt !== null || link.ownedAt === null || t < timestampMs(link.ownedAt));
|
|
7
|
+
}
|
|
8
|
+
/** Credit each event to the earliest exposed eligible visitor, with a stable ID
|
|
9
|
+
* tie-break. A user counts once if eligible links span arms for a credited event.
|
|
10
|
+
* Callers must normalize user IDs with String().
|
|
11
|
+
*/ export function creditUserEvents(units, identities, userEvents, onConflict) {
|
|
12
|
+
const starts = new Map([
|
|
13
|
+
...units
|
|
14
|
+
].map(([id, unit])=>[
|
|
15
|
+
id,
|
|
16
|
+
timestampMs(unit.firstTs)
|
|
17
|
+
]));
|
|
6
18
|
const users = new Map();
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const firstTs = starts.get(anonymousId);
|
|
13
|
-
const user = users.get(userId);
|
|
14
|
-
if (!user) users.set(userId, {
|
|
15
|
-
unitId: anonymousId,
|
|
16
|
-
firstTs,
|
|
17
|
-
arms: new Set([
|
|
18
|
-
unit.arm
|
|
19
|
-
])
|
|
20
|
-
});
|
|
21
|
-
else {
|
|
22
|
-
user.arms.add(unit.arm);
|
|
23
|
-
// Stable tie-break for simultaneous exposures, independent of identity row order.
|
|
24
|
-
if (firstTs < user.firstTs || firstTs === user.firstTs && anonymousId < user.unitId) {
|
|
25
|
-
user.unitId = anonymousId;
|
|
26
|
-
user.firstTs = firstTs;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
19
|
+
for (const link of identities){
|
|
20
|
+
if (!units.has(link.anonymousId)) continue;
|
|
21
|
+
const links = users.get(link.userId) ?? [];
|
|
22
|
+
links.push(link);
|
|
23
|
+
users.set(link.userId, links);
|
|
29
24
|
}
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
for (const links of users.values())links.sort((a, b)=>starts.get(a.anonymousId) - starts.get(b.anonymousId) || (a.anonymousId < b.anonymousId ? -1 : a.anonymousId > b.anonymousId ? 1 : 0));
|
|
26
|
+
const conflicts = new Set();
|
|
32
27
|
const events = [];
|
|
33
28
|
for (const event of userEvents){
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
unitId:
|
|
29
|
+
const eligible = eligibleIdentityLinks(users.get(event.userId) ?? [], event.ts);
|
|
30
|
+
if (!eligible.length) continue;
|
|
31
|
+
events.push({
|
|
32
|
+
unitId: eligible[0].anonymousId,
|
|
38
33
|
value: event.value,
|
|
39
34
|
ts: event.ts
|
|
40
35
|
});
|
|
36
|
+
if (new Set(eligible.map((link)=>units.get(link.anonymousId).arm)).size > 1) {
|
|
37
|
+
conflicts.add(event.userId);
|
|
38
|
+
onConflict?.(event.userId);
|
|
39
|
+
}
|
|
41
40
|
}
|
|
42
41
|
return {
|
|
43
42
|
events,
|
|
44
|
-
conflicts
|
|
43
|
+
conflicts: conflicts.size
|
|
45
44
|
};
|
|
46
45
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { XpAnalysisError } from '../errors.js';
|
|
1
2
|
import { hash, layerSlice, rolloutBucketRanges } from '@delmaredigital/experiments-stats';
|
|
2
3
|
import { timestampMs } from './aggregate.js';
|
|
3
4
|
/** Spec §7.6/§8.1.5 (X1): first-ever rollout exposures inside assignment's exact ranges. */ export function rolloutAnalysisUnits({ exposuresAllPhases, rolloutPhase, seed, testWeights, coverage, layer }) {
|
|
4
|
-
if (!seed.endsWith('|')) throw new
|
|
5
|
+
if (!seed.endsWith('|')) throw new XpAnalysisError('seed-format');
|
|
5
6
|
const first = new Map();
|
|
6
7
|
for (const row of exposuresAllPhases){
|
|
7
8
|
const ts = timestampMs(row.ts);
|
package/dist/results/snapshot.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { XpAnalysisError } from '../errors.js';
|
|
1
2
|
/** JSON cannot represent unbounded confidence limits. */ export function jsonInterval(ci) {
|
|
2
3
|
return ci.map((bound)=>bound !== null && Number.isFinite(bound) ? bound : null);
|
|
3
4
|
}
|
|
@@ -138,14 +139,19 @@ const shape = object({
|
|
|
138
139
|
}),
|
|
139
140
|
by: nullable(id)
|
|
140
141
|
}))
|
|
142
|
+
}, {
|
|
143
|
+
health: list(object({
|
|
144
|
+
code: oneOf('version-page-unavailable', 'version-page-unchecked'),
|
|
145
|
+
path: string
|
|
146
|
+
}))
|
|
141
147
|
});
|
|
142
148
|
/** Validate the frozen JSON shape and headline/rollout invariants before storage.
|
|
143
149
|
* This also accepts the shared CORE-13/admin fixtures without any I/O.
|
|
144
150
|
*/ export function validateSnapshot(value) {
|
|
145
|
-
if (!shape(value)) throw new
|
|
151
|
+
if (!shape(value)) throw new XpAnalysisError('snapshot-shape');
|
|
146
152
|
const snapshot = value;
|
|
147
153
|
if (snapshot.phaseKind === 'test' && snapshot.winnerArm !== null || snapshot.phaseKind === 'rollout' && (snapshot.winnerArm === null || !(snapshot.winnerArm in snapshot.exposures.perArm)) || snapshot.verdict.headline !== snapshot.verdict.finding + (snapshot.verdict.action ? ` ${snapshot.verdict.action}` : '') || new Date(snapshot.window.to).getTime() < new Date(snapshot.window.from).getTime()) {
|
|
148
|
-
throw new
|
|
154
|
+
throw new XpAnalysisError('snapshot-invariants');
|
|
149
155
|
}
|
|
150
156
|
}
|
|
151
157
|
/** Assemble the already-computed snapshot at the pure/I/O boundary. */ export function assembleSnapshot(snapshot) {
|
package/dist/results/srm.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { XpAnalysisError } from '../errors.js';
|
|
1
2
|
import { checkSrm } from '@delmaredigital/experiments-stats';
|
|
2
3
|
/** SRM uses exposed units and renormalised frozen weights, never allocation or
|
|
3
4
|
* assignment counts. Rollout callers pass only shipped/holdout weights (100-h,h).
|
|
@@ -5,7 +6,7 @@ import { checkSrm } from '@delmaredigital/experiments-stats';
|
|
|
5
6
|
*/ export function exposureSrm(arms, perArm) {
|
|
6
7
|
const active = arms.filter((arm)=>arm.weight > 0);
|
|
7
8
|
const weight = active.reduce((sum, arm)=>sum + arm.weight, 0);
|
|
8
|
-
if (weight <= 0) throw new
|
|
9
|
+
if (weight <= 0) throw new XpAnalysisError('srm-weights');
|
|
9
10
|
const expected = Object.fromEntries(arms.map((arm)=>[
|
|
10
11
|
arm.key,
|
|
11
12
|
arm.weight / weight
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ReadinessCode } from './types/endpoints.js';
|
|
2
|
+
/** Summary details contain identifiers only. Project known shapes rather than
|
|
3
|
+
* recursively copying host objects: titles, messages and placement detail are text.
|
|
4
|
+
* Other checks use authored client fallbacks and need no detail (including slot
|
|
5
|
+
* counts and layer capacity, which are not identifiers).
|
|
6
|
+
*/
|
|
7
|
+
export declare function sanitizeReadinessDetail(code: ReadinessCode, detail: unknown): unknown;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const object = (value)=>value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
2
|
+
// The runtime's slug/key alphabet, bounded even for host-provided parameter keys.
|
|
3
|
+
const key = (value)=>typeof value === 'string' && value.length <= 128 && /^[A-Za-z0-9_-]+$/.test(value);
|
|
4
|
+
const arm = (value)=>typeof value === 'string' && [
|
|
5
|
+
'control',
|
|
6
|
+
'variantA',
|
|
7
|
+
'variantB',
|
|
8
|
+
'variantC',
|
|
9
|
+
'holdout'
|
|
10
|
+
].includes(value);
|
|
11
|
+
const failure = (value)=>value === 'placements-failed' || value === 'forbidden' || value === 'not-found';
|
|
12
|
+
/** Summary details contain identifiers only. Project known shapes rather than
|
|
13
|
+
* recursively copying host objects: titles, messages and placement detail are text.
|
|
14
|
+
* Other checks use authored client fallbacks and need no detail (including slot
|
|
15
|
+
* counts and layer capacity, which are not identifiers).
|
|
16
|
+
*/ export function sanitizeReadinessDetail(code, detail) {
|
|
17
|
+
switch(code){
|
|
18
|
+
case 'block-path-rule':
|
|
19
|
+
return object(detail) && Array.isArray(detail.rules) && detail.rules.length && detail.rules.every((index)=>Number.isSafeInteger(index) && index >= 0) ? {
|
|
20
|
+
rules: detail.rules
|
|
21
|
+
} : undefined;
|
|
22
|
+
case 'no-placements':
|
|
23
|
+
return failure(detail) ? detail : undefined;
|
|
24
|
+
case 'page-target-unpublished':
|
|
25
|
+
// Paths only: no query, fragment, whitespace, URL authority or encoded text.
|
|
26
|
+
return typeof detail === 'string' && detail.length <= 128 && detail.startsWith('/') && !detail.includes('//') && !/[^A-Za-z0-9_/-]/.test(detail) ? detail : undefined;
|
|
27
|
+
case 'flag-params-invalid':
|
|
28
|
+
{
|
|
29
|
+
if (!Array.isArray(detail) || !detail.length || !detail.every((item)=>object(item) && arm(item.arm) && key(item.field))) return undefined;
|
|
30
|
+
return detail.map((item)=>({
|
|
31
|
+
arm: item.arm,
|
|
32
|
+
field: item.field
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
case 'flag-placement-missing':
|
|
36
|
+
{
|
|
37
|
+
if (failure(detail)) return detail;
|
|
38
|
+
if (!object(detail) || !Array.isArray(detail.missing) || !detail.missing.every(arm) || !Array.isArray(detail.placements) || !detail.placements.every((item)=>object(item) && arm(item.arm))) return undefined;
|
|
39
|
+
return {
|
|
40
|
+
missing: detail.missing,
|
|
41
|
+
placements: detail.placements.map((item)=>({
|
|
42
|
+
arm: item.arm
|
|
43
|
+
}))
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
case 'undeclared-targeting-key':
|
|
47
|
+
case 'invalid-targeting-rule':
|
|
48
|
+
return object(detail) && Array.isArray(detail.attributes) && detail.attributes.length && detail.attributes.every(key) ? {
|
|
49
|
+
attributes: detail.attributes
|
|
50
|
+
} : undefined;
|
|
51
|
+
default:
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import type { ExperimentsServerRuntime } from '../types/runtime.js';
|
|
2
|
+
import { type IdentityWriteOutcome } from '../db/identities.js';
|
|
3
|
+
export type LinkIdentityOutcome = IdentityWriteOutcome | 'failed';
|
|
4
|
+
/**
|
|
5
|
+
* verified: true means this request proves, in this browser, that the person owns
|
|
6
|
+
* the account: a magic-link or one-time-code callback, password sign-in, or OAuth
|
|
7
|
+
* callback. It does not mean "a session exists" or "the email is verified".
|
|
8
|
+
* Never throws into registration or sign-in.
|
|
9
|
+
*/
|
|
2
10
|
export declare function linkIdentity<TUser extends {
|
|
3
11
|
id: string | number;
|
|
4
12
|
} = {
|
|
@@ -6,4 +14,5 @@ export declare function linkIdentity<TUser extends {
|
|
|
6
14
|
}>(serverRuntime: ExperimentsServerRuntime<TUser>, opts: {
|
|
7
15
|
headers: Headers;
|
|
8
16
|
userId: string;
|
|
9
|
-
|
|
17
|
+
verified: boolean;
|
|
18
|
+
}): Promise<LinkIdentityOutcome>;
|
|
@@ -2,33 +2,35 @@ import { safeError } from '../log.js';
|
|
|
2
2
|
import { NextRequest } from 'next/server.js';
|
|
3
3
|
import { insertIdentityOnOwnConnection } from '../db/identities.js';
|
|
4
4
|
import { readUnitCookie } from '../runtime/cookie.js';
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* verified: true means this request proves, in this browser, that the person owns
|
|
7
|
+
* the account: a magic-link or one-time-code callback, password sign-in, or OAuth
|
|
8
|
+
* callback. It does not mean "a session exists" or "the email is verified".
|
|
9
|
+
* Never throws into registration or sign-in.
|
|
10
|
+
*/ export async function linkIdentity(serverRuntime, opts) {
|
|
6
11
|
let payload;
|
|
7
12
|
try {
|
|
8
13
|
const runtime = serverRuntime.proxyRuntime;
|
|
9
14
|
// Consent sees cookies and headers only; the URL is a placeholder.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
})) !== false;
|
|
15
|
-
} catch {
|
|
16
|
-
console.error('[payload-experiments] consent failed; treating consent as false');
|
|
17
|
-
consent = false;
|
|
18
|
-
}
|
|
19
|
-
if (!consent) return;
|
|
15
|
+
const consent = runtime.cookie.consent?.(new NextRequest('http://consent.invalid/', {
|
|
16
|
+
headers: opts.headers
|
|
17
|
+
})) !== false;
|
|
18
|
+
if (!consent) return 'skipped';
|
|
20
19
|
const anonymousId = readUnitCookie(opts.headers, runtime);
|
|
21
|
-
if (!anonymousId) return;
|
|
20
|
+
if (!anonymousId) return 'skipped';
|
|
22
21
|
payload = await serverRuntime.getPayload();
|
|
23
|
-
await insertIdentityOnOwnConnection(payload, {
|
|
22
|
+
return await insertIdentityOnOwnConnection(payload, {
|
|
24
23
|
anonymousId,
|
|
25
|
-
userId: opts.userId
|
|
24
|
+
userId: opts.userId,
|
|
25
|
+
verified: opts.verified
|
|
26
26
|
});
|
|
27
27
|
} catch (error) {
|
|
28
28
|
try {
|
|
29
|
-
payload
|
|
29
|
+
if (payload) payload.logger.error({
|
|
30
30
|
err: safeError(error)
|
|
31
31
|
}, 'Experiments: linkIdentity failed');
|
|
32
|
+
else console.error('[payload-experiments] linkIdentity failed', safeError(error));
|
|
32
33
|
} catch {}
|
|
34
|
+
return 'failed';
|
|
33
35
|
}
|
|
34
36
|
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
2
|
import type { ExperimentsServerRuntime } from '../types/runtime.js';
|
|
3
|
+
/**
|
|
4
|
+
* Reads the signed request context via `headers()`, so it decides from the URL the visitor requested, not the
|
|
5
|
+
* rewritten route. Call it only from a dynamic `generateMetadata`, never inside `'use cache'`. `path` is the
|
|
6
|
+
* fallback when the proxy did not run.
|
|
7
|
+
*/
|
|
3
8
|
export declare function experimentMetadata<TUser extends {
|
|
4
9
|
id: string | number;
|
|
5
10
|
} = {
|
package/dist/server/metadata.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { findNormalisedPathKey, normalisePath } from '../runtime/normalisePath.js';
|
|
2
|
+
import { headers } from 'next/headers.js';
|
|
3
|
+
import { serverRequest } from './request.js';
|
|
2
4
|
import { serverManifest } from './context.js';
|
|
3
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Reads the signed request context via `headers()`, so it decides from the URL the visitor requested, not the
|
|
7
|
+
* rewritten route. Call it only from a dynamic `generateMetadata`, never inside `'use cache'`. `path` is the
|
|
8
|
+
* fallback when the proxy did not run.
|
|
9
|
+
*/ export async function experimentMetadata(serverRuntime, path) {
|
|
10
|
+
const { request, hasContext } = serverRequest(new Headers(await headers()), serverRuntime.proxyRuntime.secrets);
|
|
11
|
+
if (hasContext) path = request.nextUrl.pathname;
|
|
4
12
|
const manifest = await serverManifest(serverRuntime.proxyRuntime);
|
|
5
13
|
const live = manifest?.experiments.find((e)=>e.kind === 'page' && e.arms.some((a)=>a.pageTarget !== undefined && normalisePath(a.pageTarget) === normalisePath(path)));
|
|
6
14
|
if (live) {
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Payload } from 'payload';
|
|
2
|
+
import type { MetricUnavailableReason } from '../types/reasons.js';
|
|
2
3
|
import type { DocId } from '../types/contracts.js';
|
|
3
4
|
type UnavailableMetric = {
|
|
4
5
|
experimentId: DocId;
|
|
5
6
|
phase: number;
|
|
6
7
|
metricId: DocId;
|
|
7
|
-
reason:
|
|
8
|
+
reason: MetricUnavailableReason;
|
|
8
9
|
};
|
|
9
10
|
export declare function runResults(payload: Payload, { budgetMs, experimentId, sweep }?: {
|
|
10
11
|
budgetMs?: number;
|