@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
@@ -1,32 +1,139 @@
1
1
  /**
2
2
  * Mixpanel breakdown emulator.
3
3
  *
4
- * Best-effort approximation of the table shapes Mixpanel produces for the five
5
- * analyses the Phase 4 hook patterns target. Used by `verify-dungeon` to assert
6
- * that engineered patterns actually produce the expected ratios in the data, AND
7
- * by consumers who want to validate dungeons against expected business shapes
8
- * outside of Mixpanel.
4
+ * Best-effort approximation of the table shapes Mixpanel produces for the
5
+ * five analyses the Phase 4 hook patterns target. Used by `verify-dungeon`
6
+ * to assert that engineered patterns actually produce the expected ratios
7
+ * in the data, AND by consumers who want to validate dungeons against
8
+ * expected business shapes outside of Mixpanel.
9
9
  *
10
- * Reference: Mixpanel Insights / Funnels / Flows reports, as of 2026-05.
10
+ * The counting semantics now match Mixpanel's actual implementation
11
+ * (greedy single-pass funnels, distinct-period frequency, null-aware
12
+ * aggregation, attribution touchpoint cap). See `funnel-engine.js` and
13
+ * `counting.js` for the per-rule references into the
14
+ * `mixpanel/analytics` source tree.
11
15
  *
12
16
  * Caveats:
13
- * - Mixpanel applies its own per-account UTC offset and time-bucketing rules. This
14
- * emulator uses raw event times unless the breakdown explicitly involves a window.
15
- * - Mixpanel "users" are typically distinct profiles with at least one event in
16
- * the date range; this emulator counts unique `user_id` (falling back to
17
- * `distinct_id`) found across the events array.
18
- * - This is not bit-exact — it's the *shape* check Phase 4 verification needs.
17
+ * - Mixpanel applies its own per-account UTC offset and time-bucketing
18
+ * rules. This emulator uses raw event times; UTC is assumed.
19
+ * - "Users" are unique `user_id` (falling back to `distinct_id`) found
20
+ * across the events array.
21
+ * - This is not bit-exact — it's the *shape* check verification needs.
19
22
  */
20
23
 
24
+ import { toMs } from '../hook-helpers/_internal.js';
25
+ import { evaluateFunnel, evaluateAnyOrderCompletion } from './funnel-engine.js';
26
+ import { buildIdentityMap, resolveUserId } from './identity.js';
27
+ import {
28
+ countDistinctPeriods,
29
+ nullAwareAvg,
30
+ nullAwareSum,
31
+ nullAwareExtreme,
32
+ partitionByTimeBucket,
33
+ } from './counting.js';
34
+
35
+ /**
36
+ * v1.5: pick the right per-user funnel evaluator based on the funnel's `order` mode.
37
+ *
38
+ * - `sequential`, `interrupt`, `interrupted` → greedy single-pass (Mixpanel-aligned)
39
+ * - `first-fixed` → step-0 greedy + any-order on rest
40
+ * - `last-fixed`, `outside-in`, `middle-fixed`, `first-and-last-fixed`
41
+ * → any-order completion (partial verification)
42
+ * - `random` → any-order, informational only
43
+ *
44
+ * Returns a normalized result `{ completed, reached, ttcMs, mode, verificationKind }`.
45
+ * `verificationKind` is `undefined` for sequential modes (full PASS/FAIL allowed),
46
+ * `'partial'` for completion-only modes, `'informational'` for `random`.
47
+ *
48
+ * @param {Array<Object>} userEvents
49
+ * @param {string[]} steps
50
+ * @param {Object} options
51
+ * @param {string} [options.funnelOrder='sequential']
52
+ * @param {number} [options.conversionWindowMs]
53
+ * @param {boolean} [options.reentry]
54
+ * @param {Array<Object>} [options.exclusionSteps]
55
+ * @param {boolean | string[]} [options.trackStepProperties]
56
+ * @param {boolean} [options.sessionScoped]
57
+ */
58
+ function evaluateFunnelByOrder(userEvents, steps, options = {}) {
59
+ const order = options.funnelOrder || 'sequential';
60
+ const sequentialOpts = {
61
+ conversionWindowMs: options.conversionWindowMs,
62
+ reentry: options.reentry,
63
+ exclusionSteps: options.exclusionSteps,
64
+ trackStepProperties: options.trackStepProperties,
65
+ sessionScoped: options.sessionScoped,
66
+ };
67
+ switch (order) {
68
+ case 'sequential':
69
+ case 'interrupt':
70
+ case 'interrupted': {
71
+ const r = /** @type {*} */ (evaluateFunnel(userEvents, steps, sequentialOpts));
72
+ return { ...r, mode: order, verificationKind: undefined };
73
+ }
74
+ case 'first-fixed': {
75
+ const stepZero = /** @type {*} */ (evaluateFunnel(userEvents, [steps[0]], { conversionWindowMs: options.conversionWindowMs }));
76
+ if (!stepZero.completed) {
77
+ return { completed: false, reached: -1, stepEvents: [], stepTimes: [], ttcMs: null, mode: 'first-fixed', verificationKind: 'partial' };
78
+ }
79
+ const rest = evaluateAnyOrderCompletion(userEvents, steps.slice(1));
80
+ const completed = rest.completed;
81
+ const reached = completed ? steps.length - 1 : 0;
82
+ return {
83
+ completed,
84
+ reached,
85
+ stepEvents: [],
86
+ stepTimes: [],
87
+ ttcMs: completed ? rest.completionTimeMs : null,
88
+ mode: 'first-fixed',
89
+ verificationKind: 'partial',
90
+ };
91
+ }
92
+ case 'last-fixed':
93
+ case 'middle-fixed':
94
+ case 'first-and-last-fixed':
95
+ case 'outside-in': {
96
+ const r = evaluateAnyOrderCompletion(userEvents, steps);
97
+ return {
98
+ completed: r.completed,
99
+ reached: r.completed ? steps.length - 1 : -1,
100
+ stepEvents: [],
101
+ stepTimes: [],
102
+ ttcMs: r.completionTimeMs,
103
+ mode: order,
104
+ verificationKind: 'partial',
105
+ };
106
+ }
107
+ case 'random': {
108
+ const r = evaluateAnyOrderCompletion(userEvents, steps);
109
+ return {
110
+ completed: r.completed,
111
+ reached: r.completed ? steps.length - 1 : -1,
112
+ stepEvents: [],
113
+ stepTimes: [],
114
+ ttcMs: r.completionTimeMs,
115
+ mode: 'random',
116
+ verificationKind: 'informational',
117
+ };
118
+ }
119
+ default: {
120
+ const r = /** @type {*} */ (evaluateFunnel(userEvents, steps, sequentialOpts));
121
+ return { ...r, mode: order, verificationKind: undefined };
122
+ }
123
+ }
124
+ }
125
+
21
126
  /**
22
127
  * @typedef {Object} EmulateOptions
23
- * @property {'frequencyByFrequency'|'funnelFrequency'|'aggregatePerUser'|'timeToConvert'|'attributedBy'} type
128
+ * @property {'frequencyByFrequency'|'funnelFrequency'|'aggregatePerUser'|'timeToConvert'|'attributedBy'|'sessionMetrics'|'retention'} type
24
129
  *
25
130
  * @property {string} [metricEvent]
26
131
  * @property {string} [breakdownByFrequencyOf]
27
132
  * @property {boolean} [perUser]
133
+ * @property {('hour'|'day'|'week')} [periodUnit]
28
134
  *
29
135
  * @property {string[]} [steps]
136
+ * @property {number} [conversionWindowMs]
30
137
  *
31
138
  * @property {string} [event]
32
139
  * @property {string} [property]
@@ -41,6 +148,27 @@
41
148
  * @property {string} [attributionEvent]
42
149
  * @property {string} [attributionProperty]
43
150
  * @property {'firstTouch'|'lastTouch'} [model]
151
+ * @property {number} [touchpointsLimit]
152
+ *
153
+ * v1.5.0 funnel extensions (apply to funnelFrequency + timeToConvert sequential modes):
154
+ * @property {boolean} [reentry]
155
+ * @property {Array<Object>} [exclusionSteps]
156
+ * @property {boolean | string[]} [trackStepProperties]
157
+ * @property {boolean} [sessionScoped]
158
+ *
159
+ * @property {Map<string,string>} [identityMap]
160
+ *
161
+ * Cross-cutting time-bucketed output:
162
+ * @property {('day'|'week'|'month')} [timeBucket]
163
+ * @property {{from: number|string, to: number|string}} [timeBucketRange]
164
+ *
165
+ * **timeBucket result-row contract:**
166
+ * - Buckets WITH events: `{ period, ...originalBreakdownRow }`
167
+ * - Buckets WITHOUT events (only when `timeBucketRange` set):
168
+ * `{ period, _empty: true }` — caller MUST filter `r._empty` before any
169
+ * numerical aggregation. Mixpanel `normal_query.cpp:352-356` emits zero
170
+ * rows for empty intervals; we use the `_empty` marker instead of
171
+ * guessing a per-type zero-row template.
44
172
  */
45
173
 
46
174
  /**
@@ -54,36 +182,75 @@
54
182
  export function emulateBreakdown(events, config) {
55
183
  if (!Array.isArray(events)) throw new Error('emulateBreakdown: events must be an array');
56
184
  if (!config || !config.type) throw new Error('emulateBreakdown: config.type is required');
185
+
186
+ // Auto-build identity map ONCE when profiles supplied. Threads through every
187
+ // breakdown type AND every time-bucket recursive call so pre-auth (device_id
188
+ // only) events resolve to the same canonical user as post-auth (user_id)
189
+ // events. Hoisted above the timeBucket dispatch to avoid rebuilding the
190
+ // map per-bucket on large datasets.
191
+ const identityMap = config.identityMap
192
+ || (Array.isArray(config.profiles)
193
+ && config.profiles.some(p =>
194
+ p && ((Array.isArray(p.device_ids) && p.device_ids.length)
195
+ || (Array.isArray(p.anonymousIds) && p.anonymousIds.length)))
196
+ ? buildIdentityMap(config.profiles)
197
+ : undefined);
198
+
199
+ // v1.5: time-bucketed wrapper. Partition events by UTC bucket, run the
200
+ // underlying breakdown per partition, tag rows with `period`.
201
+ //
202
+ // Empty-bucket backfill: when `timeBucketRange: { from, to }` is supplied,
203
+ // every bucket in the range gets a row, even if the breakdown returned no
204
+ // rows. Empty periods emit a single `{ period, _empty: true }` marker so
205
+ // callers can render a continuous trend axis (Mixpanel `normal_query.cpp`
206
+ // emits zero rows for empty intervals). Consumers MUST filter `r._empty`
207
+ // before any aggregation.
208
+ if (config.timeBucket) {
209
+ const range = config.timeBucketRange || {};
210
+ const buckets = partitionByTimeBucket(events, config.timeBucket, range);
211
+ // Pass the pre-built identityMap into recursive calls so the auto-build
212
+ // branch above is a no-op per bucket (would otherwise rebuild N times).
213
+ const inner = { ...config, timeBucket: undefined, timeBucketRange: undefined, identityMap };
214
+ const out = [];
215
+ for (const { period, events: evs } of buckets) {
216
+ const rows = emulateBreakdown(evs, inner);
217
+ if (rows.length) {
218
+ for (const r of rows) out.push({ period, ...r });
219
+ } else {
220
+ out.push({ period, _empty: true });
221
+ }
222
+ }
223
+ return out;
224
+ }
225
+
226
+ const cfg = identityMap ? { ...config, identityMap } : config;
57
227
  switch (config.type) {
58
- case 'frequencyByFrequency': return frequencyByFrequency(events, /** @type {*} */ (config));
59
- case 'funnelFrequency': return funnelFrequency(events, /** @type {*} */ (config));
60
- case 'aggregatePerUser': return aggregatePerUser(events, /** @type {*} */ (config));
61
- case 'timeToConvert': return timeToConvert(events, /** @type {*} */ (config));
62
- case 'attributedBy': return attributedBy(events, /** @type {*} */ (config));
228
+ case 'frequencyByFrequency': return frequencyByFrequency(events, /** @type {*} */ (cfg));
229
+ case 'funnelFrequency': return funnelFrequency(events, /** @type {*} */ (cfg));
230
+ case 'aggregatePerUser': return aggregatePerUser(events, /** @type {*} */ (cfg));
231
+ case 'timeToConvert': return timeToConvert(events, /** @type {*} */ (cfg));
232
+ case 'attributedBy': return attributedBy(events, /** @type {*} */ (cfg));
233
+ case 'sessionMetrics': return sessionMetrics(events, /** @type {*} */ (cfg));
234
+ case 'retention': return retention(events, /** @type {*} */ (cfg));
63
235
  default: throw new Error(`emulateBreakdown: unknown type "${config.type}"`);
64
236
  }
65
237
  }
66
238
 
67
239
  // ── Frequency × Frequency (Insights, Frequency Distribution by per-user count of B) ──
240
+ //
241
+ // Both axes are DISTINCT PERIOD counts (default: days), not raw event counts.
242
+ // Reference: addiction_query.cpp — see counting.js#countDistinctPeriods for
243
+ // the rule and why it matters.
68
244
 
69
- function frequencyByFrequency(events, { metricEvent, breakdownByFrequencyOf }) {
245
+ function frequencyByFrequency(events, { metricEvent, breakdownByFrequencyOf, periodUnit = 'day', identityMap }) {
70
246
  if (!metricEvent || !breakdownByFrequencyOf) {
71
247
  throw new Error('frequencyByFrequency requires metricEvent and breakdownByFrequencyOf');
72
248
  }
73
- const userMetric = new Map();
74
- const userBreakdown = new Map();
75
- const uids = new Set();
76
- for (const ev of events) {
77
- const uid = userIdOf(ev);
78
- if (!uid) continue;
79
- uids.add(uid);
80
- if (ev.event === metricEvent) userMetric.set(uid, (userMetric.get(uid) || 0) + 1);
81
- if (ev.event === breakdownByFrequencyOf) userBreakdown.set(uid, (userBreakdown.get(uid) || 0) + 1);
82
- }
249
+ const userEvents = groupByUser(events, identityMap);
83
250
  const cell = new Map(); // `${m}|${b}` → user_count
84
- for (const uid of uids) {
85
- const m = userMetric.get(uid) || 0;
86
- const b = userBreakdown.get(uid) || 0;
251
+ for (const [, evs] of userEvents) {
252
+ const m = countDistinctPeriods(evs, metricEvent, /** @type {*} */ (periodUnit));
253
+ const b = countDistinctPeriods(evs, breakdownByFrequencyOf, /** @type {*} */ (periodUnit));
87
254
  const key = `${m}|${b}`;
88
255
  cell.set(key, (cell.get(key) || 0) + 1);
89
256
  }
@@ -94,36 +261,30 @@ function frequencyByFrequency(events, { metricEvent, breakdownByFrequencyOf }) {
94
261
  }
95
262
 
96
263
  // ── Funnel Frequency Breakdown (Funnel report broken down by per-user count of X) ──
264
+ //
265
+ // Step progression uses the greedy single-pass funnel engine
266
+ // (funnel-engine.js → history.cpp). Cohort breakdown axis uses distinct-period
267
+ // counting (addiction_query.cpp).
97
268
 
98
- function funnelFrequency(events, { steps, breakdownByFrequencyOf }) {
269
+ function funnelFrequency(events, { steps, breakdownByFrequencyOf, conversionWindowMs, periodUnit = 'day', funnelOrder = 'sequential', identityMap, reentry, exclusionSteps, trackStepProperties, sessionScoped }) {
99
270
  if (!Array.isArray(steps) || !steps.length) throw new Error('funnelFrequency requires steps[]');
100
271
  if (!breakdownByFrequencyOf) throw new Error('funnelFrequency requires breakdownByFrequencyOf');
101
- const userEvents = groupByUser(events);
102
- const userBreakdown = new Map();
103
- for (const [uid, evs] of userEvents) {
104
- const c = evs.filter(e => e && e.event === breakdownByFrequencyOf).length;
105
- userBreakdown.set(uid, c);
106
- }
272
+ const userEvents = groupByUser(events, identityMap);
107
273
  const result = [];
108
- for (let s = 0; s < steps.length; s++) {
109
- const stepName = steps[s];
110
- const conversions = new Map(); // breakdown_freq count
111
- for (const [uid, evs] of userEvents) {
112
- const sorted = sortByTime(evs);
113
- let stepIdx = 0;
114
- for (const ev of sorted) {
115
- if (ev.event === steps[stepIdx]) stepIdx++;
116
- if (stepIdx > s) break;
117
- }
118
- if (stepIdx > s) {
119
- const b = userBreakdown.get(uid) || 0;
120
- conversions.set(b, (conversions.get(b) || 0) + 1);
121
- }
122
- }
123
- for (const [b, c] of conversions) {
124
- result.push({ step: stepName, step_index: s, breakdown_freq: b, conversions: c, conversion_pct: 0 });
274
+ const conversions = new Map(); // `${stepIdx}|${b}` count
275
+ for (const [, evs] of userEvents) {
276
+ // v1.5: dispatch on funnel.order so non-sequential modes don't return 0% trivially.
277
+ const r = evaluateFunnelByOrder(evs, steps, { conversionWindowMs, funnelOrder, reentry, exclusionSteps, trackStepProperties, sessionScoped });
278
+ const b = countDistinctPeriods(evs, breakdownByFrequencyOf, /** @type {*} */ (periodUnit));
279
+ for (let s = 0; s <= r.reached; s++) {
280
+ const key = `${s}|${b}`;
281
+ conversions.set(key, (conversions.get(key) || 0) + 1);
125
282
  }
126
283
  }
284
+ for (const [key, count] of conversions) {
285
+ const [s, b] = key.split('|').map(Number);
286
+ result.push({ step: steps[s], step_index: s, breakdown_freq: b, conversions: count, conversion_pct: 0 });
287
+ }
127
288
  // Conversion % at each step relative to its own breakdown_freq's step-0 baseline.
128
289
  const baseline = new Map();
129
290
  for (const r of result) {
@@ -137,49 +298,74 @@ function funnelFrequency(events, { steps, breakdownByFrequencyOf }) {
137
298
  }
138
299
 
139
300
  // ── Aggregate per user (Insights, sum/avg of property X by per-user count of B) ──
301
+ //
302
+ // AVG/SUM/MIN/MAX use null-aware aggregation (normal_query.cpp). The cohort
303
+ // breakdown axis uses distinct-period counting.
140
304
 
141
- function aggregatePerUser(events, { event, property, agg = 'avg', breakdownByFrequencyOf }) {
305
+ function aggregatePerUser(events, { event, property, agg = 'avg', breakdownByFrequencyOf, periodUnit = 'day', identityMap }) {
142
306
  if (!event) throw new Error('aggregatePerUser requires event');
143
307
  if (!breakdownByFrequencyOf) throw new Error('aggregatePerUser requires breakdownByFrequencyOf');
144
308
  if (agg !== 'count' && !property) throw new Error('aggregatePerUser requires property unless agg is "count"');
145
- const userVals = new Map();
309
+ const userEvents = groupByUser(events, identityMap);
310
+ const userAgg = new Map();
146
311
  const userBreakdown = new Map();
147
- for (const ev of events) {
148
- const uid = userIdOf(ev);
149
- if (!uid) continue;
150
- if (ev.event === event) {
151
- // `agg: 'count'` → count occurrences of the event regardless of property type.
152
- // All other aggs only consider numeric property values.
153
- if (agg === 'count') {
154
- if (!userVals.has(uid)) userVals.set(uid, []);
155
- userVals.get(uid).push(1);
156
- } else if (property && typeof ev[property] === 'number') {
157
- if (!userVals.has(uid)) userVals.set(uid, []);
158
- userVals.get(uid).push(ev[property]);
159
- }
312
+ for (const [uid, evs] of userEvents) {
313
+ const matches = evs.filter(e => e && e.event === event);
314
+ let aggValue;
315
+ if (agg === 'count') {
316
+ aggValue = matches.length;
317
+ } else {
318
+ const values = matches.map(e => e[property]);
319
+ aggValue = applyNullAwareAgg(values, agg);
320
+ }
321
+ // Skip users with no aggregate (no matching events for count==0 still
322
+ // counted; numeric agg returning null means no numeric values).
323
+ if (aggValue !== null && aggValue !== undefined) {
324
+ userAgg.set(uid, aggValue);
160
325
  }
161
- if (ev.event === breakdownByFrequencyOf) userBreakdown.set(uid, (userBreakdown.get(uid) || 0) + 1);
326
+ userBreakdown.set(uid, countDistinctPeriods(evs, breakdownByFrequencyOf, /** @type {*} */ (periodUnit)));
162
327
  }
163
- const userAgg = new Map();
164
- for (const [uid, vals] of userVals) userAgg.set(uid, applyAgg(vals, agg));
165
328
  const buckets = new Map(); // breakdown_freq → [aggregates]
166
329
  for (const [uid, v] of userAgg) {
167
330
  const b = userBreakdown.get(uid) || 0;
168
331
  if (!buckets.has(b)) buckets.set(b, []);
169
332
  buckets.get(b).push(v);
170
333
  }
171
- return [...buckets.entries()].map(([b, vs]) => ({
172
- breakdown_freq: b,
173
- user_count: vs.length,
174
- avg_aggregate: vs.reduce((a, x) => a + x, 0) / vs.length,
175
- })).sort((x, y) => x.breakdown_freq - y.breakdown_freq);
334
+ // Cohort-level rollup: Mixpanel "Aggregate per user" report applies the
335
+ // SAME `agg` mode at the cohort level (e.g. SUM-mode shows sum-of-sums,
336
+ // MAX-mode shows max-of-maxes). We expose all of them so consumers can
337
+ // pick the column that matches the report they're verifying:
338
+ // - `avg_aggregate` mean of per-user aggregates (always available)
339
+ // - `cohort_sum` / `cohort_min` / `cohort_max` — same `agg` applied across users
340
+ return [...buckets.entries()].map(([b, vs]) => {
341
+ const sum = vs.reduce((a, x) => a + x, 0);
342
+ const row = {
343
+ breakdown_freq: b,
344
+ user_count: vs.length,
345
+ avg_aggregate: sum / vs.length,
346
+ };
347
+ if (agg === 'sum' || agg === 'count') {
348
+ row.cohort_sum = sum;
349
+ } else if (agg === 'min') {
350
+ row.cohort_min = vs.reduce((a, x) => x < a ? x : a, Infinity);
351
+ } else if (agg === 'max') {
352
+ row.cohort_max = vs.reduce((a, x) => x > a ? x : a, -Infinity);
353
+ }
354
+ return row;
355
+ }).sort((x, y) => x.breakdown_freq - y.breakdown_freq);
176
356
  }
177
357
 
178
358
  // ── Time to Convert (Funnel TTC, broken down by user property) ──
359
+ //
360
+ // Step pair matched via the greedy funnel engine (history.cpp). When the
361
+ // funnel completes via the engine, ttcMs = stepTimes[1] - stepTimes[0].
362
+ // Differs from the old "first occurrence of fromEvent then first occurrence
363
+ // of toEvent after it" logic by enforcing the same temporal rules Mixpanel
364
+ // uses for funnel matching.
179
365
 
180
- function timeToConvert(events, { fromEvent, toEvent, breakdownByUserProperty, profiles = [] }) {
366
+ function timeToConvert(events, { fromEvent, toEvent, breakdownByUserProperty, profiles = [], funnelOrder = 'sequential', conversionWindowMs, identityMap, reentry, exclusionSteps, sessionScoped }) {
181
367
  if (!fromEvent || !toEvent) throw new Error('timeToConvert requires fromEvent and toEvent');
182
- const userEvents = groupByUser(events);
368
+ const userEvents = groupByUser(events, identityMap);
183
369
  const profileByUid = new Map();
184
370
  for (const p of profiles) {
185
371
  if (!p) continue;
@@ -188,20 +374,16 @@ function timeToConvert(events, { fromEvent, toEvent, breakdownByUserProperty, pr
188
374
  }
189
375
  const buckets = new Map(); // segValue → [ttcMs]
190
376
  for (const [uid, evs] of userEvents) {
191
- const sorted = sortByTime(evs);
192
- const a = sorted.find(e => e && e.event === fromEvent);
193
- if (!a) continue;
194
- const aIdx = sorted.indexOf(a);
195
- const b = sorted.slice(aIdx + 1).find(e => e && e.event === toEvent);
196
- if (!b) continue;
197
- const ttcMs = toMs(b.time) - toMs(a.time);
198
- if (!Number.isFinite(ttcMs) || ttcMs < 0) continue;
377
+ // v1.5: respect funnel.order. For random mode, ttcMs is informational
378
+ // (lastSeenTime - firstSeenTime), not Mixpanel TTC.
379
+ const r = evaluateFunnelByOrder(evs, [fromEvent, toEvent], { funnelOrder, conversionWindowMs, reentry, exclusionSteps, sessionScoped });
380
+ if (!r.completed || r.ttcMs === null || !Number.isFinite(r.ttcMs) || r.ttcMs < 0) continue;
199
381
  const profile = profileByUid.get(uid);
200
382
  const segValue = breakdownByUserProperty
201
383
  ? (profile ? (profile[breakdownByUserProperty] ?? 'unknown') : 'unknown')
202
384
  : 'all';
203
385
  if (!buckets.has(segValue)) buckets.set(segValue, []);
204
- buckets.get(segValue).push(ttcMs);
386
+ buckets.get(segValue).push(r.ttcMs);
205
387
  }
206
388
  return [...buckets.entries()].map(([seg, ttcs]) => ({
207
389
  segment_value: seg,
@@ -212,22 +394,37 @@ function timeToConvert(events, { fromEvent, toEvent, breakdownByUserProperty, pr
212
394
  }
213
395
 
214
396
  // ── Attributed By (first-/last-touch attribution by event property value) ──
397
+ //
398
+ // Touchpoint cap: max 10 touchpoints in lookback window
399
+ // (`TOUCHPOINTS_LIMIT = 10` in attributed_value_reader.cpp). For first/last
400
+ // touch the cap matters when the user has > 10 touches before conversion;
401
+ // the cap shifts which touches enter the candidate pool.
215
402
 
216
- function attributedBy(events, { conversionEvent, attributionEvent, attributionProperty, model = 'firstTouch' }) {
403
+ function attributedBy(events, {
404
+ conversionEvent,
405
+ attributionEvent,
406
+ attributionProperty,
407
+ model = 'firstTouch',
408
+ touchpointsLimit = 10,
409
+ identityMap,
410
+ }) {
217
411
  if (!conversionEvent || !attributionEvent || !attributionProperty) {
218
412
  throw new Error('attributedBy requires conversionEvent, attributionEvent, attributionProperty');
219
413
  }
220
- const userEvents = groupByUser(events);
414
+ const userEvents = groupByUser(events, identityMap);
221
415
  const counts = new Map();
222
- for (const [uid, evs] of userEvents) {
416
+ for (const [, evs] of userEvents) {
223
417
  const sorted = sortByTime(evs);
224
418
  const conversion = sorted.find(e => e && e.event === conversionEvent);
225
419
  if (!conversion) continue;
226
420
  const conversionTime = toMs(conversion.time);
227
- const touches = sorted.filter(e =>
421
+ const allTouches = sorted.filter(e =>
228
422
  e && e.event === attributionEvent && toMs(e.time) <= conversionTime
229
423
  );
230
- if (!touches.length) continue;
424
+ if (!allTouches.length) continue;
425
+ // Cap to the last `touchpointsLimit` touches in the lookback window.
426
+ // (When touch count <= cap, this is a no-op.)
427
+ const touches = allTouches.slice(-touchpointsLimit);
231
428
  const touch = model === 'lastTouch' ? touches[touches.length - 1] : touches[0];
232
429
  const v = touch[attributionProperty] ?? 'unknown';
233
430
  counts.set(v, (counts.get(v) || 0) + 1);
@@ -238,16 +435,211 @@ function attributedBy(events, { conversionEvent, attributionEvent, attributionPr
238
435
  })).sort((a, b) => b.conversions - a.conversions);
239
436
  }
240
437
 
438
+ // ── Retention (birth-anchored day buckets) ───────────────────────────────────
439
+ //
440
+ // Reference: backend/arb/reader/queries/retention_query.cpp
441
+ //
442
+ // Bucketing rule (retention_query.cpp:1227-1231):
443
+ // time_to_retention_event_s = retention_event_time_s - first_event_time_s
444
+ // bucket = floor(time_to_retention_event_s / bucket_seconds)
445
+ //
446
+ // We compute bucket = floor((return_ms - birth_ms) / DAY_MS) — a raw ms-delta
447
+ // from birth, NOT a UTC-calendar-day-number difference. So a return 23h after
448
+ // birth lands in bucket 0; a return 25h after birth lands in bucket 1.
449
+ //
450
+ // Birth-can-retain (default false; retention_query.cpp:1097-1109):
451
+ // if (birth_can_retain) return first_event_time_ms <= retention_event_time_ms;
452
+ // else return first_event_time_ms < retention_event_time_ms;
453
+ // We default to false (return events strictly after birth, ms-precise).
454
+ //
455
+ // Optional `carry_forward`: once retained on day M, count as retained on
456
+ // every later bucket (Mixpanel's CARRY_FORWARD unbounded mode —
457
+ // retention_query.cpp:1824-1837).
458
+ //
459
+ // Optional `segmentBy`: partition the cohort by the birth event's property
460
+ // value (Mixpanel's segment_event=FIRST mode — retention_query.cpp:1309).
461
+ //
462
+ // NOT IMPLEMENTED — these are MORE common than initially documented; treat as
463
+ // known scope gaps:
464
+ // - COMPOUNDED retention (retention_query.cpp:670) reuses the first-event
465
+ // filter as the return filter, making EVERY cohort event a retention
466
+ // candidate. Used heavily in Mixpanel's "DAU coming back" reports.
467
+ // - CARRY_BACK / CONSECUTIVE_FORWARD unbounded modes
468
+ // - CALENDAR_START bucket alignment (retention_query.cpp:308-321)
469
+ // - segment_event=SECOND (retention_query.cpp:1310 — return event property)
470
+ // - Cohort window (only users with birth in `from_date..to_date` are in
471
+ // cohort; we use ALL users with the birth event in the dataset)
472
+ // - week / month bucket units (only `day` here)
473
+
474
+ const DAY_MS_RET = 86400 * 1000;
475
+
476
+ function retention(events, { cohortEvent, returnEvent, dayBuckets = [1, 7, 14, 30], segmentBy, carry_forward = false, birthCanRetain = false, identityMap }) {
477
+ if (!cohortEvent) throw new Error('retention requires cohortEvent');
478
+ if (!returnEvent) throw new Error('retention requires returnEvent');
479
+ if (!Array.isArray(dayBuckets) || !dayBuckets.length) {
480
+ throw new Error('retention requires non-empty dayBuckets');
481
+ }
482
+
483
+ const userEvents = groupByUser(events, identityMap);
484
+
485
+ // segment → cohort users + per-user state
486
+ const cohorts = new Map();
487
+ const ensureSegment = (seg) => {
488
+ if (!cohorts.has(seg)) cohorts.set(seg, { users: new Set(), birthMsByUser: new Map(), returnBucketsByUser: new Map() });
489
+ return cohorts.get(seg);
490
+ };
491
+
492
+ for (const [uid, evs] of userEvents) {
493
+ // Birth = earliest cohortEvent for this user.
494
+ const sorted = sortByTime(evs);
495
+ const birth = sorted.find(e => e.event === cohortEvent);
496
+ if (!birth) continue;
497
+ const birthMs = toMs(birth.time);
498
+ if (!Number.isFinite(birthMs)) continue;
499
+ const seg = segmentBy ? (birth[segmentBy] ?? 'unknown') : 'all';
500
+ const sb = ensureSegment(seg);
501
+ sb.users.add(uid);
502
+ sb.birthMsByUser.set(uid, birthMs);
503
+
504
+ const retBuckets = new Set();
505
+ for (const ev of sorted) {
506
+ if (ev.event !== returnEvent) continue;
507
+ const evMs = toMs(ev.time);
508
+ if (!Number.isFinite(evMs)) continue;
509
+ // Mixpanel ms-strict gate (retention_query.cpp:1097-1109).
510
+ const passes = birthCanRetain ? (birthMs <= evMs) : (birthMs < evMs);
511
+ if (!passes) continue;
512
+ // Bucket by ms-delta — Mixpanel time_to_retention_event_s / bucket_seconds.
513
+ const bucket = Math.floor((evMs - birthMs) / DAY_MS_RET);
514
+ if (bucket >= 0) retBuckets.add(bucket);
515
+ }
516
+ sb.returnBucketsByUser.set(uid, retBuckets);
517
+ }
518
+
519
+ const out = [];
520
+ for (const [seg, sb] of cohorts) {
521
+ const cohortSize = sb.users.size;
522
+ for (const day of dayBuckets) {
523
+ let retained = 0;
524
+ for (const uid of sb.users) {
525
+ const buckets = sb.returnBucketsByUser.get(uid);
526
+ if (!buckets) continue;
527
+ if (carry_forward) {
528
+ // Retained on bucket N if hit any bucket in [0, N] (or [1, N] if you exclude bucket 0).
529
+ let hit = false;
530
+ for (const b of buckets) {
531
+ if (b <= day) { hit = true; break; }
532
+ }
533
+ if (hit) retained++;
534
+ } else {
535
+ if (buckets.has(day)) retained++;
536
+ }
537
+ }
538
+ out.push({
539
+ day,
540
+ retained_count: retained,
541
+ cohort_size: cohortSize,
542
+ retained_pct: cohortSize ? retained / cohortSize : 0,
543
+ segment: seg,
544
+ });
545
+ }
546
+ }
547
+ out.sort((a, b) => String(a.segment).localeCompare(String(b.segment)) || a.day - b.day);
548
+ return out;
549
+ }
550
+
551
+ // ── Session Metrics (Mixpanel session report) ────────────────────────────────
552
+ //
553
+ // Reference: backend/arb/reader/queries/session_query.cpp.
554
+ //
555
+ // Mixpanel computes sessions at query time using a 30-min gap (default) +
556
+ // 24h max model and emits synthetic event properties: $duration_s,
557
+ // $event_count, $origin_start, $origin_end. Our generator pre-stamps
558
+ // `session_id` using the same rules; here we trust that stamping and just
559
+ // group → aggregate per session.
560
+ //
561
+ // Returns an array with one row per requested metric:
562
+ // [{ metric: 'count', avg, median, p90, total_sessions }]
563
+ // [{ metric: 'duration', avg_ms, median_ms, p90_ms, total_sessions }]
564
+ // [{ metric: 'eventsPerSession',avg, median, p90, total_sessions }]
565
+
566
+ function sessionMetrics(events, { event, metrics = ['count', 'duration', 'eventsPerSession'], identityMap }) {
567
+ const userEvents = groupByUser(events, identityMap);
568
+ const sessionsByUser = new Map();
569
+ for (const [uid, evs] of userEvents) {
570
+ // One session bucket per (user, session_id). Events without session_id
571
+ // are excluded — Mixpanel only emits session reports for events that
572
+ // landed inside an evaluated session.
573
+ const buckets = new Map();
574
+ for (const ev of evs) {
575
+ if (ev.session_id == null) continue;
576
+ const sid = String(ev.session_id);
577
+ if (!buckets.has(sid)) buckets.set(sid, []);
578
+ buckets.get(sid).push(ev);
579
+ }
580
+ // Optional event filter: only sessions containing this event qualify.
581
+ if (event) {
582
+ for (const [sid, evs2] of [...buckets]) {
583
+ if (!evs2.some(e => e.event === event)) buckets.delete(sid);
584
+ }
585
+ }
586
+ if (buckets.size) sessionsByUser.set(uid, buckets);
587
+ }
588
+ const allSessions = []; // { duration_ms, event_count }
589
+ const sessionCountsPerUser = [];
590
+ for (const [, buckets] of sessionsByUser) {
591
+ sessionCountsPerUser.push(buckets.size);
592
+ for (const [, evs] of buckets) {
593
+ const sorted = sortByTime(evs);
594
+ const start = toMs(sorted[0].time);
595
+ const end = toMs(sorted[sorted.length - 1].time);
596
+ allSessions.push({ duration_ms: end - start, event_count: sorted.length });
597
+ }
598
+ }
599
+ const out = [];
600
+ const requested = new Set(metrics);
601
+ if (requested.has('count')) {
602
+ out.push({
603
+ metric: 'count',
604
+ avg: avg(sessionCountsPerUser),
605
+ median: median(sessionCountsPerUser),
606
+ p90: percentile(sessionCountsPerUser, 0.9),
607
+ total_sessions: allSessions.length,
608
+ });
609
+ }
610
+ if (requested.has('duration')) {
611
+ const durations = allSessions.map(s => s.duration_ms);
612
+ out.push({
613
+ metric: 'duration',
614
+ avg_ms: avg(durations),
615
+ median_ms: median(durations),
616
+ p90_ms: percentile(durations, 0.9),
617
+ total_sessions: allSessions.length,
618
+ });
619
+ }
620
+ if (requested.has('eventsPerSession')) {
621
+ const eventCounts = allSessions.map(s => s.event_count);
622
+ out.push({
623
+ metric: 'eventsPerSession',
624
+ avg: avg(eventCounts),
625
+ median: median(eventCounts),
626
+ p90: percentile(eventCounts, 0.9),
627
+ total_sessions: allSessions.length,
628
+ });
629
+ }
630
+ return out;
631
+ }
632
+
241
633
  // ── shared helpers ──
242
634
 
243
- function userIdOf(ev) {
244
- return ev && (ev.user_id || ev.distinct_id || ev.device_id);
635
+ function userIdOf(ev, identityMap) {
636
+ return resolveUserId(ev, identityMap);
245
637
  }
246
638
 
247
- function groupByUser(events) {
639
+ function groupByUser(events, identityMap) {
248
640
  const userEvents = new Map();
249
641
  for (const ev of events) {
250
- const uid = userIdOf(ev);
642
+ const uid = userIdOf(ev, identityMap);
251
643
  if (!uid) continue;
252
644
  if (!userEvents.has(uid)) userEvents.set(uid, []);
253
645
  userEvents.get(uid).push(ev);
@@ -259,17 +651,13 @@ function sortByTime(evs) {
259
651
  return evs.slice().sort((a, b) => toMs(a && a.time) - toMs(b && b.time));
260
652
  }
261
653
 
262
- import { toMs } from '../hook-helpers/_internal.js';
263
-
264
- function applyAgg(vals, agg) {
265
- if (!vals || !vals.length) return 0;
654
+ function applyNullAwareAgg(values, agg) {
266
655
  switch (agg) {
267
- case 'sum': return vals.reduce((a, b) => a + b, 0);
268
- case 'count': return vals.length;
269
- case 'max': return Math.max(...vals);
270
- case 'min': return Math.min(...vals);
656
+ case 'sum': return nullAwareSum(values);
657
+ case 'max': return nullAwareExtreme(values, 'max');
658
+ case 'min': return nullAwareExtreme(values, 'min');
271
659
  case 'avg':
272
- default: return vals.reduce((a, b) => a + b, 0) / vals.length;
660
+ default: return nullAwareAvg(values);
273
661
  }
274
662
  }
275
663
 
@@ -279,3 +667,19 @@ function median(arr) {
279
667
  const mid = Math.floor(sorted.length / 2);
280
668
  return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
281
669
  }
670
+
671
+ function avg(arr) {
672
+ if (!arr.length) return 0;
673
+ return arr.reduce((a, x) => a + x, 0) / arr.length;
674
+ }
675
+
676
+ function percentile(arr, p) {
677
+ if (!arr.length) return 0;
678
+ const sorted = arr.slice().sort((a, b) => a - b);
679
+ // Linear interpolation method (consistent with d3.quantile).
680
+ const idx = (sorted.length - 1) * p;
681
+ const lo = Math.floor(idx);
682
+ const hi = Math.ceil(idx);
683
+ if (lo === hi) return sorted[lo];
684
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
685
+ }