@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,1589 @@
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
+ import * as v from "ak-tools";
8
+ /** @typedef {import("../../../types").Dungeon} Config */
9
+
10
+ // ── OVERVIEW ──
11
+ /*
12
+ * NAME: FanVerse
13
+ * APP: Fan wiki and community discussion platform where users create
14
+ * articles, discuss topics, moderate content, and build collaborative
15
+ * knowledge bases across fandoms. Core loop: sign up → search → read
16
+ * articles → contribute → discuss. Revenue: free / supporter ($4.99,
17
+ * ad-free) / pro ($12.99, analytics + badges).
18
+ * SCALE: 10,000 users, ~617K events, 121 days (2026-01-01 → 2026-05-01)
19
+ * CORE LOOP: account created → search performed → article viewed → article published → comment posted
20
+ *
21
+ * EVENTS (18):
22
+ * article viewed (9) > app session (8) > upvote given (7) > comment posted (6)
23
+ * > search performed (6) > notification received (6) > discussion posted (5)
24
+ * > article edited (4) > article published (3) > user followed (3)
25
+ * > wiki page created (2) > media uploaded (2) > moderation action (2)
26
+ * > profile updated (2) > account created (1) > support ticket created (1)
27
+ * > report submitted (1) > account deactivated (1)
28
+ *
29
+ * FUNNELS (5):
30
+ * - Onboarding Flow: account created → search performed → article viewed → discussion posted (40%)
31
+ * - Content Creation: article viewed → article published → comment posted (35%)
32
+ * - Engagement Loop: article viewed → upvote given → comment posted → discussion posted (30%)
33
+ * - Creator to Supporter: article published → profile updated → notification received (45%)
34
+ * - Moderation Pipeline: report submitted → moderation action (60%)
35
+ *
36
+ * USER PROPS: role, contributor_level, articles_created, reputation_score, preferred_hub, subscription_tier, Platform, content_hub
37
+ * SUPER PROPS: subscription_tier, Platform, content_hub
38
+ * SCD PROPS: contributor_level (newcomer/regular/trusted/admin, monthly fuzzy, max 8)
39
+ * GROUPS: none
40
+ */
41
+
42
+ // ── HOOK STORIES ──
43
+ /*
44
+ * -------------------------------------------------------------------
45
+ * 1. WEEKEND CONTENT SURGE (event hook)
46
+ * -------------------------------------------------------------------
47
+ *
48
+ * PATTERN: Articles published on weekends (Sat/Sun) have 1.5x
49
+ * word_count. Creators have more time on weekends to write longer,
50
+ * more detailed wiki articles.
51
+ *
52
+ * HOW TO FIND IT IN MIXPANEL:
53
+ *
54
+ * Report 1: Weekend vs Weekday Word Count
55
+ * - Report type: Insights
56
+ * - Event: "article published"
57
+ * - Measure: Average of "word_count"
58
+ * - Breakdown: Day of Week
59
+ * - Expected: Sat/Sun ~ 1.5x avg word_count vs weekdays
60
+ * (weekend ~ 3325, weekday ~ 2218)
61
+ *
62
+ * REAL-WORLD ANALOGUE: Community wikis see longer, more thoughtful
63
+ * contributions on weekends when creators have uninterrupted time.
64
+ *
65
+ * -------------------------------------------------------------------
66
+ * 2. TRENDING TOPIC WINDOW (event hook)
67
+ * -------------------------------------------------------------------
68
+ *
69
+ * PATTERN: During days 35-50, articles in the "gaming" hub get 2x
70
+ * view_count. Simulates a major game release driving traffic to
71
+ * gaming wiki pages.
72
+ *
73
+ * HOW TO FIND IT IN MIXPANEL:
74
+ *
75
+ * Report 1: Gaming Hub View Spike
76
+ * - Report type: Insights
77
+ * - Event: "article viewed"
78
+ * - Measure: Average of "view_count"
79
+ * - Filter: content_hub = "gaming"
80
+ * - Line chart by week
81
+ * - Expected: Clear spike during days 35-50 (~2x normal)
82
+ *
83
+ * Report 2: Hub Comparison During Trend Window
84
+ * - Report type: Insights
85
+ * - Event: "article viewed"
86
+ * - Measure: Average of "view_count"
87
+ * - Breakdown: "content_hub"
88
+ * - Filter: time within trend window
89
+ * - Expected: gaming ~2x vs other hubs
90
+ *
91
+ * REAL-WORLD ANALOGUE: Major franchise releases (game launches,
92
+ * movie premieres) drive massive traffic spikes to related wikis.
93
+ *
94
+ * -------------------------------------------------------------------
95
+ * 3. POWER CREATOR ENGAGEMENT LIFT (everything hook)
96
+ * -------------------------------------------------------------------
97
+ *
98
+ * PATTERN: Users who published >20 articles get 3x avg upvote_count
99
+ * on their content events. Prolific creators earn community trust
100
+ * and visibility, amplifying their engagement metrics.
101
+ *
102
+ * HOW TO FIND IT IN MIXPANEL:
103
+ *
104
+ * Report 1: Upvote Count by Creator Volume
105
+ * - Report type: Insights (with cohorts)
106
+ * - Cohort A: users who did "article published" 21+ times
107
+ * - Cohort B: users who did "article published" 0-1 times
108
+ * - Event: "upvote given"
109
+ * - Measure: Average of "upvote_count"
110
+ * - Expected: cohort A ~3x avg upvote_count (~15 vs ~5). Because the
111
+ * multiplier is an exact integer x3 on integer draws, every treated
112
+ * upvote_count is divisible by 3 — a structural signature.
113
+ *
114
+ * REAL-WORLD ANALOGUE: Power contributors on platforms like Fandom
115
+ * and Wikipedia earn disproportionate engagement due to reputation
116
+ * and content quality.
117
+ *
118
+ * -------------------------------------------------------------------
119
+ * 4. DISCUSSION DEPTH BY CONTRIBUTOR TYPE (everything hook)
120
+ * -------------------------------------------------------------------
121
+ *
122
+ * PATTERN: Active contributors (segment "active_contributor") get
123
+ * cloned comment_posted events to simulate deeper discussion threads.
124
+ * Each existing comment has a 50% chance of spawning a follow-up.
125
+ *
126
+ * HOW TO FIND IT IN MIXPANEL:
127
+ *
128
+ * Report 1: Comments Per User by Segment
129
+ * - Report type: Insights
130
+ * - Event: "comment posted"
131
+ * - Measure: Total per user
132
+ * - Breakdown: user property "role" (contributor = active_contributor)
133
+ * - Expected: ~1.5x comments ACTIVITY-NORMALIZED (comments per app
134
+ * session) vs readers — the raw per-user ratio is dominated by the
135
+ * persona event multipliers (1.5x vs 0.3x/0.1x), not the clones.
136
+ * Secondary signature: contributor is_reply share ~0.78 vs the
137
+ * organic ~0.67 (clones are always replies).
138
+ *
139
+ * REAL-WORLD ANALOGUE: Engaged contributors create deeper discussion
140
+ * threads, replying to comments and fostering community dialogue.
141
+ *
142
+ * -------------------------------------------------------------------
143
+ * 5. EDIT WAR DETECTION (everything hook)
144
+ * -------------------------------------------------------------------
145
+ *
146
+ * PATTERN: Users with >5 rapid article_edited events within a short
147
+ * window get reduced edit_quality score (set to 1-2 range vs normal
148
+ * 1-5). Simulates contentious edits degrading quality.
149
+ *
150
+ * HOW TO FIND IT IN MIXPANEL:
151
+ *
152
+ * Report 1: Edit Quality by Volume
153
+ * - Report type: Insights
154
+ * - Event: "article edited"
155
+ * - Measure: Average of "edit_quality"
156
+ * - Breakdown: user property "segment"
157
+ * - Expected: Users with many edits show lower avg quality
158
+ * (high-edit users ~ 1.5 vs normal ~ 3.0)
159
+ *
160
+ * REAL-WORLD ANALOGUE: Wiki edit wars (e.g., Wikipedia) degrade
161
+ * content quality as users repeatedly override each other's changes.
162
+ *
163
+ * -------------------------------------------------------------------
164
+ * 6. LURKER CHURN (everything hook)
165
+ * -------------------------------------------------------------------
166
+ *
167
+ * PATTERN: Users with <5 total events lose 60% of events after
168
+ * day 10 of their activity. Simulates lurkers quickly losing
169
+ * interest and churning out.
170
+ *
171
+ * HOW TO FIND IT IN MIXPANEL:
172
+ *
173
+ * Report 1: Event Volume by Segment Over Time
174
+ * - Report type: Insights
175
+ * - Event: Any event
176
+ * - Measure: Total per user
177
+ * - Breakdown: user property "segment"
178
+ * - Line chart by week
179
+ * - Expected: lurker segment drops off sharply after first 10 days
180
+ *
181
+ * REAL-WORLD ANALOGUE: Most community platforms see >60% of new
182
+ * signups become inactive within the first 2-3 weeks.
183
+ *
184
+ * -------------------------------------------------------------------
185
+ * 7. CREATOR PROFILES (user hook)
186
+ * -------------------------------------------------------------------
187
+ *
188
+ * PATTERN: Users with role "creator" get articles_created set to
189
+ * 50-200 range and reputation_score to 80-100. Moderators get
190
+ * mid-range reputation. Readers/lurkers stay at defaults.
191
+ *
192
+ * HOW TO FIND IT IN MIXPANEL:
193
+ *
194
+ * Report 1: Reputation Distribution by Role
195
+ * - Report type: Insights
196
+ * - Event: any event
197
+ * - Measure: Average of user property "reputation_score"
198
+ * - Breakdown: user property "role"
199
+ * - Expected: creator ~ 90, moderator ~ 55, reader ~ 25
200
+ *
201
+ * Report 2: Articles Created by Role
202
+ * - Report type: Insights
203
+ * - Measure: Average of user property "articles_created"
204
+ * - Breakdown: user property "role"
205
+ * - Expected: creator ~ 125, others ~ 0
206
+ *
207
+ * REAL-WORLD ANALOGUE: Top wiki contributors have hundreds of
208
+ * articles and high community reputation scores.
209
+ *
210
+ * -------------------------------------------------------------------
211
+ * 8. PRO SUBSCRIBER CONTENT CREATION LIFT (everything hook)
212
+ * -------------------------------------------------------------------
213
+ *
214
+ * PATTERN: Free-tier users drop 65% of ALL "comment posted" events
215
+ * (not just funnel-final instances), creating a visible conversion gap
216
+ * between paid and free users. Pro/supporter users keep all their
217
+ * events. Comments-per-session for free users therefore reads ~0.35x
218
+ * of paid — the exact keep rate.
219
+ *
220
+ * HOW TO FIND IT IN MIXPANEL:
221
+ *
222
+ * Report 1: Content Creation Conversion by Tier
223
+ * - Report type: Funnels
224
+ * - Steps: "article viewed" -> "article published" -> "comment posted"
225
+ * - Breakdown: "subscription_tier" (superProp)
226
+ * - Expected: pro/supporter ~1.5-2.5x the free published→comment
227
+ * step conversion (nonlinear in the 0.35 keep rate — depends on
228
+ * per-window comment density)
229
+ *
230
+ * REAL-WORLD ANALOGUE: Premium wiki tools (analytics dashboards,
231
+ * badge systems) incentivize more content creation from subscribers.
232
+ *
233
+ * -------------------------------------------------------------------
234
+ * 9. CONTENT CREATION TIME-TO-CONVERT (funnel-post hook)
235
+ * -------------------------------------------------------------------
236
+ *
237
+ * PATTERN: Pro/supporter subscribers complete the Content Creation
238
+ * funnel 1.3x faster (time gaps scaled by 0.77). Free-tier users
239
+ * complete it 1.25x slower (gaps scaled by 1.25). The hook iterates
240
+ * over the funnel-post event array, compresses or stretches the
241
+ * inter-step time gaps based on the user's subscription_tier from
242
+ * meta.profile, then rewrites each event's timestamp. v1.6: scoped to
243
+ * the Content Creation funnel only — the v1.5 hook stretched every
244
+ * funnel's gaps, which this block never claimed.
245
+ *
246
+ * HOW TO FIND IT IN MIXPANEL:
247
+ *
248
+ * Report 1: Content Creation TTC by Subscription Tier
249
+ * - Report type: Funnels
250
+ * - Steps: "article viewed" -> "article published" -> "comment posted"
251
+ * - Breakdown: "subscription_tier" (superProp)
252
+ * - Metric: Median time to convert
253
+ * - Expected: pro/supporter median TTC below free-tier TTC. The raw
254
+ * knob distance is 0.77/1.25 = 0.62; organic cross-instance
255
+ * pairings mask part of it, so the visible ratio reads ~0.7-0.9.
256
+ *
257
+ * NOTE (funnel-post measurement): visible via Mixpanel funnel median
258
+ * TTC and via emulateBreakdown's timeToConvert (the H9 story asserts
259
+ * the delta itself at a 60h conversion window = 48h generative
260
+ * window x 1.25 max stretch). Cross-event MIN->MIN SQL queries on
261
+ * raw events do NOT show this — funnel-post adjusts gaps within
262
+ * funnel instances, not across the user's full event history.
263
+ *
264
+ * REAL-WORLD ANALOGUE: Premium wiki contributors with analytics
265
+ * dashboards and streamlined tools move from reading to publishing
266
+ * faster; free users hesitate longer without feedback loops.
267
+ *
268
+ * -------------------------------------------------------------------
269
+ * 10. ARTICLE-PUBLISHED MAGIC NUMBER (everything hook)
270
+ * -------------------------------------------------------------------
271
+ *
272
+ * PATTERN: Users who published 2-5 articles sit in a "sweet spot" --
273
+ * all their upvote_count values on "upvote given" events are boosted
274
+ * by +35% (factor 1.35). Users who published 6+ articles hit creator
275
+ * burnout: from day 60 (ARTICLE_FATIGUE_START_DAY), 40% of their
276
+ * "upvote given" events are dropped. No flag is stamped --
277
+ * discoverable only by binning users on article-published COUNT.
278
+ *
279
+ * WHY THE DROP IS CALENDAR-SCOPED: publish count is intrinsically
280
+ * coupled to activity level (E[pubs] grows with total events), so a
281
+ * uniform drop cannot be recovered from output -- every cross-arm
282
+ * rate comparison (per session, per discussion, per non-publish
283
+ * event) is confounded by activity composition; measured organic
284
+ * upvotes-per-session differs 23-58% across publish bands and
285
+ * activity-band matching leaves the arms with materially different
286
+ * event mixes. The calendar edge turns recovery into a
287
+ * difference-in-differences: each arm's own before/after
288
+ * upvotes-per-session ratio cancels its activity composition
289
+ * (measured arm-invariant to ~0.1% on untreated data), so
290
+ * (over after/before) / (sweet after/before) reads the 0.60 keep
291
+ * rate directly.
292
+ *
293
+ * HOW TO FIND IT IN MIXPANEL:
294
+ *
295
+ * Report 1: Upvote Count by Article Volume Cohort
296
+ * - Report type: Insights (with cohorts)
297
+ * - Cohort A: users who did "article published" 2-5 times
298
+ * - Cohort B: users who did "article published" 0-1 times
299
+ * - Event: "upvote given"
300
+ * - Measure: Average of "upvote_count"
301
+ * - Compare cohort A vs cohort B
302
+ * - Expected: cohort A ~ 1.35x higher avg upvote_count
303
+ *
304
+ * Report 2: Upvote Volume Collapse After Day 60 (2026-03-02)
305
+ * - Report type: Insights (with cohorts), line chart over time
306
+ * - Cohort C: users who did "article published" 6+ times
307
+ * - Cohort A: users who did "article published" 2-5 times
308
+ * - Event: "upvote given", Measure: Total events, weekly buckets
309
+ * - Expected: cohort C's upvote volume drops ~40% relative to its
310
+ * own pre-March trend at 2026-03-02; cohort A shows no break.
311
+ * (Levels differ across cohorts for organic activity reasons --
312
+ * compare each cohort to its own history, not to each other.)
313
+ *
314
+ * REAL-WORLD ANALOGUE: Creators who publish a handful of quality
315
+ * articles earn outsized community engagement; prolific publishers
316
+ * burn out mid-quarter and disengage from curating others' work.
317
+ *
318
+ * =====================================================================================
319
+ * EXPECTED METRICS SUMMARY (Measured = full fidelity, 10K users / 616,718 events)
320
+ * =====================================================================================
321
+ *
322
+ * Story id | Metric | Expected | Measured
323
+ * ------------------------------|-------------------------------------|---------------|---------
324
+ * H1-weekend-word-count[0] | published weekend/weekday word_count| [1.40, 1.60] | 1.491
325
+ * H1-weekend-word-count[1] | wiki placebo weekend/weekday | [0.92, 1.08] | 0.999
326
+ * H2-trending-gaming-window[0] | gaming in/out-window view_count | [1.80, 2.20] | 1.985
327
+ * H2-trending-gaming-window[1] | other-hub placebo in/out | [0.92, 1.08] | 0.998
328
+ * H3-power-creator-upvotes[0] | power/low avg upvote_count | [2.70, 3.30] | 3.028
329
+ * H3-power-creator-upvotes[1] | power mod-3 share (low placebo) | ≥0.995 (≤0.9) | 1.000 (0.620)
330
+ * H4-discussion-depth[0] | bracket: corrected DD ≤ 1.5 ≤ raw DD| [1.10,1.60]/[1.50,2.80] | 1.285 / 2.057
331
+ * H4-discussion-depth[1] | contributor reply share (reader) | [0.75, 0.81] | 0.780 (0.672)
332
+ * H5-edit-war[0] | war avg edit_quality; calm gap | [1.40,1.60]; ≥0.4 | 1.501; 0.840
333
+ * H6-lurker-churn[0] | pre-calibrated keep r (corrected/0.4)| [0.60, 1.40] | 1.104
334
+ * H7-creator-profiles[0] | role ranges exact; creator avg rep | 0 violations; [88,92] | exact; 90.26
335
+ * H8-pro-content-lift[0] | free/paid comments-per-session | [0.30, 0.40] | 0.346
336
+ * H8-pro-content-lift[1] | paid/free published→comment conv | [1.35, 2.60] | 2.458
337
+ * H9-content-ttc[0] | pro/free median TTC (emulator) | [0.65, 0.92] | 0.765
338
+ * H9-content-ttc[1] | supporter/pro median TTC (placebo) | [0.85, 1.15] | 0.995
339
+ * H10-article-magic-number[0] | sweet/low avg upvote_count | [1.25, 1.50] | 1.377
340
+ * H10-article-magic-number[1] | day-60 DiD upvotes-per-session | [0.50, 0.70] | 0.579
341
+ */
342
+
343
+ // ── SCALE ──
344
+ const SEED = "dm4-community";
345
+ const NUM_USERS = 10_000;
346
+ const DATASET_START = "2026-01-01T00:00:00Z";
347
+ const DATASET_END = "2026-05-01T23:59:59Z";
348
+ const EVENTS_PER_DAY = 1.2;
349
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
350
+
351
+ const chance = u.initChance(SEED);
352
+
353
+ // ── KNOBS (tweak these to reshape stories) ──
354
+ const WEEKEND_WORD_COUNT_MULT = 1.5;
355
+
356
+ const TREND_START_DAY = 35;
357
+ const TREND_END_DAY = 50;
358
+ const TREND_VIEW_MULT = 2;
359
+
360
+ const POWER_CREATOR_PUBLISH_THRESHOLD = 20;
361
+ const POWER_CREATOR_UPVOTE_MULT = 3;
362
+
363
+ const DISCUSSION_CLONE_LIKELIHOOD = 50;
364
+
365
+ const EDIT_WAR_THRESHOLD = 5;
366
+ const EDIT_WAR_QUALITY_MIN = 1.0;
367
+ const EDIT_WAR_QUALITY_MAX = 2.0;
368
+
369
+ const LURKER_EVENT_THRESHOLD = 5;
370
+ const LURKER_CHURN_CUTOFF_DAYS = 10;
371
+ const LURKER_DROP_LIKELIHOOD = 60;
372
+
373
+ const PRO_LIFT_FREE_DROP_LIKELIHOOD = 65;
374
+
375
+ const TTC_PRO_FACTOR = 0.77;
376
+ const TTC_FREE_FACTOR = 1.25;
377
+
378
+ const ARTICLE_SWEET_MIN = 2;
379
+ const ARTICLE_SWEET_MAX = 5;
380
+ const ARTICLE_OVER_THRESHOLD = 6;
381
+ const ARTICLE_UPVOTE_BOOST = 1.35;
382
+ // creator-burnout drop is calendar-scoped: publish count is intrinsically
383
+ // coupled to activity level, so a uniform drop is unrecoverable from output
384
+ // (any cross-arm rate comparison is confounded by activity composition —
385
+ // measured organic upvotes-per-session differs 23-58% across publish bands).
386
+ // A calendar edge makes it a difference-in-differences: each arm's own
387
+ // before/after upvotes-per-session ratio cancels its activity composition
388
+ // (measured arm-invariant to 0.1% on untreated data).
389
+ const ARTICLE_FATIGUE_START_DAY = 60;
390
+ const ARTICLE_UPVOTE_DROP_LIKELIHOOD = 40;
391
+
392
+ // ── DATA ARRAYS ──
393
+ // Generate consistent wiki/article IDs at module level
394
+ const wikiIds = v.range(1, 500).map(() => `WIKI_${v.uid(6)}`);
395
+ const communityIds = v.range(1, 30).map(() => `COMM_${v.uid(4)}`);
396
+
397
+ // ── HELPER FUNCTIONS ──
398
+ function handleUserHooks(record) {
399
+ // H7: CREATOR PROFILES — creators get high articles_created and reputation.
400
+ // Moderators get mid-range reputation. Readers/lurkers stay at defaults.
401
+ if (record.role === "creator") {
402
+ record.articles_created = chance.integer({ min: 50, max: 200 });
403
+ record.reputation_score = chance.integer({ min: 80, max: 100 });
404
+ record.contributor_level = "admin";
405
+ } else if (record.role === "moderator") {
406
+ record.articles_created = chance.integer({ min: 10, max: 50 });
407
+ record.reputation_score = chance.integer({ min: 40, max: 70 });
408
+ record.contributor_level = "trusted";
409
+ } else if (record.role === "contributor") {
410
+ record.articles_created = chance.integer({ min: 1, max: 15 });
411
+ record.reputation_score = chance.integer({ min: 15, max: 50 });
412
+ record.contributor_level = "regular";
413
+ } else {
414
+ record.articles_created = 0;
415
+ record.reputation_score = chance.integer({ min: 0, max: 20 });
416
+ record.contributor_level = "newcomer";
417
+ }
418
+ return record;
419
+ }
420
+
421
+ function handleFunnelPostHooks(record, meta) {
422
+ // H9: CONTENT CREATION TIME-TO-CONVERT — Pro/supporter complete 1.3x
423
+ // faster (factor 0.77); Free 1.25x slower (factor 1.25).
424
+ // v1.6: scoped to the Content Creation funnel only — the v1.5 hook
425
+ // stretched EVERY funnel's gaps (Onboarding, Engagement Loop, Creator
426
+ // to Supporter, Moderation), which the doc block never claimed.
427
+ if (meta?.funnel?.name !== "Content Creation") return record;
428
+ const segment = meta?.profile?.subscription_tier;
429
+ if (Array.isArray(record) && record.length > 1) {
430
+ const factor = (
431
+ segment === "pro" || segment === "supporter" ? TTC_PRO_FACTOR :
432
+ segment === "free" ? TTC_FREE_FACTOR :
433
+ 1.0
434
+ );
435
+ if (factor !== 1.0) {
436
+ for (let i = 1; i < record.length; i++) {
437
+ const prev = dayjs(record[i - 1].time);
438
+ const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
439
+ record[i].time = prev.add(newGap, "milliseconds").toISOString();
440
+ }
441
+ }
442
+ }
443
+ return record;
444
+ }
445
+
446
+ function handleEverythingHooks(record, meta) {
447
+ const datasetStart = dayjs.unix(meta.datasetStart);
448
+ let events = record;
449
+ if (!events.length) return record;
450
+ const profile = meta && meta.profile ? meta.profile : {};
451
+
452
+ // -- SUPERPROP STAMPING -----------------------------------
453
+ // Stamp superProp values from profile onto every event so
454
+ // they stay consistent per-user instead of randomizing per-event.
455
+ events.forEach(e => {
456
+ if (profile.subscription_tier) e.subscription_tier = profile.subscription_tier;
457
+ if (profile.Platform) e.Platform = profile.Platform;
458
+ if (profile.content_hub) e.content_hub = profile.content_hub;
459
+ });
460
+
461
+ // HOOK 1: WEEKEND CONTENT SURGE — articles on Sat/Sun get
462
+ // word_count 1.5x. Mutates raw prop. No flag. Only 'article published'
463
+ // carries word_count among touched events; 'wiki page created' also has
464
+ // word_count but is deliberately untouched — it is the placebo arm the
465
+ // H1 story uses to cancel any weekend-composition drift.
466
+ for (const e of events) {
467
+ if (e.event === 'article published') {
468
+ const dow = new Date(e.time).getUTCDay();
469
+ if ((dow === 0 || dow === 6) && e.word_count) {
470
+ e.word_count = Math.floor(e.word_count * WEEKEND_WORD_COUNT_MULT);
471
+ }
472
+ }
473
+ }
474
+
475
+ // -- HOOK 2: TRENDING TOPIC WINDOW -------------------------
476
+ // Days 35-50: gaming hub articles get 2x view_count.
477
+ // Runs after superProp stamping so content_hub is the
478
+ // profile's consistent value, not the random event-level one.
479
+ const TREND_START = datasetStart.add(TREND_START_DAY, "days");
480
+ const TREND_END = datasetStart.add(TREND_END_DAY, "days");
481
+ if (profile.content_hub === "gaming") {
482
+ events.forEach(e => {
483
+ if (e.event === "article viewed") {
484
+ const eventTime = dayjs(e.time);
485
+ if (eventTime.isAfter(TREND_START) && eventTime.isBefore(TREND_END)) {
486
+ e.view_count = Math.floor((e.view_count || 50) * TREND_VIEW_MULT);
487
+ }
488
+ }
489
+ });
490
+ }
491
+
492
+ // -- HOOK 8: PRO SUBSCRIBER CONTENT CREATION LIFT ---------
493
+ // Free-tier users drop 65% of comment events to widen the funnel
494
+ // conversion gap to ~2x vs paid subscribers.
495
+ if (profile.subscription_tier !== "pro" && profile.subscription_tier !== "supporter") {
496
+ events = events.filter(e => {
497
+ if (e.event === "comment posted" && chance.bool({ likelihood: PRO_LIFT_FREE_DROP_LIKELIHOOD })) return false;
498
+ return true;
499
+ });
500
+ }
501
+
502
+ // -- HOOK 3: POWER CREATOR ENGAGEMENT LIFT ----------------
503
+ // Users with >20 article_published events get 3x upvote_count.
504
+ let publishCount = 0;
505
+ events.forEach(e => {
506
+ if (e.event === "article published") publishCount++;
507
+ });
508
+
509
+ if (publishCount > POWER_CREATOR_PUBLISH_THRESHOLD) {
510
+ events.forEach(e => {
511
+ if (e.event === "upvote given" && e.upvote_count) {
512
+ e.upvote_count = Math.floor(e.upvote_count * POWER_CREATOR_UPVOTE_MULT);
513
+ }
514
+ });
515
+ }
516
+
517
+ // -- HOOK 4: DISCUSSION DEPTH BY CONTRIBUTOR TYPE ---------
518
+ // Active contributors get cloned comment_posted events.
519
+ if (profile.segment === "active_contributor") {
520
+ const templateComment = events.find(e => e.event === "comment posted");
521
+ if (templateComment) {
522
+ const existingComments = events.filter(e => e.event === "comment posted");
523
+ existingComments.forEach(c => {
524
+ if (chance.bool({ likelihood: DISCUSSION_CLONE_LIKELIHOOD })) {
525
+ events.push({
526
+ ...templateComment,
527
+ time: dayjs(c.time).add(chance.integer({ min: 1, max: 120 }), "minutes").toISOString(),
528
+ user_id: c.user_id,
529
+ is_reply: true,
530
+ comment_length: chance.integer({ min: 20, max: 300 }),
531
+ // engine stamps insert_id at generation — clones need fresh
532
+ // ids or Mixpanel's $insert_id dedupe silently eats them
533
+ insert_id: chance.guid(),
534
+ });
535
+ }
536
+ });
537
+ }
538
+ }
539
+
540
+ // -- HOOK 5: EDIT WAR DETECTION ---------------------------
541
+ // Users with >5 article_edited events get reduced edit_quality.
542
+ const editEvents = events.filter(e => e.event === "article edited");
543
+ if (editEvents.length > EDIT_WAR_THRESHOLD) {
544
+ editEvents.forEach(e => {
545
+ e.edit_quality = chance.floating({ min: EDIT_WAR_QUALITY_MIN, max: EDIT_WAR_QUALITY_MAX, fixed: 1 });
546
+ });
547
+ }
548
+
549
+ // HOOK 6: LURKER CHURN — users with <5 events lose 60% after
550
+ // day 10. No flag.
551
+ if (events.length < LURKER_EVENT_THRESHOLD && events.length > 0) {
552
+ const firstEventTime = dayjs(events[0].time);
553
+ const cutoff = firstEventTime.add(LURKER_CHURN_CUTOFF_DAYS, "days");
554
+ for (let i = events.length - 1; i >= 0; i--) {
555
+ if (dayjs(events[i].time).isAfter(cutoff) && chance.bool({ likelihood: LURKER_DROP_LIKELIHOOD })) {
556
+ events.splice(i, 1);
557
+ }
558
+ }
559
+ }
560
+
561
+ // HOOK 10: ARTICLE-PUBLISHED MAGIC NUMBER (no flags)
562
+ // Sweet 2-5 articles published → +35% on upvote_count for
563
+ // upvote-given events. Over 6+ → creator burnout: from day 60,
564
+ // 40% of their upvote-given events are dropped. The calendar edge
565
+ // (see ARTICLE_FATIGUE_START_DAY) is what makes the drop
566
+ // recoverable from output. No flag.
567
+ const articleCount = events.filter(e => e.event === "article published").length;
568
+ if (articleCount >= ARTICLE_SWEET_MIN && articleCount <= ARTICLE_SWEET_MAX) {
569
+ events.forEach(e => {
570
+ if (e.event === "upvote given" && typeof e.upvote_count === "number") {
571
+ e.upvote_count = Math.round(e.upvote_count * ARTICLE_UPVOTE_BOOST);
572
+ }
573
+ });
574
+ } else if (articleCount >= ARTICLE_OVER_THRESHOLD) {
575
+ const fatigueCutoff = datasetStart.add(ARTICLE_FATIGUE_START_DAY, "days");
576
+ for (let i = events.length - 1; i >= 0; i--) {
577
+ if (
578
+ events[i].event === "upvote given" &&
579
+ dayjs(events[i].time).isAfter(fatigueCutoff) &&
580
+ chance.bool({ likelihood: ARTICLE_UPVOTE_DROP_LIKELIHOOD })
581
+ ) {
582
+ events.splice(i, 1);
583
+ }
584
+ }
585
+ }
586
+
587
+ return events;
588
+ }
589
+
590
+ // ── CONFIG ──
591
+ /** @type {Config} */
592
+ const config = {
593
+ version: 2,
594
+ seed: SEED,
595
+ datasetStart: DATASET_START,
596
+ datasetEnd: DATASET_END,
597
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
598
+ numUsers: NUM_USERS,
599
+ format: "json",
600
+ gzip: true,
601
+ credentials: {
602
+ token,
603
+ },
604
+ switches: {
605
+ hasSessionIds: true,
606
+ alsoInferFunnels: false,
607
+ hasLocation: true,
608
+ hasAndroidDevices: true,
609
+ hasIOSDevices: true,
610
+ hasDesktopDevices: true,
611
+ hasBrowser: false,
612
+ hasCampaigns: false,
613
+ isAnonymous: false,
614
+ hasAdSpend: false,
615
+ hasAvatar: true,
616
+ },
617
+ identity: {
618
+ avgDevicePerUser: 2,
619
+ },
620
+ concurrency: 1,
621
+ writeToDisk: false,
622
+ scdProps: {
623
+ contributor_level: {
624
+ values: ["newcomer", "regular", "trusted", "admin"],
625
+ frequency: "month",
626
+ timing: "fuzzy",
627
+ max: 8
628
+ }
629
+ },
630
+ mirrorProps: {},
631
+ lookupTables: [],
632
+
633
+ // -- Events (18) --------------------------------------------------
634
+ events: [
635
+ {
636
+ event: "account created",
637
+ weight: 1,
638
+ isFirstEvent: true,
639
+ isAuthEvent: true,
640
+ properties: {
641
+ referral_source: ["organic", "google_search", "reddit_referral", "youtube_link", "friend_invite"],
642
+ },
643
+ },
644
+ {
645
+ event: "article viewed",
646
+ weight: 9,
647
+ isStrictEvent: false,
648
+ properties: {
649
+ wiki_id: chance.pickone.bind(chance, wikiIds),
650
+ content_hub: ["gaming", "anime", "movies", "tv", "comics", "music"],
651
+ view_count: u.weighNumRange(1, 100, 0.4, 50),
652
+ time_on_page_sec: u.weighNumRange(5, 600, 0.4, 45),
653
+ },
654
+ },
655
+ {
656
+ event: "article published",
657
+ weight: 3,
658
+ isStrictEvent: false,
659
+ properties: {
660
+ wiki_id: chance.pickone.bind(chance, wikiIds),
661
+ content_hub: ["gaming", "anime", "movies", "tv", "comics", "music"],
662
+ word_count: u.weighNumRange(200, 5000, 0.4, 1500),
663
+ has_images: [true, true, true, false],
664
+ category: ["lore", "character", "episode_guide", "review", "tutorial", "news"],
665
+ },
666
+ },
667
+ {
668
+ event: "article edited",
669
+ weight: 4,
670
+ properties: {
671
+ wiki_id: chance.pickone.bind(chance, wikiIds),
672
+ content_hub: ["gaming", "anime", "movies", "tv", "comics", "music"],
673
+ edit_type: ["content", "formatting", "grammar", "citation", "revert"],
674
+ edit_quality: u.weighNumRange(1, 5, 0.8, 3),
675
+ chars_changed: u.weighNumRange(5, 2000, 0.3, 150),
676
+ },
677
+ },
678
+ {
679
+ event: "discussion posted",
680
+ weight: 5,
681
+ isStrictEvent: false,
682
+ properties: {
683
+ community_id: chance.pickone.bind(chance, communityIds),
684
+ content_hub: ["gaming", "anime", "movies", "tv", "comics", "music"],
685
+ topic_type: ["theory", "question", "news", "review", "recommendation", "debate"],
686
+ reply_count: u.weighNumRange(0, 50, 0.3, 5),
687
+ discussion_mode: ["classic"],
688
+ },
689
+ },
690
+ {
691
+ event: "comment posted",
692
+ weight: 6,
693
+ isStrictEvent: false,
694
+ properties: {
695
+ community_id: chance.pickone.bind(chance, communityIds),
696
+ content_hub: ["gaming", "anime", "movies", "tv", "comics", "music"],
697
+ comment_length: u.weighNumRange(10, 500, 0.4, 80),
698
+ is_reply: [true, true, false],
699
+ discussion_mode: ["classic"],
700
+ },
701
+ },
702
+ {
703
+ event: "upvote given",
704
+ weight: 7,
705
+ isStrictEvent: false,
706
+ properties: {
707
+ content_type: ["article", "article", "discussion", "comment"],
708
+ content_hub: ["gaming", "anime", "movies", "tv", "comics", "music"],
709
+ upvote_count: u.weighNumRange(1, 10, 0.5, 5),
710
+ },
711
+ },
712
+ {
713
+ event: "search performed",
714
+ weight: 6,
715
+ properties: {
716
+ search_term: ["walkthrough", "character list", "ending explained", "tier list", "release date", "easter eggs", "best builds", "lore timeline", "voice actors", "soundtrack"],
717
+ results_count: u.weighNumRange(0, 50, 0.5, 12),
718
+ content_hub: ["gaming", "anime", "movies", "tv", "comics", "music"],
719
+ },
720
+ },
721
+ {
722
+ event: "wiki page created",
723
+ weight: 2,
724
+ properties: {
725
+ wiki_id: chance.pickone.bind(chance, wikiIds),
726
+ content_hub: ["gaming", "anime", "movies", "tv", "comics", "music"],
727
+ page_type: ["character", "location", "item", "episode", "concept", "organization"],
728
+ word_count: u.weighNumRange(100, 3000, 0.4, 500),
729
+ },
730
+ },
731
+ {
732
+ event: "media uploaded",
733
+ weight: 2,
734
+ properties: {
735
+ media_type: ["image", "image", "image", "gif", "video_clip", "screenshot"],
736
+ file_size_kb: u.weighNumRange(50, 5000, 0.3, 500),
737
+ content_hub: ["gaming", "anime", "movies", "tv", "comics", "music"],
738
+ },
739
+ },
740
+ {
741
+ event: "moderation action",
742
+ weight: 2,
743
+ properties: {
744
+ action_type: ["warn", "edit_revert", "content_removal", "user_mute", "spam_flag", "lock_thread"],
745
+ severity: ["low", "low", "medium", "medium", "high"],
746
+ content_hub: ["gaming", "anime", "movies", "tv", "comics", "music"],
747
+ resolution_time_hours: u.weighNumRange(0.1, 48, 0.3, 2),
748
+ },
749
+ },
750
+ {
751
+ event: "user followed",
752
+ weight: 3,
753
+ properties: {
754
+ follow_source: ["profile", "article", "discussion", "recommendation"],
755
+ },
756
+ },
757
+ {
758
+ event: "notification received",
759
+ weight: 6,
760
+ properties: {
761
+ notification_type: ["reply", "reply", "mention", "upvote", "follow", "article_update", "moderation"],
762
+ channel: ["push", "push", "email", "in_app"],
763
+ opened: [true, true, true, false],
764
+ },
765
+ },
766
+ {
767
+ event: "support ticket created",
768
+ weight: 1,
769
+ properties: {
770
+ category: ["bug_report", "content_dispute", "account_issue", "feature_request", "abuse_report", "other"],
771
+ priority: ["low", "low", "medium", "medium", "high"],
772
+ resolution_hours: u.weighNumRange(1, 96, 0.4, 24),
773
+ },
774
+ },
775
+ {
776
+ event: "profile updated",
777
+ weight: 2,
778
+ properties: {
779
+ field_updated: ["avatar", "bio", "display_name", "preferred_hub", "notification_settings", "badges"],
780
+ },
781
+ },
782
+ {
783
+ event: "app session",
784
+ weight: 8,
785
+ properties: {
786
+ session_duration_sec: u.weighNumRange(10, 3600, 0.4, 180),
787
+ pages_viewed: u.weighNumRange(1, 30, 0.5, 5),
788
+ },
789
+ },
790
+ {
791
+ event: "report submitted",
792
+ weight: 1,
793
+ properties: {
794
+ report_type: ["spam", "harassment", "misinformation", "vandalism", "copyright", "other"],
795
+ content_type: ["article", "comment", "discussion", "media"],
796
+ },
797
+ },
798
+ {
799
+ event: "account deactivated",
800
+ weight: 1,
801
+ isChurnEvent: true,
802
+ returnLikelihood: 0.15,
803
+ isStrictEvent: true,
804
+ properties: {
805
+ reason: ["lost_interest", "toxicity", "no_time", "switched_platform", "privacy_concern"],
806
+ },
807
+ },
808
+ ],
809
+
810
+ // -- Funnels (5) --------------------------------------------------
811
+ funnels: [
812
+ {
813
+ name: "Onboarding Flow",
814
+ sequence: ["account created", "search performed", "article viewed", "discussion posted"],
815
+ conversionRate: 40,
816
+ order: "sequential",
817
+ isFirstFunnel: true,
818
+ timeToConvert: 72,
819
+ weight: 3,
820
+ },
821
+ {
822
+ name: "Content Creation",
823
+ sequence: ["article viewed", "article published", "comment posted"],
824
+ conversionRate: 35,
825
+ order: "sequential",
826
+ timeToConvert: 48,
827
+ weight: 5,
828
+ },
829
+ {
830
+ name: "Engagement Loop",
831
+ sequence: ["article viewed", "upvote given", "comment posted", "discussion posted"],
832
+ conversionRate: 30,
833
+ order: "sequential",
834
+ timeToConvert: 96,
835
+ weight: 4,
836
+ reentry: true,
837
+ },
838
+ {
839
+ name: "Creator to Supporter",
840
+ sequence: ["article published", "profile updated", "notification received"],
841
+ conversionRate: 45,
842
+ order: "sequential",
843
+ timeToConvert: 168,
844
+ weight: 2,
845
+ },
846
+ {
847
+ name: "Moderation Pipeline",
848
+ sequence: ["report submitted", "moderation action"],
849
+ conversionRate: 60,
850
+ order: "sequential",
851
+ timeToConvert: 48,
852
+ weight: 2,
853
+ },
854
+ ],
855
+
856
+ // -- SuperProps ----------------------------------------------------
857
+ superProps: {
858
+ subscription_tier: ["free", "free", "free", "free", "supporter", "pro"],
859
+ Platform: ["ios", "android", "web"],
860
+ content_hub: ["gaming", "anime", "movies", "tv", "comics", "music"],
861
+ },
862
+
863
+ // -- UserProps -----------------------------------------------------
864
+ userProps: {
865
+ role: ["reader", "reader", "reader", "reader", "reader", "reader", "contributor", "contributor", "moderator", "creator"],
866
+ contributor_level: ["newcomer"],
867
+ articles_created: [0],
868
+ reputation_score: u.weighNumRange(0, 100, 0.3, 25),
869
+ preferred_hub: ["gaming", "anime", "movies", "tv", "comics", "music"],
870
+ subscription_tier: ["free", "free", "free", "free", "supporter", "pro"],
871
+ Platform: ["ios", "android", "web"],
872
+ content_hub: ["gaming", "anime", "movies", "tv", "comics", "music"],
873
+ },
874
+
875
+ // -- Phase 2: Personas --------------------------------------------
876
+ personas: [
877
+ {
878
+ name: "power_creator",
879
+ weight: 5,
880
+ eventMultiplier: 8.0,
881
+ conversionModifier: 2.0,
882
+ properties: {
883
+ role: "creator",
884
+ segment: "power_creator",
885
+ },
886
+ },
887
+ {
888
+ name: "moderator",
889
+ weight: 8,
890
+ eventMultiplier: 3.0,
891
+ conversionModifier: 1.5,
892
+ properties: {
893
+ role: "moderator",
894
+ segment: "moderator",
895
+ },
896
+ },
897
+ {
898
+ name: "active_contributor",
899
+ weight: 25,
900
+ eventMultiplier: 1.5,
901
+ conversionModifier: 1.0,
902
+ properties: {
903
+ role: "contributor",
904
+ segment: "active_contributor",
905
+ },
906
+ },
907
+ {
908
+ name: "reader",
909
+ weight: 45,
910
+ eventMultiplier: 0.3,
911
+ conversionModifier: 0.5,
912
+ properties: {
913
+ role: "reader",
914
+ segment: "reader",
915
+ },
916
+ },
917
+ {
918
+ name: "lurker",
919
+ weight: 17,
920
+ eventMultiplier: 0.1,
921
+ conversionModifier: 0.2,
922
+ properties: {
923
+ role: "reader",
924
+ segment: "lurker",
925
+ },
926
+ },
927
+ ],
928
+
929
+ // -- Phase 2: Engagement Decay ------------------------------------
930
+ engagementDecay: {
931
+ model: "linear",
932
+ halfLife: 60,
933
+ floor: 0.15,
934
+ },
935
+
936
+ hook(record, type, meta) {
937
+ if (type === "user") return handleUserHooks(record);
938
+ if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
939
+ if (type === "everything") return handleEverythingHooks(record, meta);
940
+ return record;
941
+ },
942
+ };
943
+
944
+ // ── STORIES (v1.6 machine-checkable contract — one story per numbered hook) ──
945
+ // Generate: node scripts/verify-runner.mjs dungeons/vertical/community/community.js verify-community
946
+ // Evaluate: node scripts/verify-stories.mjs dungeons/vertical/community/community.js --data-prefix verify-community
947
+ //
948
+ // Measurement doctrine for this dungeon:
949
+ // - Deletions-only logic (H6 lurker churn, H8 free-tier comment drop, H10
950
+ // over-publisher upvote drop, the silent future-time guard) means hook-time
951
+ // cohort classification is only ONE-SIDED recoverable from output counts:
952
+ // hook-time count >= output count. Cohorts below are chosen so output-side
953
+ // membership IMPLIES hook-time membership (e.g. output publishes >= 21
954
+ // proves the H3 power-creator branch fired); reverse contamination lands in
955
+ // the control arm and biases toward null.
956
+ // - Persona event multipliers (8x power creators ... 0.1x lurkers) make every
957
+ // count-per-user comparison activity-confounded BY CONSTRUCTION. Count
958
+ // assertions are activity-normalized (per app-session — untouched by all
959
+ // hooks) and, where the arms span personas with different funnel
960
+ // conversionModifiers, restricted to a single role stratum.
961
+ // - Value mutations (word_count, view_count, upvote_count, edit_quality) are
962
+ // iid per-event draws, so cross-cohort VALUE ratios are clean without
963
+ // normalization; exact integer multipliers additionally leave structural
964
+ // signatures (x3 on integers => divisible by 3).
965
+
966
+ const EV = `read_json_auto('{{PREFIX}}-EVENTS*.json', sample_size=-1, union_by_name=true)`;
967
+ const US = `read_json_auto('{{PREFIX}}-USERS*.json', sample_size=-1, union_by_name=true)`;
968
+ // identity prelude: avgDevicePerUser 2 + account created is isAuthEvent+isFirstEvent,
969
+ // so born users auth on their first event; the device-pool resolve is
970
+ // belt-and-braces for any device-only edge. ::VARCHAR casts — user_id sniffs
971
+ // as UUID, device_id as VARCHAR; DuckDB refuses to coalesce mixed types.
972
+ const ID_CTE = `
973
+ us AS (SELECT * FROM ${US}),
974
+ dm AS (SELECT unnest("anonymousIds") AS device_id, distinct_id FROM us),
975
+ ev AS (
976
+ SELECT coalesce(m.distinct_id::VARCHAR, e.user_id::VARCHAR, e.device_id::VARCHAR) AS uid,
977
+ e.time::TIMESTAMP AS t, e.*
978
+ FROM ${EV} e
979
+ LEFT JOIN dm m ON e.device_id = m.device_id
980
+ )`;
981
+
982
+ // knob-derived timestamps (dataset starts ${DATASET_START})
983
+ const DS = dayjs.utc(DATASET_START);
984
+ const TS = (d) => d.format("YYYY-MM-DD HH:mm:ss");
985
+ // H2 window bounds are EXCLUSIVE on both sides (the hook uses isAfter/isBefore)
986
+ const TREND_START_TS = TS(DS.add(TREND_START_DAY, "day"));
987
+ const TREND_END_TS = TS(DS.add(TREND_END_DAY, "day"));
988
+ // H6 clean-birth cutoff: users born within ~16d of datasetEnd lack a full
989
+ // post-cutoff observation window (10d cutoff + room for a post period)
990
+ const H6_LATEBORN_TS = TS(dayjs.utc(DATASET_END).subtract(16, "day"));
991
+ const FATIGUE_TS = TS(DS.add(ARTICLE_FATIGUE_START_DAY, "day"));
992
+
993
+ const cellsOf = (rows, key) => Object.fromEntries((rows || []).map((r) => [r[key], r]));
994
+
995
+ export const stories = [
996
+ {
997
+ id: "H1-weekend-word-count",
998
+ hook: "H1",
999
+ archetype: "temporal-inflection",
1000
+ narrative:
1001
+ `Sat/Sun 'article published' word_count is multiplied by ${WEEKEND_WORD_COUNT_MULT} (floored; draws are ` +
1002
+ "large integers so floor bias is negligible). word_count is an iid per-event draw, so the weekend/weekday " +
1003
+ "avg ratio reads the knob directly: band [1.40, 1.60]. 'wiki page created' also carries word_count but " +
1004
+ "is deliberately untouched by the hook — it is the placebo arm, and its weekend/weekday ratio must sit " +
1005
+ "at 1 within sampling noise [0.92, 1.08].",
1006
+ assertions: [
1007
+ {
1008
+ breakdown: {
1009
+ type: "duckdb",
1010
+ sql: `WITH ${ID_CTE}
1011
+ SELECT e.event || CASE WHEN dayofweek(e.t) IN (0, 6) THEN '|we' ELSE '|wd' END AS cell,
1012
+ count(DISTINCT e.uid)::BIGINT AS user_count, count(*)::BIGINT AS n_events, avg(e.word_count) AS avg_wc
1013
+ FROM ev e WHERE e.event IN ('article published', 'wiki page created')
1014
+ GROUP BY 1`,
1015
+ },
1016
+ select: {
1017
+ pwe: { where: { cell: "article published|we" } },
1018
+ pwd: { where: { cell: "article published|wd" } },
1019
+ },
1020
+ expect: { metric: "pwe.avg_wc / pwd.avg_wc", op: "between", target: [1.4, 1.6] },
1021
+ minCohort: 300,
1022
+ },
1023
+ {
1024
+ breakdown: {
1025
+ type: "duckdb",
1026
+ sql: `WITH ${ID_CTE}
1027
+ SELECT e.event || CASE WHEN dayofweek(e.t) IN (0, 6) THEN '|we' ELSE '|wd' END AS cell,
1028
+ count(DISTINCT e.uid)::BIGINT AS user_count, count(*)::BIGINT AS n_events, avg(e.word_count) AS avg_wc
1029
+ FROM ev e WHERE e.event IN ('article published', 'wiki page created')
1030
+ GROUP BY 1`,
1031
+ },
1032
+ select: {
1033
+ wwe: { where: { cell: "wiki page created|we" } },
1034
+ wwd: { where: { cell: "wiki page created|wd" } },
1035
+ },
1036
+ expect: { metric: "wwe.avg_wc / wwd.avg_wc", op: "between", target: [0.92, 1.08] },
1037
+ minCohort: 300,
1038
+ },
1039
+ ],
1040
+ },
1041
+ {
1042
+ id: "H2-trending-gaming-window",
1043
+ hook: "H2",
1044
+ archetype: "temporal-inflection",
1045
+ narrative:
1046
+ `Days ${TREND_START_DAY}-${TREND_END_DAY} (exclusive bounds — the hook uses isAfter/isBefore): users ` +
1047
+ `whose profile content_hub is 'gaming' get view_count x${TREND_VIEW_MULT} on 'article viewed'. The hook ` +
1048
+ "runs AFTER superProp stamping, so the event-level content_hub equals the profile value and selects " +
1049
+ "exactly the treated users. view_count is an iid integer draw and the multiplier is exact, so " +
1050
+ "in-window/out-of-window avg reads the knob: gaming band [1.80, 2.20], non-gaming placebo [0.92, 1.08].",
1051
+ assertions: [
1052
+ {
1053
+ breakdown: {
1054
+ type: "duckdb",
1055
+ sql: `WITH ${ID_CTE}
1056
+ SELECT CASE WHEN e.content_hub = 'gaming' THEN 'g' ELSE 'o' END ||
1057
+ CASE WHEN e.t > TIMESTAMP '${TREND_START_TS}' AND e.t < TIMESTAMP '${TREND_END_TS}' THEN 'in' ELSE 'out' END AS cell,
1058
+ count(DISTINCT e.uid)::BIGINT AS user_count, count(*)::BIGINT AS n_events, avg(e.view_count) AS avg_vc
1059
+ FROM ev e WHERE e.event = 'article viewed' GROUP BY 1`,
1060
+ },
1061
+ select: {
1062
+ gin: { where: { cell: "gin" } },
1063
+ gout: { where: { cell: "gout" } },
1064
+ },
1065
+ expect: { metric: "gin.avg_vc / gout.avg_vc", op: "between", target: [1.8, 2.2] },
1066
+ minCohort: 400,
1067
+ },
1068
+ {
1069
+ breakdown: {
1070
+ type: "duckdb",
1071
+ sql: `WITH ${ID_CTE}
1072
+ SELECT CASE WHEN e.content_hub = 'gaming' THEN 'g' ELSE 'o' END ||
1073
+ CASE WHEN e.t > TIMESTAMP '${TREND_START_TS}' AND e.t < TIMESTAMP '${TREND_END_TS}' THEN 'in' ELSE 'out' END AS cell,
1074
+ count(DISTINCT e.uid)::BIGINT AS user_count, count(*)::BIGINT AS n_events, avg(e.view_count) AS avg_vc
1075
+ FROM ev e WHERE e.event = 'article viewed' GROUP BY 1`,
1076
+ },
1077
+ select: {
1078
+ oin: { where: { cell: "oin" } },
1079
+ oout: { where: { cell: "oout" } },
1080
+ },
1081
+ expect: { metric: "oin.avg_vc / oout.avg_vc", op: "between", target: [0.92, 1.08] },
1082
+ minCohort: 400,
1083
+ },
1084
+ ],
1085
+ },
1086
+ {
1087
+ id: "H3-power-creator-upvotes",
1088
+ hook: "H3",
1089
+ archetype: "cohort-prop-scale",
1090
+ narrative:
1091
+ `Users with more than ${POWER_CREATOR_PUBLISH_THRESHOLD} 'article published' events get upvote_count ` +
1092
+ `x${POWER_CREATOR_UPVOTE_MULT} on every 'upvote given'. Publishes are never deleted (only the future ` +
1093
+ "guard trims the tail), so output publishes >= 21 IMPLIES the branch fired. upvote_count is an iid " +
1094
+ "integer draw in [1,10]; floor(3w) = 3w exactly, so the power/low value ratio reads 3.0 [2.70, 3.30] " +
1095
+ "AND every treated value is divisible by 3 (share ~1.0; H10's over-drop removes events but never " +
1096
+ "changes surviving values). The 0-1-publish placebo arm's mod-3 share is the organic pool share — " +
1097
+ "upvote_count draws uniformly from a 5-value weighNumRange pool, so the placebo share is whatever " +
1098
+ "fraction of those 5 values happens to divide by 3 (measured ~0.63); the 0.9 cap still separates it " +
1099
+ "cleanly from the exact-1.0 treated signature.",
1100
+ assertions: [
1101
+ {
1102
+ breakdown: {
1103
+ type: "duckdb",
1104
+ sql: `WITH ${ID_CTE},
1105
+ pu AS (SELECT uid, count(*) FILTER (WHERE event = 'article published') AS pubs FROM ev GROUP BY 1),
1106
+ arms AS (SELECT uid, CASE WHEN pubs > ${POWER_CREATOR_PUBLISH_THRESHOLD} THEN 'pw' WHEN pubs <= 1 THEN 'lo' END AS arm FROM pu)
1107
+ SELECT a.arm, count(DISTINCT a.uid)::BIGINT AS user_count,
1108
+ avg(e.upvote_count) AS avg_uc,
1109
+ count(*) FILTER (WHERE e.upvote_count % 3 = 0)::DOUBLE / count(*) AS mod3_share
1110
+ FROM arms a JOIN ev e ON e.uid = a.uid AND e.event = 'upvote given'
1111
+ WHERE a.arm IS NOT NULL GROUP BY 1`,
1112
+ },
1113
+ select: {
1114
+ pw: { where: { arm: "pw" } },
1115
+ lo: { where: { arm: "lo" } },
1116
+ },
1117
+ expect: { metric: "pw.avg_uc / lo.avg_uc", op: "between", target: [2.7, 3.3] },
1118
+ minCohort: 200,
1119
+ },
1120
+ {
1121
+ breakdown: {
1122
+ type: "duckdb",
1123
+ sql: `WITH ${ID_CTE},
1124
+ pu AS (SELECT uid, count(*) FILTER (WHERE event = 'article published') AS pubs FROM ev GROUP BY 1),
1125
+ arms AS (SELECT uid, CASE WHEN pubs > ${POWER_CREATOR_PUBLISH_THRESHOLD} THEN 'pw' WHEN pubs <= 1 THEN 'lo' END AS arm FROM pu)
1126
+ SELECT a.arm, count(DISTINCT a.uid)::BIGINT AS user_count,
1127
+ count(*) FILTER (WHERE e.upvote_count % 3 = 0)::DOUBLE / count(*) AS mod3_share
1128
+ FROM arms a JOIN ev e ON e.uid = a.uid AND e.event = 'upvote given'
1129
+ WHERE a.arm IS NOT NULL GROUP BY 1`,
1130
+ },
1131
+ // mod-3 structural signature needs a two-arm comparison with a
1132
+ // placebo floor — not expressible as a single-metric band
1133
+ assert: (rows) => {
1134
+ const by = cellsOf(rows, "arm");
1135
+ const p = by.pw, l = by.lo;
1136
+ if (!p || !l || Number(p.user_count) < 150 || Number(l.user_count) < 150) {
1137
+ return { verdict: "WEAK", detail: `cohort too small: power=${p?.user_count ?? 0} low=${l?.user_count ?? 0}` };
1138
+ }
1139
+ const ps = Number(p.mod3_share), ls = Number(l.mod3_share);
1140
+ const detail = `power mod-3 share ${ps.toFixed(4)} (n=${p.user_count}) vs low placebo ${ls.toFixed(4)} (n=${l.user_count})`;
1141
+ // placebo cap 0.9, not ~0.3: upvote_count's 5-value pool makes the
1142
+ // organic divisible-by-3 share pool-dependent (see narrative); the
1143
+ // signature is the treated arm's EXACT 1.0, placebo merely < 1
1144
+ if (ps >= 0.995 && ls <= 0.9) return { verdict: "NAILED", detail };
1145
+ if (ps >= 0.95 && ls <= 0.95) return { verdict: "STRONG", detail };
1146
+ return { verdict: ps > ls ? "WEAK" : "INVERSE", detail };
1147
+ },
1148
+ },
1149
+ ],
1150
+ },
1151
+ {
1152
+ id: "H4-discussion-depth",
1153
+ hook: "H4",
1154
+ archetype: "cohort-count-scale",
1155
+ narrative:
1156
+ `active_contributor users (role 'contributor' — the only persona with that role) spawn a clone for ` +
1157
+ `${DISCUSSION_CLONE_LIKELIHOOD}% of their surviving comments, an exact x1.5 in expectation that is ` +
1158
+ "MULTIPLICATIVE with H8's free-tier drop (clones run after the drop), so the effect survives the tier " +
1159
+ "mixture. Raw comments-per-user is dominated by persona event multipliers (1.5x vs 0.3x/0.1x for " +
1160
+ "readers), and per-session normalization alone is NOT enough: comments are conversion-gated funnel " +
1161
+ "steps (Content Creation step 3, Engagement Loop step 3) and contributors' funnel conversionModifier " +
1162
+ "is 1.0 vs readers' 0.5, so the raw comments-per-session DD runs ~2.1. Calibrating with 'discussion " +
1163
+ "posted' — a conversion-gated funnel step NO hook touches — over-corrects, because discussions sit " +
1164
+ "DEEPER in their funnel (Engagement Loop step 4 vs comment step 3) and deeper steps amplify the " +
1165
+ "conversionModifier gap more. The two estimators therefore bracket the knob with sign-known biases: " +
1166
+ "raw DD (no correction) is an over-estimate, discussion-corrected DD an under-estimate, and 1.5 must " +
1167
+ "sit inside [corrected, raw] — asserted as corrected in [1.10, 1.60] AND raw in [1.50, 2.80] " +
1168
+ "(NAILED). Secondary signature: clones are always is_reply=true, shifting the contributor reply " +
1169
+ "share from the organic 2/3 to (2/3 + 0.5)/1.5 = 0.778.",
1170
+ assertions: [
1171
+ {
1172
+ breakdown: {
1173
+ type: "duckdb",
1174
+ sql: `WITH ${ID_CTE},
1175
+ pr AS (SELECT distinct_id::VARCHAR AS puid, role FROM us WHERE role IN ('contributor', 'reader'))
1176
+ SELECT pr.role, count(DISTINCT pr.puid)::BIGINT AS user_count,
1177
+ count(*) FILTER (WHERE e.event = 'comment posted')::BIGINT AS comments,
1178
+ count(*) FILTER (WHERE e.event = 'app session')::BIGINT AS sessions,
1179
+ count(*) FILTER (WHERE e.event = 'discussion posted')::BIGINT AS discussions
1180
+ FROM pr JOIN ev e ON e.uid = pr.puid GROUP BY 1`,
1181
+ },
1182
+ // two-estimator bracket with sign-known biases (raw over-estimates,
1183
+ // discussion-corrected under-estimates) — beyond the one-operator grammar
1184
+ assert: (rows) => {
1185
+ const by = cellsOf(rows, "role");
1186
+ const c = by.contributor, r = by.reader;
1187
+ if (!c || !r || Number(c.user_count) < 500 || Number(r.user_count) < 500) {
1188
+ return { verdict: "WEAK", detail: `cohort too small: contributor=${c?.user_count ?? 0} reader=${r?.user_count ?? 0}` };
1189
+ }
1190
+ if (!(Number(c.sessions) > 0 && Number(r.sessions) > 0 && Number(r.comments) > 0 && Number(c.discussions) > 0 && Number(r.discussions) > 0)) {
1191
+ return { verdict: "NONE", detail: "degenerate baseline (zero sessions, comments, or discussions in an arm)" };
1192
+ }
1193
+ const ddRaw = (Number(c.comments) / Number(c.sessions)) / (Number(r.comments) / Number(r.sessions));
1194
+ const ddCal = (Number(c.discussions) / Number(c.sessions)) / (Number(r.discussions) / Number(r.sessions));
1195
+ const dd = ddRaw / ddCal;
1196
+ const detail = `bracket for knob 1.5: corrected DD=${dd.toFixed(3)} (under-estimate; discussion calib ${ddCal.toFixed(3)} over-corrects) <= 1.5 <= raw DD=${ddRaw.toFixed(3)} (over-estimate; conversionModifier composition) — contributor n=${c.user_count}, reader n=${r.user_count}`;
1197
+ if (dd >= 1.1 && dd <= 1.6 && ddRaw >= 1.5 && ddRaw <= 2.8) return { verdict: "NAILED", detail };
1198
+ if (dd >= 1.05 && ddRaw >= 1.35) return { verdict: "STRONG", detail };
1199
+ return { verdict: dd > 1.0 ? "WEAK" : "INVERSE", detail };
1200
+ },
1201
+ },
1202
+ {
1203
+ breakdown: {
1204
+ type: "duckdb",
1205
+ sql: `WITH ${ID_CTE},
1206
+ pr AS (SELECT distinct_id::VARCHAR AS puid, role FROM us WHERE role IN ('contributor', 'reader'))
1207
+ SELECT pr.role, count(DISTINCT pr.puid)::BIGINT AS user_count,
1208
+ count(*) FILTER (WHERE e.event = 'comment posted')::BIGINT AS comments,
1209
+ count(*) FILTER (WHERE e.event = 'comment posted' AND e.is_reply = true)::BIGINT AS replies
1210
+ FROM pr JOIN ev e ON e.uid = pr.puid GROUP BY 1`,
1211
+ },
1212
+ // reply-share composition: two shares with a cross-arm gap floor —
1213
+ // not a single-metric band
1214
+ assert: (rows) => {
1215
+ const by = cellsOf(rows, "role");
1216
+ const c = by.contributor, r = by.reader;
1217
+ if (!c || !r || Number(c.comments) < 500 || Number(r.comments) < 500) {
1218
+ return { verdict: "WEAK", detail: `too few comments: contributor=${c?.comments ?? 0} reader=${r?.comments ?? 0}` };
1219
+ }
1220
+ const cs = Number(c.replies) / Number(c.comments);
1221
+ const rs = Number(r.replies) / Number(r.comments);
1222
+ const detail = `is_reply share: contributor ${cs.toFixed(4)} vs reader ${rs.toFixed(4)} (expected 0.778 vs 0.667)`;
1223
+ if (cs >= 0.75 && cs <= 0.81 && rs >= 0.63 && rs <= 0.70) return { verdict: "NAILED", detail };
1224
+ if (cs >= rs + 0.05) return { verdict: "STRONG", detail };
1225
+ return { verdict: cs > rs ? "WEAK" : "INVERSE", detail };
1226
+ },
1227
+ },
1228
+ ],
1229
+ },
1230
+ {
1231
+ id: "H5-edit-war",
1232
+ hook: "H5",
1233
+ archetype: "cohort-prop-scale",
1234
+ narrative:
1235
+ `Users with more than ${EDIT_WAR_THRESHOLD} 'article edited' events get EVERY edit_quality redrawn ` +
1236
+ `U[${EDIT_WAR_QUALITY_MIN}, ${EDIT_WAR_QUALITY_MAX}] (1 decimal). Edits are never deleted, so output ` +
1237
+ "edits >= 6 IMPLIES treatment — which makes the redraw EXACT on the war arm: avg = 1.5 [1.40, 1.60] and " +
1238
+ `no surviving edit_quality above ${EDIT_WAR_QUALITY_MAX} (a zero-violation purity check). The 1-4-edit ` +
1239
+ "calm arm keeps the organic edit_quality — a 3-value weighNumRange pool whose mean is pool-dependent " +
1240
+ "(measured ~2.3), so separation is asserted as a calm-minus-war gap (>= 0.4), not an absolute calm " +
1241
+ "floor.",
1242
+ assertions: [
1243
+ {
1244
+ breakdown: {
1245
+ type: "duckdb",
1246
+ sql: `WITH ${ID_CTE},
1247
+ eu AS (SELECT uid, count(*) AS edits FROM ev WHERE event = 'article edited' GROUP BY 1),
1248
+ arms AS (SELECT uid, CASE WHEN edits > ${EDIT_WAR_THRESHOLD} THEN 'war' WHEN edits BETWEEN 1 AND 4 THEN 'calm' END AS arm FROM eu)
1249
+ SELECT a.arm, count(DISTINCT a.uid)::BIGINT AS user_count,
1250
+ avg(e.edit_quality) AS avg_q,
1251
+ count(*) FILTER (WHERE e.edit_quality > ${EDIT_WAR_QUALITY_MAX})::BIGINT AS over_cap
1252
+ FROM arms a JOIN ev e ON e.uid = a.uid AND e.event = 'article edited'
1253
+ WHERE a.arm IS NOT NULL GROUP BY 1`,
1254
+ },
1255
+ // combines an exact redraw average, a zero-violation purity count,
1256
+ // and a control-arm separation floor
1257
+ assert: (rows) => {
1258
+ const by = cellsOf(rows, "arm");
1259
+ const w = by.war, c = by.calm;
1260
+ if (!w || !c || Number(w.user_count) < 100 || Number(c.user_count) < 300) {
1261
+ return { verdict: "WEAK", detail: `cohort too small: war=${w?.user_count ?? 0} calm=${c?.user_count ?? 0}` };
1262
+ }
1263
+ const wq = Number(w.avg_q), cq = Number(c.avg_q), oc = Number(w.over_cap);
1264
+ const detail = `war avg_q=${wq.toFixed(3)} (redraw mean 1.5), over-cap violations=${oc}, calm avg_q=${cq.toFixed(3)} (war n=${w.user_count}, calm n=${c.user_count})`;
1265
+ if (oc > 0) return { verdict: "WEAK", detail: `${detail} — purity violated: one-sided recovery derivation says war-arm quality cannot exceed ${EDIT_WAR_QUALITY_MAX}` };
1266
+ // gap, not absolute calm floor: organic edit_quality is a 3-value
1267
+ // pool whose mean varies with the pool draw (see narrative)
1268
+ if (wq >= 1.4 && wq <= 1.6 && cq - wq >= 0.4) return { verdict: "NAILED", detail };
1269
+ if (wq >= 1.3 && wq <= 1.7 && cq - wq >= 0.25) return { verdict: "STRONG", detail };
1270
+ return { verdict: wq < cq ? "WEAK" : "INVERSE", detail };
1271
+ },
1272
+ },
1273
+ ],
1274
+ },
1275
+ {
1276
+ id: "H6-lurker-churn",
1277
+ hook: "H6",
1278
+ archetype: "retention-divergence",
1279
+ narrative:
1280
+ `Users with fewer than ${LURKER_EVENT_THRESHOLD} events at hook time lose ` +
1281
+ `${LURKER_DROP_LIKELIHOOD}% of events after day ${LURKER_CHURN_CUTOFF_DAYS} of their own activity ` +
1282
+ "(keep 0.4). Deletions-only recovery: output n in [2,4] implies treatment (untreated output equals " +
1283
+ "hook n >= 5), and output n in [5,8] implies NO treatment (treated hook n <= 4 can only shrink), " +
1284
+ "giving a clean control arm. n=1 users are EXCLUDED from the treated arm: a single-event user has " +
1285
+ "post=0 and days-5-10=0 by construction, so they carry no churn information while dragging both the " +
1286
+ "raw ratio and the calibrator toward 0. The raw post/pre-day-10 ratio between arms is confounded by " +
1287
+ "organic front-loading (tiny users have mechanically shorter activity spans), so the story " +
1288
+ "self-calibrates on the PRE-cutoff half-split (days 0-5 vs 5-10 — H6 never touches pre-cutoff " +
1289
+ "events): corrected DD = (rho_tiny/rho_small) / (rho_pre_tiny/rho_pre_small), and r = DD/0.4 must " +
1290
+ "land in [0.6, 1.4] (NAILED) / [0.45, 1.75] (STRONG); INVERSE if the raw ratio is not even below 1. " +
1291
+ "The control band [5,8] is ADJACENT to the treated band (not [6,10]) because calibration transfers " +
1292
+ "better between closer activity levels — the estimator is sensitive to this choice (r moved 1.9 -> " +
1293
+ "1.3 between [6,10] and [5,8] at reduced scale), which is honest evidence the residual " +
1294
+ "self-similarity assumption carries real uncertainty; the STRONG band prices that in. Both arms are " +
1295
+ "restricted to role 'reader' (the reader + lurker personas — where nearly all sub-5-event users " +
1296
+ "live) so the control arm is not polluted by low-output contributors/moderators whose funnel " +
1297
+ "conversionModifier gives them a different organic event-spacing shape. Users born within 16d of " +
1298
+ "datasetEnd are excluded (no post-cutoff observation window).",
1299
+ assertions: [
1300
+ {
1301
+ breakdown: {
1302
+ type: "duckdb",
1303
+ sql: `WITH ${ID_CTE},
1304
+ rd AS (SELECT distinct_id::VARCHAR AS puid FROM us WHERE role = 'reader'),
1305
+ n AS (SELECT e.uid, count(*) AS n, min(e.t) AS f FROM ev e JOIN rd ON rd.puid = e.uid GROUP BY 1),
1306
+ arms AS (
1307
+ SELECT uid, f, CASE WHEN n BETWEEN 2 AND 4 THEN 'tiny' WHEN n BETWEEN 5 AND 8 THEN 'small' END AS arm
1308
+ FROM n WHERE f < TIMESTAMP '${H6_LATEBORN_TS}'
1309
+ )
1310
+ SELECT a.arm, count(DISTINCT a.uid)::BIGINT AS user_count,
1311
+ count(*) FILTER (WHERE e.t <= a.f + INTERVAL ${LURKER_CHURN_CUTOFF_DAYS} DAY)::BIGINT AS pre,
1312
+ count(*) FILTER (WHERE e.t > a.f + INTERVAL ${LURKER_CHURN_CUTOFF_DAYS} DAY)::BIGINT AS post,
1313
+ count(*) FILTER (WHERE e.t <= a.f + INTERVAL ${LURKER_CHURN_CUTOFF_DAYS / 2} DAY)::BIGINT AS pre_a,
1314
+ count(*) FILTER (WHERE e.t > a.f + INTERVAL ${LURKER_CHURN_CUTOFF_DAYS / 2} DAY AND e.t <= a.f + INTERVAL ${LURKER_CHURN_CUTOFF_DAYS} DAY)::BIGINT AS pre_b
1315
+ FROM arms a JOIN ev e ON e.uid = a.uid
1316
+ WHERE a.arm IS NOT NULL GROUP BY 1`,
1317
+ },
1318
+ // self-calibrated double ratio with an INVERSE guard on the raw
1319
+ // direction — beyond the declarative grammar
1320
+ assert: (rows) => {
1321
+ const by = cellsOf(rows, "arm");
1322
+ const t = by.tiny, c = by.small;
1323
+ if (!t || !c || Number(t.user_count) < 100 || Number(c.user_count) < 100) {
1324
+ return { verdict: "WEAK", detail: `cohort too small: tiny=${t?.user_count ?? 0} small=${c?.user_count ?? 0}` };
1325
+ }
1326
+ if (!(Number(t.pre) > 0 && Number(c.pre) > 0 && Number(c.post) > 0 && Number(t.pre_a) > 0 && Number(c.pre_a) > 0 && Number(c.pre_b) > 0)) {
1327
+ return { verdict: "NONE", detail: "degenerate pooled counts (zero pre/post cell)" };
1328
+ }
1329
+ const keep = 1 - LURKER_DROP_LIKELIHOOD / 100;
1330
+ const raw = (Number(t.post) / Number(t.pre)) / (Number(c.post) / Number(c.pre));
1331
+ const calib = (Number(t.pre_b) / Number(t.pre_a)) / (Number(c.pre_b) / Number(c.pre_a));
1332
+ const corrected = raw / calib;
1333
+ const r = corrected / keep;
1334
+ const detail = `raw ratio ${raw.toFixed(4)}, pre-trajectory calib ${calib.toFixed(4)}, corrected keep ${corrected.toFixed(4)} vs knob ${keep} (r=${r.toFixed(3)}; tiny n=${t.user_count}, small n=${c.user_count})`;
1335
+ if (raw >= 1) return { verdict: "INVERSE", detail };
1336
+ if (r >= 0.6 && r <= 1.4) return { verdict: "NAILED", detail };
1337
+ if (r >= 0.45 && r <= 1.75) return { verdict: "STRONG", detail };
1338
+ return { verdict: "WEAK", detail };
1339
+ },
1340
+ },
1341
+ ],
1342
+ },
1343
+ {
1344
+ id: "H7-creator-profiles",
1345
+ hook: "H7",
1346
+ archetype: "cohort-prop-scale",
1347
+ narrative:
1348
+ "The user hook deterministically overwrites articles_created and reputation_score per role: creator " +
1349
+ "art U[50,200] rep U[80,100], moderator art U[10,50] rep U[40,70], contributor art U[1,15] rep " +
1350
+ "U[15,50], reader art 0 rep U[0,20]. Every profile hits exactly one branch and nothing else touches " +
1351
+ "these props, so the ranges are EXACT (zero violations) and the creator average sits at the uniform " +
1352
+ "midpoint 90 [88, 92] (~500 creators, se ~0.26).",
1353
+ assertions: [
1354
+ {
1355
+ breakdown: {
1356
+ type: "duckdb",
1357
+ sql: `WITH us AS (SELECT * FROM ${US})
1358
+ SELECT role, count(*)::BIGINT AS user_count,
1359
+ avg(reputation_score) AS avg_rep,
1360
+ min(reputation_score) AS min_rep, max(reputation_score) AS max_rep,
1361
+ min(articles_created) AS min_art, max(articles_created) AS max_art
1362
+ FROM us GROUP BY 1`,
1363
+ },
1364
+ // per-role exact range purity across four roles — a table of
1365
+ // zero-violation checks, not a single metric
1366
+ assert: (rows) => {
1367
+ const by = cellsOf(rows, "role");
1368
+ const RANGES = {
1369
+ creator: { rep: [80, 100], art: [50, 200], minUsers: 200 },
1370
+ moderator: { rep: [40, 70], art: [10, 50], minUsers: 300 },
1371
+ contributor: { rep: [15, 50], art: [1, 15], minUsers: 300 },
1372
+ reader: { rep: [0, 20], art: [0, 0], minUsers: 300 },
1373
+ };
1374
+ const problems = [];
1375
+ for (const [role, spec] of Object.entries(RANGES)) {
1376
+ const r = by[role];
1377
+ if (!r) { problems.push(`${role}: missing`); continue; }
1378
+ if (Number(r.user_count) < spec.minUsers) problems.push(`${role}: only ${r.user_count} users`);
1379
+ if (Number(r.min_rep) < spec.rep[0] || Number(r.max_rep) > spec.rep[1]) {
1380
+ problems.push(`${role}: rep [${r.min_rep}, ${r.max_rep}] outside [${spec.rep}]`);
1381
+ }
1382
+ if (Number(r.min_art) < spec.art[0] || Number(r.max_art) > spec.art[1]) {
1383
+ problems.push(`${role}: articles [${r.min_art}, ${r.max_art}] outside [${spec.art}]`);
1384
+ }
1385
+ }
1386
+ const cAvg = Number(by.creator?.avg_rep ?? 0);
1387
+ const detail = problems.length
1388
+ ? `range violations: ${problems.join("; ")}`
1389
+ : `all four role ranges exact; creator avg rep ${cAvg.toFixed(2)} (n=${by.creator.user_count})`;
1390
+ if (problems.length) return { verdict: "WEAK", detail };
1391
+ if (cAvg >= 88 && cAvg <= 92) return { verdict: "NAILED", detail };
1392
+ if (cAvg >= 85 && cAvg <= 95) return { verdict: "STRONG", detail };
1393
+ return { verdict: "WEAK", detail };
1394
+ },
1395
+ },
1396
+ ],
1397
+ },
1398
+ {
1399
+ id: "H8-pro-content-lift",
1400
+ hook: "H8",
1401
+ archetype: "funnel-conversion-by-segment",
1402
+ narrative:
1403
+ `Non-pro/supporter users drop ${PRO_LIFT_FREE_DROP_LIKELIHOOD}% of ALL 'comment posted' events. ` +
1404
+ "Mechanism read: comments-per-app-session free/paid = the exact keep rate 0.35 [0.30, 0.40] — H4's " +
1405
+ "clone factor applies to active contributors in BOTH tiers (tier is independent of persona) and " +
1406
+ "cancels in the pooled ratio. Mixpanel-visible read: the emulator's Content Creation published→comment " +
1407
+ "step conversion, paid/free, at the 60h window (48h generative x 1.25 H9 free stretch so the free arm " +
1408
+ "is not right-censored). The conversion lift is NONLINEAR in the keep rate (P(>=1 surviving comment in " +
1409
+ "window)); bracketing the per-window comment density over [0.5, 3] gives paid/free in [1.35, 2.60].",
1410
+ assertions: [
1411
+ {
1412
+ breakdown: {
1413
+ type: "duckdb",
1414
+ sql: `WITH ${ID_CTE}
1415
+ SELECT CASE WHEN e.subscription_tier IN ('pro', 'supporter') THEN 'paid' ELSE 'fr' END AS arm,
1416
+ count(DISTINCT e.uid)::BIGINT AS user_count,
1417
+ count(*) FILTER (WHERE e.event = 'comment posted')::DOUBLE
1418
+ / nullif(count(*) FILTER (WHERE e.event = 'app session'), 0) AS cps
1419
+ FROM ev e GROUP BY 1`,
1420
+ },
1421
+ select: {
1422
+ fr: { where: { arm: "fr" } },
1423
+ paid: { where: { arm: "paid" } },
1424
+ },
1425
+ expect: { metric: "fr.cps / paid.cps", op: "between", target: [0.30, 0.40] },
1426
+ minCohort: 1000,
1427
+ },
1428
+ {
1429
+ breakdown: {
1430
+ type: "timeToConvert",
1431
+ steps: ["article viewed", "article published", "comment posted"],
1432
+ breakdownByUserProperty: "subscription_tier",
1433
+ conversionWindowMs: Math.round(48 * TTC_FREE_FACTOR * 3600 * 1000),
1434
+ },
1435
+ // pools pro+supporter step_counts and takes a published→comment
1436
+ // step-conversion double ratio — beyond a single-metric band
1437
+ assert: (rows) => {
1438
+ const by = cellsOf(rows, "segment_value");
1439
+ const pooled = (names) => {
1440
+ const cs = names.map((n) => by[n]).filter(Boolean);
1441
+ const pub = cs.reduce((s, c) => s + (c.step_counts?.[1] ?? 0), 0);
1442
+ const com = cs.reduce((s, c) => s + (c.step_counts?.[2] ?? 0), 0);
1443
+ return pub > 0 ? { rate: com / pub, pub } : null;
1444
+ };
1445
+ const paid = pooled(["pro", "supporter"]);
1446
+ const free = pooled(["free"]);
1447
+ if (!paid || !free) return { verdict: "NONE", detail: "missing tier segments in emulator rows" };
1448
+ if (paid.pub < 300 || free.pub < 300) {
1449
+ return { verdict: "WEAK", detail: `too few published-step entries: paid=${paid.pub} free=${free.pub}` };
1450
+ }
1451
+ const r = paid.rate / free.rate;
1452
+ const detail = `published→comment step conversion paid ${paid.rate.toFixed(4)} vs free ${free.rate.toFixed(4)} (ratio ${r.toFixed(3)}; entries ${paid.pub}/${free.pub})`;
1453
+ if (r >= 1.35 && r <= 2.6) return { verdict: "NAILED", detail };
1454
+ if (r >= 1.2) return { verdict: "STRONG", detail };
1455
+ return { verdict: r > 1 ? "WEAK" : "INVERSE", detail };
1456
+ },
1457
+ },
1458
+ ],
1459
+ },
1460
+ {
1461
+ id: "H9-content-ttc",
1462
+ hook: "H9",
1463
+ archetype: "funnel-ttc-by-segment",
1464
+ narrative:
1465
+ `funnel-post scales Content Creation inter-step gaps by tier: pro/supporter x${TTC_PRO_FACTOR}, free ` +
1466
+ `x${TTC_FREE_FACTOR} (v1.6: scoped to Content Creation only). Cross-event SQL cannot see this (greedy ` +
1467
+ "single-pass pairing — the documented limitation), so the assertion goes through the emulator's " +
1468
+ `timeToConvert at 48h x ${TTC_FREE_FACTOR} = 60h, covering the stretched support so the free arm is ` +
1469
+ "not right-censored. There is NO untouched tier, so the primary read is the cross ratio pro/free " +
1470
+ "(knob distance 0.77/1.25 = 0.62, masked asymmetrically by organic cross-instance pairings — " +
1471
+ "compression survives ~45-85% of its log distance, stretch only ~15-50%, per the fitness/dating " +
1472
+ "measurements — giving [0.65, 0.92]); the consistency read is supporter/pro, identically scaled " +
1473
+ "tiers whose ratio must sit at 1 [0.85, 1.15].",
1474
+ assertions: [
1475
+ {
1476
+ breakdown: {
1477
+ type: "timeToConvert",
1478
+ steps: ["article viewed", "article published", "comment posted"],
1479
+ breakdownByUserProperty: "subscription_tier",
1480
+ conversionWindowMs: Math.round(48 * TTC_FREE_FACTOR * 3600 * 1000),
1481
+ },
1482
+ select: {
1483
+ pr: { where: { segment_value: "pro" } },
1484
+ fr: { where: { segment_value: "free" } },
1485
+ },
1486
+ expect: { metric: "pr.median_ttc_ms / fr.median_ttc_ms", op: "between", target: [0.65, 0.92] },
1487
+ // pro and supporter are each 1/6 of the tier draw (~1,667 of 10K
1488
+ // users); ~250 pro converters expected at full fidelity, and a
1489
+ // median over 200+ is statistically solid
1490
+ minCohort: 200,
1491
+ },
1492
+ {
1493
+ breakdown: {
1494
+ type: "timeToConvert",
1495
+ steps: ["article viewed", "article published", "comment posted"],
1496
+ breakdownByUserProperty: "subscription_tier",
1497
+ conversionWindowMs: Math.round(48 * TTC_FREE_FACTOR * 3600 * 1000),
1498
+ },
1499
+ select: {
1500
+ sup: { where: { segment_value: "supporter" } },
1501
+ pr: { where: { segment_value: "pro" } },
1502
+ },
1503
+ expect: { metric: "sup.median_ttc_ms / pr.median_ttc_ms", op: "between", target: [0.85, 1.15] },
1504
+ minCohort: 200,
1505
+ },
1506
+ ],
1507
+ },
1508
+ {
1509
+ id: "H10-article-magic-number",
1510
+ hook: "H10",
1511
+ archetype: "frequency-sweet-spot",
1512
+ narrative:
1513
+ `Sweet-spot publishers (${ARTICLE_SWEET_MIN}-${ARTICLE_SWEET_MAX} articles at hook time) get ` +
1514
+ `upvote_count x${ARTICLE_UPVOTE_BOOST} (rounded); over-publishers (${ARTICLE_OVER_THRESHOLD}+) lose ` +
1515
+ `${ARTICLE_UPVOTE_DROP_LIKELIHOOD}% of 'upvote given' events from day ${ARTICLE_FATIGUE_START_DAY} ` +
1516
+ "(creator burnout — the drop is calendar-scoped BY DESIGN, see the knob comment). The VALUE read is " +
1517
+ "clean across personas (iid draw): sweet/low avg upvote_count reads ~1.35 with integer-rounding " +
1518
+ "drift [1.25, 1.50]. The VOLUME read cannot be a cross-arm level comparison: within contributors, " +
1519
+ "publish count is intrinsically coupled to activity, and the organic upvote share of events varies " +
1520
+ "23-58% across publish bands no matter the denominator (sessions scale sublinearly, discussions " +
1521
+ "carry a once-per-user Onboarding component, activity-band matching leaves different event mixes). " +
1522
+ "Instead the story runs a difference-in-differences at the day-60 edge: each arm's own after/before " +
1523
+ "upvotes-per-session ratio cancels its activity composition (measured arm-invariant to ~0.1% on " +
1524
+ "untreated data — over 1.1137 vs sweet 1.1126), so DiD = (over after/before) / (sweet after/before) " +
1525
+ `reads the ${1 - ARTICLE_UPVOTE_DROP_LIKELIHOOD / 100} keep rate: [0.50, 0.70] NAILED, [0.42, 0.80] ` +
1526
+ "STRONG. Arms are role 'contributor' only; sessions are untouched by every hook; H3's x3 (at 21+ " +
1527
+ "publishes) changes values, never counts, so it cannot touch the volume read.",
1528
+ assertions: [
1529
+ {
1530
+ breakdown: {
1531
+ type: "duckdb",
1532
+ sql: `WITH ${ID_CTE},
1533
+ pu AS (SELECT uid, count(*) FILTER (WHERE event = 'article published') AS pubs FROM ev GROUP BY 1),
1534
+ arms AS (SELECT uid, CASE WHEN pubs BETWEEN ${ARTICLE_SWEET_MIN} AND ${ARTICLE_SWEET_MAX} THEN 'sw' WHEN pubs <= 1 THEN 'lo' END AS arm FROM pu)
1535
+ SELECT a.arm, count(DISTINCT a.uid)::BIGINT AS user_count, avg(e.upvote_count) AS avg_uc
1536
+ FROM arms a JOIN ev e ON e.uid = a.uid AND e.event = 'upvote given'
1537
+ WHERE a.arm IS NOT NULL GROUP BY 1`,
1538
+ },
1539
+ select: {
1540
+ sw: { where: { arm: "sw" } },
1541
+ lo: { where: { arm: "lo" } },
1542
+ },
1543
+ expect: { metric: "sw.avg_uc / lo.avg_uc", op: "between", target: [1.25, 1.5] },
1544
+ minCohort: 300,
1545
+ },
1546
+ {
1547
+ breakdown: {
1548
+ type: "duckdb",
1549
+ sql: `WITH ${ID_CTE},
1550
+ pr AS (SELECT distinct_id::VARCHAR AS puid FROM us WHERE role = 'contributor'),
1551
+ pu AS (SELECT uid, count(*) FILTER (WHERE event = 'article published') AS pubs FROM ev GROUP BY 1),
1552
+ arms AS (
1553
+ SELECT pu.uid, CASE WHEN pubs BETWEEN ${ARTICLE_SWEET_MIN} AND ${ARTICLE_SWEET_MAX} THEN 'sw'
1554
+ WHEN pubs >= ${ARTICLE_OVER_THRESHOLD} THEN 'ov' END AS arm
1555
+ FROM pu JOIN pr ON pr.puid = pu.uid
1556
+ )
1557
+ SELECT a.arm || '_' || CASE WHEN e.t >= TIMESTAMP '${FATIGUE_TS}' THEN 'after' ELSE 'before' END AS cell,
1558
+ count(DISTINCT a.uid)::BIGINT AS user_count,
1559
+ count(*) FILTER (WHERE e.event = 'upvote given')::BIGINT AS ups,
1560
+ count(*) FILTER (WHERE e.event = 'app session')::BIGINT AS sessions
1561
+ FROM arms a JOIN ev e ON e.uid = a.uid
1562
+ WHERE a.arm IS NOT NULL GROUP BY 1`,
1563
+ },
1564
+ // difference-in-differences across the day-60 fatigue edge — a
1565
+ // four-cell double ratio the one-operator grammar can't express
1566
+ assert: (rows) => {
1567
+ const by = cellsOf(rows, "cell");
1568
+ const ob = by.ov_before, oa = by.ov_after, sb = by.sw_before, sa = by.sw_after;
1569
+ const cells = { ov_before: ob, ov_after: oa, sw_before: sb, sw_after: sa };
1570
+ for (const [name, c] of Object.entries(cells)) {
1571
+ if (!c || Number(c.ups) < 300 || Number(c.sessions) < 200) {
1572
+ return { verdict: "WEAK", detail: `cell ${name} too small: ups=${c?.ups ?? 0} sessions=${c?.sessions ?? 0} (needs 300/200)` };
1573
+ }
1574
+ }
1575
+ const keep = 1 - ARTICLE_UPVOTE_DROP_LIKELIHOOD / 100;
1576
+ const rOv = (Number(oa.ups) / Number(oa.sessions)) / (Number(ob.ups) / Number(ob.sessions));
1577
+ const rSw = (Number(sa.ups) / Number(sa.sessions)) / (Number(sb.ups) / Number(sb.sessions));
1578
+ const did = rOv / rSw;
1579
+ const detail = `upvotes-per-session after/before: over ${rOv.toFixed(4)}, sweet ${rSw.toFixed(4)}, DiD=${did.toFixed(4)} vs keep ${keep} (over n=${ob.user_count}/${oa.user_count}, sweet n=${sb.user_count}/${sa.user_count})`;
1580
+ if (did >= 0.5 && did <= 0.7) return { verdict: "NAILED", detail };
1581
+ if (did >= 0.42 && did <= 0.8) return { verdict: "STRONG", detail };
1582
+ return { verdict: did < 1 ? "WEAK" : "INVERSE", detail };
1583
+ },
1584
+ },
1585
+ ],
1586
+ },
1587
+ ];
1588
+
1589
+ export default config;