@ak--47/dungeon-master 1.2.3 → 1.3.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 +42 -0
- package/README.md +51 -13
- package/dungeons/technical/ad-spend.js +2 -2
- package/dungeons/technical/anonymous-users.js +2 -2
- package/dungeons/technical/array-of-object-lookup.js +2 -2
- package/dungeons/technical/experiments.js +2 -2
- package/dungeons/technical/foobar.js +2 -2
- package/dungeons/technical/group-analytics.js +2 -2
- package/dungeons/technical/mirror-strategies.js +2 -2
- package/dungeons/technical/nested-objects.js +2 -2
- package/dungeons/technical/retention-cadence.js +2 -3
- package/dungeons/technical/sanity.js +2 -2
- package/dungeons/technical/scale-test.js +2 -2
- package/dungeons/technical/scd.js +2 -2
- package/dungeons/technical/simple.js +2 -2
- package/dungeons/technical/simplest.js +2 -2
- package/dungeons/technical/text-generation.js +2 -2
- package/dungeons/vertical/ai-platform-schema.json +617 -0
- package/dungeons/vertical/ai-platform.js +799 -0
- package/dungeons/vertical/community.js +40 -26
- package/dungeons/vertical/crypto-schema.json +546 -0
- package/dungeons/vertical/crypto.js +721 -0
- package/dungeons/vertical/dating-schema.json +401 -0
- package/dungeons/vertical/dating.js +798 -0
- package/dungeons/vertical/devtools.js +13 -9
- package/dungeons/vertical/ecommerce.js +2 -3
- package/dungeons/vertical/education.js +4 -5
- package/dungeons/vertical/fintech.js +32 -29
- package/dungeons/vertical/fitness.js +2 -3
- package/dungeons/vertical/food-delivery.js +37 -43
- package/dungeons/vertical/gaming-schema.json +2495 -230
- package/dungeons/vertical/gaming.js +771 -388
- package/dungeons/vertical/healthcare.js +2 -3
- package/dungeons/vertical/insurance-application.js +2 -3
- package/dungeons/vertical/logistics.js +20 -14
- package/dungeons/vertical/marketplace.js +22 -14
- package/dungeons/vertical/media.js +39 -30
- package/dungeons/vertical/real-estate-schema.json +527 -0
- package/dungeons/vertical/real-estate.js +774 -0
- package/dungeons/vertical/sass.js +2 -3
- package/dungeons/vertical/social.js +15 -13
- package/dungeons/vertical/travel.js +59 -26
- package/lib/core/config-validator.js +71 -15
- package/lib/orchestrators/user-loop.js +39 -5
- package/lib/templates/macro-presets.js +111 -0
- package/lib/templates/soup-presets.js +19 -36
- package/package.json +8 -2
- package/types.d.ts +219 -42
- package/dungeons/user/.gitkeep +0 -0
- package/dungeons/vertical/rpg-schema.json +0 -2491
- package/dungeons/vertical/rpg.js +0 -976
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
// ── TWEAK THESE ──
|
|
2
|
+
const SEED = "homenest";
|
|
3
|
+
const num_days = 150;
|
|
4
|
+
const num_users = 6_000;
|
|
5
|
+
const avg_events_per_user_per_day = 0.53;
|
|
6
|
+
let token = "your-mixpanel-token";
|
|
7
|
+
|
|
8
|
+
// ── env overrides ──
|
|
9
|
+
if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
|
|
10
|
+
|
|
11
|
+
import dayjs from "dayjs";
|
|
12
|
+
import utc from "dayjs/plugin/utc.js";
|
|
13
|
+
import "dotenv/config";
|
|
14
|
+
import * as u from "../../lib/utils/utils.js";
|
|
15
|
+
import * as v from "ak-tools";
|
|
16
|
+
|
|
17
|
+
dayjs.extend(utc);
|
|
18
|
+
const chance = u.initChance(SEED);
|
|
19
|
+
|
|
20
|
+
/** @typedef {import("../../types").Dungeon} Config */
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* ===================================================================
|
|
24
|
+
* DATASET OVERVIEW
|
|
25
|
+
* ===================================================================
|
|
26
|
+
*
|
|
27
|
+
* HomeNest — a property listings and agent CRM platform
|
|
28
|
+
* (Zillow meets Compass). Buyers search listings, save properties,
|
|
29
|
+
* schedule tours, get pre-approved for mortgages, and submit offers.
|
|
30
|
+
* Agents list properties, manage clients, and close deals.
|
|
31
|
+
*
|
|
32
|
+
* Scale: 6,000 users · 480K events · 150 days · 17 event types
|
|
33
|
+
* Groups: 200 brokerages
|
|
34
|
+
* User types: Buyer (60%), Agent (20%), Both (20%)
|
|
35
|
+
* Agent tiers: Standard (75%) / Premier (25%)
|
|
36
|
+
*
|
|
37
|
+
* Core loops:
|
|
38
|
+
* Buyer: account created → property search → property viewed →
|
|
39
|
+
* property saved → tour scheduled → offer submitted →
|
|
40
|
+
* offer accepted / offer rejected
|
|
41
|
+
*
|
|
42
|
+
* Agent: account created → property listed → open house attended →
|
|
43
|
+
* agent contacted → property sold
|
|
44
|
+
*
|
|
45
|
+
* Funnels:
|
|
46
|
+
* - Buyer journey: account created → property search → property viewed (80%)
|
|
47
|
+
* - Tour funnel: property viewed → tour scheduled → offer submitted (40%)
|
|
48
|
+
* - Seller funnel: property listed → open house attended → property sold (35%)
|
|
49
|
+
*
|
|
50
|
+
* ===================================================================
|
|
51
|
+
* ANALYTICS HOOKS (8 architected patterns)
|
|
52
|
+
* ===================================================================
|
|
53
|
+
*
|
|
54
|
+
* 1. SPRING BUYING SEASON (event hook — TIME-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
|
|
119
|
+
*
|
|
120
|
+
* ===================================================================
|
|
121
|
+
* ADVANCED ANALYSIS IDEAS
|
|
122
|
+
* ===================================================================
|
|
123
|
+
*
|
|
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?
|
|
130
|
+
*
|
|
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
|
|
136
|
+
*
|
|
137
|
+
* ===================================================================
|
|
138
|
+
* EXPECTED METRICS SUMMARY
|
|
139
|
+
* ===================================================================
|
|
140
|
+
*
|
|
141
|
+
* Hook | Metric | Baseline | Effect | Ratio
|
|
142
|
+
* -------------------------|------------------------|----------|---------|------
|
|
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
|
|
151
|
+
*/
|
|
152
|
+
|
|
153
|
+
/** @type {Config} */
|
|
154
|
+
const config = {
|
|
155
|
+
token,
|
|
156
|
+
seed: SEED,
|
|
157
|
+
numDays: num_days,
|
|
158
|
+
avgEventsPerUserPerDay: avg_events_per_user_per_day,
|
|
159
|
+
numUsers: num_users,
|
|
160
|
+
hasAnonIds: false,
|
|
161
|
+
hasSessionIds: true,
|
|
162
|
+
format: "json",
|
|
163
|
+
gzip: true,
|
|
164
|
+
alsoInferFunnels: false,
|
|
165
|
+
hasLocation: true,
|
|
166
|
+
hasAndroidDevices: true,
|
|
167
|
+
hasIOSDevices: true,
|
|
168
|
+
hasDesktopDevices: true,
|
|
169
|
+
hasBrowser: true,
|
|
170
|
+
hasCampaigns: false,
|
|
171
|
+
isAnonymous: false,
|
|
172
|
+
hasAdSpend: false,
|
|
173
|
+
|
|
174
|
+
hasAvatar: true,
|
|
175
|
+
|
|
176
|
+
concurrency: 1,
|
|
177
|
+
writeToDisk: false,
|
|
178
|
+
|
|
179
|
+
soup: "growth",
|
|
180
|
+
|
|
181
|
+
scdProps: {
|
|
182
|
+
agent_tier: {
|
|
183
|
+
values: ["Standard", "Standard", "Standard", "Premier"],
|
|
184
|
+
frequency: "month",
|
|
185
|
+
timing: "fuzzy",
|
|
186
|
+
max: 4
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
funnels: [
|
|
191
|
+
{
|
|
192
|
+
sequence: ["account created", "property search", "property viewed"],
|
|
193
|
+
isFirstFunnel: true,
|
|
194
|
+
conversionRate: 80,
|
|
195
|
+
timeToConvert: 0.5,
|
|
196
|
+
name: "Buyer Journey"
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
sequence: ["property viewed", "tour scheduled", "offer submitted"],
|
|
200
|
+
conversionRate: 40,
|
|
201
|
+
timeToConvert: 24,
|
|
202
|
+
weight: 5,
|
|
203
|
+
name: "Tour Funnel"
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
sequence: ["property listed", "open house attended", "property sold"],
|
|
207
|
+
conversionRate: 35,
|
|
208
|
+
timeToConvert: 48,
|
|
209
|
+
weight: 3,
|
|
210
|
+
name: "Seller Funnel"
|
|
211
|
+
}
|
|
212
|
+
],
|
|
213
|
+
|
|
214
|
+
events: [
|
|
215
|
+
{
|
|
216
|
+
event: "account created",
|
|
217
|
+
weight: 1,
|
|
218
|
+
isFirstEvent: true,
|
|
219
|
+
properties: {
|
|
220
|
+
signup_method: ["email", "google", "apple", "facebook"],
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
event: "property search",
|
|
225
|
+
weight: 10,
|
|
226
|
+
properties: {
|
|
227
|
+
location: ["Manhattan", "Brooklyn", "Austin", "Miami", "San Francisco", "Denver", "Seattle", "Chicago", "Nashville", "Portland"],
|
|
228
|
+
price_range_min: u.weighNumRange(100000, 500000, 0.5, 40),
|
|
229
|
+
price_range_max: u.weighNumRange(300000, 2000000, 0.5, 40),
|
|
230
|
+
bedrooms: [1, 2, 2, 3, 3, 3, 4, 4, 5],
|
|
231
|
+
property_type: ["Single Family", "Condo", "Townhouse", "Multi-Family"],
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
event: "property viewed",
|
|
236
|
+
weight: 12,
|
|
237
|
+
properties: {
|
|
238
|
+
listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
239
|
+
listing_price: u.weighNumRange(150000, 1500000, 0.4, 60),
|
|
240
|
+
property_type: ["Single Family", "Condo", "Townhouse", "Multi-Family"],
|
|
241
|
+
bedrooms: [1, 2, 2, 3, 3, 3, 4, 4, 5],
|
|
242
|
+
square_feet: u.weighNumRange(600, 5000, 0.5, 50),
|
|
243
|
+
spring_season: [false],
|
|
244
|
+
luxury: [false],
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
event: "property saved",
|
|
249
|
+
weight: 5,
|
|
250
|
+
properties: {
|
|
251
|
+
listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
252
|
+
listing_price: u.weighNumRange(150000, 1500000, 0.4, 40),
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
event: "saved search created",
|
|
257
|
+
weight: 3,
|
|
258
|
+
properties: {
|
|
259
|
+
location: ["Manhattan", "Brooklyn", "Austin", "Miami", "San Francisco", "Denver", "Seattle", "Chicago", "Nashville", "Portland"],
|
|
260
|
+
criteria_count: u.weighNumRange(2, 8, 0.8, 10),
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
event: "virtual tour",
|
|
265
|
+
weight: 4,
|
|
266
|
+
properties: {
|
|
267
|
+
listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
268
|
+
duration_mins: u.weighNumRange(5, 45, 0.5, 30),
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
event: "in-person tour",
|
|
273
|
+
weight: 3,
|
|
274
|
+
properties: {
|
|
275
|
+
listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
276
|
+
agent_id: () => `AGT-${chance.integer({ min: 1000, max: 9999 })}`,
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
event: "tour scheduled",
|
|
281
|
+
weight: 4,
|
|
282
|
+
properties: {
|
|
283
|
+
listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
284
|
+
agent_id: () => `AGT-${chance.integer({ min: 1000, max: 9999 })}`,
|
|
285
|
+
spring_season: [false],
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
event: "mortgage pre-approval",
|
|
290
|
+
weight: 2,
|
|
291
|
+
properties: {
|
|
292
|
+
lender: ["Chase", "Wells Fargo", "Bank of America", "Rocket Mortgage", "United Wholesale", "loanDepot"],
|
|
293
|
+
approved_amount: u.weighNumRange(200000, 1500000, 0.4, 40),
|
|
294
|
+
mortgage_rate: u.weighNumRange(5.5, 7.5, 0.8, 20),
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
event: "offer submitted",
|
|
299
|
+
weight: 2,
|
|
300
|
+
properties: {
|
|
301
|
+
listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
302
|
+
offer_price: u.weighNumRange(200000, 1500000, 0.4, 40),
|
|
303
|
+
listing_price: u.weighNumRange(200000, 1500000, 0.4, 40),
|
|
304
|
+
pre_approved: [false],
|
|
305
|
+
spring_season: [false],
|
|
306
|
+
dual_tour: [false],
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
event: "offer accepted",
|
|
311
|
+
weight: 1,
|
|
312
|
+
properties: {
|
|
313
|
+
listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
314
|
+
final_price: u.weighNumRange(200000, 1500000, 0.4, 40),
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
event: "offer rejected",
|
|
319
|
+
weight: 1,
|
|
320
|
+
properties: {
|
|
321
|
+
listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
322
|
+
reason: ["price too low", "competing offer", "seller changed mind", "inspection issues", "financing fell through"],
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
event: "property listed",
|
|
327
|
+
weight: 3,
|
|
328
|
+
properties: {
|
|
329
|
+
listing_price: u.weighNumRange(200000, 1500000, 0.4, 50),
|
|
330
|
+
property_type: ["Single Family", "Condo", "Townhouse", "Multi-Family"],
|
|
331
|
+
bedrooms: [1, 2, 2, 3, 3, 3, 4, 4, 5],
|
|
332
|
+
listing_status: ["active", "pending", "coming soon"],
|
|
333
|
+
luxury: [false],
|
|
334
|
+
premier_agent: [false],
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
event: "property sold",
|
|
339
|
+
weight: 1,
|
|
340
|
+
properties: {
|
|
341
|
+
sale_price: u.weighNumRange(200000, 1500000, 0.4, 40),
|
|
342
|
+
days_on_market: u.weighNumRange(5, 180, 0.4, 30),
|
|
343
|
+
premier_agent: [false],
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
event: "agent contacted",
|
|
348
|
+
weight: 4,
|
|
349
|
+
properties: {
|
|
350
|
+
contact_method: ["phone", "email", "in-app message", "text"],
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
event: "open house attended",
|
|
355
|
+
weight: 2,
|
|
356
|
+
properties: {
|
|
357
|
+
listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
358
|
+
attendees: u.weighNumRange(2, 30, 0.5, 15),
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
event: "review submitted",
|
|
363
|
+
weight: 2,
|
|
364
|
+
properties: {
|
|
365
|
+
rating: [1, 2, 3, 3, 4, 4, 4, 5, 5, 5],
|
|
366
|
+
review_type: ["agent", "property", "platform"],
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
],
|
|
370
|
+
|
|
371
|
+
superProps: {
|
|
372
|
+
user_type: ["Buyer", "Buyer", "Buyer", "Agent", "Both"],
|
|
373
|
+
preferred_location: ["Urban", "Suburban", "Rural"],
|
|
374
|
+
property_preference: ["Single Family", "Condo", "Townhouse", "Multi-Family"],
|
|
375
|
+
has_saved_search: [false],
|
|
376
|
+
cold_lead: [false],
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
userProps: {
|
|
380
|
+
user_type: ["Buyer", "Buyer", "Buyer", "Agent", "Both"],
|
|
381
|
+
preferred_location: ["Urban", "Suburban", "Rural"],
|
|
382
|
+
property_preference: ["Single Family", "Condo", "Townhouse", "Multi-Family"],
|
|
383
|
+
budget_max: u.weighNumRange(200000, 2000000, 0.4, 50),
|
|
384
|
+
agent_tier: ["Standard", "Standard", "Standard", "Premier"],
|
|
385
|
+
total_properties_viewed: u.weighNumRange(0, 100, 0.5, 30),
|
|
386
|
+
pre_approval_status: ["none"],
|
|
387
|
+
has_saved_search: [false],
|
|
388
|
+
cold_lead: [false],
|
|
389
|
+
},
|
|
390
|
+
|
|
391
|
+
groupKeys: [
|
|
392
|
+
["brokerage_id", 200, ["property listed", "property sold", "agent contacted", "open house attended"]],
|
|
393
|
+
],
|
|
394
|
+
|
|
395
|
+
groupProps: {
|
|
396
|
+
brokerage_id: {
|
|
397
|
+
brokerage_name: ["Compass", "RE/MAX", "Keller Williams", "Coldwell Banker", "Century 21", "Sotheby's", "eXp Realty", "Redfin"],
|
|
398
|
+
agent_count: u.weighNumRange(5, 200, 0.4, 30),
|
|
399
|
+
region: ["Northeast", "Southeast", "Midwest", "Southwest", "West Coast", "Pacific Northwest"],
|
|
400
|
+
avg_listing_price: u.weighNumRange(200000, 1500000, 0.4, 30),
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
lookupTables: [],
|
|
405
|
+
|
|
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
|
+
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;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ===============================================================
|
|
485
|
+
// EVERYTHING HOOK — Complex behavioral patterns
|
|
486
|
+
// ===============================================================
|
|
487
|
+
if (type === "everything") {
|
|
488
|
+
const userEvents = record;
|
|
489
|
+
const profile = meta.profile;
|
|
490
|
+
|
|
491
|
+
// Stamp superProps from profile for consistency
|
|
492
|
+
userEvents.forEach(e => {
|
|
493
|
+
e.user_type = profile.user_type;
|
|
494
|
+
e.preferred_location = profile.preferred_location;
|
|
495
|
+
e.property_preference = profile.property_preference;
|
|
496
|
+
e.has_saved_search = profile.has_saved_search;
|
|
497
|
+
e.cold_lead = profile.cold_lead;
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const firstEventTime = userEvents.length > 0 ? dayjs(userEvents[0].time) : null;
|
|
501
|
+
|
|
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");
|
|
508
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
509
|
+
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
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
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
|
+
// -----------------------------------------------------------
|
|
523
|
+
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
|
+
}
|
|
533
|
+
|
|
534
|
+
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
|
+
const viewTemplate = userEvents.find(e => e.event === "property viewed");
|
|
541
|
+
const tourTemplate = userEvents.find(e => e.event === "tour scheduled");
|
|
542
|
+
|
|
543
|
+
if (viewTemplate) {
|
|
544
|
+
const extraCount = chance.integer({ min: 3, max: 8 });
|
|
545
|
+
for (let i = 0; i < extraCount; i++) {
|
|
546
|
+
const offset = chance.integer({ min: 10, max: num_days - 10 });
|
|
547
|
+
userEvents.push({
|
|
548
|
+
...viewTemplate,
|
|
549
|
+
time: DATASET_START.add(offset, "days").add(chance.integer({ min: 0, max: 23 }), "hours").toISOString(),
|
|
550
|
+
user_id: viewTemplate.user_id,
|
|
551
|
+
listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
552
|
+
listing_price: chance.integer({ min: 200000, max: 1200000 }),
|
|
553
|
+
has_saved_search: true,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (tourTemplate) {
|
|
558
|
+
const extraTours = chance.integer({ min: 1, max: 3 });
|
|
559
|
+
for (let i = 0; i < extraTours; i++) {
|
|
560
|
+
const offset = chance.integer({ min: 15, max: num_days - 10 });
|
|
561
|
+
userEvents.push({
|
|
562
|
+
...tourTemplate,
|
|
563
|
+
time: DATASET_START.add(offset, "days").add(chance.integer({ min: 8, max: 18 }), "hours").toISOString(),
|
|
564
|
+
user_id: tourTemplate.user_id,
|
|
565
|
+
listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
566
|
+
has_saved_search: true,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
} else {
|
|
571
|
+
// Non-savers: remove 80% of events after day 30
|
|
572
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
573
|
+
const evt = userEvents[i];
|
|
574
|
+
if (dayjs(evt.time).isAfter(day30)) {
|
|
575
|
+
if (chance.bool({ likelihood: 80 })) {
|
|
576
|
+
userEvents.splice(i, 1);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
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
|
+
// -----------------------------------------------------------
|
|
587
|
+
const hasPreApproval = userEvents.some(e => e.event === "mortgage pre-approval");
|
|
588
|
+
|
|
589
|
+
if (hasPreApproval) {
|
|
590
|
+
profile.pre_approval_status = "approved";
|
|
591
|
+
const offerTemplate = userEvents.find(e => e.event === "offer submitted");
|
|
592
|
+
if (offerTemplate) {
|
|
593
|
+
const extraOffers = chance.integer({ min: 4, max: 6 });
|
|
594
|
+
for (let i = 0; i < extraOffers; i++) {
|
|
595
|
+
const offset = chance.integer({ min: 20, max: num_days - 5 });
|
|
596
|
+
userEvents.push({
|
|
597
|
+
...offerTemplate,
|
|
598
|
+
time: DATASET_START.add(offset, "days").add(chance.integer({ min: 8, max: 20 }), "hours").toISOString(),
|
|
599
|
+
user_id: offerTemplate.user_id,
|
|
600
|
+
listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
601
|
+
offer_price: chance.integer({ min: 250000, max: 1200000 }),
|
|
602
|
+
listing_price: chance.integer({ min: 250000, max: 1200000 }),
|
|
603
|
+
pre_approved: true,
|
|
604
|
+
spring_season: false,
|
|
605
|
+
dual_tour: false,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Also tag existing offers
|
|
611
|
+
userEvents.forEach(e => {
|
|
612
|
+
if (e.event === "offer submitted") {
|
|
613
|
+
e.pre_approved = true;
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
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
|
+
// -----------------------------------------------------------
|
|
623
|
+
if (profile.agent_tier === "Premier") {
|
|
624
|
+
const listTemplate = userEvents.find(e => e.event === "property listed");
|
|
625
|
+
const soldTemplate = userEvents.find(e => e.event === "property sold");
|
|
626
|
+
|
|
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
|
+
if (listTemplate) {
|
|
636
|
+
const existingListings = userEvents.filter(e => e.event === "property listed").length;
|
|
637
|
+
const extraListings = existingListings * 2;
|
|
638
|
+
for (let i = 0; i < extraListings; i++) {
|
|
639
|
+
const offset = chance.integer({ min: 5, max: num_days - 5 });
|
|
640
|
+
userEvents.push({
|
|
641
|
+
...listTemplate,
|
|
642
|
+
time: DATASET_START.add(offset, "days").add(chance.integer({ min: 8, max: 18 }), "hours").toISOString(),
|
|
643
|
+
user_id: listTemplate.user_id,
|
|
644
|
+
listing_price: chance.integer({ min: 300000, max: 1500000 }),
|
|
645
|
+
listing_status: chance.pickone(["active", "pending", "coming soon"]),
|
|
646
|
+
luxury: false,
|
|
647
|
+
premier_agent: true,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Inject extra sales (2x = 1 more copy per existing)
|
|
653
|
+
if (soldTemplate) {
|
|
654
|
+
const existingSales = userEvents.filter(e => e.event === "property sold").length;
|
|
655
|
+
for (let i = 0; i < existingSales; i++) {
|
|
656
|
+
const offset = chance.integer({ min: 10, max: num_days - 5 });
|
|
657
|
+
userEvents.push({
|
|
658
|
+
...soldTemplate,
|
|
659
|
+
time: DATASET_START.add(offset, "days").add(chance.integer({ min: 9, max: 17 }), "hours").toISOString(),
|
|
660
|
+
user_id: soldTemplate.user_id,
|
|
661
|
+
sale_price: chance.integer({ min: 300000, max: 1500000 }),
|
|
662
|
+
days_on_market: chance.integer({ min: 5, max: 90 }),
|
|
663
|
+
premier_agent: true,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
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
|
+
// -----------------------------------------------------------
|
|
674
|
+
const hasVirtualTour = userEvents.some(e => e.event === "virtual tour");
|
|
675
|
+
const hasInPersonTour = userEvents.some(e => e.event === "in-person tour");
|
|
676
|
+
const isDualTourer = hasVirtualTour && hasInPersonTour;
|
|
677
|
+
|
|
678
|
+
if (isDualTourer) {
|
|
679
|
+
const offerTemplate = userEvents.find(e => e.event === "offer submitted");
|
|
680
|
+
if (offerTemplate) {
|
|
681
|
+
const extraOffers = chance.integer({ min: 5, max: 7 });
|
|
682
|
+
for (let i = 0; i < extraOffers; i++) {
|
|
683
|
+
const offset = chance.integer({ min: 15, max: num_days - 5 });
|
|
684
|
+
userEvents.push({
|
|
685
|
+
...offerTemplate,
|
|
686
|
+
time: DATASET_START.add(offset, "days").add(chance.integer({ min: 8, max: 20 }), "hours").toISOString(),
|
|
687
|
+
user_id: offerTemplate.user_id,
|
|
688
|
+
listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
689
|
+
offer_price: chance.integer({ min: 300000, max: 1500000 }),
|
|
690
|
+
listing_price: chance.integer({ min: 300000, max: 1500000 }),
|
|
691
|
+
dual_tour: true,
|
|
692
|
+
pre_approved: false,
|
|
693
|
+
spring_season: false,
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Tag existing offers
|
|
699
|
+
userEvents.forEach(e => {
|
|
700
|
+
if (e.event === "offer submitted") {
|
|
701
|
+
e.dual_tour = true;
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
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");
|
|
712
|
+
if (userEvents.length > 0) {
|
|
713
|
+
const userId = userEvents[0].user_id || "";
|
|
714
|
+
const isLuxuryBrowser = typeof userId === "string" && userId.length > 0 && userId.charCodeAt(0) % 33 === 0;
|
|
715
|
+
|
|
716
|
+
if (isLuxuryBrowser) {
|
|
717
|
+
const viewTemplate = userEvents.find(e => e.event === "property viewed");
|
|
718
|
+
if (viewTemplate) {
|
|
719
|
+
const extraViews = chance.integer({ min: 3, max: 6 });
|
|
720
|
+
for (let i = 0; i < extraViews; i++) {
|
|
721
|
+
const offset = chance.integer({ min: 50, max: num_days - 5 });
|
|
722
|
+
userEvents.push({
|
|
723
|
+
...viewTemplate,
|
|
724
|
+
time: DATASET_START.add(offset, "days").add(chance.integer({ min: 9, max: 21 }), "hours").toISOString(),
|
|
725
|
+
user_id: viewTemplate.user_id,
|
|
726
|
+
listing_id: `LST-${chance.integer({ min: 90000, max: 99999 })}`,
|
|
727
|
+
listing_price: chance.integer({ min: 5000000, max: 15000000 }),
|
|
728
|
+
property_type: chance.pickone(["Single Family", "Condo"]),
|
|
729
|
+
bedrooms: chance.integer({ min: 4, max: 8 }),
|
|
730
|
+
square_feet: chance.integer({ min: 4000, max: 15000 }),
|
|
731
|
+
luxury: true,
|
|
732
|
+
spring_season: false,
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
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
|
+
// -----------------------------------------------------------
|
|
744
|
+
const day14 = firstEventTime ? firstEventTime.add(14, "days") : null;
|
|
745
|
+
|
|
746
|
+
if (day14) {
|
|
747
|
+
const earlyEvents = userEvents.filter(e => dayjs(e.time).isBefore(day14));
|
|
748
|
+
const hasView = earlyEvents.some(e => e.event === "property viewed");
|
|
749
|
+
const hasSave = earlyEvents.some(e => e.event === "property saved");
|
|
750
|
+
|
|
751
|
+
if (hasView && !hasSave) {
|
|
752
|
+
// Cold lead: remove 90% of events after day 14
|
|
753
|
+
profile.cold_lead = true;
|
|
754
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
755
|
+
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
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
// Tag surviving events
|
|
765
|
+
userEvents.forEach(e => { e.cold_lead = true; });
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return record;
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
export default config;
|