@ak--47/dungeon-master 1.5.4 → 1.6.1

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 (149) 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/powertools/SKILL.md +75 -0
  6. package/.claude/skills/powertools/pt.mjs +91 -0
  7. package/.claude/skills/powertools/snapshot-project.mjs +124 -0
  8. package/.claude/skills/verify-dungeon/SKILL.md +56 -25
  9. package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
  10. package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
  11. package/.claude/skills/write-hooks/SKILL.md +156 -18
  12. package/CHANGELOG.md +224 -0
  13. package/HOOKS.md +566 -112
  14. package/README.md +30 -9
  15. package/docs/guides/1.3.0-upgrade-guide.md +262 -0
  16. package/docs/guides/1.3.1-upgrade-guide.md +328 -0
  17. package/docs/guides/1.4.0-upgrade-guide.md +130 -0
  18. package/docs/guides/1.4.1-upgrade-guide.md +125 -0
  19. package/docs/guides/1.4.3-upgrade-guide.md +200 -0
  20. package/docs/guides/1.4.4-upgrade-guide.md +95 -0
  21. package/docs/guides/1.5.0-upgrade-guide.md +426 -0
  22. package/docs/guides/1.5.1-upgrade-guide.md +178 -0
  23. package/docs/guides/1.5.3-upgrade-guide.md +117 -0
  24. package/docs/guides/1.6.0-upgrade-guide.md +204 -0
  25. package/dungeons/technical/stories-verify.js +152 -0
  26. package/dungeons/vertical/README.md +154 -0
  27. package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
  28. package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
  29. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
  30. package/dungeons/vertical/community/community.js +1589 -0
  31. package/dungeons/vertical/community/community.sql +216 -0
  32. package/dungeons/vertical/community/community.verify.mjs +72 -0
  33. package/dungeons/vertical/crypto/crypto.js +1483 -0
  34. package/dungeons/vertical/crypto/crypto.sql +194 -0
  35. package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
  36. package/dungeons/vertical/dating/dating.js +1540 -0
  37. package/dungeons/vertical/dating/dating.sql +288 -0
  38. package/dungeons/vertical/dating/dating.verify.mjs +72 -0
  39. package/dungeons/vertical/devtools/devtools.js +1524 -0
  40. package/dungeons/vertical/devtools/devtools.sql +161 -0
  41. package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
  42. package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
  43. package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
  44. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
  45. package/dungeons/vertical/education/education.js +1763 -0
  46. package/dungeons/vertical/education/education.sql +243 -0
  47. package/dungeons/vertical/education/education.verify.mjs +75 -0
  48. package/dungeons/vertical/fintech/fintech.js +1410 -0
  49. package/dungeons/vertical/fintech/fintech.sql +175 -0
  50. package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
  51. package/dungeons/vertical/fitness/fitness.js +1495 -0
  52. package/dungeons/vertical/fitness/fitness.sql +206 -0
  53. package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
  54. package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
  55. package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
  56. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
  57. package/dungeons/vertical/gaming/gaming.js +1741 -0
  58. package/dungeons/vertical/gaming/gaming.sql +249 -0
  59. package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
  60. package/dungeons/vertical/healthcare/healthcare.js +1488 -0
  61. package/dungeons/vertical/healthcare/healthcare.sql +193 -0
  62. package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
  63. package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
  64. package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
  65. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
  66. package/dungeons/vertical/logistics/logistics.js +1541 -0
  67. package/dungeons/vertical/logistics/logistics.sql +205 -0
  68. package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
  69. package/dungeons/vertical/marketplace/marketplace.js +1438 -0
  70. package/dungeons/vertical/marketplace/marketplace.sql +148 -0
  71. package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
  72. package/dungeons/vertical/{media.js → media/media.js} +557 -58
  73. package/dungeons/vertical/media/media.sql +173 -0
  74. package/dungeons/vertical/media/media.verify.mjs +74 -0
  75. package/dungeons/vertical/real-estate/real-estate.js +1629 -0
  76. package/dungeons/vertical/real-estate/real-estate.sql +167 -0
  77. package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
  78. package/dungeons/vertical/sass/sass.js +1733 -0
  79. package/dungeons/vertical/sass/sass.sql +173 -0
  80. package/dungeons/vertical/sass/sass.verify.mjs +74 -0
  81. package/dungeons/vertical/social/social.js +1456 -0
  82. package/dungeons/vertical/social/social.sql +160 -0
  83. package/dungeons/vertical/social/social.verify.mjs +76 -0
  84. package/dungeons/vertical/streaming/streaming.js +677 -0
  85. package/dungeons/vertical/streaming/streaming.sql +101 -0
  86. package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
  87. package/dungeons/vertical/support-desk/support-desk.js +741 -0
  88. package/dungeons/vertical/support-desk/support-desk.sql +137 -0
  89. package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
  90. package/dungeons/vertical/travel/travel.js +1603 -0
  91. package/dungeons/vertical/travel/travel.sql +236 -0
  92. package/dungeons/vertical/travel/travel.verify.mjs +72 -0
  93. package/index.js +7 -3
  94. package/lib/core/config-validator.js +37 -2
  95. package/lib/core/context.js +4 -2
  96. package/lib/core/dungeon-loader.js +20 -15
  97. package/lib/core/dungeon-to-json.js +22 -4
  98. package/lib/core/storage.js +3 -3
  99. package/lib/generators/funnels.js +36 -9
  100. package/lib/hook-helpers/cohort.js +53 -0
  101. package/lib/hook-helpers/index.js +8 -0
  102. package/lib/hook-helpers/mutate.js +1 -1
  103. package/lib/hook-helpers/shape.js +319 -0
  104. package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
  105. package/lib/hook-patterns/attributed-by-source.js +60 -58
  106. package/lib/hook-patterns/frequency-by-frequency.js +14 -4
  107. package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
  108. package/lib/hook-patterns/index.js +1 -1
  109. package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
  110. package/lib/orchestrators/user-loop.js +41 -0
  111. package/lib/templates/story-spec.schema.json +175 -0
  112. package/lib/utils/json-evaluator.js +1 -1
  113. package/lib/utils/utils.js +151 -59
  114. package/lib/verify/coerce.js +186 -0
  115. package/lib/verify/counting.js +270 -32
  116. package/lib/verify/emulate-breakdown.js +1285 -203
  117. package/lib/verify/first-time.js +89 -0
  118. package/lib/verify/flows.js +679 -0
  119. package/lib/verify/formula.js +259 -0
  120. package/lib/verify/funnel-engine.js +697 -113
  121. package/lib/verify/index.js +32 -4
  122. package/lib/verify/sessionize.js +275 -0
  123. package/lib/verify/story-runner.js +483 -0
  124. package/lib/verify/verify-dungeon.js +59 -37
  125. package/package.json +2 -1
  126. package/scripts/extract-dungeon-schema.mjs +2 -2
  127. package/scripts/run-many.mjs +2 -2
  128. package/scripts/verify-runner.mjs +2 -2
  129. package/scripts/verify-stories.mjs +244 -0
  130. package/types.d.ts +298 -30
  131. package/dungeons/vertical/ai-platform.js +0 -818
  132. package/dungeons/vertical/community.js +0 -966
  133. package/dungeons/vertical/crypto.js +0 -895
  134. package/dungeons/vertical/dating.js +0 -830
  135. package/dungeons/vertical/devtools.js +0 -1050
  136. package/dungeons/vertical/ecommerce.js +0 -811
  137. package/dungeons/vertical/education.js +0 -964
  138. package/dungeons/vertical/fintech.js +0 -946
  139. package/dungeons/vertical/fitness.js +0 -935
  140. package/dungeons/vertical/food-delivery.js +0 -934
  141. package/dungeons/vertical/gaming.js +0 -1120
  142. package/dungeons/vertical/healthcare.js +0 -945
  143. package/dungeons/vertical/insurance-application.js +0 -1039
  144. package/dungeons/vertical/logistics.js +0 -986
  145. package/dungeons/vertical/marketplace.js +0 -1019
  146. package/dungeons/vertical/real-estate.js +0 -872
  147. package/dungeons/vertical/sass.js +0 -995
  148. package/dungeons/vertical/social.js +0 -904
  149. package/dungeons/vertical/travel.js +0 -930
@@ -0,0 +1,677 @@
1
+ // ── IMPORTS ──
2
+ import dayjs from "dayjs";
3
+ import utc from "dayjs/plugin/utc.js";
4
+ dayjs.extend(utc);
5
+ import "dotenv/config";
6
+ import * as u from "@ak--47/dungeon-master/utils";
7
+ import { hashFloat, applyLifecycleWave } from "@ak--47/dungeon-master/hook-helpers";
8
+ /** @typedef {import("../../../types").Dungeon} Config */
9
+
10
+ // ── OVERVIEW ──
11
+ /*
12
+ * NAME: BingeBox
13
+ * APP: Netflix-style video streaming service: browse/search discovery,
14
+ * a watchlist, trailers, push-notification re-engagement, and one
15
+ * value moment — "content played".
16
+ * SCALE: 10,000 users, ~2.8M events, 151 days (2026-01-01 → 2026-05-31)
17
+ * CORE LOOP: signup → browse → content played → (watchlist, rate, return)
18
+ *
19
+ * PURPOSE: v1.6 LIFECYCLE SHOWCASE. This dungeon exists to demonstrate the
20
+ * `applyLifecycleWave` atom and the lifecycle/uniques emulator
21
+ * families — engineered New/Retained/Resurrected/Dormant waves at
22
+ * BOTH template periods (7d and 30d), plus a resurrection
23
+ * campaign attributable to a push_open burst. Everything else is
24
+ * deliberately plain: no SCDs, no campaigns, no group keys.
25
+ *
26
+ * EVENTS (9):
27
+ * content played (30) > browse (15) > search (8) > trailer viewed (6)
28
+ * > watchlist add (5) > push_open (4) > rating submitted (3)
29
+ * > plan upgraded (1) > signup (1)
30
+ *
31
+ * FUNNELS (4):
32
+ * - Onboarding: signup → browse → content played (75%, isFirstFunnel)
33
+ * - Discovery: search → trailer viewed → watchlist add (35%)
34
+ * - Watchlist: watchlist add → content played (50%)
35
+ * - Re-engage: push_open → browse → content played (45%)
36
+ *
37
+ * USER PROPS: plan, household_size, primary_device, kids_profile
38
+ * SUPER PROPS: plan, app_version
39
+ *
40
+ * IDENTITY: avgDevicePerUser: 2; signup is isFirstEvent + isAuthEvent, so
41
+ * every event carries user_id and ~all carry device_id.
42
+ *
43
+ * ═══════════════════════════════════════════════════════════════════
44
+ * ENGINEERED PATTERNS (hooks) — 3 hooks, disjoint hashFloat cohorts
45
+ * ═══════════════════════════════════════════════════════════════════
46
+ *
47
+ * Cohort gating uses disjoint hashFloat(uid) bands (NOT nested hashCohort
48
+ * calls — hashCohort(id, 15) ⊂ hashCohort(id, 30), so two waves gated that
49
+ * way would overlap and double-drop). All three waves apply only to
50
+ * "early-born" users (first event within the first EARLY_BORN_DAYS days of
51
+ * the dataset) so the birth-relative dormancy windows land on near-absolute
52
+ * calendar days and the lifecycle tiles read as a coherent cohort wave
53
+ * instead of a smear. Under the default flat macro ~88% of users are
54
+ * pre-existing and their first in-dataset event lands within a few days of
55
+ * datasetStart, so the early-born gate keeps most of each band.
56
+ *
57
+ * 1. WEEKLY LIFECYCLE WAVE (everything) — hashFloat band [0.00, 0.25)
58
+ * PATTERN: applyLifecycleWave drops every "content played" in the window
59
+ * [birth+42d, birth+63d] (21 days = 3 whole 7d periods) and appends a
60
+ * 4-clone resurrection burst 1-3h after the window. Browse/search/watchlist
61
+ * events survive the window — the user still visits, but stops hitting the
62
+ * value moment. That is exactly the state the Lifecycle template calls
63
+ * dormant (value-moment count EqualTo 0 in the period). The window starts
64
+ * 3 weeks after H2's so each wave owns its own 7d tiles: H2's dormant
65
+ * onset lands ~Jan 25, H1's ~Feb 22, H1's resurrection ~Mar 8, H2's ~Apr 1.
66
+ * Discovery: Lifecycle (7d period) on "content played" — a dormant hump
67
+ * ~6 weeks in, then a resurrected spike 3 weeks later.
68
+ *
69
+ * Report 1: Lifecycle, value moment "content played", 7-day period
70
+ * - Measure: dormant count peak across periods; resurrected count peak;
71
+ * retained count dip while the wave cohort is dark
72
+ * - Expected (2K reduced run, hooked | organic): dormant peak / quiet
73
+ * median 1.81 | 0.93; resurrected spike / quiet median 2.38 | 0.99;
74
+ * retained dip / quiet median 0.58 | 1.01.
75
+ *
76
+ * 2. MONTHLY LIFECYCLE WAVE (everything) — hashFloat band [0.25, 0.40)
77
+ * PATTERN: same atom, sized for the 30d template variant: window
78
+ * [birth+21d, birth+86d] (65 days ≥ 2 whole 30d periods), 5-clone burst.
79
+ * A 21-day gap would be invisible at the 30d period — the wave must span
80
+ * two whole tiles for the EqualTo-0 dormancy test to fire at this
81
+ * granularity; that's the "gap discipline" rule from HOOKS.md §2.16.
82
+ * Discovery: Lifecycle (30d period) on "content played" — dormant in the
83
+ * middle tiles, resurrected in the tile after the gap.
84
+ *
85
+ * Report 1: Lifecycle, value moment "content played", 30-day period
86
+ * - Measure: dormant count peak; resurrected count in the post-gap tile
87
+ * - Expected (2K, hooked | organic): dormant tile 305 vs 27 max
88
+ * elsewhere (ratio 11.3) | 12 vs 17 (0.71); resurrected tile 299 vs
89
+ * 28 max elsewhere (10.7) | 12 vs 8 (1.5 on noise-level counts).
90
+ *
91
+ * 3. RESURRECTION CAMPAIGN (everything) — hashFloat band [0.40, 0.52)
92
+ * PATTERN: applyLifecycleWave with dropAll: true erases the ENTIRE stream
93
+ * in [birth+100d, birth+130d] — a true 30-day disappearance, not just
94
+ * value-moment abstinence — then the standard burst brings the user back.
95
+ * After the wave, the hook clones the user's own "push_open" 2-3 times
96
+ * 15-45 minutes BEFORE the first post-gap "content played", making the
97
+ * resurrection attributable: the push burst precedes the return. Users with
98
+ * no push_open anywhere in their stream get no clones (schema-first: never
99
+ * fabricate) — the campaign story's cohort is "resurrected AND pushed",
100
+ * measured as a share.
101
+ * Discovery: Lifecycle resurrected spike + per-user event-sequence audit:
102
+ * returners' first post-gap play is preceded by push_open within 24h.
103
+ *
104
+ * Report 1: Funnels / sequence audit "push_open → content played"
105
+ * - Measure: share of long-gap (≥25d) returners whose first post-gap play
106
+ * has a push_open in the preceding 24h; long-gap population size
107
+ * - Expected (2K, hooked | organic): 195 gap users, 195 returners, 190
108
+ * pushed (share 0.974) | 8 gap users, 6 returners, 3 pushed. The 7d
109
+ * lifecycle also shows the return: resurrected spike / quiet median
110
+ * 1.76 | 0.95 in the May tiles.
111
+ *
112
+ * COMBINED EFFECT — WAU DOUBLE TROUGH: H1's and H2's dormancy windows
113
+ * overlap on absolute days ~42-63 (H2 spans 21-86, H1 spans 42-63), so WAU
114
+ * (uniques on "content played", 7-day rolling window) carves a deep
115
+ * mid-dataset trough, partially recovers as H1's burst lands, fully
116
+ * recovers after H2's, then dips again (smaller) for H3's total gap on
117
+ * days ~100-130 before the final recovery. The streaming-h4 story reads
118
+ * both trough/baseline ratios and the tail recovery.
119
+ * - Expected (2K, hooked | organic): trough1 (days 49-63 vs days 7-20
120
+ * baseline) 0.586 | 0.997; trough2 (days 107-137 vs local pre-dip
121
+ * days 93-99) 0.886 | 1.002; tail recovery vs baseline 1.09 | 1.09.
122
+ *
123
+ * NOTE: engine shape guarantees apply to no-hook configs; these hooks
124
+ * intentionally carve mid-dataset activity troughs (~52% of users go
125
+ * value-moment-dormant for 3-9 weeks). That is the point of the dungeon.
126
+ */
127
+
128
+ // ── SCALE ──
129
+ const SEED = "harness-streaming";
130
+ const NUM_USERS = 10_000;
131
+ const DATASET_START = "2026-01-01T00:00:00Z";
132
+ const DATASET_END = "2026-05-31T23:59:59Z";
133
+ const EVENTS_PER_DAY = 2.0;
134
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
135
+
136
+ const chance = u.initChance(SEED);
137
+
138
+ // ── KNOBS (tweak these to reshape stories) ──
139
+ // Disjoint hashFloat(uid) cohort bands — see OVERVIEW for why not hashCohort.
140
+ const WAVE7_BAND = [0.00, 0.25]; // H1: weekly-period wave, ~25% of users
141
+ const WAVE30_BAND = [0.25, 0.40]; // H2: monthly-period wave, ~15%
142
+ const CAMPAIGN_BAND = [0.40, 0.52]; // H3: dropAll + push-attributed return, ~12%
143
+
144
+ // Waves only apply to users born in the first N dataset days, pinning the
145
+ // birth-relative windows to near-absolute calendar days.
146
+ const EARLY_BORN_DAYS = 10;
147
+
148
+ const WAVE7_FROM_DAY = 42; // window [birth+42d, birth+63d]
149
+ const WAVE7_DAYS = 21; // 3 whole 7d periods (gap discipline)
150
+ const WAVE7_BURST = 4;
151
+
152
+ const WAVE30_FROM_DAY = 21; // window [birth+21d, birth+86d]
153
+ const WAVE30_DAYS = 65; // ≥ 2 whole 30d periods
154
+ const WAVE30_BURST = 5;
155
+
156
+ const CAMPAIGN_FROM_DAY = 100; // window [birth+100d, birth+130d], dropAll
157
+ const CAMPAIGN_DAYS = 30;
158
+ const CAMPAIGN_BURST = 4;
159
+ const CAMPAIGN_PUSH_CLONES_MIN = 2;
160
+ const CAMPAIGN_PUSH_CLONES_MAX = 3;
161
+ const CAMPAIGN_PUSH_LEAD_MIN_MIN = 15; // push lands 15-45 min before the return play
162
+ const CAMPAIGN_PUSH_LEAD_MAX_MIN = 45;
163
+
164
+ const VALUE_MOMENT = "content played";
165
+
166
+ const inBand = (x, [lo, hi]) => x >= lo && x < hi;
167
+
168
+ // ── HOOK ──
169
+ function hook(record, type, meta) {
170
+ if (type === "everything") {
171
+ const userEvents = record;
172
+ if (!Array.isArray(userEvents) || userEvents.length === 0) return record;
173
+ const uid = meta?.user?.distinct_id || userEvents.find(e => e?.user_id)?.user_id;
174
+ if (!uid) return record;
175
+
176
+ // Early-born gate: first event within EARLY_BORN_DAYS of datasetStart.
177
+ // meta.datasetStart is a unix timestamp (seconds), dataset times are UTC.
178
+ const datasetStart = dayjs.unix(meta.datasetStart).utc();
179
+ let firstMs = Infinity;
180
+ for (const e of userEvents) {
181
+ const t = dayjs.utc(e?.time).valueOf();
182
+ if (Number.isFinite(t) && t < firstMs) firstMs = t;
183
+ }
184
+ if (!Number.isFinite(firstMs)) return record;
185
+ const bornDay = dayjs.utc(firstMs).diff(datasetStart, "day");
186
+ if (bornDay >= EARLY_BORN_DAYS) return record;
187
+
188
+ const h = hashFloat(uid);
189
+
190
+ // H1: WEEKLY LIFECYCLE WAVE — 21-day value-moment gap + burst.
191
+ if (inBand(h, WAVE7_BAND)) {
192
+ return applyLifecycleWave(userEvents, uid, {
193
+ dormantFromDay: WAVE7_FROM_DAY,
194
+ dormantDays: WAVE7_DAYS,
195
+ resurrectBurst: WAVE7_BURST,
196
+ valueMomentEvent: VALUE_MOMENT,
197
+ });
198
+ }
199
+
200
+ // H2: MONTHLY LIFECYCLE WAVE — 65-day gap spans two whole 30d tiles.
201
+ if (inBand(h, WAVE30_BAND)) {
202
+ return applyLifecycleWave(userEvents, uid, {
203
+ dormantFromDay: WAVE30_FROM_DAY,
204
+ dormantDays: WAVE30_DAYS,
205
+ resurrectBurst: WAVE30_BURST,
206
+ valueMomentEvent: VALUE_MOMENT,
207
+ });
208
+ }
209
+
210
+ // H3: RESURRECTION CAMPAIGN — total 30-day disappearance (dropAll),
211
+ // then the user's own push_open cloned just before the return play so
212
+ // the resurrection is attributable to the push burst.
213
+ if (inBand(h, CAMPAIGN_BAND)) {
214
+ const shaped = applyLifecycleWave(userEvents, uid, {
215
+ dormantFromDay: CAMPAIGN_FROM_DAY,
216
+ dormantDays: CAMPAIGN_DAYS,
217
+ resurrectBurst: CAMPAIGN_BURST,
218
+ valueMomentEvent: VALUE_MOMENT,
219
+ dropAll: true,
220
+ });
221
+ const windowEndMs = firstMs + (CAMPAIGN_FROM_DAY + CAMPAIGN_DAYS) * 86400000;
222
+ let returnPlay = null;
223
+ for (const e of shaped) {
224
+ if (e?.event !== VALUE_MOMENT) continue;
225
+ const t = dayjs.utc(e.time).valueOf();
226
+ if (t > windowEndMs && (!returnPlay || t < dayjs.utc(returnPlay.time).valueOf())) {
227
+ returnPlay = e;
228
+ }
229
+ }
230
+ const pushTemplate = shaped.find(e => e?.event === "push_open");
231
+ if (returnPlay && pushTemplate) {
232
+ const returnMs = dayjs.utc(returnPlay.time).valueOf();
233
+ const clones = chance.integer({ min: CAMPAIGN_PUSH_CLONES_MIN, max: CAMPAIGN_PUSH_CLONES_MAX });
234
+ for (let i = 0; i < clones; i++) {
235
+ const leadMin = chance.integer({ min: CAMPAIGN_PUSH_LEAD_MIN_MIN, max: CAMPAIGN_PUSH_LEAD_MAX_MIN });
236
+ const clone = {
237
+ ...pushTemplate,
238
+ time: dayjs.utc(returnMs - leadMin * 60000).toISOString(),
239
+ user_id: pushTemplate.user_id || uid,
240
+ };
241
+ delete clone.insert_id;
242
+ shaped.push(clone);
243
+ }
244
+ }
245
+ return shaped;
246
+ }
247
+
248
+ return record;
249
+ }
250
+
251
+ return record;
252
+ }
253
+
254
+ // ── CONFIG ──
255
+ /** @type {Config} */
256
+ const config = {
257
+ version: 2,
258
+ seed: SEED,
259
+ datasetStart: DATASET_START,
260
+ datasetEnd: DATASET_END,
261
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
262
+ numUsers: NUM_USERS,
263
+ format: "json",
264
+ gzip: true,
265
+ credentials: {
266
+ token,
267
+ },
268
+ switches: {
269
+ hasSessionIds: true,
270
+ alsoInferFunnels: false,
271
+ hasLocation: true,
272
+ hasAndroidDevices: true,
273
+ hasIOSDevices: true,
274
+ hasDesktopDevices: true,
275
+ hasBrowser: true,
276
+ hasCampaigns: false,
277
+ isAnonymous: false,
278
+ hasAdSpend: false,
279
+ hasAvatar: false,
280
+ },
281
+ identity: {
282
+ avgDevicePerUser: 2,
283
+ },
284
+ concurrency: 1,
285
+ writeToDisk: false,
286
+
287
+ funnels: [
288
+ {
289
+ sequence: ["signup", "browse", "content played"],
290
+ isFirstFunnel: true,
291
+ conversionRate: 75,
292
+ timeToConvert: 1,
293
+ },
294
+ {
295
+ sequence: ["search", "trailer viewed", "watchlist add"],
296
+ conversionRate: 35,
297
+ timeToConvert: 0.5,
298
+ weight: 3,
299
+ },
300
+ {
301
+ sequence: ["watchlist add", "content played"],
302
+ conversionRate: 50,
303
+ timeToConvert: 24,
304
+ weight: 2,
305
+ },
306
+ {
307
+ sequence: ["push_open", "browse", "content played"],
308
+ conversionRate: 45,
309
+ timeToConvert: 0.5,
310
+ weight: 2,
311
+ },
312
+ ],
313
+
314
+ events: [
315
+ {
316
+ event: "signup",
317
+ weight: 1,
318
+ isFirstEvent: true,
319
+ isAuthEvent: true,
320
+ properties: {
321
+ signup_method: ["email", "google", "apple"],
322
+ referral_source: ["organic", "friend", "promo", "app_store"],
323
+ },
324
+ },
325
+ {
326
+ event: "content played",
327
+ weight: 30,
328
+ properties: {
329
+ content_type: ["movie", "series_episode", "series_episode", "documentary", "kids"],
330
+ genre: ["drama", "comedy", "thriller", "sci-fi", "romance", "animation", "true-crime"],
331
+ duration_watched_min: u.weighNumRange(1, 180, 0.3, 35),
332
+ completion_pct: u.weighNumRange(1, 100, 0.2, 70),
333
+ audio_language: ["en", "en", "en", "es", "fr", "de", "ja"],
334
+ is_download: [false, false, false, false, true],
335
+ },
336
+ },
337
+ {
338
+ event: "browse",
339
+ weight: 15,
340
+ properties: {
341
+ row_category: ["trending", "new_releases", "continue_watching", "recommended", "originals"],
342
+ rows_scrolled: u.weighNumRange(1, 30, 0.3, 6),
343
+ },
344
+ },
345
+ {
346
+ event: "search",
347
+ weight: 8,
348
+ properties: {
349
+ query_length: u.weighNumRange(2, 40, 0.3, 11),
350
+ results_count: u.weighNumRange(0, 200, 0.3, 25),
351
+ },
352
+ },
353
+ {
354
+ event: "trailer viewed",
355
+ weight: 6,
356
+ properties: {
357
+ content_type: ["movie", "series_episode", "documentary", "kids"],
358
+ watched_full: [true, false, false],
359
+ },
360
+ },
361
+ {
362
+ event: "watchlist add",
363
+ weight: 5,
364
+ properties: {
365
+ content_type: ["movie", "series_episode", "documentary", "kids"],
366
+ list_size_after: u.weighNumRange(1, 120, 0.3, 12),
367
+ },
368
+ },
369
+ {
370
+ event: "push_open",
371
+ weight: 4,
372
+ properties: {
373
+ campaign_type: ["new_season", "because_you_watched", "weekly_digest", "win_back"],
374
+ time_to_open_min: u.weighNumRange(0, 720, 0.3, 20),
375
+ },
376
+ },
377
+ {
378
+ event: "rating submitted",
379
+ weight: 3,
380
+ properties: {
381
+ rating: ["thumbs_up", "thumbs_up", "thumbs_up", "thumbs_down"],
382
+ content_type: ["movie", "series_episode", "documentary", "kids"],
383
+ },
384
+ },
385
+ {
386
+ event: "plan upgraded",
387
+ weight: 1,
388
+ properties: {
389
+ from_plan: ["basic", "standard"],
390
+ to_plan: ["standard", "premium"],
391
+ price_usd: [7.99, 12.99, 19.99],
392
+ },
393
+ },
394
+ ],
395
+
396
+ superProps: {
397
+ plan: ["basic", "standard", "standard", "premium"],
398
+ app_version: ["6.1.0", "6.1.0", "6.2.0", "6.2.0", "6.2.1"],
399
+ },
400
+
401
+ userProps: {
402
+ plan: ["basic", "standard", "standard", "premium"],
403
+ household_size: u.weighNumRange(1, 6, 0.3, 2),
404
+ primary_device: ["smart_tv", "smart_tv", "mobile", "tablet", "desktop"],
405
+ kids_profile: [true, false, false, false],
406
+ },
407
+
408
+ hook,
409
+ };
410
+
411
+ export default config;
412
+
413
+ // ── STORIES ──
414
+ /*
415
+ * Derivation notes (2K reduced run iter-streaming-1 vs organic
416
+ * counterfactual iter-streaming-0, hook overridden to identity; full
417
+ * fidelity = 10K users, expected populations ≈ 5x the 2K numbers; scale
418
+ * guards at ~50% of that):
419
+ *
420
+ * - Lifecycle tiles are read by INDEX, not date — generation shifts the
421
+ * dataset forward to the present, so period labels move but the
422
+ * 151-day span (22 seven-day tiles, 6 thirty-day tiles, first tile
423
+ * partial) and every hook's tile position are fixed by construction.
424
+ * 7d tile i≥1 covers dataset days [7i-3, 7i+3]; H2 dormant onset lands
425
+ * tiles 3-4, H1 onset tiles 6-8, H1 burst tiles 9-10, H2 burst tiles
426
+ * 12-13, H3 gap tiles 15-18, H3 return tiles 19-20. "Quiet" tile sets
427
+ * below exclude every hook-touched tile AND tiles 0-2 (birth ramp).
428
+ * - h1: dormant peak max(d[6..8])=449 / quiet median 248.5 = 1.81
429
+ * (organic 0.93); resurrected spike max(z[9,10])=585 / quiet median
430
+ * 246 = 2.38 (organic 0.99); retained dip min(r[7,8])=658 / quiet
431
+ * median 1136 = 0.58 (organic 1.01). Cohort: wave7 band ~303 uids at
432
+ * 2K, ~87% early-born.
433
+ * - h2: 30d tiles [0..5]; gap covers exactly one whole tile (index 2):
434
+ * d[2]=305 vs max 27 elsewhere → 11.3 (organic 0.71 on counts ≤17);
435
+ * z[3]=299 vs max 28 elsewhere → 10.7 (organic 1.5 on counts ≤12).
436
+ * - h3: DuckDB LAG audit — users whose max inter-event gap ≥25d (only
437
+ * the dropAll band creates whole-stream gaps; H1/H2 keep browse/search
438
+ * running): 195 gap users, 195 return to the value moment, 190 have a
439
+ * push_open within the 24h before the first post-gap play → share
440
+ * 0.974 (organic: 8 gap users, 6 returners, 3 pushed). 7d lifecycle
441
+ * echo: max(z[19,20])=433 / quiet median 246 = 1.76 (organic 0.95).
442
+ * - h4: WAU (uniques, rollingWindow 7) day-indexed; trough1
443
+ * min(v[49..63])/mean(v[7..20]) = 811/1384.7 = 0.586 (organic 0.997);
444
+ * trough2 min(v[107..137])/mean(v[93..99]) = 1265/1427.9 = 0.886
445
+ * (organic 1.002); recovery mean(v[-14..-2])/baseline = 1.091.
446
+ * - identity: uid_share 1.0, device_share 0.9993, devices/user 2.084
447
+ * (avgDevicePerUser: 2).
448
+ */
449
+
450
+ const EV = `read_json_auto('{{PREFIX}}-EVENTS*.json', sample_size=-1, union_by_name=true)`;
451
+
452
+ const bandVerdict = (x, nailed, strong, detail, inverse = () => false) => {
453
+ if (x == null || Number.isNaN(Number(x))) return { verdict: "NONE", detail: `${detail} — metric missing` };
454
+ const v = Number(x);
455
+ if (inverse(v)) return { verdict: "INVERSE", detail };
456
+ if (v >= nailed[0] && v <= nailed[1]) return { verdict: "NAILED", detail };
457
+ if (v >= strong[0] && v <= strong[1]) return { verdict: "STRONG", detail };
458
+ return { verdict: "WEAK", detail };
459
+ };
460
+
461
+ const guarded = (ok, detail, inner) => ok ? inner() : { verdict: "WEAK", detail: `${detail} — cohort below scale guard (expected at reduced scale)` };
462
+
463
+ const worstOf = (...verdicts) => {
464
+ const order = ["INVERSE", "NONE", "WEAK", "STRONG", "NAILED"];
465
+ const worst = order.find(o => verdicts.some(v => v.verdict === o)) || "NONE";
466
+ return { verdict: worst, detail: verdicts.map(v => v.detail).join("; ") };
467
+ };
468
+
469
+ const medianOf = (arr) => {
470
+ const s = [...arr].sort((a, b) => a - b);
471
+ const m = Math.floor(s.length / 2);
472
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
473
+ };
474
+
475
+ // 7d-tile index sets (see derivation notes). Quiet sets differ per line
476
+ // because each lifecycle line is disturbed by different hooks.
477
+ const LIFECYCLE_7D = { type: "lifecycle", valueMomentEvent: VALUE_MOMENT, periodDays: 7 };
478
+ const QUIET_DORMANT = [5, 9, 10, 11, 12, 13, 14, 17, 18, 19, 20, 21];
479
+ const QUIET_RESURRECTED = [5, 6, 7, 8, 11, 14, 15, 16, 17, 18, 21];
480
+ const QUIET_RETAINED = [2, 13, 14, 20, 21];
481
+
482
+ const tiles7Ok = (rows) => Array.isArray(rows) && rows.length === 22;
483
+
484
+ export const stories = [
485
+ {
486
+ id: "streaming-h1-weekly-wave",
487
+ hook: "H1",
488
+ archetype: "lifecycle-wave",
489
+ narrative: "~25% of early-born users stop hitting the value moment for exactly 3 weeks (days 42-63) while still browsing, then a burst brings them back: the 7d Lifecycle shows a dormant hump (~1.8x quiet median), a resurrected spike (~2.4x) three tiles later, and a retained dip (~0.58x) in between (organic: 0.93 / 0.99 / 1.01).",
490
+ assertions: [
491
+ {
492
+ breakdown: LIFECYCLE_7D,
493
+ assert: (rows) => {
494
+ if (!tiles7Ok(rows)) return { verdict: "NONE", detail: `expected 22 seven-day tiles, got ${rows?.length}` };
495
+ const quiet = medianOf(QUIET_DORMANT.map(i => rows[i].dormant));
496
+ const peak = Math.max(rows[6].dormant, rows[7].dormant, rows[8].dormant);
497
+ const ratio = peak / quiet;
498
+ return guarded(quiet >= 600, `quiet dormant median ${quiet}`,
499
+ () => bandVerdict(ratio, [1.5, 2.6], [1.3, 3.2],
500
+ `dormant peak ${peak} / quiet median ${quiet} = ${ratio.toFixed(2)} (expect ~1.8, organic 0.93)`,
501
+ v => v <= 1.1));
502
+ },
503
+ },
504
+ {
505
+ breakdown: LIFECYCLE_7D,
506
+ assert: (rows) => {
507
+ if (!tiles7Ok(rows)) return { verdict: "NONE", detail: `expected 22 seven-day tiles, got ${rows?.length}` };
508
+ const quiet = medianOf(QUIET_RESURRECTED.map(i => rows[i].resurrected));
509
+ const peak = Math.max(rows[9].resurrected, rows[10].resurrected);
510
+ const ratio = peak / quiet;
511
+ return guarded(quiet >= 600, `quiet resurrected median ${quiet}`,
512
+ () => bandVerdict(ratio, [1.8, 3.2], [1.5, 4.0],
513
+ `resurrected spike ${peak} / quiet median ${quiet} = ${ratio.toFixed(2)} (expect ~2.4, organic 0.99)`,
514
+ v => v <= 1.15));
515
+ },
516
+ },
517
+ {
518
+ breakdown: LIFECYCLE_7D,
519
+ assert: (rows) => {
520
+ if (!tiles7Ok(rows)) return { verdict: "NONE", detail: `expected 22 seven-day tiles, got ${rows?.length}` };
521
+ const quiet = medianOf(QUIET_RETAINED.map(i => rows[i].retained));
522
+ const dip = Math.min(rows[7].retained, rows[8].retained);
523
+ const ratio = dip / quiet;
524
+ return guarded(quiet >= 2800, `quiet retained median ${quiet}`,
525
+ () => bandVerdict(ratio, [0.45, 0.70], [0.40, 0.82],
526
+ `retained dip ${dip} / quiet median ${quiet} = ${ratio.toFixed(2)} (expect ~0.58, organic 1.01)`,
527
+ v => v >= 0.93));
528
+ },
529
+ },
530
+ ],
531
+ },
532
+ {
533
+ id: "streaming-h2-monthly-wave",
534
+ hook: "H2",
535
+ archetype: "lifecycle-wave",
536
+ narrative: "~15% of early-born users go value-moment-dark for 65 days (days 21-86) — a gap sized to cover one whole 30d tile, per the HOOKS.md gap-discipline rule. The 30d Lifecycle shows dormant ~11x every other tile in tile 2 and resurrected ~11x in tile 3 (organic: both lines are noise-level counts ≤17).",
537
+ assertions: [
538
+ {
539
+ breakdown: { type: "lifecycle", valueMomentEvent: VALUE_MOMENT, periodDays: 30 },
540
+ assert: (rows) => {
541
+ if (!Array.isArray(rows) || rows.length !== 6) return { verdict: "NONE", detail: `expected 6 thirty-day tiles, got ${rows?.length}` };
542
+ const dOther = Math.max(...[1, 3, 4, 5].map(i => rows[i].dormant), 1);
543
+ const zOther = Math.max(...[2, 4, 5].map(i => rows[i].resurrected), 1);
544
+ const dRatio = rows[2].dormant / dOther;
545
+ const zRatio = rows[3].resurrected / zOther;
546
+ const legD = guarded(rows[2].dormant >= 760, `dormant tile ${rows[2].dormant}`,
547
+ () => bandVerdict(dRatio, [5, 1e9], [2.5, 1e9],
548
+ `30d dormant tile ${rows[2].dormant} vs ${dOther} max elsewhere = ${dRatio.toFixed(1)}x (expect ~11x, organic 0.7)`,
549
+ v => v <= 1.2));
550
+ const legZ = guarded(rows[3].resurrected >= 745, `resurrected tile ${rows[3].resurrected}`,
551
+ () => bandVerdict(zRatio, [5, 1e9], [2.5, 1e9],
552
+ `30d resurrected tile ${rows[3].resurrected} vs ${zOther} max elsewhere = ${zRatio.toFixed(1)}x (expect ~11x, organic 1.5)`,
553
+ v => v <= 1.2));
554
+ return worstOf(legD, legZ);
555
+ },
556
+ },
557
+ ],
558
+ },
559
+ {
560
+ id: "streaming-h3-resurrection-campaign",
561
+ hook: "H3",
562
+ archetype: "lifecycle-wave",
563
+ narrative: "~12% of early-born users disappear COMPLETELY (dropAll) for 30 days (days 100-130), then return — and ~97% of returners have a push_open burst in the 24h before their first post-gap play (organic: 8 users ever gap ≥25d, half their returns near a push by chance). The resurrection is attributable to the campaign.",
564
+ assertions: [
565
+ {
566
+ breakdown: {
567
+ type: "duckdb",
568
+ sql: `WITH e AS (
569
+ SELECT user_id::VARCHAR AS uid, event, time::TIMESTAMP AS t,
570
+ LAG(time::TIMESTAMP) OVER (PARTITION BY user_id::VARCHAR ORDER BY time::TIMESTAMP) AS prev_t
571
+ FROM ${EV} WHERE user_id IS NOT NULL
572
+ ), gaps AS (
573
+ SELECT uid, MAX(t - prev_t) AS max_gap FROM e WHERE prev_t IS NOT NULL GROUP BY 1
574
+ ), gap_users AS (
575
+ SELECT uid FROM gaps WHERE max_gap >= INTERVAL 25 DAY
576
+ ), gap_edge AS (
577
+ SELECT e.uid, MIN(e.t) AS gap_end
578
+ FROM e JOIN gap_users g ON e.uid = g.uid
579
+ WHERE e.prev_t IS NOT NULL AND e.t - e.prev_t >= INTERVAL 25 DAY
580
+ GROUP BY 1
581
+ ), first_play AS (
582
+ SELECT e.uid, MIN(e.t) AS play_t
583
+ FROM e JOIN gap_edge g ON e.uid = g.uid
584
+ WHERE e.event = 'content played' AND e.t >= g.gap_end
585
+ GROUP BY 1
586
+ ), pushed AS (
587
+ SELECT DISTINCT f.uid
588
+ FROM first_play f JOIN e ON e.uid = f.uid
589
+ WHERE e.event = 'push_open' AND e.t <= f.play_t AND e.t >= f.play_t - INTERVAL 24 HOUR
590
+ )
591
+ SELECT (SELECT COUNT(*) FROM gap_users) AS gap_users,
592
+ (SELECT COUNT(*) FROM first_play) AS returners,
593
+ (SELECT COUNT(*) FROM pushed) AS pushed_returners`,
594
+ },
595
+ assert: (rows) => {
596
+ const r = rows?.[0];
597
+ if (!r) return { verdict: "NONE", detail: "gap audit returned no rows" };
598
+ const gapUsers = Number(r.gap_users), returners = Number(r.returners), pushed = Number(r.pushed_returners);
599
+ return guarded(gapUsers >= 490, `${gapUsers} gap users`, () => {
600
+ const returnRate = returners / gapUsers;
601
+ const pushShare = pushed / Math.max(returners, 1);
602
+ const legReturn = bandVerdict(returnRate, [0.9, 1.0], [0.75, 1.0],
603
+ `${returners}/${gapUsers} gap users return to the value moment (${returnRate.toFixed(3)})`,
604
+ v => v <= 0.3);
605
+ const legPush = bandVerdict(pushShare, [0.85, 1.0], [0.65, 1.0],
606
+ `pushed-returner share ${pushShare.toFixed(3)} (expect ~0.97, organic ~0.5 of n=6)`,
607
+ v => v <= 0.25);
608
+ return worstOf(legReturn, legPush);
609
+ });
610
+ },
611
+ },
612
+ {
613
+ breakdown: LIFECYCLE_7D,
614
+ assert: (rows) => {
615
+ if (!tiles7Ok(rows)) return { verdict: "NONE", detail: `expected 22 seven-day tiles, got ${rows?.length}` };
616
+ const quiet = medianOf(QUIET_RESURRECTED.map(i => rows[i].resurrected));
617
+ const peak = Math.max(rows[19].resurrected, rows[20].resurrected);
618
+ const ratio = peak / quiet;
619
+ return guarded(quiet >= 600, `quiet resurrected median ${quiet}`,
620
+ () => bandVerdict(ratio, [1.4, 2.6], [1.25, 3.2],
621
+ `H3 return spike ${peak} / quiet median ${quiet} = ${ratio.toFixed(2)} (expect ~1.8, organic 0.95)`,
622
+ v => v <= 1.1));
623
+ },
624
+ },
625
+ ],
626
+ },
627
+ {
628
+ id: "streaming-h4-wau-double-trough",
629
+ hook: "H1",
630
+ archetype: "temporal-inflection",
631
+ narrative: "Combined H1+H2+H3 effect on WAU (uniques on the value moment, 7-day rolling window): a deep mid-dataset trough (~0.59x baseline, days 49-63, where H1's and H2's windows overlap), a second smaller dip (~0.89x local baseline, days 107-137, H3's total gap), and full tail recovery (~1.09x) as the bursts land (organic: 1.00 / 1.00 / 1.09).",
632
+ assertions: [
633
+ {
634
+ breakdown: { type: "uniques", event: VALUE_MOMENT, rollingWindow: 7 },
635
+ assert: (rows) => {
636
+ if (!Array.isArray(rows) || rows.length !== 151) return { verdict: "NONE", detail: `expected 151 daily WAU rows, got ${rows?.length}` };
637
+ const v = rows.map(r => r.uniques);
638
+ const mean = (a) => a.reduce((x, y) => x + y, 0) / a.length;
639
+ const baseline = mean(v.slice(7, 21));
640
+ return guarded(baseline >= 3450, `WAU baseline ${baseline.toFixed(0)}`, () => {
641
+ const r1 = Math.min(...v.slice(49, 64)) / baseline;
642
+ const r2 = Math.min(...v.slice(107, 138)) / mean(v.slice(93, 100));
643
+ const rec = mean(v.slice(-14, -2)) / baseline;
644
+ const leg1 = bandVerdict(r1, [0.45, 0.72], [0.38, 0.85],
645
+ `trough1 (d49-63) ${r1.toFixed(3)} of baseline (expect ~0.59, organic 1.00)`, x => x >= 0.93);
646
+ const leg2 = bandVerdict(r2, [0.82, 0.93], [0.75, 0.96],
647
+ `trough2 (d107-137) ${r2.toFixed(3)} of local pre-dip (expect ~0.89, organic 1.00)`, x => x >= 0.985);
648
+ const legRec = bandVerdict(rec, [1.0, 1.35], [0.92, 1.5],
649
+ `tail recovery ${rec.toFixed(3)} of baseline (expect ~1.09)`, x => x <= 0.8);
650
+ return worstOf(leg1, leg2, legRec);
651
+ });
652
+ },
653
+ },
654
+ {
655
+ breakdown: {
656
+ type: "duckdb",
657
+ sql: `SELECT COUNT(*) AS n,
658
+ AVG((user_id IS NOT NULL)::INT) AS uid_share,
659
+ AVG((device_id IS NOT NULL)::INT) AS device_share,
660
+ COUNT(DISTINCT device_id)::DOUBLE / COUNT(DISTINCT user_id) AS dpu
661
+ FROM ${EV}`,
662
+ },
663
+ assert: (rows) => {
664
+ const r = rows?.[0];
665
+ if (!r) return { verdict: "NONE", detail: "identity query returned no rows" };
666
+ const legUid = bandVerdict(Number(r.uid_share), [1, 1], [0.999, 1],
667
+ `uid_share ${Number(r.uid_share).toFixed(4)} (every event carries user_id)`, v => v < 0.99);
668
+ const legDev = bandVerdict(Number(r.device_share), [0.99, 1], [0.97, 1],
669
+ `device_share ${Number(r.device_share).toFixed(4)} (avgDevicePerUser: 2)`, v => v < 0.9);
670
+ const legDpu = bandVerdict(Number(r.dpu), [1.6, 2.4], [1.4, 2.6],
671
+ `devices/user ${Number(r.dpu).toFixed(2)} (expect ~2.08)`, v => v < 1.05);
672
+ return worstOf(legUid, legDev, legDpu);
673
+ },
674
+ },
675
+ ],
676
+ },
677
+ ];