@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 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: 15, preExistingSpread: 'uniform' } // fully custom
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 | stop at exact numEvents |
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 per-macro when both `macro` and this field are explicit) |
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
+ ```