@ak--47/dungeon-master 1.6.5 → 1.7.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/CHANGELOG.md +189 -0
- package/HOOKS.md +49 -0
- package/README.md +129 -8
- package/docs/guides/1.7.0-upgrade-guide.md +154 -0
- package/index.js +16 -1
- package/lib/core/config-validator.js +243 -13
- package/lib/core/context.js +39 -0
- package/lib/core/dungeon-loader.js +4 -1
- package/lib/generators/events.js +53 -7
- package/lib/generators/funnels.js +85 -11
- package/lib/generators/profiles.js +9 -4
- package/lib/orchestrators/mixpanel-sender.js +12 -1
- package/lib/orchestrators/user-loop.js +239 -9
- package/lib/utils/conditions.js +62 -0
- package/lib/utils/json-evaluator.js +12 -2
- package/lib/utils/utils.js +78 -7
- package/lib/verify/schema-validator.js +8 -0
- package/package.json +2 -10
- package/types.d.ts +303 -34
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,195 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@ak--47/dungeon-master`.
|
|
4
4
|
|
|
5
|
+
## 1.7.0 — 2026-09-03
|
|
6
|
+
|
|
7
|
+
The engine round for DM4 v5. Executes the 1.6.4 "Deferred to 1.7.0" table plus
|
|
8
|
+
the five round-two items (`dm-engine-round-two.md`) and the three round-one items
|
|
9
|
+
that table missed (P0-3, P1-5, P1-6). Every behavior change below carries the
|
|
10
|
+
number that proved the gap and the number after the fix, both from real
|
|
11
|
+
generation passes (400 users × 60 days unless stated; scripts in the session
|
|
12
|
+
scratchpad, assertions pinned in `tests/integration/v170-engine-requests.test.js`).
|
|
13
|
+
|
|
14
|
+
**Output compatibility.** Same seed, same config, `concurrency: 1`, pinned window:
|
|
15
|
+
1.7.0 and 1.6.5 produce byte-identical **events** (modulo `insert_id`) on every
|
|
16
|
+
technical fixture that does not set `hasLocation` — `simplest`, `datagen-v15-verify`,
|
|
17
|
+
`experiments`, `group-analytics`, `mirror-strategies`, `ad-spend`, `anonymous-users`
|
|
18
|
+
(the funnel-step time pin below still runs TimeSoup, so the RNG stream is
|
|
19
|
+
unchanged). Profiles and groups are identical where the fixture is deterministic
|
|
20
|
+
(`datagen-v15-verify`, `ad-spend`, `anonymous-users`); `simplest`, `experiments`,
|
|
21
|
+
`group-analytics` and `scd` build some profile/group props from their own unseeded
|
|
22
|
+
`new Chance()` and were never run-to-run stable. Three changes alter output on
|
|
23
|
+
purpose, each gated on a feature you would know you are using — see **Behavior
|
|
24
|
+
changes** (B1–B3). The 10-test engine-shape canary passes; `smoke-test-all` runs
|
|
25
|
+
22/22 shipped dungeons clean. The full 194-combo strict-bar sweep
|
|
26
|
+
(`RUN_FULL_SWEEP=1`, 2026-09-04, window pinned to Wednesday 2026-09-02) passes
|
|
27
|
+
191/194. The 3 failures are one config three times — `growth/365d/r1.2` with
|
|
28
|
+
born `-`/`30`/`100`, which the growth cap resolves to the same 30 — failing the
|
|
29
|
+
last-day bar at ratio 0.66 vs 0.70. Running the same `long` tier on 1.6.5 (`main`)
|
|
30
|
+
produces the identical 3 failures with identical numbers, so this is a
|
|
31
|
+
pre-existing, calendar-window-dependent marginal dip on one 365-day config, not a
|
|
32
|
+
1.7.0 regression. Tracked as a follow-up; not a release blocker.
|
|
33
|
+
|
|
34
|
+
**For DM4: tripwires that now flip.** `tests/integration/v5-engine.test.js` pins
|
|
35
|
+
several of the old behaviors; when these assertions fail on 1.7.0 that is the fix
|
|
36
|
+
landing, and the workaround it guards can go:
|
|
37
|
+
- the spike hook for The Moment (`volumeMultiplier` amplifies — P0-3)
|
|
38
|
+
- the flat-only born-share restriction (`macro: { bornRecentBias, percentUsersBornInDataset }` with no `preset` is uncapped — R2-1)
|
|
39
|
+
- the 19-country `SINGLE_COUNTRY_NAMES` enum in `tests/v5-render.test.js` (`singleCountry: 'US'` works; a miss throws — R2-2)
|
|
40
|
+
- the `importResults.users.success` overwrite (the receipt reconciles — R2-5)
|
|
41
|
+
- the repeated-value weighting idiom (`{ __weights }` — P2-1)
|
|
42
|
+
- the "fewer than two non-funnel events" refusal on Persona Difference — its premise was wrong; see P1-5 below
|
|
43
|
+
|
|
44
|
+
### Tier 1 — silent lies fixed
|
|
45
|
+
|
|
46
|
+
- **R2-1 `MacroConfig` object overrides.** Measured before: `macro: { preset: 'flat',
|
|
47
|
+
percentUsersBornInDataset: 50 }` → 10.3% born; `macro: { percentUsersBornInDataset: 50 }`
|
|
48
|
+
(no preset) → 10.0%; only the top-level key with no `macro` at all gave 52.3%.
|
|
49
|
+
Cause: the born% cap keyed the preset-less object to `flat` (12) and the warning was
|
|
50
|
+
`verbose`-gated. Now: a NAMED preset (string or `{ preset }`) is a shape contract and
|
|
51
|
+
still clamps — 12.3% after, with the clamp in `result.warnings`
|
|
52
|
+
(`{ key: 'percentUsersBornInDataset', requested: 50, applied: 12 }`); an object
|
|
53
|
+
WITHOUT `preset` is a custom macro and is honored as written — 55.0% after for
|
|
54
|
+
`{ percentUsersBornInDataset: 50 }`, 48.8% for `{ bornRecentBias: 0.3, percentUsersBornInDataset: 50 }`.
|
|
55
|
+
Canonical spelling: `macro: { preset, ...overrides }`; the top-level keys are a legacy
|
|
56
|
+
alias that wins over the object. Documented in README, `MacroConfig` JSDoc, CLAUDE.md.
|
|
57
|
+
- **R2-2 `singleCountry`.** Accepts the ISO code or the full name, case-insensitive
|
|
58
|
+
(`resolveSingleCountry`, also hoisted from `switches`). A value matching no country
|
|
59
|
+
THROWS with the list of valid values. Measured before: `'US'` and `'Narnia'` both
|
|
60
|
+
silently deleted every geo property from events and profiles; after: `'US'` → 100%
|
|
61
|
+
`country_code: US` on events and profiles.
|
|
62
|
+
- **R2-3 `strictEventCount` is exact.** Before: stopped on the GENERATED count (drops
|
|
63
|
+
included) and never topped up — 4,987 of 5,000 with ~4x headroom (DM4 measured 4,705
|
|
64
|
+
on its config). Now: the bailout reads the STORED count; a per-user budget controller
|
|
65
|
+
scales the remaining users' budgets by (events still needed ÷ expected remaining
|
|
66
|
+
delivery at the realized yield), clamped to [0.25, 4], aiming slightly high; the
|
|
67
|
+
final user's stream is trimmed with a seeded uniform sample so the count never
|
|
68
|
+
exceeds the target. Measured: 5,000 of 5,000; 30,000 of 30,000; test pins 3,000 of
|
|
69
|
+
3,000. When capacity cannot reach the target (e.g. a churn event ends every user
|
|
70
|
+
early) the run stops short and `result.warnings` carries
|
|
71
|
+
`{ key: 'numEvents', requested, applied }`. User creation still stops once the target
|
|
72
|
+
is met (legacy). Only under the flag — the default path is untouched.
|
|
73
|
+
- **R2-5 profile receipt.** `importResults.users` now carries `generated` (profiles the
|
|
74
|
+
engine pushed to storage, bots included) and `dropped_anonymous` (`_drop`-flagged
|
|
75
|
+
anonymous non-converters never sent to `/engage`) alongside mixpanel-import's
|
|
76
|
+
`success` / `failed`, so `generated - dropped_anonymous - failed === success` is
|
|
77
|
+
checkable. Counters tick at push time, so they hold in batch mode. The sender logs
|
|
78
|
+
a line when the receipt does not reconcile. DM4's "100 generated, 45 reported" is
|
|
79
|
+
now decidable from the result object.
|
|
80
|
+
|
|
81
|
+
### Tier 2 — the deferred 1.7.0 feature set
|
|
82
|
+
|
|
83
|
+
- **P0-1 `funnels[].conditions`: operators, validation, docs, tests.** Operator maps
|
|
84
|
+
`eq`, `neq`, `in`, `nin`, `gt`, `gte`, `lt`, `lte` (AND within a key, AND across keys;
|
|
85
|
+
no `or`); the scalar shorthand is unchanged. Matching moved to
|
|
86
|
+
`lib/utils/conditions.js` (re-exported from user-loop). The validator THROWS on
|
|
87
|
+
shapes that silently never matched — function values, bare arrays (points at
|
|
88
|
+
`{ in: [...] }`), unknown operators, `in`/`nin` without an array — and warns into
|
|
89
|
+
`result.warnings` when a condition key is declared nowhere the profile is built from.
|
|
90
|
+
Users who satisfy none of the author's funnels (the engine catch-all excluded) are
|
|
91
|
+
counted and reported once per run (`key: 'funnels.conditions'`). Measured: iOS 80.2%
|
|
92
|
+
vs Android 39.8% purchased-per-viewed on the duplicate-funnel idiom; `{ gte: 10 }` 89.7%
|
|
93
|
+
vs `{ lt: 10 }` 20.0%. README "segmented funnels", typed `FunnelConditions`, 30 unit
|
|
94
|
+
cases + integration coverage of the filter branch that had none.
|
|
95
|
+
- **P0-2 experiment variant on the profile.** Every exposed user carries
|
|
96
|
+
`"Experiment: <name>": "<variant>"`, stamped lazily at first exposure (so
|
|
97
|
+
`startDaysBeforeEnd` is respected and never-exposed users carry nothing), before the
|
|
98
|
+
`everything` hook. `experiment.stampProfile` (default `true`) opts out; `sticky: false`
|
|
99
|
+
implies off. Not stamped on step events (would be undeclared columns). Measured: 0
|
|
100
|
+
mismatches against `Variant name` on 13,005 exposure events.
|
|
101
|
+
- **P1-1 `(ctx) => value`.** `choose(value, ctx)` passes
|
|
102
|
+
`{ profile?, event?, time?, config }` to every property function. Zero-arity functions
|
|
103
|
+
are untouched; a function that declares a parameter is context-aware and skips the
|
|
104
|
+
source-string cache (which would otherwise freeze its first result). Bound natives
|
|
105
|
+
(`chance.animal.bind(chance)`) are still called with no argument. Funnel steps after
|
|
106
|
+
the first now know their final time before properties resolve (`fixedTimeMs`, fed by
|
|
107
|
+
a synchronous side channel from step 0) — TimeSoup still runs so the RNG stream is
|
|
108
|
+
unchanged; context-aware step properties defer from `buildFunnelEvents` into
|
|
109
|
+
`makeEvent`. `json-evaluator` emits `(ctx) => body` for expression bodies and passes
|
|
110
|
+
whole-function bodies through unwrapped. Measured: 203 of 203 `pro` users got
|
|
111
|
+
`revenue: 100` from `(ctx) => ctx.profile.plan === 'pro' ? 100 : 0`; 0 events without
|
|
112
|
+
profile context.
|
|
113
|
+
- **P1-2 `stickyEventProps` + stable location (B2).** `stickyEventProps: ['plan_tier']`
|
|
114
|
+
copies the profile value onto every event after `superProps`, before the `event`
|
|
115
|
+
hook; keys declared only in `superProps` resolve once per user. Schema-first: undeclared
|
|
116
|
+
keys throw; `lib/verify/schema-validator.js` treats sticky keys as legal on every event.
|
|
117
|
+
Measured: 67,355 of 67,355 events matched the profile. **B2:** with `hasLocation: true`
|
|
118
|
+
a user's events now share the user's location — `featureCtx.userLocation` was computed
|
|
119
|
+
and never read; measured 0.8% of events matched their profile city before, 100% after.
|
|
120
|
+
- **P1-3 `personas[].ttcModifier`.** Multiplies `timeToConvert` after the experiment
|
|
121
|
+
`ttcMultiplier`, before `funnel-pre`. Measured median TTC 0.50h vs 1.96h for 0.25 vs 1.
|
|
122
|
+
**B3:** `churnRate`, `activeWindow`, `soupOverride` removed from the `Persona` type
|
|
123
|
+
(never implemented); the validator still accepts and warns on them, and no longer
|
|
124
|
+
defaults `churnRate`.
|
|
125
|
+
- **P1-4 `campaignPerUser`.** One campaign template per user at birth; UTMs stamped on
|
|
126
|
+
the profile and reused on every touchpoint. Profile UTM keys already present (persona
|
|
127
|
+
`properties`, `user` hook) win over the draw, so a persona can own a channel. Measured:
|
|
128
|
+
0 of 399 users with more than one `utm_source` (400 of 400 before). Ad spend derived
|
|
129
|
+
from acquisitions is deferred to 1.8.0.
|
|
130
|
+
- **P2-1 `{ __weights }` + `autoPowerLaw`.** `{ __weights: { free: 60, pro: 30,
|
|
131
|
+
enterprise: 10 } }` draws exactly those shares (measured 239/123/38 over 400 users);
|
|
132
|
+
`autoPowerLaw: false` turns the implicit 45/25/15 draw off for the run (module flag set
|
|
133
|
+
per run, reset with the value caches). Both round-trip through `dungeon-to-json`.
|
|
134
|
+
- **P2-2 `result.warnings[]`.** Always present. Validator clamps (`percentUsersBornInDataset`,
|
|
135
|
+
`bornRecentBias`, compound bias, `avgEventsPerUserPerDay`, `avgActiveDaysPerUser`,
|
|
136
|
+
`numDays < 14`, `engagementDecay` + active days, auto-set `conversionWindowDays`) plus
|
|
137
|
+
runtime aggregates via `context.addWarning` (one entry per key with `count`). Console
|
|
138
|
+
output stays `verbose`-gated. `EngineWarning` type.
|
|
139
|
+
- **P2-4 `conversionRate` saturation.** Every engine clamp of a modified rate above 100
|
|
140
|
+
— experiment variant, persona, world event, or whatever a `funnel-pre` hook left behind
|
|
141
|
+
— is reported once per funnel and source
|
|
142
|
+
(`funnels[Buy].conversionRate:persona "whale" conversionModifier`, requested 195,
|
|
143
|
+
applied 100). The engine cannot see a hook's own `Math.min(95, rate * 3)`; HOOKS.md
|
|
144
|
+
says so.
|
|
145
|
+
|
|
146
|
+
### Tier 3 — open items outside the deferred table
|
|
147
|
+
|
|
148
|
+
- **P0-3 `worldEvents[].volumeMultiplier > 1` amplifies.** New per-user pass
|
|
149
|
+
`amplifyWorldEvents` clones affected in-window events — `floor(m − 1)` copies plus one
|
|
150
|
+
with probability `frac(m)` — each with a fresh `insert_id` and a timestamp spread
|
|
151
|
+
uniformly across the window (never past the dataset end), after the churn cut and
|
|
152
|
+
before decay and hooks. Measured 3x on a 4-day window: **1.08x before, 3.06x after**,
|
|
153
|
+
all `insert_id`s unique, clones on every window day; 1.5x lands in [1.3, 1.7].
|
|
154
|
+
`aftermath.volumeMultiplier` follows the same rule. Validation rejects negative or
|
|
155
|
+
non-finite multipliers.
|
|
156
|
+
- **P1-5 `eventMultiplier` and funnels — the premise was wrong.** The multiplier scales
|
|
157
|
+
the whole per-user budget, which drives funnel passes too: measured **3.19x / 2.96x**
|
|
158
|
+
for an asked 3x with every event a funnel step. DM4's 0.96x came from its fixture's
|
|
159
|
+
`isChurnEvent` (`Churned`, weight 1, `returnLikelihood: 0.15`): a churn event in the
|
|
160
|
+
standalone pool ends every user after roughly the same number of events regardless of
|
|
161
|
+
budget — measured **1.04x with the churn event, 2.90x without**, same config. The
|
|
162
|
+
engine now reports it (`key: 'personas.eventMultiplier'`) when more than half the
|
|
163
|
+
users churn while a persona multiplier is in play; `eventMultiplier` and `isChurnEvent`
|
|
164
|
+
docs state the cap. DM4 should replace its "fewer than two non-funnel events" check
|
|
165
|
+
with a churn-event check.
|
|
166
|
+
- **P1-6 day-1 retention floor — documented (option 2).** `retentionCurve` picks session
|
|
167
|
+
days; retention counts events; birth-day funnels spill into day 1 regardless of the
|
|
168
|
+
day plan, so day 1 sits near 0.85 (DM4 measured 0.885 for an asked 0.15; days 7 and 30
|
|
169
|
+
follow the curve). Stated in the `retentionCurve` JSDoc, README config table, and
|
|
170
|
+
HOOKS.md §2.7. Verify from day 7 on.
|
|
171
|
+
|
|
172
|
+
### Behavior changes (not purely additive)
|
|
173
|
+
|
|
174
|
+
- **B1** — `conditions` values that are functions or bare arrays now THROW at validation.
|
|
175
|
+
Any dungeon relying on them was already producing an empty funnel.
|
|
176
|
+
- **B2** — `hasLocation: true` now yields one stable location per user on events instead
|
|
177
|
+
of a fresh random city per event. Event geo distributions change; `scd.js` (the one
|
|
178
|
+
technical fixture with `hasLocation`) is the reference.
|
|
179
|
+
- **B3** — `churnRate`, `activeWindow`, `soupOverride` are gone from the `Persona` type.
|
|
180
|
+
Runtime still accepts them with the existing once-per-process warning.
|
|
181
|
+
- World-event windows on funnel steps after the first now test the step's FINAL time
|
|
182
|
+
(previously the pre-offset TimeSoup time). Only dungeons combining `worldEvents` with
|
|
183
|
+
multi-step funnels see different `_drop` / `injectProps` decisions; the fix is what the
|
|
184
|
+
docs always described.
|
|
185
|
+
|
|
186
|
+
### Not built (by request)
|
|
187
|
+
|
|
188
|
+
Session replay, per-funnel `soup`, `or` conditions, anything in `stories` / `verify` /
|
|
189
|
+
`emulateBreakdown`, and ad spend derived from acquisitions (P1-4 item 3, 1.8.0). R2-4
|
|
190
|
+
(the verticals as DM4 templates) is deferred to its own sprint per AK: each vertical
|
|
191
|
+
should demonstrate a different declarative trend type so the template gallery doubles
|
|
192
|
+
as a catalog demo; the R2-4 audit stands as that sprint's punch list.
|
|
193
|
+
|
|
5
194
|
## 1.6.5 — 2026-09-02
|
|
6
195
|
|
|
7
196
|
### Changed
|
package/HOOKS.md
CHANGED
|
@@ -38,6 +38,31 @@ double-fire mutations.
|
|
|
38
38
|
- `everything`: return the (possibly modified) array. Filtered array removes events.
|
|
39
39
|
- All other types: mutate `record` in-place. Return value is ignored.
|
|
40
40
|
|
|
41
|
+
**What 1.7.0 changed for hooks.** No hook signature, `meta` field, or firing
|
|
42
|
+
order changed, and the hook-helper atoms and patterns are untouched. What a hook
|
|
43
|
+
SEES did change in five places:
|
|
44
|
+
|
|
45
|
+
1. `event` hook on funnel steps after the first: `record.time` is now the step's
|
|
46
|
+
FINAL time. Before 1.7.0 it was the pre-offset TimeSoup time, overwritten after
|
|
47
|
+
the hook returned — a hook that derived a property from `record.time` on step 2+
|
|
48
|
+
was reading the wrong timestamp.
|
|
49
|
+
2. `everything` hook: the array already contains world-event clones
|
|
50
|
+
(`volumeMultiplier > 1`), `stickyEventProps` values on every event, and one
|
|
51
|
+
stable location per user under `hasLocation`. `meta.profile` carries
|
|
52
|
+
`"Experiment: <name>"` for exposed users (stamped before `everything`, after `user`).
|
|
53
|
+
3. `funnel-pre`: `record.timeToConvert` already includes the persona `ttcModifier`
|
|
54
|
+
(like `conversionModifier` today). The hook stays the final authority. A rate the
|
|
55
|
+
hook leaves above 100 is clamped downstream exactly as before and now shows up in
|
|
56
|
+
`result.warnings`.
|
|
57
|
+
4. `user` hook: under `campaignPerUser` the profile already carries the drawn
|
|
58
|
+
`utm_*` keys; a hook that overwrites them wins, and the touchpoint pass reads the
|
|
59
|
+
final values. `stickyEventProps` values are read from the profile AFTER the hook.
|
|
60
|
+
5. Hooks may not add properties (rule 1) — `stickyEventProps`, the experiment
|
|
61
|
+
profile key, and world-event clones are engine-stamped and declared, so the
|
|
62
|
+
schema validator accepts them. Prefer these declarative knobs over a hook when
|
|
63
|
+
they express the story (README "segmented funnels", "sticky event properties",
|
|
64
|
+
"campaigns per user").
|
|
65
|
+
|
|
41
66
|
---
|
|
42
67
|
|
|
43
68
|
## 2. How Mixpanel Counts Things
|
|
@@ -399,6 +424,17 @@ All items on the v1.5.0 "documented gaps" list closed in 1.6.0. Unrecognized
|
|
|
399
424
|
retention option keys now THROW — kills the silent-ignore class of bug where a
|
|
400
425
|
typo'd `compounded: true` was dropped without effect.
|
|
401
426
|
|
|
427
|
+
**`retentionCurve` cannot move day 1 (v1.7.0 doc).** `buildActiveDayPlan`
|
|
428
|
+
picks which UTC days a user gets a SESSION; retention counts EVENTS. A funnel
|
|
429
|
+
opened on the birth day spills its later steps across the following
|
|
430
|
+
`timeToConvert` hours regardless of the day plan, so day 1 sits on a floor
|
|
431
|
+
near 0.85 for funnel-driven dungeons no matter what `day1` asks for. Measured
|
|
432
|
+
(2,000 users, 60 days): `{ day1: 0.15, day7: 0.06, day30: 0.02 }` delivered
|
|
433
|
+
day 1 = 0.885, day 7 = 0.151, day 30 = 0.060 — the curve governs from day 7
|
|
434
|
+
on and over-delivers by a consistent ~2.5x there. Verify retention stories from
|
|
435
|
+
day 7 onward. To lower day 1, shorten `timeToConvert` on the funnels users
|
|
436
|
+
enter on birth, or drop next-day spill in an `everything` hook.
|
|
437
|
+
|
|
402
438
|
### 2.8 Funnel reentry: state machine resets after completion
|
|
403
439
|
|
|
404
440
|
Reference: `history.cpp` (`last_step_starts_next_funnel`). With reentry
|
|
@@ -813,6 +849,19 @@ if (type === "funnel-pre") {
|
|
|
813
849
|
Greedy funnel engine (Section 2.2) applies after — keep `conversionRate`
|
|
814
850
|
adjustments modest (1.2x is comfortable; 3x can saturate at the 95% cap).
|
|
815
851
|
|
|
852
|
+
**Saturation is reported, but only the engine's own clamp (v1.7.0, P2-4).** When a
|
|
853
|
+
persona `conversionModifier`, an experiment variant, a world event, or the value a
|
|
854
|
+
`funnel-pre` hook leaves behind pushes `conversionRate` above 100, the engine
|
|
855
|
+
clamps to 100 and adds one aggregated entry per funnel and source to
|
|
856
|
+
`result.warnings` (`funnels[Checkout].conversionRate:persona "whale"
|
|
857
|
+
conversionModifier`, `requested: 195`, `applied: 100`). It cannot see a hook's own
|
|
858
|
+
cap: `record.conversionRate = Math.min(95, rate * 3)` on a base of 65 yields 95 — a
|
|
859
|
+
1.46x lift, not 3x — and the engine never learns the intended 195. To get a true
|
|
860
|
+
multiple, read the base rate and solve for it, or lower the base rate so the multiple
|
|
861
|
+
fits under the cap. For "one segment converts differently on one funnel" prefer the
|
|
862
|
+
declarative `funnels[].conditions` (README "segmented funnels") over a `funnel-pre`
|
|
863
|
+
hook — it needs no cap arithmetic.
|
|
864
|
+
|
|
816
865
|
---
|
|
817
866
|
|
|
818
867
|
#### 4.2 Feature Launch Inflection
|
package/README.md
CHANGED
|
@@ -155,7 +155,33 @@ import { createTextGenerator, generateBatch } from '@ak--47/dungeon-master/text'
|
|
|
155
155
|
|
|
156
156
|
these are the same functions used internally. `pickAWinner` creates weighted distributions, `weighNumRange` generates realistic numeric ranges with configurable skew, and the text generators produce organic-looking strings with sentiment analysis and keyword injection.
|
|
157
157
|
|
|
158
|
-
**you usually don't need `pickAWinner`** — as of 1.6.1, any property value that is a plain array of 3–19 unique strings automatically gets a stable power-law distribution: one seed-deterministic winner per array per run (~45% winner / ~25% second / ~15% third / decaying tail). to opt out and get uniform draws, use exactly 2 values, 20+, or include one of the keywords `variant` / `group` / `experiment` / `population` in a value (experiment arms stay balanced). arrays with explicit duplicate entries (`["card", "card", "apple_pay"]`) skip the auto-weighting and honor the duplicates exactly.
|
|
158
|
+
**you usually don't need `pickAWinner`** — as of 1.6.1, any property value that is a plain array of 3–19 unique strings automatically gets a stable power-law distribution: one seed-deterministic winner per array per run (~45% winner / ~25% second / ~15% third / decaying tail). to opt out and get uniform draws, use exactly 2 values, 20+, or include one of the keywords `variant` / `group` / `experiment` / `population` in a value (experiment arms stay balanced). arrays with explicit duplicate entries (`["card", "card", "apple_pay"]`) skip the auto-weighting and honor the duplicates exactly — **repeats are the weights** (that array is 2:1).
|
|
159
|
+
|
|
160
|
+
**state the distribution instead (1.7.0):** `{ __weights: { free: 60, pro: 30, enterprise: 10 } }` draws exactly those shares — no power law, no per-run winner, zero-weight keys never draw. `autoPowerLaw: false` at the top level turns the automatic power law off for the whole run (uniform picks).
|
|
161
|
+
|
|
162
|
+
```javascript
|
|
163
|
+
userProps: {
|
|
164
|
+
plan_tier: { __weights: { free: 60, pro: 30, enterprise: 10 } }, // honest 60/30/10
|
|
165
|
+
platform: ['iOS', 'Android', 'web'], // 45/25/15 power law (default)
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**value functions see context (1.7.0):** a property function may declare a `ctx` parameter and read `ctx.profile` (the user's resolved profile), `ctx.event` (the event being built), `ctx.time` (unix ms) and `ctx.config`. this is how one field correlates with another without a hook. zero-arity functions keep working untouched.
|
|
170
|
+
|
|
171
|
+
```javascript
|
|
172
|
+
userProps: {
|
|
173
|
+
plan: ['free', 'pro'],
|
|
174
|
+
revenue: (ctx) => ctx.profile.plan === 'pro' ? 100 : 10, // profile keys resolve in declaration order
|
|
175
|
+
},
|
|
176
|
+
events: [{ event: 'Purchased', properties: {
|
|
177
|
+
price: () => integer(5, 500),
|
|
178
|
+
quantity: [1, 1, 1, 2, 3],
|
|
179
|
+
total: (ctx) => ctx.event.price * ctx.event.quantity, // event props resolve in declaration order
|
|
180
|
+
}}],
|
|
181
|
+
superProps: { plan_on_event: (ctx) => ctx.profile.plan }, // or: stickyEventProps: ['plan']
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`ctx.profile` is undefined for group profiles, lookup tables, ad spend and mirror props. on funnel steps the step's final time is known before its properties resolve, so `ctx.time` is the real timestamp.
|
|
159
185
|
|
|
160
186
|
### named exports
|
|
161
187
|
|
|
@@ -406,10 +432,14 @@ mix and match. most dungeons want `macro: "flat"` (the chart doesn't blow up at
|
|
|
406
432
|
```javascript
|
|
407
433
|
macro: 'flat' // default
|
|
408
434
|
macro: 'growth' // preset string
|
|
409
|
-
macro: { preset: 'growth', percentUsersBornInDataset: 40 } // preset + override
|
|
410
|
-
macro: { bornRecentBias: 0, percentUsersBornInDataset:
|
|
435
|
+
macro: { preset: 'growth', percentUsersBornInDataset: 40 } // preset + override (canonical object spelling)
|
|
436
|
+
macro: { bornRecentBias: 0.3, percentUsersBornInDataset: 50 } // custom macro — no preset, no cap
|
|
411
437
|
```
|
|
412
438
|
|
|
439
|
+
**canonical spelling is the object with `preset`.** the top-level `bornRecentBias` / `percentUsersBornInDataset` / `preExistingSpread` keys are a legacy alias and win over the object when both are set.
|
|
440
|
+
|
|
441
|
+
**a named preset is a shape contract.** its born% cap applies (flat 12, steady 12, growth 30, viral 55, decline 5) whether you spell it `macro: 'growth', percentUsersBornInDataset: 50` or `macro: { preset: 'growth', percentUsersBornInDataset: 50 }` — both clamp to 30 and report it in `result.warnings`. **an object without `preset` is a custom macro**: you own the shape, no cap applies, your numbers are used as written (missing fields fill from `flat`). before 1.7.0 the preset-less object was silently capped at 12.
|
|
442
|
+
|
|
413
443
|
## timesoup (intra-week / intra-day rhythm)
|
|
414
444
|
|
|
415
445
|
timesoup controls the texture of events inside the macro trend. it uses gaussian cluster sampling layered with day-of-week and hour-of-day weighting derived from... i won't tell you. a prize goes to whoever can guess. the result is realistic temporal patterns: weekday peaks, weekend valleys, morning surges, afternoon dips.
|
|
@@ -537,6 +567,24 @@ funnels: [
|
|
|
537
567
|
|
|
538
568
|
ordering strategies: `sequential`, `random`, `first-fixed`, `last-fixed`, `first-and-last-fixed`, `middle-fixed`, `interrupted`
|
|
539
569
|
|
|
570
|
+
### segmented funnels (`conditions`)
|
|
571
|
+
|
|
572
|
+
`conditions` is the only mechanism that makes **one segment convert differently on one funnel** — `personas[].conversionModifier` applies to every funnel. a funnel with `conditions` is offered only to users whose profile satisfies every key (AND across keys). the idiom is two funnels with the same `name` and `sequence`, different `conditions` and rates:
|
|
573
|
+
|
|
574
|
+
```javascript
|
|
575
|
+
userProps: { platform: ['iOS', 'Android'], seats: [1, 5, 10, 20] },
|
|
576
|
+
funnels: [
|
|
577
|
+
{ name: 'Checkout', sequence: ['Viewed Item', 'Purchased'], conditions: { platform: 'iOS' }, conversionRate: 80, timeToConvert: 0.5 },
|
|
578
|
+
{ name: 'Checkout', sequence: ['Viewed Item', 'Purchased'], conditions: { platform: 'Android' }, conversionRate: 40, timeToConvert: 4 },
|
|
579
|
+
{ name: 'Upgrade', sequence: ['Viewed Plans', 'Upgraded'],
|
|
580
|
+
conditions: { seats: { gte: 10 }, plan_tier: { in: ['pro', 'enterprise'] }, country: { neq: 'US' } } },
|
|
581
|
+
]
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
each value is a scalar (strict equality) or an operator map with any of `eq`, `neq`, `in`, `nin`, `gt`, `gte`, `lt`, `lte` (1.7.0). operators within one key AND together; there is no `or`. measured: iOS 80.2% vs Android 39.8% purchased-per-viewed on the config above.
|
|
585
|
+
|
|
586
|
+
the validator throws on shapes that can never match (a function, a bare array — use `{ in: [...] }`, an unknown operator, `in`/`nin` without an array). a condition key that is not declared in `userProps`, `superProps`, or a persona's `properties` lands in `result.warnings` — only a `user` hook could supply it. users who satisfy none of your funnels fall through to standalone events; the run reports how many under `result.warnings` (`key: 'funnels.conditions'`).
|
|
587
|
+
|
|
540
588
|
### experiments
|
|
541
589
|
|
|
542
590
|
experiments are a property of funnels. any funnel with `experiment` set fires a `$experiment_started` event (with `Experiment name` / `Variant name` properties) at the start of every qualifying pass, and the assigned variant's `conversionMultiplier` / `ttcMultiplier` modify that pass:
|
|
@@ -556,6 +604,8 @@ experiment: {
|
|
|
556
604
|
|
|
557
605
|
variant assignment is **sticky by default**: a deterministic hash of `user_id` + experiment name, so a user keeps their variant across every funnel pass (matches Mixpanel experiment SDK bucketing and makes variant lift verifiable). set `sticky: false` to re-roll the variant on each pass with the seeded RNG. hooks see the resolved variant on `meta.experiment` in `funnel-pre` / `funnel-post`.
|
|
558
606
|
|
|
607
|
+
**the variant lands on the user profile (1.7.0).** every exposed user carries `"Experiment: <name>": "<variant>"` (e.g. `"Experiment: Checkout Redesign": "New Checkout"`), so the funnel breaks down by variant in Mixpanel with a user-property breakdown — no cohort built from the exposure event. stamped when the user is first exposed (respects `startDaysBeforeEnd`); never-exposed users carry nothing; the `user` hook fires before exposure and does not see it, the `everything` hook does. `stampProfile: false` turns it off; `sticky: false` implies off. measured: 0 mismatches between the profile value and the `Variant name` on 13,005 exposure events.
|
|
608
|
+
|
|
559
609
|
## user generation
|
|
560
610
|
|
|
561
611
|
users are generated with configurable birth distributions, normally controlled via the `macro` preset (see "time shape" above). these three knobs can also be set directly on the dungeon config — they override the preset's values.
|
|
@@ -573,6 +623,48 @@ users are generated with configurable birth distributions, normally controlled v
|
|
|
573
623
|
}
|
|
574
624
|
```
|
|
575
625
|
|
|
626
|
+
### personas
|
|
627
|
+
|
|
628
|
+
`personas` split users into behavioral segments. each persona carries a `weight` (share of users), `properties` merged into the profile, and three multipliers:
|
|
629
|
+
|
|
630
|
+
| field | applies to | default |
|
|
631
|
+
|---|---|---|
|
|
632
|
+
| `eventMultiplier` | the whole per-user event budget — funnel passes AND standalone events. a 3x persona runs ~3x the funnel passes (measured 2.9–3.2x) | 1.0 |
|
|
633
|
+
| `conversionModifier` | `conversionRate` on every funnel (for one segment on one funnel use `conditions`) | 1.0 |
|
|
634
|
+
| `ttcModifier` | `timeToConvert` on every funnel (0.25 = converts four times faster; measured median 0.50h vs 1.96h) — 1.7.0 | 1.0 |
|
|
635
|
+
|
|
636
|
+
**`isChurnEvent` caps `eventMultiplier`.** a churn event in the standalone pool is drawn by weight like any other event, so it ends every user after roughly the same number of events regardless of budget — the multiplier washes out (measured 1.04x for an asked 3x with a weight-1 churn event among 16 weight units; 2.90x without it). when more than half the users churn and a persona multiplier is in play, `result.warnings` says so (`key: 'personas.eventMultiplier'`). lower the churn event's weight, raise `returnLikelihood`, or drive churn from a hook.
|
|
637
|
+
|
|
638
|
+
1.7.0 removed the never-implemented `churnRate`, `activeWindow` and `soupOverride` from the `Persona` type. the validator still accepts and warns on them. `engagementDecay` per persona IS implemented and stays.
|
|
639
|
+
|
|
640
|
+
### sticky event properties
|
|
641
|
+
|
|
642
|
+
`superProps` re-roll on every event. to put a **stable per-user value on events** — the property behind the most common mixpanel breakdown — name profile keys in `stickyEventProps` (1.7.0):
|
|
643
|
+
|
|
644
|
+
```javascript
|
|
645
|
+
userProps: { plan_tier: ['free', 'pro', 'enterprise'], platform: ['iOS', 'Android'] },
|
|
646
|
+
superProps: { app_version: ['1.0', '1.1', '2.0'] },
|
|
647
|
+
switches: { stickyEventProps: ['plan_tier', 'platform', 'app_version'] }, // or top-level
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
each key must be declared in `userProps`, a persona's `properties`, or `superProps` (schema-first; undeclared keys throw). profile keys copy the profile's value (after the `user` hook). keys declared only in `superProps` resolve once per user and hold constant. sticky values land after `superProps` and before the `event` hook. measured: 67,355 of 67,355 events matched their profile. `(ctx) => ctx.profile.plan_tier` on a super prop does the same thing one field at a time.
|
|
651
|
+
|
|
652
|
+
with `hasLocation: true`, a user's events now share the user's city / region / country (1.7.0). before, every event drew a fresh random city — 0.8% of events matched their own profile.
|
|
653
|
+
|
|
654
|
+
### campaigns per user
|
|
655
|
+
|
|
656
|
+
`hasCampaigns: true` stamps UTMs on up to `maxTouchpointsPerUser` events per user, and before 1.7.0 every touchpoint drew a fresh random campaign — attribution data was uncorrelated noise. `campaignPerUser: true` (1.7.0) draws **one acquisition campaign per user** at birth, stamps its `utm_source` / `utm_campaign` / `utm_medium` / `utm_content` / `utm_term` on the profile, and every touchpoint carries those same values. any UTM key already on the profile wins over the draw, so a persona can own a channel:
|
|
657
|
+
|
|
658
|
+
```javascript
|
|
659
|
+
switches: { hasCampaigns: true, campaignPerUser: true },
|
|
660
|
+
personas: [
|
|
661
|
+
{ name: 'paid search', weight: 30, conversionModifier: 2.0, properties: { utm_source: 'google', utm_medium: 'cpc' } },
|
|
662
|
+
{ name: 'everyone else', weight: 70 },
|
|
663
|
+
]
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
"paid search converts 2x better than organic" is now declarative. measured: 0 of 399 users with more than one `utm_source`; 400 of 400 profiles carry it. ad spend is still independent of acquisitions (deferred to 1.8.0).
|
|
667
|
+
|
|
576
668
|
## seeded generation
|
|
577
669
|
|
|
578
670
|
all randomness is seeded. same seed + same config + concurrency=1 = identical output every time:
|
|
@@ -705,7 +797,7 @@ three groups of keys accept both a nested sub-object and a flat top-level form:
|
|
|
705
797
|
| sub-object | keys it groups |
|
|
706
798
|
|---|---|
|
|
707
799
|
| `credentials` | `token`, `region`, `serviceAccount`, `serviceSecret`, `projectId` |
|
|
708
|
-
| `switches` | `hasLocation`, `hasCampaigns`, `hasAdSpend`, `hasSessionIds`, `hasAvatar`, `hasIOSDevices`, `hasAndroidDevices`, `hasDesktopDevices`, `hasBrowser`, `isAnonymous`, `alsoInferFunnels` |
|
|
800
|
+
| `switches` | `hasLocation`, `hasCampaigns`, `hasAdSpend`, `hasSessionIds`, `hasAvatar`, `hasIOSDevices`, `hasAndroidDevices`, `hasDesktopDevices`, `hasBrowser`, `isAnonymous`, `alsoInferFunnels`, `singleCountry`, `campaignPerUser`, `stickyEventProps` |
|
|
709
801
|
| `identity` | `avgDevicePerUser`, `sessionTimeout` |
|
|
710
802
|
|
|
711
803
|
**the sub-object form is canonical.** the flat top-level keys are a back-compat
|
|
@@ -727,6 +819,31 @@ you will not see unless `verbose: true`.
|
|
|
727
819
|
`hasAttributionFlags` is **not** a switch. the validator derives it from
|
|
728
820
|
`events[].isAttributionEvent`; setting it has no effect.
|
|
729
821
|
|
|
822
|
+
### `result.warnings` — what the engine changed
|
|
823
|
+
|
|
824
|
+
every value the engine clamped or flagged comes back on the result, regardless of
|
|
825
|
+
`verbose` (1.7.0). a config UI that shows the requested value can now show the
|
|
826
|
+
applied one instead of lying:
|
|
827
|
+
|
|
828
|
+
```javascript
|
|
829
|
+
const { warnings } = await DUNGEON_MASTER({ macro: 'growth', percentUsersBornInDataset: 80, ... });
|
|
830
|
+
// [{ key: 'percentUsersBornInDataset', requested: 80, applied: 30, severity: 'clamp',
|
|
831
|
+
// reason: 'macro preset "growth" caps percentUsersBornInDataset at 30 to keep its shape; ...' }]
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
validator clamps come first (`percentUsersBornInDataset`, `bornRecentBias`,
|
|
835
|
+
`avgEventsPerUserPerDay`, `avgActiveDaysPerUser`, the `numDays < 14` and
|
|
836
|
+
`engagementDecay` warnings, auto-set `conversionWindowDays`), then run-level
|
|
837
|
+
aggregates with a `count`: `conversionRate` saturation per funnel and source
|
|
838
|
+
(`funnels[Checkout].conversionRate:persona "whale" conversionModifier`, requested 195,
|
|
839
|
+
applied 100), users matching no conditioned funnel (`funnels.conditions`), churn
|
|
840
|
+
washing out a persona multiplier (`personas.eventMultiplier`), and a
|
|
841
|
+
`strictEventCount` shortfall (`numEvents`). always an array, empty when nothing was
|
|
842
|
+
touched. console output stays `verbose`-gated.
|
|
843
|
+
|
|
844
|
+
the engine can only report its own clamps. a hook's own `Math.min(95, rate * 3)` never
|
|
845
|
+
reaches it — that cap belongs to the hook. see HOOKS.md.
|
|
846
|
+
|
|
730
847
|
### group keys
|
|
731
848
|
|
|
732
849
|
`groupKeys` accepts a positional tuple or a named object. both normalize to the
|
|
@@ -762,16 +879,20 @@ see [types.d.ts](types.d.ts) for the complete `Dungeon` interface. here are the
|
|
|
762
879
|
| `writeToDisk` | boolean/string | false | write files to ./data/ or a gs:// path |
|
|
763
880
|
| `gzip` | boolean | false | compress output files |
|
|
764
881
|
| `verbose` | boolean | false | print progress |
|
|
765
|
-
| `strictEventCount` | boolean | false |
|
|
882
|
+
| `strictEventCount` | boolean | false | deliver exactly `numEvents` (forces `concurrency: 1`). 1.7.0: exact when capacity allows; a shortfall lands in `result.warnings` (`key: 'numEvents'`) |
|
|
883
|
+
| `autoPowerLaw` | boolean | true | `false` turns off the automatic 45/25/15 draw on 3–19-item string arrays (uniform picks). prefer `{ __weights }` |
|
|
884
|
+
| `stickyEventProps` | string[] | `[]` | profile keys copied onto every event of the user (schema-first: must be declared) |
|
|
885
|
+
| `campaignPerUser` | boolean | false | one campaign per user; UTMs on the profile and on every touchpoint. needs `hasCampaigns` |
|
|
886
|
+
| `singleCountry` | string | undefined | pin `hasLocation` geo to one country by ISO code or name (`'US'`, `'United States'`). a value that matches nothing throws |
|
|
766
887
|
| `batchSize` | number | 2500000 | records before auto-flush |
|
|
767
888
|
| `concurrency` | number | 1 | parallel user generation |
|
|
768
|
-
| `macro` | string/object | `'flat'` | big-picture trend preset (flat/steady/growth/viral/decline) |
|
|
889
|
+
| `macro` | string/object | `'flat'` | big-picture trend preset (flat/steady/growth/viral/decline). canonical object spelling `{ preset, ...overrides }`; an object without `preset` is a custom, uncapped macro |
|
|
769
890
|
| `soup` | string/object | `'growth'` | intra-week / intra-day rhythm preset |
|
|
770
891
|
| `bornRecentBias` | number | 0 (from macro `flat`) | user birth date skew (safe range [-0.5, 0.5]; user-explicit values outside the band are clamped) |
|
|
771
|
-
| `percentUsersBornInDataset` | number | 12 (from macro `flat`) | % of users born in window (clamped
|
|
892
|
+
| `percentUsersBornInDataset` | number | 12 (from macro `flat`) | % of users born in window (clamped to the named preset's cap; every clamp lands in `result.warnings`) |
|
|
772
893
|
| `preExistingSpread` | string | `'uniform'` (from macro `flat`) | placement of pre-existing users' first event |
|
|
773
894
|
| `avgActiveDaysPerUser` | number | undefined | concentrate events onto N distinct UTC days per user (preserves total event count). ignored when `retentionCurve` is set; warns when combined with `engagementDecay` |
|
|
774
|
-
| `retentionCurve` | object | undefined | per-day return probabilities. **wins over `avgActiveDaysPerUser`** when both are set |
|
|
895
|
+
| `retentionCurve` | object | undefined | per-day return probabilities. **wins over `avgActiveDaysPerUser`** when both are set. **day 1 has a floor near 0.85 the curve cannot move** — funnel steps spill into the next day regardless of the day plan (measured 0.885 for an asked 0.15; days 7 and 30 follow the curve). verify from day 7 on |
|
|
775
896
|
| `maxTouchpointsPerUser` | number | 10 | UTM stamping cap per user (Mixpanel `TOUCHPOINTS_LIMIT` parity) |
|
|
776
897
|
| `autoSortAfterEverything` | boolean | true | sort events by time after `everything` hook (defends greedy funnel engine) |
|
|
777
898
|
| `hook` | function/string | passthrough | data transformation function |
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# 1.7.0 Upgrade Guide
|
|
2
|
+
|
|
3
|
+
**TL;DR: every 1.6.x dungeon runs unmodified, and produces byte-identical events
|
|
4
|
+
unless it uses one of three features** — `hasLocation: true` (event geo becomes
|
|
5
|
+
stable per user), `conditions` with a function or bare-array value (now throws;
|
|
6
|
+
it never matched before), or a world event on a multi-step funnel (windows now
|
|
7
|
+
test the step's real time). 1.7.0 is the **declarative-knobs release** for
|
|
8
|
+
consumers that render configs without writing hooks: segmented funnels with
|
|
9
|
+
operators, the experiment variant on the profile, context-aware property
|
|
10
|
+
functions, sticky event properties, one campaign per user, declarative weights,
|
|
11
|
+
world-event spikes, and a `result.warnings` array that says what the engine
|
|
12
|
+
changed. Three type-level fields are removed from `Persona`.
|
|
13
|
+
|
|
14
|
+
## What changed
|
|
15
|
+
|
|
16
|
+
### 1. `result.warnings` (new, additive) — read this first
|
|
17
|
+
|
|
18
|
+
Every value the validator clamped and every run-level aggregate now comes back on
|
|
19
|
+
the result, regardless of `verbose`:
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
const { warnings } = await DUNGEON_MASTER(config);
|
|
23
|
+
// [{ key, requested, applied, reason, severity: 'clamp' | 'warn', count? }]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**Action**: if you display config values to a user, display `applied`, not what
|
|
27
|
+
you sent. Keys you will see: `percentUsersBornInDataset`, `bornRecentBias`,
|
|
28
|
+
`avgEventsPerUserPerDay`, `avgActiveDaysPerUser`, `numDays`,
|
|
29
|
+
`funnels[<name>].conversionWindowDays`, `funnels[<name>].conversionRate:<source>`,
|
|
30
|
+
`funnels.conditions`, `personas.eventMultiplier`, `numEvents`, `campaignPerUser`.
|
|
31
|
+
|
|
32
|
+
### 2. `macro` object overrides apply (fix)
|
|
33
|
+
|
|
34
|
+
Before 1.7.0 `macro: { percentUsersBornInDataset: 50 }` was silently capped at 12.
|
|
35
|
+
Now an object **without** `preset` is a custom macro with no cap. An object or
|
|
36
|
+
string **with** a named preset keeps that preset's cap (flat 12, steady 12, growth
|
|
37
|
+
30, viral 55, decline 5) and reports the clamp in `warnings`.
|
|
38
|
+
|
|
39
|
+
**Action**: if you worked around this by emitting the top-level key with no
|
|
40
|
+
`macro`, you can switch to `macro: { bornRecentBias, percentUsersBornInDataset,
|
|
41
|
+
preExistingSpread }` and combine born share with any bias you like.
|
|
42
|
+
|
|
43
|
+
### 3. `singleCountry` accepts codes and throws on a miss (fix)
|
|
44
|
+
|
|
45
|
+
`'US'`, `'us'`, `'United States'` all work. `'Narnia'` throws with the valid list.
|
|
46
|
+
Before, a miss (including `'US'`) emptied the location pool and deleted every geo
|
|
47
|
+
property. Also accepted inside `switches`.
|
|
48
|
+
|
|
49
|
+
**Action**: if you validated against your own country list, drop it — the engine
|
|
50
|
+
validates now.
|
|
51
|
+
|
|
52
|
+
### 4. `strictEventCount` is exact (fix)
|
|
53
|
+
|
|
54
|
+
Lands on `numEvents` exactly when the users' capacity allows (measured 5,000 of
|
|
55
|
+
5,000; 30,000 of 30,000). If it cannot, the run stops short and `warnings` carries
|
|
56
|
+
`{ key: 'numEvents', requested, applied }`. Only under the flag.
|
|
57
|
+
|
|
58
|
+
### 5. `importResults.users` reconciles (fix)
|
|
59
|
+
|
|
60
|
+
`{ ...mixpanelImportResult, generated, dropped_anonymous }` so
|
|
61
|
+
`generated - dropped_anonymous - failed === success`.
|
|
62
|
+
|
|
63
|
+
**Action**: stop overwriting `success` with your own user count.
|
|
64
|
+
|
|
65
|
+
### 6. `funnels[].conditions` operators (new)
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
conditions: {
|
|
69
|
+
platform: 'iOS', // scalar: strict equality (unchanged)
|
|
70
|
+
plan_tier: { in: ['pro', 'enterprise'] },
|
|
71
|
+
seats: { gte: 10 },
|
|
72
|
+
country: { neq: 'US' },
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`eq`, `neq`, `in`, `nin`, `gt`, `gte`, `lt`, `lte`. **Breaking (B1)**: a function or
|
|
77
|
+
bare-array condition value now throws at validation instead of silently never
|
|
78
|
+
matching. Undeclared condition keys and users who match no funnel land in `warnings`.
|
|
79
|
+
|
|
80
|
+
### 7. Experiment variant on the profile (new, default on)
|
|
81
|
+
|
|
82
|
+
Exposed users carry `"Experiment: <name>": "<variant>"`. `experiment: { stampProfile:
|
|
83
|
+
false }` opts out; `sticky: false` implies off. The `user` hook does not see it (it
|
|
84
|
+
fires before exposure); `everything` does.
|
|
85
|
+
|
|
86
|
+
### 8. `(ctx) => value` property functions (new)
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
revenue: (ctx) => ctx.profile.plan === 'pro' ? 100 : 10,
|
|
90
|
+
total: (ctx) => ctx.event.price * ctx.event.quantity,
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`ctx = { profile?, event?, time?, config }`. Zero-arity functions are untouched.
|
|
94
|
+
A function that declares a parameter skips the source-string cache. Bound natives
|
|
95
|
+
(`chance.x.bind(chance)`) are still called with no argument. JSON dungeons: the
|
|
96
|
+
`arrow` form now emits `(ctx) => body`.
|
|
97
|
+
|
|
98
|
+
### 9. `stickyEventProps` + stable location (new + fix B2)
|
|
99
|
+
|
|
100
|
+
`stickyEventProps: ['plan_tier', 'platform']` copies profile keys onto every event
|
|
101
|
+
(schema-first: undeclared keys throw). **B2**: with `hasLocation: true` each user's
|
|
102
|
+
events now share the user's city / region / country. Event geo distributions
|
|
103
|
+
change at the same seed; profiles do not.
|
|
104
|
+
|
|
105
|
+
### 10. `personas[].ttcModifier` (new) and dead fields removed (B3)
|
|
106
|
+
|
|
107
|
+
`ttcModifier: 0.5` halves `timeToConvert` for the persona. `churnRate`,
|
|
108
|
+
`activeWindow`, `soupOverride` are removed from the `Persona` type — they never
|
|
109
|
+
did anything. Runtime still accepts and warns on them. `churnRate` is no longer
|
|
110
|
+
defaulted to `0` on the validated persona.
|
|
111
|
+
|
|
112
|
+
### 11. `campaignPerUser` (new)
|
|
113
|
+
|
|
114
|
+
One campaign per user at birth, UTMs on the profile and on every touchpoint.
|
|
115
|
+
Persona `properties` that set `utm_*` win over the draw. Needs `hasCampaigns`.
|
|
116
|
+
|
|
117
|
+
### 12. `{ __weights }` and `autoPowerLaw` (new)
|
|
118
|
+
|
|
119
|
+
`{ __weights: { free: 60, pro: 30, enterprise: 10 } }` states a distribution.
|
|
120
|
+
`autoPowerLaw: false` turns the implicit 45/25/15 draw on 3–19-item string arrays
|
|
121
|
+
off for the run. Default behavior is unchanged.
|
|
122
|
+
|
|
123
|
+
### 13. `worldEvents[].volumeMultiplier > 1` amplifies (fix)
|
|
124
|
+
|
|
125
|
+
Affected in-window events are cloned (fresh `insert_id`, spread across the window)
|
|
126
|
+
so volume reaches the stated multiple — measured 3.06x for an asked 3x (1.08x
|
|
127
|
+
before). Fractional multipliers work. Windows on funnel steps after the first now
|
|
128
|
+
test the step's real time.
|
|
129
|
+
|
|
130
|
+
**Action**: delete any hook that cloned events to fake a spike.
|
|
131
|
+
|
|
132
|
+
### 14. `eventMultiplier` and `isChurnEvent` (doc + warning)
|
|
133
|
+
|
|
134
|
+
`eventMultiplier` does reach funnel passes (measured ~3x for 3x). What washes it
|
|
135
|
+
out is an `isChurnEvent` in the standalone pool, which ends every user after about
|
|
136
|
+
the same number of events. The run now warns (`personas.eventMultiplier`) when more
|
|
137
|
+
than half the users churn while a persona multiplier is in play.
|
|
138
|
+
|
|
139
|
+
**Action**: if you refused persona multipliers on funnel-heavy schemas, refuse them
|
|
140
|
+
on churn-heavy schemas instead — or lower the churn event's weight.
|
|
141
|
+
|
|
142
|
+
### 15. Day-1 retention floor (doc)
|
|
143
|
+
|
|
144
|
+
`retentionCurve` cannot move day 1 below ~0.85 on funnel-driven dungeons; verify
|
|
145
|
+
from day 7 on. Stated in the `retentionCurve` JSDoc, README, and HOOKS.md §2.7.
|
|
146
|
+
|
|
147
|
+
## Verification
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
npx vitest run tests/unit tests/integration tests/e2e 2>&1 | tail -50 # 1967 tests pass
|
|
151
|
+
npm run typecheck
|
|
152
|
+
node tests/engine/smoke-test-all.mjs # 22/22
|
|
153
|
+
npx vitest run tests/integration/v170-engine-requests.test.js # the measured claims
|
|
154
|
+
```
|