@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.
Files changed (67) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +158 -0
  2. package/.claude/skills/create-dungeon/SKILL.md +464 -0
  3. package/.claude/skills/verify-dungeon/SKILL.md +157 -0
  4. package/.claude/skills/verify-dungeon/references/counting-semantics.md +161 -0
  5. package/.claude/skills/verify-dungeon/references/report-format.md +216 -0
  6. package/.claude/skills/verify-dungeon/references/sql-recipes.md +857 -0
  7. package/.claude/skills/write-hooks/SKILL.md +468 -0
  8. package/CHANGELOG.md +147 -0
  9. package/HOOKS.md +1243 -597
  10. package/README.md +140 -5
  11. package/dungeons/technical/ad-spend.js +1 -1
  12. package/dungeons/technical/anonymous-users.js +1 -1
  13. package/dungeons/technical/array-of-object-lookup.js +1 -1
  14. package/dungeons/technical/datagen-v15-verify.js +74 -0
  15. package/dungeons/technical/experiments.js +1 -1
  16. package/dungeons/technical/foobar.js +1 -1
  17. package/dungeons/technical/group-analytics.js +1 -1
  18. package/dungeons/technical/mirror-strategies.js +1 -1
  19. package/dungeons/technical/nested-objects.js +1 -1
  20. package/dungeons/technical/retention-cadence.js +1 -1
  21. package/dungeons/technical/sanity.js +1 -1
  22. package/dungeons/technical/scale-test.js +1 -1
  23. package/dungeons/technical/scd.js +1 -1
  24. package/dungeons/technical/simple.js +1 -1
  25. package/dungeons/technical/simplest.js +74 -20
  26. package/dungeons/technical/text-generation.js +1 -1
  27. package/dungeons/vertical/ai-platform.js +4 -0
  28. package/dungeons/vertical/community.js +9 -3
  29. package/dungeons/vertical/crypto.js +5 -0
  30. package/dungeons/vertical/dating.js +23 -10
  31. package/dungeons/vertical/devtools.js +10 -0
  32. package/dungeons/vertical/ecommerce.js +6 -0
  33. package/dungeons/vertical/education.js +11 -0
  34. package/dungeons/vertical/fintech.js +13 -0
  35. package/dungeons/vertical/fitness.js +10 -0
  36. package/dungeons/vertical/food-delivery.js +9 -0
  37. package/dungeons/vertical/gaming.js +10 -0
  38. package/dungeons/vertical/healthcare.js +5 -0
  39. package/dungeons/vertical/insurance-application.js +10 -0
  40. package/dungeons/vertical/logistics.js +8 -1
  41. package/dungeons/vertical/marketplace.js +7 -0
  42. package/dungeons/vertical/media.js +8 -0
  43. package/dungeons/vertical/real-estate.js +7 -1
  44. package/dungeons/vertical/sass.js +12 -0
  45. package/dungeons/vertical/social.js +9 -0
  46. package/dungeons/vertical/travel.js +5 -0
  47. package/index.js +45 -7
  48. package/lib/core/config-validator.js +270 -7
  49. package/lib/core/context.js +58 -0
  50. package/lib/core/dungeon-loader.js +2 -5
  51. package/lib/generators/events.js +12 -13
  52. package/lib/generators/funnels.js +72 -1
  53. package/lib/hook-helpers/index.js +1 -0
  54. package/lib/hook-helpers/inject.js +95 -0
  55. package/lib/orchestrators/mixpanel-sender.js +27 -1
  56. package/lib/orchestrators/user-loop.js +488 -29
  57. package/lib/templates/macro-presets.js +39 -9
  58. package/lib/utils/utils.js +16 -79
  59. package/lib/verify/counting.js +320 -0
  60. package/lib/verify/emulate-breakdown.js +512 -108
  61. package/lib/verify/funnel-engine.js +539 -0
  62. package/lib/verify/identity.js +78 -0
  63. package/lib/verify/index.js +19 -0
  64. package/lib/verify/verify-dungeon.js +58 -0
  65. package/package.json +4 -2
  66. package/types.d.ts +314 -4
  67. 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 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.
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. Returning a different object replaces the event entirely.
37
- - `everything`: return the (possibly modified) array. Returning a filtered array removes events.
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. Core Principles
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 (churn/silencing hooks work there consistently).
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 (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).
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 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.
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 (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.
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
- 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
- ```
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
- 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;
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
- 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).
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
- ## 3. Recipe Catalog
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
- #### 3.1 Conversion Change Over Time
471
+ #### 4.1 Conversion Change Over Time
193
472
 
194
- **Hook type:** `funnel-pre` | **Meta:** `meta.firstEventTime`
473
+ **Hook:** `funnel-pre` | **Meta:** `meta.firstEventTime`
474
+ **Mixpanel report:** Funnels — conversion rate over time
195
475
 
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.
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
- **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.
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
- #### 3.2 Feature Launch Inflection
494
+ #### 4.2 Feature Launch Inflection
218
495
 
219
- **Hook type:** `everything` | **Meta:** `meta.profile`, `meta.datasetStart`
496
+ **Hook:** `everything` | **Meta:** `meta.datasetStart`
497
+ **Mixpanel report:** Insights — Total events broken down by `Feedback Source`
220
498
 
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.
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
- #### 3.3 End-of-Quarter Spike
527
+ #### 4.3 End-of-Quarter Spike
259
528
 
260
- **Hook type:** `event` | **Meta:** `meta.datasetStart`
529
+ **Hook:** `event` | **Meta:** `meta.datasetStart`
530
+ **Mixpanel report:** Insights — Total events filtered by `event_type`
261
531
 
262
- **In Mixpanel:** Line chart of "billing event" filtered to `event_type =
263
- "plan_upgraded"` shows a 4x spike in the final 10 days.
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
- #### 3.4 Degradation and Recovery
546
+ #### 4.4 Degradation and Recovery
284
547
 
285
- **Hook type:** `everything` | **Meta:** `meta.datasetStart`, `meta.datasetEnd`
548
+ **Hook:** `everything` | **Meta:** `meta.datasetEnd`, `meta.profile`
549
+ **Mixpanel report:** Insights — Total events broken down by `Region`
286
550
 
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.
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
- #### 3.5 Inverted-U Sweet Spot
591
+ #### 4.5 Inverted-U Sweet Spot (Frequency Report)
333
592
 
334
- **Hook type:** `everything` | **Meta:** `meta.profile`
593
+ **Hook:** `everything` | **Counting:** distinct days (Section 2.1)
594
+ **Mixpanel report:** Insights — Frequency Distribution (distinct-day count)
335
595
 
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.
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
- // everything: inverted-U conversion by onboarding question count
600
+ import { binByDistinctPeriods } from "@ak--47/dungeon-master/verify";
601
+
342
602
  if (type === "everything") {
343
603
  const BINS = {
344
- low: [0, 3], // 0-2 questions: not enough context
345
- sweet: [3, 4], // exactly 3: peak conversion
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 = binUsersByEventCount(record, "Onboarding Question", BINS);
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
- **Real-world analogue:** Signup flow friction optimization -- too few screens
363
- means users don't understand the value prop; too many means they abandon.
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:** Change the event name, bin boundaries, and drop probabilities.
366
- Add a profile-based penalty (e.g., email auth +20% drop).
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
- #### 3.6 Frequency x Engagement Sweet Spot
632
+ #### 4.6 Frequency × Engagement Sweet Spot
371
633
 
372
- **Hook type:** `everything` | **Meta:** none
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 "view item" events show 25% higher cart
375
- amounts. Users with 9+ are window-shoppers whose checkouts drop 30%.
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
- // everything: view-item magic number for cart value
642
+ import { countDistinctPeriods } from "@ak--47/dungeon-master/verify";
643
+
379
644
  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
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 (viewCount >= 9) {
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
- #### 3.7 CI Build Magic Number
657
+ #### 4.7 Move Users Between Frequency Bins (Active Day Injection)
401
658
 
402
- **Hook type:** `everything` | **Meta:** none
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:** 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).
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
- // everything: build count magic number
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
- 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
- }
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
- **Real-world analogue:** Healthy CI cadence drives reliable deploys; runaway
423
- builds signal a flaky pipeline that scares teams off shipping.
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
- **Adaptation:** Change the count event, target event, bin boundaries, and
426
- scale factors. Use `applyFrequencyByFrequency` (Phase 4) for a declarative
427
- version.
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
- #### 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.
698
+ #### 4.8 A/B/C Test with Variant-Specific Effects
440
699
 
441
- The experiment is declared on the funnel config -- the engine handles variant
442
- assignment, `$experiment_started` events, and conversion modifiers:
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
- 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
- }
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
- #### 3.9 Subscription Tier Stacking
737
+ #### 4.9 Subscription Tier Stacking
489
738
 
490
- **Hook type:** `everything` | **Meta:** `meta.profile`
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:** "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.
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
- **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.
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
- #### 3.10 Integration Users Succeed
517
-
518
- **Hook type:** `everything` | **Meta:** none (derived from events)
762
+ #### 4.10 Integration Users Succeed (Compound Cohort)
519
763
 
520
- **In Mixpanel:** Cohort of users who configured both Slack AND PagerDuty
521
- integrations shows 60% lower response time and 50% faster resolution.
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
- 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
- });
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
- #### 3.11 Power User Behavioral Amplification
550
-
551
- **Hook type:** `everything` | **Meta:** none (derived from events)
785
+ #### 4.11 Power User Behavioral Amplification
552
786
 
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.
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
- 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
- }
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
- **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.
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
- #### 3.12 Night Deploy Failure Spike
591
-
592
- **Hook type:** `everything` | **Meta:** none
821
+ #### 4.12 Night Deploy Failure Spike
593
822
 
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.
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
- 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
- }
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
- #### 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.
841
+ #### 4.13 Regional Error Injection
626
842
 
627
- See [Recipe 3.4](#34-degradation-and-recovery) for the full implementation.
628
- The key addition is a profile-segment gate:
843
+ **Mixpanel report:** Insights Total errors broken down by `Region`
629
844
 
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.
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
- #### 3.14 TTC by User Segment (Timestamp Shifting)
852
+ #### 4.14 TTC by User Segment (Timestamp Shifting)
643
853
 
644
- **Hook type:** `everything` | **Meta:** `meta.profile`
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. This is the ONLY approach that affects
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
- // everything: shift timestamps in funnel sequences by segment
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 : 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
- }
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
- **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:
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
- // 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);
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
- This is supplementary. The timestamp shift (3.14) is the primary mechanism.
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
- #### 3.15 Funnel Conversion by Profile
895
+ ---
711
896
 
712
- **Hook type:** `funnel-pre` | **Meta:** `meta.profile`, `meta.funnel`
897
+ #### 4.15 Funnel Conversion by Profile
713
898
 
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.
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
- #### 3.16 Binge-Watching Pattern
920
+ #### 4.16 Binge-Watching Pattern
745
921
 
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.
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
- record.forEach(e => {
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").forEach(e => {
768
- if (!chance.bool({ likelihood: 40 })) return;
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
- #### 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
- ```
958
+ #### 4.17 Contextual Event Injection
827
959
 
828
- **Real-world analogue:** Smart feedback prompts triggered at moments of
829
- accomplishment dramatically outperform random timed prompts.
960
+ **Hook:** `everything`
961
+ **Mixpanel report:** Flows preceding-path analysis for `Submit Feedback`
830
962
 
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.
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
- #### 3.18 Closure-Based State (Cost Overrun -> Scale Down)
971
+ #### 4.18 Closure-Based State (Cost Overrun Scale Down)
840
972
 
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"`.
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
- #### 3.19 Failed Deploy Recovery
992
+ #### 4.19 Failed Deploy Recovery
873
993
 
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.
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
- #### 3.20 Segment-Based Profile Enrichment
900
-
901
- **Hook type:** `user` | **Meta:** none
1014
+ #### 4.20 Segment-Based Profile Enrichment
902
1015
 
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).
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
- #### 3.21 Hash-Based Churn Silencing
1038
+ #### 4.21 Hash-Based Churn Silencing
934
1039
 
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.
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; // only 20% of users
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
- #### 3.22 Retention Magic Number (N Actions in First X Days)
1057
+ #### 4.22 Retention Magic Number (N Distinct Days in First X Days)
963
1058
 
964
- **Hook type:** `everything` | **Meta:** `meta.userIsBornInDataset`
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:** 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.
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
- // everything: born-in-dataset users with 5+ follows in first 14 days are retained
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 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
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
- **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.
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
- **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.
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
- #### 3.23 Deprecated Feature Replacement
1106
+ #### 4.23 Deprecated Feature Replacement
1027
1107
 
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.
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
- record.forEach(e => {
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
- #### 3.24 Post-Clone Temporal Mutation
1064
-
1065
- **Hook type:** `everything` (must run LAST) | **Meta:** `meta.datasetStart`
1129
+ #### 4.24 Post-Clone Temporal Mutation
1066
1130
 
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.
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
- // 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;
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
- **Real-world analogue:** Seasonal price effects apply to ALL transactions
1089
- in the window, regardless of how they were generated.
1146
+ ---
1147
+
1148
+ ### Attribution
1149
+
1150
+ #### 4.25 First-Touch Attribution Bias (Capped at 10 Touches)
1090
1151
 
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.
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
- ## 4. Phase 3 Atom Reference
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 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 |
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` | Check if profile property matches segment |
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 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`.
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 AB 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
- ## 5. Phase 4 Pattern Reference
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
- Full JSDoc in `lib/hook-patterns/*.js`. Pair with `emulateBreakdown` from
1138
- `@ak--47/dungeon-master/verify` to assert patterns in CI.
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
+