@ak--47/dungeon-master 1.3.1 → 1.4.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 (51) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dungeons/technical/hook-helpers-verify.js +89 -0
  3. package/dungeons/technical/identity-model-verify.js +47 -0
  4. package/dungeons/technical/pattern-aggregate-by-bin.js +41 -0
  5. package/dungeons/technical/pattern-attributed-by-source.js +42 -0
  6. package/dungeons/technical/pattern-frequency-by-frequency.js +40 -0
  7. package/dungeons/technical/pattern-funnel-frequency.js +54 -0
  8. package/dungeons/technical/pattern-ttc-by-segment.js +45 -0
  9. package/dungeons/vertical/ai-platform.js +45 -52
  10. package/dungeons/vertical/community.js +11 -8
  11. package/dungeons/vertical/crypto.js +25 -24
  12. package/dungeons/vertical/dating.js +56 -48
  13. package/dungeons/vertical/devtools.js +25 -18
  14. package/dungeons/vertical/ecommerce.js +42 -38
  15. package/dungeons/vertical/education.js +24 -9
  16. package/dungeons/vertical/fintech.js +13 -8
  17. package/dungeons/vertical/fitness.js +73 -122
  18. package/dungeons/vertical/food-delivery.js +18 -19
  19. package/dungeons/vertical/gaming.js +19 -20
  20. package/dungeons/vertical/healthcare.js +11 -8
  21. package/dungeons/vertical/insurance-application.js +6 -3
  22. package/dungeons/vertical/logistics.js +15 -9
  23. package/dungeons/vertical/marketplace.js +36 -27
  24. package/dungeons/vertical/media.js +27 -25
  25. package/dungeons/vertical/real-estate.js +18 -7
  26. package/dungeons/vertical/sass.js +84 -68
  27. package/dungeons/vertical/social.js +46 -47
  28. package/dungeons/vertical/travel.js +8 -5
  29. package/lib/core/config-validator.js +136 -157
  30. package/lib/generators/events.js +49 -93
  31. package/lib/generators/funnels.js +202 -91
  32. package/lib/hook-helpers/_internal.js +23 -0
  33. package/lib/hook-helpers/cohort.js +124 -0
  34. package/lib/hook-helpers/identity.js +56 -0
  35. package/lib/hook-helpers/index.js +44 -0
  36. package/lib/hook-helpers/inject.js +99 -0
  37. package/lib/hook-helpers/mutate.js +151 -0
  38. package/lib/hook-helpers/timing.js +99 -0
  39. package/lib/hook-patterns/aggregate-per-user-by-bin.js +38 -0
  40. package/lib/hook-patterns/attributed-by-source.js +72 -0
  41. package/lib/hook-patterns/frequency-by-frequency.js +46 -0
  42. package/lib/hook-patterns/funnel-frequency-breakdown.js +73 -0
  43. package/lib/hook-patterns/index.js +14 -0
  44. package/lib/hook-patterns/time-to-convert-by-segment.js +41 -0
  45. package/lib/orchestrators/user-loop.js +119 -269
  46. package/lib/utils/utils.js +29 -16
  47. package/lib/verify/emulate-breakdown.js +281 -0
  48. package/lib/verify/index.js +12 -0
  49. package/lib/verify/verify-dungeon.js +61 -0
  50. package/package.json +6 -4
  51. package/types.d.ts +397 -211
@@ -1,7 +1,7 @@
1
1
  // ── TWEAK THESE ──
2
2
  const SEED = "harness-social";
3
- const num_days = 100;
4
- const num_users = 5_000;
3
+ const num_days = 120;
4
+ const num_users = 10_000;
5
5
  const avg_events_per_user_per_day = 1.2;
6
6
  let token = "your-mixpanel-token";
7
7
 
@@ -107,7 +107,7 @@ const chance = u.initChance(SEED);
107
107
  * receive follow-backs and post more frequently.
108
108
  *
109
109
  * -------------------------------------------------------------------------------------
110
- * 3. ALGORITHM CHANGE (event)
110
+ * 3. ALGORITHM CHANGE (everything)
111
111
  * -------------------------------------------------------------------------------------
112
112
  *
113
113
  * PATTERN: On day 45, the dominant `source` for "post viewed" flips from
@@ -146,7 +146,7 @@ const chance = u.initChance(SEED);
146
146
  * quality is awful, dragging down avg watch time.
147
147
  *
148
148
  * -------------------------------------------------------------------------------------
149
- * 5. NOTIFICATION RE-ENGAGEMENT (event)
149
+ * 5. NOTIFICATION RE-ENGAGEMENT (everything)
150
150
  * -------------------------------------------------------------------------------------
151
151
  *
152
152
  * PATTERN: After day 30, 30% of "post viewed" events have source flipped
@@ -190,14 +190,14 @@ const chance = u.initChance(SEED);
190
190
  * 7. TOXICITY CHURN (everything)
191
191
  * -------------------------------------------------------------------------------------
192
192
  *
193
- * PATTERN: Users with 3+ "report submitted" events lose 60% of activity
193
+ * PATTERN: Users with 2+ "report submitted" events lose 60% of activity
194
194
  * after day 30. No flag — discover via retention or per-user activity drop.
195
195
  *
196
196
  * HOW TO FIND IT IN MIXPANEL:
197
197
  *
198
198
  * Report 1: Retention by Toxicity
199
199
  * - Report type: Retention
200
- * - Cohort A: users with >= 3 "report submitted"
200
+ * - Cohort A: users with >= 2 "report submitted"
201
201
  * - Cohort B: rest
202
202
  * - Expected: A ~ 40% retention vs B ~ 80%
203
203
  *
@@ -276,15 +276,17 @@ const postIds = v.range(1, 1001).map(n => `post_${v.uid(8)}`);
276
276
 
277
277
  /** @type {Config} */
278
278
  const config = {
279
+ version: 2,
279
280
  token,
280
281
  seed: SEED,
281
282
  datasetStart: "2026-01-01T00:00:00Z",
282
- datasetEnd: "2026-04-28T23:59:59Z",
283
+ datasetEnd: "2026-05-01T23:59:59Z",
283
284
  soup: { dayOfWeekWeights: [1.0, 1.0, 1.0, 1.0, 1.0, 1.2, 1.2] },
284
285
  // numDays: num_days,
285
286
  avgEventsPerUserPerDay: avg_events_per_user_per_day,
286
287
  numUsers: num_users,
287
- hasAnonIds: false,
288
+ hasAnonIds: true,
289
+ avgDevicePerUser: 2,
288
290
  hasSessionIds: true,
289
291
  format: "json",
290
292
  gzip: true,
@@ -379,6 +381,7 @@ const config = {
379
381
  event: "account created",
380
382
  weight: 1,
381
383
  isFirstEvent: true,
384
+ isAuthEvent: true,
382
385
  properties: {
383
386
  "signup_method": ["email", "google", "apple", "sso"],
384
387
  "referred_by": ["organic", "friend", "ad", "influencer"],
@@ -576,43 +579,6 @@ const config = {
576
579
  }
577
580
 
578
581
 
579
- // ─── EVENT-LEVEL HOOKS ───────────────────────────────────────────
580
-
581
- if (type === "event") {
582
- const datasetStart = dayjs.unix(meta.datasetStart);
583
- const ALGORITHM_CHANGE_DAY = datasetStart.add(45, 'days');
584
- const REENGAGEMENT_START = datasetStart.add(30, 'days');
585
- const EVENT_TIME = dayjs(record.time);
586
-
587
- // Hook #3: ALGORITHM CHANGE - Day 45 flips feed -> explore.
588
- // Mutates the existing config-defined `source` prop.
589
- if (record.event === "post viewed") {
590
- if (EVENT_TIME.isAfter(ALGORITHM_CHANGE_DAY)) {
591
- if (chance.bool({ likelihood: 70 })) {
592
- record.source = "explore";
593
- }
594
- } else {
595
- if (chance.bool({ likelihood: 70 })) {
596
- record.source = "feed";
597
- }
598
- }
599
- }
600
-
601
- // Hook #4: ENGAGEMENT BAIT - 20% of post views get crushed view duration.
602
- // No flag — analyst sees bimodal duration distribution + low-tail share.
603
- if (record.event === "post viewed") {
604
- if (chance.bool({ likelihood: 20 })) {
605
- record.view_duration_sec = chance.integer({ min: 1, max: 5 });
606
- }
607
-
608
- // Hook #5: NOTIFICATION RE-ENGAGEMENT — after day 30, 30% of views
609
- // flip source to "notification". Mutates existing source prop.
610
- if (EVENT_TIME.isAfter(REENGAGEMENT_START) && chance.bool({ likelihood: 30 })) {
611
- record.source = "notification";
612
- }
613
- }
614
- }
615
-
616
582
  // ─── EVERYTHING-LEVEL HOOKS ──────────────────────────────────────
617
583
 
618
584
  if (type === "everything") {
@@ -782,9 +748,42 @@ const config = {
782
748
  }
783
749
  }
784
750
 
751
+ // Hook #3: ALGORITHM CHANGE — day 45 flips feed → explore on post viewed.
752
+ // Hook #4: ENGAGEMENT BAIT — 20% of post-viewed events get crushed duration.
753
+ // Hook #5: NOTIFICATION RE-ENGAGEMENT — after day 30, 30% of views → notification.
754
+ // All three run AFTER injection passes so they apply to cloned events too.
755
+ const ALGORITHM_CHANGE_DAY = datasetStart.add(45, 'days');
756
+ const REENGAGEMENT_START = datasetStart.add(30, 'days');
757
+ userEvents.forEach(e => {
758
+ if (e.event === "post viewed") {
759
+ const eventTime = dayjs(e.time);
760
+
761
+ // Hook #3: Algorithm Change
762
+ if (eventTime.isAfter(ALGORITHM_CHANGE_DAY)) {
763
+ if (chance.bool({ likelihood: 70 })) {
764
+ e.source = "explore";
765
+ }
766
+ } else {
767
+ if (chance.bool({ likelihood: 70 })) {
768
+ e.source = "feed";
769
+ }
770
+ }
771
+
772
+ // Hook #4: Engagement Bait — 20% crushed view duration
773
+ if (chance.bool({ likelihood: 20 })) {
774
+ e.view_duration_sec = chance.integer({ min: 1, max: 5 });
775
+ }
776
+
777
+ // Hook #5: Notification Re-engagement (runs after #3 so can override)
778
+ if (eventTime.isAfter(REENGAGEMENT_START) && chance.bool({ likelihood: 30 })) {
779
+ e.source = "notification";
780
+ }
781
+ }
782
+ });
783
+
785
784
  // Hook #7: TOXICITY CHURN — drop 60% of activity after day 30 for high reporters.
786
- // Discovery: cohort users with >=3 report-submitted events, observe retention drop.
787
- if (reportSubmittedCount >= 3) {
785
+ // Discovery: cohort users with >=2 report-submitted events, observe retention drop.
786
+ if (reportSubmittedCount >= 2) {
788
787
  const churnCutoff = datasetStart.add(30, 'days');
789
788
  for (let i = userEvents.length - 1; i >= 0; i--) {
790
789
  const evt = userEvents[i];
@@ -1,7 +1,7 @@
1
1
  // ── TWEAK THESE ──
2
2
  const SEED = "dm4-travel";
3
- const num_days = 100;
4
- const num_users = 5_000;
3
+ const num_days = 120;
4
+ const num_users = 10_000;
5
5
  const avg_events_per_user_per_day = 1.2;
6
6
  let token = "your-mixpanel-token";
7
7
 
@@ -29,7 +29,7 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
29
29
  * StayQuest — a hotel booking platform for business and leisure travelers.
30
30
  * Users search destinations, compare hotels, book rooms, and leave reviews.
31
31
  *
32
- * - 5,000 users over 100 days, ~600K events
32
+ * - 5,000 users over 120 days, ~600K events
33
33
  * - Segments: business travelers (weekday), leisure families, luxury, budget
34
34
  * - Core loop: search → view hotel → compare → book → stay → review
35
35
  * - Revenue: commission per booking + premium loyalty membership
@@ -210,14 +210,16 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
210
210
 
211
211
  /** @type {Config} */
212
212
  const config = {
213
+ version: 2,
213
214
  token,
214
215
  seed: SEED,
215
216
  datasetStart: "2026-01-01T00:00:00Z",
216
- datasetEnd: "2026-04-28T23:59:59Z",
217
+ datasetEnd: "2026-05-01T23:59:59Z",
217
218
  // numDays: num_days,
218
219
  avgEventsPerUserPerDay: avg_events_per_user_per_day,
219
220
  numUsers: num_users,
220
- hasAnonIds: false,
221
+ hasAnonIds: true,
222
+ avgDevicePerUser: 2,
221
223
  hasSessionIds: true,
222
224
  format: "json",
223
225
  gzip: true,
@@ -250,6 +252,7 @@ const config = {
250
252
  event: "account created",
251
253
  weight: 1,
252
254
  isFirstEvent: true,
255
+ isAuthEvent: true,
253
256
  properties: {
254
257
  signup_source: ["organic", "google", "instagram", "tripadvisor", "referral", "email_campaign"],
255
258
  },
@@ -145,6 +145,106 @@ function inferFunnels(events) {
145
145
  return createdFunnels;
146
146
  }
147
147
 
148
+ /**
149
+ * Config keys removed from the engine in 1.4. Silently stripped by
150
+ * `validateDungeonConfig` with a single warning per dungeon. To recreate any of these
151
+ * patterns, use hooks (see `lib/hook-patterns/*`).
152
+ */
153
+ const KILLED_CONFIG_KEYS = ['subscription', 'attribution', 'geo', 'features', 'anomalies'];
154
+
155
+ /**
156
+ * Strip killed config keys in place, log one deprecation warning per dungeon.
157
+ * @param {Partial<Dungeon>} config
158
+ */
159
+ function stripKilledConfigKeys(config) {
160
+ const found = KILLED_CONFIG_KEYS.filter(k => config[k] !== undefined && config[k] !== null);
161
+ if (!found.length) return;
162
+ for (const k of found) delete config[k];
163
+ if (config.verbose !== false) {
164
+ console.warn(
165
+ `⚠️ dungeon-master 1.4 removed engine support for: ${found.join(', ')}. ` +
166
+ `These config keys are silently ignored. Recreate via hooks (see lib/hook-patterns/* once Phase 4 lands).`
167
+ );
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Validate `Funnel.attempts` config in place. Coerces missing/invalid bounds so callers
173
+ * downstream don't have to re-defend. Throws on logically invalid configs (max < min).
174
+ * @param {import('../../types.js').Funnel[]} funnels
175
+ */
176
+ function validateAttempts(funnels) {
177
+ for (const f of funnels) {
178
+ if (!f || !f.attempts) continue;
179
+ const a = f.attempts;
180
+ const min = Number.isFinite(a.min) ? Math.max(0, Math.floor(a.min)) : 0;
181
+ const max = Number.isFinite(a.max) ? Math.max(0, Math.floor(a.max)) : min;
182
+ if (max < min) {
183
+ throw new Error(`Funnel "${f.name || f.sequence?.join(' > ')}" attempts.max (${a.max}) must be >= attempts.min (${a.min})`);
184
+ }
185
+ a.min = min;
186
+ a.max = max;
187
+ if (a.conversionRate !== undefined) {
188
+ if (!Number.isFinite(a.conversionRate)) {
189
+ throw new Error(`Funnel "${f.name || f.sequence?.join(' > ')}" attempts.conversionRate must be a finite number 0-100`);
190
+ }
191
+ a.conversionRate = Math.max(0, Math.min(100, a.conversionRate));
192
+ }
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Normalize `Funnel.experiment` in place. `true` → default 3-variant config.
198
+ * Object → validated and resolved. Stores `funnel._experiment` for downstream.
199
+ * @param {import('../../types.js').Funnel[]} funnels
200
+ * @param {number} datasetEndUnix
201
+ */
202
+ function normalizeExperiments(funnels, datasetEndUnix) {
203
+ const DEFAULT_VARIANTS = [
204
+ { name: 'Variant A', conversionMultiplier: 0.7, ttcMultiplier: 1.5, weight: 1 },
205
+ { name: 'Variant B', conversionMultiplier: 1.3, ttcMultiplier: 0.7, weight: 1 },
206
+ { name: 'Control', conversionMultiplier: 1.0, ttcMultiplier: 1.0, weight: 1 },
207
+ ];
208
+ for (const f of funnels) {
209
+ if (!f || !f.experiment) continue;
210
+ const raw = f.experiment === true ? {} : f.experiment;
211
+ const name = raw.name || (f.name ? f.name + ' Experiment' : 'Unnamed Experiment');
212
+ const variants = (raw.variants && raw.variants.length)
213
+ ? raw.variants.map(v => ({
214
+ name: v.name || 'Unnamed Variant',
215
+ conversionMultiplier: Number.isFinite(v.conversionMultiplier) ? Math.max(0.01, v.conversionMultiplier) : 1.0,
216
+ ttcMultiplier: Number.isFinite(v.ttcMultiplier) ? Math.max(0.01, v.ttcMultiplier) : 1.0,
217
+ weight: Number.isFinite(v.weight) && v.weight > 0 ? v.weight : 1,
218
+ }))
219
+ : DEFAULT_VARIANTS;
220
+ const startDays = Number.isFinite(raw.startDaysBeforeEnd) && raw.startDaysBeforeEnd > 0
221
+ ? raw.startDaysBeforeEnd : 0;
222
+ const startUnix = startDays > 0 ? datasetEndUnix - startDays * 86400 : null;
223
+ f._experiment = { name, variants, startUnix };
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Resolve `avgDevicePerUser` per Section 3.3 rules. Returns the integer device count
229
+ * the engine should use (0 = no device_id stamping at all, 1+ = pool size).
230
+ *
231
+ * @param {Partial<Dungeon>} config
232
+ * @returns {number}
233
+ */
234
+ function resolveDevicesPerUser(config) {
235
+ const raw = config.avgDevicePerUser;
236
+ const hasAnon = config.hasAnonIds === true;
237
+ if (raw === undefined || raw === null) {
238
+ return hasAnon ? 1 : 0;
239
+ }
240
+ if (!Number.isFinite(raw)) {
241
+ return hasAnon ? 1 : 0;
242
+ }
243
+ const n = Math.round(raw);
244
+ if (n <= 0) return hasAnon ? 1 : 0;
245
+ return n;
246
+ }
247
+
148
248
  /**
149
249
  * Validates and enriches a dungeon configuration object
150
250
  * @param {Partial<Dungeon>} config - Raw configuration object
@@ -153,6 +253,9 @@ function inferFunnels(events) {
153
253
  export function validateDungeonConfig(config) {
154
254
  const chance = u.getChance();
155
255
 
256
+ // Phase 1 — strip killed config keys before anything else reads them.
257
+ stripKilledConfigKeys(config);
258
+
156
259
  // Transform SCD props to regular props if credentials are missing
157
260
  // This MUST happen BEFORE we extract values from the config
158
261
  transformSCDPropsWithoutCredentials(config);
@@ -436,35 +539,31 @@ export function validateDungeonConfig(config) {
436
539
  dataQuality = validateDataQuality(dataQuality);
437
540
  }
438
541
 
439
- // Feature 5: Subscription
440
- let subscription = config.subscription || null;
441
- if (subscription) {
442
- subscription = validateSubscription(subscription);
443
- }
542
+ // Phase 1: validate Funnel.attempts on every funnel (additive — most have none).
543
+ validateAttempts(funnels);
444
544
 
445
- // Feature 6: Attribution
446
- let attribution = config.attribution || null;
447
- if (attribution) {
448
- attribution = validateAttribution(attribution, numDays);
449
- }
545
+ // Normalize experiment configs: true → default 3-variant, object → validated.
546
+ normalizeExperiments(funnels, datasetEndUnix);
450
547
 
451
- // Feature 7: Geo
452
- let geo = config.geo || null;
453
- if (geo) {
454
- geo = validateGeo(geo);
455
- }
548
+ // Phase 1: resolve multi-device config. `avgDevicePerUser` is the canonical knob;
549
+ // `hasAnonIds: true` aliases to 1. Default 0 = no device_id stamping (legacy).
550
+ const avgDevicePerUser = resolveDevicesPerUser(config);
551
+ // Keep hasAnonIds in sync — downstream code (utils.generateUser) still reads it.
552
+ // Setting it true here when avgDevicePerUser >= 1 lets the legacy device-pool
553
+ // generation in `person()` continue to allocate `anonymousIds[]` for the user.
554
+ const hasAnonIdsResolved = avgDevicePerUser >= 1;
456
555
 
457
- // Feature 8: Features (progressive adoption)
458
- let features = config.features || null;
459
- if (features) {
460
- features = resolveFeatures(features, numDays);
556
+ // Warn if isAuthEvent is set but avgDevicePerUser=0 — pre-auth device_only
557
+ // stamping degrades to user_id via the floor guard, defeating the identity model.
558
+ if (avgDevicePerUser === 0 && validatedEvents.some(e => e.isAuthEvent)) {
559
+ console.warn(
560
+ `⚠️ isAuthEvent requires avgDevicePerUser >= 1 to produce pre-auth anonymous events. ` +
561
+ `Set avgDevicePerUser or hasAnonIds: true.`
562
+ );
461
563
  }
462
564
 
463
- // Feature 9: Anomalies
464
- let anomalies = config.anomalies || null;
465
- if (anomalies) {
466
- anomalies = resolveAnomalies(anomalies, datasetStartUnix);
467
- }
565
+ // Precompute whether any event has isAttributionEvent for UTM stamping logic.
566
+ const hasAttributionFlags = validatedEvents.some(e => e.isAttributionEvent);
468
567
 
469
568
  // Build final config object
470
569
  const validatedConfig = {
@@ -489,7 +588,8 @@ export function validateDungeonConfig(config) {
489
588
  groupKeys,
490
589
  groupProps,
491
590
  lookupTables,
492
- hasAnonIds,
591
+ hasAnonIds: hasAnonIdsResolved,
592
+ avgDevicePerUser,
493
593
  hasSessionIds,
494
594
  sessionTimeout: (typeof sessionTimeout === 'number' && sessionTimeout > 0) ? sessionTimeout : 30,
495
595
  format,
@@ -501,6 +601,7 @@ export function validateDungeonConfig(config) {
501
601
  hook,
502
602
  hasAdSpend,
503
603
  hasCampaigns,
604
+ hasAttributionFlags,
504
605
  hasLocation,
505
606
  hasAvatar,
506
607
  isAnonymous,
@@ -515,16 +616,17 @@ export function validateDungeonConfig(config) {
515
616
  bornRecentBias,
516
617
  percentUsersBornInDataset,
517
618
  preExistingSpread,
518
- // Advanced features
619
+ // Advanced features (kept after 1.4)
519
620
  personas,
520
621
  worldEvents,
521
622
  engagementDecay,
522
623
  dataQuality,
523
- subscription,
524
- attribution,
525
- geo,
526
- features,
527
- anomalies
624
+ // Killed in 1.4 — set to null so hooks/external code that reads these get a falsy value.
625
+ subscription: null,
626
+ attribution: null,
627
+ geo: null,
628
+ features: null,
629
+ anomalies: null
528
630
  };
529
631
 
530
632
  return validatedConfig;
@@ -689,131 +791,8 @@ function validateDataQuality(dq) {
689
791
  return dq;
690
792
  }
691
793
 
692
- /**
693
- * Validates subscription config
694
- * @param {import('../../types').Subscription} sub
695
- * @returns {import('../../types').Subscription}
696
- */
697
- function validateSubscription(sub) {
698
- if (!sub.plans || !Array.isArray(sub.plans) || sub.plans.length === 0) {
699
- throw new Error('subscription.plans must be a non-empty array');
700
- }
701
- const hasDefault = sub.plans.some(p => p.default);
702
- if (!hasDefault) sub.plans[0].default = true;
703
- if (!sub.lifecycle) sub.lifecycle = {};
704
- const lc = sub.lifecycle;
705
- if (lc.trialToPayRate === undefined) lc.trialToPayRate = 0.3;
706
- if (lc.upgradeRate === undefined) lc.upgradeRate = 0.1;
707
- if (lc.downgradeRate === undefined) lc.downgradeRate = 0.03;
708
- if (lc.churnRate === undefined) lc.churnRate = 0.05;
709
- if (lc.winBackRate === undefined) lc.winBackRate = 0.1;
710
- if (lc.winBackDelay === undefined) lc.winBackDelay = 30;
711
- if (lc.paymentFailureRate === undefined) lc.paymentFailureRate = 0.02;
712
- if (!sub.events) sub.events = {};
713
- const ev = sub.events;
714
- if (!ev.trialStarted) ev.trialStarted = "trial started";
715
- if (!ev.subscribed) ev.subscribed = "subscription started";
716
- if (!ev.upgraded) ev.upgraded = "plan upgraded";
717
- if (!ev.downgraded) ev.downgraded = "plan downgraded";
718
- if (!ev.renewed) ev.renewed = "subscription renewed";
719
- if (!ev.cancelled) ev.cancelled = "subscription cancelled";
720
- if (!ev.paymentFailed) ev.paymentFailed = "payment failed";
721
- if (!ev.wonBack) ev.wonBack = "subscription reactivated";
722
- return sub;
723
- }
724
-
725
- /**
726
- * Validates attribution config
727
- * @param {import('../../types').Attribution} attr
728
- * @param {number} numDays
729
- * @returns {import('../../types').Attribution}
730
- */
731
- function validateAttribution(attr, numDays) {
732
- if (!attr.campaigns || !Array.isArray(attr.campaigns)) {
733
- throw new Error('attribution.campaigns must be an array');
734
- }
735
- if (attr.model === undefined) attr.model = "last_touch";
736
- if (attr.window === undefined) attr.window = 7;
737
- if (attr.organicRate === undefined) attr.organicRate = 0.4;
738
- for (const c of attr.campaigns) {
739
- if (!c.name) throw new Error('Each attribution campaign must have a name');
740
- if (!c.source) throw new Error(`Attribution campaign "${c.name}" must have a source`);
741
- if (!c.activeDays) throw new Error(`Attribution campaign "${c.name}" must have activeDays [start, end]`);
742
- if (!c.dailyBudget) c.dailyBudget = [50, 200];
743
- if (c.acquisitionRate === undefined) c.acquisitionRate = 0.02;
744
- }
745
- return attr;
746
- }
747
-
748
- /**
749
- * Validates geo config
750
- * @param {import('../../types').GeoConfig} geo
751
- * @returns {import('../../types').GeoConfig}
752
- */
753
- function validateGeo(geo) {
754
- if (geo.sticky === undefined) geo.sticky = false;
755
- if (geo.regions && !Array.isArray(geo.regions)) throw new Error('geo.regions must be an array');
756
- if (geo.regionalLaunches && !Array.isArray(geo.regionalLaunches)) throw new Error('geo.regionalLaunches must be an array');
757
- return geo;
758
- }
759
-
760
- /**
761
- * Resolves feature configs with logistic curve parameters
762
- * @param {import('../../types').FeatureConfig[]} features
763
- * @param {number} numDays
764
- * @returns {import('../../types').FeatureConfig[]}
765
- */
766
- function resolveFeatures(features, numDays) {
767
- if (!Array.isArray(features) || features.length === 0) return null;
768
- const curvePresets = {
769
- fast: { k: 0.3, midpoint: 7 },
770
- slow: { k: 0.08, midpoint: 30 },
771
- instant: { k: 10, midpoint: 0 }
772
- };
773
- return features.map(f => {
774
- if (!f.name) throw new Error('Each feature must have a name');
775
- if (f.launchDay === undefined) throw new Error(`Feature "${f.name}" must have a launchDay`);
776
- if (!f.property) throw new Error(`Feature "${f.name}" must have a property`);
777
- if (!f.values || !Array.isArray(f.values) || f.values.length === 0) {
778
- throw new Error(`Feature "${f.name}" must have a non-empty values array`);
779
- }
780
- if (!f.affectsEvents) f.affectsEvents = "*";
781
- if (!f.adoptionCurve) f.adoptionCurve = "slow";
782
- if (typeof f.adoptionCurve === 'string') {
783
- f._resolvedCurve = curvePresets[f.adoptionCurve] || curvePresets.slow;
784
- } else {
785
- f._resolvedCurve = f.adoptionCurve;
786
- }
787
- // Pre-compute adopted values to avoid array allocation in hot loop
788
- f._adoptedValues = f.values.length > 1 ? f.values.slice(1) : f.values;
789
- return f;
790
- });
791
- }
792
-
793
- /**
794
- * Resolves anomaly configs with absolute timestamps
795
- * @param {import('../../types').AnomalyConfig[]} anomalies
796
- * @param {number} beginUnix - Dataset start (unix seconds)
797
- * @returns {import('../../types').AnomalyConfig[]}
798
- */
799
- function resolveAnomalies(anomalies, beginUnix) {
800
- if (!Array.isArray(anomalies) || anomalies.length === 0) return null;
801
- return anomalies.map(a => {
802
- if (!a.type) throw new Error('Each anomaly must have a type');
803
- if (!a.event) throw new Error('Each anomaly must have an event name');
804
- const resolved = { ...a };
805
- if (a.day !== undefined) {
806
- resolved._startUnix = beginUnix + (a.day * 86400);
807
- if (a.duration) {
808
- resolved._endUnix = resolved._startUnix + (a.duration * 86400);
809
- } else if (a.window) {
810
- resolved._endUnix = resolved._startUnix + (a.window * 86400);
811
- } else {
812
- resolved._endUnix = resolved._startUnix + 86400; // default 1 day
813
- }
814
- }
815
- return resolved;
816
- });
817
- }
794
+ // validateSubscription / validateAttribution / validateGeo / resolveFeatures /
795
+ // resolveAnomalies were removed in 1.4 along with their respective config keys.
796
+ // `stripKilledConfigKeys` deletes the inputs before they reach the validator body.
818
797
 
819
798
  export { inferFunnels, transformSCDPropsWithoutCredentials };