@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.
- 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/powertools/SKILL.md +75 -0
- package/.claude/skills/powertools/pt.mjs +91 -0
- package/.claude/skills/powertools/snapshot-project.mjs +124 -0
- 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 +224 -0
- package/HOOKS.md +566 -112
- package/README.md +30 -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 +7 -3
- 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 +151 -59
- 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 +298 -30
- package/dungeons/vertical/ai-platform.js +0 -818
- package/dungeons/vertical/community.js +0 -966
- package/dungeons/vertical/crypto.js +0 -895
- package/dungeons/vertical/dating.js +0 -830
- package/dungeons/vertical/devtools.js +0 -1050
- package/dungeons/vertical/ecommerce.js +0 -811
- package/dungeons/vertical/education.js +0 -964
- package/dungeons/vertical/fintech.js +0 -946
- package/dungeons/vertical/fitness.js +0 -935
- package/dungeons/vertical/food-delivery.js +0 -934
- package/dungeons/vertical/gaming.js +0 -1120
- package/dungeons/vertical/healthcare.js +0 -945
- package/dungeons/vertical/insurance-application.js +0 -1039
- package/dungeons/vertical/logistics.js +0 -986
- package/dungeons/vertical/marketplace.js +0 -1019
- package/dungeons/vertical/real-estate.js +0 -872
- package/dungeons/vertical/sass.js +0 -995
- package/dungeons/vertical/social.js +0 -904
- package/dungeons/vertical/travel.js +0 -930
|
@@ -0,0 +1,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
|
+
];
|