@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
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Counting and aggregation helpers matching Mixpanel's analytics semantics.
3
+ *
4
+ * These primitives differ from naive SQL in important ways. Each helper
5
+ * documents the Mixpanel source file that defines the rule and the
6
+ * specific divergence from `COUNT(*)` / `AVG(x)` / etc.
7
+ *
8
+ * References (from `mixpanel/analytics`):
9
+ * - `backend/arb/reader/queries/addiction_query.cpp` — distinct-period counting
10
+ * - `backend/arb/reader/queries/normal_query.cpp` — null-aware AVG/SUM/MIN/MAX
11
+ */
12
+
13
+ import { toMs } from '../hook-helpers/_internal.js';
14
+
15
+ const SECONDS_PER_UNIT = {
16
+ hour: 3600,
17
+ day: 86400,
18
+ week: 7 * 86400,
19
+ };
20
+
21
+ /**
22
+ * Count distinct time periods on which a user fired a given event.
23
+ *
24
+ * Two related rules exist in Mixpanel:
25
+ *
26
+ * 1. **Calendar bucket** (default here, `algorithm: 'calendar'`):
27
+ * `COUNT(DISTINCT date_trunc(unit, time))` in UTC. This is what the
28
+ * Mixpanel UI presents — frequency distribution charts bucket events
29
+ * into calendar hours/days/weeks.
30
+ *
31
+ * 2. **Rolling window** (`algorithm: 'rolling'`): the addiction_query.cpp
32
+ * rule, `qtz_time >= last_counted + seconds_for_unit(unit)`. This is
33
+ * Mixpanel's internal C++ implementation. It diverges from calendar
34
+ * bucketing at unit boundaries — events at 23:59 and 00:01 next day
35
+ * register as 1 rolling-window period (gap 120s < 86400s) but 2
36
+ * calendar-day periods.
37
+ *
38
+ * The default is `calendar` because:
39
+ * - It matches what users actually see in Mixpanel reports.
40
+ * - It aligns with `injectOnNewDays`, which classifies days by
41
+ * `Math.floor(t / DAY_MS)` to find empty days. Mixing the two
42
+ * algorithms causes the atom and verifier to disagree at boundaries.
43
+ *
44
+ * Use `algorithm: 'rolling'` only when you're verifying behavior that
45
+ * specifically depends on the C++ rolling-window check.
46
+ *
47
+ * Reference: `mixpanel/analytics`
48
+ * - calendar bucketing: implicit in the UI / Insights reports
49
+ * - rolling-window: `backend/arb/reader/queries/addiction_query.cpp`
50
+ *
51
+ * @param {Object[]} events - Events to scan (mixed types OK).
52
+ * @param {string} eventName - Event name to filter for.
53
+ * @param {('hour'|'day'|'week')} [unit='day']
54
+ * @param {Object} [options]
55
+ * @param {('calendar'|'rolling')} [options.algorithm='calendar']
56
+ * @returns {number} Distinct period count.
57
+ */
58
+ export function countDistinctPeriods(events, eventName, unit = 'day', options = {}) {
59
+ const seconds = SECONDS_PER_UNIT[unit];
60
+ if (!seconds) throw new Error(`countDistinctPeriods: unsupported unit "${unit}"`);
61
+ if (!Array.isArray(events) || !events.length) return 0;
62
+ const matches = events
63
+ .filter(e => e && e.event === eventName)
64
+ .map(e => toMs(e.time))
65
+ .filter(ms => Number.isFinite(ms));
66
+ if (!matches.length) return 0;
67
+ const unitMs = seconds * 1000;
68
+ const { algorithm = 'calendar' } = options;
69
+
70
+ if (algorithm === 'calendar') {
71
+ // Calendar bucket — UTC floor by unit. Matches what Mixpanel's UI
72
+ // shows and what `injectOnNewDays` uses internally.
73
+ const buckets = new Set();
74
+ for (const t of matches) buckets.add(Math.floor(t / unitMs));
75
+ return buckets.size;
76
+ }
77
+
78
+ // Rolling window — addiction_query.cpp semantics.
79
+ matches.sort((a, b) => a - b);
80
+ let count = 0;
81
+ let lastCountedMs = -Infinity;
82
+ for (const t of matches) {
83
+ if (t >= lastCountedMs + unitMs) {
84
+ count++;
85
+ lastCountedMs = t;
86
+ }
87
+ }
88
+ return count;
89
+ }
90
+
91
+ /**
92
+ * Null-aware average matching Mixpanel's aggregation semantics.
93
+ *
94
+ * Reference: `backend/arb/reader/queries/normal_query.cpp` ACTION_TYPE_AVERAGE:
95
+ *
96
+ * if (action_value.type == VALUE_TYPE_NUMBER && !std::isnan(value)) {
97
+ * v->average.sum += number;
98
+ * v->average.count += upsampling_factor;
99
+ * }
100
+ *
101
+ * Skips null, undefined, NaN, and non-numeric values from BOTH numerator
102
+ * and denominator. Returns null when no numeric values exist.
103
+ *
104
+ * Differs from naive `SUM(x) / COUNT(*)` which inflates the denominator
105
+ * by counting rows where x is missing — diluting the average toward 0.
106
+ *
107
+ * @param {*[]} values
108
+ * @returns {number|null}
109
+ */
110
+ export function nullAwareAvg(values) {
111
+ if (!Array.isArray(values) || !values.length) return null;
112
+ let sum = 0;
113
+ let count = 0;
114
+ for (const v of values) {
115
+ if (typeof v === 'number' && Number.isFinite(v)) {
116
+ sum += v;
117
+ count++;
118
+ }
119
+ }
120
+ return count ? sum / count : null;
121
+ }
122
+
123
+ /**
124
+ * Null-aware sum. Skips null/undefined/NaN/non-numeric silently.
125
+ *
126
+ * Reference: `normal_query.cpp` ACTION_TYPE_SUM — same numeric guard as AVG.
127
+ * Differs from naive SQL SUM only when missing values are coalesced to 0
128
+ * upstream; in JS arrays missing values are typically `undefined` which
129
+ * produces NaN under `+`.
130
+ *
131
+ * @param {*[]} values
132
+ * @returns {number}
133
+ */
134
+ export function nullAwareSum(values) {
135
+ if (!Array.isArray(values) || !values.length) return 0;
136
+ let sum = 0;
137
+ for (const v of values) {
138
+ if (typeof v === 'number' && Number.isFinite(v)) sum += v;
139
+ }
140
+ return sum;
141
+ }
142
+
143
+ /**
144
+ * Null-aware min/max. Returns null when no numeric values exist.
145
+ *
146
+ * Reference: `normal_query.cpp` ACTION_TYPE_EXTREMES — only records numeric
147
+ * values. Mixpanel starts max at -Infinity and min at +Infinity; we return
148
+ * null instead of those sentinels when no values were recorded.
149
+ *
150
+ * @param {*[]} values
151
+ * @param {('min'|'max')} mode
152
+ * @returns {number|null}
153
+ */
154
+ export function nullAwareExtreme(values, mode) {
155
+ if (!Array.isArray(values) || !values.length) return null;
156
+ let extreme = mode === 'min' ? Infinity : -Infinity;
157
+ let any = false;
158
+ for (const v of values) {
159
+ if (typeof v === 'number' && Number.isFinite(v)) {
160
+ any = true;
161
+ if (mode === 'min') {
162
+ if (v < extreme) extreme = v;
163
+ } else {
164
+ if (v > extreme) extreme = v;
165
+ }
166
+ }
167
+ }
168
+ return any ? extreme : null;
169
+ }
170
+
171
+ /**
172
+ * v1.5.1: count distinct values of a flat property across events.
173
+ *
174
+ * Mixpanel parity: `COUNT_DISTINCT(prop)` aggregator (Insights). Skips
175
+ * null/undefined/empty-string values. Returns the distinct-value count plus
176
+ * the top-N most-frequent values (default 25, matching Mixpanel UI default).
177
+ *
178
+ * Property keys are FLAT on event records per the dungeon-master schema
179
+ * contract (see HOOKS.md §1) — no dot-path support.
180
+ *
181
+ * @param {Object[]} events
182
+ * @param {string} property - Flat property name.
183
+ * @param {Object} [options]
184
+ * @param {string} [options.event] - Optional event-name filter.
185
+ * @param {number} [options.topN=25] - Number of top values to include in the result.
186
+ * @returns {{ distinct_count: number, top_values: Array<{ value: any, count: number }> }}
187
+ */
188
+ export function countDistinctValues(events, property, options = {}) {
189
+ if (!Array.isArray(events)) throw new Error('countDistinctValues: events must be an array');
190
+ if (typeof property !== 'string' || !property) throw new Error('countDistinctValues: property is required');
191
+ const topN = Number.isFinite(options.topN) && options.topN > 0 ? Math.floor(options.topN) : 25;
192
+ const filterEvent = typeof options.event === 'string' && options.event ? options.event : null;
193
+ const valueCounts = new Map();
194
+ for (const e of events) {
195
+ if (!e || typeof e !== 'object') continue;
196
+ if (filterEvent && e.event !== filterEvent) continue;
197
+ const v = e[property];
198
+ if (v === null || v === undefined || v === '') continue;
199
+ // Hashable normalization — Map keys distinguish primitives but objects
200
+ // use reference identity. For Mixpanel parity, stringify non-primitives.
201
+ const key = (typeof v === 'object') ? JSON.stringify(v) : v;
202
+ valueCounts.set(key, (valueCounts.get(key) || 0) + 1);
203
+ }
204
+ const sorted = [...valueCounts.entries()]
205
+ .sort((a, b) => b[1] - a[1])
206
+ .slice(0, topN)
207
+ .map(([value, count]) => ({ value, count }));
208
+ return { distinct_count: valueCounts.size, top_values: sorted };
209
+ }
210
+
211
+ /**
212
+ * Partition events into time buckets by UTC calendar (`day`, `week`, or
213
+ * `month`). Used by `emulateBreakdown` when `timeBucket` is set to slice any
214
+ * breakdown into a trend over time.
215
+ *
216
+ * Period labels:
217
+ * - `'day'` → `YYYY-MM-DD`
218
+ * - `'week'` → ISO week `YYYY-Www` (Monday-anchored)
219
+ * - `'month'` → `YYYY-MM`
220
+ *
221
+ * Mixpanel parity: bucket boundaries are computed in UTC. Production Mixpanel
222
+ * uses query timezone (qtz); pass timestamps already shifted to qtz if you
223
+ * need that behavior. ISO week is Monday-anchored
224
+ * (matches `eval_node.c:3641-3643`).
225
+ *
226
+ * Empty-bucket backfill (Mixpanel `normal_query.cpp:352-356, 310-313` emits
227
+ * zero rows for empty intervals): when `options.from` AND `options.to` are
228
+ * provided, the result enumerates every bucket in `[from, to]` and emits
229
+ * `{ period, events: [] }` for buckets with no events. Without `from`/`to`,
230
+ * only buckets that contain at least one event are returned.
231
+ *
232
+ * @param {Object[]} events
233
+ * @param {('day'|'week'|'month')} bucket
234
+ * @param {Object} [options]
235
+ * @param {number|string} [options.from] - Inclusive range start (ms or ISO).
236
+ * @param {number|string} [options.to] - Inclusive range end (ms or ISO).
237
+ * @returns {Array<{ period: string, events: Object[] }>}
238
+ */
239
+ export function partitionByTimeBucket(events, bucket, options = {}) {
240
+ const groups = new Map();
241
+ if (Array.isArray(events)) {
242
+ for (const ev of events) {
243
+ const ms = toMs(ev && ev.time);
244
+ if (!Number.isFinite(ms)) continue;
245
+ const period = formatBucket(ms, bucket);
246
+ if (!groups.has(period)) groups.set(period, []);
247
+ groups.get(period).push(ev);
248
+ }
249
+ }
250
+ const fromMs = options.from != null ? toMs(options.from) : null;
251
+ const toMsBound = options.to != null ? toMs(options.to) : null;
252
+ if (Number.isFinite(fromMs) && Number.isFinite(toMsBound)) {
253
+ // Enumerate every bucket period in [from, to] and seed empties.
254
+ for (const period of enumerateBucketPeriods(fromMs, toMsBound, bucket)) {
255
+ if (!groups.has(period)) groups.set(period, []);
256
+ }
257
+ }
258
+ return [...groups.entries()]
259
+ .map(([period, evs]) => ({ period, events: evs }))
260
+ .sort((a, b) => a.period.localeCompare(b.period));
261
+ }
262
+
263
+ /**
264
+ * Enumerate canonical bucket period labels covering `[fromMs, toMs]` UTC.
265
+ * Used to backfill empty rows when the caller supplies a trend axis.
266
+ *
267
+ * @param {number} fromMs
268
+ * @param {number} toMs
269
+ * @param {('day'|'week'|'month')} bucket
270
+ * @returns {string[]}
271
+ */
272
+ function enumerateBucketPeriods(fromMs, toMs, bucket) {
273
+ const out = [];
274
+ if (!(toMs >= fromMs)) return out;
275
+ if (bucket === 'day') {
276
+ const start = Math.floor(fromMs / 86400_000);
277
+ const end = Math.floor(toMs / 86400_000);
278
+ for (let d = start; d <= end; d++) out.push(formatBucket(d * 86400_000, 'day'));
279
+ } else if (bucket === 'week') {
280
+ // Walk by 7 days starting from fromMs; rely on label dedup via Set.
281
+ const seen = new Set();
282
+ for (let t = fromMs; t <= toMs; t += 7 * 86400_000) {
283
+ const p = formatBucket(t, 'week'); if (!seen.has(p)) { seen.add(p); out.push(p); }
284
+ }
285
+ const last = formatBucket(toMs, 'week');
286
+ if (!seen.has(last)) out.push(last);
287
+ } else if (bucket === 'month') {
288
+ const seen = new Set();
289
+ const start = new Date(fromMs);
290
+ const end = new Date(toMs);
291
+ let y = start.getUTCFullYear(), m = start.getUTCMonth();
292
+ const yEnd = end.getUTCFullYear(), mEnd = end.getUTCMonth();
293
+ while (y < yEnd || (y === yEnd && m <= mEnd)) {
294
+ const p = formatBucket(Date.UTC(y, m, 1), 'month');
295
+ if (!seen.has(p)) { seen.add(p); out.push(p); }
296
+ m++; if (m > 11) { m = 0; y++; }
297
+ }
298
+ }
299
+ return out;
300
+ }
301
+
302
+ function pad2(n) { return n < 10 ? `0${n}` : `${n}`; }
303
+
304
+ function formatBucket(ms, bucket) {
305
+ const d = new Date(ms);
306
+ if (bucket === 'day') {
307
+ return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
308
+ }
309
+ if (bucket === 'month') {
310
+ return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}`;
311
+ }
312
+ if (bucket === 'week') {
313
+ // ISO week: Mon-anchored. Algorithm: shift to Thursday of the week,
314
+ // take year of that Thursday + week number relative to Jan-1 of that
315
+ // year's Monday-of-Thursday.
316
+ const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
317
+ const dayNum = (date.getUTCDay() + 6) % 7; // Mon=0..Sun=6
318
+ date.setUTCDate(date.getUTCDate() - dayNum + 3); // Thursday of this week
319
+ const year = date.getUTCFullYear();
320
+ const jan4 = new Date(Date.UTC(year, 0, 4));
321
+ const jan4DayNum = (jan4.getUTCDay() + 6) % 7;
322
+ const week1Mon = new Date(Date.UTC(year, 0, 4 - jan4DayNum));
323
+ const weekNum = Math.floor((date.getTime() - week1Mon.getTime()) / (7 * 86400_000)) + 1;
324
+ return `${year}-W${pad2(weekNum)}`;
325
+ }
326
+ throw new Error(`partitionByTimeBucket: unsupported bucket "${bucket}"`);
327
+ }
328
+
329
+ /**
330
+ * Bin a user by their distinct-period count of an event. Combines
331
+ * `countDistinctPeriods` with bin classification for cohort assignment.
332
+ *
333
+ * Each bin entry is `[min, max]` with `min` inclusive, `max` exclusive.
334
+ * Returns the first matching bin name, or `null` if no bin matches.
335
+ *
336
+ * Uses calendar-bucket counting by default (matches Mixpanel UI). Pass
337
+ * `options.algorithm: 'rolling'` to use the addiction_query.cpp rule
338
+ * instead — see `countDistinctPeriods` for the difference.
339
+ *
340
+ * Replaces total-event counting for any analysis that targets Mixpanel's
341
+ * frequency distribution (which counts distinct periods, not total events).
342
+ *
343
+ * @param {Object[]} events
344
+ * @param {string} eventName
345
+ * @param {Object<string, [number, number]>} bins
346
+ * @param {('hour'|'day'|'week')} [unit='day']
347
+ * @param {Object} [options]
348
+ * @param {('calendar'|'rolling')} [options.algorithm='calendar']
349
+ * @returns {string|null}
350
+ */
351
+ export function binByDistinctPeriods(events, eventName, bins, unit = 'day', options = {}) {
352
+ const periods = countDistinctPeriods(events, eventName, unit, options);
353
+ if (!bins || typeof bins !== 'object') return null;
354
+ for (const [name, range] of Object.entries(bins)) {
355
+ if (!Array.isArray(range) || range.length !== 2) continue;
356
+ const [min, max] = range;
357
+ if (periods >= min && periods < max) return name;
358
+ }
359
+ return null;
360
+ }