@ak--47/dungeon-master 1.6.5 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/analyze-soup/SKILL.md +21 -11
- package/.claude/skills/create-dungeon/SKILL.md +49 -10
- package/.claude/skills/create-project/SKILL.md +22 -3
- package/.claude/skills/create-project/context.mjs +89 -0
- package/.claude/skills/create-project/provision.mjs +1 -60
- package/.claude/skills/headless-build/SKILL.md +18 -1
- package/.claude/skills/powertools/SKILL.md +20 -1
- package/.claude/skills/release-check/SKILL.md +99 -0
- package/.claude/skills/verify-dungeon/SKILL.md +71 -16
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +14 -0
- package/.claude/skills/verify-dungeon/references/report-format.md +18 -1
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +36 -1
- package/.claude/skills/warehouse-metrics/GAPS-template.md +34 -0
- package/.claude/skills/warehouse-metrics/SKILL.md +105 -0
- package/.claude/skills/warehouse-metrics/deploy.mjs +651 -0
- package/.claude/skills/write-hooks/SKILL.md +33 -3
- package/CHANGELOG.md +331 -0
- package/HOOKS.md +154 -5
- package/README.md +357 -8
- package/docs/guides/1.7.0-upgrade-guide.md +154 -0
- package/docs/guides/1.8.0-upgrade-guide.md +151 -0
- package/dungeons/technical/warehouse.js +187 -0
- package/index.js +131 -2
- package/lib/core/config-validator.js +264 -13
- package/lib/core/context.js +39 -0
- package/lib/core/dungeon-loader.js +5 -2
- package/lib/core/storage.js +51 -3
- package/lib/generators/events.js +53 -7
- package/lib/generators/funnels.js +85 -11
- package/lib/generators/profiles.js +9 -4
- package/lib/generators/standalone.js +248 -0
- package/lib/generators/warehouse.js +828 -0
- package/lib/orchestrators/mixpanel-sender.js +39 -3
- package/lib/orchestrators/user-loop.js +240 -9
- package/lib/templates/story-spec.schema.json +41 -16
- package/lib/utils/conditions.js +62 -0
- package/lib/utils/json-evaluator.js +12 -2
- package/lib/utils/utils.js +115 -19
- package/lib/verify/index.js +1 -0
- package/lib/verify/schema-validator.js +8 -0
- package/lib/verify/story-runner.js +71 -8
- package/lib/verify/warehouse.js +683 -0
- package/package.json +5 -11
- package/scripts/verify-stories.mjs +150 -44
- package/types.d.ts +606 -38
package/README.md
CHANGED
|
@@ -91,6 +91,11 @@ const result = await DUNGEON_MASTER({
|
|
|
91
91
|
console.log(result.importResults);
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
+
`token` imports event-shaped streams only: events, users, groups, ad spend, and
|
|
95
|
+
`standaloneEvents`. `warehouseMetrics` does **not** import through this path.
|
|
96
|
+
warehouse tables are materialized locally and need a separate warehouse deploy
|
|
97
|
+
step after the run.
|
|
98
|
+
|
|
94
99
|
## dungeons
|
|
95
100
|
|
|
96
101
|
a dungeon is a javascript file that exports a configuration object. it defines your entire data model: events, funnels, user properties, group analytics, SCDs, and a hook function that engineers discoverable patterns into the data.
|
|
@@ -155,7 +160,33 @@ import { createTextGenerator, generateBatch } from '@ak--47/dungeon-master/text'
|
|
|
155
160
|
|
|
156
161
|
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
162
|
|
|
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.
|
|
163
|
+
**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).
|
|
164
|
+
|
|
165
|
+
**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).
|
|
166
|
+
|
|
167
|
+
```javascript
|
|
168
|
+
userProps: {
|
|
169
|
+
plan_tier: { __weights: { free: 60, pro: 30, enterprise: 10 } }, // honest 60/30/10
|
|
170
|
+
platform: ['iOS', 'Android', 'web'], // 45/25/15 power law (default)
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**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.
|
|
175
|
+
|
|
176
|
+
```javascript
|
|
177
|
+
userProps: {
|
|
178
|
+
plan: ['free', 'pro'],
|
|
179
|
+
revenue: (ctx) => ctx.profile.plan === 'pro' ? 100 : 10, // profile keys resolve in declaration order
|
|
180
|
+
},
|
|
181
|
+
events: [{ event: 'Purchased', properties: {
|
|
182
|
+
price: () => integer(5, 500),
|
|
183
|
+
quantity: [1, 1, 1, 2, 3],
|
|
184
|
+
total: (ctx) => ctx.event.price * ctx.event.quantity, // event props resolve in declaration order
|
|
185
|
+
}}],
|
|
186
|
+
superProps: { plan_on_event: (ctx) => ctx.profile.plan }, // or: stickyEventProps: ['plan']
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`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
190
|
|
|
160
191
|
### named exports
|
|
161
192
|
|
|
@@ -406,10 +437,14 @@ mix and match. most dungeons want `macro: "flat"` (the chart doesn't blow up at
|
|
|
406
437
|
```javascript
|
|
407
438
|
macro: 'flat' // default
|
|
408
439
|
macro: 'growth' // preset string
|
|
409
|
-
macro: { preset: 'growth', percentUsersBornInDataset: 40 } // preset + override
|
|
410
|
-
macro: { bornRecentBias: 0, percentUsersBornInDataset:
|
|
440
|
+
macro: { preset: 'growth', percentUsersBornInDataset: 40 } // preset + override (canonical object spelling)
|
|
441
|
+
macro: { bornRecentBias: 0.3, percentUsersBornInDataset: 50 } // custom macro — no preset, no cap
|
|
411
442
|
```
|
|
412
443
|
|
|
444
|
+
**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.
|
|
445
|
+
|
|
446
|
+
**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.
|
|
447
|
+
|
|
413
448
|
## timesoup (intra-week / intra-day rhythm)
|
|
414
449
|
|
|
415
450
|
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.
|
|
@@ -507,6 +542,8 @@ dungeon-master generates multiple data types that mirror a real analytics implem
|
|
|
507
542
|
| SCDs | `scdProps` | slowly changing dimensions (subscription tier over time) |
|
|
508
543
|
| lookup tables | `lookupTables` | dimension tables (product catalog, region mapping) |
|
|
509
544
|
| ad spend | `hasAdSpend` | daily ad spend with impressions, clicks, cost metrics |
|
|
545
|
+
| standalone events | `standaloneEvents` | identity-less metric snapshots on a cadence (infrastructure, finance, ops) |
|
|
546
|
+
| warehouse metrics | `warehouseMetrics` | warehouse source tables derived from generated events, with a manifest for downstream deploy |
|
|
510
547
|
| mirror datasets | `mirrorProps` | transformed copies of event data (A/B versions) |
|
|
511
548
|
| organic text | `createTextGenerator` | reviews, support tickets, search queries, chat messages |
|
|
512
549
|
|
|
@@ -537,6 +574,24 @@ funnels: [
|
|
|
537
574
|
|
|
538
575
|
ordering strategies: `sequential`, `random`, `first-fixed`, `last-fixed`, `first-and-last-fixed`, `middle-fixed`, `interrupted`
|
|
539
576
|
|
|
577
|
+
### segmented funnels (`conditions`)
|
|
578
|
+
|
|
579
|
+
`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:
|
|
580
|
+
|
|
581
|
+
```javascript
|
|
582
|
+
userProps: { platform: ['iOS', 'Android'], seats: [1, 5, 10, 20] },
|
|
583
|
+
funnels: [
|
|
584
|
+
{ name: 'Checkout', sequence: ['Viewed Item', 'Purchased'], conditions: { platform: 'iOS' }, conversionRate: 80, timeToConvert: 0.5 },
|
|
585
|
+
{ name: 'Checkout', sequence: ['Viewed Item', 'Purchased'], conditions: { platform: 'Android' }, conversionRate: 40, timeToConvert: 4 },
|
|
586
|
+
{ name: 'Upgrade', sequence: ['Viewed Plans', 'Upgraded'],
|
|
587
|
+
conditions: { seats: { gte: 10 }, plan_tier: { in: ['pro', 'enterprise'] }, country: { neq: 'US' } } },
|
|
588
|
+
]
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
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.
|
|
592
|
+
|
|
593
|
+
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'`).
|
|
594
|
+
|
|
540
595
|
### experiments
|
|
541
596
|
|
|
542
597
|
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 +611,224 @@ experiment: {
|
|
|
556
611
|
|
|
557
612
|
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
613
|
|
|
614
|
+
**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.
|
|
615
|
+
|
|
616
|
+
## standalone events (identity-less metric snapshots)
|
|
617
|
+
|
|
618
|
+
`standaloneEvents` generates records that describe a **system, not a person**. they carry
|
|
619
|
+
no `user_id` and no `device_id`. use them for infrastructure, finance, and ops telemetry:
|
|
620
|
+
daily CDN egress per region, weekly billing rollups per plan tier, hourly queue depth per
|
|
621
|
+
cluster. `hasAdSpend` is the same idea hard-coded to `$ad_spend`; this is the general form
|
|
622
|
+
and it does not use a Mixpanel reserved event name.
|
|
623
|
+
|
|
624
|
+
```javascript
|
|
625
|
+
standaloneEvents: [
|
|
626
|
+
{
|
|
627
|
+
event: 'cdn_egress',
|
|
628
|
+
cadence: 'day', // 'hour' | 'day' | 'week' (default 'day')
|
|
629
|
+
dimensions: { region: ['us-east', 'us-west', 'eu'] }, // cross-producted
|
|
630
|
+
distinctIdFrom: 'region', // synthetic id, never a person
|
|
631
|
+
properties: {
|
|
632
|
+
gb_out: (ctx) => 400 + ctx.tickIndex * 3, // shape a trend across the window
|
|
633
|
+
cost_usd: (ctx) => (400 + ctx.tickIndex * 3) * 0.085,
|
|
634
|
+
p95_ms: [120, 140, 160], // same ValueValid forms as event props
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
event: 'billing_rollup',
|
|
639
|
+
cadence: 'week',
|
|
640
|
+
dimensions: { tier: ['free', 'pro', 'max'] },
|
|
641
|
+
properties: { mrr_usd: (ctx) => ..., churn_usd: (ctx) => ... },
|
|
642
|
+
},
|
|
643
|
+
]
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
the engine emits **one record per cadence tick per dimension cross-product row**. the
|
|
647
|
+
example above produces 3 records per day (`cdn_egress`) plus 3 records per week
|
|
648
|
+
(`billing_rollup`). ticks start at the dataset start and step by the cadence; the last tick
|
|
649
|
+
is the final one at or before the dataset end, so nothing lands in the future.
|
|
650
|
+
|
|
651
|
+
each record carries `event`, `time`, `insert_id`, `distinct_id`, every dimension as a flat
|
|
652
|
+
property, and every resolved entry in `properties`.
|
|
653
|
+
|
|
654
|
+
| field | behavior |
|
|
655
|
+
|---|---|
|
|
656
|
+
| `event` | required, unique across `standaloneEvents` |
|
|
657
|
+
| `cadence` | `'hour'`, `'day'`, or `'week'`. default `'day'` |
|
|
658
|
+
| `dimensions` | object of non-empty arrays, cross-producted. omit for one record per tick |
|
|
659
|
+
| `distinctIdFrom` | must name a declared dimension. omitted → `distinct_id` is the event name |
|
|
660
|
+
| `properties` | keys may not collide with a dimension or with `event`/`time`/`insert_id`/`distinct_id`/`user_id`/`device_id` |
|
|
661
|
+
|
|
662
|
+
property value functions receive a `StandaloneValueContext`: `{ time, config, dimensions,
|
|
663
|
+
tickIndex, tickCount, cadence, event }`. `tickIndex / (tickCount - 1)` is window progress —
|
|
664
|
+
use it to shape growth, a dip, or a spike, guarding `tickCount <= 1` before division.
|
|
665
|
+
|
|
666
|
+
the stream lands in `result.standaloneEventData`, writes to its own `-STANDALONE` file
|
|
667
|
+
shard, and imports to Mixpanel as its own event stream. hooks fire with type
|
|
668
|
+
`"standalone"`; `meta.spec` carries the resolved config so a hook can tell streams apart.
|
|
669
|
+
the hook runs before the user loop. return the record object or an array of records;
|
|
670
|
+
returning `undefined` drops the record. it has no person metadata and never enters
|
|
671
|
+
`everything`. warehouse hooks have a different contract: mutate the row in place;
|
|
672
|
+
their return values are ignored.
|
|
673
|
+
|
|
674
|
+
```javascript
|
|
675
|
+
hook: (record, type, meta) => {
|
|
676
|
+
if (type === 'standalone' && meta.spec.event === 'cdn_egress' && record.region === 'us-east') {
|
|
677
|
+
record.p95_ms *= 40;
|
|
678
|
+
}
|
|
679
|
+
return record;
|
|
680
|
+
}
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
validation throws rather than skipping. a malformed entry would silently drop a whole data
|
|
684
|
+
stream, and you would not notice until the charts were wrong.
|
|
685
|
+
|
|
686
|
+
## warehouse metrics (local source tables)
|
|
687
|
+
|
|
688
|
+
`warehouseMetrics` materializes warehouse-ready tables from the run's own event
|
|
689
|
+
stream after user generation completes. use it when you need a bookings table, a
|
|
690
|
+
subscription level snapshot, or an ARR table that reads like a real warehouse
|
|
691
|
+
source. these rows land in `result.warehouseMetricData`, write to
|
|
692
|
+
`<name>-WAREHOUSE-<table>.csv|json`, and emit one manifest at
|
|
693
|
+
`<name>-WAREHOUSE-MANIFEST.json`.
|
|
694
|
+
|
|
695
|
+
they are **not** imported by `token`. that is deliberate. the live path is:
|
|
696
|
+
|
|
697
|
+
1. run the dungeon
|
|
698
|
+
2. review `/warehouse-metrics` in dry-run mode
|
|
699
|
+
3. obtain explicit operator consent for live execution
|
|
700
|
+
4. load the tables to bigquery and save the metrics there
|
|
701
|
+
|
|
702
|
+
live deploy uses `bq load --replace`, so it overwrites the destination warehouse
|
|
703
|
+
tables. the shipped script does not prompt on its own, so the operator or agent
|
|
704
|
+
must obtain explicit consent before running it in live mode. if the
|
|
705
|
+
warehouse CRUD docs route returns 404, the deploy still loads tables and connects
|
|
706
|
+
the source, then writes `warehouse/GAPS.md` for manual metric creation.
|
|
707
|
+
|
|
708
|
+
the manifest carries `recommendedAggregation: 'sum' | 'last value'`. the
|
|
709
|
+
Mixpanel warehouse metric API spells that second value as `last_value`; the
|
|
710
|
+
deploy flow maps it for you.
|
|
711
|
+
|
|
712
|
+
there is one real preview trap: `previewWarehouseMetric` rejects raw SQL
|
|
713
|
+
containing `DROP`, `DELETE`, `TRUNCATE`, `ALTER`, `CREATE`, `INSERT`, or
|
|
714
|
+
`UPDATE` as plain substrings. `created_at` trips `CREATE`; `updated_at` trips
|
|
715
|
+
`UPDATE`. aliasing only helps if the blocked text disappears from the query
|
|
716
|
+
entirely.
|
|
717
|
+
|
|
718
|
+
### canonical shapes
|
|
719
|
+
|
|
720
|
+
additive daily bookings:
|
|
721
|
+
|
|
722
|
+
```javascript
|
|
723
|
+
warehouseMetrics: [{
|
|
724
|
+
name: 'daily_new_bookings',
|
|
725
|
+
source: {
|
|
726
|
+
event: 'new_booking',
|
|
727
|
+
measure: 'sum',
|
|
728
|
+
property: 'booking_value',
|
|
729
|
+
},
|
|
730
|
+
timeColumn: 'date',
|
|
731
|
+
valueColumn: 'bookings',
|
|
732
|
+
}]
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
point-in-time daily active subscriptions:
|
|
736
|
+
|
|
737
|
+
```javascript
|
|
738
|
+
warehouseMetrics: [{
|
|
739
|
+
name: 'daily_active_subscriptions',
|
|
740
|
+
type: 'point-in-time',
|
|
741
|
+
source: {
|
|
742
|
+
event: 'subscription_started',
|
|
743
|
+
minus: 'subscription_cancelled',
|
|
744
|
+
measure: 'count',
|
|
745
|
+
},
|
|
746
|
+
baseline: 40,
|
|
747
|
+
timeColumn: 'date',
|
|
748
|
+
valueColumn: 'active_subscriptions',
|
|
749
|
+
}]
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
sparse monthly ARR with backfill:
|
|
753
|
+
|
|
754
|
+
```javascript
|
|
755
|
+
warehouseMetrics: [{
|
|
756
|
+
name: 'monthly_arr_snapshot',
|
|
757
|
+
type: 'point-in-time',
|
|
758
|
+
grain: 'month',
|
|
759
|
+
sparse: true,
|
|
760
|
+
history: 18,
|
|
761
|
+
source: {
|
|
762
|
+
event: 'subscription_started',
|
|
763
|
+
minus: 'subscription_cancelled',
|
|
764
|
+
measure: 'sum',
|
|
765
|
+
property: 'monthly_value',
|
|
766
|
+
},
|
|
767
|
+
baseline: 24000,
|
|
768
|
+
scale: 12,
|
|
769
|
+
timeColumn: 'month',
|
|
770
|
+
valueColumn: 'arr_usd',
|
|
771
|
+
}]
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
the shipped technical fixture uses a 60-day live window plus 18 monthly backfill
|
|
775
|
+
buckets. sample row counts are illustrative only. `grain`, `history`, `sparse`,
|
|
776
|
+
and `groupBy` all change how many rows a table emits.
|
|
777
|
+
|
|
778
|
+
### config surface
|
|
779
|
+
|
|
780
|
+
| key | default | range / contract |
|
|
781
|
+
|---|---|---|
|
|
782
|
+
| `name` | required | unique table / metric name, `/^[a-z][a-z0-9_]{0,63}$/` |
|
|
783
|
+
| `type` | `'additive'` | `'additive'` or `'point-in-time'` |
|
|
784
|
+
| `grain` | `'day'` | `'day'`, `'week'`, `'month'` |
|
|
785
|
+
| `sparse` | `false` | boolean, valid only with `type: 'point-in-time'` |
|
|
786
|
+
| `source.event` | required | string or string[] of declared source events |
|
|
787
|
+
| `source.minus` | `[]` | string or string[] of declared subtractive events |
|
|
788
|
+
| `source.measure` | `'count'` | `'count'`, `'sum'`, `'avg'`, `'dau'`, `'users'`; point-in-time forbids `'avg'` and `'dau'` |
|
|
789
|
+
| `source.property` | `null` | required for `'sum'` and `'avg'`; must be declared on every source event or in `superProps` |
|
|
790
|
+
| `source.where` | `null` | optional function over flat event rows |
|
|
791
|
+
| `source.groupBy` | `[]` | up to 2 keys, each declared on every source event or in `superProps`; observed cardinality above 50 warns |
|
|
792
|
+
| `timeColumn` | `'date'` | valid JS identifier; becomes the ordered time axis in rows and manifest |
|
|
793
|
+
| `valueColumn` | `'value'` | valid JS identifier |
|
|
794
|
+
| `baseline` | `0` | number `>= 0`; used only for point-in-time metrics, ignored on additive |
|
|
795
|
+
| `scale` | `1` | finite number `> 0`, applied after bucket aggregation |
|
|
796
|
+
| `noise` | `0` | finite number, clamped to `[0, 0.5]` with a warning |
|
|
797
|
+
| `history` | `0` | integer `>= 0`; warns above roughly 3 years at each grain (`1095` day, `156` week, `36` month) |
|
|
798
|
+
| `columns` | `{}` | extra declared output columns; keys must be valid identifiers and cannot collide with time/value/groupBy columns |
|
|
799
|
+
| `format` | dungeon `format`, else `'csv'` | `'csv'` or `'json'` |
|
|
800
|
+
|
|
801
|
+
materialized tables are deterministic at the same seed and do not perturb the
|
|
802
|
+
event stream. the warehouse pass runs after the user loop, so seeded noise and
|
|
803
|
+
derived columns never change generated events.
|
|
804
|
+
|
|
805
|
+
### result and manifest
|
|
806
|
+
|
|
807
|
+
```javascript
|
|
808
|
+
const result = await DUNGEON_MASTER(config);
|
|
809
|
+
|
|
810
|
+
result.warehouseMetricData.daily_new_bookings
|
|
811
|
+
result.warehouseManifest.tables
|
|
812
|
+
result.files
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
each manifest table includes:
|
|
816
|
+
|
|
817
|
+
| field | meaning |
|
|
818
|
+
|---|---|
|
|
819
|
+
| `table` | warehouse table name |
|
|
820
|
+
| `file` | file prefix without extension |
|
|
821
|
+
| `format` | `'csv'` or `'json'` |
|
|
822
|
+
| `grain` | bucket grain |
|
|
823
|
+
| `type` | additive vs point-in-time |
|
|
824
|
+
| `timeColumn` | date axis column |
|
|
825
|
+
| `valueColumn` | numeric value column |
|
|
826
|
+
| `dimensionColumns` | copied `groupBy` keys |
|
|
827
|
+
| `columns` | ordered BigQuery schema (`DATE`, `FLOAT64`, `BOOL`, `STRING`) |
|
|
828
|
+
| `recommendedAggregation` | `'sum'` or `'last value'` |
|
|
829
|
+
| `sql` | `SELECT * FROM \`{{DATASET}}.<table>\` ORDER BY <timeColumn>` |
|
|
830
|
+
| `refreshHint` | currently `'hourly'` |
|
|
831
|
+
|
|
559
832
|
## user generation
|
|
560
833
|
|
|
561
834
|
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 +846,48 @@ users are generated with configurable birth distributions, normally controlled v
|
|
|
573
846
|
}
|
|
574
847
|
```
|
|
575
848
|
|
|
849
|
+
### personas
|
|
850
|
+
|
|
851
|
+
`personas` split users into behavioral segments. each persona carries a `weight` (share of users), `properties` merged into the profile, and three multipliers:
|
|
852
|
+
|
|
853
|
+
| field | applies to | default |
|
|
854
|
+
|---|---|---|
|
|
855
|
+
| `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 |
|
|
856
|
+
| `conversionModifier` | `conversionRate` on every funnel (for one segment on one funnel use `conditions`) | 1.0 |
|
|
857
|
+
| `ttcModifier` | `timeToConvert` on every funnel (0.25 = converts four times faster; measured median 0.50h vs 1.96h) — 1.7.0 | 1.0 |
|
|
858
|
+
|
|
859
|
+
**`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.
|
|
860
|
+
|
|
861
|
+
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.
|
|
862
|
+
|
|
863
|
+
### sticky event properties
|
|
864
|
+
|
|
865
|
+
`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):
|
|
866
|
+
|
|
867
|
+
```javascript
|
|
868
|
+
userProps: { plan_tier: ['free', 'pro', 'enterprise'], platform: ['iOS', 'Android'] },
|
|
869
|
+
superProps: { app_version: ['1.0', '1.1', '2.0'] },
|
|
870
|
+
switches: { stickyEventProps: ['plan_tier', 'platform', 'app_version'] }, // or top-level
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
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.
|
|
874
|
+
|
|
875
|
+
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.
|
|
876
|
+
|
|
877
|
+
### campaigns per user
|
|
878
|
+
|
|
879
|
+
`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:
|
|
880
|
+
|
|
881
|
+
```javascript
|
|
882
|
+
switches: { hasCampaigns: true, campaignPerUser: true },
|
|
883
|
+
personas: [
|
|
884
|
+
{ name: 'paid search', weight: 30, conversionModifier: 2.0, properties: { utm_source: 'google', utm_medium: 'cpc' } },
|
|
885
|
+
{ name: 'everyone else', weight: 70 },
|
|
886
|
+
]
|
|
887
|
+
```
|
|
888
|
+
|
|
889
|
+
"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).
|
|
890
|
+
|
|
576
891
|
## seeded generation
|
|
577
892
|
|
|
578
893
|
all randomness is seeded. same seed + same config + concurrency=1 = identical output every time:
|
|
@@ -605,8 +920,11 @@ result.userProfilesData // user profiles
|
|
|
605
920
|
result.scdTableData // SCD mutations
|
|
606
921
|
result.groupProfilesData // group profiles
|
|
607
922
|
result.adSpendData // ad spend data
|
|
923
|
+
result.standaloneEventData // identity-less event snapshots
|
|
608
924
|
result.lookupTableData // lookup table entries
|
|
609
925
|
result.mirrorEventData // mirror dataset
|
|
926
|
+
result.warehouseMetricData // warehouse tables keyed by metric name
|
|
927
|
+
result.warehouseManifest // warehouse table manifest
|
|
610
928
|
|
|
611
929
|
result.eventCount // total event count
|
|
612
930
|
result.userCount // total user count
|
|
@@ -705,7 +1023,7 @@ three groups of keys accept both a nested sub-object and a flat top-level form:
|
|
|
705
1023
|
| sub-object | keys it groups |
|
|
706
1024
|
|---|---|
|
|
707
1025
|
| `credentials` | `token`, `region`, `serviceAccount`, `serviceSecret`, `projectId` |
|
|
708
|
-
| `switches` | `hasLocation`, `hasCampaigns`, `hasAdSpend`, `hasSessionIds`, `hasAvatar`, `hasIOSDevices`, `hasAndroidDevices`, `hasDesktopDevices`, `hasBrowser`, `isAnonymous`, `alsoInferFunnels` |
|
|
1026
|
+
| `switches` | `hasLocation`, `hasCampaigns`, `hasAdSpend`, `hasSessionIds`, `hasAvatar`, `hasIOSDevices`, `hasAndroidDevices`, `hasDesktopDevices`, `hasBrowser`, `isAnonymous`, `alsoInferFunnels`, `singleCountry`, `campaignPerUser`, `stickyEventProps` |
|
|
709
1027
|
| `identity` | `avgDevicePerUser`, `sessionTimeout` |
|
|
710
1028
|
|
|
711
1029
|
**the sub-object form is canonical.** the flat top-level keys are a back-compat
|
|
@@ -727,6 +1045,31 @@ you will not see unless `verbose: true`.
|
|
|
727
1045
|
`hasAttributionFlags` is **not** a switch. the validator derives it from
|
|
728
1046
|
`events[].isAttributionEvent`; setting it has no effect.
|
|
729
1047
|
|
|
1048
|
+
### `result.warnings` — what the engine changed
|
|
1049
|
+
|
|
1050
|
+
every value the engine clamped or flagged comes back on the result, regardless of
|
|
1051
|
+
`verbose` (1.7.0). a config UI that shows the requested value can now show the
|
|
1052
|
+
applied one instead of lying:
|
|
1053
|
+
|
|
1054
|
+
```javascript
|
|
1055
|
+
const { warnings } = await DUNGEON_MASTER({ macro: 'growth', percentUsersBornInDataset: 80, ... });
|
|
1056
|
+
// [{ key: 'percentUsersBornInDataset', requested: 80, applied: 30, severity: 'clamp',
|
|
1057
|
+
// reason: 'macro preset "growth" caps percentUsersBornInDataset at 30 to keep its shape; ...' }]
|
|
1058
|
+
```
|
|
1059
|
+
|
|
1060
|
+
validator clamps come first (`percentUsersBornInDataset`, `bornRecentBias`,
|
|
1061
|
+
`avgEventsPerUserPerDay`, `avgActiveDaysPerUser`, the `numDays < 14` and
|
|
1062
|
+
`engagementDecay` warnings, auto-set `conversionWindowDays`), then run-level
|
|
1063
|
+
aggregates with a `count`: `conversionRate` saturation per funnel and source
|
|
1064
|
+
(`funnels[Checkout].conversionRate:persona "whale" conversionModifier`, requested 195,
|
|
1065
|
+
applied 100), users matching no conditioned funnel (`funnels.conditions`), churn
|
|
1066
|
+
washing out a persona multiplier (`personas.eventMultiplier`), and a
|
|
1067
|
+
`strictEventCount` shortfall (`numEvents`). always an array, empty when nothing was
|
|
1068
|
+
touched. console output stays `verbose`-gated.
|
|
1069
|
+
|
|
1070
|
+
the engine can only report its own clamps. a hook's own `Math.min(95, rate * 3)` never
|
|
1071
|
+
reaches it — that cap belongs to the hook. see HOOKS.md.
|
|
1072
|
+
|
|
730
1073
|
### group keys
|
|
731
1074
|
|
|
732
1075
|
`groupKeys` accepts a positional tuple or a named object. both normalize to the
|
|
@@ -762,22 +1105,28 @@ see [types.d.ts](types.d.ts) for the complete `Dungeon` interface. here are the
|
|
|
762
1105
|
| `writeToDisk` | boolean/string | false | write files to ./data/ or a gs:// path |
|
|
763
1106
|
| `gzip` | boolean | false | compress output files |
|
|
764
1107
|
| `verbose` | boolean | false | print progress |
|
|
765
|
-
| `strictEventCount` | boolean | false |
|
|
1108
|
+
| `strictEventCount` | boolean | false | deliver exactly `numEvents` (forces `concurrency: 1`). 1.7.0: exact when capacity allows; a shortfall lands in `result.warnings` (`key: 'numEvents'`) |
|
|
1109
|
+
| `autoPowerLaw` | boolean | true | `false` turns off the automatic 45/25/15 draw on 3–19-item string arrays (uniform picks). prefer `{ __weights }` |
|
|
1110
|
+
| `stickyEventProps` | string[] | `[]` | profile keys copied onto every event of the user (schema-first: must be declared) |
|
|
1111
|
+
| `campaignPerUser` | boolean | false | one campaign per user; UTMs on the profile and on every touchpoint. needs `hasCampaigns` |
|
|
1112
|
+
| `singleCountry` | string | undefined | pin `hasLocation` geo to one country by ISO code or name (`'US'`, `'United States'`). a value that matches nothing throws |
|
|
766
1113
|
| `batchSize` | number | 2500000 | records before auto-flush |
|
|
767
1114
|
| `concurrency` | number | 1 | parallel user generation |
|
|
768
|
-
| `macro` | string/object | `'flat'` | big-picture trend preset (flat/steady/growth/viral/decline) |
|
|
1115
|
+
| `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
1116
|
| `soup` | string/object | `'growth'` | intra-week / intra-day rhythm preset |
|
|
770
1117
|
| `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
|
|
1118
|
+
| `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
1119
|
| `preExistingSpread` | string | `'uniform'` (from macro `flat`) | placement of pre-existing users' first event |
|
|
773
1120
|
| `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 |
|
|
1121
|
+
| `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
1122
|
| `maxTouchpointsPerUser` | number | 10 | UTM stamping cap per user (Mixpanel `TOUCHPOINTS_LIMIT` parity) |
|
|
776
1123
|
| `autoSortAfterEverything` | boolean | true | sort events by time after `everything` hook (defends greedy funnel engine) |
|
|
777
1124
|
| `hook` | function/string | passthrough | data transformation function |
|
|
778
1125
|
| `hasLocation` | boolean | false | include geo properties |
|
|
779
1126
|
| `hasCampaigns` | boolean | false | include UTM properties |
|
|
780
1127
|
| `hasAdSpend` | boolean | false | generate ad spend data |
|
|
1128
|
+
| `standaloneEvents` | array | `[]` | identity-less cadence streams that import as events |
|
|
1129
|
+
| `warehouseMetrics` | array | `[]` | local warehouse source tables + manifest, derived from generated events |
|
|
781
1130
|
| `hasAnonIds` | boolean | false | generate anonymous IDs |
|
|
782
1131
|
| `hasSessionIds` | boolean | false | generate session IDs |
|
|
783
1132
|
| `alsoInferFunnels` | boolean | false | auto-generate funnels from events |
|
|
@@ -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
|
+
```
|