@ak--47/dungeon-master 1.5.3 → 1.6.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/.claude/skills/analyze-soup/SKILL.md +21 -12
- package/.claude/skills/create-dungeon/SKILL.md +65 -5
- package/.claude/skills/create-project/SKILL.md +96 -0
- package/.claude/skills/create-project/provision.mjs +336 -0
- package/.claude/skills/verify-dungeon/SKILL.md +56 -25
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
- package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
- package/.claude/skills/write-hooks/SKILL.md +156 -18
- package/CHANGELOG.md +211 -0
- package/HOOKS.md +566 -112
- package/README.md +28 -9
- package/docs/guides/1.3.0-upgrade-guide.md +262 -0
- package/docs/guides/1.3.1-upgrade-guide.md +328 -0
- package/docs/guides/1.4.0-upgrade-guide.md +130 -0
- package/docs/guides/1.4.1-upgrade-guide.md +125 -0
- package/docs/guides/1.4.3-upgrade-guide.md +200 -0
- package/docs/guides/1.4.4-upgrade-guide.md +95 -0
- package/docs/guides/1.5.0-upgrade-guide.md +426 -0
- package/docs/guides/1.5.1-upgrade-guide.md +178 -0
- package/docs/guides/1.5.3-upgrade-guide.md +117 -0
- package/docs/guides/1.6.0-upgrade-guide.md +204 -0
- package/dungeons/technical/stories-verify.js +152 -0
- package/dungeons/vertical/README.md +154 -0
- package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
- package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
- package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
- package/dungeons/vertical/community/community.js +1589 -0
- package/dungeons/vertical/community/community.sql +216 -0
- package/dungeons/vertical/community/community.verify.mjs +72 -0
- package/dungeons/vertical/crypto/crypto.js +1483 -0
- package/dungeons/vertical/crypto/crypto.sql +194 -0
- package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
- package/dungeons/vertical/dating/dating.js +1540 -0
- package/dungeons/vertical/dating/dating.sql +288 -0
- package/dungeons/vertical/dating/dating.verify.mjs +72 -0
- package/dungeons/vertical/devtools/devtools.js +1524 -0
- package/dungeons/vertical/devtools/devtools.sql +161 -0
- package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
- package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
- package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
- package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
- package/dungeons/vertical/education/education.js +1763 -0
- package/dungeons/vertical/education/education.sql +243 -0
- package/dungeons/vertical/education/education.verify.mjs +75 -0
- package/dungeons/vertical/fintech/fintech.js +1410 -0
- package/dungeons/vertical/fintech/fintech.sql +175 -0
- package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
- package/dungeons/vertical/fitness/fitness.js +1495 -0
- package/dungeons/vertical/fitness/fitness.sql +206 -0
- package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
- package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
- package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
- package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
- package/dungeons/vertical/gaming/gaming.js +1741 -0
- package/dungeons/vertical/gaming/gaming.sql +249 -0
- package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
- package/dungeons/vertical/healthcare/healthcare.js +1488 -0
- package/dungeons/vertical/healthcare/healthcare.sql +193 -0
- package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
- package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
- package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
- package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
- package/dungeons/vertical/logistics/logistics.js +1541 -0
- package/dungeons/vertical/logistics/logistics.sql +205 -0
- package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
- package/dungeons/vertical/marketplace/marketplace.js +1438 -0
- package/dungeons/vertical/marketplace/marketplace.sql +148 -0
- package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
- package/dungeons/vertical/{media.js → media/media.js} +557 -58
- package/dungeons/vertical/media/media.sql +173 -0
- package/dungeons/vertical/media/media.verify.mjs +74 -0
- package/dungeons/vertical/real-estate/real-estate.js +1629 -0
- package/dungeons/vertical/real-estate/real-estate.sql +167 -0
- package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
- package/dungeons/vertical/sass/sass.js +1733 -0
- package/dungeons/vertical/sass/sass.sql +173 -0
- package/dungeons/vertical/sass/sass.verify.mjs +74 -0
- package/dungeons/vertical/social/social.js +1456 -0
- package/dungeons/vertical/social/social.sql +160 -0
- package/dungeons/vertical/social/social.verify.mjs +76 -0
- package/dungeons/vertical/streaming/streaming.js +677 -0
- package/dungeons/vertical/streaming/streaming.sql +101 -0
- package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
- package/dungeons/vertical/support-desk/support-desk.js +741 -0
- package/dungeons/vertical/support-desk/support-desk.sql +137 -0
- package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
- package/dungeons/vertical/travel/travel.js +1603 -0
- package/dungeons/vertical/travel/travel.sql +236 -0
- package/dungeons/vertical/travel/travel.verify.mjs +72 -0
- package/index.js +2 -2
- package/lib/core/config-validator.js +37 -2
- package/lib/core/context.js +4 -2
- package/lib/core/dungeon-loader.js +20 -15
- package/lib/core/dungeon-to-json.js +22 -4
- package/lib/core/storage.js +3 -3
- package/lib/generators/funnels.js +36 -9
- package/lib/hook-helpers/cohort.js +53 -0
- package/lib/hook-helpers/index.js +8 -0
- package/lib/hook-helpers/mutate.js +1 -1
- package/lib/hook-helpers/shape.js +319 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
- package/lib/hook-patterns/attributed-by-source.js +60 -58
- package/lib/hook-patterns/frequency-by-frequency.js +14 -4
- package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
- package/lib/hook-patterns/index.js +1 -1
- package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
- package/lib/orchestrators/user-loop.js +41 -0
- package/lib/templates/story-spec.schema.json +175 -0
- package/lib/utils/json-evaluator.js +1 -1
- package/lib/utils/utils.js +5 -4
- package/lib/verify/coerce.js +186 -0
- package/lib/verify/counting.js +270 -32
- package/lib/verify/emulate-breakdown.js +1285 -203
- package/lib/verify/first-time.js +89 -0
- package/lib/verify/flows.js +679 -0
- package/lib/verify/formula.js +259 -0
- package/lib/verify/funnel-engine.js +697 -113
- package/lib/verify/index.js +32 -4
- package/lib/verify/sessionize.js +275 -0
- package/lib/verify/story-runner.js +483 -0
- package/lib/verify/verify-dungeon.js +59 -37
- package/package.json +3 -2
- package/scripts/extract-dungeon-schema.mjs +2 -2
- package/scripts/run-many.mjs +2 -2
- package/scripts/verify-runner.mjs +2 -2
- package/scripts/verify-stories.mjs +244 -0
- package/types.d.ts +296 -30
- package/dungeons/vertical/ai-platform.js +0 -818
- package/dungeons/vertical/community.js +0 -966
- package/dungeons/vertical/crypto.js +0 -895
- package/dungeons/vertical/dating.js +0 -830
- package/dungeons/vertical/devtools.js +0 -1050
- package/dungeons/vertical/ecommerce.js +0 -811
- package/dungeons/vertical/education.js +0 -964
- package/dungeons/vertical/fintech.js +0 -946
- package/dungeons/vertical/fitness.js +0 -935
- package/dungeons/vertical/food-delivery.js +0 -934
- package/dungeons/vertical/gaming.js +0 -1120
- package/dungeons/vertical/healthcare.js +0 -945
- package/dungeons/vertical/insurance-application.js +0 -1039
- package/dungeons/vertical/logistics.js +0 -986
- package/dungeons/vertical/marketplace.js +0 -1019
- package/dungeons/vertical/real-estate.js +0 -872
- package/dungeons/vertical/sass.js +0 -995
- package/dungeons/vertical/social.js +0 -904
- package/dungeons/vertical/travel.js +0 -930
|
@@ -0,0 +1,1629 @@
|
|
|
1
|
+
// ── IMPORTS ──
|
|
2
|
+
import dayjs from "dayjs";
|
|
3
|
+
import utc from "dayjs/plugin/utc.js";
|
|
4
|
+
dayjs.extend(utc);
|
|
5
|
+
import "dotenv/config";
|
|
6
|
+
import * as u from "@ak--47/dungeon-master/utils";
|
|
7
|
+
/** @typedef {import("../../../types").Dungeon} Config */
|
|
8
|
+
|
|
9
|
+
// ── OVERVIEW ──
|
|
10
|
+
/*
|
|
11
|
+
* NAME: HomeNest
|
|
12
|
+
* APP: Property listings and agent CRM platform (Zillow meets
|
|
13
|
+
* Compass). Buyers search listings, save properties, schedule
|
|
14
|
+
* tours, get pre-approved for mortgages, and submit offers.
|
|
15
|
+
* Agents list properties, manage clients, and close deals.
|
|
16
|
+
* SCALE: 10,000 users, 121 days (2026-01-01 → 2026-05-01)
|
|
17
|
+
* CORE LOOP: account created → property search → property viewed → property saved → tour scheduled → offer submitted (buyer) | property listed → open house attended → property sold (agent)
|
|
18
|
+
*
|
|
19
|
+
* EVENTS (17):
|
|
20
|
+
* property viewed (12) > property search (10) > property saved (5) > virtual tour (4)
|
|
21
|
+
* > tour scheduled (4) > agent contacted (4) > saved search created (3) > in-person tour (3)
|
|
22
|
+
* > property listed (3) > mortgage pre-approval (2) > offer submitted (2) > open house attended (2)
|
|
23
|
+
* > review submitted (2) > account created (1) > offer accepted (1) > offer rejected (1)
|
|
24
|
+
* > property sold (1)
|
|
25
|
+
*
|
|
26
|
+
* FUNNELS (3):
|
|
27
|
+
* - Buyer Journey: account created → property search → property viewed (80%)
|
|
28
|
+
* - Tour Funnel: property viewed → tour scheduled → offer submitted (40%, reentry)
|
|
29
|
+
* - Seller Funnel: property listed → open house attended → property sold (35%)
|
|
30
|
+
*
|
|
31
|
+
* USER PROPS: user_type, preferred_location, property_preference, budget_max, agent_tier, total_properties_viewed, pre_approval_status
|
|
32
|
+
* SUPER PROPS: user_type, preferred_location, property_preference
|
|
33
|
+
* SCD PROPS: agent_tier (Standard/Premier, monthly fuzzy, max 4)
|
|
34
|
+
* GROUPS: brokerage_id (200 brokerages — property listed, property sold, agent contacted, open house attended)
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
// ── HOOK STORIES ──
|
|
38
|
+
/*
|
|
39
|
+
* NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable
|
|
40
|
+
* only via behavioral cohorts (count event per user, then measure
|
|
41
|
+
* downstream) or raw-prop breakdowns (date, agent_tier).
|
|
42
|
+
*
|
|
43
|
+
* -------------------------------------------------------------------
|
|
44
|
+
* 1. SPRING BUYING SEASON (event)
|
|
45
|
+
* -------------------------------------------------------------------
|
|
46
|
+
*
|
|
47
|
+
* PATTERN: Days 30-60, tour-scheduled duration_mins boosted 3x and
|
|
48
|
+
* offer-submitted offer_price boosted 2.5x. Mutates raw props. No flag.
|
|
49
|
+
*
|
|
50
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
51
|
+
*
|
|
52
|
+
* Report 1: Avg Offer Price by Day
|
|
53
|
+
* - Report type: Insights
|
|
54
|
+
* - Event: "offer submitted"
|
|
55
|
+
* - Measure: Average of "offer_price"
|
|
56
|
+
* - Line chart by day
|
|
57
|
+
* - Expected: visible bulge days 30-60 (~ 2.5x baseline)
|
|
58
|
+
*
|
|
59
|
+
* REAL-WORLD ANALOGUE: Spring buying season elevates prices and tour intent.
|
|
60
|
+
*
|
|
61
|
+
* -------------------------------------------------------------------
|
|
62
|
+
* 2. MORTGAGE RATE SHOCK (event + everything)
|
|
63
|
+
* -------------------------------------------------------------------
|
|
64
|
+
*
|
|
65
|
+
* PATTERN: Days 75-89, mortgage_rate forced to 7.5 on pre-approval
|
|
66
|
+
* events. In everything hook, 45% of post-day-75 offer-submitted events
|
|
67
|
+
* dropped (runs after all offer cloning so the drop is visible against
|
|
68
|
+
* the pre-approved/dual-tour clone volume). No flag.
|
|
69
|
+
*
|
|
70
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
71
|
+
*
|
|
72
|
+
* Report 1: Mortgage Rate Step Change
|
|
73
|
+
* - Report type: Insights
|
|
74
|
+
* - Event: "mortgage pre-approval"
|
|
75
|
+
* - Measure: Average of "mortgage_rate"
|
|
76
|
+
* - Line chart by day
|
|
77
|
+
* - Expected: clear step change from ~ 6.5 to 7.5 at day 75
|
|
78
|
+
*
|
|
79
|
+
* Report 2: Offer Volume Drop After Rate Hike
|
|
80
|
+
* - Report type: Insights
|
|
81
|
+
* - Event: "offer submitted"
|
|
82
|
+
* - Measure: Total
|
|
83
|
+
* - Line chart by day
|
|
84
|
+
* - Expected: ~ 45% volume drop after day 75
|
|
85
|
+
*
|
|
86
|
+
* REAL-WORLD ANALOGUE: Buyer demand is sensitive to mortgage rates.
|
|
87
|
+
*
|
|
88
|
+
* -------------------------------------------------------------------
|
|
89
|
+
* 3. SAVED-SEARCH RETENTION (everything)
|
|
90
|
+
* -------------------------------------------------------------------
|
|
91
|
+
*
|
|
92
|
+
* PATTERN: Users who create saved-search-created in first 7 days get
|
|
93
|
+
* extra cloned property-viewed + tour-scheduled events. Non-savers lose
|
|
94
|
+
* 80% of post-day-30 events. No flag — discover via cohort builder.
|
|
95
|
+
*
|
|
96
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
97
|
+
*
|
|
98
|
+
* Report 1: Retention by Saved-Search Cohort
|
|
99
|
+
* - Report type: Retention
|
|
100
|
+
* - Cohort A: users with >= 1 "saved search created" in first 7 days
|
|
101
|
+
* - Cohort B: rest
|
|
102
|
+
* - Expected: A sustains higher retention through dataset
|
|
103
|
+
*
|
|
104
|
+
* REAL-WORLD ANALOGUE: Saved searches indicate buyer intent.
|
|
105
|
+
*
|
|
106
|
+
* -------------------------------------------------------------------
|
|
107
|
+
* 4. PRE-APPROVED BUYER CONVERSION (everything)
|
|
108
|
+
* -------------------------------------------------------------------
|
|
109
|
+
*
|
|
110
|
+
* PATTERN: Users with a mortgage-pre-approval event get 4-6 extra
|
|
111
|
+
* cloned offer-submitted events. No flag — discover via cohort.
|
|
112
|
+
*
|
|
113
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
114
|
+
*
|
|
115
|
+
* Report 1: Offers per User by Pre-Approval
|
|
116
|
+
* - Report type: Insights (with cohort)
|
|
117
|
+
* - Cohort A: users with >= 1 "mortgage pre-approval"
|
|
118
|
+
* - Cohort B: rest
|
|
119
|
+
* - Event: "offer submitted"
|
|
120
|
+
* - Measure: Total per user
|
|
121
|
+
* - Expected: A ~ 4.5x B (mean 5 clones diluted by churn deletions
|
|
122
|
+
* and the H9 over-bucket offer drop, which skews pre-approved)
|
|
123
|
+
*
|
|
124
|
+
* REAL-WORLD ANALOGUE: Pre-approval is the strongest buyer indicator.
|
|
125
|
+
*
|
|
126
|
+
* -------------------------------------------------------------------
|
|
127
|
+
* 5. TOP-TIER AGENT ADVANTAGE (everything)
|
|
128
|
+
* -------------------------------------------------------------------
|
|
129
|
+
*
|
|
130
|
+
* PATTERN: Premier-tier agents get 2 extra cloned listings per existing
|
|
131
|
+
* (3x rate) and 1 extra cloned sale per existing (2x rate). Mutates
|
|
132
|
+
* cloned events with raw values; reads agent_tier from profile (existing
|
|
133
|
+
* SCD prop). Discover via agent_tier breakdown.
|
|
134
|
+
*
|
|
135
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
136
|
+
*
|
|
137
|
+
* Report 1: Listings per Agent by Tier
|
|
138
|
+
* - Report type: Insights
|
|
139
|
+
* - Event: "property listed"
|
|
140
|
+
* - Measure: Total per user
|
|
141
|
+
* - Breakdown: user "agent_tier"
|
|
142
|
+
* - Expected: Premier ~ 3x Standard
|
|
143
|
+
*
|
|
144
|
+
* REAL-WORLD ANALOGUE: Top producers list and close more.
|
|
145
|
+
*
|
|
146
|
+
* -------------------------------------------------------------------
|
|
147
|
+
* 6. TOUR-TO-OFFER POWER USERS (everything)
|
|
148
|
+
* -------------------------------------------------------------------
|
|
149
|
+
*
|
|
150
|
+
* PATTERN: Users with both virtual-tour AND in-person-tour events get
|
|
151
|
+
* 5-7 extra cloned offer-submitted events. No flag — discover via
|
|
152
|
+
* compound cohort builder.
|
|
153
|
+
*
|
|
154
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
155
|
+
*
|
|
156
|
+
* Report 1: Offers per User by Dual-Tour Cohort
|
|
157
|
+
* - Report type: Insights (with cohort)
|
|
158
|
+
* - Cohort A: users with both >= 1 "virtual tour" AND >= 1 "in-person tour"
|
|
159
|
+
* - Cohort B: rest
|
|
160
|
+
* - Event: "offer submitted"
|
|
161
|
+
* - Measure: Total per user
|
|
162
|
+
* - Expected: A ~ 5x B (mean 6 clones diluted by the same churn/H9/H2
|
|
163
|
+
* deletion ledger as pattern 4)
|
|
164
|
+
*
|
|
165
|
+
* REAL-WORLD ANALOGUE: Dual-tour buyers self-qualify into serious bidders.
|
|
166
|
+
*
|
|
167
|
+
* -------------------------------------------------------------------
|
|
168
|
+
* 7. LUXURY LISTING RELEASE (event + everything)
|
|
169
|
+
* -------------------------------------------------------------------
|
|
170
|
+
*
|
|
171
|
+
* PATTERN: After day 50, 3% of property-listed events get listing_price
|
|
172
|
+
* set to $5M-$15M (raw mutation). ~6% of users (uuid first-char hash:
|
|
173
|
+
* charCodeAt(0) % 33 === 0 hits only 'c', 1 of 16 hex chars) get extra
|
|
174
|
+
* luxury cloned property-viewed events. No flag — discover via line
|
|
175
|
+
* chart by day on listing_price.
|
|
176
|
+
*
|
|
177
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
178
|
+
*
|
|
179
|
+
* Report 1: Listing Price Distribution Over Time
|
|
180
|
+
* - Report type: Insights
|
|
181
|
+
* - Event: "property listed"
|
|
182
|
+
* - Measure: Distribution of "listing_price"
|
|
183
|
+
* - Line chart by week
|
|
184
|
+
* - Expected: $5M+ outliers appear only after day 50
|
|
185
|
+
*
|
|
186
|
+
* REAL-WORLD ANALOGUE: Luxury inventory drop attracts HNW shoppers.
|
|
187
|
+
*
|
|
188
|
+
* -------------------------------------------------------------------
|
|
189
|
+
* 8. COLD-LEAD CHURN (everything)
|
|
190
|
+
* -------------------------------------------------------------------
|
|
191
|
+
*
|
|
192
|
+
* PATTERN: Users who view (property viewed) but never save (property
|
|
193
|
+
* saved) in the first 14 days lose 90% of events after day 14. No flag.
|
|
194
|
+
*
|
|
195
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
196
|
+
*
|
|
197
|
+
* Report 1: Cold-Lead Retention
|
|
198
|
+
* - Report type: Retention
|
|
199
|
+
* - Cohort A: users with >= 1 "property viewed" but 0 "property saved" in first 14 days
|
|
200
|
+
* - Cohort B: rest
|
|
201
|
+
* - Expected: A retention drops to near-zero after day 14
|
|
202
|
+
*
|
|
203
|
+
* REAL-WORLD ANALOGUE: Browsers who never save are window-shoppers.
|
|
204
|
+
*
|
|
205
|
+
* -------------------------------------------------------------------
|
|
206
|
+
* 9. PROPERTY-VIEWED MAGIC NUMBER (everything)
|
|
207
|
+
* -------------------------------------------------------------------
|
|
208
|
+
*
|
|
209
|
+
* PATTERN: Users in the 6-12 property-viewed sweet spot get +30% on
|
|
210
|
+
* offer_price for offer-submitted events. Users with 13+ views are
|
|
211
|
+
* tire-kickers; 60% of their offer-submitted events drop (the bucket
|
|
212
|
+
* is dominated by pre-approved/dual-tour power cloners, so 60% deletion
|
|
213
|
+
* nets the visible ~35%-fewer-offers cohort read). No flag.
|
|
214
|
+
*
|
|
215
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
216
|
+
*
|
|
217
|
+
* Report 1: Avg Offer Price by View Bucket
|
|
218
|
+
* - Report type: Insights (with cohort)
|
|
219
|
+
* - Cohort A: users with 6-12 "property viewed"
|
|
220
|
+
* - Cohort B: users with 0-5
|
|
221
|
+
* - Event: "offer submitted"
|
|
222
|
+
* - Measure: Average of "offer_price"
|
|
223
|
+
* - Expected: A ~ 1.3x B
|
|
224
|
+
*
|
|
225
|
+
* Report 2: Offers per User on Heavy Viewers
|
|
226
|
+
* - Report type: Insights (with cohort)
|
|
227
|
+
* - Cohort C: users with >= 13 "property viewed"
|
|
228
|
+
* - Cohort A: users with 6-12
|
|
229
|
+
* - Event: "offer submitted"
|
|
230
|
+
* - Measure: Total per user
|
|
231
|
+
* - Expected: C ~ 35% fewer offers per user vs A
|
|
232
|
+
*
|
|
233
|
+
* REAL-WORLD ANALOGUE: Focused buyers commit; obsessive browsers
|
|
234
|
+
* tire-kick and never make the leap.
|
|
235
|
+
*
|
|
236
|
+
* -------------------------------------------------------------------
|
|
237
|
+
* 10. TOUR FUNNEL TIME-TO-CONVERT (funnel-post)
|
|
238
|
+
* -------------------------------------------------------------------
|
|
239
|
+
*
|
|
240
|
+
* PATTERN: Premier-tier agents move users through the Tour funnel
|
|
241
|
+
* (property viewed -> tour scheduled -> offer submitted) 1.4x faster
|
|
242
|
+
* (factor 0.71). Standard-tier agents complete it 1.3x slower
|
|
243
|
+
* (factor 1.3). The hook intercepts funnel-post arrays, computes the
|
|
244
|
+
* time gap between consecutive steps, and scales each gap by the
|
|
245
|
+
* tier-specific factor before rewriting the step timestamps.
|
|
246
|
+
*
|
|
247
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
248
|
+
*
|
|
249
|
+
* Report 1: Tour Funnel Median TTC by Agent Tier
|
|
250
|
+
* - Report type: Funnels
|
|
251
|
+
* - Steps: "property viewed" -> "tour scheduled" -> "offer submitted"
|
|
252
|
+
* - Measure: Median time to convert
|
|
253
|
+
* - Breakdown: "agent_tier" (user property / SCD)
|
|
254
|
+
* - Expected: Premier ~ 0.71x baseline; Standard ~ 1.3x baseline
|
|
255
|
+
*
|
|
256
|
+
* NOTE (funnel-post measurement): visible via Mixpanel funnel median
|
|
257
|
+
* TTC and via the emulator's timeToConvert read (stories H10) at a
|
|
258
|
+
* 31.2h window = 24h generative x 1.3 Standard stretch. Cross-event
|
|
259
|
+
* MIN→MIN SQL queries on raw events do NOT show this — funnel-post
|
|
260
|
+
* adjusts gaps within funnel instances, not across the user's full
|
|
261
|
+
* event history. The 2-step view→tour pair is the primary read; the
|
|
262
|
+
* 3-step read attenuates toward 1 because H4/H6 offer clones collide
|
|
263
|
+
* with the greedy third-step pick.
|
|
264
|
+
*
|
|
265
|
+
* REAL-WORLD ANALOGUE: Premier agents have larger networks, faster
|
|
266
|
+
* scheduling workflows, and prioritized showing slots, translating
|
|
267
|
+
* to shorter tour-to-offer cycles.
|
|
268
|
+
*
|
|
269
|
+
* ===================================================================
|
|
270
|
+
* EXPECTED METRICS SUMMARY
|
|
271
|
+
* (Measured = full fidelity, 10K users / 269,067 events)
|
|
272
|
+
* ===================================================================
|
|
273
|
+
*
|
|
274
|
+
* Story id | Metric | Expected | Measured
|
|
275
|
+
* ---------|---------------------------------------------|---------------|---------
|
|
276
|
+
* H1 | spring/outside avg offer_price | ~2.6 (2.5 knob + sweet-spot mix) | 2.611
|
|
277
|
+
* H1 | spring/outside avg tour duration | ~2.6 (3x knob, clone leak) | 2.543
|
|
278
|
+
* H2 | shock-window mortgage_rate pin | min=max=7.5 | 7.5 / 7.5
|
|
279
|
+
* H2 | offer share-of-volume post/pre d75 | ~0.77 (0.55 x clone drift) | 0.758
|
|
280
|
+
* H3 | post-d30 events/user savers/non | ~6.5 | 6.55
|
|
281
|
+
* H3 | active-in-April savers / non (born pre-Mar) | ~0.98 / ~0.55 | 0.9870 / 0.5561
|
|
282
|
+
* H4 | offers/user pre-approved/rest | ~4.5 | 4.47
|
|
283
|
+
* H4 | profile-flag purity on event cohort | 1.0 | 1.0000
|
|
284
|
+
* H5 | listings/user Premier/Standard | ~2.8 | 2.736
|
|
285
|
+
* H5 | sales/user Premier/Standard | ~1.9 | 1.899
|
|
286
|
+
* H6 | offers/user dual-tour/rest | ~5x | 5.05
|
|
287
|
+
* H6 | decomposition: dual-only/neither | ~4.5 | 4.28
|
|
288
|
+
* H7 | $2M+ listings strictly pre-d50 / post share | 0 / ~1.9% | 0 / 2.19%
|
|
289
|
+
* H7 | luxury views: browser pu / non-browser | ~2 / 0 | 2.07 / 0
|
|
290
|
+
* H8 | post-first-14d events/user cold/rest | ~0.13 | 0.134
|
|
291
|
+
* H8 | trajectory: cold post/pre vs rest | <0.85 vs >1.6 | 0.706 vs 2.246
|
|
292
|
+
* H9 | non-spring offer_price sweet/low | ~1.34 | 1.343
|
|
293
|
+
* H9 | placebo: over/low price | ~1.0 | 1.022
|
|
294
|
+
* H9 | offers/user over/sweet | ~0.65 | 0.647
|
|
295
|
+
* H10 | emulator 2-step TTC Premier/Standard (31.2h)| 0.546 mech | 0.555
|
|
296
|
+
* H10 | secondary: 3-step TTC ratio | ~0.75 (attenuated) | 0.746
|
|
297
|
+
* H10 | identity: uid resolution / stamp agreement | 1.0 / 1.0 | 1.0 / 1.0
|
|
298
|
+
*/
|
|
299
|
+
|
|
300
|
+
// ── SCALE ──
|
|
301
|
+
const SEED = "homenest";
|
|
302
|
+
const NUM_USERS = 10_000;
|
|
303
|
+
const NUM_DAYS = 120;
|
|
304
|
+
const DATASET_START = "2026-01-01T00:00:00Z";
|
|
305
|
+
const DATASET_END = "2026-05-01T23:59:59Z";
|
|
306
|
+
const EVENTS_PER_DAY = 0.53;
|
|
307
|
+
const token = process.env.MP_TOKEN || "your-mixpanel-token";
|
|
308
|
+
|
|
309
|
+
const chance = u.initChance(SEED);
|
|
310
|
+
|
|
311
|
+
// ── KNOBS (tweak these to reshape stories) ──
|
|
312
|
+
const SPRING_START_DAY = 30;
|
|
313
|
+
const SPRING_END_DAY = 60;
|
|
314
|
+
const SPRING_TOUR_DURATION_MULT = 3;
|
|
315
|
+
const SPRING_OFFER_PRICE_MULT = 2.5;
|
|
316
|
+
|
|
317
|
+
const SHOCK_START_DAY = 75;
|
|
318
|
+
const SHOCK_END_DAY = 89;
|
|
319
|
+
const SHOCK_MORTGAGE_RATE = 7.5;
|
|
320
|
+
const SHOCK_OFFER_DROP_LIKELIHOOD = 45;
|
|
321
|
+
|
|
322
|
+
const SAVED_SEARCH_WINDOW_DAYS = 7;
|
|
323
|
+
const SAVED_SEARCH_VIEW_CLONES_MIN = 3;
|
|
324
|
+
const SAVED_SEARCH_VIEW_CLONES_MAX = 8;
|
|
325
|
+
const SAVED_SEARCH_TOUR_CLONES_MIN = 1;
|
|
326
|
+
const SAVED_SEARCH_TOUR_CLONES_MAX = 3;
|
|
327
|
+
const NON_SAVER_CUTOFF_DAY = 30;
|
|
328
|
+
const NON_SAVER_DROP_LIKELIHOOD = 80;
|
|
329
|
+
|
|
330
|
+
const PRE_APPROVAL_OFFER_CLONES_MIN = 4;
|
|
331
|
+
const PRE_APPROVAL_OFFER_CLONES_MAX = 6;
|
|
332
|
+
|
|
333
|
+
const PREMIER_LISTING_CLONE_MULT = 2;
|
|
334
|
+
const PREMIER_SALE_CLONE_MULT = 1;
|
|
335
|
+
|
|
336
|
+
const DUAL_TOUR_OFFER_CLONES_MIN = 5;
|
|
337
|
+
const DUAL_TOUR_OFFER_CLONES_MAX = 7;
|
|
338
|
+
|
|
339
|
+
const LUXURY_RELEASE_DAY = 50;
|
|
340
|
+
const LUXURY_LISTING_LIKELIHOOD = 3;
|
|
341
|
+
const LUXURY_PRICE_MIN = 5_000_000;
|
|
342
|
+
const LUXURY_PRICE_MAX = 15_000_000;
|
|
343
|
+
const LUXURY_USER_HASH_MOD = 33;
|
|
344
|
+
const LUXURY_VIEW_CLONES_MIN = 3;
|
|
345
|
+
const LUXURY_VIEW_CLONES_MAX = 6;
|
|
346
|
+
|
|
347
|
+
const COLD_LEAD_WINDOW_DAYS = 14;
|
|
348
|
+
const COLD_LEAD_DROP_LIKELIHOOD = 90;
|
|
349
|
+
|
|
350
|
+
const VIEW_SWEET_MIN = 6;
|
|
351
|
+
const VIEW_SWEET_MAX = 12;
|
|
352
|
+
const VIEW_OVER_THRESHOLD = 13;
|
|
353
|
+
const VIEW_OFFER_PRICE_BOOST = 1.3;
|
|
354
|
+
// 60 (not 35): the 13+-view bucket is ~97% pre-approved/dual-tour power
|
|
355
|
+
// cloners whose higher underlying offer volume offsets the deletion — a
|
|
356
|
+
// 35% drop left over/sweet at ~1.06 (invisible). 60% deletion nets the
|
|
357
|
+
// documented "~35% fewer offers per user" in the plain cohort read.
|
|
358
|
+
const VIEW_OVER_OFFER_DROP_LIKELIHOOD = 60;
|
|
359
|
+
|
|
360
|
+
const TTC_PREMIER_FACTOR = 0.71;
|
|
361
|
+
const TTC_STANDARD_FACTOR = 1.3;
|
|
362
|
+
|
|
363
|
+
// ── HELPER FUNCTIONS ──
|
|
364
|
+
function handleFunnelPostHooks(record, meta) {
|
|
365
|
+
// H10: Tour Funnel TTC — Premier 0.71x, Standard 1.3x.
|
|
366
|
+
const segment = meta?.profile?.agent_tier;
|
|
367
|
+
if (Array.isArray(record) && record.length > 1) {
|
|
368
|
+
const factor = (
|
|
369
|
+
segment === "Premier" ? TTC_PREMIER_FACTOR :
|
|
370
|
+
segment === "Standard" ? TTC_STANDARD_FACTOR :
|
|
371
|
+
1.0
|
|
372
|
+
);
|
|
373
|
+
if (factor !== 1.0) {
|
|
374
|
+
for (let i = 1; i < record.length; i++) {
|
|
375
|
+
const prev = dayjs(record[i - 1].time);
|
|
376
|
+
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
377
|
+
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return record;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function handleEverythingHooks(record, meta) {
|
|
385
|
+
// UTC mode is load-bearing: dayjs.unix() returns a LOCAL-mode instance,
|
|
386
|
+
// and local .add(N, "days") does calendar-day arithmetic — it slips 1h
|
|
387
|
+
// across the March DST spring-forward, making every day-boundary window
|
|
388
|
+
// (and therefore the generated data) depend on the host timezone. The
|
|
389
|
+
// H2 rate pin visibly cut off at 23:00 UTC on its last day before this.
|
|
390
|
+
const datasetStart = dayjs.unix(meta.datasetStart).utc();
|
|
391
|
+
const userEvents = record;
|
|
392
|
+
const profile = meta.profile;
|
|
393
|
+
|
|
394
|
+
// Stamp superProps from profile for consistency
|
|
395
|
+
userEvents.forEach(e => {
|
|
396
|
+
e.user_type = profile.user_type;
|
|
397
|
+
e.preferred_location = profile.preferred_location;
|
|
398
|
+
e.property_preference = profile.property_preference;
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// HOOK 1: SPRING BUYING SEASON — d30-60 tour duration 3x, offer_price 2.5x
|
|
402
|
+
// HOOK 2: MORTGAGE RATE SHOCK — d75-89 mortgage_rate pinned 7.5
|
|
403
|
+
// HOOK 7: LUXURY LISTING RELEASE — post-d50, 3% of listings priced $5M-$15M
|
|
404
|
+
const springStart = datasetStart.add(SPRING_START_DAY, "days");
|
|
405
|
+
const springEnd = datasetStart.add(SPRING_END_DAY, "days");
|
|
406
|
+
const shockStart = datasetStart.add(SHOCK_START_DAY, "days");
|
|
407
|
+
const shockEnd = datasetStart.add(SHOCK_END_DAY, "days");
|
|
408
|
+
const luxuryStart = datasetStart.add(LUXURY_RELEASE_DAY, "days");
|
|
409
|
+
userEvents.forEach(e => {
|
|
410
|
+
const t = dayjs.utc(e.time);
|
|
411
|
+
const inSpring = (t.isAfter(springStart) || t.isSame(springStart)) && t.isBefore(springEnd);
|
|
412
|
+
if (inSpring && e.event === "tour scheduled" && typeof e.duration_mins === "number") {
|
|
413
|
+
e.duration_mins = Math.round(e.duration_mins * SPRING_TOUR_DURATION_MULT);
|
|
414
|
+
}
|
|
415
|
+
// Spring offer_price boost moved to end (after all cloning)
|
|
416
|
+
if (e.event === "mortgage pre-approval") {
|
|
417
|
+
if ((t.isAfter(shockStart) || t.isSame(shockStart)) && t.isBefore(shockEnd)) {
|
|
418
|
+
e.mortgage_rate = SHOCK_MORTGAGE_RATE;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (e.event === "property listed" && t.isAfter(luxuryStart) && chance.bool({ likelihood: LUXURY_LISTING_LIKELIHOOD })) {
|
|
422
|
+
e.listing_price = chance.integer({ min: LUXURY_PRICE_MIN, max: LUXURY_PRICE_MAX });
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const firstEventTime = userEvents.length > 0 ? dayjs.utc(userEvents[0].time) : null;
|
|
427
|
+
|
|
428
|
+
// HOOK 3: SAVED-SEARCH RETENTION — early savers (saved-search-created
|
|
429
|
+
// in first 7 days) get extra cloned view + tour events. Non-savers
|
|
430
|
+
// lose 80% of events after day 30. No flag.
|
|
431
|
+
const day7 = firstEventTime ? firstEventTime.add(SAVED_SEARCH_WINDOW_DAYS, "days") : null;
|
|
432
|
+
const day30 = datasetStart.add(NON_SAVER_CUTOFF_DAY, "days");
|
|
433
|
+
const hasSavedSearch = day7 ? userEvents.some(e =>
|
|
434
|
+
e.event === "saved search created" && dayjs.utc(e.time).isBefore(day7)
|
|
435
|
+
) : false;
|
|
436
|
+
|
|
437
|
+
if (hasSavedSearch) {
|
|
438
|
+
const viewTemplate = userEvents.find(e => e.event === "property viewed");
|
|
439
|
+
const tourTemplate = userEvents.find(e => e.event === "tour scheduled");
|
|
440
|
+
if (viewTemplate) {
|
|
441
|
+
const extras = chance.integer({ min: SAVED_SEARCH_VIEW_CLONES_MIN, max: SAVED_SEARCH_VIEW_CLONES_MAX });
|
|
442
|
+
for (let i = 0; i < extras; i++) {
|
|
443
|
+
const offset = chance.integer({ min: 10, max: NUM_DAYS - 10 });
|
|
444
|
+
userEvents.push({
|
|
445
|
+
...viewTemplate,
|
|
446
|
+
time: datasetStart.add(offset, "days").add(chance.integer({ min: 0, max: 23 }), "hours").toISOString(),
|
|
447
|
+
user_id: viewTemplate.user_id,
|
|
448
|
+
listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
449
|
+
listing_price: chance.integer({ min: 200000, max: 1200000 }),
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (tourTemplate) {
|
|
454
|
+
const extras = chance.integer({ min: SAVED_SEARCH_TOUR_CLONES_MIN, max: SAVED_SEARCH_TOUR_CLONES_MAX });
|
|
455
|
+
for (let i = 0; i < extras; i++) {
|
|
456
|
+
const offset = chance.integer({ min: 15, max: NUM_DAYS - 10 });
|
|
457
|
+
userEvents.push({
|
|
458
|
+
...tourTemplate,
|
|
459
|
+
time: datasetStart.add(offset, "days").add(chance.integer({ min: 8, max: 18 }), "hours").toISOString(),
|
|
460
|
+
user_id: tourTemplate.user_id,
|
|
461
|
+
listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
} else {
|
|
466
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
467
|
+
const evt = userEvents[i];
|
|
468
|
+
if (dayjs.utc(evt.time).isAfter(day30) && chance.bool({ likelihood: NON_SAVER_DROP_LIKELIHOOD })) {
|
|
469
|
+
userEvents.splice(i, 1);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// HOOK 4: PRE-APPROVED BUYER CONVERSION — clone 4-6 extra
|
|
475
|
+
// offer-submitted events for users with a mortgage pre-approval
|
|
476
|
+
// event. No flag.
|
|
477
|
+
const hasPreApproval = userEvents.some(e => e.event === "mortgage pre-approval");
|
|
478
|
+
if (hasPreApproval) {
|
|
479
|
+
profile.pre_approval_status = "approved";
|
|
480
|
+
const offerTemplate = userEvents.find(e => e.event === "offer submitted");
|
|
481
|
+
if (offerTemplate) {
|
|
482
|
+
const extras = chance.integer({ min: PRE_APPROVAL_OFFER_CLONES_MIN, max: PRE_APPROVAL_OFFER_CLONES_MAX });
|
|
483
|
+
for (let i = 0; i < extras; i++) {
|
|
484
|
+
const offset = chance.integer({ min: 20, max: NUM_DAYS - 5 });
|
|
485
|
+
userEvents.push({
|
|
486
|
+
...offerTemplate,
|
|
487
|
+
time: datasetStart.add(offset, "days").add(chance.integer({ min: 8, max: 20 }), "hours").toISOString(),
|
|
488
|
+
user_id: offerTemplate.user_id,
|
|
489
|
+
listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
490
|
+
offer_price: chance.integer({ min: 250000, max: 1200000 }),
|
|
491
|
+
listing_price: chance.integer({ min: 250000, max: 1200000 }),
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// HOOK 5: TOP-TIER AGENT ADVANTAGE — Premier agents get 2 extra
|
|
498
|
+
// cloned listings per existing (3x rate) and 1 extra clone per
|
|
499
|
+
// sale (2x rate). Reads agent_tier from profile. No flag.
|
|
500
|
+
if (profile.agent_tier === "Premier") {
|
|
501
|
+
const listTemplate = userEvents.find(e => e.event === "property listed");
|
|
502
|
+
const soldTemplate = userEvents.find(e => e.event === "property sold");
|
|
503
|
+
|
|
504
|
+
if (listTemplate) {
|
|
505
|
+
const existingListings = userEvents.filter(e => e.event === "property listed").length;
|
|
506
|
+
const extras = existingListings * PREMIER_LISTING_CLONE_MULT;
|
|
507
|
+
for (let i = 0; i < extras; i++) {
|
|
508
|
+
const offset = chance.integer({ min: 5, max: NUM_DAYS - 5 });
|
|
509
|
+
userEvents.push({
|
|
510
|
+
...listTemplate,
|
|
511
|
+
time: datasetStart.add(offset, "days").add(chance.integer({ min: 8, max: 18 }), "hours").toISOString(),
|
|
512
|
+
user_id: listTemplate.user_id,
|
|
513
|
+
listing_price: chance.integer({ min: 300000, max: 1500000 }),
|
|
514
|
+
listing_status: chance.pickone(["active", "pending", "coming soon"]),
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (soldTemplate) {
|
|
519
|
+
const existingSales = userEvents.filter(e => e.event === "property sold").length;
|
|
520
|
+
const extras = existingSales * PREMIER_SALE_CLONE_MULT;
|
|
521
|
+
for (let i = 0; i < extras; i++) {
|
|
522
|
+
const offset = chance.integer({ min: 10, max: NUM_DAYS - 5 });
|
|
523
|
+
userEvents.push({
|
|
524
|
+
...soldTemplate,
|
|
525
|
+
time: datasetStart.add(offset, "days").add(chance.integer({ min: 9, max: 17 }), "hours").toISOString(),
|
|
526
|
+
user_id: soldTemplate.user_id,
|
|
527
|
+
sale_price: chance.integer({ min: 300000, max: 1500000 }),
|
|
528
|
+
days_on_market: chance.integer({ min: 5, max: 90 }),
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// HOOK 6: TOUR-TO-OFFER POWER USERS — users with both virtual
|
|
535
|
+
// tour AND in-person tour get 5-7 extra cloned offers. No flag.
|
|
536
|
+
const hasVirtualTour = userEvents.some(e => e.event === "virtual tour");
|
|
537
|
+
const hasInPersonTour = userEvents.some(e => e.event === "in-person tour");
|
|
538
|
+
if (hasVirtualTour && hasInPersonTour) {
|
|
539
|
+
const offerTemplate = userEvents.find(e => e.event === "offer submitted");
|
|
540
|
+
if (offerTemplate) {
|
|
541
|
+
const extras = chance.integer({ min: DUAL_TOUR_OFFER_CLONES_MIN, max: DUAL_TOUR_OFFER_CLONES_MAX });
|
|
542
|
+
for (let i = 0; i < extras; i++) {
|
|
543
|
+
const offset = chance.integer({ min: 15, max: NUM_DAYS - 5 });
|
|
544
|
+
userEvents.push({
|
|
545
|
+
...offerTemplate,
|
|
546
|
+
time: datasetStart.add(offset, "days").add(chance.integer({ min: 8, max: 20 }), "hours").toISOString(),
|
|
547
|
+
user_id: offerTemplate.user_id,
|
|
548
|
+
listing_id: `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
549
|
+
offer_price: chance.integer({ min: 300000, max: 1500000 }),
|
|
550
|
+
listing_price: chance.integer({ min: 300000, max: 1500000 }),
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// HOOK 7 (cont): LUXURY LISTING RELEASE — ~3% of users (by hash)
|
|
557
|
+
// get 3-6 extra luxury property-viewed events after day 50. No flag.
|
|
558
|
+
if (userEvents.length > 0) {
|
|
559
|
+
const userId = userEvents[0].user_id || "";
|
|
560
|
+
const isLuxuryBrowser = typeof userId === "string" && userId.length > 0 && userId.charCodeAt(0) % LUXURY_USER_HASH_MOD === 0;
|
|
561
|
+
if (isLuxuryBrowser) {
|
|
562
|
+
const viewTemplate = userEvents.find(e => e.event === "property viewed");
|
|
563
|
+
if (viewTemplate) {
|
|
564
|
+
const extras = chance.integer({ min: LUXURY_VIEW_CLONES_MIN, max: LUXURY_VIEW_CLONES_MAX });
|
|
565
|
+
for (let i = 0; i < extras; i++) {
|
|
566
|
+
const offset = chance.integer({ min: LUXURY_RELEASE_DAY, max: NUM_DAYS - 5 });
|
|
567
|
+
userEvents.push({
|
|
568
|
+
...viewTemplate,
|
|
569
|
+
time: datasetStart.add(offset, "days").add(chance.integer({ min: 9, max: 21 }), "hours").toISOString(),
|
|
570
|
+
user_id: viewTemplate.user_id,
|
|
571
|
+
listing_id: `LST-${chance.integer({ min: 90000, max: 99999 })}`,
|
|
572
|
+
listing_price: chance.integer({ min: LUXURY_PRICE_MIN, max: LUXURY_PRICE_MAX }),
|
|
573
|
+
property_type: chance.pickone(["Single Family", "Condo"]),
|
|
574
|
+
bedrooms: chance.integer({ min: 4, max: 8 }),
|
|
575
|
+
square_feet: chance.integer({ min: 4000, max: 15000 }),
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// HOOK 8: COLD-LEAD CHURN — users who view but never save in
|
|
583
|
+
// first 14 days lose 90% of post-day-14 events. No flag.
|
|
584
|
+
const day14 = firstEventTime ? firstEventTime.add(COLD_LEAD_WINDOW_DAYS, "days") : null;
|
|
585
|
+
if (day14) {
|
|
586
|
+
const earlyEvents = userEvents.filter(e => dayjs.utc(e.time).isBefore(day14));
|
|
587
|
+
const hasView = earlyEvents.some(e => e.event === "property viewed");
|
|
588
|
+
const hasSave = earlyEvents.some(e => e.event === "property saved");
|
|
589
|
+
if (hasView && !hasSave) {
|
|
590
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
591
|
+
const evt = userEvents[i];
|
|
592
|
+
if (dayjs.utc(evt.time).isAfter(day14) && chance.bool({ likelihood: COLD_LEAD_DROP_LIKELIHOOD })) {
|
|
593
|
+
userEvents.splice(i, 1);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// HOOK 9: PROPERTY-VIEWED MAGIC NUMBER (no flags)
|
|
600
|
+
// Sweet 6-12 views → +30% offer_price on offer submitted events.
|
|
601
|
+
// Over 13+ → drop 60% of offer submitted events (tire-kickers).
|
|
602
|
+
const viewCount = userEvents.filter(e => e.event === "property viewed").length;
|
|
603
|
+
if (viewCount >= VIEW_SWEET_MIN && viewCount <= VIEW_SWEET_MAX) {
|
|
604
|
+
userEvents.forEach(e => {
|
|
605
|
+
if (e.event === "offer submitted" && typeof e.offer_price === "number") {
|
|
606
|
+
e.offer_price = Math.round(e.offer_price * VIEW_OFFER_PRICE_BOOST);
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
} else if (viewCount >= VIEW_OVER_THRESHOLD) {
|
|
610
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
611
|
+
if (userEvents[i].event === "offer submitted" && chance.bool({ likelihood: VIEW_OVER_OFFER_DROP_LIKELIHOOD })) {
|
|
612
|
+
userEvents.splice(i, 1);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// HOOK 2 (cont): MORTGAGE RATE SHOCK — drop 45% of post-day-75
|
|
618
|
+
// offer-submitted events. Runs AFTER all offer cloning (H4/H6) so the
|
|
619
|
+
// drop hits clones too: pre-approved/dual-tour clones spread uniformly
|
|
620
|
+
// to day 115 and would otherwise flood post-d75 volume and invert the
|
|
621
|
+
// documented drop (measured share RISING 0.132 -> 0.154 when the drop
|
|
622
|
+
// ran pre-clone). No flag.
|
|
623
|
+
const day75 = datasetStart.add(SHOCK_START_DAY, "days");
|
|
624
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
625
|
+
const evt = userEvents[i];
|
|
626
|
+
if (evt.event === "offer submitted" && dayjs.utc(evt.time).isAfter(day75) && chance.bool({ likelihood: SHOCK_OFFER_DROP_LIKELIHOOD })) {
|
|
627
|
+
userEvents.splice(i, 1);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// HOOK 1 (cont): Spring offer_price boost — runs AFTER all cloning
|
|
632
|
+
// so cloned offers in the spring window also get the 2.5x boost
|
|
633
|
+
userEvents.forEach(e => {
|
|
634
|
+
if (e.event !== "offer submitted") return;
|
|
635
|
+
const t = dayjs.utc(e.time);
|
|
636
|
+
if ((t.isAfter(springStart) || t.isSame(springStart)) && t.isBefore(springEnd)) {
|
|
637
|
+
e.offer_price = Math.floor((e.offer_price || 400000) * SPRING_OFFER_PRICE_MULT);
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
return record;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ── CONFIG ──
|
|
645
|
+
/** @type {Config} */
|
|
646
|
+
const config = {
|
|
647
|
+
version: 2,
|
|
648
|
+
seed: SEED,
|
|
649
|
+
datasetStart: DATASET_START,
|
|
650
|
+
datasetEnd: DATASET_END,
|
|
651
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
652
|
+
numUsers: NUM_USERS,
|
|
653
|
+
format: "json",
|
|
654
|
+
gzip: true,
|
|
655
|
+
credentials: {
|
|
656
|
+
token,
|
|
657
|
+
},
|
|
658
|
+
switches: {
|
|
659
|
+
hasSessionIds: true,
|
|
660
|
+
alsoInferFunnels: false,
|
|
661
|
+
hasLocation: true,
|
|
662
|
+
hasAndroidDevices: true,
|
|
663
|
+
hasIOSDevices: true,
|
|
664
|
+
hasDesktopDevices: true,
|
|
665
|
+
hasBrowser: true,
|
|
666
|
+
hasCampaigns: false,
|
|
667
|
+
isAnonymous: false,
|
|
668
|
+
hasAdSpend: false,
|
|
669
|
+
hasAvatar: true,
|
|
670
|
+
},
|
|
671
|
+
identity: {
|
|
672
|
+
avgDevicePerUser: 2,
|
|
673
|
+
},
|
|
674
|
+
|
|
675
|
+
concurrency: 1,
|
|
676
|
+
writeToDisk: false,
|
|
677
|
+
|
|
678
|
+
soup: "growth",
|
|
679
|
+
|
|
680
|
+
scdProps: {
|
|
681
|
+
agent_tier: {
|
|
682
|
+
values: ["Standard", "Standard", "Standard", "Premier"],
|
|
683
|
+
frequency: "month",
|
|
684
|
+
timing: "fuzzy",
|
|
685
|
+
max: 4
|
|
686
|
+
}
|
|
687
|
+
},
|
|
688
|
+
|
|
689
|
+
funnels: [
|
|
690
|
+
{
|
|
691
|
+
sequence: ["account created", "property search", "property viewed"],
|
|
692
|
+
isFirstFunnel: true,
|
|
693
|
+
conversionRate: 80,
|
|
694
|
+
timeToConvert: 0.5,
|
|
695
|
+
name: "Buyer Journey"
|
|
696
|
+
},
|
|
697
|
+
{
|
|
698
|
+
sequence: ["property viewed", "tour scheduled", "offer submitted"],
|
|
699
|
+
conversionRate: 40,
|
|
700
|
+
timeToConvert: 24,
|
|
701
|
+
weight: 5,
|
|
702
|
+
name: "Tour Funnel",
|
|
703
|
+
reentry: true,
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
sequence: ["property listed", "open house attended", "property sold"],
|
|
707
|
+
conversionRate: 35,
|
|
708
|
+
timeToConvert: 48,
|
|
709
|
+
weight: 3,
|
|
710
|
+
name: "Seller Funnel"
|
|
711
|
+
}
|
|
712
|
+
],
|
|
713
|
+
|
|
714
|
+
events: [
|
|
715
|
+
{
|
|
716
|
+
event: "account created",
|
|
717
|
+
weight: 1,
|
|
718
|
+
isFirstEvent: true,
|
|
719
|
+
isAuthEvent: true,
|
|
720
|
+
properties: {
|
|
721
|
+
signup_method: ["email", "google", "apple", "facebook"],
|
|
722
|
+
}
|
|
723
|
+
},
|
|
724
|
+
{
|
|
725
|
+
event: "property search",
|
|
726
|
+
weight: 10,
|
|
727
|
+
properties: {
|
|
728
|
+
location: ["Manhattan", "Brooklyn", "Austin", "Miami", "San Francisco", "Denver", "Seattle", "Chicago", "Nashville", "Portland"],
|
|
729
|
+
price_range_min: u.weighNumRange(100000, 500000, 0.5, 40),
|
|
730
|
+
price_range_max: u.weighNumRange(300000, 2000000, 0.5, 40),
|
|
731
|
+
bedrooms: [1, 2, 2, 3, 3, 3, 4, 4, 5],
|
|
732
|
+
property_type: ["Single Family", "Condo", "Townhouse", "Multi-Family"],
|
|
733
|
+
}
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
event: "property viewed",
|
|
737
|
+
weight: 12,
|
|
738
|
+
isStrictEvent: false,
|
|
739
|
+
properties: {
|
|
740
|
+
listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
741
|
+
listing_price: u.weighNumRange(150000, 1500000, 0.4, 60),
|
|
742
|
+
property_type: ["Single Family", "Condo", "Townhouse", "Multi-Family"],
|
|
743
|
+
bedrooms: [1, 2, 2, 3, 3, 3, 4, 4, 5],
|
|
744
|
+
square_feet: u.weighNumRange(600, 5000, 0.5, 50),
|
|
745
|
+
}
|
|
746
|
+
},
|
|
747
|
+
{
|
|
748
|
+
event: "property saved",
|
|
749
|
+
weight: 5,
|
|
750
|
+
properties: {
|
|
751
|
+
listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
752
|
+
listing_price: u.weighNumRange(150000, 1500000, 0.4, 40),
|
|
753
|
+
}
|
|
754
|
+
},
|
|
755
|
+
{
|
|
756
|
+
event: "saved search created",
|
|
757
|
+
weight: 3,
|
|
758
|
+
properties: {
|
|
759
|
+
location: ["Manhattan", "Brooklyn", "Austin", "Miami", "San Francisco", "Denver", "Seattle", "Chicago", "Nashville", "Portland"],
|
|
760
|
+
criteria_count: u.weighNumRange(2, 8, 0.8, 10),
|
|
761
|
+
}
|
|
762
|
+
},
|
|
763
|
+
{
|
|
764
|
+
event: "virtual tour",
|
|
765
|
+
weight: 4,
|
|
766
|
+
properties: {
|
|
767
|
+
listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
768
|
+
duration_mins: u.weighNumRange(5, 45, 0.5, 30),
|
|
769
|
+
}
|
|
770
|
+
},
|
|
771
|
+
{
|
|
772
|
+
event: "in-person tour",
|
|
773
|
+
weight: 3,
|
|
774
|
+
properties: {
|
|
775
|
+
listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
776
|
+
agent_id: () => `AGT-${chance.integer({ min: 1000, max: 9999 })}`,
|
|
777
|
+
}
|
|
778
|
+
},
|
|
779
|
+
{
|
|
780
|
+
event: "tour scheduled",
|
|
781
|
+
weight: 4,
|
|
782
|
+
isStrictEvent: false,
|
|
783
|
+
properties: {
|
|
784
|
+
listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
785
|
+
agent_id: () => `AGT-${chance.integer({ min: 1000, max: 9999 })}`,
|
|
786
|
+
duration_mins: u.weighNumRange(5, 90, 0.5, 30),
|
|
787
|
+
}
|
|
788
|
+
},
|
|
789
|
+
{
|
|
790
|
+
event: "mortgage pre-approval",
|
|
791
|
+
weight: 2,
|
|
792
|
+
properties: {
|
|
793
|
+
lender: ["Chase", "Wells Fargo", "Bank of America", "Rocket Mortgage", "United Wholesale", "loanDepot"],
|
|
794
|
+
approved_amount: u.weighNumRange(200000, 1500000, 0.4, 40),
|
|
795
|
+
mortgage_rate: u.weighNumRange(5.5, 7.5, 0.8, 20),
|
|
796
|
+
}
|
|
797
|
+
},
|
|
798
|
+
{
|
|
799
|
+
event: "offer submitted",
|
|
800
|
+
weight: 2,
|
|
801
|
+
isStrictEvent: false,
|
|
802
|
+
properties: {
|
|
803
|
+
listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
804
|
+
offer_price: u.weighNumRange(200000, 1500000, 0.4, 40),
|
|
805
|
+
listing_price: u.weighNumRange(200000, 1500000, 0.4, 40),
|
|
806
|
+
}
|
|
807
|
+
},
|
|
808
|
+
{
|
|
809
|
+
event: "offer accepted",
|
|
810
|
+
weight: 1,
|
|
811
|
+
properties: {
|
|
812
|
+
listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
813
|
+
final_price: u.weighNumRange(200000, 1500000, 0.4, 40),
|
|
814
|
+
}
|
|
815
|
+
},
|
|
816
|
+
{
|
|
817
|
+
event: "offer rejected",
|
|
818
|
+
weight: 1,
|
|
819
|
+
properties: {
|
|
820
|
+
listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
821
|
+
reason: ["price too low", "competing offer", "seller changed mind", "inspection issues", "financing fell through"],
|
|
822
|
+
}
|
|
823
|
+
},
|
|
824
|
+
{
|
|
825
|
+
event: "property listed",
|
|
826
|
+
weight: 3,
|
|
827
|
+
isStrictEvent: false,
|
|
828
|
+
properties: {
|
|
829
|
+
listing_price: u.weighNumRange(200000, 1500000, 0.4, 50),
|
|
830
|
+
property_type: ["Single Family", "Condo", "Townhouse", "Multi-Family"],
|
|
831
|
+
bedrooms: [1, 2, 2, 3, 3, 3, 4, 4, 5],
|
|
832
|
+
listing_status: ["active", "pending", "coming soon"],
|
|
833
|
+
}
|
|
834
|
+
},
|
|
835
|
+
{
|
|
836
|
+
event: "property sold",
|
|
837
|
+
weight: 1,
|
|
838
|
+
isStrictEvent: false,
|
|
839
|
+
properties: {
|
|
840
|
+
sale_price: u.weighNumRange(200000, 1500000, 0.4, 40),
|
|
841
|
+
days_on_market: u.weighNumRange(5, 180, 0.4, 30),
|
|
842
|
+
}
|
|
843
|
+
},
|
|
844
|
+
{
|
|
845
|
+
event: "agent contacted",
|
|
846
|
+
weight: 4,
|
|
847
|
+
properties: {
|
|
848
|
+
contact_method: ["phone", "email", "in-app message", "text"],
|
|
849
|
+
}
|
|
850
|
+
},
|
|
851
|
+
{
|
|
852
|
+
event: "open house attended",
|
|
853
|
+
weight: 2,
|
|
854
|
+
properties: {
|
|
855
|
+
listing_id: () => `LST-${chance.integer({ min: 10000, max: 99999 })}`,
|
|
856
|
+
attendees: u.weighNumRange(2, 30, 0.5, 15),
|
|
857
|
+
}
|
|
858
|
+
},
|
|
859
|
+
{
|
|
860
|
+
event: "review submitted",
|
|
861
|
+
weight: 2,
|
|
862
|
+
properties: {
|
|
863
|
+
rating: [1, 2, 3, 3, 4, 4, 4, 5, 5, 5],
|
|
864
|
+
review_type: ["agent", "property", "platform"],
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
],
|
|
868
|
+
|
|
869
|
+
superProps: {
|
|
870
|
+
user_type: ["Buyer", "Buyer", "Buyer", "Agent", "Both"],
|
|
871
|
+
preferred_location: ["Urban", "Suburban", "Rural"],
|
|
872
|
+
property_preference: ["Single Family", "Condo", "Townhouse", "Multi-Family"],
|
|
873
|
+
},
|
|
874
|
+
|
|
875
|
+
userProps: {
|
|
876
|
+
user_type: ["Buyer", "Buyer", "Buyer", "Agent", "Both"],
|
|
877
|
+
preferred_location: ["Urban", "Suburban", "Rural"],
|
|
878
|
+
property_preference: ["Single Family", "Condo", "Townhouse", "Multi-Family"],
|
|
879
|
+
budget_max: u.weighNumRange(200000, 2000000, 0.4, 50),
|
|
880
|
+
agent_tier: ["Standard", "Standard", "Standard", "Premier"],
|
|
881
|
+
total_properties_viewed: u.weighNumRange(0, 100, 0.5, 30),
|
|
882
|
+
pre_approval_status: ["none"],
|
|
883
|
+
},
|
|
884
|
+
|
|
885
|
+
groupKeys: [
|
|
886
|
+
["brokerage_id", 200, ["property listed", "property sold", "agent contacted", "open house attended"]],
|
|
887
|
+
],
|
|
888
|
+
|
|
889
|
+
groupProps: {
|
|
890
|
+
brokerage_id: {
|
|
891
|
+
brokerage_name: ["Compass", "RE/MAX", "Keller Williams", "Coldwell Banker", "Century 21", "Sotheby's", "eXp Realty", "Redfin"],
|
|
892
|
+
agent_count: u.weighNumRange(5, 200, 0.4, 30),
|
|
893
|
+
region: ["Northeast", "Southeast", "Midwest", "Southwest", "West Coast", "Pacific Northwest"],
|
|
894
|
+
avg_listing_price: u.weighNumRange(200000, 1500000, 0.4, 30),
|
|
895
|
+
}
|
|
896
|
+
},
|
|
897
|
+
|
|
898
|
+
lookupTables: [],
|
|
899
|
+
|
|
900
|
+
hook(record, type, meta) {
|
|
901
|
+
if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
|
|
902
|
+
if (type === "everything") return handleEverythingHooks(record, meta);
|
|
903
|
+
return record;
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
export default config;
|
|
908
|
+
|
|
909
|
+
// ── STORIES (v1.6 verification contract) ──
|
|
910
|
+
/*
|
|
911
|
+
* MEASUREMENT DOCTRINE — how these reads stay honest
|
|
912
|
+
*
|
|
913
|
+
* LITERAL WINDOW: datasetStart/datasetEnd are explicit (2026-01-01 →
|
|
914
|
+
* 2026-05-01), no forward shift — day_idx = date_diff('day',
|
|
915
|
+
* DATE '2026-01-01', t::DATE) matches the hook's datasetStart offsets
|
|
916
|
+
* exactly. Day-50/75 boundaries are STRICT dayjs isAfter(midnight), so
|
|
917
|
+
* structural "before day N" reads use day_idx < N (day-N events after
|
|
918
|
+
* midnight are legitimately treated).
|
|
919
|
+
*
|
|
920
|
+
* IDENTITY: avgDevicePerUser: 2, but 'account created' is both
|
|
921
|
+
* isFirstEvent and isAuthEvent — every user auths on their very first
|
|
922
|
+
* event, so 0 device-only events exist and uid resolution through the
|
|
923
|
+
* profiles' "anonymousIds" device map is total (asserted structurally
|
|
924
|
+
* in H10). The everything hook stamps user_type/preferred_location/
|
|
925
|
+
* property_preference from the profile onto every event (agreement 1.0).
|
|
926
|
+
*
|
|
927
|
+
* OFFER LEDGER: 'offer submitted' is touched by FIVE hooks, in hook
|
|
928
|
+
* order: H4 clones (4-6, pre-approved users), H6 clones (5-7,
|
|
929
|
+
* dual-tour users), H9 sweet +30% price / over 60% deletion, H2 45%
|
|
930
|
+
* post-d75 deletion (moved AFTER all cloning — v1.6 surgery: when it
|
|
931
|
+
* ran pre-clone, uniform-in-time clones flooded post-d75 and the
|
|
932
|
+
* documented drop INVERTED, share 0.132→0.154), then H1 spring 2.5x
|
|
933
|
+
* price boost LAST. Price reads therefore exclude the spring window;
|
|
934
|
+
* volume reads use share-of-total-volume to cancel the growth soup.
|
|
935
|
+
*
|
|
936
|
+
* OUTPUT-COHORT EXACTNESS: H8 classifies cold leads AFTER H3/H7 view
|
|
937
|
+
* clones and H3 deletions are in the array, and nothing after H8
|
|
938
|
+
* touches views/saves in a user's first 14 days — so output first-14d
|
|
939
|
+
* view/save cohorts equal the hook-time cohorts. Same for H9's view
|
|
940
|
+
* count (H8 runs before H9; only offer deletions follow). H3's
|
|
941
|
+
* saved-search-in-first-7d cohort survives all later deletions (they
|
|
942
|
+
* only remove post-d14/post-d30 events).
|
|
943
|
+
*
|
|
944
|
+
* COUPLED COHORTS: pre-approved (56% of users) and dual-tour (33%)
|
|
945
|
+
* overlap heavily and both clone offers, and the 13+-view bucket is
|
|
946
|
+
* ~97% power-cloners. The H9 over-bucket deletion knob is 60% so the
|
|
947
|
+
* plain cohort read nets the documented ~35%-fewer-offers (activity
|
|
948
|
+
* coupling offsets ~25 points). H4/H6 reads follow the doc's plain
|
|
949
|
+
* all-users cohort comparison; the narrative carries the overlap
|
|
950
|
+
* decomposition.
|
|
951
|
+
*
|
|
952
|
+
* EMULATOR TTC (H10): primary read is the 2-step
|
|
953
|
+
* ['property viewed','tour scheduled'] pair — the 3-step read's
|
|
954
|
+
* offer-submitted anchor collides with H4/H6 offer clones at random
|
|
955
|
+
* timestamps (greedy pairing attenuates the ratio toward 1: measured
|
|
956
|
+
* 0.74 vs 2-step 0.56 against mechanism 0.71/1.3 = 0.546). Window
|
|
957
|
+
* 31.2h = 24h generative x 1.3 Standard stretch, covering the
|
|
958
|
+
* stretched support. The 3-step read stays as a directional secondary.
|
|
959
|
+
*/
|
|
960
|
+
|
|
961
|
+
const DATASET_START_DATE = "2026-01-01";
|
|
962
|
+
|
|
963
|
+
const ID_CTE = `
|
|
964
|
+
us AS (SELECT * FROM read_json_auto('{{PREFIX}}-USERS*.json', sample_size=-1, union_by_name=true)),
|
|
965
|
+
dm AS (SELECT unnest("anonymousIds") AS device_id, distinct_id FROM us),
|
|
966
|
+
ev AS (SELECT coalesce(m.distinct_id::VARCHAR, e.user_id::VARCHAR, e.device_id::VARCHAR) AS uid,
|
|
967
|
+
e.time::TIMESTAMP AS t,
|
|
968
|
+
date_diff('day', DATE '${DATASET_START_DATE}', e.time::TIMESTAMP::DATE) AS day_idx, e.*
|
|
969
|
+
FROM read_json_auto('{{PREFIX}}-EVENTS*.json', sample_size=-1, union_by_name=true) e
|
|
970
|
+
LEFT JOIN dm m ON e.device_id = m.device_id)`;
|
|
971
|
+
|
|
972
|
+
const PU_CTE = `
|
|
973
|
+
pu AS (SELECT e.uid, min(e.t) AS first_t, max(e.t) AS last_t,
|
|
974
|
+
count(*) AS total_ev,
|
|
975
|
+
count(*) FILTER (WHERE event = 'property viewed') AS views,
|
|
976
|
+
count(*) FILTER (WHERE event = 'offer submitted') AS offers,
|
|
977
|
+
count(*) FILTER (WHERE event = 'property listed') AS listings,
|
|
978
|
+
count(*) FILTER (WHERE event = 'property sold') AS solds,
|
|
979
|
+
count(*) FILTER (WHERE event = 'virtual tour') AS vtours,
|
|
980
|
+
count(*) FILTER (WHERE event = 'in-person tour') AS iptours,
|
|
981
|
+
count(*) FILTER (WHERE event = 'mortgage pre-approval') AS preapps
|
|
982
|
+
FROM ev e GROUP BY 1),
|
|
983
|
+
puu AS (SELECT p.*, u.agent_tier, u.user_type, u.pre_approval_status,
|
|
984
|
+
EXISTS (SELECT 1 FROM ev s WHERE s.uid = p.uid AND s.event = 'saved search created'
|
|
985
|
+
AND s.t < p.first_t + INTERVAL '${SAVED_SEARCH_WINDOW_DAYS} days') AS saver,
|
|
986
|
+
EXISTS (SELECT 1 FROM ev v WHERE v.uid = p.uid AND v.event = 'property viewed'
|
|
987
|
+
AND v.t < p.first_t + INTERVAL '${COLD_LEAD_WINDOW_DAYS} days') AS viewed14,
|
|
988
|
+
EXISTS (SELECT 1 FROM ev sv WHERE sv.uid = p.uid AND sv.event = 'property saved'
|
|
989
|
+
AND sv.t < p.first_t + INTERVAL '${COLD_LEAD_WINDOW_DAYS} days') AS saved14,
|
|
990
|
+
(p.vtours >= 1 AND p.iptours >= 1) AS dual_tour,
|
|
991
|
+
(p.preapps >= 1) AS preapproved
|
|
992
|
+
FROM pu p JOIN us u ON p.uid = u.distinct_id::VARCHAR)`;
|
|
993
|
+
|
|
994
|
+
const cellsOf = (rows, key) => Object.fromEntries((rows || []).map((r) => [r[key], r]));
|
|
995
|
+
|
|
996
|
+
export const stories = [
|
|
997
|
+
{
|
|
998
|
+
id: "H1-spring-season",
|
|
999
|
+
hook: "H1",
|
|
1000
|
+
archetype: "temporal-inflection",
|
|
1001
|
+
narrative:
|
|
1002
|
+
`Days ${SPRING_START_DAY}-${SPRING_END_DAY}: offer_price x${SPRING_OFFER_PRICE_MULT} (runs LAST, ` +
|
|
1003
|
+
`hits clones in-window too) and tour duration_mins x${SPRING_TOUR_DURATION_MULT}. Price ratio reads ` +
|
|
1004
|
+
"slightly above the knob (2.64 measured) because sweet-spot +30% boosts distribute evenly across " +
|
|
1005
|
+
"zones. Duration ratio reads BELOW its 3x knob (~2.65): the duration boost runs before H3's " +
|
|
1006
|
+
"tour cloning, so boosted spring templates leak boosted durations outside the window and " +
|
|
1007
|
+
"unboosted templates leak in — a known dilution, bounded in the band.",
|
|
1008
|
+
assertions: [
|
|
1009
|
+
{
|
|
1010
|
+
breakdown: {
|
|
1011
|
+
type: "duckdb",
|
|
1012
|
+
sql: `WITH ${ID_CTE}
|
|
1013
|
+
SELECT CASE WHEN day_idx BETWEEN ${SPRING_START_DAY} AND ${SPRING_END_DAY - 1} THEN 'spring' ELSE 'outside' END AS zone,
|
|
1014
|
+
count(*)::BIGINT AS n, count(DISTINCT uid)::BIGINT AS user_count, avg(offer_price) AS price
|
|
1015
|
+
FROM ev WHERE event = 'offer submitted' GROUP BY 1`,
|
|
1016
|
+
},
|
|
1017
|
+
assert: (rows) => {
|
|
1018
|
+
const by = cellsOf(rows, "zone");
|
|
1019
|
+
const s = by.spring, o = by.outside;
|
|
1020
|
+
if (!s || !o || Number(s.n) < 3000 || Number(o.n) < 8000) {
|
|
1021
|
+
return { verdict: "WEAK", detail: `offer volume too small: spring=${s?.n ?? 0} outside=${o?.n ?? 0}` };
|
|
1022
|
+
}
|
|
1023
|
+
const ratio = Number(s.price) / Number(o.price);
|
|
1024
|
+
const detail = `spring/outside avg offer_price=${ratio.toFixed(3)} (knob ${SPRING_OFFER_PRICE_MULT}x; n=${s.n}/${o.n})`;
|
|
1025
|
+
if (ratio >= 2.35 && ratio <= 2.95) return { verdict: "NAILED", detail };
|
|
1026
|
+
if (ratio >= 2.1 && ratio <= 3.2) return { verdict: "STRONG", detail };
|
|
1027
|
+
if (ratio >= 1.5) return { verdict: "WEAK", detail };
|
|
1028
|
+
return { verdict: ratio <= 1 ? "INVERSE" : "NONE", detail };
|
|
1029
|
+
},
|
|
1030
|
+
},
|
|
1031
|
+
{
|
|
1032
|
+
breakdown: {
|
|
1033
|
+
type: "duckdb",
|
|
1034
|
+
sql: `WITH ${ID_CTE}
|
|
1035
|
+
SELECT CASE WHEN day_idx BETWEEN ${SPRING_START_DAY} AND ${SPRING_END_DAY - 1} THEN 'spring' ELSE 'outside' END AS zone,
|
|
1036
|
+
count(*)::BIGINT AS n, avg(duration_mins) AS dur
|
|
1037
|
+
FROM ev WHERE event = 'tour scheduled' GROUP BY 1`,
|
|
1038
|
+
},
|
|
1039
|
+
assert: (rows) => {
|
|
1040
|
+
const by = cellsOf(rows, "zone");
|
|
1041
|
+
const s = by.spring, o = by.outside;
|
|
1042
|
+
if (!s || !o || Number(s.n) < 1500 || Number(o.n) < 8000) {
|
|
1043
|
+
return { verdict: "WEAK", detail: `tour volume too small: spring=${s?.n ?? 0} outside=${o?.n ?? 0}` };
|
|
1044
|
+
}
|
|
1045
|
+
const ratio = Number(s.dur) / Number(o.dur);
|
|
1046
|
+
const detail = `spring/outside avg tour duration=${ratio.toFixed(3)} (knob ${SPRING_TOUR_DURATION_MULT}x diluted by H3 clone leak; n=${s.n}/${o.n})`;
|
|
1047
|
+
if (ratio >= 2.3 && ratio <= 3.05) return { verdict: "NAILED", detail };
|
|
1048
|
+
if (ratio >= 2.0 && ratio <= 3.3) return { verdict: "STRONG", detail };
|
|
1049
|
+
if (ratio >= 1.5) return { verdict: "WEAK", detail };
|
|
1050
|
+
return { verdict: ratio <= 1 ? "INVERSE" : "NONE", detail };
|
|
1051
|
+
},
|
|
1052
|
+
},
|
|
1053
|
+
],
|
|
1054
|
+
},
|
|
1055
|
+
{
|
|
1056
|
+
id: "H2-rate-shock",
|
|
1057
|
+
hook: "H2",
|
|
1058
|
+
archetype: "temporal-inflection",
|
|
1059
|
+
narrative:
|
|
1060
|
+
`Days ${SHOCK_START_DAY}-${SHOCK_END_DAY}: mortgage_rate PINNED to ${SHOCK_MORTGAGE_RATE} on every ` +
|
|
1061
|
+
"pre-approval event in-window (structural: min=max=7.5). Post-d75 offers face a " +
|
|
1062
|
+
`${SHOCK_OFFER_DROP_LIKELIHOOD}% deletion that runs AFTER all cloning (v1.6 surgery — see doctrine). ` +
|
|
1063
|
+
"Volume read uses offer share-of-total-volume post/pre to cancel the growth soup: visible ratio = " +
|
|
1064
|
+
"0.55 knob x ~1.40 clone-flood drift (uniform-in-time clones raise the counterfactual post-window " +
|
|
1065
|
+
"share) = ~0.77 measured.",
|
|
1066
|
+
assertions: [
|
|
1067
|
+
{
|
|
1068
|
+
breakdown: {
|
|
1069
|
+
type: "duckdb",
|
|
1070
|
+
sql: `WITH ${ID_CTE}
|
|
1071
|
+
SELECT CASE WHEN day_idx BETWEEN ${SHOCK_START_DAY} AND ${SHOCK_END_DAY - 1} THEN 'shock' ELSE 'outside' END AS zone,
|
|
1072
|
+
count(*)::BIGINT AS n, avg(mortgage_rate) AS rate, min(mortgage_rate) AS mn, max(mortgage_rate) AS mx
|
|
1073
|
+
FROM ev WHERE event = 'mortgage pre-approval' GROUP BY 1`,
|
|
1074
|
+
},
|
|
1075
|
+
assert: (rows) => {
|
|
1076
|
+
const by = cellsOf(rows, "zone");
|
|
1077
|
+
const s = by.shock, o = by.outside;
|
|
1078
|
+
if (!s || !o || Number(s.n) < 200 || Number(o.n) < 3000) {
|
|
1079
|
+
return { verdict: "WEAK", detail: `pre-approval volume too small: shock=${s?.n ?? 0} outside=${o?.n ?? 0}` };
|
|
1080
|
+
}
|
|
1081
|
+
const pinned = Number(s.mn) === SHOCK_MORTGAGE_RATE && Number(s.mx) === SHOCK_MORTGAGE_RATE;
|
|
1082
|
+
const outsideRate = Number(o.rate);
|
|
1083
|
+
const detail = `shock rate min=${s.mn} max=${s.mx} (pin ${SHOCK_MORTGAGE_RATE}); outside avg=${outsideRate.toFixed(3)} (n=${s.n}/${o.n})`;
|
|
1084
|
+
if (pinned && outsideRate >= 6.2 && outsideRate <= 6.5) return { verdict: "NAILED", detail };
|
|
1085
|
+
if (Number(s.rate) >= 7.45 && outsideRate < 7.0) return { verdict: "STRONG", detail };
|
|
1086
|
+
if (Number(s.rate) > outsideRate + 0.5) return { verdict: "WEAK", detail };
|
|
1087
|
+
return { verdict: "NONE", detail };
|
|
1088
|
+
},
|
|
1089
|
+
},
|
|
1090
|
+
{
|
|
1091
|
+
breakdown: {
|
|
1092
|
+
type: "duckdb",
|
|
1093
|
+
sql: `WITH ${ID_CTE}
|
|
1094
|
+
SELECT (day_idx > ${SHOCK_START_DAY}) AS post,
|
|
1095
|
+
count(*) FILTER (WHERE event = 'offer submitted')::BIGINT AS offers,
|
|
1096
|
+
count(*)::BIGINT AS all_ev,
|
|
1097
|
+
count(*) FILTER (WHERE event = 'offer submitted')::DOUBLE / count(*) AS offer_share
|
|
1098
|
+
FROM ev GROUP BY 1`,
|
|
1099
|
+
},
|
|
1100
|
+
assert: (rows) => {
|
|
1101
|
+
const by = cellsOf(rows, "post");
|
|
1102
|
+
const pre = by.false, post = by.true;
|
|
1103
|
+
if (!pre || !post || Number(post.offers) < 2500 || Number(pre.offers) < 8000) {
|
|
1104
|
+
return { verdict: "WEAK", detail: `offer volume too small: pre=${pre?.offers ?? 0} post=${post?.offers ?? 0}` };
|
|
1105
|
+
}
|
|
1106
|
+
const ratio = Number(post.offer_share) / Number(pre.offer_share);
|
|
1107
|
+
const detail = `offer share-of-volume post/pre d${SHOCK_START_DAY}=${ratio.toFixed(3)} (0.55 knob x ~1.40 clone drift; shares ${Number(pre.offer_share).toFixed(4)}→${Number(post.offer_share).toFixed(4)})`;
|
|
1108
|
+
if (ratio >= 0.66 && ratio <= 0.87) return { verdict: "NAILED", detail };
|
|
1109
|
+
if (ratio >= 0.55 && ratio <= 0.95) return { verdict: "STRONG", detail };
|
|
1110
|
+
if (ratio < 1) return { verdict: "WEAK", detail };
|
|
1111
|
+
return { verdict: "INVERSE", detail };
|
|
1112
|
+
},
|
|
1113
|
+
},
|
|
1114
|
+
],
|
|
1115
|
+
},
|
|
1116
|
+
{
|
|
1117
|
+
id: "H3-saved-search-retention",
|
|
1118
|
+
hook: "H3",
|
|
1119
|
+
archetype: "retention-divergence",
|
|
1120
|
+
narrative:
|
|
1121
|
+
`Savers (saved search created within ${SAVED_SEARCH_WINDOW_DAYS}d of first event, ~14% of users) get ` +
|
|
1122
|
+
`${SAVED_SEARCH_VIEW_CLONES_MIN}-${SAVED_SEARCH_VIEW_CLONES_MAX} view + ${SAVED_SEARCH_TOUR_CLONES_MIN}-${SAVED_SEARCH_TOUR_CLONES_MAX} ` +
|
|
1123
|
+
`tour clones spread to day ~110; non-savers lose ${NON_SAVER_DROP_LIKELIHOOD}% of post-day-${NON_SAVER_CUTOFF_DAY} ` +
|
|
1124
|
+
"events (calendar). Post-d30 events/user ratio compounds clones x deletion x the cold-lead overlap " +
|
|
1125
|
+
"(97% of savers are NOT cold leads — property saved co-occurs); April-activity split is the doc's " +
|
|
1126
|
+
"retention read: savers ~0.98 active (clones guarantee late events), non-savers ~0.55.",
|
|
1127
|
+
assertions: [
|
|
1128
|
+
{
|
|
1129
|
+
breakdown: {
|
|
1130
|
+
type: "duckdb",
|
|
1131
|
+
sql: `WITH ${ID_CTE}, ${PU_CTE}
|
|
1132
|
+
SELECT saver, count(*)::BIGINT AS user_count,
|
|
1133
|
+
avg((SELECT count(*) FROM ev e WHERE e.uid = puu.uid AND e.day_idx > ${NON_SAVER_CUTOFF_DAY})) AS ev_post30
|
|
1134
|
+
FROM puu GROUP BY 1`,
|
|
1135
|
+
},
|
|
1136
|
+
assert: (rows) => {
|
|
1137
|
+
const by = cellsOf(rows, "saver");
|
|
1138
|
+
const sv = by.true, non = by.false;
|
|
1139
|
+
if (!sv || !non || Number(sv.user_count) < 600 || Number(non.user_count) < 3000) {
|
|
1140
|
+
return { verdict: "WEAK", detail: `cohorts too small: savers=${sv?.user_count ?? 0} non=${non?.user_count ?? 0}` };
|
|
1141
|
+
}
|
|
1142
|
+
const ratio = Number(sv.ev_post30) / Number(non.ev_post30);
|
|
1143
|
+
const detail = `post-d${NON_SAVER_CUTOFF_DAY} events/user savers/non=${ratio.toFixed(2)} (${Number(sv.ev_post30).toFixed(1)} vs ${Number(non.ev_post30).toFixed(1)}; n=${sv.user_count}/${non.user_count})`;
|
|
1144
|
+
if (ratio >= 5.2 && ratio <= 8.5) return { verdict: "NAILED", detail };
|
|
1145
|
+
if (ratio >= 4.0 && ratio <= 10.5) return { verdict: "STRONG", detail };
|
|
1146
|
+
if (ratio >= 2.0) return { verdict: "WEAK", detail };
|
|
1147
|
+
return { verdict: ratio <= 1 ? "INVERSE" : "NONE", detail };
|
|
1148
|
+
},
|
|
1149
|
+
},
|
|
1150
|
+
{
|
|
1151
|
+
breakdown: {
|
|
1152
|
+
type: "duckdb",
|
|
1153
|
+
sql: `WITH ${ID_CTE}, ${PU_CTE}
|
|
1154
|
+
SELECT saver, count(*)::BIGINT AS user_count,
|
|
1155
|
+
avg((last_t >= TIMESTAMP '2026-04-01')::INT) AS active_apr
|
|
1156
|
+
FROM puu WHERE first_t < TIMESTAMP '2026-03-01' GROUP BY 1`,
|
|
1157
|
+
},
|
|
1158
|
+
assert: (rows) => {
|
|
1159
|
+
const by = cellsOf(rows, "saver");
|
|
1160
|
+
const sv = by.true, non = by.false;
|
|
1161
|
+
if (!sv || !non || Number(sv.user_count) < 550 || Number(non.user_count) < 2800) {
|
|
1162
|
+
return { verdict: "WEAK", detail: `cohorts too small: savers=${sv?.user_count ?? 0} non=${non?.user_count ?? 0}` };
|
|
1163
|
+
}
|
|
1164
|
+
const a = Number(sv.active_apr), b = Number(non.active_apr);
|
|
1165
|
+
const detail = `active-in-April (born pre-Mar): savers=${a.toFixed(4)} non-savers=${b.toFixed(4)} (n=${sv.user_count}/${non.user_count})`;
|
|
1166
|
+
if (a >= 0.96 && b >= 0.45 && b <= 0.66) return { verdict: "NAILED", detail };
|
|
1167
|
+
if (a >= 0.92 && b >= 0.38 && b <= 0.72) return { verdict: "STRONG", detail };
|
|
1168
|
+
if (a > b + 0.1) return { verdict: "WEAK", detail };
|
|
1169
|
+
return { verdict: a <= b ? "INVERSE" : "NONE", detail };
|
|
1170
|
+
},
|
|
1171
|
+
},
|
|
1172
|
+
],
|
|
1173
|
+
},
|
|
1174
|
+
{
|
|
1175
|
+
id: "H4-preapproval-conversion",
|
|
1176
|
+
hook: "H4",
|
|
1177
|
+
archetype: "cohort-prop-scale",
|
|
1178
|
+
narrative:
|
|
1179
|
+
`Users with a mortgage pre-approval event get ${PRE_APPROVAL_OFFER_CLONES_MIN}-${PRE_APPROVAL_OFFER_CLONES_MAX} ` +
|
|
1180
|
+
"cloned offers (needs an existing offer template) and profile pre_approval_status flipped to " +
|
|
1181
|
+
"'approved'. Doc read is the plain all-users cohort (>=1 pre-approval event in output): 4.44 " +
|
|
1182
|
+
"measured — mean 5 clones diluted by churn (H3/H8 delete clones for churned users), the H9 60% " +
|
|
1183
|
+
"over-bucket deletion (the bucket is preapproval-heavy), and the post-d75 H2 drop. The profile " +
|
|
1184
|
+
"flag is a strict superset of the output-event cohort (H8 can delete the pre-approval event " +
|
|
1185
|
+
"after H4 ran) — subset relation asserted structurally.",
|
|
1186
|
+
assertions: [
|
|
1187
|
+
{
|
|
1188
|
+
breakdown: {
|
|
1189
|
+
type: "duckdb",
|
|
1190
|
+
sql: `WITH ${ID_CTE}, ${PU_CTE}
|
|
1191
|
+
SELECT preapproved, count(*)::BIGINT AS user_count, avg(offers) AS offers_pu
|
|
1192
|
+
FROM puu GROUP BY 1`,
|
|
1193
|
+
},
|
|
1194
|
+
assert: (rows) => {
|
|
1195
|
+
const by = cellsOf(rows, "preapproved");
|
|
1196
|
+
const a = by.true, b = by.false;
|
|
1197
|
+
if (!a || !b || Number(a.user_count) < 1500 || Number(b.user_count) < 2500) {
|
|
1198
|
+
return { verdict: "WEAK", detail: `cohorts too small: preapproved=${a?.user_count ?? 0} rest=${b?.user_count ?? 0}` };
|
|
1199
|
+
}
|
|
1200
|
+
const ratio = Number(a.offers_pu) / Number(b.offers_pu);
|
|
1201
|
+
const detail = `offers/user preapproved/rest=${ratio.toFixed(2)} (${Number(a.offers_pu).toFixed(2)} vs ${Number(b.offers_pu).toFixed(2)}; n=${a.user_count}/${b.user_count})`;
|
|
1202
|
+
if (ratio >= 3.7 && ratio <= 5.3) return { verdict: "NAILED", detail };
|
|
1203
|
+
if (ratio >= 3.0 && ratio <= 6.2) return { verdict: "STRONG", detail };
|
|
1204
|
+
if (ratio >= 1.5) return { verdict: "WEAK", detail };
|
|
1205
|
+
return { verdict: ratio <= 1 ? "INVERSE" : "NONE", detail };
|
|
1206
|
+
},
|
|
1207
|
+
},
|
|
1208
|
+
{
|
|
1209
|
+
breakdown: {
|
|
1210
|
+
type: "duckdb",
|
|
1211
|
+
sql: `WITH ${ID_CTE}, ${PU_CTE}
|
|
1212
|
+
SELECT count(*)::BIGINT AS n,
|
|
1213
|
+
avg((pre_approval_status = 'approved')::INT) AS flagged
|
|
1214
|
+
FROM puu WHERE preapproved`,
|
|
1215
|
+
},
|
|
1216
|
+
assert: (rows) => {
|
|
1217
|
+
const r = rows?.[0];
|
|
1218
|
+
if (!r || Number(r.n) < 1500) {
|
|
1219
|
+
return { verdict: "WEAK", detail: `cohort too small: n=${r?.n ?? 0}` };
|
|
1220
|
+
}
|
|
1221
|
+
const f = Number(r.flagged);
|
|
1222
|
+
const detail = `profile pre_approval_status='approved' on ${f.toFixed(4)} of ${r.n} users with a surviving pre-approval event (hook flips the profile whenever the event existed at hook time)`;
|
|
1223
|
+
if (f === 1) return { verdict: "NAILED", detail };
|
|
1224
|
+
if (f >= 0.995) return { verdict: "STRONG", detail };
|
|
1225
|
+
return { verdict: "NONE", detail };
|
|
1226
|
+
},
|
|
1227
|
+
},
|
|
1228
|
+
],
|
|
1229
|
+
},
|
|
1230
|
+
{
|
|
1231
|
+
id: "H5-premier-agents",
|
|
1232
|
+
hook: "H5",
|
|
1233
|
+
archetype: "cohort-prop-scale",
|
|
1234
|
+
narrative:
|
|
1235
|
+
`Premier-tier profiles (pool 3:1 Standard) get ${PREMIER_LISTING_CLONE_MULT} extra cloned listings per ` +
|
|
1236
|
+
`existing (3x rate) and ${PREMIER_SALE_CLONE_MULT} extra sale per existing (2x rate), cloned AFTER the ` +
|
|
1237
|
+
"H3 non-saver deletion (clones multiply the surviving count) but BEFORE the H8 cold-lead deletion " +
|
|
1238
|
+
"(which prunes clones tier-independently, uniform-in-time clones slightly more exposed than " +
|
|
1239
|
+
"soup-shaped organics — measured 2.77 vs the 3x knob, 1.90 vs the 2x).",
|
|
1240
|
+
assertions: [
|
|
1241
|
+
{
|
|
1242
|
+
breakdown: {
|
|
1243
|
+
type: "duckdb",
|
|
1244
|
+
sql: `WITH ${ID_CTE}, ${PU_CTE}
|
|
1245
|
+
SELECT agent_tier, count(*)::BIGINT AS user_count, avg(listings) AS listings_pu, avg(solds) AS solds_pu
|
|
1246
|
+
FROM puu GROUP BY 1`,
|
|
1247
|
+
},
|
|
1248
|
+
assert: (rows) => {
|
|
1249
|
+
const by = cellsOf(rows, "agent_tier");
|
|
1250
|
+
const p = by.Premier, s = by.Standard;
|
|
1251
|
+
if (!p || !s || Number(p.user_count) < 1100 || Number(s.user_count) < 2800) {
|
|
1252
|
+
return { verdict: "WEAK", detail: `tiers too small: Premier=${p?.user_count ?? 0} Standard=${s?.user_count ?? 0}` };
|
|
1253
|
+
}
|
|
1254
|
+
const ratio = Number(p.listings_pu) / Number(s.listings_pu);
|
|
1255
|
+
const detail = `listings/user Premier/Standard=${ratio.toFixed(3)} (${Number(p.listings_pu).toFixed(2)} vs ${Number(s.listings_pu).toFixed(2)}; knob 3x minus churn asymmetry; n=${p.user_count}/${s.user_count})`;
|
|
1256
|
+
if (ratio >= 2.45 && ratio <= 3.10) return { verdict: "NAILED", detail };
|
|
1257
|
+
if (ratio >= 2.2 && ratio <= 3.35) return { verdict: "STRONG", detail };
|
|
1258
|
+
if (ratio >= 1.5) return { verdict: "WEAK", detail };
|
|
1259
|
+
return { verdict: ratio <= 1 ? "INVERSE" : "NONE", detail };
|
|
1260
|
+
},
|
|
1261
|
+
},
|
|
1262
|
+
{
|
|
1263
|
+
breakdown: {
|
|
1264
|
+
type: "duckdb",
|
|
1265
|
+
sql: `WITH ${ID_CTE}, ${PU_CTE}
|
|
1266
|
+
SELECT agent_tier, count(*)::BIGINT AS user_count, avg(solds) AS solds_pu
|
|
1267
|
+
FROM puu GROUP BY 1`,
|
|
1268
|
+
},
|
|
1269
|
+
assert: (rows) => {
|
|
1270
|
+
const by = cellsOf(rows, "agent_tier");
|
|
1271
|
+
const p = by.Premier, s = by.Standard;
|
|
1272
|
+
if (!p || !s || Number(p.user_count) < 1100 || Number(s.user_count) < 2800) {
|
|
1273
|
+
return { verdict: "WEAK", detail: `tiers too small: Premier=${p?.user_count ?? 0} Standard=${s?.user_count ?? 0}` };
|
|
1274
|
+
}
|
|
1275
|
+
const ratio = Number(p.solds_pu) / Number(s.solds_pu);
|
|
1276
|
+
const detail = `sales/user Premier/Standard=${ratio.toFixed(3)} (${Number(p.solds_pu).toFixed(3)} vs ${Number(s.solds_pu).toFixed(3)}; knob 2x minus churn asymmetry; n=${p.user_count}/${s.user_count})`;
|
|
1277
|
+
if (ratio >= 1.65 && ratio <= 2.15) return { verdict: "NAILED", detail };
|
|
1278
|
+
if (ratio >= 1.5 && ratio <= 2.35) return { verdict: "STRONG", detail };
|
|
1279
|
+
if (ratio >= 1.2) return { verdict: "WEAK", detail };
|
|
1280
|
+
return { verdict: ratio <= 1 ? "INVERSE" : "NONE", detail };
|
|
1281
|
+
},
|
|
1282
|
+
},
|
|
1283
|
+
],
|
|
1284
|
+
},
|
|
1285
|
+
{
|
|
1286
|
+
id: "H6-dual-tour-buyers",
|
|
1287
|
+
hook: "H6",
|
|
1288
|
+
archetype: "cohort-prop-scale",
|
|
1289
|
+
narrative:
|
|
1290
|
+
`Users with BOTH virtual tour AND in-person tour events (~33%) get ${DUAL_TOUR_OFFER_CLONES_MIN}-${DUAL_TOUR_OFFER_CLONES_MAX} ` +
|
|
1291
|
+
"cloned offers. Doc read is the plain all-users compound cohort: 4.98 measured (mean 6 clones " +
|
|
1292
|
+
"diluted by the same churn/H9/H2 ledger as H4; 87% of dual-tour users are also pre-approved). " +
|
|
1293
|
+
"The overlap decomposition re-reads the effect on the pre-approval-free margin: dual-only vs " +
|
|
1294
|
+
"neither ~4.8x. Exclusion uses the profile flag (exact hook-time cohort, set unconditionally " +
|
|
1295
|
+
"at H4) — the event-cohort proxy under-excludes users whose pre-approval event H8 later " +
|
|
1296
|
+
"deleted but who kept their H4 clones, contaminating the baseline (~3.5x).",
|
|
1297
|
+
assertions: [
|
|
1298
|
+
{
|
|
1299
|
+
breakdown: {
|
|
1300
|
+
type: "duckdb",
|
|
1301
|
+
sql: `WITH ${ID_CTE}, ${PU_CTE}
|
|
1302
|
+
SELECT dual_tour, count(*)::BIGINT AS user_count, avg(offers) AS offers_pu
|
|
1303
|
+
FROM puu GROUP BY 1`,
|
|
1304
|
+
},
|
|
1305
|
+
assert: (rows) => {
|
|
1306
|
+
const by = cellsOf(rows, "dual_tour");
|
|
1307
|
+
const a = by.true, b = by.false;
|
|
1308
|
+
if (!a || !b || Number(a.user_count) < 1300 || Number(b.user_count) < 2600) {
|
|
1309
|
+
return { verdict: "WEAK", detail: `cohorts too small: dual=${a?.user_count ?? 0} rest=${b?.user_count ?? 0}` };
|
|
1310
|
+
}
|
|
1311
|
+
const ratio = Number(a.offers_pu) / Number(b.offers_pu);
|
|
1312
|
+
const detail = `offers/user dual-tour/rest=${ratio.toFixed(2)} (${Number(a.offers_pu).toFixed(2)} vs ${Number(b.offers_pu).toFixed(2)}; n=${a.user_count}/${b.user_count})`;
|
|
1313
|
+
if (ratio >= 4.2 && ratio <= 5.9) return { verdict: "NAILED", detail };
|
|
1314
|
+
if (ratio >= 3.5 && ratio <= 6.8) return { verdict: "STRONG", detail };
|
|
1315
|
+
if (ratio >= 1.5) return { verdict: "WEAK", detail };
|
|
1316
|
+
return { verdict: ratio <= 1 ? "INVERSE" : "NONE", detail };
|
|
1317
|
+
},
|
|
1318
|
+
},
|
|
1319
|
+
{
|
|
1320
|
+
breakdown: {
|
|
1321
|
+
type: "duckdb",
|
|
1322
|
+
sql: `WITH ${ID_CTE}, ${PU_CTE}
|
|
1323
|
+
SELECT dual_tour, count(*)::BIGINT AS user_count, avg(offers) AS offers_pu
|
|
1324
|
+
FROM puu WHERE pre_approval_status != 'approved' GROUP BY 1`,
|
|
1325
|
+
},
|
|
1326
|
+
assert: (rows) => {
|
|
1327
|
+
const by = cellsOf(rows, "dual_tour");
|
|
1328
|
+
const a = by.true, b = by.false;
|
|
1329
|
+
if (!a || !b || Number(a.user_count) < 110 || Number(b.user_count) < 1500) {
|
|
1330
|
+
return { verdict: "WEAK", detail: `cohorts too small: dual-only=${a?.user_count ?? 0} neither=${b?.user_count ?? 0}` };
|
|
1331
|
+
}
|
|
1332
|
+
const ratio = Number(a.offers_pu) / Number(b.offers_pu);
|
|
1333
|
+
const detail = `offers/user dual-only/neither (profile-flag pre-approved excluded)=${ratio.toFixed(2)} (${Number(a.offers_pu).toFixed(2)} vs ${Number(b.offers_pu).toFixed(2)}; n=${a.user_count}/${b.user_count})`;
|
|
1334
|
+
if (ratio >= 3.9 && ratio <= 5.8) return { verdict: "NAILED", detail };
|
|
1335
|
+
if (ratio >= 3.2 && ratio <= 6.6) return { verdict: "STRONG", detail };
|
|
1336
|
+
if (ratio >= 1.5) return { verdict: "WEAK", detail };
|
|
1337
|
+
return { verdict: ratio <= 1 ? "INVERSE" : "NONE", detail };
|
|
1338
|
+
},
|
|
1339
|
+
},
|
|
1340
|
+
],
|
|
1341
|
+
},
|
|
1342
|
+
{
|
|
1343
|
+
id: "H7-luxury-release",
|
|
1344
|
+
hook: "H7",
|
|
1345
|
+
archetype: "temporal-inflection",
|
|
1346
|
+
narrative:
|
|
1347
|
+
`Post-day-${LUXURY_RELEASE_DAY}, ${LUXURY_LISTING_LIKELIHOOD}% of ORGANIC listings get price ` +
|
|
1348
|
+
`$${LUXURY_PRICE_MIN / 1e6}M-$${LUXURY_PRICE_MAX / 1e6}M (the mutation runs before H5's Premier clones, whose ` +
|
|
1349
|
+
"prices cap at $1.5M — visible share = 3% x organic share of listings 0.643 (tier mix: " +
|
|
1350
|
+
"1/(1+2x0.2775)) = 1.93%). Organic prices cap at $1.5M so ANY $2M+ listing is engineered — " +
|
|
1351
|
+
"strictly-before-day-50 count is structurally 0. Luxury-browser cohort: uuid first char 'c' " +
|
|
1352
|
+
"(charCodeAt%33==0, 1/16 of users) gets " +
|
|
1353
|
+
`${LUXURY_VIEW_CLONES_MIN}-${LUXURY_VIEW_CLONES_MAX} $5M+ view clones — non-browsers have exactly 0.`,
|
|
1354
|
+
assertions: [
|
|
1355
|
+
{
|
|
1356
|
+
breakdown: {
|
|
1357
|
+
type: "duckdb",
|
|
1358
|
+
sql: `WITH ${ID_CTE}
|
|
1359
|
+
SELECT count(*) FILTER (WHERE day_idx < ${LUXURY_RELEASE_DAY} AND listing_price >= 2000000)::BIGINT AS lux_pre,
|
|
1360
|
+
count(*) FILTER (WHERE day_idx >= ${LUXURY_RELEASE_DAY})::BIGINT AS post_n,
|
|
1361
|
+
count(*) FILTER (WHERE day_idx >= ${LUXURY_RELEASE_DAY} AND listing_price >= 2000000)::BIGINT AS post_lux,
|
|
1362
|
+
min(listing_price) FILTER (WHERE listing_price >= 2000000) AS lux_floor
|
|
1363
|
+
FROM ev WHERE event = 'property listed'`,
|
|
1364
|
+
},
|
|
1365
|
+
assert: (rows) => {
|
|
1366
|
+
const r = rows?.[0];
|
|
1367
|
+
if (!r || Number(r.post_n) < 5000) {
|
|
1368
|
+
return { verdict: "WEAK", detail: `too few post-d${LUXURY_RELEASE_DAY} listings: ${r?.post_n ?? 0}` };
|
|
1369
|
+
}
|
|
1370
|
+
const share = Number(r.post_lux) / Number(r.post_n);
|
|
1371
|
+
const pre = Number(r.lux_pre);
|
|
1372
|
+
const detail = `$2M+ listings strictly pre-d${LUXURY_RELEASE_DAY}=${pre}; post share=${(share * 100).toFixed(2)}% (mechanism 3% x 0.643 organic = 1.93%; floor $${(Number(r.lux_floor) / 1e6).toFixed(1)}M; post n=${r.post_n})`;
|
|
1373
|
+
if (pre === 0 && share >= 0.015 && share <= 0.025) return { verdict: "NAILED", detail };
|
|
1374
|
+
if (pre === 0 && share >= 0.012 && share <= 0.029) return { verdict: "STRONG", detail };
|
|
1375
|
+
if (pre === 0 && share > 0.005) return { verdict: "WEAK", detail };
|
|
1376
|
+
return { verdict: "NONE", detail };
|
|
1377
|
+
},
|
|
1378
|
+
},
|
|
1379
|
+
{
|
|
1380
|
+
breakdown: {
|
|
1381
|
+
type: "duckdb",
|
|
1382
|
+
sql: `WITH ${ID_CTE}, ${PU_CTE}
|
|
1383
|
+
SELECT (left(puu.uid, 1) = 'c') AS browser, count(*)::BIGINT AS user_count,
|
|
1384
|
+
avg((SELECT count(*) FROM ev e WHERE e.uid = puu.uid AND e.event = 'property viewed' AND e.listing_price >= ${LUXURY_PRICE_MIN})) AS luxviews_pu,
|
|
1385
|
+
sum((SELECT count(*) FROM ev e WHERE e.uid = puu.uid AND e.event = 'property viewed' AND e.listing_price >= ${LUXURY_PRICE_MIN} AND e.day_idx < ${LUXURY_RELEASE_DAY})) AS luxviews_pre
|
|
1386
|
+
FROM puu GROUP BY 1`,
|
|
1387
|
+
},
|
|
1388
|
+
assert: (rows) => {
|
|
1389
|
+
const by = cellsOf(rows, "browser");
|
|
1390
|
+
const br = by.true, non = by.false;
|
|
1391
|
+
if (!br || !non || Number(br.user_count) < 240) {
|
|
1392
|
+
return { verdict: "WEAK", detail: `browser cohort too small: ${br?.user_count ?? 0}` };
|
|
1393
|
+
}
|
|
1394
|
+
const total = Number(br.user_count) + Number(non.user_count);
|
|
1395
|
+
const shr = Number(br.user_count) / total;
|
|
1396
|
+
const nonPu = Number(non.luxviews_pu), brPu = Number(br.luxviews_pu);
|
|
1397
|
+
const pre = Number(br.luxviews_pre) + Number(non.luxviews_pre);
|
|
1398
|
+
const detail = `luxury ($5M+) views: browsers=${brPu.toFixed(2)}/user (share ${(shr * 100).toFixed(2)}% of users, 1/16 hash), non-browsers=${nonPu}, strictly pre-d50=${pre}`;
|
|
1399
|
+
if (nonPu === 0 && pre === 0 && brPu >= 1.4 && brPu <= 2.4 && shr >= 0.045 && shr <= 0.075) return { verdict: "NAILED", detail };
|
|
1400
|
+
if (nonPu === 0 && pre === 0 && brPu >= 1.1 && brPu <= 2.8) return { verdict: "STRONG", detail };
|
|
1401
|
+
if (nonPu === 0 && brPu > 0.5) return { verdict: "WEAK", detail };
|
|
1402
|
+
return { verdict: "NONE", detail };
|
|
1403
|
+
},
|
|
1404
|
+
},
|
|
1405
|
+
],
|
|
1406
|
+
},
|
|
1407
|
+
{
|
|
1408
|
+
id: "H8-cold-lead-churn",
|
|
1409
|
+
hook: "H8",
|
|
1410
|
+
archetype: "retention-divergence",
|
|
1411
|
+
narrative:
|
|
1412
|
+
`Cold leads (viewed but never saved a property within ${COLD_LEAD_WINDOW_DAYS}d of first event — ` +
|
|
1413
|
+
`~58% of users; 'property saved' is a different event from H3's 'saved search created') lose ` +
|
|
1414
|
+
`${COLD_LEAD_DROP_LIKELIHOOD}% of post-day-14 events. Classification from output is exact (H8 runs ` +
|
|
1415
|
+
"after every hook that adds/removes first-14d views/saves — see doctrine). Absolute post-14 ratio " +
|
|
1416
|
+
"~0.13 (the 0.1 keep knob x composition: cold leads are lighter users pre-treatment too, so the " +
|
|
1417
|
+
"within-user decay pair — cold post/pre vs rest post/pre — carries the causal read.",
|
|
1418
|
+
assertions: [
|
|
1419
|
+
{
|
|
1420
|
+
breakdown: {
|
|
1421
|
+
type: "duckdb",
|
|
1422
|
+
sql: `WITH ${ID_CTE}, ${PU_CTE}
|
|
1423
|
+
SELECT (viewed14 AND NOT saved14) AS cold, count(*)::BIGINT AS user_count,
|
|
1424
|
+
avg((SELECT count(*) FROM ev e WHERE e.uid = puu.uid AND e.t > puu.first_t + INTERVAL '${COLD_LEAD_WINDOW_DAYS} days')) AS post14,
|
|
1425
|
+
avg((SELECT count(*) FROM ev e WHERE e.uid = puu.uid AND e.t <= puu.first_t + INTERVAL '${COLD_LEAD_WINDOW_DAYS} days')) AS pre14
|
|
1426
|
+
FROM puu WHERE first_t < TIMESTAMP '2026-04-01' GROUP BY 1`,
|
|
1427
|
+
},
|
|
1428
|
+
assert: (rows) => {
|
|
1429
|
+
const by = cellsOf(rows, "cold");
|
|
1430
|
+
const c = by.true, r = by.false;
|
|
1431
|
+
if (!c || !r || Number(c.user_count) < 2200 || Number(r.user_count) < 1500) {
|
|
1432
|
+
return { verdict: "WEAK", detail: `cohorts too small: cold=${c?.user_count ?? 0} rest=${r?.user_count ?? 0}` };
|
|
1433
|
+
}
|
|
1434
|
+
const ratio = Number(c.post14) / Number(r.post14);
|
|
1435
|
+
const detail = `post-first-14d events/user cold/rest=${ratio.toFixed(3)} (${Number(c.post14).toFixed(1)} vs ${Number(r.post14).toFixed(1)}; n=${c.user_count}/${r.user_count})`;
|
|
1436
|
+
if (ratio >= 0.09 && ratio <= 0.17) return { verdict: "NAILED", detail };
|
|
1437
|
+
if (ratio >= 0.06 && ratio <= 0.22) return { verdict: "STRONG", detail };
|
|
1438
|
+
if (ratio < 0.5) return { verdict: "WEAK", detail };
|
|
1439
|
+
return { verdict: ratio >= 1 ? "INVERSE" : "NONE", detail };
|
|
1440
|
+
},
|
|
1441
|
+
},
|
|
1442
|
+
{
|
|
1443
|
+
breakdown: {
|
|
1444
|
+
type: "duckdb",
|
|
1445
|
+
sql: `WITH ${ID_CTE}, ${PU_CTE}
|
|
1446
|
+
SELECT (viewed14 AND NOT saved14) AS cold, count(*)::BIGINT AS user_count,
|
|
1447
|
+
avg((SELECT count(*) FROM ev e WHERE e.uid = puu.uid AND e.t > puu.first_t + INTERVAL '${COLD_LEAD_WINDOW_DAYS} days')) AS post14,
|
|
1448
|
+
avg((SELECT count(*) FROM ev e WHERE e.uid = puu.uid AND e.t <= puu.first_t + INTERVAL '${COLD_LEAD_WINDOW_DAYS} days')) AS pre14
|
|
1449
|
+
FROM puu WHERE first_t < TIMESTAMP '2026-04-01' GROUP BY 1`,
|
|
1450
|
+
},
|
|
1451
|
+
assert: (rows) => {
|
|
1452
|
+
const by = cellsOf(rows, "cold");
|
|
1453
|
+
const c = by.true, r = by.false;
|
|
1454
|
+
if (!c || !r || Number(c.user_count) < 2200 || Number(r.user_count) < 1500) {
|
|
1455
|
+
return { verdict: "WEAK", detail: `cohorts too small: cold=${c?.user_count ?? 0} rest=${r?.user_count ?? 0}` };
|
|
1456
|
+
}
|
|
1457
|
+
const decay = Number(c.post14) / Number(c.pre14);
|
|
1458
|
+
const growth = Number(r.post14) / Number(r.pre14);
|
|
1459
|
+
const detail = `within-user trajectory: cold post/pre=${decay.toFixed(3)} vs rest post/pre=${growth.toFixed(3)} (cold collapse vs organic growth)`;
|
|
1460
|
+
if (decay >= 0.5 && decay <= 0.85 && growth >= 1.6) return { verdict: "NAILED", detail };
|
|
1461
|
+
if (decay <= 1.0 && growth >= 1.3) return { verdict: "STRONG", detail };
|
|
1462
|
+
if (decay < growth) return { verdict: "WEAK", detail };
|
|
1463
|
+
return { verdict: "INVERSE", detail };
|
|
1464
|
+
},
|
|
1465
|
+
},
|
|
1466
|
+
],
|
|
1467
|
+
},
|
|
1468
|
+
{
|
|
1469
|
+
id: "H9-view-magic-number",
|
|
1470
|
+
hook: "H9",
|
|
1471
|
+
archetype: "frequency-sweet-spot",
|
|
1472
|
+
narrative:
|
|
1473
|
+
`${VIEW_SWEET_MIN}-${VIEW_SWEET_MAX} output views (exact hook-time count — see doctrine) => offer_price ` +
|
|
1474
|
+
`x${VIEW_OFFER_PRICE_BOOST}; ${VIEW_OVER_THRESHOLD}+ views => ${VIEW_OVER_OFFER_DROP_LIKELIHOOD}% of the user's ` +
|
|
1475
|
+
"offers deleted. Price read excludes the spring window (H1's 2.5x runs last and would swamp it); " +
|
|
1476
|
+
"over-bucket prices are untreated (placebo vs low ~1.04). Volume read is the doc's plain cohort " +
|
|
1477
|
+
"comparison over/sweet: the 60% deletion knob nets ~0.65 visible because the over bucket's " +
|
|
1478
|
+
"power-cloner composition (97% pre-approved/dual-tour) offsets ~25 points — knob raised from 35 " +
|
|
1479
|
+
"in v1.6 precisely so the documented '~35% fewer offers' materializes in the plain read.",
|
|
1480
|
+
assertions: [
|
|
1481
|
+
{
|
|
1482
|
+
breakdown: {
|
|
1483
|
+
type: "duckdb",
|
|
1484
|
+
sql: `WITH ${ID_CTE}, ${PU_CTE}
|
|
1485
|
+
SELECT CASE WHEN p.views BETWEEN ${VIEW_SWEET_MIN} AND ${VIEW_SWEET_MAX} THEN 'sweet'
|
|
1486
|
+
WHEN p.views < ${VIEW_SWEET_MIN} THEN 'low' ELSE 'over' END AS bucket,
|
|
1487
|
+
count(*)::BIGINT AS n, count(DISTINCT e.uid)::BIGINT AS user_count, avg(e.offer_price) AS price
|
|
1488
|
+
FROM puu p JOIN ev e ON e.uid = p.uid AND e.event = 'offer submitted'
|
|
1489
|
+
WHERE e.day_idx NOT BETWEEN ${SPRING_START_DAY} AND ${SPRING_END_DAY - 1}
|
|
1490
|
+
GROUP BY 1`,
|
|
1491
|
+
},
|
|
1492
|
+
assert: (rows) => {
|
|
1493
|
+
const by = cellsOf(rows, "bucket");
|
|
1494
|
+
const sw = by.sweet, lo = by.low, ov = by.over;
|
|
1495
|
+
if (!sw || !lo || Number(sw.n) < 2500 || Number(lo.n) < 4000) {
|
|
1496
|
+
return { verdict: "WEAK", detail: `offer volume too small: sweet=${sw?.n ?? 0} low=${lo?.n ?? 0}` };
|
|
1497
|
+
}
|
|
1498
|
+
const ratio = Number(sw.price) / Number(lo.price);
|
|
1499
|
+
const placebo = ov ? Number(ov.price) / Number(lo.price) : NaN;
|
|
1500
|
+
const detail = `non-spring avg offer_price sweet/low=${ratio.toFixed(3)} (knob ${VIEW_OFFER_PRICE_BOOST}x); over/low placebo=${placebo.toFixed(3)} (n=${sw.n}/${lo.n}/${ov?.n ?? 0})`;
|
|
1501
|
+
if (ratio >= 1.25 && ratio <= 1.45) return { verdict: "NAILED", detail };
|
|
1502
|
+
if (ratio >= 1.18 && ratio <= 1.55) return { verdict: "STRONG", detail };
|
|
1503
|
+
if (ratio > 1.05) return { verdict: "WEAK", detail };
|
|
1504
|
+
return { verdict: ratio <= 1 ? "INVERSE" : "NONE", detail };
|
|
1505
|
+
},
|
|
1506
|
+
},
|
|
1507
|
+
{
|
|
1508
|
+
breakdown: {
|
|
1509
|
+
type: "duckdb",
|
|
1510
|
+
sql: `WITH ${ID_CTE}, ${PU_CTE}
|
|
1511
|
+
SELECT CASE WHEN p.views BETWEEN ${VIEW_SWEET_MIN} AND ${VIEW_SWEET_MAX} THEN 'sweet'
|
|
1512
|
+
WHEN p.views < ${VIEW_SWEET_MIN} THEN 'low' ELSE 'over' END AS bucket,
|
|
1513
|
+
count(*)::BIGINT AS n, count(DISTINCT e.uid)::BIGINT AS user_count, avg(e.offer_price) AS price
|
|
1514
|
+
FROM puu p JOIN ev e ON e.uid = p.uid AND e.event = 'offer submitted'
|
|
1515
|
+
WHERE e.day_idx NOT BETWEEN ${SPRING_START_DAY} AND ${SPRING_END_DAY - 1}
|
|
1516
|
+
GROUP BY 1`,
|
|
1517
|
+
},
|
|
1518
|
+
select: {
|
|
1519
|
+
over: { where: { bucket: "over" } },
|
|
1520
|
+
low: { where: { bucket: "low" } },
|
|
1521
|
+
},
|
|
1522
|
+
expect: { metric: "over.price / low.price", op: "between", target: [0.92, 1.15] },
|
|
1523
|
+
minCohort: 400,
|
|
1524
|
+
},
|
|
1525
|
+
{
|
|
1526
|
+
breakdown: {
|
|
1527
|
+
type: "duckdb",
|
|
1528
|
+
sql: `WITH ${ID_CTE}, ${PU_CTE}
|
|
1529
|
+
SELECT CASE WHEN views BETWEEN ${VIEW_SWEET_MIN} AND ${VIEW_SWEET_MAX} THEN 'sweet'
|
|
1530
|
+
WHEN views >= ${VIEW_OVER_THRESHOLD} THEN 'over' ELSE 'low' END AS bucket,
|
|
1531
|
+
count(*)::BIGINT AS user_count, avg(offers) AS offers_pu
|
|
1532
|
+
FROM puu GROUP BY 1`,
|
|
1533
|
+
},
|
|
1534
|
+
assert: (rows) => {
|
|
1535
|
+
const by = cellsOf(rows, "bucket");
|
|
1536
|
+
const ov = by.over, sw = by.sweet;
|
|
1537
|
+
if (!ov || !sw || Number(ov.user_count) < 430 || Number(sw.user_count) < 500) {
|
|
1538
|
+
return { verdict: "WEAK", detail: `buckets too small: over=${ov?.user_count ?? 0} sweet=${sw?.user_count ?? 0}` };
|
|
1539
|
+
}
|
|
1540
|
+
const ratio = Number(ov.offers_pu) / Number(sw.offers_pu);
|
|
1541
|
+
const detail = `offers/user over/sweet=${ratio.toFixed(3)} (${Number(ov.offers_pu).toFixed(2)} vs ${Number(sw.offers_pu).toFixed(2)}; 60% knob nets ~0.65 visible; n=${ov.user_count}/${sw.user_count})`;
|
|
1542
|
+
if (ratio >= 0.52 && ratio <= 0.78) return { verdict: "NAILED", detail };
|
|
1543
|
+
if (ratio >= 0.42 && ratio <= 0.90) return { verdict: "STRONG", detail };
|
|
1544
|
+
if (ratio < 1) return { verdict: "WEAK", detail };
|
|
1545
|
+
return { verdict: "INVERSE", detail };
|
|
1546
|
+
},
|
|
1547
|
+
},
|
|
1548
|
+
],
|
|
1549
|
+
},
|
|
1550
|
+
{
|
|
1551
|
+
id: "H10-tour-ttc-by-tier",
|
|
1552
|
+
hook: "H10",
|
|
1553
|
+
archetype: "funnel-conversion-by-segment",
|
|
1554
|
+
narrative:
|
|
1555
|
+
`funnel-post scales step gaps by agent_tier: Premier x${TTC_PREMIER_FACTOR}, Standard x${TTC_STANDARD_FACTOR} ` +
|
|
1556
|
+
`(mechanism ratio ${(TTC_PREMIER_FACTOR / TTC_STANDARD_FACTOR).toFixed(3)}) — applied to ALL three funnels; the read ` +
|
|
1557
|
+
"targets the Tour Funnel. Primary: emulator 2-step view→tour-scheduled median TTC at 31.2h " +
|
|
1558
|
+
"(24h generative x 1.3 stretch) — measured 0.56, on-mechanism. Secondary: the doc's 3-step read, " +
|
|
1559
|
+
"attenuated toward 1 (~0.74) by H4/H6 offer clones colliding with the greedy third-step pick — " +
|
|
1560
|
+
"kept directional. v1.6 graduation: real-estate previously carried the funnel-post limitation " +
|
|
1561
|
+
"flag ('populations present' only); the emulator read asserts the TTC delta itself. Identity " +
|
|
1562
|
+
"invariants ride here: total uid resolution and profile-stamp agreement are structural.",
|
|
1563
|
+
assertions: [
|
|
1564
|
+
{
|
|
1565
|
+
breakdown: {
|
|
1566
|
+
type: "timeToConvert",
|
|
1567
|
+
steps: ["property viewed", "tour scheduled"],
|
|
1568
|
+
breakdownByUserProperty: "agent_tier",
|
|
1569
|
+
conversionWindowMs: Math.round(24 * TTC_STANDARD_FACTOR * 3600 * 1000),
|
|
1570
|
+
},
|
|
1571
|
+
assert: (rows) => {
|
|
1572
|
+
const by = cellsOf(rows, "segment_value");
|
|
1573
|
+
const p = by.Premier, s = by.Standard;
|
|
1574
|
+
if (!p || !s || Number(p.user_count) < 600 || Number(s.user_count) < 1600) {
|
|
1575
|
+
return { verdict: "WEAK", detail: `converters too small: Premier=${p?.user_count ?? 0} Standard=${s?.user_count ?? 0}` };
|
|
1576
|
+
}
|
|
1577
|
+
const ratio = Number(p.median_ttc_ms) / Number(s.median_ttc_ms);
|
|
1578
|
+
const detail = `2-step view→tour median TTC Premier/Standard=${ratio.toFixed(3)} (mechanism 0.71/1.3=0.546; medians ${(Number(p.median_ttc_ms) / 3600000).toFixed(2)}h vs ${(Number(s.median_ttc_ms) / 3600000).toFixed(2)}h; converters=${p.user_count}/${s.user_count})`;
|
|
1579
|
+
if (ratio >= 0.48 && ratio <= 0.63) return { verdict: "NAILED", detail };
|
|
1580
|
+
if (ratio >= 0.42 && ratio <= 0.70) return { verdict: "STRONG", detail };
|
|
1581
|
+
if (ratio < 0.9) return { verdict: "WEAK", detail };
|
|
1582
|
+
return { verdict: ratio >= 1 ? "INVERSE" : "NONE", detail };
|
|
1583
|
+
},
|
|
1584
|
+
},
|
|
1585
|
+
{
|
|
1586
|
+
breakdown: {
|
|
1587
|
+
type: "timeToConvert",
|
|
1588
|
+
steps: ["property viewed", "tour scheduled", "offer submitted"],
|
|
1589
|
+
breakdownByUserProperty: "agent_tier",
|
|
1590
|
+
conversionWindowMs: Math.round(24 * TTC_STANDARD_FACTOR * 3600 * 1000),
|
|
1591
|
+
},
|
|
1592
|
+
assert: (rows) => {
|
|
1593
|
+
const by = cellsOf(rows, "segment_value");
|
|
1594
|
+
const p = by.Premier, s = by.Standard;
|
|
1595
|
+
if (!p || !s || Number(p.user_count) < 300 || Number(s.user_count) < 850) {
|
|
1596
|
+
return { verdict: "WEAK", detail: `converters too small: Premier=${p?.user_count ?? 0} Standard=${s?.user_count ?? 0}` };
|
|
1597
|
+
}
|
|
1598
|
+
const ratio = Number(p.median_ttc_ms) / Number(s.median_ttc_ms);
|
|
1599
|
+
const detail = `3-step median TTC Premier/Standard=${ratio.toFixed(3)} (clone-pollution attenuates 0.546 toward 1; converters=${p.user_count}/${s.user_count})`;
|
|
1600
|
+
if (ratio >= 0.62 && ratio <= 0.86) return { verdict: "NAILED", detail };
|
|
1601
|
+
if (ratio >= 0.55 && ratio <= 0.95) return { verdict: "STRONG", detail };
|
|
1602
|
+
if (ratio < 1) return { verdict: "WEAK", detail };
|
|
1603
|
+
return { verdict: "INVERSE", detail };
|
|
1604
|
+
},
|
|
1605
|
+
},
|
|
1606
|
+
{
|
|
1607
|
+
breakdown: {
|
|
1608
|
+
type: "duckdb",
|
|
1609
|
+
sql: `WITH ${ID_CTE}
|
|
1610
|
+
SELECT count(*)::BIGINT AS n,
|
|
1611
|
+
avg((u.distinct_id IS NOT NULL)::INT) AS uid_resolved,
|
|
1612
|
+
avg(CASE WHEN u.distinct_id IS NOT NULL THEN (e2.user_type = u.user_type)::INT END) AS stamp_agree
|
|
1613
|
+
FROM ev e2 LEFT JOIN us u ON e2.uid = u.distinct_id::VARCHAR`,
|
|
1614
|
+
},
|
|
1615
|
+
assert: (rows) => {
|
|
1616
|
+
const r = rows?.[0];
|
|
1617
|
+
if (!r || Number(r.n) < 100000) {
|
|
1618
|
+
return { verdict: "WEAK", detail: `too few events: n=${r?.n ?? 0}` };
|
|
1619
|
+
}
|
|
1620
|
+
const res = Number(r.uid_resolved), agree = Number(r.stamp_agree);
|
|
1621
|
+
const detail = `identity invariants: uid resolution=${res} profile-stamp agreement=${agree} over ${r.n} events (auth-on-first-event; superProps stamped from profile)`;
|
|
1622
|
+
if (res === 1 && agree === 1) return { verdict: "NAILED", detail };
|
|
1623
|
+
if (res >= 0.999 && agree >= 0.999) return { verdict: "STRONG", detail };
|
|
1624
|
+
return { verdict: "NONE", detail };
|
|
1625
|
+
},
|
|
1626
|
+
},
|
|
1627
|
+
],
|
|
1628
|
+
},
|
|
1629
|
+
];
|