@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.
Files changed (146) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +21 -12
  2. package/.claude/skills/create-dungeon/SKILL.md +65 -5
  3. package/.claude/skills/create-project/SKILL.md +1 -1
  4. package/.claude/skills/create-project/provision.mjs +57 -10
  5. package/.claude/skills/verify-dungeon/SKILL.md +56 -25
  6. package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
  7. package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
  8. package/.claude/skills/write-hooks/SKILL.md +156 -18
  9. package/CHANGELOG.md +183 -0
  10. package/HOOKS.md +566 -112
  11. package/README.md +28 -9
  12. package/docs/guides/1.3.0-upgrade-guide.md +262 -0
  13. package/docs/guides/1.3.1-upgrade-guide.md +328 -0
  14. package/docs/guides/1.4.0-upgrade-guide.md +130 -0
  15. package/docs/guides/1.4.1-upgrade-guide.md +125 -0
  16. package/docs/guides/1.4.3-upgrade-guide.md +200 -0
  17. package/docs/guides/1.4.4-upgrade-guide.md +95 -0
  18. package/docs/guides/1.5.0-upgrade-guide.md +426 -0
  19. package/docs/guides/1.5.1-upgrade-guide.md +178 -0
  20. package/docs/guides/1.5.3-upgrade-guide.md +117 -0
  21. package/docs/guides/1.6.0-upgrade-guide.md +204 -0
  22. package/dungeons/technical/stories-verify.js +152 -0
  23. package/dungeons/vertical/README.md +154 -0
  24. package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
  25. package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
  26. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
  27. package/dungeons/vertical/community/community.js +1589 -0
  28. package/dungeons/vertical/community/community.sql +216 -0
  29. package/dungeons/vertical/community/community.verify.mjs +72 -0
  30. package/dungeons/vertical/crypto/crypto.js +1483 -0
  31. package/dungeons/vertical/crypto/crypto.sql +194 -0
  32. package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
  33. package/dungeons/vertical/dating/dating.js +1540 -0
  34. package/dungeons/vertical/dating/dating.sql +288 -0
  35. package/dungeons/vertical/dating/dating.verify.mjs +72 -0
  36. package/dungeons/vertical/devtools/devtools.js +1524 -0
  37. package/dungeons/vertical/devtools/devtools.sql +161 -0
  38. package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
  39. package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
  40. package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
  41. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
  42. package/dungeons/vertical/education/education.js +1763 -0
  43. package/dungeons/vertical/education/education.sql +243 -0
  44. package/dungeons/vertical/education/education.verify.mjs +75 -0
  45. package/dungeons/vertical/fintech/fintech.js +1410 -0
  46. package/dungeons/vertical/fintech/fintech.sql +175 -0
  47. package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
  48. package/dungeons/vertical/fitness/fitness.js +1495 -0
  49. package/dungeons/vertical/fitness/fitness.sql +206 -0
  50. package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
  51. package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
  52. package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
  53. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
  54. package/dungeons/vertical/gaming/gaming.js +1741 -0
  55. package/dungeons/vertical/gaming/gaming.sql +249 -0
  56. package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
  57. package/dungeons/vertical/healthcare/healthcare.js +1488 -0
  58. package/dungeons/vertical/healthcare/healthcare.sql +193 -0
  59. package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
  60. package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
  61. package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
  62. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
  63. package/dungeons/vertical/logistics/logistics.js +1541 -0
  64. package/dungeons/vertical/logistics/logistics.sql +205 -0
  65. package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
  66. package/dungeons/vertical/marketplace/marketplace.js +1438 -0
  67. package/dungeons/vertical/marketplace/marketplace.sql +148 -0
  68. package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
  69. package/dungeons/vertical/{media.js → media/media.js} +557 -58
  70. package/dungeons/vertical/media/media.sql +173 -0
  71. package/dungeons/vertical/media/media.verify.mjs +74 -0
  72. package/dungeons/vertical/real-estate/real-estate.js +1629 -0
  73. package/dungeons/vertical/real-estate/real-estate.sql +167 -0
  74. package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
  75. package/dungeons/vertical/sass/sass.js +1733 -0
  76. package/dungeons/vertical/sass/sass.sql +173 -0
  77. package/dungeons/vertical/sass/sass.verify.mjs +74 -0
  78. package/dungeons/vertical/social/social.js +1456 -0
  79. package/dungeons/vertical/social/social.sql +160 -0
  80. package/dungeons/vertical/social/social.verify.mjs +76 -0
  81. package/dungeons/vertical/streaming/streaming.js +677 -0
  82. package/dungeons/vertical/streaming/streaming.sql +101 -0
  83. package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
  84. package/dungeons/vertical/support-desk/support-desk.js +741 -0
  85. package/dungeons/vertical/support-desk/support-desk.sql +137 -0
  86. package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
  87. package/dungeons/vertical/travel/travel.js +1603 -0
  88. package/dungeons/vertical/travel/travel.sql +236 -0
  89. package/dungeons/vertical/travel/travel.verify.mjs +72 -0
  90. package/index.js +2 -2
  91. package/lib/core/config-validator.js +37 -2
  92. package/lib/core/context.js +4 -2
  93. package/lib/core/dungeon-loader.js +20 -15
  94. package/lib/core/dungeon-to-json.js +22 -4
  95. package/lib/core/storage.js +3 -3
  96. package/lib/generators/funnels.js +36 -9
  97. package/lib/hook-helpers/cohort.js +53 -0
  98. package/lib/hook-helpers/index.js +8 -0
  99. package/lib/hook-helpers/mutate.js +1 -1
  100. package/lib/hook-helpers/shape.js +319 -0
  101. package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
  102. package/lib/hook-patterns/attributed-by-source.js +60 -58
  103. package/lib/hook-patterns/frequency-by-frequency.js +14 -4
  104. package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
  105. package/lib/hook-patterns/index.js +1 -1
  106. package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
  107. package/lib/orchestrators/user-loop.js +41 -0
  108. package/lib/templates/story-spec.schema.json +175 -0
  109. package/lib/utils/json-evaluator.js +1 -1
  110. package/lib/utils/utils.js +5 -4
  111. package/lib/verify/coerce.js +186 -0
  112. package/lib/verify/counting.js +270 -32
  113. package/lib/verify/emulate-breakdown.js +1285 -203
  114. package/lib/verify/first-time.js +89 -0
  115. package/lib/verify/flows.js +679 -0
  116. package/lib/verify/formula.js +259 -0
  117. package/lib/verify/funnel-engine.js +697 -113
  118. package/lib/verify/index.js +32 -4
  119. package/lib/verify/sessionize.js +275 -0
  120. package/lib/verify/story-runner.js +483 -0
  121. package/lib/verify/verify-dungeon.js +59 -37
  122. package/package.json +2 -1
  123. package/scripts/extract-dungeon-schema.mjs +2 -2
  124. package/scripts/run-many.mjs +2 -2
  125. package/scripts/verify-runner.mjs +2 -2
  126. package/scripts/verify-stories.mjs +244 -0
  127. package/types.d.ts +296 -30
  128. package/dungeons/vertical/ai-platform.js +0 -818
  129. package/dungeons/vertical/community.js +0 -966
  130. package/dungeons/vertical/crypto.js +0 -895
  131. package/dungeons/vertical/dating.js +0 -830
  132. package/dungeons/vertical/devtools.js +0 -1050
  133. package/dungeons/vertical/ecommerce.js +0 -811
  134. package/dungeons/vertical/education.js +0 -964
  135. package/dungeons/vertical/fintech.js +0 -946
  136. package/dungeons/vertical/fitness.js +0 -935
  137. package/dungeons/vertical/food-delivery.js +0 -934
  138. package/dungeons/vertical/gaming.js +0 -1120
  139. package/dungeons/vertical/healthcare.js +0 -945
  140. package/dungeons/vertical/insurance-application.js +0 -1039
  141. package/dungeons/vertical/logistics.js +0 -986
  142. package/dungeons/vertical/marketplace.js +0 -1019
  143. package/dungeons/vertical/real-estate.js +0 -872
  144. package/dungeons/vertical/sass.js +0 -995
  145. package/dungeons/vertical/social.js +0 -904
  146. package/dungeons/vertical/travel.js +0 -930
@@ -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 "../../lib/utils/utils.js";
6
+ import * as u from "@ak--47/dungeon-master/utils";
7
7
  import * as v from "ak-tools";
8
- import { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing.js";
9
- /** @typedef {import("../../types").Dungeon} Config */
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, ~600K events, 121 days (2026-01-01 → 2026-05-01)
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.7x conversion vs other genres
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.5x more completions per user
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.5x weekday avg
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 avg_post_d45_events < light_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 share is elevated above pure random
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-improvement window ~ 1.5x conversion
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 ~ 85% vs B ~ 68%
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 B
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 (over) has lower avg watch_duration_min than A (sweet)
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
- * Scales the existing watch_duration_min property no flag. Applied BEFORE
257
- * weekend/subtitle/rec-click hooks so each subsequent effect amplifies from
258
- * the plan-adjusted base.
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 ratio >= 2x
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 | Baseline | Hook Effect | Ratio
277
- * ─────────────────────────|─────────────────────────|----------|-------------|------
278
- * Genre Funnel Conversion | documentary funnel conv | 1x | 0.7x | -30%
279
- * Binge-Watching | completions per streak | 1x | ~ 1.5x | 1.5x
280
- * Weekend vs Weekday | weekend watch_duration | 1x | ~ 1.5x | 1.5x
281
- * Ad Fatigue Churn | heavy_ad post_d45 events| 1x | < light_ad | -93%
282
- * New Release Spike | blockbuster id share | 0% | ~ 20% | n/a
283
- * Kids Profile Safety | animation/doc share | baseline | + 15% | n/a
284
- * Rec Engine Improvement | content-rated post day60| 1x | ~ 1.5x | 1.5x
285
- * Subtitle Users | completion % | 68% | 85% | 1.25x
286
- * Rec-Click Magic Number | sweet watch duration | 1x | 1.25x | 1.25x
287
- * Rec-Click Magic Number | over watch_duration_min | 1x | 0.5x | -50%
288
- * Core Viewing Loop | free/premium duration | 1x | >= 2x | 2.09x
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 TTC premium 0.67x, free 1.4x.
372
- // Timestamp shift for Mixpanel funnel TTC + property scale for
373
- // Insights. Applied first so weekend/subtitle/rec-click hooks
374
- // amplify from the plan-adjusted base.
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 = profile ? profile.subscription_plan : "free";
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 * 10) / 10;
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
+ ];