@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.
Files changed (146) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +21 -12
  2. package/.claude/skills/create-dungeon/SKILL.md +65 -5
  3. package/.claude/skills/create-project/SKILL.md +96 -0
  4. package/.claude/skills/create-project/provision.mjs +336 -0
  5. package/.claude/skills/verify-dungeon/SKILL.md +56 -25
  6. package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
  7. package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
  8. package/.claude/skills/write-hooks/SKILL.md +156 -18
  9. package/CHANGELOG.md +211 -0
  10. package/HOOKS.md +566 -112
  11. package/README.md +28 -9
  12. package/docs/guides/1.3.0-upgrade-guide.md +262 -0
  13. package/docs/guides/1.3.1-upgrade-guide.md +328 -0
  14. package/docs/guides/1.4.0-upgrade-guide.md +130 -0
  15. package/docs/guides/1.4.1-upgrade-guide.md +125 -0
  16. package/docs/guides/1.4.3-upgrade-guide.md +200 -0
  17. package/docs/guides/1.4.4-upgrade-guide.md +95 -0
  18. package/docs/guides/1.5.0-upgrade-guide.md +426 -0
  19. package/docs/guides/1.5.1-upgrade-guide.md +178 -0
  20. package/docs/guides/1.5.3-upgrade-guide.md +117 -0
  21. package/docs/guides/1.6.0-upgrade-guide.md +204 -0
  22. package/dungeons/technical/stories-verify.js +152 -0
  23. package/dungeons/vertical/README.md +154 -0
  24. package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
  25. package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
  26. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
  27. package/dungeons/vertical/community/community.js +1589 -0
  28. package/dungeons/vertical/community/community.sql +216 -0
  29. package/dungeons/vertical/community/community.verify.mjs +72 -0
  30. package/dungeons/vertical/crypto/crypto.js +1483 -0
  31. package/dungeons/vertical/crypto/crypto.sql +194 -0
  32. package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
  33. package/dungeons/vertical/dating/dating.js +1540 -0
  34. package/dungeons/vertical/dating/dating.sql +288 -0
  35. package/dungeons/vertical/dating/dating.verify.mjs +72 -0
  36. package/dungeons/vertical/devtools/devtools.js +1524 -0
  37. package/dungeons/vertical/devtools/devtools.sql +161 -0
  38. package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
  39. package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
  40. package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
  41. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
  42. package/dungeons/vertical/education/education.js +1763 -0
  43. package/dungeons/vertical/education/education.sql +243 -0
  44. package/dungeons/vertical/education/education.verify.mjs +75 -0
  45. package/dungeons/vertical/fintech/fintech.js +1410 -0
  46. package/dungeons/vertical/fintech/fintech.sql +175 -0
  47. package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
  48. package/dungeons/vertical/fitness/fitness.js +1495 -0
  49. package/dungeons/vertical/fitness/fitness.sql +206 -0
  50. package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
  51. package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
  52. package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
  53. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
  54. package/dungeons/vertical/gaming/gaming.js +1741 -0
  55. package/dungeons/vertical/gaming/gaming.sql +249 -0
  56. package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
  57. package/dungeons/vertical/healthcare/healthcare.js +1488 -0
  58. package/dungeons/vertical/healthcare/healthcare.sql +193 -0
  59. package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
  60. package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
  61. package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
  62. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
  63. package/dungeons/vertical/logistics/logistics.js +1541 -0
  64. package/dungeons/vertical/logistics/logistics.sql +205 -0
  65. package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
  66. package/dungeons/vertical/marketplace/marketplace.js +1438 -0
  67. package/dungeons/vertical/marketplace/marketplace.sql +148 -0
  68. package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
  69. package/dungeons/vertical/{media.js → media/media.js} +557 -58
  70. package/dungeons/vertical/media/media.sql +173 -0
  71. package/dungeons/vertical/media/media.verify.mjs +74 -0
  72. package/dungeons/vertical/real-estate/real-estate.js +1629 -0
  73. package/dungeons/vertical/real-estate/real-estate.sql +167 -0
  74. package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
  75. package/dungeons/vertical/sass/sass.js +1733 -0
  76. package/dungeons/vertical/sass/sass.sql +173 -0
  77. package/dungeons/vertical/sass/sass.verify.mjs +74 -0
  78. package/dungeons/vertical/social/social.js +1456 -0
  79. package/dungeons/vertical/social/social.sql +160 -0
  80. package/dungeons/vertical/social/social.verify.mjs +76 -0
  81. package/dungeons/vertical/streaming/streaming.js +677 -0
  82. package/dungeons/vertical/streaming/streaming.sql +101 -0
  83. package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
  84. package/dungeons/vertical/support-desk/support-desk.js +741 -0
  85. package/dungeons/vertical/support-desk/support-desk.sql +137 -0
  86. package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
  87. package/dungeons/vertical/travel/travel.js +1603 -0
  88. package/dungeons/vertical/travel/travel.sql +236 -0
  89. package/dungeons/vertical/travel/travel.verify.mjs +72 -0
  90. package/index.js +2 -2
  91. package/lib/core/config-validator.js +37 -2
  92. package/lib/core/context.js +4 -2
  93. package/lib/core/dungeon-loader.js +20 -15
  94. package/lib/core/dungeon-to-json.js +22 -4
  95. package/lib/core/storage.js +3 -3
  96. package/lib/generators/funnels.js +36 -9
  97. package/lib/hook-helpers/cohort.js +53 -0
  98. package/lib/hook-helpers/index.js +8 -0
  99. package/lib/hook-helpers/mutate.js +1 -1
  100. package/lib/hook-helpers/shape.js +319 -0
  101. package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
  102. package/lib/hook-patterns/attributed-by-source.js +60 -58
  103. package/lib/hook-patterns/frequency-by-frequency.js +14 -4
  104. package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
  105. package/lib/hook-patterns/index.js +1 -1
  106. package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
  107. package/lib/orchestrators/user-loop.js +41 -0
  108. package/lib/templates/story-spec.schema.json +175 -0
  109. package/lib/utils/json-evaluator.js +1 -1
  110. package/lib/utils/utils.js +5 -4
  111. package/lib/verify/coerce.js +186 -0
  112. package/lib/verify/counting.js +270 -32
  113. package/lib/verify/emulate-breakdown.js +1285 -203
  114. package/lib/verify/first-time.js +89 -0
  115. package/lib/verify/flows.js +679 -0
  116. package/lib/verify/formula.js +259 -0
  117. package/lib/verify/funnel-engine.js +697 -113
  118. package/lib/verify/index.js +32 -4
  119. package/lib/verify/sessionize.js +275 -0
  120. package/lib/verify/story-runner.js +483 -0
  121. package/lib/verify/verify-dungeon.js +59 -37
  122. package/package.json +3 -2
  123. package/scripts/extract-dungeon-schema.mjs +2 -2
  124. package/scripts/run-many.mjs +2 -2
  125. package/scripts/verify-runner.mjs +2 -2
  126. package/scripts/verify-stories.mjs +244 -0
  127. package/types.d.ts +296 -30
  128. package/dungeons/vertical/ai-platform.js +0 -818
  129. package/dungeons/vertical/community.js +0 -966
  130. package/dungeons/vertical/crypto.js +0 -895
  131. package/dungeons/vertical/dating.js +0 -830
  132. package/dungeons/vertical/devtools.js +0 -1050
  133. package/dungeons/vertical/ecommerce.js +0 -811
  134. package/dungeons/vertical/education.js +0 -964
  135. package/dungeons/vertical/fintech.js +0 -946
  136. package/dungeons/vertical/fitness.js +0 -935
  137. package/dungeons/vertical/food-delivery.js +0 -934
  138. package/dungeons/vertical/gaming.js +0 -1120
  139. package/dungeons/vertical/healthcare.js +0 -945
  140. package/dungeons/vertical/insurance-application.js +0 -1039
  141. package/dungeons/vertical/logistics.js +0 -986
  142. package/dungeons/vertical/marketplace.js +0 -1019
  143. package/dungeons/vertical/real-estate.js +0 -872
  144. package/dungeons/vertical/sass.js +0 -995
  145. package/dungeons/vertical/social.js +0 -904
  146. 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
+ ];