@ak--47/dungeon-master 1.4.5 → 1.5.1

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 (78) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +158 -0
  2. package/.claude/skills/create-dungeon/SKILL.md +464 -0
  3. package/.claude/skills/verify-dungeon/SKILL.md +157 -0
  4. package/.claude/skills/verify-dungeon/references/counting-semantics.md +161 -0
  5. package/.claude/skills/verify-dungeon/references/report-format.md +216 -0
  6. package/.claude/skills/verify-dungeon/references/sql-recipes.md +857 -0
  7. package/.claude/skills/write-hooks/SKILL.md +468 -0
  8. package/CHANGELOG.md +182 -0
  9. package/HOOKS.md +1256 -597
  10. package/README.md +140 -5
  11. package/dungeons/technical/ad-spend.js +41 -49
  12. package/dungeons/technical/anonymous-users.js +38 -36
  13. package/dungeons/technical/array-of-object-lookup.js +136 -153
  14. package/dungeons/technical/datagen-v15-verify.js +87 -0
  15. package/dungeons/technical/experiments.js +42 -40
  16. package/dungeons/technical/foobar.js +114 -118
  17. package/dungeons/technical/group-analytics.js +42 -40
  18. package/dungeons/technical/hook-helpers-verify.js +69 -50
  19. package/dungeons/technical/identity-model-verify.js +22 -12
  20. package/dungeons/technical/mirror-strategies.js +37 -39
  21. package/dungeons/technical/nested-objects.js +119 -118
  22. package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
  23. package/dungeons/technical/pattern-attributed-by-source.js +23 -9
  24. package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
  25. package/dungeons/technical/pattern-funnel-frequency.js +30 -15
  26. package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
  27. package/dungeons/technical/retention-cadence.js +115 -112
  28. package/dungeons/technical/sanity.js +86 -80
  29. package/dungeons/technical/scale-test.js +34 -38
  30. package/dungeons/technical/scd.js +111 -128
  31. package/dungeons/technical/simple.js +134 -141
  32. package/dungeons/technical/simplest.js +111 -65
  33. package/dungeons/technical/text-generation.js +110 -146
  34. package/dungeons/vertical/ai-platform.js +300 -333
  35. package/dungeons/vertical/community.js +290 -255
  36. package/dungeons/vertical/crypto.js +400 -391
  37. package/dungeons/vertical/dating.js +421 -375
  38. package/dungeons/vertical/devtools.js +346 -298
  39. package/dungeons/vertical/ecommerce.js +322 -394
  40. package/dungeons/vertical/education.js +380 -325
  41. package/dungeons/vertical/fintech.js +371 -325
  42. package/dungeons/vertical/fitness.js +345 -291
  43. package/dungeons/vertical/food-delivery.js +352 -307
  44. package/dungeons/vertical/gaming.js +490 -444
  45. package/dungeons/vertical/healthcare.js +311 -262
  46. package/dungeons/vertical/insurance-application.js +437 -409
  47. package/dungeons/vertical/logistics.js +278 -252
  48. package/dungeons/vertical/marketplace.js +340 -323
  49. package/dungeons/vertical/media.js +390 -335
  50. package/dungeons/vertical/real-estate.js +402 -347
  51. package/dungeons/vertical/sass.js +331 -333
  52. package/dungeons/vertical/social.js +377 -316
  53. package/dungeons/vertical/travel.js +302 -295
  54. package/index.js +64 -7
  55. package/lib/core/config-validator.js +378 -17
  56. package/lib/core/dungeon-loader.js +2 -5
  57. package/lib/generators/events.js +12 -13
  58. package/lib/generators/funnels.js +76 -2
  59. package/lib/hook-helpers/index.js +1 -0
  60. package/lib/hook-helpers/inject.js +95 -0
  61. package/lib/orchestrators/mixpanel-sender.js +7 -0
  62. package/lib/orchestrators/user-loop.js +598 -48
  63. package/lib/templates/defaults.js +59 -59
  64. package/lib/templates/macro-presets.js +53 -11
  65. package/lib/utils/dataset-context.js +103 -0
  66. package/lib/utils/retention-curve.js +140 -0
  67. package/lib/utils/utils.js +157 -109
  68. package/lib/verify/counting.js +360 -0
  69. package/lib/verify/emulate-breakdown.js +531 -108
  70. package/lib/verify/funnel-engine.js +539 -0
  71. package/lib/verify/identity.js +78 -0
  72. package/lib/verify/index.js +20 -0
  73. package/lib/verify/schema-validator.js +3 -1
  74. package/lib/verify/verify-dungeon.js +58 -0
  75. package/package.json +14 -3
  76. package/scripts/run-dungeon.mjs +12 -1
  77. package/types.d.ts +353 -4
  78. package/scripts/smoke-test-all.mjs +0 -162
@@ -1,67 +1,71 @@
1
- // ── TWEAK THESE ──
1
+ // ── IMPORTS ──
2
+ import Chance from 'chance';
3
+ let chance = new Chance();
4
+ import { pickAWinner, weighNumRange, integer, weighChoices, dateRange, listOf, objectList } from "../../lib/utils/utils.js";
5
+ /** @typedef {import("../../types").Dungeon} Config */
6
+
7
+ // ── OVERVIEW ──
8
+ /*
9
+ * NAME: simplest
10
+ * PURPOSE: Engine-validation baseline — no hooks. Engine + TimeSoup alone produce the trend shape. Used by engine-shape canary + full sweep.
11
+ * SCALE: 2,500 users, ~250K events, 100 days
12
+ * EVENTS (25): page view (10) > view item (9) > view category (8) > login (8) > search (7) > notification received (7) > watch video (6) > add to cart (5) > notification clicked (5) > rate item (4) > add to wishlist (4) > checkout (3) > remove from cart (3) > update profile (3) > compare items (3) > share content (2) > support ticket (2) > apply coupon (2) > leave review (2) > sign up (1) > add payment method (1) > invite friend (1) > save address (1) > subscribe newsletter (1) > redeem reward (1)
13
+ * FUNNELS (11): Signup Funnel, Purchase Funnel, Content Engagement, Review Funnel, Browse to Cart, Notification Engagement, Wishlist to Purchase, Post-Purchase Advocacy, Coupon Purchase Flow, Newsletter to Purchase, Support Recovery
14
+ * USER PROPS: theme, title, spiritAnimal, luckyNumber, emailOptIn, signupDate, favoriteCategories, preferences, recentOrders
15
+ * SUPER PROPS: theme
16
+ * GROUPS: none
17
+ *
18
+ * USE CASES:
19
+ * - Engine sweep harness baseline (`scripts/sweep-engine.mjs`). Strict-bar regression
20
+ * testing across the 184-combo macro/born/rate/active-day matrix.
21
+ * - Reference for "what an average dungeon looks like" — diverse events + funnels +
22
+ * user props with no hook architecting on top.
23
+ * - Starting template for new dungeons before adding bespoke hooks.
24
+ * - At least one property of every Mixpanel data type:
25
+ * string, numeric, boolean, date, list, object, list-of-objects
26
+ * on both event properties and user properties.
27
+ * - No device/location/campaign data — pure event stream.
28
+ * - **No hooks.** This is the engine-validation baseline; engine + TimeSoup alone
29
+ * produce the trend shape. Hook authors should reach for vertical dungeons or
30
+ * write hooks against this template; do NOT add hooks here.
31
+ */
32
+
33
+ // ── SCALE ──
2
34
  const SEED = "simple is best";
3
35
  const num_days = 100;
4
36
  const num_users = 2_500;
5
37
  const avg_events_per_user_per_day = 1;
6
- let token = "your-mixpanel-token";
7
-
8
- // ── env overrides ──
9
- if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
10
-
11
- import Chance from 'chance';
12
- let chance = new Chance();
13
- import dayjs from "dayjs";
14
- import utc from "dayjs/plugin/utc.js";
15
- dayjs.extend(utc);
16
- import { uid, comma } from 'ak-tools';
17
- import { pickAWinner, weighNumRange, date, integer, weighChoices } from "../../lib/utils/utils.js";
38
+ const token = process.env.MP_TOKEN || "";
18
39
 
19
- /** @typedef {import("../../types").Dungeon} Config */
40
+ // ── DATA ARRAYS ──
20
41
  const itemCategories = ["Books", "Movies", "Music", "Games", "Electronics", "Computers", "Smart Home", "Home", "Garden", "Pet", "Beauty", "Health", "Toys", "Kids", "Baby", "Handmade", "Sports", "Outdoors", "Automotive", "Industrial", "Entertainment", "Art", "Food", "Appliances", "Office", "Wedding", "Software"];
21
42
 
22
43
  const videoCategories = ["funny", "educational", "inspirational", "music", "news", "sports", "cooking", "DIY", "travel", "gaming"];
23
44
 
24
- /**
25
- * ═══════════════════════════════════════════════════════════════
26
- * DATASET OVERVIEW
27
- * ═══════════════════════════════════════════════════════════════
28
- *
29
- * Simplest E-Commerce App — the minimal dungeon with no hooks.
30
- * - 2,500 users over 100 days, ~250K events
31
- * - 25 event types covering browse, search, cart, checkout,
32
- * notifications, reviews, wishlists, rewards
33
- * - 11 funnels (signup, purchase, content, browse-to-cart, etc.)
34
- * - No device/location/campaign data — pure event stream
35
- *
36
- * ═══════════════════════════════════════════════════════════════
37
- * ANALYTICS HOOKS
38
- * ═══════════════════════════════════════════════════════════════
39
- *
40
- * None — this is a baseline dungeon with a no-op hook function.
41
- * Use it for testing core generation, funnel inference, or as a
42
- * starting template for new dungeons.
43
- */
44
-
45
+ // ── CONFIG ──
45
46
  /** @type {import('../types.js').Dungeon} */
46
47
  const config = {
47
- token,
48
48
  seed: SEED,
49
49
  numDays: num_days,
50
50
  avgEventsPerUserPerDay: avg_events_per_user_per_day,
51
51
  numUsers: num_users,
52
52
  format: 'json', //csv or json
53
- region: "US",
54
- hasAnonIds: false, //if true, anonymousIds are created for each user
55
- hasSessionIds: false, //if true, hasSessionIds are created for each user
56
- hasAdSpend: false,
57
- hasLocation: false,
58
- hasAndroidDevices: false,
59
- hasIOSDevices: false,
60
- hasDesktopDevices: false,
61
- hasBrowser: false,
62
- hasCampaigns: false,
63
- isAnonymous: false,
64
- alsoInferFunnels: false,
53
+ credentials: {
54
+ token,
55
+ region: "US",
56
+ },
57
+ switches: {
58
+ hasSessionIds: false, //if true, hasSessionIds are created for each user
59
+ hasAdSpend: false,
60
+ hasLocation: false,
61
+ hasAndroidDevices: false,
62
+ hasIOSDevices: false,
63
+ hasDesktopDevices: false,
64
+ hasBrowser: false,
65
+ hasCampaigns: false,
66
+ isAnonymous: false,
67
+ alsoInferFunnels: false,
68
+ },
65
69
  concurrency: 1,
66
70
  writeToDisk: false,
67
71
 
@@ -69,6 +73,7 @@ const config = {
69
73
  {
70
74
  event: "page view",
71
75
  weight: 10,
76
+ isStrictEvent: false,
72
77
  properties: {
73
78
  page: ["/", "/", "/help", "/account", "/pricing", "/product", "/about", "/blog"],
74
79
  utm_source: ["$organic", "$organic", "$organic", "$organic", "google", "google", "google", "facebook", "facebook", "twitter", "linkedin"],
@@ -77,6 +82,7 @@ const config = {
77
82
  {
78
83
  event: "sign up",
79
84
  weight: 1,
85
+ isStrictEvent: false,
80
86
  isFirstEvent: true,
81
87
  properties: {
82
88
  signupMethod: ["email", "google", "facebook", "github"],
@@ -86,6 +92,7 @@ const config = {
86
92
  {
87
93
  event: "login",
88
94
  weight: 8,
95
+ isStrictEvent: false,
89
96
  properties: {
90
97
  method: ["password", "google", "facebook", "github"],
91
98
  }
@@ -93,6 +100,7 @@ const config = {
93
100
  {
94
101
  event: "search",
95
102
  weight: 7,
103
+ isStrictEvent: false,
96
104
  properties: {
97
105
  query_length: weighNumRange(1, 50),
98
106
  resultsReturned: weighNumRange(0, 100, .25),
@@ -102,16 +110,22 @@ const config = {
102
110
  {
103
111
  event: "view item",
104
112
  weight: 9,
113
+ isStrictEvent: false,
105
114
  properties: {
106
115
  isFeaturedItem: [true, false, false],
107
116
  itemCategory: pickAWinner(itemCategories, integer(0, 27)),
108
117
  price: weighNumRange(5, 500, .25),
109
118
  rating: weighNumRange(1, 5),
119
+ // list (array of strings) — Mixpanel "List" property type
120
+ tags: listOf(["new", "sale", "limited", "popular", "trending", "exclusive", "clearance", "preorder"], { min: 1, max: 3 }),
121
+ // date — Mixpanel "Date" property type, default ISO string
122
+ lastRestockedAt: dateRange(),
110
123
  }
111
124
  },
112
125
  {
113
126
  event: "add to cart",
114
127
  weight: 5,
128
+ isStrictEvent: false,
115
129
  properties: {
116
130
  amount: weighNumRange(5, 500, .25),
117
131
  itemCategory: pickAWinner(itemCategories, integer(0, 27)),
@@ -121,16 +135,27 @@ const config = {
121
135
  {
122
136
  event: "checkout",
123
137
  weight: 3,
138
+ isStrictEvent: false,
124
139
  properties: {
125
140
  amount: weighNumRange(10, 500, .25),
126
141
  currency: ["USD", "CAD", "EUR", "JPY"],
127
142
  coupon: weighChoices(["none", "none", "none", "none", "10%OFF", "20%OFF", "30%OFF"]),
128
143
  numItems: weighNumRange(1, 10),
144
+ // object — Mixpanel "Object" property type (plain object, returned as-is)
145
+ billingAddress: { country: "US", region: "CA", postalCode: "94016" },
146
+ // list of objects — Mixpanel "List of Objects" property type
147
+ lineItems: objectList({
148
+ sku: weighNumRange(10000, 99999),
149
+ name: pickAWinner(itemCategories, integer(0, 27)),
150
+ qty: [1, 1, 1, 2, 2, 3],
151
+ price: weighNumRange(5, 200, .25),
152
+ }, { min: 1, max: 5 }),
129
153
  }
130
154
  },
131
155
  {
132
156
  event: "watch video",
133
157
  weight: 6,
158
+ isStrictEvent: false,
134
159
  properties: {
135
160
  videoCategory: pickAWinner(videoCategories, integer(0, 9)),
136
161
  watchTimeSec: weighNumRange(10, 600, .25),
@@ -140,6 +165,7 @@ const config = {
140
165
  {
141
166
  event: "share content",
142
167
  weight: 2,
168
+ isStrictEvent: false,
143
169
  properties: {
144
170
  platform: ["twitter", "facebook", "linkedin", "email", "link"],
145
171
  contentType: ["video", "product", "article"],
@@ -148,6 +174,7 @@ const config = {
148
174
  {
149
175
  event: "rate item",
150
176
  weight: 4,
177
+ isStrictEvent: false,
151
178
  properties: {
152
179
  rating: weighNumRange(1, 5),
153
180
  itemCategory: pickAWinner(itemCategories, integer(0, 27)),
@@ -160,6 +187,7 @@ const config = {
160
187
  {
161
188
  event: "support ticket",
162
189
  weight: 2,
190
+ isStrictEvent: false,
163
191
  properties: {
164
192
  priority: weighChoices(["low", "low", "medium", "medium", "medium", "high"]),
165
193
  category: ["billing", "technical", "account", "shipping", "returns"],
@@ -168,6 +196,7 @@ const config = {
168
196
  {
169
197
  event: "add to wishlist",
170
198
  weight: 4,
199
+ isStrictEvent: false,
171
200
  properties: {
172
201
  itemCategory: pickAWinner(itemCategories, integer(0, 27)),
173
202
  price: weighNumRange(5, 500, .25),
@@ -176,6 +205,7 @@ const config = {
176
205
  {
177
206
  event: "remove from cart",
178
207
  weight: 3,
208
+ isStrictEvent: false,
179
209
  properties: {
180
210
  reason: weighChoices(["changed mind", "too expensive", "found better", "duplicate", "changed mind", "changed mind"]),
181
211
  }
@@ -183,6 +213,7 @@ const config = {
183
213
  {
184
214
  event: "apply coupon",
185
215
  weight: 2,
216
+ isStrictEvent: false,
186
217
  properties: {
187
218
  couponCode: weighChoices(["SAVE10", "SAVE20", "WELCOME", "FREESHIP", "VIP30", "SAVE10", "SAVE10"]),
188
219
  discountPercent: weighNumRange(5, 50),
@@ -191,6 +222,7 @@ const config = {
191
222
  {
192
223
  event: "notification received",
193
224
  weight: 7,
225
+ isStrictEvent: false,
194
226
  properties: {
195
227
  channel: ["push", "email", "in-app", "sms"],
196
228
  type: ["promo", "order update", "recommendation", "reminder"],
@@ -199,6 +231,7 @@ const config = {
199
231
  {
200
232
  event: "notification clicked",
201
233
  weight: 5,
234
+ isStrictEvent: false,
202
235
  properties: {
203
236
  channel: ["push", "email", "in-app", "sms"],
204
237
  type: ["promo", "order update", "recommendation", "reminder"],
@@ -207,6 +240,7 @@ const config = {
207
240
  {
208
241
  event: "add payment method",
209
242
  weight: 1,
243
+ isStrictEvent: false,
210
244
  properties: {
211
245
  type: ["credit card", "debit card", "paypal", "apple pay", "google pay"],
212
246
  }
@@ -214,6 +248,7 @@ const config = {
214
248
  {
215
249
  event: "update profile",
216
250
  weight: 3,
251
+ isStrictEvent: false,
217
252
  properties: {
218
253
  field: ["avatar", "name", "email", "address", "phone", "preferences"],
219
254
  }
@@ -221,6 +256,7 @@ const config = {
221
256
  {
222
257
  event: "invite friend",
223
258
  weight: 1,
259
+ isStrictEvent: false,
224
260
  properties: {
225
261
  method: ["email", "link", "sms"],
226
262
  }
@@ -228,6 +264,7 @@ const config = {
228
264
  {
229
265
  event: "view category",
230
266
  weight: 8,
267
+ isStrictEvent: false,
231
268
  properties: {
232
269
  category: pickAWinner(itemCategories, integer(0, 27)),
233
270
  sortBy: ["popular", "newest", "price low", "price high", "rating"],
@@ -236,6 +273,7 @@ const config = {
236
273
  {
237
274
  event: "save address",
238
275
  weight: 1,
276
+ isStrictEvent: false,
239
277
  properties: {
240
278
  type: ["home", "work", "other"],
241
279
  }
@@ -243,6 +281,7 @@ const config = {
243
281
  {
244
282
  event: "compare items",
245
283
  weight: 3,
284
+ isStrictEvent: false,
246
285
  properties: {
247
286
  numItems: weighNumRange(2, 5),
248
287
  itemCategory: pickAWinner(itemCategories, integer(0, 27)),
@@ -251,6 +290,7 @@ const config = {
251
290
  {
252
291
  event: "subscribe newsletter",
253
292
  weight: 1,
293
+ isStrictEvent: false,
254
294
  properties: {
255
295
  frequency: ["daily", "weekly", "monthly"],
256
296
  topics: ["deals", "new arrivals", "recommendations", "deals", "deals"],
@@ -259,6 +299,7 @@ const config = {
259
299
  {
260
300
  event: "leave review",
261
301
  weight: 2,
302
+ isStrictEvent: false,
262
303
  properties: {
263
304
  rating: weighNumRange(1, 5),
264
305
  wordCount: weighNumRange(10, 200, .25),
@@ -268,6 +309,7 @@ const config = {
268
309
  {
269
310
  event: "redeem reward",
270
311
  weight: 1,
312
+ isStrictEvent: false,
271
313
  properties: {
272
314
  rewardType: ["discount", "free shipping", "free item", "points bonus"],
273
315
  pointsUsed: weighNumRange(100, 5000, .25),
@@ -375,35 +417,39 @@ const config = {
375
417
  each key should be an array or function reference
376
418
  */
377
419
  userProps: {
420
+ // string
378
421
  theme: ["light", "dark", "custom", "light", "dark"],
379
422
  title: chance.profession.bind(chance),
423
+ spiritAnimal: ["duck", "dog", "otter", "penguin", "cat", "elephant", "lion", "cheetah", "giraffe", "zebra", "rhino", "hippo", "whale", "dolphin", "shark", "octopus", "squid", "jellyfish", "starfish", "seahorse", "crab", "lobster", "shrimp", "clam", "snail", "slug", "butterfly", "moth", "bee", "wasp", "ant", "beetle", "ladybug", "caterpillar", "centipede", "millipede", "scorpion", "spider", "tarantula", "tick", "mite", "mosquito", "fly", "dragonfly", "damselfly", "grasshopper", "cricket", "locust", "mantis", "cockroach", "termite", "praying mantis", "walking stick", "stick bug", "leaf insect", "lacewing", "aphid", "cicada", "thrips", "psyllid", "scale insect", "whitefly", "mealybug", "planthopper", "leafhopper", "treehopper", "flea", "louse", "bedbug", "flea beetle", "weevil", "longhorn beetle", "leaf beetle", "tiger beetle", "ground beetle", "lady beetle", "firefly", "click beetle", "rove beetle", "scarab beetle", "dung beetle", "stag beetle", "rhinoceros beetle", "hercules beetle", "goliath beetle", "jewel beetle", "tortoise beetle"],
424
+ // numeric
380
425
  luckyNumber: weighNumRange(42, 420, .3),
381
- spiritAnimal: ["duck", "dog", "otter", "penguin", "cat", "elephant", "lion", "cheetah", "giraffe", "zebra", "rhino", "hippo", "whale", "dolphin", "shark", "octopus", "squid", "jellyfish", "starfish", "seahorse", "crab", "lobster", "shrimp", "clam", "snail", "slug", "butterfly", "moth", "bee", "wasp", "ant", "beetle", "ladybug", "caterpillar", "centipede", "millipede", "scorpion", "spider", "tarantula", "tick", "mite", "mosquito", "fly", "dragonfly", "damselfly", "grasshopper", "cricket", "locust", "mantis", "cockroach", "termite", "praying mantis", "walking stick", "stick bug", "leaf insect", "lacewing", "aphid", "cicada", "thrips", "psyllid", "scale insect", "whitefly", "mealybug", "planthopper", "leafhopper", "treehopper", "flea", "louse", "bedbug", "flea beetle", "weevil", "longhorn beetle", "leaf beetle", "tiger beetle", "ground beetle", "lady beetle", "firefly", "click beetle", "rove beetle", "scarab beetle", "dung beetle", "stag beetle", "rhinoceros beetle", "hercules beetle", "goliath beetle", "jewel beetle", "tortoise beetle"]
426
+ // boolean
427
+ emailOptIn: [true, true, false],
428
+ // date
429
+ signupDate: dateRange(),
430
+ // list
431
+ favoriteCategories: listOf(itemCategories, { min: 1, max: 4 }),
432
+ // object
433
+ preferences: { notifications: true, currency: "USD", language: "en-US" },
434
+ // list of objects
435
+ recentOrders: objectList({
436
+ orderId: weighNumRange(100000, 999999),
437
+ total: weighNumRange(10, 500, .25),
438
+ itemCount: [1, 1, 2, 2, 3],
439
+ }, { min: 0, max: 3 }),
382
440
  },
383
441
  scdProps: {},
384
442
  mirrorProps: {},
385
443
 
386
444
  /*
387
- for group analytics keys, we need an array of arrays [[],[],[]]
445
+ for group analytics keys, we need an array of arrays [[],[],[]]
388
446
  each pair represents a group_key and the number of profiles for that key
389
447
  */
390
448
  groupKeys: [],
391
449
  groupProps: {},
392
450
  lookupTables: [],
393
- hook: function (record, type, meta) {
394
-
395
- if (type === "everything") {
396
- const profile = meta.profile;
397
- record.forEach(e => {
398
- e.theme = profile.theme;
399
- });
400
- }
401
-
402
- return record;
403
- }
451
+ // No hook by design this is the engine-validation baseline. Engine + TimeSoup
452
+ // alone produce the trend shape. To experiment with hooks, fork this file.
404
453
  };
405
454
 
406
-
407
-
408
-
409
- export default config;
455
+ export default config;