@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
@@ -1,67 +1,49 @@
1
- // ── TWEAK THESE ──
2
- const SEED = "dm4-marketplace";
3
- const num_days = 120;
4
- const num_users = 10_000;
5
- const avg_events_per_user_per_day = 1.2;
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";
4
+ dayjs.extend(utc);
13
5
  import "dotenv/config";
14
6
  import * as u from "../../lib/utils/utils.js";
15
7
  import * as v from "ak-tools";
16
-
17
- dayjs.extend(utc);
18
- const chance = u.initChance(SEED);
19
8
  /** @typedef {import("../../types").Dungeon} Config */
20
9
 
21
- // Generate consistent seller store and listing IDs at module level
22
- const storeIds = v.range(1, 150).map(() => `STORE_${v.uid(6)}`);
23
- const listingIds = v.range(1, 500).map(() => `LST_${v.uid(8)}`);
24
-
25
- /**
26
- * ═══════════════════════════════════════════════════════════════
27
- * DATASET OVERVIEW
28
- * ═══════════════════════════════════════════════════════════════
29
- *
30
- * TradeNest a two-sided marketplace connecting sellers who list
31
- * products with buyers who search, purchase, and review items.
32
- *
33
- * - 5,000 users over 100 days, ~600K events
34
- * - Two-sided: sellers (power, casual, new) and buyers (frequent, window_shopper)
35
- * - Core loop: search view add to cart purchase review
36
- * - Seller loop: create listing receive offers accept ship
37
- * - Revenue: marketplace fees on transactions, seller listing fees
38
- *
39
- * Advanced Features:
40
- * - Personas: 5 archetypes spanning both buyer and seller roles
41
- * - World Events: marketplace fee change (day 45, permanent), viral TikTok moment (day 55, 3 days)
42
- * - Engagement Decay: exponential with 75-day half-life, 0.1 floor
43
- * - Attribution: Google Shopping, TikTok, Seller Referral, organic
44
- * - Geo: US/UK/Australia/Canada with local currencies
45
- * - Anomalies: whale purchases, viral signup burst
46
- *
47
- * Key entities:
48
- * - store_id: unique seller storefront
49
- * - listing_id: individual product listing
50
- * - category: product vertical (electronics, clothing, etc.)
51
- * - user_type: buyer vs seller role
10
+ // ── OVERVIEW ──
11
+ /*
12
+ * NAME: TradeNest
13
+ * APP: Two-sided marketplace connecting sellers who list products with
14
+ * buyers who search, purchase, and review. Sellers list items;
15
+ * buyers browse, message, negotiate, and buy. Revenue from
16
+ * marketplace fees and listing fees.
17
+ * SCALE: 10,000 users, ~1.4M events, 121 days (2026-01-01 → 2026-05-01)
18
+ * CORE LOOP: search → view → add to cart → purchase → review
19
+ * (seller side: create listing receive offers accept → ship)
20
+ *
21
+ * EVENTS (18):
22
+ * item searched (8) > app session (8) > item viewed (7) > notification received (6)
23
+ * > add to cart (5) > message sent (5) > listing created (4) > purchase completed (3)
24
+ * > listing updated (3) > offer received (3) > shipping updated (3) > offer accepted (2)
25
+ * > review submitted (2) > seller rated (2) > profile updated (2) > account created (1)
26
+ * > refund requested (1) > account deactivated (1)
27
+ *
28
+ * FUNNELS (5):
29
+ * - Buyer Onboarding: account created item searched item viewed → add to cart (40%)
30
+ * - Browse to Purchase: item searched item viewed add to cart purchase completed (30%)
31
+ * - Seller Listing Flow: listing created listing updated offer received → offer accepted (25%)
32
+ * - Offer Negotiation: offer received message sent offer accepted (35%)
33
+ * - Review After Purchase: purchase completed shipping updated → review submitted (20%)
34
+ *
35
+ * USER PROPS: user_type, segment, seller_rating, total_transactions, response_time_hours,
36
+ * store_name, Platform, category
37
+ * SUPER PROPS: Platform, category
38
+ * SCD PROPS: seller_tier (new/verified/power/featured, monthly fuzzy, max 8)
39
+ * GROUPS: none
52
40
  */
53
41
 
54
- /**
55
- * ═══════════════════════════════════════════════════════════════
56
- * ANALYTICS HOOKS (10 hooks)
57
- *
42
+ // ── HOOK STORIES ──
43
+ /*
58
44
  * NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable via
59
45
  * raw-prop breakdowns (segment, day, category) or behavioral cohorts.
60
46
  *
61
- * Adds 9. BROWSE TO PURCHASE TIME-TO-CONVERT and 10. MESSAGE-COUNT MAGIC
62
- * NUMBER on top of original 8.
63
- * ═══════════════════════════════════════════════════════════════
64
- *
65
47
  * ───────────────────────────────────────────────────────────────
66
48
  * 1. FEE CHANGE IMPACT (everything hook)
67
49
  * ───────────────────────────────────────────────────────────────
@@ -238,6 +220,7 @@ const listingIds = v.range(1, 500).map(() => `LST_${v.uid(8)}`);
238
220
  *
239
221
  * ───────────────────────────────────────────────────────────────
240
222
  * 9. BROWSE TO PURCHASE TIME-TO-CONVERT (funnel-post)
223
+ * ───────────────────────────────────────────────────────────────
241
224
  *
242
225
  * PATTERN: Power_seller and frequent_buyer users complete the
243
226
  * Browse to Purchase funnel 1.4x faster (factor 0.71); window_shopper
@@ -258,6 +241,7 @@ const listingIds = v.range(1, 500).map(() => `LST_${v.uid(8)}`);
258
241
  *
259
242
  * ───────────────────────────────────────────────────────────────
260
243
  * 10. MESSAGE-COUNT MAGIC NUMBER (in-funnel, everything)
244
+ * ───────────────────────────────────────────────────────────────
261
245
  *
262
246
  * PATTERN: Sweet 2-5 message-sent events between item-viewed and
263
247
  * offer-received → +35% on offer_amount of offer-received events.
@@ -302,31 +286,305 @@ const listingIds = v.range(1, 500).map(() => `LST_${v.uid(8)}`);
302
286
  * Message Magic Number | over purchases/user | 1x | 0.75x | -25%
303
287
  */
304
288
 
289
+ // ── SCALE ──
290
+ const SEED = "dm4-marketplace";
291
+ const NUM_USERS = 10_000;
292
+ const DATASET_START = "2026-01-01T00:00:00Z";
293
+ const DATASET_END = "2026-05-01T23:59:59Z";
294
+ const EVENTS_PER_DAY = 1.2;
295
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
296
+
297
+ const chance = u.initChance(SEED);
298
+
299
+ // ── KNOBS (tweak these to reshape stories) ──
300
+ const FEE_CHANGE_DAY = 45;
301
+ const FEE_CHANGE_MULT = 1.3;
302
+
303
+ const WEEKEND_SPEND_MULT = 1.2;
304
+
305
+ const POWER_SELLER_CLONE_LIKELIHOOD = 65;
306
+
307
+ const ELECTRONICS_CLONE_LIKELIHOOD = 40;
308
+ const ELECTRONICS_MAX_CLONES = 3;
309
+
310
+ const FAST_RESPONDER_HASH_MOD = 5;
311
+ const FAST_RESPONDER_HASH_THRESHOLD = 2;
312
+ const FAST_OFFER_CLONE_COUNT = 2;
313
+ const FAST_OFFER_FROM_RECEIVED_LIKELIHOOD = 60;
314
+ const SLOW_OFFER_DROP_LIKELIHOOD = 60;
315
+
316
+ const NEW_SELLER_CUTOFF_DAYS = 14;
317
+ const NEW_SELLER_DROP_LIKELIHOOD = 50;
318
+
319
+ const FREQUENT_BUYER_DROP_LIKELIHOOD = 25;
320
+
321
+ const TTC_FAST_FACTOR = 0.71;
322
+ const TTC_SLOW_FACTOR = 1.4;
323
+
324
+ const MSG_SWEET_MIN = 2;
325
+ const MSG_SWEET_MAX = 5;
326
+ const MSG_OVER_THRESHOLD = 6;
327
+ const MSG_OFFER_BOOST = 1.35;
328
+ const MSG_PURCHASE_DROP_LIKELIHOOD = 25;
329
+
330
+ // ── DATA ARRAYS ──
331
+ // Generate consistent seller store and listing IDs at module level
332
+ const storeIds = v.range(1, 150).map(() => `STORE_${v.uid(6)}`);
333
+ const listingIds = v.range(1, 500).map(() => `LST_${v.uid(8)}`);
334
+
335
+ // ── HELPER FUNCTIONS ──
336
+ function handleUserHooks(record) {
337
+ // H7: Power seller profiles — enrich segment-tagged profiles
338
+ if (record.segment === "power_seller") {
339
+ record.total_transactions = chance.integer({ min: 100, max: 500 });
340
+ record.seller_rating = chance.floating({ min: 4.5, max: 5.0, fixed: 1 });
341
+ record.store_name = chance.pickone([
342
+ "TechVault Pro", "StyleHaven", "GearFactory", "PrimeFinds",
343
+ "TopShelf Goods", "EliteDeals", "QualityFirst Store", "BestBuy Resale",
344
+ ]);
345
+ } else if (record.segment === "casual_seller") {
346
+ record.total_transactions = chance.integer({ min: 5, max: 50 });
347
+ record.seller_rating = chance.floating({ min: 3.0, max: 4.5, fixed: 1 });
348
+ record.store_name = chance.pickone([
349
+ "My Garage Sale", "Closet Cleanout", "Random Finds", "Weekend Seller",
350
+ ]);
351
+ } else if (record.segment === "new_seller") {
352
+ record.total_transactions = chance.integer({ min: 0, max: 3 });
353
+ record.seller_rating = 0;
354
+ record.store_name = "New Store";
355
+ }
356
+ return record;
357
+ }
358
+
359
+ function handleFunnelPostHooks(record, meta) {
360
+ // H9: Browse-to-purchase TTC scaled by segment
361
+ const segment = meta?.profile?.segment;
362
+ if (Array.isArray(record) && record.length > 1) {
363
+ const factor = (
364
+ segment === "power_seller" || segment === "frequent_buyer" ? TTC_FAST_FACTOR :
365
+ segment === "window_shopper" ? TTC_SLOW_FACTOR :
366
+ 1.0
367
+ );
368
+ if (factor !== 1.0) {
369
+ for (let i = 1; i < record.length; i++) {
370
+ const prev = dayjs(record[i - 1].time);
371
+ const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
372
+ record[i].time = prev.add(newGap, "milliseconds").toISOString();
373
+ }
374
+ }
375
+ }
376
+ return record;
377
+ }
378
+
379
+ function handleEverythingHooks(record, meta) {
380
+ const datasetStart = dayjs.unix(meta.datasetStart);
381
+ const FEE_CHANGE_CUTOFF = datasetStart.add(FEE_CHANGE_DAY, "days");
382
+ let events = record;
383
+ if (!events.length) return record;
384
+
385
+ const profile = meta.profile;
386
+
387
+ // Stamp superProps from profile so they are consistent per user.
388
+ if (profile) {
389
+ events.forEach(e => {
390
+ if (profile.Platform) e.Platform = profile.Platform;
391
+ if (profile.category) e.category = profile.category;
392
+ });
393
+ }
394
+
395
+ // H1: Fee change impact — listings after d45 get listing_fee 1.3x.
396
+ // In everything hook so timestamp comparison sees post-bunchIntoSessions times.
397
+ events.forEach(e => {
398
+ if (e.event === "listing created" && dayjs(e.time).isAfter(FEE_CHANGE_CUTOFF)) {
399
+ e.listing_fee = Math.floor((e.listing_fee || 15) * FEE_CHANGE_MULT);
400
+ }
401
+ });
402
+
403
+ // H8: Frequent buyer conversion filter — non-frequent-buyer users
404
+ // drop ~25% of "purchase completed" events.
405
+ if (profile && profile.segment !== "frequent_buyer") {
406
+ record = record.filter(e => {
407
+ if (e.event === "purchase completed" && chance.bool({ likelihood: FREQUENT_BUYER_DROP_LIKELIHOOD })) return false;
408
+ return true;
409
+ });
410
+ events = record;
411
+ }
412
+
413
+ // H3: Seller success → buyer trust — power sellers get 2x purchase events cloned.
414
+ if (profile && profile.segment === "power_seller") {
415
+ const purchases = events.filter(e => e.event === "purchase completed");
416
+ const templatePurchase = purchases[0];
417
+ if (templatePurchase && purchases.length > 0) {
418
+ purchases.forEach(p => {
419
+ if (chance.bool({ likelihood: POWER_SELLER_CLONE_LIKELIHOOD })) {
420
+ events.push({
421
+ ...templatePurchase,
422
+ time: dayjs(p.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
423
+ user_id: p.user_id,
424
+ total_amount: chance.integer({ min: 15, max: 400 }),
425
+ item_count: chance.integer({ min: 1, max: 4 }),
426
+ });
427
+ }
428
+ });
429
+ }
430
+ }
431
+
432
+ // H4: Search-to-purchase by category — electronics gets cloned purchase events.
433
+ const hasElectronicsSearch = events.some(e =>
434
+ e.event === "item searched" && e.category === "electronics"
435
+ );
436
+ if (hasElectronicsSearch) {
437
+ const templatePurchase = events.find(e => e.event === "purchase completed");
438
+ if (templatePurchase) {
439
+ const electronicsSearches = events.filter(e =>
440
+ e.event === "item searched" && e.category === "electronics"
441
+ );
442
+ electronicsSearches.slice(0, ELECTRONICS_MAX_CLONES).forEach(search => {
443
+ if (chance.bool({ likelihood: ELECTRONICS_CLONE_LIKELIHOOD })) {
444
+ events.push({
445
+ ...templatePurchase,
446
+ time: dayjs(search.time).add(chance.integer({ min: 1, max: 12 }), "hours").toISOString(),
447
+ user_id: search.user_id,
448
+ total_amount: chance.integer({ min: 50, max: 300 }),
449
+ category: "electronics",
450
+ });
451
+ }
452
+ });
453
+ }
454
+ }
455
+
456
+ // H5: Response time → conversion — deterministic fast/slow cohorts via user hash.
457
+ // Fast (~40%): set response_time_hours 1-4, clone offer_accepted.
458
+ // Slow (~60%): set 8-36, drop 60% of offer_accepted events.
459
+ const msgEvents = events.filter(e => e.event === "message sent");
460
+ if (msgEvents.length > 0) {
461
+ const uid = msgEvents[0].user_id || "";
462
+ const isFast = (uid.charCodeAt(0) + uid.charCodeAt(uid.length - 1)) % FAST_RESPONDER_HASH_MOD < FAST_RESPONDER_HASH_THRESHOLD;
463
+ msgEvents.forEach(m => {
464
+ m.response_time_hours = isFast
465
+ ? chance.floating({ min: 0.5, max: 4, fixed: 1 })
466
+ : chance.floating({ min: 8, max: 36, fixed: 1 });
467
+ });
468
+ const templateOffer = events.find(e => e.event === "offer accepted");
469
+ if (isFast && templateOffer) {
470
+ // Fast responders: clone existing offer_accepted 2x each
471
+ const existingAccepts = events.filter(e => e.event === "offer accepted");
472
+ existingAccepts.forEach(accept => {
473
+ for (let c = 0; c < FAST_OFFER_CLONE_COUNT; c++) {
474
+ events.push({
475
+ ...accept,
476
+ time: dayjs(accept.time).add(chance.integer({ min: 1, max: 8 }), "hours").toISOString(),
477
+ user_id: accept.user_id,
478
+ response_time_hours: chance.floating({ min: 0.1, max: 2, fixed: 1 }),
479
+ });
480
+ }
481
+ });
482
+ // Also clone from offer_received → offer_accepted
483
+ const offers = events.filter(e => e.event === "offer received");
484
+ offers.forEach(offer => {
485
+ if (chance.bool({ likelihood: FAST_OFFER_FROM_RECEIVED_LIKELIHOOD })) {
486
+ events.push({
487
+ ...templateOffer,
488
+ time: dayjs(offer.time).add(chance.integer({ min: 1, max: 6 }), "hours").toISOString(),
489
+ user_id: offer.user_id,
490
+ response_time_hours: chance.floating({ min: 0.1, max: 3, fixed: 1 }),
491
+ });
492
+ }
493
+ });
494
+ } else if (!isFast) {
495
+ // Slow responders: drop 60% of offer_accepted events
496
+ for (let i = events.length - 1; i >= 0; i--) {
497
+ if (events[i].event === "offer accepted" && chance.bool({ likelihood: SLOW_OFFER_DROP_LIKELIHOOD })) {
498
+ events.splice(i, 1);
499
+ }
500
+ }
501
+ }
502
+ }
503
+
504
+ // H6: New seller churn — drop 50% of events after user's first 14 days.
505
+ if (profile && profile.segment === "new_seller") {
506
+ const firstEventTime = events.length > 0 ? dayjs(events[0].time) : null;
507
+ if (firstEventTime) {
508
+ const userCutoff = firstEventTime.add(NEW_SELLER_CUTOFF_DAYS, "days");
509
+ for (let i = events.length - 1; i >= 0; i--) {
510
+ if (dayjs(events[i].time).isAfter(userCutoff) && chance.bool({ likelihood: NEW_SELLER_DROP_LIKELIHOOD })) {
511
+ events.splice(i, 1);
512
+ }
513
+ }
514
+ }
515
+ }
516
+
517
+ // H2: Weekend shopping surge — Sat/Sun purchases get total_amount 1.2x.
518
+ events.forEach(e => {
519
+ if (e.event === "purchase completed") {
520
+ const dow = new Date(e.time).getUTCDay();
521
+ if (dow === 0 || dow === 6) {
522
+ e.total_amount = Math.floor((e.total_amount || 60) * WEEKEND_SPEND_MULT);
523
+ }
524
+ }
525
+ });
526
+
527
+ // H10: Message-count magic number (in-funnel, no flags)
528
+ // Sweet 2-5 messages between item-viewed and offer-received →
529
+ // +35% on offer-received offer_amount. Over 6+ → drop 25% of purchases.
530
+ const itemViewed = events.find(e => e.event === "item viewed");
531
+ const offerReceived = events.find(e => e.event === "offer received");
532
+ if (itemViewed && offerReceived) {
533
+ const aTime = dayjs(itemViewed.time);
534
+ const bTime = dayjs(offerReceived.time);
535
+ const msgBetween = events.filter(e =>
536
+ e.event === "message sent" &&
537
+ dayjs(e.time).isAfter(aTime) &&
538
+ dayjs(e.time).isBefore(bTime)
539
+ ).length;
540
+ if (msgBetween >= MSG_SWEET_MIN && msgBetween <= MSG_SWEET_MAX) {
541
+ events.forEach(e => {
542
+ if (e.event === "offer received" && typeof e.offer_amount === "number") {
543
+ e.offer_amount = Math.round(e.offer_amount * MSG_OFFER_BOOST);
544
+ }
545
+ });
546
+ } else if (msgBetween >= MSG_OVER_THRESHOLD) {
547
+ for (let i = events.length - 1; i >= 0; i--) {
548
+ if (events[i].event === "purchase completed" && chance.bool({ likelihood: MSG_PURCHASE_DROP_LIKELIHOOD })) {
549
+ events.splice(i, 1);
550
+ }
551
+ }
552
+ }
553
+ }
554
+
555
+ return record;
556
+ }
557
+
558
+ // ── CONFIG ──
305
559
  /** @type {Config} */
306
560
  const config = {
307
561
  version: 2,
308
- token,
309
562
  seed: SEED,
310
- datasetStart: "2026-01-01T00:00:00Z",
311
- datasetEnd: "2026-05-01T23:59:59Z",
312
- // numDays: num_days,
313
- avgEventsPerUserPerDay: avg_events_per_user_per_day,
314
- numUsers: num_users,
315
- hasAnonIds: true,
316
- avgDevicePerUser: 2,
317
- hasSessionIds: true,
563
+ datasetStart: DATASET_START,
564
+ datasetEnd: DATASET_END,
565
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
566
+ numUsers: NUM_USERS,
318
567
  format: "json",
319
568
  gzip: true,
320
- alsoInferFunnels: false,
321
- hasLocation: true,
322
- hasAndroidDevices: true,
323
- hasIOSDevices: true,
324
- hasDesktopDevices: true,
325
- hasBrowser: false,
326
- hasCampaigns: false,
327
- isAnonymous: false,
328
- hasAdSpend: false,
329
- hasAvatar: true,
569
+ credentials: {
570
+ token,
571
+ },
572
+ switches: {
573
+ hasSessionIds: true,
574
+ alsoInferFunnels: false,
575
+ hasLocation: true,
576
+ hasAndroidDevices: true,
577
+ hasIOSDevices: true,
578
+ hasDesktopDevices: true,
579
+ hasBrowser: false,
580
+ hasCampaigns: false,
581
+ isAnonymous: false,
582
+ hasAdSpend: false,
583
+ hasAvatar: true,
584
+ },
585
+ identity: {
586
+ avgDevicePerUser: 2,
587
+ },
330
588
  concurrency: 1,
331
589
  writeToDisk: false,
332
590
  scdProps: {
@@ -340,7 +598,6 @@ const config = {
340
598
  mirrorProps: {},
341
599
  lookupTables: [],
342
600
 
343
- // ── Events (18) ──────────────────────────────────────────
344
601
  events: [
345
602
  {
346
603
  event: "account created",
@@ -519,7 +776,6 @@ const config = {
519
776
  },
520
777
  ],
521
778
 
522
- // ── Funnels (5) ──────────────────────────────────────────
523
779
  funnels: [
524
780
  {
525
781
  name: "Buyer Onboarding",
@@ -564,13 +820,11 @@ const config = {
564
820
  },
565
821
  ],
566
822
 
567
- // ── SuperProps ──────────────────────────────────────────
568
823
  superProps: {
569
824
  Platform: ["ios", "android", "web"],
570
825
  category: ["electronics", "clothing", "home_garden", "collectibles", "sports", "toys", "automotive"],
571
826
  },
572
827
 
573
- // ── UserProps ──────────────────────────────────────────
574
828
  userProps: {
575
829
  user_type: ["buyer"],
576
830
  segment: ["window_shopper"],
@@ -582,7 +836,6 @@ const config = {
582
836
  category: ["electronics", "clothing", "home_garden", "collectibles", "sports", "toys", "automotive"],
583
837
  },
584
838
 
585
- // ── Personas ──────────────────────────────────
586
839
  personas: [
587
840
  {
588
841
  name: "power_seller",
@@ -642,7 +895,6 @@ const config = {
642
895
  },
643
896
  ],
644
897
 
645
- // ── World Events ──────────────────────────────
646
898
  worldEvents: [
647
899
  {
648
900
  name: "marketplace_fee_change",
@@ -662,14 +914,12 @@ const config = {
662
914
  },
663
915
  ],
664
916
 
665
- // ── Engagement Decay ──────────────────────────
666
917
  engagementDecay: {
667
918
  model: "exponential",
668
919
  halfLife: 75,
669
920
  floor: 0.1,
670
921
  },
671
922
 
672
- // ── Attribution ──────────────────────────────
673
923
  attribution: {
674
924
  model: "last_touch",
675
925
  window: 7,
@@ -705,7 +955,6 @@ const config = {
705
955
  organicRate: 0.30,
706
956
  },
707
957
 
708
- // ── Geo ──────────────────────────────────────
709
958
  geo: {
710
959
  sticky: true,
711
960
  regions: [
@@ -740,7 +989,6 @@ const config = {
740
989
  ],
741
990
  },
742
991
 
743
- // ── Anomalies ──────────────────────────────────
744
992
  anomalies: [
745
993
  {
746
994
  type: "extreme_value",
@@ -760,248 +1008,10 @@ const config = {
760
1008
  },
761
1009
  ],
762
1010
 
763
- // ── Hook Function ──────────────────────────────────────
764
- hook: function (record, type, meta) {
765
- // ── HOOK 7: POWER SELLER PROFILES (user) ─────────────
766
- // Power sellers get high total_transactions, top seller_rating,
767
- // and a realistic store name. Casual sellers get mid-range stats.
768
- if (type === "user") {
769
- if (record.segment === "power_seller") {
770
- record.total_transactions = chance.integer({ min: 100, max: 500 });
771
- record.seller_rating = chance.floating({ min: 4.5, max: 5.0, fixed: 1 });
772
- record.store_name = chance.pickone([
773
- "TechVault Pro", "StyleHaven", "GearFactory", "PrimeFinds",
774
- "TopShelf Goods", "EliteDeals", "QualityFirst Store", "BestBuy Resale",
775
- ]);
776
- } else if (record.segment === "casual_seller") {
777
- record.total_transactions = chance.integer({ min: 5, max: 50 });
778
- record.seller_rating = chance.floating({ min: 3.0, max: 4.5, fixed: 1 });
779
- record.store_name = chance.pickone([
780
- "My Garage Sale", "Closet Cleanout", "Random Finds", "Weekend Seller",
781
- ]);
782
- } else if (record.segment === "new_seller") {
783
- record.total_transactions = chance.integer({ min: 0, max: 3 });
784
- record.seller_rating = 0;
785
- record.store_name = "New Store";
786
- }
787
- }
788
-
789
- // HOOK 9 (T2C): BROWSE TO PURCHASE TIME-TO-CONVERT (funnel-post)
790
- // Power_seller users complete Browse to Purchase funnel 1.4x faster
791
- // (factor 0.71); window_shopper 1.4x slower (factor 1.4).
792
- if (type === "funnel-post") {
793
- const segment = meta?.profile?.segment;
794
- if (Array.isArray(record) && record.length > 1) {
795
- const factor = (
796
- segment === "power_seller" || segment === "frequent_buyer" ? 0.71 :
797
- segment === "window_shopper" ? 1.4 :
798
- 1.0
799
- );
800
- if (factor !== 1.0) {
801
- for (let i = 1; i < record.length; i++) {
802
- const prev = dayjs(record[i - 1].time);
803
- const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
804
- record[i].time = prev.add(newGap, "milliseconds").toISOString();
805
- }
806
- }
807
- }
808
- }
809
-
810
- // HOOK 1: FEE CHANGE IMPACT — moved to everything hook below.
811
- // (event hook fires before bunchIntoSessions reshuffles timestamps)
812
-
813
- // ── EVERYTHING HOOKS ─────────────────────────────────
814
- if (type === "everything") {
815
- const datasetStart = dayjs.unix(meta.datasetStart);
816
- const FEE_CHANGE_DAY = datasetStart.add(45, "days");
817
- let events = record;
818
- if (!events.length) return record;
819
-
820
- const profile = meta.profile;
821
-
822
- // ── SUPERPROP STAMPING ──────────────────────────
823
- // Stamp superProps from profile so they are consistent per user.
824
- if (profile) {
825
- events.forEach(e => {
826
- if (profile.Platform) e.Platform = profile.Platform;
827
- if (profile.category) e.category = profile.category;
828
- });
829
- }
830
-
831
- // HOOK 1: FEE CHANGE IMPACT — listings after d45 get listing_fee 1.3x.
832
- // In everything hook so timestamp comparison sees post-bunchIntoSessions times.
833
- events.forEach(e => {
834
- if (e.event === "listing created" && dayjs(e.time).isAfter(FEE_CHANGE_DAY)) {
835
- e.listing_fee = Math.floor((e.listing_fee || 15) * 1.3);
836
- }
837
- });
838
-
839
- // ── HOOK 8: FREQUENT BUYER CONVERSION FILTER ────
840
- // Non-frequent-buyer users drop ~25% of "purchase completed"
841
- // (last step of Browse to Purchase funnel) to simulate lower conversion.
842
- if (profile && profile.segment !== "frequent_buyer") {
843
- record = record.filter(e => {
844
- if (e.event === "purchase completed" && chance.bool({ likelihood: 25 })) return false;
845
- return true;
846
- });
847
- events = record;
848
- }
849
-
850
- // ── HOOK 3: SELLER SUCCESS → BUYER TRUST ─────────
851
- // Power sellers get 2x purchase events (cloned from existing).
852
- if (profile && profile.segment === "power_seller") {
853
- const purchases = events.filter(e => e.event === "purchase completed");
854
- const templatePurchase = purchases[0];
855
- if (templatePurchase && purchases.length > 0) {
856
- purchases.forEach(p => {
857
- if (chance.bool({ likelihood: 65 })) {
858
- events.push({
859
- ...templatePurchase,
860
- time: dayjs(p.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
861
- user_id: p.user_id,
862
- total_amount: chance.integer({ min: 15, max: 400 }),
863
- item_count: chance.integer({ min: 1, max: 4 }),
864
- });
865
- }
866
- });
867
- }
868
- }
869
-
870
- // ── HOOK 4: SEARCH-TO-PURCHASE BY CATEGORY ───────
871
- // Electronics category gets cloned purchase events (higher conversion).
872
- const hasElectronicsSearch = events.some(e =>
873
- e.event === "item searched" && e.category === "electronics"
874
- );
875
- if (hasElectronicsSearch) {
876
- const templatePurchase = events.find(e => e.event === "purchase completed");
877
- if (templatePurchase) {
878
- const electronicsSearches = events.filter(e =>
879
- e.event === "item searched" && e.category === "electronics"
880
- );
881
- electronicsSearches.slice(0, 3).forEach(search => {
882
- if (chance.bool({ likelihood: 40 })) {
883
- events.push({
884
- ...templatePurchase,
885
- time: dayjs(search.time).add(chance.integer({ min: 1, max: 12 }), "hours").toISOString(),
886
- user_id: search.user_id,
887
- total_amount: chance.integer({ min: 50, max: 300 }),
888
- category: "electronics",
889
- });
890
- }
891
- });
892
- }
893
- }
894
-
895
- // ── HOOK 5: RESPONSE TIME → CONVERSION ───────────
896
- // Deterministic fast/slow cohorts using user hash.
897
- // Fast responders (~40%): response_time_hours set to 1-4,
898
- // extra offer_accepted clones. Slow (~60%): set to 8-36,
899
- // lose 60% of offer_accepted events.
900
- const msgEvents = events.filter(e => e.event === "message sent");
901
- if (msgEvents.length > 0) {
902
- const uid = msgEvents[0].user_id || "";
903
- const isFast = (uid.charCodeAt(0) + uid.charCodeAt(uid.length - 1)) % 5 < 2; // ~40%
904
- // Stamp response_time_hours to create the measurable correlation
905
- msgEvents.forEach(m => {
906
- m.response_time_hours = isFast
907
- ? chance.floating({ min: 0.5, max: 4, fixed: 1 })
908
- : chance.floating({ min: 8, max: 36, fixed: 1 });
909
- });
910
- const templateOffer = events.find(e => e.event === "offer accepted");
911
- if (isFast && templateOffer) {
912
- // Fast responders: clone existing offer_accepted 2x each
913
- const existingAccepts = events.filter(e => e.event === "offer accepted");
914
- existingAccepts.forEach(accept => {
915
- for (let c = 0; c < 2; c++) {
916
- events.push({
917
- ...accept,
918
- time: dayjs(accept.time).add(chance.integer({ min: 1, max: 8 }), "hours").toISOString(),
919
- user_id: accept.user_id,
920
- response_time_hours: chance.floating({ min: 0.1, max: 2, fixed: 1 }),
921
- });
922
- }
923
- });
924
- // Also clone from offer_received → offer_accepted
925
- const offers = events.filter(e => e.event === "offer received");
926
- offers.forEach(offer => {
927
- if (chance.bool({ likelihood: 60 })) {
928
- events.push({
929
- ...templateOffer,
930
- time: dayjs(offer.time).add(chance.integer({ min: 1, max: 6 }), "hours").toISOString(),
931
- user_id: offer.user_id,
932
- response_time_hours: chance.floating({ min: 0.1, max: 3, fixed: 1 }),
933
- });
934
- }
935
- });
936
- } else if (!isFast) {
937
- // Slow responders: drop 60% of offer_accepted events
938
- for (let i = events.length - 1; i >= 0; i--) {
939
- if (events[i].event === "offer accepted" && chance.bool({ likelihood: 60 })) {
940
- events.splice(i, 1);
941
- }
942
- }
943
- }
944
- }
945
-
946
- // ── HOOK 6: NEW SELLER CHURN ─────────────────────
947
- // New sellers lose 50% of events after their first 14 days.
948
- // Uses user's first event as anchor (not dataset start).
949
- if (profile && profile.segment === "new_seller") {
950
- const firstEventTime = events.length > 0 ? dayjs(events[0].time) : null;
951
- if (firstEventTime) {
952
- const userDay14 = firstEventTime.add(14, "days");
953
- for (let i = events.length - 1; i >= 0; i--) {
954
- if (dayjs(events[i].time).isAfter(userDay14) && chance.bool({ likelihood: 50 })) {
955
- events.splice(i, 1);
956
- }
957
- }
958
- }
959
- }
960
-
961
- // HOOK 2: WEEKEND SHOPPING SURGE — Sat/Sun purchases get
962
- // total_amount 1.2x. Mutates raw prop. No flag.
963
- events.forEach(e => {
964
- if (e.event === "purchase completed") {
965
- const dow = new Date(e.time).getUTCDay();
966
- if (dow === 0 || dow === 6) {
967
- e.total_amount = Math.floor((e.total_amount || 60) * 1.2);
968
- }
969
- }
970
- });
971
-
972
- // HOOK 10: MESSAGE-COUNT MAGIC NUMBER (in-funnel, no flags)
973
- // Sweet 2-5 message-sent events between item-viewed and
974
- // offer-received → +35% on offer-received offer_amount/asking_price.
975
- // Over 6+ → drop 25% of purchase-completed events (over-message
976
- // signals haggling deadlock).
977
- const itemViewed = events.find(e => e.event === "item viewed");
978
- const offerReceived = events.find(e => e.event === "offer received");
979
- if (itemViewed && offerReceived) {
980
- const aTime = dayjs(itemViewed.time);
981
- const bTime = dayjs(offerReceived.time);
982
- const msgBetween = events.filter(e =>
983
- e.event === "message sent" &&
984
- dayjs(e.time).isAfter(aTime) &&
985
- dayjs(e.time).isBefore(bTime)
986
- ).length;
987
- if (msgBetween >= 2 && msgBetween <= 5) {
988
- events.forEach(e => {
989
- if (e.event === "offer received" && typeof e.offer_amount === "number") {
990
- e.offer_amount = Math.round(e.offer_amount * 1.35);
991
- }
992
- });
993
- } else if (msgBetween >= 6) {
994
- for (let i = events.length - 1; i >= 0; i--) {
995
- if (events[i].event === "purchase completed" && chance.bool({ likelihood: 25 })) {
996
- events.splice(i, 1);
997
- }
998
- }
999
- }
1000
- }
1001
-
1002
- return record;
1003
- }
1004
-
1011
+ hook(record, type, meta) {
1012
+ if (type === "user") return handleUserHooks(record);
1013
+ if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
1014
+ if (type === "everything") return handleEverythingHooks(record, meta);
1005
1015
  return record;
1006
1016
  },
1007
1017
  };