@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,1456 @@
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: Chirp
13
+ * APP: Twitter+Instagram-style social media platform with algorithmic
14
+ * feed, creator monetization, communities, and direct messaging.
15
+ * Power users become "creators" with subscriber tiers; native ads
16
+ * woven into feed and story placements.
17
+ * SCALE: 10,000 users, ~1.4M events, 121 days (2026-01-01 → 2026-05-01)
18
+ * CORE LOOP: account created → profile updated → post created → consume + engage
19
+ *
20
+ * EVENTS (18):
21
+ * post viewed (30) > post liked (18) > story viewed (15) > post created (12)
22
+ * > notification received (12) > comment posted (10) > ad viewed (10)
23
+ * > user followed (8) > dm sent (8) > search performed (7) > post shared (6)
24
+ * > story created (5) > profile updated (3) > user unfollowed (2)
25
+ * > ad clicked (2) > creator subscription started (2) > report submitted (1)
26
+ * > account created (1)
27
+ *
28
+ * FUNNELS (8):
29
+ * - Onboarding: account created → profile updated → post created (70%)
30
+ * - Feed Engagement: post viewed → post liked → comment posted (45%)
31
+ * - Content Cycle: post created → post viewed → post liked → post shared (30%)
32
+ * - Stories: story created → story viewed → dm sent (40%)
33
+ * - Discovery: search performed → post viewed → user followed (35%)
34
+ * - Notifications: notification received → post viewed → post liked (50%)
35
+ * - Creator Monetize: profile updated → creator subscription started → post created (15%)
36
+ * - Ad Moderation: ad viewed → ad clicked → report submitted (20%)
37
+ *
38
+ * USER PROPS: app_version, account_type, follower_count, following_count,
39
+ * bio_length, verified, content_niche
40
+ * SUPER PROPS: app_version, account_type
41
+ * SCD PROPS: account_type (personal/creator/business/verified, monthly fuzzy, max 6),
42
+ * community_status (new/growing/established/featured, monthly fixed,
43
+ * max 6, community_id-scoped)
44
+ * GROUPS: community_id (100 communities)
45
+ */
46
+
47
+ // ── HOOK STORIES ──
48
+ /*
49
+ * NOTE: All cohort effects are HIDDEN — discoverable only via behavioral cohorts
50
+ * (count an event per user, then measure downstream). No cohort flag is stamped
51
+ * on events. Algorithm-change source flips and engagement-bait short durations
52
+ * are raw mutations of config-defined props.
53
+ *
54
+ * -------------------------------------------------------------------------------------
55
+ * 1. VIRAL CONTENT CASCADE (everything)
56
+ * -------------------------------------------------------------------------------------
57
+ *
58
+ * PATTERN: Among users with 10+ pre-injection "post created" events, 5%
59
+ * become viral creators. Each of their posts triggers 60-120 cloned
60
+ * post-viewed, 60-120 post-liked, AND 60-120 post-shared events with
61
+ * offset timestamps (VIRAL_CLONES_MIN/MAX knobs).
62
+ *
63
+ * MEASUREMENT NOTE: the old "cohort A = 10+ post created" read is dead.
64
+ * At 1.2 events/user/day over 121 days the organic median is ~10 posts,
65
+ * and clone injections (H2/H6/H8) push ~85% of users past 10 final posts
66
+ * — binning on 10+ measures activity, not virality. The analyst-visible
67
+ * signal is CONCENTRATION: viral creators are ~3% of users but hold the
68
+ * bulk of all engagement events.
69
+ *
70
+ * HOW TO FIND IT IN MIXPANEL:
71
+ *
72
+ * Report 1: Engagement Concentration
73
+ * - Report type: Insights
74
+ * - Events: "post viewed" + "post liked" + "post shared"
75
+ * - Measure: Total per user, sorted descending
76
+ * - Expected: top 3% of users hold ~55-68% of all engagement events
77
+ * (measured 62% at reduced scale; organic counterfactual: 5.7%).
78
+ * Viral creators show 500+ engagement events vs a ~45 median.
79
+ *
80
+ * REAL-WORLD ANALOGUE: A small share of creators drive a disproportionate
81
+ * fraction of all platform engagement.
82
+ *
83
+ * -------------------------------------------------------------------------------------
84
+ * 2. FOLLOW-BACK SNOWBALL (everything)
85
+ * -------------------------------------------------------------------------------------
86
+ *
87
+ * PATTERN: Users with 5+ "user followed" events have 50% of their posts
88
+ * duplicated with a 30-240 minute offset, plus an extra comment cloned.
89
+ * No flag — discover by binning users on user-followed count.
90
+ *
91
+ * HOW TO FIND IT IN MIXPANEL:
92
+ *
93
+ * Report 1: Posts per User by Follow Activity
94
+ * - Report type: Insights (with cohort)
95
+ * - Cohort A: users with >= 5 "user followed"
96
+ * - Cohort B: users with < 5 "user followed"
97
+ * - Event: "post created"
98
+ * - Measure: Total per user
99
+ * - Expected: A ~ 1.5x posts per user vs B (measured 1.50 at reduced
100
+ * scale)
101
+ *
102
+ * MEASUREMENT NOTE: the 1.5x cohort ratio decomposes as ~1.24x activity
103
+ * confound (high-follow users are simply more active — organic
104
+ * counterfactual ratio) x ~1.21x true hook increment. The hook's +50%
105
+ * applies to pre-injection posts only, so H6's +2-clones-per-post
106
+ * stacking dilutes the incremental share for subscriber users. The
107
+ * headline number still lands on ~1.5x because both factors compound.
108
+ *
109
+ * REAL-WORLD ANALOGUE: Users who actively follow many people tend to
110
+ * receive follow-backs and post more frequently.
111
+ *
112
+ * -------------------------------------------------------------------------------------
113
+ * 3. ALGORITHM CHANGE (everything)
114
+ * -------------------------------------------------------------------------------------
115
+ *
116
+ * PATTERN: On day 45 (2026-02-15), the dominant `source` for "post viewed"
117
+ * flips: before day 45, 70% of views are forced to "feed"; after, 70% are
118
+ * forced to "explore". Mutates an existing config-defined prop — no flag.
119
+ *
120
+ * INTERPLAY: H5 runs AFTER this flip and steals 30% of all post-day-30
121
+ * views for source="notification", so the post-day-45 explore share tops
122
+ * out near ~54%, not 70%. Three visible regimes (measured, reduced scale):
123
+ * - pre-day-30: feed 78%, notification 3%, explore 6%
124
+ * - day 30-45: feed 54%, notification 32%
125
+ * - post-day-45: explore 54%, notification 32%, feed 5%
126
+ *
127
+ * HOW TO FIND IT IN MIXPANEL:
128
+ *
129
+ * Report 1: Source Distribution Over Time
130
+ * - Report type: Insights
131
+ * - Event: "post viewed"
132
+ * - Measure: Total
133
+ * - Breakdown: "source"
134
+ * - Line chart by day
135
+ * - Expected: feed dominates pre-day-45 (~78% before day 30, ~54%
136
+ * day 30-45); explore dominates post-day-45 (~54%); feed collapses
137
+ * to ~5% after the flip
138
+ *
139
+ * REAL-WORLD ANALOGUE: Algorithmic feed redesigns shift content discovery
140
+ * from chronological to interest-based.
141
+ *
142
+ * -------------------------------------------------------------------------------------
143
+ * 4. ENGAGEMENT BAIT (event)
144
+ * -------------------------------------------------------------------------------------
145
+ *
146
+ * PATTERN: 20% of "post viewed" events get view_duration_sec collapsed
147
+ * to 1-5 seconds. No flag — analyst sees a bimodal duration distribution.
148
+ * Runs after all injection passes, so cloned views (H1/H6) are crushed at
149
+ * the same rate.
150
+ *
151
+ * HOW TO FIND IT IN MIXPANEL:
152
+ *
153
+ * Report 1: View Duration Distribution
154
+ * - Report type: Insights
155
+ * - Event: "post viewed"
156
+ * - Measure: Distribution of "view_duration_sec"
157
+ * - Expected: ~ 20% of values cluster at 1-5 sec; rest at 5+ sec
158
+ * (measured 20.4% at reduced scale; the organic generator floors
159
+ * durations above 5s, so the low mode is 100% hook-made)
160
+ *
161
+ * REAL-WORLD ANALOGUE: Clickbait posts collect impressions but bounce
162
+ * quality is awful, dragging down avg watch time.
163
+ *
164
+ * -------------------------------------------------------------------------------------
165
+ * 5. NOTIFICATION RE-ENGAGEMENT (everything)
166
+ * -------------------------------------------------------------------------------------
167
+ *
168
+ * PATTERN: After day 30 (2026-01-31), 30% of "post viewed" events have
169
+ * source flipped to "notification". Runs after H3, so it wins on the
170
+ * overlap. Mutates the existing config-defined `source` prop.
171
+ *
172
+ * HOW TO FIND IT IN MIXPANEL:
173
+ *
174
+ * Report 1: Notification-Sourced Views Over Time
175
+ * - Report type: Insights
176
+ * - Event: "post viewed"
177
+ * - Measure: Total
178
+ * - Filter: source = "notification"
179
+ * - Line chart by day
180
+ * - Expected: ~3% before day 30 (organic 20% share diluted by H3's
181
+ * 70% feed-force and by H1/H6 clones that never stamp notification),
182
+ * then ~32% after (measured 3.3% -> 32.2% at reduced scale, ~10x)
183
+ *
184
+ * REAL-WORLD ANALOGUE: Push notifications about trending content are a
185
+ * primary lever for waking up dormant users.
186
+ *
187
+ * -------------------------------------------------------------------------------------
188
+ * 6. CREATOR MONETIZATION (everything)
189
+ * -------------------------------------------------------------------------------------
190
+ *
191
+ * PATTERN: Users with any "creator subscription started" event get 2 extra
192
+ * cloned posts and stories per original (3x rate), plus 25% extra cloned
193
+ * post-viewed events stamped `source="profile"`. No flag — discover via
194
+ * cohort builder.
195
+ *
196
+ * SCALE NOTE: "creator subscription started" is a weight-2 event over 121
197
+ * days, so ~78% of users have at least one — the cohort is an engagement
198
+ * marker, not an elite-creator flag. The 3x contrast still reads cleanly
199
+ * because the clone multiplier applies per-post. The `source="profile"`
200
+ * stamp is mostly overwritten by H3/H5 (which run later): only ~21% of
201
+ * the stamps survive, showing as a mild profile-share elevation
202
+ * (subscribers ~4.4% vs non ~2.3% of views), not a dominant source.
203
+ *
204
+ * HOW TO FIND IT IN MIXPANEL:
205
+ *
206
+ * Report 1: Posts per User — Subscribers vs Not
207
+ * - Report type: Insights (with cohort)
208
+ * - Cohort A: users with >= 1 "creator subscription started"
209
+ * - Cohort B: users with 0
210
+ * - Event: "post created"
211
+ * - Measure: Total per user
212
+ * - Expected: A ~ 3.3x posts per user (3x knob compounded with a mild
213
+ * activity confound; measured 3.32 at reduced scale)
214
+ *
215
+ * Report 2: Stories per User — Subscribers vs Not
216
+ * - Same cohorts, event "story created"
217
+ * - Expected: A ~ 3.8x stories per user — a purer read than posts:
218
+ * H2's snowball dupes lift non-subscriber POSTS (denominator) but
219
+ * never stories, so the story contrast runs higher (measured 3.82)
220
+ *
221
+ * REAL-WORLD ANALOGUE: Creators with paying subscribers publish more often.
222
+ *
223
+ * -------------------------------------------------------------------------------------
224
+ * 7. TOXICITY CHURN (everything)
225
+ * -------------------------------------------------------------------------------------
226
+ *
227
+ * PATTERN: Users with 2+ "report submitted" events lose 60% of ALL
228
+ * activity after day 30 (2026-01-31) — each post-cutoff event dropped at
229
+ * 60% likelihood, including injected clones and the reports themselves.
230
+ * No flag — discover via retention or per-user activity drop.
231
+ *
232
+ * MEASUREMENT NOTE: compare each cohort's post-day-30 / pre-day-30 event
233
+ * ratio, not absolute counts — the dataset is growth-shaped (born-in
234
+ * users make post/pre ~3.2x for everyone), and reporters are heavy users
235
+ * whose absolute counts stay high even after the drop. Measured (reduced
236
+ * scale): reporters 1.27 post/pre vs normal 3.21 — a 0.40x relative
237
+ * contrast, matching the 60% drop knob. The organic counterfactual
238
+ * confirms the paired per-user drop is 0.42x exactly.
239
+ *
240
+ * HOW TO FIND IT IN MIXPANEL:
241
+ *
242
+ * Report 1: Retention by Toxicity
243
+ * - Report type: Retention (or Insights: post/pre day-30 event ratio)
244
+ * - Cohort A: users with >= 2 "report submitted"
245
+ * - Cohort B: rest
246
+ * - Expected: A's post-day-30 activity ~ 0.4x of B's, relative to
247
+ * each cohort's own pre-day-30 baseline
248
+ *
249
+ * REAL-WORLD ANALOGUE: Repeated reporters are signaling dissatisfaction
250
+ * and often quietly churn.
251
+ *
252
+ * -------------------------------------------------------------------------------------
253
+ * 8. WEEKEND CONTENT SURGE (everything)
254
+ * -------------------------------------------------------------------------------------
255
+ *
256
+ * PATTERN: 30% of Sat/Sun "post created" and "story created" events get a
257
+ * duplicate cloned 1-3 hours later. No flag — discover via day-of-week chart.
258
+ *
259
+ * BASELINE NOTE: the config's soup dayOfWeekWeights are all >= 1.0, which
260
+ * TimeSoup treats as accept-always (see lib/utils/utils.js TimeSoup phase
261
+ * 2) — the organic DOW distribution is FLAT (measured 0.98 weekend/weekday
262
+ * on the organic counterfactual). The 1.25x hooked ratio is therefore
263
+ * pure hook signal, slightly under the 1.3 knob because late-Sunday dupes
264
+ * (+1-3h) can roll into Monday and H7 drops some weekend clones.
265
+ *
266
+ * HOW TO FIND IT IN MIXPANEL:
267
+ *
268
+ * Report 1: Posts by Day of Week
269
+ * - Report type: Insights
270
+ * - Events: "post created" + "story created"
271
+ * - Measure: Total
272
+ * - Breakdown: Day of week
273
+ * - Expected: Sat/Sun ~ 1.25x weekday bars (measured 1.25 at reduced
274
+ * scale)
275
+ *
276
+ * REAL-WORLD ANALOGUE: Weekend leisure time produces a natural creation surge.
277
+ *
278
+ * -------------------------------------------------------------------------------------
279
+ * 9. POST-CREATED MAGIC NUMBER (everything)
280
+ * -------------------------------------------------------------------------------------
281
+ *
282
+ * PATTERN: Users in the 3-7 pre-injection post-created sweet spot get
283
+ * +40% comment_length on their comment-posted events (richer engagement).
284
+ * Users with 8+ pre-injection posts get comment_length x0.7 (burnout —
285
+ * shorter, lazier comments). NO events are dropped; the effect is purely
286
+ * on the comment_length property. No flag — discover by binning users on
287
+ * post count.
288
+ *
289
+ * MEASUREMENT NOTE: the hook buckets on PRE-injection post counts;
290
+ * analysts see POST-injection counts inflated by H2/H6/H8 clones. With an
291
+ * organic median of ~10 posts, 73% of users are hook-"over" (burnout is
292
+ * the norm), and clone inflation pushes most boosted sweet-organic users
293
+ * into the 8+ final bucket, diluting it. The contrast survives because
294
+ * the final 3-7 bucket is dominated by boosted users who dodged clone
295
+ * inflation (non-subscribers). Paired-counterfactual per-user lifts are
296
+ * exact: low 1.00, sweet 1.40, over 0.68.
297
+ *
298
+ * HOW TO FIND IT IN MIXPANEL:
299
+ *
300
+ * Report 1: Comment Length by Post Bucket
301
+ * - Report type: Insights (with cohort)
302
+ * - Cohort A: users with 3-7 "post created" (final counts)
303
+ * - Cohort C: users with >= 8 "post created"
304
+ * - Event: "comment posted"
305
+ * - Measure: Average of "comment_length"
306
+ * - Expected: A ~ 1.7x C (measured 250 vs 145 at reduced scale)
307
+ *
308
+ * Report 2: Bucket Ordering
309
+ * - Add Cohort B: users with 0-2 "post created" (small population —
310
+ * unmutated users with almost no comments)
311
+ * - Expected ordering: A (sweet, ~250) > B (low, ~200) > C (over, ~145)
312
+ *
313
+ * REAL-WORLD ANALOGUE: Moderate posters write thoughtful comments; the
314
+ * over-prolific burn out and leave short, low-effort replies.
315
+ *
316
+ * -------------------------------------------------------------------------------------
317
+ * 10. ONBOARDING TIME-TO-CONVERT (funnel-post)
318
+ * -------------------------------------------------------------------------------------
319
+ *
320
+ * PATTERN: Creator and business account_type users complete funnels
321
+ * 1.4x faster (gap factor 0.71); personal users 1.25x slower (factor
322
+ * 1.25). The hook intercepts EVERY funnel-post array (all 8 funnels, not
323
+ * just onboarding), computes the gap between consecutive steps, and
324
+ * scales each gap by the account-type factor before rewriting the step
325
+ * timestamps. The onboarding funnel (account created -> profile updated
326
+ * -> post created) is where the effect is cleanly measurable: "account
327
+ * created" is unique per user (isFirstEvent), so the funnel evaluation
328
+ * anchors on the scaled instance instead of racing organic standalones.
329
+ *
330
+ * MEASURED (reduced scale, emulateBreakdown timeToConvert, stable across
331
+ * 1h-24h windows): personal median ~23min, creator/business ~16min —
332
+ * fast/personal = 0.71. Per-leg attenuation vs the organic counterfactual
333
+ * (baseline ratio 0.96, medians ~20min): fast leg 0.71 knob -> 0.81
334
+ * realized, slow leg 1.25 -> 1.11; greedy step-matching occasionally
335
+ * substitutes organic standalone events for scaled ones, pulling both
336
+ * legs toward 1 (same mechanism as sass H9, milder here thanks to the
337
+ * unique first-step anchor).
338
+ *
339
+ * HOW TO FIND IT IN MIXPANEL:
340
+ *
341
+ * Report 1: Onboarding Funnel Median TTC by Account Type
342
+ * - Report type: Funnels
343
+ * - Steps: "account created" -> "profile updated" -> "post created"
344
+ * - Measure: Median time to convert
345
+ * - Breakdown: "account_type" (superProp)
346
+ * - Expected: creator/business median ~ 0.71x of personal
347
+ *
348
+ * NOTE (funnel-post measurement): visible via Mixpanel funnel median
349
+ * TTC. Cross-event MIN->MIN SQL queries on raw events do NOT show this.
350
+ *
351
+ * REAL-WORLD ANALOGUE: Creators and businesses arrive with clear intent
352
+ * and complete profile setup faster; personal users browse casually and
353
+ * take longer to commit to their first post.
354
+ *
355
+ * =====================================================================================
356
+ * EXPECTED METRICS SUMMARY (measured at reduced scale, 2K users; see stories
357
+ * export below for the NAILED/STRONG bands asserted at full fidelity)
358
+ * =====================================================================================
359
+ *
360
+ * Hook | Analyst-visible metric | Measured
361
+ * --------------------------|-----------------------------------|------------------
362
+ * H1 Viral Content Cascade | top-3% share of engagement events | 0.62 (organic 0.06)
363
+ * H2 Follow-Back Snowball | posts/user, 5+ follows vs rest | 1.50x
364
+ * H3 Algorithm Change | post-d45 explore share | 0.54 (pre-d30 feed 0.78)
365
+ * H4 Engagement Bait | share of views <= 5 sec | 0.204 (organic 0.00)
366
+ * H5 Notification Re-engage | source=notification share | 0.033 pre-d30 -> 0.322 post
367
+ * H6 Creator Monetization | posts/user sub vs non | 3.32x (stories 3.82x)
368
+ * H7 Toxicity Churn | post/pre-d30 ratio, rep vs norm | 1.27 vs 3.21 (0.40x)
369
+ * H8 Weekend Surge | weekend/weekday daily creations | 1.25x (organic 0.98)
370
+ * H9 Magic Number | comment_length sweet vs over | 1.72x (250 vs 145)
371
+ * H10 Onboarding TTC | funnel median TTC fast/personal | 0.71 (organic 0.96)
372
+ *
373
+ * MEASUREMENT CAVEATS:
374
+ * - Paired organic counterfactual (hook: r => r, same seed) gives exact
375
+ * per-user baselines: H9 lifts land on the knobs (1.00/1.40/0.68), H7's
376
+ * drop is 0.42x paired, and the engagement-concentration jump (0.06 ->
377
+ * 0.62) is entirely injection-made.
378
+ * - Post-count cohorts use FINAL counts (analyst view). Clone injections
379
+ * inflate these: 85% of users clear 10 final posts, 73% are hook-"over".
380
+ * - H10 is invisible to cross-event MIN->MIN SQL; use funnel median TTC
381
+ * (emulateBreakdown timeToConvert) as in the stories export.
382
+ * =====================================================================================
383
+ */
384
+
385
+ // ── SCALE ──
386
+ const SEED = "harness-social";
387
+ const NUM_USERS = 10_000;
388
+ const DATASET_START = "2026-01-01T00:00:00Z";
389
+ const DATASET_END = "2026-05-01T23:59:59Z";
390
+ const EVENTS_PER_DAY = 1.2;
391
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
392
+
393
+ const chance = u.initChance(SEED);
394
+
395
+ // ── KNOBS (tweak these to reshape stories) ──
396
+ const VIRAL_POST_THRESHOLD = 10;
397
+ const VIRAL_USER_LIKELIHOOD = 5;
398
+ const VIRAL_CLONES_MIN = 60;
399
+ const VIRAL_CLONES_MAX = 120;
400
+
401
+ const FOLLOW_SNOWBALL_THRESHOLD = 5;
402
+ const FOLLOW_SNOWBALL_LIKELIHOOD = 50;
403
+
404
+ const ALGORITHM_CHANGE_DAY = 45;
405
+ const ALGORITHM_FLIP_LIKELIHOOD = 70;
406
+
407
+ const ENGAGEMENT_BAIT_LIKELIHOOD = 20;
408
+
409
+ const REENGAGEMENT_START_DAY = 30;
410
+ const REENGAGEMENT_LIKELIHOOD = 30;
411
+
412
+ const CREATOR_POST_CLONES = 2;
413
+ const CREATOR_STORY_CLONES = 2;
414
+ const CREATOR_VIEW_CLONE_LIKELIHOOD = 25;
415
+
416
+ const TOXICITY_THRESHOLD = 2;
417
+ const TOXICITY_CUTOFF_DAYS = 30;
418
+ const TOXICITY_DROP_LIKELIHOOD = 60;
419
+
420
+ const WEEKEND_CLONE_LIKELIHOOD = 30;
421
+
422
+ const POST_SWEET_MIN = 3;
423
+ const POST_SWEET_MAX = 7;
424
+ const POST_OVER_THRESHOLD = 8;
425
+ const POST_SWEET_COMMENT_BOOST = 1.4;
426
+ const POST_OVER_COMMENT_FACTOR = 0.7;
427
+
428
+ const ONBOARDING_TTC_FAST = 0.71;
429
+ const ONBOARDING_TTC_SLOW = 1.25;
430
+
431
+ // ── DATA ARRAYS ──
432
+ // Generate consistent post IDs for lookup tables
433
+ const postIds = v.range(1, 1001).map(n => `post_${v.uid(8)}`);
434
+
435
+ // ── HELPER FUNCTIONS ──
436
+ function handleFunnelPostHooks(record, meta) {
437
+ // H10: ONBOARDING TIME-TO-CONVERT — creator/business 0.71x faster,
438
+ // personal 1.25x slower. Scales inter-step gaps via timestamp rewrite.
439
+ const segment = meta?.profile?.account_type;
440
+ if (Array.isArray(record) && record.length > 1) {
441
+ const factor = (
442
+ segment === "creator" || segment === "business" ? ONBOARDING_TTC_FAST :
443
+ segment === "personal" ? ONBOARDING_TTC_SLOW :
444
+ 1.0
445
+ );
446
+ if (factor !== 1.0) {
447
+ for (let i = 1; i < record.length; i++) {
448
+ const prev = dayjs(record[i - 1].time);
449
+ const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
450
+ record[i].time = prev.add(newGap, "milliseconds").toISOString();
451
+ }
452
+ }
453
+ }
454
+ return record;
455
+ }
456
+
457
+ function handleEverythingHooks(record, meta) {
458
+ const datasetStart = dayjs.unix(meta.datasetStart);
459
+ const userEvents = record;
460
+ if (!userEvents || userEvents.length === 0) return record;
461
+
462
+ // Stamp superProps from profile for consistency
463
+ const profile = meta.profile;
464
+ userEvents.forEach(e => {
465
+ e.app_version = profile.app_version;
466
+ e.account_type = profile.account_type;
467
+ });
468
+
469
+ // First pass: identify behavioral patterns (no flags written)
470
+ let postCreatedCount = 0;
471
+ let followReceivedCount = 0;
472
+ let reportSubmittedCount = 0;
473
+ let hasCreatorSubscription = false;
474
+ let isViralCreator = false;
475
+
476
+ userEvents.forEach((event) => {
477
+ if (event.event === "post created") postCreatedCount++;
478
+ if (event.event === "user followed") followReceivedCount++;
479
+ if (event.event === "report submitted") reportSubmittedCount++;
480
+ if (event.event === "creator subscription started") hasCreatorSubscription = true;
481
+ });
482
+
483
+ if (postCreatedCount >= VIRAL_POST_THRESHOLD && chance.bool({ likelihood: VIRAL_USER_LIKELIHOOD })) {
484
+ isViralCreator = true;
485
+ }
486
+
487
+ // Second pass: inject cloned events (no behavioral cohort flags)
488
+ for (let idx = userEvents.length - 1; idx >= 0; idx--) {
489
+ const event = userEvents[idx];
490
+ const eventTime = dayjs(event.time);
491
+
492
+ // H1: VIRAL CONTENT CASCADE — clone 60-120 view/like/share per post.
493
+ // Discovery: bin users by post-created count, observe per-user view/like/share volume.
494
+ if (isViralCreator && event.event === "post created") {
495
+ const viralViews = chance.integer({ min: VIRAL_CLONES_MIN, max: VIRAL_CLONES_MAX });
496
+ const viralLikes = chance.integer({ min: VIRAL_CLONES_MIN, max: VIRAL_CLONES_MAX });
497
+ const viralShares = chance.integer({ min: VIRAL_CLONES_MIN, max: VIRAL_CLONES_MAX });
498
+ const injected = [];
499
+
500
+ // Clone only from a real same-type template. The old
501
+ // `...(viewTemplate || event)` fallback would bleed post-created
502
+ // props (character_count, has_media, hashtag_count) onto
503
+ // view/like/share records — undeclared columns on those event
504
+ // types (schema rule 1). Viral users virtually always carry all
505
+ // three templates, so skipping is behavior-neutral in practice.
506
+ const viewTemplate = userEvents.find(e => e.event === "post viewed");
507
+ const likeTemplate = userEvents.find(e => e.event === "post liked");
508
+ const shareTemplate = userEvents.find(e => e.event === "post shared");
509
+
510
+ if (viewTemplate) for (let i = 0; i < viralViews; i++) {
511
+ injected.push({
512
+ ...viewTemplate,
513
+ event: "post viewed",
514
+ time: eventTime.add(chance.integer({ min: 1, max: 180 }), 'minutes').toISOString(),
515
+ user_id: event.user_id,
516
+ post_type: event.post_type || "text",
517
+ source: chance.pickone(["feed", "explore", "search"]),
518
+ view_duration_sec: chance.integer({ min: 5, max: 90 }),
519
+ });
520
+ }
521
+ if (likeTemplate) for (let i = 0; i < viralLikes; i++) {
522
+ injected.push({
523
+ ...likeTemplate,
524
+ event: "post liked",
525
+ time: eventTime.add(chance.integer({ min: 2, max: 240 }), 'minutes').toISOString(),
526
+ user_id: event.user_id,
527
+ post_type: event.post_type || "text",
528
+ });
529
+ }
530
+ if (shareTemplate) for (let i = 0; i < viralShares; i++) {
531
+ injected.push({
532
+ ...shareTemplate,
533
+ event: "post shared",
534
+ time: eventTime.add(chance.integer({ min: 5, max: 300 }), 'minutes').toISOString(),
535
+ user_id: event.user_id,
536
+ share_destination: chance.pickone(["repost", "dm", "external", "copy_link"]),
537
+ });
538
+ }
539
+
540
+ userEvents.splice(idx + 1, 0, ...injected);
541
+ }
542
+
543
+ // H2: FOLLOW-BACK SNOWBALL — extra post + comment per user-followed cluster.
544
+ // Discovery: cohort users with >=5 user-followed events, compare posts/user.
545
+ if (followReceivedCount >= FOLLOW_SNOWBALL_THRESHOLD && event.event === "post created") {
546
+ if (chance.bool({ likelihood: FOLLOW_SNOWBALL_LIKELIHOOD })) {
547
+ const commentTemplate = userEvents.find(e => e.event === "comment posted");
548
+ const duplicatePost = {
549
+ ...event,
550
+ time: eventTime.add(chance.integer({ min: 30, max: 240 }), 'minutes').toISOString(),
551
+ user_id: event.user_id,
552
+ post_type: chance.pickone(["text", "image", "video"]),
553
+ character_count: chance.integer({ min: 10, max: 280 }),
554
+ has_media: chance.bool({ likelihood: 60 }),
555
+ hashtag_count: chance.integer({ min: 0, max: 5 }),
556
+ };
557
+ const extraComment = {
558
+ ...(commentTemplate || event),
559
+ event: "comment posted",
560
+ time: eventTime.add(chance.integer({ min: 10, max: 120 }), 'minutes').toISOString(),
561
+ user_id: event.user_id,
562
+ comment_length: chance.integer({ min: 5, max: 200 }),
563
+ has_mention: chance.bool({ likelihood: 40 }),
564
+ };
565
+ userEvents.splice(idx + 1, 0, duplicatePost, extraComment);
566
+ }
567
+ }
568
+
569
+ // H6: CREATOR MONETIZATION — 3x post/story rate for subscribers.
570
+ // Discovery: cohort users with creator-subscription-started event, compare posts/user.
571
+ if (hasCreatorSubscription && event.event === "post created") {
572
+ for (let i = 0; i < CREATOR_POST_CLONES; i++) {
573
+ const extraPost = {
574
+ ...event,
575
+ time: eventTime.add(chance.integer({ min: 1, max: 12 }), 'hours').toISOString(),
576
+ user_id: event.user_id,
577
+ post_type: chance.pickone(["text", "image", "video", "link"]),
578
+ character_count: chance.integer({ min: 20, max: 280 }),
579
+ has_media: chance.bool({ likelihood: 70 }),
580
+ hashtag_count: chance.integer({ min: 1, max: 8 }),
581
+ };
582
+ userEvents.splice(idx + 1, 0, extraPost);
583
+ }
584
+ }
585
+ if (hasCreatorSubscription && event.event === "story created") {
586
+ for (let i = 0; i < CREATOR_STORY_CLONES; i++) {
587
+ const extraStory = {
588
+ ...event,
589
+ time: eventTime.add(chance.integer({ min: 1, max: 8 }), 'hours').toISOString(),
590
+ user_id: event.user_id,
591
+ story_type: chance.pickone(["photo", "video", "text"]),
592
+ has_filter: chance.bool({ likelihood: 60 }),
593
+ has_sticker: chance.bool({ likelihood: 40 }),
594
+ };
595
+ userEvents.splice(idx + 1, 0, extraStory);
596
+ }
597
+ }
598
+ if (hasCreatorSubscription && event.event === "post viewed") {
599
+ if (chance.bool({ likelihood: CREATOR_VIEW_CLONE_LIKELIHOOD })) {
600
+ const analyticsView = {
601
+ ...event,
602
+ time: eventTime.add(chance.integer({ min: 1, max: 30 }), 'minutes').toISOString(),
603
+ user_id: event.user_id,
604
+ post_type: event.post_type || "text",
605
+ source: "profile",
606
+ view_duration_sec: chance.integer({ min: 10, max: 60 }),
607
+ };
608
+ userEvents.splice(idx + 1, 0, analyticsView);
609
+ }
610
+ }
611
+ }
612
+
613
+ // H8: WEEKEND CONTENT SURGE — duplicate weekend posts/stories with offset.
614
+ // Discovery: line chart by day-of-week shows Sat/Sun bump.
615
+ for (let idx = userEvents.length - 1; idx >= 0; idx--) {
616
+ const event = userEvents[idx];
617
+ if (event.event === "post created" || event.event === "story created") {
618
+ const dow = new Date(event.time).getUTCDay();
619
+ if ((dow === 0 || dow === 6) && chance.bool({ likelihood: WEEKEND_CLONE_LIKELIHOOD })) {
620
+ const etime = dayjs(event.time);
621
+ const dup = {
622
+ ...event,
623
+ time: etime.add(chance.integer({ min: 1, max: 3 }), 'hours').toISOString(),
624
+ };
625
+ userEvents.splice(idx + 1, 0, dup);
626
+ }
627
+ }
628
+ }
629
+
630
+ // H3: ALGORITHM CHANGE — day 45 flips feed → explore on post viewed.
631
+ // H4: ENGAGEMENT BAIT — 20% of post-viewed events get crushed duration.
632
+ // H5: NOTIFICATION RE-ENGAGEMENT — after day 30, 30% of views → notification.
633
+ // All three run AFTER injection passes so they apply to cloned events too.
634
+ const algorithmChangeDay = datasetStart.add(ALGORITHM_CHANGE_DAY, 'days');
635
+ const reengagementStart = datasetStart.add(REENGAGEMENT_START_DAY, 'days');
636
+ userEvents.forEach(e => {
637
+ if (e.event === "post viewed") {
638
+ const eventTime = dayjs(e.time);
639
+
640
+ // H3: Algorithm Change
641
+ if (eventTime.isAfter(algorithmChangeDay)) {
642
+ if (chance.bool({ likelihood: ALGORITHM_FLIP_LIKELIHOOD })) {
643
+ e.source = "explore";
644
+ }
645
+ } else {
646
+ if (chance.bool({ likelihood: ALGORITHM_FLIP_LIKELIHOOD })) {
647
+ e.source = "feed";
648
+ }
649
+ }
650
+
651
+ // H4: Engagement Bait — 20% crushed view duration
652
+ if (chance.bool({ likelihood: ENGAGEMENT_BAIT_LIKELIHOOD })) {
653
+ e.view_duration_sec = chance.integer({ min: 1, max: 5 });
654
+ }
655
+
656
+ // H5: Notification Re-engagement (runs after #3 so can override)
657
+ if (eventTime.isAfter(reengagementStart) && chance.bool({ likelihood: REENGAGEMENT_LIKELIHOOD })) {
658
+ e.source = "notification";
659
+ }
660
+ }
661
+ });
662
+
663
+ // H7: TOXICITY CHURN — drop 60% of activity after day 30 for high reporters.
664
+ // Discovery: cohort users with >=2 report-submitted events, observe retention drop.
665
+ if (reportSubmittedCount >= TOXICITY_THRESHOLD) {
666
+ const churnCutoff = datasetStart.add(TOXICITY_CUTOFF_DAYS, 'days');
667
+ for (let i = userEvents.length - 1; i >= 0; i--) {
668
+ const evt = userEvents[i];
669
+ if (dayjs(evt.time).isAfter(churnCutoff) && chance.bool({ likelihood: TOXICITY_DROP_LIKELIHOOD })) {
670
+ userEvents.splice(i, 1);
671
+ }
672
+ }
673
+ }
674
+
675
+ // H9: POST-CREATED MAGIC NUMBER (no flags)
676
+ // Sweet 3-7 posts → +40% on comment_length on the user's comment events.
677
+ // Over 8+ → -30% comment_length (engagement burnout).
678
+ if (postCreatedCount >= POST_SWEET_MIN && postCreatedCount <= POST_SWEET_MAX) {
679
+ userEvents.forEach(e => {
680
+ if (e.event === 'comment posted' && typeof e.comment_length === 'number') {
681
+ e.comment_length = Math.round(e.comment_length * POST_SWEET_COMMENT_BOOST);
682
+ }
683
+ });
684
+ } else if (postCreatedCount >= POST_OVER_THRESHOLD) {
685
+ userEvents.forEach(e => {
686
+ if (e.event === 'comment posted' && typeof e.comment_length === 'number') {
687
+ e.comment_length = Math.round(e.comment_length * POST_OVER_COMMENT_FACTOR);
688
+ }
689
+ });
690
+ }
691
+
692
+ return record;
693
+ }
694
+
695
+ // ── CONFIG ──
696
+ /** @type {Config} */
697
+ const config = {
698
+ version: 2,
699
+ seed: SEED,
700
+ datasetStart: DATASET_START,
701
+ datasetEnd: DATASET_END,
702
+ // All weights >= 1.0 are accept-always in TimeSoup's rejection sampler →
703
+ // flat organic DOW. Load-bearing: removing this key re-activates
704
+ // DEFAULT_DOW_WEIGHTS (weekend-suppressed), which would fight H8.
705
+ soup: { dayOfWeekWeights: [1.0, 1.0, 1.0, 1.0, 1.0, 1.2, 1.2] },
706
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
707
+ numUsers: NUM_USERS,
708
+ format: "json",
709
+ gzip: true,
710
+ credentials: {
711
+ token,
712
+ },
713
+ switches: {
714
+ hasSessionIds: true,
715
+ alsoInferFunnels: false,
716
+ hasLocation: true,
717
+ hasAndroidDevices: true,
718
+ hasIOSDevices: true,
719
+ hasDesktopDevices: true,
720
+ hasBrowser: false,
721
+ hasCampaigns: false,
722
+ isAnonymous: false,
723
+ hasAdSpend: false,
724
+ hasAvatar: true,
725
+ },
726
+ identity: {
727
+ avgDevicePerUser: 2,
728
+ },
729
+ concurrency: 1,
730
+ writeToDisk: false,
731
+ scdProps: {
732
+ account_type: {
733
+ values: ["personal", "creator", "business", "verified"],
734
+ frequency: "month",
735
+ timing: "fuzzy",
736
+ max: 6
737
+ },
738
+ community_status: {
739
+ values: ["new", "growing", "established", "featured"],
740
+ frequency: "month",
741
+ timing: "fixed",
742
+ max: 6,
743
+ type: "community_id"
744
+ }
745
+ },
746
+
747
+ funnels: [
748
+ {
749
+ sequence: ["account created", "profile updated", "post created"],
750
+ isFirstFunnel: true,
751
+ conversionRate: 70,
752
+ timeToConvert: 0.5,
753
+ },
754
+ {
755
+ // Feed consumption: view → like → comment (most common loop)
756
+ sequence: ["post viewed", "post liked", "comment posted"],
757
+ conversionRate: 45,
758
+ timeToConvert: 0.5,
759
+ weight: 6,
760
+ },
761
+ {
762
+ // Content creation cycle: create → views → engagement
763
+ sequence: ["post created", "post viewed", "post liked", "post shared"],
764
+ conversionRate: 30,
765
+ timeToConvert: 3,
766
+ weight: 3,
767
+ },
768
+ {
769
+ // Stories engagement
770
+ sequence: ["story created", "story viewed", "dm sent"],
771
+ conversionRate: 40,
772
+ timeToConvert: 1,
773
+ weight: 3,
774
+ },
775
+ {
776
+ // Discovery and follow loop
777
+ sequence: ["search performed", "post viewed", "user followed"],
778
+ conversionRate: 35,
779
+ timeToConvert: 1,
780
+ weight: 2,
781
+ },
782
+ {
783
+ // Notifications driving re-engagement
784
+ sequence: ["notification received", "post viewed", "post liked"],
785
+ conversionRate: 50,
786
+ timeToConvert: 0.5,
787
+ weight: 2,
788
+ },
789
+ {
790
+ // Profile management and creator monetization
791
+ sequence: ["profile updated", "creator subscription started", "post created"],
792
+ conversionRate: 15,
793
+ timeToConvert: 24,
794
+ weight: 1,
795
+ },
796
+ {
797
+ // Ad interaction and moderation
798
+ sequence: ["ad viewed", "ad clicked", "report submitted"],
799
+ conversionRate: 20,
800
+ timeToConvert: 2,
801
+ weight: 1,
802
+ },
803
+ ],
804
+
805
+ events: [
806
+ {
807
+ event: "account created",
808
+ weight: 1,
809
+ isFirstEvent: true,
810
+ isAuthEvent: true,
811
+ properties: {
812
+ "signup_method": ["email", "google", "apple", "sso"],
813
+ "referred_by": ["organic", "friend", "ad", "influencer"],
814
+ }
815
+ },
816
+ {
817
+ event: "post created",
818
+ weight: 12,
819
+ isStrictEvent: false,
820
+ properties: {
821
+ "post_type": ["text", "image", "video", "poll", "link"],
822
+ "character_count": u.weighNumRange(1, 280),
823
+ "has_media": [false, false, false, true, true],
824
+ "hashtag_count": u.weighNumRange(0, 10, 0.5),
825
+ }
826
+ },
827
+ {
828
+ event: "post viewed",
829
+ weight: 30,
830
+ isStrictEvent: false,
831
+ properties: {
832
+ "post_type": ["text", "image", "video", "poll", "link"],
833
+ "view_duration_sec": u.weighNumRange(1, 120, 0.3, 5),
834
+ "source": ["feed", "explore", "search", "profile", "notification"],
835
+ }
836
+ },
837
+ {
838
+ event: "post liked",
839
+ weight: 18,
840
+ isStrictEvent: false,
841
+ properties: {
842
+ "post_type": ["text", "image", "video", "poll", "link"],
843
+ }
844
+ },
845
+ {
846
+ event: "post shared",
847
+ weight: 6,
848
+ isStrictEvent: false,
849
+ properties: {
850
+ "share_destination": ["repost", "dm", "external", "copy_link"],
851
+ }
852
+ },
853
+ {
854
+ event: "comment posted",
855
+ weight: 10,
856
+ isStrictEvent: false,
857
+ properties: {
858
+ "comment_length": u.weighNumRange(1, 500, 0.3, 20),
859
+ "has_mention": [true, false, false],
860
+ }
861
+ },
862
+ {
863
+ event: "user followed",
864
+ weight: 8,
865
+ isStrictEvent: false,
866
+ properties: {
867
+ "discovery_source": ["suggested", "search", "post", "profile", "mutual"],
868
+ }
869
+ },
870
+ {
871
+ event: "user unfollowed",
872
+ weight: 2,
873
+ properties: {
874
+ "reason": ["content_quality", "too_frequent", "lost_interest", "offensive"],
875
+ }
876
+ },
877
+ {
878
+ event: "story viewed",
879
+ weight: 15,
880
+ isStrictEvent: false,
881
+ properties: {
882
+ "story_type": ["photo", "video", "text"],
883
+ "view_duration_sec": u.weighNumRange(1, 30, 0.5, 5),
884
+ "completed": [false, false, true, true, true],
885
+ }
886
+ },
887
+ {
888
+ event: "story created",
889
+ weight: 5,
890
+ isStrictEvent: false,
891
+ properties: {
892
+ "story_type": ["photo", "video", "text"],
893
+ "has_filter": [true, false],
894
+ "has_sticker": [false, false, true],
895
+ }
896
+ },
897
+ {
898
+ event: "search performed",
899
+ weight: 7,
900
+ properties: {
901
+ "search_type": ["users", "hashtags", "posts"],
902
+ "results_count": u.weighNumRange(0, 50, 0.5, 10),
903
+ }
904
+ },
905
+ {
906
+ event: "notification received",
907
+ weight: 12,
908
+ properties: {
909
+ "notification_type": ["like", "follow", "comment", "mention", "trending"],
910
+ "clicked": [false, false, false, true, true],
911
+ }
912
+ },
913
+ {
914
+ event: "dm sent",
915
+ weight: 8,
916
+ isStrictEvent: false,
917
+ properties: {
918
+ "message_type": ["text", "image", "voice", "link"],
919
+ "conversation_length": u.weighNumRange(1, 100),
920
+ }
921
+ },
922
+ {
923
+ event: "ad viewed",
924
+ weight: 10,
925
+ properties: {
926
+ "ad_format": ["feed_native", "story", "banner", "video"],
927
+ "ad_category": ["retail", "tech", "food", "finance", "entertainment"],
928
+ "view_duration_sec": u.weighNumRange(1, 30, 0.3),
929
+ }
930
+ },
931
+ {
932
+ event: "ad clicked",
933
+ weight: 2,
934
+ properties: {
935
+ "ad_format": ["feed_native", "story", "banner", "video"],
936
+ "ad_category": ["retail", "tech", "food", "finance", "entertainment"],
937
+ }
938
+ },
939
+ {
940
+ event: "report submitted",
941
+ weight: 1,
942
+ properties: {
943
+ "report_type": ["spam", "harassment", "misinformation", "hate_speech", "other"],
944
+ "content_type": ["post", "comment", "user", "dm"],
945
+ }
946
+ },
947
+ {
948
+ event: "profile updated",
949
+ weight: 3,
950
+ properties: {
951
+ "field_updated": ["bio", "avatar", "display_name", "privacy_settings", "interests"],
952
+ }
953
+ },
954
+ {
955
+ event: "creator subscription started",
956
+ weight: 2,
957
+ properties: {
958
+ "tier": ["basic", "premium", "vip"],
959
+ "price_usd": [4.99, 9.99, 19.99],
960
+ }
961
+ },
962
+ ],
963
+
964
+ superProps: {
965
+ app_version: ["4.0", "4.1", "4.2", "4.3", "5.0"],
966
+ account_type: ["personal", "creator", "business"],
967
+ },
968
+
969
+ userProps: {
970
+ app_version: ["4.0", "4.1", "4.2", "4.3", "5.0"],
971
+ account_type: ["personal", "creator", "business"],
972
+ "follower_count": u.weighNumRange(0, 10000, 0.2, 50),
973
+ "following_count": u.weighNumRange(0, 5000, 0.3, 100),
974
+ "bio_length": u.weighNumRange(0, 160),
975
+ "verified": [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true],
976
+ "content_niche": ["lifestyle", "tech", "food", "fitness", "travel", "comedy", "news", "art"],
977
+ },
978
+
979
+ groupKeys: [
980
+ ["community_id", 100, ["post created", "comment posted", "post liked", "post shared"]],
981
+ ],
982
+
983
+ groupProps: {
984
+ community_id: {
985
+ "name": () => `${chance.word()} ${chance.pickone(["Hub", "Circle", "Squad", "Zone", "Space"])}`,
986
+ "member_count": u.weighNumRange(50, 5000, 0.3, 200),
987
+ "category": ["technology", "entertainment", "sports", "politics", "art", "science"],
988
+ "is_moderated": [false, false, false, true, true, true, true, true, true, true],
989
+ }
990
+ },
991
+
992
+ lookupTables: [],
993
+
994
+ hook(record, type, meta) {
995
+ if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
996
+ if (type === "everything") return handleEverythingHooks(record, meta);
997
+ return record;
998
+ }
999
+ };
1000
+
1001
+ export default config;
1002
+
1003
+ // ── STORIES (verification contract — consumed by social.verify.mjs) ──
1004
+ /*
1005
+ * DERIVATION NOTES (all numbers measured at 2K reduced scale, seed
1006
+ * harness-social, plus an exact organic counterfactual run — same seed,
1007
+ * identity hook — used to separate engineered effects from the organic
1008
+ * activity curve):
1009
+ *
1010
+ * - H1: top-3% of users hold 0.623 of all post viewed/liked/shared
1011
+ * events (organic counterfactual: 0.057). 59 users show 500+
1012
+ * engagement events vs a 45 median among 10+ posters. The old
1013
+ * "10+ post created cohort" read is dead: 85% of users clear 10
1014
+ * final posts (organic median ~10, clones inflate further).
1015
+ * - H2: big-follower (5+ user followed) posts/user 42.30 vs 28.19 =
1016
+ * 1.50x. Decomposition via counterfactual: 1.24x activity confound
1017
+ * x 1.21x true hook increment (H6's +2-clones-per-post dilutes
1018
+ * H2's +50%-of-organic-posts for subscriber users).
1019
+ * - H3+H5 regimes (post viewed source shares): pre-d30 feed 0.779,
1020
+ * notification 0.033; day 30-45 feed 0.545, notification 0.320;
1021
+ * post-d45 explore 0.539, notification 0.322, feed 0.055.
1022
+ * - H4: share of views <= 5 sec = 0.204 (organic 0.000 — generator
1023
+ * floors organic durations above 5s; the low mode is 100% hook-made).
1024
+ * - H6: subscribers = 78% of users (weight-2 event over 121 days).
1025
+ * posts/user sub vs non 35.34/10.63 = 3.32; stories/user 32.32/8.47
1026
+ * = 3.82 (purer read: H2 dupes lift non-sub posts but not stories).
1027
+ * Paired lifts: sub posts 3.07x, stories 3.00x; non posts 1.24x.
1028
+ * - H7: post/pre-day-30 event ratio reporters (2+ final reports) 1.27
1029
+ * vs normal 3.21 → 0.395 contrast. Paired per-user drop 0.419
1030
+ * (60% knob). Growth shape makes the raw post/pre ~3.2 for everyone.
1031
+ * - H8: weekend/weekday daily creations hooked 1.2545, organic 0.9802
1032
+ * (soup weights >= 1.0 are accept-always → flat DOW; the old "soup
1033
+ * dampens weekends ~0.55x" claim in the v1.5 verifier was wrong).
1034
+ * - H9 (final-count buckets — analyst view): sweet(3-7) avg
1035
+ * comment_length 250.09 (162 users), low(0-2) 200.28 (33 users),
1036
+ * over(8+) 145.43 (1768 users) → sweet/over 1.72, ordering
1037
+ * sweet > low > over. Paired per-user lifts land on the knobs:
1038
+ * low 0.998, sweet 1.397, over 0.681.
1039
+ * - H10 (emulateBreakdown timeToConvert @6h, stable 1h-24h): personal
1040
+ * median 22.9min (n=64), creator 16.3 (51), business 16.2 (47) →
1041
+ * fast/personal 0.709. Organic counterfactual: 0.963 baseline ratio,
1042
+ * medians ~20min. Per-leg attenuation: 0.71 knob → 0.81 realized,
1043
+ * 1.25 → 1.11 (greedy step-matching, unique first-step anchor).
1044
+ * - Identity: uid share 1.0 (auth on first event), device share
1045
+ * 0.9989, devices/user 2.07 (avgDevicePerUser: 2).
1046
+ *
1047
+ * Scale guards sit at ~50% of expected 10K populations, so 2K runs trip
1048
+ * WEAK by design; verdicts ship only from full-fidelity runs.
1049
+ *
1050
+ * Fix-round Q5 (2026-07-04, adversarial-review S1): NAILED bands
1051
+ * re-derived as knob ±10% wherever the knob converts directly to the
1052
+ * metric (H6 3.0x clone lifts, H9 sweet/over 1.4/0.7 → 2.00 + margin
1053
+ * knobs, H10 TTC 0.71/1.25 → 0.568; H4/H7/dpu tightened to ±10%).
1054
+ * Where the realized magnitude is confounded (H1 concentration share,
1055
+ * H5 pre-d30 clone dilution), the assertion is a knob-bounded
1056
+ * floor/ceiling that grades STRONG by design. The measured numbers
1057
+ * above remain as documentation of expected full-fidelity readings —
1058
+ * they are no longer verdict anchors.
1059
+ */
1060
+
1061
+ const EV = `read_json_auto('{{PREFIX}}-EVENTS*.json', sample_size=-1, union_by_name=true)`;
1062
+
1063
+ const bandVerdict = (x, nailed, strong, detail, inverse = () => false) => {
1064
+ if (x == null || Number.isNaN(Number(x))) return { verdict: "NONE", detail: `${detail} — metric missing` };
1065
+ const v = Number(x);
1066
+ if (inverse(v)) return { verdict: "INVERSE", detail };
1067
+ if (v >= nailed[0] && v <= nailed[1]) return { verdict: "NAILED", detail };
1068
+ if (v >= strong[0] && v <= strong[1]) return { verdict: "STRONG", detail };
1069
+ return { verdict: "WEAK", detail };
1070
+ };
1071
+ const guarded = (ok, detail, inner) => ok ? inner() : { verdict: "WEAK", detail: `${detail} — cohort below scale guard (expected at reduced scale)` };
1072
+ const worstOf = (...verdicts) => { const order = ["INVERSE", "NONE", "WEAK", "STRONG", "NAILED"]; return order.find(o => verdicts.some(v => v.verdict === o)) || "NONE"; };
1073
+ const cellsOf = (rows, key) => Object.fromEntries((rows || []).map(r => [r[key], r]));
1074
+
1075
+ const DAY30 = `TIMESTAMP '2026-01-31 00:00:00'`;
1076
+ const DAY45 = `TIMESTAMP '2026-02-15 00:00:00'`;
1077
+
1078
+ const SOURCE_WINDOWS_SQL = `SELECT
1079
+ CASE WHEN time::TIMESTAMP < ${DAY30} THEN 'pre_d30'
1080
+ WHEN time::TIMESTAMP < ${DAY45} THEN 'd30_45'
1081
+ ELSE 'post_d45' END AS win,
1082
+ COUNT(*) AS n,
1083
+ AVG((source = 'feed')::INT) AS feed,
1084
+ AVG((source = 'explore')::INT) AS explore,
1085
+ AVG((source = 'notification')::INT) AS notif
1086
+ FROM ${EV} WHERE event = 'post viewed' GROUP BY 1 ORDER BY 1`;
1087
+
1088
+ export const stories = [
1089
+ {
1090
+ id: "social-h1-viral-concentration",
1091
+ hook: "H1",
1092
+ archetype: "cohort-count-scale",
1093
+ narrative: "Viral creators (~3% of users) dominate engagement: top-3% share floor 0.35 (~6x the 0.057 organic ceiling; magnitude confounded — graded STRONG by design).",
1094
+ assertions: [
1095
+ {
1096
+ breakdown: {
1097
+ type: "duckdb",
1098
+ sql: `WITH pu AS (
1099
+ SELECT user_id::VARCHAR AS uid,
1100
+ COUNT(*) FILTER (WHERE event IN ('post viewed', 'post liked', 'post shared')) AS eng
1101
+ FROM ${EV} WHERE user_id IS NOT NULL GROUP BY 1
1102
+ ), ranked AS (
1103
+ SELECT eng, ROW_NUMBER() OVER (ORDER BY eng DESC) AS rn,
1104
+ COUNT(*) OVER () AS n, SUM(eng) OVER () AS tot
1105
+ FROM pu
1106
+ )
1107
+ SELECT SUM(eng)::DOUBLE / MAX(tot) AS top3_share, MAX(n) AS users
1108
+ FROM ranked WHERE rn <= CEIL(n * 0.03)`,
1109
+ },
1110
+ assert: (rows) => {
1111
+ const r = rows?.[0] || {};
1112
+ return guarded(Number(r.users) >= 5000, `users=${r.users ?? 0}`, () => {
1113
+ const share = Number(r.top3_share);
1114
+ const detail = `top-3% share of engagement events=${share.toFixed(3)} (measured 0.623; organic counterfactual 0.057)`;
1115
+ // Fix-round Q5 (S1): concentration magnitude is confounded (organic engagement
1116
+ // volume, H6 view clones, H7 drops) — not derivable from VIRAL_* knobs. Knob-derived
1117
+ // floor: each viral creator injects >= 10 posts × 3 types × VIRAL_CLONES_MIN(60)
1118
+ // = 1,800 engagement events vs ~145 organic events/user, so share >= 0.35 (~6× the
1119
+ // 0.057 organic ceiling) grades STRONG by design; no NAILED band from a measured value.
1120
+ if (share <= 0.15) return { verdict: "INVERSE", detail };
1121
+ if (share >= 0.35) return { verdict: "STRONG", detail };
1122
+ return { verdict: "WEAK", detail };
1123
+ });
1124
+ },
1125
+ },
1126
+ ],
1127
+ },
1128
+ {
1129
+ id: "social-h2-followback-snowball",
1130
+ hook: "H2",
1131
+ archetype: "cohort-count-scale",
1132
+ narrative: "Users with 5+ user-followed events post ~1.5x more (1.24x activity confound x 1.21x hook increment).",
1133
+ assertions: [
1134
+ {
1135
+ breakdown: {
1136
+ type: "duckdb",
1137
+ sql: `WITH pu AS (
1138
+ SELECT user_id::VARCHAR AS uid,
1139
+ COUNT(*) FILTER (WHERE event = 'user followed') AS fc,
1140
+ COUNT(*) FILTER (WHERE event = 'post created') AS pc
1141
+ FROM ${EV} WHERE user_id IS NOT NULL GROUP BY 1
1142
+ )
1143
+ SELECT (fc >= 5) AS big, COUNT(*) AS users, AVG(pc) AS posts
1144
+ FROM pu GROUP BY 1 ORDER BY 1`,
1145
+ },
1146
+ assert: (rows) => {
1147
+ const by = cellsOf(rows, "big");
1148
+ const big = by.true, small = by.false;
1149
+ return guarded(Number(big?.users) >= 570 && Number(small?.users) >= 4000, `cohorts: big=${big?.users ?? 0} small=${small?.users ?? 0}`, () => {
1150
+ const ratio = Number(big.posts) / Number(small.posts);
1151
+ const detail = `posts/user 5+ follows vs rest=${ratio.toFixed(3)} (measured 1.501; ${Number(big.posts).toFixed(1)} vs ${Number(small.posts).toFixed(1)})`;
1152
+ return bandVerdict(ratio, [1.40, 1.62], [1.28, 1.80], detail, v => v <= 1.0);
1153
+ });
1154
+ },
1155
+ },
1156
+ ],
1157
+ },
1158
+ {
1159
+ id: "social-h3-algorithm-change",
1160
+ hook: "H3",
1161
+ archetype: "temporal-inflection",
1162
+ narrative: "Day-45 feed→explore flip: pre-d30 feed 78% of views; post-d45 explore 54%, feed collapses to 5%.",
1163
+ assertions: [
1164
+ {
1165
+ breakdown: { type: "duckdb", sql: SOURCE_WINDOWS_SQL },
1166
+ assert: (rows) => {
1167
+ const by = cellsOf(rows, "win");
1168
+ const pre = by.pre_d30, post = by.post_d45;
1169
+ return guarded(Number(pre?.n) >= 90000 && Number(post?.n) >= 230000, `views: pre_d30=${pre?.n ?? 0} post_d45=${post?.n ?? 0}`, () => {
1170
+ const preFeed = Number(pre.feed), postExplore = Number(post.explore), postFeed = Number(post.feed);
1171
+ const detail = `pre-d30 feed=${preFeed.toFixed(3)} (measured 0.779); post-d45 explore=${postExplore.toFixed(3)} (0.539), feed=${postFeed.toFixed(3)} (0.055)`;
1172
+ const legs = [
1173
+ bandVerdict(preFeed, [0.74, 0.82], [0.70, 0.86], detail, v => v <= 0.30),
1174
+ bandVerdict(postExplore, [0.50, 0.58], [0.45, 0.63], detail, v => v <= 0.15),
1175
+ // postFeed width is mixture-motivated (H1 clones carry 1/3 feed pre-flip); the
1176
+ // pure knob residual 0.30 × 0.70 × 0.20 = 0.042 sits inside the NAILED band.
1177
+ bandVerdict(postFeed, [0.03, 0.08], [0.02, 0.12], detail, v => v >= 0.30),
1178
+ ];
1179
+ return { verdict: worstOf(...legs), detail };
1180
+ });
1181
+ },
1182
+ },
1183
+ ],
1184
+ },
1185
+ {
1186
+ id: "social-h4-engagement-bait",
1187
+ hook: "H4",
1188
+ archetype: "composition-drift",
1189
+ narrative: "20% of post-viewed events get view_duration_sec crushed to 1-5s — a bimodal low mode that is 100% hook-made.",
1190
+ assertions: [
1191
+ {
1192
+ breakdown: {
1193
+ type: "duckdb",
1194
+ sql: `SELECT COUNT(*) AS n, AVG((view_duration_sec <= 5)::INT) AS crushed_share
1195
+ FROM ${EV} WHERE event = 'post viewed' AND view_duration_sec IS NOT NULL`,
1196
+ },
1197
+ assert: (rows) => {
1198
+ const r = rows?.[0] || {};
1199
+ return guarded(Number(r.n) >= 375000, `views=${r.n ?? 0}`, () => {
1200
+ const share = Number(r.crushed_share);
1201
+ const detail = `share of views <= 5 sec=${share.toFixed(4)} (measured 0.204; organic 0.000, generator floors durations above 5s)`;
1202
+ // Fix-round Q5 (S1): NAILED = ENGAGEMENT_BAIT_LIKELIHOOD(20) → 0.20 ±10%.
1203
+ return bandVerdict(share, [0.18, 0.22], [0.15, 0.26], detail, v => v <= 0.05);
1204
+ });
1205
+ },
1206
+ },
1207
+ ],
1208
+ },
1209
+ {
1210
+ id: "social-h5-notification-reengagement",
1211
+ hook: "H5",
1212
+ archetype: "temporal-inflection",
1213
+ narrative: "After day 30, ~32% of post-viewed events carry source=notification (knob 0.30 + residual); pre-d30 share stays under the 0.066 knob ceiling (clone-diluted — pre leg graded STRONG by design).",
1214
+ assertions: [
1215
+ {
1216
+ breakdown: { type: "duckdb", sql: SOURCE_WINDOWS_SQL },
1217
+ assert: (rows) => {
1218
+ const by = cellsOf(rows, "win");
1219
+ const pre = by.pre_d30, mid = by.d30_45, post = by.post_d45;
1220
+ const postN = Number(mid?.n ?? 0) + Number(post?.n ?? 0);
1221
+ return guarded(Number(pre?.n) >= 90000 && postN >= 280000, `views: pre_d30=${pre?.n ?? 0} post_d30=${postN}`, () => {
1222
+ const preNotif = Number(pre.notif);
1223
+ const postNotif = (Number(mid.notif) * Number(mid.n) + Number(post.notif) * Number(post.n)) / postN;
1224
+ const detail = `notification share pre-d30=${preNotif.toFixed(4)} (measured 0.033) → post-d30=${postNotif.toFixed(4)} (measured 0.322)`;
1225
+ // Fix-round Q5 (S1): pre-d30 knob-implied notif share = 0.30 unflipped-by-H3
1226
+ // × 0.20 organic = 0.06; H1/H6 clone dilution (clones never stamp notification)
1227
+ // drags it lower by a non-knob-derivable amount. Knob-derived ceiling (low-before
1228
+ // check): <= 0.066 (knob + 10%) grades STRONG by design; no NAILED band. postNotif
1229
+ // keeps its knob band (REENGAGEMENT_LIKELIHOOD=30 floor + 0.042 residual).
1230
+ const legPre = preNotif >= 0.15 ? { verdict: "INVERSE", detail }
1231
+ : preNotif <= 0.066 ? { verdict: "STRONG", detail }
1232
+ : { verdict: "WEAK", detail };
1233
+ const legs = [
1234
+ legPre,
1235
+ bandVerdict(postNotif, [0.29, 0.35], [0.26, 0.38], detail, v => v <= 0.08),
1236
+ ];
1237
+ return { verdict: worstOf(...legs), detail };
1238
+ });
1239
+ },
1240
+ },
1241
+ ],
1242
+ },
1243
+ {
1244
+ id: "social-h6-creator-monetization",
1245
+ hook: "H6",
1246
+ archetype: "cohort-count-scale",
1247
+ narrative: "Subscribers (~78% of users) post and story ~3.0x more per user (CREATOR_*_CLONES=2 knob; activity confound lifts measured readings to 3.3x/3.8x — graded STRONG by design).",
1248
+ assertions: [
1249
+ {
1250
+ breakdown: {
1251
+ type: "duckdb",
1252
+ sql: `WITH pu AS (
1253
+ SELECT user_id::VARCHAR AS uid,
1254
+ BOOL_OR(event = 'creator subscription started') AS sub,
1255
+ COUNT(*) FILTER (WHERE event = 'post created') AS pc,
1256
+ COUNT(*) FILTER (WHERE event = 'story created') AS sc
1257
+ FROM ${EV} WHERE user_id IS NOT NULL GROUP BY 1
1258
+ )
1259
+ SELECT sub, COUNT(*) AS users, AVG(pc) AS posts, AVG(sc) AS stories
1260
+ FROM pu GROUP BY 1 ORDER BY 1`,
1261
+ },
1262
+ assert: (rows) => {
1263
+ const by = cellsOf(rows, "sub");
1264
+ const s = by.true, n = by.false;
1265
+ return guarded(Number(s?.users) >= 3800 && Number(n?.users) >= 1100, `cohorts: sub=${s?.users ?? 0} non=${n?.users ?? 0}`, () => {
1266
+ const postRatio = Number(s.posts) / Number(n.posts);
1267
+ const storyRatio = Number(s.stories) / Number(n.stories);
1268
+ const detail = `sub vs non posts/user=${postRatio.toFixed(3)} (measured 3.32); stories/user=${storyRatio.toFixed(3)} (measured 3.82)`;
1269
+ // Fix-round Q5 (S1): knob is CREATOR_POST_CLONES=2 / CREATOR_STORY_CLONES=2 → 3.0x
1270
+ // per-user lift for both metrics; NAILED = knob ±10% = [2.70, 3.30]. Measured 3.32
1271
+ // (posts) and 3.82 (stories) carry a subscriber activity confound on top of the knob
1272
+ // (paired lifts land on 3.07x/3.00x) — those readings grade STRONG by design.
1273
+ const legs = [
1274
+ bandVerdict(postRatio, [2.70, 3.30], [2.40, 4.00], detail, v => v <= 1.5),
1275
+ bandVerdict(storyRatio, [2.70, 3.30], [2.40, 4.60], detail, v => v <= 1.5),
1276
+ ];
1277
+ return { verdict: worstOf(...legs), detail };
1278
+ });
1279
+ },
1280
+ },
1281
+ ],
1282
+ },
1283
+ {
1284
+ id: "social-h7-toxicity-churn",
1285
+ hook: "H7",
1286
+ archetype: "retention-divergence",
1287
+ narrative: "Users with 2+ reports lose 60% of post-day-30 activity: post/pre ratio 1.27 vs 3.21 for the rest (0.40x contrast).",
1288
+ assertions: [
1289
+ {
1290
+ breakdown: {
1291
+ type: "duckdb",
1292
+ sql: `WITH pu AS (
1293
+ SELECT user_id::VARCHAR AS uid,
1294
+ COUNT(*) FILTER (WHERE event = 'report submitted') AS rep,
1295
+ COUNT(*) FILTER (WHERE time::TIMESTAMP <= ${DAY30}) AS pre,
1296
+ COUNT(*) FILTER (WHERE time::TIMESTAMP > ${DAY30}) AS post
1297
+ FROM ${EV} WHERE user_id IS NOT NULL GROUP BY 1
1298
+ )
1299
+ SELECT (rep >= 2) AS reporter, COUNT(*) AS users, AVG(post::DOUBLE / pre) AS ratio
1300
+ FROM pu WHERE pre > 0 GROUP BY 1 ORDER BY 1`,
1301
+ },
1302
+ assert: (rows) => {
1303
+ const by = cellsOf(rows, "reporter");
1304
+ const rep = by.true, norm = by.false;
1305
+ return guarded(Number(rep?.users) >= 310 && Number(norm?.users) >= 4000, `cohorts: reporters=${rep?.users ?? 0} normal=${norm?.users ?? 0}`, () => {
1306
+ const contrast = Number(rep.ratio) / Number(norm.ratio);
1307
+ const detail = `post/pre-d30 ratio reporters=${Number(rep.ratio).toFixed(2)} vs normal=${Number(norm.ratio).toFixed(2)} → contrast=${contrast.toFixed(3)} (measured 0.395; 60% drop knob)`;
1308
+ // Fix-round Q5 (S1): NAILED = survival knob (1 − TOXICITY_DROP_LIKELIHOOD(60)/100)
1309
+ // = 0.40 ±10% (was ±15%).
1310
+ return bandVerdict(contrast, [0.36, 0.44], [0.28, 0.55], detail, v => v >= 0.85);
1311
+ });
1312
+ },
1313
+ },
1314
+ ],
1315
+ },
1316
+ {
1317
+ id: "social-h8-weekend-surge",
1318
+ hook: "H8",
1319
+ archetype: "bespoke",
1320
+ narrative: "Sat/Sun creations run ~1.25x weekday daily rate on a flat organic DOW baseline (0.98 counterfactual).",
1321
+ assertions: [
1322
+ {
1323
+ breakdown: {
1324
+ type: "duckdb",
1325
+ sql: `WITH d AS (
1326
+ SELECT time::DATE AS dt, COUNT(*) AS n FROM ${EV}
1327
+ WHERE event IN ('post created', 'story created') GROUP BY 1
1328
+ )
1329
+ SELECT
1330
+ AVG(n) FILTER (WHERE EXTRACT(DOW FROM dt) IN (0, 6)) AS wkn,
1331
+ AVG(n) FILTER (WHERE EXTRACT(DOW FROM dt) NOT IN (0, 6)) AS wkd
1332
+ FROM d`,
1333
+ },
1334
+ assert: (rows) => {
1335
+ const r = rows?.[0] || {};
1336
+ return guarded(Number(r.wkd) >= 2300, `weekday creations/day=${r.wkd ? Number(r.wkd).toFixed(0) : 0}`, () => {
1337
+ const ratio = Number(r.wkn) / Number(r.wkd);
1338
+ const detail = `weekend/weekday daily creations=${ratio.toFixed(3)} (measured 1.254; organic counterfactual 0.980 — flat DOW)`;
1339
+ return bandVerdict(ratio, [1.19, 1.32], [1.10, 1.42], detail, v => v <= 1.02);
1340
+ });
1341
+ },
1342
+ },
1343
+ ],
1344
+ },
1345
+ {
1346
+ id: "social-h9-magic-number",
1347
+ hook: "H9",
1348
+ archetype: "frequency-sweet-spot",
1349
+ narrative: "Sweet-spot posters (3-7 final posts) out-comment 8+ posters ~2.0x per knobs (1.4 boost / 0.7 factor); bucket migration attenuates the measured ratio to ~1.7 — graded STRONG by design. Ordering sweet > low > over.",
1350
+ assertions: [
1351
+ {
1352
+ breakdown: {
1353
+ type: "duckdb",
1354
+ sql: `WITH pu AS (
1355
+ SELECT user_id::VARCHAR AS uid, COUNT(*) FILTER (WHERE event = 'post created') AS pc
1356
+ FROM ${EV} WHERE user_id IS NOT NULL GROUP BY 1
1357
+ )
1358
+ SELECT CASE WHEN pu.pc >= 8 THEN 'over' WHEN pu.pc >= 3 THEN 'sweet' ELSE 'low' END AS bucket,
1359
+ COUNT(DISTINCT pu.uid) AS users, COUNT(*) AS n_comments, AVG(e.comment_length) AS len
1360
+ FROM ${EV} e JOIN pu ON e.user_id::VARCHAR = pu.uid
1361
+ WHERE e.event = 'comment posted' AND e.comment_length IS NOT NULL
1362
+ GROUP BY 1 ORDER BY 1`,
1363
+ },
1364
+ assert: (rows) => {
1365
+ const by = cellsOf(rows, "bucket");
1366
+ const sweet = by.sweet, low = by.low, over = by.over;
1367
+ return guarded(
1368
+ Number(sweet?.users) >= 400 && Number(low?.users) >= 80 && Number(over?.n_comments) >= 44000,
1369
+ `buckets: sweet=${sweet?.users ?? 0}u low=${low?.users ?? 0}u over=${over?.n_comments ?? 0}c`,
1370
+ () => {
1371
+ const sweetOver = Number(sweet.len) / Number(over.len);
1372
+ const sl = Number(sweet.len), ll = Number(low.len), ol = Number(over.len);
1373
+ const detail = `comment_length sweet/over=${sweetOver.toFixed(3)} (measured 1.720); ordering sweet=${sl.toFixed(0)} low=${ll.toFixed(0)} over=${ol.toFixed(0)} (measured 250/200/145)`;
1374
+ // Fix-round Q5 (S1): knob-implied sweet/over = POST_SWEET_COMMENT_BOOST(1.4)
1375
+ // / POST_OVER_COMMENT_FACTOR(0.7) = 2.00; NAILED = knob ±10% = [1.80, 2.20].
1376
+ // Final-count bucket migration (H2/H6/H8 clones push boosted sweet users into
1377
+ // the 8+ bucket) attenuates the measured ratio to 1.72 — that reading grades
1378
+ // STRONG by design. Ordering leg: NAILED only when both margins sit within
1379
+ // knob ±10% (sweet/low ∈ [1.26, 1.54] around 1.40; low/over ∈ [1.29, 1.57]
1380
+ // around 1/0.7 = 1.43); the 1.15-margin ordering grades STRONG.
1381
+ const legRatio = bandVerdict(sweetOver, [1.80, 2.20], [1.40, 2.20], detail, v => v <= 1.05);
1382
+ const mSweetLow = sl / ll, mLowOver = ll / ol;
1383
+ let legOrder;
1384
+ if (sl < ol) legOrder = { verdict: "INVERSE", detail };
1385
+ else if (mSweetLow >= 1.26 && mSweetLow <= 1.54 && mLowOver >= 1.29 && mLowOver <= 1.57) legOrder = { verdict: "NAILED", detail };
1386
+ else if (sl >= 1.15 * ll && ll >= 1.15 * ol) legOrder = { verdict: "STRONG", detail };
1387
+ else legOrder = { verdict: "WEAK", detail };
1388
+ return { verdict: worstOf(legRatio, legOrder), detail };
1389
+ }
1390
+ );
1391
+ },
1392
+ },
1393
+ ],
1394
+ },
1395
+ {
1396
+ id: "social-h10-onboarding-ttc",
1397
+ hook: "H10",
1398
+ archetype: "funnel-ttc-by-segment",
1399
+ narrative: "Creator/business complete onboarding faster than personal: knob-implied TTC ratio 0.568 (0.71/1.25); step-matching attenuation realizes ~0.71 — graded STRONG by design (window-stable 1h-24h).",
1400
+ assertions: [
1401
+ {
1402
+ breakdown: {
1403
+ type: "timeToConvert",
1404
+ steps: ["account created", "profile updated", "post created"],
1405
+ breakdownByUserProperty: "account_type",
1406
+ conversionWindowMs: 6 * 3600 * 1000,
1407
+ },
1408
+ assert: (rows) => {
1409
+ const by = cellsOf(rows, "segment_value");
1410
+ const p = by.personal, c = by.creator, b = by.business;
1411
+ const counts = `personal=${p?.user_count ?? 0} creator=${c?.user_count ?? 0} business=${b?.user_count ?? 0}`;
1412
+ return guarded(
1413
+ Number(p?.user_count) >= 160 && Number(c?.user_count) >= 125 && Number(b?.user_count) >= 115,
1414
+ `converters: ${counts}`,
1415
+ () => {
1416
+ const fast = (Number(c.median_ttc_ms) + Number(b.median_ttc_ms)) / 2;
1417
+ const ratio = fast / Number(p.median_ttc_ms);
1418
+ const detail = `funnel median TTC @6h fast(creator/business)/personal=${ratio.toFixed(3)} (measured 0.709; medians ${(fast / 60000).toFixed(1)}min vs ${(Number(p.median_ttc_ms) / 60000).toFixed(1)}min; knobs 0.71/1.25 attenuate to 0.81/1.11 per leg)`;
1419
+ // Fix-round Q5 (S1): knob-implied ratio = ONBOARDING_TTC_FAST(0.71) /
1420
+ // ONBOARDING_TTC_SLOW(1.25) = 0.568; NAILED = knob ±10% = [0.52, 0.62].
1421
+ // Greedy funnel step-matching substitutes organic standalone events and
1422
+ // attenuates both legs toward 1 (0.81/1.11 realized — same mechanism as
1423
+ // sass H9), by a non-knob-derivable amount; the measured 0.709 grades
1424
+ // STRONG by design. Floor 0.51 = knob − 10% (mechanism can only attenuate
1425
+ // toward 1, never overshoot); ceiling 0.85 sits clearly below the 0.963
1426
+ // organic baseline.
1427
+ return bandVerdict(ratio, [0.52, 0.62], [0.51, 0.85], detail, v => v >= 0.95);
1428
+ }
1429
+ );
1430
+ },
1431
+ },
1432
+ {
1433
+ breakdown: {
1434
+ type: "duckdb",
1435
+ sql: `SELECT COUNT(*) AS n,
1436
+ AVG((user_id IS NOT NULL)::INT) AS uid_share,
1437
+ AVG((device_id IS NOT NULL)::INT) AS device_share,
1438
+ COUNT(DISTINCT device_id)::DOUBLE / COUNT(DISTINCT user_id) AS devices_per_user
1439
+ FROM ${EV}`,
1440
+ },
1441
+ assert: (rows) => {
1442
+ const r = rows?.[0] || {};
1443
+ return guarded(Number(r.n) >= 1300000, `events=${r.n ?? 0}`, () => {
1444
+ const uid = Number(r.uid_share), dev = Number(r.device_share), dpu = Number(r.devices_per_user);
1445
+ const detail = `identity invariants: uid_share=${uid} device_share=${dev.toFixed(4)} devices/user=${dpu.toFixed(2)} over ${r.n} events (auth on first event; avgDevicePerUser: 2)`;
1446
+ // Fix-round Q5 (S1): dpu NAILED tightened to avgDevicePerUser(2) ±10%.
1447
+ if (uid === 1 && dev >= 0.99 && dpu >= 1.8 && dpu <= 2.2) return { verdict: "NAILED", detail };
1448
+ if (uid >= 0.999 && dev >= 0.98) return { verdict: "STRONG", detail };
1449
+ if (uid < 0.9) return { verdict: "INVERSE", detail };
1450
+ return { verdict: "WEAK", detail };
1451
+ });
1452
+ },
1453
+ },
1454
+ ],
1455
+ },
1456
+ ];