@ak--47/dungeon-master 1.4.5 → 1.5.1

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