@ak--47/dungeon-master 1.3.0 → 1.4.0

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 (98) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dungeons/technical/array-of-object-lookup.js +0 -2
  3. package/dungeons/technical/hook-helpers-verify.js +89 -0
  4. package/dungeons/technical/identity-model-verify.js +47 -0
  5. package/dungeons/technical/pattern-aggregate-by-bin.js +41 -0
  6. package/dungeons/technical/pattern-attributed-by-source.js +42 -0
  7. package/dungeons/technical/pattern-frequency-by-frequency.js +40 -0
  8. package/dungeons/technical/pattern-funnel-frequency.js +54 -0
  9. package/dungeons/technical/pattern-ttc-by-segment.js +45 -0
  10. package/dungeons/technical/simple.js +3 -4
  11. package/dungeons/technical/simplest-schema.json +5 -0
  12. package/dungeons/technical/text-generation.js +1 -1
  13. package/dungeons/vertical/ai-platform.js +216 -164
  14. package/dungeons/vertical/community.js +74 -41
  15. package/dungeons/vertical/crypto.js +343 -233
  16. package/dungeons/vertical/dating.js +246 -292
  17. package/dungeons/vertical/devtools.js +179 -70
  18. package/dungeons/vertical/ecommerce.js +275 -122
  19. package/dungeons/vertical/education.js +345 -312
  20. package/dungeons/vertical/fintech.js +445 -314
  21. package/dungeons/vertical/fitness.js +199 -165
  22. package/dungeons/vertical/food-delivery.js +309 -330
  23. package/dungeons/vertical/gaming.js +387 -241
  24. package/dungeons/vertical/healthcare.js +149 -66
  25. package/dungeons/vertical/insurance-application.js +173 -75
  26. package/dungeons/vertical/logistics.js +107 -12
  27. package/dungeons/vertical/marketplace.js +171 -76
  28. package/dungeons/vertical/media.js +259 -402
  29. package/dungeons/vertical/real-estate.js +336 -318
  30. package/dungeons/vertical/sass.js +294 -288
  31. package/dungeons/vertical/social.js +289 -234
  32. package/dungeons/vertical/travel.js +66 -20
  33. package/index.js +17 -17
  34. package/lib/core/config-validator.js +226 -175
  35. package/lib/core/context.js +10 -24
  36. package/lib/core/storage.js +6 -1
  37. package/lib/generators/events.js +57 -104
  38. package/lib/generators/funnels.js +212 -93
  39. package/lib/generators/mirror.js +3 -2
  40. package/lib/generators/product-names.js +1 -1
  41. package/lib/generators/scd.js +2 -1
  42. package/lib/generators/text.js +1 -1
  43. package/lib/hook-helpers/_internal.js +23 -0
  44. package/lib/hook-helpers/cohort.js +124 -0
  45. package/lib/hook-helpers/identity.js +56 -0
  46. package/lib/hook-helpers/index.js +44 -0
  47. package/lib/hook-helpers/inject.js +99 -0
  48. package/lib/hook-helpers/mutate.js +151 -0
  49. package/lib/hook-helpers/timing.js +99 -0
  50. package/lib/hook-patterns/aggregate-per-user-by-bin.js +38 -0
  51. package/lib/hook-patterns/attributed-by-source.js +72 -0
  52. package/lib/hook-patterns/frequency-by-frequency.js +46 -0
  53. package/lib/hook-patterns/funnel-frequency-breakdown.js +73 -0
  54. package/lib/hook-patterns/index.js +14 -0
  55. package/lib/hook-patterns/time-to-convert-by-segment.js +41 -0
  56. package/lib/orchestrators/user-loop.js +143 -293
  57. package/lib/utils/utils.js +97 -52
  58. package/lib/verify/emulate-breakdown.js +281 -0
  59. package/lib/verify/index.js +12 -0
  60. package/lib/verify/verify-dungeon.js +61 -0
  61. package/package.json +6 -4
  62. package/scripts/smoke-test-all.mjs +162 -0
  63. package/scripts/verify-runner.mjs +72 -24
  64. package/types.d.ts +434 -225
  65. package/dungeons/technical/ad-spend-schema.json +0 -128
  66. package/dungeons/technical/anonymous-users-schema.json +0 -92
  67. package/dungeons/technical/array-of-object-lookup-schema.json +0 -191
  68. package/dungeons/technical/experiments-schema.json +0 -203
  69. package/dungeons/technical/foobar-schema.json +0 -362
  70. package/dungeons/technical/group-analytics-schema.json +0 -241
  71. package/dungeons/technical/mirror-strategies-schema.json +0 -84
  72. package/dungeons/technical/nested-objects-schema.json +0 -145
  73. package/dungeons/technical/retention-cadence-schema.json +0 -37
  74. package/dungeons/technical/sanity-schema.json +0 -185
  75. package/dungeons/technical/scale-test-schema.json +0 -70
  76. package/dungeons/technical/scd-schema.json +0 -467
  77. package/dungeons/technical/simple-schema.json +0 -362
  78. package/dungeons/technical/text-generation-schema.json +0 -1062
  79. package/dungeons/vertical/ai-platform-schema.json +0 -617
  80. package/dungeons/vertical/community-schema.json +0 -579
  81. package/dungeons/vertical/crypto-schema.json +0 -546
  82. package/dungeons/vertical/dating-schema.json +0 -401
  83. package/dungeons/vertical/devtools-schema.json +0 -601
  84. package/dungeons/vertical/ecommerce-schema.json +0 -604
  85. package/dungeons/vertical/education-schema.json +0 -5686
  86. package/dungeons/vertical/fintech-schema.json +0 -630
  87. package/dungeons/vertical/fitness-schema.json +0 -530
  88. package/dungeons/vertical/food-delivery-schema.json +0 -36728
  89. package/dungeons/vertical/gaming-schema.json +0 -2703
  90. package/dungeons/vertical/healthcare-schema.json +0 -549
  91. package/dungeons/vertical/insurance-application-schema.json +0 -485
  92. package/dungeons/vertical/logistics-schema.json +0 -574
  93. package/dungeons/vertical/marketplace-schema.json +0 -533
  94. package/dungeons/vertical/media-schema.json +0 -4749
  95. package/dungeons/vertical/real-estate-schema.json +0 -527
  96. package/dungeons/vertical/sass-schema.json +0 -3128
  97. package/dungeons/vertical/social-schema.json +0 -620
  98. package/dungeons/vertical/travel-schema.json +0 -580
@@ -1,7 +1,7 @@
1
1
  // ── TWEAK THESE ──
2
2
  const SEED = "homenest";
3
- const num_days = 150;
4
- const num_users = 6_000;
3
+ const num_days = 120;
4
+ const num_users = 10_000;
5
5
  const avg_events_per_user_per_day = 0.53;
6
6
  let token = "your-mixpanel-token";
7
7
 
@@ -48,91 +48,204 @@ const chance = u.initChance(SEED);
48
48
  * - Seller funnel: property listed → open house attended → property sold (35%)
49
49
  *
50
50
  * ===================================================================
51
- * ANALYTICS HOOKS (8 architected patterns)
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.
52
57
  * ===================================================================
53
58
  *
54
- * 1. SPRING BUYING SEASON (event hookTIME-BASED)
55
- * Days 30-60: spring_season=true on tours/offers. Tour events get
56
- * duration_mins 3x. Offer events get offer_price 2.5x.
57
- * → Insights: "tour scheduled" total over time, breakdown spring_season
58
- * (expect: 3x volume spike during days 30-60)
59
- * → Insights: "offer submitted" avg offer_price, breakdown spring_season
60
- * (expect: 2.5x average during spring)
61
- *
62
- * 2. MORTGAGE RATE SHOCK (event + everything hook — TIME-BASED)
63
- * Day 75: mortgage_rate jumps from 6.5 to 7.5. After day 75 and
64
- * before day 89, mortgage_rate forced to 7.5 on pre-approval events.
65
- * In everything hook, 45% of offer_submitted events after day 75
66
- * are removed (buyer pullback).
67
- * → Insights: "mortgage pre-approval" avg mortgage_rate over time
68
- * (expect: step change from ~6.5 to 7.5 at day 75)
69
- * → Insights: "offer submitted" total over time
70
- * (expect: ~45% volume drop after day 75)
71
- *
72
- * 3. SAVED-SEARCH RETENTION (everything hook — RETENTION)
73
- * Users who create a "saved search created" within first 7 days
74
- * get extra property_viewed and tour_scheduled events injected.
75
- * Non-savers lose 80% of events after day 30.
76
- * → Retention: any → any, segment has_saved_search = true vs false
77
- * (expect: dramatically higher retention for saved-search users)
78
- * → Insights: "property viewed" total, breakdown has_saved_search
79
- *
80
- * 4. PRE-APPROVED BUYER CONVERSION (everything hook — CONVERSION)
81
- * Users with a "mortgage pre-approval" event complete offer_submitted
82
- * 5x more often. Extra offer events injected, pre_approved=true.
83
- * → Insights: "offer submitted" total, breakdown pre_approved
84
- * (expect: 5x volume for pre_approved=true)
85
- * → Funnels: property viewed → tour scheduled → offer submitted,
86
- * segment pre_approved = true vs false
87
- *
88
- * 5. TOP-TIER AGENT ADVANTAGE (everything hook — SUBSCRIPTION TIER)
89
- * Users with agent_tier="Premier" from profile get 3x property_listed
90
- * events and 2x property_sold events. premier_agent=true.
91
- * → Insights: "property listed" total, breakdown premier_agent
92
- * (expect: Premier agents ~3x listings)
93
- * → Insights: "property sold" total, breakdown premier_agent
94
- * (expect: Premier agents ~2x sales)
95
- *
96
- * 6. TOUR-TO-OFFER POWER USERS (everything hook — BEHAVIORS TOGETHER)
97
- * Users who did BOTH "virtual tour" AND "in-person tour" get 6x
98
- * offer_submitted events injected. dual_tour=true on offers.
99
- * → Insights: "offer submitted" total, breakdown dual_tour
100
- * (expect: dual_tour=true ~6x volume)
101
- * → Funnels: virtual tour → in-person tour → offer submitted,
102
- * filter dual_tour = true
103
- *
104
- * 7. LUXURY LISTING RELEASE (event + everything hook — TIMED RELEASE)
105
- * Day 50: luxury listings appear. After day 50, 3% of property_listed
106
- * events get listing_price set to $5M+ and luxury=true. ~3% of users
107
- * (by hash) get extra luxury property_viewed events.
108
- * → Insights: "property listed" avg listing_price, filter luxury=true
109
- * (expect: $5M+ only after day 50)
110
- * → Insights: "property viewed" total, breakdown luxury
111
- * (expect: luxury=true cluster starting day 50)
112
- *
113
- * 8. COLD-LEAD CHURN (everything hook — CHURN)
114
- * Users who browse (property viewed) but never save (property saved)
115
- * in the first 14 days lose 90% of events after day 14.
116
- * → Retention: any → any, segment cold_lead = true vs false
117
- * (expect: cold_lead=true drops to near zero after day 14)
118
- * → Insights: any event total, filter cold_lead = true
59
+ * NOTE: All cohort effects are HIDDENno flag stamping. Discoverable
60
+ * only via behavioral cohorts (count event per user, then measure
61
+ * downstream) or raw-prop breakdowns (date, agent_tier).
119
62
  *
120
- * ===================================================================
121
- * ADVANCED ANALYSIS IDEAS
122
- * ===================================================================
63
+ * -------------------------------------------------------------------
64
+ * 1. SPRING BUYING SEASON (event)
65
+ * -------------------------------------------------------------------
66
+ *
67
+ * PATTERN: Days 30-60, tour-scheduled duration_mins boosted 3x and
68
+ * offer-submitted offer_price boosted 2.5x. Mutates raw props. No flag.
69
+ *
70
+ * HOW TO FIND IT IN MIXPANEL:
71
+ *
72
+ * Report 1: Avg Offer Price by Day
73
+ * - Report type: Insights
74
+ * - Event: "offer submitted"
75
+ * - Measure: Average of "offer_price"
76
+ * - Line chart by day
77
+ * - Expected: visible bulge days 30-60 (~ 2.5x baseline)
78
+ *
79
+ * REAL-WORLD ANALOGUE: Spring buying season elevates prices and tour intent.
80
+ *
81
+ * -------------------------------------------------------------------
82
+ * 2. MORTGAGE RATE SHOCK (event + everything)
83
+ * -------------------------------------------------------------------
84
+ *
85
+ * PATTERN: Days 75-89, mortgage_rate forced to 7.5 on pre-approval
86
+ * events. In everything hook, 45% of post-day-75 offer-submitted events
87
+ * dropped. No flag.
88
+ *
89
+ * HOW TO FIND IT IN MIXPANEL:
90
+ *
91
+ * Report 1: Mortgage Rate Step Change
92
+ * - Report type: Insights
93
+ * - Event: "mortgage pre-approval"
94
+ * - Measure: Average of "mortgage_rate"
95
+ * - Line chart by day
96
+ * - Expected: clear step change from ~ 6.5 to 7.5 at day 75
97
+ *
98
+ * Report 2: Offer Volume Drop After Rate Hike
99
+ * - Report type: Insights
100
+ * - Event: "offer submitted"
101
+ * - Measure: Total
102
+ * - Line chart by day
103
+ * - Expected: ~ 45% volume drop after day 75
104
+ *
105
+ * REAL-WORLD ANALOGUE: Buyer demand is sensitive to mortgage rates.
106
+ *
107
+ * -------------------------------------------------------------------
108
+ * 3. SAVED-SEARCH RETENTION (everything)
109
+ * -------------------------------------------------------------------
110
+ *
111
+ * PATTERN: Users who create saved-search-created in first 7 days get
112
+ * extra cloned property-viewed + tour-scheduled events. Non-savers lose
113
+ * 80% of post-day-30 events. No flag — discover via cohort builder.
114
+ *
115
+ * HOW TO FIND IT IN MIXPANEL:
116
+ *
117
+ * Report 1: Retention by Saved-Search Cohort
118
+ * - Report type: Retention
119
+ * - Cohort A: users with >= 1 "saved search created" in first 7 days
120
+ * - Cohort B: rest
121
+ * - Expected: A sustains higher retention through dataset
122
+ *
123
+ * REAL-WORLD ANALOGUE: Saved searches indicate buyer intent.
124
+ *
125
+ * -------------------------------------------------------------------
126
+ * 4. PRE-APPROVED BUYER CONVERSION (everything)
127
+ * -------------------------------------------------------------------
128
+ *
129
+ * PATTERN: Users with a mortgage-pre-approval event get 4-6 extra
130
+ * cloned offer-submitted events. No flag — discover via cohort.
131
+ *
132
+ * HOW TO FIND IT IN MIXPANEL:
133
+ *
134
+ * Report 1: Offers per User by Pre-Approval
135
+ * - Report type: Insights (with cohort)
136
+ * - Cohort A: users with >= 1 "mortgage pre-approval"
137
+ * - Cohort B: rest
138
+ * - Event: "offer submitted"
139
+ * - Measure: Total per user
140
+ * - Expected: A ~ 5x B
141
+ *
142
+ * REAL-WORLD ANALOGUE: Pre-approval is the strongest buyer indicator.
143
+ *
144
+ * -------------------------------------------------------------------
145
+ * 5. TOP-TIER AGENT ADVANTAGE (everything)
146
+ * -------------------------------------------------------------------
147
+ *
148
+ * PATTERN: Premier-tier agents get 2 extra cloned listings per existing
149
+ * (3x rate) and 1 extra cloned sale per existing (2x rate). Mutates
150
+ * cloned events with raw values; reads agent_tier from profile (existing
151
+ * SCD prop). Discover via agent_tier breakdown.
152
+ *
153
+ * HOW TO FIND IT IN MIXPANEL:
154
+ *
155
+ * Report 1: Listings per Agent by Tier
156
+ * - Report type: Insights
157
+ * - Event: "property listed"
158
+ * - Measure: Total per user
159
+ * - Breakdown: user "agent_tier"
160
+ * - Expected: Premier ~ 3x Standard
161
+ *
162
+ * REAL-WORLD ANALOGUE: Top producers list and close more.
123
163
  *
124
- * Cross-hook patterns:
125
- * - Spring + Rate Shock: Does the spring surge survive the rate hike?
126
- * - Saved Search + Cold Lead: Are saved-search users immune to churn?
127
- * - Pre-Approved + Dual Tour: Do pre-approved dual-tourers dominate offers?
128
- * - Premier Agent + Luxury: Do Premier agents list more luxury properties?
129
- * - Rate Shock + Pre-Approved: Do pre-approved buyers still submit after rate jump?
164
+ * -------------------------------------------------------------------
165
+ * 6. TOUR-TO-OFFER POWER USERS (everything)
166
+ * -------------------------------------------------------------------
130
167
  *
131
- * Cohort analysis:
132
- * - By user_type: Buyer vs Agent vs Both engagement
133
- * - By agent_tier: Standard vs Premier listing volume and close rate
134
- * - By property_preference: Which housing type converts best?
135
- * - By preferred_location: Urban vs Suburban vs Rural tour rates
168
+ * PATTERN: Users with both virtual-tour AND in-person-tour events get
169
+ * 5-7 extra cloned offer-submitted events. No flag discover via
170
+ * compound cohort builder.
171
+ *
172
+ * HOW TO FIND IT IN MIXPANEL:
173
+ *
174
+ * Report 1: Offers per User by Dual-Tour Cohort
175
+ * - Report type: Insights (with cohort)
176
+ * - Cohort A: users with both >= 1 "virtual tour" AND >= 1 "in-person tour"
177
+ * - Cohort B: rest
178
+ * - Event: "offer submitted"
179
+ * - Measure: Total per user
180
+ * - Expected: A ~ 6x B
181
+ *
182
+ * REAL-WORLD ANALOGUE: Dual-tour buyers self-qualify into serious bidders.
183
+ *
184
+ * -------------------------------------------------------------------
185
+ * 7. LUXURY LISTING RELEASE (event + everything)
186
+ * -------------------------------------------------------------------
187
+ *
188
+ * PATTERN: After day 50, 3% of property-listed events get listing_price
189
+ * set to $5M-$15M (raw mutation). ~3% of users (by id hash) get extra
190
+ * luxury cloned property-viewed events. No flag — discover via line
191
+ * chart by day on listing_price.
192
+ *
193
+ * HOW TO FIND IT IN MIXPANEL:
194
+ *
195
+ * Report 1: Listing Price Distribution Over Time
196
+ * - Report type: Insights
197
+ * - Event: "property listed"
198
+ * - Measure: Distribution of "listing_price"
199
+ * - Line chart by week
200
+ * - Expected: $5M+ outliers appear only after day 50
201
+ *
202
+ * REAL-WORLD ANALOGUE: Luxury inventory drop attracts HNW shoppers.
203
+ *
204
+ * -------------------------------------------------------------------
205
+ * 8. COLD-LEAD CHURN (everything)
206
+ * -------------------------------------------------------------------
207
+ *
208
+ * PATTERN: Users who view (property viewed) but never save (property
209
+ * saved) in the first 14 days lose 90% of events after day 14. No flag.
210
+ *
211
+ * HOW TO FIND IT IN MIXPANEL:
212
+ *
213
+ * Report 1: Cold-Lead Retention
214
+ * - Report type: Retention
215
+ * - Cohort A: users with >= 1 "property viewed" but 0 "property saved" in first 14 days
216
+ * - Cohort B: rest
217
+ * - Expected: A retention drops to near-zero after day 14
218
+ *
219
+ * REAL-WORLD ANALOGUE: Browsers who never save are window-shoppers.
220
+ *
221
+ * -------------------------------------------------------------------
222
+ * 9. PROPERTY-VIEWED MAGIC NUMBER (everything)
223
+ * -------------------------------------------------------------------
224
+ *
225
+ * PATTERN: Users in the 6-12 property-viewed sweet spot get +30% on
226
+ * offer_price for offer-submitted events. Users with 13+ views are
227
+ * tire-kickers; 35% of their offer-submitted events drop. No flag.
228
+ *
229
+ * HOW TO FIND IT IN MIXPANEL:
230
+ *
231
+ * Report 1: Avg Offer Price by View Bucket
232
+ * - Report type: Insights (with cohort)
233
+ * - Cohort A: users with 6-12 "property viewed"
234
+ * - Cohort B: users with 0-5
235
+ * - Event: "offer submitted"
236
+ * - Measure: Average of "offer_price"
237
+ * - Expected: A ~ 1.3x B
238
+ *
239
+ * Report 2: Offers per User on Heavy Viewers
240
+ * - Report type: Insights (with cohort)
241
+ * - Cohort C: users with >= 13 "property viewed"
242
+ * - Cohort A: users with 6-12
243
+ * - Event: "offer submitted"
244
+ * - Measure: Total per user
245
+ * - Expected: C ~ 35% fewer offers per user vs A
246
+ *
247
+ * REAL-WORLD ANALOGUE: Focused buyers commit; obsessive browsers
248
+ * tire-kick and never make the leap.
136
249
  *
137
250
  * ===================================================================
138
251
  * EXPECTED METRICS SUMMARY
@@ -140,24 +253,30 @@ const chance = u.initChance(SEED);
140
253
  *
141
254
  * Hook | Metric | Baseline | Effect | Ratio
142
255
  * -------------------------|------------------------|----------|---------|------
143
- * Spring Buying Season | Tour volume | 1x | 3x | 3x
144
- * Mortgage Rate Shock | Offer volume post D75 | 100% | 55% | 0.55x
145
- * Saved-Search Retention | D30+ events (non-saver)| 100% | 20% | 0.2x
146
- * Pre-Approved Conversion | Offer count | 1x | 5x | 5x
147
- * Premier Agent Advantage | Listings | 1x | 3x | 3x
148
- * Dual Tour Power Users | Offer count | 1x | 6x | 6x
149
- * Luxury Listing Release | Luxury listings | 0% | 3% | D50+
150
- * Cold-Lead Churn | D14+ events (cold lead)| 100% | 10% | 0.1x
256
+ * Spring Buying Season | Avg offer_price d30-60 | 1x | 2.5x | 2.5x
257
+ * Mortgage Rate Shock | Offer vol post-day-75 | 1x | 0.55x | -45%
258
+ * Saved-Search Retention | non-saver post-day-30 | 1x | 0.2x | -80%
259
+ * Pre-Approved Conversion | offers/user | 1x | ~ 5x | 5x
260
+ * Premier Agent Advantage | listings/user | 1x | ~ 3x | 3x
261
+ * Dual-Tour Power Users | offers/user | 1x | ~ 6x | 6x
262
+ * Luxury Listing Release | $5M+ listings | 0% | ~ 3% | d50+
263
+ * Cold-Lead Churn | non-save post-day-14 | 1x | 0.1x | -90%
264
+ * View-Count Magic Number | sweet offer_price | 1x | 1.3x | 1.3x
265
+ * View-Count Magic Number | over offers/user | 1x | 0.65x | -35%
151
266
  */
152
267
 
153
268
  /** @type {Config} */
154
269
  const config = {
270
+ version: 2,
155
271
  token,
156
272
  seed: SEED,
157
- numDays: num_days,
273
+ datasetStart: "2026-01-01T00:00:00Z",
274
+ datasetEnd: "2026-05-01T23:59:59Z",
275
+ // numDays: num_days,
158
276
  avgEventsPerUserPerDay: avg_events_per_user_per_day,
159
277
  numUsers: num_users,
160
- hasAnonIds: false,
278
+ hasAnonIds: true,
279
+ avgDevicePerUser: 2,
161
280
  hasSessionIds: true,
162
281
  format: "json",
163
282
  gzip: true,
@@ -216,6 +335,7 @@ const config = {
216
335
  event: "account created",
217
336
  weight: 1,
218
337
  isFirstEvent: true,
338
+ isAuthEvent: true,
219
339
  properties: {
220
340
  signup_method: ["email", "google", "apple", "facebook"],
221
341
  }
@@ -240,8 +360,6 @@ const config = {
240
360
  property_type: ["Single Family", "Condo", "Townhouse", "Multi-Family"],
241
361
  bedrooms: [1, 2, 2, 3, 3, 3, 4, 4, 5],
242
362
  square_feet: u.weighNumRange(600, 5000, 0.5, 50),
243
- spring_season: [false],
244
- luxury: [false],
245
363
  }
246
364
  },
247
365
  {
@@ -282,7 +400,7 @@ const config = {
282
400
  properties: {
283
401
  listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
284
402
  agent_id: () => `AGT-${chance.integer({ min: 1000, max: 9999 })}`,
285
- spring_season: [false],
403
+ duration_mins: u.weighNumRange(5, 90, 0.5, 30),
286
404
  }
287
405
  },
288
406
  {
@@ -301,9 +419,6 @@ const config = {
301
419
  listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
302
420
  offer_price: u.weighNumRange(200000, 1500000, 0.4, 40),
303
421
  listing_price: u.weighNumRange(200000, 1500000, 0.4, 40),
304
- pre_approved: [false],
305
- spring_season: [false],
306
- dual_tour: [false],
307
422
  }
308
423
  },
309
424
  {
@@ -330,8 +445,6 @@ const config = {
330
445
  property_type: ["Single Family", "Condo", "Townhouse", "Multi-Family"],
331
446
  bedrooms: [1, 2, 2, 3, 3, 3, 4, 4, 5],
332
447
  listing_status: ["active", "pending", "coming soon"],
333
- luxury: [false],
334
- premier_agent: [false],
335
448
  }
336
449
  },
337
450
  {
@@ -340,7 +453,6 @@ const config = {
340
453
  properties: {
341
454
  sale_price: u.weighNumRange(200000, 1500000, 0.4, 40),
342
455
  days_on_market: u.weighNumRange(5, 180, 0.4, 30),
343
- premier_agent: [false],
344
456
  }
345
457
  },
346
458
  {
@@ -372,8 +484,6 @@ const config = {
372
484
  user_type: ["Buyer", "Buyer", "Buyer", "Agent", "Both"],
373
485
  preferred_location: ["Urban", "Suburban", "Rural"],
374
486
  property_preference: ["Single Family", "Condo", "Townhouse", "Multi-Family"],
375
- has_saved_search: [false],
376
- cold_lead: [false],
377
487
  },
378
488
 
379
489
  userProps: {
@@ -384,8 +494,6 @@ const config = {
384
494
  agent_tier: ["Standard", "Standard", "Standard", "Premier"],
385
495
  total_properties_viewed: u.weighNumRange(0, 100, 0.5, 30),
386
496
  pre_approval_status: ["none"],
387
- has_saved_search: [false],
388
- cold_lead: [false],
389
497
  },
390
498
 
391
499
  groupKeys: [
@@ -403,88 +511,34 @@ const config = {
403
511
 
404
512
  lookupTables: [],
405
513
 
406
- /**
407
- * ARCHITECTED ANALYTICS HOOKS
408
- *
409
- * This hook function creates 8 deliberate patterns in the data:
410
- *
411
- * 1. SPRING BUYING SEASON: Days 30-60, tours get 3x duration_mins, offers get 2.5x offer_price, spring_season=true
412
- * 2. MORTGAGE RATE SHOCK: Day 75, rate jumps to 7.5; 45% of post-day-75 offers removed
413
- * 3. SAVED-SEARCH RETENTION: First-7-day savers get extra engagement; non-savers lose 80% after day 30
414
- * 4. PRE-APPROVED BUYER CONVERSION: Users with pre-approval get 5x offer events, pre_approved=true
415
- * 5. TOP-TIER AGENT ADVANTAGE: Premier agents get 3x listings, 2x sales, premier_agent=true
416
- * 6. TOUR-TO-OFFER POWER USERS: Users with both virtual + in-person tours get 6x offers, dual_tour=true
417
- * 7. LUXURY LISTING RELEASE: Day 50+, 3% of listings become $5M+ luxury; ~3% of users browse luxury
418
- * 8. COLD-LEAD CHURN: Browsers who never save in first 14 days lose 90% of events after day 14
419
- */
420
514
  hook: function (record, type, meta) {
421
- const NOW = dayjs.utc();
422
- const DATASET_START = NOW.subtract(num_days, "days");
423
-
424
- // ===============================================================
425
- // Hook #1: SPRING BUYING SEASON (event)
426
- // Days 30-60: spring market heats up. Scheduled tours get
427
- // duration_mins 3x (more serious buyers). Offers get offer_price
428
- // 2.5x (bidding wars). spring_season=true flag set on both.
429
- // ===============================================================
430
- if (type === "event") {
431
- const EVENT_TIME = dayjs.utc(record.time);
432
- const dayInDataset = EVENT_TIME.diff(DATASET_START, "day");
433
-
434
- const isSpring = dayInDataset >= 30 && dayInDataset <= 60;
435
-
436
- if (record.event === "tour scheduled") {
437
- if (isSpring) {
438
- record.spring_season = true;
439
- } else {
440
- record.spring_season = false;
441
- }
442
- }
443
-
444
- if (record.event === "offer submitted") {
445
- if (isSpring) {
446
- record.offer_price = Math.floor((record.offer_price || 400000) * 2.5);
447
- record.spring_season = true;
448
- } else {
449
- record.spring_season = false;
450
- }
451
- }
452
-
453
- if (record.event === "property viewed" && isSpring) {
454
- record.spring_season = true;
455
- }
456
-
457
- // ===============================================================
458
- // Hook #2 (event part): MORTGAGE RATE SHOCK
459
- // Day 75-89: mortgage rates forced to 7.5 on pre-approval events
460
- // (up from baseline ~6.5).
461
- // ===============================================================
462
- if (record.event === "mortgage pre-approval") {
463
- if (dayInDataset >= 75 && dayInDataset < 89) {
464
- record.mortgage_rate = 7.5;
465
- }
466
- }
467
-
468
- // ===============================================================
469
- // Hook #7 (event part): LUXURY LISTING RELEASE
470
- // After day 50, 3% of property_listed events become luxury
471
- // ($5M-$15M) with luxury=true. Before day 50, no luxury.
472
- // ===============================================================
473
- if (record.event === "property listed") {
474
- if (dayInDataset >= 50 && chance.bool({ likelihood: 3 })) {
475
- record.listing_price = chance.integer({ min: 5000000, max: 15000000 });
476
- record.luxury = true;
477
- record.premier_agent = false;
478
- } else {
479
- record.luxury = false;
515
+ // HOOK 10 (T2C): TOUR FUNNEL TIME-TO-CONVERT (funnel-post)
516
+ // Premier agents move users through Tour funnel 1.4x faster
517
+ // (factor 0.71); Standard agents 1.3x slower (factor 1.3).
518
+ if (type === "funnel-post") {
519
+ const segment = meta?.profile?.agent_tier;
520
+ if (Array.isArray(record) && record.length > 1) {
521
+ const factor = (
522
+ segment === "Premier" ? 0.71 :
523
+ segment === "Standard" ? 1.3 :
524
+ 1.0
525
+ );
526
+ if (factor !== 1.0) {
527
+ for (let i = 1; i < record.length; i++) {
528
+ const prev = dayjs(record[i - 1].time);
529
+ const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
530
+ record[i].time = prev.add(newGap, "milliseconds").toISOString();
531
+ }
480
532
  }
481
533
  }
482
534
  }
483
535
 
484
- // ===============================================================
485
- // EVERYTHING HOOK Complex behavioral patterns
486
- // ===============================================================
536
+ // HOOKS 1, 2, 7 (time-window event-level effects) moved to everything hook —
537
+ // event hook fires before bunchIntoSessions reshuffles timestamps, so day-window
538
+ // tags leak across boundaries.
539
+
487
540
  if (type === "everything") {
541
+ const datasetStart = dayjs.unix(meta.datasetStart);
488
542
  const userEvents = record;
489
543
  const profile = meta.profile;
490
544
 
@@ -493,278 +547,242 @@ const config = {
493
547
  e.user_type = profile.user_type;
494
548
  e.preferred_location = profile.preferred_location;
495
549
  e.property_preference = profile.property_preference;
496
- e.has_saved_search = profile.has_saved_search;
497
- e.cold_lead = profile.cold_lead;
550
+ });
551
+
552
+ // HOOK 1: SPRING BUYING SEASON — d30-60 tour duration 3x, offer_price 2.5x
553
+ // HOOK 2: MORTGAGE RATE SHOCK — d75-89 mortgage_rate pinned 7.5
554
+ // HOOK 7: LUXURY LISTING RELEASE — post-d50, 3% of listings priced $5M-$15M
555
+ const springStart = datasetStart.add(30, "days");
556
+ const springEnd = datasetStart.add(60, "days");
557
+ const shockStart = datasetStart.add(75, "days");
558
+ const shockEnd = datasetStart.add(89, "days");
559
+ const luxuryStart = datasetStart.add(50, "days");
560
+ userEvents.forEach(e => {
561
+ const t = dayjs.utc(e.time);
562
+ const inSpring = (t.isAfter(springStart) || t.isSame(springStart)) && t.isBefore(springEnd);
563
+ if (inSpring && e.event === "tour scheduled" && typeof e.duration_mins === "number") {
564
+ e.duration_mins = Math.round(e.duration_mins * 3);
565
+ }
566
+ // Spring offer_price boost moved to end (after all cloning)
567
+ if (e.event === "mortgage pre-approval") {
568
+ if ((t.isAfter(shockStart) || t.isSame(shockStart)) && t.isBefore(shockEnd)) {
569
+ e.mortgage_rate = 7.5;
570
+ }
571
+ }
572
+ if (e.event === "property listed" && t.isAfter(luxuryStart) && chance.bool({ likelihood: 3 })) {
573
+ e.listing_price = chance.integer({ min: 5000000, max: 15000000 });
574
+ }
498
575
  });
499
576
 
500
577
  const firstEventTime = userEvents.length > 0 ? dayjs(userEvents[0].time) : null;
501
578
 
502
- // -----------------------------------------------------------
503
- // Hook #2 (everything part): MORTGAGE RATE SHOCK
504
- // After day 75, 45% of offer_submitted events are removed
505
- // (buyer pullback due to higher rates).
506
- // -----------------------------------------------------------
507
- const day75 = DATASET_START.add(75, "days");
579
+ // HOOK 2 (cont): MORTGAGE RATE SHOCK — drop 45% of post-day-75
580
+ // offer-submitted events. No flag.
581
+ const day75 = datasetStart.add(75, "days");
508
582
  for (let i = userEvents.length - 1; i >= 0; i--) {
509
583
  const evt = userEvents[i];
510
- if (evt.event === "offer submitted" && dayjs(evt.time).isAfter(day75)) {
511
- if (chance.bool({ likelihood: 45 })) {
512
- userEvents.splice(i, 1);
513
- }
584
+ if (evt.event === "offer submitted" && dayjs(evt.time).isAfter(day75) && chance.bool({ likelihood: 45 })) {
585
+ userEvents.splice(i, 1);
514
586
  }
515
587
  }
516
588
 
517
- // -----------------------------------------------------------
518
- // Hook #3: SAVED-SEARCH RETENTION
519
- // Users who create a "saved search created" within first 7
520
- // days get extra property_viewed and tour_scheduled events.
521
- // Non-savers lose 80% of events after day 30.
522
- // -----------------------------------------------------------
589
+ // HOOK 3: SAVED-SEARCH RETENTION — early savers (saved-search-created
590
+ // in first 7 days) get extra cloned view + tour events. Non-savers
591
+ // lose 80% of events after day 30. No flag.
523
592
  const day7 = firstEventTime ? firstEventTime.add(7, "days") : null;
524
- const day30 = DATASET_START.add(30, "days");
525
-
526
- let hasSavedSearch = false;
527
- if (day7) {
528
- hasSavedSearch = userEvents.some(e =>
529
- e.event === "saved search created" &&
530
- dayjs(e.time).isBefore(day7)
531
- );
532
- }
593
+ const day30 = datasetStart.add(30, "days");
594
+ const hasSavedSearch = day7 ? userEvents.some(e =>
595
+ e.event === "saved search created" && dayjs(e.time).isBefore(day7)
596
+ ) : false;
533
597
 
534
598
  if (hasSavedSearch) {
535
- // Mark the user and inject extra engagement events
536
- profile.has_saved_search = true;
537
- userEvents.forEach(e => { e.has_saved_search = true; });
538
-
539
- // Inject extra property_viewed and tour_scheduled events
540
599
  const viewTemplate = userEvents.find(e => e.event === "property viewed");
541
600
  const tourTemplate = userEvents.find(e => e.event === "tour scheduled");
542
-
543
601
  if (viewTemplate) {
544
- const extraCount = chance.integer({ min: 3, max: 8 });
545
- for (let i = 0; i < extraCount; i++) {
602
+ const extras = chance.integer({ min: 3, max: 8 });
603
+ for (let i = 0; i < extras; i++) {
546
604
  const offset = chance.integer({ min: 10, max: num_days - 10 });
547
605
  userEvents.push({
548
606
  ...viewTemplate,
549
- time: DATASET_START.add(offset, "days").add(chance.integer({ min: 0, max: 23 }), "hours").toISOString(),
607
+ time: datasetStart.add(offset, "days").add(chance.integer({ min: 0, max: 23 }), "hours").toISOString(),
550
608
  user_id: viewTemplate.user_id,
551
609
  listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
552
610
  listing_price: chance.integer({ min: 200000, max: 1200000 }),
553
- has_saved_search: true,
554
611
  });
555
612
  }
556
613
  }
557
614
  if (tourTemplate) {
558
- const extraTours = chance.integer({ min: 1, max: 3 });
559
- for (let i = 0; i < extraTours; i++) {
615
+ const extras = chance.integer({ min: 1, max: 3 });
616
+ for (let i = 0; i < extras; i++) {
560
617
  const offset = chance.integer({ min: 15, max: num_days - 10 });
561
618
  userEvents.push({
562
619
  ...tourTemplate,
563
- time: DATASET_START.add(offset, "days").add(chance.integer({ min: 8, max: 18 }), "hours").toISOString(),
620
+ time: datasetStart.add(offset, "days").add(chance.integer({ min: 8, max: 18 }), "hours").toISOString(),
564
621
  user_id: tourTemplate.user_id,
565
622
  listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
566
- has_saved_search: true,
567
623
  });
568
624
  }
569
625
  }
570
626
  } else {
571
- // Non-savers: remove 80% of events after day 30
572
627
  for (let i = userEvents.length - 1; i >= 0; i--) {
573
628
  const evt = userEvents[i];
574
- if (dayjs(evt.time).isAfter(day30)) {
575
- if (chance.bool({ likelihood: 80 })) {
576
- userEvents.splice(i, 1);
577
- }
629
+ if (dayjs(evt.time).isAfter(day30) && chance.bool({ likelihood: 80 })) {
630
+ userEvents.splice(i, 1);
578
631
  }
579
632
  }
580
633
  }
581
634
 
582
- // -----------------------------------------------------------
583
- // Hook #4: PRE-APPROVED BUYER CONVERSION
584
- // Users with a "mortgage pre-approval" event get 5x more
585
- // offer_submitted events injected. pre_approved=true.
586
- // -----------------------------------------------------------
635
+ // HOOK 4: PRE-APPROVED BUYER CONVERSION — clone 4-6 extra
636
+ // offer-submitted events for users with a mortgage pre-approval
637
+ // event. No flag.
587
638
  const hasPreApproval = userEvents.some(e => e.event === "mortgage pre-approval");
588
-
589
639
  if (hasPreApproval) {
590
640
  profile.pre_approval_status = "approved";
591
641
  const offerTemplate = userEvents.find(e => e.event === "offer submitted");
592
642
  if (offerTemplate) {
593
- const extraOffers = chance.integer({ min: 4, max: 6 });
594
- for (let i = 0; i < extraOffers; i++) {
643
+ const extras = chance.integer({ min: 4, max: 6 });
644
+ for (let i = 0; i < extras; i++) {
595
645
  const offset = chance.integer({ min: 20, max: num_days - 5 });
596
646
  userEvents.push({
597
647
  ...offerTemplate,
598
- time: DATASET_START.add(offset, "days").add(chance.integer({ min: 8, max: 20 }), "hours").toISOString(),
648
+ time: datasetStart.add(offset, "days").add(chance.integer({ min: 8, max: 20 }), "hours").toISOString(),
599
649
  user_id: offerTemplate.user_id,
600
650
  listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
601
651
  offer_price: chance.integer({ min: 250000, max: 1200000 }),
602
652
  listing_price: chance.integer({ min: 250000, max: 1200000 }),
603
- pre_approved: true,
604
- spring_season: false,
605
- dual_tour: false,
606
653
  });
607
654
  }
608
655
  }
609
-
610
- // Also tag existing offers
611
- userEvents.forEach(e => {
612
- if (e.event === "offer submitted") {
613
- e.pre_approved = true;
614
- }
615
- });
616
656
  }
617
657
 
618
- // -----------------------------------------------------------
619
- // Hook #5: TOP-TIER AGENT ADVANTAGE
620
- // Premier agents get 3x property_listed and 2x property_sold
621
- // events. premier_agent=true on all their listing/sale events.
622
- // -----------------------------------------------------------
658
+ // HOOK 5: TOP-TIER AGENT ADVANTAGE — Premier agents get 2 extra
659
+ // cloned listings per existing (3x rate) and 1 extra clone per
660
+ // sale (2x rate). Reads agent_tier from profile. No flag.
623
661
  if (profile.agent_tier === "Premier") {
624
662
  const listTemplate = userEvents.find(e => e.event === "property listed");
625
663
  const soldTemplate = userEvents.find(e => e.event === "property sold");
626
664
 
627
- // Tag existing events
628
- userEvents.forEach(e => {
629
- if (e.event === "property listed" || e.event === "property sold") {
630
- e.premier_agent = true;
631
- }
632
- });
633
-
634
- // Inject extra listings (3x = 2 more copies per existing)
635
665
  if (listTemplate) {
636
666
  const existingListings = userEvents.filter(e => e.event === "property listed").length;
637
- const extraListings = existingListings * 2;
638
- for (let i = 0; i < extraListings; i++) {
667
+ const extras = existingListings * 2;
668
+ for (let i = 0; i < extras; i++) {
639
669
  const offset = chance.integer({ min: 5, max: num_days - 5 });
640
670
  userEvents.push({
641
671
  ...listTemplate,
642
- time: DATASET_START.add(offset, "days").add(chance.integer({ min: 8, max: 18 }), "hours").toISOString(),
672
+ time: datasetStart.add(offset, "days").add(chance.integer({ min: 8, max: 18 }), "hours").toISOString(),
643
673
  user_id: listTemplate.user_id,
644
674
  listing_price: chance.integer({ min: 300000, max: 1500000 }),
645
675
  listing_status: chance.pickone(["active", "pending", "coming soon"]),
646
- luxury: false,
647
- premier_agent: true,
648
676
  });
649
677
  }
650
678
  }
651
-
652
- // Inject extra sales (2x = 1 more copy per existing)
653
679
  if (soldTemplate) {
654
680
  const existingSales = userEvents.filter(e => e.event === "property sold").length;
655
681
  for (let i = 0; i < existingSales; i++) {
656
682
  const offset = chance.integer({ min: 10, max: num_days - 5 });
657
683
  userEvents.push({
658
684
  ...soldTemplate,
659
- time: DATASET_START.add(offset, "days").add(chance.integer({ min: 9, max: 17 }), "hours").toISOString(),
685
+ time: datasetStart.add(offset, "days").add(chance.integer({ min: 9, max: 17 }), "hours").toISOString(),
660
686
  user_id: soldTemplate.user_id,
661
687
  sale_price: chance.integer({ min: 300000, max: 1500000 }),
662
688
  days_on_market: chance.integer({ min: 5, max: 90 }),
663
- premier_agent: true,
664
689
  });
665
690
  }
666
691
  }
667
692
  }
668
693
 
669
- // -----------------------------------------------------------
670
- // Hook #6: TOUR-TO-OFFER POWER USERS
671
- // Users who did BOTH "virtual tour" AND "in-person tour"
672
- // get 6x offer_submitted events. dual_tour=true on offers.
673
- // -----------------------------------------------------------
694
+ // HOOK 6: TOUR-TO-OFFER POWER USERS — users with both virtual
695
+ // tour AND in-person tour get 5-7 extra cloned offers. No flag.
674
696
  const hasVirtualTour = userEvents.some(e => e.event === "virtual tour");
675
697
  const hasInPersonTour = userEvents.some(e => e.event === "in-person tour");
676
- const isDualTourer = hasVirtualTour && hasInPersonTour;
677
-
678
- if (isDualTourer) {
698
+ if (hasVirtualTour && hasInPersonTour) {
679
699
  const offerTemplate = userEvents.find(e => e.event === "offer submitted");
680
700
  if (offerTemplate) {
681
- const extraOffers = chance.integer({ min: 5, max: 7 });
682
- for (let i = 0; i < extraOffers; i++) {
701
+ const extras = chance.integer({ min: 5, max: 7 });
702
+ for (let i = 0; i < extras; i++) {
683
703
  const offset = chance.integer({ min: 15, max: num_days - 5 });
684
704
  userEvents.push({
685
705
  ...offerTemplate,
686
- time: DATASET_START.add(offset, "days").add(chance.integer({ min: 8, max: 20 }), "hours").toISOString(),
706
+ time: datasetStart.add(offset, "days").add(chance.integer({ min: 8, max: 20 }), "hours").toISOString(),
687
707
  user_id: offerTemplate.user_id,
688
708
  listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
689
709
  offer_price: chance.integer({ min: 300000, max: 1500000 }),
690
710
  listing_price: chance.integer({ min: 300000, max: 1500000 }),
691
- dual_tour: true,
692
- pre_approved: false,
693
- spring_season: false,
694
711
  });
695
712
  }
696
713
  }
697
-
698
- // Tag existing offers
699
- userEvents.forEach(e => {
700
- if (e.event === "offer submitted") {
701
- e.dual_tour = true;
702
- }
703
- });
704
714
  }
705
715
 
706
- // -----------------------------------------------------------
707
- // Hook #7 (everything part): LUXURY LISTING RELEASE
708
- // ~3% of users (by hash) get extra luxury property_viewed
709
- // events after day 50. Deterministic via character code.
710
- // -----------------------------------------------------------
711
- const day50 = DATASET_START.add(50, "days");
716
+ // HOOK 7 (cont): LUXURY LISTING RELEASE — ~3% of users (by hash)
717
+ // get 3-6 extra luxury property-viewed events after day 50. No flag.
712
718
  if (userEvents.length > 0) {
713
719
  const userId = userEvents[0].user_id || "";
714
720
  const isLuxuryBrowser = typeof userId === "string" && userId.length > 0 && userId.charCodeAt(0) % 33 === 0;
715
-
716
721
  if (isLuxuryBrowser) {
717
722
  const viewTemplate = userEvents.find(e => e.event === "property viewed");
718
723
  if (viewTemplate) {
719
- const extraViews = chance.integer({ min: 3, max: 6 });
720
- for (let i = 0; i < extraViews; i++) {
724
+ const extras = chance.integer({ min: 3, max: 6 });
725
+ for (let i = 0; i < extras; i++) {
721
726
  const offset = chance.integer({ min: 50, max: num_days - 5 });
722
727
  userEvents.push({
723
728
  ...viewTemplate,
724
- time: DATASET_START.add(offset, "days").add(chance.integer({ min: 9, max: 21 }), "hours").toISOString(),
729
+ time: datasetStart.add(offset, "days").add(chance.integer({ min: 9, max: 21 }), "hours").toISOString(),
725
730
  user_id: viewTemplate.user_id,
726
731
  listing_id: `LST-${chance.integer({ min: 90000, max: 99999 })}`,
727
732
  listing_price: chance.integer({ min: 5000000, max: 15000000 }),
728
733
  property_type: chance.pickone(["Single Family", "Condo"]),
729
734
  bedrooms: chance.integer({ min: 4, max: 8 }),
730
735
  square_feet: chance.integer({ min: 4000, max: 15000 }),
731
- luxury: true,
732
- spring_season: false,
733
736
  });
734
737
  }
735
738
  }
736
739
  }
737
740
  }
738
741
 
739
- // -----------------------------------------------------------
740
- // Hook #8: COLD-LEAD CHURN
741
- // Users who browse (property viewed) but never save (property
742
- // saved) in the first 14 days lose 90% of events after day 14.
743
- // -----------------------------------------------------------
742
+ // HOOK 8: COLD-LEAD CHURN — users who view but never save in
743
+ // first 14 days lose 90% of post-day-14 events. No flag.
744
744
  const day14 = firstEventTime ? firstEventTime.add(14, "days") : null;
745
-
746
745
  if (day14) {
747
746
  const earlyEvents = userEvents.filter(e => dayjs(e.time).isBefore(day14));
748
747
  const hasView = earlyEvents.some(e => e.event === "property viewed");
749
748
  const hasSave = earlyEvents.some(e => e.event === "property saved");
750
-
751
749
  if (hasView && !hasSave) {
752
- // Cold lead: remove 90% of events after day 14
753
- profile.cold_lead = true;
754
750
  for (let i = userEvents.length - 1; i >= 0; i--) {
755
751
  const evt = userEvents[i];
756
- if (dayjs(evt.time).isAfter(day14)) {
757
- if (chance.bool({ likelihood: 90 })) {
758
- userEvents.splice(i, 1);
759
- } else {
760
- evt.cold_lead = true;
761
- }
752
+ if (dayjs(evt.time).isAfter(day14) && chance.bool({ likelihood: 90 })) {
753
+ userEvents.splice(i, 1);
762
754
  }
763
755
  }
764
- // Tag surviving events
765
- userEvents.forEach(e => { e.cold_lead = true; });
766
756
  }
767
757
  }
758
+
759
+ // HOOK 9: PROPERTY-VIEWED MAGIC NUMBER (no flags)
760
+ // Sweet 6-12 views → +30% offer_price on offer submitted events.
761
+ // Over 13+ → drop 35% of offer submitted events (tire-kickers).
762
+ const viewCount = userEvents.filter(e => e.event === "property viewed").length;
763
+ if (viewCount >= 6 && viewCount <= 12) {
764
+ userEvents.forEach(e => {
765
+ if (e.event === "offer submitted" && typeof e.offer_price === "number") {
766
+ e.offer_price = Math.round(e.offer_price * 1.3);
767
+ }
768
+ });
769
+ } else if (viewCount >= 13) {
770
+ for (let i = userEvents.length - 1; i >= 0; i--) {
771
+ if (userEvents[i].event === "offer submitted" && chance.bool({ likelihood: 35 })) {
772
+ userEvents.splice(i, 1);
773
+ }
774
+ }
775
+ }
776
+
777
+ // HOOK 1 (cont): Spring offer_price boost — runs AFTER all cloning
778
+ // so cloned offers in the spring window also get the 2.5x boost
779
+ userEvents.forEach(e => {
780
+ if (e.event !== "offer submitted") return;
781
+ const t = dayjs.utc(e.time);
782
+ if ((t.isAfter(springStart) || t.isSame(springStart)) && t.isBefore(springEnd)) {
783
+ e.offer_price = Math.floor((e.offer_price || 400000) * 2.5);
784
+ }
785
+ });
768
786
  }
769
787
 
770
788
  return record;