@ak--47/dungeon-master 1.5.0 → 1.5.2

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 (66) hide show
  1. package/.claude/skills/create-dungeon/SKILL.md +139 -46
  2. package/.claude/skills/verify-dungeon/references/counting-semantics.md +31 -6
  3. package/.claude/skills/verify-dungeon/references/sql-recipes.md +44 -25
  4. package/.claude/skills/write-hooks/SKILL.md +31 -3
  5. package/CHANGELOG.md +85 -0
  6. package/HOOKS.md +13 -0
  7. package/dungeons/technical/ad-spend.js +41 -49
  8. package/dungeons/technical/anonymous-users.js +38 -36
  9. package/dungeons/technical/array-of-object-lookup.js +136 -153
  10. package/dungeons/technical/datagen-v15-verify.js +24 -11
  11. package/dungeons/technical/experiments.js +42 -40
  12. package/dungeons/technical/foobar.js +114 -118
  13. package/dungeons/technical/group-analytics.js +42 -40
  14. package/dungeons/technical/hook-helpers-verify.js +69 -50
  15. package/dungeons/technical/identity-model-verify.js +22 -12
  16. package/dungeons/technical/mirror-strategies.js +37 -39
  17. package/dungeons/technical/nested-objects.js +119 -118
  18. package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
  19. package/dungeons/technical/pattern-attributed-by-source.js +23 -9
  20. package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
  21. package/dungeons/technical/pattern-funnel-frequency.js +30 -15
  22. package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
  23. package/dungeons/technical/retention-cadence.js +115 -112
  24. package/dungeons/technical/sanity.js +86 -80
  25. package/dungeons/technical/scale-test.js +34 -38
  26. package/dungeons/technical/scd.js +111 -128
  27. package/dungeons/technical/simple.js +134 -141
  28. package/dungeons/technical/simplest.js +54 -62
  29. package/dungeons/technical/text-generation.js +110 -146
  30. package/dungeons/vertical/ai-platform.js +296 -333
  31. package/dungeons/vertical/community.js +284 -255
  32. package/dungeons/vertical/crypto.js +395 -391
  33. package/dungeons/vertical/dating.js +411 -378
  34. package/dungeons/vertical/devtools.js +336 -298
  35. package/dungeons/vertical/ecommerce.js +316 -394
  36. package/dungeons/vertical/education.js +369 -325
  37. package/dungeons/vertical/fintech.js +358 -325
  38. package/dungeons/vertical/fitness.js +335 -291
  39. package/dungeons/vertical/food-delivery.js +343 -307
  40. package/dungeons/vertical/gaming.js +480 -444
  41. package/dungeons/vertical/healthcare.js +306 -262
  42. package/dungeons/vertical/insurance-application.js +427 -409
  43. package/dungeons/vertical/logistics.js +271 -252
  44. package/dungeons/vertical/marketplace.js +333 -323
  45. package/dungeons/vertical/media.js +382 -335
  46. package/dungeons/vertical/real-estate.js +395 -346
  47. package/dungeons/vertical/sass.js +319 -333
  48. package/dungeons/vertical/social.js +368 -316
  49. package/dungeons/vertical/travel.js +297 -295
  50. package/index.js +46 -4
  51. package/lib/core/config-validator.js +126 -28
  52. package/lib/generators/funnels.js +4 -1
  53. package/lib/orchestrators/mixpanel-sender.js +7 -0
  54. package/lib/orchestrators/user-loop.js +132 -31
  55. package/lib/templates/defaults.js +59 -59
  56. package/lib/templates/macro-presets.js +14 -2
  57. package/lib/utils/dataset-context.js +103 -0
  58. package/lib/utils/retention-curve.js +140 -0
  59. package/lib/utils/utils.js +149 -38
  60. package/lib/verify/counting.js +40 -0
  61. package/lib/verify/emulate-breakdown.js +20 -1
  62. package/lib/verify/index.js +1 -0
  63. package/lib/verify/schema-validator.js +3 -1
  64. package/package.json +11 -2
  65. package/scripts/run-dungeon.mjs +12 -1
  66. package/types.d.ts +117 -1
@@ -15,6 +15,7 @@ import { makeEvent } from "../generators/events.js";
15
15
  import { makeFunnel } from "../generators/funnels.js";
16
16
  import { makeUserProfile } from "../generators/profiles.js";
17
17
  import { makeSCD } from "../generators/scd.js";
18
+ import { buildCurveWeightFn, expectedActiveDays } from "../utils/retention-curve.js";
18
19
 
19
20
  /**
20
21
  * Main user generation loop that creates users, their profiles, events, and SCDs
@@ -43,6 +44,7 @@ export async function userLoop(context) {
43
44
  numDays,
44
45
  avgEventsPerUserPerDay,
45
46
  avgActiveDaysPerUser,
47
+ retentionCurve,
46
48
  percentUsersBornInDataset = 15,
47
49
  strictEventCount = false,
48
50
  bornRecentBias = 0, // -1..1; positive = births skew toward end of window
@@ -119,7 +121,12 @@ export async function userLoop(context) {
119
121
  percentComplete: Math.min(100, Math.round((context.getUserCount() / numUsers) * 100))
120
122
  });
121
123
 
122
- const userId = chance.guid();
124
+ // v1.5.1: distinct_id sourced from a separate `userChance` when
125
+ // `Dungeon.userSeed` is set. This lets sharded runs (e.g., kodiak
126
+ // Cloud Run Job) generate the SAME user pool with DIFFERENT events
127
+ // across shards. Falls back to the event chance when userSeed is
128
+ // unset so existing dungeons stay byte-identical.
129
+ const userId = u.getUserChance().guid();
123
130
  const user = u.generateUser(userId, { numDays, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds, datasetEndUnix: context.FIXED_NOW, avgDevicePerUser });
124
131
  const { distinct_id, created } = user;
125
132
  const userDevicePool = (user.anonymousIds && user.anonymousIds.length) ? user.anonymousIds.slice() : null;
@@ -219,9 +226,14 @@ export async function userLoop(context) {
219
226
  dataQuality,
220
227
  };
221
228
 
222
- // Call user hook after profile creation (hooks override persona properties)
229
+ // Call user hook after profile creation (hooks override persona properties).
230
+ // v1.5.1: an explicit `null` return is interpreted as "drop the user
231
+ // PROFILE record" — the user's events still generate normally. Used
232
+ // by sharded runs that have one canonical chunk per bucket emit profiles
233
+ // while every other chunk skips them (see dungeons/user/kodiak/).
234
+ let dropUserProfile = false;
223
235
  if (config.hook) {
224
- await config.hook(profile, "user", {
236
+ const hookedProfile = await config.hook(profile, "user", {
225
237
  user,
226
238
  config,
227
239
  userIsBornInDataset,
@@ -229,6 +241,9 @@ export async function userLoop(context) {
229
241
  datasetStart: context.DATASET_START_SECONDS,
230
242
  datasetEnd: context.DATASET_END_SECONDS
231
243
  });
244
+ if (hookedProfile === null) {
245
+ dropUserProfile = true;
246
+ }
232
247
  }
233
248
 
234
249
  // SCD creation
@@ -268,19 +283,25 @@ export async function userLoop(context) {
268
283
  : numDays;
269
284
  const userEventBudget = ratePerDay * userActiveDays;
270
285
 
271
- let numEventsThisUserWillPreform = Math.floor(chance.normal({
286
+ // v1.5.1 (TODO #10): per-user event budget.
287
+ //
288
+ // Removed:
289
+ // - the 0.714 magic dampening factor (pre-existing vestigial constant)
290
+ // - the ×5 / ×0.333 "power user" / "low activity" dice rolls
291
+ // (E[dice mult] ≈ 1.62, combined with 0.714 ≈ 1.16x inflation
292
+ // by design — produced 60-100% systematic overshoot on
293
+ // `numEvents` targets across all macros).
294
+ //
295
+ // New: pure normal distribution around `userEventBudget` with
296
+ // `dev = userEventBudget / 3` (~68% of users within ±1σ of target,
297
+ // ~95% within ±2σ). Real heavy-tail behavior should come from
298
+ // `personas` (`eventMultiplier`) — explicit, opt-in, documented.
299
+ let numEventsThisUserWillPreform = Math.max(0, Math.round(chance.normal({
272
300
  mean: userEventBudget,
273
- dev: userEventBudget / u.integer(u.integer(2, 5), u.integer(2, 7))
274
- }) * 0.714159265359);
275
-
276
- // Power users and low-activity users logic
301
+ dev: userEventBudget / 3,
302
+ })));
277
303
  if (persona) {
278
- // Persona-driven event multiplier replaces the old dice rolls
279
304
  numEventsThisUserWillPreform *= persona.eventMultiplier;
280
- } else {
281
- // Legacy behavior when no personas configured
282
- chance.bool({ likelihood: 20 }) ? numEventsThisUserWillPreform *= 5 : null;
283
- chance.bool({ likelihood: 15 }) ? numEventsThisUserWillPreform *= 0.333 : null;
284
305
  }
285
306
  numEventsThisUserWillPreform = Math.round(numEventsThisUserWillPreform);
286
307
 
@@ -300,12 +321,17 @@ export async function userLoop(context) {
300
321
  // `{ plan, pickedDayBuckets }`. `pickedDayBuckets` flows into
301
322
  // `applyEngagementDecay` so the decay filter never drops the last event
302
323
  // on a picked day — preserving the configured distinct-day count.
303
- const dayPlanResult = (avgActiveDaysPerUser !== undefined && avgActiveDaysPerUser !== null && Number.isFinite(avgActiveDaysPerUser))
324
+ // v1.5.1: also fires when `retentionCurve` is set (curve wins over
325
+ // explicit `avgActiveDaysPerUser` — see TODO #4 resolution (b)).
326
+ const hasActiveDaysKnob = (avgActiveDaysPerUser !== undefined && avgActiveDaysPerUser !== null && Number.isFinite(avgActiveDaysPerUser));
327
+ const hasRetentionCurve = !!retentionCurve;
328
+ const dayPlanResult = (hasActiveDaysKnob || hasRetentionCurve)
304
329
  ? buildActiveDayPlan({
305
330
  adjustedCreated, fixedBegin: context.FIXED_BEGIN, fixedNow: context.FIXED_NOW,
306
331
  avgActiveDaysPerUser, userActiveDays,
307
332
  numEvents: numEventsThisUserWillPreform,
308
333
  dowWeights: soupCfgForActiveDay.dayOfWeekWeights,
334
+ retentionCurve,
309
335
  chance,
310
336
  })
311
337
  : null;
@@ -422,9 +448,13 @@ export async function userLoop(context) {
422
448
 
423
449
  userFirstEventTime = firstAttemptFirstEventTime !== null
424
450
  ? firstAttemptFirstEventTime
425
- : adjustedCreated.subtract(noise(), 'seconds').unix();
451
+ : Math.max(adjustedCreated.subtract(noise(), 'seconds').unix(), context.FIXED_BEGIN);
426
452
  } else {
427
- userFirstEventTime = adjustedCreated.subtract(noise(), 'seconds').unix();
453
+ // v1.5.1: clamp at FIXED_BEGIN so pre-existing-user events stay strictly
454
+ // inside the dataset window. Without this, `noise()` (up to 1 day) can
455
+ // shift the first-event time before FIXED_BEGIN — fine when the dataset
456
+ // is years long, but visible at sub-day chunk windows (kodiak shards).
457
+ userFirstEventTime = Math.max(adjustedCreated.subtract(noise(), 'seconds').unix(), context.FIXED_BEGIN);
428
458
  }
429
459
 
430
460
  // ALL SUBSEQUENT EVENTS (funnels for converted users, standalone for all)
@@ -458,6 +488,14 @@ export async function userLoop(context) {
458
488
  // out cleanly rather than crashing on `pick([])`.
459
489
  const hasUsageFunnels = usageFunnels.length > 0;
460
490
  const hasStandaloneEvents = weightedEvents.length > 0;
491
+ // v1.5.1 (TODO #10): shortest usage-funnel length. Used to decide
492
+ // whether the remaining budget can fit another funnel run — funnels
493
+ // add N events at once, so iterations near the budget ceiling
494
+ // systematically overshot. Now we redirect to standalone (or break)
495
+ // when the remaining headroom is smaller than the smallest funnel.
496
+ const minUsageFunnelLen = hasUsageFunnels
497
+ ? Math.min(...usageFunnels.map(f => Array.isArray(f.sequence) ? f.sequence.length : 1))
498
+ : Infinity;
461
499
  // Hard ceiling on iterations: defends against pathological configs where
462
500
  // every funnel produces 0 surviving events (e.g. ttc > numDays so step1
463
501
  // uniform anchor always lands too late). Cap at 2× expected iteration count
@@ -483,7 +521,16 @@ export async function userLoop(context) {
483
521
  // Nothing to generate: user converted via firstFunnel only and has no
484
522
  // usage funnels and no standalone events. Stop attempting.
485
523
  if ((!hasUsageFunnels || !userConverted) && !hasStandaloneEvents) break;
486
- if (hasUsageFunnels && userConverted) {
524
+ // v1.5.1 (TODO #10): if the remaining budget can't fit even the
525
+ // shortest usage funnel, prefer a standalone event (or break if
526
+ // no standalone). Without this, the funnel branch added N events
527
+ // at once and overshot `numEvents` by `(funnel_length - 1)` per
528
+ // terminal iteration — measured ~5-15% systematic overshoot.
529
+ const remainingBudget = numEventsThisUserWillPreform - numEventsPreformed;
530
+ const useFunnel = hasUsageFunnels && userConverted && remainingBudget >= minUsageFunnelLen;
531
+ // Budget exhausted for funnels AND no standalone → break.
532
+ if (!useFunnel && !hasStandaloneEvents) break;
533
+ if (useFunnel) {
487
534
  const currentFunnel = chance.pickone(usageFunnels);
488
535
  const ttcSec = (currentFunnel.timeToConvert || 0) * 3600;
489
536
  // Anchor cursor at picked day's start when active-day mode is on,
@@ -533,7 +580,12 @@ export async function userLoop(context) {
533
580
  // `earliestTime`, which the now-deleted bunchIntoSessions used to paper
534
581
  // over. With bunchIntoSessions removed, TimeSoup is the time source.
535
582
  const standaloneEarliest = dayBounds ? dayBounds.earliest : userFirstEventTime;
536
- const data = await makeEvent(context, distinct_id, standaloneEarliest, u.pick(weightedEvents), user.anonymousIds, {}, config.groupKeys, false, false, standaloneFeatureCtx, standaloneIdentityCtx);
583
+ // v1.5.1: pass `config.superProps` so standalone events get super-property
584
+ // stamping. Pre-1.5.1, standalone events received `{}` here, but the
585
+ // validator's auto-funnel (catch-all) consumed all non-strict events so
586
+ // the bug was invisible. The TODO #10 `useFunnel` gate redirects some
587
+ // budget-boundary iterations to standalone, exposing the gap.
588
+ const data = await makeEvent(context, distinct_id, standaloneEarliest, u.pick(weightedEvents), user.anonymousIds, config.superProps || {}, config.groupKeys, false, false, standaloneFeatureCtx, standaloneIdentityCtx);
537
589
  numEventsPreformed++;
538
590
  newEvents = [data];
539
591
  }
@@ -630,6 +682,14 @@ export async function userLoop(context) {
630
682
  applyTouchpointCap(usersEvents, config, defaults, chance);
631
683
  }
632
684
 
685
+ // v1.5.1: anonymous non-converters never call $identify in production,
686
+ // so Mixpanel never creates a profile for them. Stamp `_drop: true` so
687
+ // mixpanel-sender skips the /engage push. Stamped BEFORE the everything
688
+ // hook fires so hooks can rescue a profile by deleting the flag.
689
+ if (userIsBornInDataset && !userAuthed) {
690
+ profile._drop = true;
691
+ }
692
+
633
693
  // Hook for processing all user events (hooks override everything)
634
694
  if (config.hook) {
635
695
  // `meta.isPreAuth(event)` predicate bound to this user's auth state.
@@ -691,8 +751,11 @@ export async function userLoop(context) {
691
751
  console.warn(`⚠️ Dropped ${droppedFuture} future-dated event(s) for user ${distinct_id}`);
692
752
  }
693
753
 
694
- // Store all user data
695
- await userProfilesData.hookPush(profile);
754
+ // Store all user data (skip profile push when a hook returned null
755
+ // for type='user' — see dropUserProfile above).
756
+ if (!dropUserProfile) {
757
+ await userProfilesData.hookPush(profile);
758
+ }
696
759
 
697
760
  if (Object.keys(userSCD).length) {
698
761
  for (const [key, changesArray] of Object.entries(userSCD)) {
@@ -714,10 +777,24 @@ export async function userLoop(context) {
714
777
  });
715
778
 
716
779
  userPromises.push(userPromise);
780
+
781
+ // v1.5.1: V8's Promise.all has a hard ceiling (~65K elements). Sharded
782
+ // kodiak runs pass numUsers in the millions per chunk, which blows that
783
+ // limit. Drain the in-flight buffer in batches of 50K to keep Promise.all
784
+ // well under the cap. With p-limit honoring `concurrency`, this is purely
785
+ // a backpressure boundary — no behavior change for existing dungeons that
786
+ // have numUsers under 50K.
787
+ if (userPromises.length >= 50_000) {
788
+ await Promise.all(userPromises);
789
+ userPromises.length = 0;
790
+ }
717
791
  }
718
792
 
719
- // Wait for all users to complete
720
- await Promise.all(userPromises);
793
+ // Drain whatever's left in the final partial batch.
794
+ if (userPromises.length > 0) {
795
+ await Promise.all(userPromises);
796
+ userPromises.length = 0;
797
+ }
721
798
 
722
799
  // Feature 4: Generate bot users (after regular users)
723
800
  if (dataQuality && dataQuality.botUsers > 0) {
@@ -774,11 +851,15 @@ export function matchConditions(profile, conditions) {
774
851
  * @param {number} args.userActiveDays - Capacity (max possible distinct days)
775
852
  * @param {number} args.numEvents - Total events to schedule for this user
776
853
  * @param {number[]} [args.dowWeights] - 7-element soup DOW weights (Sun..Sat)
854
+ * @param {import('../utils/retention-curve.js').RetentionCurveConfig} [args.retentionCurve]
855
+ * v1.5.1 — when set, weights candidate days by curve(day_offset_from_birth)
856
+ * instead of DOW weights AND derives the effective avgActiveDaysPerUser from
857
+ * the curve's sum across the window.
777
858
  * @param {Object} args.chance - Seeded chance instance
778
859
  * @returns {{ plan: number[], pickedDayBuckets: Set<number> } | null} Day plan
779
860
  * + bucket set, or null if not applicable (no candidate days, no events, etc.).
780
861
  */
781
- function buildActiveDayPlan({ adjustedCreated, fixedBegin, fixedNow, avgActiveDaysPerUser, userActiveDays, numEvents, dowWeights, chance }) {
862
+ function buildActiveDayPlan({ adjustedCreated, fixedBegin, fixedNow, avgActiveDaysPerUser, userActiveDays, numEvents, dowWeights, retentionCurve, chance }) {
782
863
  if (!Number.isFinite(numEvents) || numEvents <= 0) return null;
783
864
 
784
865
  // Candidate day buckets: UTC days intersecting [max(adjustedCreated, FIXED_BEGIN), FIXED_NOW].
@@ -800,16 +881,36 @@ function buildActiveDayPlan({ adjustedCreated, fixedBegin, fixedNow, avgActiveDa
800
881
  }
801
882
  if (!candidateDays.length) return null;
802
883
 
803
- // Weight by soup DOW (Sun=0..Sat=6). Default uniform if no weights configured.
804
- const weights = candidateDays.map(daySec => {
805
- if (!Array.isArray(dowWeights) || dowWeights.length !== 7) return 1;
806
- const dow = new Date(daySec * 1000).getUTCDay();
807
- const w = Number(dowWeights[dow]);
808
- return Number.isFinite(w) && w > 0 ? w : 0.0001;
809
- });
884
+ // v1.5.1 (TODO #4): when `retentionCurve` is set, weight candidate days by
885
+ // curve(day_offset_from_birth) biases active-day selection toward the
886
+ // requested retention shape. Replaces DOW weighting entirely (curve wins).
887
+ // Otherwise: legacy DOW weights.
888
+ let weights;
889
+ if (retentionCurve) {
890
+ const curveFn = buildCurveWeightFn(retentionCurve);
891
+ const birthDay = Math.floor(userStartUnix / dayMs);
892
+ weights = candidateDays.map(daySec => {
893
+ const dayOffset = (daySec / dayMs) - birthDay;
894
+ const w = curveFn(dayOffset);
895
+ return Number.isFinite(w) && w > 0 ? w : 0.0001;
896
+ });
897
+ } else {
898
+ weights = candidateDays.map(daySec => {
899
+ if (!Array.isArray(dowWeights) || dowWeights.length !== 7) return 1;
900
+ const dow = new Date(daySec * 1000).getUTCDay();
901
+ const w = Number(dowWeights[dow]);
902
+ return Number.isFinite(w) && w > 0 ? w : 0.0001;
903
+ });
904
+ }
810
905
 
811
906
  // Draw target active-day count: normal(mean, sd=mean/3), clamped.
812
- const meanActive = Math.max(1, Math.min(avgActiveDaysPerUser, candidateDays.length));
907
+ // v1.5.1: when `retentionCurve` is set, the mean is derived from the curve's
908
+ // sum across the user's window (resolution (b): curve wins, explicit
909
+ // avgActiveDaysPerUser ignored).
910
+ const effectiveMean = retentionCurve
911
+ ? Math.max(1, expectedActiveDays(retentionCurve, candidateDays.length))
912
+ : avgActiveDaysPerUser;
913
+ const meanActive = Math.max(1, Math.min(effectiveMean, candidateDays.length));
813
914
  const sd = Math.max(0.5, meanActive / 3);
814
915
  let targetActiveDays = Math.round(chance.normal({ mean: meanActive, dev: sd }));
815
916
  targetActiveDays = Math.max(1, Math.min(targetActiveDays, candidateDays.length));