@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.
Files changed (65) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +158 -0
  2. package/.claude/skills/create-dungeon/SKILL.md +464 -0
  3. package/.claude/skills/verify-dungeon/SKILL.md +157 -0
  4. package/.claude/skills/verify-dungeon/references/counting-semantics.md +161 -0
  5. package/.claude/skills/verify-dungeon/references/report-format.md +216 -0
  6. package/.claude/skills/verify-dungeon/references/sql-recipes.md +857 -0
  7. package/.claude/skills/write-hooks/SKILL.md +468 -0
  8. package/CHANGELOG.md +139 -0
  9. package/HOOKS.md +1243 -597
  10. package/README.md +140 -5
  11. package/dungeons/technical/ad-spend.js +1 -1
  12. package/dungeons/technical/anonymous-users.js +1 -1
  13. package/dungeons/technical/array-of-object-lookup.js +1 -1
  14. package/dungeons/technical/datagen-v15-verify.js +74 -0
  15. package/dungeons/technical/experiments.js +1 -1
  16. package/dungeons/technical/foobar.js +1 -1
  17. package/dungeons/technical/group-analytics.js +1 -1
  18. package/dungeons/technical/mirror-strategies.js +1 -1
  19. package/dungeons/technical/nested-objects.js +1 -1
  20. package/dungeons/technical/retention-cadence.js +1 -1
  21. package/dungeons/technical/sanity.js +1 -1
  22. package/dungeons/technical/scale-test.js +1 -1
  23. package/dungeons/technical/scd.js +1 -1
  24. package/dungeons/technical/simple.js +1 -1
  25. package/dungeons/technical/simplest.js +74 -20
  26. package/dungeons/technical/text-generation.js +1 -1
  27. package/dungeons/vertical/ai-platform.js +4 -0
  28. package/dungeons/vertical/community.js +9 -3
  29. package/dungeons/vertical/crypto.js +5 -0
  30. package/dungeons/vertical/dating.js +23 -10
  31. package/dungeons/vertical/devtools.js +10 -0
  32. package/dungeons/vertical/ecommerce.js +6 -0
  33. package/dungeons/vertical/education.js +11 -0
  34. package/dungeons/vertical/fintech.js +13 -0
  35. package/dungeons/vertical/fitness.js +10 -0
  36. package/dungeons/vertical/food-delivery.js +9 -0
  37. package/dungeons/vertical/gaming.js +10 -0
  38. package/dungeons/vertical/healthcare.js +5 -0
  39. package/dungeons/vertical/insurance-application.js +10 -0
  40. package/dungeons/vertical/logistics.js +8 -1
  41. package/dungeons/vertical/marketplace.js +7 -0
  42. package/dungeons/vertical/media.js +8 -0
  43. package/dungeons/vertical/real-estate.js +7 -1
  44. package/dungeons/vertical/sass.js +12 -0
  45. package/dungeons/vertical/social.js +9 -0
  46. package/dungeons/vertical/travel.js +5 -0
  47. package/index.js +19 -4
  48. package/lib/core/config-validator.js +270 -7
  49. package/lib/core/dungeon-loader.js +2 -5
  50. package/lib/generators/events.js +12 -13
  51. package/lib/generators/funnels.js +72 -1
  52. package/lib/hook-helpers/index.js +1 -0
  53. package/lib/hook-helpers/inject.js +95 -0
  54. package/lib/orchestrators/user-loop.js +478 -29
  55. package/lib/templates/macro-presets.js +39 -9
  56. package/lib/utils/utils.js +16 -79
  57. package/lib/verify/counting.js +320 -0
  58. package/lib/verify/emulate-breakdown.js +512 -108
  59. package/lib/verify/funnel-engine.js +539 -0
  60. package/lib/verify/identity.js +78 -0
  61. package/lib/verify/index.js +19 -0
  62. package/lib/verify/verify-dungeon.js +58 -0
  63. package/package.json +4 -2
  64. package/types.d.ts +237 -4
  65. 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
+ }
@@ -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';