@ak--47/dungeon-master 1.3.0 → 1.4.0

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 (98) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dungeons/technical/array-of-object-lookup.js +0 -2
  3. package/dungeons/technical/hook-helpers-verify.js +89 -0
  4. package/dungeons/technical/identity-model-verify.js +47 -0
  5. package/dungeons/technical/pattern-aggregate-by-bin.js +41 -0
  6. package/dungeons/technical/pattern-attributed-by-source.js +42 -0
  7. package/dungeons/technical/pattern-frequency-by-frequency.js +40 -0
  8. package/dungeons/technical/pattern-funnel-frequency.js +54 -0
  9. package/dungeons/technical/pattern-ttc-by-segment.js +45 -0
  10. package/dungeons/technical/simple.js +3 -4
  11. package/dungeons/technical/simplest-schema.json +5 -0
  12. package/dungeons/technical/text-generation.js +1 -1
  13. package/dungeons/vertical/ai-platform.js +216 -164
  14. package/dungeons/vertical/community.js +74 -41
  15. package/dungeons/vertical/crypto.js +343 -233
  16. package/dungeons/vertical/dating.js +246 -292
  17. package/dungeons/vertical/devtools.js +179 -70
  18. package/dungeons/vertical/ecommerce.js +275 -122
  19. package/dungeons/vertical/education.js +345 -312
  20. package/dungeons/vertical/fintech.js +445 -314
  21. package/dungeons/vertical/fitness.js +199 -165
  22. package/dungeons/vertical/food-delivery.js +309 -330
  23. package/dungeons/vertical/gaming.js +387 -241
  24. package/dungeons/vertical/healthcare.js +149 -66
  25. package/dungeons/vertical/insurance-application.js +173 -75
  26. package/dungeons/vertical/logistics.js +107 -12
  27. package/dungeons/vertical/marketplace.js +171 -76
  28. package/dungeons/vertical/media.js +259 -402
  29. package/dungeons/vertical/real-estate.js +336 -318
  30. package/dungeons/vertical/sass.js +294 -288
  31. package/dungeons/vertical/social.js +289 -234
  32. package/dungeons/vertical/travel.js +66 -20
  33. package/index.js +17 -17
  34. package/lib/core/config-validator.js +226 -175
  35. package/lib/core/context.js +10 -24
  36. package/lib/core/storage.js +6 -1
  37. package/lib/generators/events.js +57 -104
  38. package/lib/generators/funnels.js +212 -93
  39. package/lib/generators/mirror.js +3 -2
  40. package/lib/generators/product-names.js +1 -1
  41. package/lib/generators/scd.js +2 -1
  42. package/lib/generators/text.js +1 -1
  43. package/lib/hook-helpers/_internal.js +23 -0
  44. package/lib/hook-helpers/cohort.js +124 -0
  45. package/lib/hook-helpers/identity.js +56 -0
  46. package/lib/hook-helpers/index.js +44 -0
  47. package/lib/hook-helpers/inject.js +99 -0
  48. package/lib/hook-helpers/mutate.js +151 -0
  49. package/lib/hook-helpers/timing.js +99 -0
  50. package/lib/hook-patterns/aggregate-per-user-by-bin.js +38 -0
  51. package/lib/hook-patterns/attributed-by-source.js +72 -0
  52. package/lib/hook-patterns/frequency-by-frequency.js +46 -0
  53. package/lib/hook-patterns/funnel-frequency-breakdown.js +73 -0
  54. package/lib/hook-patterns/index.js +14 -0
  55. package/lib/hook-patterns/time-to-convert-by-segment.js +41 -0
  56. package/lib/orchestrators/user-loop.js +143 -293
  57. package/lib/utils/utils.js +97 -52
  58. package/lib/verify/emulate-breakdown.js +281 -0
  59. package/lib/verify/index.js +12 -0
  60. package/lib/verify/verify-dungeon.js +61 -0
  61. package/package.json +6 -4
  62. package/scripts/smoke-test-all.mjs +162 -0
  63. package/scripts/verify-runner.mjs +72 -24
  64. package/types.d.ts +434 -225
  65. package/dungeons/technical/ad-spend-schema.json +0 -128
  66. package/dungeons/technical/anonymous-users-schema.json +0 -92
  67. package/dungeons/technical/array-of-object-lookup-schema.json +0 -191
  68. package/dungeons/technical/experiments-schema.json +0 -203
  69. package/dungeons/technical/foobar-schema.json +0 -362
  70. package/dungeons/technical/group-analytics-schema.json +0 -241
  71. package/dungeons/technical/mirror-strategies-schema.json +0 -84
  72. package/dungeons/technical/nested-objects-schema.json +0 -145
  73. package/dungeons/technical/retention-cadence-schema.json +0 -37
  74. package/dungeons/technical/sanity-schema.json +0 -185
  75. package/dungeons/technical/scale-test-schema.json +0 -70
  76. package/dungeons/technical/scd-schema.json +0 -467
  77. package/dungeons/technical/simple-schema.json +0 -362
  78. package/dungeons/technical/text-generation-schema.json +0 -1062
  79. package/dungeons/vertical/ai-platform-schema.json +0 -617
  80. package/dungeons/vertical/community-schema.json +0 -579
  81. package/dungeons/vertical/crypto-schema.json +0 -546
  82. package/dungeons/vertical/dating-schema.json +0 -401
  83. package/dungeons/vertical/devtools-schema.json +0 -601
  84. package/dungeons/vertical/ecommerce-schema.json +0 -604
  85. package/dungeons/vertical/education-schema.json +0 -5686
  86. package/dungeons/vertical/fintech-schema.json +0 -630
  87. package/dungeons/vertical/fitness-schema.json +0 -530
  88. package/dungeons/vertical/food-delivery-schema.json +0 -36728
  89. package/dungeons/vertical/gaming-schema.json +0 -2703
  90. package/dungeons/vertical/healthcare-schema.json +0 -549
  91. package/dungeons/vertical/insurance-application-schema.json +0 -485
  92. package/dungeons/vertical/logistics-schema.json +0 -574
  93. package/dungeons/vertical/marketplace-schema.json +0 -533
  94. package/dungeons/vertical/media-schema.json +0 -4749
  95. package/dungeons/vertical/real-estate-schema.json +0 -527
  96. package/dungeons/vertical/sass-schema.json +0 -3128
  97. package/dungeons/vertical/social-schema.json +0 -620
  98. package/dungeons/vertical/travel-schema.json +0 -580
@@ -10,25 +10,28 @@
10
10
  /** @typedef {import('../../types').Context} Context */
11
11
 
12
12
  import dayjs from "dayjs";
13
+ import { randomUUID } from "node:crypto";
13
14
  import * as u from "../utils/utils.js";
14
15
  import { dataLogger as logger } from "../utils/logger.js";
15
16
 
16
17
  // Keys that must never be nulled by data quality gremlins
17
18
  const NULL_EXEMPT_KEYS = new Set(['event', 'time', 'insert_id', 'user_id', 'device_id', 'distinct_id', '_drop', '_anomaly', '_persona']);
18
19
 
20
+
19
21
  /**
20
22
  * Creates a Mixpanel event with a flat shape
21
- * @param {Context} context - Context object containing config, defaults, etc.
22
- * @param {string} distinct_id - User identifier
23
+ * @param {Context} context
24
+ * @param {string} distinct_id
23
25
  * @param {number} earliestTime - Unix timestamp for earliest possible event time
24
26
  * @param {Object} chosenEvent - Event configuration object
25
27
  * @param {string[]} [anonymousIds] - Array of anonymous/device IDs
26
- * @param {string[]} [sessionIds] - Array of session IDs
27
28
  * @param {Object} [superProps] - Super properties to add to event
28
29
  * @param {Array} [groupKeys] - Group key configurations
29
- * @param {boolean} [isFirstEvent] - Whether this is the user's first event
30
- * @param {boolean} [skipDefaults] - Whether to skip adding default properties
31
- * @returns {Promise<Object>} Generated event object
30
+ * @param {boolean} [isFirstEvent]
31
+ * @param {boolean} [skipDefaults]
32
+ * @param {Object} [featureCtx] - Feature context (persona, worldEvents, dataQuality)
33
+ * @param {Object} [identityCtx] - Phase 2 identity context ({ stamping, devicePool })
34
+ * @returns {Promise<Object>}
32
35
  */
33
36
  export async function makeEvent(
34
37
  context,
@@ -36,12 +39,12 @@ export async function makeEvent(
36
39
  earliestTime,
37
40
  chosenEvent,
38
41
  anonymousIds = [],
39
- sessionIds = [],
40
42
  superProps = {},
41
43
  groupKeys = [],
42
44
  isFirstEvent = false,
43
45
  skipDefaults = false,
44
- featureCtx = {}
46
+ featureCtx = {},
47
+ identityCtx = null
45
48
  ) {
46
49
  // Validate required parameters
47
50
  if (!distinct_id) throw new Error("no distinct_id");
@@ -89,9 +92,16 @@ export async function makeEvent(
89
92
  defaultProps.browser = u.choose(defaults.browsers());
90
93
  }
91
94
 
92
- // Add campaigns with attribution likelihood
93
- if (hasCampaigns && chance.bool({ likelihood: 25 })) {
94
- defaultProps.campaigns = u.pickRandom(defaults.campaigns());
95
+ // Add campaigns with attribution likelihood.
96
+ // When any event has isAttributionEvent, only stamp UTMs on those events (25% chance).
97
+ // Otherwise, backwards-compat: ~25% of all events get UTMs.
98
+ if (hasCampaigns) {
99
+ const shouldStamp = config.hasAttributionFlags
100
+ ? (chosenEvent.isAttributionEvent && chance.bool({ likelihood: 25 }))
101
+ : chance.bool({ likelihood: 25 });
102
+ if (shouldStamp) {
103
+ defaultProps.campaigns = u.pickRandom(defaults.campaigns());
104
+ }
95
105
  }
96
106
 
97
107
  // PERFORMANCE: Use pre-computed device pool instead of rebuilding every time
@@ -101,34 +111,41 @@ export async function makeEvent(
101
111
 
102
112
  // Set event time using TimeSoup for realistic distribution
103
113
  if (earliestTime) {
104
- let shiftedTimestamp;
114
+ let unixTime;
105
115
  if (isFirstEvent) {
106
- shiftedTimestamp = earliestTime + context.TIME_SHIFT_SECONDS;
116
+ unixTime = earliestTime;
107
117
  } else {
108
- // TimeSoup returns unix seconds; shift and convert to ISO once
109
- const soupTimestamp = u.TimeSoup(earliestTime, context.FIXED_NOW, peaks, deviation, mean, dayOfWeekWeights, hourOfDayWeights, context.TIME_SHIFT_SECONDS);
110
- shiftedTimestamp = soupTimestamp + context.TIME_SHIFT_SECONDS;
118
+ unixTime = u.TimeSoup(earliestTime, context.FIXED_NOW, peaks, deviation, mean, dayOfWeekWeights, hourOfDayWeights);
111
119
  }
112
- // Drop events that would land in the future (Mixpanel rewrites these to "now", causing pile-ups)
113
- if (shiftedTimestamp > context.MAX_TIME) {
114
- eventTemplate._drop = true;
115
- }
116
- eventTemplate.time = dayjs.unix(Math.min(shiftedTimestamp, context.MAX_TIME)).toISOString();
120
+ eventTemplate.time = dayjs.unix(unixTime).toISOString();
117
121
  }
118
122
 
119
- // Add anonymous identifiers
120
- if (anonymousIds.length) {
121
- eventTemplate.device_id = u.pickRandom(anonymousIds);
123
+ // ── Phase 2 identity stamping ──
124
+ // `identityCtx.stamping` modes:
125
+ // "both" → user_id + device_id (when pool non-empty). DEFAULT.
126
+ // "user_only" → user_id only (post-auth funnel events).
127
+ // "device_only" → device_id only (pre-auth funnel events).
128
+ // "stitch" → both (the one stitch event per converted born-in-dataset user).
129
+ // Callers (funnels.js / user-loop.js) compute the mode based on the user's auth state
130
+ // and the funnel's `isAuthEvent` placement. Default "both" preserves backwards-compat
131
+ // for dungeons that don't flag auth events. The legacy 42%-per-event user_id dice is
132
+ // gone — every event now gets user_id by default.
133
+ const stamping = (identityCtx && identityCtx.stamping) || 'both';
134
+ const wantsUser = stamping === 'both' || stamping === 'user_only' || stamping === 'stitch';
135
+ const wantsDevice = stamping === 'both' || stamping === 'device_only' || stamping === 'stitch';
136
+ const devicePool = (identityCtx && identityCtx.devicePool) || anonymousIds || [];
137
+
138
+ if (wantsDevice && devicePool && devicePool.length) {
139
+ eventTemplate.device_id = u.pickRandom(devicePool);
122
140
  }
123
-
124
- // Session IDs are assigned post-hoc in user-loop.js based on temporal gaps
125
-
126
- // Sometimes add user_id (for attribution modeling)
127
- if (!isFirstEvent && chance.bool({ likelihood: 42 })) {
141
+ if (wantsUser) {
128
142
  eventTemplate.user_id = distinct_id;
129
143
  }
144
+ // Session IDs are assigned post-hoc in user-loop.js based on temporal gaps
130
145
 
131
- // Ensure we have either user_id or device_id
146
+ // Floor: every event must carry at least one of user_id / device_id (storage layer
147
+ // rejects events lacking both). If the stamping mode is "device_only" but there's no
148
+ // device pool, fall back to user_id rather than producing an invalid event.
132
149
  if (!eventTemplate.user_id && !eventTemplate.device_id) {
133
150
  eventTemplate.user_id = distinct_id;
134
151
  }
@@ -171,36 +188,14 @@ export async function makeEvent(
171
188
  addGroupProperties(eventTemplate, groupKeys);
172
189
 
173
190
  // ── Event-level features (applied before hooks, so hooks can override) ──
174
- const { userLocation, persona, worldEventsTimeline, resolvedFeatures, resolvedAnomalies, dataQuality: dq, geo: geoConfig, userCampaign } = featureCtx;
175
-
176
- // Feature 6: Attribution — stamp UTM properties on events as touchpoints
177
- // Mixpanel attribution analysis needs UTM on EVENTS, not just profiles.
178
- // Pattern: first events carry acquisition UTM, later events occasionally carry re-engagement UTM.
179
- if (userCampaign) {
180
- // ~40% of events carry UTM (simulates page loads, session starts, ad clicks)
181
- // First events (isFirstEvent) always carry UTM (acquisition touchpoint)
182
- if (isFirstEvent || chance.bool({ likelihood: 40 })) {
183
- eventTemplate.utm_source = userCampaign.source;
184
- eventTemplate.utm_campaign = userCampaign.name;
185
- if (userCampaign.medium) eventTemplate.utm_medium = userCampaign.medium;
186
- if (userCampaign.utm_content) eventTemplate.utm_content = userCampaign.utm_content;
187
- if (userCampaign.utm_term) eventTemplate.utm_term = userCampaign.utm_term;
188
- }
189
- }
190
-
191
- // Feature 7: Sticky geo location
192
- if (userLocation && geoConfig?.sticky) {
193
- // Override the random location with user's sticky location
194
- for (const key in userLocation) {
195
- eventTemplate[key] = userLocation[key];
196
- }
197
- }
191
+ const { userLocation, persona, worldEventsTimeline, dataQuality: dq } = featureCtx;
198
192
 
199
- // Perf 1: Compute eventUnix once for all time-based checks
200
- // Subtracts TIME_SHIFT to convert real time back to FIXED time (world events/features use FIXED time)
193
+ // Perf 1: Compute eventUnix once for all time-based checks. World events
194
+ // were resolved against the dataset window (no shift), and event times now
195
+ // also live in that same window — direct unix conversion works.
201
196
  let eventUnix = null;
202
- if ((worldEventsTimeline || resolvedFeatures) && eventTemplate.time) {
203
- eventUnix = dayjs(eventTemplate.time).subtract(context.TIME_SHIFT_SECONDS, 'seconds').unix();
197
+ if (worldEventsTimeline && eventTemplate.time) {
198
+ eventUnix = dayjs(eventTemplate.time).unix();
204
199
  }
205
200
 
206
201
  // Feature 2: World events — inject props for active events at this timestamp
@@ -227,47 +222,6 @@ export async function makeEvent(
227
222
  }
228
223
  }
229
224
 
230
- // Feature 8: Progressive feature adoption
231
- if (resolvedFeatures && eventUnix !== null) {
232
- const daysSinceBegin = (eventUnix - context.FIXED_BEGIN) / 86400;
233
- for (const feat of resolvedFeatures) {
234
- const affects = feat.affectsEvents;
235
- if (affects !== "*" && !(Array.isArray(affects) && affects.includes(eventTemplate.event))) continue;
236
- if (daysSinceBegin < feat.launchDay) {
237
- if (feat.defaultBefore !== undefined) {
238
- eventTemplate[feat.property] = feat.defaultBefore;
239
- }
240
- } else {
241
- const daysSinceLaunch = daysSinceBegin - feat.launchDay;
242
- const { k, midpoint } = feat._resolvedCurve || { k: 0.08, midpoint: 30 };
243
- const adoptionProb = 1 / (1 + Math.exp(-k * (daysSinceLaunch - midpoint)));
244
- if (chance.bool({ likelihood: Math.min(100, adoptionProb * 100) })) {
245
- eventTemplate[feat.property] = u.pickRandom(feat._adoptedValues);
246
- } else if (feat.defaultBefore !== undefined) {
247
- eventTemplate[feat.property] = feat.defaultBefore;
248
- } else if (feat.values.length > 0) {
249
- eventTemplate[feat.property] = feat.values[0];
250
- }
251
- }
252
- }
253
- }
254
-
255
- // Feature 9: Anomaly extreme values
256
- if (resolvedAnomalies && eventTemplate.event) {
257
- for (const a of resolvedAnomalies) {
258
- if (a.type === 'extreme_value' && a.event === eventTemplate.event && a.property) {
259
- if (chance.bool({ likelihood: (a.frequency ?? 0.001) * 100 })) {
260
- const currentVal = eventTemplate[a.property];
261
- if (typeof currentVal === 'number') {
262
- eventTemplate[a.property] = currentVal * (a.multiplier || 10);
263
- }
264
- if (a.tag) eventTemplate._anomaly = a.tag;
265
- if (a.properties) Object.assign(eventTemplate, a.properties);
266
- }
267
- }
268
- }
269
- }
270
-
271
225
  // Feature 4: Data quality — null injection and timezone confusion
272
226
  if (dq) {
273
227
  // Null injection
@@ -292,10 +246,7 @@ export async function makeEvent(
292
246
  }
293
247
  }
294
248
 
295
- // Generate unique insert_id
296
- const distinctId = eventTemplate.user_id || eventTemplate.device_id || eventTemplate.distinct_id || distinct_id;
297
- const tuple = `${eventTemplate.event}-${eventTemplate.time}-${distinctId}`;
298
- eventTemplate.insert_id = u.quickHash(tuple);
249
+ eventTemplate.insert_id = randomUUID();
299
250
 
300
251
  // Call hook if configured (hooks override everything — they are the final authority)
301
252
  const { hook } = config;
@@ -303,7 +254,9 @@ export async function makeEvent(
303
254
  const hookedEvent = await hook(eventTemplate, "event", {
304
255
  user: { distinct_id },
305
256
  config,
306
- persona: featureCtx.persona || null
257
+ persona: featureCtx.persona || null,
258
+ datasetStart: context.DATASET_START_SECONDS,
259
+ datasetEnd: context.DATASET_END_SECONDS
307
260
  });
308
261
  // If hook returns a modified event, use it; otherwise use original
309
262
  if (hookedEvent && typeof hookedEvent === 'object') {
@@ -19,9 +19,17 @@ import { dataLogger as logger } from "../utils/logger.js";
19
19
  * @param {Object} profile - User profile object
20
20
  * @param {Object} scd - Slowly changing dimensions object
21
21
  * @param {Object} [persona] - User's assigned persona
22
- * @returns {Promise<[Array, boolean]>} Tuple of [events, didConvert]
22
+ * @param {Object} [featureCtx] - Persona / world-event / data-quality / etc. context.
23
+ * @param {Object} [attemptMeta] - Phase 2 multi-attempt + identity context. Shape:
24
+ * `{ isFirstFunnel, isBorn, attemptsConfig, attemptNumber, totalAttempts, isFinalAttempt,
25
+ * truncateBeforeAuth, devicePool }`. When omitted defaults to a single normal attempt
26
+ * with no identity stamping overrides (legacy behavior).
27
+ * @returns {Promise<[Array, boolean, number|null]>} Tuple `[events, didConvert, authTimeMs]`
28
+ * where `authTimeMs` is the unix-millisecond timestamp of the stitch event (the first
29
+ * `isAuthEvent` step that actually fired in this funnel run), or null if the user did
30
+ * not reach the stitch step in this run.
23
31
  */
24
- export async function makeFunnel(context, funnel, user, firstEventTime, profile = {}, scd = {}, persona = null, featureCtx = {}) {
32
+ export async function makeFunnel(context, funnel, user, firstEventTime, profile = {}, scd = {}, persona = null, featureCtx = {}, attemptMeta = null) {
25
33
  if (!funnel) throw new Error("no funnel");
26
34
  if (!user) throw new Error("no user");
27
35
 
@@ -29,48 +37,110 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
29
37
  const chance = u.getChance();
30
38
  const { hook = async (a) => a } = config;
31
39
 
40
+ // ── Phase 2 attempt + identity context ──
41
+ // Resolve a defensive default so legacy callers that don't pass attemptMeta still
42
+ // get a complete shape downstream (hook meta, identity stamping logic).
43
+ const meta = attemptMeta || {};
44
+ const attemptInfo = {
45
+ isFirstFunnel: !!meta.isFirstFunnel,
46
+ isBorn: meta.isBorn === undefined ? false : !!meta.isBorn,
47
+ attemptsConfig: meta.attemptsConfig || null,
48
+ attemptNumber: meta.attemptNumber || 1,
49
+ totalAttempts: meta.totalAttempts || 1,
50
+ isFinalAttempt: meta.isFinalAttempt === undefined ? true : !!meta.isFinalAttempt,
51
+ truncateBeforeAuth: !!meta.truncateBeforeAuth,
52
+ };
53
+ const devicePool = meta.devicePool || null;
54
+
32
55
  // Get session start events if configured
33
56
  const sessionStartEvents = config.events?.filter(a => a.isSessionStartEvent) || [];
34
57
 
35
58
  // Clone funnel to avoid mutating the original object
36
59
  funnel = { ...funnel };
37
60
 
38
- // Experiment handling: if funnel.experiment === true, create 3 variants
61
+ // Experiment handling: resolved by config-validator into funnel._experiment.
62
+ // Variant assignment is deterministic per-user (hash of userId + experiment name).
39
63
  let experimentVariant = null;
40
64
  let experimentName = null;
41
-
42
- if (funnel.experiment) {
43
- experimentName = funnel.name + ` Experiment` || "Unnamed Funnel";
44
-
45
- // Evenly distribute across 3 variants (33.33% each) using seeded chance
46
- const randomValue = chance.floating({ min: 0, max: 1 });
47
- if (randomValue < 0.333) {
48
- // Variant A: WORSE conversion, slower
49
- funnel.conversionRate = Math.max(1, Math.floor(funnel.conversionRate * 0.7));
50
- funnel.timeToConvert = Math.max(0.1, funnel.timeToConvert * 1.5);
51
- experimentVariant = "A";
52
- } else if (randomValue < 0.666) {
53
- // Variant B: BETTER conversion, faster
54
- funnel.conversionRate = Math.min(100, Math.ceil(funnel.conversionRate * 1.3));
55
- funnel.timeToConvert = Math.max(0.1, funnel.timeToConvert * 0.7);
56
- experimentVariant = "B";
57
- } else {
58
- // Variant C: CONTROL - original values (no changes)
59
- experimentVariant = "C";
65
+ let experimentMeta = null;
66
+
67
+ let expCfg = funnel._experiment;
68
+ if (!expCfg && funnel.experiment) {
69
+ const DEFAULT_VARIANTS = [
70
+ { name: 'Variant A', conversionMultiplier: 0.7, ttcMultiplier: 1.5, weight: 1 },
71
+ { name: 'Variant B', conversionMultiplier: 1.3, ttcMultiplier: 0.7, weight: 1 },
72
+ { name: 'Control', conversionMultiplier: 1.0, ttcMultiplier: 1.0, weight: 1 },
73
+ ];
74
+ expCfg = { name: (funnel.name ? funnel.name + ' Experiment' : 'Unnamed Experiment'), variants: DEFAULT_VARIANTS, startUnix: null };
75
+ }
76
+ if (expCfg) {
77
+ const isActive = !expCfg.startUnix || firstEventTime >= expCfg.startUnix;
78
+ if (isActive) {
79
+ experimentName = expCfg.name;
80
+ const userId = user.distinct_id || '';
81
+ const totalWeight = expCfg.variants.reduce((s, v) => s + v.weight, 0);
82
+ const hashVal = Number(u.quickHash(`${userId}:${experimentName}`)) % totalWeight;
83
+ let cumWeight = 0;
84
+ let chosenVariant = expCfg.variants[0];
85
+ let chosenIdx = 0;
86
+ for (let vi = 0; vi < expCfg.variants.length; vi++) {
87
+ cumWeight += expCfg.variants[vi].weight;
88
+ if (hashVal < cumWeight) { chosenVariant = expCfg.variants[vi]; chosenIdx = vi; break; }
89
+ }
90
+ experimentVariant = chosenVariant.name;
91
+ funnel.conversionRate = Math.min(100, Math.max(1,
92
+ Math.round((funnel.conversionRate || 50) * chosenVariant.conversionMultiplier)));
93
+ funnel.timeToConvert = Math.max(0.1,
94
+ (funnel.timeToConvert || 1) * chosenVariant.ttcMultiplier);
95
+ funnel._experimentName = experimentName;
96
+ funnel._experimentVariant = experimentVariant;
97
+ funnel.sequence = ["$experiment_started", ...funnel.sequence];
98
+ experimentMeta = {
99
+ name: experimentName,
100
+ variantName: experimentVariant,
101
+ variantIndex: chosenIdx,
102
+ conversionMultiplier: chosenVariant.conversionMultiplier,
103
+ ttcMultiplier: chosenVariant.ttcMultiplier,
104
+ startDate: expCfg.startUnix,
105
+ };
60
106
  }
107
+ }
61
108
 
62
- // Mark that this funnel has experiment metadata (used later)
63
- funnel._experimentName = experimentName;
64
- funnel._experimentVariant = experimentVariant;
65
-
66
- // Insert $experiment_started at beginning of sequence (clone array to avoid mutation)
67
- funnel.sequence = ["$experiment_started", ...funnel.sequence];
109
+ // Apply persona and world-event modifiers to the funnel BEFORE the hook fires,
110
+ // so funnel-pre sees the effective rate and has final authority.
111
+ if (persona && persona.conversionModifier) {
112
+ funnel.conversionRate = Math.min(100, Math.max(0, Math.round((funnel.conversionRate || 50) * persona.conversionModifier)));
113
+ }
114
+ const resolvedWorldEvents = /** @type {import('../../types').ResolvedWorldEvent[]} */ (config.worldEvents);
115
+ if (resolvedWorldEvents && firstEventTime) {
116
+ for (const we of resolvedWorldEvents) {
117
+ if (firstEventTime >= we.startUnix && firstEventTime < we.endUnix && we.conversionModifier !== 1.0) {
118
+ const seq = funnel.sequence || [];
119
+ const affects = we.affectsEvents;
120
+ if (affects === "*" || (Array.isArray(affects) && seq.some(s => affects.includes(s)))) {
121
+ funnel.conversionRate = Math.min(100, Math.max(0, Math.round((funnel.conversionRate || 50) * we.conversionModifier)));
122
+ }
123
+ }
124
+ }
68
125
  }
69
126
 
70
- // Call pre-funnel hook
71
- await hook(funnel, "funnel-pre", { user, profile, scd, funnel, config, firstEventTime });
127
+ // funnel-pre hook fires AFTER modifiers — hook has final authority on conversionRate,
128
+ // props, timeToConvert, and sequence.
129
+ await hook(funnel, "funnel-pre", {
130
+ user, profile, scd, funnel, config, firstEventTime,
131
+ datasetStart: context.DATASET_START_SECONDS,
132
+ datasetEnd: context.DATASET_END_SECONDS,
133
+ isFirstFunnel: attemptInfo.isFirstFunnel,
134
+ isBorn: attemptInfo.isBorn,
135
+ attemptsConfig: attemptInfo.attemptsConfig,
136
+ attemptNumber: attemptInfo.attemptNumber,
137
+ totalAttempts: attemptInfo.totalAttempts,
138
+ isFinalAttempt: attemptInfo.isFinalAttempt,
139
+ persona,
140
+ experiment: experimentMeta,
141
+ });
72
142
 
73
- // Extract funnel configuration
143
+ // Extract funnel configuration (post-hook — hook's mutations are the final word)
74
144
  let {
75
145
  sequence,
76
146
  conversionRate = 50,
@@ -83,7 +153,7 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
83
153
  bindPropsIndex = 0
84
154
  } = funnel;
85
155
 
86
- const { distinct_id, created, anonymousIds = [], sessionIds = [] } = user;
156
+ const { distinct_id, created, anonymousIds = [] } = user;
87
157
  const { superProps = {}, groupKeys = [] } = config;
88
158
 
89
159
  // Choose properties for this funnel instance
@@ -107,57 +177,61 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
107
177
  chance
108
178
  );
109
179
 
110
- // Apply persona conversion modifier (before hook, so hook can override via funnel-pre)
111
- if (persona && persona.conversionModifier) {
112
- adjustedConversionRate = Math.min(100, Math.max(0, Math.round(adjustedConversionRate * persona.conversionModifier)));
113
- }
114
-
115
- // Apply world event conversion modifier if active at firstEventTime
116
- const resolvedWorldEvents = /** @type {import('../../types').ResolvedWorldEvent[]} */ (config.worldEvents);
117
- if (resolvedWorldEvents && firstEventTime) {
118
- for (const we of resolvedWorldEvents) {
119
- if (firstEventTime >= we.startUnix && firstEventTime < we.endUnix && we.conversionModifier !== 1.0) {
120
- const affects = we.affectsEvents;
121
- if (affects === "*" || (Array.isArray(affects) && sequence.some(s => affects.includes(s)))) {
122
- adjustedConversionRate = Math.min(100, Math.max(0, Math.round(adjustedConversionRate * we.conversionModifier)));
123
- }
124
- }
125
- }
126
- }
127
-
128
- // Apply feature conversion lifts
129
- const resolvedFeatures = config.features;
130
- if (resolvedFeatures && firstEventTime) {
131
- const daysSinceBegin = (firstEventTime - context.FIXED_BEGIN) / 86400;
132
- for (const feat of resolvedFeatures) {
133
- if (feat.conversionLift && daysSinceBegin >= feat.launchDay) {
134
- const daysSinceLaunch = daysSinceBegin - feat.launchDay;
135
- const { k, midpoint } = feat._resolvedCurve || { k: 0.08, midpoint: 30 };
136
- const adoptionProb = 1 / (1 + Math.exp(-k * (daysSinceLaunch - midpoint)));
137
- if (chance.bool({ likelihood: Math.min(100, adoptionProb * 100) })) {
138
- adjustedConversionRate = Math.min(100, Math.max(0, Math.round(adjustedConversionRate * feat.conversionLift)));
139
- }
140
- }
141
- }
142
- }
143
-
144
180
  // Determine if user converts and how many steps they'll take
145
181
  // When experiment mode is active, $experiment_started is prepended to sequence
146
182
  // but should not count as a funnel step for conversion purposes
147
183
  const conversionStepCount = expName ? sequence.length - 1 : sequence.length;
148
- const { doesUserConvert, numStepsUserWillTake } = determineConversion(
184
+ let { doesUserConvert, numStepsUserWillTake } = determineConversion(
149
185
  adjustedConversionRate,
150
186
  conversionStepCount,
151
187
  chance
152
188
  );
153
189
 
190
+ // ── Phase 2 identity helpers ──
191
+ // `firstAuthSeqIdx` is the index in `sequence` (and processedEvents) of the first
192
+ // step whose event config has `isAuthEvent: true`. Used both for truncating failed
193
+ // prior attempts (cap before auth) and for picking per-step identity stamping mode
194
+ // inside isFirstFunnel runs. -1 if no step in this funnel is flagged.
195
+ const eventsByName = (() => {
196
+ const map = new Map();
197
+ for (const e of (config.events || [])) map.set(e.event, e);
198
+ return map;
199
+ })();
200
+ let firstAuthSeqIdx = -1;
201
+ for (let i = 0; i < sequence.length; i++) {
202
+ const ev = eventsByName.get(sequence[i]);
203
+ if (ev && ev.isAuthEvent) { firstAuthSeqIdx = i; break; }
204
+ }
205
+ // When experiment mode prepends $experiment_started, the auth index in
206
+ // `processedEvents` is shifted right by 1.
207
+ const firstAuthProcessedIdx = firstAuthSeqIdx === -1
208
+ ? -1
209
+ : (expName ? firstAuthSeqIdx + 1 : firstAuthSeqIdx);
210
+
211
+ // Truncated pre-auth attempt: force the user to drop somewhere strictly before the
212
+ // stitch. If there is no auth step in this funnel, truncation is a no-op (default
213
+ // flow runs). When firstAuthSeqIdx === 0 there's no pre-auth room — force 0 steps
214
+ // (the attempt produces nothing for that user).
215
+ if (attemptInfo.truncateBeforeAuth && firstAuthSeqIdx >= 0 && conversionStepCount > 0) {
216
+ if (firstAuthSeqIdx === 0) {
217
+ numStepsUserWillTake = 0;
218
+ } else {
219
+ numStepsUserWillTake = chance.integer({ min: 1, max: firstAuthSeqIdx });
220
+ }
221
+ doesUserConvert = false;
222
+ }
223
+
154
224
  // Get steps user will actually take
155
225
  let funnelStepsUserWillTake;
156
- if (expName) {
157
- // $experiment_started always fires; conversion only applies to actual funnel steps
158
- funnelStepsUserWillTake = [processedEvents[0], ...processedEvents.slice(1, 1 + numStepsUserWillTake)];
226
+ if (attemptInfo.truncateBeforeAuth && firstAuthSeqIdx === 0) {
227
+ // Pre-auth truncation with the stitch at index 0 leaves no room before it → no events.
228
+ funnelStepsUserWillTake = [];
229
+ } else if (expName) {
230
+ // $experiment_started always fires; conversion only applies to actual funnel steps.
231
+ // (Pre-Phase 2 behavior: even when numStepsUserWillTake===0, $experiment_started fires.)
232
+ funnelStepsUserWillTake = [processedEvents[0], ...processedEvents.slice(1, 1 + Math.max(0, numStepsUserWillTake))];
159
233
  } else {
160
- funnelStepsUserWillTake = processedEvents.slice(0, numStepsUserWillTake);
234
+ funnelStepsUserWillTake = processedEvents.slice(0, Math.max(0, numStepsUserWillTake));
161
235
  }
162
236
 
163
237
  // Apply ordering strategy
@@ -187,12 +261,34 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
187
261
  userCampaign: featureCtx.userCampaign || null,
188
262
  userLocation: featureCtx.userLocation || null,
189
263
  worldEventsTimeline: featureCtx.worldEventsTimeline || context.config.worldEvents || null,
190
- resolvedFeatures: featureCtx.resolvedFeatures || context.config.features || null,
191
- resolvedAnomalies: featureCtx.resolvedAnomalies || context.config.anomalies || null,
192
264
  dataQuality: featureCtx.dataQuality || context.config.dataQuality || null,
193
- geo: featureCtx.geo || context.config.geo || null,
194
265
  };
195
266
 
267
+ // Pre-compute per-step stamping modes for execution order. For isFirstFunnel + isBorn
268
+ // runs, the first event in execution order whose config has `isAuthEvent: true` is
269
+ // the stitch event; events before it stamp `device_only`, events after stamp
270
+ // `user_only`. For everything else we stamp `both` (current default identity model).
271
+ const stampingByIndex = new Array(funnelEventsWithTiming.length).fill('both');
272
+ let runAuthExecIdx = -1;
273
+ if (attemptInfo.isFirstFunnel && attemptInfo.isBorn) {
274
+ for (let i = 0; i < funnelEventsWithTiming.length; i++) {
275
+ const evName = funnelEventsWithTiming[i].event;
276
+ const cfg = eventsByName.get(evName);
277
+ if (cfg && cfg.isAuthEvent) { runAuthExecIdx = i; break; }
278
+ }
279
+ for (let i = 0; i < funnelEventsWithTiming.length; i++) {
280
+ if (runAuthExecIdx === -1) {
281
+ stampingByIndex[i] = 'device_only';
282
+ } else if (i < runAuthExecIdx) {
283
+ stampingByIndex[i] = 'device_only';
284
+ } else if (i === runAuthExecIdx) {
285
+ stampingByIndex[i] = 'stitch';
286
+ } else {
287
+ stampingByIndex[i] = 'user_only';
288
+ }
289
+ }
290
+ }
291
+
196
292
  // Generate actual events with timing
197
293
  const finalEvents = await generateFunnelEvents(
198
294
  context,
@@ -200,15 +296,33 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
200
296
  distinct_id,
201
297
  firstEventTime || dayjs(created).unix(),
202
298
  anonymousIds,
203
- sessionIds,
204
299
  groupKeys,
205
- funnelFeatureCtx
300
+ funnelFeatureCtx,
301
+ { devicePool, stampingByIndex }
206
302
  );
207
303
 
304
+ // Compute the auth-time of the actual stitch event in execution order, if any.
305
+ const authTimeMs = runAuthExecIdx >= 0 && finalEvents[runAuthExecIdx]
306
+ ? Date.parse(finalEvents[runAuthExecIdx].time) || null
307
+ : null;
308
+
208
309
  // Call post-funnel hook
209
- await hook(finalEvents, "funnel-post", { user, profile, scd, funnel, config });
310
+ await hook(finalEvents, "funnel-post", {
311
+ user, profile, scd, funnel, config,
312
+ firstEventTime,
313
+ datasetStart: context.DATASET_START_SECONDS,
314
+ datasetEnd: context.DATASET_END_SECONDS,
315
+ isFirstFunnel: attemptInfo.isFirstFunnel,
316
+ isBorn: attemptInfo.isBorn,
317
+ attemptsConfig: attemptInfo.attemptsConfig,
318
+ attemptNumber: attemptInfo.attemptNumber,
319
+ totalAttempts: attemptInfo.totalAttempts,
320
+ isFinalAttempt: attemptInfo.isFinalAttempt,
321
+ persona,
322
+ experiment: experimentMeta,
323
+ });
210
324
 
211
- return [finalEvents, doesUserConvert];
325
+ return [finalEvents, doesUserConvert, authTimeMs];
212
326
  }
213
327
 
214
328
  /**
@@ -394,14 +508,15 @@ function addTimingOffsets(events, timeToConvert, numSteps) {
394
508
 
395
509
  /**
396
510
  * Generates actual events with proper timing
397
- * @param {Context} context - Context object
398
- * @param {Array} eventsWithTiming - Events with timing information
399
- * @param {string} distinct_id - User ID
400
- * @param {number} earliestTime - Base timestamp
401
- * @param {Array} anonymousIds - Anonymous IDs
402
- * @param {Array} sessionIds - Session IDs
403
- * @param {Array} groupKeys - Group keys
404
- * @returns {Promise<Array>} Generated events
511
+ * @param {Context} context
512
+ * @param {Array} eventsWithTiming
513
+ * @param {string} distinct_id
514
+ * @param {number} earliestTime
515
+ * @param {Array} anonymousIds
516
+ * @param {Array} groupKeys
517
+ * @param {Object} [featureCtx] - Feature context (persona, worldEvents, dataQuality)
518
+ * @param {Object} [identityArgs] - Phase 2 identity args ({ stampingByIndex, devicePool })
519
+ * @returns {Promise<Array>}
405
520
  */
406
521
  async function generateFunnelEvents(
407
522
  context,
@@ -409,25 +524,29 @@ async function generateFunnelEvents(
409
524
  distinct_id,
410
525
  earliestTime,
411
526
  anonymousIds,
412
- sessionIds,
413
527
  groupKeys,
414
- featureCtx = {}
528
+ featureCtx = {},
529
+ identityArgs = null
415
530
  ) {
416
531
  let funnelStartTime;
532
+ const stampingByIndex = (identityArgs && identityArgs.stampingByIndex) || null;
533
+ const devicePool = (identityArgs && identityArgs.devicePool) || null;
417
534
 
418
535
  const finalEvents = await Promise.all(eventsWithTiming.map(async (event, index) => {
536
+ const stamping = stampingByIndex ? stampingByIndex[index] : 'both';
537
+ const identityCtx = (devicePool || stamping !== 'both') ? { stamping, devicePool } : null;
419
538
  const newEvent = await makeEvent(
420
539
  context,
421
540
  distinct_id,
422
541
  earliestTime,
423
542
  event,
424
543
  anonymousIds,
425
- sessionIds,
426
544
  {},
427
545
  groupKeys,
428
- false, // Let all funnel events use TimeSoup for proper time distribution
429
546
  false,
430
- featureCtx
547
+ false,
548
+ featureCtx,
549
+ identityCtx
431
550
  );
432
551
 
433
552
  if (index === 0) {
@@ -446,8 +565,8 @@ async function generateFunnelEvents(
446
565
 
447
566
  try {
448
567
  let computedTime = dayjs(funnelStartTime).add(event.relativeTimeMs, "milliseconds");
449
- // Drop events that would land in the future
450
- if (context.MAX_TIME && computedTime.unix() > context.MAX_TIME) {
568
+ // Drop events that would land past the dataset window
569
+ if (context.FIXED_NOW && computedTime.unix() > context.FIXED_NOW) {
451
570
  newEvent._drop = true;
452
571
  }
453
572
  if (computedTime.isValid()) {