make-mp-data 3.0.2 → 3.0.4

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 (47) hide show
  1. package/dungeons/adspend.js +13 -26
  2. package/dungeons/anon.js +1 -1
  3. package/dungeons/array-of-object-lookup.js +1 -2
  4. package/dungeons/benchmark-heavy.js +5 -6
  5. package/dungeons/benchmark-light.js +13 -28
  6. package/dungeons/big.js +3 -3
  7. package/dungeons/business.js +11 -12
  8. package/dungeons/complex.js +1 -2
  9. package/dungeons/copilot.js +8 -6
  10. package/dungeons/education.js +21 -22
  11. package/dungeons/experiments.js +4 -5
  12. package/dungeons/fintech.js +25 -26
  13. package/dungeons/foobar.js +1 -1
  14. package/dungeons/food.js +24 -25
  15. package/dungeons/funnels.js +2 -2
  16. package/dungeons/gaming.js +39 -40
  17. package/dungeons/media.js +30 -31
  18. package/dungeons/mil.js +17 -18
  19. package/dungeons/mirror.js +2 -3
  20. package/dungeons/retention-cadence.js +1 -2
  21. package/dungeons/rpg.js +42 -43
  22. package/dungeons/sanity.js +1 -2
  23. package/dungeons/sass.js +32 -33
  24. package/dungeons/scd.js +3 -4
  25. package/dungeons/simple.js +13 -14
  26. package/dungeons/social.js +27 -28
  27. package/dungeons/soup-test.js +52 -0
  28. package/dungeons/streaming.js +17 -18
  29. package/dungeons/student-teacher.js +0 -1
  30. package/dungeons/text-generation.js +0 -1
  31. package/dungeons/user-agent.js +1 -2
  32. package/index.js +18 -6
  33. package/lib/core/config-validator.js +22 -33
  34. package/lib/core/context.js +6 -3
  35. package/lib/generators/events.js +13 -10
  36. package/lib/generators/funnels.js +7 -4
  37. package/lib/generators/scd.js +29 -17
  38. package/lib/generators/text.js +18 -12
  39. package/lib/orchestrators/mixpanel-sender.js +26 -38
  40. package/lib/orchestrators/user-loop.js +68 -15
  41. package/lib/templates/phrases.js +8 -5
  42. package/lib/utils/function-registry.js +17 -0
  43. package/lib/utils/utils.js +15 -84
  44. package/package.json +3 -1
  45. package/types.d.ts +86 -19
  46. package/lib/templates/verbose-schema.js +0 -272
  47. package/lib/utils/chart.js +0 -210
@@ -44,10 +44,9 @@ const config = {
44
44
  hasAdSpend: false,
45
45
 
46
46
  hasAvatar: false,
47
- makeChart: false,
48
47
 
49
48
  batchSize: 2_500_000,
50
- concurrency: 10,
49
+ concurrency: 1,
51
50
  writeToDisk: false,
52
51
 
53
52
  funnels: [],
@@ -66,7 +65,7 @@ const config = {
66
65
  ]),
67
66
  "watch time": u.weighNumRange(1, 65, .89, 100),
68
67
 
69
- "category": u.pickAWinner([
68
+ "category": [
70
69
  "comedy",
71
70
  "educational",
72
71
  "music",
@@ -74,15 +73,15 @@ const config = {
74
73
  "news",
75
74
  "gaming",
76
75
  "travel",
77
- ]),
78
- quality: u.pickAWinner([
76
+ ],
77
+ quality: [
79
78
  "240p",
80
79
  "360p",
81
80
  "480p",
82
81
  "720p",
83
82
  "1080p",
84
83
  "4k",
85
- ], 4),
84
+ ],
86
85
  autoplay: [
87
86
  true,
88
87
  false,
@@ -121,13 +120,13 @@ const config = {
121
120
  weight: 3,
122
121
  properties: {
123
122
  video_id: u.pickAWinner(videoIds),
124
- "share network": u.pickAWinner([
123
+ "share network": [
125
124
  "facebook",
126
125
  "twitter",
127
126
  "reddit",
128
127
  "email",
129
128
  "whatsapp",
130
- ]),
129
+ ],
131
130
  },
132
131
  },
133
132
  {
@@ -175,18 +174,18 @@ const config = {
175
174
  event: "create playlist",
176
175
  weight: 4,
177
176
  properties: {
178
- "play list name": u.pickAWinner([
177
+ "play list name": [
179
178
  "favorites",
180
179
  "watch later",
181
180
  "my music",
182
181
  "funny videos",
183
182
  "educational",
184
- ]),
185
- privacy: u.pickAWinner([
183
+ ],
184
+ privacy: [
186
185
  "public",
187
186
  "private",
188
187
  "unlisted",
189
- ]),
188
+ ],
190
189
  },
191
190
  },
192
191
  {
@@ -205,11 +204,11 @@ const config = {
205
204
  event: "account login",
206
205
  weight: 9,
207
206
  properties: {
208
- "log in method": u.pickAWinner([
207
+ "log in method": [
209
208
  "email",
210
209
  "google",
211
210
  "facebook",
212
- ]),
211
+ ],
213
212
  success: [
214
213
  true,
215
214
  false,
@@ -233,11 +232,11 @@ const config = {
233
232
  }
234
233
  ],
235
234
  superProps: {
236
- platform: u.pickAWinner([
235
+ platform: [
237
236
  "web",
238
237
  "ios",
239
238
  "android",
240
- ]),
239
+ ],
241
240
  network_type: [
242
241
  "wifi",
243
242
  "cellular",
@@ -257,13 +256,13 @@ const config = {
257
256
  "45-54",
258
257
  "55+",
259
258
  ],
260
- preferred_genre: u.pickAWinner([
259
+ preferred_genre: [
261
260
  "comedy",
262
261
  "action",
263
262
  "drama",
264
263
  "sci-fi",
265
264
  "horror",
266
- ]),
265
+ ],
267
266
  upload_count: [
268
267
  0,
269
268
  1,
@@ -34,7 +34,6 @@ const config = {
34
34
  hasAdSpend: true,
35
35
 
36
36
  hasAvatar: true,
37
- makeChart: false,
38
37
 
39
38
  batchSize: 2_500_000,
40
39
  concurrency: 1,
@@ -344,7 +344,6 @@ const dungeon = {
344
344
  isAnonymous: false,
345
345
  hasAdSpend: false,
346
346
  hasAvatar: false,
347
- makeChart: false,
348
347
 
349
348
  batchSize: 2_500_000,
350
349
  concurrency: 1,
@@ -33,10 +33,9 @@ const config = {
33
33
  hasAdSpend: true,
34
34
 
35
35
  hasAvatar: true,
36
- makeChart: false,
37
36
 
38
37
  batchSize: 500_000,
39
- concurrency: 500,
38
+ concurrency: 1,
40
39
  writeToDisk: false,
41
40
 
42
41
  funnels: [],
package/index.js CHANGED
@@ -27,6 +27,7 @@ import { makeMirror } from './lib/generators/mirror.js';
27
27
  import { makeGroupProfile, makeProfile } from './lib/generators/profiles.js';
28
28
 
29
29
  // Utilities
30
+ import { initChance } from './lib/utils/utils.js';
30
31
 
31
32
  // External dependencies
32
33
  import dayjs from "dayjs";
@@ -138,6 +139,12 @@ async function main(config) {
138
139
  // Step 1: Validate and enrich configuration
139
140
  validatedConfig = validateDungeonConfig(config);
140
141
 
142
+ // Ensure seeded RNG is initialized (dungeons do this at module scope,
143
+ // but npm-module consumers pass seed via config object)
144
+ if (validatedConfig.seed) {
145
+ initChance(validatedConfig.seed);
146
+ }
147
+
141
148
  // Update FIXED_BEGIN based on configured numDays
142
149
  const configNumDays = validatedConfig.numDays || 30;
143
150
  global.FIXED_BEGIN = dayjs.unix(FIXED_NOW).subtract(configNumDays, 'd').unix();
@@ -237,8 +244,11 @@ async function generateAdSpendData(context) {
237
244
  const { config, storage } = context;
238
245
  const { numDays } = config;
239
246
 
247
+ const timeShift = context.TIME_SHIFT_SECONDS;
240
248
  for (let day = 0; day < numDays; day++) {
241
- const targetDay = dayjs.unix(global.FIXED_BEGIN).add(day, 'day').toISOString();
249
+ const fixedDay = dayjs.unix(global.FIXED_BEGIN).add(day, 'day').unix();
250
+ const shiftedDay = Math.min(fixedDay + timeShift, context.MAX_TIME);
251
+ const targetDay = dayjs.unix(shiftedDay).toISOString();
242
252
  const adSpendEvents = await makeAdSpend(context, targetDay);
243
253
 
244
254
  if (adSpendEvents.length > 0) {
@@ -266,7 +276,7 @@ async function generateGroupProfiles(context) {
266
276
  const groupContainer = storage.groupProfilesData[i];
267
277
 
268
278
  if (!groupContainer) {
269
- console.warn(`Warning: No storage container found for group key: ${groupKey}`);
279
+ if (config.verbose) console.warn(`Warning: No storage container found for group key: ${groupKey}`);
270
280
  continue;
271
281
  }
272
282
 
@@ -309,7 +319,7 @@ async function generateLookupTables(context) {
309
319
  const lookupContainer = storage.lookupTableData[i];
310
320
 
311
321
  if (!lookupContainer) {
312
- console.warn(`Warning: No storage container found for lookup table: ${key}`);
322
+ if (config.verbose) console.warn(`Warning: No storage container found for lookup table: ${key}`);
313
323
  continue;
314
324
  }
315
325
 
@@ -378,16 +388,19 @@ async function generateGroupSCDs(context) {
378
388
 
379
389
  // Use a base time for the group entity (similar to user creation time)
380
390
  const baseTime = context.FIXED_BEGIN || context.FIXED_NOW;
381
- const changes = await makeSCD(context, scdConfig, scdKey, groupId, mutations, baseTime);
391
+ let changes = await makeSCD(context, scdConfig, scdKey, groupId, mutations, baseTime);
382
392
 
383
393
  // Apply hook if configured
384
394
  if (config.hook) {
385
- await config.hook(changes, "scd-pre", {
395
+ const hookResult = await config.hook(changes, "scd-pre", {
386
396
  type: 'group',
387
397
  groupKey,
388
398
  scd: { [scdKey]: scdConfig },
389
399
  config
390
400
  });
401
+ if (Array.isArray(hookResult)) {
402
+ changes = hookResult;
403
+ }
391
404
  }
392
405
 
393
406
  // Store SCDs in the appropriate SCD table
@@ -538,7 +551,6 @@ async function extractFileInfo(storage, config) {
538
551
  }
539
552
  } catch (error) {
540
553
  // If scanning fails, just return empty array
541
- console.warn('Warning: Could not scan data directory for files:', error.message);
542
554
  }
543
555
  }
544
556
 
@@ -106,8 +106,7 @@ export function validateDungeonConfig(config) {
106
106
  token = null,
107
107
  region = "US",
108
108
  writeToDisk = false,
109
- verbose = true,
110
- makeChart = false,
109
+ verbose = false,
111
110
  soup = {},
112
111
  hook = (record) => record,
113
112
  hasAdSpend = false,
@@ -167,15 +166,17 @@ export function validateDungeonConfig(config) {
167
166
  throw new Error('Hook string did not evaluate to a function');
168
167
  }
169
168
  } catch (error) {
170
- console.warn(`\u26a0\ufe0f Failed to convert hook string to function: ${error.message}`);
171
- console.warn('Using default pass-through hook');
169
+ if (config.verbose !== false) {
170
+ console.warn(`\u26a0\ufe0f Failed to convert hook string to function: ${error.message}`);
171
+ console.warn('Using default pass-through hook');
172
+ }
172
173
  hook = (record) => record;
173
174
  }
174
175
  }
175
176
 
176
177
  // Ensure hook is a function
177
178
  if (typeof hook !== 'function') {
178
- console.warn('\u26a0\ufe0f Hook is not a function, using default pass-through hook');
179
+ if (config.verbose !== false) console.warn('\u26a0\ufe0f Hook is not a function, using default pass-through hook');
179
180
  hook = (record) => record;
180
181
  }
181
182
 
@@ -187,6 +188,17 @@ export function validateDungeonConfig(config) {
187
188
  events = events.map(e => ({ event: /** @type {string} */ (e) }));
188
189
  }
189
190
 
191
+ // Validate: if every user is born in dataset, we need either isFirstEvent or isFirstFunnel
192
+ const percentBorn = config.percentUsersBornInDataset ?? 15;
193
+ const hasFirstEvent = events.some(e => e.isFirstEvent);
194
+ const hasFirstFunnel = funnels.some(f => f.isFirstFunnel);
195
+ if (percentBorn >= 100 && !hasFirstEvent && !hasFirstFunnel) {
196
+ throw new Error(
197
+ "percentUsersBornInDataset is 100% but no event has isFirstEvent and no funnel has isFirstFunnel. " +
198
+ "Either add isFirstEvent to an event, add a first funnel, or lower percentUsersBornInDataset."
199
+ );
200
+ }
201
+
190
202
  // Handle funnel inference
191
203
  if (alsoInferFunnels) {
192
204
  const inferredFunnels = inferFunnels(events);
@@ -202,6 +214,7 @@ export function validateDungeonConfig(config) {
202
214
 
203
215
  const eventsNotInFunnels = events
204
216
  .filter(e => !e.isFirstEvent)
217
+ .filter(e => !e.isStrictEvent)
205
218
  .filter(e => !eventContainedInFunnels.includes(e.event))
206
219
  .map(e => e.event);
207
220
 
@@ -283,36 +296,12 @@ export function validateDungeonConfig(config) {
283
296
  hasDesktopDevices,
284
297
  hasIOSDevices,
285
298
  name,
286
- makeChart,
287
299
  strictEventCount
288
300
  };
289
301
 
290
302
  return validatedConfig;
291
303
  }
292
304
 
293
- /**
294
- * Validates configuration for required fields
295
- * @param {Object} config - Configuration to validate
296
- * @throws {Error} If required fields are missing
297
- */
298
- /**
299
- * Validates required configuration parameters
300
- * @param {Dungeon} config - Configuration object to validate
301
- * @returns {boolean} True if validation passes
302
- */
303
- export function validateRequiredConfig(config) {
304
- if (!config) {
305
- throw new Error("Configuration is required");
306
- }
307
-
308
- if (typeof config !== 'object') {
309
- throw new Error("Configuration must be an object");
310
- }
311
-
312
- // Could add more specific validation here
313
- return true;
314
- }
315
-
316
305
  /**
317
306
  * Transforms SCD properties to regular user/group properties when service account credentials are missing
318
307
  * ONLY applies to UI jobs - programmatic usage always generates SCD files
@@ -350,7 +339,7 @@ function transformSCDPropsWithoutCredentials(config) {
350
339
  }
351
340
 
352
341
  // UI job without credentials - convert SCD props to regular props
353
- console.log('\u26a0\ufe0f Service account credentials missing - converting SCD properties to static properties');
342
+ if (config.verbose !== false) console.log('\u26a0\ufe0f Service account credentials missing - converting SCD properties to static properties');
354
343
 
355
344
  // Ensure userProps and groupProps exist
356
345
  if (!config.userProps) config.userProps = {};
@@ -369,20 +358,20 @@ function transformSCDPropsWithoutCredentials(config) {
369
358
  if (type === "user") {
370
359
  // Add to userProps
371
360
  config.userProps[propKey] = values;
372
- console.log(` \u2713 Converted user SCD property: ${propKey}`);
361
+ if (config.verbose !== false) console.log(` \u2713 Converted user SCD property: ${propKey}`);
373
362
  } else {
374
363
  // Add to groupProps for the specific group type
375
364
  if (!config.groupProps[type]) {
376
365
  config.groupProps[type] = {};
377
366
  }
378
367
  config.groupProps[type][propKey] = values;
379
- console.log(` \u2713 Converted group SCD property: ${propKey} (${type})`);
368
+ if (config.verbose !== false) console.log(` \u2713 Converted group SCD property: ${propKey} (${type})`);
380
369
  }
381
370
  }
382
371
 
383
372
  // Clear out scdProps since we've converted everything
384
373
  config.scdProps = {};
385
- console.log('\u2713 SCD properties converted to static properties\n');
374
+ if (config.verbose !== false) console.log('\u2713 SCD properties converted to static properties\n');
386
375
  }
387
376
 
388
377
  export { inferFunnels, transformSCDPropsWithoutCredentials };
@@ -149,12 +149,12 @@ export function createContext(config, storage = null, _unusedCliMode = null) {
149
149
 
150
150
  // Time helper methods
151
151
  getTimeShift() {
152
- const actualNow = dayjs().add(1, "day");
152
+ const actualNow = dayjs().subtract(1, "hour");
153
153
  return actualNow.diff(dayjs.unix(this.FIXED_NOW), "seconds");
154
154
  },
155
155
 
156
156
  getDaysShift() {
157
- const actualNow = dayjs().add(1, "day");
157
+ const actualNow = dayjs().subtract(1, "hour");
158
158
  return actualNow.diff(dayjs.unix(this.FIXED_NOW), "days");
159
159
  },
160
160
 
@@ -164,9 +164,12 @@ export function createContext(config, storage = null, _unusedCliMode = null) {
164
164
 
165
165
  // PERFORMANCE: Pre-calculated time shift (instead of calculating per-event)
166
166
  TIME_SHIFT_SECONDS: (() => {
167
- const actualNow = dayjs().add(1, "day");
167
+ const actualNow = dayjs().subtract(1, "hour");
168
168
  return actualNow.diff(dayjs.unix(global.FIXED_NOW), "seconds");
169
169
  })(),
170
+
171
+ // Max timestamp (unix seconds) — clamp here to prevent future events
172
+ MAX_TIME: dayjs().unix(),
170
173
  };
171
174
 
172
175
  return context;
@@ -52,7 +52,9 @@ export async function makeEvent(
52
52
  const chance = u.getChance();
53
53
 
54
54
  // Extract soup configuration for time distribution
55
- const { mean = 0, deviation = 2, peaks = 5 } = config.soup || {};
55
+ // Dynamic peaks: one per week for long ranges, minimum 5
56
+ const defaultPeaks = Math.max(5, Math.ceil((config.numDays || 30) / 7));
57
+ const { mean = 0, deviation = 2, peaks = defaultPeaks } = config.soup || {};
56
58
 
57
59
  // Extract feature flags from config
58
60
  const {
@@ -95,18 +97,19 @@ export async function makeEvent(
95
97
 
96
98
  // Set event time using TimeSoup for realistic distribution
97
99
  if (earliestTime) {
100
+ let shiftedTimestamp;
98
101
  if (isFirstEvent) {
99
- // PERFORMANCE: Direct numeric calculation instead of dayjs object creation
100
- const shiftedTimestamp = earliestTime + context.TIME_SHIFT_SECONDS;
101
- eventTemplate.time = dayjs.unix(shiftedTimestamp).toISOString();
102
+ shiftedTimestamp = earliestTime + context.TIME_SHIFT_SECONDS;
102
103
  } else {
103
- // Get time from TimeSoup (returns ISO string) and apply precomputed time shift
104
- const soupTime = u.TimeSoup(earliestTime, context.FIXED_NOW, peaks, deviation, mean);
105
- // PERFORMANCE: Parse ISO directly to milliseconds, add shift, convert back to ISO with one dayjs call
106
- const soupTimestamp = new Date(soupTime).getTime() / 1000; // Convert to unix seconds
107
- const shiftedTimestamp = soupTimestamp + context.TIME_SHIFT_SECONDS;
108
- eventTemplate.time = dayjs.unix(shiftedTimestamp).toISOString();
104
+ // TimeSoup returns unix seconds; shift and convert to ISO once
105
+ const soupTimestamp = u.TimeSoup(earliestTime, context.FIXED_NOW, peaks, deviation, mean);
106
+ shiftedTimestamp = soupTimestamp + context.TIME_SHIFT_SECONDS;
109
107
  }
108
+ // Drop events that would land in the future (Mixpanel rewrites these to "now", causing pile-ups)
109
+ if (shiftedTimestamp > context.MAX_TIME) {
110
+ eventTemplate._drop = true;
111
+ }
112
+ eventTemplate.time = dayjs.unix(Math.min(shiftedTimestamp, context.MAX_TIME)).toISOString();
110
113
  }
111
114
 
112
115
  // Add anonymous and session identifiers
@@ -131,10 +131,9 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
131
131
  numStepsUserWillTake
132
132
  );
133
133
 
134
- // Add session start event if configured
134
+ // Add session start event if configured (clone to avoid mutating shared config)
135
135
  if (sessionStartEvents.length) {
136
- const sessionStartEvent = chance.pickone(sessionStartEvents);
137
- sessionStartEvent.relativeTimeMs = -15000; // 15 seconds before funnel
136
+ const sessionStartEvent = { ...chance.pickone(sessionStartEvents), relativeTimeMs: -15000 };
138
137
  funnelEventsWithTiming.push(sessionStartEvent);
139
138
  }
140
139
 
@@ -386,7 +385,11 @@ async function generateFunnelEvents(
386
385
  }
387
386
 
388
387
  try {
389
- const computedTime = dayjs(funnelStartTime).add(event.relativeTimeMs, "milliseconds");
388
+ let computedTime = dayjs(funnelStartTime).add(event.relativeTimeMs, "milliseconds");
389
+ // Drop events that would land in the future
390
+ if (context.MAX_TIME && computedTime.unix() > context.MAX_TIME) {
391
+ newEvent._drop = true;
392
+ }
390
393
  if (computedTime.isValid()) {
391
394
  newEvent.time = computedTime.toISOString();
392
395
  }
@@ -41,19 +41,22 @@ export async function makeSCD(context, scdProp, scdKey, distinct_id, mutations,
41
41
  } = scdProp;
42
42
 
43
43
  // Return empty array if no values provided
44
- if (JSON.stringify(values) === "{}" || JSON.stringify(values) === "[]") {
44
+ if (!values || (Array.isArray(values) && values.length === 0) ||
45
+ (typeof values === 'object' && Object.keys(values).length === 0)) {
45
46
  return [];
46
47
  }
47
-
48
+
48
49
  const scdEntries = [];
49
50
  let lastInserted = dayjs(created);
50
- const deltaDays = dayjs().diff(lastInserted, "day");
51
+ const now = dayjs();
52
+ const deltaDays = now.diff(lastInserted, "day");
51
53
  const uuidKeyName = type === 'user' ? 'distinct_id' : type;
54
+ let lastStartTime = null; // Track for monotonic ordering
52
55
 
53
56
  for (let i = 0; i < mutations; i++) {
54
57
  // Stop if we've reached the current time
55
- if (lastInserted.isAfter(dayjs())) break;
56
-
58
+ if (lastInserted.isAfter(now)) break;
59
+
57
60
  // Create profile with the SCD property
58
61
  const scd = await makeProfile(context, { [scdKey]: values }, { [uuidKeyName]: distinct_id });
59
62
 
@@ -78,6 +81,9 @@ export async function makeSCD(context, scdProp, scdKey, distinct_id, mutations,
78
81
  case "month":
79
82
  scdEntry.startTime = lastInserted.add(1, "month").startOf('month').toISOString();
80
83
  break;
84
+ case "year":
85
+ scdEntry.startTime = lastInserted.add(1, "year").startOf('year').toISOString();
86
+ break;
81
87
  }
82
88
  }
83
89
 
@@ -85,28 +91,34 @@ export async function makeSCD(context, scdProp, scdKey, distinct_id, mutations,
85
91
  scdEntry.startTime = lastInserted.toISOString();
86
92
  }
87
93
 
94
+ // Enforce monotonic ordering — startTime must advance
95
+ if (lastStartTime && scdEntry.startTime && scdEntry.startTime <= lastStartTime) {
96
+ scdEntry.startTime = dayjs(lastStartTime).add(1, "second").toISOString();
97
+ }
98
+ lastStartTime = scdEntry.startTime;
99
+
88
100
  // Set insert time (slightly after start time)
89
- const insertTime = lastInserted.add(u.integer(1, 9000), "seconds");
101
+ const insertTime = dayjs(scdEntry.startTime).add(u.integer(1, 9000), "seconds");
90
102
  scdEntry.insertTime = insertTime.toISOString();
91
103
 
92
104
  // Set the time field for Mixpanel SCD import (uses startTime)
93
105
  scdEntry.time = scdEntry.startTime;
94
106
 
95
107
  // Only add entry if all required properties are set
96
- if (scdEntry.hasOwnProperty('insertTime') && scdEntry.hasOwnProperty('startTime') && scdEntry.hasOwnProperty('time')) {
108
+ if (scdEntry.startTime && scdEntry.insertTime && scdEntry.time) {
97
109
  scdEntries.push(scdEntry);
98
110
  }
99
111
 
100
- // Advance time for next entry
101
- lastInserted = lastInserted
102
- .add(u.integer(0, deltaDays), "day")
103
- .subtract(u.integer(1, 9000), "seconds");
112
+ // Advance time for next entry — scale step size to available range
113
+ if (deltaDays > 1) {
114
+ const advance = Math.max(1, u.integer(0, Math.ceil(deltaDays / mutations)));
115
+ lastInserted = dayjs(scdEntry.startTime).add(advance, "day");
116
+ } else {
117
+ // Short range: advance by hours instead of days
118
+ const advance = Math.max(1, u.integer(1, 12));
119
+ lastInserted = dayjs(scdEntry.startTime).add(advance, "hour");
120
+ }
104
121
  }
105
122
 
106
- // De-duplicate on startTime
107
- const deduped = scdEntries.filter((entry, index, self) =>
108
- index === self.findIndex((t) => t.startTime === entry.startTime)
109
- );
110
-
111
- return deduped;
123
+ return scdEntries;
112
124
  }
@@ -27,23 +27,29 @@ import seedrandom from 'seedrandom';
27
27
  import crypto from 'crypto';
28
28
  import SentimentPkg from 'sentiment';
29
29
  import { PHRASE_BANK, GENERATION_PATTERNS, ORGANIC_PATTERNS } from '../templates/phrases.js';
30
+ import { getChance } from '../utils/utils.js';
30
31
 
31
32
  const Sentiment = typeof SentimentPkg === 'function' ? SentimentPkg : SentimentPkg.default;
32
33
  const sentiment = new Sentiment();
33
34
 
34
35
  // ============= Helper Functions =============
35
36
 
37
+ function seededRandom() {
38
+ const c = getChance();
39
+ return c.floating({ min: 0, max: 1 });
40
+ }
41
+
36
42
  function chance(probability) {
37
- return Math.random() < probability;
43
+ return seededRandom() < probability;
38
44
  }
39
45
 
40
46
  function pick(array) {
41
- return array[Math.floor(Math.random() * array.length)];
47
+ return array[Math.floor(seededRandom() * array.length)];
42
48
  }
43
49
 
44
50
  function pickWeighted(items, weights) {
45
51
  const total = weights.reduce((a, b) => a + b, 0);
46
- let random = Math.random() * total;
52
+ let random = seededRandom() * total;
47
53
  for (let i = 0; i < items.length; i++) {
48
54
  random -= weights[i];
49
55
  if (random <= 0) return items[i];
@@ -437,7 +443,7 @@ class NaturalTypoEngine {
437
443
 
438
444
  // Fallback: transpose letters
439
445
  if (word.length > 3) {
440
- const pos = Math.floor(Math.random() * (word.length - 2)) + 1;
446
+ const pos = Math.floor(seededRandom() * (word.length - 2)) + 1;
441
447
  return word.slice(0, pos) + word[pos + 1] + word[pos] + word.slice(pos + 2);
442
448
  }
443
449
 
@@ -705,7 +711,7 @@ class OrganicTextGenerator {
705
711
 
706
712
  generateStreamOfConsciousness() {
707
713
  const thoughts = [];
708
- const numThoughts = 2 + Math.floor(Math.random() * 4);
714
+ const numThoughts = 2 + Math.floor(seededRandom() * 4);
709
715
 
710
716
  for (let i = 0; i < numThoughts; i++) {
711
717
  const thought = this.thoughtStream.generateThought(
@@ -778,11 +784,11 @@ class OrganicTextGenerator {
778
784
  generateFragmented() {
779
785
  const fragments = [];
780
786
  const numFragments = this.config.style === 'search' ?
781
- 1 + Math.floor(Math.random() * 3) :
782
- 2 + Math.floor(Math.random() * 4);
787
+ 1 + Math.floor(seededRandom() * 3) :
788
+ 2 + Math.floor(seededRandom() * 4);
783
789
 
784
790
  for (let i = 0; i < numFragments; i++) {
785
- const type = Math.random();
791
+ const type = seededRandom();
786
792
 
787
793
  if (type < 0.3) {
788
794
  fragments.push(pick(ORGANIC_PATTERNS.fragments.incomplete));
@@ -933,7 +939,7 @@ class OrganicTextGenerator {
933
939
 
934
940
  // Insert markers naturally
935
941
  for (const marker of markers) {
936
- const insertPoint = Math.floor(Math.random() * text.length);
942
+ const insertPoint = Math.floor(seededRandom() * text.length);
937
943
  text = text.slice(0, insertPoint) + ' ' + marker + ' ' + text.slice(insertPoint);
938
944
  }
939
945
 
@@ -958,8 +964,8 @@ class OrganicTextGenerator {
958
964
  }
959
965
 
960
966
  addTimestamp(text) {
961
- const hour = Math.floor(Math.random() * 24);
962
- const min = Math.floor(Math.random() * 60);
967
+ const hour = Math.floor(seededRandom() * 24);
968
+ const min = Math.floor(seededRandom() * 60);
963
969
  const timestamp = `[${hour}:${min.toString().padStart(2, '0')}]`;
964
970
 
965
971
  return timestamp + ' ' + text;
@@ -978,7 +984,7 @@ class OrganicTextGenerator {
978
984
  }
979
985
 
980
986
  breakSentence(sentence) {
981
- const breakPoint = Math.floor(sentence.length * (0.3 + Math.random() * 0.4));
987
+ const breakPoint = Math.floor(sentence.length * (0.3 + seededRandom() * 0.4));
982
988
  return sentence.slice(0, breakPoint) + '...';
983
989
  }
984
990