@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,61 +1,41 @@
1
- // ── TWEAK THESE ──
2
- const SEED = "homenest";
3
- const num_days = 120;
4
- const num_users = 10_000;
5
- const avg_events_per_user_per_day = 0.53;
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
- import * as v from "ak-tools";
16
-
17
- dayjs.extend(utc);
18
- const chance = u.initChance(SEED);
19
-
20
7
  /** @typedef {import("../../types").Dungeon} Config */
21
8
 
22
- /**
23
- * ===================================================================
24
- * DATASET OVERVIEW
25
- * ===================================================================
26
- *
27
- * HomeNest a property listings and agent CRM platform
28
- * (Zillow meets Compass). Buyers search listings, save properties,
29
- * schedule tours, get pre-approved for mortgages, and submit offers.
30
- * Agents list properties, manage clients, and close deals.
31
- *
32
- * Scale: 6,000 users · 480K events · 150 days · 17 event types
33
- * Groups: 200 brokerages
34
- * User types: Buyer (60%), Agent (20%), Both (20%)
35
- * Agent tiers: Standard (75%) / Premier (25%)
36
- *
37
- * Core loops:
38
- * Buyer: account created → property search → property viewed →
39
- * property saved → tour scheduled → offer submitted →
40
- * offer accepted / offer rejected
41
- *
42
- * Agent: account created → property listed → open house attended →
43
- * agent contacted → property sold
44
- *
45
- * Funnels:
46
- * - Buyer journey: account created property search → property viewed (80%)
47
- * - Tour funnel: property viewed tour scheduled offer submitted (40%)
48
- * - Seller funnel: property listed → open house attended → property sold (35%)
49
- *
50
- * ===================================================================
51
- * ANALYTICS HOOKS (10 hooks)
52
- *
53
- * Adds 10. TOUR FUNNEL TIME-TO-CONVERT: Premier agents 0.71x faster, Standard
54
- * 1.3x slower (funnel-post). Discover via Tour Funnel median TTC by agent_tier.
55
- * NOTE (funnel-post measurement): visible only via Mixpanel funnel median TTC.
56
- * Cross-event MIN→MIN SQL queries on raw events do NOT show this.
57
- * ===================================================================
58
- *
9
+ // ── OVERVIEW ──
10
+ /*
11
+ * NAME: HomeNest
12
+ * APP: Property listings and agent CRM platform (Zillow meets
13
+ * Compass). Buyers search listings, save properties, schedule
14
+ * tours, get pre-approved for mortgages, and submit offers.
15
+ * Agents list properties, manage clients, and close deals.
16
+ * SCALE: 10,000 users, 121 days (2026-01-01 2026-05-01)
17
+ * CORE LOOP: account created property search property viewed → property saved → tour scheduled → offer submitted (buyer) | property listed → open house attended → property sold (agent)
18
+ *
19
+ * EVENTS (17):
20
+ * property viewed (12) > property search (10) > property saved (5) > virtual tour (4)
21
+ * > tour scheduled (4) > agent contacted (4) > saved search created (3) > in-person tour (3)
22
+ * > property listed (3) > mortgage pre-approval (2) > offer submitted (2) > open house attended (2)
23
+ * > review submitted (2) > account created (1) > offer accepted (1) > offer rejected (1)
24
+ * > property sold (1)
25
+ *
26
+ * FUNNELS (3):
27
+ * - Buyer Journey: account created → property search → property viewed (80%)
28
+ * - Tour Funnel: property viewed → tour scheduled → offer submitted (40%, reentry)
29
+ * - Seller Funnel: property listed → open house attended → property sold (35%)
30
+ *
31
+ * USER PROPS: user_type, preferred_location, property_preference, budget_max, agent_tier, total_properties_viewed, pre_approval_status
32
+ * SUPER PROPS: user_type, preferred_location, property_preference
33
+ * SCD PROPS: agent_tier (Standard/Premier, monthly fuzzy, max 4)
34
+ * GROUPS: brokerage_id (200 brokerages — property listed, property sold, agent contacted, open house attended)
35
+ */
36
+
37
+ // ── HOOK STORIES ──
38
+ /*
59
39
  * NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable
60
40
  * only via behavioral cohorts (count event per user, then measure
61
41
  * downstream) or raw-prop breakdowns (date, agent_tier).
@@ -267,6 +247,11 @@ const chance = u.initChance(SEED);
267
247
  * - Breakdown: "agent_tier" (user property / SCD)
268
248
  * - Expected: Premier ~ 0.71x baseline; Standard ~ 1.3x baseline
269
249
  *
250
+ * NOTE (funnel-post measurement): visible only via Mixpanel funnel
251
+ * median TTC. Cross-event MIN→MIN SQL queries on raw events do NOT
252
+ * show this — funnel-post adjusts gaps within funnel instances, not
253
+ * across the user's full event history.
254
+ *
270
255
  * REAL-WORLD ANALOGUE: Premier agents have larger networks, faster
271
256
  * scheduling workflows, and prioritized showing slots, translating
272
257
  * to shorter tour-to-offer cycles.
@@ -290,32 +275,367 @@ const chance = u.initChance(SEED);
290
275
  * Tour Funnel TTC | median TTC by tier | 1x | 0.71/1.3x| ~ 1.8x range
291
276
  */
292
277
 
278
+ // ── SCALE ──
279
+ const SEED = "homenest";
280
+ const NUM_USERS = 10_000;
281
+ const NUM_DAYS = 120;
282
+ const DATASET_START = "2026-01-01T00:00:00Z";
283
+ const DATASET_END = "2026-05-01T23:59:59Z";
284
+ const EVENTS_PER_DAY = 0.53;
285
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
286
+
287
+ const chance = u.initChance(SEED);
288
+
289
+ // ── KNOBS (tweak these to reshape stories) ──
290
+ const SPRING_START_DAY = 30;
291
+ const SPRING_END_DAY = 60;
292
+ const SPRING_TOUR_DURATION_MULT = 3;
293
+ const SPRING_OFFER_PRICE_MULT = 2.5;
294
+
295
+ const SHOCK_START_DAY = 75;
296
+ const SHOCK_END_DAY = 89;
297
+ const SHOCK_MORTGAGE_RATE = 7.5;
298
+ const SHOCK_OFFER_DROP_LIKELIHOOD = 45;
299
+
300
+ const SAVED_SEARCH_WINDOW_DAYS = 7;
301
+ const SAVED_SEARCH_VIEW_CLONES_MIN = 3;
302
+ const SAVED_SEARCH_VIEW_CLONES_MAX = 8;
303
+ const SAVED_SEARCH_TOUR_CLONES_MIN = 1;
304
+ const SAVED_SEARCH_TOUR_CLONES_MAX = 3;
305
+ const NON_SAVER_CUTOFF_DAY = 30;
306
+ const NON_SAVER_DROP_LIKELIHOOD = 80;
307
+
308
+ const PRE_APPROVAL_OFFER_CLONES_MIN = 4;
309
+ const PRE_APPROVAL_OFFER_CLONES_MAX = 6;
310
+
311
+ const PREMIER_LISTING_CLONE_MULT = 2;
312
+ const PREMIER_SALE_CLONE_MULT = 1;
313
+
314
+ const DUAL_TOUR_OFFER_CLONES_MIN = 5;
315
+ const DUAL_TOUR_OFFER_CLONES_MAX = 7;
316
+
317
+ const LUXURY_RELEASE_DAY = 50;
318
+ const LUXURY_LISTING_LIKELIHOOD = 3;
319
+ const LUXURY_PRICE_MIN = 5_000_000;
320
+ const LUXURY_PRICE_MAX = 15_000_000;
321
+ const LUXURY_USER_HASH_MOD = 33;
322
+ const LUXURY_VIEW_CLONES_MIN = 3;
323
+ const LUXURY_VIEW_CLONES_MAX = 6;
324
+
325
+ const COLD_LEAD_WINDOW_DAYS = 14;
326
+ const COLD_LEAD_DROP_LIKELIHOOD = 90;
327
+
328
+ const VIEW_SWEET_MIN = 6;
329
+ const VIEW_SWEET_MAX = 12;
330
+ const VIEW_OVER_THRESHOLD = 13;
331
+ const VIEW_OFFER_PRICE_BOOST = 1.3;
332
+ const VIEW_OVER_OFFER_DROP_LIKELIHOOD = 35;
333
+
334
+ const TTC_PREMIER_FACTOR = 0.71;
335
+ const TTC_STANDARD_FACTOR = 1.3;
336
+
337
+ // ── HELPER FUNCTIONS ──
338
+ function handleFunnelPostHooks(record, meta) {
339
+ // H10: Tour Funnel TTC — Premier 0.71x, Standard 1.3x.
340
+ const segment = meta?.profile?.agent_tier;
341
+ if (Array.isArray(record) && record.length > 1) {
342
+ const factor = (
343
+ segment === "Premier" ? TTC_PREMIER_FACTOR :
344
+ segment === "Standard" ? TTC_STANDARD_FACTOR :
345
+ 1.0
346
+ );
347
+ if (factor !== 1.0) {
348
+ for (let i = 1; i < record.length; i++) {
349
+ const prev = dayjs(record[i - 1].time);
350
+ const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
351
+ record[i].time = prev.add(newGap, "milliseconds").toISOString();
352
+ }
353
+ }
354
+ }
355
+ return record;
356
+ }
357
+
358
+ function handleEverythingHooks(record, meta) {
359
+ const datasetStart = dayjs.unix(meta.datasetStart);
360
+ const userEvents = record;
361
+ const profile = meta.profile;
362
+
363
+ // Stamp superProps from profile for consistency
364
+ userEvents.forEach(e => {
365
+ e.user_type = profile.user_type;
366
+ e.preferred_location = profile.preferred_location;
367
+ e.property_preference = profile.property_preference;
368
+ });
369
+
370
+ // HOOK 1: SPRING BUYING SEASON — d30-60 tour duration 3x, offer_price 2.5x
371
+ // HOOK 2: MORTGAGE RATE SHOCK — d75-89 mortgage_rate pinned 7.5
372
+ // HOOK 7: LUXURY LISTING RELEASE — post-d50, 3% of listings priced $5M-$15M
373
+ const springStart = datasetStart.add(SPRING_START_DAY, "days");
374
+ const springEnd = datasetStart.add(SPRING_END_DAY, "days");
375
+ const shockStart = datasetStart.add(SHOCK_START_DAY, "days");
376
+ const shockEnd = datasetStart.add(SHOCK_END_DAY, "days");
377
+ const luxuryStart = datasetStart.add(LUXURY_RELEASE_DAY, "days");
378
+ userEvents.forEach(e => {
379
+ const t = dayjs.utc(e.time);
380
+ const inSpring = (t.isAfter(springStart) || t.isSame(springStart)) && t.isBefore(springEnd);
381
+ if (inSpring && e.event === "tour scheduled" && typeof e.duration_mins === "number") {
382
+ e.duration_mins = Math.round(e.duration_mins * SPRING_TOUR_DURATION_MULT);
383
+ }
384
+ // Spring offer_price boost moved to end (after all cloning)
385
+ if (e.event === "mortgage pre-approval") {
386
+ if ((t.isAfter(shockStart) || t.isSame(shockStart)) && t.isBefore(shockEnd)) {
387
+ e.mortgage_rate = SHOCK_MORTGAGE_RATE;
388
+ }
389
+ }
390
+ if (e.event === "property listed" && t.isAfter(luxuryStart) && chance.bool({ likelihood: LUXURY_LISTING_LIKELIHOOD })) {
391
+ e.listing_price = chance.integer({ min: LUXURY_PRICE_MIN, max: LUXURY_PRICE_MAX });
392
+ }
393
+ });
394
+
395
+ const firstEventTime = userEvents.length > 0 ? dayjs(userEvents[0].time) : null;
396
+
397
+ // HOOK 2 (cont): MORTGAGE RATE SHOCK — drop 45% of post-day-75
398
+ // offer-submitted events. No flag.
399
+ const day75 = datasetStart.add(SHOCK_START_DAY, "days");
400
+ for (let i = userEvents.length - 1; i >= 0; i--) {
401
+ const evt = userEvents[i];
402
+ if (evt.event === "offer submitted" && dayjs(evt.time).isAfter(day75) && chance.bool({ likelihood: SHOCK_OFFER_DROP_LIKELIHOOD })) {
403
+ userEvents.splice(i, 1);
404
+ }
405
+ }
406
+
407
+ // HOOK 3: SAVED-SEARCH RETENTION — early savers (saved-search-created
408
+ // in first 7 days) get extra cloned view + tour events. Non-savers
409
+ // lose 80% of events after day 30. No flag.
410
+ const day7 = firstEventTime ? firstEventTime.add(SAVED_SEARCH_WINDOW_DAYS, "days") : null;
411
+ const day30 = datasetStart.add(NON_SAVER_CUTOFF_DAY, "days");
412
+ const hasSavedSearch = day7 ? userEvents.some(e =>
413
+ e.event === "saved search created" && dayjs(e.time).isBefore(day7)
414
+ ) : false;
415
+
416
+ if (hasSavedSearch) {
417
+ const viewTemplate = userEvents.find(e => e.event === "property viewed");
418
+ const tourTemplate = userEvents.find(e => e.event === "tour scheduled");
419
+ if (viewTemplate) {
420
+ const extras = chance.integer({ min: SAVED_SEARCH_VIEW_CLONES_MIN, max: SAVED_SEARCH_VIEW_CLONES_MAX });
421
+ for (let i = 0; i < extras; i++) {
422
+ const offset = chance.integer({ min: 10, max: NUM_DAYS - 10 });
423
+ userEvents.push({
424
+ ...viewTemplate,
425
+ time: datasetStart.add(offset, "days").add(chance.integer({ min: 0, max: 23 }), "hours").toISOString(),
426
+ user_id: viewTemplate.user_id,
427
+ listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
428
+ listing_price: chance.integer({ min: 200000, max: 1200000 }),
429
+ });
430
+ }
431
+ }
432
+ if (tourTemplate) {
433
+ const extras = chance.integer({ min: SAVED_SEARCH_TOUR_CLONES_MIN, max: SAVED_SEARCH_TOUR_CLONES_MAX });
434
+ for (let i = 0; i < extras; i++) {
435
+ const offset = chance.integer({ min: 15, max: NUM_DAYS - 10 });
436
+ userEvents.push({
437
+ ...tourTemplate,
438
+ time: datasetStart.add(offset, "days").add(chance.integer({ min: 8, max: 18 }), "hours").toISOString(),
439
+ user_id: tourTemplate.user_id,
440
+ listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
441
+ });
442
+ }
443
+ }
444
+ } else {
445
+ for (let i = userEvents.length - 1; i >= 0; i--) {
446
+ const evt = userEvents[i];
447
+ if (dayjs(evt.time).isAfter(day30) && chance.bool({ likelihood: NON_SAVER_DROP_LIKELIHOOD })) {
448
+ userEvents.splice(i, 1);
449
+ }
450
+ }
451
+ }
452
+
453
+ // HOOK 4: PRE-APPROVED BUYER CONVERSION — clone 4-6 extra
454
+ // offer-submitted events for users with a mortgage pre-approval
455
+ // event. No flag.
456
+ const hasPreApproval = userEvents.some(e => e.event === "mortgage pre-approval");
457
+ if (hasPreApproval) {
458
+ profile.pre_approval_status = "approved";
459
+ const offerTemplate = userEvents.find(e => e.event === "offer submitted");
460
+ if (offerTemplate) {
461
+ const extras = chance.integer({ min: PRE_APPROVAL_OFFER_CLONES_MIN, max: PRE_APPROVAL_OFFER_CLONES_MAX });
462
+ for (let i = 0; i < extras; i++) {
463
+ const offset = chance.integer({ min: 20, max: NUM_DAYS - 5 });
464
+ userEvents.push({
465
+ ...offerTemplate,
466
+ time: datasetStart.add(offset, "days").add(chance.integer({ min: 8, max: 20 }), "hours").toISOString(),
467
+ user_id: offerTemplate.user_id,
468
+ listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
469
+ offer_price: chance.integer({ min: 250000, max: 1200000 }),
470
+ listing_price: chance.integer({ min: 250000, max: 1200000 }),
471
+ });
472
+ }
473
+ }
474
+ }
475
+
476
+ // HOOK 5: TOP-TIER AGENT ADVANTAGE — Premier agents get 2 extra
477
+ // cloned listings per existing (3x rate) and 1 extra clone per
478
+ // sale (2x rate). Reads agent_tier from profile. No flag.
479
+ if (profile.agent_tier === "Premier") {
480
+ const listTemplate = userEvents.find(e => e.event === "property listed");
481
+ const soldTemplate = userEvents.find(e => e.event === "property sold");
482
+
483
+ if (listTemplate) {
484
+ const existingListings = userEvents.filter(e => e.event === "property listed").length;
485
+ const extras = existingListings * PREMIER_LISTING_CLONE_MULT;
486
+ for (let i = 0; i < extras; i++) {
487
+ const offset = chance.integer({ min: 5, max: NUM_DAYS - 5 });
488
+ userEvents.push({
489
+ ...listTemplate,
490
+ time: datasetStart.add(offset, "days").add(chance.integer({ min: 8, max: 18 }), "hours").toISOString(),
491
+ user_id: listTemplate.user_id,
492
+ listing_price: chance.integer({ min: 300000, max: 1500000 }),
493
+ listing_status: chance.pickone(["active", "pending", "coming soon"]),
494
+ });
495
+ }
496
+ }
497
+ if (soldTemplate) {
498
+ const existingSales = userEvents.filter(e => e.event === "property sold").length;
499
+ const extras = existingSales * PREMIER_SALE_CLONE_MULT;
500
+ for (let i = 0; i < extras; i++) {
501
+ const offset = chance.integer({ min: 10, max: NUM_DAYS - 5 });
502
+ userEvents.push({
503
+ ...soldTemplate,
504
+ time: datasetStart.add(offset, "days").add(chance.integer({ min: 9, max: 17 }), "hours").toISOString(),
505
+ user_id: soldTemplate.user_id,
506
+ sale_price: chance.integer({ min: 300000, max: 1500000 }),
507
+ days_on_market: chance.integer({ min: 5, max: 90 }),
508
+ });
509
+ }
510
+ }
511
+ }
512
+
513
+ // HOOK 6: TOUR-TO-OFFER POWER USERS — users with both virtual
514
+ // tour AND in-person tour get 5-7 extra cloned offers. No flag.
515
+ const hasVirtualTour = userEvents.some(e => e.event === "virtual tour");
516
+ const hasInPersonTour = userEvents.some(e => e.event === "in-person tour");
517
+ if (hasVirtualTour && hasInPersonTour) {
518
+ const offerTemplate = userEvents.find(e => e.event === "offer submitted");
519
+ if (offerTemplate) {
520
+ const extras = chance.integer({ min: DUAL_TOUR_OFFER_CLONES_MIN, max: DUAL_TOUR_OFFER_CLONES_MAX });
521
+ for (let i = 0; i < extras; i++) {
522
+ const offset = chance.integer({ min: 15, max: NUM_DAYS - 5 });
523
+ userEvents.push({
524
+ ...offerTemplate,
525
+ time: datasetStart.add(offset, "days").add(chance.integer({ min: 8, max: 20 }), "hours").toISOString(),
526
+ user_id: offerTemplate.user_id,
527
+ listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
528
+ offer_price: chance.integer({ min: 300000, max: 1500000 }),
529
+ listing_price: chance.integer({ min: 300000, max: 1500000 }),
530
+ });
531
+ }
532
+ }
533
+ }
534
+
535
+ // HOOK 7 (cont): LUXURY LISTING RELEASE — ~3% of users (by hash)
536
+ // get 3-6 extra luxury property-viewed events after day 50. No flag.
537
+ if (userEvents.length > 0) {
538
+ const userId = userEvents[0].user_id || "";
539
+ const isLuxuryBrowser = typeof userId === "string" && userId.length > 0 && userId.charCodeAt(0) % LUXURY_USER_HASH_MOD === 0;
540
+ if (isLuxuryBrowser) {
541
+ const viewTemplate = userEvents.find(e => e.event === "property viewed");
542
+ if (viewTemplate) {
543
+ const extras = chance.integer({ min: LUXURY_VIEW_CLONES_MIN, max: LUXURY_VIEW_CLONES_MAX });
544
+ for (let i = 0; i < extras; i++) {
545
+ const offset = chance.integer({ min: LUXURY_RELEASE_DAY, max: NUM_DAYS - 5 });
546
+ userEvents.push({
547
+ ...viewTemplate,
548
+ time: datasetStart.add(offset, "days").add(chance.integer({ min: 9, max: 21 }), "hours").toISOString(),
549
+ user_id: viewTemplate.user_id,
550
+ listing_id: `LST-${chance.integer({ min: 90000, max: 99999 })}`,
551
+ listing_price: chance.integer({ min: LUXURY_PRICE_MIN, max: LUXURY_PRICE_MAX }),
552
+ property_type: chance.pickone(["Single Family", "Condo"]),
553
+ bedrooms: chance.integer({ min: 4, max: 8 }),
554
+ square_feet: chance.integer({ min: 4000, max: 15000 }),
555
+ });
556
+ }
557
+ }
558
+ }
559
+ }
560
+
561
+ // HOOK 8: COLD-LEAD CHURN — users who view but never save in
562
+ // first 14 days lose 90% of post-day-14 events. No flag.
563
+ const day14 = firstEventTime ? firstEventTime.add(COLD_LEAD_WINDOW_DAYS, "days") : null;
564
+ if (day14) {
565
+ const earlyEvents = userEvents.filter(e => dayjs(e.time).isBefore(day14));
566
+ const hasView = earlyEvents.some(e => e.event === "property viewed");
567
+ const hasSave = earlyEvents.some(e => e.event === "property saved");
568
+ if (hasView && !hasSave) {
569
+ for (let i = userEvents.length - 1; i >= 0; i--) {
570
+ const evt = userEvents[i];
571
+ if (dayjs(evt.time).isAfter(day14) && chance.bool({ likelihood: COLD_LEAD_DROP_LIKELIHOOD })) {
572
+ userEvents.splice(i, 1);
573
+ }
574
+ }
575
+ }
576
+ }
577
+
578
+ // HOOK 9: PROPERTY-VIEWED MAGIC NUMBER (no flags)
579
+ // Sweet 6-12 views → +30% offer_price on offer submitted events.
580
+ // Over 13+ → drop 35% of offer submitted events (tire-kickers).
581
+ const viewCount = userEvents.filter(e => e.event === "property viewed").length;
582
+ if (viewCount >= VIEW_SWEET_MIN && viewCount <= VIEW_SWEET_MAX) {
583
+ userEvents.forEach(e => {
584
+ if (e.event === "offer submitted" && typeof e.offer_price === "number") {
585
+ e.offer_price = Math.round(e.offer_price * VIEW_OFFER_PRICE_BOOST);
586
+ }
587
+ });
588
+ } else if (viewCount >= VIEW_OVER_THRESHOLD) {
589
+ for (let i = userEvents.length - 1; i >= 0; i--) {
590
+ if (userEvents[i].event === "offer submitted" && chance.bool({ likelihood: VIEW_OVER_OFFER_DROP_LIKELIHOOD })) {
591
+ userEvents.splice(i, 1);
592
+ }
593
+ }
594
+ }
595
+
596
+ // HOOK 1 (cont): Spring offer_price boost — runs AFTER all cloning
597
+ // so cloned offers in the spring window also get the 2.5x boost
598
+ userEvents.forEach(e => {
599
+ if (e.event !== "offer submitted") return;
600
+ const t = dayjs.utc(e.time);
601
+ if ((t.isAfter(springStart) || t.isSame(springStart)) && t.isBefore(springEnd)) {
602
+ e.offer_price = Math.floor((e.offer_price || 400000) * SPRING_OFFER_PRICE_MULT);
603
+ }
604
+ });
605
+
606
+ return record;
607
+ }
608
+
609
+ // ── CONFIG ──
293
610
  /** @type {Config} */
294
611
  const config = {
295
612
  version: 2,
296
- token,
297
613
  seed: SEED,
298
- datasetStart: "2026-01-01T00:00:00Z",
299
- datasetEnd: "2026-05-01T23:59:59Z",
300
- // numDays: num_days,
301
- avgEventsPerUserPerDay: avg_events_per_user_per_day,
302
- numUsers: num_users,
303
- hasAnonIds: true,
304
- avgDevicePerUser: 2,
305
- hasSessionIds: true,
614
+ datasetStart: DATASET_START,
615
+ datasetEnd: DATASET_END,
616
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
617
+ numUsers: NUM_USERS,
306
618
  format: "json",
307
619
  gzip: true,
308
- alsoInferFunnels: false,
309
- hasLocation: true,
310
- hasAndroidDevices: true,
311
- hasIOSDevices: true,
312
- hasDesktopDevices: true,
313
- hasBrowser: true,
314
- hasCampaigns: false,
315
- isAnonymous: false,
316
- hasAdSpend: false,
317
-
318
- hasAvatar: true,
620
+ credentials: {
621
+ token,
622
+ },
623
+ switches: {
624
+ hasSessionIds: true,
625
+ alsoInferFunnels: false,
626
+ hasLocation: true,
627
+ hasAndroidDevices: true,
628
+ hasIOSDevices: true,
629
+ hasDesktopDevices: true,
630
+ hasBrowser: true,
631
+ hasCampaigns: false,
632
+ isAnonymous: false,
633
+ hasAdSpend: false,
634
+ hasAvatar: true,
635
+ },
636
+ identity: {
637
+ avgDevicePerUser: 2,
638
+ },
319
639
 
320
640
  concurrency: 1,
321
641
  writeToDisk: false,
@@ -344,7 +664,8 @@ const config = {
344
664
  conversionRate: 40,
345
665
  timeToConvert: 24,
346
666
  weight: 5,
347
- name: "Tour Funnel"
667
+ name: "Tour Funnel",
668
+ reentry: true,
348
669
  },
349
670
  {
350
671
  sequence: ["property listed", "open house attended", "property sold"],
@@ -379,6 +700,7 @@ const config = {
379
700
  {
380
701
  event: "property viewed",
381
702
  weight: 12,
703
+ isStrictEvent: false,
382
704
  properties: {
383
705
  listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
384
706
  listing_price: u.weighNumRange(150000, 1500000, 0.4, 60),
@@ -422,6 +744,7 @@ const config = {
422
744
  {
423
745
  event: "tour scheduled",
424
746
  weight: 4,
747
+ isStrictEvent: false,
425
748
  properties: {
426
749
  listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
427
750
  agent_id: () => `AGT-${chance.integer({ min: 1000, max: 9999 })}`,
@@ -440,6 +763,7 @@ const config = {
440
763
  {
441
764
  event: "offer submitted",
442
765
  weight: 2,
766
+ isStrictEvent: false,
443
767
  properties: {
444
768
  listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
445
769
  offer_price: u.weighNumRange(200000, 1500000, 0.4, 40),
@@ -465,6 +789,7 @@ const config = {
465
789
  {
466
790
  event: "property listed",
467
791
  weight: 3,
792
+ isStrictEvent: false,
468
793
  properties: {
469
794
  listing_price: u.weighNumRange(200000, 1500000, 0.4, 50),
470
795
  property_type: ["Single Family", "Condo", "Townhouse", "Multi-Family"],
@@ -475,6 +800,7 @@ const config = {
475
800
  {
476
801
  event: "property sold",
477
802
  weight: 1,
803
+ isStrictEvent: false,
478
804
  properties: {
479
805
  sale_price: u.weighNumRange(200000, 1500000, 0.4, 40),
480
806
  days_on_market: u.weighNumRange(5, 180, 0.4, 30),
@@ -536,280 +862,9 @@ const config = {
536
862
 
537
863
  lookupTables: [],
538
864
 
539
- hook: function (record, type, meta) {
540
- // HOOK 10 (T2C): TOUR FUNNEL TIME-TO-CONVERT (funnel-post)
541
- // Premier agents move users through Tour funnel 1.4x faster
542
- // (factor 0.71); Standard agents 1.3x slower (factor 1.3).
543
- if (type === "funnel-post") {
544
- const segment = meta?.profile?.agent_tier;
545
- if (Array.isArray(record) && record.length > 1) {
546
- const factor = (
547
- segment === "Premier" ? 0.71 :
548
- segment === "Standard" ? 1.3 :
549
- 1.0
550
- );
551
- if (factor !== 1.0) {
552
- for (let i = 1; i < record.length; i++) {
553
- const prev = dayjs(record[i - 1].time);
554
- const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
555
- record[i].time = prev.add(newGap, "milliseconds").toISOString();
556
- }
557
- }
558
- }
559
- }
560
-
561
- // HOOKS 1, 2, 7 (time-window event-level effects) moved to everything hook —
562
- // event hook fires before bunchIntoSessions reshuffles timestamps, so day-window
563
- // tags leak across boundaries.
564
-
565
- if (type === "everything") {
566
- const datasetStart = dayjs.unix(meta.datasetStart);
567
- const userEvents = record;
568
- const profile = meta.profile;
569
-
570
- // Stamp superProps from profile for consistency
571
- userEvents.forEach(e => {
572
- e.user_type = profile.user_type;
573
- e.preferred_location = profile.preferred_location;
574
- e.property_preference = profile.property_preference;
575
- });
576
-
577
- // HOOK 1: SPRING BUYING SEASON — d30-60 tour duration 3x, offer_price 2.5x
578
- // HOOK 2: MORTGAGE RATE SHOCK — d75-89 mortgage_rate pinned 7.5
579
- // HOOK 7: LUXURY LISTING RELEASE — post-d50, 3% of listings priced $5M-$15M
580
- const springStart = datasetStart.add(30, "days");
581
- const springEnd = datasetStart.add(60, "days");
582
- const shockStart = datasetStart.add(75, "days");
583
- const shockEnd = datasetStart.add(89, "days");
584
- const luxuryStart = datasetStart.add(50, "days");
585
- userEvents.forEach(e => {
586
- const t = dayjs.utc(e.time);
587
- const inSpring = (t.isAfter(springStart) || t.isSame(springStart)) && t.isBefore(springEnd);
588
- if (inSpring && e.event === "tour scheduled" && typeof e.duration_mins === "number") {
589
- e.duration_mins = Math.round(e.duration_mins * 3);
590
- }
591
- // Spring offer_price boost moved to end (after all cloning)
592
- if (e.event === "mortgage pre-approval") {
593
- if ((t.isAfter(shockStart) || t.isSame(shockStart)) && t.isBefore(shockEnd)) {
594
- e.mortgage_rate = 7.5;
595
- }
596
- }
597
- if (e.event === "property listed" && t.isAfter(luxuryStart) && chance.bool({ likelihood: 3 })) {
598
- e.listing_price = chance.integer({ min: 5000000, max: 15000000 });
599
- }
600
- });
601
-
602
- const firstEventTime = userEvents.length > 0 ? dayjs(userEvents[0].time) : null;
603
-
604
- // HOOK 2 (cont): MORTGAGE RATE SHOCK — drop 45% of post-day-75
605
- // offer-submitted events. No flag.
606
- const day75 = datasetStart.add(75, "days");
607
- for (let i = userEvents.length - 1; i >= 0; i--) {
608
- const evt = userEvents[i];
609
- if (evt.event === "offer submitted" && dayjs(evt.time).isAfter(day75) && chance.bool({ likelihood: 45 })) {
610
- userEvents.splice(i, 1);
611
- }
612
- }
613
-
614
- // HOOK 3: SAVED-SEARCH RETENTION — early savers (saved-search-created
615
- // in first 7 days) get extra cloned view + tour events. Non-savers
616
- // lose 80% of events after day 30. No flag.
617
- const day7 = firstEventTime ? firstEventTime.add(7, "days") : null;
618
- const day30 = datasetStart.add(30, "days");
619
- const hasSavedSearch = day7 ? userEvents.some(e =>
620
- e.event === "saved search created" && dayjs(e.time).isBefore(day7)
621
- ) : false;
622
-
623
- if (hasSavedSearch) {
624
- const viewTemplate = userEvents.find(e => e.event === "property viewed");
625
- const tourTemplate = userEvents.find(e => e.event === "tour scheduled");
626
- if (viewTemplate) {
627
- const extras = chance.integer({ min: 3, max: 8 });
628
- for (let i = 0; i < extras; i++) {
629
- const offset = chance.integer({ min: 10, max: num_days - 10 });
630
- userEvents.push({
631
- ...viewTemplate,
632
- time: datasetStart.add(offset, "days").add(chance.integer({ min: 0, max: 23 }), "hours").toISOString(),
633
- user_id: viewTemplate.user_id,
634
- listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
635
- listing_price: chance.integer({ min: 200000, max: 1200000 }),
636
- });
637
- }
638
- }
639
- if (tourTemplate) {
640
- const extras = chance.integer({ min: 1, max: 3 });
641
- for (let i = 0; i < extras; i++) {
642
- const offset = chance.integer({ min: 15, max: num_days - 10 });
643
- userEvents.push({
644
- ...tourTemplate,
645
- time: datasetStart.add(offset, "days").add(chance.integer({ min: 8, max: 18 }), "hours").toISOString(),
646
- user_id: tourTemplate.user_id,
647
- listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
648
- });
649
- }
650
- }
651
- } else {
652
- for (let i = userEvents.length - 1; i >= 0; i--) {
653
- const evt = userEvents[i];
654
- if (dayjs(evt.time).isAfter(day30) && chance.bool({ likelihood: 80 })) {
655
- userEvents.splice(i, 1);
656
- }
657
- }
658
- }
659
-
660
- // HOOK 4: PRE-APPROVED BUYER CONVERSION — clone 4-6 extra
661
- // offer-submitted events for users with a mortgage pre-approval
662
- // event. No flag.
663
- const hasPreApproval = userEvents.some(e => e.event === "mortgage pre-approval");
664
- if (hasPreApproval) {
665
- profile.pre_approval_status = "approved";
666
- const offerTemplate = userEvents.find(e => e.event === "offer submitted");
667
- if (offerTemplate) {
668
- const extras = chance.integer({ min: 4, max: 6 });
669
- for (let i = 0; i < extras; i++) {
670
- const offset = chance.integer({ min: 20, max: num_days - 5 });
671
- userEvents.push({
672
- ...offerTemplate,
673
- time: datasetStart.add(offset, "days").add(chance.integer({ min: 8, max: 20 }), "hours").toISOString(),
674
- user_id: offerTemplate.user_id,
675
- listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
676
- offer_price: chance.integer({ min: 250000, max: 1200000 }),
677
- listing_price: chance.integer({ min: 250000, max: 1200000 }),
678
- });
679
- }
680
- }
681
- }
682
-
683
- // HOOK 5: TOP-TIER AGENT ADVANTAGE — Premier agents get 2 extra
684
- // cloned listings per existing (3x rate) and 1 extra clone per
685
- // sale (2x rate). Reads agent_tier from profile. No flag.
686
- if (profile.agent_tier === "Premier") {
687
- const listTemplate = userEvents.find(e => e.event === "property listed");
688
- const soldTemplate = userEvents.find(e => e.event === "property sold");
689
-
690
- if (listTemplate) {
691
- const existingListings = userEvents.filter(e => e.event === "property listed").length;
692
- const extras = existingListings * 2;
693
- for (let i = 0; i < extras; i++) {
694
- const offset = chance.integer({ min: 5, max: num_days - 5 });
695
- userEvents.push({
696
- ...listTemplate,
697
- time: datasetStart.add(offset, "days").add(chance.integer({ min: 8, max: 18 }), "hours").toISOString(),
698
- user_id: listTemplate.user_id,
699
- listing_price: chance.integer({ min: 300000, max: 1500000 }),
700
- listing_status: chance.pickone(["active", "pending", "coming soon"]),
701
- });
702
- }
703
- }
704
- if (soldTemplate) {
705
- const existingSales = userEvents.filter(e => e.event === "property sold").length;
706
- for (let i = 0; i < existingSales; i++) {
707
- const offset = chance.integer({ min: 10, max: num_days - 5 });
708
- userEvents.push({
709
- ...soldTemplate,
710
- time: datasetStart.add(offset, "days").add(chance.integer({ min: 9, max: 17 }), "hours").toISOString(),
711
- user_id: soldTemplate.user_id,
712
- sale_price: chance.integer({ min: 300000, max: 1500000 }),
713
- days_on_market: chance.integer({ min: 5, max: 90 }),
714
- });
715
- }
716
- }
717
- }
718
-
719
- // HOOK 6: TOUR-TO-OFFER POWER USERS — users with both virtual
720
- // tour AND in-person tour get 5-7 extra cloned offers. No flag.
721
- const hasVirtualTour = userEvents.some(e => e.event === "virtual tour");
722
- const hasInPersonTour = userEvents.some(e => e.event === "in-person tour");
723
- if (hasVirtualTour && hasInPersonTour) {
724
- const offerTemplate = userEvents.find(e => e.event === "offer submitted");
725
- if (offerTemplate) {
726
- const extras = chance.integer({ min: 5, max: 7 });
727
- for (let i = 0; i < extras; i++) {
728
- const offset = chance.integer({ min: 15, max: num_days - 5 });
729
- userEvents.push({
730
- ...offerTemplate,
731
- time: datasetStart.add(offset, "days").add(chance.integer({ min: 8, max: 20 }), "hours").toISOString(),
732
- user_id: offerTemplate.user_id,
733
- listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
734
- offer_price: chance.integer({ min: 300000, max: 1500000 }),
735
- listing_price: chance.integer({ min: 300000, max: 1500000 }),
736
- });
737
- }
738
- }
739
- }
740
-
741
- // HOOK 7 (cont): LUXURY LISTING RELEASE — ~3% of users (by hash)
742
- // get 3-6 extra luxury property-viewed events after day 50. No flag.
743
- if (userEvents.length > 0) {
744
- const userId = userEvents[0].user_id || "";
745
- const isLuxuryBrowser = typeof userId === "string" && userId.length > 0 && userId.charCodeAt(0) % 33 === 0;
746
- if (isLuxuryBrowser) {
747
- const viewTemplate = userEvents.find(e => e.event === "property viewed");
748
- if (viewTemplate) {
749
- const extras = chance.integer({ min: 3, max: 6 });
750
- for (let i = 0; i < extras; i++) {
751
- const offset = chance.integer({ min: 50, max: num_days - 5 });
752
- userEvents.push({
753
- ...viewTemplate,
754
- time: datasetStart.add(offset, "days").add(chance.integer({ min: 9, max: 21 }), "hours").toISOString(),
755
- user_id: viewTemplate.user_id,
756
- listing_id: `LST-${chance.integer({ min: 90000, max: 99999 })}`,
757
- listing_price: chance.integer({ min: 5000000, max: 15000000 }),
758
- property_type: chance.pickone(["Single Family", "Condo"]),
759
- bedrooms: chance.integer({ min: 4, max: 8 }),
760
- square_feet: chance.integer({ min: 4000, max: 15000 }),
761
- });
762
- }
763
- }
764
- }
765
- }
766
-
767
- // HOOK 8: COLD-LEAD CHURN — users who view but never save in
768
- // first 14 days lose 90% of post-day-14 events. No flag.
769
- const day14 = firstEventTime ? firstEventTime.add(14, "days") : null;
770
- if (day14) {
771
- const earlyEvents = userEvents.filter(e => dayjs(e.time).isBefore(day14));
772
- const hasView = earlyEvents.some(e => e.event === "property viewed");
773
- const hasSave = earlyEvents.some(e => e.event === "property saved");
774
- if (hasView && !hasSave) {
775
- for (let i = userEvents.length - 1; i >= 0; i--) {
776
- const evt = userEvents[i];
777
- if (dayjs(evt.time).isAfter(day14) && chance.bool({ likelihood: 90 })) {
778
- userEvents.splice(i, 1);
779
- }
780
- }
781
- }
782
- }
783
-
784
- // HOOK 9: PROPERTY-VIEWED MAGIC NUMBER (no flags)
785
- // Sweet 6-12 views → +30% offer_price on offer submitted events.
786
- // Over 13+ → drop 35% of offer submitted events (tire-kickers).
787
- const viewCount = userEvents.filter(e => e.event === "property viewed").length;
788
- if (viewCount >= 6 && viewCount <= 12) {
789
- userEvents.forEach(e => {
790
- if (e.event === "offer submitted" && typeof e.offer_price === "number") {
791
- e.offer_price = Math.round(e.offer_price * 1.3);
792
- }
793
- });
794
- } else if (viewCount >= 13) {
795
- for (let i = userEvents.length - 1; i >= 0; i--) {
796
- if (userEvents[i].event === "offer submitted" && chance.bool({ likelihood: 35 })) {
797
- userEvents.splice(i, 1);
798
- }
799
- }
800
- }
801
-
802
- // HOOK 1 (cont): Spring offer_price boost — runs AFTER all cloning
803
- // so cloned offers in the spring window also get the 2.5x boost
804
- userEvents.forEach(e => {
805
- if (e.event !== "offer submitted") return;
806
- const t = dayjs.utc(e.time);
807
- if ((t.isAfter(springStart) || t.isSame(springStart)) && t.isBefore(springEnd)) {
808
- e.offer_price = Math.floor((e.offer_price || 400000) * 2.5);
809
- }
810
- });
811
- }
812
-
865
+ hook(record, type, meta) {
866
+ if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
867
+ if (type === "everything") return handleEverythingHooks(record, meta);
813
868
  return record;
814
869
  }
815
870
  };