@delmaredigital/payload-experiments 0.2.0-rc.6 → 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/arms.d.ts +1 -7
- package/dist/admin/copy/arms.js +1 -7
- 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 +12 -0
- package/dist/admin/copy/testPage.js +12 -0
- package/dist/admin/format/date.d.ts +2 -0
- package/dist/admin/format/date.js +10 -0
- package/dist/admin/labels.d.ts +2 -7
- package/dist/admin/labels.js +2 -7
- 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 +6 -2
- package/dist/admin/results/results.model.js +12 -5
- package/dist/admin/surface/ResultsRoute.d.ts +1 -1
- package/dist/admin/surface/ResultsRoute.js +60 -31
- 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/contracts.d.ts +1 -0
- 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 +63 -36
- 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 +15 -2
- package/dist/admin/surface/results/ResultsBody.js +281 -107
- 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 -59
- 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.d.ts +1 -0
- package/dist/admin/surface/useTestDocument.js +40 -9
- 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 +44 -1
- package/dist/collections/experiments/lockHook.js +16 -2
- 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/constants.d.ts +1 -0
- package/dist/constants.js +1 -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/staleEdit.d.ts +6 -0
- package/dist/db/staleEdit.js +10 -0
- package/dist/db/sweep.js +11 -6
- package/dist/endpoints/events.js +3 -3
- package/dist/endpoints/index.js +19 -1
- package/dist/endpoints/recompute.js +2 -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/adminClient.d.ts +1 -0
- package/dist/exports/adminClient.js +1 -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/hostHook.js +3 -2
- package/dist/hooks/pageGuard.d.ts +5 -0
- package/dist/hooks/pageGuard.js +121 -0
- package/dist/labels/arms.d.ts +7 -0
- package/dist/labels/arms.js +7 -0
- package/dist/log.d.ts +7 -0
- package/dist/log.js +38 -0
- 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 +6 -6
- 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/results/verdict.d.ts +2 -0
- package/dist/results/verdict.js +18 -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 +20 -18
- package/dist/server/metadata.d.ts +5 -0
- package/dist/server/metadata.js +9 -1
- package/dist/server/recordContentChange.js +2 -1
- package/dist/server/recordExposures.js +3 -3
- package/dist/server/runResults.d.ts +3 -2
- package/dist/server/runResults.js +125 -34
- package/dist/server/trackConversion.js +5 -6
- 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/deleteExperiment.js +2 -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 -69
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { XpAnalysisError } from '../errors.js';
|
|
2
|
+
import { safeError } from '../log.js';
|
|
3
|
+
import { createLocalReq } from 'payload';
|
|
1
4
|
import { resultsCandidates, resultsExecutor, writeSnapshots } from '../db/snapshots.js';
|
|
2
5
|
import { sweepEvents } from '../db/sweep.js';
|
|
3
6
|
import { armMismatchCount, identityRows, metricEventRows, phaseExposureRows, rolloutExposureRows } from '../db/results.js';
|
|
@@ -5,8 +8,8 @@ import { raceDeadline, ResultsBudgetError, RESULTS_TIMEOUT_MARGIN_MS } from '../
|
|
|
5
8
|
import { analysePhase } from '../results/analyse.js';
|
|
6
9
|
import { firstExposures, perUnitValues, timestampMs } from '../results/aggregate.js';
|
|
7
10
|
import { rolloutAnalysisUnits } from '../results/rolloutUnits.js';
|
|
8
|
-
import { creditUserEvents } from '../results/identityCredit.js';
|
|
9
|
-
async function analyseCandidate(exec, candidate, options, now, deadline, unavailableMetrics) {
|
|
11
|
+
import { creditUserEvents, eligibleIdentityLinks } from '../results/identityCredit.js';
|
|
12
|
+
async function analyseCandidate(exec, candidate, options, now, deadline, unavailableMetrics, logger) {
|
|
10
13
|
const { phase } = candidate;
|
|
11
14
|
// Rollout assignment preserves the original test ranges, but its SRM compares
|
|
12
15
|
// only the shipped group against holdout with weights {100-h, h}.
|
|
@@ -58,6 +61,7 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
|
|
|
58
61
|
...new Set(identities.map((identity)=>identity.userId))
|
|
59
62
|
];
|
|
60
63
|
const linked = new Set(userIds);
|
|
64
|
+
const identityConflicts = new Set();
|
|
61
65
|
const credit = (events, userKeyed)=>{
|
|
62
66
|
if (candidate.unitType === 'user') return events.map((event)=>({
|
|
63
67
|
...event,
|
|
@@ -68,13 +72,12 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
|
|
|
68
72
|
value: event.value,
|
|
69
73
|
ts: event.ts
|
|
70
74
|
}));
|
|
71
|
-
const credited = creditUserEvents(units, identities, userEvents).events;
|
|
75
|
+
const credited = creditUserEvents(units, identities, userEvents, (userId)=>identityConflicts.add(userId)).events;
|
|
72
76
|
return userKeyed ? credited : [
|
|
73
77
|
...events.filter((event)=>event.userId == null || !linked.has(event.userId)),
|
|
74
78
|
...credited
|
|
75
79
|
];
|
|
76
80
|
};
|
|
77
|
-
const diagnostics = creditUserEvents(units, identities, []);
|
|
78
81
|
const metricData = [];
|
|
79
82
|
for (const definition of phase.metricDefinitions){
|
|
80
83
|
try {
|
|
@@ -88,7 +91,7 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
|
|
|
88
91
|
let events, covariates;
|
|
89
92
|
if (definition.source.startsWith('events:')) {
|
|
90
93
|
const eventName = definition.source.slice(7);
|
|
91
|
-
if (!options.eventSources?.some((source)=>source.name === eventName)) throw new
|
|
94
|
+
if (!options.eventSources?.some((source)=>source.name === eventName)) throw new XpAnalysisError('source-unavailable');
|
|
92
95
|
const read = async (start)=>{
|
|
93
96
|
if (candidate.unitType === 'user') return metricEventRows(exec, {
|
|
94
97
|
eventName,
|
|
@@ -131,9 +134,9 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
|
|
|
131
134
|
}
|
|
132
135
|
} else {
|
|
133
136
|
const source = options.metricSources?.find((source)=>source.id === definition.source);
|
|
134
|
-
if (!source || source.kind !== kind || definition.keyedBy && source.keyedBy !== definition.keyedBy) throw new
|
|
137
|
+
if (!source || source.kind !== kind || definition.keyedBy && source.keyedBy !== definition.keyedBy) throw new XpAnalysisError('source-unavailable');
|
|
135
138
|
const userKeyed = source.keyedBy === 'user' && candidate.unitType === 'visitor';
|
|
136
|
-
if (candidate.unitType === 'user' && source.keyedBy !== 'user') throw new
|
|
139
|
+
if (candidate.unitType === 'user' && source.keyedBy !== 'user') throw new XpAnalysisError('unit-type-unavailable');
|
|
137
140
|
const sourceIds = userKeyed ? userIds : ids;
|
|
138
141
|
events = credit(await raceDeadline(deadline, ()=>source.query({
|
|
139
142
|
from,
|
|
@@ -141,35 +144,51 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
|
|
|
141
144
|
unitIds: sourceIds
|
|
142
145
|
})), userKeyed);
|
|
143
146
|
if (phase.analysis.cuped === 'auto' && source.covariate) {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
147
|
+
const userUnits = new Map();
|
|
148
|
+
if (userKeyed) for (const link of identities){
|
|
149
|
+
const unit = units.get(link.anonymousId);
|
|
150
|
+
if (!unit) continue;
|
|
151
|
+
// Hosts aggregate pre-period values in SQL; per-event rows over 14 days would break the one-row-per-unit contract and multiply host traffic.
|
|
152
|
+
// CUPED only reduces variance: mis-credited pre-exposure values add noise, but cannot bias the effect because they are independent of assignment.
|
|
153
|
+
if (!eligibleIdentityLinks([
|
|
154
|
+
link
|
|
155
|
+
], unit.firstTs).length) continue;
|
|
156
|
+
const previous = userUnits.get(link.userId);
|
|
157
|
+
if (!previous || +unit.firstTs < +previous.firstExposureTs || +unit.firstTs === +previous.firstExposureTs && link.anonymousId < previous.unitId) {
|
|
158
|
+
userUnits.set(link.userId, {
|
|
159
|
+
unitId: link.anonymousId,
|
|
160
|
+
firstExposureTs: unit.firstTs
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const sourceUnits = userKeyed ? [
|
|
165
|
+
...userUnits
|
|
166
|
+
].map(([unitId, unit])=>({
|
|
167
|
+
unitId,
|
|
168
|
+
firstExposureTs: unit.firstExposureTs
|
|
151
169
|
})) : [
|
|
152
170
|
...units
|
|
153
171
|
].map(([unitId, unit])=>({
|
|
154
172
|
unitId,
|
|
155
|
-
firstExposureTs: unit.firstTs
|
|
173
|
+
firstExposureTs: new Date(timestampMs(unit.firstTs))
|
|
156
174
|
}));
|
|
157
175
|
const pre = await raceDeadline(deadline, ()=>source.covariate({
|
|
158
176
|
units: sourceUnits,
|
|
159
177
|
lookbackDays: 14
|
|
160
178
|
}));
|
|
161
|
-
if (pre.some((row)=>!Number.isFinite(row.value))) throw new
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
]
|
|
179
|
+
if (pre.some((row)=>!Number.isFinite(row.value))) throw new XpAnalysisError('non-finite-covariates');
|
|
180
|
+
covariates = new Map(pre.flatMap((row)=>{
|
|
181
|
+
const unitId = userKeyed ? userUnits.get(row.unitId)?.unitId : row.unitId;
|
|
182
|
+
return unitId === undefined ? [] : [
|
|
183
|
+
[
|
|
184
|
+
unitId,
|
|
185
|
+
row.value
|
|
186
|
+
]
|
|
187
|
+
];
|
|
188
|
+
}));
|
|
170
189
|
}
|
|
171
190
|
}
|
|
172
|
-
if (events.some((event)=>!Number.isFinite(event.value))) throw new
|
|
191
|
+
if (events.some((event)=>!Number.isFinite(event.value))) throw new XpAnalysisError('non-finite-values');
|
|
173
192
|
metricData.push({
|
|
174
193
|
metricId: definition.metricId,
|
|
175
194
|
values: perUnitValues(units, events, {
|
|
@@ -182,7 +201,22 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
|
|
|
182
201
|
} : {}
|
|
183
202
|
});
|
|
184
203
|
} catch (error) {
|
|
185
|
-
|
|
204
|
+
let reason = error instanceof ResultsBudgetError ? 'budget' : 'source-failed';
|
|
205
|
+
if (error instanceof XpAnalysisError) {
|
|
206
|
+
switch(error.code){
|
|
207
|
+
case 'source-unavailable':
|
|
208
|
+
case 'unit-type-unavailable':
|
|
209
|
+
case 'non-finite-values':
|
|
210
|
+
case 'non-finite-covariates':
|
|
211
|
+
reason = error.code;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
logger.error({
|
|
216
|
+
err: safeError(error),
|
|
217
|
+
experimentId: phase.experiment,
|
|
218
|
+
metricId: definition.metricId
|
|
219
|
+
}, 'Experiment metric source failed');
|
|
186
220
|
metricData.push({
|
|
187
221
|
metricId: definition.metricId,
|
|
188
222
|
values: new Map(),
|
|
@@ -204,9 +238,14 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
|
|
|
204
238
|
units,
|
|
205
239
|
metricData,
|
|
206
240
|
status,
|
|
241
|
+
onMetricError: (error, metricId)=>logger.error({
|
|
242
|
+
err: safeError(error),
|
|
243
|
+
experimentId: phase.experiment,
|
|
244
|
+
metricId
|
|
245
|
+
}, 'Experiment metric analysis failed'),
|
|
207
246
|
excludedMultiArm: exposed.excludedMultiArm,
|
|
208
247
|
armMismatch: await armMismatchCount(exec, query),
|
|
209
|
-
identityConflictShare: userIds.length ?
|
|
248
|
+
identityConflictShare: userIds.length ? identityConflicts.size / userIds.length : 0,
|
|
210
249
|
...phase.phaseKind === 'rollout' ? {
|
|
211
250
|
winnerArm: phase.arms.find((arm)=>arm.kind !== 'holdout').key
|
|
212
251
|
} : {}
|
|
@@ -247,12 +286,12 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
|
|
|
247
286
|
if (!(error instanceof ResultsBudgetError)) summary.failed++;
|
|
248
287
|
summary.budgetExhausted = error instanceof ResultsBudgetError;
|
|
249
288
|
payload.logger.error({
|
|
250
|
-
err: error,
|
|
289
|
+
err: safeError(error),
|
|
251
290
|
experimentId
|
|
252
291
|
}, 'Experiment scheduling failed');
|
|
253
292
|
}
|
|
254
|
-
//
|
|
255
|
-
// phases together
|
|
293
|
+
// Analysis precedes health-only refreshes, then oldest phase snapshots first;
|
|
294
|
+
// grouping keeps each experiment's phases together with that priority.
|
|
256
295
|
const experiments = new Map();
|
|
257
296
|
for (const candidate of candidates){
|
|
258
297
|
const id = candidate.phase.experiment;
|
|
@@ -271,10 +310,62 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
|
|
|
271
310
|
const snapshots = [];
|
|
272
311
|
for (const phase of phases){
|
|
273
312
|
if (Date.now() >= analysisDeadline) throw new ResultsBudgetError();
|
|
274
|
-
|
|
313
|
+
// Health refreshes must not reopen inference after the late-beacon grace.
|
|
314
|
+
snapshots.push(phase.frozenSnapshot ? {
|
|
315
|
+
...phase.frozenSnapshot,
|
|
316
|
+
computedAt: now.toISOString()
|
|
317
|
+
} : await analyseCandidate(exec, phase, options, now, analysisDeadline, summary.unavailableMetrics, payload.logger));
|
|
318
|
+
}
|
|
319
|
+
if (Date.now() >= analysisDeadline) throw new ResultsBudgetError();
|
|
320
|
+
const current = phases.find((candidate)=>candidate.kind === 'page' && candidate.phase.phase === candidate.currentPhase && (candidate.status === 'running' || candidate.status === 'decided'));
|
|
321
|
+
if (current) {
|
|
322
|
+
const req = await raceDeadline(analysisDeadline, ()=>createLocalReq({}, payload));
|
|
323
|
+
const health = [];
|
|
324
|
+
// Decided tests serve only the winner and Original, including rollouts.
|
|
325
|
+
const arms = current.phase.phaseKind === 'rollout' ? [
|
|
326
|
+
...current.phase.arms.filter((arm)=>arm.kind !== 'holdout'),
|
|
327
|
+
...current.testArms.filter((arm)=>arm.kind === 'control')
|
|
328
|
+
] : current.testArms.filter((arm)=>arm.kind !== 'holdout' && (current.status !== 'decided' || arm.kind === 'control' || arm.key === current.winnerArm));
|
|
329
|
+
const targets = new Set(arms.flatMap((arm)=>arm.pageTarget ? [
|
|
330
|
+
arm.pageTarget
|
|
331
|
+
] : []));
|
|
332
|
+
for (const path of targets){
|
|
333
|
+
let published = false, found = false, unchecked = false;
|
|
334
|
+
for (const collection of options.pages?.collections ?? []){
|
|
335
|
+
try {
|
|
336
|
+
const page = await raceDeadline(analysisDeadline, ()=>collection.findByPath(path, req));
|
|
337
|
+
found ||= page != null;
|
|
338
|
+
published ||= page?.published === true;
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (error instanceof ResultsBudgetError) throw error;
|
|
341
|
+
payload.logger.error({
|
|
342
|
+
err: safeError(error),
|
|
343
|
+
experimentId: id
|
|
344
|
+
}, 'Experiment version page lookup failed');
|
|
345
|
+
unchecked = true;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (!published) health.push({
|
|
349
|
+
code: !found && (unchecked || !options.pages?.collections.length) ? 'version-page-unchecked' : 'version-page-unavailable',
|
|
350
|
+
path
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
for (const snapshot of snapshots)snapshot.health = health;
|
|
275
354
|
}
|
|
276
355
|
if (Date.now() >= analysisDeadline) throw new ResultsBudgetError();
|
|
277
|
-
|
|
356
|
+
// Order and optional empty health are semantically identical. Frozen
|
|
357
|
+
// inference gets a new row only when its page availability changes.
|
|
358
|
+
const canonicalHealth = (snapshot)=>JSON.stringify((snapshot.health ?? []).map(({ code, path })=>[
|
|
359
|
+
code,
|
|
360
|
+
path
|
|
361
|
+
]).map((entry)=>JSON.stringify(entry)).sort((a, b)=>a < b ? -1 : a > b ? 1 : 0));
|
|
362
|
+
const changed = snapshots.filter((snapshot, index)=>!phases[index].frozenSnapshot || canonicalHealth(snapshot) !== canonicalHealth(phases[index].frozenSnapshot));
|
|
363
|
+
// Unchanged health is a successful refresh with nothing to write.
|
|
364
|
+
if (!changed.length) {
|
|
365
|
+
summary.ran++;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
await writeSnapshots(exec, changed);
|
|
278
369
|
summary.ran++;
|
|
279
370
|
if (summary.processedIds.length < 100) summary.processedIds.push(id);
|
|
280
371
|
} catch (error) {
|
|
@@ -282,7 +373,7 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
|
|
|
282
373
|
if (!(error instanceof ResultsBudgetError)) summary.failed++;
|
|
283
374
|
summary.budgetExhausted ||= error instanceof ResultsBudgetError;
|
|
284
375
|
payload.logger.error({
|
|
285
|
-
err: error,
|
|
376
|
+
err: safeError(error),
|
|
286
377
|
experimentId: id
|
|
287
378
|
}, 'Experiment results failed');
|
|
288
379
|
}
|
|
@@ -302,7 +393,7 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
|
|
|
302
393
|
if (!(error instanceof ResultsBudgetError)) summary.failed++;
|
|
303
394
|
summary.budgetExhausted ||= error instanceof ResultsBudgetError;
|
|
304
395
|
payload.logger.error({
|
|
305
|
-
err: error
|
|
396
|
+
err: safeError(error)
|
|
306
397
|
}, 'Experiment retention sweep failed');
|
|
307
398
|
}
|
|
308
399
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { safeError } from '../log.js';
|
|
1
2
|
import { NextRequest } from 'next/server.js';
|
|
2
3
|
import { pooledExecutor } from '../db/executor.js';
|
|
3
4
|
import { insertEvents } from '../db/events.js';
|
|
@@ -39,9 +40,8 @@ export async function trackConversion(serverRuntime, event) {
|
|
|
39
40
|
} catch (error) {
|
|
40
41
|
try {
|
|
41
42
|
payload.logger.error({
|
|
42
|
-
err: error
|
|
43
|
-
|
|
44
|
-
});
|
|
43
|
+
err: safeError(error)
|
|
44
|
+
}, 'Experiments: trackConversion getUser failed');
|
|
45
45
|
} catch {}
|
|
46
46
|
}
|
|
47
47
|
}
|
|
@@ -61,9 +61,8 @@ export async function trackConversion(serverRuntime, event) {
|
|
|
61
61
|
} catch (error) {
|
|
62
62
|
try {
|
|
63
63
|
payload?.logger.error({
|
|
64
|
-
err: error
|
|
65
|
-
|
|
66
|
-
});
|
|
64
|
+
err: safeError(error)
|
|
65
|
+
}, 'Experiments: trackConversion failed');
|
|
67
66
|
} catch {}
|
|
68
67
|
}
|
|
69
68
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Trim and deduplicate list inputs while preserving commas inside array items. */
|
|
2
|
+
export declare function coerceListValue(raw: unknown): string[];
|
|
3
|
+
/** Only the read boundary accepts legacy comma-separated document values. */
|
|
4
|
+
export declare function readListValue(raw: unknown): string[];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Trim and deduplicate list inputs while preserving commas inside array items. */ export function coerceListValue(raw) {
|
|
2
|
+
return [
|
|
3
|
+
...new Set((Array.isArray(raw) ? raw.map(String) : [
|
|
4
|
+
String(raw ?? '')
|
|
5
|
+
]).map((value)=>value.trim()).filter(Boolean))
|
|
6
|
+
];
|
|
7
|
+
}
|
|
8
|
+
/** Only the read boundary accepts legacy comma-separated document values. */ export function readListValue(raw) {
|
|
9
|
+
return coerceListValue(typeof raw === 'string' ? raw.split(',') : raw);
|
|
10
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { lockMetricsForFreeze } from '../db/metricUsage.js';
|
|
1
2
|
import { readState } from '../db/state.js';
|
|
2
3
|
import { XpRefusal } from '../errors.js';
|
|
3
4
|
import { relationshipId } from './checkStart.js';
|
|
@@ -44,7 +45,14 @@ export async function addMetric(req, { experimentId, metric, role, options }) {
|
|
|
44
45
|
if (ids.some((id)=>id !== undefined && String(id) === String(metric))) refuse('duplicate-metric', {
|
|
45
46
|
message: 'This metric is already part of the experiment.'
|
|
46
47
|
});
|
|
48
|
+
const lockedMetrics = await lockMetricsForFreeze(exec, caps.definitions.map((metric)=>metric.id));
|
|
49
|
+
if (!lockedMetrics) {
|
|
50
|
+
throw new XpRefusal('stale-draft', 'This goal was deleted. Choose another goal.', undefined, 409);
|
|
51
|
+
}
|
|
47
52
|
const definition = caps.definitions[0];
|
|
53
|
+
if (lockedMetrics[0].slug !== definition.slug || lockedMetrics[0].source !== definition.source) {
|
|
54
|
+
throw new XpRefusal('stale-draft', 'This goal changed. Review it again before adding it.', undefined, 409);
|
|
55
|
+
}
|
|
48
56
|
if (current.unitType === 'user' && definition.keyedBy === 'visitor' && !definition.source.startsWith('events:')) refuse('unit-source-mismatch');
|
|
49
57
|
// §7.8: phase lookup serves the access-authorised transition write.
|
|
50
58
|
const phases = await req.payload.find({
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { contentDone } from '../contentDone.js';
|
|
1
2
|
import { relationshipId } from './checkStart.js';
|
|
2
3
|
import { expectedConfirm, matchesConfirm, storedWinnerLabel } from './confirmPhrases.js';
|
|
3
4
|
import { withTransition } from './kernel.js';
|
|
@@ -14,9 +15,7 @@ export async function archive(req, { experimentId, mode, confirmation }) {
|
|
|
14
15
|
if (mode === 'stopped' ? current.status !== 'stopped' : current.status !== 'decided') refuse('invalid-status');
|
|
15
16
|
if (mode === 'hard-coded' && current.kind !== 'code') refuse('invalid-status');
|
|
16
17
|
if (mode === 'after-content') {
|
|
17
|
-
|
|
18
|
-
const docs = Array.isArray(report) ? report : report?.docs;
|
|
19
|
-
if (!Array.isArray(docs) || !docs.every((doc)=>doc?.ok === true)) refuse('invalid-status');
|
|
18
|
+
if (!contentDone(decision?.contentReport)) refuse('invalid-status');
|
|
20
19
|
}
|
|
21
20
|
if (mode === 'discard' && !matchesConfirm(expectedConfirm('discard', ''), confirmation)) refuse('confirmation-required');
|
|
22
21
|
if (mode === 'hard-coded') {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Arm, DocId, ExperimentKind, ExposurePolicy, FlagPoint, MetricSource, ResolvedOptions, Status, TargetingRule, UnitType } from '../types/contracts.js';
|
|
2
2
|
import type { ArmIssueCode, PreflightResult } from '../types/endpoints.js';
|
|
3
3
|
import type { BlockPlacement, FlagPlacement } from './placements.js';
|
|
4
|
-
export type ReadinessCode = ArmIssueCode | 'invalid-status' | 'no-planned-duration' | 'no-primary-metric' | 'no-reach-per-day' | 'page-no-targets' | 'page-user-unit' | 'flag-no-point' | 'flag-params-invalid' | 'flag-point-busy' | 'flag-placement-missing' | 'no-placements' | 'empty-slot' | 'layer-unit-mismatch' | 'layer-full' | 'unknown-metric-source' | 'unit-source-mismatch' | 'undeclared-targeting-key' | 'invalid-targeting-rule';
|
|
4
|
+
export type ReadinessCode = ArmIssueCode | 'invalid-status' | 'no-planned-duration' | 'no-primary-metric' | 'no-reach-per-day' | 'page-no-targets' | 'page-target-unpublished' | 'page-targets-unchecked' | 'version-page-unavailable' | 'version-page-unchecked' | 'page-user-unit' | 'flag-no-point' | 'flag-params-invalid' | 'flag-point-busy' | 'flag-placement-missing' | 'no-placements' | 'empty-slot' | 'make-content-unsupported' | 'layer-unit-mismatch' | 'layer-full' | 'unknown-metric-source' | 'unit-source-mismatch' | 'block-path-rule' | 'undeclared-targeting-key' | 'invalid-targeting-rule';
|
|
5
5
|
export interface StartExperiment {
|
|
6
6
|
id: DocId;
|
|
7
7
|
slug: string;
|
|
@@ -38,6 +38,7 @@ export interface StartMetric {
|
|
|
38
38
|
id: DocId;
|
|
39
39
|
source: string;
|
|
40
40
|
}
|
|
41
|
+
export type PlacementFailure = 'placements-failed' | 'forbidden' | 'not-found';
|
|
41
42
|
export interface CheckStartInput {
|
|
42
43
|
experiment: StartExperiment;
|
|
43
44
|
metrics: StartMetric[];
|
|
@@ -45,9 +46,11 @@ export interface CheckStartInput {
|
|
|
45
46
|
flagPoints?: FlagPoint[];
|
|
46
47
|
placements?: BlockPlacement[];
|
|
47
48
|
flagPlacements?: FlagPlacement[];
|
|
48
|
-
placementError?:
|
|
49
|
-
flagPlacementError?:
|
|
49
|
+
placementError?: PlacementFailure;
|
|
50
|
+
flagPlacementError?: PlacementFailure;
|
|
50
51
|
flagPointBusy?: boolean;
|
|
52
|
+
unpublishedPageTargets?: string[];
|
|
53
|
+
pageTargetsUnchecked?: boolean;
|
|
51
54
|
layer?: {
|
|
52
55
|
unitType: UnitType;
|
|
53
56
|
usedSlices: number[];
|
|
@@ -56,6 +56,9 @@ function invalidParams(arms, point) {
|
|
|
56
56
|
'viewport'
|
|
57
57
|
].includes(e.exposurePolicy) || positive(e.analysis?.reachPerDay));
|
|
58
58
|
add('page-no-targets', e.kind !== 'page' || e.arms.filter((arm)=>arm.kind !== 'holdout').every((arm)=>typeof arm.pageTarget === 'string' && arm.pageTarget.trim().length > 0));
|
|
59
|
+
add('page-target-unpublished', !input.unpublishedPageTargets?.length, input.unpublishedPageTargets?.[0]);
|
|
60
|
+
add('page-targets-unchecked', true);
|
|
61
|
+
if (input.pageTargetsUnchecked) checks[checks.length - 1].warning = true;
|
|
59
62
|
add('page-user-unit', e.kind !== 'page' || e.unitType === 'visitor');
|
|
60
63
|
const point = input.flagPoints?.find((point)=>point.id === e.flagPoint);
|
|
61
64
|
add('flag-no-point', e.kind !== 'flag' || !!point);
|
|
@@ -72,6 +75,12 @@ function invalidParams(arms, point) {
|
|
|
72
75
|
add('no-placements', e.kind !== 'block' || !input.placementError && placements.length > 0, input.placementError);
|
|
73
76
|
const empty = placements.filter((placement)=>e.arms.some((arm)=>arm.kind !== 'holdout' && (!arm.slot || !(placement.slots[arm.slot] > 0))));
|
|
74
77
|
add('empty-slot', e.kind !== 'block' || empty.length === 0, empty);
|
|
78
|
+
add('make-content-unsupported', true);
|
|
79
|
+
const localized = placements.find((placement)=>placement.localized);
|
|
80
|
+
if (localized) Object.assign(checks[checks.length - 1], {
|
|
81
|
+
warning: true,
|
|
82
|
+
detail: localized.title
|
|
83
|
+
});
|
|
75
84
|
add('layer-unit-mismatch', e.layer == null || input.layer?.unitType === e.unitType);
|
|
76
85
|
const free = 100 - new Set(input.layer?.usedSlices).size;
|
|
77
86
|
add('layer-full', e.layer == null || !!input.layer && free >= e.allocation, {
|
|
@@ -82,6 +91,12 @@ function invalidParams(arms, point) {
|
|
|
82
91
|
add('unknown-metric-source', unknown.length === 0, unknown);
|
|
83
92
|
const mismatches = input.metrics.filter((metric)=>!metric.source.startsWith('events:') && e.unitType === 'user' && input.metricSources?.some((source)=>source.id === metric.source && source.keyedBy === 'visitor'));
|
|
84
93
|
add('unit-source-mismatch', mismatches.length === 0, mismatches);
|
|
94
|
+
const pathRules = (e.targeting ?? []).flatMap((rule, index)=>rule.attribute === 'path' ? [
|
|
95
|
+
index
|
|
96
|
+
] : []);
|
|
97
|
+
add('block-path-rule', e.kind !== 'block' || pathRules.length === 0, {
|
|
98
|
+
rules: pathRules
|
|
99
|
+
});
|
|
85
100
|
const undeclared = (e.targeting ?? []).filter((rule)=>!Object.hasOwn(input.attributeKeys ?? {}, rule.attribute));
|
|
86
101
|
add('undeclared-targeting-key', undeclared.length === 0, {
|
|
87
102
|
message: 'Targeting uses an undeclared attribute.',
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Block, Field, SanitizedConfig } from 'payload';
|
|
2
|
+
/** Payload keeps both inline definitions and slug references in blockReferences. */
|
|
3
|
+
export declare function contentBlocks(field: Extract<Field, {
|
|
4
|
+
type: 'blocks';
|
|
5
|
+
}>, blocks?: Block[]): Block[];
|
|
6
|
+
/** Any localized data makes a full-document publish unsafe in Payload 3.90. */
|
|
7
|
+
export declare function hasLocalizedFields(fields: Field[], config: Pick<SanitizedConfig, 'localization' | 'blocks'>, seen?: Set<Field>): boolean;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Payload keeps both inline definitions and slug references in blockReferences. */ export function contentBlocks(field, blocks = []) {
|
|
2
|
+
return [
|
|
3
|
+
...field.blocks,
|
|
4
|
+
...(field.blockReferences ?? []).flatMap((reference)=>typeof reference === 'string' ? blocks.filter((block)=>block.slug === reference) : [
|
|
5
|
+
reference
|
|
6
|
+
])
|
|
7
|
+
];
|
|
8
|
+
}
|
|
9
|
+
/** Any localized data makes a full-document publish unsafe in Payload 3.90. */ export function hasLocalizedFields(fields, config, seen = new Set()) {
|
|
10
|
+
if (!config.localization) return false;
|
|
11
|
+
const blocks = config.blocks ?? [];
|
|
12
|
+
return fields.some((field)=>{
|
|
13
|
+
if (seen.has(field)) return false;
|
|
14
|
+
const branch = new Set(seen).add(field);
|
|
15
|
+
if ('localized' in field && field.localized) return true;
|
|
16
|
+
if ('tabs' in field && field.tabs.some((tab)=>tab.localized || hasLocalizedFields(tab.fields, config, branch))) return true;
|
|
17
|
+
if ('fields' in field && hasLocalizedFields(field.fields, config, branch)) return true;
|
|
18
|
+
return field.type === 'blocks' && contentBlocks(field, blocks).some((block)=>hasLocalizedFields(block.fields, config, branch));
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DEFAULT_SEQUENTIAL_TUNING_PARAMETER } from '../constants.js';
|
|
1
2
|
import { reclaimSlices } from '../db/slices.js';
|
|
2
3
|
import { decideGuard } from '../results/decideGuard.js';
|
|
3
4
|
import { relationshipId } from './checkStart.js';
|
|
@@ -112,7 +113,11 @@ export async function decide(req, { experimentId, winnerArm, note, confirmation
|
|
|
112
113
|
allocationChanges: [],
|
|
113
114
|
targeting: test.targeting,
|
|
114
115
|
metricDefinitions: test.metricDefinitions,
|
|
115
|
-
analysis:
|
|
116
|
+
analysis: {
|
|
117
|
+
...test.analysis,
|
|
118
|
+
// Older test phases may predate freezing the effective tuning value.
|
|
119
|
+
sequentialTuningParameter: test.analysis?.sequentialTuningParameter ?? DEFAULT_SEQUENTIAL_TUNING_PARAMETER
|
|
120
|
+
},
|
|
116
121
|
contentChanges: []
|
|
117
122
|
});
|
|
118
123
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { safeError } from '../log.js';
|
|
1
2
|
import { txExecutor } from '../db/executor.js';
|
|
2
3
|
import { deleteExperimentSnapshots, serializeDelete } from '../db/delete.js';
|
|
3
4
|
import { deleteExperimentEvents } from '../db/events.js';
|
|
@@ -45,7 +46,7 @@ import { readExperiment, refuse } from './start.js';
|
|
|
45
46
|
} catch (error) {
|
|
46
47
|
req.payload.logger.error({
|
|
47
48
|
experimentId,
|
|
48
|
-
err: error
|
|
49
|
+
err: safeError(error)
|
|
49
50
|
}, 'Experiment cascade failed; aborting delete');
|
|
50
51
|
throw error;
|
|
51
52
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { PayloadRequest } from 'payload';
|
|
2
|
+
import type { DocId } from '../types/contracts.js';
|
|
3
|
+
import type { DismissContentEntryBody } from '../types/endpoints.js';
|
|
4
|
+
export declare function dismissContentEntry(req: PayloadRequest, { experimentId, collection, id }: DismissContentEntryBody & {
|
|
5
|
+
experimentId: DocId;
|
|
6
|
+
}): Promise<{
|
|
7
|
+
doc: import("payload").JsonObject & import("payload").TypeWithID;
|
|
8
|
+
}>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { relationshipId } from './checkStart.js';
|
|
2
|
+
import { withTransition } from './kernel.js';
|
|
3
|
+
import { readExperiment, refuse, updateExperiment } from './start.js';
|
|
4
|
+
export async function dismissContentEntry(req, { experimentId, collection, id }) {
|
|
5
|
+
const before = await readExperiment(req, experimentId);
|
|
6
|
+
// Only the audit report changes; assignment and the manifest stay the same.
|
|
7
|
+
const { result: doc } = await withTransition(req, {
|
|
8
|
+
experimentId,
|
|
9
|
+
layerId: relationshipId(before.layer),
|
|
10
|
+
flagPointId: before.kind === 'flag' ? before.flagPoint : undefined,
|
|
11
|
+
bump: false
|
|
12
|
+
}, async ({ req })=>{
|
|
13
|
+
const current = await readExperiment(req, experimentId);
|
|
14
|
+
const decision = current.decision;
|
|
15
|
+
const report = decision?.contentReport;
|
|
16
|
+
const index = Array.isArray(report?.docs) ? report.docs.findIndex((entry)=>entry.collection === collection && String(entry.id) === String(id)) : -1;
|
|
17
|
+
const entry = report?.docs?.[index];
|
|
18
|
+
if (current.status !== 'decided' || !decision?.winnerArm || decision.discardedAt) refuse('not-decided');
|
|
19
|
+
if (!entry) refuse('entry-not-found');
|
|
20
|
+
if (entry.ok !== false) refuse('entry-ok');
|
|
21
|
+
if (entry.dismissedAt) refuse('entry-dismissed');
|
|
22
|
+
const docs = report.docs.map((entry, i)=>i === index ? {
|
|
23
|
+
...entry,
|
|
24
|
+
dismissedAt: new Date().toISOString(),
|
|
25
|
+
dismissedBy: req.user?.id == null ? null : String(req.user.id)
|
|
26
|
+
} : entry);
|
|
27
|
+
return updateExperiment(req, experimentId, {
|
|
28
|
+
decision: {
|
|
29
|
+
...decision,
|
|
30
|
+
contentReport: {
|
|
31
|
+
...report,
|
|
32
|
+
docs
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
req.payload.logger.info({
|
|
38
|
+
experimentId,
|
|
39
|
+
collection,
|
|
40
|
+
id
|
|
41
|
+
}, 'Experiment content entry dismissed');
|
|
42
|
+
return {
|
|
43
|
+
doc
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { type PayloadRequest } from 'payload';
|
|
2
|
+
import type { ContentReportReason } from '../types/reasons.js';
|
|
2
3
|
import type { DocId, ResolvedOptions } from '../types/contracts.js';
|
|
3
4
|
type ContentDoc = {
|
|
4
5
|
collection: string;
|
|
5
6
|
id: DocId;
|
|
6
7
|
ok: boolean;
|
|
7
|
-
detail?:
|
|
8
|
-
error?:
|
|
8
|
+
detail?: ContentReportReason;
|
|
9
|
+
error?: ContentReportReason;
|
|
10
|
+
dismissedAt?: string;
|
|
11
|
+
dismissedBy?: string | null;
|
|
9
12
|
};
|
|
10
13
|
export declare function makeContent<TUser extends {
|
|
11
14
|
id: DocId;
|