@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.
- package/.claude/skills/analyze-soup/SKILL.md +21 -12
- package/.claude/skills/create-dungeon/SKILL.md +65 -5
- package/.claude/skills/create-project/SKILL.md +1 -1
- package/.claude/skills/create-project/provision.mjs +57 -10
- package/.claude/skills/verify-dungeon/SKILL.md +56 -25
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
- package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
- package/.claude/skills/write-hooks/SKILL.md +156 -18
- package/CHANGELOG.md +183 -0
- package/HOOKS.md +566 -112
- package/README.md +28 -9
- package/docs/guides/1.3.0-upgrade-guide.md +262 -0
- package/docs/guides/1.3.1-upgrade-guide.md +328 -0
- package/docs/guides/1.4.0-upgrade-guide.md +130 -0
- package/docs/guides/1.4.1-upgrade-guide.md +125 -0
- package/docs/guides/1.4.3-upgrade-guide.md +200 -0
- package/docs/guides/1.4.4-upgrade-guide.md +95 -0
- package/docs/guides/1.5.0-upgrade-guide.md +426 -0
- package/docs/guides/1.5.1-upgrade-guide.md +178 -0
- package/docs/guides/1.5.3-upgrade-guide.md +117 -0
- package/docs/guides/1.6.0-upgrade-guide.md +204 -0
- package/dungeons/technical/stories-verify.js +152 -0
- package/dungeons/vertical/README.md +154 -0
- package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
- package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
- package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
- package/dungeons/vertical/community/community.js +1589 -0
- package/dungeons/vertical/community/community.sql +216 -0
- package/dungeons/vertical/community/community.verify.mjs +72 -0
- package/dungeons/vertical/crypto/crypto.js +1483 -0
- package/dungeons/vertical/crypto/crypto.sql +194 -0
- package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
- package/dungeons/vertical/dating/dating.js +1540 -0
- package/dungeons/vertical/dating/dating.sql +288 -0
- package/dungeons/vertical/dating/dating.verify.mjs +72 -0
- package/dungeons/vertical/devtools/devtools.js +1524 -0
- package/dungeons/vertical/devtools/devtools.sql +161 -0
- package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
- package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
- package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
- package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
- package/dungeons/vertical/education/education.js +1763 -0
- package/dungeons/vertical/education/education.sql +243 -0
- package/dungeons/vertical/education/education.verify.mjs +75 -0
- package/dungeons/vertical/fintech/fintech.js +1410 -0
- package/dungeons/vertical/fintech/fintech.sql +175 -0
- package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
- package/dungeons/vertical/fitness/fitness.js +1495 -0
- package/dungeons/vertical/fitness/fitness.sql +206 -0
- package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
- package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
- package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
- package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
- package/dungeons/vertical/gaming/gaming.js +1741 -0
- package/dungeons/vertical/gaming/gaming.sql +249 -0
- package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
- package/dungeons/vertical/healthcare/healthcare.js +1488 -0
- package/dungeons/vertical/healthcare/healthcare.sql +193 -0
- package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
- package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
- package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
- package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
- package/dungeons/vertical/logistics/logistics.js +1541 -0
- package/dungeons/vertical/logistics/logistics.sql +205 -0
- package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
- package/dungeons/vertical/marketplace/marketplace.js +1438 -0
- package/dungeons/vertical/marketplace/marketplace.sql +148 -0
- package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
- package/dungeons/vertical/{media.js → media/media.js} +557 -58
- package/dungeons/vertical/media/media.sql +173 -0
- package/dungeons/vertical/media/media.verify.mjs +74 -0
- package/dungeons/vertical/real-estate/real-estate.js +1629 -0
- package/dungeons/vertical/real-estate/real-estate.sql +167 -0
- package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
- package/dungeons/vertical/sass/sass.js +1733 -0
- package/dungeons/vertical/sass/sass.sql +173 -0
- package/dungeons/vertical/sass/sass.verify.mjs +74 -0
- package/dungeons/vertical/social/social.js +1456 -0
- package/dungeons/vertical/social/social.sql +160 -0
- package/dungeons/vertical/social/social.verify.mjs +76 -0
- package/dungeons/vertical/streaming/streaming.js +677 -0
- package/dungeons/vertical/streaming/streaming.sql +101 -0
- package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
- package/dungeons/vertical/support-desk/support-desk.js +741 -0
- package/dungeons/vertical/support-desk/support-desk.sql +137 -0
- package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
- package/dungeons/vertical/travel/travel.js +1603 -0
- package/dungeons/vertical/travel/travel.sql +236 -0
- package/dungeons/vertical/travel/travel.verify.mjs +72 -0
- package/index.js +2 -2
- package/lib/core/config-validator.js +37 -2
- package/lib/core/context.js +4 -2
- package/lib/core/dungeon-loader.js +20 -15
- package/lib/core/dungeon-to-json.js +22 -4
- package/lib/core/storage.js +3 -3
- package/lib/generators/funnels.js +36 -9
- package/lib/hook-helpers/cohort.js +53 -0
- package/lib/hook-helpers/index.js +8 -0
- package/lib/hook-helpers/mutate.js +1 -1
- package/lib/hook-helpers/shape.js +319 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
- package/lib/hook-patterns/attributed-by-source.js +60 -58
- package/lib/hook-patterns/frequency-by-frequency.js +14 -4
- package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
- package/lib/hook-patterns/index.js +1 -1
- package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
- package/lib/orchestrators/user-loop.js +41 -0
- package/lib/templates/story-spec.schema.json +175 -0
- package/lib/utils/json-evaluator.js +1 -1
- package/lib/utils/utils.js +5 -4
- package/lib/verify/coerce.js +186 -0
- package/lib/verify/counting.js +270 -32
- package/lib/verify/emulate-breakdown.js +1285 -203
- package/lib/verify/first-time.js +89 -0
- package/lib/verify/flows.js +679 -0
- package/lib/verify/formula.js +259 -0
- package/lib/verify/funnel-engine.js +697 -113
- package/lib/verify/index.js +32 -4
- package/lib/verify/sessionize.js +275 -0
- package/lib/verify/story-runner.js +483 -0
- package/lib/verify/verify-dungeon.js +59 -37
- package/package.json +2 -1
- package/scripts/extract-dungeon-schema.mjs +2 -2
- package/scripts/run-many.mjs +2 -2
- package/scripts/verify-runner.mjs +2 -2
- package/scripts/verify-stories.mjs +244 -0
- package/types.d.ts +296 -30
- package/dungeons/vertical/ai-platform.js +0 -818
- package/dungeons/vertical/community.js +0 -966
- package/dungeons/vertical/crypto.js +0 -895
- package/dungeons/vertical/dating.js +0 -830
- package/dungeons/vertical/devtools.js +0 -1050
- package/dungeons/vertical/ecommerce.js +0 -811
- package/dungeons/vertical/education.js +0 -964
- package/dungeons/vertical/fintech.js +0 -946
- package/dungeons/vertical/fitness.js +0 -935
- package/dungeons/vertical/food-delivery.js +0 -934
- package/dungeons/vertical/gaming.js +0 -1120
- package/dungeons/vertical/healthcare.js +0 -945
- package/dungeons/vertical/insurance-application.js +0 -1039
- package/dungeons/vertical/logistics.js +0 -986
- package/dungeons/vertical/marketplace.js +0 -1019
- package/dungeons/vertical/real-estate.js +0 -872
- package/dungeons/vertical/sass.js +0 -995
- package/dungeons/vertical/social.js +0 -904
- 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.
|