@ak--47/dungeon-master 1.3.1 → 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 (51) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dungeons/technical/hook-helpers-verify.js +89 -0
  3. package/dungeons/technical/identity-model-verify.js +47 -0
  4. package/dungeons/technical/pattern-aggregate-by-bin.js +41 -0
  5. package/dungeons/technical/pattern-attributed-by-source.js +42 -0
  6. package/dungeons/technical/pattern-frequency-by-frequency.js +40 -0
  7. package/dungeons/technical/pattern-funnel-frequency.js +54 -0
  8. package/dungeons/technical/pattern-ttc-by-segment.js +45 -0
  9. package/dungeons/vertical/ai-platform.js +45 -52
  10. package/dungeons/vertical/community.js +11 -8
  11. package/dungeons/vertical/crypto.js +25 -24
  12. package/dungeons/vertical/dating.js +56 -48
  13. package/dungeons/vertical/devtools.js +25 -18
  14. package/dungeons/vertical/ecommerce.js +42 -38
  15. package/dungeons/vertical/education.js +24 -9
  16. package/dungeons/vertical/fintech.js +13 -8
  17. package/dungeons/vertical/fitness.js +73 -122
  18. package/dungeons/vertical/food-delivery.js +18 -19
  19. package/dungeons/vertical/gaming.js +19 -20
  20. package/dungeons/vertical/healthcare.js +11 -8
  21. package/dungeons/vertical/insurance-application.js +6 -3
  22. package/dungeons/vertical/logistics.js +15 -9
  23. package/dungeons/vertical/marketplace.js +36 -27
  24. package/dungeons/vertical/media.js +27 -25
  25. package/dungeons/vertical/real-estate.js +18 -7
  26. package/dungeons/vertical/sass.js +84 -68
  27. package/dungeons/vertical/social.js +46 -47
  28. package/dungeons/vertical/travel.js +8 -5
  29. package/lib/core/config-validator.js +136 -157
  30. package/lib/generators/events.js +49 -93
  31. package/lib/generators/funnels.js +202 -91
  32. package/lib/hook-helpers/_internal.js +23 -0
  33. package/lib/hook-helpers/cohort.js +124 -0
  34. package/lib/hook-helpers/identity.js +56 -0
  35. package/lib/hook-helpers/index.js +44 -0
  36. package/lib/hook-helpers/inject.js +99 -0
  37. package/lib/hook-helpers/mutate.js +151 -0
  38. package/lib/hook-helpers/timing.js +99 -0
  39. package/lib/hook-patterns/aggregate-per-user-by-bin.js +38 -0
  40. package/lib/hook-patterns/attributed-by-source.js +72 -0
  41. package/lib/hook-patterns/frequency-by-frequency.js +46 -0
  42. package/lib/hook-patterns/funnel-frequency-breakdown.js +73 -0
  43. package/lib/hook-patterns/index.js +14 -0
  44. package/lib/hook-patterns/time-to-convert-by-segment.js +41 -0
  45. package/lib/orchestrators/user-loop.js +119 -269
  46. package/lib/utils/utils.js +29 -16
  47. package/lib/verify/emulate-breakdown.js +281 -0
  48. package/lib/verify/index.js +12 -0
  49. package/lib/verify/verify-dungeon.js +61 -0
  50. package/package.json +6 -4
  51. package/types.d.ts +397 -211
@@ -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
@@ -110,19 +120,32 @@ export async function makeEvent(
110
120
  eventTemplate.time = dayjs.unix(unixTime).toISOString();
111
121
  }
112
122
 
113
- // Add anonymous identifiers
114
- if (anonymousIds.length) {
115
- 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);
116
140
  }
117
-
118
- // Session IDs are assigned post-hoc in user-loop.js based on temporal gaps
119
-
120
- // Sometimes add user_id (for attribution modeling)
121
- if (!isFirstEvent && chance.bool({ likelihood: 42 })) {
141
+ if (wantsUser) {
122
142
  eventTemplate.user_id = distinct_id;
123
143
  }
144
+ // Session IDs are assigned post-hoc in user-loop.js based on temporal gaps
124
145
 
125
- // 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.
126
149
  if (!eventTemplate.user_id && !eventTemplate.device_id) {
127
150
  eventTemplate.user_id = distinct_id;
128
151
  }
@@ -165,36 +188,13 @@ export async function makeEvent(
165
188
  addGroupProperties(eventTemplate, groupKeys);
166
189
 
167
190
  // ── Event-level features (applied before hooks, so hooks can override) ──
168
- const { userLocation, persona, worldEventsTimeline, resolvedFeatures, resolvedAnomalies, dataQuality: dq, geo: geoConfig, userCampaign } = featureCtx;
169
-
170
- // Feature 6: Attribution — stamp UTM properties on events as touchpoints
171
- // Mixpanel attribution analysis needs UTM on EVENTS, not just profiles.
172
- // Pattern: first events carry acquisition UTM, later events occasionally carry re-engagement UTM.
173
- if (userCampaign) {
174
- // ~40% of events carry UTM (simulates page loads, session starts, ad clicks)
175
- // First events (isFirstEvent) always carry UTM (acquisition touchpoint)
176
- if (isFirstEvent || chance.bool({ likelihood: 40 })) {
177
- eventTemplate.utm_source = userCampaign.source;
178
- eventTemplate.utm_campaign = userCampaign.name;
179
- if (userCampaign.medium) eventTemplate.utm_medium = userCampaign.medium;
180
- if (userCampaign.utm_content) eventTemplate.utm_content = userCampaign.utm_content;
181
- if (userCampaign.utm_term) eventTemplate.utm_term = userCampaign.utm_term;
182
- }
183
- }
184
-
185
- // Feature 7: Sticky geo location
186
- if (userLocation && geoConfig?.sticky) {
187
- // Override the random location with user's sticky location
188
- for (const key in userLocation) {
189
- eventTemplate[key] = userLocation[key];
190
- }
191
- }
191
+ const { userLocation, persona, worldEventsTimeline, dataQuality: dq } = featureCtx;
192
192
 
193
- // Perf 1: Compute eventUnix once for all time-based checks. World events &
194
- // features were resolved against the dataset window (no shift), and event
195
- // times now also live in that same window — direct unix conversion works.
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.
196
196
  let eventUnix = null;
197
- if ((worldEventsTimeline || resolvedFeatures) && eventTemplate.time) {
197
+ if (worldEventsTimeline && eventTemplate.time) {
198
198
  eventUnix = dayjs(eventTemplate.time).unix();
199
199
  }
200
200
 
@@ -222,47 +222,6 @@ export async function makeEvent(
222
222
  }
223
223
  }
224
224
 
225
- // Feature 8: Progressive feature adoption
226
- if (resolvedFeatures && eventUnix !== null) {
227
- const daysSinceBegin = (eventUnix - context.FIXED_BEGIN) / 86400;
228
- for (const feat of resolvedFeatures) {
229
- const affects = feat.affectsEvents;
230
- if (affects !== "*" && !(Array.isArray(affects) && affects.includes(eventTemplate.event))) continue;
231
- if (daysSinceBegin < feat.launchDay) {
232
- if (feat.defaultBefore !== undefined) {
233
- eventTemplate[feat.property] = feat.defaultBefore;
234
- }
235
- } else {
236
- const daysSinceLaunch = daysSinceBegin - feat.launchDay;
237
- const { k, midpoint } = feat._resolvedCurve || { k: 0.08, midpoint: 30 };
238
- const adoptionProb = 1 / (1 + Math.exp(-k * (daysSinceLaunch - midpoint)));
239
- if (chance.bool({ likelihood: Math.min(100, adoptionProb * 100) })) {
240
- eventTemplate[feat.property] = u.pickRandom(feat._adoptedValues);
241
- } else if (feat.defaultBefore !== undefined) {
242
- eventTemplate[feat.property] = feat.defaultBefore;
243
- } else if (feat.values.length > 0) {
244
- eventTemplate[feat.property] = feat.values[0];
245
- }
246
- }
247
- }
248
- }
249
-
250
- // Feature 9: Anomaly extreme values
251
- if (resolvedAnomalies && eventTemplate.event) {
252
- for (const a of resolvedAnomalies) {
253
- if (a.type === 'extreme_value' && a.event === eventTemplate.event && a.property) {
254
- if (chance.bool({ likelihood: (a.frequency ?? 0.001) * 100 })) {
255
- const currentVal = eventTemplate[a.property];
256
- if (typeof currentVal === 'number') {
257
- eventTemplate[a.property] = currentVal * (a.multiplier || 10);
258
- }
259
- if (a.tag) eventTemplate._anomaly = a.tag;
260
- if (a.properties) Object.assign(eventTemplate, a.properties);
261
- }
262
- }
263
- }
264
- }
265
-
266
225
  // Feature 4: Data quality — null injection and timezone confusion
267
226
  if (dq) {
268
227
  // Null injection
@@ -287,10 +246,7 @@ export async function makeEvent(
287
246
  }
288
247
  }
289
248
 
290
- // Generate unique insert_id
291
- const distinctId = eventTemplate.user_id || eventTemplate.device_id || eventTemplate.distinct_id || distinct_id;
292
- const tuple = `${eventTemplate.event}-${eventTemplate.time}-${distinctId}`;
293
- eventTemplate.insert_id = u.quickHash(tuple);
249
+ eventTemplate.insert_id = randomUUID();
294
250
 
295
251
  // Call hook if configured (hooks override everything — they are the final authority)
296
252
  const { hook } = config;
@@ -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,52 +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
127
+ // funnel-pre hook fires AFTER modifiers — hook has final authority on conversionRate,
128
+ // props, timeToConvert, and sequence.
71
129
  await hook(funnel, "funnel-pre", {
72
130
  user, profile, scd, funnel, config, firstEventTime,
73
131
  datasetStart: context.DATASET_START_SECONDS,
74
- datasetEnd: context.DATASET_END_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,
75
141
  });
76
142
 
77
- // Extract funnel configuration
143
+ // Extract funnel configuration (post-hook — hook's mutations are the final word)
78
144
  let {
79
145
  sequence,
80
146
  conversionRate = 50,
@@ -87,7 +153,7 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
87
153
  bindPropsIndex = 0
88
154
  } = funnel;
89
155
 
90
- const { distinct_id, created, anonymousIds = [], sessionIds = [] } = user;
156
+ const { distinct_id, created, anonymousIds = [] } = user;
91
157
  const { superProps = {}, groupKeys = [] } = config;
92
158
 
93
159
  // Choose properties for this funnel instance
@@ -111,57 +177,61 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
111
177
  chance
112
178
  );
113
179
 
114
- // Apply persona conversion modifier (before hook, so hook can override via funnel-pre)
115
- if (persona && persona.conversionModifier) {
116
- adjustedConversionRate = Math.min(100, Math.max(0, Math.round(adjustedConversionRate * persona.conversionModifier)));
117
- }
118
-
119
- // Apply world event conversion modifier if active at firstEventTime
120
- const resolvedWorldEvents = /** @type {import('../../types').ResolvedWorldEvent[]} */ (config.worldEvents);
121
- if (resolvedWorldEvents && firstEventTime) {
122
- for (const we of resolvedWorldEvents) {
123
- if (firstEventTime >= we.startUnix && firstEventTime < we.endUnix && we.conversionModifier !== 1.0) {
124
- const affects = we.affectsEvents;
125
- if (affects === "*" || (Array.isArray(affects) && sequence.some(s => affects.includes(s)))) {
126
- adjustedConversionRate = Math.min(100, Math.max(0, Math.round(adjustedConversionRate * we.conversionModifier)));
127
- }
128
- }
129
- }
130
- }
131
-
132
- // Apply feature conversion lifts
133
- const resolvedFeatures = config.features;
134
- if (resolvedFeatures && firstEventTime) {
135
- const daysSinceBegin = (firstEventTime - context.FIXED_BEGIN) / 86400;
136
- for (const feat of resolvedFeatures) {
137
- if (feat.conversionLift && daysSinceBegin >= feat.launchDay) {
138
- const daysSinceLaunch = daysSinceBegin - feat.launchDay;
139
- const { k, midpoint } = feat._resolvedCurve || { k: 0.08, midpoint: 30 };
140
- const adoptionProb = 1 / (1 + Math.exp(-k * (daysSinceLaunch - midpoint)));
141
- if (chance.bool({ likelihood: Math.min(100, adoptionProb * 100) })) {
142
- adjustedConversionRate = Math.min(100, Math.max(0, Math.round(adjustedConversionRate * feat.conversionLift)));
143
- }
144
- }
145
- }
146
- }
147
-
148
180
  // Determine if user converts and how many steps they'll take
149
181
  // When experiment mode is active, $experiment_started is prepended to sequence
150
182
  // but should not count as a funnel step for conversion purposes
151
183
  const conversionStepCount = expName ? sequence.length - 1 : sequence.length;
152
- const { doesUserConvert, numStepsUserWillTake } = determineConversion(
184
+ let { doesUserConvert, numStepsUserWillTake } = determineConversion(
153
185
  adjustedConversionRate,
154
186
  conversionStepCount,
155
187
  chance
156
188
  );
157
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
+
158
224
  // Get steps user will actually take
159
225
  let funnelStepsUserWillTake;
160
- if (expName) {
161
- // $experiment_started always fires; conversion only applies to actual funnel steps
162
- 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))];
163
233
  } else {
164
- funnelStepsUserWillTake = processedEvents.slice(0, numStepsUserWillTake);
234
+ funnelStepsUserWillTake = processedEvents.slice(0, Math.max(0, numStepsUserWillTake));
165
235
  }
166
236
 
167
237
  // Apply ordering strategy
@@ -191,12 +261,34 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
191
261
  userCampaign: featureCtx.userCampaign || null,
192
262
  userLocation: featureCtx.userLocation || null,
193
263
  worldEventsTimeline: featureCtx.worldEventsTimeline || context.config.worldEvents || null,
194
- resolvedFeatures: featureCtx.resolvedFeatures || context.config.features || null,
195
- resolvedAnomalies: featureCtx.resolvedAnomalies || context.config.anomalies || null,
196
264
  dataQuality: featureCtx.dataQuality || context.config.dataQuality || null,
197
- geo: featureCtx.geo || context.config.geo || null,
198
265
  };
199
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
+
200
292
  // Generate actual events with timing
201
293
  const finalEvents = await generateFunnelEvents(
202
294
  context,
@@ -204,19 +296,33 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
204
296
  distinct_id,
205
297
  firstEventTime || dayjs(created).unix(),
206
298
  anonymousIds,
207
- sessionIds,
208
299
  groupKeys,
209
- funnelFeatureCtx
300
+ funnelFeatureCtx,
301
+ { devicePool, stampingByIndex }
210
302
  );
211
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
+
212
309
  // Call post-funnel hook
213
310
  await hook(finalEvents, "funnel-post", {
214
311
  user, profile, scd, funnel, config,
312
+ firstEventTime,
215
313
  datasetStart: context.DATASET_START_SECONDS,
216
- datasetEnd: context.DATASET_END_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,
217
323
  });
218
324
 
219
- return [finalEvents, doesUserConvert];
325
+ return [finalEvents, doesUserConvert, authTimeMs];
220
326
  }
221
327
 
222
328
  /**
@@ -402,14 +508,15 @@ function addTimingOffsets(events, timeToConvert, numSteps) {
402
508
 
403
509
  /**
404
510
  * Generates actual events with proper timing
405
- * @param {Context} context - Context object
406
- * @param {Array} eventsWithTiming - Events with timing information
407
- * @param {string} distinct_id - User ID
408
- * @param {number} earliestTime - Base timestamp
409
- * @param {Array} anonymousIds - Anonymous IDs
410
- * @param {Array} sessionIds - Session IDs
411
- * @param {Array} groupKeys - Group keys
412
- * @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>}
413
520
  */
414
521
  async function generateFunnelEvents(
415
522
  context,
@@ -417,25 +524,29 @@ async function generateFunnelEvents(
417
524
  distinct_id,
418
525
  earliestTime,
419
526
  anonymousIds,
420
- sessionIds,
421
527
  groupKeys,
422
- featureCtx = {}
528
+ featureCtx = {},
529
+ identityArgs = null
423
530
  ) {
424
531
  let funnelStartTime;
532
+ const stampingByIndex = (identityArgs && identityArgs.stampingByIndex) || null;
533
+ const devicePool = (identityArgs && identityArgs.devicePool) || null;
425
534
 
426
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;
427
538
  const newEvent = await makeEvent(
428
539
  context,
429
540
  distinct_id,
430
541
  earliestTime,
431
542
  event,
432
543
  anonymousIds,
433
- sessionIds,
434
544
  {},
435
545
  groupKeys,
436
- false, // Let all funnel events use TimeSoup for proper time distribution
437
546
  false,
438
- featureCtx
547
+ false,
548
+ featureCtx,
549
+ identityCtx
439
550
  );
440
551
 
441
552
  if (index === 0) {
@@ -0,0 +1,23 @@
1
+ export function toMs(t) {
2
+ if (typeof t === 'number') return t > 1e12 ? t : t > 1e9 ? t * 1000 : t;
3
+ return Date.parse(t);
4
+ }
5
+
6
+ export function writeTime(event, ms) {
7
+ if (typeof event.time === 'string' || event.time === undefined) {
8
+ event.time = new Date(ms).toISOString();
9
+ } else if (event.time > 1e12) {
10
+ event.time = ms;
11
+ } else {
12
+ event.time = ms / 1000;
13
+ }
14
+ }
15
+
16
+ export function simpleHashFloat(s) {
17
+ let h = 0x811c9dc5;
18
+ for (let i = 0; i < s.length; i++) {
19
+ h ^= s.charCodeAt(i);
20
+ h = Math.imul(h, 0x01000193);
21
+ }
22
+ return ((h >>> 0) % 1000) / 1000;
23
+ }