@ak--47/dungeon-master 1.5.4 → 1.6.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 (146) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +21 -12
  2. package/.claude/skills/create-dungeon/SKILL.md +65 -5
  3. package/.claude/skills/create-project/SKILL.md +1 -1
  4. package/.claude/skills/create-project/provision.mjs +57 -10
  5. package/.claude/skills/verify-dungeon/SKILL.md +56 -25
  6. package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
  7. package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
  8. package/.claude/skills/write-hooks/SKILL.md +156 -18
  9. package/CHANGELOG.md +183 -0
  10. package/HOOKS.md +566 -112
  11. package/README.md +28 -9
  12. package/docs/guides/1.3.0-upgrade-guide.md +262 -0
  13. package/docs/guides/1.3.1-upgrade-guide.md +328 -0
  14. package/docs/guides/1.4.0-upgrade-guide.md +130 -0
  15. package/docs/guides/1.4.1-upgrade-guide.md +125 -0
  16. package/docs/guides/1.4.3-upgrade-guide.md +200 -0
  17. package/docs/guides/1.4.4-upgrade-guide.md +95 -0
  18. package/docs/guides/1.5.0-upgrade-guide.md +426 -0
  19. package/docs/guides/1.5.1-upgrade-guide.md +178 -0
  20. package/docs/guides/1.5.3-upgrade-guide.md +117 -0
  21. package/docs/guides/1.6.0-upgrade-guide.md +204 -0
  22. package/dungeons/technical/stories-verify.js +152 -0
  23. package/dungeons/vertical/README.md +154 -0
  24. package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
  25. package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
  26. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
  27. package/dungeons/vertical/community/community.js +1589 -0
  28. package/dungeons/vertical/community/community.sql +216 -0
  29. package/dungeons/vertical/community/community.verify.mjs +72 -0
  30. package/dungeons/vertical/crypto/crypto.js +1483 -0
  31. package/dungeons/vertical/crypto/crypto.sql +194 -0
  32. package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
  33. package/dungeons/vertical/dating/dating.js +1540 -0
  34. package/dungeons/vertical/dating/dating.sql +288 -0
  35. package/dungeons/vertical/dating/dating.verify.mjs +72 -0
  36. package/dungeons/vertical/devtools/devtools.js +1524 -0
  37. package/dungeons/vertical/devtools/devtools.sql +161 -0
  38. package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
  39. package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
  40. package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
  41. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
  42. package/dungeons/vertical/education/education.js +1763 -0
  43. package/dungeons/vertical/education/education.sql +243 -0
  44. package/dungeons/vertical/education/education.verify.mjs +75 -0
  45. package/dungeons/vertical/fintech/fintech.js +1410 -0
  46. package/dungeons/vertical/fintech/fintech.sql +175 -0
  47. package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
  48. package/dungeons/vertical/fitness/fitness.js +1495 -0
  49. package/dungeons/vertical/fitness/fitness.sql +206 -0
  50. package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
  51. package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
  52. package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
  53. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
  54. package/dungeons/vertical/gaming/gaming.js +1741 -0
  55. package/dungeons/vertical/gaming/gaming.sql +249 -0
  56. package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
  57. package/dungeons/vertical/healthcare/healthcare.js +1488 -0
  58. package/dungeons/vertical/healthcare/healthcare.sql +193 -0
  59. package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
  60. package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
  61. package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
  62. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
  63. package/dungeons/vertical/logistics/logistics.js +1541 -0
  64. package/dungeons/vertical/logistics/logistics.sql +205 -0
  65. package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
  66. package/dungeons/vertical/marketplace/marketplace.js +1438 -0
  67. package/dungeons/vertical/marketplace/marketplace.sql +148 -0
  68. package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
  69. package/dungeons/vertical/{media.js → media/media.js} +557 -58
  70. package/dungeons/vertical/media/media.sql +173 -0
  71. package/dungeons/vertical/media/media.verify.mjs +74 -0
  72. package/dungeons/vertical/real-estate/real-estate.js +1629 -0
  73. package/dungeons/vertical/real-estate/real-estate.sql +167 -0
  74. package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
  75. package/dungeons/vertical/sass/sass.js +1733 -0
  76. package/dungeons/vertical/sass/sass.sql +173 -0
  77. package/dungeons/vertical/sass/sass.verify.mjs +74 -0
  78. package/dungeons/vertical/social/social.js +1456 -0
  79. package/dungeons/vertical/social/social.sql +160 -0
  80. package/dungeons/vertical/social/social.verify.mjs +76 -0
  81. package/dungeons/vertical/streaming/streaming.js +677 -0
  82. package/dungeons/vertical/streaming/streaming.sql +101 -0
  83. package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
  84. package/dungeons/vertical/support-desk/support-desk.js +741 -0
  85. package/dungeons/vertical/support-desk/support-desk.sql +137 -0
  86. package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
  87. package/dungeons/vertical/travel/travel.js +1603 -0
  88. package/dungeons/vertical/travel/travel.sql +236 -0
  89. package/dungeons/vertical/travel/travel.verify.mjs +72 -0
  90. package/index.js +2 -2
  91. package/lib/core/config-validator.js +37 -2
  92. package/lib/core/context.js +4 -2
  93. package/lib/core/dungeon-loader.js +20 -15
  94. package/lib/core/dungeon-to-json.js +22 -4
  95. package/lib/core/storage.js +3 -3
  96. package/lib/generators/funnels.js +36 -9
  97. package/lib/hook-helpers/cohort.js +53 -0
  98. package/lib/hook-helpers/index.js +8 -0
  99. package/lib/hook-helpers/mutate.js +1 -1
  100. package/lib/hook-helpers/shape.js +319 -0
  101. package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
  102. package/lib/hook-patterns/attributed-by-source.js +60 -58
  103. package/lib/hook-patterns/frequency-by-frequency.js +14 -4
  104. package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
  105. package/lib/hook-patterns/index.js +1 -1
  106. package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
  107. package/lib/orchestrators/user-loop.js +41 -0
  108. package/lib/templates/story-spec.schema.json +175 -0
  109. package/lib/utils/json-evaluator.js +1 -1
  110. package/lib/utils/utils.js +5 -4
  111. package/lib/verify/coerce.js +186 -0
  112. package/lib/verify/counting.js +270 -32
  113. package/lib/verify/emulate-breakdown.js +1285 -203
  114. package/lib/verify/first-time.js +89 -0
  115. package/lib/verify/flows.js +679 -0
  116. package/lib/verify/formula.js +259 -0
  117. package/lib/verify/funnel-engine.js +697 -113
  118. package/lib/verify/index.js +32 -4
  119. package/lib/verify/sessionize.js +275 -0
  120. package/lib/verify/story-runner.js +483 -0
  121. package/lib/verify/verify-dungeon.js +59 -37
  122. package/package.json +2 -1
  123. package/scripts/extract-dungeon-schema.mjs +2 -2
  124. package/scripts/run-many.mjs +2 -2
  125. package/scripts/verify-runner.mjs +2 -2
  126. package/scripts/verify-stories.mjs +244 -0
  127. package/types.d.ts +296 -30
  128. package/dungeons/vertical/ai-platform.js +0 -818
  129. package/dungeons/vertical/community.js +0 -966
  130. package/dungeons/vertical/crypto.js +0 -895
  131. package/dungeons/vertical/dating.js +0 -830
  132. package/dungeons/vertical/devtools.js +0 -1050
  133. package/dungeons/vertical/ecommerce.js +0 -811
  134. package/dungeons/vertical/education.js +0 -964
  135. package/dungeons/vertical/fintech.js +0 -946
  136. package/dungeons/vertical/fitness.js +0 -935
  137. package/dungeons/vertical/food-delivery.js +0 -934
  138. package/dungeons/vertical/gaming.js +0 -1120
  139. package/dungeons/vertical/healthcare.js +0 -945
  140. package/dungeons/vertical/insurance-application.js +0 -1039
  141. package/dungeons/vertical/logistics.js +0 -986
  142. package/dungeons/vertical/marketplace.js +0 -1019
  143. package/dungeons/vertical/real-estate.js +0 -872
  144. package/dungeons/vertical/sass.js +0 -995
  145. package/dungeons/vertical/social.js +0 -904
  146. package/dungeons/vertical/travel.js +0 -930
package/HOOKS.md CHANGED
@@ -53,20 +53,33 @@ Mixpanel's frequency distribution / cohort-by-event-count reports count
53
53
  **distinct time periods** (default: days) on which the user fired the
54
54
  event. Two purchases on the same day = frequency **1**, not 2.
55
55
 
56
- Two related rules exist:
56
+ Two related rules exist (v1.6 names them for what they are; the old
57
+ `'calendar'` / `'rolling'` names remain as silent aliases, unknown names
58
+ now throw):
57
59
 
58
- - **Calendar bucket** (default in our verifier, `algorithm: 'calendar'`):
60
+ - **`algorithm: 'ui-bucket'`** (default in our verifier):
59
61
  `COUNT(DISTINCT date_trunc(unit, time))` in UTC. Matches what the Mixpanel
60
62
  UI shows and what [`injectOnNewDays`](lib/hook-helpers/inject.js) uses
61
63
  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
64
+ - **`algorithm: 'mixpanel-rolling'`**: the C++
65
+ `addiction_query.cpp` rule `qtz_time >= last_counted + seconds_for_unit`
66
+ (`addiction_query_update_history`, `addiction_query.cpp:363-374`) what
67
+ Mixpanel's reader actually computes. Diverges from ui-bucket at unit
68
+ boundaries (events at 23:59 + 00:01 next day = 1 rolling period, 2
69
+ calendar periods).
70
+
71
+ Use the default (`ui-bucket`) for hooks. Use `'mixpanel-rolling'` only
68
72
  when verifying behavior that explicitly depends on the C++ implementation.
69
73
 
74
+ **The actual Frequency report shape** is `frequencyHistogram(events,
75
+ { event, unit, intervalDays, profiles })` (v1.6): per report interval, a
76
+ per-user ROLLING unit counter that **resets at every interval boundary**
77
+ (`last_counted` is per-(user, interval), `addiction_query.cpp:363-374`),
78
+ bucketed into `histogram[count - 1]` with zero-count users **omitted** — no
79
+ zero bucket (`addiction_query.cpp:546-573`). Array length is
80
+ `ceil(interval / unit)` (`unit.c:108-113`). Use it when a dungeon targets
81
+ the Frequency report itself rather than a frequency-derived cohort.
82
+
70
83
  **Implication for hooks:** `scaleEventCount(record, "Buy", 3)` clones 3x as
71
84
  many Buy events at sub-second offsets — they all land on the same calendar
72
85
  day, so the user moves **zero bins** in Mixpanel's frequency report. Use
@@ -93,6 +106,26 @@ Implementation: [`evaluateFunnel`](lib/verify/funnel-engine.js).
93
106
  - TTC is `stepTimes[last] - stepTimes[0]` from the greedy match, not from a
94
107
  property value. To shift TTC, shift event timestamps (Recipe 3.14).
95
108
 
109
+ **v1.6 funnel completion** (all in [`evaluateFunnel`](lib/verify/funnel-engine.js)
110
+ / `emulateBreakdown`):
111
+
112
+ - **Session-count conversion windows** (`conversionWindowSessions`): the
113
+ window is bounded by N session boundaries after step 0, not wall-clock
114
+ (`WINDOW_TYPE_SESSIONS`, `conversion_window.cpp`). `countMode: 'sessions'`
115
+ is the API preset that rewrites count type + window together.
116
+ - **Exclusion steps** use ARB's gap-slot semantics: an exclusion event
117
+ kills only the funnel attempt whose gap it lands in — full audit against
118
+ `history.cpp` shipped in v1.6.
119
+ - **Any-order step blocks** run ARB's anchor/chunk greedy pass: unordered
120
+ steps inside a block match in any sequence between the surrounding
121
+ anchors.
122
+ - **Trends under `timeBucket`** anchor on STEP 0's timestamp — a funnel
123
+ converting across midnight counts in the bucket where it STARTED
124
+ (`funnel_query.cpp` anchors step 0 in `[start, stop)`).
125
+ - **TTC** aggregates use ARB's integer-second gap deltas: per-gap deltas
126
+ floor to whole seconds and clamp to 0 per gap; `$ttc` is integer seconds
127
+ (`history.cpp`). Sub-second TTC engineering is invisible to Mixpanel.
128
+
96
129
  ### 2.3 Aggregations are null-aware
97
130
 
98
131
  `AVG(x)` skips null/undefined/NaN/non-numeric from BOTH numerator AND
@@ -105,13 +138,26 @@ it exists. You don't need to "fill" missing values with 0 to keep the
105
138
  average sensible — Mixpanel ignores them. Conversely, if you want to dilute
106
139
  an average, removing the property is a no-op; you have to add zeros.
107
140
 
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."
141
+ **List-valued properties (v1.6):** Mixpanel's list branch aggregates each
142
+ numeric list ITEM independently — for AVG every item adds to the numerator
143
+ AND increments the denominator (`normal_query.cpp:1585-1617`), one level
144
+ deep (nested lists are skipped, not recursed). `nullAwareAvg` /
145
+ `nullAwareSum` mirror this behind `{ flatten: true }` — opt-in, because the
146
+ v1.5 default (arrays skipped whole) would otherwise silently change results
147
+ for 1-item-array data. If a dungeon carries numeric list properties and
148
+ targets an Insights SUM/AVG, verify with `flatten: true` or the numbers
149
+ won't match Mixpanel.
150
+
151
+ ### 2.4 Attribution: first/last touch are UNCAPPED; only multi-touch caps at 10
152
+
153
+ First-touch and last-touch attribution read the globally first / most
154
+ recent touch in the lookback window, no matter how many touches precede
155
+ the conversion — ARB's FIRST/LAST paths execute hard-`LIMIT 1` statements
156
+ (`whoval/read.cpp:173-192`, `:643-655`). `TOUCHPOINTS_LIMIT = 10`
157
+ (`attributed_value_reader.cpp:16`) is consumed only by the sorted-list
158
+ statement (`LIMIT ?4`, `read.cpp:595`) serving multi-touch models
159
+ (linear / participation / time-decay). A user with 50 touches before
160
+ conversion still first-touch-attributes to touch #1, not touch #41.
115
161
 
116
162
  **v1.5 generation contract:** the engine now caps UTM stamping at
117
163
  `maxTouchpointsPerUser` (default 10) per user, sampled uniform-random across
@@ -120,6 +166,24 @@ chronologically before being applied, so attribution properties land in
120
166
  time order. Hooks that bias attribution should OVERWRITE engine-stamped
121
167
  values, not stamp from scratch (those would push the user past the cap).
122
168
 
169
+ **Per-conversion attribution (v1.6):** Mixpanel runs attribution once PER
170
+ conversion event — each conversion gets its own lookback read ending at
171
+ that conversion (`attributed_value_reader_read` takes one `event_time_ms`
172
+ per read; `backend/libquery/properties_over_time/attributed_value_reader.cpp`).
173
+ The verifier's `attributedBy` matches with `perConversion: 'all'`; its
174
+ default stays `'first'` (one conversion per user — v1.5 back-compat, NOT
175
+ ARB semantics).
176
+
177
+ **⚠ Touchpoint seam (documented, by design):** the generator samples WHICH
178
+ events get UTMs uniformly across a user's lifetime (capped at
179
+ `maxTouchpointsPerUser`, default 10); the verifier and Mixpanel read
180
+ touches BEFORE each conversion. A user with more eligible events than the
181
+ generator cap can carry touches that never got stamped — and stamped
182
+ touches can postdate every conversion. Divergence is theoretical below
183
+ ~10 eligible events per user. Attribution-engineering hooks should
184
+ overwrite engine-stamped UTMs rather than relying on lifetime-uniform
185
+ sampling.
186
+
123
187
  ### 2.5 Active-day distribution is config-first
124
188
 
125
189
  Mixpanel frequency reports count distinct days (§2.1). The v1.5 engine
@@ -166,10 +230,24 @@ query from THREE reset triggers:
166
230
  Each session emits synthetic `$duration_s`, `$event_count`, `$origin_start`,
167
231
  `$origin_end` properties.
168
232
 
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.
233
+ **Namespace rule:** synthetic session events are `EVENT_TYPE_SESSION` a
234
+ separate selector namespace from regular events (`libquery/event/filter.h`).
235
+ A dungeon event literally named `"$session_start"` would NOT merge with or
236
+ shadow Mixpanel's session events; don't name events into the `$session_*`
237
+ family (see `lib/verify/sessionize.js:41-42`).
238
+
239
+ **v1.6 contract:** the verifier derives sessions at query time via
240
+ `sessionize()` (`lib/verify/sessionize.js`) — the same three triggers, all
241
+ strict `>`, plus synthetic `$session_start`/`$session_end` events carrying
242
+ the four computed props. `sessionMetrics` defaults to `source: 'derived'`;
243
+ the generator's pre-stamped `session_id` (from `assignSessionIds`) is a
244
+ generator artifact Mixpanel never sees, kept available via
245
+ `source: 'stamped'` and audited by the per-row `stampedDivergence` count.
246
+ `eventBreakdown` and `uniques` accept `countType: 'sessions'` (count once
247
+ per (user, session, segment) / distinct (user, session) pairs per bucket —
248
+ `normal_query.cpp:1318-1352`). Sessions always derive from the FULL event
249
+ stream; name filters select which events count, never which events shape
250
+ sessions (`normal_query.cpp:2271-2280`).
173
251
 
174
252
  **Verifier-only conveniences (not directly reproducible in Mixpanel UI):**
175
253
  - `evaluateFunnel({ sessionScoped: true })` partitions events per session and
@@ -202,31 +280,63 @@ bucket = floor(time_to_retention_event_s / bucket_seconds)
202
280
  A return 23h after birth lands in bucket 0; a return 25h after birth lands in
203
281
  bucket 1 — even when both fall on the UTC calendar day after the birth day.
204
282
 
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
283
+ **`birth_can_retain`** (default `false`; `retention_query.cpp:1120-1139`,
284
+ `retention_query_event_occurs_after_birth` cites COR-233): returns strictly
285
+ after the birth ms count by default (`first_event_time <
286
+ retention_event_time`). `birthCanRetain: true` relaxes the gate to `<=` but
287
+ ONLY when the return event ALSO matches the birth filter (`matches_first`). A
288
+ *distinct* return event at the exact birth ms stays excluded even with the
289
+ flag on. The gate reads the RAW birth time — calendar alignment applies only
290
+ inside the bucket delta.
291
+
292
+ **Unbounded modes** — `unbounded: 'carryForward' | 'carryBack' |
293
+ 'consecutiveForward'` (v1.6; the v1.5 `carry_forward: true` spelling remains
294
+ as an alias for `carryForward`):
295
+
296
+ - `carryForward` retained at bucket N if active in ANY bucket ≤ N; read-time
297
+ carry (`retention_query.cpp:1854-1868`). Curve is monotonically
298
+ non-decreasing.
299
+ - `carryBack` retained at bucket N if active in ANY bucket ≥ N;
300
+ reverse-iteration carry (`retention_query.cpp:274-278`). Curve is
301
+ monotonically non-increasing.
302
+ - `consecutiveForward` gated at WRITE time (`retention_query.cpp:1275-1287`):
303
+ bucket N is marked only if N−1 is already marked (except N = 0), so a user's
304
+ surviving marks are exactly their maximal consecutive streak `{0..k}` from
305
+ birth.
306
+
307
+ **`compounded: true`** (`retention_query.cpp:677-685`): `rq->second =
308
+ rq->first` — the return side IS the cohort side, so every cohort event is a
309
+ return candidate. This is Mixpanel's "DAU coming back" report family. Setting
310
+ a conflicting `returnEvent` alongside `compounded` throws.
311
+
312
+ **`bucketUnit: 'hour' | 'day' | 'week' | 'month'`** — month is **31 days
313
+ FIXED** ("maximum possible seconds in a month", `libquery/util.h:265-273`),
314
+ NOT calendar months. Week is 7 fixed days.
315
+
316
+ **`bucketAlignment: 'calendarStart'`** (`retention_query.cpp:312-332`): floors
317
+ the BIRTH time to the bucket-unit boundary before computing deltas (week
318
+ floors to ISO Monday, matching `partitionByTimeBucket`). Month alignment
319
+ floors to the calendar month start while the bucket WIDTH stays 31d fixed.
320
+
321
+ **`segmentBy` + `segmentOn: 'birth' | 'return'`**: `'birth'` (default,
322
+ SEGMENT_EVENT_FIRST) reads the segment value from the BIRTH event; `'return'`
323
+ (SEGMENT_EVENT_SECOND, `retention_query.cpp:1421-1444`) reads it from each
324
+ RETURN event — a user joins a segment's cohort only via a qualifying return
325
+ carrying that value, so births are unsegmented unless `birthCanRetain` lets
326
+ the birth itself qualify as a return.
327
+
328
+ **`cohortWindow: { from, to }`** restricts births to `[from, to]` inclusive —
329
+ Mixpanel's `[from_date, to_date]` cohort restriction. Without it the verifier
330
+ uses all users with the birth event anywhere in the dataset.
331
+
332
+ **Internal-event ignore list** (`retention_query.cpp:2546-2555`): when a side
333
+ has no explicit event selector (`'$any_event'`), `$campaign_delivery`,
334
+ `$campaign_bounced`, `$create_alias`, `$identify`, `$merge` are ignored for
335
+ that side. Explicit selectors bypass the list.
336
+
337
+ All items on the v1.5.0 "documented gaps" list closed in 1.6.0. Unrecognized
338
+ retention option keys now THROW — kills the silent-ignore class of bug where a
339
+ typo'd `compounded: true` was dropped without effect.
230
340
 
231
341
  ### 2.8 Funnel reentry: state machine resets after completion
232
342
 
@@ -244,7 +354,9 @@ HPC partitions a single funnel into one parallel sub-funnel per unique value
244
354
  of the held property on the step-0 event. A user can complete the funnel in
245
355
  one HPC bucket and drop off in another simultaneously — the buckets are
246
356
  independent. Use `evaluateFunnelHPC(events, steps, holdProperty, options)`
247
- directly (not auto-routed through `funnelFrequency` in v1.5.0).
357
+ directly, or (v1.6) pass `holdPropertyConstant: '<prop>'` to the
358
+ `funnelFrequency` emulator — it routes through the HPC engine and reports
359
+ per-held-value sub-funnel counts.
248
360
 
249
361
  ### 2.10 Funnel segment modes (FIRST_TOUCH / LAST_TOUCH / STEP)
250
362
 
@@ -302,6 +414,152 @@ Validator strict-clamps prevent the worst pathological combos (e.g.,
302
414
  with arbitrary timestamps without polluting the dataset. Verified across
303
415
  the 194-combo matrix; this guarantee survives every hook pattern.
304
416
 
417
+ ### 2.12 Event breakdown counts EVENTS, not users (v1.6)
418
+
419
+ Reference: `normal_query.cpp:1718-1776` (ACTION_TYPE_FOR_EACH). Insights
420
+ "Total" broken down by a property counts every matching EVENT into its
421
+ segment — a user firing 50 times contributes 50, not 1. Emulator:
422
+ `emulateBreakdown(events, { type: 'eventBreakdown', event, breakdownProperty,
423
+ topN })`.
424
+
425
+ - **List-valued properties explode**: an event with `tags: ['a', 'b']`
426
+ contributes one count to segment `a` AND one to segment `b`. An EMPTY list
427
+ lands in the literal segment `"$empty_list"` (`normal_query.cpp:1762`).
428
+ - **Segments are case-sensitive and type-tagged**: number `1` and string
429
+ `'1'` are DIFFERENT segments. Engineer property values with exact casing
430
+ and types.
431
+ - **Two rulebooks — segment IDENTITY vs WHERE-filter matching**
432
+ (implementation: `lib/verify/coerce.js`):
433
+
434
+ | Operation | Case | Types | ARB source |
435
+ |---|---|---|---|
436
+ | Segment bucketing (breakdown key) | **sensitive** | tagged (`1` ≠ `'1'`) | `hash_value.c:114-115` (raw XXH3), `:92-97` (type tags); ordering `cmp.c:24-32` (`arb_strcmp`) |
437
+ | Filter `==` / `!=` | **INsensitive** | string-coerced | `value.c:285` (`arb_strcasecmp`) |
438
+ | Filter `contains` | **INsensitive** | string-coerced | `eval_node.c:2914` (`arb_strcaseinstr`) |
439
+ | Filter `<` `>` `<=` `>=` on strings | **INsensitive** | string-coerced | `eval_node.c:2931` (`arb_strcasecmp`) |
440
+
441
+ So `plan == "PRO"` in a WHERE filter matches `"pro"` events, but those
442
+ events still land in a `"pro"` segment distinct from `"PRO"` when broken
443
+ down. A hook that stamps mixed-case variants passes its own filter check
444
+ and STILL splits the breakdown table.
445
+ - **Coercion to breakdown key**: `null`/`undefined` → `"undefined"`
446
+ (`arb_selector.py:889-916`), booleans → `"true"`/`"false"`, `-0` → `0`
447
+ (`hash_value.c:111`), objects JSON-stringify, lists fan out per item
448
+ BEFORE coercion.
449
+ - **`topN` defaults to 250** (`normal_query.cpp:1195-1197` — "If no
450
+ meaningful limit is supplied, set it to 250"), sorted count-desc, truncated
451
+ with NO "other" bucket. Segments below the cut disappear from the table
452
+ entirely — keep engineered breakdowns well under 250 distinct values.
453
+ - `countType: 'unique'` switches to per-segment distinct users;
454
+ `countType: 'sessions'` counts distinct derived sessions per segment.
455
+ `firstTimeOnly: true` composes (see §2.15).
456
+
457
+ ### 2.13 Uniques, XAU rolling windows, and cumulative (v1.6)
458
+
459
+ Reference: `normal_query.cpp:1300-1316` (per-interval dedup), `:1797-1830`
460
+ (rolling), `:1834-1863` (cumulative). Emulator: `{ type: 'uniques', event,
461
+ unit, rollingWindow, cumulative }`.
462
+
463
+ - **Per-interval dedup is independent**: a user active on day 3 and day 5
464
+ counts once in EACH bucket. DAU across a week can sum to 7× the true user
465
+ count.
466
+ - **XAU (`rollingWindow: W`) is a look-back window, NOT a calendar period**:
467
+ an event on day E contributes the user to buckets `[E, E+W−1]` — forward
468
+ tiling of the look-back. WAU on a Wednesday covers the 7 days ENDING that
469
+ Wednesday, not the ISO calendar week. Do not verify weekly-active stories
470
+ against calendar-week buckets.
471
+ - **`cumulative: true`**: bucket N reports distinct users seen in buckets
472
+ 0..N — each user counts once, at first appearance, and the curve is
473
+ monotonically non-decreasing.
474
+ - **Empty/missing distinct_ids are skipped** (`normal_query.cpp:2200-2208`) —
475
+ events with `''` ids never count toward uniques.
476
+ - `countType: 'sessions'` composes with `rollingWindow` but NOT with
477
+ `cumulative` (`normal_query.cpp:1318-1352` — no cumulative sessions path).
478
+
479
+ ### 2.14 Formulas evaluate in the API layer, zero-filled (v1.6)
480
+
481
+ Reference: `analytics/api/.../formula/util.py` `operate()` (:25-47),
482
+ div-by-zero (:81-86), `grammar.lark` (PEMDAS). Insights formulas (e.g.
483
+ `A/B*100`) are NOT an ARB query — the API layer fetches each letter's series
484
+ independently and combines them in Python:
485
+
486
+ - **Union-of-keys broadcast**: series are joined on the union of their date
487
+ keys; a date missing from one series contributes **0** (not null, not
488
+ skipped).
489
+ - **Division by zero yields 0** — not NaN, not infinity, not a gap. A
490
+ conversion-rate formula over a day with zero denominators shows 0%.
491
+ - **A missing/empty series is all zeros.**
492
+
493
+ Helper: `evaluateFormula(expr, { A: series, B: series })` implements the same
494
+ grammar and zero-fill rules. Engineer stories so denominators are non-zero on
495
+ days the chart must look alive.
496
+
497
+ ### 2.15 First-time-ever is a two-query rewrite (v1.6)
498
+
499
+ Reference: `analytics/.../event_selector.py:59-149`,
500
+ `arb_selector.py:1874-1936`. "First time ever doing X" runs TWO queries:
501
+ pass 1 computes each user's `first_event_time` over (event + **pre-filters**);
502
+ pass 2 selects events matching event name + `$time == first_event_time` +
503
+ **post-filters**. Consequences:
504
+
505
+ - **Pre-filters define the universe** — they decide WHICH event is "the
506
+ first". Post-filters only test the already-picked event; a first event
507
+ failing a post-filter is dropped, NOT replaced by the next candidate.
508
+ - **Filter position decides pre vs post** — in Mixpanel's UI, filters above
509
+ the first-time operator are pre, below are post.
510
+ - Helper: `filterFirstTimeEver(events, { event, preWhere, postWhere })`;
511
+ `firstTimeOnly: true` on the `eventBreakdown`/`uniques` emulators applies
512
+ the no-filter form.
513
+
514
+ ### 2.16 Lifecycle is a board template, not an engine query (v1.6)
515
+
516
+ Mixpanel has NO lifecycle query type (`query_type.cpp:8-30` enumerates every
517
+ ARB query — nothing lifecycle-shaped). "Lifecycle" is the Lifecycle Cohort
518
+ Analysis BOARD TEMPLATE: Insights uniques filtered by four behavioral cohorts
519
+ on a **Value Moment** event (`iron/common/report/dashboards/types.ts:367-390`),
520
+ in 7- and 30-day period variants. The canonical cohort definition is
521
+ `weeklyResurrectedUserBookmark`
522
+ (`iron/common/widgets/profile-summary/bookmark_templates.ts:179-320`).
523
+
524
+ Classification per period T: **new** = first-ever value moment in T;
525
+ **retained** = active in T and T−1; **resurrected** = active in T, inactive
526
+ in T−1, active in some period before; **dormant** = INACTIVE in T, active in
527
+ T−1. Emulator: `{ type: 'lifecycle', valueMomentEvent, periodDays: 7|30 }`.
528
+
529
+ - **Declared divergence (tiled vs rolling)**: the real template's cohort
530
+ windows are rolling, re-anchored as-of each charting interval; the emulator
531
+ tiles fixed periods back from the dataset's last event day — identical
532
+ classification rules, deterministic period edges.
533
+ - **Dormancy is an EqualTo-0 filter**: ONE stray value-moment event inside a
534
+ would-be dormancy window reclassifies the user (resurrected → retained).
535
+ Resurrection stories need disciplined gaps — use the lifecycle-wave atom
536
+ rather than hand-rolled probabilistic gaps.
537
+
538
+ ### 2.17 Flows (Top Paths) are next-anchor-only with per-level pruning (v1.6)
539
+
540
+ Reference: `flows_query.cpp:988-994` (next-anchor-only), `flows.cpp:680-717`
541
+ (buffers), `bookmark.py:96/:110` (pruning thresholds). Emulator: `{ type:
542
+ 'topPaths', anchors, forward, reverse, countType, output }`; helpers
543
+ `extractFlows` / `aggregateFlows`.
544
+
545
+ - **Next-anchor-only matching**: an event only advances the flow if it
546
+ matches anchor `reached + 1`. Matching a LATER anchor (or an earlier one
547
+ again) makes it a plain step. Out-of-order anchor engineering does nothing.
548
+ - **Capacity rings**: each anchor keeps `forward` steps after it (linear —
549
+ first N) and `reverse` steps before it (circular — LAST N). Defaults
550
+ forward=4, reverse=0 mirror the Flows UI view, not ARB constants.
551
+ - **`countType: 'unique'`** = one flow per user across the whole stream;
552
+ `'general'` coincides for pure flows; `'sessions'` restarts the flow
553
+ universe at every session boundary (sessions derive from the user's FULL
554
+ stream, not the filtered steps).
555
+ - **Per-level top-N pruning**: nodes below the per-level top
556
+ `cardinalityThreshold` coalesce into `$mp_uncommon_flows_events`. Defaults:
557
+ **50** for list output, **3** for sankey. ANCHOR nodes are exempt from
558
+ coalescing. A path must hold roughly ≥20-25% share at each level to survive
559
+ the sankey view — engineer dominant paths, not long tails.
560
+ - **Step spacing**: give engineered path steps ≥1s spacing — same-ms steps
561
+ sort nondeterministically and can reorder the path.
562
+
305
563
  ---
306
564
 
307
565
  ## 3. Core Principles
@@ -451,13 +709,16 @@ Validator strict-clamps prevent the worst pathological combos (e.g.,
451
709
  `injectOnNewDays` for cohort-conditional cases ("premium users get 7+
452
710
  active days, rest stay default"). See §2.5.
453
711
 
454
- 29. **Touchpoint cap awareness for attribution hooks.** The engine now caps
712
+ 29. **Touchpoint cap awareness for attribution hooks.** The engine caps
455
713
  UTM stamping at `maxTouchpointsPerUser` (default 10) per user, sampled
456
714
  across the user's lifetime. Attribution-biasing hooks should OVERWRITE
457
715
  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.
716
+ already-stamped touches), NOT stamp fresh touches from scratch.
717
+ First/last-touch attribution is UNCAPPED (§2.4) a fresh stamp
718
+ EARLIER than the engine's first stamp silently becomes the first-touch
719
+ winner, changing results out from under your derivation. Overwriting
720
+ the engine's stamps keeps the touch set fixed so your bias lands
721
+ exactly where attribution reads.
461
722
 
462
723
  ---
463
724
 
@@ -888,6 +1149,13 @@ max gap. If your sequence has multiple `step_a` events, only the first is
888
1149
  used. The greedy engine in Mixpanel picks the same first one, so behavior
889
1150
  matches.
890
1151
 
1152
+ **As a pattern (v1.6):** `applyTTCBySegmentV2` from
1153
+ `@ak--47/dungeon-master/hook-patterns` packages this recipe — segment lookup,
1154
+ `findFirstSequence`, `scaleFunnelTTC` — in one call. The older funnel-post
1155
+ `applyTTCBySegment` is deprecated: it scales one run's internal gaps, which
1156
+ only reaches the TTC report when that run is the user's first occurrence of
1157
+ the steps.
1158
+
891
1159
  **Conversion-window strict `<`:** If `step_c` lands at exactly `step_a + window`,
892
1160
  it is **excluded**. When shifting timestamps, leave at least 1ms of slack
893
1161
  under the conversion-window cap.
@@ -1041,10 +1309,14 @@ if (type === "user") {
1041
1309
  **Mixpanel report:** Retention / Insights — surviving event count broken down by hash-derived cohort
1042
1310
 
1043
1311
  ```js
1312
+ import { hashCohort } from "@ak--47/dungeon-master/hook-helpers";
1313
+
1044
1314
  if (type === "everything") {
1045
1315
  const uid = record[0]?.user_id || record[0]?.device_id || "";
1046
- const idHash = String(uid).split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
1047
- if (idHash % 5 !== 0) return record;
1316
+ // hashCohort = FNV-1a over the FULL id (v1.6). Don't hand-roll char-code
1317
+ // arithmetic id alphabets don't cover charcode space uniformly, so
1318
+ // `% N` idioms silently miss their target rate (see §5, hashFloat).
1319
+ if (!hashCohort(uid, 20)) return record; // ~20% of users churn-silenced
1048
1320
 
1049
1321
  const cutoff = dayjs.unix(meta.datasetStart).add(30, "days");
1050
1322
  dropEventsWhere(record, e => dayjs(e.time).isAfter(cutoff));
@@ -1109,9 +1381,11 @@ for distinct days, not total events.
1109
1381
  **Mixpanel report:** Varies by use — same report the deprecated feature targeted (typically Insights breakdown by `subscription_tier`)
1110
1382
 
1111
1383
  ```js
1384
+ import { hashFloat } from "@ak--47/dungeon-master/hook-helpers";
1385
+
1112
1386
  if (type === "user") {
1113
- const hash = String(record.distinct_id || "").charCodeAt(0) % 10;
1114
- record.subscription_tier = hash < 6 ? "free" : hash < 8 ? "monthly" : "annual";
1387
+ const h = hashFloat(record.distinct_id); // FNV-1a full-string → [0,1)
1388
+ record.subscription_tier = h < 0.6 ? "free" : h < 0.8 ? "monthly" : "annual";
1115
1389
  }
1116
1390
 
1117
1391
  if (type === "everything") {
@@ -1147,10 +1421,10 @@ return record;
1147
1421
 
1148
1422
  ### Attribution
1149
1423
 
1150
- #### 4.25 First-Touch Attribution Bias (Capped at 10 Touches)
1424
+ #### 4.25 First-Touch Attribution Bias
1151
1425
 
1152
- **Hook:** `everything` | **Counting:** TOUCHPOINTS_LIMIT = 10 (Section 2.4)
1153
- **Mixpanel report:** Attribution — Conversions by Source (first-touch model, last-10 lookback)
1426
+ **Hook:** `everything` | **Counting:** first-touch is uncapped (Section 2.4)
1427
+ **Mixpanel report:** Attribution — Conversions by Source (first-touch model)
1154
1428
 
1155
1429
  **In Mixpanel:** `Convert` events broken down by first-touch `Touch.source`
1156
1430
  show Google >> Facebook >> Twitter (10:5:1 weights).
@@ -1162,33 +1436,30 @@ if (type === "everything") {
1162
1436
  const conversion = record.find(e => e.event === "Convert");
1163
1437
  if (!conversion) return record;
1164
1438
  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
1439
  const sources = weighArray(["google", "facebook", "twitter"], [10, 5, 1]);
1168
1440
  const touches = record.filter(e => e.event === "Touch" && dayjs(e.time).valueOf() <= convTime);
1169
1441
  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
- }
1442
+ // First-touch reads the CHRONOLOGICALLY FIRST touch before conversion
1443
+ // no cap (whoval/read.cpp LIMIT 1). Bias exactly that one.
1444
+ const sorted = touches.slice().sort((a, b) => dayjs(a.time).valueOf() - dayjs(b.time).valueOf());
1445
+ sorted[0].source = chance.pickone(sources);
1177
1446
  return record;
1178
1447
  }
1179
1448
  ```
1180
1449
 
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.
1450
+ **Where the 10-cap DOES apply:** only multi-touch models (linear /
1451
+ participation / time-decay) consider just the last `TOUCHPOINTS_LIMIT = 10`
1452
+ touches. First/last-touch read the true first/most-recent touch however
1453
+ many exist. Sparse touches are still good practice — they keep the data
1454
+ legible — but they're realism, not a correctness requirement.
1185
1455
 
1186
1456
  **v1.5 with `hasCampaigns: true`:** when the engine has already stamped UTMs
1187
1457
  on up to `maxTouchpointsPerUser` events per user (default 10), DO NOT stamp
1188
- fresh touches in your hook — they'd push the user past the cap and fall
1189
- outside Mixpanel's last-10 window. Use [Recipe 4.26](#426-bias-engine-stamped-touches-v15)
1190
- to OVERWRITE the engine's `utm_source` on the existing stamped events
1191
- instead. See [§2.4](#24-attribution-caps-at-10-touchpoints).
1458
+ fresh touches in your hook — a fresh stamp earlier than the engine's first
1459
+ would silently become the first-touch winner. Use
1460
+ [Recipe 4.26](#426-bias-engine-stamped-touches-v15) to OVERWRITE the
1461
+ engine's `utm_source` on the existing stamped events instead. See
1462
+ [§2.4](#24-attribution-firstlast-touch-are-uncapped-only-multi-touch-caps-at-10).
1192
1463
 
1193
1464
  ---
1194
1465
 
@@ -1223,9 +1494,19 @@ if (type === "everything") {
1223
1494
  ```
1224
1495
 
1225
1496
  **Why this works under v1.5:** the engine has already capped + sampled the
1226
- touches. Stamping fresh ones from scratch in the hook would push the user
1227
- past the cap, and your stamps would land outside Mixpanel's last-10 lookback
1228
- window giving them no effect. Overwriting is correct.
1497
+ touches, so the stamped set IS the touch set attribution reads. Stamping
1498
+ fresh touches from scratch would change that set an earlier fresh stamp
1499
+ becomes the new first-touch winner (first/last-touch are uncapped, §2.4) —
1500
+ invisibly to whatever derivation your bands came from. Overwriting is
1501
+ correct.
1502
+
1503
+ **As a pattern (v1.6):** `applyAttributedBySource` from
1504
+ `@ak--47/dungeon-master/hook-patterns` packages this recipe:
1505
+ `applyAttributedBySource(record, null, { weights: { google: 10, facebook: 5,
1506
+ twitter: 1 }, model: 'firstTouch' })` overwrites the first engine-stamped
1507
+ touch with a seeded weighted pick (models: `firstTouch`, `lastTouch`,
1508
+ `both`). It never stamps unstamped events, so total touch count is
1509
+ unchanged.
1229
1510
 
1230
1511
  ---
1231
1512
 
@@ -1242,7 +1523,7 @@ a cohort-conditional `everything` hook that uses `injectOnNewDays` to push
1242
1523
  specific users above the baseline.
1243
1524
 
1244
1525
  ```js
1245
- import { injectOnNewDays } from "@ak--47/dungeon-master/hook-helpers";
1526
+ import { injectOnNewDays, hashCohort } from "@ak--47/dungeon-master/hook-helpers";
1246
1527
  import { countDistinctPeriods } from "@ak--47/dungeon-master/verify";
1247
1528
 
1248
1529
  // Config:
@@ -1250,10 +1531,9 @@ import { countDistinctPeriods } from "@ak--47/dungeon-master/verify";
1250
1531
  // events: [{ event: "open app", weight: 5 }, ...]
1251
1532
 
1252
1533
  if (type === "everything") {
1253
- // Hash-based cohort (deterministic, ~10% of users).
1534
+ // Hash-based cohort (deterministic, ~10% of users — FNV-1a full-string).
1254
1535
  const uid = record[0]?.user_id || "";
1255
- const isPowerUser = uid.charCodeAt(0) % 10 === 0;
1256
- if (!isPowerUser) return record;
1536
+ if (!hashCohort(uid, 10)) return record;
1257
1537
 
1258
1538
  const days = countDistinctPeriods(record, "open app", "day");
1259
1539
  if (days >= 10) return record;
@@ -1308,6 +1588,111 @@ your factor.
1308
1588
 
1309
1589
  ---
1310
1590
 
1591
+ #### 4.29 Lifecycle Wave — Dormancy + Resurrection (v1.6)
1592
+
1593
+ **Hook:** `everything`
1594
+ **Mixpanel report:** Lifecycle — Resurrected users spike after the dormancy window (Section 2.16)
1595
+
1596
+ **In Mixpanel:** ~15% of users go dormant for two full weeks starting a week
1597
+ after signup, then resurrect with a burst of value-moment activity.
1598
+
1599
+ ```js
1600
+ import { applyLifecycleWave, hashCohort } from "@ak--47/dungeon-master/hook-helpers";
1601
+
1602
+ if (type === "everything") {
1603
+ const uid = record[0]?.user_id || "";
1604
+ if (!hashCohort(uid, 15)) return record;
1605
+ // Sweep [birth+7d, birth+21d] clean of value moments, then clone a
1606
+ // 4-event resurrection burst 1-3h after the window.
1607
+ return applyLifecycleWave(record, uid, {
1608
+ dormantFromDay: 7,
1609
+ dormantDays: 14,
1610
+ resurrectBurst: 4,
1611
+ valueMomentEvent: "complete workout",
1612
+ });
1613
+ }
1614
+ ```
1615
+
1616
+ **Why the atom sweeps the whole window:** lifecycle "dormant" is an
1617
+ `EqualTo 0` filter per period — ONE stray value moment inside the window
1618
+ reclassifies the user and the Resurrected spike vanishes. The atom filters
1619
+ by timestamp over the array as passed, so events injected by EARLIER hook
1620
+ logic in the same pass get swept too. Size `dormantDays` to ≥ 2 lifecycle
1621
+ periods (2 weeks for weekly charts) so period tiling can't clip the gap, and
1622
+ keep the window inside the user's lifespan (clones past the dataset end are
1623
+ dropped by the future-time guard). `dropAll: true` silences the user
1624
+ completely for the window — use it when the lifecycle chart counts a broad
1625
+ event set rather than one value moment.
1626
+
1627
+ ---
1628
+
1629
+ #### 4.30 Flows Path Share — Biased Branch After an Anchor (v1.6)
1630
+
1631
+ **Hook:** `everything`
1632
+ **Mixpanel report:** Flows — top paths after the anchor event show the engineered branch (Section 2.17)
1633
+
1634
+ **In Mixpanel:** ~30% of users who view an item proceed straight down
1635
+ `add to cart → begin checkout`, making it the dominant Sankey branch.
1636
+
1637
+ ```js
1638
+ import { applyPathBias } from "@ak--47/dungeon-master/hook-helpers";
1639
+
1640
+ if (type === "everything") {
1641
+ const uid = record[0]?.user_id || "";
1642
+ return applyPathBias(record, uid, {
1643
+ anchor: "view item",
1644
+ path: ["add to cart", "begin checkout"],
1645
+ share: 0.30, // FRACTION [0,1] — the atom hashes uid itself
1646
+ gapSeconds: [2, 30],
1647
+ });
1648
+ }
1649
+ ```
1650
+
1651
+ **Why the constraints exist:** Flows' unique mode reads only the FIRST flow
1652
+ per user, so the atom anchors on the first `anchor` occurrence; gaps are
1653
+ clamped ≥1s because sub-second jitter scrambles Sankey step order; and the
1654
+ branch needs roughly ≥20-25% share to survive top-3-per-level pruning —
1655
+ don't engineer a 5% path and expect to see it. Users missing a source event
1656
+ for ANY step are skipped entirely (a partial path would pollute the share);
1657
+ verify the effective share with `extractFlows`/`aggregateFlows` rather than
1658
+ assuming `share` landed.
1659
+
1660
+ ---
1661
+
1662
+ #### 4.31 Session Shape — Deterministic Cadence (v1.6)
1663
+
1664
+ **Hook:** `everything`
1665
+ **Mixpanel report:** Insights — sessions per user per week / events per session (Section 2.13)
1666
+
1667
+ **In Mixpanel:** A "focused" cohort shows exactly ~3 tight sessions per week
1668
+ of ~5 events each, against a diffuse baseline.
1669
+
1670
+ ```js
1671
+ import { applySessionShape, hashCohort } from "@ak--47/dungeon-master/hook-helpers";
1672
+
1673
+ if (type === "everything") {
1674
+ const uid = record[0]?.user_id || "";
1675
+ if (!hashCohort(uid, 20)) return record; // cohort gating is the caller's job
1676
+ return applySessionShape(record, uid, {
1677
+ sessionsPerWeek: 3,
1678
+ eventsPerSession: 5,
1679
+ sessionMinutes: 25,
1680
+ });
1681
+ }
1682
+ ```
1683
+
1684
+ **Why it's safe in v1.6:** the engine re-derives `session_id` on the FINAL
1685
+ event set (after the `everything` hook), so wholesale timestamp rewrites no
1686
+ longer leave stale session labels. The atom keeps intra-session gaps well
1687
+ under the 30-min timeout (spacing capped at 20min + bounded jitter), keeps
1688
+ inter-session gaps well over it, and never crosses UTC midnight inside one
1689
+ engineered session (the day-boundary split would cut it). Retiming only — no
1690
+ events are added or dropped, so total counts and event mixes are untouched.
1691
+ Session count follows `min(sessionsPerWeek × weeks, ceil(N /
1692
+ eventsPerSession))`: scarce users get fewer sessions, not fabricated events.
1693
+
1694
+ ---
1695
+
1311
1696
  ## 5. Phase 3 Atom Reference
1312
1697
 
1313
1698
  Import from `@ak--47/dungeon-master/hook-helpers`:
@@ -1318,6 +1703,8 @@ Import from `@ak--47/dungeon-master/hook-helpers`:
1318
1703
  | `binUsersByEventInRange` | cohort | `(events, eventName, start, end, bins) -> string\|null` | Same, restricted to a time range |
1319
1704
  | `countEventsBetween` | cohort | `(events, eventA, eventB) -> number` | Count events between first A and first B |
1320
1705
  | `userInProfileSegment` | cohort | `(profile, key, values) -> boolean` | Profile property match |
1706
+ | **`hashFloat`** | cohort | `(id) -> number` | FNV-1a over the FULL id string → [0,1). Deterministic bucketing primitive (v1.6) — replaces `charCodeAt(0) % N` idioms, which bias cohort rates on hex-ish id alphabets |
1707
+ | **`hashCohort`** | cohort | `(id, pct) -> boolean` | True for ~`pct`% of ids (pct on a 0–100 scale). Membership nests: `pct=5` ⊂ `pct=20` |
1321
1708
  | `cloneEvent` | mutate | `(template, overrides?) -> event` | Shallow clone with overrides |
1322
1709
  | `dropEventsWhere` | mutate | `(events, predicate) -> number` | Remove matching events in-place |
1323
1710
  | `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) |
@@ -1332,6 +1719,9 @@ Import from `@ak--47/dungeon-master/hook-helpers`:
1332
1719
  | **`injectOnNewDays`** | inject | `(events, eventName, targetDays, options?) -> events[]` | Inject clones on previously empty days within active window — **the right tool for moving frequency-distribution bins** |
1333
1720
  | `isPreAuthEvent` | identity | `(event, authTime) -> boolean` | Check if before user's stitch |
1334
1721
  | `splitByAuth` | identity | `(events, authTime) -> { preAuth, postAuth, stitch }` | Partition by auth boundary |
1722
+ | **`applyLifecycleWave`** | shape | `(events, uid, { dormantFromDay, dormantDays, resurrectBurst?, valueMomentEvent, dropAll? }) -> events[]` | Clean dormancy gap + resurrection burst; sweeps the ENTIRE window by timestamp (v1.6, recipe 4.29). Returns a NEW array |
1723
+ | **`applyPathBias`** | shape | `(events, uid, { anchor, path, share, gapSeconds? }) -> events[]` | Inject a Flows path after the user's first anchor for ~`share` (fraction) of users; skips users missing any step template (v1.6, recipe 4.30) |
1724
+ | **`applySessionShape`** | shape | `(events, uid, { sessionsPerWeek, eventsPerSession, sessionMinutes }) -> events[]` | Retime the stream into deterministic session clusters — intra-gaps ≪ 30min, inter-gaps ≫ 30min, never crosses UTC midnight (v1.6, recipe 4.31) |
1335
1725
 
1336
1726
  **Inject atoms + v1.5:** the engine auto-sorts events by time after the
1337
1727
  `everything` hook (`autoSortAfterEverything: true` default — see Principle
@@ -1349,15 +1739,25 @@ Import from `@ak--47/dungeon-master/verify`:
1349
1739
 
1350
1740
  | Function | Purpose | Mixpanel Reference |
1351
1741
  |---|---|---|
1352
- | `emulateBreakdown(events, config)` | Run the table-shape emulator for one of 5 analyses | Insights / Funnels / Flows |
1742
+ | `emulateBreakdown(events, config)` | Run the table-shape emulator; `config.type` selects one of 12 analyses (`frequencyByFrequency`, `funnelFrequency`, `aggregatePerUser`, `timeToConvert`, `attributedBy`, `sessionMetrics`, `retention`, `distinctCount`, `eventBreakdown`, `uniques`, `lifecycle`, `topPaths` — `topPaths` returns an object, the rest return row arrays) | Insights / Funnels / Retention / Flows |
1353
1743
  | `verifyDungeon(dungeonConfig, assertions)` | High-level wrapper: run dungeon + run assertions | n/a |
1354
1744
  | `evaluateFunnel(events, steps, options?)` | Greedy single-pass funnel state machine | `history.cpp` |
1745
+ | `evaluateFunnelHPC(events, steps, holdProperty, options?)` | Hold-property-constant parallel sub-funnels (also routed via `funnelFrequency` + `holdPropertyConstant`) | `funnel_query.cpp` |
1355
1746
  | `timestampComesAfter(t1, t2, grace?)` | 2-second grace window check | `history.cpp` |
1356
1747
  | `withinConversionWindow(eventTime, step0Time, windowMs)` | Strict `<` window check | `conversion_window.cpp` |
1357
- | `countDistinctPeriods(events, eventName, unit?, options?)` | Distinct-period count (default day, calendar bucket; pass `{algorithm:'rolling'}` for C++ semantics) | `addiction_query.cpp` |
1358
- | `nullAwareAvg(values)` / `nullAwareSum(values)` | AVG/SUM that skip non-numeric | `normal_query.cpp` |
1748
+ | `countDistinctPeriods(events, eventName, unit?, options?)` | Distinct-period count (default `'ui-bucket'` calendar math; `{algorithm: 'mixpanel-rolling'}` for the C++ rolling counter; unknown names throw) | `addiction_query.cpp` |
1749
+ | `frequencyHistogram(events, { event, unit, intervalDays, profiles? })` | Full Frequency-report shape: per-interval histogram of users by rolling-counter value | `addiction_query.cpp` |
1750
+ | `countEvents(events, eventName?, where?)` | Total event count with optional name/property filter | `normal_query.cpp` |
1751
+ | `countDistinctValues(events, property, options?)` | Distinct property-value count | `normal_query.cpp` |
1752
+ | `nullAwareAvg(values, options?)` / `nullAwareSum(values, options?)` | AVG/SUM that skip non-numeric; `{ flatten: true }` explodes list values one level | `normal_query.cpp` |
1359
1753
  | `nullAwareExtreme(values, mode)` | MIN/MAX that skip non-numeric | `normal_query.cpp` |
1360
1754
  | `binByDistinctPeriods(events, eventName, bins, unit?)` | Distinct-period cohort assignment | `addiction_query.cpp` |
1755
+ | `partitionByTimeBucket(events, unit, range?)` | Split a stream into day/week/month buckets (week = ISO Monday) | `normal_query.cpp` |
1756
+ | `evaluateFormula(expr, series)` | API-layer formula math: PEMDAS, zero-fill broadcast, div-by-zero → 0 | `formula/util.py` |
1757
+ | `filterFirstTimeEver(events, { event, preWhere?, postWhere? })` | Two-query first-time-ever rewrite (§2.15) | `event_selector.py` |
1758
+ | `sessionize(events, options?)` / `sessionOrdinals(events, options?)` | Query-time session derivation (30-min gap, 24h max) | `session_query.cpp` |
1759
+ | `extractFlows(events, options)` / `aggregateFlows(flows, options?)` | Per-user flow extraction + Top Paths tree (`UNCOMMON_FLOWS_EVENT` = coalesced-node label) | `flows_query.cpp` |
1760
+ | `buildIdentityMap(profiles)` / `resolveUserId(event, identityMap?)` | device_id → canonical-id identity merge | n/a |
1361
1761
  | `deriveExpectedSchema(config)` / `validateSchema(events, schema)` | Schema integrity checks | n/a |
1362
1762
 
1363
1763
  Full JSDoc in [`lib/verify/*.js`](lib/verify/).
@@ -1370,19 +1770,21 @@ Import from `@ak--47/dungeon-master/hook-patterns`:
1370
1770
 
1371
1771
  | Pattern | Hook Type | Signature | Mixpanel Report |
1372
1772
  |---|---|---|---|
1373
- | `applyFrequencyByFrequency` | everything | `(events, profile, { cohortEvent, bins, targetEvent, multipliers })` | Frequency of A by per-user count of B |
1374
- | `applyFunnelFrequencyBreakdown` | funnel-post | `(allEvents, profile, funnelEvents, { cohortEvent, bins, dropMultipliers })` | Funnel conversion by per-user activity bucket |
1375
- | `applyAggregateByBin` | everything | `(events, profile, { cohortEvent, bins, event, propertyName, deltas })` | Avg property value by per-user activity bucket |
1376
- | `applyTTCBySegment` | funnel-post | `(funnelEvents, profile, { segmentKey, factors })` | Funnel median TTC by profile segment |
1377
- | `applyAttributedBySource` | everything | `(events, profile, { sourceEvent, sourceProperty, downstreamEvent, weights })` | Conversions by source (first/last touch) |
1378
-
1379
- > **Caveat (eval follow-up).** The three `*ByBin` / `*Frequency*` patterns
1380
- > currently use `binUsersByEventCount` (total events) for cohort assignment.
1381
- > The verification emulator now bins by **distinct days**, so cohort axes
1382
- > can mismatch high-event-count users may not be high-distinct-day users.
1383
- > When verifying these patterns, expect signal dilution until the patterns
1384
- > switch to `binByDistinctPeriods`. For new dungeons targeting frequency
1385
- > reports, use the recipes in Section 4.5–4.7 instead of these patterns.
1773
+ | `applyFrequencyByFrequency` | everything | `(events, profile, { cohortEvent, bins, targetEvent, multipliers, binBy? })` | Frequency of A by per-user frequency of B |
1774
+ | `applyFunnelFrequencyBreakdown` | funnel-post | `(allEvents, profile, funnelEvents, { cohortEvent, bins, dropMultipliers, binBy? })` | Funnel conversion by per-user activity bucket |
1775
+ | `applyAggregateByBin` | everything | `(events, profile, { cohortEvent, bins, event, propertyName, deltas, binBy? })` | Avg property value by per-user activity bucket |
1776
+ | `applyTTCBySegmentV2` | everything | `(events, profile, { segmentKey, factors, steps, maxGapMinutes? })` | Funnel TTC by profile segment (greedy first sequence — recipe 4.14 as code, v1.6) |
1777
+ | `applyTTCBySegment` | funnel-post | `(funnelEvents, profile, { segmentKey, factors })` | **Deprecated (v1.6)** scales one run's gaps; only reaches Mixpanel TTC for `isFirstFunnel` runs. Use V2. |
1778
+ | `applyAttributedBySource` | everything | `(events, profile, { weights, property?, model? })` | Conversions by source — overwrites engine-stamped touches (recipe 4.26 as code, rewritten v1.6) |
1779
+
1780
+ > **Bin axis (v1.6).** The three `*ByBin` / `*Frequency*` patterns bin by
1781
+ > **distinct calendar days** of `cohortEvent` by default
1782
+ > (`binBy: 'distinctDays'`, via `binByDistinctPeriods`) the same axis the
1783
+ > verification emulator and Mixpanel's frequency reports use, so pattern
1784
+ > cohorts and report buckets align. Pass `binBy: 'events'` to restore the
1785
+ > pre-1.6 total-event-count axis (needed for
1786
+ > `applyFunnelFrequencyBreakdown`'s funnelEvents fallback, where a single
1787
+ > funnel run rarely spans more than one day).
1386
1788
 
1387
1789
  Full JSDoc in [`lib/hook-patterns/*.js`](lib/hook-patterns/). Pair with
1388
1790
  `emulateBreakdown` from `@ak--47/dungeon-master/verify` to assert patterns
@@ -1408,7 +1810,7 @@ const rows = emulateBreakdown(events, {
1408
1810
  returnEvent: 'Login',
1409
1811
  dayBuckets: [0, 1, 7, 14, 30], // bucket 0 = within 24h of birth
1410
1812
  segmentBy: 'plan', // optional — segment by birth event prop
1411
- carry_forward: false, // optional — monotonically non-decreasing
1813
+ unbounded: 'none', // optional — 'carryForward' = monotonically non-decreasing
1412
1814
  birthCanRetain: false, // optional — count returns AT birth ms (default false)
1413
1815
  profiles, // optional — auto-builds identity map
1414
1816
  });
@@ -1424,15 +1826,56 @@ const rows = emulateBreakdown(events, {
1424
1826
  ```js
1425
1827
  const rows = emulateBreakdown(events, {
1426
1828
  type: 'sessionMetrics',
1427
- event: 'Page View', // optional — only sessions containing this event
1829
+ event: 'Page View', // optional — only sessions containing this event
1428
1830
  metrics: ['count', 'duration', 'eventsPerSession'],
1831
+ source: 'derived', // default — sessions re-derived from timestamps
1832
+ sessionTimeoutMs: 30 * 60_000, // optional — gap trigger (strict >)
1833
+ });
1834
+ // → [{ metric, avg, median, p90, total_sessions, source, stampedDivergence }, ...]
1835
+ ```
1836
+
1837
+ `source: 'derived'` (default since v1.6.0) re-derives sessions from raw
1838
+ timestamps the way `session_query.cpp` does — pre-stamped `session_id`s are
1839
+ ignored. `source: 'stamped'` keeps the v1.5 group-by-`(user, session_id)`
1840
+ behavior. Either way, when events carry stamps, every row reports
1841
+ `stampedDivergence`: the number of consecutive stamped-event pairs whose
1842
+ stamped session boundary disagrees with the derived one (0 means the
1843
+ generator's stamping matches what Mixpanel will compute).
1844
+
1845
+ **Session duration / depth metrics need no special type.** `sessionize()`
1846
+ returns `syntheticEvents` — `$session_start`/`$session_end` pairs carrying
1847
+ `$duration_s` and `$event_count` as plain numeric properties (plus
1848
+ `$origin_start`/`$origin_end` and the copy props). Aggregate them with the
1849
+ existing types, exactly like Mixpanel aggregates session properties:
1850
+
1851
+ ```js
1852
+ import { sessionize, emulateBreakdown } from '@ak--47/dungeon-master/verify';
1853
+
1854
+ const { syntheticEvents } = sessionize(events);
1855
+ // Avg session duration per user, cohorted by # active session days
1856
+ // (Insights: AGGREGATE $duration_s on $session_end):
1857
+ emulateBreakdown(syntheticEvents, {
1858
+ type: 'aggregatePerUser', event: '$session_end', property: '$duration_s',
1859
+ agg: 'avg', breakdownByFrequencyOf: '$session_start',
1860
+ });
1861
+ // Session-depth distribution (Insights: $session_end segmented by $event_count):
1862
+ emulateBreakdown(syntheticEvents, {
1863
+ type: 'eventBreakdown', event: '$session_end', breakdownProperty: '$event_count',
1429
1864
  });
1430
- // → [{ metric, avg, median, p90, total_sessions }, ...]
1431
1865
  ```
1432
1866
 
1433
- Trusts the generator's pre-stamped `session_id`. If you need to verify
1434
- session-scoped funnels (steps must land in same session), pass
1435
- `sessionScoped: true` to `evaluateFunnel`.
1867
+ For plain duration averages without a cohort axis, `sessionMetrics`'s
1868
+ `duration` row (avg/median/p90) is the direct path.
1869
+
1870
+ Synthetic session events live OUTSIDE the regular event-name namespace
1871
+ (`libquery/event/filter.h`) — name filters and "all events" over raw events
1872
+ never match them, which is why `sessionize` returns them as a separate array
1873
+ you feed in explicitly.
1874
+
1875
+ If you need to verify session-scoped funnels (steps must land in same
1876
+ session), pass `sessionScoped: true` to `evaluateFunnel`, or use the
1877
+ Mixpanel-faithful session count window
1878
+ (`conversionWindow: { unit: 'sessions', n: 1 }`).
1436
1879
 
1437
1880
  ### 8.3 Funnel reentry (counting repeat completions)
1438
1881
 
@@ -1493,10 +1936,10 @@ console.log(map.get('pro').completed); // pro user completed
1493
1936
  console.log(map.get('free').completed); // free user did not (independent)
1494
1937
  ```
1495
1938
 
1496
- HPC is **not auto-routed** through `funnelFrequency` in v1.5.0 — the report
1497
- shape differs (one row per `step × hpc_value` instead of `step × cohort`).
1498
- Call `evaluateFunnelHPC` directly inside your `verifyDungeon` check's
1499
- `assert` callback when you need it.
1939
+ Since v1.6, passing `holdPropertyConstant: 'plan'` to the `funnelFrequency`
1940
+ emulator routes through the HPC engine and reports per-held-value sub-funnel
1941
+ rows. `evaluateFunnelHPC` remains available directly for bespoke assertions
1942
+ inside a `verifyDungeon` check's `assert` callback.
1500
1943
 
1501
1944
  ### 8.6 Step-level filter patterns
1502
1945
 
@@ -1587,10 +2030,11 @@ emulateBreakdown(events, { type: 'retention', cohortEvent: 'Sign Up',
1587
2030
 
1588
2031
  20-dungeon eval surfaced patterns where naive verification inverts. Apply
1589
2032
  these recipes when writing per-dungeon verify scripts under
1590
- `verification/verticals/`.
2033
+ `dungeons/vertical/<name>/`.
1591
2034
 
1592
- **Proof in repo:** `verification/verticals/<dungeon>.{verify.mjs,sql}`
1593
- 20 dungeons, 107 documented hooks, 107 verification checks. Each
2035
+ **Proof in repo:** `dungeons/vertical/<dungeon>/<dungeon>.{verify.mjs,sql}` plus a
2036
+ `stories` export per dungeon — 22 dungeons, 212 machine-checkable stories,
2037
+ evaluated mechanically by `scripts/verify-stories.mjs`. Each
1594
2038
  `<dungeon>.verify.mjs` is a CI-runnable assertion that the dungeon's
1595
2039
  engineered story patterns appear in full-fidelity generated data.
1596
2040
 
@@ -1695,22 +2139,32 @@ Cleanest pattern for hidden cohorts. Deterministic, no schema mutation, no
1695
2139
  flag stamping. Produces textbook long-tail signals:
1696
2140
 
1697
2141
  ```js
2142
+ import { hashCohort } from "@ak--47/dungeon-master/hook-helpers";
2143
+
1698
2144
  // In hook (everything):
1699
2145
  for (const e of events) {
1700
- const isWhale = e.user_id && e.user_id.charCodeAt(0) % 50 === 0; // 2%
2146
+ const isWhale = e.user_id && hashCohort(e.user_id, 2); // ~2% of users
1701
2147
  if (isWhale && e.event === 'swap') e.trade_amount_usd *= 50;
1702
2148
  }
1703
2149
 
1704
- // In verify:
2150
+ // In verify (same primitive — hook and verifier CANNOT disagree on membership):
1705
2151
  const whaleAmts = [], rest = [];
1706
2152
  for (const [uid, evs] of byUser) {
1707
- const isWhale = uid.charCodeAt(0) % 50 === 0;
2153
+ const isWhale = hashCohort(uid, 2);
1708
2154
  const amts = evs.filter(e => e.event === 'swap').map(e => e.trade_amount_usd);
1709
2155
  (isWhale ? whaleAmts : rest).push(...amts);
1710
2156
  }
1711
2157
  check('whale 5x+ trade', avg(whaleAmts) / avg(rest) >= 5);
1712
2158
  ```
1713
2159
 
2160
+ **Why not `uid.charCodeAt(0) % 50 === 0`** (the pre-1.6 idiom): first-char
2161
+ arithmetic depends on the id alphabet covering charcode space uniformly — it
2162
+ doesn't. GUID first chars are hex (0-9, a-f), which reach only ~2 of 50
2163
+ residues under `% 50`, so the "2% cohort" actually lands anywhere from 0% to
2164
+ ~12% depending on id format. `hashCohort` runs FNV-1a over the FULL string;
2165
+ on engine-stamped GUIDs the share tracks the target within a few tenths of a
2166
+ point.
2167
+
1714
2168
  ### 9.7 Hook ordering inside `everything`
1715
2169
 
1716
2170
  When one hook injects events that another hook then mutates, ordering