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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +158 -0
  2. package/.claude/skills/create-dungeon/SKILL.md +464 -0
  3. package/.claude/skills/verify-dungeon/SKILL.md +157 -0
  4. package/.claude/skills/verify-dungeon/references/counting-semantics.md +161 -0
  5. package/.claude/skills/verify-dungeon/references/report-format.md +216 -0
  6. package/.claude/skills/verify-dungeon/references/sql-recipes.md +857 -0
  7. package/.claude/skills/write-hooks/SKILL.md +468 -0
  8. package/CHANGELOG.md +182 -0
  9. package/HOOKS.md +1256 -597
  10. package/README.md +140 -5
  11. package/dungeons/technical/ad-spend.js +41 -49
  12. package/dungeons/technical/anonymous-users.js +38 -36
  13. package/dungeons/technical/array-of-object-lookup.js +136 -153
  14. package/dungeons/technical/datagen-v15-verify.js +87 -0
  15. package/dungeons/technical/experiments.js +42 -40
  16. package/dungeons/technical/foobar.js +114 -118
  17. package/dungeons/technical/group-analytics.js +42 -40
  18. package/dungeons/technical/hook-helpers-verify.js +69 -50
  19. package/dungeons/technical/identity-model-verify.js +22 -12
  20. package/dungeons/technical/mirror-strategies.js +37 -39
  21. package/dungeons/technical/nested-objects.js +119 -118
  22. package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
  23. package/dungeons/technical/pattern-attributed-by-source.js +23 -9
  24. package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
  25. package/dungeons/technical/pattern-funnel-frequency.js +30 -15
  26. package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
  27. package/dungeons/technical/retention-cadence.js +115 -112
  28. package/dungeons/technical/sanity.js +86 -80
  29. package/dungeons/technical/scale-test.js +34 -38
  30. package/dungeons/technical/scd.js +111 -128
  31. package/dungeons/technical/simple.js +134 -141
  32. package/dungeons/technical/simplest.js +111 -65
  33. package/dungeons/technical/text-generation.js +110 -146
  34. package/dungeons/vertical/ai-platform.js +300 -333
  35. package/dungeons/vertical/community.js +290 -255
  36. package/dungeons/vertical/crypto.js +400 -391
  37. package/dungeons/vertical/dating.js +421 -375
  38. package/dungeons/vertical/devtools.js +346 -298
  39. package/dungeons/vertical/ecommerce.js +322 -394
  40. package/dungeons/vertical/education.js +380 -325
  41. package/dungeons/vertical/fintech.js +371 -325
  42. package/dungeons/vertical/fitness.js +345 -291
  43. package/dungeons/vertical/food-delivery.js +352 -307
  44. package/dungeons/vertical/gaming.js +490 -444
  45. package/dungeons/vertical/healthcare.js +311 -262
  46. package/dungeons/vertical/insurance-application.js +437 -409
  47. package/dungeons/vertical/logistics.js +278 -252
  48. package/dungeons/vertical/marketplace.js +340 -323
  49. package/dungeons/vertical/media.js +390 -335
  50. package/dungeons/vertical/real-estate.js +402 -347
  51. package/dungeons/vertical/sass.js +331 -333
  52. package/dungeons/vertical/social.js +377 -316
  53. package/dungeons/vertical/travel.js +302 -295
  54. package/index.js +64 -7
  55. package/lib/core/config-validator.js +378 -17
  56. package/lib/core/dungeon-loader.js +2 -5
  57. package/lib/generators/events.js +12 -13
  58. package/lib/generators/funnels.js +76 -2
  59. package/lib/hook-helpers/index.js +1 -0
  60. package/lib/hook-helpers/inject.js +95 -0
  61. package/lib/orchestrators/mixpanel-sender.js +7 -0
  62. package/lib/orchestrators/user-loop.js +598 -48
  63. package/lib/templates/defaults.js +59 -59
  64. package/lib/templates/macro-presets.js +53 -11
  65. package/lib/utils/dataset-context.js +103 -0
  66. package/lib/utils/retention-curve.js +140 -0
  67. package/lib/utils/utils.js +157 -109
  68. package/lib/verify/counting.js +360 -0
  69. package/lib/verify/emulate-breakdown.js +531 -108
  70. package/lib/verify/funnel-engine.js +539 -0
  71. package/lib/verify/identity.js +78 -0
  72. package/lib/verify/index.js +20 -0
  73. package/lib/verify/schema-validator.js +3 -1
  74. package/lib/verify/verify-dungeon.js +58 -0
  75. package/package.json +14 -3
  76. package/scripts/run-dungeon.mjs +12 -1
  77. package/types.d.ts +353 -4
  78. package/scripts/smoke-test-all.mjs +0 -162
@@ -1,78 +1,40 @@
1
- // ── TWEAK THESE ──
2
- const SEED = "simple is best";
3
- const num_days = 120;
4
- const num_users = 42_000;
5
- const avg_events_per_user_per_day = 0.37;
6
- let token = "your-mixpanel-token";
7
-
8
- // ── env overrides ──
9
- if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
10
-
1
+ // ── IMPORTS ──
11
2
  import dayjs from "dayjs";
12
3
  import utc from "dayjs/plugin/utc.js";
13
4
  dayjs.extend(utc);
14
- import { uid, comma } from 'ak-tools';
15
- import { weighNumRange, date, integer, weighChoices, decimal, initChance } from "../../lib/utils/utils.js";
5
+ import { uid } from "ak-tools";
6
+ import { weighNumRange, integer, weighChoices, decimal, initChance } from "../../lib/utils/utils.js";
16
7
  import { scaleFunnelTTC } from "../../lib/hook-helpers/timing.js";
17
- const chance = initChance(SEED);
18
-
19
8
  /** @typedef {import("../../types").Dungeon} Config */
20
- const itemCategories = ["Books", "Movies", "Music", "Games", "Electronics", "Computers", "Smart Home", "Home", "Garden", "Pet", "Beauty", "Health", "Toys", "Kids", "Baby", "Handmade", "Sports", "Outdoors", "Automotive", "Industrial", "Entertainment", "Art", "Food", "Appliances", "Office", "Wedding", "Software"];
21
- const videoCategories = ["funny", "educational", "inspirational", "music", "news", "sports", "cooking", "DIY", "travel", "gaming"];
22
- const spiritAnimals = ["duck", "dog", "otter", "penguin", "cat", "elephant", "lion", "cheetah", "giraffe", "zebra", "rhino", "hippo", "whale", "dolphin", "shark", "octopus", "squid", "jellyfish", "starfish", "seahorse", "crab", "lobster", "shrimp", "clam", "snail", "slug", "butterfly", "moth", "bee", "wasp", "ant", "beetle", "ladybug", "caterpillar", "centipede", "millipede", "scorpion", "spider", "tarantula", "tick", "mite", "mosquito", "fly", "dragonfly", "damselfly", "grasshopper", "cricket", "locust", "mantis", "cockroach", "termite", "praying mantis", "walking stick", "stick bug", "leaf insect", "lacewing", "aphid", "cicada", "thrips", "psyllid", "scale insect", "whitefly", "mealybug", "planthopper", "leafhopper", "treehopper", "flea", "louse", "bedbug", "flea beetle", "weevil", "longhorn beetle", "leaf beetle", "tiger beetle", "ground beetle", "lady beetle", "firefly", "click beetle", "rove beetle", "scarab beetle", "dung beetle", "stag beetle", "rhinoceros beetle", "hercules beetle", "goliath beetle", "jewel beetle", "tortoise beetle"];
23
9
 
10
+ // ── OVERVIEW ──
24
11
  /*
25
- * ============================================================================
26
- * DATASET OVERVIEW
27
- * ============================================================================
28
- *
29
- * App: eCommerce marketplace with integrated video content
30
- * Scale: 50K users, 2M events, 108 days
31
- *
32
- * Core loop: Users browse products, watch videos, build carts, and check out.
33
- * A signup funnel converts browsers into registered users. Video engagement
34
- * (likes/dislikes) runs alongside the shopping flow.
35
- *
36
- * Events:
37
- * page view (10) > view item (8) > watch video (8) > like video (6)
38
- * > save item (5) > add to cart (4) > dislike video (4) > checkout (2) > sign up (1)
39
- *
40
- * Funnels:
41
- * - Signup Flow: page view → view item → save item → page viewsign up (50%)
42
- * - Video Likes: watch → like → watch → like (60%)
43
- * - Video Dislikes: watch dislike → watch → dislike (20%)
44
- * - eCommerce Purchase: view → view → add to cart → view → add to cart → checkout (15%)
45
- *
46
- * User props: title, luckyNumber, spiritAnimal
47
- * Super props: theme (light/dark/custom)
48
- * ============================================================================
12
+ * NAME: ShopStream
13
+ * APP: eCommerce marketplace with integrated video content. Users browse
14
+ * products, watch videos, build carts, and check out. A signup
15
+ * funnel converts anonymous browsers into registered users; video
16
+ * engagement (like/dislike) runs alongside the shopping flow.
17
+ * SCALE: 42,000 users, ~2M events, 121 days (2026-01-01 → 2026-05-01)
18
+ * CORE LOOP: page view → view item → save/add to cart → checkout (+ video like/dislike side loop)
19
+ *
20
+ * EVENTS (9):
21
+ * page view (10) > view item (8) > watch video (8) > like video (6) > save item (5)
22
+ * > add to cart (4) > dislike video (4) > checkout (2) > sign up (1)
23
+ *
24
+ * FUNNELS (4):
25
+ * - Signup Flow: page view view item save item page view sign up (50%)
26
+ * - Video Likes: watch video → like video → watch video → like video (60%)
27
+ * - Video Dislikes: watch video → dislike video → watch video → dislike video (20%)
28
+ * - eCommerce Purchase: view item → view item → add to cart → view item → add to cart checkout (15%, A/B/C experiment)
29
+ *
30
+ * USER PROPS: title, luckyNumber, spiritAnimal, theme
31
+ * SUPER PROPS: theme
32
+ * SCD PROPS: loyalty_tier (bronze/silver/gold/platinum, monthly fuzzy, max 8)
33
+ * GROUPS: none
49
34
  */
50
35
 
36
+ // ── HOOK STORIES ──
51
37
  /*
52
- * ============================================================================
53
- * ANALYTICS HOOKS (10 hooks)
54
- *
55
- * Adds 7. SIGNUP FLOW TIME-TO-CONVERT: gold/platinum loyalty 0.67x faster,
56
- * bronze 1.33x slower (everything hook via scaleFunnelTTC on the page-view-
57
- * to-sign-up window). Loyalty tier is determined from SCD data
58
- * (meta.scd.loyalty_tier) with deterministic hash fallback. Discover via
59
- * bound-sequence funnel TTC breakdown by loyalty tier.
60
- *
61
- * Adds 8. CHECKOUT FLOW EXPERIMENT: A/B/C experiment on the eCommerce
62
- * Purchase funnel — "Control", "Express Checkout" (1.25x conversion),
63
- * "Social Proof" (1.15x conversion, 0.9x TTC). Engine-managed via funnel
64
- * experiment config; starts 30 days before dataset end.
65
- *
66
- * Adds 9. DARK THEME POWER USERS: funnel-pre hook that boosts purchase
67
- * funnel conversion 1.3x for dark-theme users and penalizes light-theme
68
- * users to 0.85x. Discoverable by breakdown of funnel conversion by theme.
69
- *
70
- * Adds 10. SAVE-ITEM RETENTION: born-in-dataset users who save 2+ items
71
- * in their first 10 days retain long-term; those below threshold lose ~70%
72
- * of post-day-25 events. Discoverable by retention or frequency analysis
73
- * segmented by early save-item count.
74
- * ============================================================================
75
- *
76
38
  * ----------------------------------------------------------------------------
77
39
  * Hook 1: Signup Flow Improvement (event + everything)
78
40
  * ----------------------------------------------------------------------------
@@ -386,30 +348,69 @@ const spiritAnimals = ["duck", "dog", "otter", "penguin", "cat", "elephant", "li
386
348
  * ============================================================================
387
349
  */
388
350
 
351
+ // ── SCALE ──
352
+ const SEED = "simple is best";
353
+ const NUM_USERS = 42_000;
354
+ const DATASET_START = "2026-01-01T00:00:00Z";
355
+ const DATASET_END = "2026-05-01T23:59:59Z";
356
+ const EVENTS_PER_DAY = 0.37;
357
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
358
+
359
+ const chance = initChance(SEED);
360
+
361
+ // ── KNOBS (tweak these to reshape stories) ──
362
+ const SIGNUP_FIX_DAYS_AGO = 7;
363
+ const WATCH_INFLECTION_DAYS_AGO = 30;
364
+ const WATCH_FACTOR_MIN = 0.25;
365
+ const WATCH_FACTOR_MAX = 0.79;
366
+ const VIEW_SWEET_MIN = 3;
367
+ const VIEW_SWEET_MAX = 8;
368
+ const VIEW_OVER_THRESHOLD = 9;
369
+ const SWEET_CART_BOOST = 1.25;
370
+ const SWEET_CLONE_LIKELIHOOD = 45;
371
+ const OVER_CHECKOUT_DROP_LIKELIHOOD = 30;
372
+ const LOYALTY_TTC_FAST = 0.67;
373
+ const LOYALTY_TTC_SLOW = 1.33;
374
+ const LOYALTY_LOOKBACK_DAYS = 2;
375
+ const SAVE_RETENTION_MIN = 2;
376
+ const SAVE_RETENTION_WINDOW_DAYS = 10;
377
+ const SAVE_RETENTION_CUTOFF_DAYS = 25;
378
+ const SAVE_RETENTION_DROP_LIKELIHOOD = 70;
379
+ const QUALITY_FACTORS = {
380
+ "2160p": 1.5,
381
+ "1440p": 1.4,
382
+ "1080p": 1.3,
383
+ "720p": 1.15,
384
+ "480p": 1.0,
385
+ "360p": 0.85,
386
+ "240p": 0.7,
387
+ };
389
388
 
389
+ // ── DATA ARRAYS ──
390
+ const videoCategories = ["funny", "educational", "inspirational", "music", "news", "sports", "cooking", "DIY", "travel", "gaming"];
391
+ const spiritAnimals = ["duck", "dog", "otter", "penguin", "cat", "elephant", "lion", "cheetah", "giraffe", "zebra", "rhino", "hippo", "whale", "dolphin", "shark", "octopus", "squid", "jellyfish", "starfish", "seahorse", "crab", "lobster", "shrimp", "clam", "snail", "slug", "butterfly", "moth", "bee", "wasp", "ant", "beetle", "ladybug", "caterpillar", "centipede", "millipede", "scorpion", "spider", "tarantula", "tick", "mite", "mosquito", "fly", "dragonfly", "damselfly", "grasshopper", "cricket", "locust", "mantis", "cockroach", "termite", "praying mantis", "walking stick", "stick bug", "leaf insect", "lacewing", "aphid", "cicada", "thrips", "psyllid", "scale insect", "whitefly", "mealybug", "planthopper", "leafhopper", "treehopper", "flea", "louse", "bedbug", "flea beetle", "weevil", "longhorn beetle", "leaf beetle", "tiger beetle", "ground beetle", "lady beetle", "firefly", "click beetle", "rove beetle", "scarab beetle", "dung beetle", "stag beetle", "rhinoceros beetle", "hercules beetle", "goliath beetle", "jewel beetle", "tortoise beetle"];
392
+ const productCategories = ["electronics", "books", "clothing", "home", "garden", "toys", "sports", "automotive", "beauty", "health", "grocery", "jewelry", "shoes", "tools", "office supplies"];
393
+ const productDescriptors = ["brand new", "open box", "refurbished", "used", "like new", "vintage", "antique", "collectible"];
394
+ const productSuffixes = ["item", "product", "good", "merchandise", "thing", "object", "widget", "gadget", "device", "apparatus", "contraption", "instrument", "tool", "implement", "utensil", "appliance", "machine", "equipment", "gear", "kit", "set", "package"];
395
+ const assetPreview = [".png", ".jpg", ".jpeg", ".heic", ".mp4", ".mov", ".avi"];
396
+
397
+ // ── HELPER FUNCTIONS ──
390
398
  function makeProducts(maxItems = 5) {
391
399
  return function () {
392
- const categories = ["electronics", "books", "clothing", "home", "garden", "toys", "sports", "automotive", "beauty", "health", "grocery", "jewelry", "shoes", "tools", "office supplies"];
393
- const descriptors = ["brand new", "open box", "refurbished", "used", "like new", "vintage", "antique", "collectible"];
394
- const suffix = ["item", "product", "good", "merchandise", "thing", "object", "widget", "gadget", "device", "apparatus", "contraption", "instrument", "tool", "implement", "utensil", "appliance", "machine", "equipment", "gear", "kit", "set", "package"];
395
- const assetPreview = ['.png', '.jpg', '.jpeg', '.heic', '.mp4', '.mov', '.avi'];
396
400
  const data = [];
397
401
  const numOfItems = integer(1, maxItems);
398
402
 
399
- for (var i = 0; i < numOfItems; i++) {
400
- const category = chance.pickone(categories);
401
- const descriptor = chance.pickone(descriptors);
402
- const suffixWord = chance.pickone(suffix);
403
- const slug = `${descriptor.replace(/\s+/g, '-').toLowerCase()}-${suffixWord.replace(/\s+/g, '-').toLowerCase()}`;
403
+ for (let i = 0; i < numOfItems; i++) {
404
+ const category = chance.pickone(productCategories);
405
+ const descriptor = chance.pickone(productDescriptors);
406
+ const suffixWord = chance.pickone(productSuffixes);
407
+ const slug = `${descriptor.replace(/\s+/g, "-").toLowerCase()}-${suffixWord.replace(/\s+/g, "-").toLowerCase()}`;
404
408
  const asset = chance.pickone(assetPreview);
405
409
 
406
- // const product_id = chance.guid();
407
410
  const price = integer(1, 100);
408
411
  const quantity = integer(1, 5);
409
412
 
410
- const item = {
411
- // product_id: product_id,
412
- // sku: integer(11111, 99999),
413
+ data.push({
413
414
  amount: price,
414
415
  quantity: quantity,
415
416
  total_value: price * quantity,
@@ -418,132 +419,303 @@ function makeProducts(maxItems = 5) {
418
419
  descriptor: descriptor,
419
420
  slug: slug,
420
421
  assetPreview: `https://example.com/assets/${slug}${asset}`,
421
- assetType: asset
422
-
423
- };
424
-
425
- data.push(item);
422
+ assetType: asset,
423
+ });
426
424
  }
427
425
 
428
426
  return () => [data];
429
427
  };
430
- };
428
+ }
429
+
430
+ function handleFunnelPreHooks(record, meta) {
431
+ // H9: Dark Theme Power Users — dark +30%, light -15%, custom unchanged
432
+ const isPurchaseFunnel = meta.funnel?.sequence?.includes("checkout");
433
+ if (isPurchaseFunnel) {
434
+ const theme = meta.profile?.theme;
435
+ if (theme === "dark") record.conversionRate = Math.min(95, Math.round(record.conversionRate * 1.3));
436
+ else if (theme === "light") record.conversionRate = Math.round(record.conversionRate * 0.85);
437
+ }
438
+ return record;
439
+ }
431
440
 
441
+ function handleEventHooks(record) {
442
+ // H5: Item flattening — promote first item's fields to top level
443
+ if (record.item && Array.isArray(record.item)) {
444
+ record = { ...record, ...record.item[0] };
445
+ delete record.item;
446
+ }
447
+
448
+ // H3: Toys + Shoes cart correlation
449
+ if (record.event === "checkout" && Array.isArray(record.cart)) {
450
+ const hasToys = record.cart.some(item => item.category === "toys");
451
+ const hasShoes = record.cart.some(item => item.category === "shoes");
452
+ if (hasToys && !hasShoes) {
453
+ const bigCart = makeProducts(20)()()[0];
454
+ const shoeItems = bigCart.filter(item => item.category === "shoes");
455
+ if (shoeItems.length > 0) record.cart.push(shoeItems[0]);
456
+ }
457
+ if (hasShoes && !hasToys) {
458
+ const bigCart = makeProducts(20)()()[0];
459
+ const toyItems = bigCart.filter(item => item.category === "toys");
460
+ if (toyItems.length > 0) record.cart.push(toyItems[0]);
461
+ }
462
+ if (!hasToys && !hasShoes) {
463
+ const cheapFactor = decimal(0.75, 0.9);
464
+ record.cart = record.cart.map(item => ({
465
+ ...item,
466
+ amount: Math.round(item.amount * cheapFactor),
467
+ total_value: Math.round(item.total_value * cheapFactor),
468
+ }));
469
+ }
470
+ }
432
471
 
433
- /** @type {import('../types.d.ts').Dungeon} */
472
+ // H4: Quality → watch time correlation
473
+ if (record.event === "watch video") {
474
+ const quality = record.quality || "480p";
475
+ const factor = QUALITY_FACTORS[quality] ?? 1.0;
476
+ record.watchTimeSec = Math.round(record.watchTimeSec * factor);
477
+ }
478
+
479
+ return record;
480
+ }
481
+
482
+ function handleEverythingHooks(record, meta) {
483
+ const profile = meta.profile;
484
+ record.forEach(e => {
485
+ e.theme = profile.theme;
486
+ });
487
+
488
+ const datasetEnd = dayjs.unix(meta.datasetEnd);
489
+ const DAY_SIGNUPS_IMPROVED = datasetEnd.subtract(SIGNUP_FIX_DAYS_AGO, "day");
490
+ const DAY_WATCH_TIME_WENT_UP = datasetEnd.subtract(WATCH_INFLECTION_DAYS_AGO, "day");
491
+
492
+ // H1: Signup flow v1 → v2; pre-fix v1 signups lose 50% to _drop
493
+ record.forEach(e => {
494
+ if (e.event !== "sign up") return;
495
+ const eventTime = dayjs(e.time);
496
+ e.signup_flow = "v1";
497
+ if (eventTime.isBefore(DAY_SIGNUPS_IMPROVED)) {
498
+ if (chance.bool({ likelihood: 50 })) e._drop = true;
499
+ }
500
+ if (eventTime.isAfter(DAY_SIGNUPS_IMPROVED)) {
501
+ e.signup_flow = "v2";
502
+ }
503
+ });
504
+
505
+ // H2: Watch time inflection — before: shrink, after: grow
506
+ record.forEach(e => {
507
+ if (e.event !== "watch video") return;
508
+ const eventTime = dayjs(e.time);
509
+ const factor = decimal(WATCH_FACTOR_MIN, WATCH_FACTOR_MAX);
510
+ if (eventTime.isBefore(DAY_WATCH_TIME_WENT_UP)) {
511
+ e.watchTimeSec = Math.round(e.watchTimeSec * (1 - factor));
512
+ }
513
+ if (eventTime.isAfter(DAY_WATCH_TIME_WENT_UP)) {
514
+ e.watchTimeSec = Math.round(e.watchTimeSec * (1 + factor));
515
+ }
516
+ });
517
+
518
+ // H6: View-item magic number — sweet spot (3-8) boost; over (9+) checkout drop
519
+ const viewItemCount = record.filter(e => e.event === "view item").length;
520
+ if (viewItemCount >= VIEW_SWEET_MIN && viewItemCount <= VIEW_SWEET_MAX) {
521
+ record.forEach(e => {
522
+ if (e.event === "checkout" && Array.isArray(e.cart)) {
523
+ e.cart = e.cart.map(it => ({
524
+ ...it,
525
+ amount: typeof it.amount === "number" ? Math.round(it.amount * SWEET_CART_BOOST) : it.amount,
526
+ total_value: typeof it.total_value === "number" ? Math.round(it.total_value * SWEET_CART_BOOST) : it.total_value,
527
+ }));
528
+ }
529
+ });
530
+ const addToCartEvents = record.filter(e => e.event === "add to cart");
531
+ const clones = [];
532
+ addToCartEvents.forEach(e => {
533
+ if (chance.bool({ likelihood: SWEET_CLONE_LIKELIHOOD })) {
534
+ const offsetMs = integer(60_000, 600_000);
535
+ clones.push({
536
+ ...e,
537
+ insert_id: uid(),
538
+ time: dayjs(e.time).add(offsetMs, "milliseconds").toISOString(),
539
+ });
540
+ }
541
+ });
542
+ if (clones.length > 0) record.push(...clones);
543
+ } else if (viewItemCount >= VIEW_OVER_THRESHOLD) {
544
+ for (let i = record.length - 1; i >= 0; i--) {
545
+ if (record[i].event === "checkout" && chance.bool({ likelihood: OVER_CHECKOUT_DROP_LIKELIHOOD })) {
546
+ record.splice(i, 1);
547
+ }
548
+ }
549
+ }
550
+
551
+ // H7: Signup-flow TTC scaled by loyalty tier (gold/plat fast, bronze slow)
552
+ {
553
+ let loyaltyTier = "silver";
554
+ const scdEntries = meta?.scd?.loyalty_tier;
555
+ if (Array.isArray(scdEntries) && scdEntries.length > 0) {
556
+ const latest = scdEntries.reduce((a, b) => (a.time > b.time ? a : b));
557
+ loyaltyTier = latest.loyalty_tier || "silver";
558
+ } else {
559
+ const uidStr = record[0]?.user_id || "";
560
+ const hash = uidStr.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
561
+ const bucket = hash % 100;
562
+ loyaltyTier = bucket < 20 ? "gold" : bucket < 50 ? "silver" : "bronze";
563
+ }
564
+ const ttcFactor = (
565
+ loyaltyTier === "gold" || loyaltyTier === "platinum" ? LOYALTY_TTC_FAST :
566
+ loyaltyTier === "bronze" ? LOYALTY_TTC_SLOW :
567
+ 1.0
568
+ );
569
+ if (ttcFactor !== 1.0) {
570
+ const funnelSteps = new Set(["page view", "view item", "save item", "sign up"]);
571
+ const sorted = record.slice().sort((a, b) => new Date(a.time) - new Date(b.time));
572
+ const signupEvent = sorted.find(e => e.event === "sign up");
573
+ if (signupEvent) {
574
+ const signupMs = new Date(signupEvent.time).getTime();
575
+ const windowMs = LOYALTY_LOOKBACK_DAYS * 24 * 60 * 60 * 1000;
576
+ const funnelEvents = sorted.filter(e =>
577
+ funnelSteps.has(e.event) &&
578
+ new Date(e.time).getTime() >= signupMs - windowMs &&
579
+ new Date(e.time).getTime() <= signupMs
580
+ );
581
+ if (funnelEvents.length >= 2) scaleFunnelTTC(funnelEvents, ttcFactor);
582
+ }
583
+ }
584
+ }
585
+
586
+ // H10: Save-item retention — born-in users below threshold churn after day 25
587
+ if (meta.userIsBornInDataset) {
588
+ const firstT = record[0]?.time;
589
+ if (firstT) {
590
+ const window10 = dayjs(firstT).add(SAVE_RETENTION_WINDOW_DAYS, "days").toISOString();
591
+ const saves = record.filter(e => e.event === "save item" && e.time <= window10).length;
592
+ if (saves < SAVE_RETENTION_MIN) {
593
+ const cutoff = dayjs(firstT).add(SAVE_RETENTION_CUTOFF_DAYS, "days");
594
+ for (let i = record.length - 1; i >= 0; i--) {
595
+ if (dayjs(record[i].time).isAfter(cutoff) && chance.bool({ likelihood: SAVE_RETENTION_DROP_LIKELIHOOD })) {
596
+ record.splice(i, 1);
597
+ }
598
+ }
599
+ }
600
+ }
601
+ }
602
+
603
+ return record.filter(e => !e._drop);
604
+ }
605
+
606
+ // ── CONFIG ──
607
+ /** @type {Config} */
434
608
  const config = {
435
609
  version: 2,
436
- token,
437
610
  seed: SEED,
438
- datasetStart: "2026-01-01T00:00:00Z",
439
- datasetEnd: "2026-05-01T23:59:59Z",
440
- // numDays: num_days,
441
- avgEventsPerUserPerDay: avg_events_per_user_per_day,
442
- numUsers: num_users,
443
- format: 'json', //csv or json
444
- region: "US",
445
- hasAnonIds: true,
446
- avgDevicePerUser: 2,
447
- hasSessionIds: true,
448
- hasAdSpend: false,
449
- hasLocation: true,
450
- hasAndroidDevices: true,
451
- hasIOSDevices: true,
452
- hasDesktopDevices: true,
453
- hasBrowser: true,
454
- hasCampaigns: false,
455
- isAnonymous: false,
456
- alsoInferFunnels: false,
611
+ datasetStart: DATASET_START,
612
+ datasetEnd: DATASET_END,
613
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
614
+ numUsers: NUM_USERS,
615
+ format: "json",
616
+ credentials: {
617
+ token,
618
+ region: "US",
619
+ },
620
+ switches: {
621
+ hasSessionIds: true,
622
+ hasAdSpend: false,
623
+ hasLocation: true,
624
+ hasAndroidDevices: true,
625
+ hasIOSDevices: true,
626
+ hasDesktopDevices: true,
627
+ hasBrowser: true,
628
+ hasCampaigns: false,
629
+ isAnonymous: false,
630
+ alsoInferFunnels: false,
631
+ },
632
+ identity: {
633
+ avgDevicePerUser: 2,
634
+ },
457
635
  concurrency: 1,
458
636
  events: [
459
637
  {
460
638
  event: "checkout",
461
639
  weight: 2,
640
+ isStrictEvent: false,
462
641
  properties: {
463
642
  currency: ["USD", "CAD", "EUR", "BTC", "ETH", "JPY"],
464
643
  coupon: weighChoices(["none", "none", "none", "none", "10%OFF", "20%OFF", "10%OFF", "20%OFF", "30%OFF", "40%OFF", "50%OFF"]),
465
644
  cart: makeProducts(),
466
- }
645
+ },
467
646
  },
468
647
  {
469
648
  event: "add to cart",
470
649
  weight: 4,
650
+ isStrictEvent: false,
471
651
  properties: {
472
652
  item: makeProducts(1),
473
653
  amount: weighNumRange(1, 100, 0.3),
474
654
  quantity: weighNumRange(1, 5, 0.3),
475
655
  total_value: weighNumRange(1, 500, 0.3),
476
656
  featured: [true, false, false],
477
- category: ["electronics", "books", "clothing", "home", "garden", "toys", "sports", "automotive", "beauty", "health", "grocery", "jewelry", "shoes", "tools", "office supplies"],
478
- descriptor: ["brand new", "open box", "refurbished", "used", "like new", "vintage", "antique", "collectible"],
657
+ category: productCategories,
658
+ descriptor: productDescriptors,
479
659
  slug: ["item"],
480
660
  assetPreview: ["https://example.com/assets/item.png"],
481
- assetType: [".png", ".jpg", ".jpeg", ".heic", ".mp4", ".mov", ".avi"],
482
- }
661
+ assetType: assetPreview,
662
+ },
483
663
  },
484
664
  {
485
665
  event: "view item",
486
666
  weight: 8,
667
+ isStrictEvent: false,
487
668
  properties: {
488
669
  item: makeProducts(1),
489
670
  amount: weighNumRange(1, 100, 0.3),
490
671
  quantity: weighNumRange(1, 5, 0.3),
491
672
  total_value: weighNumRange(1, 500, 0.3),
492
673
  featured: [true, false, false],
493
- category: ["electronics", "books", "clothing", "home", "garden", "toys", "sports", "automotive", "beauty", "health", "grocery", "jewelry", "shoes", "tools", "office supplies"],
494
- descriptor: ["brand new", "open box", "refurbished", "used", "like new", "vintage", "antique", "collectible"],
674
+ category: productCategories,
675
+ descriptor: productDescriptors,
495
676
  slug: ["item"],
496
677
  assetPreview: ["https://example.com/assets/item.png"],
497
- assetType: [".png", ".jpg", ".jpeg", ".heic", ".mp4", ".mov", ".avi"],
498
- }
678
+ assetType: assetPreview,
679
+ },
499
680
  },
500
681
  {
501
682
  event: "save item",
502
683
  weight: 5,
684
+ isStrictEvent: false,
503
685
  properties: {
504
686
  item: makeProducts(1),
505
687
  amount: weighNumRange(1, 100, 0.3),
506
688
  quantity: weighNumRange(1, 5, 0.3),
507
689
  total_value: weighNumRange(1, 500, 0.3),
508
690
  featured: [true, false, false],
509
- category: ["electronics", "books", "clothing", "home", "garden", "toys", "sports", "automotive", "beauty", "health", "grocery", "jewelry", "shoes", "tools", "office supplies"],
510
- descriptor: ["brand new", "open box", "refurbished", "used", "like new", "vintage", "antique", "collectible"],
691
+ category: productCategories,
692
+ descriptor: productDescriptors,
511
693
  slug: ["item"],
512
694
  assetPreview: ["https://example.com/assets/item.png"],
513
- assetType: [".png", ".jpg", ".jpeg", ".heic", ".mp4", ".mov", ".avi"],
514
- }
695
+ assetType: assetPreview,
696
+ },
515
697
  },
516
698
  {
517
699
  event: "page view",
518
700
  weight: 10,
701
+ isStrictEvent: false,
519
702
  properties: {
520
703
  page: ["/", "/help", "/account", "/watch", "/listen", "/product", "/people", "/peace"],
521
- }
704
+ },
522
705
  },
523
706
  {
524
707
  event: "watch video",
525
708
  weight: 8,
709
+ isStrictEvent: false,
526
710
  properties: {
527
- watchTimeSec: weighNumRange(10, 600, .25),
711
+ watchTimeSec: weighNumRange(10, 600, 0.25),
528
712
  quality: ["2160p", "1440p", "1080p", "720p", "480p", "360p", "240p"],
529
713
  format: ["mp4", "avi", "mov", "mpg"],
530
714
  uploader_id: chance.guid.bind(chance),
531
- }
532
- },
533
- {
534
- event: "like video",
535
- weight: 6,
536
- properties: {
537
-
538
- }
539
- },
540
- {
541
- event: "dislike video",
542
- weight: 4,
543
- properties: {
544
-
545
- }
715
+ },
546
716
  },
717
+ { event: "like video", weight: 6, properties: {} },
718
+ { event: "dislike video", weight: 4, properties: {} },
547
719
  {
548
720
  event: "sign up",
549
721
  weight: 1,
@@ -553,9 +725,8 @@ const config = {
553
725
  signupMethod: ["email", "google", "facebook", "twitter", "linkedin", "github"],
554
726
  referral: weighChoices(["none", "none", "none", "friend", "ad", "ad", "ad", "friend", "friend", "friend", "friend"]),
555
727
  signup_flow: ["v1"],
556
- }
728
+ },
557
729
  },
558
-
559
730
  ],
560
731
  funnels: [
561
732
  {
@@ -566,8 +737,7 @@ const config = {
566
737
  isFirstFunnel: true,
567
738
  timeToConvert: 2,
568
739
  experiment: false,
569
- name: "Signup Flow"
570
-
740
+ name: "Signup Flow",
571
741
  },
572
742
  {
573
743
  sequence: ["watch video", "like video", "watch video", "like video"],
@@ -577,8 +747,8 @@ const config = {
577
747
  videoCategory: videoCategories,
578
748
  quality: ["2160p", "1440p", "1080p", "720p", "480p", "360p", "240p"],
579
749
  format: ["mp4", "avi", "mov", "mpg"],
580
- uploader_id: chance.guid.bind(chance)
581
- }
750
+ uploader_id: chance.guid.bind(chance),
751
+ },
582
752
  },
583
753
  {
584
754
  name: "Video Dislikes",
@@ -588,8 +758,8 @@ const config = {
588
758
  videoCategory: videoCategories,
589
759
  quality: ["2160p", "1440p", "1080p", "720p", "480p", "360p", "240p"],
590
760
  format: ["mp4", "avi", "mov", "mpg"],
591
- uploader_id: chance.guid.bind(chance)
592
- }
761
+ uploader_id: chance.guid.bind(chance),
762
+ },
593
763
  },
594
764
  {
595
765
  name: "eCommerce Purchase",
@@ -607,19 +777,14 @@ const config = {
607
777
  ],
608
778
  startDaysBeforeEnd: 30,
609
779
  },
610
- }
611
-
780
+ },
612
781
  ],
613
782
  superProps: {
614
783
  theme: ["light", "dark", "custom", "light", "dark"],
615
784
  },
616
- /*
617
- user properties work the same as event properties
618
- each key should be an array or function reference
619
- */
620
785
  userProps: {
621
786
  title: chance.profession.bind(chance),
622
- luckyNumber: weighNumRange(42, 420, .3),
787
+ luckyNumber: weighNumRange(42, 420, 0.3),
623
788
  spiritAnimal: spiritAnimals,
624
789
  theme: ["light", "dark", "custom", "light", "dark"],
625
790
  },
@@ -628,256 +793,19 @@ const config = {
628
793
  values: ["bronze", "silver", "gold", "platinum"],
629
794
  frequency: "month",
630
795
  timing: "fuzzy",
631
- max: 8
632
- }
796
+ max: 8,
797
+ },
633
798
  },
634
799
  mirrorProps: {},
635
-
636
- /*
637
- for group analytics keys, we need an array of arrays [[],[],[]]
638
- each pair represents a group_key and the number of profiles for that key
639
- */
640
800
  groupKeys: [],
641
801
  groupProps: {},
642
802
  lookupTables: [],
643
- hook: function (record, type, meta) {
644
-
645
- // H9: Dark Theme Power Users (funnel-pre)
646
- // Dark-theme users convert 1.3x better on the purchase funnel;
647
- // light-theme users convert 0.85x. Custom-theme is unchanged.
648
- if (type === "funnel-pre") {
649
- const isPurchaseFunnel = meta.funnel?.sequence?.includes("checkout");
650
- if (isPurchaseFunnel) {
651
- const theme = meta.profile?.theme;
652
- if (theme === "dark") record.conversionRate = Math.min(95, Math.round(record.conversionRate * 1.3));
653
- else if (theme === "light") record.conversionRate = Math.round(record.conversionRate * 0.85);
654
- }
655
- }
656
-
657
- if (type === "event") {
658
- // unflattering 'items'
659
- if (record.item && Array.isArray(record.item)) {
660
- record = { ...record, ...record.item[0] };
661
- delete record.item;
662
- }
663
-
664
- // toys + shoes frequently purchases together (and are higher cart values)
665
- if (record.event === 'checkout' && Array.isArray(record.cart)) {
666
- const hasToys = record.cart.some(item => item.category === 'toys');
667
- const hasShoes = record.cart.some(item => item.category === 'shoes');
668
- if (hasToys && !hasShoes) {
669
- const bigCart = makeProducts(20)()()[0];
670
- const shoeItems = bigCart.filter(item => item.category === 'shoes');
671
- if (shoeItems.length > 0) {
672
- record.cart.push(shoeItems[0]);
673
- }
674
- }
675
-
676
- if (hasShoes && !hasToys) {
677
- const bigCart = makeProducts(20)()()[0];
678
- const toyItems = bigCart.filter(item => item.category === 'toys');
679
- if (toyItems.length > 0) {
680
- record.cart.push(toyItems[0]);
681
- }
682
- }
683
-
684
- if (!hasToys && !hasShoes) {
685
- const cheapFactor = decimal(.75, 0.9);
686
- // make every item a bit cheaper
687
- record.cart = record.cart.map(item => {
688
- return {
689
- ...item,
690
- amount: Math.round(item.amount * cheapFactor),
691
- total_value: Math.round(item.total_value * cheapFactor)
692
- };
693
- });
694
- }
695
- }
696
- // high quality video means longer watch times (lower quality shorter watch times)
697
- if (record.event === 'watch video') {
698
- const qualityFactors = {
699
- "2160p": 1.5,
700
- "1440p": 1.4,
701
- "1080p": 1.3,
702
- "720p": 1.15,
703
- "480p": 1.0,
704
- "360p": 0.85,
705
- "240p": 0.7
706
- };
707
- const quality = record.quality || "480p";
708
- const factor = qualityFactors[quality] || 1.0;
709
- record.watchTimeSec = Math.round(record.watchTimeSec * factor);
710
- }
711
-
712
-
713
-
714
- }
715
-
716
-
717
-
718
- if (type === "everything") {
719
- // Stamp superProps from profile for consistency
720
- const profile = meta.profile;
721
- record.forEach(e => {
722
- e.theme = profile.theme;
723
- });
724
-
725
- const datasetEnd = dayjs.unix(meta.datasetEnd);
726
- const DAY_SIGNUPS_IMPROVED = datasetEnd.subtract(7, 'day');
727
- const DAY_WATCH_TIME_WENT_UP = datasetEnd.subtract(30, 'day');
728
-
729
- // Hook 1: Signup flow improvement — pre-fix signups (v1) get 50% dropped
730
- record.forEach(e => {
731
- if (e.event !== 'sign up') return;
732
- const eventTime = dayjs(e.time);
733
- e.signup_flow = "v1";
734
- if (eventTime.isBefore(DAY_SIGNUPS_IMPROVED)) {
735
- if (chance.bool({ likelihood: 50 })) {
736
- e._drop = true;
737
- }
738
- }
739
- if (eventTime.isAfter(DAY_SIGNUPS_IMPROVED)) {
740
- e.signup_flow = "v2";
741
- }
742
- });
743
-
744
- // Hook 2: Watch time inflection — before 30 days ago: reduce, after: increase
745
- record.forEach(e => {
746
- if (e.event !== 'watch video') return;
747
- const eventTime = dayjs(e.time);
748
- const factor = decimal(0.25, 0.79);
749
- if (eventTime.isBefore(DAY_WATCH_TIME_WENT_UP)) {
750
- e.watchTimeSec = Math.round(e.watchTimeSec * (1 - factor));
751
- }
752
- if (eventTime.isAfter(DAY_WATCH_TIME_WENT_UP)) {
753
- e.watchTimeSec = Math.round(e.watchTimeSec * (1 + factor));
754
- }
755
- });
756
-
757
- // Hook 6: View-Item Magic Number (behavioral, no flags)
758
- // Users in 3-8 view-item sweet spot show ~25% higher cart amounts
759
- // AND elevated add-to-cart rate (~45% boost via cloned events).
760
- // Users >=9 are over-engaged window-shoppers; ~30% of their checkouts drop.
761
- const viewItemCount = record.filter(e => e.event === 'view item').length;
762
- if (viewItemCount >= 3 && viewItemCount <= 8) {
763
- record.forEach(e => {
764
- if (e.event === 'checkout' && Array.isArray(e.cart)) {
765
- e.cart = e.cart.map(it => ({
766
- ...it,
767
- amount: typeof it.amount === 'number' ? Math.round(it.amount * 1.25) : it.amount,
768
- total_value: typeof it.total_value === 'number' ? Math.round(it.total_value * 1.25) : it.total_value
769
- }));
770
- }
771
- });
772
- // Clone ~45% of add-to-cart events to boost cart add rate
773
- const addToCartEvents = record.filter(e => e.event === 'add to cart');
774
- const clones = [];
775
- addToCartEvents.forEach(e => {
776
- if (chance.bool({ likelihood: 45 })) {
777
- const offsetMs = integer(60_000, 600_000); // 1-10 min offset
778
- clones.push({
779
- ...e,
780
- insert_id: uid(),
781
- time: dayjs(e.time).add(offsetMs, 'milliseconds').toISOString()
782
- });
783
- }
784
- });
785
- if (clones.length > 0) record.push(...clones);
786
- } else if (viewItemCount >= 9) {
787
- for (let i = record.length - 1; i >= 0; i--) {
788
- if (record[i].event === 'checkout' && chance.bool({ likelihood: 30 })) {
789
- record.splice(i, 1);
790
- }
791
- }
792
- }
793
-
794
- // Hook 7 (TTC): SIGNUP FLOW TIME-TO-CONVERT (everything)
795
- // Gold/platinum loyalty users complete the Signup Flow funnel
796
- // 0.67x faster; bronze users 1.33x slower. Loyalty tier is read
797
- // from SCD data (meta.scd.loyalty_tier); falls back to a
798
- // deterministic hash of user_id for consistent assignment.
799
- //
800
- // The funnel uses order:"first-and-last-fixed" so middle steps
801
- // can reorder. We anchor on the first "sign up" event, look back
802
- // up to 2 days for all funnel-step events in that window, and
803
- // scale the whole cluster via scaleFunnelTTC.
804
- {
805
- // Determine loyalty tier from SCD (latest entry) or hash fallback
806
- let loyaltyTier = "silver"; // default baseline
807
- const scdEntries = meta?.scd?.loyalty_tier;
808
- if (Array.isArray(scdEntries) && scdEntries.length > 0) {
809
- // Pick the latest SCD entry
810
- const latest = scdEntries.reduce((a, b) =>
811
- (a.time > b.time ? a : b));
812
- loyaltyTier = latest.loyalty_tier || "silver";
813
- } else {
814
- // Deterministic hash fallback: gold ~20%, silver ~30%, bronze ~50%
815
- const uid = record[0]?.user_id || "";
816
- const hash = uid.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
817
- const bucket = hash % 100;
818
- loyaltyTier = bucket < 20 ? "gold" : bucket < 50 ? "silver" : "bronze";
819
- }
820
-
821
- const ttcFactor = (
822
- loyaltyTier === "gold" || loyaltyTier === "platinum" ? 0.67 :
823
- loyaltyTier === "bronze" ? 1.33 :
824
- 1.0
825
- );
826
- if (ttcFactor !== 1.0) {
827
- // Find signup funnel: anchor on first "sign up", look back 2 days
828
- // for the cluster of funnel-step events
829
- const funnelSteps = new Set(["page view", "view item", "save item", "sign up"]);
830
- const sorted = record.slice().sort((a, b) =>
831
- new Date(a.time) - new Date(b.time));
832
- const signupEvent = sorted.find(e => e.event === "sign up");
833
- if (signupEvent) {
834
- const signupMs = new Date(signupEvent.time).getTime();
835
- const windowMs = 2 * 24 * 60 * 60 * 1000; // 2-day lookback
836
- const funnelEvents = sorted.filter(e =>
837
- funnelSteps.has(e.event) &&
838
- new Date(e.time).getTime() >= signupMs - windowMs &&
839
- new Date(e.time).getTime() <= signupMs
840
- );
841
- if (funnelEvents.length >= 2) {
842
- scaleFunnelTTC(funnelEvents, ttcFactor);
843
- }
844
- }
845
- }
846
- }
847
-
848
- }
849
-
850
- // H10: Save-Item Retention (everything — retention magic number)
851
- // Born-in-dataset users with 2+ "save item" in their first 10 days
852
- // retain; users below the threshold lose ~70% of events after day 25.
853
- if (type === "everything" && meta.userIsBornInDataset) {
854
- const firstT = record[0]?.time;
855
- if (firstT) {
856
- const window10 = dayjs(firstT).add(10, 'days').toISOString();
857
- const saves = record.filter(e => e.event === 'save item' && e.time <= window10).length;
858
- if (saves < 2) {
859
- const cutoff = dayjs(firstT).add(25, 'days');
860
- for (let i = record.length - 1; i >= 0; i--) {
861
- if (dayjs(record[i].time).isAfter(cutoff) && chance.bool({ likelihood: 70 })) {
862
- record.splice(i, 1);
863
- }
864
- }
865
- }
866
- }
867
- }
868
-
869
- // Filter out events tagged for removal in the event hook
870
- if (type === "everything") {
871
- record = record.filter(e => !e._drop);
872
- }
873
-
803
+ hook(record, type, meta) {
804
+ if (type === "funnel-pre") return handleFunnelPreHooks(record, meta);
805
+ if (type === "event") return handleEventHooks(record);
806
+ if (type === "everything") return handleEverythingHooks(record, meta);
874
807
  return record;
875
- }
808
+ },
876
809
  };
877
810
 
878
- function flip(likelihood = 50) {
879
- return chance.bool({ likelihood });
880
- }
881
-
882
-
883
811
  export default config;