@ak--47/dungeon-master 1.3.1 → 1.4.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 (54) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dungeons/technical/hook-helpers-verify.js +89 -0
  3. package/dungeons/technical/identity-model-verify.js +47 -0
  4. package/dungeons/technical/pattern-aggregate-by-bin.js +41 -0
  5. package/dungeons/technical/pattern-attributed-by-source.js +42 -0
  6. package/dungeons/technical/pattern-frequency-by-frequency.js +40 -0
  7. package/dungeons/technical/pattern-funnel-frequency.js +54 -0
  8. package/dungeons/technical/pattern-ttc-by-segment.js +45 -0
  9. package/dungeons/vertical/ai-platform.js +45 -52
  10. package/dungeons/vertical/community.js +11 -8
  11. package/dungeons/vertical/crypto.js +25 -24
  12. package/dungeons/vertical/dating.js +56 -48
  13. package/dungeons/vertical/devtools.js +25 -18
  14. package/dungeons/vertical/ecommerce.js +42 -38
  15. package/dungeons/vertical/education.js +24 -9
  16. package/dungeons/vertical/fintech.js +13 -8
  17. package/dungeons/vertical/fitness.js +73 -122
  18. package/dungeons/vertical/food-delivery.js +18 -19
  19. package/dungeons/vertical/gaming.js +19 -20
  20. package/dungeons/vertical/healthcare.js +11 -8
  21. package/dungeons/vertical/insurance-application.js +6 -3
  22. package/dungeons/vertical/logistics.js +15 -9
  23. package/dungeons/vertical/marketplace.js +36 -27
  24. package/dungeons/vertical/media.js +27 -25
  25. package/dungeons/vertical/real-estate.js +18 -7
  26. package/dungeons/vertical/sass.js +84 -68
  27. package/dungeons/vertical/social.js +46 -47
  28. package/dungeons/vertical/travel.js +8 -5
  29. package/index.js +17 -71
  30. package/lib/core/config-validator.js +143 -164
  31. package/lib/core/storage.js +5 -1
  32. package/lib/generators/events.js +49 -93
  33. package/lib/generators/funnels.js +202 -91
  34. package/lib/hook-helpers/_internal.js +23 -0
  35. package/lib/hook-helpers/cohort.js +124 -0
  36. package/lib/hook-helpers/identity.js +56 -0
  37. package/lib/hook-helpers/index.js +44 -0
  38. package/lib/hook-helpers/inject.js +99 -0
  39. package/lib/hook-helpers/mutate.js +151 -0
  40. package/lib/hook-helpers/timing.js +99 -0
  41. package/lib/hook-patterns/aggregate-per-user-by-bin.js +38 -0
  42. package/lib/hook-patterns/attributed-by-source.js +72 -0
  43. package/lib/hook-patterns/frequency-by-frequency.js +46 -0
  44. package/lib/hook-patterns/funnel-frequency-breakdown.js +73 -0
  45. package/lib/hook-patterns/index.js +14 -0
  46. package/lib/hook-patterns/time-to-convert-by-segment.js +41 -0
  47. package/lib/orchestrators/mixpanel-sender.js +46 -51
  48. package/lib/orchestrators/user-loop.js +119 -269
  49. package/lib/utils/utils.js +39 -16
  50. package/lib/verify/emulate-breakdown.js +281 -0
  51. package/lib/verify/index.js +12 -0
  52. package/lib/verify/verify-dungeon.js +61 -0
  53. package/package.json +6 -4
  54. package/types.d.ts +404 -212
@@ -1156,21 +1156,14 @@ CORE
1156
1156
  */
1157
1157
 
1158
1158
  //the function which generates $distinct_id + $anonymous_ids, $session_ids, and created, skewing towards the present
1159
- function generateUser(user_id, opts, amplitude = 1, frequency = 1, skew = 1) {
1159
+ function generateUser(user_id, opts) {
1160
1160
  const chance = getChance();
1161
- const { numDays, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds, datasetEndUnix } = opts;
1162
- // Uniformly distributed `u`, then skew applied
1163
- let u = Math.pow(chance.random(), skew);
1164
-
1165
- // Sine function for a smoother curve
1166
- const sineValue = (Math.sin(u * Math.PI * frequency - Math.PI / 2) * amplitude + 1) / 2;
1167
-
1168
- // Scale the sineValue to the range of days
1169
- let daysAgoBorn = Math.round(sineValue * (numDays - 1)) + 1;
1170
-
1171
- // Clamp values to ensure they are within the desired range
1161
+ const { numDays, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds, datasetEndUnix, avgDevicePerUser } = opts;
1162
+ // Birth date placement is overridden by bornRecentBias in user-loop.js;
1163
+ // use a simple uniform distribution as the seed value.
1164
+ let daysAgoBorn = Math.round(chance.random() * (numDays - 1)) + 1;
1172
1165
  daysAgoBorn = Math.min(daysAgoBorn, numDays);
1173
- const props = person(user_id, daysAgoBorn, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds, datasetEndUnix);
1166
+ const props = person(user_id, daysAgoBorn, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds, datasetEndUnix, avgDevicePerUser);
1174
1167
 
1175
1168
  const user = {
1176
1169
  distinct_id: user_id,
@@ -1275,9 +1268,13 @@ function TimeSoup(earliestTime, latestTime, peaks = 5, deviation = 2, mean = 0,
1275
1268
  * @param {boolean} hasAvatar
1276
1269
  * @param {boolean} hasAnonIds
1277
1270
  * @param {boolean} hasSessionIds
1271
+ * @param {number} [datasetEndUnix]
1272
+ * @param {number} [avgDevicePerUser] - Whole number ≥ 0. When ≥ 1 (and `hasAnonIds: true`),
1273
+ * sets the per-user device pool size. `1` = exactly one device. `>1` = drawn from
1274
+ * `chance.normal({mean: avgDevicePerUser, dev: avgDevicePerUser/2})`, clamped ≥ 1, integer.
1278
1275
  * @return {Person}
1279
1276
  */
1280
- function person(userId, bornDaysAgo = 30, isAnonymous = false, hasAvatar = false, hasAnonIds = false, hasSessionIds = false, datasetEndUnix) {
1277
+ function person(userId, bornDaysAgo = 30, isAnonymous = false, hasAvatar = false, hasAnonIds = false, hasSessionIds = false, datasetEndUnix, avgDevicePerUser) {
1281
1278
  const chance = getChance();
1282
1279
  //names and photos
1283
1280
  const l = chance.letter.bind(chance);
@@ -1320,9 +1317,25 @@ function person(userId, bornDaysAgo = 30, isAnonymous = false, hasAvatar = false
1320
1317
 
1321
1318
  if (!hasAvatar) delete user.avatar;
1322
1319
 
1323
- //anon Ids
1320
+ // Device pool ("anonymousIds" — name preserved for backwards compat).
1321
+ // Phase 2 identity model: pool size is governed by `avgDevicePerUser` when set,
1322
+ // else legacy 2–10 random pool size for any dungeon that just sets `hasAnonIds: true`
1323
+ // without `avgDevicePerUser`. (Validator coerces `hasAnonIds: true` to an effective
1324
+ // `avgDevicePerUser >= 1`, so this branch fires whenever there's a device pool to build.)
1324
1325
  if (hasAnonIds) {
1325
- const clusterSize = integer(2, 10);
1326
+ let clusterSize;
1327
+ if (typeof avgDevicePerUser === 'number' && avgDevicePerUser >= 1) {
1328
+ if (avgDevicePerUser === 1) {
1329
+ clusterSize = 1;
1330
+ } else {
1331
+ const sd = avgDevicePerUser / 2;
1332
+ const sample = chance.normal({ mean: avgDevicePerUser, dev: sd });
1333
+ clusterSize = Math.max(1, Math.round(sample));
1334
+ }
1335
+ } else {
1336
+ // Legacy fallback (pre-1.4 behavior): random pool of 2–10 devices.
1337
+ clusterSize = integer(2, 10);
1338
+ }
1326
1339
  for (let i = 0; i < clusterSize; i++) {
1327
1340
  // Use seeded chance, not ak-tools uid() (which uses Math.random).
1328
1341
  const anonId = chance.string({ length: 42, pool: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' });
@@ -1584,6 +1597,15 @@ function assignSessionIds(events, timeoutMinutes = 30) {
1584
1597
  return events;
1585
1598
  }
1586
1599
 
1600
+ async function deleteFile(filePath) {
1601
+ if (filePath.startsWith('gs://')) {
1602
+ const { bucket, file } = parseGCSUri(filePath);
1603
+ await storage.bucket(bucket).file(file).delete({ ignoreNotFound: true });
1604
+ } else {
1605
+ await fs.promises.unlink(filePath).catch(() => {});
1606
+ }
1607
+ }
1608
+
1587
1609
  export {
1588
1610
  pick,
1589
1611
  date,
@@ -1638,4 +1660,5 @@ export {
1638
1660
  assignSessionIds,
1639
1661
  bunchIntoSessions,
1640
1662
  setDatasetNow,
1663
+ deleteFile,
1641
1664
  };
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Mixpanel breakdown emulator.
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-hooks` 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.
9
+ *
10
+ * Reference: Mixpanel Insights / Funnels / Flows reports, as of 2026-05.
11
+ *
12
+ * 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.
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} EmulateOptions
23
+ * @property {'frequencyByFrequency'|'funnelFrequency'|'aggregatePerUser'|'timeToConvert'|'attributedBy'} type
24
+ *
25
+ * @property {string} [metricEvent]
26
+ * @property {string} [breakdownByFrequencyOf]
27
+ * @property {boolean} [perUser]
28
+ *
29
+ * @property {string[]} [steps]
30
+ *
31
+ * @property {string} [event]
32
+ * @property {string} [property]
33
+ * @property {'avg'|'sum'|'count'|'max'|'min'} [agg]
34
+ *
35
+ * @property {string} [fromEvent]
36
+ * @property {string} [toEvent]
37
+ * @property {string} [breakdownByUserProperty]
38
+ * @property {Array<Object>} [profiles]
39
+ *
40
+ * @property {string} [conversionEvent]
41
+ * @property {string} [attributionEvent]
42
+ * @property {string} [attributionProperty]
43
+ * @property {'firstTouch'|'lastTouch'} [model]
44
+ */
45
+
46
+ /**
47
+ * Run a Mixpanel breakdown emulation against an events array.
48
+ * Routes to the type-specific implementation based on `config.type`.
49
+ *
50
+ * @param {Array<Object>} events
51
+ * @param {EmulateOptions} config
52
+ * @returns {Array<Object>} Breakdown table rows.
53
+ */
54
+ export function emulateBreakdown(events, config) {
55
+ if (!Array.isArray(events)) throw new Error('emulateBreakdown: events must be an array');
56
+ if (!config || !config.type) throw new Error('emulateBreakdown: config.type is required');
57
+ 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));
63
+ default: throw new Error(`emulateBreakdown: unknown type "${config.type}"`);
64
+ }
65
+ }
66
+
67
+ // ── Frequency × Frequency (Insights, Frequency Distribution by per-user count of B) ──
68
+
69
+ function frequencyByFrequency(events, { metricEvent, breakdownByFrequencyOf }) {
70
+ if (!metricEvent || !breakdownByFrequencyOf) {
71
+ throw new Error('frequencyByFrequency requires metricEvent and breakdownByFrequencyOf');
72
+ }
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
+ }
83
+ 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;
87
+ const key = `${m}|${b}`;
88
+ cell.set(key, (cell.get(key) || 0) + 1);
89
+ }
90
+ return [...cell.entries()].map(([k, count]) => {
91
+ const [m, b] = k.split('|').map(Number);
92
+ return { metric_freq: m, breakdown_freq: b, user_count: count };
93
+ }).sort((x, y) => x.breakdown_freq - y.breakdown_freq || x.metric_freq - y.metric_freq);
94
+ }
95
+
96
+ // ── Funnel Frequency Breakdown (Funnel report broken down by per-user count of X) ──
97
+
98
+ function funnelFrequency(events, { steps, breakdownByFrequencyOf }) {
99
+ if (!Array.isArray(steps) || !steps.length) throw new Error('funnelFrequency requires steps[]');
100
+ 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
+ }
107
+ 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 });
125
+ }
126
+ }
127
+ // Conversion % at each step relative to its own breakdown_freq's step-0 baseline.
128
+ const baseline = new Map();
129
+ for (const r of result) {
130
+ if (r.step_index === 0) baseline.set(r.breakdown_freq, r.conversions);
131
+ }
132
+ for (const r of result) {
133
+ const denom = baseline.get(r.breakdown_freq) || 0;
134
+ r.conversion_pct = denom ? (r.conversions / denom) * 100 : 0;
135
+ }
136
+ return result.sort((a, b) => a.step_index - b.step_index || a.breakdown_freq - b.breakdown_freq);
137
+ }
138
+
139
+ // ── Aggregate per user (Insights, sum/avg of property X by per-user count of B) ──
140
+
141
+ function aggregatePerUser(events, { event, property, agg = 'avg', breakdownByFrequencyOf }) {
142
+ if (!event) throw new Error('aggregatePerUser requires event');
143
+ if (!breakdownByFrequencyOf) throw new Error('aggregatePerUser requires breakdownByFrequencyOf');
144
+ if (agg !== 'count' && !property) throw new Error('aggregatePerUser requires property unless agg is "count"');
145
+ const userVals = new Map();
146
+ 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
+ }
160
+ }
161
+ if (ev.event === breakdownByFrequencyOf) userBreakdown.set(uid, (userBreakdown.get(uid) || 0) + 1);
162
+ }
163
+ const userAgg = new Map();
164
+ for (const [uid, vals] of userVals) userAgg.set(uid, applyAgg(vals, agg));
165
+ const buckets = new Map(); // breakdown_freq → [aggregates]
166
+ for (const [uid, v] of userAgg) {
167
+ const b = userBreakdown.get(uid) || 0;
168
+ if (!buckets.has(b)) buckets.set(b, []);
169
+ buckets.get(b).push(v);
170
+ }
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);
176
+ }
177
+
178
+ // ── Time to Convert (Funnel TTC, broken down by user property) ──
179
+
180
+ function timeToConvert(events, { fromEvent, toEvent, breakdownByUserProperty, profiles = [] }) {
181
+ if (!fromEvent || !toEvent) throw new Error('timeToConvert requires fromEvent and toEvent');
182
+ const userEvents = groupByUser(events);
183
+ const profileByUid = new Map();
184
+ for (const p of profiles) {
185
+ if (!p) continue;
186
+ const uid = p.distinct_id || p.user_id;
187
+ if (uid) profileByUid.set(uid, p);
188
+ }
189
+ const buckets = new Map(); // segValue → [ttcMs]
190
+ 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;
199
+ const profile = profileByUid.get(uid);
200
+ const segValue = breakdownByUserProperty
201
+ ? (profile ? (profile[breakdownByUserProperty] ?? 'unknown') : 'unknown')
202
+ : 'all';
203
+ if (!buckets.has(segValue)) buckets.set(segValue, []);
204
+ buckets.get(segValue).push(ttcMs);
205
+ }
206
+ return [...buckets.entries()].map(([seg, ttcs]) => ({
207
+ segment_value: seg,
208
+ user_count: ttcs.length,
209
+ avg_ttc_ms: ttcs.reduce((a, x) => a + x, 0) / ttcs.length,
210
+ median_ttc_ms: median(ttcs),
211
+ })).sort((x, y) => String(x.segment_value).localeCompare(String(y.segment_value)));
212
+ }
213
+
214
+ // ── Attributed By (first-/last-touch attribution by event property value) ──
215
+
216
+ function attributedBy(events, { conversionEvent, attributionEvent, attributionProperty, model = 'firstTouch' }) {
217
+ if (!conversionEvent || !attributionEvent || !attributionProperty) {
218
+ throw new Error('attributedBy requires conversionEvent, attributionEvent, attributionProperty');
219
+ }
220
+ const userEvents = groupByUser(events);
221
+ const counts = new Map();
222
+ for (const [uid, evs] of userEvents) {
223
+ const sorted = sortByTime(evs);
224
+ const conversion = sorted.find(e => e && e.event === conversionEvent);
225
+ if (!conversion) continue;
226
+ const conversionTime = toMs(conversion.time);
227
+ const touches = sorted.filter(e =>
228
+ e && e.event === attributionEvent && toMs(e.time) <= conversionTime
229
+ );
230
+ if (!touches.length) continue;
231
+ const touch = model === 'lastTouch' ? touches[touches.length - 1] : touches[0];
232
+ const v = touch[attributionProperty] ?? 'unknown';
233
+ counts.set(v, (counts.get(v) || 0) + 1);
234
+ }
235
+ return [...counts.entries()].map(([source, count]) => ({
236
+ attribution_value: source,
237
+ conversions: count,
238
+ })).sort((a, b) => b.conversions - a.conversions);
239
+ }
240
+
241
+ // ── shared helpers ──
242
+
243
+ function userIdOf(ev) {
244
+ return ev && (ev.user_id || ev.distinct_id || ev.device_id);
245
+ }
246
+
247
+ function groupByUser(events) {
248
+ const userEvents = new Map();
249
+ for (const ev of events) {
250
+ const uid = userIdOf(ev);
251
+ if (!uid) continue;
252
+ if (!userEvents.has(uid)) userEvents.set(uid, []);
253
+ userEvents.get(uid).push(ev);
254
+ }
255
+ return userEvents;
256
+ }
257
+
258
+ function sortByTime(evs) {
259
+ return evs.slice().sort((a, b) => toMs(a && a.time) - toMs(b && b.time));
260
+ }
261
+
262
+ import { toMs } from '../hook-helpers/_internal.js';
263
+
264
+ function applyAgg(vals, agg) {
265
+ if (!vals || !vals.length) return 0;
266
+ 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);
271
+ case 'avg':
272
+ default: return vals.reduce((a, b) => a + b, 0) / vals.length;
273
+ }
274
+ }
275
+
276
+ function median(arr) {
277
+ if (!arr.length) return 0;
278
+ const sorted = arr.slice().sort((a, b) => a - b);
279
+ const mid = Math.floor(sorted.length / 2);
280
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
281
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @ak--47/dungeon-master/verify — Phase 4 verification surface.
3
+ *
4
+ * `emulateBreakdown` produces the table shapes Mixpanel shows for the five
5
+ * supported analyses (frequencyByFrequency, funnelFrequency, aggregatePerUser,
6
+ * timeToConvert, attributedBy). `verifyDungeon` is the higher-level wrapper
7
+ * that runs a dungeon and asserts emulator outputs match expected ratios — wire
8
+ * this into your CI to catch dungeon drift.
9
+ */
10
+
11
+ export { emulateBreakdown } from './emulate-breakdown.js';
12
+ export { verifyDungeon } from './verify-dungeon.js';
@@ -0,0 +1,61 @@
1
+ /**
2
+ * verifyDungeon — run a dungeon (in-memory) and run a series of emulator checks
3
+ * against its output, returning a structured report. Designed for CI use:
4
+ *
5
+ * const report = await verifyDungeon(dungeonConfig, [
6
+ * {
7
+ * name: 'engaged users do 2x purchases',
8
+ * breakdown: { type: 'frequencyByFrequency',
9
+ * metricEvent: 'Purchase', breakdownByFrequencyOf: 'Browse' },
10
+ * assert: (rows) => {
11
+ * // custom assertion against the emulator's output table
12
+ * return { pass: true, detail: 'looks good' };
13
+ * }
14
+ * }
15
+ * ]);
16
+ * if (!report.pass) process.exit(1);
17
+ *
18
+ * The dungeon is run via the same default-export entry point external consumers
19
+ * use, so this is a true end-to-end check.
20
+ */
21
+
22
+ import DUNGEON_MASTER from '../../index.js';
23
+ import { emulateBreakdown } from './emulate-breakdown.js';
24
+
25
+ /**
26
+ * @typedef {Object} VerifyCheck
27
+ * @property {string} name - Human-readable name for the check.
28
+ * @property {Object} breakdown - Argument passed to `emulateBreakdown`.
29
+ * @property {(rows: Array<Object>, ctx: { events: Array<Object>, profiles: Array<Object> }) => { pass: boolean, detail?: string }} assert
30
+ */
31
+
32
+ /**
33
+ * @param {Object} config - Dungeon config (or path; passed straight to DUNGEON_MASTER).
34
+ * @param {VerifyCheck[]} checks
35
+ * @returns {Promise<{ pass: boolean, results: Array<{ name: string, pass: boolean, detail?: string, rows?: Array<Object> }> }>}
36
+ */
37
+ export async function verifyDungeon(config, checks) {
38
+ if (!checks || !checks.length) throw new Error('verifyDungeon: at least one check required');
39
+ let result = await DUNGEON_MASTER(config);
40
+ if (Array.isArray(result)) result = result[0];
41
+ const events = Array.isArray(result.eventData) ? result.eventData : Array.from(result.eventData);
42
+ const profiles = Array.isArray(result.userProfilesData) ? result.userProfilesData : Array.from(result.userProfilesData);
43
+ const ctx = { events, profiles };
44
+ const results = [];
45
+ for (const check of checks) {
46
+ try {
47
+ const breakdownArgs = { ...check.breakdown };
48
+ // timeToConvert + attributedBy may want profiles; auto-inject if not provided.
49
+ if (breakdownArgs.type === 'timeToConvert' && !breakdownArgs.profiles) {
50
+ breakdownArgs.profiles = profiles;
51
+ }
52
+ const rows = emulateBreakdown(events, breakdownArgs);
53
+ const verdict = check.assert(rows, ctx);
54
+ results.push({ name: check.name, pass: !!verdict.pass, detail: verdict.detail, rows });
55
+ } catch (err) {
56
+ results.push({ name: check.name, pass: false, detail: `error: ${err.message}` });
57
+ }
58
+ }
59
+ const pass = results.every(r => r.pass);
60
+ return { pass, results };
61
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ak--47/dungeon-master",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "description": "generate fancy datasets",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -11,7 +11,10 @@
11
11
  "types": "./types.d.ts"
12
12
  },
13
13
  "./utils": "./lib/utils/utils.js",
14
- "./text": "./lib/generators/text.js"
14
+ "./text": "./lib/generators/text.js",
15
+ "./hook-helpers": "./lib/hook-helpers/index.js",
16
+ "./hook-patterns": "./lib/hook-patterns/index.js",
17
+ "./verify": "./lib/verify/index.js"
15
18
  },
16
19
  "files": [
17
20
  "index.js",
@@ -21,7 +24,6 @@
21
24
  "!dungeons/customers/",
22
25
  "!dungeons/user/",
23
26
  "scripts/",
24
- "!scripts/experiments/",
25
27
  "package.json",
26
28
  "README.md",
27
29
  "CHANGELOG.md"
@@ -72,7 +74,7 @@
72
74
  "dotenv": "^16.4.5",
73
75
  "hyparquet-writer": "^0.6.1",
74
76
  "mixpanel": "^0.18.0",
75
- "mixpanel-import": "^3.2.9",
77
+ "mixpanel-import": "^3.3.1",
76
78
  "p-limit": "^3.1.0",
77
79
  "pino": "^9.0.0",
78
80
  "pino-pretty": "^11.0.0",