@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.
Files changed (267) hide show
  1. package/README.md +129 -11
  2. package/dist/admin/PreviewLinks.d.ts +2 -1
  3. package/dist/admin/PreviewLinks.js +24 -16
  4. package/dist/admin/actions/ConfirmDialog.d.ts +7 -4
  5. package/dist/admin/actions/ConfirmDialog.js +37 -82
  6. package/dist/admin/actions/DecideDialog.d.ts +3 -1
  7. package/dist/admin/actions/DecideDialog.js +30 -13
  8. package/dist/admin/actions/MakeContentResult.d.ts +13 -2
  9. package/dist/admin/actions/MakeContentResult.js +115 -16
  10. package/dist/admin/actions/actions.model.d.ts +2 -4
  11. package/dist/admin/actions/actions.model.js +2 -3
  12. package/dist/admin/allocation.model.js +3 -5
  13. package/dist/admin/api.d.ts +10 -4
  14. package/dist/admin/api.js +11 -7
  15. package/dist/admin/copy/actions.d.ts +39 -3
  16. package/dist/admin/copy/actions.js +40 -4
  17. package/dist/admin/copy/armSelect.js +5 -5
  18. package/dist/admin/copy/audience.d.ts +2 -1
  19. package/dist/admin/copy/audience.js +3 -2
  20. package/dist/admin/copy/common.d.ts +13 -6
  21. package/dist/admin/copy/common.js +14 -8
  22. package/dist/admin/copy/goals.d.ts +21 -6
  23. package/dist/admin/copy/goals.js +27 -6
  24. package/dist/admin/copy/readiness.d.ts +19 -0
  25. package/dist/admin/copy/readiness.js +19 -0
  26. package/dist/admin/copy/recommendation.d.ts +0 -3
  27. package/dist/admin/copy/recommendation.js +0 -3
  28. package/dist/admin/copy/results.d.ts +41 -2
  29. package/dist/admin/copy/results.js +42 -3
  30. package/dist/admin/copy/review.d.ts +1 -1
  31. package/dist/admin/copy/review.js +1 -1
  32. package/dist/admin/copy/setting.d.ts +2 -0
  33. package/dist/admin/copy/setting.js +2 -0
  34. package/dist/admin/copy/surface.d.ts +7 -4
  35. package/dist/admin/copy/surface.js +9 -4
  36. package/dist/admin/copy/testPage.d.ts +10 -0
  37. package/dist/admin/copy/testPage.js +10 -0
  38. package/dist/admin/format/date.d.ts +2 -0
  39. package/dist/admin/format/date.js +10 -0
  40. package/dist/admin/previewLinks.model.d.ts +2 -2
  41. package/dist/admin/previewLinks.model.js +5 -7
  42. package/dist/admin/results/CiChart.js +11 -21
  43. package/dist/admin/results/DataQualityPanel.d.ts +4 -2
  44. package/dist/admin/results/DataQualityPanel.js +30 -17
  45. package/dist/admin/results/DataTable.d.ts +16 -0
  46. package/dist/admin/results/DataTable.js +152 -0
  47. package/dist/admin/results/StatTile.d.ts +7 -0
  48. package/dist/admin/results/StatTile.js +22 -0
  49. package/dist/admin/results/VerdictHero.d.ts +13 -0
  50. package/dist/admin/results/{VerdictCard.js → VerdictHero.js} +15 -12
  51. package/dist/admin/results/releasePages.model.d.ts +17 -0
  52. package/dist/admin/results/releasePages.model.js +10 -0
  53. package/dist/admin/results/results.css +64 -0
  54. package/dist/admin/results/results.model.d.ts +5 -1
  55. package/dist/admin/results/results.model.js +10 -2
  56. package/dist/admin/surface/ResultsRoute.d.ts +1 -1
  57. package/dist/admin/surface/ResultsRoute.js +59 -34
  58. package/dist/admin/surface/SurfaceShell.d.ts +11 -2
  59. package/dist/admin/surface/SurfaceShell.js +49 -14
  60. package/dist/admin/surface/TestsHome.d.ts +6 -1
  61. package/dist/admin/surface/TestsHome.js +183 -112
  62. package/dist/admin/surface/TestsSurface.js +56 -16
  63. package/dist/admin/surface/goals/GoalForm.js +341 -308
  64. package/dist/admin/surface/goals/GoalsList.js +123 -89
  65. package/dist/admin/surface/goals/data.d.ts +8 -2
  66. package/dist/admin/surface/goals/data.js +11 -3
  67. package/dist/admin/surface/goals/goals.css +10 -13
  68. package/dist/admin/surface/home.model.d.ts +7 -8
  69. package/dist/admin/surface/home.model.js +23 -12
  70. package/dist/admin/surface/newTest/NewTest.js +5 -5
  71. package/dist/admin/surface/pageBlock/parts.js +31 -26
  72. package/dist/admin/surface/readiness/ActionDialogs.js +43 -14
  73. package/dist/admin/surface/readiness/Banner.d.ts +3 -0
  74. package/dist/admin/surface/readiness/Banner.js +58 -33
  75. package/dist/admin/surface/readiness/SaveStatus.d.ts +1 -0
  76. package/dist/admin/surface/readiness/SaveStatus.js +6 -3
  77. package/dist/admin/surface/readiness/readiness.model.d.ts +2 -0
  78. package/dist/admin/surface/readiness/readiness.model.js +18 -9
  79. package/dist/admin/surface/readiness/useTransition.d.ts +1 -0
  80. package/dist/admin/surface/readiness/useTransition.js +2 -1
  81. package/dist/admin/surface/results/ReleasePages.d.ts +14 -0
  82. package/dist/admin/surface/results/ReleasePages.js +116 -0
  83. package/dist/admin/surface/results/ResultsBody.d.ts +11 -1
  84. package/dist/admin/surface/results/ResultsBody.js +276 -105
  85. package/dist/admin/surface/results/ResultsHeader.d.ts +10 -0
  86. package/dist/admin/surface/results/ResultsHeader.js +177 -0
  87. package/dist/admin/surface/results/recommendation.model.js +2 -6
  88. package/dist/admin/surface/results/useResults.d.ts +21 -3
  89. package/dist/admin/surface/results/useResults.js +2 -1
  90. package/dist/admin/surface/setting/parts.js +75 -88
  91. package/dist/admin/surface/setting/setting.css +3 -15
  92. package/dist/admin/surface/surface.css +62 -47
  93. package/dist/admin/surface/test/AdvancedSection.js +55 -48
  94. package/dist/admin/surface/test/GoalSection.js +39 -32
  95. package/dist/admin/surface/test/LengthSection.js +46 -34
  96. package/dist/admin/surface/test/Rail.d.ts +4 -0
  97. package/dist/admin/surface/test/Rail.js +83 -0
  98. package/dist/admin/surface/test/ReadinessBar.d.ts +5 -0
  99. package/dist/admin/surface/test/ReadinessBar.js +33 -0
  100. package/dist/admin/surface/test/Section.d.ts +1 -1
  101. package/dist/admin/surface/test/Section.js +36 -16
  102. package/dist/admin/surface/test/TargetingSection.js +168 -142
  103. package/dist/admin/surface/test/TestPage.d.ts +2 -1
  104. package/dist/admin/surface/test/TestPage.js +146 -68
  105. package/dist/admin/surface/test/VersionsSection.js +151 -120
  106. package/dist/admin/surface/test/fields.d.ts +11 -4
  107. package/dist/admin/surface/test/fields.js +84 -85
  108. package/dist/admin/surface/test/jumpToSection.d.ts +3 -0
  109. package/dist/admin/surface/test/jumpToSection.js +10 -0
  110. package/dist/admin/surface/test/pageParts.js +9 -3
  111. package/dist/admin/surface/test/testPage.css +81 -14
  112. package/dist/admin/surface/useTestDocument.js +10 -4
  113. package/dist/admin/targeting/targeting.model.d.ts +1 -3
  114. package/dist/admin/targeting/targeting.model.js +3 -8
  115. package/dist/admin/theme.css +115 -36
  116. package/dist/admin/theme.d.ts +38 -0
  117. package/dist/admin/theme.js +38 -1
  118. package/dist/admin/useApi.d.ts +17 -2
  119. package/dist/admin/views/ExperimentsRootView.js +12 -2
  120. package/dist/collections/experiments/config.js +25 -0
  121. package/dist/collections/experiments/lockHook.js +3 -1
  122. package/dist/collections/identities.js +6 -1
  123. package/dist/collections/metrics.js +7 -0
  124. package/dist/collections/metricsGuards.d.ts +9 -0
  125. package/dist/collections/metricsGuards.js +33 -0
  126. package/dist/contentDone.d.ts +4 -0
  127. package/dist/contentDone.js +5 -0
  128. package/dist/db/identities.d.ts +8 -7
  129. package/dist/db/identities.js +9 -6
  130. package/dist/db/metricUsage.d.ts +19 -0
  131. package/dist/db/metricUsage.js +54 -0
  132. package/dist/db/names.d.ts +5 -0
  133. package/dist/db/names.js +5 -0
  134. package/dist/db/publishedContent.d.ts +10 -0
  135. package/dist/db/publishedContent.js +43 -0
  136. package/dist/db/results.d.ts +2 -0
  137. package/dist/db/results.js +6 -2
  138. package/dist/db/snapshots.d.ts +18 -6
  139. package/dist/db/snapshots.js +23 -7
  140. package/dist/db/sweep.js +11 -6
  141. package/dist/endpoints/index.js +19 -1
  142. package/dist/endpoints/results.js +5 -0
  143. package/dist/endpoints/sanitizeReadinessDetail.d.ts +1 -0
  144. package/dist/endpoints/sanitizeReadinessDetail.js +2 -0
  145. package/dist/endpoints/summary.js +20 -2
  146. package/dist/endpoints/transitions.d.ts +15 -0
  147. package/dist/endpoints/transitions.js +36 -0
  148. package/dist/errors.d.ts +8 -0
  149. package/dist/errors.js +28 -0
  150. package/dist/exports/index.d.ts +2 -1
  151. package/dist/exports/index.js +1 -0
  152. package/dist/exports/puckEditor.d.ts +1 -0
  153. package/dist/exports/puckEditor.js +1 -0
  154. package/dist/exports/rsc.d.ts +5 -1
  155. package/dist/exports/rsc.js +2 -0
  156. package/dist/hooks/pageGuard.d.ts +5 -0
  157. package/dist/hooks/pageGuard.js +121 -0
  158. package/dist/log.js +6 -3
  159. package/dist/manifest/compile.d.ts +5 -0
  160. package/dist/manifest/compile.js +5 -1
  161. package/dist/options.js +23 -0
  162. package/dist/plugin.js +26 -2
  163. package/dist/puck/copy.d.ts +1 -1
  164. package/dist/puck/copy.js +1 -1
  165. package/dist/puck/editor/editor.chrome.css +235 -0
  166. package/dist/puck/editor/editor.css +241 -43
  167. package/dist/results/aggregate.js +4 -3
  168. package/dist/results/analyse.d.ts +4 -1
  169. package/dist/results/analyse.js +27 -11
  170. package/dist/results/cuped.js +3 -2
  171. package/dist/results/identityCredit.d.ts +15 -7
  172. package/dist/results/identityCredit.js +32 -33
  173. package/dist/results/rolloutUnits.js +2 -1
  174. package/dist/results/snapshot.js +8 -2
  175. package/dist/results/srm.js +2 -1
  176. package/dist/sanitizeReadinessDetail.d.ts +7 -0
  177. package/dist/sanitizeReadinessDetail.js +54 -0
  178. package/dist/server/linkIdentity.d.ts +10 -1
  179. package/dist/server/linkIdentity.js +17 -15
  180. package/dist/server/metadata.d.ts +5 -0
  181. package/dist/server/metadata.js +9 -1
  182. package/dist/server/runResults.d.ts +3 -2
  183. package/dist/server/runResults.js +121 -31
  184. package/dist/targeting/values.d.ts +4 -0
  185. package/dist/targeting/values.js +10 -0
  186. package/dist/transitions/addMetric.js +8 -0
  187. package/dist/transitions/archive.js +2 -3
  188. package/dist/transitions/checkStart.d.ts +6 -3
  189. package/dist/transitions/checkStart.js +15 -0
  190. package/dist/transitions/contentFields.d.ts +7 -0
  191. package/dist/transitions/contentFields.js +20 -0
  192. package/dist/transitions/decide.js +6 -1
  193. package/dist/transitions/dismissContentEntry.d.ts +8 -0
  194. package/dist/transitions/dismissContentEntry.js +45 -0
  195. package/dist/transitions/makeContent.d.ts +5 -2
  196. package/dist/transitions/makeContent.js +217 -118
  197. package/dist/transitions/phaseRecord.d.ts +8 -2
  198. package/dist/transitions/placements.d.ts +1 -0
  199. package/dist/transitions/placements.js +4 -0
  200. package/dist/transitions/preflightData.d.ts +1 -1
  201. package/dist/transitions/preflightData.js +42 -4
  202. package/dist/transitions/releasePages.d.ts +8 -0
  203. package/dist/transitions/releasePages.js +26 -0
  204. package/dist/transitions/restoreContentEntry.d.ts +8 -0
  205. package/dist/transitions/restoreContentEntry.js +47 -0
  206. package/dist/transitions/start.js +30 -5
  207. package/dist/types/contracts.d.ts +14 -0
  208. package/dist/types/endpoints.d.ts +34 -3
  209. package/dist/types/index.d.ts +1 -0
  210. package/dist/types/index.js +1 -0
  211. package/dist/types/reasons.d.ts +3 -0
  212. package/dist/types/reasons.js +1 -0
  213. package/dist/types/snapshot.d.ts +6 -1
  214. package/dist/ui/Banner.d.ts +2 -0
  215. package/dist/ui/Banner.js +2 -0
  216. package/dist/ui/Breadcrumb.d.ts +7 -0
  217. package/dist/ui/Breadcrumb.js +25 -0
  218. package/dist/ui/Button.d.ts +15 -0
  219. package/dist/ui/Button.js +32 -0
  220. package/dist/ui/Card.d.ts +12 -0
  221. package/dist/ui/Card.js +40 -0
  222. package/dist/ui/Checkbox.d.ts +2 -0
  223. package/dist/ui/Checkbox.js +2 -0
  224. package/dist/{admin/components → ui}/ChipsInput.d.ts +4 -3
  225. package/dist/{admin/components → ui}/ChipsInput.js +20 -12
  226. package/dist/ui/Dialog.d.ts +13 -0
  227. package/dist/ui/Dialog.js +85 -0
  228. package/dist/ui/EmptyState.d.ts +2 -0
  229. package/dist/ui/EmptyState.js +2 -0
  230. package/dist/ui/Feedback.d.ts +24 -0
  231. package/dist/ui/Feedback.js +40 -0
  232. package/dist/ui/Inputs.d.ts +34 -0
  233. package/dist/ui/Inputs.js +225 -0
  234. package/dist/ui/NumberInput.d.ts +2 -0
  235. package/dist/ui/NumberInput.js +2 -0
  236. package/dist/ui/Range.d.ts +2 -0
  237. package/dist/ui/Range.js +2 -0
  238. package/dist/ui/SearchInput.d.ts +2 -0
  239. package/dist/ui/SearchInput.js +2 -0
  240. package/dist/ui/SectionCard.d.ts +2 -0
  241. package/dist/ui/SectionCard.js +2 -0
  242. package/dist/ui/Select.d.ts +2 -0
  243. package/dist/ui/Select.js +2 -0
  244. package/dist/ui/StatusChip.d.ts +2 -0
  245. package/dist/ui/StatusChip.js +2 -0
  246. package/dist/ui/Tabs.d.ts +14 -0
  247. package/dist/ui/Tabs.js +54 -0
  248. package/dist/ui/TextInput.d.ts +2 -0
  249. package/dist/ui/TextInput.js +2 -0
  250. package/dist/ui/Textarea.d.ts +2 -0
  251. package/dist/ui/Textarea.js +2 -0
  252. package/dist/ui/Toggle.d.ts +2 -0
  253. package/dist/ui/Toggle.js +2 -0
  254. package/dist/ui/accent.d.ts +21 -0
  255. package/dist/ui/accent.js +81 -0
  256. package/dist/ui/index.d.ts +9 -0
  257. package/dist/ui/index.js +9 -0
  258. package/dist/ui/ui.css +92 -0
  259. package/package.json +1 -1
  260. package/dist/admin/components/chipsInput.css +0 -11
  261. package/dist/admin/results/ArmTable.d.ts +0 -8
  262. package/dist/admin/results/ArmTable.js +0 -113
  263. package/dist/admin/results/VerdictCard.d.ts +0 -9
  264. package/dist/admin/surface/goals/GoalsBreadcrumb.d.ts +0 -5
  265. package/dist/admin/surface/goals/GoalsBreadcrumb.js +0 -47
  266. package/dist/admin/surface/test/TestHeader.d.ts +0 -4
  267. package/dist/admin/surface/test/TestHeader.js +0 -70
@@ -1,4 +1,6 @@
1
+ import { XpAnalysisError } from '../errors.js';
1
2
  import { safeError } from '../log.js';
3
+ import { createLocalReq } from 'payload';
2
4
  import { resultsCandidates, resultsExecutor, writeSnapshots } from '../db/snapshots.js';
3
5
  import { sweepEvents } from '../db/sweep.js';
4
6
  import { armMismatchCount, identityRows, metricEventRows, phaseExposureRows, rolloutExposureRows } from '../db/results.js';
@@ -6,8 +8,8 @@ import { raceDeadline, ResultsBudgetError, RESULTS_TIMEOUT_MARGIN_MS } from '../
6
8
  import { analysePhase } from '../results/analyse.js';
7
9
  import { firstExposures, perUnitValues, timestampMs } from '../results/aggregate.js';
8
10
  import { rolloutAnalysisUnits } from '../results/rolloutUnits.js';
9
- import { creditUserEvents } from '../results/identityCredit.js';
10
- 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) {
11
13
  const { phase } = candidate;
12
14
  // Rollout assignment preserves the original test ranges, but its SRM compares
13
15
  // only the shipped group against holdout with weights {100-h, h}.
@@ -59,6 +61,7 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
59
61
  ...new Set(identities.map((identity)=>identity.userId))
60
62
  ];
61
63
  const linked = new Set(userIds);
64
+ const identityConflicts = new Set();
62
65
  const credit = (events, userKeyed)=>{
63
66
  if (candidate.unitType === 'user') return events.map((event)=>({
64
67
  ...event,
@@ -69,13 +72,12 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
69
72
  value: event.value,
70
73
  ts: event.ts
71
74
  }));
72
- const credited = creditUserEvents(units, identities, userEvents).events;
75
+ const credited = creditUserEvents(units, identities, userEvents, (userId)=>identityConflicts.add(userId)).events;
73
76
  return userKeyed ? credited : [
74
77
  ...events.filter((event)=>event.userId == null || !linked.has(event.userId)),
75
78
  ...credited
76
79
  ];
77
80
  };
78
- const diagnostics = creditUserEvents(units, identities, []);
79
81
  const metricData = [];
80
82
  for (const definition of phase.metricDefinitions){
81
83
  try {
@@ -89,7 +91,7 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
89
91
  let events, covariates;
90
92
  if (definition.source.startsWith('events:')) {
91
93
  const eventName = definition.source.slice(7);
92
- if (!options.eventSources?.some((source)=>source.name === eventName)) throw new Error('Metric source unavailable');
94
+ if (!options.eventSources?.some((source)=>source.name === eventName)) throw new XpAnalysisError('source-unavailable');
93
95
  const read = async (start)=>{
94
96
  if (candidate.unitType === 'user') return metricEventRows(exec, {
95
97
  eventName,
@@ -132,9 +134,9 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
132
134
  }
133
135
  } else {
134
136
  const source = options.metricSources?.find((source)=>source.id === definition.source);
135
- if (!source || source.kind !== kind || definition.keyedBy && source.keyedBy !== definition.keyedBy) throw new Error('Metric source unavailable');
137
+ if (!source || source.kind !== kind || definition.keyedBy && source.keyedBy !== definition.keyedBy) throw new XpAnalysisError('source-unavailable');
136
138
  const userKeyed = source.keyedBy === 'user' && candidate.unitType === 'visitor';
137
- if (candidate.unitType === 'user' && source.keyedBy !== 'user') throw new Error('Metric unit type unavailable');
139
+ if (candidate.unitType === 'user' && source.keyedBy !== 'user') throw new XpAnalysisError('unit-type-unavailable');
138
140
  const sourceIds = userKeyed ? userIds : ids;
139
141
  events = credit(await raceDeadline(deadline, ()=>source.query({
140
142
  from,
@@ -142,35 +144,51 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
142
144
  unitIds: sourceIds
143
145
  })), userKeyed);
144
146
  if (phase.analysis.cuped === 'auto' && source.covariate) {
145
- const sourceUnits = userKeyed ? creditUserEvents(units, identities, userIds.map((userId)=>({
146
- userId,
147
- ts: now,
148
- value: 0
149
- }))).events.map((event)=>({
150
- unitId: identities.find((identity)=>identity.anonymousId === event.unitId).userId,
151
- firstExposureTs: units.get(event.unitId).firstTs
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
152
169
  })) : [
153
170
  ...units
154
171
  ].map(([unitId, unit])=>({
155
172
  unitId,
156
- firstExposureTs: unit.firstTs
173
+ firstExposureTs: new Date(timestampMs(unit.firstTs))
157
174
  }));
158
175
  const pre = await raceDeadline(deadline, ()=>source.covariate({
159
176
  units: sourceUnits,
160
177
  lookbackDays: 14
161
178
  }));
162
- if (pre.some((row)=>!Number.isFinite(row.value))) throw new Error('Covariates must be finite');
163
- const credited = credit(pre.map((row)=>({
164
- ...row,
165
- ts: now
166
- })), userKeyed);
167
- covariates = new Map(credited.map((row)=>[
168
- row.unitId,
169
- row.value
170
- ]));
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
+ }));
171
189
  }
172
190
  }
173
- if (events.some((event)=>!Number.isFinite(event.value))) throw new Error('Metric values must be finite');
191
+ if (events.some((event)=>!Number.isFinite(event.value))) throw new XpAnalysisError('non-finite-values');
174
192
  metricData.push({
175
193
  metricId: definition.metricId,
176
194
  values: perUnitValues(units, events, {
@@ -183,7 +201,22 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
183
201
  } : {}
184
202
  });
185
203
  } catch (error) {
186
- const reason = error instanceof Error ? error.message : 'Metric source unavailable';
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');
187
220
  metricData.push({
188
221
  metricId: definition.metricId,
189
222
  values: new Map(),
@@ -205,9 +238,14 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
205
238
  units,
206
239
  metricData,
207
240
  status,
241
+ onMetricError: (error, metricId)=>logger.error({
242
+ err: safeError(error),
243
+ experimentId: phase.experiment,
244
+ metricId
245
+ }, 'Experiment metric analysis failed'),
208
246
  excludedMultiArm: exposed.excludedMultiArm,
209
247
  armMismatch: await armMismatchCount(exec, query),
210
- identityConflictShare: userIds.length ? diagnostics.conflicts / userIds.length : 0,
248
+ identityConflictShare: userIds.length ? identityConflicts.size / userIds.length : 0,
211
249
  ...phase.phaseKind === 'rollout' ? {
212
250
  winnerArm: phase.arms.find((arm)=>arm.kind !== 'holdout').key
213
251
  } : {}
@@ -252,8 +290,8 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
252
290
  experimentId
253
291
  }, 'Experiment scheduling failed');
254
292
  }
255
- // Query order is oldest phase snapshot first; grouping keeps each experiment's
256
- // phases together while preserving that scheduling priority.
293
+ // Analysis precedes health-only refreshes, then oldest phase snapshots first;
294
+ // grouping keeps each experiment's phases together with that priority.
257
295
  const experiments = new Map();
258
296
  for (const candidate of candidates){
259
297
  const id = candidate.phase.experiment;
@@ -272,10 +310,62 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
272
310
  const snapshots = [];
273
311
  for (const phase of phases){
274
312
  if (Date.now() >= analysisDeadline) throw new ResultsBudgetError();
275
- snapshots.push(await analyseCandidate(exec, phase, options, now, analysisDeadline, summary.unavailableMetrics));
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));
276
318
  }
277
319
  if (Date.now() >= analysisDeadline) throw new ResultsBudgetError();
278
- await writeSnapshots(exec, snapshots);
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;
354
+ }
355
+ if (Date.now() >= analysisDeadline) throw new ResultsBudgetError();
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);
279
369
  summary.ran++;
280
370
  if (summary.processedIds.length < 100) summary.processedIds.push(id);
281
371
  } catch (error) {
@@ -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
- const report = decision?.contentReport;
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?: string;
49
- flagPlacementError?: string;
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: test.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
  }
@@ -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?: string;
8
- error?: string;
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;