@ak--47/dungeon-master 1.4.5 → 1.5.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.
- package/.claude/skills/analyze-soup/SKILL.md +158 -0
- package/.claude/skills/create-dungeon/SKILL.md +464 -0
- package/.claude/skills/verify-dungeon/SKILL.md +157 -0
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +161 -0
- package/.claude/skills/verify-dungeon/references/report-format.md +216 -0
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +857 -0
- package/.claude/skills/write-hooks/SKILL.md +468 -0
- package/CHANGELOG.md +139 -0
- package/HOOKS.md +1243 -597
- package/README.md +140 -5
- package/dungeons/technical/ad-spend.js +1 -1
- package/dungeons/technical/anonymous-users.js +1 -1
- package/dungeons/technical/array-of-object-lookup.js +1 -1
- package/dungeons/technical/datagen-v15-verify.js +74 -0
- package/dungeons/technical/experiments.js +1 -1
- package/dungeons/technical/foobar.js +1 -1
- package/dungeons/technical/group-analytics.js +1 -1
- package/dungeons/technical/mirror-strategies.js +1 -1
- package/dungeons/technical/nested-objects.js +1 -1
- package/dungeons/technical/retention-cadence.js +1 -1
- package/dungeons/technical/sanity.js +1 -1
- package/dungeons/technical/scale-test.js +1 -1
- package/dungeons/technical/scd.js +1 -1
- package/dungeons/technical/simple.js +1 -1
- package/dungeons/technical/simplest.js +74 -20
- package/dungeons/technical/text-generation.js +1 -1
- package/dungeons/vertical/ai-platform.js +4 -0
- package/dungeons/vertical/community.js +9 -3
- package/dungeons/vertical/crypto.js +5 -0
- package/dungeons/vertical/dating.js +23 -10
- package/dungeons/vertical/devtools.js +10 -0
- package/dungeons/vertical/ecommerce.js +6 -0
- package/dungeons/vertical/education.js +11 -0
- package/dungeons/vertical/fintech.js +13 -0
- package/dungeons/vertical/fitness.js +10 -0
- package/dungeons/vertical/food-delivery.js +9 -0
- package/dungeons/vertical/gaming.js +10 -0
- package/dungeons/vertical/healthcare.js +5 -0
- package/dungeons/vertical/insurance-application.js +10 -0
- package/dungeons/vertical/logistics.js +8 -1
- package/dungeons/vertical/marketplace.js +7 -0
- package/dungeons/vertical/media.js +8 -0
- package/dungeons/vertical/real-estate.js +7 -1
- package/dungeons/vertical/sass.js +12 -0
- package/dungeons/vertical/social.js +9 -0
- package/dungeons/vertical/travel.js +5 -0
- package/index.js +19 -4
- package/lib/core/config-validator.js +270 -7
- package/lib/core/dungeon-loader.js +2 -5
- package/lib/generators/events.js +12 -13
- package/lib/generators/funnels.js +72 -1
- package/lib/hook-helpers/index.js +1 -0
- package/lib/hook-helpers/inject.js +95 -0
- package/lib/orchestrators/user-loop.js +478 -29
- package/lib/templates/macro-presets.js +39 -9
- package/lib/utils/utils.js +16 -79
- package/lib/verify/counting.js +320 -0
- package/lib/verify/emulate-breakdown.js +512 -108
- package/lib/verify/funnel-engine.js +539 -0
- package/lib/verify/identity.js +78 -0
- package/lib/verify/index.js +19 -0
- package/lib/verify/verify-dungeon.js +58 -0
- package/package.json +4 -2
- package/types.d.ts +237 -4
- package/scripts/smoke-test-all.mjs +0 -162
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Greedy single-pass funnel state machine matching Mixpanel's behavior, plus
|
|
3
|
+
* v1.5.0 extensions: reentry, exclusion steps, HPC (hold property constant),
|
|
4
|
+
* step-level property filters, step property tracking, segment modes,
|
|
5
|
+
* simultaneous histories (totals mode), and session-scoped evaluation.
|
|
6
|
+
*
|
|
7
|
+
* Mixpanel processes funnel events in chronological order, single pass, with
|
|
8
|
+
* no backtracking. Each event is greedily assigned to the first eligible
|
|
9
|
+
* funnel step. This is a streaming optimization for processing billions of
|
|
10
|
+
* events; it differs from a SQL-style "find best combination" search.
|
|
11
|
+
*
|
|
12
|
+
* Reference: `mixpanel/analytics`
|
|
13
|
+
* - Greedy single-pass: backend/arb/reader/funnels/history.cpp
|
|
14
|
+
* - 2s grace + conversion window: history.cpp + conversion_window.cpp
|
|
15
|
+
* - Reentry: history.cpp `last_step_starts_next_funnel`
|
|
16
|
+
* - Exclusion: backend/arb/reader/queries/funnel_query.cpp
|
|
17
|
+
* - HPC: funnel_query.cpp lines 749-784 `aggregate_hash_get_key_cursor`
|
|
18
|
+
* - Step properties: history.cpp `property_set_buffer`
|
|
19
|
+
* - Segment modes: backend/arb/reader/options.hpp `funnel_segment_mode`
|
|
20
|
+
* - Totals vs uniques: funnel_query.cpp
|
|
21
|
+
*
|
|
22
|
+
* Documented edge case (history.cpp ~line 456): For funnel `[A, B, B]` with
|
|
23
|
+
* event stream `[B, B, A]` all within 2 seconds, the engine does NOT
|
|
24
|
+
* attribute the second B to step 2.
|
|
25
|
+
*
|
|
26
|
+
* NOT implemented:
|
|
27
|
+
* - Aggressive/optimized reentry (`enable_early_reentry`)
|
|
28
|
+
* - Any-order step blocks (`is_any_order_step`)
|
|
29
|
+
* - Selector expressions beyond eq/neq/gt/lt/gte/lte/contains/not_contains
|
|
30
|
+
*
|
|
31
|
+
* @typedef {Object} StepFilter
|
|
32
|
+
* @property {string} prop
|
|
33
|
+
* @property {'eq'|'neq'|'gt'|'lt'|'gte'|'lte'|'contains'|'not_contains'} op
|
|
34
|
+
* @property {*} value
|
|
35
|
+
*
|
|
36
|
+
* @typedef {string | { event: string, where?: StepFilter }} FunnelStep
|
|
37
|
+
*
|
|
38
|
+
* @typedef {Object} ExclusionStep
|
|
39
|
+
* @property {string} event - Event name that terminates the attempt.
|
|
40
|
+
* @property {number} [afterStep] - Exclusion active when `reached >= afterStep`. Mixpanel
|
|
41
|
+
* `funnel_query.cpp` exclusion `i` fires when user has reached step `i` (between step
|
|
42
|
+
* `i` and step `i+1`). Default `-Infinity` — fires anywhere in the attempt (including
|
|
43
|
+
* before step 0), useful for the simple `[{event: 'X'}]` shape used by
|
|
44
|
+
* `Funnel.exclusionEvents` ("X anywhere kills").
|
|
45
|
+
* @property {number} [beforeStep] - Exclusion active when `reached < beforeStep`.
|
|
46
|
+
* Default `steps.length` — fires until completion.
|
|
47
|
+
*
|
|
48
|
+
* @typedef {Object} FunnelOptions
|
|
49
|
+
* @property {number} [conversionWindowMs] - Max time from step 0 to last
|
|
50
|
+
* step (strict `<`). Omit for no window check.
|
|
51
|
+
* @property {boolean} [graceperiod=true] - Enable the 2-second grace window
|
|
52
|
+
* on ordering checks. Disable only for tests that need strict ordering.
|
|
53
|
+
* @property {boolean} [reentry=false] - When true, after completing all steps,
|
|
54
|
+
* reset to step 0 and continue scanning. Increments `completions`.
|
|
55
|
+
* @property {ExclusionStep[]} [exclusionSteps] - Exclusion events that
|
|
56
|
+
* terminate the current attempt when fired between specified steps.
|
|
57
|
+
* @property {boolean | string[]} [trackStepProperties=false] - When truthy,
|
|
58
|
+
* `result.stepProperties[i]` contains the matched event's properties at
|
|
59
|
+
* each step. Pass an array to filter to specific property names.
|
|
60
|
+
* @property {'uniques'|'totals'} [countMode='uniques'] - `'totals'` returns an
|
|
61
|
+
* ARRAY of FunnelResult — one per attempt (Mixpanel funnel_query.cpp:2055-2100).
|
|
62
|
+
* Includes incomplete attempts (drop-offs contribute to per-step counts).
|
|
63
|
+
* Without `reentry: true`, the array has at most one entry (the single attempt).
|
|
64
|
+
* @property {boolean} [sessionScoped=false] - **Verifier-only convenience** — partitions
|
|
65
|
+
* events by `session_id` and runs the matcher independently per session, returning the
|
|
66
|
+
* best result (or all results when `countMode: 'totals'`). Mixpanel does NOT have an
|
|
67
|
+
* exact equivalent; the closest production analog is `WINDOW_TYPE_SESSIONS` on the
|
|
68
|
+
* conversion window (`conversion_window.cpp:9-13`), which bounds the funnel by session
|
|
69
|
+
* COUNT, not by partitioning per session. Results from `sessionScoped: true` are NOT
|
|
70
|
+
* directly reproducible in the Mixpanel UI.
|
|
71
|
+
*
|
|
72
|
+
* @typedef {Object} FunnelResult
|
|
73
|
+
* @property {boolean} completed - Reached every step.
|
|
74
|
+
* @property {number} reached - Highest step index reached (0-based). `-1` if no steps reached.
|
|
75
|
+
* @property {Array<Object|null>} stepEvents - The event assigned to each reached step.
|
|
76
|
+
* @property {Array<number|null>} stepTimes - Timestamp (ms) of each reached step.
|
|
77
|
+
* @property {number|null} ttcMs - Time-to-convert: stepTimes[last] - stepTimes[0]. `null` if not completed.
|
|
78
|
+
* @property {number} completions - Total completions (1 if no reentry; 0 if not completed).
|
|
79
|
+
* @property {Array<Object>|undefined} stepProperties - Per-step property snapshots when `trackStepProperties` set.
|
|
80
|
+
* @property {string|undefined} sessionId - Set when result came from a session-scoped slice.
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
import { toMs } from '../hook-helpers/_internal.js';
|
|
84
|
+
|
|
85
|
+
const OUT_OF_ORDER_MS = 2000;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Returns true if `t1` is "after" `t2` by Mixpanel's funnel rules.
|
|
89
|
+
* Matches `timestamp_comes_after()` in history.cpp.
|
|
90
|
+
*
|
|
91
|
+
* @param {number} t1
|
|
92
|
+
* @param {number} t2
|
|
93
|
+
* @param {boolean} [graceperiod=true]
|
|
94
|
+
* @returns {boolean}
|
|
95
|
+
*/
|
|
96
|
+
export function timestampComesAfter(t1, t2, graceperiod = true) {
|
|
97
|
+
if (!(t1 > 0)) return false;
|
|
98
|
+
if (t1 >= t2) return true;
|
|
99
|
+
if (graceperiod && t1 + OUT_OF_ORDER_MS >= t2) return true;
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Returns true if `eventTime` is within `windowMs` of `step0Time`. Matches
|
|
105
|
+
* `is_within_conversion_window()` (strict `<`).
|
|
106
|
+
*
|
|
107
|
+
* @param {number} eventTime
|
|
108
|
+
* @param {number} step0Time
|
|
109
|
+
* @param {number|undefined} windowMs
|
|
110
|
+
* @returns {boolean}
|
|
111
|
+
*/
|
|
112
|
+
export function withinConversionWindow(eventTime, step0Time, windowMs) {
|
|
113
|
+
if (typeof windowMs !== 'number' || windowMs <= 0) return true;
|
|
114
|
+
return eventTime < step0Time + windowMs;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Normalize a funnel step (string OR `{ event, where? }`) into the canonical
|
|
119
|
+
* `{ event, where }` shape.
|
|
120
|
+
*
|
|
121
|
+
* @param {FunnelStep} step
|
|
122
|
+
* @returns {{ event: string, where?: StepFilter }}
|
|
123
|
+
*/
|
|
124
|
+
export function normalizeStep(step) {
|
|
125
|
+
if (typeof step === 'string') return { event: step };
|
|
126
|
+
if (step && typeof step === 'object' && typeof step.event === 'string') {
|
|
127
|
+
return step.where ? { event: step.event, where: step.where } : { event: step.event };
|
|
128
|
+
}
|
|
129
|
+
throw new Error(`normalizeStep: invalid step ${JSON.stringify(step)}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Apply a step filter against a candidate event's flat property map.
|
|
134
|
+
* Supports eq / neq / gt / lt / gte / lte / contains / not_contains.
|
|
135
|
+
*
|
|
136
|
+
* @param {Object} ev
|
|
137
|
+
* @param {StepFilter | undefined} where
|
|
138
|
+
* @returns {boolean}
|
|
139
|
+
*/
|
|
140
|
+
export function matchesStepFilter(ev, where) {
|
|
141
|
+
if (!where || !where.prop) return true;
|
|
142
|
+
const v = ev ? ev[where.prop] : undefined;
|
|
143
|
+
const target = where.value;
|
|
144
|
+
switch (where.op) {
|
|
145
|
+
case 'eq': return v === target;
|
|
146
|
+
case 'neq': return v !== target;
|
|
147
|
+
case 'gt': return typeof v === 'number' && v > target;
|
|
148
|
+
case 'lt': return typeof v === 'number' && v < target;
|
|
149
|
+
case 'gte': return typeof v === 'number' && v >= target;
|
|
150
|
+
case 'lte': return typeof v === 'number' && v <= target;
|
|
151
|
+
case 'contains': return typeof v === 'string' && v.includes(String(target));
|
|
152
|
+
case 'not_contains': return !(typeof v === 'string' && v.includes(String(target)));
|
|
153
|
+
default: throw new Error(`matchesStepFilter: unsupported op "${where.op}"`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Test whether an event qualifies for a (normalized) step.
|
|
159
|
+
*
|
|
160
|
+
* @param {Object} ev
|
|
161
|
+
* @param {{ event: string, where?: StepFilter }} step
|
|
162
|
+
* @returns {boolean}
|
|
163
|
+
*/
|
|
164
|
+
function eventMatchesStep(ev, step) {
|
|
165
|
+
return ev.event === step.event && matchesStepFilter(ev, step.where);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Snapshot of an event's properties for `stepProperties` tracking.
|
|
170
|
+
*
|
|
171
|
+
* @param {Object} ev
|
|
172
|
+
* @param {boolean | string[]} mode
|
|
173
|
+
* @returns {Object}
|
|
174
|
+
*/
|
|
175
|
+
function snapshotProperties(ev, mode) {
|
|
176
|
+
if (!ev) return {};
|
|
177
|
+
if (mode === true) {
|
|
178
|
+
const { event: _e, time: _t, user_id: _u, distinct_id: _d, device_id: _v, session_id: _s, ...rest } = ev;
|
|
179
|
+
return rest;
|
|
180
|
+
}
|
|
181
|
+
if (Array.isArray(mode)) {
|
|
182
|
+
const out = {};
|
|
183
|
+
for (const k of mode) if (k in ev) out[k] = ev[k];
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
return {};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Build an empty FunnelResult for failed attempts.
|
|
191
|
+
*
|
|
192
|
+
* @param {boolean | string[]} [trackStepProperties]
|
|
193
|
+
* @returns {FunnelResult}
|
|
194
|
+
*/
|
|
195
|
+
function emptyResult(trackStepProperties) {
|
|
196
|
+
const r = { completed: false, reached: -1, stepEvents: [], stepTimes: [], ttcMs: null, completions: 0, stepProperties: undefined, sessionId: undefined };
|
|
197
|
+
if (trackStepProperties) r.stepProperties = [];
|
|
198
|
+
return r;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Run one greedy single-pass funnel attempt over a pre-sorted event list.
|
|
203
|
+
* Internal helper used by `evaluateFunnel` for both basic and reentry modes.
|
|
204
|
+
*
|
|
205
|
+
* @param {Array<Object>} sorted - Pre-sorted-by-time events.
|
|
206
|
+
* @param {number} startIdx - Index to begin scanning from.
|
|
207
|
+
* @param {{ event: string, where?: StepFilter }[]} steps
|
|
208
|
+
* @param {ExclusionStep[]} exclusionSteps
|
|
209
|
+
* @param {Object} options
|
|
210
|
+
* @returns {{ result: FunnelResult, nextIdx: number, terminatedByExclusion: boolean }}
|
|
211
|
+
*/
|
|
212
|
+
function runOneAttempt(sorted, startIdx, steps, exclusionSteps, options) {
|
|
213
|
+
const { conversionWindowMs, graceperiod, trackStepProperties } = options;
|
|
214
|
+
const stepTimes = new Array(steps.length).fill(0);
|
|
215
|
+
const stepEvents = new Array(steps.length).fill(null);
|
|
216
|
+
const stepProps = trackStepProperties ? new Array(steps.length).fill(null) : null;
|
|
217
|
+
let reached = -1;
|
|
218
|
+
let i = startIdx;
|
|
219
|
+
let terminatedByExclusion = false;
|
|
220
|
+
|
|
221
|
+
for (; i < sorted.length; i++) {
|
|
222
|
+
const ev = sorted[i];
|
|
223
|
+
const t = toMs(ev.time);
|
|
224
|
+
if (!Number.isFinite(t)) continue;
|
|
225
|
+
|
|
226
|
+
// Exclusion check FIRST — if this event matches any active exclusion,
|
|
227
|
+
// terminate the attempt. Mixpanel `funnel_query.cpp` exclusion `i`
|
|
228
|
+
// fires when the user has reached step `i` (between step `i` and step
|
|
229
|
+
// `i+1`). API: `afterStep` is the index of the step that must have
|
|
230
|
+
// been reached; `beforeStep` is the index of the step that must NOT
|
|
231
|
+
// have been reached yet. Range check: `reached >= after && reached < before`.
|
|
232
|
+
// Defaults (afterStep=-Infinity, beforeStep=steps.length) mean
|
|
233
|
+
// "fires anywhere in the attempt" — used by the simple
|
|
234
|
+
// `[{event: 'X'}]` shape that `Funnel.exclusionEvents` produces.
|
|
235
|
+
if (exclusionSteps && exclusionSteps.length) {
|
|
236
|
+
let excluded = false;
|
|
237
|
+
for (const ex of exclusionSteps) {
|
|
238
|
+
if (ev.event !== ex.event) continue;
|
|
239
|
+
const after = typeof ex.afterStep === 'number' ? ex.afterStep : -Infinity;
|
|
240
|
+
const before = typeof ex.beforeStep === 'number' ? ex.beforeStep : steps.length;
|
|
241
|
+
if (reached >= after && reached < before) {
|
|
242
|
+
excluded = true; break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (excluded) { terminatedByExclusion = true; i++; break; }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Greedy assignment: this event goes to the first not-yet-reached
|
|
249
|
+
// step whose name + filter matches.
|
|
250
|
+
let matchedStep = -1;
|
|
251
|
+
for (let s = reached + 1; s < steps.length; s++) {
|
|
252
|
+
if (eventMatchesStep(ev, steps[s])) { matchedStep = s; break; }
|
|
253
|
+
}
|
|
254
|
+
if (matchedStep < 0) continue;
|
|
255
|
+
|
|
256
|
+
// "Always record the latest matching event for this step" — history.cpp.
|
|
257
|
+
stepTimes[matchedStep] = t;
|
|
258
|
+
stepEvents[matchedStep] = ev;
|
|
259
|
+
if (stepProps) stepProps[matchedStep] = snapshotProperties(ev, trackStepProperties);
|
|
260
|
+
|
|
261
|
+
if (matchedStep !== reached + 1) continue;
|
|
262
|
+
|
|
263
|
+
if (matchedStep > 0 && !withinConversionWindow(t, stepTimes[0], conversionWindowMs)) {
|
|
264
|
+
stepTimes[matchedStep] = 0;
|
|
265
|
+
stepEvents[matchedStep] = null;
|
|
266
|
+
if (stepProps) stepProps[matchedStep] = null;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
reached = matchedStep;
|
|
271
|
+
|
|
272
|
+
// Cascade through pre-recorded later steps.
|
|
273
|
+
const step0Time = stepTimes[0];
|
|
274
|
+
let lastReachedTime = t;
|
|
275
|
+
let ns = reached + 1;
|
|
276
|
+
while (ns < steps.length) {
|
|
277
|
+
const recorded = stepTimes[ns];
|
|
278
|
+
if (recorded > 0
|
|
279
|
+
&& timestampComesAfter(recorded, lastReachedTime, graceperiod)
|
|
280
|
+
&& withinConversionWindow(recorded, step0Time, conversionWindowMs)
|
|
281
|
+
) {
|
|
282
|
+
reached = ns;
|
|
283
|
+
lastReachedTime = recorded;
|
|
284
|
+
ns++;
|
|
285
|
+
} else {
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (reached === steps.length - 1) { i++; break; }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const reachedStepEvents = stepEvents.slice(0, reached + 1);
|
|
294
|
+
const reachedStepTimes = stepTimes.slice(0, reached + 1);
|
|
295
|
+
const completed = reached === steps.length - 1;
|
|
296
|
+
const ttcMs = completed && reachedStepTimes.length > 1
|
|
297
|
+
? reachedStepTimes[reachedStepTimes.length - 1] - reachedStepTimes[0]
|
|
298
|
+
: null;
|
|
299
|
+
const result = {
|
|
300
|
+
completed,
|
|
301
|
+
reached,
|
|
302
|
+
stepEvents: reachedStepEvents,
|
|
303
|
+
stepTimes: reachedStepTimes,
|
|
304
|
+
ttcMs,
|
|
305
|
+
completions: completed ? 1 : 0,
|
|
306
|
+
stepProperties: stepProps ? stepProps.slice(0, reached + 1).map(p => p || {}) : undefined,
|
|
307
|
+
sessionId: undefined,
|
|
308
|
+
};
|
|
309
|
+
return { result, nextIdx: i, terminatedByExclusion };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Evaluate a funnel against a user's event stream.
|
|
314
|
+
*
|
|
315
|
+
* Returns a `FunnelResult` (uniques mode, default) or an Array<FunnelResult>
|
|
316
|
+
* (totals mode with `reentry: true`). When `sessionScoped: true`, partitions
|
|
317
|
+
* by `session_id` and reports the best per-session result (or aggregates all
|
|
318
|
+
* with totals mode).
|
|
319
|
+
*
|
|
320
|
+
* @param {Array<Object>} events
|
|
321
|
+
* @param {FunnelStep[]} steps
|
|
322
|
+
* @param {FunnelOptions} [options]
|
|
323
|
+
* @returns {FunnelResult | FunnelResult[]}
|
|
324
|
+
*/
|
|
325
|
+
export function evaluateFunnel(events, steps, options = {}) {
|
|
326
|
+
if (!Array.isArray(steps) || steps.length === 0) {
|
|
327
|
+
const empty = emptyResult(options.trackStepProperties);
|
|
328
|
+
return options.countMode === 'totals' ? [] : empty;
|
|
329
|
+
}
|
|
330
|
+
const normSteps = steps.map(normalizeStep);
|
|
331
|
+
const {
|
|
332
|
+
conversionWindowMs,
|
|
333
|
+
graceperiod = true,
|
|
334
|
+
reentry = false,
|
|
335
|
+
exclusionSteps,
|
|
336
|
+
trackStepProperties = false,
|
|
337
|
+
countMode = 'uniques',
|
|
338
|
+
sessionScoped = false,
|
|
339
|
+
} = options;
|
|
340
|
+
|
|
341
|
+
const sorted = (events || [])
|
|
342
|
+
.filter(e => e && typeof e.event === 'string')
|
|
343
|
+
.slice()
|
|
344
|
+
.sort((a, b) => toMs(a.time) - toMs(b.time));
|
|
345
|
+
|
|
346
|
+
if (sessionScoped) {
|
|
347
|
+
const bySession = new Map();
|
|
348
|
+
for (const ev of sorted) {
|
|
349
|
+
const sid = ev.session_id != null ? String(ev.session_id) : '__no_session__';
|
|
350
|
+
if (!bySession.has(sid)) bySession.set(sid, []);
|
|
351
|
+
bySession.get(sid).push(ev);
|
|
352
|
+
}
|
|
353
|
+
const allResults = [];
|
|
354
|
+
for (const [sid, evs] of bySession) {
|
|
355
|
+
const sub = evaluateFunnel(evs, steps, { ...options, sessionScoped: false });
|
|
356
|
+
if (Array.isArray(sub)) {
|
|
357
|
+
for (const r of sub) { r.sessionId = sid; allResults.push(r); }
|
|
358
|
+
} else {
|
|
359
|
+
sub.sessionId = sid; allResults.push(sub);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (countMode === 'totals') return allResults;
|
|
363
|
+
// Uniques: return the best (highest reached, then earliest) session result.
|
|
364
|
+
if (!allResults.length) return emptyResult(trackStepProperties);
|
|
365
|
+
allResults.sort((a, b) => b.reached - a.reached || (a.stepTimes[0] || 0) - (b.stepTimes[0] || 0));
|
|
366
|
+
return allResults[0];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const opts = { conversionWindowMs, graceperiod, trackStepProperties };
|
|
370
|
+
|
|
371
|
+
if (!reentry) {
|
|
372
|
+
const { result } = runOneAttempt(sorted, 0, normSteps, exclusionSteps, opts);
|
|
373
|
+
// Totals mode: ALWAYS return the attempt (including incomplete) so per-step
|
|
374
|
+
// drop-off counts are preserved. Mixpanel funnel_query.cpp:1747 aggregates
|
|
375
|
+
// `history_get_reached >= 0`, not "completed".
|
|
376
|
+
return countMode === 'totals' ? [result] : result;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Reentry: keep running attempts after each completion or exclusion.
|
|
380
|
+
const allAttempts = [];
|
|
381
|
+
const completedAttempts = [];
|
|
382
|
+
let idx = 0;
|
|
383
|
+
let lastResult = emptyResult(trackStepProperties);
|
|
384
|
+
while (idx < sorted.length) {
|
|
385
|
+
const { result, nextIdx, terminatedByExclusion } = runOneAttempt(sorted, idx, normSteps, exclusionSteps, opts);
|
|
386
|
+
// Always advance — runOneAttempt returns nextIdx > idx when it processed an event.
|
|
387
|
+
const advanced = nextIdx > idx ? nextIdx : idx + 1;
|
|
388
|
+
idx = advanced;
|
|
389
|
+
if (result.completed) {
|
|
390
|
+
allAttempts.push(result);
|
|
391
|
+
completedAttempts.push(result);
|
|
392
|
+
lastResult = result;
|
|
393
|
+
} else if (!terminatedByExclusion && result.reached < 0) {
|
|
394
|
+
// Nothing matched in this slice — break to avoid infinite loop.
|
|
395
|
+
break;
|
|
396
|
+
} else {
|
|
397
|
+
// Failed/excluded attempt that DID reach >= 0 contributes to totals.
|
|
398
|
+
if (result.reached >= 0) allAttempts.push(result);
|
|
399
|
+
lastResult = result;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (countMode === 'totals') return allAttempts;
|
|
404
|
+
// Uniques mode with reentry: report aggregate `completions` on the LAST completion
|
|
405
|
+
// (consistent with Mixpanel's stepEvents/stepTimes reporting LAST completion).
|
|
406
|
+
if (completedAttempts.length) {
|
|
407
|
+
const last = completedAttempts[completedAttempts.length - 1];
|
|
408
|
+
last.completions = completedAttempts.length;
|
|
409
|
+
return last;
|
|
410
|
+
}
|
|
411
|
+
lastResult.completions = 0;
|
|
412
|
+
return lastResult;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Hold Property Constant (HPC) — split a funnel into parallel sub-funnels per
|
|
417
|
+
* unique value of `holdProperty` on the step-0 event. Returns
|
|
418
|
+
* `Map<propertyValue, FunnelResult>`.
|
|
419
|
+
*
|
|
420
|
+
* Each sub-funnel runs independently (a user CAN convert in one HPC value
|
|
421
|
+
* group and drop off in another simultaneously).
|
|
422
|
+
*
|
|
423
|
+
* Reference: `funnel_query.cpp` lines 749-784 (`aggregate_hash_get_key_cursor`).
|
|
424
|
+
*
|
|
425
|
+
* **Limitation (v1.5.0):** scalar HPC values only. Mixpanel's
|
|
426
|
+
* `aggregate_hash_get_key_cursor` iterates *each value* of a list-valued
|
|
427
|
+
* property, exploding into N sub-funnels per event. List-valued HPC keys are
|
|
428
|
+
* not supported here — events with non-scalar `holdProperty` values will
|
|
429
|
+
* stringify and bucket incorrectly.
|
|
430
|
+
*
|
|
431
|
+
* @param {Array<Object>} events
|
|
432
|
+
* @param {FunnelStep[]} steps
|
|
433
|
+
* @param {string} holdProperty
|
|
434
|
+
* @param {FunnelOptions} [options]
|
|
435
|
+
* @returns {Map<string|number, FunnelResult | FunnelResult[]>}
|
|
436
|
+
*/
|
|
437
|
+
export function evaluateFunnelHPC(events, steps, holdProperty, options = {}) {
|
|
438
|
+
if (!holdProperty) throw new Error('evaluateFunnelHPC: holdProperty is required');
|
|
439
|
+
if (!Array.isArray(steps) || !steps.length) return new Map();
|
|
440
|
+
const normSteps = steps.map(normalizeStep);
|
|
441
|
+
const step0Name = normSteps[0].event;
|
|
442
|
+
|
|
443
|
+
// Bucket events by HPC value. The step-0 events define the universe of
|
|
444
|
+
// HPC values for this user; later events only populate buckets whose
|
|
445
|
+
// value matches.
|
|
446
|
+
const valueBuckets = new Map();
|
|
447
|
+
for (const ev of events || []) {
|
|
448
|
+
if (!ev || typeof ev.event !== 'string') continue;
|
|
449
|
+
// Step-0 events seed the bucket on their own value.
|
|
450
|
+
if (ev.event === step0Name) {
|
|
451
|
+
const v = ev[holdProperty];
|
|
452
|
+
if (v === undefined || v === null) continue;
|
|
453
|
+
if (!valueBuckets.has(v)) valueBuckets.set(v, []);
|
|
454
|
+
valueBuckets.get(v).push(ev);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
// Now route every event with a known HPC value into its bucket.
|
|
458
|
+
for (const ev of events || []) {
|
|
459
|
+
if (!ev || typeof ev.event !== 'string' || ev.event === step0Name) continue;
|
|
460
|
+
const v = ev[holdProperty];
|
|
461
|
+
if (v === undefined || v === null) continue;
|
|
462
|
+
if (valueBuckets.has(v)) valueBuckets.get(v).push(ev);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const out = new Map();
|
|
466
|
+
for (const [v, evs] of valueBuckets) {
|
|
467
|
+
out.set(v, evaluateFunnel(evs, steps, options));
|
|
468
|
+
}
|
|
469
|
+
return out;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Resolve the property snapshot for a given segment mode against a result's
|
|
474
|
+
* `stepProperties`. Use to mimic Mixpanel's FIRST_TOUCH / LAST_TOUCH / STEP
|
|
475
|
+
* funnel segment modes.
|
|
476
|
+
*
|
|
477
|
+
* @param {FunnelResult} result
|
|
478
|
+
* @param {'first'|'last' | { step: number }} mode
|
|
479
|
+
* @returns {Object | undefined}
|
|
480
|
+
*/
|
|
481
|
+
export function resolveFunnelSegment(result, mode) {
|
|
482
|
+
if (!result || !Array.isArray(result.stepProperties) || !result.stepProperties.length) return undefined;
|
|
483
|
+
if (mode === 'first') return result.stepProperties[0];
|
|
484
|
+
if (mode === 'last') return result.stepProperties[result.reached >= 0 ? result.reached : result.stepProperties.length - 1];
|
|
485
|
+
if (mode && typeof mode === 'object' && typeof mode.step === 'number') {
|
|
486
|
+
return result.stepProperties[mode.step];
|
|
487
|
+
}
|
|
488
|
+
throw new Error(`resolveFunnelSegment: invalid mode ${JSON.stringify(mode)}`);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Set-membership funnel completion check for non-sequential funnel modes.
|
|
493
|
+
*
|
|
494
|
+
* Returns true when the user fired all `steps` event names at least once,
|
|
495
|
+
* regardless of order. Used for funnels generated with order modes other than
|
|
496
|
+
* `sequential` / `interrupt` where Mixpanel's greedy single-pass doesn't apply.
|
|
497
|
+
*
|
|
498
|
+
* `completionTimeMs` = `lastSeenTime - firstSeenTime` (proxy for "how long to
|
|
499
|
+
* hit all steps in any order"). Mixpanel's funnel TTC analog doesn't exist for
|
|
500
|
+
* non-sequential funnels — this is informational only.
|
|
501
|
+
*
|
|
502
|
+
* @param {Array<Object>} events
|
|
503
|
+
* @param {string[]} steps
|
|
504
|
+
* @returns {{
|
|
505
|
+
* completed: boolean,
|
|
506
|
+
* eventsFired: string[],
|
|
507
|
+
* firstSeenTime: number,
|
|
508
|
+
* lastSeenTime: number,
|
|
509
|
+
* completionTimeMs: number | null
|
|
510
|
+
* }}
|
|
511
|
+
*/
|
|
512
|
+
export function evaluateAnyOrderCompletion(events, steps) {
|
|
513
|
+
const empty = { completed: false, eventsFired: [], firstSeenTime: 0, lastSeenTime: 0, completionTimeMs: null };
|
|
514
|
+
if (!Array.isArray(steps) || steps.length === 0) return empty;
|
|
515
|
+
if (!Array.isArray(events) || events.length === 0) return empty;
|
|
516
|
+
const stepSet = new Set(steps);
|
|
517
|
+
const earliestByEvent = new Map();
|
|
518
|
+
let firstSeenTime = Infinity, lastSeenTime = -Infinity;
|
|
519
|
+
for (const ev of events) {
|
|
520
|
+
if (!ev || typeof ev.event !== 'string' || !stepSet.has(ev.event)) continue;
|
|
521
|
+
const t = toMs(ev.time);
|
|
522
|
+
if (!Number.isFinite(t)) continue;
|
|
523
|
+
const prev = earliestByEvent.get(ev.event);
|
|
524
|
+
if (prev === undefined || t < prev) earliestByEvent.set(ev.event, t);
|
|
525
|
+
if (t < firstSeenTime) firstSeenTime = t;
|
|
526
|
+
if (t > lastSeenTime) lastSeenTime = t;
|
|
527
|
+
}
|
|
528
|
+
const completed = earliestByEvent.size === stepSet.size;
|
|
529
|
+
if (!completed) {
|
|
530
|
+
return { ...empty, eventsFired: [...earliestByEvent.keys()] };
|
|
531
|
+
}
|
|
532
|
+
return {
|
|
533
|
+
completed: true,
|
|
534
|
+
eventsFired: [...earliestByEvent.keys()],
|
|
535
|
+
firstSeenTime,
|
|
536
|
+
lastSeenTime,
|
|
537
|
+
completionTimeMs: lastSeenTime - firstSeenTime,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity resolution for verifier — builds a device→user map from profiles
|
|
3
|
+
* and resolves a canonical user id per event. Mirrors Mixpanel's ID merge
|
|
4
|
+
* semantics: pre-auth events stamped with `device_id` and post-auth events
|
|
5
|
+
* stamped with `user_id` belong to the same canonical identity.
|
|
6
|
+
*
|
|
7
|
+
* Reference: `mixpanel/analytics` — identity merge / profiles `device_ids`
|
|
8
|
+
* inversion. We invert each profile's `device_ids` array into a flat
|
|
9
|
+
* `Map<device_id, canonical_user_id>` so query-time event grouping resolves
|
|
10
|
+
* pre-auth touches alongside post-auth events.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build a `Map<device_id, canonical_user_id>` by inverting each profile's
|
|
15
|
+
* device-pool array. Reads `device_ids` first, falling back to the legacy
|
|
16
|
+
* `anonymousIds` field that user profiles use today (carved out for
|
|
17
|
+
* backwards compat — see `lib/utils/utils.js`'s `generateUser`).
|
|
18
|
+
*
|
|
19
|
+
* Profiles without a pool are skipped. When two profiles claim the same
|
|
20
|
+
* device id, the first profile wins (deterministic by profile order).
|
|
21
|
+
*
|
|
22
|
+
* @param {Array<Object>} profiles
|
|
23
|
+
* @returns {Map<string, string>}
|
|
24
|
+
*/
|
|
25
|
+
export function buildIdentityMap(profiles) {
|
|
26
|
+
const map = new Map();
|
|
27
|
+
if (!Array.isArray(profiles)) return map;
|
|
28
|
+
for (const p of profiles) {
|
|
29
|
+
if (!p) continue;
|
|
30
|
+
const uid = p.distinct_id || p.user_id;
|
|
31
|
+
if (!uid) continue;
|
|
32
|
+
const devices = Array.isArray(p.device_ids) ? p.device_ids
|
|
33
|
+
: Array.isArray(p.anonymousIds) ? p.anonymousIds
|
|
34
|
+
: null;
|
|
35
|
+
if (!devices || !devices.length) continue;
|
|
36
|
+
for (const d of devices) {
|
|
37
|
+
if (!d || map.has(d)) continue;
|
|
38
|
+
map.set(d, uid);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return map;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the canonical user id for an event. Lookup order:
|
|
46
|
+
* 1. `event.distinct_id` — Mixpanel's canonical post-merge identifier. When
|
|
47
|
+
* a downstream pipeline has already stitched the cluster, this is the
|
|
48
|
+
* ground truth; never override it.
|
|
49
|
+
* 2. `identityMap.get(event.device_id)` — device→user merge from profile inversion.
|
|
50
|
+
* 3. `event.user_id` — already authed (pre-merge analog of distinct_id).
|
|
51
|
+
* 4. `event.device_id` — anonymous fallback.
|
|
52
|
+
*
|
|
53
|
+
* Returns `undefined` if none of the above produces a value.
|
|
54
|
+
*
|
|
55
|
+
* Reference: Mixpanel identity-manager treats `distinct_id` as the canonical
|
|
56
|
+
* cluster-anchor id (`go/.../v3/lookup_and_update_handler.go`). Verifier must
|
|
57
|
+
* not demote a stitched id to the merge map's output.
|
|
58
|
+
*
|
|
59
|
+
* **Note:** the v1.5 generator does NOT stamp `distinct_id` on raw events —
|
|
60
|
+
* it stamps `user_id` and/or `device_id`. The `event.distinct_id` short-circuit
|
|
61
|
+
* exists for external callers feeding in already-stitched data. If you see
|
|
62
|
+
* this branch firing on output from this generator, identity has been
|
|
63
|
+
* corrupted upstream (a hook stamped `distinct_id` on event records, or
|
|
64
|
+
* something carried it over from a profile clone).
|
|
65
|
+
*
|
|
66
|
+
* @param {Object} event
|
|
67
|
+
* @param {Map<string, string>} [identityMap]
|
|
68
|
+
* @returns {string|undefined}
|
|
69
|
+
*/
|
|
70
|
+
export function resolveUserId(event, identityMap) {
|
|
71
|
+
if (!event) return undefined;
|
|
72
|
+
if (event.distinct_id) return event.distinct_id;
|
|
73
|
+
if (identityMap && event.device_id) {
|
|
74
|
+
const merged = identityMap.get(event.device_id);
|
|
75
|
+
if (merged) return merged;
|
|
76
|
+
}
|
|
77
|
+
return event.user_id || event.device_id || undefined;
|
|
78
|
+
}
|
package/lib/verify/index.js
CHANGED
|
@@ -11,3 +11,22 @@
|
|
|
11
11
|
export { emulateBreakdown } from './emulate-breakdown.js';
|
|
12
12
|
export { verifyDungeon } from './verify-dungeon.js';
|
|
13
13
|
export { deriveExpectedSchema, validateSchema } from './schema-validator.js';
|
|
14
|
+
export {
|
|
15
|
+
evaluateFunnel,
|
|
16
|
+
evaluateFunnelHPC,
|
|
17
|
+
resolveFunnelSegment,
|
|
18
|
+
normalizeStep,
|
|
19
|
+
matchesStepFilter,
|
|
20
|
+
timestampComesAfter,
|
|
21
|
+
withinConversionWindow,
|
|
22
|
+
evaluateAnyOrderCompletion,
|
|
23
|
+
} from './funnel-engine.js';
|
|
24
|
+
export { buildIdentityMap, resolveUserId } from './identity.js';
|
|
25
|
+
export {
|
|
26
|
+
countDistinctPeriods,
|
|
27
|
+
nullAwareAvg,
|
|
28
|
+
nullAwareSum,
|
|
29
|
+
nullAwareExtreme,
|
|
30
|
+
binByDistinctPeriods,
|
|
31
|
+
partitionByTimeBucket,
|
|
32
|
+
} from './counting.js';
|