@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,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",
@@ -354,6 +611,7 @@ const config = {
354
611
  {
355
612
  event: "item searched",
356
613
  weight: 8,
614
+ isStrictEvent: false,
357
615
  properties: {
358
616
  search_query: ["laptop", "vintage jacket", "sneakers", "headphones", "phone case", "gaming console", "watch", "sunglasses", "backpack", "camera"],
359
617
  results_count: u.weighNumRange(0, 50, 0.5),
@@ -363,6 +621,7 @@ const config = {
363
621
  {
364
622
  event: "item viewed",
365
623
  weight: 7,
624
+ isStrictEvent: false,
366
625
  properties: {
367
626
  listing_id: chance.pickone.bind(chance, listingIds),
368
627
  item_price: u.weighNumRange(5, 500, 0.3, 45),
@@ -383,6 +642,7 @@ const config = {
383
642
  {
384
643
  event: "purchase completed",
385
644
  weight: 3,
645
+ isStrictEvent: false,
386
646
  properties: {
387
647
  listing_id: chance.pickone.bind(chance, listingIds),
388
648
  total_amount: u.weighNumRange(10, 500, 0.3, 60),
@@ -394,6 +654,7 @@ const config = {
394
654
  {
395
655
  event: "listing created",
396
656
  weight: 4,
657
+ isStrictEvent: false,
397
658
  properties: {
398
659
  listing_id: chance.pickone.bind(chance, listingIds),
399
660
  store_id: chance.pickone.bind(chance, storeIds),
@@ -414,6 +675,7 @@ const config = {
414
675
  {
415
676
  event: "offer received",
416
677
  weight: 3,
678
+ isStrictEvent: false,
417
679
  properties: {
418
680
  listing_id: chance.pickone.bind(chance, listingIds),
419
681
  offer_amount: u.weighNumRange(5, 400, 0.3, 40),
@@ -423,6 +685,7 @@ const config = {
423
685
  {
424
686
  event: "offer accepted",
425
687
  weight: 2,
688
+ isStrictEvent: false,
426
689
  properties: {
427
690
  listing_id: chance.pickone.bind(chance, listingIds),
428
691
  final_price: u.weighNumRange(10, 500, 0.3, 55),
@@ -452,6 +715,7 @@ const config = {
452
715
  {
453
716
  event: "message sent",
454
717
  weight: 5,
718
+ isStrictEvent: false,
455
719
  properties: {
456
720
  message_type: ["inquiry", "inquiry", "negotiation", "shipping_question", "complaint", "thank_you"],
457
721
  recipient_type: ["seller", "seller", "buyer", "support"],
@@ -512,7 +776,6 @@ const config = {
512
776
  },
513
777
  ],
514
778
 
515
- // ── Funnels (5) ──────────────────────────────────────────
516
779
  funnels: [
517
780
  {
518
781
  name: "Buyer Onboarding",
@@ -557,13 +820,11 @@ const config = {
557
820
  },
558
821
  ],
559
822
 
560
- // ── SuperProps ──────────────────────────────────────────
561
823
  superProps: {
562
824
  Platform: ["ios", "android", "web"],
563
825
  category: ["electronics", "clothing", "home_garden", "collectibles", "sports", "toys", "automotive"],
564
826
  },
565
827
 
566
- // ── UserProps ──────────────────────────────────────────
567
828
  userProps: {
568
829
  user_type: ["buyer"],
569
830
  segment: ["window_shopper"],
@@ -575,7 +836,6 @@ const config = {
575
836
  category: ["electronics", "clothing", "home_garden", "collectibles", "sports", "toys", "automotive"],
576
837
  },
577
838
 
578
- // ── Personas ──────────────────────────────────
579
839
  personas: [
580
840
  {
581
841
  name: "power_seller",
@@ -635,7 +895,6 @@ const config = {
635
895
  },
636
896
  ],
637
897
 
638
- // ── World Events ──────────────────────────────
639
898
  worldEvents: [
640
899
  {
641
900
  name: "marketplace_fee_change",
@@ -655,14 +914,12 @@ const config = {
655
914
  },
656
915
  ],
657
916
 
658
- // ── Engagement Decay ──────────────────────────
659
917
  engagementDecay: {
660
918
  model: "exponential",
661
919
  halfLife: 75,
662
920
  floor: 0.1,
663
921
  },
664
922
 
665
- // ── Attribution ──────────────────────────────
666
923
  attribution: {
667
924
  model: "last_touch",
668
925
  window: 7,
@@ -698,7 +955,6 @@ const config = {
698
955
  organicRate: 0.30,
699
956
  },
700
957
 
701
- // ── Geo ──────────────────────────────────────
702
958
  geo: {
703
959
  sticky: true,
704
960
  regions: [
@@ -733,7 +989,6 @@ const config = {
733
989
  ],
734
990
  },
735
991
 
736
- // ── Anomalies ──────────────────────────────────
737
992
  anomalies: [
738
993
  {
739
994
  type: "extreme_value",
@@ -753,248 +1008,10 @@ const config = {
753
1008
  },
754
1009
  ],
755
1010
 
756
- // ── Hook Function ──────────────────────────────────────
757
- hook: function (record, type, meta) {
758
- // ── HOOK 7: POWER SELLER PROFILES (user) ─────────────
759
- // Power sellers get high total_transactions, top seller_rating,
760
- // and a realistic store name. Casual sellers get mid-range stats.
761
- if (type === "user") {
762
- if (record.segment === "power_seller") {
763
- record.total_transactions = chance.integer({ min: 100, max: 500 });
764
- record.seller_rating = chance.floating({ min: 4.5, max: 5.0, fixed: 1 });
765
- record.store_name = chance.pickone([
766
- "TechVault Pro", "StyleHaven", "GearFactory", "PrimeFinds",
767
- "TopShelf Goods", "EliteDeals", "QualityFirst Store", "BestBuy Resale",
768
- ]);
769
- } else if (record.segment === "casual_seller") {
770
- record.total_transactions = chance.integer({ min: 5, max: 50 });
771
- record.seller_rating = chance.floating({ min: 3.0, max: 4.5, fixed: 1 });
772
- record.store_name = chance.pickone([
773
- "My Garage Sale", "Closet Cleanout", "Random Finds", "Weekend Seller",
774
- ]);
775
- } else if (record.segment === "new_seller") {
776
- record.total_transactions = chance.integer({ min: 0, max: 3 });
777
- record.seller_rating = 0;
778
- record.store_name = "New Store";
779
- }
780
- }
781
-
782
- // HOOK 9 (T2C): BROWSE TO PURCHASE TIME-TO-CONVERT (funnel-post)
783
- // Power_seller users complete Browse to Purchase funnel 1.4x faster
784
- // (factor 0.71); window_shopper 1.4x slower (factor 1.4).
785
- if (type === "funnel-post") {
786
- const segment = meta?.profile?.segment;
787
- if (Array.isArray(record) && record.length > 1) {
788
- const factor = (
789
- segment === "power_seller" || segment === "frequent_buyer" ? 0.71 :
790
- segment === "window_shopper" ? 1.4 :
791
- 1.0
792
- );
793
- if (factor !== 1.0) {
794
- for (let i = 1; i < record.length; i++) {
795
- const prev = dayjs(record[i - 1].time);
796
- const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
797
- record[i].time = prev.add(newGap, "milliseconds").toISOString();
798
- }
799
- }
800
- }
801
- }
802
-
803
- // HOOK 1: FEE CHANGE IMPACT — moved to everything hook below.
804
- // (event hook fires before bunchIntoSessions reshuffles timestamps)
805
-
806
- // ── EVERYTHING HOOKS ─────────────────────────────────
807
- if (type === "everything") {
808
- const datasetStart = dayjs.unix(meta.datasetStart);
809
- const FEE_CHANGE_DAY = datasetStart.add(45, "days");
810
- let events = record;
811
- if (!events.length) return record;
812
-
813
- const profile = meta.profile;
814
-
815
- // ── SUPERPROP STAMPING ──────────────────────────
816
- // Stamp superProps from profile so they are consistent per user.
817
- if (profile) {
818
- events.forEach(e => {
819
- if (profile.Platform) e.Platform = profile.Platform;
820
- if (profile.category) e.category = profile.category;
821
- });
822
- }
823
-
824
- // HOOK 1: FEE CHANGE IMPACT — listings after d45 get listing_fee 1.3x.
825
- // In everything hook so timestamp comparison sees post-bunchIntoSessions times.
826
- events.forEach(e => {
827
- if (e.event === "listing created" && dayjs(e.time).isAfter(FEE_CHANGE_DAY)) {
828
- e.listing_fee = Math.floor((e.listing_fee || 15) * 1.3);
829
- }
830
- });
831
-
832
- // ── HOOK 8: FREQUENT BUYER CONVERSION FILTER ────
833
- // Non-frequent-buyer users drop ~25% of "purchase completed"
834
- // (last step of Browse to Purchase funnel) to simulate lower conversion.
835
- if (profile && profile.segment !== "frequent_buyer") {
836
- record = record.filter(e => {
837
- if (e.event === "purchase completed" && chance.bool({ likelihood: 25 })) return false;
838
- return true;
839
- });
840
- events = record;
841
- }
842
-
843
- // ── HOOK 3: SELLER SUCCESS → BUYER TRUST ─────────
844
- // Power sellers get 2x purchase events (cloned from existing).
845
- if (profile && profile.segment === "power_seller") {
846
- const purchases = events.filter(e => e.event === "purchase completed");
847
- const templatePurchase = purchases[0];
848
- if (templatePurchase && purchases.length > 0) {
849
- purchases.forEach(p => {
850
- if (chance.bool({ likelihood: 65 })) {
851
- events.push({
852
- ...templatePurchase,
853
- time: dayjs(p.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
854
- user_id: p.user_id,
855
- total_amount: chance.integer({ min: 15, max: 400 }),
856
- item_count: chance.integer({ min: 1, max: 4 }),
857
- });
858
- }
859
- });
860
- }
861
- }
862
-
863
- // ── HOOK 4: SEARCH-TO-PURCHASE BY CATEGORY ───────
864
- // Electronics category gets cloned purchase events (higher conversion).
865
- const hasElectronicsSearch = events.some(e =>
866
- e.event === "item searched" && e.category === "electronics"
867
- );
868
- if (hasElectronicsSearch) {
869
- const templatePurchase = events.find(e => e.event === "purchase completed");
870
- if (templatePurchase) {
871
- const electronicsSearches = events.filter(e =>
872
- e.event === "item searched" && e.category === "electronics"
873
- );
874
- electronicsSearches.slice(0, 3).forEach(search => {
875
- if (chance.bool({ likelihood: 40 })) {
876
- events.push({
877
- ...templatePurchase,
878
- time: dayjs(search.time).add(chance.integer({ min: 1, max: 12 }), "hours").toISOString(),
879
- user_id: search.user_id,
880
- total_amount: chance.integer({ min: 50, max: 300 }),
881
- category: "electronics",
882
- });
883
- }
884
- });
885
- }
886
- }
887
-
888
- // ── HOOK 5: RESPONSE TIME → CONVERSION ───────────
889
- // Deterministic fast/slow cohorts using user hash.
890
- // Fast responders (~40%): response_time_hours set to 1-4,
891
- // extra offer_accepted clones. Slow (~60%): set to 8-36,
892
- // lose 60% of offer_accepted events.
893
- const msgEvents = events.filter(e => e.event === "message sent");
894
- if (msgEvents.length > 0) {
895
- const uid = msgEvents[0].user_id || "";
896
- const isFast = (uid.charCodeAt(0) + uid.charCodeAt(uid.length - 1)) % 5 < 2; // ~40%
897
- // Stamp response_time_hours to create the measurable correlation
898
- msgEvents.forEach(m => {
899
- m.response_time_hours = isFast
900
- ? chance.floating({ min: 0.5, max: 4, fixed: 1 })
901
- : chance.floating({ min: 8, max: 36, fixed: 1 });
902
- });
903
- const templateOffer = events.find(e => e.event === "offer accepted");
904
- if (isFast && templateOffer) {
905
- // Fast responders: clone existing offer_accepted 2x each
906
- const existingAccepts = events.filter(e => e.event === "offer accepted");
907
- existingAccepts.forEach(accept => {
908
- for (let c = 0; c < 2; c++) {
909
- events.push({
910
- ...accept,
911
- time: dayjs(accept.time).add(chance.integer({ min: 1, max: 8 }), "hours").toISOString(),
912
- user_id: accept.user_id,
913
- response_time_hours: chance.floating({ min: 0.1, max: 2, fixed: 1 }),
914
- });
915
- }
916
- });
917
- // Also clone from offer_received → offer_accepted
918
- const offers = events.filter(e => e.event === "offer received");
919
- offers.forEach(offer => {
920
- if (chance.bool({ likelihood: 60 })) {
921
- events.push({
922
- ...templateOffer,
923
- time: dayjs(offer.time).add(chance.integer({ min: 1, max: 6 }), "hours").toISOString(),
924
- user_id: offer.user_id,
925
- response_time_hours: chance.floating({ min: 0.1, max: 3, fixed: 1 }),
926
- });
927
- }
928
- });
929
- } else if (!isFast) {
930
- // Slow responders: drop 60% of offer_accepted events
931
- for (let i = events.length - 1; i >= 0; i--) {
932
- if (events[i].event === "offer accepted" && chance.bool({ likelihood: 60 })) {
933
- events.splice(i, 1);
934
- }
935
- }
936
- }
937
- }
938
-
939
- // ── HOOK 6: NEW SELLER CHURN ─────────────────────
940
- // New sellers lose 50% of events after their first 14 days.
941
- // Uses user's first event as anchor (not dataset start).
942
- if (profile && profile.segment === "new_seller") {
943
- const firstEventTime = events.length > 0 ? dayjs(events[0].time) : null;
944
- if (firstEventTime) {
945
- const userDay14 = firstEventTime.add(14, "days");
946
- for (let i = events.length - 1; i >= 0; i--) {
947
- if (dayjs(events[i].time).isAfter(userDay14) && chance.bool({ likelihood: 50 })) {
948
- events.splice(i, 1);
949
- }
950
- }
951
- }
952
- }
953
-
954
- // HOOK 2: WEEKEND SHOPPING SURGE — Sat/Sun purchases get
955
- // total_amount 1.2x. Mutates raw prop. No flag.
956
- events.forEach(e => {
957
- if (e.event === "purchase completed") {
958
- const dow = new Date(e.time).getUTCDay();
959
- if (dow === 0 || dow === 6) {
960
- e.total_amount = Math.floor((e.total_amount || 60) * 1.2);
961
- }
962
- }
963
- });
964
-
965
- // HOOK 10: MESSAGE-COUNT MAGIC NUMBER (in-funnel, no flags)
966
- // Sweet 2-5 message-sent events between item-viewed and
967
- // offer-received → +35% on offer-received offer_amount/asking_price.
968
- // Over 6+ → drop 25% of purchase-completed events (over-message
969
- // signals haggling deadlock).
970
- const itemViewed = events.find(e => e.event === "item viewed");
971
- const offerReceived = events.find(e => e.event === "offer received");
972
- if (itemViewed && offerReceived) {
973
- const aTime = dayjs(itemViewed.time);
974
- const bTime = dayjs(offerReceived.time);
975
- const msgBetween = events.filter(e =>
976
- e.event === "message sent" &&
977
- dayjs(e.time).isAfter(aTime) &&
978
- dayjs(e.time).isBefore(bTime)
979
- ).length;
980
- if (msgBetween >= 2 && msgBetween <= 5) {
981
- events.forEach(e => {
982
- if (e.event === "offer received" && typeof e.offer_amount === "number") {
983
- e.offer_amount = Math.round(e.offer_amount * 1.35);
984
- }
985
- });
986
- } else if (msgBetween >= 6) {
987
- for (let i = events.length - 1; i >= 0; i--) {
988
- if (events[i].event === "purchase completed" && chance.bool({ likelihood: 25 })) {
989
- events.splice(i, 1);
990
- }
991
- }
992
- }
993
- }
994
-
995
- return record;
996
- }
997
-
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);
998
1015
  return record;
999
1016
  },
1000
1017
  };