@ak--47/dungeon-master 1.2.3 → 1.3.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.
- package/CHANGELOG.md +42 -0
- package/README.md +51 -13
- package/dungeons/technical/ad-spend.js +2 -2
- package/dungeons/technical/anonymous-users.js +2 -2
- package/dungeons/technical/array-of-object-lookup.js +2 -4
- package/dungeons/technical/experiments.js +2 -2
- package/dungeons/technical/foobar.js +2 -2
- package/dungeons/technical/group-analytics.js +2 -2
- package/dungeons/technical/mirror-strategies.js +2 -2
- package/dungeons/technical/nested-objects.js +2 -2
- package/dungeons/technical/retention-cadence.js +2 -3
- package/dungeons/technical/sanity.js +2 -2
- package/dungeons/technical/scale-test.js +2 -2
- package/dungeons/technical/scd.js +2 -2
- package/dungeons/technical/simple.js +5 -6
- package/dungeons/technical/simplest-schema.json +5 -0
- package/dungeons/technical/simplest.js +2 -2
- package/dungeons/technical/text-generation.js +3 -3
- package/dungeons/vertical/ai-platform.js +858 -0
- package/dungeons/vertical/community.js +84 -40
- package/dungeons/vertical/crypto.js +830 -0
- package/dungeons/vertical/dating.js +744 -0
- package/dungeons/vertical/devtools.js +175 -69
- package/dungeons/vertical/ecommerce.js +242 -94
- package/dungeons/vertical/education.js +330 -313
- package/dungeons/vertical/fintech.js +442 -313
- package/dungeons/vertical/fitness.js +143 -61
- package/dungeons/vertical/food-delivery.js +327 -353
- package/dungeons/vertical/gaming.js +912 -382
- package/dungeons/vertical/healthcare.js +142 -63
- package/dungeons/vertical/insurance-application.js +170 -76
- package/dungeons/vertical/logistics.js +115 -20
- package/dungeons/vertical/marketplace.js +152 -58
- package/dungeons/vertical/media.js +248 -384
- package/dungeons/vertical/real-estate.js +781 -0
- package/dungeons/vertical/sass.js +255 -266
- package/dungeons/vertical/social.js +264 -206
- package/dungeons/vertical/travel.js +117 -41
- package/index.js +17 -17
- package/lib/core/config-validator.js +159 -31
- package/lib/core/context.js +10 -24
- package/lib/core/storage.js +6 -1
- package/lib/generators/events.js +11 -14
- package/lib/generators/funnels.js +12 -4
- package/lib/generators/mirror.js +3 -2
- package/lib/generators/product-names.js +1 -1
- package/lib/generators/scd.js +2 -1
- package/lib/generators/text.js +1 -1
- package/lib/orchestrators/user-loop.js +81 -47
- package/lib/templates/macro-presets.js +111 -0
- package/lib/templates/soup-presets.js +19 -36
- package/lib/utils/utils.js +71 -39
- package/package.json +8 -2
- package/scripts/smoke-test-all.mjs +162 -0
- package/scripts/verify-runner.mjs +72 -24
- package/types.d.ts +251 -51
- package/dungeons/technical/ad-spend-schema.json +0 -128
- package/dungeons/technical/anonymous-users-schema.json +0 -92
- package/dungeons/technical/array-of-object-lookup-schema.json +0 -191
- package/dungeons/technical/experiments-schema.json +0 -203
- package/dungeons/technical/foobar-schema.json +0 -362
- package/dungeons/technical/group-analytics-schema.json +0 -241
- package/dungeons/technical/mirror-strategies-schema.json +0 -84
- package/dungeons/technical/nested-objects-schema.json +0 -145
- package/dungeons/technical/retention-cadence-schema.json +0 -37
- package/dungeons/technical/sanity-schema.json +0 -185
- package/dungeons/technical/scale-test-schema.json +0 -70
- package/dungeons/technical/scd-schema.json +0 -467
- package/dungeons/technical/simple-schema.json +0 -362
- package/dungeons/technical/text-generation-schema.json +0 -1062
- package/dungeons/user/.gitkeep +0 -0
- package/dungeons/vertical/community-schema.json +0 -579
- package/dungeons/vertical/devtools-schema.json +0 -601
- package/dungeons/vertical/ecommerce-schema.json +0 -604
- package/dungeons/vertical/education-schema.json +0 -5686
- package/dungeons/vertical/fintech-schema.json +0 -630
- package/dungeons/vertical/fitness-schema.json +0 -530
- package/dungeons/vertical/food-delivery-schema.json +0 -36728
- package/dungeons/vertical/gaming-schema.json +0 -438
- package/dungeons/vertical/healthcare-schema.json +0 -549
- package/dungeons/vertical/insurance-application-schema.json +0 -485
- package/dungeons/vertical/logistics-schema.json +0 -574
- package/dungeons/vertical/marketplace-schema.json +0 -533
- package/dungeons/vertical/media-schema.json +0 -4749
- package/dungeons/vertical/rpg-schema.json +0 -2491
- package/dungeons/vertical/rpg.js +0 -976
- package/dungeons/vertical/sass-schema.json +0 -3128
- package/dungeons/vertical/social-schema.json +0 -620
- package/dungeons/vertical/travel-schema.json +0 -580
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
const SEED = "dm4-travel";
|
|
3
3
|
const num_days = 100;
|
|
4
4
|
const num_users = 5_000;
|
|
5
|
-
const
|
|
5
|
+
const avg_events_per_user_per_day = 1.2;
|
|
6
6
|
let token = "your-mixpanel-token";
|
|
7
7
|
|
|
8
8
|
// ── env overrides ──
|
|
@@ -16,9 +16,6 @@ import * as v from "ak-tools";
|
|
|
16
16
|
|
|
17
17
|
dayjs.extend(utc);
|
|
18
18
|
const chance = u.initChance(SEED);
|
|
19
|
-
const NOW = dayjs();
|
|
20
|
-
const DATASET_START = NOW.subtract(num_days, "days");
|
|
21
|
-
|
|
22
19
|
/** @typedef {import("../../types").Dungeon} Config */
|
|
23
20
|
|
|
24
21
|
const hotelIds = v.range(1, 200).map(() => `HTL_${v.uid(6)}`);
|
|
@@ -48,7 +45,16 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
|
|
|
48
45
|
|
|
49
46
|
/**
|
|
50
47
|
* ═══════════════════════════════════════════════════════════════
|
|
51
|
-
* ANALYTICS HOOKS (
|
|
48
|
+
* ANALYTICS HOOKS (10 hooks)
|
|
49
|
+
*
|
|
50
|
+
* NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable via
|
|
51
|
+
* raw-prop breakdowns (booking_window, day, segment) or behavioral cohorts.
|
|
52
|
+
*
|
|
53
|
+
* Adds 9. BOOKING TIME-TO-CONVERT (Business 1.35x faster, Budget 1.25x slower)
|
|
54
|
+
* [funnel-post: visible only in Mixpanel funnel median TTC; cross-event
|
|
55
|
+
* MIN→MIN SQL queries do NOT show this]
|
|
56
|
+
* and 10. HOTEL-VIEWED MAGIC NUMBER (sweet 5-10 → +30% nightly_rate;
|
|
57
|
+
* over 11+ → drop 35% of bookings).
|
|
52
58
|
* ═══════════════════════════════════════════════════════════════
|
|
53
59
|
*
|
|
54
60
|
* ───────────────────────────────────────────────────────────────
|
|
@@ -206,8 +212,10 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
|
|
|
206
212
|
const config = {
|
|
207
213
|
token,
|
|
208
214
|
seed: SEED,
|
|
209
|
-
|
|
210
|
-
|
|
215
|
+
datasetStart: "2026-01-01T00:00:00Z",
|
|
216
|
+
datasetEnd: "2026-04-28T23:59:59Z",
|
|
217
|
+
// numDays: num_days,
|
|
218
|
+
avgEventsPerUserPerDay: avg_events_per_user_per_day,
|
|
211
219
|
numUsers: num_users,
|
|
212
220
|
hasAnonIds: false,
|
|
213
221
|
hasSessionIds: true,
|
|
@@ -222,7 +230,6 @@ const config = {
|
|
|
222
230
|
hasCampaigns: false,
|
|
223
231
|
isAnonymous: false,
|
|
224
232
|
hasAdSpend: false,
|
|
225
|
-
percentUsersBornInDataset: 35,
|
|
226
233
|
hasAvatar: true,
|
|
227
234
|
concurrency: 1,
|
|
228
235
|
writeToDisk: false,
|
|
@@ -640,36 +647,34 @@ const config = {
|
|
|
640
647
|
}
|
|
641
648
|
}
|
|
642
649
|
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
if (type === "funnel-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
650
|
+
// HOOK 9 (T2C): BOOKING TIME-TO-CONVERT (funnel-post)
|
|
651
|
+
// Business travelers complete the Search-to-Book funnel 1.35x faster
|
|
652
|
+
// (factor 0.74); leisure_family/budget customers 1.25x slower (1.25).
|
|
653
|
+
if (type === "funnel-post") {
|
|
654
|
+
const segment = meta?.profile?.customer_segment;
|
|
655
|
+
if (Array.isArray(record) && record.length > 1) {
|
|
656
|
+
const factor = (
|
|
657
|
+
segment === "business_traveler" ? 0.74 :
|
|
658
|
+
segment === "budget_hunter" || segment === "leisure_family" ? 1.25 :
|
|
659
|
+
1.0
|
|
660
|
+
);
|
|
661
|
+
if (factor !== 1.0) {
|
|
662
|
+
for (let i = 1; i < record.length; i++) {
|
|
663
|
+
const prev = dayjs(record[i - 1].time);
|
|
664
|
+
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
665
|
+
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
666
|
+
}
|
|
658
667
|
}
|
|
659
668
|
}
|
|
669
|
+
}
|
|
660
670
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
} else if (daysUntilEnd < 3) {
|
|
669
|
-
record.booking_window = "last_minute";
|
|
670
|
-
record.nightly_rate = Math.floor((record.nightly_rate || 150) * 1.4);
|
|
671
|
-
}
|
|
672
|
-
}
|
|
671
|
+
// ── HOOK 1: WEEKEND LEISURE SURGE ────────────
|
|
672
|
+
// Moved to everything hook (sessionization reassigns times after event hook)
|
|
673
|
+
|
|
674
|
+
// ── HOOK 2: ADVANCE BOOKING DISCOUNT ─────
|
|
675
|
+
// Moved to everything hook (sessionization reassigns times after event hook)
|
|
676
|
+
if (type === "event") {
|
|
677
|
+
// no-op: DOW-dependent hooks moved to everything hook
|
|
673
678
|
}
|
|
674
679
|
|
|
675
680
|
// ── EVERYTHING HOOKS ─────────────────────────────────
|
|
@@ -689,17 +694,45 @@ const config = {
|
|
|
689
694
|
});
|
|
690
695
|
}
|
|
691
696
|
|
|
697
|
+
// ── HOOK 1: WEEKEND LEISURE SURGE ────────────
|
|
698
|
+
// Apply AFTER sessionization has finalized times
|
|
699
|
+
events.forEach(e => {
|
|
700
|
+
if (e.event === "booking completed") {
|
|
701
|
+
const dayOfWeek = new Date(e.time).getUTCDay();
|
|
702
|
+
// Friday=5, Saturday=6, Sunday=0
|
|
703
|
+
if (dayOfWeek === 0 || dayOfWeek === 5 || dayOfWeek === 6) {
|
|
704
|
+
e.nightly_rate = Math.floor((e.nightly_rate || 150) * 1.3);
|
|
705
|
+
e.total_cost = Math.floor((e.total_cost || 450) * 1.3);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// ── HOOK 2: ADVANCE BOOKING DISCOUNT ─────
|
|
711
|
+
const datasetEndForBooking = dayjs.unix(meta.datasetEnd);
|
|
712
|
+
events.forEach(e => {
|
|
713
|
+
if (e.event === "booking completed") {
|
|
714
|
+
const eventTime = dayjs(e.time);
|
|
715
|
+
const daysUntilEnd = datasetEndForBooking.diff(eventTime, "days");
|
|
716
|
+
if (daysUntilEnd > 21) {
|
|
717
|
+
e.booking_window = "advance";
|
|
718
|
+
e.nightly_rate = Math.floor((e.nightly_rate || 150) * 0.8);
|
|
719
|
+
} else if (daysUntilEnd < 3) {
|
|
720
|
+
e.booking_window = "last_minute";
|
|
721
|
+
e.nightly_rate = Math.floor((e.nightly_rate || 150) * 1.4);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
|
|
692
726
|
// ─── Bug 2 fix: Repeat destination clustering conversion filtering ───
|
|
693
727
|
// Drop ~25% of "booking completed" events for users who are NOT
|
|
694
728
|
// business_traveler or luxury_seeker to simulate their lower funnel
|
|
695
729
|
// conversion (was conversionRate * 1.3 / 1.15 in funnel-pre)
|
|
696
730
|
const segment = profile && profile.customer_segment;
|
|
697
|
-
if (segment !== "business_traveler" && segment !== "luxury_seeker"
|
|
731
|
+
if (segment !== "business_traveler" && segment !== "luxury_seeker"
|
|
732
|
+
&& chance.bool({ likelihood: 25 })) {
|
|
698
733
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
699
734
|
if (events[i].event === "booking completed") {
|
|
700
|
-
|
|
701
|
-
events.splice(i, 1);
|
|
702
|
-
}
|
|
735
|
+
events.splice(i, 1);
|
|
703
736
|
}
|
|
704
737
|
}
|
|
705
738
|
}
|
|
@@ -716,7 +749,29 @@ const config = {
|
|
|
716
749
|
}
|
|
717
750
|
|
|
718
751
|
// ── HOOK 4: CANCELLATION BY BOOKING WINDOW ───────
|
|
719
|
-
//
|
|
752
|
+
// Match each cancellation to its nearest preceding booking
|
|
753
|
+
// and copy the booking's booking_window. This replaces the
|
|
754
|
+
// random booking_window that the event config assigns to
|
|
755
|
+
// cancellation events independently.
|
|
756
|
+
const bookingsByTime = events
|
|
757
|
+
.filter(e => e.event === "booking completed")
|
|
758
|
+
.sort((a, b) => new Date(a.time) - new Date(b.time));
|
|
759
|
+
events.forEach(e => {
|
|
760
|
+
if (e.event === "booking cancelled" && bookingsByTime.length > 0) {
|
|
761
|
+
const cancelTime = new Date(e.time).getTime();
|
|
762
|
+
// Find the nearest preceding booking (or the first one)
|
|
763
|
+
let matched = bookingsByTime[0];
|
|
764
|
+
for (let b = bookingsByTime.length - 1; b >= 0; b--) {
|
|
765
|
+
if (new Date(bookingsByTime[b].time).getTime() <= cancelTime) {
|
|
766
|
+
matched = bookingsByTime[b];
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
e.booking_window = matched.booking_window;
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
// Last-minute bookers rarely cancel — drop 40%.
|
|
720
775
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
721
776
|
if (events[i].event === "booking cancelled" && events[i].booking_window === "last_minute") {
|
|
722
777
|
if (chance.bool({ likelihood: 40 })) {
|
|
@@ -743,7 +798,8 @@ const config = {
|
|
|
743
798
|
}
|
|
744
799
|
}
|
|
745
800
|
|
|
746
|
-
//
|
|
801
|
+
// HOOK 6: REVIEW QUALITY BY STAY RATING — high avg ratings get
|
|
802
|
+
// review_length 1.5x; low avg ratings get 0.5x. Mutates raw prop.
|
|
747
803
|
let totalRating = 0;
|
|
748
804
|
let ratingCount = 0;
|
|
749
805
|
events.forEach(e => {
|
|
@@ -763,6 +819,26 @@ const config = {
|
|
|
763
819
|
}
|
|
764
820
|
});
|
|
765
821
|
|
|
822
|
+
// HOOK 10: HOTEL-VIEWED MAGIC NUMBER (in-funnel, no flags)
|
|
823
|
+
// Sweet 5-10 hotel-viewed events → +30% on booking nightly_rate
|
|
824
|
+
// (decisive comparison shoppers book higher-tier rooms).
|
|
825
|
+
// Over 11+ → drop 35% of booking-completed events (analysis
|
|
826
|
+
// paralysis blocks conversion).
|
|
827
|
+
const hotelViews = events.filter(e => e.event === "hotel viewed").length;
|
|
828
|
+
if (hotelViews >= 5 && hotelViews <= 10) {
|
|
829
|
+
events.forEach(e => {
|
|
830
|
+
if (e.event === "booking completed" && typeof e.nightly_rate === "number") {
|
|
831
|
+
e.nightly_rate = Math.round(e.nightly_rate * 1.3);
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
} else if (hotelViews >= 11) {
|
|
835
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
836
|
+
if (events[i].event === "booking completed" && chance.bool({ likelihood: 35 })) {
|
|
837
|
+
events.splice(i, 1);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
766
842
|
return record;
|
|
767
843
|
}
|
|
768
844
|
|
package/index.js
CHANGED
|
@@ -25,7 +25,7 @@ import { makeMirror } from './lib/generators/mirror.js';
|
|
|
25
25
|
import { makeGroupProfile, makeProfile } from './lib/generators/profiles.js';
|
|
26
26
|
|
|
27
27
|
// Utilities
|
|
28
|
-
import { initChance } from './lib/utils/utils.js';
|
|
28
|
+
import { initChance, setDatasetNow } from './lib/utils/utils.js';
|
|
29
29
|
|
|
30
30
|
// External dependencies
|
|
31
31
|
import dayjs from "dayjs";
|
|
@@ -33,12 +33,8 @@ import utc from "dayjs/plugin/utc.js";
|
|
|
33
33
|
import { timer } from 'ak-tools';
|
|
34
34
|
import { dataLogger as logger } from './lib/utils/logger.js';
|
|
35
35
|
|
|
36
|
-
// Initialize dayjs
|
|
36
|
+
// Initialize dayjs (window anchors are now resolved per-config in config-validator)
|
|
37
37
|
dayjs.extend(utc);
|
|
38
|
-
const FIXED_NOW = dayjs('2024-02-02').unix();
|
|
39
|
-
global.FIXED_NOW = FIXED_NOW;
|
|
40
|
-
let FIXED_BEGIN = dayjs.unix(FIXED_NOW).subtract(90, 'd').unix();
|
|
41
|
-
global.FIXED_BEGIN = FIXED_BEGIN;
|
|
42
38
|
|
|
43
39
|
|
|
44
40
|
/**
|
|
@@ -134,18 +130,21 @@ async function runDungeon(config) {
|
|
|
134
130
|
initChance(config.seed);
|
|
135
131
|
}
|
|
136
132
|
|
|
137
|
-
// Step 1: Validate and enrich configuration
|
|
133
|
+
// Step 1: Validate and enrich configuration (resolves dataset window)
|
|
138
134
|
validatedConfig = validateDungeonConfig(config);
|
|
139
135
|
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
const
|
|
136
|
+
// validateDungeonConfig always resolves these to unix seconds, but the
|
|
137
|
+
// public Dungeon type accepts string | number on input. Narrow here.
|
|
138
|
+
const fixedNow = /** @type {number} */ (validatedConfig.datasetEnd);
|
|
139
|
+
const fixedBegin = /** @type {number} */ (validatedConfig.datasetStart);
|
|
143
140
|
|
|
144
|
-
//
|
|
145
|
-
|
|
141
|
+
// Anchor the wall-clock-free reference used by date()/day() helpers in
|
|
142
|
+
// dungeon configs. Without this, those factories produce values relative
|
|
143
|
+
// to process-start, which leaks wall-clock time into the output.
|
|
144
|
+
setDatasetNow(fixedNow);
|
|
146
145
|
|
|
147
146
|
// Step 2: Create context with validated config (pass time constants explicitly)
|
|
148
|
-
const context = createContext(validatedConfig, null, { fixedNow
|
|
147
|
+
const context = createContext(validatedConfig, null, { fixedNow, fixedBegin });
|
|
149
148
|
|
|
150
149
|
// Step 3: Initialize storage containers
|
|
151
150
|
const storageManager = new StorageManager(context);
|
|
@@ -237,11 +236,12 @@ async function generateAdSpendData(context) {
|
|
|
237
236
|
const { config, storage } = context;
|
|
238
237
|
const { numDays } = config;
|
|
239
238
|
|
|
240
|
-
const timeShift = context.TIME_SHIFT_SECONDS;
|
|
241
239
|
for (let day = 0; day < numDays; day++) {
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
240
|
+
const dayUnix = Math.min(
|
|
241
|
+
dayjs.unix(context.FIXED_BEGIN).add(day, 'day').unix(),
|
|
242
|
+
context.FIXED_NOW
|
|
243
|
+
);
|
|
244
|
+
const targetDay = dayjs.unix(dayUnix).toISOString();
|
|
245
245
|
const adSpendEvents = await makeAdSpend(context, targetDay);
|
|
246
246
|
|
|
247
247
|
if (adSpendEvents.length > 0) {
|
|
@@ -12,9 +12,82 @@ import dayjs from "dayjs";
|
|
|
12
12
|
import { makeName } from "ak-tools";
|
|
13
13
|
import * as u from "../utils/utils.js";
|
|
14
14
|
import { resolveSoup } from "../templates/soup-presets.js";
|
|
15
|
+
import { resolveMacro } from "../templates/macro-presets.js";
|
|
15
16
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Resolve dataset window from config. Returns { datasetStartUnix, datasetEndUnix, numDays }.
|
|
19
|
+
* Three modes:
|
|
20
|
+
* 1. Both `datasetStart` AND `datasetEnd` provided → pin window. Recompute numDays from the
|
|
21
|
+
* window (ignore any user-supplied numDays — warn on conflict). Bit-exact deterministic.
|
|
22
|
+
* 2. Neither provided → fall back to (today_start - numDays, today_start). Deterministic
|
|
23
|
+
* within a single calendar day; slides across days. Warn once.
|
|
24
|
+
* 3. Exactly one provided → throw.
|
|
25
|
+
*
|
|
26
|
+
* Accepts ISO strings, unix seconds, or anything dayjs() can parse.
|
|
27
|
+
*
|
|
28
|
+
* @param {*} datasetStart
|
|
29
|
+
* @param {*} datasetEnd
|
|
30
|
+
* @param {number} [userNumDays]
|
|
31
|
+
* @returns {{ datasetStartUnix: number, datasetEndUnix: number, numDays: number }}
|
|
32
|
+
*/
|
|
33
|
+
function resolveDatasetWindow(datasetStart, datasetEnd, userNumDays) {
|
|
34
|
+
const hasStart = datasetStart !== undefined && datasetStart !== null;
|
|
35
|
+
const hasEnd = datasetEnd !== undefined && datasetEnd !== null;
|
|
36
|
+
|
|
37
|
+
if (hasStart !== hasEnd) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`datasetStart and datasetEnd must be specified together (got datasetStart=${datasetStart}, datasetEnd=${datasetEnd}). ` +
|
|
40
|
+
`Provide both to pin the window, or neither to fall back to numDays.`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (hasStart && hasEnd) {
|
|
45
|
+
const startUnix = parseToUnix(datasetStart, 'datasetStart');
|
|
46
|
+
const endUnix = parseToUnix(datasetEnd, 'datasetEnd');
|
|
47
|
+
if (endUnix <= startUnix) {
|
|
48
|
+
throw new Error(`datasetEnd (${datasetEnd}) must be after datasetStart (${datasetStart}).`);
|
|
49
|
+
}
|
|
50
|
+
const derivedNumDays = Math.max(1, Math.round((endUnix - startUnix) / 86400));
|
|
51
|
+
if (userNumDays !== undefined && userNumDays !== null && userNumDays !== derivedNumDays) {
|
|
52
|
+
console.warn(
|
|
53
|
+
`⚠️ datasetStart/datasetEnd take precedence; user-supplied numDays=${userNumDays} ignored, derived numDays=${derivedNumDays}.`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return { datasetStartUnix: startUnix, datasetEndUnix: endUnix, numDays: derivedNumDays };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Fallback: anchor to today's start-of-day, walk back numDays
|
|
60
|
+
const fallbackNumDays = (typeof userNumDays === 'number' && userNumDays > 0) ? userNumDays : 30;
|
|
61
|
+
const todayStart = dayjs().startOf('day').unix();
|
|
62
|
+
const fallbackStart = todayStart - fallbackNumDays * 86400;
|
|
63
|
+
console.warn(
|
|
64
|
+
`⚠️ No 'datasetStart'/'datasetEnd' set — dataset window anchored to today's date and will shift across runs. Pin both for full determinism.`
|
|
65
|
+
);
|
|
66
|
+
return { datasetStartUnix: fallbackStart, datasetEndUnix: todayStart, numDays: fallbackNumDays };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse a value (ISO string, unix seconds, dayjs-parseable) into unix seconds.
|
|
71
|
+
* Throws if the value can't be parsed into a valid date.
|
|
72
|
+
* @param {*} value
|
|
73
|
+
* @param {string} fieldName
|
|
74
|
+
* @returns {number}
|
|
75
|
+
*/
|
|
76
|
+
function parseToUnix(value, fieldName) {
|
|
77
|
+
// Treat numbers as unix seconds (or unix milliseconds if too large)
|
|
78
|
+
if (typeof value === 'number') {
|
|
79
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
80
|
+
throw new Error(`${fieldName} must be a positive finite number (got ${value}).`);
|
|
81
|
+
}
|
|
82
|
+
// Heuristic: > 10^12 means milliseconds, otherwise seconds
|
|
83
|
+
return value > 1e12 ? Math.floor(value / 1000) : Math.floor(value);
|
|
84
|
+
}
|
|
85
|
+
const parsed = dayjs(value);
|
|
86
|
+
if (!parsed.isValid()) {
|
|
87
|
+
throw new Error(`${fieldName} could not be parsed as a date (got ${JSON.stringify(value)}).`);
|
|
88
|
+
}
|
|
89
|
+
return parsed.unix();
|
|
90
|
+
}
|
|
18
91
|
|
|
19
92
|
/**
|
|
20
93
|
* Infers funnels from the provided events
|
|
@@ -87,9 +160,10 @@ export function validateDungeonConfig(config) {
|
|
|
87
160
|
// Extract configuration with defaults
|
|
88
161
|
let {
|
|
89
162
|
seed,
|
|
90
|
-
numEvents
|
|
163
|
+
numEvents,
|
|
91
164
|
numUsers = 1000,
|
|
92
165
|
numDays = 30,
|
|
166
|
+
avgEventsPerUserPerDay,
|
|
93
167
|
epochStart = 0,
|
|
94
168
|
epochEnd = dayjs().unix(),
|
|
95
169
|
events = [{ event: "foo" }, { event: "bar" }, { event: "baz" }],
|
|
@@ -148,35 +222,83 @@ export function validateDungeonConfig(config) {
|
|
|
148
222
|
);
|
|
149
223
|
}
|
|
150
224
|
|
|
151
|
-
// Auto-enable batch mode for large datasets to prevent OOM
|
|
152
|
-
if (numEvents >= 2_000_000 && config.batchSize === undefined) {
|
|
153
|
-
batchSize = 1_000_000;
|
|
154
|
-
console.warn(`⚠️ Auto-enabling batch mode: numEvents (${numEvents.toLocaleString()}) >= 2M. Using batchSize of ${batchSize.toLocaleString()}.`);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
225
|
// Ensure defaults for deep objects
|
|
158
226
|
if (!config.superProps) config.superProps = superProps;
|
|
159
227
|
if (!config.userProps || Object.keys(config.userProps).length === 0) {
|
|
160
228
|
userProps = { spiritAnimal: chance.animal.bind(chance) };
|
|
161
229
|
}
|
|
162
230
|
|
|
163
|
-
//
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
231
|
+
// Guard against zero/negative numUsers up front (would produce NaN budgets).
|
|
232
|
+
if (!Number.isFinite(numUsers) || numUsers <= 0) {
|
|
233
|
+
throw new Error(`numUsers must be a positive number (got ${numUsers})`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Reject explicit zero/negative numDays from the caller before falling through
|
|
237
|
+
// to the epoch-derivation branches below (which would otherwise replace 0
|
|
238
|
+
// with a derived value or throw a less-specific message).
|
|
239
|
+
if (config.numDays !== undefined && (!Number.isFinite(config.numDays) || config.numDays <= 0)) {
|
|
240
|
+
throw new Error(`numDays must be a positive number (got ${config.numDays})`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── Resolve dataset window ──
|
|
244
|
+
// Preferred path: explicit datasetStart + datasetEnd → pinned, deterministic window.
|
|
245
|
+
// Fallback: numDays only → today_start - numDays back (sliding, warn-emitted).
|
|
246
|
+
const windowResolution = resolveDatasetWindow(config.datasetStart, config.datasetEnd, config.numDays);
|
|
247
|
+
const datasetStartUnix = windowResolution.datasetStartUnix;
|
|
248
|
+
const datasetEndUnix = windowResolution.datasetEndUnix;
|
|
249
|
+
numDays = windowResolution.numDays;
|
|
250
|
+
|
|
251
|
+
// Mirror window into legacy epoch* surface (still consumed by inferFunnels callers
|
|
252
|
+
// and any external code that read these from validated config).
|
|
253
|
+
epochStart = datasetStartUnix;
|
|
254
|
+
epochEnd = datasetEndUnix;
|
|
255
|
+
|
|
256
|
+
// Resolve event-rate primitive: avgEventsPerUserPerDay is the canonical knob.
|
|
257
|
+
// numEvents is supported as a fallback (legacy + total-volume target). Whichever
|
|
258
|
+
// is set, derive the other so downstream code (batching, progress, mixpanel-sender)
|
|
259
|
+
// can use either.
|
|
260
|
+
if (avgEventsPerUserPerDay !== undefined && numEvents !== undefined) {
|
|
261
|
+
// Both provided: avgEventsPerUserPerDay wins. Recompute numEvents from rate.
|
|
262
|
+
numEvents = Math.round(avgEventsPerUserPerDay * numUsers * numDays);
|
|
263
|
+
} else if (avgEventsPerUserPerDay !== undefined) {
|
|
264
|
+
numEvents = Math.round(avgEventsPerUserPerDay * numUsers * numDays);
|
|
265
|
+
} else if (numEvents !== undefined) {
|
|
266
|
+
avgEventsPerUserPerDay = numEvents / numUsers / numDays;
|
|
267
|
+
} else {
|
|
268
|
+
// Neither set — fall back to legacy default (100K total).
|
|
269
|
+
numEvents = 100_000;
|
|
270
|
+
avgEventsPerUserPerDay = numEvents / numUsers / numDays;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Auto-enable batch mode for large datasets to prevent OOM.
|
|
274
|
+
// MUST run after rate→numEvents resolution above, otherwise dungeons that set
|
|
275
|
+
// only avgEventsPerUserPerDay would never trigger auto-batch.
|
|
276
|
+
if (numEvents >= 2_000_000 && config.batchSize === undefined) {
|
|
277
|
+
batchSize = 1_000_000;
|
|
278
|
+
console.warn(`⚠️ Auto-enabling batch mode: numEvents (${numEvents.toLocaleString()}) >= 2M. Using batchSize of ${batchSize.toLocaleString()}.`);
|
|
169
279
|
}
|
|
170
280
|
|
|
171
|
-
// Resolve soup presets (must happen after numDays is computed)
|
|
281
|
+
// Resolve soup presets (intra-week / intra-day shape — must happen after numDays is computed)
|
|
172
282
|
const resolved = resolveSoup(soup, numDays);
|
|
173
283
|
soup = resolved.soup;
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
284
|
+
|
|
285
|
+
// Resolve macro preset (big-picture trend shape across the window).
|
|
286
|
+
// Default is "flat" — see lib/templates/macro-presets.js. Top-level
|
|
287
|
+
// bornRecentBias / percentUsersBornInDataset / preExistingSpread on the
|
|
288
|
+
// dungeon config win over the macro preset's values, so existing dungeons
|
|
289
|
+
// that set these explicitly continue to render the same way.
|
|
290
|
+
// Resolve into local vars (do NOT mutate input config).
|
|
291
|
+
const macroResolved = resolveMacro(config.macro);
|
|
292
|
+
let bornRecentBias = config.bornRecentBias !== undefined ? config.bornRecentBias : macroResolved.bornRecentBias;
|
|
293
|
+
let percentUsersBornInDataset = config.percentUsersBornInDataset !== undefined ? config.percentUsersBornInDataset : macroResolved.percentUsersBornInDataset;
|
|
294
|
+
let preExistingSpread = config.preExistingSpread !== undefined ? config.preExistingSpread : macroResolved.preExistingSpread;
|
|
295
|
+
|
|
296
|
+
// Clamp bornRecentBias to [-1, 1] — values outside this range produce
|
|
297
|
+
// nonsensical exponents (e.g. Math.pow(0, -0.4) = Infinity in user-loop.js).
|
|
298
|
+
if (typeof bornRecentBias === 'number' && Number.isFinite(bornRecentBias)) {
|
|
299
|
+
bornRecentBias = Math.max(-1, Math.min(1, bornRecentBias));
|
|
300
|
+
} else {
|
|
301
|
+
bornRecentBias = 0;
|
|
180
302
|
}
|
|
181
303
|
|
|
182
304
|
// Use provided name if non-empty string, otherwise generate one
|
|
@@ -220,7 +342,7 @@ export function validateDungeonConfig(config) {
|
|
|
220
342
|
}
|
|
221
343
|
|
|
222
344
|
// Validate: if every user is born in dataset, we need either isFirstEvent or isFirstFunnel
|
|
223
|
-
const percentBorn =
|
|
345
|
+
const percentBorn = percentUsersBornInDataset;
|
|
224
346
|
const hasFirstEvent = events.some(e => e.isFirstEvent);
|
|
225
347
|
const hasFirstFunnel = funnels.some(f => f.isFirstFunnel);
|
|
226
348
|
if (percentBorn >= 100 && !hasFirstEvent && !hasFirstFunnel) {
|
|
@@ -299,7 +421,7 @@ export function validateDungeonConfig(config) {
|
|
|
299
421
|
// Feature 2: World Events
|
|
300
422
|
let worldEvents = config.worldEvents || null;
|
|
301
423
|
if (worldEvents) {
|
|
302
|
-
worldEvents = resolveWorldEvents(worldEvents,
|
|
424
|
+
worldEvents = resolveWorldEvents(worldEvents, datasetStartUnix);
|
|
303
425
|
}
|
|
304
426
|
|
|
305
427
|
// Feature 3: Engagement Decay
|
|
@@ -341,7 +463,7 @@ export function validateDungeonConfig(config) {
|
|
|
341
463
|
// Feature 9: Anomalies
|
|
342
464
|
let anomalies = config.anomalies || null;
|
|
343
465
|
if (anomalies) {
|
|
344
|
-
anomalies = resolveAnomalies(anomalies,
|
|
466
|
+
anomalies = resolveAnomalies(anomalies, datasetStartUnix);
|
|
345
467
|
}
|
|
346
468
|
|
|
347
469
|
// Build final config object
|
|
@@ -354,8 +476,11 @@ export function validateDungeonConfig(config) {
|
|
|
354
476
|
numEvents,
|
|
355
477
|
numUsers,
|
|
356
478
|
numDays,
|
|
479
|
+
avgEventsPerUserPerDay,
|
|
357
480
|
epochStart,
|
|
358
481
|
epochEnd,
|
|
482
|
+
datasetStart: datasetStartUnix,
|
|
483
|
+
datasetEnd: datasetEndUnix,
|
|
359
484
|
events: validatedEvents,
|
|
360
485
|
superProps,
|
|
361
486
|
userProps,
|
|
@@ -385,6 +510,11 @@ export function validateDungeonConfig(config) {
|
|
|
385
510
|
hasIOSDevices,
|
|
386
511
|
name,
|
|
387
512
|
strictEventCount,
|
|
513
|
+
// Macro trend (resolved from preset + per-dungeon overrides; clamped)
|
|
514
|
+
macro: config.macro,
|
|
515
|
+
bornRecentBias,
|
|
516
|
+
percentUsersBornInDataset,
|
|
517
|
+
preExistingSpread,
|
|
388
518
|
// Advanced features
|
|
389
519
|
personas,
|
|
390
520
|
worldEvents,
|
|
@@ -495,12 +625,11 @@ function validatePersonas(personas) {
|
|
|
495
625
|
/**
|
|
496
626
|
* Resolves world events to absolute timestamps
|
|
497
627
|
* @param {import('../../types').WorldEvent[]} worldEvents
|
|
498
|
-
* @param {number}
|
|
628
|
+
* @param {number} beginUnix - Dataset start (unix seconds)
|
|
499
629
|
* @returns {import('../../types').ResolvedWorldEvent[]}
|
|
500
630
|
*/
|
|
501
|
-
function resolveWorldEvents(worldEvents,
|
|
631
|
+
function resolveWorldEvents(worldEvents, beginUnix) {
|
|
502
632
|
if (!Array.isArray(worldEvents) || worldEvents.length === 0) return null;
|
|
503
|
-
const beginUnix = dayjs.unix(FIXED_NOW).subtract(numDays, 'day').unix();
|
|
504
633
|
|
|
505
634
|
return worldEvents.map(we => {
|
|
506
635
|
const startUnix = beginUnix + (we.startDay * 86400);
|
|
@@ -664,12 +793,11 @@ function resolveFeatures(features, numDays) {
|
|
|
664
793
|
/**
|
|
665
794
|
* Resolves anomaly configs with absolute timestamps
|
|
666
795
|
* @param {import('../../types').AnomalyConfig[]} anomalies
|
|
667
|
-
* @param {number}
|
|
796
|
+
* @param {number} beginUnix - Dataset start (unix seconds)
|
|
668
797
|
* @returns {import('../../types').AnomalyConfig[]}
|
|
669
798
|
*/
|
|
670
|
-
function resolveAnomalies(anomalies,
|
|
799
|
+
function resolveAnomalies(anomalies, beginUnix) {
|
|
671
800
|
if (!Array.isArray(anomalies) || anomalies.length === 0) return null;
|
|
672
|
-
const beginUnix = dayjs.unix(FIXED_NOW).subtract(numDays, 'day').unix();
|
|
673
801
|
return anomalies.map(a => {
|
|
674
802
|
if (!a.type) throw new Error('Each anomaly must have a type');
|
|
675
803
|
if (!a.event) throw new Error('Each anomaly must have an event name');
|
package/lib/core/context.js
CHANGED
|
@@ -156,30 +156,16 @@ export function createContext(config, storage = null, timeConstants = {}) {
|
|
|
156
156
|
return runtime.isBatchMode;
|
|
157
157
|
},
|
|
158
158
|
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
},
|
|
169
|
-
|
|
170
|
-
// Time constants (passed explicitly from index.js; global fallback only for direct test usage)
|
|
171
|
-
FIXED_NOW: timeConstants.fixedNow || global.FIXED_NOW,
|
|
172
|
-
FIXED_BEGIN: timeConstants.fixedBegin || global.FIXED_BEGIN,
|
|
173
|
-
|
|
174
|
-
// PERFORMANCE: Pre-calculated time shift (instead of calculating per-event)
|
|
175
|
-
TIME_SHIFT_SECONDS: (() => {
|
|
176
|
-
const fixedNow = timeConstants.fixedNow || global.FIXED_NOW;
|
|
177
|
-
const actualNow = dayjs().subtract(1, "hour");
|
|
178
|
-
return actualNow.diff(dayjs.unix(fixedNow), "seconds");
|
|
179
|
-
})(),
|
|
180
|
-
|
|
181
|
-
// Max timestamp (unix seconds) — clamp here to prevent future events
|
|
182
|
-
MAX_TIME: dayjs().unix(),
|
|
159
|
+
// Dataset window anchors (resolved by config-validator). FIXED_BEGIN and
|
|
160
|
+
// FIXED_NOW are the authoritative window — events are generated directly
|
|
161
|
+
// inside this range, no time-shift step. DATASET_*_SECONDS are aliases
|
|
162
|
+
// kept for the existing `meta.datasetStart`/`meta.datasetEnd` hook surface.
|
|
163
|
+
// Resolution: explicit timeConstants override, otherwise pull from validated config.
|
|
164
|
+
// validateDungeonConfig always normalizes datasetStart/datasetEnd to unix seconds.
|
|
165
|
+
FIXED_NOW: /** @type {number} */ (timeConstants.fixedNow || config.datasetEnd),
|
|
166
|
+
FIXED_BEGIN: /** @type {number} */ (timeConstants.fixedBegin || config.datasetStart),
|
|
167
|
+
get DATASET_START_SECONDS() { return this.FIXED_BEGIN; },
|
|
168
|
+
get DATASET_END_SECONDS() { return this.FIXED_NOW; },
|
|
183
169
|
};
|
|
184
170
|
|
|
185
171
|
return context;
|
package/lib/core/storage.js
CHANGED
|
@@ -111,7 +111,12 @@ export async function createHookArray(arr = [], opts) {
|
|
|
111
111
|
}
|
|
112
112
|
} else {
|
|
113
113
|
// Slow path for actual transformation hooks
|
|
114
|
-
const allMetaData = {
|
|
114
|
+
const allMetaData = {
|
|
115
|
+
...rest,
|
|
116
|
+
...meta,
|
|
117
|
+
datasetStart: context?.DATASET_START_SECONDS,
|
|
118
|
+
datasetEnd: context?.DATASET_END_SECONDS
|
|
119
|
+
};
|
|
115
120
|
|
|
116
121
|
// Helper to validate events have required properties
|
|
117
122
|
// Note: event-type hooks are handled in the fast path (alreadyHooked),
|