@ak--47/dungeon-master 1.3.0 → 1.4.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 (98) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dungeons/technical/array-of-object-lookup.js +0 -2
  3. package/dungeons/technical/hook-helpers-verify.js +89 -0
  4. package/dungeons/technical/identity-model-verify.js +47 -0
  5. package/dungeons/technical/pattern-aggregate-by-bin.js +41 -0
  6. package/dungeons/technical/pattern-attributed-by-source.js +42 -0
  7. package/dungeons/technical/pattern-frequency-by-frequency.js +40 -0
  8. package/dungeons/technical/pattern-funnel-frequency.js +54 -0
  9. package/dungeons/technical/pattern-ttc-by-segment.js +45 -0
  10. package/dungeons/technical/simple.js +3 -4
  11. package/dungeons/technical/simplest-schema.json +5 -0
  12. package/dungeons/technical/text-generation.js +1 -1
  13. package/dungeons/vertical/ai-platform.js +216 -164
  14. package/dungeons/vertical/community.js +74 -41
  15. package/dungeons/vertical/crypto.js +343 -233
  16. package/dungeons/vertical/dating.js +246 -292
  17. package/dungeons/vertical/devtools.js +179 -70
  18. package/dungeons/vertical/ecommerce.js +275 -122
  19. package/dungeons/vertical/education.js +345 -312
  20. package/dungeons/vertical/fintech.js +445 -314
  21. package/dungeons/vertical/fitness.js +199 -165
  22. package/dungeons/vertical/food-delivery.js +309 -330
  23. package/dungeons/vertical/gaming.js +387 -241
  24. package/dungeons/vertical/healthcare.js +149 -66
  25. package/dungeons/vertical/insurance-application.js +173 -75
  26. package/dungeons/vertical/logistics.js +107 -12
  27. package/dungeons/vertical/marketplace.js +171 -76
  28. package/dungeons/vertical/media.js +259 -402
  29. package/dungeons/vertical/real-estate.js +336 -318
  30. package/dungeons/vertical/sass.js +294 -288
  31. package/dungeons/vertical/social.js +289 -234
  32. package/dungeons/vertical/travel.js +66 -20
  33. package/index.js +17 -17
  34. package/lib/core/config-validator.js +226 -175
  35. package/lib/core/context.js +10 -24
  36. package/lib/core/storage.js +6 -1
  37. package/lib/generators/events.js +57 -104
  38. package/lib/generators/funnels.js +212 -93
  39. package/lib/generators/mirror.js +3 -2
  40. package/lib/generators/product-names.js +1 -1
  41. package/lib/generators/scd.js +2 -1
  42. package/lib/generators/text.js +1 -1
  43. package/lib/hook-helpers/_internal.js +23 -0
  44. package/lib/hook-helpers/cohort.js +124 -0
  45. package/lib/hook-helpers/identity.js +56 -0
  46. package/lib/hook-helpers/index.js +44 -0
  47. package/lib/hook-helpers/inject.js +99 -0
  48. package/lib/hook-helpers/mutate.js +151 -0
  49. package/lib/hook-helpers/timing.js +99 -0
  50. package/lib/hook-patterns/aggregate-per-user-by-bin.js +38 -0
  51. package/lib/hook-patterns/attributed-by-source.js +72 -0
  52. package/lib/hook-patterns/frequency-by-frequency.js +46 -0
  53. package/lib/hook-patterns/funnel-frequency-breakdown.js +73 -0
  54. package/lib/hook-patterns/index.js +14 -0
  55. package/lib/hook-patterns/time-to-convert-by-segment.js +41 -0
  56. package/lib/orchestrators/user-loop.js +143 -293
  57. package/lib/utils/utils.js +97 -52
  58. package/lib/verify/emulate-breakdown.js +281 -0
  59. package/lib/verify/index.js +12 -0
  60. package/lib/verify/verify-dungeon.js +61 -0
  61. package/package.json +6 -4
  62. package/scripts/smoke-test-all.mjs +162 -0
  63. package/scripts/verify-runner.mjs +72 -24
  64. package/types.d.ts +434 -225
  65. package/dungeons/technical/ad-spend-schema.json +0 -128
  66. package/dungeons/technical/anonymous-users-schema.json +0 -92
  67. package/dungeons/technical/array-of-object-lookup-schema.json +0 -191
  68. package/dungeons/technical/experiments-schema.json +0 -203
  69. package/dungeons/technical/foobar-schema.json +0 -362
  70. package/dungeons/technical/group-analytics-schema.json +0 -241
  71. package/dungeons/technical/mirror-strategies-schema.json +0 -84
  72. package/dungeons/technical/nested-objects-schema.json +0 -145
  73. package/dungeons/technical/retention-cadence-schema.json +0 -37
  74. package/dungeons/technical/sanity-schema.json +0 -185
  75. package/dungeons/technical/scale-test-schema.json +0 -70
  76. package/dungeons/technical/scd-schema.json +0 -467
  77. package/dungeons/technical/simple-schema.json +0 -362
  78. package/dungeons/technical/text-generation-schema.json +0 -1062
  79. package/dungeons/vertical/ai-platform-schema.json +0 -617
  80. package/dungeons/vertical/community-schema.json +0 -579
  81. package/dungeons/vertical/crypto-schema.json +0 -546
  82. package/dungeons/vertical/dating-schema.json +0 -401
  83. package/dungeons/vertical/devtools-schema.json +0 -601
  84. package/dungeons/vertical/ecommerce-schema.json +0 -604
  85. package/dungeons/vertical/education-schema.json +0 -5686
  86. package/dungeons/vertical/fintech-schema.json +0 -630
  87. package/dungeons/vertical/fitness-schema.json +0 -530
  88. package/dungeons/vertical/food-delivery-schema.json +0 -36728
  89. package/dungeons/vertical/gaming-schema.json +0 -2703
  90. package/dungeons/vertical/healthcare-schema.json +0 -549
  91. package/dungeons/vertical/insurance-application-schema.json +0 -485
  92. package/dungeons/vertical/logistics-schema.json +0 -574
  93. package/dungeons/vertical/marketplace-schema.json +0 -533
  94. package/dungeons/vertical/media-schema.json +0 -4749
  95. package/dungeons/vertical/real-estate-schema.json +0 -527
  96. package/dungeons/vertical/sass-schema.json +0 -3128
  97. package/dungeons/vertical/social-schema.json +0 -620
  98. package/dungeons/vertical/travel-schema.json +0 -580
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import Chance from 'chance';
3
3
  import readline from 'readline';
4
- import { comma, uid } from 'ak-tools';
4
+ import { comma } from 'ak-tools';
5
5
  import dayjs from 'dayjs';
6
6
  import utc from 'dayjs/plugin/utc.js';
7
7
  import path from 'path';
@@ -24,7 +24,27 @@ const { NODE_ENV = "unknown" } = process.env;
24
24
  let globalChance;
25
25
  let chanceInitialized = false;
26
26
 
27
- const ACTUAL_NOW = dayjs.utc();
27
+ // Module-scoped memoization cache for weighted-array resolvers in `choose()`.
28
+ // Lives for the lifetime of the node process; key is the function source string.
29
+ const weightedArrayCache = new Map();
30
+
31
+ // Reference "now" used by date() and day() factories. Defaults to wall-clock at
32
+ // import time; the orchestrator overrides this via setDatasetNow() once the dataset
33
+ // window is resolved, so date helpers in dungeon configs produce deterministic
34
+ // values relative to the dataset end (not the process start).
35
+ let DATASET_NOW = dayjs.utc();
36
+
37
+ /**
38
+ * Override the reference "now" used by date()/day() factories. Called by the
39
+ * orchestrator with the resolved dataset end so every date helper produces
40
+ * deterministic values relative to the dataset window.
41
+ * @param {number} unixSeconds
42
+ */
43
+ function setDatasetNow(unixSeconds) {
44
+ if (typeof unixSeconds === 'number' && Number.isFinite(unixSeconds)) {
45
+ DATASET_NOW = dayjs.unix(unixSeconds).utc();
46
+ }
47
+ }
28
48
 
29
49
 
30
50
  import { Storage as cloudStorage } from '@google-cloud/storage';
@@ -102,7 +122,7 @@ function pick(items) {
102
122
  */
103
123
  function date(inTheLast = 30, isPast = true, format = 'YYYY-MM-DD') {
104
124
  const chance = getChance();
105
- const now = ACTUAL_NOW;
125
+ const now = DATASET_NOW;
106
126
  if (Math.abs(inTheLast) > 365 * 10) inTheLast = chance.integer({ min: 1, max: 180 });
107
127
  return function () {
108
128
  const when = chance.integer({ min: 0, max: Math.abs(inTheLast) });
@@ -159,8 +179,8 @@ function datesBetween(start, end) {
159
179
  */
160
180
  function day(start, end) {
161
181
  // if (!end) end = global.FIXED_NOW ? global.FIXED_NOW : dayjs().unix();
162
- if (!start) start = ACTUAL_NOW.subtract(30, 'd').toISOString();
163
- if (!end) end = ACTUAL_NOW.toISOString();
182
+ if (!start) start = DATASET_NOW.subtract(30, 'd').toISOString();
183
+ if (!end) end = DATASET_NOW.toISOString();
164
184
  const chance = getChance();
165
185
  const format = 'YYYY-MM-DD';
166
186
  return function (min, max) {
@@ -219,20 +239,15 @@ function choose(value) {
219
239
  while (typeof value === 'function') {
220
240
  const funcString = value.toString();
221
241
 
222
- // Check cache for weighted array functions
223
- if (typeof global.weightedArrayCache === 'undefined') {
224
- global.weightedArrayCache = new Map();
225
- }
226
-
227
- if (global.weightedArrayCache.has(funcString)) {
228
- value = global.weightedArrayCache.get(funcString);
242
+ if (weightedArrayCache.has(funcString)) {
243
+ value = weightedArrayCache.get(funcString);
229
244
  break;
230
245
  }
231
246
 
232
247
  const result = value();
233
248
  if (Array.isArray(result) && result.length > 10) {
234
249
  // Cache large arrays (likely weighted arrays)
235
- global.weightedArrayCache.set(funcString, result);
250
+ weightedArrayCache.set(funcString, result);
236
251
  }
237
252
  value = result;
238
253
  }
@@ -1141,21 +1156,14 @@ CORE
1141
1156
  */
1142
1157
 
1143
1158
  //the function which generates $distinct_id + $anonymous_ids, $session_ids, and created, skewing towards the present
1144
- function generateUser(user_id, opts, amplitude = 1, frequency = 1, skew = 1) {
1159
+ function generateUser(user_id, opts) {
1145
1160
  const chance = getChance();
1146
- const { numDays, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds } = opts;
1147
- // Uniformly distributed `u`, then skew applied
1148
- let u = Math.pow(chance.random(), skew);
1149
-
1150
- // Sine function for a smoother curve
1151
- const sineValue = (Math.sin(u * Math.PI * frequency - Math.PI / 2) * amplitude + 1) / 2;
1152
-
1153
- // Scale the sineValue to the range of days
1154
- let daysAgoBorn = Math.round(sineValue * (numDays - 1)) + 1;
1155
-
1156
- // 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;
1157
1165
  daysAgoBorn = Math.min(daysAgoBorn, numDays);
1158
- const props = person(user_id, daysAgoBorn, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds);
1166
+ const props = person(user_id, daysAgoBorn, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds, datasetEndUnix, avgDevicePerUser);
1159
1167
 
1160
1168
  const user = {
1161
1169
  distinct_id: user_id,
@@ -1188,7 +1196,7 @@ const DEFAULT_HOD_WEIGHTS = [
1188
1196
  0.584, 0.574, 0.554, 0.576, 0.604, 0.655, 0.722, 0.816
1189
1197
  ];
1190
1198
 
1191
- function TimeSoup(earliestTime, latestTime, peaks = 5, deviation = 2, mean = 0, dayOfWeekWeights = DEFAULT_DOW_WEIGHTS, hourOfDayWeights = DEFAULT_HOD_WEIGHTS, timeShiftSeconds = 0) {
1199
+ function TimeSoup(earliestTime, latestTime, peaks = 5, deviation = 2, mean = 0, dayOfWeekWeights = DEFAULT_DOW_WEIGHTS, hourOfDayWeights = DEFAULT_HOD_WEIGHTS) {
1192
1200
  if (!earliestTime) earliestTime = global.FIXED_BEGIN ? global.FIXED_BEGIN : dayjs().subtract(30, 'd').unix();
1193
1201
  if (!latestTime) latestTime = global.FIXED_NOW ? global.FIXED_NOW : dayjs().unix();
1194
1202
  const chance = getChance();
@@ -1215,7 +1223,7 @@ function TimeSoup(earliestTime, latestTime, peaks = 5, deviation = 2, mean = 0,
1215
1223
  // Phase 2: DOW accept/reject — retry if day-of-week doesn't pass weight check
1216
1224
  if (dayOfWeekWeights) {
1217
1225
  for (let attempt = 0; attempt < 50; attempt++) {
1218
- const dow = new Date((candidate + timeShiftSeconds) * 1000).getUTCDay();
1226
+ const dow = new Date(candidate * 1000).getUTCDay();
1219
1227
  if (chance.random() < dayOfWeekWeights[dow]) break;
1220
1228
  // Rejected — resample from Gaussian chunks
1221
1229
  const pi = integer(0, peaks - 1);
@@ -1231,8 +1239,7 @@ function TimeSoup(earliestTime, latestTime, peaks = 5, deviation = 2, mean = 0,
1231
1239
 
1232
1240
  // Phase 3: Redistribute hour-of-day (changes only hour within same day)
1233
1241
  if (hourOfDayWeights) {
1234
- const shifted = candidate + timeShiftSeconds;
1235
- const d = new Date(shifted * 1000);
1242
+ const d = new Date(candidate * 1000);
1236
1243
  const currentMinute = d.getUTCMinutes();
1237
1244
  const currentSecond = d.getUTCSeconds();
1238
1245
 
@@ -1244,9 +1251,8 @@ function TimeSoup(earliestTime, latestTime, peaks = 5, deviation = 2, mean = 0,
1244
1251
  if (roll <= 0) { newHour = h; break; }
1245
1252
  }
1246
1253
 
1247
- const dayStartShifted = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) / 1000;
1248
- const newShifted = dayStartShifted + newHour * 3600 + currentMinute * 60 + currentSecond;
1249
- candidate = newShifted - timeShiftSeconds;
1254
+ const dayStart = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) / 1000;
1255
+ candidate = dayStart + newHour * 3600 + currentMinute * 60 + currentSecond;
1250
1256
  candidate = Math.max(earliestTime, Math.min(latestTime, candidate));
1251
1257
  }
1252
1258
 
@@ -1262,9 +1268,13 @@ function TimeSoup(earliestTime, latestTime, peaks = 5, deviation = 2, mean = 0,
1262
1268
  * @param {boolean} hasAvatar
1263
1269
  * @param {boolean} hasAnonIds
1264
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.
1265
1275
  * @return {Person}
1266
1276
  */
1267
- function person(userId, bornDaysAgo = 30, isAnonymous = false, hasAvatar = false, hasAnonIds = false, hasSessionIds = false) {
1277
+ function person(userId, bornDaysAgo = 30, isAnonymous = false, hasAvatar = false, hasAnonIds = false, hasSessionIds = false, datasetEndUnix, avgDevicePerUser) {
1268
1278
  const chance = getChance();
1269
1279
  //names and photos
1270
1280
  const l = chance.letter.bind(chance);
@@ -1278,7 +1288,11 @@ function person(userId, bornDaysAgo = 30, isAnonymous = false, hasAvatar = false
1278
1288
  let randomAvatarNumber = integer(1, 99);
1279
1289
  let avPath = gender === 'male' ? `/men/${randomAvatarNumber}.jpg` : `/women/${randomAvatarNumber}.jpg`;
1280
1290
  let avatar = avatarPrefix + avPath;
1281
- let created = dayjs().subtract(bornDaysAgo, 'day').format('YYYY-MM-DD');
1291
+ // Birth date: anchor to dataset end (post-resolution) so user.created lives inside
1292
+ // the configured window, not wall-clock time. Falls back to dayjs() only when
1293
+ // person() is called outside the normal generation pipeline (e.g. direct unit tests).
1294
+ const anchor = datasetEndUnix ? dayjs.unix(datasetEndUnix) : dayjs();
1295
+ let created = anchor.subtract(bornDaysAgo, 'day').format('YYYY-MM-DD');
1282
1296
 
1283
1297
 
1284
1298
  // const created = date(bornDaysAgo, true)();
@@ -1303,11 +1317,28 @@ function person(userId, bornDaysAgo = 30, isAnonymous = false, hasAvatar = false
1303
1317
 
1304
1318
  if (!hasAvatar) delete user.avatar;
1305
1319
 
1306
- //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.)
1307
1325
  if (hasAnonIds) {
1308
- 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
+ }
1309
1339
  for (let i = 0; i < clusterSize; i++) {
1310
- const anonId = uid(42);
1340
+ // Use seeded chance, not ak-tools uid() (which uses Math.random).
1341
+ const anonId = chance.string({ length: 42, pool: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' });
1311
1342
  user.anonymousIds.push(anonId);
1312
1343
  }
1313
1344
  }
@@ -1429,11 +1460,26 @@ function deepClone(thing, opts) {
1429
1460
 
1430
1461
 
1431
1462
  /**
1432
- * Generates a session ID in the standard format
1463
+ * Generates a session ID in the standard format. Derives it deterministically
1464
+ * from a seed string (first event time + user_id, typically) via quickHash so
1465
+ * we don't consume from the main seeded RNG stream — that consumption would
1466
+ * cascade into all downstream events whenever the number of sessions changed
1467
+ * (e.g. when sessionTimeout differs).
1468
+ * @param {string} [seedStr] - Stable input string. If absent, falls back to a
1469
+ * chance.string() pull (only used by tests / direct callers without a seed).
1433
1470
  * @returns {string} Session ID like "xxxxx-xxxxx-xxxxx-xxxxx"
1434
1471
  */
1435
- function generateSessionId() {
1436
- return [uid(5), uid(5), uid(5), uid(5)].join("-");
1472
+ function generateSessionId(seedStr) {
1473
+ if (seedStr) {
1474
+ // quickHash returns ~16 hex chars; expand to 4×5 segments via re-hashing.
1475
+ const h1 = quickHash(seedStr);
1476
+ const h2 = quickHash(h1);
1477
+ const all = (h1 + h2).replace(/[^a-zA-Z0-9]/g, '').padEnd(20, '0');
1478
+ return [all.slice(0, 5), all.slice(5, 10), all.slice(10, 15), all.slice(15, 20)].join('-');
1479
+ }
1480
+ const c = getChance();
1481
+ const seg = () => c.string({ length: 5, pool: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' });
1482
+ return [seg(), seg(), seg(), seg()].join("-");
1437
1483
  }
1438
1484
 
1439
1485
  /**
@@ -1459,7 +1505,7 @@ function bunchIntoSessions(events, timeoutMinutes, soupParams) {
1459
1505
 
1460
1506
  const chance = getChance();
1461
1507
  const { earliestTime, latestTime, peaks, deviation, mean,
1462
- dayOfWeekWeights, hourOfDayWeights, timeShiftSeconds, maxTime } = soupParams;
1508
+ dayOfWeekWeights, hourOfDayWeights } = soupParams;
1463
1509
 
1464
1510
  // Sort by time first
1465
1511
  events.sort((a, b) => a.time < b.time ? -1 : a.time > b.time ? 1 : 0);
@@ -1472,8 +1518,8 @@ function bunchIntoSessions(events, timeoutMinutes, soupParams) {
1472
1518
  const anchors = [];
1473
1519
  for (let i = 0; i < numSessions; i++) {
1474
1520
  const soupTime = TimeSoup(earliestTime, latestTime, peaks, deviation, mean,
1475
- dayOfWeekWeights, hourOfDayWeights, timeShiftSeconds);
1476
- anchors.push(soupTime + timeShiftSeconds); // shifted to present time
1521
+ dayOfWeekWeights, hourOfDayWeights);
1522
+ anchors.push(soupTime);
1477
1523
  }
1478
1524
  anchors.sort((a, b) => a - b);
1479
1525
 
@@ -1485,7 +1531,6 @@ function bunchIntoSessions(events, timeoutMinutes, soupParams) {
1485
1531
  }
1486
1532
 
1487
1533
  // Retime events within each session
1488
- let writeIndex = 0;
1489
1534
  for (let s = 0; s < numSessions; s++) {
1490
1535
  const bucket = sessionBuckets[s];
1491
1536
  if (bucket.length === 0) continue;
@@ -1493,17 +1538,13 @@ function bunchIntoSessions(events, timeoutMinutes, soupParams) {
1493
1538
  let currentTime = anchors[s];
1494
1539
  for (let e = 0; e < bucket.length; e++) {
1495
1540
  const ev = bucket[e];
1496
- const clampedTime = Math.min(currentTime, maxTime);
1541
+ const clampedTime = Math.min(currentTime, latestTime);
1497
1542
 
1498
1543
  ev.time = dayjs.unix(clampedTime).toISOString();
1499
1544
  // Regenerate insert_id to match new time
1500
1545
  const distinctId = ev.user_id || ev.device_id || ev.distinct_id || '';
1501
1546
  ev.insert_id = quickHash(`${ev.event}-${ev.time}-${distinctId}`);
1502
1547
 
1503
- if (currentTime > maxTime) {
1504
- ev._drop = true;
1505
- }
1506
-
1507
1548
  // Advance time within session: 5-300 seconds (5s to 5min)
1508
1549
  currentTime += chance.integer({ min: 5, max: 300 });
1509
1550
  }
@@ -1532,7 +1573,10 @@ function assignSessionIds(events, timeoutMinutes = 30) {
1532
1573
  const timeoutMs = timeoutMinutes * 60 * 1000;
1533
1574
  const maxSessionMs = 24 * 60 * 60 * 1000;
1534
1575
 
1535
- let currentSessionId = generateSessionId();
1576
+ // Derive a stable seed from the first event so session IDs are deterministic
1577
+ // across runs without consuming the main seeded RNG stream.
1578
+ const userKey = events[0].user_id || events[0].device_id || events[0].distinct_id || '';
1579
+ let currentSessionId = generateSessionId(`${userKey}-${events[0].time}`);
1536
1580
  let sessionStartMs = new Date(events[0].time).getTime();
1537
1581
  let lastEventMs = sessionStartMs;
1538
1582
 
@@ -1542,7 +1586,7 @@ function assignSessionIds(events, timeoutMinutes = 30) {
1542
1586
  const sessionDuration = eventMs - sessionStartMs;
1543
1587
 
1544
1588
  if (gapFromLast > timeoutMs || sessionDuration > maxSessionMs) {
1545
- currentSessionId = generateSessionId();
1589
+ currentSessionId = generateSessionId(`${userKey}-${event.time}`);
1546
1590
  sessionStartMs = eventMs;
1547
1591
  }
1548
1592
 
@@ -1606,4 +1650,5 @@ export {
1606
1650
  generateSessionId,
1607
1651
  assignSessionIds,
1608
1652
  bunchIntoSessions,
1653
+ setDatasetNow,
1609
1654
  };
@@ -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
+ }