@ak--47/dungeon-master 1.4.4 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/analyze-soup/SKILL.md +158 -0
- package/.claude/skills/create-dungeon/SKILL.md +464 -0
- package/.claude/skills/verify-dungeon/SKILL.md +157 -0
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +161 -0
- package/.claude/skills/verify-dungeon/references/report-format.md +216 -0
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +857 -0
- package/.claude/skills/write-hooks/SKILL.md +468 -0
- package/CHANGELOG.md +147 -0
- package/HOOKS.md +1243 -597
- package/README.md +140 -5
- package/dungeons/technical/ad-spend.js +1 -1
- package/dungeons/technical/anonymous-users.js +1 -1
- package/dungeons/technical/array-of-object-lookup.js +1 -1
- package/dungeons/technical/datagen-v15-verify.js +74 -0
- package/dungeons/technical/experiments.js +1 -1
- package/dungeons/technical/foobar.js +1 -1
- package/dungeons/technical/group-analytics.js +1 -1
- package/dungeons/technical/mirror-strategies.js +1 -1
- package/dungeons/technical/nested-objects.js +1 -1
- package/dungeons/technical/retention-cadence.js +1 -1
- package/dungeons/technical/sanity.js +1 -1
- package/dungeons/technical/scale-test.js +1 -1
- package/dungeons/technical/scd.js +1 -1
- package/dungeons/technical/simple.js +1 -1
- package/dungeons/technical/simplest.js +74 -20
- package/dungeons/technical/text-generation.js +1 -1
- package/dungeons/vertical/ai-platform.js +4 -0
- package/dungeons/vertical/community.js +9 -3
- package/dungeons/vertical/crypto.js +5 -0
- package/dungeons/vertical/dating.js +23 -10
- package/dungeons/vertical/devtools.js +10 -0
- package/dungeons/vertical/ecommerce.js +6 -0
- package/dungeons/vertical/education.js +11 -0
- package/dungeons/vertical/fintech.js +13 -0
- package/dungeons/vertical/fitness.js +10 -0
- package/dungeons/vertical/food-delivery.js +9 -0
- package/dungeons/vertical/gaming.js +10 -0
- package/dungeons/vertical/healthcare.js +5 -0
- package/dungeons/vertical/insurance-application.js +10 -0
- package/dungeons/vertical/logistics.js +8 -1
- package/dungeons/vertical/marketplace.js +7 -0
- package/dungeons/vertical/media.js +8 -0
- package/dungeons/vertical/real-estate.js +7 -1
- package/dungeons/vertical/sass.js +12 -0
- package/dungeons/vertical/social.js +9 -0
- package/dungeons/vertical/travel.js +5 -0
- package/index.js +45 -7
- package/lib/core/config-validator.js +270 -7
- package/lib/core/context.js +58 -0
- package/lib/core/dungeon-loader.js +2 -5
- package/lib/generators/events.js +12 -13
- package/lib/generators/funnels.js +72 -1
- package/lib/hook-helpers/index.js +1 -0
- package/lib/hook-helpers/inject.js +95 -0
- package/lib/orchestrators/mixpanel-sender.js +27 -1
- package/lib/orchestrators/user-loop.js +488 -29
- package/lib/templates/macro-presets.js +39 -9
- package/lib/utils/utils.js +16 -79
- package/lib/verify/counting.js +320 -0
- package/lib/verify/emulate-breakdown.js +512 -108
- package/lib/verify/funnel-engine.js +539 -0
- package/lib/verify/identity.js +78 -0
- package/lib/verify/index.js +19 -0
- package/lib/verify/verify-dungeon.js +58 -0
- package/package.json +4 -2
- package/types.d.ts +314 -4
- package/scripts/smoke-test-all.mjs +0 -162
package/HOOKS.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# HOOKS.md -- Hook Encyclopedia
|
|
2
2
|
|
|
3
|
-
Hook reference and recipe catalog for dungeon-master. Every
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Hook reference and recipe catalog for dungeon-master. Every recipe is calibrated
|
|
4
|
+
against Mixpanel's actual counting semantics (greedy single-pass funnels,
|
|
5
|
+
distinct-period frequency, null-aware aggregation, capped attribution) — see
|
|
6
|
+
[Section 2](#2-how-mixpanel-counts-things) before adapting any pattern.
|
|
6
7
|
|
|
7
8
|
---
|
|
8
9
|
|
|
@@ -33,13 +34,277 @@ fire only in the generator/orchestrator -- storage skips them to prevent
|
|
|
33
34
|
double-fire mutations.
|
|
34
35
|
|
|
35
36
|
**Return rules:**
|
|
36
|
-
- `event`: return the (possibly replaced) event object.
|
|
37
|
-
- `everything`: return the (possibly modified) array.
|
|
37
|
+
- `event`: return the (possibly replaced) event object.
|
|
38
|
+
- `everything`: return the (possibly modified) array. Filtered array removes events.
|
|
38
39
|
- All other types: mutate `record` in-place. Return value is ignored.
|
|
39
40
|
|
|
40
41
|
---
|
|
41
42
|
|
|
42
|
-
## 2.
|
|
43
|
+
## 2. How Mixpanel Counts Things
|
|
44
|
+
|
|
45
|
+
**Read this before writing any hook that targets a Mixpanel report.** The
|
|
46
|
+
verification emulator (`@ak--47/dungeon-master/verify`) now matches these
|
|
47
|
+
rules; old recipes that ignored them will look correct on the dataset but
|
|
48
|
+
fail when verified or queried in Mixpanel.
|
|
49
|
+
|
|
50
|
+
### 2.1 Frequency reports count DISTINCT PERIODS, not total events
|
|
51
|
+
|
|
52
|
+
Mixpanel's frequency distribution / cohort-by-event-count reports count
|
|
53
|
+
**distinct time periods** (default: days) on which the user fired the
|
|
54
|
+
event. Two purchases on the same day = frequency **1**, not 2.
|
|
55
|
+
|
|
56
|
+
Two related rules exist:
|
|
57
|
+
|
|
58
|
+
- **Calendar bucket** (default in our verifier, `algorithm: 'calendar'`):
|
|
59
|
+
`COUNT(DISTINCT date_trunc(unit, time))` in UTC. Matches what the Mixpanel
|
|
60
|
+
UI shows and what [`injectOnNewDays`](lib/hook-helpers/inject.js) uses
|
|
61
|
+
internally.
|
|
62
|
+
- **Rolling window** (`algorithm: 'rolling'`): the C++
|
|
63
|
+
`addiction_query.cpp` rule `qtz_time >= last_counted + seconds_for_unit`.
|
|
64
|
+
Diverges from calendar at unit boundaries (events at 23:59 + 00:01 next
|
|
65
|
+
day = 1 rolling period, 2 calendar periods).
|
|
66
|
+
|
|
67
|
+
Use the default (`calendar`) for hooks. Use `algorithm: 'rolling'` only
|
|
68
|
+
when verifying behavior that explicitly depends on the C++ implementation.
|
|
69
|
+
|
|
70
|
+
**Implication for hooks:** `scaleEventCount(record, "Buy", 3)` clones 3x as
|
|
71
|
+
many Buy events at sub-second offsets — they all land on the same calendar
|
|
72
|
+
day, so the user moves **zero bins** in Mixpanel's frequency report. Use
|
|
73
|
+
[`injectOnNewDays`](lib/hook-helpers/inject.js) when the goal is to move
|
|
74
|
+
users between frequency bins. Both `injectOnNewDays` and the default
|
|
75
|
+
`countDistinctPeriods` algorithm use calendar-bucket math, so they agree
|
|
76
|
+
at boundaries.
|
|
77
|
+
|
|
78
|
+
### 2.2 Funnels are GREEDY single-pass with a 2-second grace
|
|
79
|
+
|
|
80
|
+
Mixpanel processes events in chronological order, single pass. Each event is
|
|
81
|
+
greedily assigned to the first eligible step. Step N requires step N-1 first.
|
|
82
|
+
The "after" check has a 2-second grace window
|
|
83
|
+
(`OUT_OF_ORDER_MILLISECONDS = 2000` in `history.cpp`).
|
|
84
|
+
|
|
85
|
+
Implementation: [`evaluateFunnel`](lib/verify/funnel-engine.js).
|
|
86
|
+
|
|
87
|
+
**Implications for hooks:**
|
|
88
|
+
- Out-of-order injected events get **consumed by the first matching step**
|
|
89
|
+
even if they break the intended sequence — the engine has no backtracking.
|
|
90
|
+
- Conversion window is measured from step 0 with **strict `<`**
|
|
91
|
+
(`event_time < step_0_time + window`). An event at exactly the boundary
|
|
92
|
+
is excluded.
|
|
93
|
+
- TTC is `stepTimes[last] - stepTimes[0]` from the greedy match, not from a
|
|
94
|
+
property value. To shift TTC, shift event timestamps (Recipe 3.14).
|
|
95
|
+
|
|
96
|
+
### 2.3 Aggregations are null-aware
|
|
97
|
+
|
|
98
|
+
`AVG(x)` skips null/undefined/NaN/non-numeric from BOTH numerator AND
|
|
99
|
+
denominator. Same for SUM, MIN, MAX. Reference: `normal_query.cpp`
|
|
100
|
+
ACTION_TYPE_AVERAGE / ACTION_TYPE_SUM / ACTION_TYPE_EXTREMES.
|
|
101
|
+
|
|
102
|
+
**Implication for hooks:** A property that's only sometimes present (e.g.,
|
|
103
|
+
`order_value` only on Purchase events) is averaged ONLY across events where
|
|
104
|
+
it exists. You don't need to "fill" missing values with 0 to keep the
|
|
105
|
+
average sensible — Mixpanel ignores them. Conversely, if you want to dilute
|
|
106
|
+
an average, removing the property is a no-op; you have to add zeros.
|
|
107
|
+
|
|
108
|
+
### 2.4 Attribution caps at 10 touchpoints
|
|
109
|
+
|
|
110
|
+
Multi-touch attribution models cap consideration at the last 10 touchpoints
|
|
111
|
+
in the lookback window (`TOUCHPOINTS_LIMIT = 10` in
|
|
112
|
+
`attributed_value_reader.cpp`). For first-touch attribution this matters
|
|
113
|
+
when a user has > 10 touches before conversion — the cap shifts which
|
|
114
|
+
touch is "first."
|
|
115
|
+
|
|
116
|
+
**v1.5 generation contract:** the engine now caps UTM stamping at
|
|
117
|
+
`maxTouchpointsPerUser` (default 10) per user, sampled uniform-random across
|
|
118
|
+
the user's lifetime (NOT first-N-chronological). Stamps are sorted
|
|
119
|
+
chronologically before being applied, so attribution properties land in
|
|
120
|
+
time order. Hooks that bias attribution should OVERWRITE engine-stamped
|
|
121
|
+
values, not stamp from scratch (those would push the user past the cap).
|
|
122
|
+
|
|
123
|
+
### 2.5 Active-day distribution is config-first
|
|
124
|
+
|
|
125
|
+
Mixpanel frequency reports count distinct days (§2.1). The v1.5 engine
|
|
126
|
+
exposes `Dungeon.avgActiveDaysPerUser` as the canonical primitive for this
|
|
127
|
+
shape. Set it at the config level and the engine concentrates each user's
|
|
128
|
+
events onto a sampled subset of days drawn from `normal(mean=N, sd=N/3)`,
|
|
129
|
+
clamped to `[1, userActiveDays]`.
|
|
130
|
+
|
|
131
|
+
**Concentrator semantic — total event count is preserved.** The per-active-day
|
|
132
|
+
rate INFLATES when `avgActiveDaysPerUser < userActiveDays`. Example:
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
avgEventsPerUserPerDay: 4
|
|
136
|
+
avgActiveDaysPerUser: 2
|
|
137
|
+
numDays: 30
|
|
138
|
+
→ userEventBudget = 4 × 30 = 120 events (preserved)
|
|
139
|
+
→ events concentrated onto 2 days
|
|
140
|
+
→ effective per-active-day rate = 120 / 2 = 60 events per active day
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The validator emits a warning when implied per-active-day rate > 50.
|
|
144
|
+
|
|
145
|
+
**Hook authoring rule:** do NOT engineer global active-day distribution in
|
|
146
|
+
hooks. Use the config knob. Reserve `injectOnNewDays` for cohort-conditional
|
|
147
|
+
patterns ("premium users get 7+ active days, rest stay default").
|
|
148
|
+
|
|
149
|
+
**Incompatibility with `engagementDecay`:** decay drops events from late-day
|
|
150
|
+
positions in the user's lifetime, eroding picked active days. Setting both
|
|
151
|
+
`avgActiveDaysPerUser` AND `engagementDecay` produces an effective active-day
|
|
152
|
+
count BELOW the configured target. Pick one. If you need both effects, set
|
|
153
|
+
`avgActiveDaysPerUser` and write decay logic in an `everything` hook scoped
|
|
154
|
+
to specific cohorts (gives explicit control over the interaction).
|
|
155
|
+
|
|
156
|
+
### 2.6 Sessions are query-time computed (30-min gap, 24h max, day-boundary split)
|
|
157
|
+
|
|
158
|
+
Reference: `backend/arb/reader/queries/session_query.cpp:828-830, 905-928`.
|
|
159
|
+
Sessions are NOT persisted on raw events in Mixpanel — they're derived per
|
|
160
|
+
query from THREE reset triggers:
|
|
161
|
+
|
|
162
|
+
1. **Inactivity gap** > `session_timeout` (default 30 min, strict `>`)
|
|
163
|
+
2. **Max session duration** > `session_max_time` (default 24h)
|
|
164
|
+
3. **UTC (qtz) day boundary** — `last_event_day_idx != day_idx` ends the session
|
|
165
|
+
|
|
166
|
+
Each session emits synthetic `$duration_s`, `$event_count`, `$origin_start`,
|
|
167
|
+
`$origin_end` properties.
|
|
168
|
+
|
|
169
|
+
**v1.5 contract:** the generator's `assignSessionIds` pre-stamps `session_id`
|
|
170
|
+
using all three rules (UTC day boundary added in v1.5.0 audit). Verifier
|
|
171
|
+
trusts pre-stamped IDs and groups by `(user, session_id)`. Use
|
|
172
|
+
`emulateBreakdown({ type: 'sessionMetrics' })` to verify session-level shapes.
|
|
173
|
+
|
|
174
|
+
**Verifier-only conveniences (not directly reproducible in Mixpanel UI):**
|
|
175
|
+
- `evaluateFunnel({ sessionScoped: true })` partitions events per session and
|
|
176
|
+
runs the matcher independently. Mixpanel's closest analog is
|
|
177
|
+
`WINDOW_TYPE_SESSIONS` (`conversion_window.cpp:9-13`) — bounds the window
|
|
178
|
+
by session COUNT, not by partitioning. Use sessionScoped for clean
|
|
179
|
+
per-session funnel verification; for Mixpanel-replay accuracy, prefer
|
|
180
|
+
setting `conversionWindowMs = 1800000` (30 min).
|
|
181
|
+
- `sessionMetrics({ event: 'X' })` filters to sessions containing event X.
|
|
182
|
+
Mixpanel has no direct equivalent in `session_query.cpp`.
|
|
183
|
+
|
|
184
|
+
**Divergences (documented):**
|
|
185
|
+
- Verifier uses **UTC**, not query timezone (qtz). For non-UTC accounts,
|
|
186
|
+
bucket boundaries shift by hours.
|
|
187
|
+
- Percentiles use linear interpolation (d3.quantile). Mixpanel uses TDigest
|
|
188
|
+
in production — diverges by single-digit % at p90 on small samples.
|
|
189
|
+
|
|
190
|
+
### 2.7 Retention is birth-anchored, ms-delta bucketed
|
|
191
|
+
|
|
192
|
+
Reference: `backend/arb/reader/queries/retention_query.cpp:1227-1231`. For
|
|
193
|
+
each user, the engine finds the first occurrence of `birth_event` →
|
|
194
|
+
`first_event_time_s`. For each return event, it computes:
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
time_to_retention_event_s = retention_event_time_s - first_event_time_s
|
|
198
|
+
bucket = floor(time_to_retention_event_s / bucket_seconds)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
**Bucketing is ms-delta from birth, NOT a UTC-calendar-day-number difference.**
|
|
202
|
+
A return 23h after birth lands in bucket 0; a return 25h after birth lands in
|
|
203
|
+
bucket 1 — even when both fall on the UTC calendar day after the birth day.
|
|
204
|
+
|
|
205
|
+
**`birth_can_retain`** (default `false`; `retention_query.cpp:1097-1109`):
|
|
206
|
+
ms-strict check on whether returns AT the birth ms count. Default excludes
|
|
207
|
+
them (`first_event_time < retention_event_time`). Set `birthCanRetain: true`
|
|
208
|
+
on the verifier to count exact-birth-ms returns.
|
|
209
|
+
|
|
210
|
+
`carry_forward` mode marks a user as retained for every later bucket once
|
|
211
|
+
they hit any earlier bucket (Mixpanel's CARRY_FORWARD unbounded mode,
|
|
212
|
+
`retention_query.cpp:1824-1837`). Retention is monotonically non-decreasing
|
|
213
|
+
across buckets in this mode.
|
|
214
|
+
|
|
215
|
+
`segmentBy` partitions the cohort by a property on the BIRTH event
|
|
216
|
+
(Mixpanel's `segment_event=FIRST` mode — `retention_query.cpp:1309`).
|
|
217
|
+
|
|
218
|
+
**Documented gaps (out of v1.5.0 verifier scope):**
|
|
219
|
+
- **COMPOUNDED retention** (`retention_query.cpp:670`) reuses the first-event
|
|
220
|
+
filter as the return filter, making EVERY cohort event a retention
|
|
221
|
+
candidate. Used heavily in Mixpanel's "DAU coming back" reports — verify
|
|
222
|
+
these patterns in DuckDB or directly in Mixpanel.
|
|
223
|
+
- **CARRY_BACK / CONSECUTIVE_FORWARD** unbounded modes
|
|
224
|
+
- **CALENDAR_START** bucket alignment (anchor buckets to absolute calendar
|
|
225
|
+
periods instead of birth time)
|
|
226
|
+
- **`segment_event=SECOND`** (segment by return-event property)
|
|
227
|
+
- **Cohort window** — verifier uses ALL users with the birth event in the
|
|
228
|
+
dataset; Mixpanel restricts to users with birth in `[from_date, to_date]`
|
|
229
|
+
- **Week / month bucket units** — verifier supports day buckets only
|
|
230
|
+
|
|
231
|
+
### 2.8 Funnel reentry: state machine resets after completion
|
|
232
|
+
|
|
233
|
+
Reference: `history.cpp` (`last_step_starts_next_funnel`). With reentry
|
|
234
|
+
enabled, after the state machine reaches the final step the engine resets to
|
|
235
|
+
step 0 and continues scanning. `result.completions` reports the total. In
|
|
236
|
+
`countMode: 'totals'` the engine returns one `FunnelResult` per completion
|
|
237
|
+
(simultaneous histories — one user, many funnel completions). Without
|
|
238
|
+
reentry the funnel runs once per user.
|
|
239
|
+
|
|
240
|
+
### 2.9 HPC (Hold Property Constant) — parallel sub-funnels
|
|
241
|
+
|
|
242
|
+
Reference: `funnel_query.cpp` lines 749-784 (`aggregate_hash_get_key_cursor`).
|
|
243
|
+
HPC partitions a single funnel into one parallel sub-funnel per unique value
|
|
244
|
+
of the held property on the step-0 event. A user can complete the funnel in
|
|
245
|
+
one HPC bucket and drop off in another simultaneously — the buckets are
|
|
246
|
+
independent. Use `evaluateFunnelHPC(events, steps, holdProperty, options)`
|
|
247
|
+
directly (not auto-routed through `funnelFrequency` in v1.5.0).
|
|
248
|
+
|
|
249
|
+
### 2.10 Funnel segment modes (FIRST_TOUCH / LAST_TOUCH / STEP)
|
|
250
|
+
|
|
251
|
+
Reference: `options.hpp` `funnel_segment_mode`; `history.cpp`
|
|
252
|
+
`property_set_buffer`. The engine snapshots the matched event's properties
|
|
253
|
+
at every funnel step. Segmentation chooses which step's properties to use:
|
|
254
|
+
FIRST_TOUCH (step 0), LAST_TOUCH (last reached), or STEP N (specific index).
|
|
255
|
+
Enable with `evaluateFunnel({ trackStepProperties: true })`, then pick with
|
|
256
|
+
`resolveFunnelSegment(result, 'first' | 'last' | { step: N })`.
|
|
257
|
+
|
|
258
|
+
### 2.11 Engine-validation guarantees (v1.5+)
|
|
259
|
+
|
|
260
|
+
The v1.5 ship gate added a 194-combo cross-product sweep
|
|
261
|
+
(`tests/engine/sweep-engine.mjs`) that proves the **no-hook** baseline
|
|
262
|
+
(`dungeons/technical/simplest.js`) satisfies a per-macro strict bar across
|
|
263
|
+
the supported param space. Same 6 conditions enumerated in
|
|
264
|
+
[CLAUDE.md "Tuning guidance"](CLAUDE.md#tuning-guidance--safe-ranges-and-engine-guarantees-v15).
|
|
265
|
+
Validator strict-clamps prevent the worst pathological combos (e.g.,
|
|
266
|
+
`percentUsersBornInDataset: 100` + `bornRecentBias: 0.6`) at config time.
|
|
267
|
+
|
|
268
|
+
**What this means for hook authors:**
|
|
269
|
+
|
|
270
|
+
1. **Engine gives you a clean canvas.** Without hooks, the per-day distribution
|
|
271
|
+
stays in-band across all 5 macro presets (flat / steady / growth / viral /
|
|
272
|
+
decline) at any reasonable `numDays` / `numUsers` / `rate` / `activeDays`
|
|
273
|
+
combo. You don't have to defend against engine drift — the canary at
|
|
274
|
+
`tests/unit/engine-shape-canary.test.js` runs every commit, the full sweep
|
|
275
|
+
gate at `tests/e2e/engine-shape-full-sweep.test.js` runs pre-release.
|
|
276
|
+
|
|
277
|
+
2. **Hooks own their shape — guarantees stop applying.** The strict bar is
|
|
278
|
+
measured on no-hook output. Engineered hook patterns CAN and SHOULD
|
|
279
|
+
intentionally bend the bar:
|
|
280
|
+
- **Decline + churn cohort** (engagementDecay or `everything`-hook event-drop)
|
|
281
|
+
produces tail_ratio < 0.4 — well below the decline bar's 0.4 floor.
|
|
282
|
+
Sunset-story design intent.
|
|
283
|
+
- **Viral hooks with persona-driven late-cohort lift** can push the
|
|
284
|
+
right-edge spike above the viral preset's 7.0 cap. Hockey-stick stories
|
|
285
|
+
are louder than the engine baseline.
|
|
286
|
+
- **World-event spike** (e.g., a launch-day burst of 5x normal volume)
|
|
287
|
+
creates a single-day right-edge spike above the spike cap.
|
|
288
|
+
Document intentional deviations in the dungeon's overview JSDoc + the
|
|
289
|
+
hook's pattern documentation block.
|
|
290
|
+
|
|
291
|
+
3. **Validator clamps fire only when you pass user-explicit values.** Macro
|
|
292
|
+
preset values (e.g., `viral` = bias=0.6 + born=55%) are exempt — clamp
|
|
293
|
+
targets dungeons that override `percentUsersBornInDataset`,
|
|
294
|
+
`bornRecentBias`, `avgEventsPerUserPerDay`, `avgActiveDaysPerUser` outside
|
|
295
|
+
safe bounds. If your dungeon explicitly sets these and you see a
|
|
296
|
+
`⚠️ ... clamped to ...` warning on first run, the validator just rescued
|
|
297
|
+
you from a broken-looking chart. Either accept the clamped value or fix
|
|
298
|
+
the config (the warning explains which).
|
|
299
|
+
|
|
300
|
+
4. **`futureEvents == 0` is unconditional.** The future-time guard at storage
|
|
301
|
+
step 14 drops any event with `time > FIXED_NOW`. Hooks can clone events
|
|
302
|
+
with arbitrary timestamps without polluting the dataset. Verified across
|
|
303
|
+
the 194-combo matrix; this guarantee survives every hook pattern.
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## 3. Core Principles
|
|
43
308
|
|
|
44
309
|
1. **Schema-first.** Every property in the output must be defined in the dungeon
|
|
45
310
|
config (`events[].properties`, `userProps`, or `superProps`) with a default
|
|
@@ -69,136 +334,151 @@ double-fire mutations.
|
|
|
69
334
|
`dayInDataset >= N` must live in the `everything` hook, not the `event`
|
|
70
335
|
hook. The `event` hook's `meta.datasetStart` produces unreliable day
|
|
71
336
|
calculations. The `everything` hook's `meta.datasetStart` is verified
|
|
72
|
-
correct
|
|
337
|
+
correct.
|
|
73
338
|
|
|
74
339
|
8. **Event cloning requires `everything`.** The `event` hook's return value
|
|
75
|
-
REPLACES the original event. To DUPLICATE or INJECT events
|
|
76
|
-
|
|
77
|
-
array. Only use the `event` hook return for event REPLACEMENT patterns
|
|
78
|
-
(e.g., alert triggered → incident created).
|
|
340
|
+
REPLACES the original event. To DUPLICATE or INJECT events, use the
|
|
341
|
+
`everything` hook and `push()` to the array.
|
|
79
342
|
|
|
80
343
|
9. **Property baselines must contrast with hook targets.** If a hook sets
|
|
81
|
-
`event_type = "plan_upgraded"` during a
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
hook forces `scale_direction = "down"`, the baseline must favor "up" so
|
|
85
|
-
the forced "down" creates measurable contrast.
|
|
344
|
+
`event_type = "plan_upgraded"` during a window, the baseline distribution
|
|
345
|
+
must make `plan_upgraded` rare (~10-15%). Same applies to direction-of-change
|
|
346
|
+
properties (forced "down" needs baseline favoring "up").
|
|
86
347
|
|
|
87
348
|
10. **TTC effects go in `everything`, not `funnel-post`.** Funnel-post TTC
|
|
88
|
-
scaling
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
→ alert resolved). Use stronger factors (0.5x/1.8x) to compensate for
|
|
93
|
-
dilution by non-funnel events.
|
|
94
|
-
|
|
95
|
-
11. **Temporal mutations run AFTER all cloning.** If Hook A clones events
|
|
96
|
-
with time offsets, and Hook B mutates events in a time window, cloned
|
|
97
|
-
events can land inside B's window without getting the mutation. Fix:
|
|
98
|
-
run temporal value mutations at the END of the everything hook, after
|
|
99
|
-
all event injection/cloning is complete. This is distinct from L7/L8
|
|
100
|
-
(temporal hooks in event vs everything) — even within the everything
|
|
101
|
-
hook, ordering matters.
|
|
102
|
-
|
|
103
|
-
12. **Cohort detection must survive downstream filtering.** If Hook A
|
|
104
|
-
classifies users as "agentic" based on having certain events, and
|
|
105
|
-
Hook B later removes some of those events (churn, retention filter),
|
|
106
|
-
verification queries can't reconstruct the cohort from output data.
|
|
107
|
-
Fix: use stricter detection (3+ events instead of 1+) so surviving
|
|
108
|
-
events still identify the cohort, or accept the verification
|
|
109
|
-
limitation and verify by mechanism inspection.
|
|
110
|
-
|
|
111
|
-
13. **Deprecated feature replacement in hooks.** When a dungeon used
|
|
112
|
-
`subscription`, `attribution`, `features`, or other deprecated config
|
|
113
|
-
blocks, the 1.4 engine silently strips them. Hooks that depended on
|
|
114
|
-
properties generated by those features (e.g., `coaching_mode`,
|
|
115
|
-
`subscription_plan`) will see those properties missing. Fix: add
|
|
116
|
-
equivalent property assignments in the `user` or `everything` hook,
|
|
117
|
-
or add the property to `superProps`/`userProps` with a default value.
|
|
118
|
-
|
|
119
|
-
14. **Unseeded Chance instances break determinism.** Some pre-1.4 dungeons
|
|
120
|
-
used `new Chance()` without a seed. Replace with
|
|
121
|
-
`const chance = u.initChance(SEED)` to ensure reproducible output.
|
|
349
|
+
scaling is not verifiable via cross-event SQL — standalone events drown
|
|
350
|
+
the within-funnel signal. Move TTC effects to `everything` and shift
|
|
351
|
+
timestamps directly with `findFirstSequence` + `scaleFunnelTTC`. Use
|
|
352
|
+
factors of 0.5x/1.8x or stronger.
|
|
122
353
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
interactions — higher conversion on non-target funnels consumes the
|
|
139
|
-
user's event budget, displacing standalone events and triggering churn
|
|
140
|
-
hooks in unrelated code paths.
|
|
141
|
-
```js
|
|
142
|
-
if (type === "funnel-pre") {
|
|
143
|
-
const isCertFunnel = meta.funnel?.sequence?.includes("certificate earned");
|
|
144
|
-
if (!isCertFunnel) return; // only adjust the cert funnel
|
|
145
|
-
// ... apply conversion scaling
|
|
146
|
-
}
|
|
147
|
-
```
|
|
354
|
+
11. **Temporal mutations run AFTER all cloning.** If Hook A clones events and
|
|
355
|
+
Hook B mutates events in a time window, run B at the END of the everything
|
|
356
|
+
hook so cloned events land inside the window correctly.
|
|
357
|
+
|
|
358
|
+
12. **Cohort detection must survive downstream filtering.** Use stricter
|
|
359
|
+
detection (3+ events instead of 1+) so surviving events still identify
|
|
360
|
+
the cohort even if churn / retention filters prune some.
|
|
361
|
+
|
|
362
|
+
13. **Deprecated feature replacement.** Hooks that depended on properties
|
|
363
|
+
generated by `subscription`, `attribution`, `features`, etc. (removed in
|
|
364
|
+
1.4) must add equivalent property assignments via `user` or `everything`
|
|
365
|
+
hooks; add the property to `superProps`/`userProps` with a default.
|
|
366
|
+
|
|
367
|
+
14. **Unseeded `Chance` instances break determinism.** Always
|
|
368
|
+
`const chance = u.initChance(SEED)`.
|
|
148
369
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
370
|
+
15. **TTC effects must shift timestamps, not just properties.** Mixpanel's
|
|
371
|
+
funnel TTC measures the delta between event *timestamps*. Scaling a
|
|
372
|
+
timing *property* (`wait_time_hours *= 0.67`) changes Insights AVG
|
|
373
|
+
reports but does NOT affect Funnel TTC. Use `scaleFunnelTTC` on
|
|
374
|
+
timestamps as the primary mechanism.
|
|
375
|
+
|
|
376
|
+
16. **Scope `funnel-pre` to specific funnels.** Unscoped `funnel-pre` hooks
|
|
377
|
+
affect ALL funnels. Always check `meta.funnel?.sequence?.includes(...)`
|
|
378
|
+
before scaling `record.conversionRate`.
|
|
379
|
+
|
|
380
|
+
17. **SCD props live in `meta.scd`, not `meta.profile`.** Read the latest
|
|
381
|
+
entry from `meta.scd.<scdName>`. Reading `meta.profile.<scdPropName>`
|
|
382
|
+
is always `undefined`.
|
|
383
|
+
|
|
384
|
+
18. **Calibrate thresholds against actual event distributions.** Run the
|
|
385
|
+
distribution query before setting thresholds:
|
|
386
|
+
```sql
|
|
387
|
+
SELECT count, COUNT(*) FROM (
|
|
388
|
+
SELECT user_id, COUNT(*) AS count FROM events WHERE event = 'X' GROUP BY user_id
|
|
389
|
+
) GROUP BY count ORDER BY count;
|
|
166
390
|
```
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
`
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
391
|
+
Aim for ~80th percentile so ~20% of users qualify.
|
|
392
|
+
|
|
393
|
+
19. **Prefer boosts over drops for retention hooks.** `scaleEventCount(record,
|
|
394
|
+
"X", 1.8)` on the positive cohort produces cleaner signal than
|
|
395
|
+
`dropEventsWhere` on the negative cohort. Drops compound destructively;
|
|
396
|
+
boosts don't.
|
|
397
|
+
|
|
398
|
+
20. **Compounding drop hooks destroy signal.** Use at most ONE drop-based
|
|
399
|
+
retention hook per dungeon. Move the rest to boost-based patterns.
|
|
400
|
+
|
|
401
|
+
### New principles from the emulator alignment
|
|
402
|
+
|
|
403
|
+
21. **Distinct-day vs total-event binning.** Frequency-distribution reports in
|
|
404
|
+
Mixpanel count distinct days (Section 2.1). For any hook whose verification
|
|
405
|
+
target is a frequency report, use [`binByDistinctPeriods`](lib/verify/counting.js)
|
|
406
|
+
instead of `binUsersByEventCount`. For hooks targeting raw event counts
|
|
407
|
+
(Insights `total events`, `events per user`), `binUsersByEventCount` is
|
|
408
|
+
still correct.
|
|
409
|
+
|
|
410
|
+
22. **`scaleEventCount` does not move users between frequency bins.** Cloning
|
|
411
|
+
Buy events at sub-second offsets places them on the same calendar day, so
|
|
412
|
+
the user's distinct-day count is unchanged. To shift frequency bins use
|
|
413
|
+
[`injectOnNewDays`](lib/hook-helpers/inject.js), which spreads injections
|
|
414
|
+
across previously empty days within the user's active window.
|
|
415
|
+
|
|
416
|
+
23. **Out-of-order injected events get consumed by the funnel engine.** Adding
|
|
417
|
+
a "step C" event before "step B" in the stream causes Mixpanel's greedy
|
|
418
|
+
engine to assign the C event correctly only if step B has already
|
|
419
|
+
advanced. If your hook injects funnel-step events, ensure they land
|
|
420
|
+
after the prior step's timestamp (with margin > 2 seconds for the grace
|
|
421
|
+
window).
|
|
422
|
+
|
|
423
|
+
24. **Attribution stamping is capped at 10 touchpoints.** When biasing
|
|
424
|
+
`firstTouch` attribution by stamping touchpoint events, ≤10 touches per
|
|
425
|
+
user enter the candidate pool. Stamping 50 weighted Touch events per
|
|
426
|
+
user gives the same answer as stamping 10. Aim for sparse, distinct
|
|
427
|
+
touches with deterministic weight ratios.
|
|
428
|
+
|
|
429
|
+
25. **Null-aware aggregation removes the need to "fill" defaults.** Don't
|
|
430
|
+
coalesce missing numeric properties to 0 to keep AVG sane — Mixpanel
|
|
431
|
+
skips them. Use absence to signal "no measurement," not "zero."
|
|
432
|
+
|
|
433
|
+
### v1.5 principles
|
|
434
|
+
|
|
435
|
+
26. **Engine auto-sorts events by time after `everything` hook.** Default ON;
|
|
436
|
+
opt out via `autoSortAfterEverything: false` on the dungeon config. Hooks
|
|
437
|
+
that `push()` cloned events with arbitrary timestamps no longer need to
|
|
438
|
+
hand-sort to keep the greedy funnel engine happy. Sort is O(n log n)
|
|
439
|
+
where n is single-user events.
|
|
440
|
+
|
|
441
|
+
27. **Validator auto-promotes funnel-step events to `isStrictEvent: true`.**
|
|
442
|
+
When an event in `events[]` also appears in any user-declared funnel
|
|
443
|
+
sequence, the validator stamps `isStrictEvent: true` and warns. This
|
|
444
|
+
heals the silent-corruption footgun where the greedy engine consumed
|
|
445
|
+
standalone instances as funnel matches. Set `isStrictEvent: false`
|
|
446
|
+
explicitly to opt out and preserve mixed funnel/standalone semantics.
|
|
447
|
+
|
|
448
|
+
28. **Active-day distribution is set via config, not hooks.** Use
|
|
449
|
+
`Dungeon.avgActiveDaysPerUser` at the config level. Hooks should NOT
|
|
450
|
+
engineer global active-day patterns via `injectOnNewDays`. Reserve
|
|
451
|
+
`injectOnNewDays` for cohort-conditional cases ("premium users get 7+
|
|
452
|
+
active days, rest stay default"). See §2.5.
|
|
453
|
+
|
|
454
|
+
29. **Touchpoint cap awareness for attribution hooks.** The engine now caps
|
|
455
|
+
UTM stamping at `maxTouchpointsPerUser` (default 10) per user, sampled
|
|
456
|
+
across the user's lifetime. Attribution-biasing hooks should OVERWRITE
|
|
457
|
+
engine-stamped values (e.g., set `event.utm_source = "google"` on
|
|
458
|
+
already-stamped touches), NOT stamp fresh touches from scratch. Stamping
|
|
459
|
+
from scratch would push the user past the cap and your hook's stamps
|
|
460
|
+
would land outside Mixpanel's last-10 lookback window.
|
|
185
461
|
|
|
186
462
|
---
|
|
187
463
|
|
|
188
|
-
##
|
|
464
|
+
## 4. Recipe Catalog
|
|
465
|
+
|
|
466
|
+
Each recipe shows the hook, the Mixpanel report it targets, and (where
|
|
467
|
+
counting semantics matter) the rule from Section 2.
|
|
189
468
|
|
|
190
469
|
### Temporal Trends
|
|
191
470
|
|
|
192
|
-
####
|
|
471
|
+
#### 4.1 Conversion Change Over Time
|
|
193
472
|
|
|
194
|
-
**Hook
|
|
473
|
+
**Hook:** `funnel-pre` | **Meta:** `meta.firstEventTime`
|
|
474
|
+
**Mixpanel report:** Funnels — conversion rate over time
|
|
195
475
|
|
|
196
|
-
**In Mixpanel:** Funnel conversion
|
|
197
|
-
Before the date, conversion is baseline; after, it jumps or drops.
|
|
476
|
+
**In Mixpanel:** Funnel conversion shows a step-change at a specific date.
|
|
198
477
|
|
|
199
478
|
```js
|
|
200
|
-
// funnel-pre: feature launch boosts onboarding conversion by 20%
|
|
201
479
|
if (type === "funnel-pre") {
|
|
480
|
+
const isTargetFunnel = meta.funnel?.sequence?.includes("Activate");
|
|
481
|
+
if (!isTargetFunnel) return;
|
|
202
482
|
const LAUNCH = dayjs.unix(meta.datasetStart).add(60, "days").valueOf();
|
|
203
483
|
if (meta.firstEventTime > LAUNCH) {
|
|
204
484
|
record.conversionRate *= 1.2;
|
|
@@ -206,30 +486,25 @@ if (type === "funnel-pre") {
|
|
|
206
486
|
}
|
|
207
487
|
```
|
|
208
488
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
**Adaptation:** Change the date offset and multiplier. Use `< LAUNCH` with a
|
|
213
|
-
multiplier `< 1` for degradation stories.
|
|
489
|
+
Greedy funnel engine (Section 2.2) applies after — keep `conversionRate`
|
|
490
|
+
adjustments modest (1.2x is comfortable; 3x can saturate at the 95% cap).
|
|
214
491
|
|
|
215
492
|
---
|
|
216
493
|
|
|
217
|
-
####
|
|
494
|
+
#### 4.2 Feature Launch Inflection
|
|
218
495
|
|
|
219
|
-
**Hook
|
|
496
|
+
**Hook:** `everything` | **Meta:** `meta.datasetStart`
|
|
497
|
+
**Mixpanel report:** Insights — Total events broken down by `Feedback Source`
|
|
220
498
|
|
|
221
|
-
**In Mixpanel:**
|
|
222
|
-
Source
|
|
223
|
-
after a launch date, with volume and ratings jumping.
|
|
499
|
+
**In Mixpanel:** Line chart of `Submit Feedback` broken down by `Feedback
|
|
500
|
+
Source` shows new sources appearing only after a launch date.
|
|
224
501
|
|
|
225
502
|
```js
|
|
226
|
-
// everything: contextual feedback sources appear after feature launch
|
|
227
503
|
if (type === "everything") {
|
|
228
504
|
const LAUNCH = dayjs.unix(meta.datasetStart).add(74, "days");
|
|
229
505
|
const feedbackTemplate = record.find(e => e.event === "Submit Feedback");
|
|
230
506
|
if (!feedbackTemplate) return record;
|
|
231
507
|
|
|
232
|
-
// Path A: Ask MyBuddy -> View Summary within 5 min triggers "Post Search"
|
|
233
508
|
for (const e of record) {
|
|
234
509
|
if (e.event !== "Ask MyBuddy" || !dayjs(e.time).isAfter(LAUNCH)) continue;
|
|
235
510
|
const tail = record.slice(record.indexOf(e));
|
|
@@ -247,23 +522,17 @@ if (type === "everything") {
|
|
|
247
522
|
}
|
|
248
523
|
```
|
|
249
524
|
|
|
250
|
-
**Real-world analogue:** PM discovers that users at "moments of accomplishment"
|
|
251
|
-
are receptive to feedback prompts; contextual triggers outperform timed prompts.
|
|
252
|
-
|
|
253
|
-
**Adaptation:** Replace the sequence and source labels. Any behavioral trigger
|
|
254
|
-
(event count threshold, property match) can gate the injection.
|
|
255
|
-
|
|
256
525
|
---
|
|
257
526
|
|
|
258
|
-
####
|
|
527
|
+
#### 4.3 End-of-Quarter Spike
|
|
259
528
|
|
|
260
|
-
**Hook
|
|
529
|
+
**Hook:** `event` | **Meta:** `meta.datasetStart`
|
|
530
|
+
**Mixpanel report:** Insights — Total events filtered by `event_type`
|
|
261
531
|
|
|
262
|
-
**In Mixpanel:**
|
|
263
|
-
|
|
532
|
+
**In Mixpanel:** `billing event` filtered to `event_type = "plan_upgraded"`
|
|
533
|
+
shows a 4x spike in the final 10 days.
|
|
264
534
|
|
|
265
535
|
```js
|
|
266
|
-
// event: days 80-90, 40% of billing events become plan upgrades
|
|
267
536
|
if (type === "event" && record.event === "billing event") {
|
|
268
537
|
const dayInDataset = dayjs(record.time).diff(dayjs.unix(meta.datasetStart), "days", true);
|
|
269
538
|
if (dayInDataset >= 80 && dayInDataset <= 90 && chance.bool({ likelihood: 40 })) {
|
|
@@ -272,24 +541,18 @@ if (type === "event" && record.event === "billing event") {
|
|
|
272
541
|
}
|
|
273
542
|
```
|
|
274
543
|
|
|
275
|
-
**Real-world analogue:** B2B SaaS revenue clusters at quarter-close as sales
|
|
276
|
-
teams pull deals forward and customers expand seats.
|
|
277
|
-
|
|
278
|
-
**Adaptation:** Change the day range and target property. Clone events for
|
|
279
|
-
volume spikes (team invites, seat additions).
|
|
280
|
-
|
|
281
544
|
---
|
|
282
545
|
|
|
283
|
-
####
|
|
546
|
+
#### 4.4 Degradation and Recovery
|
|
284
547
|
|
|
285
|
-
**Hook
|
|
548
|
+
**Hook:** `everything` | **Meta:** `meta.datasetEnd`, `meta.profile`
|
|
549
|
+
**Mixpanel report:** Insights — Total events broken down by `Region`
|
|
286
550
|
|
|
287
|
-
**In Mixpanel:**
|
|
288
|
-
during the bug window,
|
|
289
|
-
|
|
551
|
+
**In Mixpanel:** `Agenda Error` line chart shows zero before April 10, ramps
|
|
552
|
+
during the bug window, decays after the fix. Breakdown by `Region` shows EU
|
|
553
|
+
dominates.
|
|
290
554
|
|
|
291
555
|
```js
|
|
292
|
-
// everything: EU users get 60% error rate during bug window, exponential decay after fix
|
|
293
556
|
if (type === "everything") {
|
|
294
557
|
const BUG_START = dayjs.unix(meta.datasetEnd).subtract(20, "days");
|
|
295
558
|
const FIX_DATE = dayjs.unix(meta.datasetEnd).subtract(4, "days");
|
|
@@ -301,7 +564,6 @@ if (type === "everything") {
|
|
|
301
564
|
const t = dayjs(agenda.time);
|
|
302
565
|
let likelihood = 60;
|
|
303
566
|
if (t.isAfter(FIX_DATE)) {
|
|
304
|
-
// exponential decay: 60 * 0.15^(days since fix)
|
|
305
567
|
likelihood = Math.max(0, 60 * Math.pow(0.15, t.diff(FIX_DATE, "days", true)));
|
|
306
568
|
}
|
|
307
569
|
if (likelihood > 0 && chance.bool({ likelihood })) {
|
|
@@ -318,40 +580,37 @@ if (type === "everything") {
|
|
|
318
580
|
}
|
|
319
581
|
```
|
|
320
582
|
|
|
321
|
-
**Real-world analogue:** A/B test deployed globally where the backend model
|
|
322
|
-
serving layer lacks coverage in certain EU regions. The experiment looks great
|
|
323
|
-
in aggregate but is silently failing for 30% of users.
|
|
324
|
-
|
|
325
|
-
**Adaptation:** Replace the region check with any profile segment. Adjust the
|
|
326
|
-
decay base (0.15 is aggressive; use 0.5 for slower recovery).
|
|
327
|
-
|
|
328
583
|
---
|
|
329
584
|
|
|
330
|
-
### Magic Numbers
|
|
585
|
+
### Magic Numbers (Distinct-Day Frequency)
|
|
586
|
+
|
|
587
|
+
> The recipes in this section all target Mixpanel **frequency distribution**
|
|
588
|
+
> reports. They use distinct-day binning (Section 2.1), not total-event
|
|
589
|
+
> binning.
|
|
331
590
|
|
|
332
|
-
####
|
|
591
|
+
#### 4.5 Inverted-U Sweet Spot (Frequency Report)
|
|
333
592
|
|
|
334
|
-
**Hook
|
|
593
|
+
**Hook:** `everything` | **Counting:** distinct days (Section 2.1)
|
|
594
|
+
**Mixpanel report:** Insights — Frequency Distribution (distinct-day count)
|
|
335
595
|
|
|
336
|
-
**In Mixpanel:**
|
|
337
|
-
peak conversion at 3
|
|
338
|
-
inverted-U.
|
|
596
|
+
**In Mixpanel:** Insights frequency distribution of `Onboarding Question`
|
|
597
|
+
shows peak conversion at 3 distinct days of activity, dropping on both sides.
|
|
339
598
|
|
|
340
599
|
```js
|
|
341
|
-
|
|
600
|
+
import { binByDistinctPeriods } from "@ak--47/dungeon-master/verify";
|
|
601
|
+
|
|
342
602
|
if (type === "everything") {
|
|
343
603
|
const BINS = {
|
|
344
|
-
low: [0, 3],
|
|
345
|
-
sweet: [3, 4],
|
|
604
|
+
low: [0, 3],
|
|
605
|
+
sweet: [3, 4],
|
|
346
606
|
four: [4, 5],
|
|
347
607
|
high: [5, Infinity],
|
|
348
608
|
};
|
|
349
609
|
const DROP = { low: 75, sweet: 0, four: 20, high: 70 };
|
|
350
|
-
const bin =
|
|
610
|
+
const bin = binByDistinctPeriods(record, "Onboarding Question", BINS, "day");
|
|
351
611
|
const dropProb = DROP[bin] ?? 0;
|
|
352
612
|
|
|
353
613
|
if (dropProb > 0 && chance.bool({ likelihood: dropProb })) {
|
|
354
|
-
// non-converter: keep only acquisition events
|
|
355
614
|
const keep = new Set(["View Shared Page", "Onboarding Question"]);
|
|
356
615
|
dropEventsWhere(record, e => !keep.has(e.event));
|
|
357
616
|
}
|
|
@@ -359,90 +618,89 @@ if (type === "everything") {
|
|
|
359
618
|
}
|
|
360
619
|
```
|
|
361
620
|
|
|
362
|
-
**
|
|
363
|
-
|
|
621
|
+
**Why distinct-day binning:** A user who answered 5 onboarding questions in
|
|
622
|
+
a single sitting is still in the "1 distinct day" bin in Mixpanel's
|
|
623
|
+
frequency report. Total-event binning would mis-classify them as "high
|
|
624
|
+
engagement."
|
|
364
625
|
|
|
365
|
-
**Adaptation:**
|
|
366
|
-
|
|
626
|
+
**Adaptation:** For bins targeting Insights *total event count* rather than
|
|
627
|
+
frequency distribution, swap `binByDistinctPeriods` for
|
|
628
|
+
`binUsersByEventCount`.
|
|
367
629
|
|
|
368
630
|
---
|
|
369
631
|
|
|
370
|
-
####
|
|
632
|
+
#### 4.6 Frequency × Engagement Sweet Spot
|
|
371
633
|
|
|
372
|
-
**Hook
|
|
634
|
+
**Hook:** `everything` | **Counting:** distinct days
|
|
635
|
+
**Mixpanel report:** Insights — Frequency × Frequency cross-table (distinct-day counts on both axes)
|
|
373
636
|
|
|
374
|
-
**In Mixpanel:** Users with 3-8
|
|
375
|
-
amounts. Users with 9+ are window-shoppers whose
|
|
637
|
+
**In Mixpanel:** Users with 3-8 distinct browse days show 25% higher cart
|
|
638
|
+
amounts. Users with 9+ distinct browse days are window-shoppers whose
|
|
639
|
+
checkouts drop 30%.
|
|
376
640
|
|
|
377
641
|
```js
|
|
378
|
-
|
|
642
|
+
import { countDistinctPeriods } from "@ak--47/dungeon-master/verify";
|
|
643
|
+
|
|
379
644
|
if (type === "everything") {
|
|
380
|
-
const
|
|
381
|
-
if (
|
|
382
|
-
// sweet spot: boost cart amounts
|
|
645
|
+
const browseDays = countDistinctPeriods(record, "view item", "day");
|
|
646
|
+
if (browseDays >= 3 && browseDays <= 8) {
|
|
383
647
|
scalePropertyValue(record, e => e.event === "checkout", "amount", 1.25);
|
|
384
|
-
} else if (
|
|
385
|
-
// decision paralysis: drop checkouts
|
|
648
|
+
} else if (browseDays >= 9) {
|
|
386
649
|
dropEventsWhere(record, e => e.event === "checkout" && chance.bool({ likelihood: 30 }));
|
|
387
650
|
}
|
|
388
651
|
return record;
|
|
389
652
|
}
|
|
390
653
|
```
|
|
391
654
|
|
|
392
|
-
**Real-world analogue:** Shoppers who browse a moderate amount convert with
|
|
393
|
-
higher carts; excessive browsing signals indecision and abandonment.
|
|
394
|
-
|
|
395
|
-
**Adaptation:** Replace event names and property. Works for any
|
|
396
|
-
count-of-A-affects-B-outcome pattern.
|
|
397
|
-
|
|
398
655
|
---
|
|
399
656
|
|
|
400
|
-
####
|
|
657
|
+
#### 4.7 Move Users Between Frequency Bins (Active Day Injection)
|
|
401
658
|
|
|
402
|
-
**Hook
|
|
659
|
+
**Hook:** `everything` | **Atom:** [`injectOnNewDays`](lib/hook-helpers/inject.js)
|
|
660
|
+
**Mixpanel report:** Insights — Frequency Distribution (distinct-day buckets per user)
|
|
403
661
|
|
|
404
|
-
**In Mixpanel:**
|
|
405
|
-
|
|
662
|
+
**In Mixpanel:** A "power user" cohort needs ≥7 distinct days of `commit
|
|
663
|
+
pushed` activity in the dataset window. Some users have the right total
|
|
664
|
+
count but cluster on 2-3 days; spread their activity across more days to
|
|
665
|
+
move them into the cohort.
|
|
406
666
|
|
|
407
667
|
```js
|
|
408
|
-
|
|
668
|
+
import { injectOnNewDays } from "@ak--47/dungeon-master/hook-helpers";
|
|
669
|
+
import { countDistinctPeriods } from "@ak--47/dungeon-master/verify";
|
|
670
|
+
|
|
409
671
|
if (type === "everything") {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
// flaky burnout: drop 25% of deploys
|
|
416
|
-
scaleEventCount(record, "deployment completed", 0.75);
|
|
417
|
-
}
|
|
672
|
+
if (meta.profile?.tier !== "premium") return record;
|
|
673
|
+
const days = countDistinctPeriods(record, "commit pushed", "day");
|
|
674
|
+
if (days >= 7) return record;
|
|
675
|
+
// Only premium users get the boost; spread commits across 7 distinct days.
|
|
676
|
+
injectOnNewDays(record, "commit pushed", 7);
|
|
418
677
|
return record;
|
|
419
678
|
}
|
|
420
679
|
```
|
|
421
680
|
|
|
422
|
-
**
|
|
423
|
-
|
|
681
|
+
**Why this works:** `scaleEventCount(record, "commit pushed", 3)` clones
|
|
682
|
+
events at sub-second offsets — they all land on the same calendar day, so
|
|
683
|
+
Mixpanel's frequency report shows no movement.
|
|
684
|
+
[`injectOnNewDays`](lib/hook-helpers/inject.js) finds days inside the
|
|
685
|
+
user's active window with no `commit pushed` activity and clones one event
|
|
686
|
+
onto each, advancing the user's distinct-day count by exactly the right
|
|
687
|
+
amount.
|
|
424
688
|
|
|
425
|
-
**
|
|
426
|
-
|
|
427
|
-
|
|
689
|
+
**Constraints:**
|
|
690
|
+
- Requires at least one `commit pushed` event already on the user (template).
|
|
691
|
+
- Injections respect the user's first-to-last event window — never extends it.
|
|
692
|
+
- Stripped `insert_id` so Mixpanel re-dedups on import.
|
|
428
693
|
|
|
429
694
|
---
|
|
430
695
|
|
|
431
696
|
### Experiments
|
|
432
697
|
|
|
433
|
-
####
|
|
434
|
-
|
|
435
|
-
**Hook type:** `funnel-post` + `everything` | **Meta:** `meta.experiment`
|
|
436
|
-
|
|
437
|
-
**In Mixpanel:** Experiment report shows Variant B outperforms on downstream
|
|
438
|
-
metrics (more Add Talking Point events, higher engagement). Breakdown by
|
|
439
|
-
"Variant name" on `$experiment_started` shows even distribution.
|
|
698
|
+
#### 4.8 A/B/C Test with Variant-Specific Effects
|
|
440
699
|
|
|
441
|
-
|
|
442
|
-
|
|
700
|
+
**Hook:** `funnel-post` + `everything` | **Meta:** `meta.experiment`
|
|
701
|
+
**Mixpanel report:** Experiments — variant performance on downstream metric
|
|
443
702
|
|
|
444
703
|
```js
|
|
445
|
-
// Funnel config (declarative):
|
|
446
704
|
{
|
|
447
705
|
sequence: ["Create Agenda", "Agenda Generated"],
|
|
448
706
|
conversionRate: 60,
|
|
@@ -459,41 +717,32 @@ assignment, `$experiment_started` events, and conversion modifiers:
|
|
|
459
717
|
```
|
|
460
718
|
|
|
461
719
|
```js
|
|
462
|
-
// funnel-post: inject downstream events for Variant B
|
|
463
720
|
if (type === "funnel-post" && meta.experiment?.variantName === "Variant B") {
|
|
464
721
|
const last = record[record.length - 1];
|
|
465
|
-
if (last)
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
}
|
|
722
|
+
if (!last) return;
|
|
723
|
+
const tpTemplate = record.find(e => e.event === "Add Talking Point") || last;
|
|
724
|
+
record.push(cloneEvent(tpTemplate, {
|
|
725
|
+
event: "Add Talking Point",
|
|
726
|
+
time: dayjs(last.time).add(chance.integer({ min: 5, max: 30 }), "minutes").toISOString(),
|
|
727
|
+
user_id: last.user_id,
|
|
728
|
+
"Source": "AI Suggested",
|
|
729
|
+
}));
|
|
474
730
|
}
|
|
475
731
|
```
|
|
476
732
|
|
|
477
|
-
**Real-world analogue:** A/B test where the winning variant drives measurably
|
|
478
|
-
more downstream engagement, not just higher funnel conversion.
|
|
479
|
-
|
|
480
|
-
**Adaptation:** Change the variant names, multipliers, and the downstream
|
|
481
|
-
events injected. Combine with an everything-hook EU bug story for a "looks
|
|
482
|
-
great in aggregate, broken in a segment" narrative.
|
|
483
|
-
|
|
484
733
|
---
|
|
485
734
|
|
|
486
735
|
### Cohort Effects
|
|
487
736
|
|
|
488
|
-
####
|
|
737
|
+
#### 4.9 Subscription Tier Stacking
|
|
489
738
|
|
|
490
|
-
**Hook
|
|
739
|
+
**Hook:** `everything` | **Counting:** null-aware AVG (Section 2.3)
|
|
740
|
+
**Mixpanel report:** Insights — AVG of `reward_gold` broken down by `subscription_tier`
|
|
491
741
|
|
|
492
|
-
**In Mixpanel:**
|
|
493
|
-
subscription_tier
|
|
742
|
+
**In Mixpanel:** `quest turned in` AVG `reward_gold` broken down by
|
|
743
|
+
`subscription_tier` shows Premium at 1.4x, Elite at 1.8x vs Free.
|
|
494
744
|
|
|
495
745
|
```js
|
|
496
|
-
// everything: tier-based reward scaling
|
|
497
746
|
if (type === "everything") {
|
|
498
747
|
const tier = meta.profile.subscription_tier;
|
|
499
748
|
const multiplier = tier === "Elite" ? 1.8 : tier === "Premium" ? 1.4 : 1.0;
|
|
@@ -505,31 +754,24 @@ if (type === "everything") {
|
|
|
505
754
|
}
|
|
506
755
|
```
|
|
507
756
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
**Adaptation:** Change the profile property, event, and value property. Works
|
|
512
|
-
for any segment-scales-value pattern.
|
|
757
|
+
Null-aware AVG means events without a numeric `reward_gold` are silently
|
|
758
|
+
ignored — no need to fill defaults to keep the average sensible.
|
|
513
759
|
|
|
514
760
|
---
|
|
515
761
|
|
|
516
|
-
####
|
|
517
|
-
|
|
518
|
-
**Hook type:** `everything` | **Meta:** none (derived from events)
|
|
762
|
+
#### 4.10 Integration Users Succeed (Compound Cohort)
|
|
519
763
|
|
|
520
|
-
**
|
|
521
|
-
|
|
764
|
+
**Hook:** `everything`
|
|
765
|
+
**Mixpanel report:** Insights — AVG of `response_time_mins` broken down by behavioral cohort
|
|
522
766
|
|
|
523
767
|
```js
|
|
524
|
-
// everything: integration users resolve incidents faster
|
|
525
768
|
if (type === "everything") {
|
|
526
769
|
let hasSlack = false, hasPagerduty = false;
|
|
527
|
-
|
|
528
|
-
if (e.event
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
});
|
|
770
|
+
for (const e of record) {
|
|
771
|
+
if (e.event !== "integration configured") continue;
|
|
772
|
+
if (e.integration_type === "slack") hasSlack = true;
|
|
773
|
+
if (e.integration_type === "pagerduty") hasPagerduty = true;
|
|
774
|
+
}
|
|
533
775
|
if (hasSlack && hasPagerduty) {
|
|
534
776
|
scalePropertyValue(record, e => e.event === "alert acknowledged", "response_time_mins", 0.4);
|
|
535
777
|
scalePropertyValue(record, e => e.event === "alert resolved", "resolution_time_mins", 0.5);
|
|
@@ -538,186 +780,127 @@ if (type === "everything") {
|
|
|
538
780
|
}
|
|
539
781
|
```
|
|
540
782
|
|
|
541
|
-
**Real-world analogue:** Teams that wire alerting into their existing comms
|
|
542
|
-
stack respond minutes faster -- the alert literally finds the human.
|
|
543
|
-
|
|
544
|
-
**Adaptation:** Replace the integration check with any compound behavioral
|
|
545
|
-
condition (two+ events, property matches, thresholds).
|
|
546
|
-
|
|
547
783
|
---
|
|
548
784
|
|
|
549
|
-
####
|
|
550
|
-
|
|
551
|
-
**Hook type:** `everything` | **Meta:** none (derived from events)
|
|
785
|
+
#### 4.11 Power User Behavioral Amplification
|
|
552
786
|
|
|
553
|
-
**
|
|
554
|
-
|
|
787
|
+
**Hook:** `everything`
|
|
788
|
+
**Mixpanel report:** Insights — Total events + AVG of `reward_gold` for behavioral cohort
|
|
555
789
|
|
|
556
790
|
```js
|
|
557
|
-
// everything: Ancient Compass users get amplified rewards + extra quests
|
|
558
791
|
if (type === "everything") {
|
|
559
792
|
const usedCompass = record.some(e => e.event === "use item" && e.item_type === "Ancient Compass");
|
|
560
793
|
if (!usedCompass) return record;
|
|
561
794
|
|
|
562
|
-
|
|
563
|
-
if (
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
}));
|
|
573
|
-
}
|
|
795
|
+
for (const e of record) {
|
|
796
|
+
if (e.event !== "quest turned in") continue;
|
|
797
|
+
e.reward_gold = Math.floor((e.reward_gold || 100) * 1.5);
|
|
798
|
+
e.reward_xp = Math.floor((e.reward_xp || 500) * 1.5);
|
|
799
|
+
if (chance.bool({ likelihood: 40 })) {
|
|
800
|
+
record.push(cloneEvent(e, {
|
|
801
|
+
time: dayjs(e.time).add(chance.integer({ min: 10, max: 120 }), "minutes").toISOString(),
|
|
802
|
+
user_id: e.user_id,
|
|
803
|
+
quest_id: chance.pickone(questIds),
|
|
804
|
+
}));
|
|
574
805
|
}
|
|
575
|
-
}
|
|
806
|
+
}
|
|
576
807
|
return record;
|
|
577
808
|
}
|
|
578
809
|
```
|
|
579
810
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
811
|
+
Cloned quest events at minute offsets land on the same day as the original
|
|
812
|
+
~95% of the time — fine for Insights `count of quests` reports, but won't
|
|
813
|
+
move users in a "distinct days with quest" frequency report. If that's the
|
|
814
|
+
verification target, add `injectOnNewDays(record, "quest turned in", N)`
|
|
815
|
+
after the main cloning loop.
|
|
585
816
|
|
|
586
817
|
---
|
|
587
818
|
|
|
588
819
|
### Operational Stories
|
|
589
820
|
|
|
590
|
-
####
|
|
591
|
-
|
|
592
|
-
**Hook type:** `everything` | **Meta:** none
|
|
821
|
+
#### 4.12 Night Deploy Failure Spike
|
|
593
822
|
|
|
594
|
-
**
|
|
595
|
-
|
|
823
|
+
**Hook:** `everything`
|
|
824
|
+
**Mixpanel report:** Insights — Total events filtered by `deploy_status='failed'`, broken down by hour-of-day
|
|
596
825
|
|
|
597
826
|
```js
|
|
598
|
-
// everything: night deploys fail at 40% rate
|
|
599
827
|
if (type === "everything") {
|
|
600
|
-
|
|
601
|
-
if (e.event
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
}
|
|
828
|
+
for (const e of record) {
|
|
829
|
+
if (e.event !== "deployment completed") continue;
|
|
830
|
+
const hour = new Date(e.time).getUTCHours();
|
|
831
|
+
if ((hour >= 22 || hour < 6) && chance.bool({ likelihood: 40 })) {
|
|
832
|
+
e.deploy_status = "failed";
|
|
606
833
|
}
|
|
607
|
-
}
|
|
834
|
+
}
|
|
608
835
|
return record;
|
|
609
836
|
}
|
|
610
837
|
```
|
|
611
838
|
|
|
612
|
-
**Real-world analogue:** Night deploys fail more due to skeleton crews and
|
|
613
|
-
delayed incident response.
|
|
614
|
-
|
|
615
|
-
**Adaptation:** Change the hour range and failure likelihood. Works for any
|
|
616
|
-
time-of-day-affects-outcome pattern.
|
|
617
|
-
|
|
618
839
|
---
|
|
619
840
|
|
|
620
|
-
####
|
|
621
|
-
|
|
622
|
-
**Hook type:** `everything` | **Meta:** `meta.profile`
|
|
623
|
-
|
|
624
|
-
**In Mixpanel:** Error events broken down by Region show EU dominating (>90%
|
|
625
|
-
of errors), concentrated in a specific date window.
|
|
841
|
+
#### 4.13 Regional Error Injection
|
|
626
842
|
|
|
627
|
-
|
|
628
|
-
The key addition is a profile-segment gate:
|
|
843
|
+
**Mixpanel report:** Insights — Total errors broken down by `Region`
|
|
629
844
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
// ... inject errors only for EU users during the bug window
|
|
633
|
-
```
|
|
634
|
-
|
|
635
|
-
**Real-world analogue:** Region-specific infrastructure failure that only
|
|
636
|
-
affects a subset of users, invisible in aggregate metrics.
|
|
845
|
+
See [4.4 Degradation and Recovery](#44-degradation-and-recovery) — the same
|
|
846
|
+
template with a profile-segment gate.
|
|
637
847
|
|
|
638
848
|
---
|
|
639
849
|
|
|
640
850
|
### Funnel Manipulation
|
|
641
851
|
|
|
642
|
-
####
|
|
852
|
+
#### 4.14 TTC by User Segment (Timestamp Shifting)
|
|
643
853
|
|
|
644
|
-
**Hook
|
|
854
|
+
**Hook:** `everything` | **Counting:** greedy funnel TTC (Section 2.2)
|
|
855
|
+
**Mixpanel report:** Funnels — Time to Convert, broken down by user segment
|
|
645
856
|
|
|
646
857
|
**In Mixpanel:** Funnel median TTC, broken down by segment, shows Enterprise
|
|
647
|
-
completing 3x faster than Free.
|
|
648
|
-
Mixpanel's Funnel TTC report — Mixpanel measures the delta between event
|
|
649
|
-
timestamps, not property values.
|
|
858
|
+
completing 3x faster than Free.
|
|
650
859
|
|
|
651
860
|
```js
|
|
652
|
-
|
|
861
|
+
import { findFirstSequence, scaleFunnelTTC } from "@ak--47/dungeon-master/hook-helpers";
|
|
862
|
+
|
|
653
863
|
if (type === "everything") {
|
|
654
|
-
const factor = meta.profile?.tier === "elite" ? 0.3
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
864
|
+
const factor = meta.profile?.tier === "elite" ? 0.3
|
|
865
|
+
: meta.profile?.tier === "free" ? 1.4
|
|
866
|
+
: 1.0;
|
|
867
|
+
if (factor === 1.0) return record;
|
|
868
|
+
|
|
869
|
+
const seq = findFirstSequence(record, ["step_a", "step_b", "step_c"], 60 * 24 * 30);
|
|
870
|
+
if (seq) scaleFunnelTTC(seq, factor);
|
|
659
871
|
return record;
|
|
660
872
|
}
|
|
661
873
|
```
|
|
662
874
|
|
|
663
|
-
**
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
SELECT user_id, event, time::TIMESTAMP AS t
|
|
667
|
-
FROM events WHERE event IN ('step_a', 'step_b', 'step_c')
|
|
668
|
-
),
|
|
669
|
-
funnel AS (
|
|
670
|
-
SELECT DISTINCT ON (a.user_id) a.user_id, a.t AS start_t,
|
|
671
|
-
(SELECT MIN(t) FROM steps c
|
|
672
|
-
WHERE c.user_id = a.user_id AND c.event = 'step_c' AND c.t > a.t) AS end_t
|
|
673
|
-
FROM steps a WHERE a.event = 'step_a'
|
|
674
|
-
ORDER BY a.user_id, a.t
|
|
675
|
-
)
|
|
676
|
-
SELECT segment,
|
|
677
|
-
COUNT(*) AS users,
|
|
678
|
-
ROUND(MEDIAN(EXTRACT(EPOCH FROM (end_t - start_t)) / 60), 1) AS median_min
|
|
679
|
-
FROM funnel JOIN users USING (user_id)
|
|
680
|
-
WHERE end_t IS NOT NULL GROUP BY segment ORDER BY median_min;
|
|
681
|
-
```
|
|
682
|
-
|
|
683
|
-
**Warning:** Never use the lazy proxy `MIN(step_a.time) → MIN(step_c.time)` per
|
|
684
|
-
user. This mixes events from different funnel passes and produces inverted or
|
|
685
|
-
flat results. Always bind the sequence: first A, then first C *after that A*.
|
|
686
|
-
|
|
687
|
-
**Real-world analogue:** Enterprise customers with dedicated CSMs and priority
|
|
688
|
-
support complete multi-step workflows faster.
|
|
689
|
-
|
|
690
|
-
**Adaptation:** Change the profile key, funnel steps, and factors. Use stronger
|
|
691
|
-
factors (0.3x/1.4x) to produce clear separation in the funnel TTC report.
|
|
692
|
-
|
|
693
|
-
#### 3.14b Supplementary Property Scaling
|
|
694
|
-
|
|
695
|
-
Optionally also scale timing *properties* (e.g., `response_time_mins`) by the
|
|
696
|
-
same segment. This creates a complementary signal visible in Mixpanel Insights
|
|
697
|
-
(`AVG(property) GROUP BY segment`) but does NOT affect the Funnel TTC report.
|
|
698
|
-
Useful when the dungeon has timing properties on the relevant events:
|
|
875
|
+
**Why timestamps and not properties:** Mixpanel's funnel TTC report uses the
|
|
876
|
+
greedy engine (Section 2.2) on event timestamps. A property like
|
|
877
|
+
`wait_time_hours` doesn't enter the calculation. To verify locally:
|
|
699
878
|
|
|
700
879
|
```js
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
880
|
+
import { evaluateFunnel } from "@ak--47/dungeon-master/verify";
|
|
881
|
+
|
|
882
|
+
const r = evaluateFunnel(userEvents, ["step_a", "step_b", "step_c"]);
|
|
883
|
+
console.log(r.completed, r.ttcMs);
|
|
704
884
|
```
|
|
705
885
|
|
|
706
|
-
|
|
886
|
+
**Greedy gotcha:** `findFirstSequence` finds an in-order sequence within the
|
|
887
|
+
max gap. If your sequence has multiple `step_a` events, only the first is
|
|
888
|
+
used. The greedy engine in Mixpanel picks the same first one, so behavior
|
|
889
|
+
matches.
|
|
707
890
|
|
|
708
|
-
|
|
891
|
+
**Conversion-window strict `<`:** If `step_c` lands at exactly `step_a + window`,
|
|
892
|
+
it is **excluded**. When shifting timestamps, leave at least 1ms of slack
|
|
893
|
+
under the conversion-window cap.
|
|
709
894
|
|
|
710
|
-
|
|
895
|
+
---
|
|
711
896
|
|
|
712
|
-
|
|
897
|
+
#### 4.15 Funnel Conversion by Profile
|
|
713
898
|
|
|
714
|
-
**
|
|
715
|
-
|
|
899
|
+
**Hook:** `funnel-pre` | **Meta:** `meta.profile`, `meta.funnel`
|
|
900
|
+
**Mixpanel report:** Funnels — conversion rate broken down by `plan_tier`
|
|
716
901
|
|
|
717
902
|
```js
|
|
718
|
-
// funnel-pre: paid users get boosted conversion — SCOPED to a specific funnel
|
|
719
903
|
if (type === "funnel-pre") {
|
|
720
|
-
// Always scope to the intended funnel (see principle #16)
|
|
721
904
|
const isTargetFunnel = meta.funnel?.sequence?.includes("certificate earned");
|
|
722
905
|
if (!isTargetFunnel) return;
|
|
723
906
|
|
|
@@ -730,42 +913,29 @@ if (type === "funnel-pre") {
|
|
|
730
913
|
}
|
|
731
914
|
```
|
|
732
915
|
|
|
733
|
-
**Real-world analogue:** Paid-tier users who've invested in the product
|
|
734
|
-
complete multi-step workflows at higher rates.
|
|
735
|
-
|
|
736
|
-
**Adaptation:** Change the profile key, multipliers, and funnel scope check.
|
|
737
|
-
Always include the `isTargetFunnel` guard — unscoped funnel-pre hooks affect
|
|
738
|
-
ALL funnels and create cascading event-budget interactions (see principle #16).
|
|
739
|
-
|
|
740
916
|
---
|
|
741
917
|
|
|
742
918
|
### Event Injection
|
|
743
919
|
|
|
744
|
-
####
|
|
920
|
+
#### 4.16 Binge-Watching Pattern
|
|
745
921
|
|
|
746
|
-
**Hook
|
|
747
|
-
|
|
748
|
-
**In Mixpanel:** Users with 3+ consecutive completions show 1.5x more
|
|
749
|
-
completions per user. Pause events are suppressed for bingers.
|
|
922
|
+
**Hook:** `everything`
|
|
923
|
+
**Mixpanel report:** Insights — Total `playback completed` per user / Flows
|
|
750
924
|
|
|
751
925
|
```js
|
|
752
|
-
// everything: binge-watchers get extra playback pairs, fewer pauses
|
|
753
926
|
if (type === "everything") {
|
|
754
|
-
// detect 3+ consecutive completions
|
|
755
927
|
let streak = 0, maxStreak = 0;
|
|
756
|
-
|
|
928
|
+
for (const e of record) {
|
|
757
929
|
if (e.event === "playback completed") { streak++; maxStreak = Math.max(maxStreak, streak); }
|
|
758
930
|
else if (e.event !== "playback started") { streak = 0; }
|
|
759
|
-
}
|
|
931
|
+
}
|
|
760
932
|
if (maxStreak < 3) return record;
|
|
761
933
|
|
|
762
|
-
// suppress 60% of pauses
|
|
763
934
|
dropEventsWhere(record, e => e.event === "playback paused" && chance.bool({ likelihood: 60 }));
|
|
764
935
|
|
|
765
|
-
// clone start+complete pairs for 40% of completions
|
|
766
936
|
const startTemplate = record.find(e => e.event === "playback started");
|
|
767
|
-
record.filter(e => e.event === "playback completed")
|
|
768
|
-
if (!chance.bool({ likelihood: 40 }))
|
|
937
|
+
for (const e of record.filter(e => e.event === "playback completed")) {
|
|
938
|
+
if (!chance.bool({ likelihood: 40 })) continue;
|
|
769
939
|
const t = dayjs(e.time);
|
|
770
940
|
if (startTemplate) {
|
|
771
941
|
record.push(cloneEvent(startTemplate, {
|
|
@@ -778,77 +948,34 @@ if (type === "everything") {
|
|
|
778
948
|
time: t.add(chance.integer({ min: 30, max: 90 }), "minutes").toISOString(),
|
|
779
949
|
user_id: e.user_id,
|
|
780
950
|
}));
|
|
781
|
-
}
|
|
951
|
+
}
|
|
782
952
|
return record;
|
|
783
953
|
}
|
|
784
954
|
```
|
|
785
955
|
|
|
786
|
-
**Real-world analogue:** Autoplay and cliffhangers push hooked viewers through
|
|
787
|
-
entire seasons in a sitting.
|
|
788
|
-
|
|
789
|
-
**Adaptation:** Replace event names. The pattern (detect streak -> suppress
|
|
790
|
-
interrupts -> clone continuation pairs) generalizes to any repeat-consumption
|
|
791
|
-
flow.
|
|
792
|
-
|
|
793
956
|
---
|
|
794
957
|
|
|
795
|
-
####
|
|
796
|
-
|
|
797
|
-
**Hook type:** `everything` | **Meta:** `meta.datasetStart`
|
|
798
|
-
|
|
799
|
-
**In Mixpanel:** Flows report shows "Ask MyBuddy" -> "View Summary" as a
|
|
800
|
-
strong preceding path for "Submit Feedback". Feedback source breakdown reveals
|
|
801
|
-
"Post Search" only appearing after the feature launch date.
|
|
802
|
-
|
|
803
|
-
```js
|
|
804
|
-
// everything: detect Ask -> View within 5 min, inject contextual feedback
|
|
805
|
-
if (type === "everything") {
|
|
806
|
-
const LAUNCH = dayjs.unix(meta.datasetStart).add(74, "days");
|
|
807
|
-
const feedbackTemplate = record.find(e => e.event === "Submit Feedback");
|
|
808
|
-
if (!feedbackTemplate) return record;
|
|
809
|
-
|
|
810
|
-
for (let i = 0; i < record.length; i++) {
|
|
811
|
-
if (record[i].event !== "Ask MyBuddy") continue;
|
|
812
|
-
if (!dayjs(record[i].time).isAfter(LAUNCH)) continue;
|
|
813
|
-
const tail = record.slice(i);
|
|
814
|
-
const match = findFirstSequence(tail, ["Ask MyBuddy", "View Summary"], 5);
|
|
815
|
-
if (match && chance.bool({ likelihood: 35 })) {
|
|
816
|
-
record.push(cloneEvent(feedbackTemplate, {
|
|
817
|
-
time: dayjs(match[1].time).add(2, "minutes").toISOString(),
|
|
818
|
-
user_id: record[0].user_id,
|
|
819
|
-
"Rating": chance.integer({ min: 4, max: 5 }),
|
|
820
|
-
"Feedback Source": "Post Search",
|
|
821
|
-
}));
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
return record;
|
|
825
|
-
}
|
|
826
|
-
```
|
|
958
|
+
#### 4.17 Contextual Event Injection
|
|
827
959
|
|
|
828
|
-
**
|
|
829
|
-
|
|
960
|
+
**Hook:** `everything`
|
|
961
|
+
**Mixpanel report:** Flows — preceding-path analysis for `Submit Feedback`
|
|
830
962
|
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
963
|
+
See [4.2 Feature Launch Inflection](#42-feature-launch-inflection) for the
|
|
964
|
+
full implementation. Key atom: `findFirstSequence(tail, [eventA, eventB],
|
|
965
|
+
maxGapMin)` returns the matched events or `null`.
|
|
834
966
|
|
|
835
967
|
---
|
|
836
968
|
|
|
837
969
|
### Cross-Event State
|
|
838
970
|
|
|
839
|
-
####
|
|
971
|
+
#### 4.18 Closure-Based State (Cost Overrun → Scale Down)
|
|
840
972
|
|
|
841
|
-
**Hook
|
|
842
|
-
|
|
843
|
-
**In Mixpanel:** Sequencing users' cost reports with 25%+ cost_change_percent
|
|
844
|
-
followed by their next "infrastructure scaled" event shows 100% of those
|
|
845
|
-
next-scale events are `scale_direction = "down"`.
|
|
973
|
+
**Hook:** `event` | Module-level Map
|
|
974
|
+
**Mixpanel report:** Insights — `infrastructure scaled` broken down by `scale_direction`, sequenced after `cost report generated`
|
|
846
975
|
|
|
847
976
|
```js
|
|
848
|
-
// Module-level Map — persists across hook calls within a single dungeon run
|
|
849
977
|
const costOverrunUsers = new Map();
|
|
850
978
|
|
|
851
|
-
// event: cost report > 25% records user; next scale event forced down
|
|
852
979
|
if (type === "event") {
|
|
853
980
|
if (record.event === "cost report generated" && record.cost_change_percent > 25) {
|
|
854
981
|
costOverrunUsers.set(record.user_id, true);
|
|
@@ -860,21 +987,12 @@ if (type === "event") {
|
|
|
860
987
|
}
|
|
861
988
|
```
|
|
862
989
|
|
|
863
|
-
**Real-world analogue:** A surprise cloud bill triggers an immediate
|
|
864
|
-
downscale; no engineer ignores a 25% month-over-month cost jump.
|
|
865
|
-
|
|
866
|
-
**Adaptation:** Replace the trigger condition and the forced property value.
|
|
867
|
-
Module-level Maps work for any "event A for user X affects their next event B"
|
|
868
|
-
pattern. The Map acts as a one-shot flag that is consumed on the next match.
|
|
869
|
-
|
|
870
990
|
---
|
|
871
991
|
|
|
872
|
-
####
|
|
992
|
+
#### 4.19 Failed Deploy Recovery
|
|
873
993
|
|
|
874
|
-
**Hook
|
|
875
|
-
|
|
876
|
-
**In Mixpanel:** Successful deploys immediately following a failed deploy show
|
|
877
|
-
1.5x longer duration, reflecting the extra verification overhead.
|
|
994
|
+
**Hook:** `event` | Module-level Map
|
|
995
|
+
**Mixpanel report:** Insights — AVG of `duration_sec` broken down by `status`, sequenced after a failed run
|
|
878
996
|
|
|
879
997
|
```js
|
|
880
998
|
const failedDeployUsers = new Map();
|
|
@@ -889,23 +1007,16 @@ if (type === "event" && record.event === "deployment pipeline run") {
|
|
|
889
1007
|
}
|
|
890
1008
|
```
|
|
891
1009
|
|
|
892
|
-
**Real-world analogue:** After a bad deploy, teams add manual gates and extra
|
|
893
|
-
verification steps that slow the very next release.
|
|
894
|
-
|
|
895
1010
|
---
|
|
896
1011
|
|
|
897
1012
|
### Profile Enrichment
|
|
898
1013
|
|
|
899
|
-
####
|
|
900
|
-
|
|
901
|
-
**Hook type:** `user` | **Meta:** none
|
|
1014
|
+
#### 4.20 Segment-Based Profile Enrichment
|
|
902
1015
|
|
|
903
|
-
**
|
|
904
|
-
|
|
905
|
-
(50-500).
|
|
1016
|
+
**Hook:** `user`
|
|
1017
|
+
**Mixpanel report:** Insights — User profile property AVG broken down by `company_size` (no event report — profile-only)
|
|
906
1018
|
|
|
907
1019
|
```js
|
|
908
|
-
// user: company size determines seat count, ACV, and CSM assignment
|
|
909
1020
|
if (type === "user") {
|
|
910
1021
|
const size = record.company_size;
|
|
911
1022
|
if (size === "enterprise") {
|
|
@@ -920,29 +1031,20 @@ if (type === "user") {
|
|
|
920
1031
|
}
|
|
921
1032
|
```
|
|
922
1033
|
|
|
923
|
-
**Real-world analogue:** B2B SaaS pricing scales orders of magnitude across
|
|
924
|
-
customer segments.
|
|
925
|
-
|
|
926
|
-
**Adaptation:** Change the profile properties and segment values. Use for any
|
|
927
|
-
"profile property A determines profile properties B, C, D" pattern.
|
|
928
|
-
|
|
929
1034
|
---
|
|
930
1035
|
|
|
931
1036
|
### Churn and Retention
|
|
932
1037
|
|
|
933
|
-
####
|
|
1038
|
+
#### 4.21 Hash-Based Churn Silencing
|
|
934
1039
|
|
|
935
|
-
**Hook
|
|
936
|
-
|
|
937
|
-
**In Mixpanel:** Retention report shows a visible cliff at day 30, with 10-20%
|
|
938
|
-
of users going completely silent.
|
|
1040
|
+
**Hook:** `everything`
|
|
1041
|
+
**Mixpanel report:** Retention / Insights — surviving event count broken down by hash-derived cohort
|
|
939
1042
|
|
|
940
1043
|
```js
|
|
941
|
-
// everything: deterministic 20% of users go silent after day 30
|
|
942
1044
|
if (type === "everything") {
|
|
943
1045
|
const uid = record[0]?.user_id || record[0]?.device_id || "";
|
|
944
1046
|
const idHash = String(uid).split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
|
945
|
-
if (idHash % 5 !== 0) return record;
|
|
1047
|
+
if (idHash % 5 !== 0) return record;
|
|
946
1048
|
|
|
947
1049
|
const cutoff = dayjs.unix(meta.datasetStart).add(30, "days");
|
|
948
1050
|
dropEventsWhere(record, e => dayjs(e.time).isAfter(cutoff));
|
|
@@ -950,39 +1052,37 @@ if (type === "everything") {
|
|
|
950
1052
|
}
|
|
951
1053
|
```
|
|
952
1054
|
|
|
953
|
-
**Real-world analogue:** Most SaaS churn happens silently -- accounts simply
|
|
954
|
-
stop logging in long before the formal cancellation.
|
|
955
|
-
|
|
956
|
-
**Adaptation:** Change the hash modulus (5 = 20%, 10 = 10%) and the day
|
|
957
|
-
cutoff. Use char-code hashing for deterministic, seedless cohort assignment
|
|
958
|
-
that survives re-runs.
|
|
959
|
-
|
|
960
1055
|
---
|
|
961
1056
|
|
|
962
|
-
####
|
|
1057
|
+
#### 4.22 Retention Magic Number (N Distinct Days in First X Days)
|
|
963
1058
|
|
|
964
|
-
**Hook
|
|
1059
|
+
**Hook:** `everything` | **Meta:** `meta.userIsBornInDataset` |
|
|
1060
|
+
**Mixpanel report:** Retention — N+1 day retention by cohort
|
|
1061
|
+
**Counting:** distinct days (Section 2.1)
|
|
965
1062
|
|
|
966
|
-
**In Mixpanel:**
|
|
967
|
-
|
|
968
|
-
|
|
1063
|
+
**In Mixpanel:** Born-in-dataset users with **5+ distinct days** of `user
|
|
1064
|
+
followed` activity in their first 14 days retain ~2x better past day 36 than
|
|
1065
|
+
users below the threshold.
|
|
969
1066
|
|
|
970
1067
|
```js
|
|
971
|
-
|
|
1068
|
+
import { countDistinctPeriods } from "@ak--47/dungeon-master/verify";
|
|
1069
|
+
|
|
972
1070
|
if (type === "everything") {
|
|
973
1071
|
if (!meta.userIsBornInDataset) return record;
|
|
974
1072
|
const firstEventTime = record[0]?.time;
|
|
975
1073
|
if (!firstEventTime) return record;
|
|
976
1074
|
|
|
977
1075
|
const userStart = dayjs(firstEventTime);
|
|
978
|
-
const
|
|
979
|
-
const
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1076
|
+
const windowEndMs = userStart.add(14, "days").valueOf();
|
|
1077
|
+
const startMs = userStart.valueOf();
|
|
1078
|
+
// Slice to the user's first 14 days.
|
|
1079
|
+
const firstWindow = record.filter(e => {
|
|
1080
|
+
const t = dayjs(e.time).valueOf();
|
|
1081
|
+
return t >= startMs && t < windowEndMs;
|
|
1082
|
+
});
|
|
1083
|
+
const followDays = countDistinctPeriods(firstWindow, "user followed", "day");
|
|
1084
|
+
|
|
1085
|
+
if (followDays < 5) {
|
|
986
1086
|
const cutoff = userStart.add(36, "days");
|
|
987
1087
|
dropEventsWhere(record, e => dayjs(e.time).isAfter(cutoff));
|
|
988
1088
|
}
|
|
@@ -990,139 +1090,268 @@ if (type === "everything") {
|
|
|
990
1090
|
}
|
|
991
1091
|
```
|
|
992
1092
|
|
|
993
|
-
**
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
**Key design decisions:**
|
|
998
|
-
|
|
999
|
-
- **User-relative cutoff, not dataset-relative.** The silence cutoff is anchored
|
|
1000
|
-
to each user's first event (`userStart.add(36, "days")`), not `datasetStart`.
|
|
1001
|
-
A dataset-anchored cutoff would miss late-born users entirely — their first
|
|
1002
|
-
event is already past the cutoff date.
|
|
1003
|
-
|
|
1004
|
-
- **`binUsersByEventInRange` over manual counting.** The atom handles time
|
|
1005
|
-
parsing and bin matching. Use it instead of rolling your own
|
|
1006
|
-
`filter().length >= N` to avoid ISO-string / unix-seconds footguns.
|
|
1007
|
-
|
|
1008
|
-
- **`percentUsersBornInDataset: 50` is important.** At the default 15%, only
|
|
1009
|
-
~750 of 5K users are born-in-dataset. After splitting into retained/not-retained,
|
|
1010
|
-
the retained cohort can be <100 users — too small for reliable signal. Bump to
|
|
1011
|
-
50% for retention hooks. The "flat" macro preset defaults to 50%.
|
|
1012
|
-
|
|
1013
|
-
- **Threshold calibration.** The threshold (5 follows) × window (14 days) must
|
|
1014
|
-
be achievable but not trivial given the event rate. At 5 events/user/day with
|
|
1015
|
-
`user followed` at weight 5 out of ~84 total weight, expect ~0.3 follows/day
|
|
1016
|
-
→ ~4.2 follows in 14 days. Threshold of 5 means ~15-20% of born users qualify.
|
|
1017
|
-
Too high a threshold (7+ in 10 days) produces cohorts < 5% — too small.
|
|
1093
|
+
**Why distinct days:** A user who logged 50 follows in a single sitting and
|
|
1094
|
+
nothing else is **not** the "engaged" cohort. Mixpanel's retention reports
|
|
1095
|
+
backed by behavioral cohorts use distinct-day filters to capture sustained
|
|
1096
|
+
engagement, not single-session bursts.
|
|
1018
1097
|
|
|
1019
|
-
**
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1098
|
+
**Calibration:** With `user followed` at weight 5 of ~84 total weight and
|
|
1099
|
+
~5 events/user/day, expect ~0.3 follows/day → ~4.2 follows in 14 days.
|
|
1100
|
+
If most follow events cluster on the same days, distinct-day count is even
|
|
1101
|
+
lower. Set the threshold by running the actual distribution (Principle 18)
|
|
1102
|
+
for distinct days, not total events.
|
|
1023
1103
|
|
|
1024
1104
|
---
|
|
1025
1105
|
|
|
1026
|
-
####
|
|
1106
|
+
#### 4.23 Deprecated Feature Replacement
|
|
1027
1107
|
|
|
1028
|
-
**Hook
|
|
1029
|
-
|
|
1030
|
-
**In Mixpanel:** User property `subscription_tier` drives downstream event
|
|
1031
|
-
patterns (premium users get higher conversion, more features). Works
|
|
1032
|
-
identically to the deprecated `subscription` config block.
|
|
1108
|
+
**Hook:** `user` + `everything`
|
|
1109
|
+
**Mixpanel report:** Varies by use — same report the deprecated feature targeted (typically Insights breakdown by `subscription_tier`)
|
|
1033
1110
|
|
|
1034
1111
|
```js
|
|
1035
|
-
// user: assign subscription tier based on persona/hash
|
|
1036
1112
|
if (type === "user") {
|
|
1037
1113
|
const hash = String(record.distinct_id || "").charCodeAt(0) % 10;
|
|
1038
1114
|
record.subscription_tier = hash < 6 ? "free" : hash < 8 ? "monthly" : "annual";
|
|
1039
1115
|
}
|
|
1040
1116
|
|
|
1041
|
-
// everything: use tier to drive effects
|
|
1042
1117
|
if (type === "everything") {
|
|
1043
1118
|
const tier = meta.profile.subscription_tier;
|
|
1044
1119
|
if (tier === "annual") {
|
|
1045
|
-
|
|
1120
|
+
for (const e of record) {
|
|
1046
1121
|
if (e.event === "feature used") e.feature_limit = 999;
|
|
1047
|
-
}
|
|
1122
|
+
}
|
|
1048
1123
|
}
|
|
1049
1124
|
}
|
|
1050
1125
|
```
|
|
1051
1126
|
|
|
1052
|
-
**Real-world analogue:** Subscription tiers gate features and drive
|
|
1053
|
-
engagement — a pattern previously handled by the deprecated `subscription`
|
|
1054
|
-
config block, now reproduced via hooks.
|
|
1055
|
-
|
|
1056
|
-
**Adaptation:** Replace `subscription_tier` with any deprecated feature's
|
|
1057
|
-
key property. Use the `user` hook for assignment (runs once) and
|
|
1058
|
-
`everything` for downstream effects. Add the property to `userProps` and
|
|
1059
|
-
`superProps` with matching default values.
|
|
1060
|
-
|
|
1061
1127
|
---
|
|
1062
1128
|
|
|
1063
|
-
####
|
|
1064
|
-
|
|
1065
|
-
**Hook type:** `everything` (must run LAST) | **Meta:** `meta.datasetStart`
|
|
1129
|
+
#### 4.24 Post-Clone Temporal Mutation
|
|
1066
1130
|
|
|
1067
|
-
**
|
|
1068
|
-
|
|
1069
|
-
earlier hooks that happen to land in the window.
|
|
1131
|
+
**Hook:** `everything` (must run LAST)
|
|
1132
|
+
**Mixpanel report:** Insights — AVG of `offer_price` over time, expecting consistent within-window value
|
|
1070
1133
|
|
|
1071
1134
|
```js
|
|
1072
|
-
//
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
// RIGHT: temporal mutation runs AFTER all cloning
|
|
1077
|
-
// [all cloning hooks run first]
|
|
1078
|
-
// Then at the end:
|
|
1079
|
-
userEvents.forEach(e => {
|
|
1080
|
-
if (e.event !== "offer submitted") return;
|
|
1135
|
+
// Run all cloning hooks first, THEN apply window mutation:
|
|
1136
|
+
for (const e of record) {
|
|
1137
|
+
if (e.event !== "offer submitted") continue;
|
|
1081
1138
|
const t = dayjs(e.time);
|
|
1082
1139
|
if (t.isAfter(springStart) && t.isBefore(springEnd)) {
|
|
1083
1140
|
e.offer_price = Math.floor((e.offer_price || 400000) * 2.5);
|
|
1084
1141
|
}
|
|
1085
|
-
}
|
|
1142
|
+
}
|
|
1143
|
+
return record;
|
|
1086
1144
|
```
|
|
1087
1145
|
|
|
1088
|
-
|
|
1089
|
-
|
|
1146
|
+
---
|
|
1147
|
+
|
|
1148
|
+
### Attribution
|
|
1149
|
+
|
|
1150
|
+
#### 4.25 First-Touch Attribution Bias (Capped at 10 Touches)
|
|
1090
1151
|
|
|
1091
|
-
**
|
|
1092
|
-
|
|
1093
|
-
|
|
1152
|
+
**Hook:** `everything` | **Counting:** TOUCHPOINTS_LIMIT = 10 (Section 2.4)
|
|
1153
|
+
**Mixpanel report:** Attribution — Conversions by Source (first-touch model, last-10 lookback)
|
|
1154
|
+
|
|
1155
|
+
**In Mixpanel:** `Convert` events broken down by first-touch `Touch.source`
|
|
1156
|
+
show Google >> Facebook >> Twitter (10:5:1 weights).
|
|
1157
|
+
|
|
1158
|
+
```js
|
|
1159
|
+
import { weighArray } from "@ak--47/dungeon-master/utils";
|
|
1160
|
+
|
|
1161
|
+
if (type === "everything") {
|
|
1162
|
+
const conversion = record.find(e => e.event === "Convert");
|
|
1163
|
+
if (!conversion) return record;
|
|
1164
|
+
const convTime = dayjs(conversion.time).valueOf();
|
|
1165
|
+
// Mixpanel only considers the LAST 10 touches before conversion. Stamp at
|
|
1166
|
+
// most ~6-8 touches per user so the first one is clearly the bias target.
|
|
1167
|
+
const sources = weighArray(["google", "facebook", "twitter"], [10, 5, 1]);
|
|
1168
|
+
const touches = record.filter(e => e.event === "Touch" && dayjs(e.time).valueOf() <= convTime);
|
|
1169
|
+
if (touches.length === 0) return record;
|
|
1170
|
+
// Bias the FIRST touch (chronologically earliest within the cap window).
|
|
1171
|
+
// Sort descending by time, take last 10, then sort ascending.
|
|
1172
|
+
const sortedDesc = touches.slice().sort((a, b) => dayjs(b.time).valueOf() - dayjs(a.time).valueOf());
|
|
1173
|
+
const inCap = sortedDesc.slice(0, 10).sort((a, b) => dayjs(a.time).valueOf() - dayjs(b.time).valueOf());
|
|
1174
|
+
if (inCap.length > 0) {
|
|
1175
|
+
inCap[0].source = chance.pickone(sources);
|
|
1176
|
+
}
|
|
1177
|
+
return record;
|
|
1178
|
+
}
|
|
1179
|
+
```
|
|
1180
|
+
|
|
1181
|
+
**Why the cap matters:** Stamping 50 weighted touches per user gives the same
|
|
1182
|
+
first-touch result as stamping 10 — Mixpanel's attribution module
|
|
1183
|
+
(`attributed_value_reader.cpp`) only considers `TOUCHPOINTS_LIMIT = 10`. Aim
|
|
1184
|
+
for sparse, deterministic touches.
|
|
1094
1185
|
|
|
1095
1186
|
---
|
|
1096
1187
|
|
|
1097
|
-
|
|
1188
|
+
#### 4.26 Bias Engine-Stamped Touches (v1.5)
|
|
1189
|
+
|
|
1190
|
+
**Hook:** `everything` | **Counting:** `maxTouchpointsPerUser` (default 10)
|
|
1191
|
+
**Mixpanel report:** Attribution — Conversions by `utm_source` (first-touch + last-touch models)
|
|
1192
|
+
|
|
1193
|
+
**In Mixpanel:** First-touch attribution shows your campaign sources weighted
|
|
1194
|
+
toward "google" without changing total touch count.
|
|
1195
|
+
|
|
1196
|
+
In v1.5, the engine stamps UTMs on up to `maxTouchpointsPerUser` events per
|
|
1197
|
+
user (default 10), sampled across the user's lifetime. Hooks bias attribution
|
|
1198
|
+
by OVERWRITING engine-stamped values rather than stamping new touches.
|
|
1199
|
+
|
|
1200
|
+
```js
|
|
1201
|
+
import { weighArray } from "@ak--47/dungeon-master/utils";
|
|
1202
|
+
|
|
1203
|
+
if (type === "everything") {
|
|
1204
|
+
// Find events the engine already stamped with a campaign.
|
|
1205
|
+
const stamped = record
|
|
1206
|
+
.filter(e => e.utm_source != null)
|
|
1207
|
+
.sort((a, b) => dayjs(a.time).valueOf() - dayjs(b.time).valueOf());
|
|
1208
|
+
if (stamped.length === 0) return record;
|
|
1209
|
+
// Bias the FIRST stamped touch (Mixpanel's first-touch model picks this).
|
|
1210
|
+
const sources = weighArray(["google", "facebook", "twitter"], [10, 5, 1]);
|
|
1211
|
+
stamped[0].utm_source = chance.pickone(sources);
|
|
1212
|
+
// Optional: also bias the LAST stamped touch for last-touch attribution.
|
|
1213
|
+
if (stamped.length > 1) stamped[stamped.length - 1].utm_source = chance.pickone(sources);
|
|
1214
|
+
return record;
|
|
1215
|
+
}
|
|
1216
|
+
```
|
|
1217
|
+
|
|
1218
|
+
**Why this works under v1.5:** the engine has already capped + sampled the
|
|
1219
|
+
touches. Stamping fresh ones from scratch in the hook would push the user
|
|
1220
|
+
past the cap, and your stamps would land outside Mixpanel's last-10 lookback
|
|
1221
|
+
window — giving them no effect. Overwriting is correct.
|
|
1222
|
+
|
|
1223
|
+
---
|
|
1224
|
+
|
|
1225
|
+
#### 4.27 Active-Day Cohort Engineering (v1.5)
|
|
1226
|
+
|
|
1227
|
+
**Hook:** `everything` | **Counting:** distinct calendar days (Section 2.1)
|
|
1228
|
+
**Mixpanel report:** Insights — Frequency Distribution; cohort-conditional active-day boost
|
|
1229
|
+
|
|
1230
|
+
**In Mixpanel:** 10% of users land in a "power user" cohort with ≥10 distinct
|
|
1231
|
+
days of activity, while the rest stay at the dataset baseline.
|
|
1232
|
+
|
|
1233
|
+
Use `Dungeon.avgActiveDaysPerUser` for the BASELINE distribution, then write
|
|
1234
|
+
a cohort-conditional `everything` hook that uses `injectOnNewDays` to push
|
|
1235
|
+
specific users above the baseline.
|
|
1236
|
+
|
|
1237
|
+
```js
|
|
1238
|
+
import { injectOnNewDays } from "@ak--47/dungeon-master/hook-helpers";
|
|
1239
|
+
import { countDistinctPeriods } from "@ak--47/dungeon-master/verify";
|
|
1240
|
+
|
|
1241
|
+
// Config:
|
|
1242
|
+
// avgActiveDaysPerUser: 5 // baseline: most users active ~5 days
|
|
1243
|
+
// events: [{ event: "open app", weight: 5 }, ...]
|
|
1244
|
+
|
|
1245
|
+
if (type === "everything") {
|
|
1246
|
+
// Hash-based cohort (deterministic, ~10% of users).
|
|
1247
|
+
const uid = record[0]?.user_id || "";
|
|
1248
|
+
const isPowerUser = uid.charCodeAt(0) % 10 === 0;
|
|
1249
|
+
if (!isPowerUser) return record;
|
|
1250
|
+
|
|
1251
|
+
const days = countDistinctPeriods(record, "open app", "day");
|
|
1252
|
+
if (days >= 10) return record;
|
|
1253
|
+
// Boost: spread events across more days inside the user's active window.
|
|
1254
|
+
injectOnNewDays(record, "open app", 10);
|
|
1255
|
+
return record;
|
|
1256
|
+
}
|
|
1257
|
+
```
|
|
1258
|
+
|
|
1259
|
+
**Why two layers:** the config knob keeps the dungeon's distribution sane.
|
|
1260
|
+
The hook engineers a SPECIFIC cohort above baseline. If you tried to use the
|
|
1261
|
+
hook ALONE for the global shape, you'd reinvent the engine's day-picking and
|
|
1262
|
+
fight its scheduling.
|
|
1263
|
+
|
|
1264
|
+
---
|
|
1265
|
+
|
|
1266
|
+
#### 4.28 Conversion-Window-Aware TTC Scaling (v1.5)
|
|
1267
|
+
|
|
1268
|
+
**Hook:** `funnel-pre` | **Meta:** `meta.funnel`
|
|
1269
|
+
**Mixpanel report:** Funnels — Time to Convert by user segment, respecting `conversionWindowDays`
|
|
1270
|
+
|
|
1271
|
+
**In Mixpanel:** Funnel TTC for Enterprise users is 2x faster than Free, but
|
|
1272
|
+
the scaling factor must respect the funnel's `conversionWindowDays` cap so
|
|
1273
|
+
shifted timestamps don't fall outside the window (where Mixpanel's strict-`<`
|
|
1274
|
+
rule excludes them).
|
|
1275
|
+
|
|
1276
|
+
```js
|
|
1277
|
+
if (type === "funnel-pre") {
|
|
1278
|
+
const tier = meta.profile?.tier || "free";
|
|
1279
|
+
const baseFactor = tier === "enterprise" ? 0.5 : tier === "free" ? 1.4 : 1.0;
|
|
1280
|
+
if (baseFactor === 1.0) return;
|
|
1281
|
+
|
|
1282
|
+
// Read the funnel's conversion window (auto-applied by validator, default 30d).
|
|
1283
|
+
const windowDays = meta.funnel?.conversionWindowDays || 30;
|
|
1284
|
+
const ttcDays = (meta.funnel?.timeToConvert || 24) / 24;
|
|
1285
|
+
// Clamp factor so scaled TTC stays at most 90% of the window — leaves slack
|
|
1286
|
+
// for the strict-< boundary AND for engine-side per-step jitter.
|
|
1287
|
+
const maxSafeFactor = (windowDays * 0.9) / ttcDays;
|
|
1288
|
+
const factor = baseFactor < 1.0
|
|
1289
|
+
? baseFactor // shorter is always safe
|
|
1290
|
+
: Math.min(baseFactor, maxSafeFactor); // longer must respect the window
|
|
1291
|
+
|
|
1292
|
+
meta.funnel.timeToConvert *= factor;
|
|
1293
|
+
}
|
|
1294
|
+
```
|
|
1295
|
+
|
|
1296
|
+
**Why clamp:** A 1.4× factor on a `timeToConvert: 720h` (30d) funnel pushes
|
|
1297
|
+
the last step past 30d. Mixpanel's `is_within_conversion_window` is strict
|
|
1298
|
+
`<`, so step C at exactly window-boundary gets excluded — funnel completion
|
|
1299
|
+
silently drops. Always read `meta.funnel.conversionWindowDays` and bound
|
|
1300
|
+
your factor.
|
|
1301
|
+
|
|
1302
|
+
---
|
|
1303
|
+
|
|
1304
|
+
## 5. Phase 3 Atom Reference
|
|
1098
1305
|
|
|
1099
1306
|
Import from `@ak--47/dungeon-master/hook-helpers`:
|
|
1100
1307
|
|
|
1101
1308
|
| Atom | Module | Signature | Purpose |
|
|
1102
1309
|
|---|---|---|---|
|
|
1103
|
-
| `binUsersByEventCount` | cohort | `(events, eventName, bins) -> string\|null` | Classify
|
|
1104
|
-
| `binUsersByEventInRange` | cohort | `(events, eventName, start, end, bins) -> string\|null` | Same,
|
|
1310
|
+
| `binUsersByEventCount` | cohort | `(events, eventName, bins) -> string\|null` | Classify by **total event count** (use for Insights "events per user") |
|
|
1311
|
+
| `binUsersByEventInRange` | cohort | `(events, eventName, start, end, bins) -> string\|null` | Same, restricted to a time range |
|
|
1105
1312
|
| `countEventsBetween` | cohort | `(events, eventA, eventB) -> number` | Count events between first A and first B |
|
|
1106
|
-
| `userInProfileSegment` | cohort | `(profile, key, values) -> boolean` |
|
|
1313
|
+
| `userInProfileSegment` | cohort | `(profile, key, values) -> boolean` | Profile property match |
|
|
1107
1314
|
| `cloneEvent` | mutate | `(template, overrides?) -> event` | Shallow clone with overrides |
|
|
1108
1315
|
| `dropEventsWhere` | mutate | `(events, predicate) -> number` | Remove matching events in-place |
|
|
1109
|
-
| `scaleEventCount` | mutate | `(events, eventName, factor) -> number` | Scale count
|
|
1110
|
-
| `scalePropertyValue` | mutate | `(events, predicate, prop, factor) -> number` | Multiply
|
|
1111
|
-
| `shiftEventTime` | mutate | `(event, deltaMs) -> event` | Shift one
|
|
1112
|
-
| `scaleTimingBetween` | timing | `(events, eventA, eventB, factor) -> boolean` | Scale
|
|
1113
|
-
| `scaleFunnelTTC` | timing | `(funnelEvents, factor) -> number` | Scale
|
|
1114
|
-
| `findFirstSequence` | timing | `(events, names[], maxGapMin) -> events[]\|null` | Detect ordered sequence
|
|
1115
|
-
| `injectAfterEvent` | inject | `(events, source, template, gapMs, overrides?) -> event` | Splice
|
|
1116
|
-
| `injectBetween` | inject | `(events, eventA, eventB, template, overrides?) -> event` | Splice
|
|
1117
|
-
| `injectBurst` | inject | `(events, template, count, anchor, spreadMs, overrides?) -> events[]` | Inject N clones
|
|
1118
|
-
|
|
|
1119
|
-
| `
|
|
1120
|
-
|
|
1121
|
-
|
|
1316
|
+
| `scaleEventCount` | mutate | `(events, eventName, factor) -> number` | Scale total count via clones at sub-second offsets (does NOT move frequency-distribution bins — see Section 2.1) |
|
|
1317
|
+
| `scalePropertyValue` | mutate | `(events, predicate, prop, factor) -> number` | Multiply numeric property; null-aware safe |
|
|
1318
|
+
| `shiftEventTime` | mutate | `(event, deltaMs) -> event` | Shift one timestamp |
|
|
1319
|
+
| `scaleTimingBetween` | timing | `(events, eventA, eventB, factor) -> boolean` | Scale gap between first A and first B |
|
|
1320
|
+
| `scaleFunnelTTC` | timing | `(funnelEvents, factor) -> number` | Scale offsets from funnel's first event |
|
|
1321
|
+
| `findFirstSequence` | timing | `(events, names[], maxGapMin) -> events[]\|null` | Detect ordered sequence |
|
|
1322
|
+
| `injectAfterEvent` | inject | `(events, source, template, gapMs, overrides?) -> event` | Splice clone after a specific event |
|
|
1323
|
+
| `injectBetween` | inject | `(events, eventA, eventB, template, overrides?) -> event` | Splice clone at midpoint of A→B gap |
|
|
1324
|
+
| `injectBurst` | inject | `(events, template, count, anchor, spreadMs, overrides?) -> events[]` | Inject N clones around an anchor |
|
|
1325
|
+
| **`injectOnNewDays`** | inject | `(events, eventName, targetDays, options?) -> events[]` | Inject clones on previously empty days within active window — **the right tool for moving frequency-distribution bins** |
|
|
1326
|
+
| `isPreAuthEvent` | identity | `(event, authTime) -> boolean` | Check if before user's stitch |
|
|
1327
|
+
| `splitByAuth` | identity | `(events, authTime) -> { preAuth, postAuth, stitch }` | Partition by auth boundary |
|
|
1328
|
+
|
|
1329
|
+
Full JSDoc in [`lib/hook-helpers/*.js`](lib/hook-helpers/).
|
|
1330
|
+
|
|
1331
|
+
---
|
|
1332
|
+
|
|
1333
|
+
## 6. Verification Helpers
|
|
1334
|
+
|
|
1335
|
+
Import from `@ak--47/dungeon-master/verify`:
|
|
1336
|
+
|
|
1337
|
+
| Function | Purpose | Mixpanel Reference |
|
|
1338
|
+
|---|---|---|
|
|
1339
|
+
| `emulateBreakdown(events, config)` | Run the table-shape emulator for one of 5 analyses | Insights / Funnels / Flows |
|
|
1340
|
+
| `verifyDungeon(dungeonConfig, assertions)` | High-level wrapper: run dungeon + run assertions | n/a |
|
|
1341
|
+
| `evaluateFunnel(events, steps, options?)` | Greedy single-pass funnel state machine | `history.cpp` |
|
|
1342
|
+
| `timestampComesAfter(t1, t2, grace?)` | 2-second grace window check | `history.cpp` |
|
|
1343
|
+
| `withinConversionWindow(eventTime, step0Time, windowMs)` | Strict `<` window check | `conversion_window.cpp` |
|
|
1344
|
+
| `countDistinctPeriods(events, eventName, unit?, options?)` | Distinct-period count (default day, calendar bucket; pass `{algorithm:'rolling'}` for C++ semantics) | `addiction_query.cpp` |
|
|
1345
|
+
| `nullAwareAvg(values)` / `nullAwareSum(values)` | AVG/SUM that skip non-numeric | `normal_query.cpp` |
|
|
1346
|
+
| `nullAwareExtreme(values, mode)` | MIN/MAX that skip non-numeric | `normal_query.cpp` |
|
|
1347
|
+
| `binByDistinctPeriods(events, eventName, bins, unit?)` | Distinct-period cohort assignment | `addiction_query.cpp` |
|
|
1348
|
+
| `deriveExpectedSchema(config)` / `validateSchema(events, schema)` | Schema integrity checks | n/a |
|
|
1349
|
+
|
|
1350
|
+
Full JSDoc in [`lib/verify/*.js`](lib/verify/).
|
|
1122
1351
|
|
|
1123
1352
|
---
|
|
1124
1353
|
|
|
1125
|
-
##
|
|
1354
|
+
## 7. Phase 4 Pattern Reference
|
|
1126
1355
|
|
|
1127
1356
|
Import from `@ak--47/dungeon-master/hook-patterns`:
|
|
1128
1357
|
|
|
@@ -1134,5 +1363,422 @@ Import from `@ak--47/dungeon-master/hook-patterns`:
|
|
|
1134
1363
|
| `applyTTCBySegment` | funnel-post | `(funnelEvents, profile, { segmentKey, factors })` | Funnel median TTC by profile segment |
|
|
1135
1364
|
| `applyAttributedBySource` | everything | `(events, profile, { sourceEvent, sourceProperty, downstreamEvent, weights })` | Conversions by source (first/last touch) |
|
|
1136
1365
|
|
|
1137
|
-
|
|
1138
|
-
|
|
1366
|
+
> **Caveat (eval follow-up).** The three `*ByBin` / `*Frequency*` patterns
|
|
1367
|
+
> currently use `binUsersByEventCount` (total events) for cohort assignment.
|
|
1368
|
+
> The verification emulator now bins by **distinct days**, so cohort axes
|
|
1369
|
+
> can mismatch — high-event-count users may not be high-distinct-day users.
|
|
1370
|
+
> When verifying these patterns, expect signal dilution until the patterns
|
|
1371
|
+
> switch to `binByDistinctPeriods`. For new dungeons targeting frequency
|
|
1372
|
+
> reports, use the recipes in Section 4.5–4.7 instead of these patterns.
|
|
1373
|
+
|
|
1374
|
+
Full JSDoc in [`lib/hook-patterns/*.js`](lib/hook-patterns/). Pair with
|
|
1375
|
+
`emulateBreakdown` from `@ak--47/dungeon-master/verify` to assert patterns
|
|
1376
|
+
in CI.
|
|
1377
|
+
|
|
1378
|
+
---
|
|
1379
|
+
|
|
1380
|
+
## 8. v1.5.0 Verification Recipes
|
|
1381
|
+
|
|
1382
|
+
Verifier primitives added in v1.5.0. All accept the same `emulateBreakdown`
|
|
1383
|
+
options shape, with `type` selecting the analysis. Pass `profiles` on any
|
|
1384
|
+
type to enable identity merge (pre-auth `device_id` events resolve to the
|
|
1385
|
+
same canonical user as post-auth `user_id` events).
|
|
1386
|
+
|
|
1387
|
+
### 8.1 Retention curves
|
|
1388
|
+
|
|
1389
|
+
```js
|
|
1390
|
+
import { emulateBreakdown } from '@ak--47/dungeon-master/verify';
|
|
1391
|
+
|
|
1392
|
+
const rows = emulateBreakdown(events, {
|
|
1393
|
+
type: 'retention',
|
|
1394
|
+
cohortEvent: 'Sign Up',
|
|
1395
|
+
returnEvent: 'Login',
|
|
1396
|
+
dayBuckets: [0, 1, 7, 14, 30], // bucket 0 = within 24h of birth
|
|
1397
|
+
segmentBy: 'plan', // optional — segment by birth event prop
|
|
1398
|
+
carry_forward: false, // optional — monotonically non-decreasing
|
|
1399
|
+
birthCanRetain: false, // optional — count returns AT birth ms (default false)
|
|
1400
|
+
profiles, // optional — auto-builds identity map
|
|
1401
|
+
});
|
|
1402
|
+
// → [{ day, retained_count, cohort_size, retained_pct, segment }, ...]
|
|
1403
|
+
```
|
|
1404
|
+
|
|
1405
|
+
**In Mixpanel:** Retention report with cohort = "did Sign Up", return =
|
|
1406
|
+
"did Login". Buckets use ms-delta from birth, NOT calendar-day differences
|
|
1407
|
+
(see §2.7) — a return 23h after birth = bucket 0, 25h = bucket 1.
|
|
1408
|
+
|
|
1409
|
+
### 8.2 Session metrics
|
|
1410
|
+
|
|
1411
|
+
```js
|
|
1412
|
+
const rows = emulateBreakdown(events, {
|
|
1413
|
+
type: 'sessionMetrics',
|
|
1414
|
+
event: 'Page View', // optional — only sessions containing this event
|
|
1415
|
+
metrics: ['count', 'duration', 'eventsPerSession'],
|
|
1416
|
+
});
|
|
1417
|
+
// → [{ metric, avg, median, p90, total_sessions }, ...]
|
|
1418
|
+
```
|
|
1419
|
+
|
|
1420
|
+
Trusts the generator's pre-stamped `session_id`. If you need to verify
|
|
1421
|
+
session-scoped funnels (steps must land in same session), pass
|
|
1422
|
+
`sessionScoped: true` to `evaluateFunnel`.
|
|
1423
|
+
|
|
1424
|
+
### 8.3 Funnel reentry (counting repeat completions)
|
|
1425
|
+
|
|
1426
|
+
```js
|
|
1427
|
+
import { evaluateFunnel } from '@ak--47/dungeon-master/verify';
|
|
1428
|
+
|
|
1429
|
+
// Per-user totals: how many times did this user complete the funnel?
|
|
1430
|
+
const r = evaluateFunnel(userEvents, ['Sign Up', 'Activate'], {
|
|
1431
|
+
reentry: true,
|
|
1432
|
+
countMode: 'totals', // returns FunnelResult[] (one per completion)
|
|
1433
|
+
});
|
|
1434
|
+
console.log(r.length); // e.g. 3 completions
|
|
1435
|
+
```
|
|
1436
|
+
|
|
1437
|
+
For dungeon-level verification, set `Funnel.reentry: true` — the verifier
|
|
1438
|
+
auto-applies it in `funnelFrequency` / `timeToConvert` when matching the
|
|
1439
|
+
funnel.
|
|
1440
|
+
|
|
1441
|
+
### 8.4 Exclusion step patterns
|
|
1442
|
+
|
|
1443
|
+
```js
|
|
1444
|
+
// Direct API
|
|
1445
|
+
const r = evaluateFunnel(events, ['Sign Up', 'Activate'], {
|
|
1446
|
+
exclusionSteps: [{ event: 'Bounce', afterStep: 1, beforeStep: 2 }],
|
|
1447
|
+
});
|
|
1448
|
+
```
|
|
1449
|
+
|
|
1450
|
+
For dungeon-level wiring: declare exclusion events in `events[]` (schema-first),
|
|
1451
|
+
then add them to the funnel:
|
|
1452
|
+
|
|
1453
|
+
```js
|
|
1454
|
+
{
|
|
1455
|
+
events: [
|
|
1456
|
+
{ event: 'land', isFirstEvent: true },
|
|
1457
|
+
{ event: 'sign_up' },
|
|
1458
|
+
{ event: 'rage_click' }, // declared
|
|
1459
|
+
],
|
|
1460
|
+
funnels: [
|
|
1461
|
+
{ sequence: ['land', 'sign_up'],
|
|
1462
|
+
conversionRate: 30,
|
|
1463
|
+
exclusionEvents: ['rage_click'] }, // generator + verifier
|
|
1464
|
+
],
|
|
1465
|
+
}
|
|
1466
|
+
```
|
|
1467
|
+
|
|
1468
|
+
The generator stamps 1-2 cloned `rage_click` events on non-converters; the
|
|
1469
|
+
verifier reads `funnel.exclusionEvents` and applies them as exclusion steps
|
|
1470
|
+
when emulating the funnel.
|
|
1471
|
+
|
|
1472
|
+
### 8.5 Hold Property Constant (HPC) patterns
|
|
1473
|
+
|
|
1474
|
+
```js
|
|
1475
|
+
import { evaluateFunnelHPC } from '@ak--47/dungeon-master/verify';
|
|
1476
|
+
|
|
1477
|
+
// One sub-funnel per unique value of `plan` on the step-0 event.
|
|
1478
|
+
const map = evaluateFunnelHPC(userEvents, ['Add to Bag', 'Checkout'], 'plan');
|
|
1479
|
+
console.log(map.get('pro').completed); // pro user completed
|
|
1480
|
+
console.log(map.get('free').completed); // free user did not (independent)
|
|
1481
|
+
```
|
|
1482
|
+
|
|
1483
|
+
HPC is **not auto-routed** through `funnelFrequency` in v1.5.0 — the report
|
|
1484
|
+
shape differs (one row per `step × hpc_value` instead of `step × cohort`).
|
|
1485
|
+
Call `evaluateFunnelHPC` directly inside your `verifyDungeon` check's
|
|
1486
|
+
`assert` callback when you need it.
|
|
1487
|
+
|
|
1488
|
+
### 8.6 Step-level filter patterns
|
|
1489
|
+
|
|
1490
|
+
```js
|
|
1491
|
+
const r = evaluateFunnel(events, [
|
|
1492
|
+
{ event: 'View Pricing' },
|
|
1493
|
+
{ event: 'Sign Up', where: { prop: 'plan', op: 'eq', value: 'pro' } },
|
|
1494
|
+
], { conversionWindowMs: 30 * 86400_000 });
|
|
1495
|
+
```
|
|
1496
|
+
|
|
1497
|
+
For dungeon-level wiring: set `Funnel.stepFilters` (verifier-only hint):
|
|
1498
|
+
|
|
1499
|
+
```js
|
|
1500
|
+
{
|
|
1501
|
+
funnels: [{
|
|
1502
|
+
sequence: ['View Pricing', 'Sign Up'],
|
|
1503
|
+
stepFilters: { 1: { prop: 'plan', op: 'eq', value: 'pro' } },
|
|
1504
|
+
}],
|
|
1505
|
+
}
|
|
1506
|
+
```
|
|
1507
|
+
|
|
1508
|
+
The verifier auto-applies the filter to the matching step at index 1.
|
|
1509
|
+
|
|
1510
|
+
### 8.7 Time-series verification (timeBucket)
|
|
1511
|
+
|
|
1512
|
+
```js
|
|
1513
|
+
const rows = emulateBreakdown(events, {
|
|
1514
|
+
type: 'frequencyByFrequency',
|
|
1515
|
+
metricEvent: 'Purchase',
|
|
1516
|
+
breakdownByFrequencyOf: 'Browse',
|
|
1517
|
+
timeBucket: 'week', // 'day' | 'week' | 'month'
|
|
1518
|
+
});
|
|
1519
|
+
// → [{ period: '2024-W01', metric_freq, breakdown_freq, user_count }, ...]
|
|
1520
|
+
```
|
|
1521
|
+
|
|
1522
|
+
Cross-cutting on every breakdown type. Period labels: `YYYY-MM-DD` (day),
|
|
1523
|
+
`YYYY-Www` (ISO Monday-anchored week), `YYYY-MM` (month).
|
|
1524
|
+
|
|
1525
|
+
**Empty-bucket backfill (Mixpanel parity):** Mixpanel's `normal_query.cpp`
|
|
1526
|
+
emits zero rows for empty intervals on its trend axis. Pass
|
|
1527
|
+
`timeBucketRange: { from, to }` to enumerate every bucket in the range and
|
|
1528
|
+
emit `{ period, _empty: true }` markers for buckets with no events:
|
|
1529
|
+
|
|
1530
|
+
```js
|
|
1531
|
+
emulateBreakdown(events, {
|
|
1532
|
+
type: 'frequencyByFrequency',
|
|
1533
|
+
metricEvent: 'Purchase',
|
|
1534
|
+
breakdownByFrequencyOf: 'Browse',
|
|
1535
|
+
timeBucket: 'day',
|
|
1536
|
+
timeBucketRange: { from: '2024-01-01', to: '2024-01-31' },
|
|
1537
|
+
});
|
|
1538
|
+
// → 31 rows, one per day. Days with no data: { period, _empty: true }.
|
|
1539
|
+
```
|
|
1540
|
+
|
|
1541
|
+
**Documented divergence:** verifier uses UTC; Mixpanel uses qtz. For non-UTC
|
|
1542
|
+
accounts, day/week/month boundaries shift by hours.
|
|
1543
|
+
|
|
1544
|
+
### 8.8 Identity-aware verification
|
|
1545
|
+
|
|
1546
|
+
For dungeons using `avgDevicePerUser > 0` or `hasAnonIds: true`, ALWAYS
|
|
1547
|
+
pass `profiles` so the verifier resolves pre-auth `device_id` events to the
|
|
1548
|
+
same canonical user as post-auth `user_id` events. Without it, pre-auth
|
|
1549
|
+
events bucket as separate "users" — funnel completion drops, retention
|
|
1550
|
+
deflates, attribution mis-routes.
|
|
1551
|
+
|
|
1552
|
+
```js
|
|
1553
|
+
const rows = emulateBreakdown(events, {
|
|
1554
|
+
type: 'funnelFrequency',
|
|
1555
|
+
steps: ['visit_landing', 'sign_up', 'first_action'],
|
|
1556
|
+
breakdownByFrequencyOf: 'visit_landing',
|
|
1557
|
+
profiles, // auto-builds identityMap from profile.device_ids/anonymousIds
|
|
1558
|
+
});
|
|
1559
|
+
```
|
|
1560
|
+
|
|
1561
|
+
You can also pre-build the map and reuse:
|
|
1562
|
+
|
|
1563
|
+
```js
|
|
1564
|
+
import { buildIdentityMap } from '@ak--47/dungeon-master/verify';
|
|
1565
|
+
const identityMap = buildIdentityMap(profiles);
|
|
1566
|
+
emulateBreakdown(events, { type: 'retention', cohortEvent: 'Sign Up',
|
|
1567
|
+
returnEvent: 'Login', dayBuckets: [7],
|
|
1568
|
+
identityMap });
|
|
1569
|
+
```
|
|
1570
|
+
|
|
1571
|
+
---
|
|
1572
|
+
|
|
1573
|
+
## 9. Verification Patterns from the v1.5.0 Vertical Eval
|
|
1574
|
+
|
|
1575
|
+
20-dungeon eval surfaced patterns where naive verification inverts. Apply
|
|
1576
|
+
these recipes when writing per-dungeon verify scripts under
|
|
1577
|
+
`verification/verticals/`.
|
|
1578
|
+
|
|
1579
|
+
**Proof in repo:** `verification/verticals/<dungeon>.{verify.mjs,sql}` —
|
|
1580
|
+
20 dungeons, 107 documented hooks, 107 verification checks. Each
|
|
1581
|
+
`<dungeon>.verify.mjs` is a CI-runnable assertion that the dungeon's
|
|
1582
|
+
engineered story patterns appear in full-fidelity generated data.
|
|
1583
|
+
|
|
1584
|
+
### 9.1 Stream-load shards (>500MB)
|
|
1585
|
+
|
|
1586
|
+
`fs.readFileSync` caps at ~512MB. For dungeons that produce sharded output
|
|
1587
|
+
(`data/PREFIX-EVENTS-part-*.json`) or single shards above the cap, stream:
|
|
1588
|
+
|
|
1589
|
+
```js
|
|
1590
|
+
import fs from 'fs';
|
|
1591
|
+
import path from 'path';
|
|
1592
|
+
import readline from 'readline';
|
|
1593
|
+
|
|
1594
|
+
async function loadShards(prefix, suffix) {
|
|
1595
|
+
const dir = path.dirname(prefix), base = path.basename(prefix);
|
|
1596
|
+
const out = [];
|
|
1597
|
+
for (const f of fs.readdirSync(dir)
|
|
1598
|
+
.filter(f => f.startsWith(`${base}-${suffix}`) && f.endsWith('.json'))
|
|
1599
|
+
.sort()) {
|
|
1600
|
+
const stream = fs.createReadStream(path.join(dir, f));
|
|
1601
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
1602
|
+
for await (const line of rl) if (line.trim()) out.push(JSON.parse(line));
|
|
1603
|
+
}
|
|
1604
|
+
return out;
|
|
1605
|
+
}
|
|
1606
|
+
const events = await loadShards('data/verify-mydungeon', 'EVENTS');
|
|
1607
|
+
const profiles = await loadShards('data/verify-mydungeon', 'USERS');
|
|
1608
|
+
```
|
|
1609
|
+
|
|
1610
|
+
Works for both single-shard (`...EVENTS.json`) and glob output
|
|
1611
|
+
(`...EVENTS-part-001.json`).
|
|
1612
|
+
|
|
1613
|
+
### 9.2 Per-user post/pre ratio (population dilution)
|
|
1614
|
+
|
|
1615
|
+
Cohorts that themselves bias event volume break absolute-count comparisons.
|
|
1616
|
+
Example: low-balance users check their balance constantly — a hook that
|
|
1617
|
+
reduces post-d30 activity 50% still leaves them with MORE absolute events
|
|
1618
|
+
than high-balance users.
|
|
1619
|
+
|
|
1620
|
+
```js
|
|
1621
|
+
const ratios = [];
|
|
1622
|
+
for (const [uid, evs] of byUser) {
|
|
1623
|
+
const pre = evs.filter(e => new Date(e.time).getTime() < day30).length;
|
|
1624
|
+
const post = evs.filter(e => new Date(e.time).getTime() >= day30).length;
|
|
1625
|
+
if (pre > 0) ratios.push(post / pre);
|
|
1626
|
+
}
|
|
1627
|
+
const avg = a => a.reduce((s, v) => s + v, 0) / a.length;
|
|
1628
|
+
// Cohort A vs cohort B: compare avg(ratiosA) vs avg(ratiosB), not raw post counts
|
|
1629
|
+
```
|
|
1630
|
+
|
|
1631
|
+
### 9.3 Neighbor-day baseline (born-ramp confound)
|
|
1632
|
+
|
|
1633
|
+
Born-in-dataset users plus pre-existing-spread make late-window days denser
|
|
1634
|
+
on average. Comparing a 5-day window against the full-dataset average can
|
|
1635
|
+
invert the signal. Compare against adjacent days only:
|
|
1636
|
+
|
|
1637
|
+
```js
|
|
1638
|
+
const inWindow = (e, lo, hi) => {
|
|
1639
|
+
const t = new Date(e.time).getTime();
|
|
1640
|
+
return t >= ds + lo * 86400000 && t < ds + hi * 86400000;
|
|
1641
|
+
};
|
|
1642
|
+
const target = events.filter(e => e.event === 'order' && inWindow(e, 20, 27)).length;
|
|
1643
|
+
const before = events.filter(e => e.event === 'order' && inWindow(e, 15, 19)).length;
|
|
1644
|
+
const after = events.filter(e => e.event === 'order' && inWindow(e, 28, 32)).length;
|
|
1645
|
+
const baseline = (before + after) / 9; // 9 neighbor days
|
|
1646
|
+
const targetRate = target / 7; // 7 target days
|
|
1647
|
+
check('rainy week dip', targetRate < baseline * 0.85);
|
|
1648
|
+
```
|
|
1649
|
+
|
|
1650
|
+
### 9.4 Soup-aware weekend baseline
|
|
1651
|
+
|
|
1652
|
+
Default soup `dayOfWeekWeights` dampens weekends to ~0.55x weekday. A hook
|
|
1653
|
+
that adds 30% weekend clones lifts to ~0.70x — still <1.0x but ABOVE soup
|
|
1654
|
+
baseline. Verify against expected-without-hook ratio, not against 1.0:
|
|
1655
|
+
|
|
1656
|
+
```js
|
|
1657
|
+
const wkndDelta = wkndAvg / wkdayAvg;
|
|
1658
|
+
const SOUP_BASELINE = 0.55;
|
|
1659
|
+
check('weekend surge above soup baseline', wkndDelta > SOUP_BASELINE * 1.2,
|
|
1660
|
+
`wknd/wkday=${wkndDelta.toFixed(2)} (baseline ${SOUP_BASELINE})`);
|
|
1661
|
+
```
|
|
1662
|
+
|
|
1663
|
+
### 9.5 Verify by spread when cohort key isn't in schema
|
|
1664
|
+
|
|
1665
|
+
Hooks sometimes reference a `profile.X` that isn't a defined userProp. The
|
|
1666
|
+
validator doesn't catch this — `X` resolves to `undefined`. Verify the
|
|
1667
|
+
resulting data SPREAD instead of segment correlation:
|
|
1668
|
+
|
|
1669
|
+
```js
|
|
1670
|
+
// HOOK references profile.level (not in userProps); can't bin by level.
|
|
1671
|
+
// Verify quest_gold spread shows the engineered variance instead.
|
|
1672
|
+
const golds = events.filter(e => e.event === 'quest completed')
|
|
1673
|
+
.map(e => e.gold_earned).filter(g => typeof g === 'number');
|
|
1674
|
+
const max = Math.max(...golds), min = Math.min(...golds);
|
|
1675
|
+
const cv = stddev(golds) / avg(golds);
|
|
1676
|
+
check('quest gold spread engineered', max / min > 50 && cv > 1.5);
|
|
1677
|
+
```
|
|
1678
|
+
|
|
1679
|
+
### 9.6 Hash-based whale/bot cohort
|
|
1680
|
+
|
|
1681
|
+
Cleanest pattern for hidden cohorts. Deterministic, no schema mutation, no
|
|
1682
|
+
flag stamping. Produces textbook long-tail signals:
|
|
1683
|
+
|
|
1684
|
+
```js
|
|
1685
|
+
// In hook (everything):
|
|
1686
|
+
for (const e of events) {
|
|
1687
|
+
const isWhale = e.user_id && e.user_id.charCodeAt(0) % 50 === 0; // 2%
|
|
1688
|
+
if (isWhale && e.event === 'swap') e.trade_amount_usd *= 50;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// In verify:
|
|
1692
|
+
const whaleAmts = [], rest = [];
|
|
1693
|
+
for (const [uid, evs] of byUser) {
|
|
1694
|
+
const isWhale = uid.charCodeAt(0) % 50 === 0;
|
|
1695
|
+
const amts = evs.filter(e => e.event === 'swap').map(e => e.trade_amount_usd);
|
|
1696
|
+
(isWhale ? whaleAmts : rest).push(...amts);
|
|
1697
|
+
}
|
|
1698
|
+
check('whale 5x+ trade', avg(whaleAmts) / avg(rest) >= 5);
|
|
1699
|
+
```
|
|
1700
|
+
|
|
1701
|
+
### 9.7 Hook ordering inside `everything`
|
|
1702
|
+
|
|
1703
|
+
When one hook injects events that another hook then mutates, ordering
|
|
1704
|
+
breaks ratios. Example (dating):
|
|
1705
|
+
|
|
1706
|
+
- HOOK 1: cap `match_score` for a cohort to ≤0.4
|
|
1707
|
+
- HOOK 4: clone high-`match_score` matches for premium users
|
|
1708
|
+
|
|
1709
|
+
If HOOK 1 runs FIRST, then HOOK 4 injects new high-score matches into the
|
|
1710
|
+
capped cohort — the cohort's avg moves back up.
|
|
1711
|
+
|
|
1712
|
+
**Recipe:** in a single `everything` block, run cohort-degrading hooks
|
|
1713
|
+
AFTER all injection hooks. Document the ordering inline:
|
|
1714
|
+
|
|
1715
|
+
```js
|
|
1716
|
+
hook(events, type, meta) {
|
|
1717
|
+
if (type !== 'everything') return events;
|
|
1718
|
+
// 1. Injection hooks first
|
|
1719
|
+
applyHook4(events, meta);
|
|
1720
|
+
applyHook5(events, meta);
|
|
1721
|
+
// 2. Cohort-shaping hooks last (operate on final event set)
|
|
1722
|
+
applyHook1(events, meta);
|
|
1723
|
+
return events;
|
|
1724
|
+
}
|
|
1725
|
+
```
|
|
1726
|
+
|
|
1727
|
+
### 9.8 Funnel-post TTC limitation (KNOWN)
|
|
1728
|
+
|
|
1729
|
+
`funnel-post` hooks that compress timing between funnel events DON'T move
|
|
1730
|
+
the verifier's `timeToConvert` rows because `evaluateFunnel` is a greedy
|
|
1731
|
+
single-pass over the user's full event history — it picks the first
|
|
1732
|
+
matching event for each step regardless of which funnel-instance the hook
|
|
1733
|
+
mutated. Document the limitation rather than failing the check:
|
|
1734
|
+
|
|
1735
|
+
```js
|
|
1736
|
+
const rows = emulateBreakdown(events, {
|
|
1737
|
+
type: 'timeToConvert',
|
|
1738
|
+
fromEvent: 'deposit',
|
|
1739
|
+
toEvent: 'withdrawal',
|
|
1740
|
+
breakdownByUserProperty: 'trading_tier',
|
|
1741
|
+
profiles,
|
|
1742
|
+
});
|
|
1743
|
+
check('TTC populations present (limitation)', rows.length > 0,
|
|
1744
|
+
`rows=${rows.length} tiers=${rows.map(r => r.segment_value).join(',')}`);
|
|
1745
|
+
```
|
|
1746
|
+
|
|
1747
|
+
### 9.9 Per-dungeon verify script template
|
|
1748
|
+
|
|
1749
|
+
```js
|
|
1750
|
+
import fs from 'fs';
|
|
1751
|
+
import path from 'path';
|
|
1752
|
+
import readline from 'readline';
|
|
1753
|
+
import { emulateBreakdown, evaluateFunnel,
|
|
1754
|
+
buildIdentityMap, resolveUserId } from '@ak--47/dungeon-master/verify';
|
|
1755
|
+
|
|
1756
|
+
const PREFIX = 'data/verify-MYDUNGEON';
|
|
1757
|
+
async function loadShards(suffix) { /* see 9.1 */ }
|
|
1758
|
+
|
|
1759
|
+
const events = await loadShards('EVENTS');
|
|
1760
|
+
const profiles = await loadShards('USERS');
|
|
1761
|
+
const identityMap = buildIdentityMap(profiles);
|
|
1762
|
+
const profileBy = new Map(profiles.map(p => [p.distinct_id, p]));
|
|
1763
|
+
|
|
1764
|
+
const byUser = new Map();
|
|
1765
|
+
for (const e of events) {
|
|
1766
|
+
const uid = resolveUserId(e, identityMap);
|
|
1767
|
+
if (!byUser.has(uid)) byUser.set(uid, []);
|
|
1768
|
+
byUser.get(uid).push(e);
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
const results = [];
|
|
1772
|
+
const check = (n, p, d = '') => {
|
|
1773
|
+
results.push({ n, p, d });
|
|
1774
|
+
console.log(` ${p ? 'PASS' : 'FAIL'} ${n} ${d}`);
|
|
1775
|
+
};
|
|
1776
|
+
const avg = a => a.length ? a.reduce((s, v) => s + v, 0) / a.length : 0;
|
|
1777
|
+
|
|
1778
|
+
// ... per-hook checks ...
|
|
1779
|
+
|
|
1780
|
+
const passed = results.filter(r => r.p).length;
|
|
1781
|
+
console.log(`\n${passed}/${results.length} checks passed`);
|
|
1782
|
+
process.exit(passed === results.length ? 0 : 1);
|
|
1783
|
+
```
|
|
1784
|
+
|