@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.
- package/CHANGELOG.md +58 -0
- package/dungeons/technical/array-of-object-lookup.js +0 -2
- package/dungeons/technical/hook-helpers-verify.js +89 -0
- package/dungeons/technical/identity-model-verify.js +47 -0
- package/dungeons/technical/pattern-aggregate-by-bin.js +41 -0
- package/dungeons/technical/pattern-attributed-by-source.js +42 -0
- package/dungeons/technical/pattern-frequency-by-frequency.js +40 -0
- package/dungeons/technical/pattern-funnel-frequency.js +54 -0
- package/dungeons/technical/pattern-ttc-by-segment.js +45 -0
- package/dungeons/technical/simple.js +3 -4
- package/dungeons/technical/simplest-schema.json +5 -0
- package/dungeons/technical/text-generation.js +1 -1
- package/dungeons/vertical/ai-platform.js +216 -164
- package/dungeons/vertical/community.js +74 -41
- package/dungeons/vertical/crypto.js +343 -233
- package/dungeons/vertical/dating.js +246 -292
- package/dungeons/vertical/devtools.js +179 -70
- package/dungeons/vertical/ecommerce.js +275 -122
- package/dungeons/vertical/education.js +345 -312
- package/dungeons/vertical/fintech.js +445 -314
- package/dungeons/vertical/fitness.js +199 -165
- package/dungeons/vertical/food-delivery.js +309 -330
- package/dungeons/vertical/gaming.js +387 -241
- package/dungeons/vertical/healthcare.js +149 -66
- package/dungeons/vertical/insurance-application.js +173 -75
- package/dungeons/vertical/logistics.js +107 -12
- package/dungeons/vertical/marketplace.js +171 -76
- package/dungeons/vertical/media.js +259 -402
- package/dungeons/vertical/real-estate.js +336 -318
- package/dungeons/vertical/sass.js +294 -288
- package/dungeons/vertical/social.js +289 -234
- package/dungeons/vertical/travel.js +66 -20
- package/index.js +17 -17
- package/lib/core/config-validator.js +226 -175
- package/lib/core/context.js +10 -24
- package/lib/core/storage.js +6 -1
- package/lib/generators/events.js +57 -104
- package/lib/generators/funnels.js +212 -93
- package/lib/generators/mirror.js +3 -2
- package/lib/generators/product-names.js +1 -1
- package/lib/generators/scd.js +2 -1
- package/lib/generators/text.js +1 -1
- package/lib/hook-helpers/_internal.js +23 -0
- package/lib/hook-helpers/cohort.js +124 -0
- package/lib/hook-helpers/identity.js +56 -0
- package/lib/hook-helpers/index.js +44 -0
- package/lib/hook-helpers/inject.js +99 -0
- package/lib/hook-helpers/mutate.js +151 -0
- package/lib/hook-helpers/timing.js +99 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +38 -0
- package/lib/hook-patterns/attributed-by-source.js +72 -0
- package/lib/hook-patterns/frequency-by-frequency.js +46 -0
- package/lib/hook-patterns/funnel-frequency-breakdown.js +73 -0
- package/lib/hook-patterns/index.js +14 -0
- package/lib/hook-patterns/time-to-convert-by-segment.js +41 -0
- package/lib/orchestrators/user-loop.js +143 -293
- package/lib/utils/utils.js +97 -52
- package/lib/verify/emulate-breakdown.js +281 -0
- package/lib/verify/index.js +12 -0
- package/lib/verify/verify-dungeon.js +61 -0
- package/package.json +6 -4
- package/scripts/smoke-test-all.mjs +162 -0
- package/scripts/verify-runner.mjs +72 -24
- package/types.d.ts +434 -225
- package/dungeons/technical/ad-spend-schema.json +0 -128
- package/dungeons/technical/anonymous-users-schema.json +0 -92
- package/dungeons/technical/array-of-object-lookup-schema.json +0 -191
- package/dungeons/technical/experiments-schema.json +0 -203
- package/dungeons/technical/foobar-schema.json +0 -362
- package/dungeons/technical/group-analytics-schema.json +0 -241
- package/dungeons/technical/mirror-strategies-schema.json +0 -84
- package/dungeons/technical/nested-objects-schema.json +0 -145
- package/dungeons/technical/retention-cadence-schema.json +0 -37
- package/dungeons/technical/sanity-schema.json +0 -185
- package/dungeons/technical/scale-test-schema.json +0 -70
- package/dungeons/technical/scd-schema.json +0 -467
- package/dungeons/technical/simple-schema.json +0 -362
- package/dungeons/technical/text-generation-schema.json +0 -1062
- package/dungeons/vertical/ai-platform-schema.json +0 -617
- package/dungeons/vertical/community-schema.json +0 -579
- package/dungeons/vertical/crypto-schema.json +0 -546
- package/dungeons/vertical/dating-schema.json +0 -401
- package/dungeons/vertical/devtools-schema.json +0 -601
- package/dungeons/vertical/ecommerce-schema.json +0 -604
- package/dungeons/vertical/education-schema.json +0 -5686
- package/dungeons/vertical/fintech-schema.json +0 -630
- package/dungeons/vertical/fitness-schema.json +0 -530
- package/dungeons/vertical/food-delivery-schema.json +0 -36728
- package/dungeons/vertical/gaming-schema.json +0 -2703
- package/dungeons/vertical/healthcare-schema.json +0 -549
- package/dungeons/vertical/insurance-application-schema.json +0 -485
- package/dungeons/vertical/logistics-schema.json +0 -574
- package/dungeons/vertical/marketplace-schema.json +0 -533
- package/dungeons/vertical/media-schema.json +0 -4749
- package/dungeons/vertical/real-estate-schema.json +0 -527
- package/dungeons/vertical/sass-schema.json +0 -3128
- package/dungeons/vertical/social-schema.json +0 -620
- 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 =
|
|
4
|
-
const num_users =
|
|
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 (
|
|
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
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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 HIDDEN — no 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
|
-
*
|
|
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
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
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 |
|
|
144
|
-
* Mortgage Rate Shock | Offer
|
|
145
|
-
* Saved-Search Retention |
|
|
146
|
-
* Pre-Approved Conversion |
|
|
147
|
-
* Premier Agent Advantage |
|
|
148
|
-
* Dual
|
|
149
|
-
* Luxury Listing Release |
|
|
150
|
-
* Cold-Lead Churn |
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
497
|
-
|
|
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
|
-
//
|
|
504
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
519
|
-
//
|
|
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 =
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
|
545
|
-
for (let i = 0; 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:
|
|
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
|
|
559
|
-
for (let i = 0; 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:
|
|
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
|
-
|
|
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
|
-
//
|
|
584
|
-
//
|
|
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
|
|
594
|
-
for (let i = 0; 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:
|
|
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
|
-
//
|
|
620
|
-
//
|
|
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
|
|
638
|
-
for (let i = 0; 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:
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
682
|
-
for (let i = 0; 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:
|
|
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
|
-
//
|
|
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
|
|
720
|
-
for (let i = 0; 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:
|
|
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
|
-
//
|
|
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
|
-
|
|
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;
|