@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
|
@@ -3,10 +3,10 @@ import dayjs from "dayjs";
|
|
|
3
3
|
import utc from "dayjs/plugin/utc.js";
|
|
4
4
|
dayjs.extend(utc);
|
|
5
5
|
import "dotenv/config";
|
|
6
|
-
import * as u from "
|
|
6
|
+
import * as u from "@ak--47/dungeon-master/utils";
|
|
7
7
|
import * as v from "ak-tools";
|
|
8
|
-
import { findFirstSequence, scaleFunnelTTC } from "
|
|
9
|
-
/** @typedef {import("
|
|
8
|
+
import { findFirstSequence, scaleFunnelTTC } from "@ak--47/dungeon-master/hook-helpers";
|
|
9
|
+
/** @typedef {import("../../../types").Dungeon} Config */
|
|
10
10
|
|
|
11
11
|
// ── OVERVIEW ──
|
|
12
12
|
/*
|
|
@@ -16,7 +16,7 @@ import { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing
|
|
|
16
16
|
* watchlists, watch with configurable quality/subtitle/speed,
|
|
17
17
|
* rate and share content, and switch household profiles (main,
|
|
18
18
|
* kids, partner, guest) under a single account.
|
|
19
|
-
* SCALE: 10,000 users, ~
|
|
19
|
+
* SCALE: 10,000 users, ~1.24M events, 121 days (2026-01-01 → 2026-05-01)
|
|
20
20
|
* CORE LOOP: account created → content browsed → content selected → playback started → playback completed
|
|
21
21
|
*
|
|
22
22
|
* EVENTS (17):
|
|
@@ -64,7 +64,16 @@ import { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing
|
|
|
64
64
|
* - Report type: Funnels
|
|
65
65
|
* - Steps: "content browsed" -> "content selected" -> "playback started" -> "playback completed"
|
|
66
66
|
* - Breakdown: "genre"
|
|
67
|
-
* - Expected: documentary ~ 0.
|
|
67
|
+
* - Expected: documentary ~ 0.75x conversion vs other genres
|
|
68
|
+
*
|
|
69
|
+
* MEASUREMENT CAVEATS: raw per-genre completion counts are confounded by
|
|
70
|
+
* (a) the engine's favored-index property sampling (action/romance selections
|
|
71
|
+
* run ~1.5x the uniform share) and (b) H6, which inflates animation+documentary
|
|
72
|
+
* SELECTION counts without adding completions. The clean read is the
|
|
73
|
+
* completions-per-selection ratio-of-ratios vs animation — animation absorbs
|
|
74
|
+
* the same H6 denominator inflation, so (doc c/s)/(anim c/s) isolates the
|
|
75
|
+
* engineered 0.75 (measured 0.74 @2K). Genre exists on completions only via
|
|
76
|
+
* core-viewing-loop funnel props; other completions carry genre = null.
|
|
68
77
|
*
|
|
69
78
|
* REAL-WORLD ANALOGUE: Heavier content gets started but abandoned more often
|
|
70
79
|
* than light comedy or animation.
|
|
@@ -86,7 +95,8 @@ import { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing
|
|
|
86
95
|
* - Cohort B: rest
|
|
87
96
|
* - Event: "playback completed"
|
|
88
97
|
* - Measure: Total per user
|
|
89
|
-
* - Expected: A ~ 1.
|
|
98
|
+
* - Expected: A ~ 1.6x more completions per user (1.4x clone mechanism
|
|
99
|
+
* × ~1.15x activity/streak-selection confound; measured 1.64 @2K)
|
|
90
100
|
*
|
|
91
101
|
* REAL-WORLD ANALOGUE: Autoplay and cliffhangers push hooked viewers through
|
|
92
102
|
* entire seasons in a sitting.
|
|
@@ -106,7 +116,8 @@ import { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing
|
|
|
106
116
|
* - Event: "playback completed"
|
|
107
117
|
* - Measure: Average of "watch_duration_min"
|
|
108
118
|
* - Breakdown: Day of week
|
|
109
|
-
* - Expected: Sat/Sun ~ 1.
|
|
119
|
+
* - Expected: Sat/Sun ~ 1.45x weekday avg (engineered 1.5x, diluted by
|
|
120
|
+
* binge/subtitle clones whose durations are fresh draws, never x1.5)
|
|
110
121
|
*
|
|
111
122
|
* REAL-WORLD ANALOGUE: Weekend viewing stretches into multi-hour marathons.
|
|
112
123
|
*
|
|
@@ -127,7 +138,9 @@ import { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing
|
|
|
127
138
|
* - Report type: Retention
|
|
128
139
|
* - Cohort A: users with >= 5 "ad impression" in first 45 days
|
|
129
140
|
* - Cohort B: users with < 5 ads
|
|
130
|
-
* - Expected: heavy_ad
|
|
141
|
+
* - Expected: heavy_ad post/pre event ratio ~ 0.04x light_ad (engineered
|
|
142
|
+
* 5% keep; measured 0.07 vs 1.67 @2K among users whose lifecycle spans
|
|
143
|
+
* past day 52 — restrict to those or late-born users dilute cohort B)
|
|
131
144
|
*
|
|
132
145
|
* REAL-WORLD ANALOGUE: Ad-supported tiers carry a tolerance ceiling.
|
|
133
146
|
*
|
|
@@ -166,7 +179,11 @@ import { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing
|
|
|
166
179
|
* - Event: "content selected"
|
|
167
180
|
* - Measure: Total
|
|
168
181
|
* - Breakdown: "genre"
|
|
169
|
-
* - Expected: animation + documentary
|
|
182
|
+
* - Expected: animation + documentary ~ 32% of "content selected" (vs ~20%
|
|
183
|
+
* un-hooked base; engineered 15% forced + 85% x base). Read on "content
|
|
184
|
+
* selected" only — "playback started" carries genre solely via
|
|
185
|
+
* core-viewing-loop funnel props or this hook's stamp, so its
|
|
186
|
+
* genre-carrying subset over-represents animation/documentary (~48%)
|
|
170
187
|
*
|
|
171
188
|
* REAL-WORLD ANALOGUE: Kids profiles enforce age-appropriate content.
|
|
172
189
|
*
|
|
@@ -191,7 +208,9 @@ import { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing
|
|
|
191
208
|
* - Report type: Funnels
|
|
192
209
|
* - Steps: "recommendation clicked" -> "playback started" -> "content rated"
|
|
193
210
|
* - Compare date ranges (days 0-59 vs 60-100)
|
|
194
|
-
* - Expected: post-
|
|
211
|
+
* - Expected: post-window rated-share-of-all-events ~ 1.43x pre-window
|
|
212
|
+
* (1/0.7 mechanism; normalizing by total events cancels the user-growth
|
|
213
|
+
* ramp. Measured 1.50 @2K — H4 thins the post-window denominator)
|
|
195
214
|
*
|
|
196
215
|
* REAL-WORLD ANALOGUE: Rec model upgrades produce a step-change in CTR.
|
|
197
216
|
*
|
|
@@ -211,7 +230,11 @@ import { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing
|
|
|
211
230
|
* - Cohort B: rest
|
|
212
231
|
* - Event: "playback completed"
|
|
213
232
|
* - Measure: Average of "completion_percent"
|
|
214
|
-
* - Expected: A ~
|
|
233
|
+
* - Expected: A ~ 67% vs B ~ 52% (~1.28x; the raw completion_percent pool
|
|
234
|
+
* mean is ~52, not the ~68 an 85-peaked weighNumRange suggests — the 1.5
|
|
235
|
+
* skew leaves substantial low-end mass. watch_duration_min is NOT a clean
|
|
236
|
+
* read for this cohort: subtitle users skew into the H9 over-bucket,
|
|
237
|
+
* whose halving cancels the 1.15x almost exactly)
|
|
215
238
|
*
|
|
216
239
|
* REAL-WORLD ANALOGUE: Subtitle adoption correlates with attentive viewers.
|
|
217
240
|
*
|
|
@@ -234,7 +257,7 @@ import { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing
|
|
|
234
257
|
* - Cohort B: users with 0-3 "recommendation clicked"
|
|
235
258
|
* - Event: "playback completed"
|
|
236
259
|
* - Measure: Average of "watch_duration_min"
|
|
237
|
-
* - Expected: A ~ 1.25x
|
|
260
|
+
* - Expected: A ~ 1.23x B (engineered 1.25x; measured 1.23 @2K)
|
|
238
261
|
*
|
|
239
262
|
* Report 2: Completions per User on Heavy Rec Clickers
|
|
240
263
|
* - Report type: Insights (with cohort)
|
|
@@ -242,7 +265,9 @@ import { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing
|
|
|
242
265
|
* - Cohort A: users with 4-6
|
|
243
266
|
* - Event: "playback completed"
|
|
244
267
|
* - Measure: Average of "watch_duration_min"
|
|
245
|
-
* - Expected: C
|
|
268
|
+
* - Expected: C ~ 0.40x A exactly — the over/sweet pair is a clean
|
|
269
|
+
* mechanism read (0.5 halving / 1.25 sweet boost = 0.40; both buckets
|
|
270
|
+
* share the high-engagement confound, which cancels. Measured 0.41 @2K)
|
|
246
271
|
*
|
|
247
272
|
* REAL-WORLD ANALOGUE: A few good recs surface a watchworthy title; too many
|
|
248
273
|
* clicks signals indecision and drives abandonment.
|
|
@@ -253,9 +278,10 @@ import { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing
|
|
|
253
278
|
*
|
|
254
279
|
* PATTERN: Premium subscribers have 0.67x watch_duration_min on "playback
|
|
255
280
|
* completed" (efficient viewers), free users have 1.4x (lingering viewers).
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
281
|
+
* Applied ONCE, before weekend/subtitle/rec-click hooks so each subsequent
|
|
282
|
+
* effect amplifies from the plan-adjusted base. Two mechanisms: the
|
|
283
|
+
* watch_duration_min property scale (Insights) and scaleFunnelTTC on each
|
|
284
|
+
* user's first core-viewing sequence (funnel time-to-convert).
|
|
259
285
|
*
|
|
260
286
|
* HOW TO FIND IT IN MIXPANEL:
|
|
261
287
|
*
|
|
@@ -264,7 +290,22 @@ import { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing
|
|
|
264
290
|
* - Event: "playback completed"
|
|
265
291
|
* - Measure: Average of "watch_duration_min"
|
|
266
292
|
* - Breakdown: "subscription_plan"
|
|
267
|
-
* - Expected: premium < standard < free, free/premium
|
|
293
|
+
* - Expected: premium < standard < free, free/premium ~ 2.0x (engineered
|
|
294
|
+
* 2.09 = 1.4/0.67, diluted slightly by unscaled clone durations)
|
|
295
|
+
*
|
|
296
|
+
* Report 2: Core Viewing Funnel TTC by Plan
|
|
297
|
+
* - Report type: Funnels (time to convert)
|
|
298
|
+
* - Steps: content browsed -> content selected -> playback started -> playback completed
|
|
299
|
+
* - Breakdown: "subscription_plan"; conversion window 6 HOURS
|
|
300
|
+
* - Expected: free ~ 1.4x standard median TTC, premium ~ 0.67x standard,
|
|
301
|
+
* free/premium ~ 2.09x (medians ~2.1h / ~1.5h / ~1.0h)
|
|
302
|
+
* - The 6-hour window is essential. The hook scales within-session step
|
|
303
|
+
* gaps; at multi-day windows each plan's TTC distribution is bimodal —
|
|
304
|
+
* a compressed within-session mode plus a 6-72h cross-session organic
|
|
305
|
+
* tail — with the median sitting on the ~50% boundary between them,
|
|
306
|
+
* so it flips between modes on sampling noise (a 3-day window read
|
|
307
|
+
* 0.70 prem/std at 2K and 1.14 at 10K from the same config). At 6h
|
|
308
|
+
* the distributions are unimodal and the medians are scale-stable.
|
|
268
309
|
*
|
|
269
310
|
* REAL-WORLD ANALOGUE: Premium subscribers binge curated content efficiently;
|
|
270
311
|
* free-tier users browse and linger with ad interruptions.
|
|
@@ -273,19 +314,23 @@ import { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing
|
|
|
273
314
|
* EXPECTED METRICS SUMMARY
|
|
274
315
|
* ═══════════════════════════════════════════════════════════════════════════════
|
|
275
316
|
*
|
|
276
|
-
* Hook | Metric
|
|
277
|
-
*
|
|
278
|
-
* Genre Funnel Conversion |
|
|
279
|
-
* Binge-Watching | completions
|
|
280
|
-
* Weekend vs Weekday |
|
|
281
|
-
* Ad Fatigue Churn |
|
|
282
|
-
* New Release Spike | blockbuster
|
|
283
|
-
* Kids Profile Safety |
|
|
284
|
-
* Rec Engine Improvement |
|
|
285
|
-
* Subtitle Users |
|
|
286
|
-
* Rec-Click Magic Number | sweet
|
|
287
|
-
* Rec-Click Magic Number | over
|
|
288
|
-
* Core Viewing Loop | free/premium duration
|
|
317
|
+
* Hook | Metric | Engineered | Measured @2K
|
|
318
|
+
* ─────────────────────────|──────────────────────────────|------------|-------------
|
|
319
|
+
* Genre Funnel Conversion | doc/anim comp-per-sel RoR | 0.75 | 0.739
|
|
320
|
+
* Binge-Watching | binge/rest completions pu | ~1.6x | 1.64
|
|
321
|
+
* Weekend vs Weekday | wkn/wkd avg duration | ~1.45x | 1.45
|
|
322
|
+
* Ad Fatigue Churn | fatigued/rest post-pre ratio | ~0.05 | 0.043
|
|
323
|
+
* New Release Spike | in-window blockbuster share | 0.20 | 0.203 (out: 0)
|
|
324
|
+
* Kids Profile Safety | anim+doc share of selected | ~0.32 | 0.317
|
|
325
|
+
* Rec Engine Improvement | post/pre rated share ratio | 1.43 | 1.497
|
|
326
|
+
* Subtitle Users | completion_percent ratio | ~1.28x | 1.281
|
|
327
|
+
* Rec-Click Magic Number | sweet/low avg duration | 1.25x | 1.227
|
|
328
|
+
* Rec-Click Magic Number | over/sweet avg duration | 0.40x | 0.413
|
|
329
|
+
* Core Viewing Loop | free/premium avg duration | 2.09x | 2.010
|
|
330
|
+
* Core Viewing Loop | TTC free/premium (6h win) | 2.09x | 2.08
|
|
331
|
+
*
|
|
332
|
+
* (Measured column re-checked at full 10K fidelity during verification;
|
|
333
|
+
* @2K values shown where the full-run delta was within band.)
|
|
289
334
|
*/
|
|
290
335
|
|
|
291
336
|
// ── SCALE ──
|
|
@@ -368,12 +413,16 @@ function handleEverythingHooks(record, meta) {
|
|
|
368
413
|
});
|
|
369
414
|
}
|
|
370
415
|
|
|
371
|
-
// HOOK 10: CORE VIEWING LOOP
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
416
|
+
// HOOK 10: CORE VIEWING LOOP — subscription_plan scaling, applied ONCE.
|
|
417
|
+
// Premium 0.67x, free 1.4x, standard 1.0x. Two mechanisms:
|
|
418
|
+
// 1. scaleFunnelTTC on the user's first core-viewing sequence
|
|
419
|
+
// (visible to Mixpanel funnel time-to-convert)
|
|
420
|
+
// 2. watch_duration_min property scale on playback completed
|
|
421
|
+
// (visible to Insights AVG; free/premium engineered ratio 2.09)
|
|
422
|
+
// Applied first so weekend/subtitle/rec-click hooks amplify from the
|
|
423
|
+
// plan-adjusted base.
|
|
375
424
|
{
|
|
376
|
-
const plan =
|
|
425
|
+
const plan = stampPlan;
|
|
377
426
|
const ttcFactor = plan === "premium" ? PLAN_TTC_PREMIUM : plan === "free" ? PLAN_TTC_FREE : 1.0;
|
|
378
427
|
if (ttcFactor !== 1.0) {
|
|
379
428
|
// Timestamp shift: affects Mixpanel funnel TTC
|
|
@@ -386,7 +435,7 @@ function handleEverythingHooks(record, meta) {
|
|
|
386
435
|
// Property scale: affects Insights AVG reports
|
|
387
436
|
for (const e of userEvents) {
|
|
388
437
|
if (e.event === "playback completed" && typeof e.watch_duration_min === "number") {
|
|
389
|
-
e.watch_duration_min = Math.round(e.watch_duration_min * ttcFactor
|
|
438
|
+
e.watch_duration_min = Math.round(e.watch_duration_min * ttcFactor);
|
|
390
439
|
}
|
|
391
440
|
}
|
|
392
441
|
}
|
|
@@ -410,28 +459,6 @@ function handleEverythingHooks(record, meta) {
|
|
|
410
459
|
}
|
|
411
460
|
}
|
|
412
461
|
|
|
413
|
-
// Hook #10: CORE VIEWING LOOP — subscription_plan property scaling.
|
|
414
|
-
// Premium users watch more efficiently (shorter durations), free users
|
|
415
|
-
// linger (longer durations). Scales watch_duration_min on playback
|
|
416
|
-
// completed events BEFORE H3/H8/H9 so each subsequent hook amplifies
|
|
417
|
-
// from the plan-adjusted base.
|
|
418
|
-
// Discover via: Insights → playback completed → Avg watch_duration_min → breakdown subscription_plan.
|
|
419
|
-
{
|
|
420
|
-
const plan = stampPlan;
|
|
421
|
-
const factor = (
|
|
422
|
-
plan === "premium" ? PLAN_TTC_PREMIUM :
|
|
423
|
-
plan === "free" ? PLAN_TTC_FREE :
|
|
424
|
-
1.0
|
|
425
|
-
);
|
|
426
|
-
if (factor !== 1.0) {
|
|
427
|
-
for (const e of userEvents) {
|
|
428
|
-
if (e.event === "playback completed" && typeof e.watch_duration_min === "number") {
|
|
429
|
-
e.watch_duration_min = Math.round(e.watch_duration_min * factor);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
462
|
// Hook #3: WEEKEND VS WEEKDAY — 1.5x watch_duration_min on weekends.
|
|
436
463
|
// No flag — analyst breaks down by day of week.
|
|
437
464
|
for (const e of userEvents) {
|
|
@@ -902,3 +929,475 @@ const config = {
|
|
|
902
929
|
};
|
|
903
930
|
|
|
904
931
|
export default config;
|
|
932
|
+
|
|
933
|
+
// ── STORIES (v1.6 verification) ──
|
|
934
|
+
/*
|
|
935
|
+
* Derivation notes (bands centered on knob-derived mechanisms, sized from
|
|
936
|
+
* the 2K reduced-scale measurement pass; scale guards at ~50% of expected
|
|
937
|
+
* 10K populations so reduced runs read WEAK by design):
|
|
938
|
+
*
|
|
939
|
+
* - H1 uses a completions-per-selection ratio-of-ratios vs ANIMATION:
|
|
940
|
+
* animation absorbs the same H6 selection-count inflation as documentary,
|
|
941
|
+
* and per-selection normalization cancels the favored-index popularity
|
|
942
|
+
* skew (action/romance run hot). 0.75 mechanism, 0.739 measured.
|
|
943
|
+
* - H2/H8/H9 cohorts are re-derived from FINAL output; hook-time vs
|
|
944
|
+
* final-count migration (H4 drops post-d45 events incl. rec clicks /
|
|
945
|
+
* subtitle toggles) is part of the measured mechanism.
|
|
946
|
+
* - H7 normalizes rated counts by total events pre/post day 60 — cancels
|
|
947
|
+
* the user-growth ramp. 1/0.7 = 1.43 mechanism; H4 thins the post-window
|
|
948
|
+
* denominator, nudging the measured value to ~1.50.
|
|
949
|
+
* - H9 over/sweet = 0.5/1.25 = 0.40 exact: both buckets share the
|
|
950
|
+
* high-engagement confound, which cancels in the pair.
|
|
951
|
+
* - H10 TTC uses a 6-HOUR conversion window — the within-session read.
|
|
952
|
+
* At multi-day windows each plan's TTC distribution is bimodal
|
|
953
|
+
* (compressed within-session mode + 6-72h cross-session organic tail)
|
|
954
|
+
* with the median sitting on the ~50% mode boundary; a 3-day window
|
|
955
|
+
* read 0.70 prem/std at 2K and 1.14 at 10K from the same config. At 6h
|
|
956
|
+
* the medians are unimodal and scale-stable (free 2.11h / std 1.52h /
|
|
957
|
+
* prem 1.01h at both scales; free/prem 2.089 vs 2.0896 mechanism).
|
|
958
|
+
* Insights read (avg duration by plan) is the primary; 2.09 mechanism
|
|
959
|
+
* diluted to ~2.0 by fresh-draw clone durations.
|
|
960
|
+
* - H4/H5 windows: hook computes day boundaries via local-mode dayjs from
|
|
961
|
+
* datasetStart; all fixed windows (day 50/60/65) land before the
|
|
962
|
+
* 2026-03-08 US DST shift, so boundaries are exact UTC midnights. The
|
|
963
|
+
* per-user +45d ad-fatigue cutoff can drift 1h after March 8 —
|
|
964
|
+
* immaterial at day granularity.
|
|
965
|
+
*/
|
|
966
|
+
|
|
967
|
+
const EV = `read_json_auto('{{PREFIX}}-EVENTS*.json', sample_size=-1, union_by_name=true)`;
|
|
968
|
+
|
|
969
|
+
const bandVerdict = (x, nailed, strong, detail, inverse = () => false) => {
|
|
970
|
+
if (x == null || Number.isNaN(Number(x))) return { verdict: "NONE", detail: `${detail} — metric missing` };
|
|
971
|
+
const v = Number(x);
|
|
972
|
+
if (inverse(v)) return { verdict: "INVERSE", detail };
|
|
973
|
+
if (v >= nailed[0] && v <= nailed[1]) return { verdict: "NAILED", detail };
|
|
974
|
+
if (v >= strong[0] && v <= strong[1]) return { verdict: "STRONG", detail };
|
|
975
|
+
return { verdict: "WEAK", detail };
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
const guarded = (ok, detail, inner) => ok ? inner() : { verdict: "WEAK", detail: `${detail} — cohort below scale guard (expected at reduced scale)` };
|
|
979
|
+
|
|
980
|
+
export const stories = [
|
|
981
|
+
{
|
|
982
|
+
id: "media-h1-genre-funnel",
|
|
983
|
+
hook: "H1",
|
|
984
|
+
archetype: "funnel-conversion-by-segment",
|
|
985
|
+
narrative: "Documentary playback completions are dropped 25%, depressing the documentary core-viewing funnel. Clean read: completions-per-selection vs animation (shared H6 denominator inflation cancels).",
|
|
986
|
+
assertions: [
|
|
987
|
+
{
|
|
988
|
+
breakdown: {
|
|
989
|
+
type: "duckdb",
|
|
990
|
+
sql: `WITH g AS (
|
|
991
|
+
SELECT genre,
|
|
992
|
+
count(*) FILTER (WHERE event = 'content selected') AS sel_n,
|
|
993
|
+
count(*) FILTER (WHERE event = 'playback completed') AS comp_n
|
|
994
|
+
FROM ${EV}
|
|
995
|
+
WHERE genre IS NOT NULL AND event IN ('content selected', 'playback completed')
|
|
996
|
+
GROUP BY genre
|
|
997
|
+
)
|
|
998
|
+
SELECT
|
|
999
|
+
max(comp_n) FILTER (WHERE genre = 'documentary') AS doc_comp,
|
|
1000
|
+
max(comp_n) FILTER (WHERE genre = 'animation') AS anim_comp,
|
|
1001
|
+
(max(comp_n) FILTER (WHERE genre = 'documentary')::DOUBLE / max(sel_n) FILTER (WHERE genre = 'documentary'))
|
|
1002
|
+
/ (max(comp_n) FILTER (WHERE genre = 'animation')::DOUBLE / max(sel_n) FILTER (WHERE genre = 'animation')) AS ror
|
|
1003
|
+
FROM g`,
|
|
1004
|
+
},
|
|
1005
|
+
assert: (rows) => {
|
|
1006
|
+
const r = rows[0] || {};
|
|
1007
|
+
const detail = `doc/anim comp-per-sel RoR=${Number(r.ror).toFixed(3)} (mechanism 0.75; doc_comp=${r.doc_comp} anim_comp=${r.anim_comp})`;
|
|
1008
|
+
return guarded(Number(r.doc_comp) >= 1700 && Number(r.anim_comp) >= 2400, detail,
|
|
1009
|
+
() => bandVerdict(r.ror, [0.68, 0.82], [0.60, 0.90], detail, v => v >= 0.95));
|
|
1010
|
+
},
|
|
1011
|
+
},
|
|
1012
|
+
],
|
|
1013
|
+
},
|
|
1014
|
+
{
|
|
1015
|
+
id: "media-h2-binge-watching",
|
|
1016
|
+
hook: "H2",
|
|
1017
|
+
archetype: "cohort-count-scale",
|
|
1018
|
+
narrative: "Users with 3+ consecutive completions get 40% of completions cloned as start+complete pairs and 60% of pauses dropped. Binge cohort shows ~1.6x completions per user (1.4x clones x activity confound).",
|
|
1019
|
+
assertions: [
|
|
1020
|
+
{
|
|
1021
|
+
breakdown: {
|
|
1022
|
+
type: "duckdb",
|
|
1023
|
+
sql: `WITH seq AS (
|
|
1024
|
+
SELECT user_id::VARCHAR AS uid, event,
|
|
1025
|
+
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY time::TIMESTAMP, event) AS rn
|
|
1026
|
+
FROM ${EV} WHERE event != 'playback started'
|
|
1027
|
+
), runs AS (
|
|
1028
|
+
SELECT uid, rn - ROW_NUMBER() OVER (PARTITION BY uid ORDER BY rn) AS grp
|
|
1029
|
+
FROM seq WHERE event = 'playback completed'
|
|
1030
|
+
), streaks AS (
|
|
1031
|
+
SELECT uid, max(cnt) AS max_streak
|
|
1032
|
+
FROM (SELECT uid, grp, count(*) AS cnt FROM runs GROUP BY uid, grp) GROUP BY uid
|
|
1033
|
+
), pu AS (
|
|
1034
|
+
SELECT user_id::VARCHAR AS uid,
|
|
1035
|
+
count(*) FILTER (WHERE event = 'playback completed') AS completions
|
|
1036
|
+
FROM ${EV} GROUP BY 1
|
|
1037
|
+
)
|
|
1038
|
+
SELECT coalesce(s.max_streak >= 3, false) AS binge, count(*) AS users,
|
|
1039
|
+
avg(p.completions) AS completions_pu
|
|
1040
|
+
FROM pu p LEFT JOIN streaks s USING (uid)
|
|
1041
|
+
GROUP BY (coalesce(s.max_streak >= 3, false))`,
|
|
1042
|
+
},
|
|
1043
|
+
assert: (rows) => {
|
|
1044
|
+
const binge = rows.find(r => r.binge === true || r.binge === "true");
|
|
1045
|
+
const rest = rows.find(r => r.binge === false || r.binge === "false");
|
|
1046
|
+
if (!binge || !rest) return { verdict: "NONE", detail: "binge/rest buckets missing" };
|
|
1047
|
+
const ratio = Number(binge.completions_pu) / Number(rest.completions_pu);
|
|
1048
|
+
const detail = `binge ${Number(binge.completions_pu).toFixed(2)} vs rest ${Number(rest.completions_pu).toFixed(2)} completions/user = ${ratio.toFixed(2)}x (mechanism ~1.6; binge n=${binge.users})`;
|
|
1049
|
+
return guarded(Number(binge.users) >= 420 && Number(rest.users) >= 4500, detail,
|
|
1050
|
+
() => bandVerdict(ratio, [1.45, 1.85], [1.30, 2.10], detail, v => v <= 1.05));
|
|
1051
|
+
},
|
|
1052
|
+
},
|
|
1053
|
+
],
|
|
1054
|
+
},
|
|
1055
|
+
{
|
|
1056
|
+
id: "media-h3-weekend-duration",
|
|
1057
|
+
hook: "H3",
|
|
1058
|
+
archetype: "bespoke",
|
|
1059
|
+
narrative: "Saturday/Sunday completions get watch_duration_min x1.5. Measured ~1.45 — binge/subtitle clones carry fresh unscaled durations and dilute both buckets asymmetrically.",
|
|
1060
|
+
assertions: [
|
|
1061
|
+
{
|
|
1062
|
+
breakdown: {
|
|
1063
|
+
type: "duckdb",
|
|
1064
|
+
sql: `SELECT
|
|
1065
|
+
avg(watch_duration_min) FILTER (WHERE dayofweek(time::TIMESTAMP) IN (0, 6)) AS wkn,
|
|
1066
|
+
avg(watch_duration_min) FILTER (WHERE dayofweek(time::TIMESTAMP) NOT IN (0, 6)) AS wkd,
|
|
1067
|
+
count(*) FILTER (WHERE dayofweek(time::TIMESTAMP) IN (0, 6)) AS n_wkn
|
|
1068
|
+
FROM ${EV}
|
|
1069
|
+
WHERE event = 'playback completed' AND watch_duration_min IS NOT NULL`,
|
|
1070
|
+
},
|
|
1071
|
+
assert: (rows) => {
|
|
1072
|
+
const r = rows[0] || {};
|
|
1073
|
+
const ratio = Number(r.wkn) / Number(r.wkd);
|
|
1074
|
+
const detail = `weekend ${Number(r.wkn).toFixed(1)} vs weekday ${Number(r.wkd).toFixed(1)} min = ${ratio.toFixed(3)}x (mechanism 1.5 diluted to ~1.45; n_wkn=${r.n_wkn})`;
|
|
1075
|
+
return guarded(Number(r.n_wkn) >= 10000, detail,
|
|
1076
|
+
() => bandVerdict(ratio, [1.38, 1.55], [1.25, 1.65], detail, v => v <= 1.05));
|
|
1077
|
+
},
|
|
1078
|
+
},
|
|
1079
|
+
],
|
|
1080
|
+
},
|
|
1081
|
+
{
|
|
1082
|
+
id: "media-h4-ad-fatigue",
|
|
1083
|
+
hook: "H4",
|
|
1084
|
+
archetype: "retention-divergence",
|
|
1085
|
+
narrative: "Users with 5+ ad impressions in their first 45 days lose ~95% of events after day 45 of their lifecycle. Post/pre event ratio for the fatigued cohort collapses to ~0.04x the rest.",
|
|
1086
|
+
assertions: [
|
|
1087
|
+
{
|
|
1088
|
+
breakdown: {
|
|
1089
|
+
type: "duckdb",
|
|
1090
|
+
sql: `WITH pu AS (
|
|
1091
|
+
SELECT user_id::VARCHAR AS uid, min(time::TIMESTAMP) AS t0, max(time::TIMESTAMP) AS tlast
|
|
1092
|
+
FROM ${EV} GROUP BY 1
|
|
1093
|
+
), flags AS (
|
|
1094
|
+
SELECT p.uid,
|
|
1095
|
+
count(*) FILTER (WHERE e.event = 'ad impression' AND e.time::TIMESTAMP < p.t0 + INTERVAL 45 DAY) AS early_ads,
|
|
1096
|
+
count(*) FILTER (WHERE e.time::TIMESTAMP > p.t0 + INTERVAL 45 DAY) AS post,
|
|
1097
|
+
count(*) FILTER (WHERE e.time::TIMESTAMP <= p.t0 + INTERVAL 45 DAY) AS pre
|
|
1098
|
+
FROM ${EV} e JOIN pu p ON e.user_id::VARCHAR = p.uid
|
|
1099
|
+
WHERE p.tlast > p.t0 + INTERVAL 52 DAY
|
|
1100
|
+
GROUP BY 1
|
|
1101
|
+
)
|
|
1102
|
+
SELECT (early_ads >= 5) AS fatigued, count(*) AS users,
|
|
1103
|
+
avg(post::DOUBLE / nullif(pre, 0)) AS post_pre
|
|
1104
|
+
FROM flags WHERE pre > 0
|
|
1105
|
+
GROUP BY (early_ads >= 5)`,
|
|
1106
|
+
},
|
|
1107
|
+
assert: (rows) => {
|
|
1108
|
+
const fat = rows.find(r => r.fatigued === true || r.fatigued === "true");
|
|
1109
|
+
const rest = rows.find(r => r.fatigued === false || r.fatigued === "false");
|
|
1110
|
+
if (!fat || !rest) return { verdict: "NONE", detail: "fatigued/rest buckets missing" };
|
|
1111
|
+
const ratio = Number(fat.post_pre) / Number(rest.post_pre);
|
|
1112
|
+
const detail = `fatigued post/pre ${Number(fat.post_pre).toFixed(3)} vs rest ${Number(rest.post_pre).toFixed(3)} = ${ratio.toFixed(3)}x (mechanism ~0.05; fatigued n=${fat.users})`;
|
|
1113
|
+
return guarded(Number(fat.users) >= 400 && Number(rest.users) >= 4000, detail,
|
|
1114
|
+
() => bandVerdict(ratio, [0.02, 0.08], [0.01, 0.15], detail, v => v >= 0.5));
|
|
1115
|
+
},
|
|
1116
|
+
},
|
|
1117
|
+
],
|
|
1118
|
+
},
|
|
1119
|
+
{
|
|
1120
|
+
id: "media-h5-blockbuster-spike",
|
|
1121
|
+
hook: "H5",
|
|
1122
|
+
archetype: "temporal-inflection",
|
|
1123
|
+
narrative: "Days 50-65: 20% of content-selected events swap to the blockbuster id; blockbuster ratings are pinned 4-5. Zero blockbuster traffic outside the window.",
|
|
1124
|
+
assertions: [
|
|
1125
|
+
{
|
|
1126
|
+
breakdown: {
|
|
1127
|
+
type: "duckdb",
|
|
1128
|
+
sql: `SELECT
|
|
1129
|
+
avg((content_id LIKE 'blockbuster%')::INT) FILTER (WHERE in_win) AS in_share,
|
|
1130
|
+
avg((content_id LIKE 'blockbuster%')::INT) FILTER (WHERE NOT in_win) AS out_share,
|
|
1131
|
+
count(*) FILTER (WHERE in_win) AS n_in
|
|
1132
|
+
FROM (
|
|
1133
|
+
SELECT content_id,
|
|
1134
|
+
date_diff('day', TIMESTAMP '2026-01-01 00:00:00', time::TIMESTAMP) BETWEEN 50 AND 64 AS in_win
|
|
1135
|
+
FROM ${EV} WHERE event = 'content selected'
|
|
1136
|
+
)`,
|
|
1137
|
+
},
|
|
1138
|
+
assert: (rows) => {
|
|
1139
|
+
const r = rows[0] || {};
|
|
1140
|
+
const detail = `in-window blockbuster share=${Number(r.in_share).toFixed(4)} (mechanism 0.20), out-window=${Number(r.out_share).toFixed(5)}, n_in=${r.n_in}`;
|
|
1141
|
+
return guarded(Number(r.n_in) >= 11000, detail, () => {
|
|
1142
|
+
if (Number(r.out_share) > 0.002) return { verdict: "WEAK", detail: `${detail} — blockbuster leaked outside window` };
|
|
1143
|
+
return bandVerdict(r.in_share, [0.18, 0.22], [0.15, 0.25], detail, v => v <= 0.02);
|
|
1144
|
+
});
|
|
1145
|
+
},
|
|
1146
|
+
},
|
|
1147
|
+
{
|
|
1148
|
+
breakdown: {
|
|
1149
|
+
type: "duckdb",
|
|
1150
|
+
sql: `SELECT count(*) AS n, min(rating) AS min_rating
|
|
1151
|
+
FROM ${EV}
|
|
1152
|
+
WHERE event = 'content rated' AND content_id LIKE 'blockbuster%'`,
|
|
1153
|
+
},
|
|
1154
|
+
assert: (rows) => {
|
|
1155
|
+
const r = rows[0] || {};
|
|
1156
|
+
const detail = `blockbuster ratings n=${r.n}, min=${r.min_rating} (all engineered 4-5)`;
|
|
1157
|
+
return guarded(Number(r.n) >= 280, detail, () => {
|
|
1158
|
+
if (Number(r.min_rating) >= 4) return { verdict: "NAILED", detail };
|
|
1159
|
+
if (Number(r.min_rating) >= 3) return { verdict: "WEAK", detail };
|
|
1160
|
+
return { verdict: "NONE", detail };
|
|
1161
|
+
});
|
|
1162
|
+
},
|
|
1163
|
+
},
|
|
1164
|
+
],
|
|
1165
|
+
},
|
|
1166
|
+
{
|
|
1167
|
+
id: "media-h6-kids-safety",
|
|
1168
|
+
hook: "H6",
|
|
1169
|
+
archetype: "composition-drift",
|
|
1170
|
+
narrative: "15% of content-selected events get genre forced to animation/documentary. Share rises from ~0.20 organic to ~0.32. Read on content selected only — playback started carries genre solely via funnel-2 props or this hook.",
|
|
1171
|
+
assertions: [
|
|
1172
|
+
{
|
|
1173
|
+
breakdown: {
|
|
1174
|
+
type: "duckdb",
|
|
1175
|
+
sql: `SELECT count(*) AS n,
|
|
1176
|
+
avg((genre IN ('animation', 'documentary'))::INT) AS kid_share
|
|
1177
|
+
FROM ${EV}
|
|
1178
|
+
WHERE event = 'content selected' AND genre IS NOT NULL`,
|
|
1179
|
+
},
|
|
1180
|
+
assert: (rows) => {
|
|
1181
|
+
const r = rows[0] || {};
|
|
1182
|
+
const detail = `animation+documentary share of selected=${Number(r.kid_share).toFixed(4)} (mechanism 0.15 + 0.85 x ~0.20 base = ~0.32; n=${r.n})`;
|
|
1183
|
+
return guarded(Number(r.n) >= 96000, detail,
|
|
1184
|
+
() => bandVerdict(r.kid_share, [0.29, 0.345], [0.26, 0.38], detail, v => v <= 0.23));
|
|
1185
|
+
},
|
|
1186
|
+
},
|
|
1187
|
+
],
|
|
1188
|
+
},
|
|
1189
|
+
{
|
|
1190
|
+
id: "media-h7-rec-improvement",
|
|
1191
|
+
hook: "H7",
|
|
1192
|
+
archetype: "temporal-inflection",
|
|
1193
|
+
narrative: "30% of pre-day-60 content-rated events dropped. Normalizing rated counts by total events cancels the user-growth ramp: post/pre share ratio = 1/0.7 = 1.43 mechanism.",
|
|
1194
|
+
assertions: [
|
|
1195
|
+
{
|
|
1196
|
+
breakdown: {
|
|
1197
|
+
type: "duckdb",
|
|
1198
|
+
sql: `WITH ev AS (
|
|
1199
|
+
SELECT event,
|
|
1200
|
+
date_diff('day', TIMESTAMP '2026-01-01 00:00:00', time::TIMESTAMP) < 60 AS pre
|
|
1201
|
+
FROM ${EV}
|
|
1202
|
+
)
|
|
1203
|
+
SELECT
|
|
1204
|
+
count(*) FILTER (WHERE pre AND event = 'content rated') AS pre_rated,
|
|
1205
|
+
count(*) FILTER (WHERE NOT pre AND event = 'content rated') AS post_rated,
|
|
1206
|
+
(count(*) FILTER (WHERE NOT pre AND event = 'content rated')::DOUBLE / count(*) FILTER (WHERE NOT pre))
|
|
1207
|
+
/ (count(*) FILTER (WHERE pre AND event = 'content rated')::DOUBLE / count(*) FILTER (WHERE pre)) AS share_ratio
|
|
1208
|
+
FROM ev`,
|
|
1209
|
+
},
|
|
1210
|
+
assert: (rows) => {
|
|
1211
|
+
const r = rows[0] || {};
|
|
1212
|
+
const detail = `post/pre rated-share ratio=${Number(r.share_ratio).toFixed(3)} (mechanism 1.43; pre_rated=${r.pre_rated} post_rated=${r.post_rated})`;
|
|
1213
|
+
return guarded(Number(r.pre_rated) >= 4500 && Number(r.post_rated) >= 7400, detail,
|
|
1214
|
+
() => bandVerdict(r.share_ratio, [1.35, 1.62], [1.20, 1.80], detail, v => v <= 1.05));
|
|
1215
|
+
},
|
|
1216
|
+
},
|
|
1217
|
+
],
|
|
1218
|
+
},
|
|
1219
|
+
{
|
|
1220
|
+
id: "media-h8-subtitle-cohort",
|
|
1221
|
+
hook: "H8",
|
|
1222
|
+
archetype: "cohort-prop-scale",
|
|
1223
|
+
narrative: "Subtitle-enabled users: completion_percent x1.25 (cap 100) plus 20% cloned completions. completion_percent is the clean read — the duration x1.15 is cancelled by subtitle users skewing into the H9 over-bucket.",
|
|
1224
|
+
assertions: [
|
|
1225
|
+
{
|
|
1226
|
+
breakdown: {
|
|
1227
|
+
type: "duckdb",
|
|
1228
|
+
sql: `WITH pu AS (
|
|
1229
|
+
SELECT user_id::VARCHAR AS uid,
|
|
1230
|
+
bool_or(event = 'subtitle toggled' AND action = 'enabled') AS subs
|
|
1231
|
+
FROM ${EV} GROUP BY 1
|
|
1232
|
+
)
|
|
1233
|
+
SELECT p.subs, count(DISTINCT p.uid) AS users, avg(e.completion_percent) AS avg_cp
|
|
1234
|
+
FROM ${EV} e JOIN pu p ON e.user_id::VARCHAR = p.uid
|
|
1235
|
+
WHERE e.event = 'playback completed' AND e.completion_percent IS NOT NULL
|
|
1236
|
+
GROUP BY p.subs`,
|
|
1237
|
+
},
|
|
1238
|
+
assert: (rows) => {
|
|
1239
|
+
const sub = rows.find(r => r.subs === true || r.subs === "true");
|
|
1240
|
+
const non = rows.find(r => r.subs === false || r.subs === "false");
|
|
1241
|
+
if (!sub || !non) return { verdict: "NONE", detail: "subtitle buckets missing" };
|
|
1242
|
+
const ratio = Number(sub.avg_cp) / Number(non.avg_cp);
|
|
1243
|
+
const detail = `subtitle avg completion_percent ${Number(sub.avg_cp).toFixed(1)} vs ${Number(non.avg_cp).toFixed(1)} = ${ratio.toFixed(3)}x (mechanism ~1.28 after cap; subs n=${sub.users})`;
|
|
1244
|
+
return guarded(Number(sub.users) >= 3600 && Number(non.users) >= 1300, detail,
|
|
1245
|
+
() => bandVerdict(ratio, [1.20, 1.36], [1.10, 1.45], detail, v => v <= 1.03));
|
|
1246
|
+
},
|
|
1247
|
+
},
|
|
1248
|
+
{
|
|
1249
|
+
breakdown: {
|
|
1250
|
+
type: "duckdb",
|
|
1251
|
+
sql: `WITH pu AS (
|
|
1252
|
+
SELECT user_id::VARCHAR AS uid,
|
|
1253
|
+
bool_or(event = 'subtitle toggled' AND action = 'enabled') AS subs,
|
|
1254
|
+
count(*) FILTER (WHERE event = 'playback completed') AS completions
|
|
1255
|
+
FROM ${EV} GROUP BY 1
|
|
1256
|
+
)
|
|
1257
|
+
SELECT subs, count(*) AS users, avg(completions) AS completions_pu
|
|
1258
|
+
FROM pu GROUP BY subs`,
|
|
1259
|
+
},
|
|
1260
|
+
assert: (rows) => {
|
|
1261
|
+
const sub = rows.find(r => r.subs === true || r.subs === "true");
|
|
1262
|
+
const non = rows.find(r => r.subs === false || r.subs === "false");
|
|
1263
|
+
if (!sub || !non) return { verdict: "NONE", detail: "subtitle buckets missing" };
|
|
1264
|
+
const ratio = Number(sub.completions_pu) / Number(non.completions_pu);
|
|
1265
|
+
const detail = `subtitle ${Number(sub.completions_pu).toFixed(2)} vs ${Number(non.completions_pu).toFixed(2)} completions/user = ${ratio.toFixed(2)}x (1.2x clones x activity; measured 1.53 @2K unweighted-per-user; subs n=${sub.users})`;
|
|
1266
|
+
return guarded(Number(sub.users) >= 3600 && Number(non.users) >= 1300, detail,
|
|
1267
|
+
() => bandVerdict(ratio, [1.38, 1.68], [1.25, 1.85], detail, v => v <= 1.0));
|
|
1268
|
+
},
|
|
1269
|
+
},
|
|
1270
|
+
],
|
|
1271
|
+
},
|
|
1272
|
+
{
|
|
1273
|
+
id: "media-h9-rec-magic-number",
|
|
1274
|
+
hook: "H9",
|
|
1275
|
+
archetype: "frequency-sweet-spot",
|
|
1276
|
+
narrative: "4-6 rec clicks: watch_duration_min x1.25. 7+: halved plus 55% completions dropped. over/sweet = 0.5/1.25 = 0.40 exact — the engagement confound cancels between the two high-activity buckets.",
|
|
1277
|
+
assertions: [
|
|
1278
|
+
{
|
|
1279
|
+
breakdown: {
|
|
1280
|
+
type: "duckdb",
|
|
1281
|
+
sql: `WITH pu AS (
|
|
1282
|
+
SELECT user_id::VARCHAR AS uid,
|
|
1283
|
+
count(*) FILTER (WHERE event = 'recommendation clicked') AS rcs
|
|
1284
|
+
FROM ${EV} GROUP BY 1
|
|
1285
|
+
)
|
|
1286
|
+
SELECT
|
|
1287
|
+
CASE WHEN p.rcs BETWEEN 4 AND 6 THEN 'sweet' WHEN p.rcs >= 7 THEN 'over' ELSE 'low' END AS bucket,
|
|
1288
|
+
count(DISTINCT p.uid) AS users, avg(e.watch_duration_min) AS avg_dur
|
|
1289
|
+
FROM ${EV} e JOIN pu p ON e.user_id::VARCHAR = p.uid
|
|
1290
|
+
WHERE e.event = 'playback completed' AND e.watch_duration_min IS NOT NULL
|
|
1291
|
+
GROUP BY (CASE WHEN p.rcs BETWEEN 4 AND 6 THEN 'sweet' WHEN p.rcs >= 7 THEN 'over' ELSE 'low' END)`,
|
|
1292
|
+
},
|
|
1293
|
+
assert: (rows) => {
|
|
1294
|
+
const sweet = rows.find(r => r.bucket === "sweet");
|
|
1295
|
+
const low = rows.find(r => r.bucket === "low");
|
|
1296
|
+
if (!sweet || !low) return { verdict: "NONE", detail: "sweet/low buckets missing" };
|
|
1297
|
+
const ratio = Number(sweet.avg_dur) / Number(low.avg_dur);
|
|
1298
|
+
const detail = `sweet ${Number(sweet.avg_dur).toFixed(1)} vs low ${Number(low.avg_dur).toFixed(1)} min = ${ratio.toFixed(3)}x (mechanism 1.25; sweet n=${sweet.users} low n=${low.users})`;
|
|
1299
|
+
return guarded(Number(sweet.users) >= 1390 && Number(low.users) >= 700, detail,
|
|
1300
|
+
() => bandVerdict(ratio, [1.15, 1.35], [1.05, 1.45], detail, v => v <= 0.98));
|
|
1301
|
+
},
|
|
1302
|
+
},
|
|
1303
|
+
{
|
|
1304
|
+
breakdown: {
|
|
1305
|
+
type: "duckdb",
|
|
1306
|
+
sql: `WITH pu AS (
|
|
1307
|
+
SELECT user_id::VARCHAR AS uid,
|
|
1308
|
+
count(*) FILTER (WHERE event = 'recommendation clicked') AS rcs
|
|
1309
|
+
FROM ${EV} GROUP BY 1
|
|
1310
|
+
)
|
|
1311
|
+
SELECT
|
|
1312
|
+
CASE WHEN p.rcs BETWEEN 4 AND 6 THEN 'sweet' WHEN p.rcs >= 7 THEN 'over' ELSE 'low' END AS bucket,
|
|
1313
|
+
count(DISTINCT p.uid) AS users, avg(e.watch_duration_min) AS avg_dur
|
|
1314
|
+
FROM ${EV} e JOIN pu p ON e.user_id::VARCHAR = p.uid
|
|
1315
|
+
WHERE e.event = 'playback completed' AND e.watch_duration_min IS NOT NULL
|
|
1316
|
+
GROUP BY (CASE WHEN p.rcs BETWEEN 4 AND 6 THEN 'sweet' WHEN p.rcs >= 7 THEN 'over' ELSE 'low' END)`,
|
|
1317
|
+
},
|
|
1318
|
+
assert: (rows) => {
|
|
1319
|
+
const sweet = rows.find(r => r.bucket === "sweet");
|
|
1320
|
+
const over = rows.find(r => r.bucket === "over");
|
|
1321
|
+
if (!sweet || !over) return { verdict: "NONE", detail: "sweet/over buckets missing" };
|
|
1322
|
+
const ratio = Number(over.avg_dur) / Number(sweet.avg_dur);
|
|
1323
|
+
const detail = `over ${Number(over.avg_dur).toFixed(1)} vs sweet ${Number(sweet.avg_dur).toFixed(1)} min = ${ratio.toFixed(3)}x (mechanism 0.40 exact; over n=${over.users})`;
|
|
1324
|
+
return guarded(Number(over.users) >= 2850 && Number(sweet.users) >= 1390, detail,
|
|
1325
|
+
() => bandVerdict(ratio, [0.35, 0.46], [0.28, 0.55], detail, v => v >= 0.90));
|
|
1326
|
+
},
|
|
1327
|
+
},
|
|
1328
|
+
],
|
|
1329
|
+
},
|
|
1330
|
+
{
|
|
1331
|
+
id: "media-h10-plan-scaling",
|
|
1332
|
+
hook: "H10",
|
|
1333
|
+
archetype: "funnel-ttc-by-segment",
|
|
1334
|
+
narrative: "Plan scaling applied once: premium 0.67x, free 1.4x on watch_duration_min AND on the first core-viewing funnel's step gaps. Insights read: free/premium ~2.0x duration. Funnel read: TTC medians at a 6-HOUR window — the within-session read. At multi-day windows every plan's TTC distribution is bimodal (compressed within-session mode vs 6-72h cross-session organic tail) with the median sitting on the ~50% mode boundary; it flips between modes on sampling noise (2K read 0.70 prem/std, 10K read 1.14 from the same config). At 6h the distributions are unimodal and scale-stable: free 2.11h / std 1.52h / prem 1.01h medians at both 2K and 10K. Identity invariants ride along.",
|
|
1335
|
+
assertions: [
|
|
1336
|
+
{
|
|
1337
|
+
breakdown: {
|
|
1338
|
+
type: "duckdb",
|
|
1339
|
+
sql: `SELECT subscription_plan AS plan, count(*) AS n, avg(watch_duration_min) AS avg_dur
|
|
1340
|
+
FROM ${EV}
|
|
1341
|
+
WHERE event = 'playback completed' AND watch_duration_min IS NOT NULL AND subscription_plan IS NOT NULL
|
|
1342
|
+
GROUP BY subscription_plan`,
|
|
1343
|
+
},
|
|
1344
|
+
assert: (rows) => {
|
|
1345
|
+
const free = rows.find(r => r.plan === "free");
|
|
1346
|
+
const prem = rows.find(r => r.plan === "premium");
|
|
1347
|
+
const std = rows.find(r => r.plan === "standard");
|
|
1348
|
+
if (!free || !prem || !std) return { verdict: "NONE", detail: "plan buckets missing" };
|
|
1349
|
+
const ratio = Number(free.avg_dur) / Number(prem.avg_dur);
|
|
1350
|
+
const ordered = Number(prem.avg_dur) < Number(std.avg_dur) && Number(std.avg_dur) < Number(free.avg_dur);
|
|
1351
|
+
const detail = `free ${Number(free.avg_dur).toFixed(1)} / premium ${Number(prem.avg_dur).toFixed(1)} = ${ratio.toFixed(3)}x (mechanism 2.09 diluted; standard ${Number(std.avg_dur).toFixed(1)} ordered=${ordered})`;
|
|
1352
|
+
return guarded(Number(prem.n) >= 8300 && Number(free.n) >= 18900, detail, () => {
|
|
1353
|
+
if (!ordered) return { verdict: "WEAK", detail: `${detail} — plan ordering broken` };
|
|
1354
|
+
return bandVerdict(ratio, [1.85, 2.20], [1.60, 2.40], detail, v => v <= 1.10);
|
|
1355
|
+
});
|
|
1356
|
+
},
|
|
1357
|
+
},
|
|
1358
|
+
{
|
|
1359
|
+
breakdown: {
|
|
1360
|
+
type: "timeToConvert",
|
|
1361
|
+
steps: ["content browsed", "content selected", "playback started", "playback completed"],
|
|
1362
|
+
breakdownByUserProperty: "subscription_plan",
|
|
1363
|
+
conversionWindowMs: 6 * 3600 * 1000,
|
|
1364
|
+
},
|
|
1365
|
+
assert: (rows) => {
|
|
1366
|
+
const cell = Object.fromEntries(rows.map(r => [r.segment_value, r]));
|
|
1367
|
+
const med = t => cell[t] ? Number(cell[t].median_ttc_ms) : null;
|
|
1368
|
+
if (!med("free") || !med("premium") || !med("standard")) return { verdict: "NONE", detail: "plan TTC cells missing" };
|
|
1369
|
+
const freePrem = med("free") / med("premium");
|
|
1370
|
+
const premStd = med("premium") / med("standard");
|
|
1371
|
+
const detail = `TTC free/premium=${freePrem.toFixed(2)} (mechanism 2.09), premium/standard=${premStd.toFixed(2)} (mechanism 0.67); free n=${cell.free.user_count} premium n=${cell.premium.user_count} (6h window)`;
|
|
1372
|
+
return guarded(Number(cell.free.user_count) >= 360 && Number(cell.premium.user_count) >= 170, detail, () => {
|
|
1373
|
+
const v1 = bandVerdict(freePrem, [1.90, 2.30], [1.60, 2.60], detail, v => v <= 1.10);
|
|
1374
|
+
const v2 = bandVerdict(premStd, [0.61, 0.73], [0.52, 0.85], detail, v => v >= 1.00);
|
|
1375
|
+
const order = ["INVERSE", "NONE", "WEAK", "STRONG", "NAILED"];
|
|
1376
|
+
const verdict = order.find(o => v1.verdict === o || v2.verdict === o) || "NONE";
|
|
1377
|
+
return { verdict, detail };
|
|
1378
|
+
});
|
|
1379
|
+
},
|
|
1380
|
+
},
|
|
1381
|
+
{
|
|
1382
|
+
breakdown: {
|
|
1383
|
+
type: "duckdb",
|
|
1384
|
+
sql: `SELECT
|
|
1385
|
+
avg((user_id IS NOT NULL)::INT) AS uid_share,
|
|
1386
|
+
avg((device_id IS NOT NULL)::INT) AS device_share,
|
|
1387
|
+
avg((subscription_plan IS NOT NULL)::INT) AS plan_share,
|
|
1388
|
+
count(DISTINCT device_id)::DOUBLE / count(DISTINCT user_id) AS devices_per_user
|
|
1389
|
+
FROM ${EV}`,
|
|
1390
|
+
},
|
|
1391
|
+
assert: (rows) => {
|
|
1392
|
+
const r = rows[0] || {};
|
|
1393
|
+
const uid = Number(r.uid_share), dev = Number(r.device_share), plan = Number(r.plan_share), dpu = Number(r.devices_per_user);
|
|
1394
|
+
const detail = `uid=${uid.toFixed(4)} device=${dev.toFixed(4)} plan=${plan.toFixed(4)} devices/user=${dpu.toFixed(2)} (avgDevicePerUser=2)`;
|
|
1395
|
+
if (uid < 0.9) return { verdict: "INVERSE", detail };
|
|
1396
|
+
if (uid === 1 && dev >= 0.99 && plan >= 0.995 && dpu >= 1.6 && dpu <= 2.4) return { verdict: "NAILED", detail };
|
|
1397
|
+
if (uid >= 0.999 && dev >= 0.98 && plan >= 0.99) return { verdict: "STRONG", detail };
|
|
1398
|
+
return { verdict: "WEAK", detail };
|
|
1399
|
+
},
|
|
1400
|
+
},
|
|
1401
|
+
],
|
|
1402
|
+
},
|
|
1403
|
+
];
|