@ak--47/dungeon-master 1.5.4 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/analyze-soup/SKILL.md +21 -12
- package/.claude/skills/create-dungeon/SKILL.md +65 -5
- package/.claude/skills/create-project/SKILL.md +1 -1
- package/.claude/skills/create-project/provision.mjs +57 -10
- package/.claude/skills/verify-dungeon/SKILL.md +56 -25
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
- package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
- package/.claude/skills/write-hooks/SKILL.md +156 -18
- package/CHANGELOG.md +183 -0
- package/HOOKS.md +566 -112
- package/README.md +28 -9
- package/docs/guides/1.3.0-upgrade-guide.md +262 -0
- package/docs/guides/1.3.1-upgrade-guide.md +328 -0
- package/docs/guides/1.4.0-upgrade-guide.md +130 -0
- package/docs/guides/1.4.1-upgrade-guide.md +125 -0
- package/docs/guides/1.4.3-upgrade-guide.md +200 -0
- package/docs/guides/1.4.4-upgrade-guide.md +95 -0
- package/docs/guides/1.5.0-upgrade-guide.md +426 -0
- package/docs/guides/1.5.1-upgrade-guide.md +178 -0
- package/docs/guides/1.5.3-upgrade-guide.md +117 -0
- package/docs/guides/1.6.0-upgrade-guide.md +204 -0
- package/dungeons/technical/stories-verify.js +152 -0
- package/dungeons/vertical/README.md +154 -0
- package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
- package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
- package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
- package/dungeons/vertical/community/community.js +1589 -0
- package/dungeons/vertical/community/community.sql +216 -0
- package/dungeons/vertical/community/community.verify.mjs +72 -0
- package/dungeons/vertical/crypto/crypto.js +1483 -0
- package/dungeons/vertical/crypto/crypto.sql +194 -0
- package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
- package/dungeons/vertical/dating/dating.js +1540 -0
- package/dungeons/vertical/dating/dating.sql +288 -0
- package/dungeons/vertical/dating/dating.verify.mjs +72 -0
- package/dungeons/vertical/devtools/devtools.js +1524 -0
- package/dungeons/vertical/devtools/devtools.sql +161 -0
- package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
- package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
- package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
- package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
- package/dungeons/vertical/education/education.js +1763 -0
- package/dungeons/vertical/education/education.sql +243 -0
- package/dungeons/vertical/education/education.verify.mjs +75 -0
- package/dungeons/vertical/fintech/fintech.js +1410 -0
- package/dungeons/vertical/fintech/fintech.sql +175 -0
- package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
- package/dungeons/vertical/fitness/fitness.js +1495 -0
- package/dungeons/vertical/fitness/fitness.sql +206 -0
- package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
- package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
- package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
- package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
- package/dungeons/vertical/gaming/gaming.js +1741 -0
- package/dungeons/vertical/gaming/gaming.sql +249 -0
- package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
- package/dungeons/vertical/healthcare/healthcare.js +1488 -0
- package/dungeons/vertical/healthcare/healthcare.sql +193 -0
- package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
- package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
- package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
- package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
- package/dungeons/vertical/logistics/logistics.js +1541 -0
- package/dungeons/vertical/logistics/logistics.sql +205 -0
- package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
- package/dungeons/vertical/marketplace/marketplace.js +1438 -0
- package/dungeons/vertical/marketplace/marketplace.sql +148 -0
- package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
- package/dungeons/vertical/{media.js → media/media.js} +557 -58
- package/dungeons/vertical/media/media.sql +173 -0
- package/dungeons/vertical/media/media.verify.mjs +74 -0
- package/dungeons/vertical/real-estate/real-estate.js +1629 -0
- package/dungeons/vertical/real-estate/real-estate.sql +167 -0
- package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
- package/dungeons/vertical/sass/sass.js +1733 -0
- package/dungeons/vertical/sass/sass.sql +173 -0
- package/dungeons/vertical/sass/sass.verify.mjs +74 -0
- package/dungeons/vertical/social/social.js +1456 -0
- package/dungeons/vertical/social/social.sql +160 -0
- package/dungeons/vertical/social/social.verify.mjs +76 -0
- package/dungeons/vertical/streaming/streaming.js +677 -0
- package/dungeons/vertical/streaming/streaming.sql +101 -0
- package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
- package/dungeons/vertical/support-desk/support-desk.js +741 -0
- package/dungeons/vertical/support-desk/support-desk.sql +137 -0
- package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
- package/dungeons/vertical/travel/travel.js +1603 -0
- package/dungeons/vertical/travel/travel.sql +236 -0
- package/dungeons/vertical/travel/travel.verify.mjs +72 -0
- package/index.js +2 -2
- package/lib/core/config-validator.js +37 -2
- package/lib/core/context.js +4 -2
- package/lib/core/dungeon-loader.js +20 -15
- package/lib/core/dungeon-to-json.js +22 -4
- package/lib/core/storage.js +3 -3
- package/lib/generators/funnels.js +36 -9
- package/lib/hook-helpers/cohort.js +53 -0
- package/lib/hook-helpers/index.js +8 -0
- package/lib/hook-helpers/mutate.js +1 -1
- package/lib/hook-helpers/shape.js +319 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
- package/lib/hook-patterns/attributed-by-source.js +60 -58
- package/lib/hook-patterns/frequency-by-frequency.js +14 -4
- package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
- package/lib/hook-patterns/index.js +1 -1
- package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
- package/lib/orchestrators/user-loop.js +41 -0
- package/lib/templates/story-spec.schema.json +175 -0
- package/lib/utils/json-evaluator.js +1 -1
- package/lib/utils/utils.js +5 -4
- package/lib/verify/coerce.js +186 -0
- package/lib/verify/counting.js +270 -32
- package/lib/verify/emulate-breakdown.js +1285 -203
- package/lib/verify/first-time.js +89 -0
- package/lib/verify/flows.js +679 -0
- package/lib/verify/formula.js +259 -0
- package/lib/verify/funnel-engine.js +697 -113
- package/lib/verify/index.js +32 -4
- package/lib/verify/sessionize.js +275 -0
- package/lib/verify/story-runner.js +483 -0
- package/lib/verify/verify-dungeon.js +59 -37
- package/package.json +2 -1
- package/scripts/extract-dungeon-schema.mjs +2 -2
- package/scripts/run-many.mjs +2 -2
- package/scripts/verify-runner.mjs +2 -2
- package/scripts/verify-stories.mjs +244 -0
- package/types.d.ts +296 -30
- package/dungeons/vertical/ai-platform.js +0 -818
- package/dungeons/vertical/community.js +0 -966
- package/dungeons/vertical/crypto.js +0 -895
- package/dungeons/vertical/dating.js +0 -830
- package/dungeons/vertical/devtools.js +0 -1050
- package/dungeons/vertical/ecommerce.js +0 -811
- package/dungeons/vertical/education.js +0 -964
- package/dungeons/vertical/fintech.js +0 -946
- package/dungeons/vertical/fitness.js +0 -935
- package/dungeons/vertical/food-delivery.js +0 -934
- package/dungeons/vertical/gaming.js +0 -1120
- package/dungeons/vertical/healthcare.js +0 -945
- package/dungeons/vertical/insurance-application.js +0 -1039
- package/dungeons/vertical/logistics.js +0 -986
- package/dungeons/vertical/marketplace.js +0 -1019
- package/dungeons/vertical/real-estate.js +0 -872
- package/dungeons/vertical/sass.js +0 -995
- package/dungeons/vertical/social.js +0 -904
- package/dungeons/vertical/travel.js +0 -930
|
@@ -0,0 +1,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;
|