@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
@@ -0,0 +1,426 @@
1
+ # 1.5.0 Upgrade Guide
2
+
3
+ **TL;DR: No breaking API changes.** Existing dungeons run without modification. Six behavioral changes affect generated output — all produce more correct data for Mixpanel analysis.
4
+
5
+ ## What Changed
6
+
7
+ ### 1. `bunchIntoSessions` Removed
8
+
9
+ **What**: The function that rewrote all event timestamps into synthetic session clusters is deleted. Events now use their natural TimeSoup-generated timestamps. `assignSessionIds` (which was already running) provides session IDs based on 30-minute gaps.
10
+
11
+ **Why**: `bunchIntoSessions` overwrote the exact timestamps that funnels depended on. A funnel generating events in sequence (step A → step B → step C) would have its ordering scrambled when timestamps were reassigned into session clusters. This made greedy single-pass funnel evaluation (Mixpanel's actual algorithm) unreliable.
12
+
13
+ **Impact on your data**:
14
+ - Event timestamps are more spread out (follow TimeSoup's Gaussian distribution instead of tight session clusters)
15
+ - Funnel step ordering is now temporally correct — step A always occurs before step B
16
+ - Session IDs still exist when `hasSessionIds: true`; they're computed from natural 30-minute gaps instead of artificial clusters
17
+ - If your hooks or downstream analysis relied on events being tightly clustered in ~30min sessions, you'll see looser temporal distribution
18
+
19
+ **Action needed**: None. The new behavior is strictly more correct for Mixpanel analysis.
20
+
21
+ ### 2. `isStrictEvent` Auto-Promote
22
+
23
+ **What**: The config validator now detects events that appear in both `events[]` and any `funnels[].sequence` and auto-promotes them to `isStrictEvent: true`. This prevents standalone instances of funnel-step events from confounding greedy funnel counting.
24
+
25
+ **Why**: Mixpanel's funnel engine uses greedy single-pass matching. If a standalone "sign up" event appears between two funnel runs, the greedy matcher counts it as a funnel conversion — producing phantom conversions that don't correspond to actual user journeys.
26
+
27
+ **Impact on your data**:
28
+ - Events that are funnel steps will only appear inside funnel sequences, not as standalone events
29
+ - You'll see auto-promote warnings in stderr (these are informational)
30
+ - Total event count may decrease if you had standalone instances of funnel-step events
31
+ - If your hooks count standalone "sign up" events outside funnel context, those events won't exist anymore
32
+
33
+ **Action needed**: If you need standalone instances of a funnel-step event, add `isStrictEvent: false` on that event's config:
34
+
35
+ ```javascript
36
+ events: [
37
+ { event: 'sign up', weight: 3, isStrictEvent: false }, // keep standalone instances
38
+ { event: 'purchase', weight: 5 }, // auto-promoted if in a funnel
39
+ ]
40
+ ```
41
+
42
+ ### 3. Touchpoint Cap (10 per user)
43
+
44
+ **What**: UTM stamping now samples up to `maxTouchpointsPerUser` (default 10) eligible events per user, matching Mixpanel's `attributed_value_reader.cpp` 10-touchpoint cap. Replaces the old behavior of stamping ~25% of all events inline.
45
+
46
+ **Why**: Mixpanel's attribution engine ignores touchpoints beyond the first 10. Generating 25% of all events with UTMs produced unrealistic attribution distributions that couldn't be reproduced in Mixpanel queries.
47
+
48
+ **Impact on your data**:
49
+ - Fewer events have UTM parameters (~10 per user instead of ~25% of all events)
50
+ - Attribution analysis in Mixpanel will match what `emulateBreakdown` computes
51
+ - Events without `isAttributionEvent: true` are eligible for touchpoint selection
52
+
53
+ **Action needed**: None for most dungeons. If your hooks depend on a specific density of UTM-stamped events, set `maxTouchpointsPerUser` higher:
54
+
55
+ ```javascript
56
+ { maxTouchpointsPerUser: 50 } // more touchpoints for attribution-heavy analysis
57
+ ```
58
+
59
+ ### 4. Auto-Sort After `everything` Hook
60
+
61
+ **What**: After the `everything` hook fires, the engine auto-sorts all user events by timestamp (default `autoSortAfterEverything: true`).
62
+
63
+ **Why**: Hooks that inject events (e.g., `injectBetween`, `injectBurst`, event cloning) often produce out-of-order timestamps. The greedy funnel engine requires strictly ordered events. Auto-sort guarantees correctness without requiring hook authors to manually sort.
64
+
65
+ **Impact on your data**:
66
+ - Events are always in chronological order per user
67
+ - Hooks no longer need `events.sort((a,b) => ...)` at the end
68
+ - If a hook intentionally produces non-chronological order (rare), set `autoSortAfterEverything: false`
69
+
70
+ **Action needed**: None. Remove manual sort calls from `everything` hooks if you had them.
71
+
72
+ ### 5. Conversion Window on Funnels
73
+
74
+ **What**: New `conversionWindowDays` field on funnels (default 30, hard cap 180). Validator auto-bumps when `timeToConvert` exceeds the default. Generator caps step-to-step time spans to the window.
75
+
76
+ **Why**: Mixpanel applies a conversion window (`conversion_window.cpp`) with strict `<` comparison. Events outside the window don't count as conversions. The generator must respect this so that verification and actual Mixpanel reports agree.
77
+
78
+ **Impact on your data**:
79
+ - Funnels with `timeToConvert` > 30 days will have `conversionWindowDays` auto-set to `ceil(TTC * 1.5)` (capped at 180)
80
+ - You'll see a warning: `"Funnel X: timeToConvert exceeds default 30d conversion window. Auto-set conversionWindowDays=Y."`
81
+ - Generated funnel events are guaranteed to complete within the conversion window
82
+
83
+ **Action needed**: None for most dungeons. To silence the warning, set `conversionWindowDays` explicitly:
84
+
85
+ ```javascript
86
+ funnels: [{
87
+ sequence: ['trial start', 'trial end', 'subscribe'],
88
+ timeToConvert: 45, // 45 days between steps
89
+ conversionWindowDays: 60, // explicit window
90
+ }]
91
+ ```
92
+
93
+ ### 6. Sessions Split on UTC Day Boundary
94
+
95
+ **What**: `assignSessionIds` now ends a session whenever the UTC day index changes between consecutive events. Three reset triggers: timeout gap > 30 min, max session > 24h, OR day-index change.
96
+
97
+ **Why**: Mixpanel `session_query.cpp:828-830, 911` terminates sessions on day boundary in query timezone. Without this, a session crossing midnight would be ONE session in our generator but TWO in Mixpanel — verification numbers would diverge.
98
+
99
+ **Impact on your data**:
100
+ - Sessions per user may be HIGHER than 1.4 baselines (sessions spanning midnight now split)
101
+ - Session duration aggregates shift (no more multi-day sessions)
102
+ - Funnel evaluation with `sessionScoped: true` sees more session boundaries
103
+
104
+ **Action needed**: None. Update any test expectations that asserted exact session counts under the old behavior.
105
+
106
+ ### 7. Engine Validation Strict Clamps (post-eval ship gate)
107
+
108
+ **What**: 1.5.0 added a 194-combo engine-validation sweep that proves the no-hook baseline produces in-band charts across the param space. It also added 7 validator strict-clamps that override pathological knob combinations at config time with a `console.warn` explaining what changed.
109
+
110
+ **Why**: Earlier 1.5 builds could produce nosedive (last-day cliff) on funnel-heavy dungeons OR right-edge explosion on `percentUsersBornInDataset: 100` + `bornRecentBias: 0.6` configs. Both look broken in Mixpanel charts. The engine fix (`FUNNEL_DEAD_ZONE_CAP_SEC = 0`) eliminates the cliff; the clamps catch the explosion at config time before it ships.
111
+
112
+ **Engine fix**: `lib/orchestrators/user-loop.js` removed the 1-day "dead zone" before `FIXED_NOW` for funnel step-1 anchors. Earlier defense-in-depth that became unnecessary once the cursor-accumulation bug was fixed in round 1; the future-time guard at storage step 14 catches anything past `FIXED_NOW`. Funnels can now anchor right up to `FN`. **Verified: `futureEvents == 0` across the entire 194-combo matrix.**
113
+
114
+ **Validator clamps** (each emits one `console.warn` per validation pass):
115
+
116
+ | # | Clamp | When it fires |
117
+ |---|-------|--------------|
118
+ | 1 | `percentUsersBornInDataset` ∈ `[0, 100]` | Always (data sanity) |
119
+ | 2 | Per-macro born cap (flat=12, steady=12, growth=30, viral=55, decline=5) | User explicitly sets BOTH `macro` AND `percentUsersBornInDataset` |
120
+ | 3 | `bornRecentBias` ∈ `[-0.5, 0.5]` | User-explicit (top-level OR `macro: { ..., bornRecentBias }`) |
121
+ | 4 | Compound `born > 60 && bias > 0.4` → bias=0.3 | Either user-explicit |
122
+ | 5 | `bornRecentBias` ∈ `[-1, 1]` (`Math.pow` guard) | Always |
123
+ | 6 | `avgEventsPerUserPerDay > 50` → 50 | Always |
124
+ | 7 | `avgActiveDaysPerUser > numDays * 0.5` → `floor(numDays * 0.5)` | Always |
125
+
126
+ Plus a warning-only check for `numDays < 14`.
127
+
128
+ **Impact on your data**:
129
+ - **None for 1.5.0 and earlier vertical dungeons** — none of them set `percentUsersBornInDataset`, `bornRecentBias`, or `avgEventsPerUserPerDay > 50` explicitly. All clamps are no-ops on existing dungeons.
130
+ - **Macro presets are exempt from clamps 2-4** — `macro: 'viral'` uses bias=0.6 + born=55 by design and continues to work unchanged.
131
+ - **New dungeons that set `macro: 'flat'` + `percentUsersBornInDataset: 100`** will see born clamped to 12 with a warning. Switch to `macro: 'growth'` or `macro: 'viral'` to genuinely run with high born%.
132
+ - **Dungeons that set `macro: { preset: 'growth', bornRecentBias: 0.7 }` (object override form)** were previously bypassing clamps; v1.5 final closes this backdoor — bias clamps to 0.5.
133
+
134
+ **Action needed**: Watch stderr for `⚠️ ... clamped to ... To suppress, fix the config.` lines on first run. Adjust config or accept clamped values.
135
+
136
+ **The 6 strict-bar conditions** — what counts as "in-band" output (per macro):
137
+
138
+ | Macro | tail band | spike cap | l7c min |
139
+ |---------|---------------|-----------|---------|
140
+ | flat | `[0.85, 1.5]` | 2.5 | 0.5 |
141
+ | steady | `[0.85, 1.7]` | 2.5 | 0.5 |
142
+ | growth | `[0.85, 2.5]` | 3.5 | 0.45 |
143
+ | viral | `[0.5, 5.0]` | 7.0 | 0.3 |
144
+ | decline | `[0.4, 2.0]` | 3.0 | 0.3 |
145
+
146
+ Plus `lastDay >= 0.7 * sameDowPrev` (DOW-fair comparison; relaxed to 0.6 in `avgActiveDaysPerUser` mode), `futureEvents == 0`, and a signup-floor check (bypassed for `mean < 5/day` or `macro === 'decline'`).
147
+
148
+ **Engine guarantees apply to no-hook configs only.** Hooks own their shape — engagementDecay churn cohorts can produce tail < 0.4, viral hooks with persona-driven late-cohort lift can exceed the spike cap. Document intentional deviations in your dungeon's overview JSDoc.
149
+
150
+ **Reference**: `plans/ENGINE-VALIDATION/PLAN.md` (spec), `plans/ENGINE-VALIDATION/FIX.md` (sweep evidence), `tests/engine/sweep-engine.mjs` (harness), `tests/unit/engine-shape-canary.test.js` (commit-time gate), `tests/e2e/engine-shape-full-sweep.test.js` (`RUN_FULL_SWEEP=1` pre-release gate).
151
+
152
+ ## New Features (Opt-In)
153
+
154
+ ### `avgActiveDaysPerUser`
155
+
156
+ Concentrates events onto fewer distinct UTC days per user. A user with `avgActiveDaysPerUser: 5` in a 30-day window will have events on ~5 distinct days (normal distribution, sd ≈ target/3).
157
+
158
+ ```javascript
159
+ {
160
+ avgActiveDaysPerUser: 5,
161
+ avgEventsPerUserPerDay: 4, // 4 events per ACTIVE day, not per calendar day
162
+ }
163
+ ```
164
+
165
+ Interacts with:
166
+ - **`engagementDecay`**: Decay protects the last event on each picked active day from being dropped
167
+ - **`soup.dayOfWeekWeights`**: Active days are picked using DOW weights (weekday-biased by default)
168
+ - **Funnel generation**: First funnel event for a user lands on the first picked active day
169
+ - **`everything` hooks**: Events are already concentrated when the hook fires
170
+
171
+ ### `maxTouchpointsPerUser`
172
+
173
+ Control the touchpoint cap per user. Default 10 matches Mixpanel.
174
+
175
+ ```javascript
176
+ { maxTouchpointsPerUser: 20 } // more attribution data
177
+ ```
178
+
179
+ ### `autoSortAfterEverything`
180
+
181
+ Disable auto-sort if your hook intentionally produces non-chronological event sequences.
182
+
183
+ ```javascript
184
+ { autoSortAfterEverything: false } // hook manages its own ordering
185
+ ```
186
+
187
+ ## Migration Checklist
188
+
189
+ For most dungeons: **nothing to do.** Upgrade and run.
190
+
191
+ If you want to fine-tune:
192
+
193
+ 1. **Run your dungeon and check for auto-promote warnings.** Add `isStrictEvent: false` where you need standalone instances of funnel-step events.
194
+ 2. **Review hooks that count UTM events.** Touchpoint cap means fewer UTM events per user.
195
+ 3. **Remove manual sort calls** from `everything` hooks. Auto-sort handles it.
196
+ 4. **Add `avgActiveDaysPerUser`** if you want realistic usage patterns (most real products have users active on 3-10 days per month, not every day).
197
+ 5. **Run `/verify-dungeon`** to confirm hooks still produce expected patterns under v1.5 engine behavior.
198
+
199
+ ## Verifier Updates
200
+
201
+ `emulateBreakdown` now:
202
+ - Auto-applies `conversionWindowDays` from matched funnel config
203
+ - Auto-dispatches `evaluateAnyOrderCompletion` for non-sequential funnel modes
204
+ - Passes `funnelOrder` through to `funnelFrequency` and `timeToConvert` analyses
205
+ - **Auto-builds identity map** when `profiles` are passed — pre-auth `device_id`
206
+ events resolve to the same canonical user as post-auth `user_id` events.
207
+ Apply this to ANY identity-model dungeon (`avgDevicePerUser > 0` or
208
+ `hasAnonIds: true`). The map is built ONCE and reused across `timeBucket`
209
+ recursion (no per-bucket rebuild).
210
+ - **Threads new funnel options** (`reentry`, `exclusionSteps`,
211
+ `trackStepProperties`, `sessionScoped`) for `funnelFrequency` and
212
+ `timeToConvert`.
213
+ - **Applies funnel-level v1.5.0 hints** when matching (verify-dungeon):
214
+ `funnel.reentry`, `funnel.exclusionEvents`, `funnel.stepFilters`.
215
+ - **New types** — `'sessionMetrics'` and `'retention'` breakdowns; cross-cutting
216
+ `timeBucket: 'day' | 'week' | 'month'` on every type.
217
+
218
+ ### Complete module exports
219
+
220
+ `@ak--47/dungeon-master/verify` (resolved from `lib/verify/index.js`):
221
+
222
+ ```javascript
223
+ import {
224
+ // top-level orchestration
225
+ emulateBreakdown, // dispatch on { type: 'frequencyByFrequency' | 'funnelFrequency'
226
+ // | 'aggregatePerUser' | 'timeToConvert' | 'attributedBy'
227
+ // | 'sessionMetrics' | 'retention' }
228
+ verifyDungeon, // run dungeon + run check[] against output, return structured report
229
+
230
+ // schema validation
231
+ deriveExpectedSchema, // (Dungeon) → Map<eventType, Set<column>>
232
+ validateSchema, // (events, Dungeon) → SchemaReport (catches flag stamping)
233
+
234
+ // funnel engine + extensions
235
+ evaluateFunnel, // greedy single-pass + reentry/exclusion/step-filter/sessionScoped/totals
236
+ evaluateFunnelHPC, // Hold Property Constant — parallel sub-funnels per holdProperty value
237
+ resolveFunnelSegment, // (FunnelResult, 'first'|'last'|{step:N}) → step properties
238
+ evaluateAnyOrderCompletion, // set-membership check for non-sequential funnel modes
239
+ normalizeStep, // FunnelStep → { event, where? }
240
+ matchesStepFilter, // (event, where?) → bool — step where-clause check
241
+
242
+ // funnel internals (also exported for advanced use)
243
+ timestampComesAfter, // (t1, t2, graceperiod=true) — Mixpanel 2s grace rule
244
+ withinConversionWindow, // (eventTime, step0Time, windowMs) — strict <
245
+
246
+ // identity resolution
247
+ buildIdentityMap, // (profiles[]) → Map<device_id, canonical_user_id>
248
+ resolveUserId, // (event, identityMap?) → canonical id (distinct_id wins)
249
+
250
+ // counting + aggregation primitives
251
+ countDistinctPeriods, // (events, eventName, unit='day', { algorithm }) — Mixpanel addiction_query parity
252
+ binByDistinctPeriods, // bin a user by distinct-period count
253
+ nullAwareAvg, // skips null/NaN/non-numeric (normal_query parity)
254
+ nullAwareSum,
255
+ nullAwareExtreme, // (values, 'min'|'max')
256
+
257
+ // time-bucketing
258
+ partitionByTimeBucket, // (events, 'day'|'week'|'month', { from, to }?) — empty-backfill optional
259
+ } from '@ak--47/dungeon-master/verify';
260
+ ```
261
+
262
+ All of these are stable v1.5.0 surface. JSDoc on each function points to the
263
+ specific Mixpanel C++ source file it mirrors.
264
+
265
+ ### New `Funnel` config fields (all optional, opt-in)
266
+
267
+ ```javascript
268
+ funnels: [{
269
+ sequence: ['land', 'sign_up'],
270
+ conversionRate: 30,
271
+ exclusionEvents: ['rage_click'], // generator + verifier
272
+ reentry: true, // verifier-only hint
273
+ stepFilters: { 1: { prop: 'plan', op: 'eq', value: 'pro' } }, // verifier-only
274
+ }]
275
+ ```
276
+
277
+ > **Note:** `Funnel.holdPropertyConstant` was considered and dropped during the
278
+ > Mixpanel parity audit. HPC requires a different report shape than
279
+ > `funnelFrequency` (one row per `step × hpc_value` instead of per `step ×
280
+ > cohort`), so it cannot be auto-routed. Call `evaluateFunnelHPC` directly
281
+ > inside your `verifyDungeon` check's `assert` callback when you need it.
282
+
283
+ ### Identity-aware verification (REQUIRED for identity-model dungeons)
284
+
285
+ If you set `hasAnonIds: true` or `avgDevicePerUser > 0`, ALWAYS pass
286
+ `profiles` to `emulateBreakdown`:
287
+
288
+ ```javascript
289
+ const result = await DUNGEON_MASTER(config);
290
+ const events = Array.from(result.eventData);
291
+ const profiles = Array.from(result.userProfilesData);
292
+
293
+ emulateBreakdown(events, {
294
+ type: 'funnelFrequency',
295
+ steps: ['visit_landing', 'sign_up', 'first_action'],
296
+ breakdownByFrequencyOf: 'visit_landing',
297
+ profiles, // ← required for correct grouping
298
+ });
299
+ ```
300
+
301
+ Without `profiles`, pre-auth `device_id` events bucket as separate "users";
302
+ funnel completion drops, retention deflates, attribution mis-routes.
303
+
304
+ ## Mixpanel Parity Semantics (post-audit)
305
+
306
+ The verifier was audited against `mixpanel/analytics` C++ source after the
307
+ initial implementation. These are non-obvious behaviors callers should know:
308
+
309
+ ### Retention buckets are ms-delta from birth (not calendar-day diff)
310
+
311
+ Reference: `retention_query.cpp:1227-1231`. Bucket = `floor((return_ms -
312
+ birth_ms) / DAY_MS)`. A return 23h after a birth at 6pm UTC lands in **bucket 0**
313
+ (within 24h); a return 25h later lands in **bucket 1** — even when both fall
314
+ on the next UTC calendar day.
315
+
316
+ ```javascript
317
+ emulateBreakdown(events, {
318
+ type: 'retention',
319
+ cohortEvent: 'Sign Up',
320
+ returnEvent: 'Login',
321
+ dayBuckets: [0, 1, 7, 14, 30], // bucket 0 = within 24h
322
+ birthCanRetain: false, // (default) excludes returns at exact birth ms
323
+ });
324
+ ```
325
+
326
+ Naive intuition ("day 1 = next calendar day") will give the wrong answer.
327
+
328
+ ### `aggregatePerUser` cohort-level columns
329
+
330
+ When `agg: 'sum'`, `'min'`, or `'max'`, the result row includes BOTH:
331
+ - `avg_aggregate` — mean of per-user aggregates within the cohort
332
+ - `cohort_sum` / `cohort_min` / `cohort_max` — the same `agg` applied across users
333
+
334
+ Pick the column matching the Mixpanel report you're verifying. Mixpanel's
335
+ "Aggregate per user → SUM" report shows the cohort sum, not the mean of sums.
336
+
337
+ ### `timeBucket` empty-bucket markers
338
+
339
+ `timeBucketRange: { from, to }` enumerates every bucket in the range and emits
340
+ `{ period, _empty: true }` for buckets with no events (Mixpanel
341
+ `normal_query.cpp:352-356` parity). **Consumers MUST filter `r._empty`**
342
+ before any numerical aggregation:
343
+
344
+ ```javascript
345
+ const rows = emulateBreakdown(events, {
346
+ type: 'frequencyByFrequency',
347
+ metricEvent: 'Purchase',
348
+ breakdownByFrequencyOf: 'Browse',
349
+ timeBucket: 'day',
350
+ timeBucketRange: { from: '2026-01-01', to: '2026-01-31' },
351
+ });
352
+ const dataRows = rows.filter(r => !r._empty); // skip backfilled markers
353
+ ```
354
+
355
+ ### `countMode: 'totals'` semantics
356
+
357
+ `evaluateFunnel({ countMode: 'totals' })` returns `FunnelResult[]` of ALL
358
+ attempts including INCOMPLETE drop-offs (`funnel_query.cpp:1747` aggregates
359
+ `reached >= 0`, not "completed"). Without `reentry: true`, returns a
360
+ single-attempt array. The "totals requires reentry" constraint that existed
361
+ in the initial impl was removed during the audit.
362
+
363
+ ### Identity resolver order
364
+
365
+ `resolveUserId(event, identityMap)` priority:
366
+ 1. `event.distinct_id` (Mixpanel canonical post-merge id; never demoted)
367
+ 2. `identityMap.get(event.device_id)` (device→user merge from profile inversion)
368
+ 3. `event.user_id`
369
+ 4. `event.device_id`
370
+
371
+ The v1.5 generator does NOT stamp `distinct_id` on raw events — that branch
372
+ exists for callers feeding in already-stitched data from external pipelines.
373
+ If a hook accidentally writes `distinct_id` onto event records, identity
374
+ merge breaks; strip it.
375
+
376
+ ### Funnel exclusion `afterStep` / `beforeStep`
377
+
378
+ `exclusionSteps: [{event, afterStep, beforeStep}]` — `afterStep` is the index
379
+ of the step that must HAVE BEEN reached; `beforeStep` is the step that must NOT
380
+ have been reached yet. Range check: `reached >= afterStep && reached < beforeStep`.
381
+
382
+ Defaults: `afterStep = -Infinity`, `beforeStep = steps.length`. With these, the
383
+ exclusion fires anywhere in the attempt — used by the simple shape that
384
+ `Funnel.exclusionEvents: ['X']` produces. For "exclusion only between step 1
385
+ and step 2," set explicit `{event: 'X', afterStep: 1, beforeStep: 2}`.
386
+
387
+ ### Generator: exclusion event injection is schema-clean
388
+
389
+ When the generator stamps an exclusion event for a non-converter, it copies
390
+ ONLY identity (`user_id`, `device_id`, `distinct_id`, `session_id`,
391
+ `insert_id`) + super props + group keys + props declared on the exclusion
392
+ event's own config. Source-event-specific props (e.g., `cart_value` on `Add
393
+ to Cart`) WILL NOT bleed onto a different event type. Schema validator
394
+ passes cleanly.
395
+
396
+ ## Documented divergences (intentional v1.5.0 scope)
397
+
398
+ These were considered and explicitly out of scope:
399
+
400
+ - **HPC list-property values** — scalar only. Mixpanel
401
+ `aggregate_hash_get_key_cursor` explodes list values into N sub-funnels per
402
+ event; we don't.
403
+ - **`sessionScoped` funnel** + **`sessionMetrics({ event })` filter** —
404
+ verifier-only conveniences; not directly reproducible in Mixpanel UI.
405
+ - **Retention COMPOUNDED, CARRY_BACK, CONSECUTIVE_FORWARD, CALENDAR_START,
406
+ segment_event=SECOND, cohort window, week/month bucket units** — out of
407
+ scope for v1.5.0.
408
+ - **Timezone** — verifier uses UTC; Mixpanel uses query timezone (qtz).
409
+ Bucket boundaries shift by hours for non-UTC accounts.
410
+ - **Percentiles** — `sessionMetrics` p50/p90 use linear interpolation;
411
+ Mixpanel uses TDigest (single-digit % divergence at p90 on small N).
412
+ - **Selector grammar** — eq/neq/gt/lt/gte/lte/contains/not_contains only;
413
+ no is_set/between/regex/contains_ci.
414
+
415
+ ## Test Suite
416
+
417
+ Full suite after v1.5.0 (engine + verifier + audit fixes): **46 files,
418
+ 1108 tests, 1 skipped** (~9s wall time). New files cover:
419
+ - `tests/unit/identity-resolution.test.js` (14)
420
+ - `tests/unit/session-metrics.test.js` (11) — includes generator UTC day-boundary split parity
421
+ - `tests/unit/retention.test.js` (9) — ms-delta bucketing + birthCanRetain
422
+ - `tests/unit/time-bucketed.test.js` (11) — day/week/month + empty backfill
423
+ - `tests/unit/funnel-engine.test.js` extended to 51 tests (was 18) — reentry, exclusion (default + explicit), totals (with incomplete attempts), HPC, step filters, step properties, segment modes, sessionScoped. Includes 5+ ported fixtures from `mixpanel/analytics test_qt_funnel.py`.
424
+ - `tests/integration/hook-patterns-emulator.test.js` extended (+7) — funnel option threading + new breakdown types + `aggregatePerUser` SUM/MAX cohort columns
425
+ - `tests/integration/features.test.js` extended (+4) — `exclusionEvents` validator + injection + schema-clean clone roundtrip
426
+ - `tests/integration/identity-model.test.js` extended (+1) — `emulateBreakdown` profile-merge round-trip
@@ -0,0 +1,178 @@
1
+ # 1.5.1 Upgrade Guide
2
+
3
+ **TL;DR: No breaking API changes.** Old top-level config keys keep working through 1.5.x. Most dungeons run without modification. Two behavioral changes are worth knowing about: `numEvents` is now accurate (was over-shooting by 1.6-2x), and anonymous non-converters get `_drop: true` stamped on their profile.
4
+
5
+ ## What Changed
6
+
7
+ ### 1. Config Sub-Object API (`credentials` / `switches` / `identity`)
8
+
9
+ **What**: Three new optional config sub-objects group related keys:
10
+
11
+ ```js
12
+ const config = {
13
+ seed: SEED,
14
+ numUsers: 10_000,
15
+ datasetStart: "2026-01-01T00:00:00Z",
16
+ datasetEnd: "2026-05-01T23:59:59Z",
17
+ avgEventsPerUserPerDay: 2,
18
+
19
+ // NEW shape (recommended)
20
+ credentials: {
21
+ token,
22
+ region: "US",
23
+ serviceAccount,
24
+ serviceSecret,
25
+ projectId,
26
+ },
27
+ switches: {
28
+ hasLocation: true,
29
+ hasCampaigns: true,
30
+ hasSessionIds: true,
31
+ hasIOSDevices: true,
32
+ hasAndroidDevices: true,
33
+ // ...
34
+ },
35
+ identity: {
36
+ avgDevicePerUser: 2,
37
+ },
38
+
39
+ events: [...],
40
+ funnels: [...],
41
+ };
42
+ ```
43
+
44
+ **Why**: Flat configs with 20+ keys at the top level became hard to scan. Grouped sub-objects make the dungeon's intent clearer at a glance.
45
+
46
+ **Action needed**: None. The old flat shape keeps working. `mergeConfigSubObjects` hoists sub-object values into the top-level keys at validation time. When verbose, the validator emits a one-time warning per dungeon nudging migration. All 48 shipped dungeons have already moved.
47
+
48
+ ### 2. `hasAnonIds` Deprecated
49
+
50
+ **What**: `hasAnonIds: true` is a deprecated alias. Use `identity.avgDevicePerUser: 1` (or higher) instead.
51
+
52
+ ```js
53
+ // Before
54
+ { hasAnonIds: true }
55
+
56
+ // After
57
+ { identity: { avgDevicePerUser: 1 } }
58
+ ```
59
+
60
+ **Why**: `hasAnonIds` was a binary flag that conflated "users have a device pool" with "device pool size = 1." The new shape exposes the pool size directly.
61
+
62
+ **Action needed**: Optional — `hasAnonIds: true` still maps to `avgDevicePerUser: 1` at validation. The validator emits a deprecation note in verbose mode.
63
+
64
+ ### 3. Anonymous Non-Converters Get `_drop: true`
65
+
66
+ **What**: Born-in-dataset users who never reach an `isAuthEvent` step in their first funnel are anonymous. Their profile object exists in `result.userProfilesData` but carries `_drop: true`, and the Mixpanel sender filters those out before pushing to `/engage`. `result.profilesPushed` reports the count actually pushed.
67
+
68
+ **Why**: Mirrors real Mixpanel `$identify` semantics — profiles only exist for users who actually identified. Pre-existing 1.5.0 behavior pushed phantom profiles for every born-in user, including anonymous ones who never called `identify()`.
69
+
70
+ **Impact on your data**:
71
+ - `userProfilesData` size is unchanged (still includes every profile).
72
+ - Profile push count drops by the anonymous-non-converter rate (typically 30-70% of born-in users at default first-funnel conversion rates).
73
+ - Pre-existing users (born outside the window) never get `_drop` — they're considered already-identified per our model.
74
+ - Anonymous users' EVENTS still flow normally (tied to `device_id`).
75
+
76
+ **Action needed**: If your downstream cares about every profile object regardless of identification state, read `userProfilesData` directly instead of relying on Mixpanel's `/engage` count. The `everything` hook can rescue a profile by `delete meta.profile._drop`.
77
+
78
+ ### 4. `numEvents` More Accurate (40-60% Fewer Events at Same Rate)
79
+
80
+ **What**: Per-user event budget computation cleaned up. Removed `0.714` magic dampening + random dice rolls; replaced with a clean `chance.normal(mean=budget, dev=budget/3)`.
81
+
82
+ **Why**: Old behavior overshot the target by 1.6-2x. New behavior matches the configured rate within ±3% across all 5 macros at 50K target.
83
+
84
+ **Impact on your data**: If you previously tuned `avgEventsPerUserPerDay` around the overshoot, expect **~40-60% fewer events at the same rate**. Recompute targets.
85
+
86
+ **Action needed**: Bump `avgEventsPerUserPerDay` to the actual rate you want. The old config that produced 2M events at `rate=1.0` will produce ~800K-1.2M events at `rate=1.0` in 1.5.1. Set `rate=2.0` (or whatever) to hit your old volume.
87
+
88
+ ### 5. Default `Platform` Property Removed
89
+
90
+ **What**: 59 `Platform:` entries removed from `lib/templates/defaults.js`. `os` already carries the platform signal (`iOS`, `Android`, `Windows`, `macOS`, `Linux`, etc.).
91
+
92
+ **Why**: Two competing properties (`Platform` and `os`) carrying the same signal led to inconsistent breakdowns and downstream confusion.
93
+
94
+ **Impact on your data**: Events generated by 1.5.1 will not have a `Platform` property by default. Hooks or downstream queries that read `Platform` will see undefined.
95
+
96
+ **Action needed**: If you need `Platform` for downstream compatibility, define it explicitly in your event properties:
97
+
98
+ ```js
99
+ events: [
100
+ {
101
+ event: "page view",
102
+ properties: {
103
+ Platform: ["iOS", "Android", "Windows", "macOS", "Linux"],
104
+ // ...
105
+ },
106
+ },
107
+ ]
108
+ ```
109
+
110
+ ### 6. `retentionCurve` Config Knob
111
+
112
+ **What**: New top-level config knob that shapes retention via log-linear interpolation. Independent of `engagementDecay`.
113
+
114
+ ```js
115
+ const config = {
116
+ // ...
117
+ retentionCurve: [
118
+ { day: 0, retention: 1.0 },
119
+ { day: 1, retention: 0.80 },
120
+ { day: 7, retention: 0.50 },
121
+ { day: 30, retention: 0.20 },
122
+ ],
123
+ };
124
+ ```
125
+
126
+ **Why**: Hand-rolling retention shapes via `engagementDecay` hooks is verbose. `retentionCurve` lets you express the intent declaratively at config level.
127
+
128
+ **Action needed**: Opt-in only. No change for existing dungeons.
129
+
130
+ ### 7. Concurrent `generate()` Now Safe In-Process
131
+
132
+ **What**: `DATASET_NOW` / `DATASET_BEGIN` scoped via `AsyncLocalStorage`. Two parallel `generate()` calls with different `datasetStart`/`datasetEnd` windows now produce isolated, in-window output.
133
+
134
+ **Why**: Pre-1.5.1, those values lived as module-level mutables. Concurrent calls would race and leak each other's windows.
135
+
136
+ **Action needed**: None for normal usage. If you need explicit scoping (rare), import `runWithDataset(begin, now, fn)` from `lib/utils/dataset-context.js`.
137
+
138
+ ### 8. Quiet by Default
139
+
140
+ **What**: `verbose: false` (default) now gates all warnings, info logs, and dataset-context messages.
141
+
142
+ **Why**: Test output and CI logs were dominated by harmless validator chatter. Pinning the default to quiet lets actual errors surface.
143
+
144
+ **Action needed**: Set `verbose: true` to opt back into the chatty output.
145
+
146
+ ### 9. UTC Bare-Date Parsing
147
+
148
+ **What**: `datasetStart: "2026-01-01"` now parses as `2026-01-01T00:00:00Z` (not local midnight, which shifted the window by UTC offset).
149
+
150
+ **Why**: Local-midnight parsing made `datasetStart` ambiguous across timezones — the same config produced different windows depending on where it ran.
151
+
152
+ **Action needed**: None unless you have a dungeon that relied on the old local-midnight behavior (none of the shipped dungeons did).
153
+
154
+ ### 10. Bug Fixes
155
+
156
+ - **Standalone events now stamp `config.superProps`.** Was silently `{}` before — masked by the validator's auto-funnel pre-fix in 1.5.0. If your hooks/downstream noticed missing superProps on standalone events, that's fixed.
157
+ - **Born-late funnel auth events past `FIXED_NOW` no longer set `userAuthTimeMs`.** Affected ~1-2% of users — engine dropped the event at storage time but still marked the user as authed. Anonymous-non-converter test now strictly asserts every kept profile has a sign_up event.
158
+ - **Pre-existing user events strict-clamp at `FIXED_BEGIN`.** Born-outside-window users no longer leak events into the pre-dataset window via TimeSoup's sub-window distribution.
159
+
160
+ ## Migration Checklist
161
+
162
+ For most users:
163
+
164
+ - [ ] Read your `numEvents` output — likely ~40-60% lower than 1.5.0. Bump `avgEventsPerUserPerDay` if you want the old volume.
165
+ - [ ] If you read `Platform` in hooks or downstream, declare it explicitly in your event properties.
166
+ - [ ] If you push profiles to Mixpanel, note that `result.profilesPushed` may be lower than `userProfilesData.size` — `_drop:true` profiles are filtered.
167
+ - [ ] (Optional) Migrate config keys to `credentials`/`switches`/`identity` sub-objects for cleaner files.
168
+ - [ ] (Optional) Replace `hasAnonIds: true` with `identity.avgDevicePerUser: 1`.
169
+
170
+ ## What's NOT in 1.5.1
171
+
172
+ Deferred to 1.6:
173
+
174
+ - New HOOKS.md recipes for active-day, conversion window, and touchpoint budget.
175
+ - RNG scoping via AsyncLocalStorage (same pattern as DATASET_NOW).
176
+ - dayjs performance optimization (primary bottleneck).
177
+ - Sweep harness simplification (replace `child_process.fork` with `Promise.all`).
178
+ - Engine-level rate derivation that accounts for born-late undershoot.