@ak--47/dungeon-master 1.3.1 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/dungeons/technical/hook-helpers-verify.js +89 -0
- package/dungeons/technical/identity-model-verify.js +47 -0
- package/dungeons/technical/pattern-aggregate-by-bin.js +41 -0
- package/dungeons/technical/pattern-attributed-by-source.js +42 -0
- package/dungeons/technical/pattern-frequency-by-frequency.js +40 -0
- package/dungeons/technical/pattern-funnel-frequency.js +54 -0
- package/dungeons/technical/pattern-ttc-by-segment.js +45 -0
- package/dungeons/vertical/ai-platform.js +45 -52
- package/dungeons/vertical/community.js +11 -8
- package/dungeons/vertical/crypto.js +25 -24
- package/dungeons/vertical/dating.js +56 -48
- package/dungeons/vertical/devtools.js +25 -18
- package/dungeons/vertical/ecommerce.js +42 -38
- package/dungeons/vertical/education.js +24 -9
- package/dungeons/vertical/fintech.js +13 -8
- package/dungeons/vertical/fitness.js +73 -122
- package/dungeons/vertical/food-delivery.js +18 -19
- package/dungeons/vertical/gaming.js +19 -20
- package/dungeons/vertical/healthcare.js +11 -8
- package/dungeons/vertical/insurance-application.js +6 -3
- package/dungeons/vertical/logistics.js +15 -9
- package/dungeons/vertical/marketplace.js +36 -27
- package/dungeons/vertical/media.js +27 -25
- package/dungeons/vertical/real-estate.js +18 -7
- package/dungeons/vertical/sass.js +84 -68
- package/dungeons/vertical/social.js +46 -47
- package/dungeons/vertical/travel.js +8 -5
- package/index.js +17 -71
- package/lib/core/config-validator.js +143 -164
- package/lib/core/storage.js +5 -1
- package/lib/generators/events.js +49 -93
- package/lib/generators/funnels.js +202 -91
- package/lib/hook-helpers/_internal.js +23 -0
- package/lib/hook-helpers/cohort.js +124 -0
- package/lib/hook-helpers/identity.js +56 -0
- package/lib/hook-helpers/index.js +44 -0
- package/lib/hook-helpers/inject.js +99 -0
- package/lib/hook-helpers/mutate.js +151 -0
- package/lib/hook-helpers/timing.js +99 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +38 -0
- package/lib/hook-patterns/attributed-by-source.js +72 -0
- package/lib/hook-patterns/frequency-by-frequency.js +46 -0
- package/lib/hook-patterns/funnel-frequency-breakdown.js +73 -0
- package/lib/hook-patterns/index.js +14 -0
- package/lib/hook-patterns/time-to-convert-by-segment.js +41 -0
- package/lib/orchestrators/mixpanel-sender.js +46 -51
- package/lib/orchestrators/user-loop.js +119 -269
- package/lib/utils/utils.js +39 -16
- package/lib/verify/emulate-breakdown.js +281 -0
- package/lib/verify/index.js +12 -0
- package/lib/verify/verify-dungeon.js +61 -0
- package/package.json +6 -4
- package/types.d.ts +404 -212
|
@@ -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
|
-
* @
|
|
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:
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
//
|
|
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 = []
|
|
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
|
-
|
|
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 (
|
|
161
|
-
//
|
|
162
|
-
funnelStepsUserWillTake = [
|
|
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
|
|
406
|
-
* @param {Array} eventsWithTiming
|
|
407
|
-
* @param {string} distinct_id
|
|
408
|
-
* @param {number} earliestTime
|
|
409
|
-
* @param {Array} anonymousIds
|
|
410
|
-
* @param {Array}
|
|
411
|
-
* @param {
|
|
412
|
-
* @
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook helpers — cohort atoms.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions used inside `everything` / `event` hooks to classify users into
|
|
5
|
+
* behavioral cohorts. None of these mutate the input. They derive a label from a
|
|
6
|
+
* user's events or profile and return it; the caller decides what to do with the
|
|
7
|
+
* label (typically: feed into a `mutate` or `inject` atom).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { toMs } from './_internal.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Classify a user into a named bin based on the count of a specific event in their stream.
|
|
14
|
+
* Bin definitions use inclusive lower bound, exclusive upper bound (`[lo, hi)`).
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* const tier = binUsersByEventCount(events, 'Complete Action Item', {
|
|
18
|
+
* low: [0, 5],
|
|
19
|
+
* sweet: [5, 20],
|
|
20
|
+
* over: [20, Infinity],
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* @param {Array<{event:string,time?:string|number}>} events - User's event stream.
|
|
24
|
+
* @param {string} eventName - Event to count.
|
|
25
|
+
* @param {Record<string, [number, number]>} bins - Map of bin name → [lo, hi).
|
|
26
|
+
* @returns {string|null} Matching bin name, or null if no bin matches.
|
|
27
|
+
*/
|
|
28
|
+
export function binUsersByEventCount(events, eventName, bins) {
|
|
29
|
+
if (!events || !eventName || !bins) return null;
|
|
30
|
+
let count = 0;
|
|
31
|
+
for (const ev of events) {
|
|
32
|
+
if (ev && ev.event === eventName) count++;
|
|
33
|
+
}
|
|
34
|
+
for (const [name, range] of Object.entries(bins)) {
|
|
35
|
+
if (!Array.isArray(range) || range.length !== 2) continue;
|
|
36
|
+
const [lo, hi] = range;
|
|
37
|
+
if (count >= lo && count < hi) return name;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Like `binUsersByEventCount` but only counts events whose timestamp falls inside
|
|
44
|
+
* `[startTime, endTime]` (inclusive). Times can be unix milliseconds, unix seconds,
|
|
45
|
+
* ISO strings, or anything `Date.parse` accepts.
|
|
46
|
+
*
|
|
47
|
+
* @param {Array<{event:string,time:string|number}>} events
|
|
48
|
+
* @param {string} eventName
|
|
49
|
+
* @param {string|number} startTime
|
|
50
|
+
* @param {string|number} endTime
|
|
51
|
+
* @param {Record<string, [number, number]>} bins
|
|
52
|
+
* @returns {string|null}
|
|
53
|
+
*/
|
|
54
|
+
export function binUsersByEventInRange(events, eventName, startTime, endTime, bins) {
|
|
55
|
+
if (!events || !eventName || !bins) return null;
|
|
56
|
+
const startMs = toMs(startTime);
|
|
57
|
+
const endMs = toMs(endTime);
|
|
58
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null;
|
|
59
|
+
let count = 0;
|
|
60
|
+
for (const ev of events) {
|
|
61
|
+
if (!ev || ev.event !== eventName || ev.time === undefined) continue;
|
|
62
|
+
const t = toMs(ev.time);
|
|
63
|
+
if (Number.isFinite(t) && t >= startMs && t <= endMs) count++;
|
|
64
|
+
}
|
|
65
|
+
for (const [name, range] of Object.entries(bins)) {
|
|
66
|
+
if (!Array.isArray(range) || range.length !== 2) continue;
|
|
67
|
+
const [lo, hi] = range;
|
|
68
|
+
if (count >= lo && count < hi) return name;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Count events that occur strictly between the FIRST `eventA` and the FIRST `eventB`
|
|
75
|
+
* after it in the stream. Useful for "how many ${X} did the user do between landing
|
|
76
|
+
* and converting" measurements that hooks then condition on.
|
|
77
|
+
*
|
|
78
|
+
* @param {Array<{event:string,time:string|number}>} events
|
|
79
|
+
* @param {string} eventA
|
|
80
|
+
* @param {string} eventB
|
|
81
|
+
* @returns {number} Count, or 0 if either anchor is missing.
|
|
82
|
+
*/
|
|
83
|
+
export function countEventsBetween(events, eventA, eventB) {
|
|
84
|
+
if (!events || !eventA || !eventB) return 0;
|
|
85
|
+
const sorted = sortByTime(events);
|
|
86
|
+
const a = sorted.find(e => e && e.event === eventA);
|
|
87
|
+
if (!a) return 0;
|
|
88
|
+
const aIdx = sorted.indexOf(a);
|
|
89
|
+
const b = sorted.slice(aIdx + 1).find(e => e && e.event === eventB);
|
|
90
|
+
if (!b) return 0;
|
|
91
|
+
const aT = toMs(a.time);
|
|
92
|
+
const bT = toMs(b.time);
|
|
93
|
+
let n = 0;
|
|
94
|
+
for (const ev of sorted) {
|
|
95
|
+
if (!ev || ev.time === undefined) continue;
|
|
96
|
+
const t = toMs(ev.time);
|
|
97
|
+
if (Number.isFinite(t) && t > aT && t < bT) n++;
|
|
98
|
+
}
|
|
99
|
+
return n;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Profile-based cohort check. Returns true if `profile[segmentKey]` matches one of
|
|
104
|
+
* `segmentValues` (array) or equals the single value passed.
|
|
105
|
+
*
|
|
106
|
+
* @param {Object} profile
|
|
107
|
+
* @param {string} segmentKey
|
|
108
|
+
* @param {*|Array<*>} segmentValues
|
|
109
|
+
* @returns {boolean}
|
|
110
|
+
*/
|
|
111
|
+
export function userInProfileSegment(profile, segmentKey, segmentValues) {
|
|
112
|
+
if (!profile || !segmentKey) return false;
|
|
113
|
+
const v = profile[segmentKey];
|
|
114
|
+
if (Array.isArray(segmentValues)) return segmentValues.includes(v);
|
|
115
|
+
return v === segmentValues;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── internal helpers ──
|
|
119
|
+
|
|
120
|
+
function sortByTime(events) {
|
|
121
|
+
const copy = events.slice();
|
|
122
|
+
copy.sort((a, b) => toMs(a && a.time) - toMs(b && b.time));
|
|
123
|
+
return copy;
|
|
124
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook helpers — identity atoms.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the Phase 2 identity primitives so hook authors can re-derive pre-auth /
|
|
5
|
+
* post-auth / stitch info without having to grovel inside `meta`. The `everything`
|
|
6
|
+
* hook already exposes `meta.authTime` and `meta.isPreAuth(event)`; these helpers
|
|
7
|
+
* cover callers that operate on stored events outside that hook.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Returns true if the event happened strictly before the user's stitch event.
|
|
12
|
+
* - `authTime === null | undefined` is interpreted as "user never authed" → every
|
|
13
|
+
* event is pre-auth (matches the `everything` hook's behavior for born-in-dataset
|
|
14
|
+
* users that never converted).
|
|
15
|
+
* - Pre-existing users (already authed before the dataset window) won't have
|
|
16
|
+
* `authTime` populated by the engine; callers wanting "always false" semantics
|
|
17
|
+
* should pass `0` or `-Infinity`.
|
|
18
|
+
*
|
|
19
|
+
* @param {{time: string|number}} event
|
|
20
|
+
* @param {number|null|undefined} authTime - Unix milliseconds.
|
|
21
|
+
* @returns {boolean}
|
|
22
|
+
*/
|
|
23
|
+
export function isPreAuthEvent(event, authTime) {
|
|
24
|
+
if (!event || event.time === undefined || event.time === null) return false;
|
|
25
|
+
if (authTime === null || authTime === undefined) return true;
|
|
26
|
+
const t = typeof event.time === 'number'
|
|
27
|
+
? (event.time > 1e12 ? event.time : event.time * 1000)
|
|
28
|
+
: Date.parse(event.time);
|
|
29
|
+
return Number.isFinite(t) ? t < authTime : false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Partition `events` into pre-auth / post-auth / stitch buckets relative to
|
|
34
|
+
* `authTime`. The stitch is the first post-auth event whose record carries BOTH
|
|
35
|
+
* `user_id` and `device_id` (the engine stamps this exactly once per converted
|
|
36
|
+
* born-in-dataset user). When no such event exists, `stitch` is `null`.
|
|
37
|
+
*
|
|
38
|
+
* @param {Array<{event:string,time:string|number,user_id?:string,device_id?:string}>} events
|
|
39
|
+
* @param {number|null|undefined} authTime - Unix milliseconds.
|
|
40
|
+
* @returns {{ preAuth: Object[], postAuth: Object[], stitch: Object|null }}
|
|
41
|
+
*/
|
|
42
|
+
export function splitByAuth(events, authTime) {
|
|
43
|
+
const result = { preAuth: [], postAuth: [], stitch: null };
|
|
44
|
+
if (!events) return result;
|
|
45
|
+
for (const ev of events) {
|
|
46
|
+
if (isPreAuthEvent(ev, authTime)) {
|
|
47
|
+
result.preAuth.push(ev);
|
|
48
|
+
} else {
|
|
49
|
+
result.postAuth.push(ev);
|
|
50
|
+
if (!result.stitch && ev && ev.user_id && ev.device_id) {
|
|
51
|
+
result.stitch = ev;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ak--47/dungeon-master/hook-helpers — Phase 3 atom barrel export.
|
|
3
|
+
*
|
|
4
|
+
* Atoms are pure-ish primitives that hooks compose to build trends. The five
|
|
5
|
+
* sub-modules (cohort, mutate, timing, inject, identity) cover the moves Mixpanel
|
|
6
|
+
* analyses need: classify users into bins, scale event counts and property values,
|
|
7
|
+
* adjust timings, splice in cloned events, and reason about pre-auth state.
|
|
8
|
+
*
|
|
9
|
+
* Each atom carries full JSDoc on its definition; see the individual files for
|
|
10
|
+
* the contract details. Patterns (Phase 4, lib/hook-patterns) are higher-level
|
|
11
|
+
* recipes built on top of these atoms.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
binUsersByEventCount,
|
|
16
|
+
binUsersByEventInRange,
|
|
17
|
+
countEventsBetween,
|
|
18
|
+
userInProfileSegment,
|
|
19
|
+
} from './cohort.js';
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
cloneEvent,
|
|
23
|
+
dropEventsWhere,
|
|
24
|
+
scaleEventCount,
|
|
25
|
+
scalePropertyValue,
|
|
26
|
+
shiftEventTime,
|
|
27
|
+
} from './mutate.js';
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
scaleTimingBetween,
|
|
31
|
+
scaleFunnelTTC,
|
|
32
|
+
findFirstSequence,
|
|
33
|
+
} from './timing.js';
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
injectAfterEvent,
|
|
37
|
+
injectBetween,
|
|
38
|
+
injectBurst,
|
|
39
|
+
} from './inject.js';
|
|
40
|
+
|
|
41
|
+
export {
|
|
42
|
+
isPreAuthEvent,
|
|
43
|
+
splitByAuth,
|
|
44
|
+
} from './identity.js';
|