@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.
Files changed (45) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +21 -11
  2. package/.claude/skills/create-dungeon/SKILL.md +49 -10
  3. package/.claude/skills/create-project/SKILL.md +22 -3
  4. package/.claude/skills/create-project/context.mjs +89 -0
  5. package/.claude/skills/create-project/provision.mjs +1 -60
  6. package/.claude/skills/headless-build/SKILL.md +18 -1
  7. package/.claude/skills/powertools/SKILL.md +20 -1
  8. package/.claude/skills/release-check/SKILL.md +99 -0
  9. package/.claude/skills/verify-dungeon/SKILL.md +71 -16
  10. package/.claude/skills/verify-dungeon/references/counting-semantics.md +14 -0
  11. package/.claude/skills/verify-dungeon/references/report-format.md +18 -1
  12. package/.claude/skills/verify-dungeon/references/sql-recipes.md +36 -1
  13. package/.claude/skills/warehouse-metrics/GAPS-template.md +34 -0
  14. package/.claude/skills/warehouse-metrics/SKILL.md +105 -0
  15. package/.claude/skills/warehouse-metrics/deploy.mjs +651 -0
  16. package/.claude/skills/write-hooks/SKILL.md +33 -3
  17. package/CHANGELOG.md +331 -0
  18. package/HOOKS.md +154 -5
  19. package/README.md +357 -8
  20. package/docs/guides/1.7.0-upgrade-guide.md +154 -0
  21. package/docs/guides/1.8.0-upgrade-guide.md +151 -0
  22. package/dungeons/technical/warehouse.js +187 -0
  23. package/index.js +131 -2
  24. package/lib/core/config-validator.js +264 -13
  25. package/lib/core/context.js +39 -0
  26. package/lib/core/dungeon-loader.js +5 -2
  27. package/lib/core/storage.js +51 -3
  28. package/lib/generators/events.js +53 -7
  29. package/lib/generators/funnels.js +85 -11
  30. package/lib/generators/profiles.js +9 -4
  31. package/lib/generators/standalone.js +248 -0
  32. package/lib/generators/warehouse.js +828 -0
  33. package/lib/orchestrators/mixpanel-sender.js +39 -3
  34. package/lib/orchestrators/user-loop.js +240 -9
  35. package/lib/templates/story-spec.schema.json +41 -16
  36. package/lib/utils/conditions.js +62 -0
  37. package/lib/utils/json-evaluator.js +12 -2
  38. package/lib/utils/utils.js +115 -19
  39. package/lib/verify/index.js +1 -0
  40. package/lib/verify/schema-validator.js +8 -0
  41. package/lib/verify/story-runner.js +71 -8
  42. package/lib/verify/warehouse.js +683 -0
  43. package/package.json +5 -11
  44. package/scripts/verify-stories.mjs +150 -44
  45. 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: 15, preExistingSpread: 'uniform' } // fully custom
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 | stop at exact numEvents |
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 per-macro when both `macro` and this field are explicit) |
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
+ ```