@ak--47/dungeon-master 1.4.3 → 1.4.4

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/CHANGELOG.md CHANGED
@@ -2,6 +2,77 @@
2
2
 
3
3
  All notable changes to `@ak--47/dungeon-master`.
4
4
 
5
+ ## 1.4.4 — 2026-05-06
6
+
7
+ The "GCS imports actually work now" release.
8
+
9
+ ### Fixed
10
+
11
+ - **GCS-sourced imports hung indefinitely.** `streamJSON`, `streamCSV`, and `streamParquet` used `createWriteStream({ gzip: true })` for all GCS writes, setting `Content-Encoding: gzip` on objects. The HTTP transport auto-decompressed on some environments but not others (Cloud Run). When it didn't, raw gzip bytes reached parsers, stream errors didn't propagate through `.pipe()` chains, and the pipeline promise never resolved. Fix: GCS writes no longer use `Content-Encoding: gzip`. Gzip is handled at the application level (pipe through `zlib.createGzip()`), same as local writes.
12
+ - **Group profiles silently skipped in batch mode.** After flush, `groupEntity.length === 0` triggered an early `continue` even when batch files existed on GCS. Events/users/ad-spend had `isBATCH_MODE` fallbacks — groups didn't.
13
+ - **GCS gzip finish-event timing.** Promise resolved on gzip transform's `finish` (compression done) instead of GCS stream's `finish` (upload complete), potentially producing truncated files.
14
+ - **Group events batch mode fallback** (latent). Added `isBATCH_MODE` guard for future use.
15
+
16
+ ### Changed
17
+
18
+ - **GCS default format is now JSONL.** When `writeToDisk` is a `gs://` path and no `format` is specified, the default is `"json"` instead of `"csv"`. Explicit `format` settings are unaffected.
19
+ - **HOOKS.md shipped in npm package.** The hook encyclopedia (24 recipes, 20 principles, atom/pattern reference) is now included in the published package.
20
+
21
+ ### Added
22
+
23
+ - **GCS round-trip tests.** Three e2e tests that write to GCS and read back through `mixpanel-import`'s stream parser: default JSONL, gzipped JSONL, and full dungeon (events + users + groups + SCDs + ad spend). Verifies actual record counts, not just file existence.
24
+
25
+ ### Documentation
26
+
27
+ - **`research/1.4.4-upgrade-guide.md`** — full details on the GCS fix, migration notes, and root cause analysis.
28
+
29
+ ## 1.4.3 — 2026-05-05
30
+
31
+ ### Changed
32
+
33
+ - **`percentUsersBornInDataset` defaults raised.** The "flat" macro preset (default) changed from 15% to 50%. All other presets raised proportionally (floor 25%). Retention/onboarding hooks now have much larger cohorts for cleaner signal.
34
+ - **Skill rename:** `verify-hooks` → `verify-dungeon`. Reflects broader scope (schema + hooks + identity + experiments).
35
+ - **Test directory cleanup.** Removed benchmark scripts, intellisense test files, and legacy test helpers. Flattened hook helper/pattern test file names.
36
+
37
+ ### Added
38
+
39
+ - **HOOKS.md recipe 3.22** — Retention Magic Number pattern ("N actions in first X days predicts retention"), drawn from the Twitter dungeon iteration.
40
+ - **Schema validation** (`lib/verify/schema-validator.js`). Catches hooks that introduce undeclared columns. Integrated into `verifyDungeon()`.
41
+ - **Property type helpers:** `dateRange()`, `listOf()`, `objectList()` — complete coverage of all 7 Mixpanel property data types.
42
+ - **Twitter/X dungeon** (`dungeons/user/twitter.js`) — consumer social platform with 4 verified hooks.
43
+
44
+ ### Documentation
45
+
46
+ - **`research/1.4.3-upgrade-guide.md`** — macro preset migration, retention hook calibration lessons, schema validation API.
47
+
48
+ ## 1.4.2 — 2026-05-04
49
+
50
+ ### Changed
51
+
52
+ - **All 20 vertical dungeons verified STRONG or NAILED.** 200 hooks across 20 verticals evaluated and fixed via the verify-dungeon pipeline.
53
+
54
+ ### Fixed
55
+
56
+ - Various hook bugs across `dating`, `social`, `travel`, `community`, `logistics`, `media`, `fintech`, `food-delivery`, `education`, `real-estate`, `devtools`, and `marketplace` dungeons surfaced by `/verify-dungeon`.
57
+ - Vertical dungeon property defaults, temporal hook ordering, and cohort threshold calibration.
58
+
59
+ ## 1.4.1 — 2026-05-04
60
+
61
+ ### Added
62
+
63
+ - **File path tracking (`getWrittenFiles()`).** HookedArray containers track exact file paths written during a run. Replaces fragile `ls()` + string-filter directory scans. Works for local and `gs://` paths.
64
+ - **`cleanup: true` config option.** Deletes all written files at end of run (local and GCS). Runs in `finally` block.
65
+ - **Cloud Run / serverless OOM guide** in upgrade guide — `batchSize` + `writeToDisk: 'gs://'` + `concurrency: 1` pattern for low peak memory.
66
+
67
+ ### Fixed
68
+
69
+ - **SCD multi-batch import.** Sender used `.pop()` when discovering SCD batch files — only the last batch was imported. All batch files now imported.
70
+ - **Warnings gated behind `verbose: true`.** Config validator and storage layer warnings no longer fire unconditionally.
71
+
72
+ ### Documentation
73
+
74
+ - **`research/1.4.1-upgrade-guide.md`** — file tracking API, cleanup option, Cloud Run deployment guide.
75
+
5
76
  ## 1.4.0 — 2026-05-03
6
77
 
7
78
  The "identity model + hook verification" release. Users get multi-device identity, declarative experiments, and a complete hook authoring pipeline with verification. All 20 vertical dungeons upgraded and verified.
package/HOOKS.md ADDED
@@ -0,0 +1,1138 @@
1
+ # HOOKS.md -- Hook Encyclopedia
2
+
3
+ Hook reference and recipe catalog for dungeon-master. Every pattern here is
4
+ drawn from production dungeons. Code snippets are concrete but adaptable --
5
+ change event names, property names, and thresholds to fit your schema.
6
+
7
+ ---
8
+
9
+ ## 1. Quick Reference
10
+
11
+ ```
12
+ hook: function (record, type, meta) { ... return record; }
13
+ ```
14
+
15
+ | Type | Fires In | `record` Is | Return | Key `meta` Fields |
16
+ |---|---|---|---|---|
17
+ | `user` | `user-loop.js:156` | User profile object | Ignored (mutate in-place) | `user`, `config`, `userIsBornInDataset` |
18
+ | `scd-pre` | `user-loop.js:175` | Array of SCD entries | Ignored (mutate in-place) | `profile`, `type`, `scd`, `config`, `allSCDs` |
19
+ | `funnel-pre` | `funnels.js:70` | Funnel config object | Ignored (mutate in-place) | `user`, `profile`, `scd`, `funnel`, `config`, `firstEventTime`, `experiment` |
20
+ | `event` | `events.js:176` | Single event (flat props) | **Used** (replaces event) | `user: { distinct_id }`, `config`, `datasetStart`, `datasetEnd` |
21
+ | `funnel-post` | `funnels.js:153` | Array of funnel events | Ignored (mutate in-place) | `user`, `profile`, `scd`, `funnel`, `config`, `experiment` |
22
+ | `everything` | `user-loop.js:280` | Array of ALL user events | **Used** if array returned | `profile`, `scd`, `config`, `datasetStart`, `datasetEnd`, `userIsBornInDataset`, `authTime`, `isPreAuth`, `persona` |
23
+ | `ad-spend` | `storage.js` | Ad spend event | Ignored | -- |
24
+ | `group` | `storage.js` | Group profile | Ignored | -- |
25
+ | `mirror` | `storage.js` | Mirror data point | Ignored | -- |
26
+ | `lookup` | `storage.js` | Lookup table entry | Ignored | -- |
27
+
28
+ **Per-user execution order:** `user` -> `scd-pre` -> `funnel-pre` -> `event` -> `funnel-post` -> `everything`
29
+
30
+ Storage-only hooks (`ad-spend`, `group`, `mirror`, `lookup`) fire once per
31
+ record when pushed to a HookedArray. Core hooks (`event`, `user`, `scd-pre`)
32
+ fire only in the generator/orchestrator -- storage skips them to prevent
33
+ double-fire mutations.
34
+
35
+ **Return rules:**
36
+ - `event`: return the (possibly replaced) event object. Returning a different object replaces the event entirely.
37
+ - `everything`: return the (possibly modified) array. Returning a filtered array removes events.
38
+ - All other types: mutate `record` in-place. Return value is ignored.
39
+
40
+ ---
41
+
42
+ ## 2. Core Principles
43
+
44
+ 1. **Schema-first.** Every property in the output must be defined in the dungeon
45
+ config (`events[].properties`, `userProps`, or `superProps`) with a default
46
+ value. Hooks modify existing values. They never invent new properties.
47
+
48
+ 2. **No flag-stamping.** Never add cohort labels like `is_whale = true` or
49
+ `power_user = true`. Derive segments from behavioral data (event counts,
50
+ property values, sequences) that analysts discover via Mixpanel cohorts.
51
+
52
+ 3. **Clone-don't-construct.** Injected events must be cloned from an existing
53
+ event using spread (`{...template, time: t, user_id: uid}`). Never build
54
+ events from scratch. This guarantees the schema stays consistent.
55
+
56
+ 4. **Properties are FLAT.** Access `record.amount`, not
57
+ `record.properties.amount`. The engine flattens event properties before
58
+ hooks see them.
59
+
60
+ 5. **Hooks are the final authority.** They override persona modifiers, world
61
+ events, experiment modifiers, and engagement decay. Whatever the hook writes
62
+ is what ships.
63
+
64
+ 6. **Use `dayjs` for time, seeded `chance` for randomness.** Never use
65
+ `Math.random()`. Initialize `chance` at module scope with the dungeon seed:
66
+ `const chance = u.initChance(SEED)`.
67
+
68
+ 7. **Temporal hooks go in `everything`.** Any hook that checks
69
+ `dayInDataset >= N` must live in the `everything` hook, not the `event`
70
+ hook. The `event` hook's `meta.datasetStart` produces unreliable day
71
+ calculations. The `everything` hook's `meta.datasetStart` is verified
72
+ correct (churn/silencing hooks work there consistently).
73
+
74
+ 8. **Event cloning requires `everything`.** The `event` hook's return value
75
+ REPLACES the original event. To DUPLICATE or INJECT events (spike
76
+ patterns, burst clones), use the `everything` hook and `push()` to the
77
+ array. Only use the `event` hook return for event REPLACEMENT patterns
78
+ (e.g., alert triggered → incident created).
79
+
80
+ 9. **Property baselines must contrast with hook targets.** If a hook sets
81
+ `event_type = "plan_upgraded"` during a time window, the baseline
82
+ distribution must make `plan_upgraded` rare (~10-15%). If it's already
83
+ 20%+ at baseline, the hook produces no visible spike. Similarly, if a
84
+ hook forces `scale_direction = "down"`, the baseline must favor "up" so
85
+ the forced "down" creates measurable contrast.
86
+
87
+ 10. **TTC effects go in `everything`, not `funnel-post`.** Funnel-post TTC
88
+ scaling (e.g., enterprise converts 1.4x faster) is not verifiable via
89
+ cross-event SQL queries — standalone events drown the within-funnel
90
+ signal. Move TTC-by-segment effects to the `everything` hook where you
91
+ can directly scale time gaps between event pairs (e.g., alert triggered
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.
122
+
123
+ 15. **TTC effects must shift timestamps, not just properties.** Mixpanel's
124
+ funnel TTC measures the delta between event *timestamps* (step A time →
125
+ step B time). Scaling a timing *property* (e.g., `wait_time_hours *= 0.67`)
126
+ changes what shows up in Insights AVG reports, but does NOT affect what
127
+ Mixpanel's funnel TTC report shows — that report uses the event's
128
+ timestamp, not any property value. To create a visible TTC-by-segment
129
+ story in Mixpanel Funnels, you MUST shift the actual event timestamps in
130
+ the `everything` hook using `findFirstSequence()` + `scaleFunnelTTC()`.
131
+ Property scaling is fine as a complementary Insights signal, but the
132
+ timestamp shift is the primary mechanism.
133
+
134
+ 16. **Scope `funnel-pre` to specific funnels.** When a `funnel-pre` hook
135
+ adjusts conversion rates for a segment (e.g., free users 0.5x), apply it
136
+ only to the intended funnel using `meta.funnel.sequence`. Unscoped
137
+ funnel-pre hooks affect ALL funnels, which can create unexpected
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
+ ```
148
+
149
+ 17. **SCD props live in `meta.scd`, not `meta.profile`.** Slowly Changing
150
+ Dimension values are never present on the profile object. If your hook
151
+ needs the user's current SCD value (e.g., `loyalty_tier`, `plan_tier`),
152
+ read it from `meta.scd.<scdName>` and extract the latest entry. Reading
153
+ `meta.profile.<scdPropName>` will always be `undefined`.
154
+
155
+ 18. **Calibrate thresholds against actual event distributions.** When a hook
156
+ gates behavior on "N+ events of type X in first Y days," the threshold
157
+ must be achievable given the event weight, total event rate, and number
158
+ of event types. With 200 event types and 2.5 events/user/day, a weight-7
159
+ event gets ~0.2 occurrences/day. A threshold of 5 in 7 days is impossible
160
+ for most users. Always check the distribution before setting thresholds:
161
+ ```js
162
+ // Run this query to see the actual distribution
163
+ // SELECT count, COUNT(*) FROM (
164
+ // SELECT user_id, COUNT(*) as count FROM events WHERE event = 'X' GROUP BY user_id
165
+ // ) GROUP BY count ORDER BY count;
166
+ ```
167
+ Set thresholds at roughly the 80th percentile — enough users exceed it to
168
+ form a meaningful cohort (~20%), but not so many that "everyone qualifies."
169
+
170
+ 19. **Prefer boosts over drops for retention hooks.** Using
171
+ `scaleEventCount(events, "X", 1.8)` on the positive cohort produces
172
+ cleaner signal than `dropEventsWhere` on the negative cohort.
173
+ Drops compound with other drop hooks and persona-level churn — two hooks
174
+ each dropping 40% after day 21 combine to drop 76% for users in both
175
+ cohorts, masking all intended signal. Boosts are additive and don't
176
+ interact destructively with other hooks. Reserve drops for single-hook
177
+ churn patterns where the cohort is precisely defined.
178
+
179
+ 20. **Compounding drop hooks destroy signal.** If Hook A drops 40% after
180
+ day 21 for "non-loyal" users AND Hook B drops 60% after day 21 for
181
+ "non-streak" users, and 95% of users are in BOTH groups, then baseline
182
+ post-day-21 events are pruned by ~76%. The "control group" barely exists.
183
+ Fix: use at most ONE drop-based retention hook per dungeon. Move other
184
+ retention effects to boost-based patterns (principle #19).
185
+
186
+ ---
187
+
188
+ ## 3. Recipe Catalog
189
+
190
+ ### Temporal Trends
191
+
192
+ #### 3.1 Conversion Change Over Time
193
+
194
+ **Hook type:** `funnel-pre` | **Meta:** `meta.firstEventTime`
195
+
196
+ **In Mixpanel:** Funnel conversion rate shows a step-change at a specific date.
197
+ Before the date, conversion is baseline; after, it jumps or drops.
198
+
199
+ ```js
200
+ // funnel-pre: feature launch boosts onboarding conversion by 20%
201
+ if (type === "funnel-pre") {
202
+ const LAUNCH = dayjs.unix(meta.datasetStart).add(60, "days").valueOf();
203
+ if (meta.firstEventTime > LAUNCH) {
204
+ record.conversionRate *= 1.2;
205
+ }
206
+ }
207
+ ```
208
+
209
+ **Real-world analogue:** Product team ships a new onboarding wizard; conversion
210
+ lifts overnight and stays elevated.
211
+
212
+ **Adaptation:** Change the date offset and multiplier. Use `< LAUNCH` with a
213
+ multiplier `< 1` for degradation stories.
214
+
215
+ ---
216
+
217
+ #### 3.2 Feature Launch Inflection
218
+
219
+ **Hook type:** `everything` | **Meta:** `meta.profile`, `meta.datasetStart`
220
+
221
+ **In Mixpanel:** A line chart of "Submit Feedback" broken down by "Feedback
222
+ Source" shows new source values ("Post Search", "Post Action") appearing only
223
+ after a launch date, with volume and ratings jumping.
224
+
225
+ ```js
226
+ // everything: contextual feedback sources appear after feature launch
227
+ if (type === "everything") {
228
+ const LAUNCH = dayjs.unix(meta.datasetStart).add(74, "days");
229
+ const feedbackTemplate = record.find(e => e.event === "Submit Feedback");
230
+ if (!feedbackTemplate) return record;
231
+
232
+ // Path A: Ask MyBuddy -> View Summary within 5 min triggers "Post Search"
233
+ for (const e of record) {
234
+ if (e.event !== "Ask MyBuddy" || !dayjs(e.time).isAfter(LAUNCH)) continue;
235
+ const tail = record.slice(record.indexOf(e));
236
+ const match = findFirstSequence(tail, ["Ask MyBuddy", "View Summary"], 5);
237
+ if (match && chance.bool({ likelihood: 35 })) {
238
+ record.push(cloneEvent(feedbackTemplate, {
239
+ time: dayjs(match[1].time).add(2, "minutes").toISOString(),
240
+ user_id: record[0].user_id,
241
+ "Rating": chance.integer({ min: 4, max: 5 }),
242
+ "Feedback Source": "Post Search",
243
+ }));
244
+ }
245
+ }
246
+ return record;
247
+ }
248
+ ```
249
+
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
+ ---
257
+
258
+ #### 3.3 End-of-Quarter Spike
259
+
260
+ **Hook type:** `event` | **Meta:** `meta.datasetStart`
261
+
262
+ **In Mixpanel:** Line chart of "billing event" filtered to `event_type =
263
+ "plan_upgraded"` shows a 4x spike in the final 10 days.
264
+
265
+ ```js
266
+ // event: days 80-90, 40% of billing events become plan upgrades
267
+ if (type === "event" && record.event === "billing event") {
268
+ const dayInDataset = dayjs(record.time).diff(dayjs.unix(meta.datasetStart), "days", true);
269
+ if (dayInDataset >= 80 && dayInDataset <= 90 && chance.bool({ likelihood: 40 })) {
270
+ record.event_type = "plan_upgraded";
271
+ }
272
+ }
273
+ ```
274
+
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
+ ---
282
+
283
+ #### 3.4 Degradation and Recovery
284
+
285
+ **Hook type:** `everything` | **Meta:** `meta.datasetStart`, `meta.datasetEnd`
286
+
287
+ **In Mixpanel:** "Agenda Error" line chart shows zero before April 10, ramps up
288
+ during the bug window, then decays exponentially after the fix on April 26.
289
+ Breakdown by "Region" shows EU dominates errors.
290
+
291
+ ```js
292
+ // everything: EU users get 60% error rate during bug window, exponential decay after fix
293
+ if (type === "everything") {
294
+ const BUG_START = dayjs.unix(meta.datasetEnd).subtract(20, "days");
295
+ const FIX_DATE = dayjs.unix(meta.datasetEnd).subtract(4, "days");
296
+ if (meta.profile.Region !== "EU") return record;
297
+
298
+ const errorTemplate = record.find(e => e.event === "Agenda Error") || record[0];
299
+ record.filter(e => e.event === "Create Agenda" && dayjs(e.time).isAfter(BUG_START))
300
+ .forEach(agenda => {
301
+ const t = dayjs(agenda.time);
302
+ let likelihood = 60;
303
+ if (t.isAfter(FIX_DATE)) {
304
+ // exponential decay: 60 * 0.15^(days since fix)
305
+ likelihood = Math.max(0, 60 * Math.pow(0.15, t.diff(FIX_DATE, "days", true)));
306
+ }
307
+ if (likelihood > 0 && chance.bool({ likelihood })) {
308
+ record.push(cloneEvent(errorTemplate, {
309
+ event: "Agenda Error",
310
+ time: t.add(chance.integer({ min: 2, max: 10 }), "seconds").toISOString(),
311
+ user_id: record[0].user_id,
312
+ "Error Message": "model not found in region eu-west-5-2",
313
+ "Error Code": 500,
314
+ }));
315
+ }
316
+ });
317
+ return record;
318
+ }
319
+ ```
320
+
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
+ ---
329
+
330
+ ### Magic Numbers
331
+
332
+ #### 3.5 Inverted-U Sweet Spot
333
+
334
+ **Hook type:** `everything` | **Meta:** `meta.profile`
335
+
336
+ **In Mixpanel:** Users bucketed by count of "Onboarding Question" events show
337
+ peak conversion at 3 questions (~85%), dropping on both sides. Classic
338
+ inverted-U.
339
+
340
+ ```js
341
+ // everything: inverted-U conversion by onboarding question count
342
+ if (type === "everything") {
343
+ const BINS = {
344
+ low: [0, 3], // 0-2 questions: not enough context
345
+ sweet: [3, 4], // exactly 3: peak conversion
346
+ four: [4, 5],
347
+ high: [5, Infinity],
348
+ };
349
+ const DROP = { low: 75, sweet: 0, four: 20, high: 70 };
350
+ const bin = binUsersByEventCount(record, "Onboarding Question", BINS);
351
+ const dropProb = DROP[bin] ?? 0;
352
+
353
+ if (dropProb > 0 && chance.bool({ likelihood: dropProb })) {
354
+ // non-converter: keep only acquisition events
355
+ const keep = new Set(["View Shared Page", "Onboarding Question"]);
356
+ dropEventsWhere(record, e => !keep.has(e.event));
357
+ }
358
+ return record;
359
+ }
360
+ ```
361
+
362
+ **Real-world analogue:** Signup flow friction optimization -- too few screens
363
+ means users don't understand the value prop; too many means they abandon.
364
+
365
+ **Adaptation:** Change the event name, bin boundaries, and drop probabilities.
366
+ Add a profile-based penalty (e.g., email auth +20% drop).
367
+
368
+ ---
369
+
370
+ #### 3.6 Frequency x Engagement Sweet Spot
371
+
372
+ **Hook type:** `everything` | **Meta:** none
373
+
374
+ **In Mixpanel:** Users with 3-8 "view item" events show 25% higher cart
375
+ amounts. Users with 9+ are window-shoppers whose checkouts drop 30%.
376
+
377
+ ```js
378
+ // everything: view-item magic number for cart value
379
+ if (type === "everything") {
380
+ const viewCount = record.filter(e => e.event === "view item").length;
381
+ if (viewCount >= 3 && viewCount <= 8) {
382
+ // sweet spot: boost cart amounts
383
+ scalePropertyValue(record, e => e.event === "checkout", "amount", 1.25);
384
+ } else if (viewCount >= 9) {
385
+ // decision paralysis: drop checkouts
386
+ dropEventsWhere(record, e => e.event === "checkout" && chance.bool({ likelihood: 30 }));
387
+ }
388
+ return record;
389
+ }
390
+ ```
391
+
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
+ ---
399
+
400
+ #### 3.7 CI Build Magic Number
401
+
402
+ **Hook type:** `everything` | **Meta:** none
403
+
404
+ **In Mixpanel:** Users with 15-30 builds sit in the healthy CI sweet spot
405
+ (30% more deploys). Users with 31+ suffer flaky-CI burnout (25% fewer deploys).
406
+
407
+ ```js
408
+ // everything: build count magic number
409
+ if (type === "everything") {
410
+ const buildCount = record.filter(e => e.event === "build completed").length;
411
+ if (buildCount >= 15 && buildCount <= 30) {
412
+ // healthy CI: clone 30% extra deploys
413
+ scaleEventCount(record, "deployment completed", 1.3);
414
+ } else if (buildCount >= 31) {
415
+ // flaky burnout: drop 25% of deploys
416
+ scaleEventCount(record, "deployment completed", 0.75);
417
+ }
418
+ return record;
419
+ }
420
+ ```
421
+
422
+ **Real-world analogue:** Healthy CI cadence drives reliable deploys; runaway
423
+ builds signal a flaky pipeline that scares teams off shipping.
424
+
425
+ **Adaptation:** Change the count event, target event, bin boundaries, and
426
+ scale factors. Use `applyFrequencyByFrequency` (Phase 4) for a declarative
427
+ version.
428
+
429
+ ---
430
+
431
+ ### Experiments
432
+
433
+ #### 3.8 A/B/C Test with Variant-Specific Effects
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.
440
+
441
+ The experiment is declared on the funnel config -- the engine handles variant
442
+ assignment, `$experiment_started` events, and conversion modifiers:
443
+
444
+ ```js
445
+ // Funnel config (declarative):
446
+ {
447
+ sequence: ["Create Agenda", "Agenda Generated"],
448
+ conversionRate: 60,
449
+ experiment: {
450
+ name: "Collaborative Agenda",
451
+ variants: [
452
+ { name: "Control" },
453
+ { name: "Variant A", conversionMultiplier: 1.15, ttcMultiplier: 0.9 },
454
+ { name: "Variant B", conversionMultiplier: 1.35, ttcMultiplier: 0.7 },
455
+ ],
456
+ startDaysBeforeEnd: 30,
457
+ },
458
+ }
459
+ ```
460
+
461
+ ```js
462
+ // funnel-post: inject downstream events for Variant B
463
+ if (type === "funnel-post" && meta.experiment?.variantName === "Variant B") {
464
+ const last = record[record.length - 1];
465
+ if (last) {
466
+ const tpTemplate = record.find(e => e.event === "Add Talking Point") || last;
467
+ record.push(cloneEvent(tpTemplate, {
468
+ event: "Add Talking Point",
469
+ time: dayjs(last.time).add(chance.integer({ min: 5, max: 30 }), "minutes").toISOString(),
470
+ user_id: last.user_id,
471
+ "Source": "AI Suggested",
472
+ }));
473
+ }
474
+ }
475
+ ```
476
+
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
+ ---
485
+
486
+ ### Cohort Effects
487
+
488
+ #### 3.9 Subscription Tier Stacking
489
+
490
+ **Hook type:** `everything` | **Meta:** `meta.profile`
491
+
492
+ **In Mixpanel:** "quest turned in" avg reward_gold, broken down by
493
+ subscription_tier, shows Premium at 1.4x and Elite at 1.8x vs Free baseline.
494
+
495
+ ```js
496
+ // everything: tier-based reward scaling
497
+ if (type === "everything") {
498
+ const tier = meta.profile.subscription_tier;
499
+ const multiplier = tier === "Elite" ? 1.8 : tier === "Premium" ? 1.4 : 1.0;
500
+ if (multiplier !== 1.0) {
501
+ scalePropertyValue(record, e => e.event === "quest turned in", "reward_gold", multiplier);
502
+ scalePropertyValue(record, e => e.event === "quest turned in", "reward_xp", multiplier);
503
+ }
504
+ return record;
505
+ }
506
+ ```
507
+
508
+ **Real-world analogue:** Subscription tiers in live-service games confer
509
+ XP/loot bonuses that translate into measurable progress speed.
510
+
511
+ **Adaptation:** Change the profile property, event, and value property. Works
512
+ for any segment-scales-value pattern.
513
+
514
+ ---
515
+
516
+ #### 3.10 Integration Users Succeed
517
+
518
+ **Hook type:** `everything` | **Meta:** none (derived from events)
519
+
520
+ **In Mixpanel:** Cohort of users who configured both Slack AND PagerDuty
521
+ integrations shows 60% lower response time and 50% faster resolution.
522
+
523
+ ```js
524
+ // everything: integration users resolve incidents faster
525
+ if (type === "everything") {
526
+ let hasSlack = false, hasPagerduty = false;
527
+ record.forEach(e => {
528
+ if (e.event === "integration configured") {
529
+ if (e.integration_type === "slack") hasSlack = true;
530
+ if (e.integration_type === "pagerduty") hasPagerduty = true;
531
+ }
532
+ });
533
+ if (hasSlack && hasPagerduty) {
534
+ scalePropertyValue(record, e => e.event === "alert acknowledged", "response_time_mins", 0.4);
535
+ scalePropertyValue(record, e => e.event === "alert resolved", "resolution_time_mins", 0.5);
536
+ }
537
+ return record;
538
+ }
539
+ ```
540
+
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
+ ---
548
+
549
+ #### 3.11 Power User Behavioral Amplification
550
+
551
+ **Hook type:** `everything` | **Meta:** none (derived from events)
552
+
553
+ **In Mixpanel:** Users who used the "Ancient Compass" item earn 1.5x quest
554
+ rewards and get 40% more quest completions via cloned events.
555
+
556
+ ```js
557
+ // everything: Ancient Compass users get amplified rewards + extra quests
558
+ if (type === "everything") {
559
+ const usedCompass = record.some(e => e.event === "use item" && e.item_type === "Ancient Compass");
560
+ if (!usedCompass) return record;
561
+
562
+ record.forEach((event, idx) => {
563
+ if (event.event === "quest turned in") {
564
+ event.reward_gold = Math.floor((event.reward_gold || 100) * 1.5);
565
+ event.reward_xp = Math.floor((event.reward_xp || 500) * 1.5);
566
+ // 40% chance: clone a bonus quest completion
567
+ if (chance.bool({ likelihood: 40 })) {
568
+ record.push(cloneEvent(event, {
569
+ time: dayjs(event.time).add(chance.integer({ min: 10, max: 120 }), "minutes").toISOString(),
570
+ user_id: event.user_id,
571
+ quest_id: chance.pickone(questIds),
572
+ }));
573
+ }
574
+ }
575
+ });
576
+ return record;
577
+ }
578
+ ```
579
+
580
+ **Real-world analogue:** Players who discover a power-up item measurably
581
+ outperform those who don't -- classic feature discovery correlation.
582
+
583
+ **Adaptation:** Replace the trigger event/property and the amplified
584
+ downstream event. The pattern works for any "did X -> gets more Y" story.
585
+
586
+ ---
587
+
588
+ ### Operational Stories
589
+
590
+ #### 3.12 Night Deploy Failure Spike
591
+
592
+ **Hook type:** `everything` | **Meta:** none
593
+
594
+ **In Mixpanel:** "deployment completed" failure rate broken down by hour of
595
+ day shows 22:00-05:59 at 40% failure vs 15% baseline.
596
+
597
+ ```js
598
+ // everything: night deploys fail at 40% rate
599
+ if (type === "everything") {
600
+ record.forEach(e => {
601
+ if (e.event === "deployment completed") {
602
+ const hour = new Date(e.time).getUTCHours();
603
+ if ((hour >= 22 || hour < 6) && chance.bool({ likelihood: 40 })) {
604
+ e.deploy_status = "failed";
605
+ }
606
+ }
607
+ });
608
+ return record;
609
+ }
610
+ ```
611
+
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
+ ---
619
+
620
+ #### 3.13 Regional Error Injection
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.
626
+
627
+ See [Recipe 3.4](#34-degradation-and-recovery) for the full implementation.
628
+ The key addition is a profile-segment gate:
629
+
630
+ ```js
631
+ if (meta.profile.Region !== "EU") return record;
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.
637
+
638
+ ---
639
+
640
+ ### Funnel Manipulation
641
+
642
+ #### 3.14 TTC by User Segment (Timestamp Shifting)
643
+
644
+ **Hook type:** `everything` | **Meta:** `meta.profile`
645
+
646
+ **In Mixpanel:** Funnel median TTC, broken down by segment, shows Enterprise
647
+ completing 3x faster than Free. This is the ONLY approach that affects
648
+ Mixpanel's Funnel TTC report — Mixpanel measures the delta between event
649
+ timestamps, not property values.
650
+
651
+ ```js
652
+ // everything: shift timestamps in funnel sequences by segment
653
+ if (type === "everything") {
654
+ const factor = meta.profile?.tier === "elite" ? 0.3 : meta.profile?.tier === "free" ? 1.4 : 1.0;
655
+ if (factor !== 1.0) {
656
+ const seq = findFirstSequence(record, ["step_a", "step_b", "step_c"], 60 * 24 * 30);
657
+ if (seq) scaleFunnelTTC(seq, factor);
658
+ }
659
+ return record;
660
+ }
661
+ ```
662
+
663
+ **SQL verification** (bound-sequence pattern — don't use lazy MIN→MIN):
664
+ ```sql
665
+ WITH steps AS (
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:
699
+
700
+ ```js
701
+ // everything: ALSO scale timing properties for Insights signal
702
+ scalePropertyValue(record, e => e.event === "alert acknowledged", "response_time_mins", factor);
703
+ scalePropertyValue(record, e => e.event === "alert resolved", "resolution_time_mins", factor);
704
+ ```
705
+
706
+ This is supplementary. The timestamp shift (3.14) is the primary mechanism.
707
+
708
+ ---
709
+
710
+ #### 3.15 Funnel Conversion by Profile
711
+
712
+ **Hook type:** `funnel-pre` | **Meta:** `meta.profile`, `meta.funnel`
713
+
714
+ **In Mixpanel:** Funnel conversion rate broken down by a user property shows
715
+ paid users converting at 1.3x the rate of free users.
716
+
717
+ ```js
718
+ // funnel-pre: paid users get boosted conversion — SCOPED to a specific funnel
719
+ if (type === "funnel-pre") {
720
+ // Always scope to the intended funnel (see principle #16)
721
+ const isTargetFunnel = meta.funnel?.sequence?.includes("certificate earned");
722
+ if (!isTargetFunnel) return;
723
+
724
+ const tier = meta.profile?.plan_tier;
725
+ if (tier === "enterprise" || tier === "business") {
726
+ record.conversionRate = Math.min(95, record.conversionRate * 1.3);
727
+ } else if (tier === "free") {
728
+ record.conversionRate *= 0.7;
729
+ }
730
+ }
731
+ ```
732
+
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
+ ---
741
+
742
+ ### Event Injection
743
+
744
+ #### 3.16 Binge-Watching Pattern
745
+
746
+ **Hook type:** `everything` | **Meta:** none (derived from events)
747
+
748
+ **In Mixpanel:** Users with 3+ consecutive completions show 1.5x more
749
+ completions per user. Pause events are suppressed for bingers.
750
+
751
+ ```js
752
+ // everything: binge-watchers get extra playback pairs, fewer pauses
753
+ if (type === "everything") {
754
+ // detect 3+ consecutive completions
755
+ let streak = 0, maxStreak = 0;
756
+ record.forEach(e => {
757
+ if (e.event === "playback completed") { streak++; maxStreak = Math.max(maxStreak, streak); }
758
+ else if (e.event !== "playback started") { streak = 0; }
759
+ });
760
+ if (maxStreak < 3) return record;
761
+
762
+ // suppress 60% of pauses
763
+ dropEventsWhere(record, e => e.event === "playback paused" && chance.bool({ likelihood: 60 }));
764
+
765
+ // clone start+complete pairs for 40% of completions
766
+ const startTemplate = record.find(e => e.event === "playback started");
767
+ record.filter(e => e.event === "playback completed").forEach(e => {
768
+ if (!chance.bool({ likelihood: 40 })) return;
769
+ const t = dayjs(e.time);
770
+ if (startTemplate) {
771
+ record.push(cloneEvent(startTemplate, {
772
+ time: t.add(chance.integer({ min: 1, max: 5 }), "minutes").toISOString(),
773
+ user_id: e.user_id,
774
+ content_type: "series",
775
+ }));
776
+ }
777
+ record.push(cloneEvent(e, {
778
+ time: t.add(chance.integer({ min: 30, max: 90 }), "minutes").toISOString(),
779
+ user_id: e.user_id,
780
+ }));
781
+ });
782
+ return record;
783
+ }
784
+ ```
785
+
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
+ ---
794
+
795
+ #### 3.17 Contextual Event Injection
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
+ ```
827
+
828
+ **Real-world analogue:** Smart feedback prompts triggered at moments of
829
+ accomplishment dramatically outperform random timed prompts.
830
+
831
+ **Adaptation:** Replace the trigger sequence and injected event. The
832
+ `findFirstSequence` atom handles the gap detection; change the max gap (in
833
+ minutes) to match your use case.
834
+
835
+ ---
836
+
837
+ ### Cross-Event State
838
+
839
+ #### 3.18 Closure-Based State (Cost Overrun -> Scale Down)
840
+
841
+ **Hook type:** `event` | **Meta:** none (module-level Map)
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"`.
846
+
847
+ ```js
848
+ // Module-level Map — persists across hook calls within a single dungeon run
849
+ const costOverrunUsers = new Map();
850
+
851
+ // event: cost report > 25% records user; next scale event forced down
852
+ if (type === "event") {
853
+ if (record.event === "cost report generated" && record.cost_change_percent > 25) {
854
+ costOverrunUsers.set(record.user_id, true);
855
+ }
856
+ if (record.event === "infrastructure scaled" && costOverrunUsers.has(record.user_id)) {
857
+ record.scale_direction = "down";
858
+ costOverrunUsers.delete(record.user_id);
859
+ }
860
+ }
861
+ ```
862
+
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
+ ---
871
+
872
+ #### 3.19 Failed Deploy Recovery
873
+
874
+ **Hook type:** `event` | **Meta:** none (module-level Map)
875
+
876
+ **In Mixpanel:** Successful deploys immediately following a failed deploy show
877
+ 1.5x longer duration, reflecting the extra verification overhead.
878
+
879
+ ```js
880
+ const failedDeployUsers = new Map();
881
+
882
+ if (type === "event" && record.event === "deployment pipeline run") {
883
+ if (record.status === "failed") {
884
+ failedDeployUsers.set(record.user_id, true);
885
+ } else if (record.status === "success" && failedDeployUsers.has(record.user_id)) {
886
+ record.duration_sec = Math.floor((record.duration_sec || 300) * 1.5);
887
+ failedDeployUsers.delete(record.user_id);
888
+ }
889
+ }
890
+ ```
891
+
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
+ ---
896
+
897
+ ### Profile Enrichment
898
+
899
+ #### 3.20 Segment-Based Profile Enrichment
900
+
901
+ **Hook type:** `user` | **Meta:** none
902
+
903
+ **In Mixpanel:** Average user property "seat_count" broken down by
904
+ "company_size" shows a monotonic ramp from startup (1-5) to enterprise
905
+ (50-500).
906
+
907
+ ```js
908
+ // user: company size determines seat count, ACV, and CSM assignment
909
+ if (type === "user") {
910
+ const size = record.company_size;
911
+ if (size === "enterprise") {
912
+ record.seat_count = chance.integer({ min: 50, max: 500 });
913
+ record.annual_contract_value = chance.integer({ min: 50000, max: 500000 });
914
+ record.customer_success_manager = true;
915
+ } else if (size === "startup") {
916
+ record.seat_count = chance.integer({ min: 1, max: 5 });
917
+ record.annual_contract_value = chance.integer({ min: 0, max: 3600 });
918
+ record.customer_success_manager = false;
919
+ }
920
+ }
921
+ ```
922
+
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
+ ---
930
+
931
+ ### Churn and Retention
932
+
933
+ #### 3.21 Hash-Based Churn Silencing
934
+
935
+ **Hook type:** `everything` | **Meta:** `meta.datasetStart`
936
+
937
+ **In Mixpanel:** Retention report shows a visible cliff at day 30, with 10-20%
938
+ of users going completely silent.
939
+
940
+ ```js
941
+ // everything: deterministic 20% of users go silent after day 30
942
+ if (type === "everything") {
943
+ const uid = record[0]?.user_id || record[0]?.device_id || "";
944
+ const idHash = String(uid).split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
945
+ if (idHash % 5 !== 0) return record; // only 20% of users
946
+
947
+ const cutoff = dayjs.unix(meta.datasetStart).add(30, "days");
948
+ dropEventsWhere(record, e => dayjs(e.time).isAfter(cutoff));
949
+ return record;
950
+ }
951
+ ```
952
+
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
+ ---
961
+
962
+ #### 3.22 Retention Magic Number (N Actions in First X Days)
963
+
964
+ **Hook type:** `everything` | **Meta:** `meta.userIsBornInDataset`
965
+
966
+ **In Mixpanel:** Retention report — users who performed 5+ "user followed"
967
+ events in their first 14 days retain ~2x better past day 36 than users who
968
+ didn't hit that threshold. Discoverable via behavioral cohort comparison.
969
+
970
+ ```js
971
+ // everything: born-in-dataset users with 5+ follows in first 14 days are retained
972
+ if (type === "everything") {
973
+ if (!meta.userIsBornInDataset) return record;
974
+ const firstEventTime = record[0]?.time;
975
+ if (!firstEventTime) return record;
976
+
977
+ const userStart = dayjs(firstEventTime);
978
+ const windowEnd = userStart.add(14, "days").toISOString();
979
+ const followBin = binUsersByEventInRange(
980
+ record, "user followed",
981
+ firstEventTime, windowEnd,
982
+ { retained: [5, Infinity], not_retained: [0, 5] }
983
+ );
984
+ if (followBin === "not_retained") {
985
+ // Silence 36 days after user's first event
986
+ const cutoff = userStart.add(36, "days");
987
+ dropEventsWhere(record, e => dayjs(e.time).isAfter(cutoff));
988
+ }
989
+ return record;
990
+ }
991
+ ```
992
+
993
+ **Real-world analogue:** Twitter/social networks have a well-documented "aha
994
+ moment" — users who follow N accounts in their first week build a feed worth
995
+ returning to. Below that threshold, the timeline is empty and users churn.
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.
1018
+
1019
+ **Adaptation:** Replace the event name, threshold, window, and cutoff. Works
1020
+ for any "early activation predicts retention" story: messages sent, items
1021
+ purchased, friends added, content created. The pattern generalizes to any
1022
+ product's "aha moment" hypothesis.
1023
+
1024
+ ---
1025
+
1026
+ #### 3.23 Deprecated Feature Replacement
1027
+
1028
+ **Hook type:** `user` + `everything` | **Meta:** `meta.profile`
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.
1033
+
1034
+ ```js
1035
+ // user: assign subscription tier based on persona/hash
1036
+ if (type === "user") {
1037
+ const hash = String(record.distinct_id || "").charCodeAt(0) % 10;
1038
+ record.subscription_tier = hash < 6 ? "free" : hash < 8 ? "monthly" : "annual";
1039
+ }
1040
+
1041
+ // everything: use tier to drive effects
1042
+ if (type === "everything") {
1043
+ const tier = meta.profile.subscription_tier;
1044
+ if (tier === "annual") {
1045
+ record.forEach(e => {
1046
+ if (e.event === "feature used") e.feature_limit = 999;
1047
+ });
1048
+ }
1049
+ }
1050
+ ```
1051
+
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
+ ---
1062
+
1063
+ #### 3.24 Post-Clone Temporal Mutation
1064
+
1065
+ **Hook type:** `everything` (must run LAST) | **Meta:** `meta.datasetStart`
1066
+
1067
+ **In Mixpanel:** A time-window effect (price spike, error surge) applies
1068
+ consistently to ALL events in the window, including events cloned by
1069
+ earlier hooks that happen to land in the window.
1070
+
1071
+ ```js
1072
+ // WRONG: temporal mutation runs BEFORE cloning — clones miss the effect
1073
+ userEvents.forEach(e => { /* temporal mutation */ });
1074
+ // ... later hooks clone events into the same window
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;
1081
+ const t = dayjs(e.time);
1082
+ if (t.isAfter(springStart) && t.isBefore(springEnd)) {
1083
+ e.offer_price = Math.floor((e.offer_price || 400000) * 2.5);
1084
+ }
1085
+ });
1086
+ ```
1087
+
1088
+ **Real-world analogue:** Seasonal price effects apply to ALL transactions
1089
+ in the window, regardless of how they were generated.
1090
+
1091
+ **Adaptation:** Any time-window value mutation that co-exists with event
1092
+ cloning hooks. Move the temporal mutation to the end of the everything
1093
+ hook, after all push/splice operations.
1094
+
1095
+ ---
1096
+
1097
+ ## 4. Phase 3 Atom Reference
1098
+
1099
+ Import from `@ak--47/dungeon-master/hook-helpers`:
1100
+
1101
+ | Atom | Module | Signature | Purpose |
1102
+ |---|---|---|---|
1103
+ | `binUsersByEventCount` | cohort | `(events, eventName, bins) -> string\|null` | Classify user into a named bin by event count |
1104
+ | `binUsersByEventInRange` | cohort | `(events, eventName, start, end, bins) -> string\|null` | Same, but only counts events in a time range |
1105
+ | `countEventsBetween` | cohort | `(events, eventA, eventB) -> number` | Count events between first A and first B |
1106
+ | `userInProfileSegment` | cohort | `(profile, key, values) -> boolean` | Check if profile property matches segment |
1107
+ | `cloneEvent` | mutate | `(template, overrides?) -> event` | Shallow clone with overrides |
1108
+ | `dropEventsWhere` | mutate | `(events, predicate) -> number` | Remove matching events in-place |
1109
+ | `scaleEventCount` | mutate | `(events, eventName, factor) -> number` | Scale count of an event type (clone or drop) |
1110
+ | `scalePropertyValue` | mutate | `(events, predicate, prop, factor) -> number` | Multiply a numeric property on matching events |
1111
+ | `shiftEventTime` | mutate | `(event, deltaMs) -> event` | Shift one event's timestamp |
1112
+ | `scaleTimingBetween` | timing | `(events, eventA, eventB, factor) -> boolean` | Scale the gap between first A and first B |
1113
+ | `scaleFunnelTTC` | timing | `(funnelEvents, factor) -> number` | Scale all offsets from the funnel's first event |
1114
+ | `findFirstSequence` | timing | `(events, names[], maxGapMin) -> events[]\|null` | Detect ordered sequence within a max gap |
1115
+ | `injectAfterEvent` | inject | `(events, source, template, gapMs, overrides?) -> event` | Splice a clone after a specific event |
1116
+ | `injectBetween` | inject | `(events, eventA, eventB, template, overrides?) -> event` | Splice a clone at the midpoint of A-B gap |
1117
+ | `injectBurst` | inject | `(events, template, count, anchor, spreadMs, overrides?) -> events[]` | Inject N clones distributed around an anchor time |
1118
+ | `isPreAuthEvent` | identity | `(event, authTime) -> boolean` | Check if event is before the user's stitch |
1119
+ | `splitByAuth` | identity | `(events, authTime) -> { preAuth, postAuth, stitch }` | Partition events by auth boundary |
1120
+
1121
+ Full JSDoc in `lib/hook-helpers/*.js`.
1122
+
1123
+ ---
1124
+
1125
+ ## 5. Phase 4 Pattern Reference
1126
+
1127
+ Import from `@ak--47/dungeon-master/hook-patterns`:
1128
+
1129
+ | Pattern | Hook Type | Signature | Mixpanel Report |
1130
+ |---|---|---|---|
1131
+ | `applyFrequencyByFrequency` | everything | `(events, profile, { cohortEvent, bins, targetEvent, multipliers })` | Frequency of A by per-user count of B |
1132
+ | `applyFunnelFrequencyBreakdown` | funnel-post | `(allEvents, profile, funnelEvents, { cohortEvent, bins, dropMultipliers })` | Funnel conversion by per-user activity bucket |
1133
+ | `applyAggregateByBin` | everything | `(events, profile, { cohortEvent, bins, event, propertyName, deltas })` | Avg property value by per-user activity bucket |
1134
+ | `applyTTCBySegment` | funnel-post | `(funnelEvents, profile, { segmentKey, factors })` | Funnel median TTC by profile segment |
1135
+ | `applyAttributedBySource` | everything | `(events, profile, { sourceEvent, sourceProperty, downstreamEvent, weights })` | Conversions by source (first/last touch) |
1136
+
1137
+ Full JSDoc in `lib/hook-patterns/*.js`. Pair with `emulateBreakdown` from
1138
+ `@ak--47/dungeon-master/verify` to assert patterns in CI.
@@ -283,7 +283,7 @@ export function validateDungeonConfig(config) {
283
283
  hasAnonIds = false,
284
284
  hasSessionIds = false,
285
285
  sessionTimeout = 30,
286
- format = "csv",
286
+ format,
287
287
  token = null,
288
288
  region = "US",
289
289
  writeToDisk = false,
@@ -592,7 +592,7 @@ export function validateDungeonConfig(config) {
592
592
  avgDevicePerUser,
593
593
  hasSessionIds,
594
594
  sessionTimeout: (typeof sessionTimeout === 'number' && sessionTimeout > 0) ? sessionTimeout : 30,
595
- format,
595
+ format: format || (typeof writeToDisk === 'string' && writeToDisk.startsWith('gs://') ? 'json' : 'csv'),
596
596
  token,
597
597
  region,
598
598
  writeToDisk,
@@ -121,7 +121,8 @@ export async function sendToMixpanel(context) {
121
121
  // Import group profiles
122
122
  if (groupProfilesData && Array.isArray(groupProfilesData) && groupProfilesData.length > 0) {
123
123
  for (const groupEntity of groupProfilesData) {
124
- if (!groupEntity || groupEntity.length === 0) continue;
124
+ if (!groupEntity) continue;
125
+ if (groupEntity.length === 0 && !isBATCH_MODE) continue;
125
126
  const groupKey = groupEntity?.groupKey;
126
127
  log(` Group Profiles (${groupKey})`);
127
128
  let groupProfilesToImport = u.deepClone(groupEntity);
@@ -141,7 +142,7 @@ export async function sendToMixpanel(context) {
141
142
  }
142
143
 
143
144
  // Import group events
144
- if (groupEventData?.length > 0) {
145
+ if (groupEventData?.length > 0 || (isBATCH_MODE && groupEventData)) {
145
146
  log(` Group Events`);
146
147
  let groupEventDataToImport = u.deepClone(groupEventData);
147
148
  const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && groupEventData.length === 0);
@@ -579,7 +579,16 @@ function streamJSON(filePath, data, options = {}) {
579
579
 
580
580
  if (filePath?.startsWith('gs://')) {
581
581
  const { uri, bucket, file } = parseGCSUri(filePath);
582
- writeStream = storage.bucket(bucket).file(file).createWriteStream({ gzip: true });
582
+ const gcsStream = storage.bucket(bucket).file(file).createWriteStream();
583
+ gcsStream.on('finish', () => resolve(filePath));
584
+ gcsStream.on('error', reject);
585
+ if (gzip) {
586
+ const gzipStream = zlib.createGzip();
587
+ gzipStream.pipe(gcsStream);
588
+ writeStream = gzipStream;
589
+ } else {
590
+ writeStream = gcsStream;
591
+ }
583
592
  }
584
593
  else {
585
594
  writeStream = fs.createWriteStream(filePath, { encoding: 'utf8' });
@@ -593,9 +602,9 @@ function streamJSON(filePath, data, options = {}) {
593
602
  writeStream.write(JSON.stringify(item) + '\n');
594
603
  });
595
604
  writeStream.end();
596
- writeStream.on('finish', () => {
597
- resolve(filePath);
598
- });
605
+ if (!filePath?.startsWith('gs://')) {
606
+ writeStream.on('finish', () => resolve(filePath));
607
+ }
599
608
  writeStream.on('error', reject);
600
609
  });
601
610
  }
@@ -607,7 +616,16 @@ function streamCSV(filePath, data, options = {}) {
607
616
 
608
617
  if (filePath?.startsWith('gs://')) {
609
618
  const { uri, bucket, file } = parseGCSUri(filePath);
610
- writeStream = storage.bucket(bucket).file(file).createWriteStream({ gzip: true });
619
+ const gcsStream = storage.bucket(bucket).file(file).createWriteStream();
620
+ gcsStream.on('finish', () => resolve(filePath));
621
+ gcsStream.on('error', reject);
622
+ if (gzip) {
623
+ const gzipStream = zlib.createGzip();
624
+ gzipStream.pipe(gcsStream);
625
+ writeStream = gzipStream;
626
+ } else {
627
+ writeStream = gcsStream;
628
+ }
611
629
  }
612
630
  else {
613
631
  writeStream = fs.createWriteStream(filePath, { encoding: 'utf8' });
@@ -635,9 +653,9 @@ function streamCSV(filePath, data, options = {}) {
635
653
  });
636
654
 
637
655
  writeStream.end();
638
- writeStream.on('finish', () => {
639
- resolve(filePath);
640
- });
656
+ if (!filePath?.startsWith('gs://')) {
657
+ writeStream.on('finish', () => resolve(filePath));
658
+ }
641
659
  writeStream.on('error', reject);
642
660
  });
643
661
  }
@@ -710,20 +728,23 @@ async function streamParquet(filePath, data, options = {}) {
710
728
  });
711
729
 
712
730
  if (filePath?.startsWith('gs://')) {
713
- // For GCS, write to buffer first, then upload
714
731
  // @ts-ignore
715
732
  const arrayBuffer = parquetWriteBuffer({ columnData });
716
733
  const { bucket, file } = parseGCSUri(filePath);
717
-
718
- const writeStream = storage.bucket(bucket).file(file).createWriteStream({
719
- gzip: gzip || true // Always gzip for GCS
720
- });
734
+ const gcsStream = storage.bucket(bucket).file(file).createWriteStream();
721
735
 
722
736
  return new Promise((resolve, reject) => {
723
- writeStream.write(Buffer.from(arrayBuffer));
724
- writeStream.end();
725
- writeStream.on('finish', () => resolve(filePath));
726
- writeStream.on('error', reject);
737
+ gcsStream.on('finish', () => resolve(filePath));
738
+ gcsStream.on('error', reject);
739
+ if (gzip) {
740
+ const gzipStream = zlib.createGzip();
741
+ gzipStream.pipe(gcsStream);
742
+ gzipStream.write(Buffer.from(arrayBuffer));
743
+ gzipStream.end();
744
+ } else {
745
+ gcsStream.write(Buffer.from(arrayBuffer));
746
+ gcsStream.end();
747
+ }
727
748
  });
728
749
  } else {
729
750
  // For local files
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ak--47/dungeon-master",
3
- "version": "1.4.3",
3
+ "version": "1.4.4",
4
4
  "description": "generate fancy datasets",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -26,7 +26,8 @@
26
26
  "scripts/",
27
27
  "package.json",
28
28
  "README.md",
29
- "CHANGELOG.md"
29
+ "CHANGELOG.md",
30
+ "HOOKS.md"
30
31
  ],
31
32
  "engines": {
32
33
  "node": ">=18.0.0"