@ak--47/dungeon-master 1.5.4 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +21 -12
  2. package/.claude/skills/create-dungeon/SKILL.md +65 -5
  3. package/.claude/skills/create-project/SKILL.md +1 -1
  4. package/.claude/skills/create-project/provision.mjs +57 -10
  5. package/.claude/skills/verify-dungeon/SKILL.md +56 -25
  6. package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
  7. package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
  8. package/.claude/skills/write-hooks/SKILL.md +156 -18
  9. package/CHANGELOG.md +183 -0
  10. package/HOOKS.md +566 -112
  11. package/README.md +28 -9
  12. package/docs/guides/1.3.0-upgrade-guide.md +262 -0
  13. package/docs/guides/1.3.1-upgrade-guide.md +328 -0
  14. package/docs/guides/1.4.0-upgrade-guide.md +130 -0
  15. package/docs/guides/1.4.1-upgrade-guide.md +125 -0
  16. package/docs/guides/1.4.3-upgrade-guide.md +200 -0
  17. package/docs/guides/1.4.4-upgrade-guide.md +95 -0
  18. package/docs/guides/1.5.0-upgrade-guide.md +426 -0
  19. package/docs/guides/1.5.1-upgrade-guide.md +178 -0
  20. package/docs/guides/1.5.3-upgrade-guide.md +117 -0
  21. package/docs/guides/1.6.0-upgrade-guide.md +204 -0
  22. package/dungeons/technical/stories-verify.js +152 -0
  23. package/dungeons/vertical/README.md +154 -0
  24. package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
  25. package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
  26. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
  27. package/dungeons/vertical/community/community.js +1589 -0
  28. package/dungeons/vertical/community/community.sql +216 -0
  29. package/dungeons/vertical/community/community.verify.mjs +72 -0
  30. package/dungeons/vertical/crypto/crypto.js +1483 -0
  31. package/dungeons/vertical/crypto/crypto.sql +194 -0
  32. package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
  33. package/dungeons/vertical/dating/dating.js +1540 -0
  34. package/dungeons/vertical/dating/dating.sql +288 -0
  35. package/dungeons/vertical/dating/dating.verify.mjs +72 -0
  36. package/dungeons/vertical/devtools/devtools.js +1524 -0
  37. package/dungeons/vertical/devtools/devtools.sql +161 -0
  38. package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
  39. package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
  40. package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
  41. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
  42. package/dungeons/vertical/education/education.js +1763 -0
  43. package/dungeons/vertical/education/education.sql +243 -0
  44. package/dungeons/vertical/education/education.verify.mjs +75 -0
  45. package/dungeons/vertical/fintech/fintech.js +1410 -0
  46. package/dungeons/vertical/fintech/fintech.sql +175 -0
  47. package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
  48. package/dungeons/vertical/fitness/fitness.js +1495 -0
  49. package/dungeons/vertical/fitness/fitness.sql +206 -0
  50. package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
  51. package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
  52. package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
  53. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
  54. package/dungeons/vertical/gaming/gaming.js +1741 -0
  55. package/dungeons/vertical/gaming/gaming.sql +249 -0
  56. package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
  57. package/dungeons/vertical/healthcare/healthcare.js +1488 -0
  58. package/dungeons/vertical/healthcare/healthcare.sql +193 -0
  59. package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
  60. package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
  61. package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
  62. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
  63. package/dungeons/vertical/logistics/logistics.js +1541 -0
  64. package/dungeons/vertical/logistics/logistics.sql +205 -0
  65. package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
  66. package/dungeons/vertical/marketplace/marketplace.js +1438 -0
  67. package/dungeons/vertical/marketplace/marketplace.sql +148 -0
  68. package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
  69. package/dungeons/vertical/{media.js → media/media.js} +557 -58
  70. package/dungeons/vertical/media/media.sql +173 -0
  71. package/dungeons/vertical/media/media.verify.mjs +74 -0
  72. package/dungeons/vertical/real-estate/real-estate.js +1629 -0
  73. package/dungeons/vertical/real-estate/real-estate.sql +167 -0
  74. package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
  75. package/dungeons/vertical/sass/sass.js +1733 -0
  76. package/dungeons/vertical/sass/sass.sql +173 -0
  77. package/dungeons/vertical/sass/sass.verify.mjs +74 -0
  78. package/dungeons/vertical/social/social.js +1456 -0
  79. package/dungeons/vertical/social/social.sql +160 -0
  80. package/dungeons/vertical/social/social.verify.mjs +76 -0
  81. package/dungeons/vertical/streaming/streaming.js +677 -0
  82. package/dungeons/vertical/streaming/streaming.sql +101 -0
  83. package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
  84. package/dungeons/vertical/support-desk/support-desk.js +741 -0
  85. package/dungeons/vertical/support-desk/support-desk.sql +137 -0
  86. package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
  87. package/dungeons/vertical/travel/travel.js +1603 -0
  88. package/dungeons/vertical/travel/travel.sql +236 -0
  89. package/dungeons/vertical/travel/travel.verify.mjs +72 -0
  90. package/index.js +2 -2
  91. package/lib/core/config-validator.js +37 -2
  92. package/lib/core/context.js +4 -2
  93. package/lib/core/dungeon-loader.js +20 -15
  94. package/lib/core/dungeon-to-json.js +22 -4
  95. package/lib/core/storage.js +3 -3
  96. package/lib/generators/funnels.js +36 -9
  97. package/lib/hook-helpers/cohort.js +53 -0
  98. package/lib/hook-helpers/index.js +8 -0
  99. package/lib/hook-helpers/mutate.js +1 -1
  100. package/lib/hook-helpers/shape.js +319 -0
  101. package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
  102. package/lib/hook-patterns/attributed-by-source.js +60 -58
  103. package/lib/hook-patterns/frequency-by-frequency.js +14 -4
  104. package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
  105. package/lib/hook-patterns/index.js +1 -1
  106. package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
  107. package/lib/orchestrators/user-loop.js +41 -0
  108. package/lib/templates/story-spec.schema.json +175 -0
  109. package/lib/utils/json-evaluator.js +1 -1
  110. package/lib/utils/utils.js +5 -4
  111. package/lib/verify/coerce.js +186 -0
  112. package/lib/verify/counting.js +270 -32
  113. package/lib/verify/emulate-breakdown.js +1285 -203
  114. package/lib/verify/first-time.js +89 -0
  115. package/lib/verify/flows.js +679 -0
  116. package/lib/verify/formula.js +259 -0
  117. package/lib/verify/funnel-engine.js +697 -113
  118. package/lib/verify/index.js +32 -4
  119. package/lib/verify/sessionize.js +275 -0
  120. package/lib/verify/story-runner.js +483 -0
  121. package/lib/verify/verify-dungeon.js +59 -37
  122. package/package.json +2 -1
  123. package/scripts/extract-dungeon-schema.mjs +2 -2
  124. package/scripts/run-many.mjs +2 -2
  125. package/scripts/verify-runner.mjs +2 -2
  126. package/scripts/verify-stories.mjs +244 -0
  127. package/types.d.ts +296 -30
  128. package/dungeons/vertical/ai-platform.js +0 -818
  129. package/dungeons/vertical/community.js +0 -966
  130. package/dungeons/vertical/crypto.js +0 -895
  131. package/dungeons/vertical/dating.js +0 -830
  132. package/dungeons/vertical/devtools.js +0 -1050
  133. package/dungeons/vertical/ecommerce.js +0 -811
  134. package/dungeons/vertical/education.js +0 -964
  135. package/dungeons/vertical/fintech.js +0 -946
  136. package/dungeons/vertical/fitness.js +0 -935
  137. package/dungeons/vertical/food-delivery.js +0 -934
  138. package/dungeons/vertical/gaming.js +0 -1120
  139. package/dungeons/vertical/healthcare.js +0 -945
  140. package/dungeons/vertical/insurance-application.js +0 -1039
  141. package/dungeons/vertical/logistics.js +0 -986
  142. package/dungeons/vertical/marketplace.js +0 -1019
  143. package/dungeons/vertical/real-estate.js +0 -872
  144. package/dungeons/vertical/sass.js +0 -995
  145. package/dungeons/vertical/social.js +0 -904
  146. package/dungeons/vertical/travel.js +0 -930
package/README.md CHANGED
@@ -51,9 +51,9 @@ const result = await DUNGEON_MASTER('./dungeons/technical/simple-schema.json');
51
51
 
52
52
  // run multiple dungeons
53
53
  const results = await DUNGEON_MASTER([
54
- './dungeons/vertical/gaming.js',
55
- './dungeons/vertical/media.js',
56
- './dungeons/vertical/food-delivery.js'
54
+ './dungeons/vertical/gaming/gaming.js',
55
+ './dungeons/vertical/media/media.js',
56
+ './dungeons/vertical/food-delivery/food-delivery.js'
57
57
  ]);
58
58
 
59
59
  // pass raw javascript as a string
@@ -70,7 +70,7 @@ const result = await DUNGEON_MASTER(`
70
70
  `);
71
71
 
72
72
  // override any config when loading from files
73
- const result = await DUNGEON_MASTER('./dungeons/vertical/fintech.js', {
73
+ const result = await DUNGEON_MASTER('./dungeons/vertical/fintech/fintech.js', {
74
74
  numUsers: 100, // shrink for testing
75
75
  writeToDisk: true,
76
76
  verbose: true
@@ -95,9 +95,9 @@ console.log(result.importResults);
95
95
 
96
96
  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.
97
97
 
98
- see `dungeons/vertical/` for customer-facing story dungeons (18 events, 8 hooks) and `dungeons/technical/` for feature-testing dungeons (mirrors, groups, scale, anonymous users).
98
+ see `dungeons/vertical/` for customer-facing story dungeons (one folder per vertical, each with engineered hooks and machine-checkable stories) and `dungeons/technical/` for feature-testing dungeons (mirrors, groups, scale, anonymous users).
99
99
 
100
- every vertical dungeon ships with a verification proof at `verification/verticals/<name>.{verify.mjs,sql}` — a CI-runnable assertion that the dungeon's documented hooks actually appear in the generated data at full fidelity. 20 dungeons, 107 hooks, 107 checks. see [`verification/verticals/README.md`](verification/verticals/README.md).
100
+ every vertical dungeon ships with a verification proof at `dungeons/vertical/<name>/<name>.{verify.mjs,sql}` plus a `stories` export evaluated mechanically by `scripts/verify-stories.mjs` — a CI-runnable assertion that the dungeon's documented hooks actually appear in the generated data at full fidelity. 22 dungeons, 212 machine-checkable stories. see [`dungeons/vertical/README.md`](dungeons/vertical/README.md).
101
101
 
102
102
  ```javascript
103
103
  // dungeons/my-app.js
@@ -173,7 +173,7 @@ import DUNGEON_MASTER, {
173
173
  `dungeonToJSON` accepts a config object, a file path, raw JS source, or an array of paths, and returns the `{ schema, hooks, timestamp, version }` wrapper format. it round-trips with `parseJSONDungeon`:
174
174
 
175
175
  ```javascript
176
- const json = await dungeonToJSON('./dungeons/vertical/ecommerce.js'); // creds stripped by default
176
+ const json = await dungeonToJSON('./dungeons/vertical/ecommerce/ecommerce.js'); // creds stripped by default
177
177
  const config = parseJSONDungeon(json); // back to a runnable dungeon
178
178
  ```
179
179
 
@@ -535,6 +535,25 @@ funnels: [
535
535
 
536
536
  ordering strategies: `sequential`, `random`, `first-fixed`, `last-fixed`, `first-and-last-fixed`, `middle-fixed`, `interrupted`
537
537
 
538
+ ### experiments
539
+
540
+ 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:
541
+
542
+ ```javascript
543
+ experiment: true // shorthand: Variant A (worse) / Variant B (better) / Control
544
+ experiment: {
545
+ name: 'Checkout Redesign',
546
+ startDaysBeforeEnd: 30, // runs before this date skip the experiment entirely
547
+ sticky: true, // default — see below
548
+ variants: [
549
+ { name: 'Control' },
550
+ { name: 'New Checkout', conversionMultiplier: 1.25, ttcMultiplier: 0.8, weight: 1 },
551
+ ]
552
+ }
553
+ ```
554
+
555
+ 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`.
556
+
538
557
  ## user generation
539
558
 
540
559
  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.
@@ -609,7 +628,7 @@ styles: `support`, `review`, `search`, `feedback`, `chat`, `email`, `forum`, `co
609
628
  ## scripts
610
629
 
611
630
  ```bash
612
- npm test # vitest test suite (~10s, 1122 tests)
631
+ npm test # full vitest test suite
613
632
  npm run typecheck # typescript check
614
633
  npm run dungeon:run # run a dungeon file locally
615
634
  npm run dungeon:to-json # convert JS dungeon to JSON (for UI import)
@@ -649,7 +668,7 @@ npx vitest run tests/integration/features.test.js # single file
649
668
  npx vitest tests/unit # watch mode
650
669
  ```
651
670
 
652
- `tests/e2e/sanity.test.js` is excluded by default (parked); run isolated with `npx vitest run tests/e2e/sanity.test.js`.
671
+ `tests/e2e/engine-shape-full-sweep.test.js` skips itself unless `RUN_FULL_SWEEP=1` is set (it wraps the long-running 194-combo engine sweep).
653
672
 
654
673
  ### engine tests (direct-run, NOT vitest)
655
674
 
@@ -0,0 +1,262 @@
1
+ # dungeon-master 1.3.0 — Upgrade Guide for Agents
2
+
3
+ This is the guide for AI agents (Claude, Cursor, etc.) writing or modifying `dungeon-master` configurations and code on or after the 1.3.0 release. Read this if you've been trained on 1.2.x patterns; the API has changed in non-breaking but important ways.
4
+
5
+ **TL;DR**:
6
+ 1. Use `avgEventsPerUserPerDay` instead of `numEvents` when writing new dungeon configs.
7
+ 2. Use `macro: "..."` for big-picture trend; `soup: "..."` is now intra-week / intra-day rhythm only.
8
+ 3. The default trend is **flat** (no growth ramp). If you want a growth narrative, set `macro: "growth"` explicitly.
9
+ 4. Hooks may not introduce events past `MAX_TIME` — the runtime drops them defensively, so plan around that.
10
+
11
+ ## Why 1.3.0 exists
12
+
13
+ In 1.2.x and earlier, every dungeon's Mixpanel chart blew up at the right edge: a meteoric ramp in the final ~14 days that wasn't intentional. Three mechanisms compounded:
14
+
15
+ 1. `avgEvPerUser = numEvents / numUsers` was fixed regardless of birth time, so a user born day 80 of 90 stuffed their whole event budget into 10 days (9× the per-day density of a pre-existing user).
16
+ 2. The default `bornRecentBias = 0.3` (in old `growth` soup preset) skewed births toward recent.
17
+ 3. Pre-existing users all started at `FIXED_BEGIN`, front-loading the early window.
18
+
19
+ 1.3.0 fixes all three. The default behavior is now flat-with-weekly-cycle. Hooks layer the story on top.
20
+
21
+ See [`end-bunchiness.md`](end-bunchiness.md) for the full diagnosis, experiments, and final regression numbers.
22
+
23
+ ## What changed at the API level
24
+
25
+ ### NEW: `avgEventsPerUserPerDay` (top-level config field)
26
+
27
+ The canonical event-volume primitive. Each user's event budget is `rate × user_active_days`:
28
+ - Pre-existing user (created before window): full `numDays` of activity → `rate × numDays` events.
29
+ - Born-in-dataset user: from birth to now → `rate × remaining_days` events (floored at 1 day).
30
+
31
+ Before:
32
+ ```javascript
33
+ const num_days = 100;
34
+ const num_users = 5_000;
35
+ const avg_events_per_user = 120; // per-user TOTAL
36
+
37
+ const config = {
38
+ numDays: num_days,
39
+ numEvents: num_users * avg_events_per_user,
40
+ numUsers: num_users,
41
+ // ...
42
+ };
43
+ ```
44
+
45
+ After:
46
+ ```javascript
47
+ const num_days = 100;
48
+ const num_users = 5_000;
49
+ const avg_events_per_user_per_day = 1.2; // per-user-PER-DAY rate
50
+
51
+ const config = {
52
+ numDays: num_days,
53
+ avgEventsPerUserPerDay: avg_events_per_user_per_day,
54
+ numUsers: num_users,
55
+ // ...
56
+ };
57
+ ```
58
+
59
+ `numEvents` still works as a **fallback**: if you only set `numEvents`, the validator derives `avgEventsPerUserPerDay = numEvents / numUsers / numDays`. Both fields end up populated in the resolved config. **Prefer the rate field in new dungeons** — it's the right unit for the proportional allocation.
60
+
61
+ ### NEW: `macro` preset system (top-level config field)
62
+
63
+ Big-picture trend across the whole window. Default: `"flat"`.
64
+
65
+ | preset | bornRecentBias | percentUsersBornInDataset | preExistingSpread | shape |
66
+ |--------|----------------|---------------------------|-------------------|-------|
67
+ | `flat` (default) | 0 | 15 | uniform | tail ≈ 1.0 |
68
+ | `steady` | 0.1 | 10 | uniform | slight uptrend |
69
+ | `growth` | 0.3 | 25 | pinned | mild uptrend |
70
+ | `viral` | 0.6 | 50 | pinned | hockey-stick |
71
+ | `decline` | -0.3 | 5 | uniform | downtrend (pair with churn hooks) |
72
+
73
+ Three forms:
74
+ ```javascript
75
+ macro: "growth" // preset string
76
+ macro: { preset: "growth", percentUsersBornInDataset: 40 } // preset + override
77
+ macro: { bornRecentBias: 0, percentUsersBornInDataset: 25, preExistingSpread: "uniform" } // custom
78
+ ```
79
+
80
+ Setting `bornRecentBias` / `percentUsersBornInDataset` / `preExistingSpread` directly on the dungeon config still works — those values **override** the macro preset. Migrating existing dungeons that used these fields directly does not require an explicit `macro` setting (the default `flat` is fine; the explicit overrides win).
81
+
82
+ ### NEW: `preExistingSpread` (top-level config field)
83
+
84
+ How pre-existing users' first event time is placed:
85
+ - `"uniform"` (default in `flat` macro): sample uniformly from `[FIXED_BEGIN - 30 days, FIXED_BEGIN]`. Pre-existing users have varied "born before dataset" timestamps.
86
+ - `"pinned"` (legacy): all pre-existing users start exactly at `FIXED_BEGIN`. Used by `growth` and `viral` macros (intentional front-loading for those narratives).
87
+
88
+ ### CHANGED: soup presets are now intra-week / intra-day only
89
+
90
+ Soup no longer carries `bornRecentBias` / `percentUsersBornInDataset`. Those fields moved to `macro`. Soup presets now only set `peaks`, `deviation`, `mean`, `dayOfWeekWeights`, `hourOfDayWeights`. The preset names (`growth`, `steady`, `spiky`, `seasonal`, `global`, `churny`, `chaotic`) are unchanged.
91
+
92
+ If you write `soup: "growth"`, you get the same intra-week rhythm as before, but you do NOT get the old `bornRecentBias=0.3` / `percentUsersBornInDataset=15` defaults — those come from `macro: "flat"` (the new default). To recreate the legacy behavior, set both: `soup: "growth", macro: "growth"`.
93
+
94
+ ### NEW: future-event guard at end of user loop
95
+
96
+ `lib/orchestrators/user-loop.js` now drops any event whose timestamp landed past `context.MAX_TIME` after the `everything` hook runs. This catches hook-injected duplicates that overflow into the future. **Plan around it**: hooks that duplicate events with positive time offsets near the right edge of the window will silently lose those duplicates. If you need them, clamp the time yourself in the hook.
97
+
98
+ ### RENAMED: `dungeons/vertical/questforge.js` → `dungeons/vertical/gaming.js`
99
+
100
+ The file name follows the **vertical** convention (`gaming`, `dating`, `fintech`, etc.). The product/app name (QuestForge) lives inside the file's comments.
101
+
102
+ ## Migration checklist for an existing dungeon
103
+
104
+ If you're updating a 1.2.x dungeon to 1.3.0:
105
+
106
+ 1. **Rename the local var**: `const avg_events_per_user = N` → `const avg_events_per_user_per_day = N / num_days` (rounded to 2 decimal places).
107
+ 2. **Update the config field**: `numEvents: num_users * avg_events_per_user` → `avgEventsPerUserPerDay: avg_events_per_user_per_day`.
108
+ 3. **Remove explicit `percentUsersBornInDataset` overrides** unless the dungeon needs a non-default value. The `flat` macro default (15%) is the new convention.
109
+ 4. **Optionally set `macro: "growth"` / `"viral"` / `"decline"`** if the dungeon has an intentional growth/decline narrative. Otherwise omit `macro` and let the default `flat` take over.
110
+ 5. **Audit hooks for time-injected events past `MAX_TIME`**: if your hook duplicates events with `time + N hours/days`, those duplicates may be silently dropped by the new guard. Clamp inside the hook if needed.
111
+ 6. **Run `npx vitest run tests/dungeons.test.js`** to confirm the dungeon still parses and validates.
112
+
113
+ A bulk migration script lives at [`scripts/experiments/migrate-dungeons.mjs`](../scripts/experiments/migrate-dungeons.mjs). It's idempotent — safe to re-run.
114
+
115
+ ## Writing a new dungeon in 1.3.0
116
+
117
+ Use this skeleton (from [`.claude/skills/create-dungeon/SKILL.md`](../.claude/skills/create-dungeon/SKILL.md)):
118
+
119
+ ```javascript
120
+ // ── TWEAK THESE ──
121
+ const SEED = "dm4-VERTICAL";
122
+ const num_days = 100;
123
+ const num_users = 5_000;
124
+ const avg_events_per_user_per_day = 1.2; // events per user per ACTIVE day
125
+ let token = "your-mixpanel-token";
126
+
127
+ // ... imports ...
128
+
129
+ const config = {
130
+ token,
131
+ seed: SEED,
132
+ numDays: num_days,
133
+ avgEventsPerUserPerDay: avg_events_per_user_per_day,
134
+ numUsers: num_users,
135
+ // ... feature switches, scdProps, mirrorProps, lookupTables: [] ...
136
+
137
+ // Trend shape — leave both at default unless prompt asks otherwise.
138
+ // macro defaults to "flat"; soup defaults to "growth".
139
+ // macro: "flat", // also: "steady" | "growth" | "viral" | "decline"
140
+ // soup: "growth", // also: "steady" | "spiky" | "seasonal" | "global" | "churny" | "chaotic"
141
+
142
+ events: [ /* ... */ ],
143
+ funnels: [ /* ... */ ],
144
+ hook: function (record, type, meta) { /* ... */ return record; }
145
+ };
146
+
147
+ export default config;
148
+ ```
149
+
150
+ **Key rules** (unchanged from 1.2.x):
151
+ - Hooks shape data within the schema; they cannot invent properties not defined in `events` / `userProps` / `superProps`.
152
+ - All event properties are FLAT (`record.amount`, not `record.properties.amount`).
153
+ - Injected events must be cloned from existing events with `{...existingEvent, time: newTime}`.
154
+ - Use `dayjs` for time math inside hooks. Use the seeded `chance` instance.
155
+
156
+ **New rules in 1.3.0**:
157
+ - Use `avgEventsPerUserPerDay` not `numEvents`.
158
+ - Don't override `percentUsersBornInDataset` unless the dungeon has a specific narrative reason. Use `macro` instead.
159
+ - If a hook duplicates events with a positive time offset and the source event is near the right edge, expect the duplicate to be dropped by the future-event guard.
160
+
161
+ ## Quick reference — config primitives
162
+
163
+ | field | type | default | what it does |
164
+ |-------|------|---------|--------------|
165
+ | `avgEventsPerUserPerDay` | number | derived from `numEvents` | per-user-per-active-day rate (canonical) |
166
+ | `numEvents` | number | 100_000 | legacy total target; populates `avgEventsPerUserPerDay` if unset |
167
+ | `numUsers` | number | 1000 | total user count |
168
+ | `numDays` | number | 30 | dataset window length |
169
+ | `macro` | string \| object | `"flat"` | big-picture trend preset |
170
+ | `soup` | string \| object | `"growth"` | intra-week / intra-day rhythm preset |
171
+ | `bornRecentBias` | number | 0 (from `flat`) | birth-time skew, -1..1 |
172
+ | `percentUsersBornInDataset` | number | 15 (from `flat`) | share of users born inside the window |
173
+ | `preExistingSpread` | `"uniform"` \| `"pinned"` | `"uniform"` (from `flat`) | placement of pre-existing users' first event |
174
+
175
+ ## Where to look in the codebase
176
+
177
+ - [`lib/core/config-validator.js`](../lib/core/config-validator.js) — resolves both primitives, applies macro defaults
178
+ - [`lib/templates/macro-presets.js`](../lib/templates/macro-presets.js) — macro preset definitions + `resolveMacro`
179
+ - [`lib/templates/soup-presets.js`](../lib/templates/soup-presets.js) — soup preset definitions (no birth fields)
180
+ - [`lib/orchestrators/user-loop.js`](../lib/orchestrators/user-loop.js) — per-user-per-day allocation (search `userActiveDays`), pre-existing spread, future-event guard
181
+ - [`tests/macro-and-rate.test.js`](../tests/macro-and-rate.test.js) — usage examples + invariants
182
+ - [`research/end-bunchiness.md`](end-bunchiness.md) — full diagnosis + experiment data + before/after numbers
183
+
184
+ ## Hook-writing learnings (from the 1.3.0 verify-hooks pass)
185
+
186
+ We re-verified every hook on every vertical dungeon at 1.3.0 (~160 hooks across 20 dungeons). The pass surfaced five recurring failure modes that aren't obvious from the hook API alone — codify them in any new dungeon you write.
187
+
188
+ ### Five rules that prevent ~all observed hook failures
189
+
190
+ 1. **Never read DOW / day-of-month from `record.time` inside an `event` hook when `hasSessionIds: true`.**
191
+ `bunchIntoSessions()` runs AFTER the event hook but BEFORE the everything hook. It reassigns timestamps to fit session windows, so any `dayjs(record.time).day()` you compute in the event hook becomes decorrelated from the actual stored timestamp. Move all DOW / day-of-month logic to the everything hook, where `e.time` is final.
192
+ ```js
193
+ // ❌ WRONG — event hook fires before sessionization
194
+ if (type === 'event' && dayjs(record.time).day() === 0) record.weekend_surge = true;
195
+
196
+ // ✅ RIGHT — everything hook fires after sessionization
197
+ if (type === 'everything') {
198
+ for (const e of record) {
199
+ if (new Date(e.time).getUTCDay() === 0) e.weekend_surge = true;
200
+ }
201
+ }
202
+ ```
203
+ Note: also use `getUTCDay()` (or `dayjs.utc()`), not `.day()`. TimeSoup distributes events using UTC DOW; local-time DOW will skew the result for users in non-UTC timezones.
204
+
205
+ 2. **Never read superProps inside an `event` hook — they aren't your user's value yet.**
206
+ At event-hook time, properties listed in `superProps` come from the random picker (one value per event). The everything hook later overwrites each event with the user's stamped value from `meta.profile`. So an event hook that branches on `record.account_tier` is making decisions on noise. Read `meta.profile.account_tier` in the everything hook instead.
207
+ ```js
208
+ // ❌ WRONG — record.account_tier is a fresh random pick
209
+ if (type === 'event' && record.account_tier === 'premium') record.reward *= 3;
210
+
211
+ // ✅ RIGHT — meta.profile is the authoritative user profile
212
+ if (type === 'everything') {
213
+ for (const e of record) {
214
+ if (meta.profile.account_tier === 'premium') e.reward *= 3;
215
+ }
216
+ }
217
+ ```
218
+
219
+ 3. **Use UTC consistently for relative-time windows.**
220
+ When you anchor a window like "days 20-27 of the dataset," compute both the anchor and the comparison in UTC. Mixing `dayjs()` (local) with timestamps from TimeSoup (UTC) shifts the window by the host's timezone offset.
221
+ ```js
222
+ const DATASET_START = dayjs.utc().subtract(num_days, 'day');
223
+ const launchDay = DATASET_START.add(45, 'day');
224
+ if (dayjs.utc(record.time).isAfter(launchDay)) { /* ... */ }
225
+ ```
226
+
227
+ 4. **Apply value scaling AFTER cloning.**
228
+ If you clone events in the everything hook to inject duplicates (weekend surge, viral spread, etc.) AND you also scale values in the event hook, the event-hook scaling never touches the clones. Move both the cloning and the scaling into the everything hook so the multiplier applies after the dataset's final shape exists.
229
+
230
+ 5. **Sanity-check thresholds at small scale.**
231
+ At 1K-user verification scale, hooks like "users with ≥5 errors in 7 days" or "sessions with <2 hours between events" frequently produce zero qualifying users. The hook works, but at small scale the threshold is too strict to register. Either:
232
+ - Relax the threshold so it passes verification (preferred for thresholds tuned on noise), OR
233
+ - Document in the dungeon comment that the hook needs production scale to fire (preferred for thresholds tied to real product semantics).
234
+
235
+ ### What goes where (decision matrix)
236
+
237
+ | Logic type | event hook | everything hook |
238
+ |-----------|:---------:|:---------------:|
239
+ | Value scaling driven by an event property already on `record` | ✅ | ✅ |
240
+ | Value scaling driven by user profile / superProp | ❌ | ✅ (`meta.profile.X`) |
241
+ | DOW / day-of-month patterns (any dungeon) | ❌ | ✅ (`new Date(e.time).getUTCDay()`) |
242
+ | Closure-state Maps that track per-user context across events | ✅ | — |
243
+ | Relative-time windows (DATASET_START + N days) | ✅ (`dayjs.utc`) | ✅ |
244
+ | Event cloning / injection (weekend surge, viral, etc.) | ❌ | ✅ |
245
+ | Value scaling that must include cloned events | ❌ | ✅ |
246
+ | Event filtering (churn, drop-off) | ❌ | ✅ (`return record.filter(...)`) |
247
+
248
+ ### Other things worth knowing
249
+
250
+ - **Hooks cannot invent new properties.** Every property a hook sets must already exist in the config (in `events[].properties`, `userProps`, or `superProps`) with a default value. If you need a flag like `payday`, add `payday: [false]` to the event's properties; the hook flips it to `true`.
251
+ - **Spliced/cloned events need `user_id` and `time`** — copy from the source: `{ ...source, time: newIso, user_id: source.user_id }`. Building events from scratch will fail validation.
252
+ - **Future-event guard drops time-shifted clones.** If your hook clones an event near the right edge of the window with `time + N hours`, the future-event guard at the end of the user loop will silently drop the clone. Clamp inside the hook if you need it.
253
+ - **Event property access is FLAT.** Use `record.amount`, never `record.properties.amount`.
254
+
255
+ These rules + the verify-hooks skill (`/verify-hooks`) caught 13 silent hook failures across the vertical pack; running it after any non-trivial hook change is the cheapest insurance against shipping a flat-line story.
256
+
257
+ ## Backward compatibility
258
+
259
+ - 1.2.x dungeons that set `numEvents` directly continue to work — the rate is derived. They will, however, render with the new `flat` macro defaults (no growth bias, uniform pre-existing spread). If a 1.2.x dungeon depended on the implicit `bornRecentBias=0.3` from the old `growth` soup preset, add `macro: "growth"` to restore that behavior.
260
+ - 1.2.x dungeons that set `bornRecentBias` / `percentUsersBornInDataset` directly continue to work unchanged — those fields override the macro preset.
261
+ - Soup preset names are unchanged. Hook semantics are unchanged. Output file naming is unchanged.
262
+ - The `numEvents` config field is **not deprecated**. It remains the right primitive when you want a fixed total volume target rather than a per-day rate.