@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
@@ -15,6 +15,7 @@ import { makeEvent } from "../generators/events.js";
15
15
  import { makeFunnel } from "../generators/funnels.js";
16
16
  import { makeUserProfile } from "../generators/profiles.js";
17
17
  import { makeSCD } from "../generators/scd.js";
18
+ import { buildCurveWeightFn, expectedActiveDays } from "../utils/retention-curve.js";
18
19
 
19
20
  /**
20
21
  * Main user generation loop that creates users, their profiles, events, and SCDs
@@ -42,6 +43,8 @@ export async function userLoop(context) {
42
43
  scdProps,
43
44
  numDays,
44
45
  avgEventsPerUserPerDay,
46
+ avgActiveDaysPerUser,
47
+ retentionCurve,
45
48
  percentUsersBornInDataset = 15,
46
49
  strictEventCount = false,
47
50
  bornRecentBias = 0, // -1..1; positive = births skew toward end of window
@@ -66,7 +69,10 @@ export async function userLoop(context) {
66
69
  // Track if we've already logged the strict event count message
67
70
  let hasLoggedStrictCountReached = false;
68
71
 
69
- // Handle graceful shutdown on SIGINT (Ctrl+C)
72
+ // Handle graceful shutdown on SIGINT (Ctrl+C).
73
+ // CRITICAL: listener MUST be removed in a `finally` block — pre-fix, throws /
74
+ // cancellation paths leaked listeners across test runs, accumulating until
75
+ // Node fired the MaxListenersExceededWarning AND test workers stalled.
70
76
  let cancelled = false;
71
77
  const onSigint = () => {
72
78
  cancelled = true;
@@ -75,6 +81,7 @@ export async function userLoop(context) {
75
81
  };
76
82
  process.on('SIGINT', onSigint);
77
83
 
84
+ try {
78
85
  for (let i = 0; i < numUsers; i++) {
79
86
  const userPromise = USER_CONN(async () => {
80
87
  // Bail out if cancelled
@@ -114,7 +121,12 @@ export async function userLoop(context) {
114
121
  percentComplete: Math.min(100, Math.round((context.getUserCount() / numUsers) * 100))
115
122
  });
116
123
 
117
- const userId = chance.guid();
124
+ // v1.5.1: distinct_id sourced from a separate `userChance` when
125
+ // `Dungeon.userSeed` is set. This lets sharded runs (e.g., kodiak
126
+ // Cloud Run Job) generate the SAME user pool with DIFFERENT events
127
+ // across shards. Falls back to the event chance when userSeed is
128
+ // unset so existing dungeons stay byte-identical.
129
+ const userId = u.getUserChance().guid();
118
130
  const user = u.generateUser(userId, { numDays, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds, datasetEndUnix: context.FIXED_NOW, avgDevicePerUser });
119
131
  const { distinct_id, created } = user;
120
132
  const userDevicePool = (user.anonymousIds && user.anonymousIds.length) ? user.anonymousIds.slice() : null;
@@ -214,9 +226,14 @@ export async function userLoop(context) {
214
226
  dataQuality,
215
227
  };
216
228
 
217
- // Call user hook after profile creation (hooks override persona properties)
229
+ // Call user hook after profile creation (hooks override persona properties).
230
+ // v1.5.1: an explicit `null` return is interpreted as "drop the user
231
+ // PROFILE record" — the user's events still generate normally. Used
232
+ // by sharded runs that have one canonical chunk per bucket emit profiles
233
+ // while every other chunk skips them (see dungeons/user/kodiak/).
234
+ let dropUserProfile = false;
218
235
  if (config.hook) {
219
- await config.hook(profile, "user", {
236
+ const hookedProfile = await config.hook(profile, "user", {
220
237
  user,
221
238
  config,
222
239
  userIsBornInDataset,
@@ -224,6 +241,9 @@ export async function userLoop(context) {
224
241
  datasetStart: context.DATASET_START_SECONDS,
225
242
  datasetEnd: context.DATASET_END_SECONDS
226
243
  });
244
+ if (hookedProfile === null) {
245
+ dropUserProfile = true;
246
+ }
227
247
  }
228
248
 
229
249
  // SCD creation
@@ -263,24 +283,71 @@ export async function userLoop(context) {
263
283
  : numDays;
264
284
  const userEventBudget = ratePerDay * userActiveDays;
265
285
 
266
- let numEventsThisUserWillPreform = Math.floor(chance.normal({
286
+ // v1.5.1 (TODO #10): per-user event budget.
287
+ //
288
+ // Removed:
289
+ // - the 0.714 magic dampening factor (pre-existing vestigial constant)
290
+ // - the ×5 / ×0.333 "power user" / "low activity" dice rolls
291
+ // (E[dice mult] ≈ 1.62, combined with 0.714 ≈ 1.16x inflation
292
+ // by design — produced 60-100% systematic overshoot on
293
+ // `numEvents` targets across all macros).
294
+ //
295
+ // New: pure normal distribution around `userEventBudget` with
296
+ // `dev = userEventBudget / 3` (~68% of users within ±1σ of target,
297
+ // ~95% within ±2σ). Real heavy-tail behavior should come from
298
+ // `personas` (`eventMultiplier`) — explicit, opt-in, documented.
299
+ let numEventsThisUserWillPreform = Math.max(0, Math.round(chance.normal({
267
300
  mean: userEventBudget,
268
- dev: userEventBudget / u.integer(u.integer(2, 5), u.integer(2, 7))
269
- }) * 0.714159265359);
270
-
271
- // Power users and low-activity users logic
301
+ dev: userEventBudget / 3,
302
+ })));
272
303
  if (persona) {
273
- // Persona-driven event multiplier replaces the old dice rolls
274
304
  numEventsThisUserWillPreform *= persona.eventMultiplier;
275
- } else {
276
- // Legacy behavior when no personas configured
277
- chance.bool({ likelihood: 20 }) ? numEventsThisUserWillPreform *= 5 : null;
278
- chance.bool({ likelihood: 15 }) ? numEventsThisUserWillPreform *= 0.333 : null;
279
305
  }
280
306
  numEventsThisUserWillPreform = Math.round(numEventsThisUserWillPreform);
281
307
 
282
308
  let userFirstEventTime;
283
309
 
310
+ // ── v1.5 Active-day scheduling ──
311
+ // When `avgActiveDaysPerUser` is set, build a per-user day plan: a list
312
+ // of UTC day-start unix-seconds, one entry per planned event. Each event
313
+ // generation call pops the next day from the plan and constrains TimeSoup
314
+ // to that day's [start, end] range. Funnel events anchor on the picked
315
+ // day; subsequent funnel steps spill within `timeToConvert` hours.
316
+ //
317
+ // When unset, dayPlan stays null and behavior is fully legacy (TimeSoup
318
+ // across [adjustedCreated, FIXED_NOW]).
319
+ const soupCfgForActiveDay = /** @type {import('../../types').SoupConfig} */ (config.soup) || {};
320
+ // v1.5 follow-up (Fix #1): buildActiveDayPlan returns
321
+ // `{ plan, pickedDayBuckets }`. `pickedDayBuckets` flows into
322
+ // `applyEngagementDecay` so the decay filter never drops the last event
323
+ // on a picked day — preserving the configured distinct-day count.
324
+ // v1.5.1: also fires when `retentionCurve` is set (curve wins over
325
+ // explicit `avgActiveDaysPerUser` — see TODO #4 resolution (b)).
326
+ const hasActiveDaysKnob = (avgActiveDaysPerUser !== undefined && avgActiveDaysPerUser !== null && Number.isFinite(avgActiveDaysPerUser));
327
+ const hasRetentionCurve = !!retentionCurve;
328
+ const dayPlanResult = (hasActiveDaysKnob || hasRetentionCurve)
329
+ ? buildActiveDayPlan({
330
+ adjustedCreated, fixedBegin: context.FIXED_BEGIN, fixedNow: context.FIXED_NOW,
331
+ avgActiveDaysPerUser, userActiveDays,
332
+ numEvents: numEventsThisUserWillPreform,
333
+ dowWeights: soupCfgForActiveDay.dayOfWeekWeights,
334
+ retentionCurve,
335
+ chance,
336
+ })
337
+ : null;
338
+ const dayPlan = dayPlanResult ? dayPlanResult.plan : null;
339
+ const pickedDayBuckets = dayPlanResult ? dayPlanResult.pickedDayBuckets : null;
340
+ let dayPlanCursor = 0;
341
+ const nextDayBounds = () => {
342
+ if (!dayPlan || !dayPlan.length) return null;
343
+ // When the plan is exhausted, wrap (over-generation due to noise rounding;
344
+ // bounded re-use keeps remaining events on picked days rather than spilling).
345
+ const dayStartSec = dayPlan[dayPlanCursor % dayPlan.length];
346
+ dayPlanCursor++;
347
+ const dayEndSec = Math.min(dayStartSec + 86400 - 1, context.FIXED_NOW);
348
+ return { earliest: Math.max(dayStartSec, context.FIXED_BEGIN), latest: dayEndSec };
349
+ };
350
+
284
351
  const firstFunnels = funnels.filter((f) => f.isFirstFunnel)
285
352
  .filter((f) => !f.conditions || matchConditions(profile, f.conditions))
286
353
  .reduce(weighFunnels, []);
@@ -320,7 +387,16 @@ export async function userLoop(context) {
320
387
  // PATH FOR USERS BORN IN DATASET AND PERFORMING FIRST FUNNEL
321
388
  if (firstFunnels.length && userIsBornInDataset) {
322
389
  const firstFunnel = chance.pickone(firstFunnels, user);
323
- let cursor = adjustedCreated.subtract(noise(), 'seconds').unix();
390
+ // Active-day mode: anchor the first funnel on a picked day so the user's
391
+ // signup lands within their planned active window. Legacy mode: anchor at
392
+ // adjustedCreated minus a noise offset.
393
+ let cursor;
394
+ if (dayPlan) {
395
+ const bounds = nextDayBounds();
396
+ cursor = bounds ? bounds.earliest : adjustedCreated.subtract(noise(), 'seconds').unix();
397
+ } else {
398
+ cursor = adjustedCreated.subtract(noise(), 'seconds').unix();
399
+ }
324
400
 
325
401
  // Resolve attempts plan. `attempts.{min,max}` count FAILED PRIORS; total
326
402
  // passes = failedPriors + 1. Validator coerced bounds; default both 0.
@@ -372,9 +448,13 @@ export async function userLoop(context) {
372
448
 
373
449
  userFirstEventTime = firstAttemptFirstEventTime !== null
374
450
  ? firstAttemptFirstEventTime
375
- : adjustedCreated.subtract(noise(), 'seconds').unix();
451
+ : Math.max(adjustedCreated.subtract(noise(), 'seconds').unix(), context.FIXED_BEGIN);
376
452
  } else {
377
- userFirstEventTime = adjustedCreated.subtract(noise(), 'seconds').unix();
453
+ // v1.5.1: clamp at FIXED_BEGIN so pre-existing-user events stay strictly
454
+ // inside the dataset window. Without this, `noise()` (up to 1 day) can
455
+ // shift the first-event time before FIXED_BEGIN — fine when the dataset
456
+ // is years long, but visible at sub-day chunk windows (kodiak shards).
457
+ userFirstEventTime = Math.max(adjustedCreated.subtract(noise(), 'seconds').unix(), context.FIXED_BEGIN);
378
458
  }
379
459
 
380
460
  // ALL SUBSEQUENT EVENTS (funnels for converted users, standalone for all)
@@ -391,20 +471,121 @@ export async function userLoop(context) {
391
471
  // Usage funnels for converted users: identity already stitched, just default 'both'.
392
472
  const usageAttemptMeta = { isFirstFunnel: false, isBorn: userIsBornInDataset, devicePool: userDevicePool };
393
473
 
394
- let usageFunnelCursor = userFirstEventTime;
395
- while (numEventsPreformed < numEventsThisUserWillPreform && !cancelled) {
474
+ // v1.5 follow-up (engine bunchiness fix, 2026-05-09):
475
+ // REMOVED `usageFunnelCursor` accumulator. Each funnel call now uses
476
+ // `userFirstEventTime` (constant) as the anchor. Without this, the cursor
477
+ // chained `last_event_time + small_gap` between funnel runs, walking past
478
+ // FIXED_NOW and producing the right-edge bunchiness regression. See
479
+ // `plans/ENGINE-BUNCHINESS/FIX.md` for the full diagnosis.
480
+ //
481
+ // Loop budget now counts SURVIVING events (post `_drop`), not raw output.
482
+ // Combined with the cursor removal, this gives each funnel an independent
483
+ // uniform-in-window anchor (via TimeSoup) and lets the budget loop iterate
484
+ // until the user actually has the target event count.
485
+ //
486
+ // v1.5: when auto-promote marks every event strict (e.g. all events appear in
487
+ // the only funnel), there's nothing for the standalone branch to pick. Bail
488
+ // out cleanly rather than crashing on `pick([])`.
489
+ const hasUsageFunnels = usageFunnels.length > 0;
490
+ const hasStandaloneEvents = weightedEvents.length > 0;
491
+ // v1.5.1 (TODO #10): shortest usage-funnel length. Used to decide
492
+ // whether the remaining budget can fit another funnel run — funnels
493
+ // add N events at once, so iterations near the budget ceiling
494
+ // systematically overshot. Now we redirect to standalone (or break)
495
+ // when the remaining headroom is smaller than the smallest funnel.
496
+ const minUsageFunnelLen = hasUsageFunnels
497
+ ? Math.min(...usageFunnels.map(f => Array.isArray(f.sequence) ? f.sequence.length : 1))
498
+ : Infinity;
499
+ // Hard ceiling on iterations: defends against pathological configs where
500
+ // every funnel produces 0 surviving events (e.g. ttc > numDays so step1
501
+ // uniform anchor always lands too late). Cap at 2× expected iteration count
502
+ // based on average funnel length (~7 steps) to avoid infinite loops.
503
+ const MAX_ITERATIONS = Math.max(100, numEventsThisUserWillPreform * 2);
504
+ let iterationCount = 0;
505
+ while (numEventsPreformed < numEventsThisUserWillPreform && !cancelled && iterationCount < MAX_ITERATIONS) {
506
+ iterationCount++;
396
507
  let newEvents;
397
- if (usageFunnels.length && userConverted) {
508
+ // v1.5 active-day: pop next picked-day bounds. Pass `latestTime` through
509
+ // featureCtx so makeEvent's TimeSoup confines to that day. Funnel cursor
510
+ // gets re-anchored to the picked day's start (subsequent funnel steps
511
+ // spill within `timeToConvert` hours; this is intentional).
512
+ const dayBounds = dayPlan ? nextDayBounds() : null;
513
+ // Compute step1's `latestTime` so the funnel's relative span fits before
514
+ // FIXED_NOW. Without this, born-late users + long-ttc funnels generate
515
+ // large numbers of `_drop`'d events that consume budget cycles. The
516
+ // safety margin is `timeToConvert * 3600 - 1` seconds (matches v1.5
517
+ // conversion window contract).
518
+ const standaloneFeatureCtx = dayBounds
519
+ ? { ...featureCtx, latestTime: dayBounds.latest }
520
+ : featureCtx;
521
+ // Nothing to generate: user converted via firstFunnel only and has no
522
+ // usage funnels and no standalone events. Stop attempting.
523
+ if ((!hasUsageFunnels || !userConverted) && !hasStandaloneEvents) break;
524
+ // v1.5.1 (TODO #10): if the remaining budget can't fit even the
525
+ // shortest usage funnel, prefer a standalone event (or break if
526
+ // no standalone). Without this, the funnel branch added N events
527
+ // at once and overshot `numEvents` by `(funnel_length - 1)` per
528
+ // terminal iteration — measured ~5-15% systematic overshoot.
529
+ const remainingBudget = numEventsThisUserWillPreform - numEventsPreformed;
530
+ const useFunnel = hasUsageFunnels && userConverted && remainingBudget >= minUsageFunnelLen;
531
+ // Budget exhausted for funnels AND no standalone → break.
532
+ if (!useFunnel && !hasStandaloneEvents) break;
533
+ if (useFunnel) {
398
534
  const currentFunnel = chance.pickone(usageFunnels);
399
- const [data, converted] = await makeFunnel(context, currentFunnel, user, usageFunnelCursor, profile, userSCD, persona, featureCtx, usageAttemptMeta);
535
+ const ttcSec = (currentFunnel.timeToConvert || 0) * 3600;
536
+ // Anchor cursor at picked day's start when active-day mode is on,
537
+ // otherwise pass userFirstEventTime (constant). NO cursor accumulation.
538
+ const funnelCursor = dayBounds ? dayBounds.earliest : userFirstEventTime;
539
+ // Constrain funnel step1's TimeSoup latestTime so the full funnel fits in
540
+ // window. Without this, late steps spill past FIXED_NOW and get `_drop`'d.
541
+ //
542
+ // v1.5 final (2026-05-09): `FUNNEL_DEAD_ZONE_CAP_SEC = 0` — funnels can
543
+ // anchor step1 right up to FIXED_NOW. Earlier rounds defended against a
544
+ // cursor-accumulation bug by reserving a `ttc`-sized dead zone at the
545
+ // right edge; round 1 fixed cursor accumulation directly, leaving the
546
+ // dead zone as defense-in-depth. The future-time guard at storage step
547
+ // 14 (per CLAUDE.md "Execution Order") drops any event with `time >
548
+ // FIXED_NOW`, so spillover from late funnel steps is filtered there
549
+ // instead of by anchoring upstream. Removing the dead zone eliminated
550
+ // the last-day cliff for funnel-heavy dungeons WITHOUT re-introducing
551
+ // `futureEvents > 0` — verified across the 194-combo engine-validation
552
+ // sweep (`scripts/sweep-engine.mjs`, `plans/ENGINE-VALIDATION/FIX.md`).
553
+ //
554
+ // Trade-off retained: long-ttc funnels still lose some late steps to
555
+ // `_drop`. Budget loop iterates more to compensate. Catch-all funnel
556
+ // (`ttc=1d`, set in config-validator) is unaffected.
557
+ //
558
+ // Born-late edge case: when `funnelCursor > FN`, the `safeLatest >
559
+ // funnelCursor` check below falls back to `FN` so the user can emit.
560
+ const FUNNEL_DEAD_ZONE_CAP_SEC = 0;
561
+ const deadZoneSec = Math.min(ttcSec, FUNNEL_DEAD_ZONE_CAP_SEC);
562
+ const safeLatest = context.FIXED_NOW - deadZoneSec;
563
+ const funnelLatestTime = dayBounds
564
+ ? dayBounds.latest
565
+ : (safeLatest > funnelCursor ? safeLatest : context.FIXED_NOW);
566
+ const funnelEventFeatureCtx = { ...featureCtx, latestTime: funnelLatestTime };
567
+ const [data, converted] = await makeFunnel(context, currentFunnel, user, funnelCursor, profile, userSCD, persona, funnelEventFeatureCtx, usageAttemptMeta);
568
+ // Budget counts raw output (matches pre-fix semantics). For short-ttc
569
+ // funnels (≤1d), almost no events `_drop` so the loop terminates at the
570
+ // expected count. For long-ttc funnels (>1d), some late steps `_drop` —
571
+ // loop iterates more to compensate, world-event `_drop`s still reduce
572
+ // the user's surviving total since they fire on dropped events too.
400
573
  numEventsPreformed += data.length;
401
574
  newEvents = data;
402
- if (data.length) {
403
- const lastTime = dayjs(data[data.length - 1].time).unix();
404
- usageFunnelCursor = lastTime + chance.integer({ min: 60, max: 30 * 60 });
405
- }
406
575
  } else {
407
- const data = await makeEvent(context, distinct_id, userFirstEventTime, u.pick(weightedEvents), user.anonymousIds, {}, config.groupKeys, true, false, featureCtx, standaloneIdentityCtx);
576
+ // Active-day mode: standalone event uses picked-day start as earliestTime
577
+ // + day-end as latestTime (passed via eventFeatureCtx).
578
+ // `isFirstEvent: false` (8th arg) so TimeSoup distributes the timestamp
579
+ // — passing `true` here would pin every standalone event to the same
580
+ // `earliestTime`, which the now-deleted bunchIntoSessions used to paper
581
+ // over. With bunchIntoSessions removed, TimeSoup is the time source.
582
+ const standaloneEarliest = dayBounds ? dayBounds.earliest : userFirstEventTime;
583
+ // v1.5.1: pass `config.superProps` so standalone events get super-property
584
+ // stamping. Pre-1.5.1, standalone events received `{}` here, but the
585
+ // validator's auto-funnel (catch-all) consumed all non-strict events so
586
+ // the bug was invisible. The TODO #10 `useFunnel` gate redirects some
587
+ // budget-boundary iterations to standalone, exposing the gap.
588
+ const data = await makeEvent(context, distinct_id, standaloneEarliest, u.pick(weightedEvents), user.anonymousIds, config.superProps || {}, config.groupKeys, false, false, standaloneFeatureCtx, standaloneIdentityCtx);
408
589
  numEventsPreformed++;
409
590
  newEvents = [data];
410
591
  }
@@ -434,7 +615,7 @@ export async function userLoop(context) {
434
615
  const userDecay = persona?.engagementDecay || globalEngagementDecay;
435
616
  if (userDecay && userDecay.model !== 'none' && usersEvents.length > 0) {
436
617
  // adjustedCreated and event times now share the same dataset window — no shift.
437
- usersEvents = applyEngagementDecay(usersEvents, userDecay, adjustedCreated, context, chance);
618
+ usersEvents = applyEngagementDecay(usersEvents, userDecay, adjustedCreated, context, chance, pickedDayBuckets);
438
619
  }
439
620
 
440
621
  // Feature 4: Data quality — duplicates and late-arriving
@@ -460,18 +641,17 @@ export async function userLoop(context) {
460
641
  }
461
642
  }
462
643
 
463
- // Session clustering: redistribute events into temporal bursts, then assign session IDs
644
+ // Session clustering: assign session IDs based on natural temporal gaps.
645
+ // v1.5: bunchIntoSessions deleted — was a redundant wholesale time-overwrite
646
+ // that scrambled multi-step funnels (round-robin into anchor buckets) and
647
+ // clobbered v1.5 active-day picking. assignSessionIds operates on the original
648
+ // TimeSoup-driven timestamps (Mixpanel-aligned 30-min-gap rule).
464
649
  if (hasSessionIds && usersEvents.length > 0) {
465
- const soupCfg = /** @type {import('../../types').SoupConfig} */ (config.soup) || {};
466
- const defaultPeaks = Math.max(5, (config.numDays || 30) * 2);
467
- const { mean: soupMean = 0, deviation: soupDev = 2, peaks: soupPeaks = defaultPeaks,
468
- dayOfWeekWeights: soupDOW, hourOfDayWeights: soupHOD } = soupCfg;
469
-
470
- u.bunchIntoSessions(usersEvents, sessionTimeout, {
471
- earliestTime: userFirstEventTime,
472
- latestTime: context.FIXED_NOW,
473
- peaks: soupPeaks, deviation: soupDev, mean: soupMean,
474
- dayOfWeekWeights: soupDOW, hourOfDayWeights: soupHOD
650
+ // assignSessionIds requires events sorted ascending by time.
651
+ usersEvents.sort((a, b) => {
652
+ const ta = typeof a.time === 'string' ? Date.parse(a.time) : Number(a.time);
653
+ const tb = typeof b.time === 'string' ? Date.parse(b.time) : Number(b.time);
654
+ return ta - tb;
475
655
  });
476
656
  u.assignSessionIds(usersEvents, sessionTimeout);
477
657
 
@@ -493,6 +673,23 @@ export async function userLoop(context) {
493
673
  }
494
674
  }
495
675
 
676
+ // v1.5: Touchpoint cap. Sample up to `maxTouchpointsPerUser` (default 10,
677
+ // matching Mixpanel `TOUCHPOINTS_LIMIT`) eligible events from the user's
678
+ // lifetime and stamp UTMs on the sample. Lifetime-distributed sampling
679
+ // preserves realistic touch shape — last-10-window attribution then
680
+ // gives meaningful first/last-touch results.
681
+ if (config.hasCampaigns && usersEvents.length > 0) {
682
+ applyTouchpointCap(usersEvents, config, defaults, chance);
683
+ }
684
+
685
+ // v1.5.1: anonymous non-converters never call $identify in production,
686
+ // so Mixpanel never creates a profile for them. Stamp `_drop: true` so
687
+ // mixpanel-sender skips the /engage push. Stamped BEFORE the everything
688
+ // hook fires so hooks can rescue a profile by deleting the flag.
689
+ if (userIsBornInDataset && !userAuthed) {
690
+ profile._drop = true;
691
+ }
692
+
496
693
  // Hook for processing all user events (hooks override everything)
497
694
  if (config.hook) {
498
695
  // `meta.isPreAuth(event)` predicate bound to this user's auth state.
@@ -521,17 +718,44 @@ export async function userLoop(context) {
521
718
  if (Array.isArray(newEvents)) usersEvents = newEvents;
522
719
  }
523
720
 
721
+ // v1.5: auto-sort by time after everything hook. Defends against the
722
+ // most common new footgun — hooks that push() cloned events with
723
+ // arbitrary timestamps and break the greedy funnel engine's
724
+ // chronological-order requirement. Opt out with `autoSortAfterEverything: false`.
725
+ if (config.autoSortAfterEverything !== false && usersEvents.length > 1) {
726
+ usersEvents.sort((a, b) => {
727
+ const ta = (a && typeof a.time === 'string') ? Date.parse(a.time) : Number(a && a.time);
728
+ const tb = (b && typeof b.time === 'string') ? Date.parse(b.time) : Number(b && b.time);
729
+ if (!Number.isFinite(ta) && !Number.isFinite(tb)) return 0;
730
+ if (!Number.isFinite(ta)) return 1;
731
+ if (!Number.isFinite(tb)) return -1;
732
+ return ta - tb;
733
+ });
734
+ }
735
+
524
736
  // Defensive guard: drop any events whose timestamp landed past the
525
737
  // configured dataset end. Hooks that duplicate events with time offsets
526
738
  // (weekend surges, viral spreads) can leak a few past the boundary.
739
+ // v1.5 follow-up (`reccomendations-agent-1.md` Fix #2): surface the drop
740
+ // in verbose mode. Silent dropping made determinism failures hard to debug
741
+ // — events vanished without trace. Per-user log helps the owner see which
742
+ // users + how many events were affected.
743
+ const beforeFutureFilter = usersEvents.length;
527
744
  usersEvents = usersEvents.filter(e => {
528
745
  if (!e || !e.time) return true;
529
746
  const t = typeof e.time === 'string' ? Date.parse(e.time) / 1000 : Number(e.time);
530
747
  return Number.isFinite(t) ? t <= context.FIXED_NOW : true;
531
748
  });
749
+ const droppedFuture = beforeFutureFilter - usersEvents.length;
750
+ if (droppedFuture > 0 && config.verbose) {
751
+ console.warn(`⚠️ Dropped ${droppedFuture} future-dated event(s) for user ${distinct_id}`);
752
+ }
532
753
 
533
- // Store all user data
534
- await userProfilesData.hookPush(profile);
754
+ // Store all user data (skip profile push when a hook returned null
755
+ // for type='user' — see dropUserProfile above).
756
+ if (!dropUserProfile) {
757
+ await userProfilesData.hookPush(profile);
758
+ }
535
759
 
536
760
  if (Object.keys(userSCD).length) {
537
761
  for (const [key, changesArray] of Object.entries(userSCD)) {
@@ -553,18 +777,34 @@ export async function userLoop(context) {
553
777
  });
554
778
 
555
779
  userPromises.push(userPromise);
780
+
781
+ // v1.5.1: V8's Promise.all has a hard ceiling (~65K elements). Sharded
782
+ // kodiak runs pass numUsers in the millions per chunk, which blows that
783
+ // limit. Drain the in-flight buffer in batches of 50K to keep Promise.all
784
+ // well under the cap. With p-limit honoring `concurrency`, this is purely
785
+ // a backpressure boundary — no behavior change for existing dungeons that
786
+ // have numUsers under 50K.
787
+ if (userPromises.length >= 50_000) {
788
+ await Promise.all(userPromises);
789
+ userPromises.length = 0;
790
+ }
556
791
  }
557
792
 
558
- // Wait for all users to complete
559
- await Promise.all(userPromises);
793
+ // Drain whatever's left in the final partial batch.
794
+ if (userPromises.length > 0) {
795
+ await Promise.all(userPromises);
796
+ userPromises.length = 0;
797
+ }
560
798
 
561
799
  // Feature 4: Generate bot users (after regular users)
562
800
  if (dataQuality && dataQuality.botUsers > 0) {
563
801
  await generateBotUsers(context, dataQuality, storage);
564
802
  }
565
-
566
- // Clean up SIGINT handler
567
- process.removeListener('SIGINT', onSigint);
803
+ } finally {
804
+ // Always remove the SIGINT listener — even if userLoop throws or is
805
+ // cancelled. Pre-fix this leaked across test runs and stalled workers.
806
+ process.removeListener('SIGINT', onSigint);
807
+ }
568
808
  }
569
809
 
570
810
 
@@ -583,12 +823,285 @@ export function matchConditions(profile, conditions) {
583
823
  return true;
584
824
  }
585
825
 
826
+ // ── v1.5 Active-Day Plan Helpers ──
827
+
828
+ /**
829
+ * Build a deterministic per-user "day plan" for active-day mode.
830
+ *
831
+ * Each element of `plan` names the day on which the event-i should land. Events
832
+ * naturally concentrate onto `targetActiveDays` distinct days (drawn from a normal
833
+ * around `avgActiveDaysPerUser`, clamped to `[1, userActiveDays]`).
834
+ *
835
+ * Day picking uses weighted-without-replacement against soup DOW weights so the
836
+ * cohort-level weekly rhythm is preserved. Event distribution across picked days
837
+ * is proportional to those same weights, with a floor of 1 event per picked day.
838
+ *
839
+ * **Return shape (v1.5 follow-up — `reccomendations-agent-1.md` Fix #1):**
840
+ * `{ plan, pickedDayBuckets }` — `plan` is the shuffled per-event day-start
841
+ * unix-seconds array; `pickedDayBuckets` is a `Set<number>` of UTC-day-index
842
+ * buckets (`Math.floor(timestamp_ms / 86400000)`). Downstream consumers like
843
+ * `applyEngagementDecay` use `pickedDayBuckets` to enforce the v1.5
844
+ * distinct-day contract — see Fix #1 below.
845
+ *
846
+ * @param {Object} args
847
+ * @param {import('dayjs').Dayjs} args.adjustedCreated - User's "created" timestamp
848
+ * @param {number} args.fixedBegin - Dataset start (unix seconds)
849
+ * @param {number} args.fixedNow - Dataset end (unix seconds)
850
+ * @param {number} args.avgActiveDaysPerUser
851
+ * @param {number} args.userActiveDays - Capacity (max possible distinct days)
852
+ * @param {number} args.numEvents - Total events to schedule for this user
853
+ * @param {number[]} [args.dowWeights] - 7-element soup DOW weights (Sun..Sat)
854
+ * @param {import('../utils/retention-curve.js').RetentionCurveConfig} [args.retentionCurve]
855
+ * v1.5.1 — when set, weights candidate days by curve(day_offset_from_birth)
856
+ * instead of DOW weights AND derives the effective avgActiveDaysPerUser from
857
+ * the curve's sum across the window.
858
+ * @param {Object} args.chance - Seeded chance instance
859
+ * @returns {{ plan: number[], pickedDayBuckets: Set<number> } | null} Day plan
860
+ * + bucket set, or null if not applicable (no candidate days, no events, etc.).
861
+ */
862
+ function buildActiveDayPlan({ adjustedCreated, fixedBegin, fixedNow, avgActiveDaysPerUser, userActiveDays, numEvents, dowWeights, retentionCurve, chance }) {
863
+ if (!Number.isFinite(numEvents) || numEvents <= 0) return null;
864
+
865
+ // Candidate day buckets: UTC days intersecting [max(adjustedCreated, FIXED_BEGIN), FIXED_NOW].
866
+ // Pre-existing users with 'uniform' preExistingSpread have adjustedCreated < FIXED_BEGIN;
867
+ // active-day mode constrains them to in-window days only (matches what Mixpanel sees).
868
+ const userStartUnix = Math.max(
869
+ Math.floor(adjustedCreated.unix()),
870
+ Math.floor(fixedBegin)
871
+ );
872
+ const userEndUnix = Math.floor(fixedNow);
873
+ if (userEndUnix < userStartUnix) return null;
874
+
875
+ const dayMs = 86400;
876
+ const firstDay = Math.floor(userStartUnix / dayMs);
877
+ const lastDay = Math.floor(userEndUnix / dayMs);
878
+ const candidateDays = [];
879
+ for (let d = firstDay; d <= lastDay; d++) {
880
+ candidateDays.push(d * dayMs);
881
+ }
882
+ if (!candidateDays.length) return null;
883
+
884
+ // v1.5.1 (TODO #4): when `retentionCurve` is set, weight candidate days by
885
+ // curve(day_offset_from_birth) — biases active-day selection toward the
886
+ // requested retention shape. Replaces DOW weighting entirely (curve wins).
887
+ // Otherwise: legacy DOW weights.
888
+ let weights;
889
+ if (retentionCurve) {
890
+ const curveFn = buildCurveWeightFn(retentionCurve);
891
+ const birthDay = Math.floor(userStartUnix / dayMs);
892
+ weights = candidateDays.map(daySec => {
893
+ const dayOffset = (daySec / dayMs) - birthDay;
894
+ const w = curveFn(dayOffset);
895
+ return Number.isFinite(w) && w > 0 ? w : 0.0001;
896
+ });
897
+ } else {
898
+ weights = candidateDays.map(daySec => {
899
+ if (!Array.isArray(dowWeights) || dowWeights.length !== 7) return 1;
900
+ const dow = new Date(daySec * 1000).getUTCDay();
901
+ const w = Number(dowWeights[dow]);
902
+ return Number.isFinite(w) && w > 0 ? w : 0.0001;
903
+ });
904
+ }
905
+
906
+ // Draw target active-day count: normal(mean, sd=mean/3), clamped.
907
+ // v1.5.1: when `retentionCurve` is set, the mean is derived from the curve's
908
+ // sum across the user's window (resolution (b): curve wins, explicit
909
+ // avgActiveDaysPerUser ignored).
910
+ const effectiveMean = retentionCurve
911
+ ? Math.max(1, expectedActiveDays(retentionCurve, candidateDays.length))
912
+ : avgActiveDaysPerUser;
913
+ const meanActive = Math.max(1, Math.min(effectiveMean, candidateDays.length));
914
+ const sd = Math.max(0.5, meanActive / 3);
915
+ let targetActiveDays = Math.round(chance.normal({ mean: meanActive, dev: sd }));
916
+ targetActiveDays = Math.max(1, Math.min(targetActiveDays, candidateDays.length));
917
+
918
+ // Pick which days are active.
919
+ const pickedDays = weightedSampleNoReplacement(candidateDays, weights, targetActiveDays, chance);
920
+ if (!pickedDays.length) return null;
921
+ pickedDays.sort((a, b) => a - b);
922
+
923
+ // Recompute weights aligned to picked days for event distribution.
924
+ const pickedWeights = pickedDays.map(daySec => {
925
+ if (!Array.isArray(dowWeights) || dowWeights.length !== 7) return 1;
926
+ const dow = new Date(daySec * 1000).getUTCDay();
927
+ const w = Number(dowWeights[dow]);
928
+ return Number.isFinite(w) && w > 0 ? w : 0.0001;
929
+ });
930
+ const totalWeight = pickedWeights.reduce((s, x) => s + x, 0);
931
+
932
+ // Allocate event counts across picked days. Each picked day gets at least 1
933
+ // event so every day is "used" — distinct-day count actually hits target.
934
+ const k = pickedDays.length;
935
+ let allocations;
936
+ if (numEvents <= k) {
937
+ // Fewer events than picked days: distribute one each, sample which days get them.
938
+ const subPicks = weightedSampleNoReplacement(pickedDays, pickedWeights, numEvents, chance);
939
+ const subSet = new Set(subPicks);
940
+ allocations = pickedDays.map(d => subSet.has(d) ? 1 : 0);
941
+ } else {
942
+ // Floor 1 per picked day, distribute remainder proportionally.
943
+ const remainder = numEvents - k;
944
+ allocations = pickedWeights.map(w => Math.floor((remainder * w) / totalWeight));
945
+ // Add floor of 1 to each
946
+ for (let i = 0; i < k; i++) allocations[i] += 1;
947
+ // Distribute rounding remainder by descending weight order
948
+ let assigned = allocations.reduce((s, x) => s + x, 0);
949
+ let rem = numEvents - assigned;
950
+ const order = pickedDays.map((_, i) => i).sort((a, b) => pickedWeights[b] - pickedWeights[a]);
951
+ for (let i = 0; i < rem; i++) {
952
+ allocations[order[i % order.length]]++;
953
+ }
954
+ }
955
+
956
+ // Expand into a flat plan and shuffle deterministically.
957
+ const plan = [];
958
+ for (let i = 0; i < k; i++) {
959
+ for (let j = 0; j < allocations[i]; j++) {
960
+ plan.push(pickedDays[i]);
961
+ }
962
+ }
963
+ // v1.5 follow-up (Fix #1): expose the picked-day bucket set so engagement
964
+ // decay can protect the last surviving event per picked day. Buckets use
965
+ // the same `floor(ms / 86400000)` formula that decay applies to event times.
966
+ const pickedDayBuckets = new Set(pickedDays.map(daySec => Math.floor(daySec / 86400)));
967
+ // chance.shuffle uses seeded RNG → deterministic.
968
+ return { plan: chance.shuffle(plan), pickedDayBuckets };
969
+ }
970
+
971
+ /**
972
+ * v1.5 Touchpoint cap: sample up to `maxTouchpointsPerUser` eligible events from
973
+ * the user's stream and stamp UTMs on the sample. Mirrors Mixpanel's
974
+ * `TOUCHPOINTS_LIMIT = 10` (`backend/libquery/properties_over_time/attributed_value_reader.cpp`).
975
+ *
976
+ * Eligibility:
977
+ * - If any event in `config.events[]` has `isAttributionEvent: true`: candidate
978
+ * pool = events whose name carries that flag.
979
+ * - Else (legacy fallback): candidate pool = ALL events in the user's stream.
980
+ *
981
+ * Sampling:
982
+ * - If `eligible.length <= cap`: stamp all of them.
983
+ * - Else: uniform random sample of size `cap` (seeded `chance.pickset`,
984
+ * deterministic). Sort sample chronologically before stamping so UTMs land
985
+ * in time order.
986
+ *
987
+ * Mutates `events` in place.
988
+ *
989
+ * @param {Object[]} events - User's events (flat shape with .event, .time)
990
+ * @param {Object} config - Validated dungeon config
991
+ * @param {Object} defaults - Context.defaults (provides campaigns())
992
+ * @param {Object} chance - Seeded chance instance
993
+ */
994
+ function applyTouchpointCap(events, config, defaults, chance) {
995
+ const cap = Number.isFinite(config.maxTouchpointsPerUser)
996
+ ? config.maxTouchpointsPerUser
997
+ : 10;
998
+ if (cap <= 0) return;
999
+
1000
+ // Build event-name → config map for isAttributionEvent lookup.
1001
+ const eventCfgByName = new Map();
1002
+ for (const e of (config.events || [])) {
1003
+ if (e && e.event) eventCfgByName.set(e.event, e);
1004
+ }
1005
+
1006
+ // Determine eligible events.
1007
+ let eligible;
1008
+ if (config.hasAttributionFlags) {
1009
+ eligible = events.filter(e => {
1010
+ if (!e || !e.event) return false;
1011
+ const cfg = eventCfgByName.get(e.event);
1012
+ return cfg && cfg.isAttributionEvent === true;
1013
+ });
1014
+ } else {
1015
+ eligible = events.slice();
1016
+ }
1017
+ if (eligible.length === 0) return;
1018
+
1019
+ // Sample up to cap (uniform random without replacement, seeded).
1020
+ let sample;
1021
+ if (eligible.length <= cap) {
1022
+ sample = eligible;
1023
+ } else {
1024
+ sample = chance.pickset(eligible, cap);
1025
+ }
1026
+
1027
+ // Sort sample chronologically so UTMs land in time order.
1028
+ sample.sort((a, b) => {
1029
+ const ta = typeof a.time === 'string' ? Date.parse(a.time) : Number(a.time);
1030
+ const tb = typeof b.time === 'string' ? Date.parse(b.time) : Number(b.time);
1031
+ return ta - tb;
1032
+ });
1033
+
1034
+ // Stamp UTMs on each sampled event using a campaign template.
1035
+ for (const ev of sample) {
1036
+ const campaignTemplate = u.pickRandom(defaults.campaigns());
1037
+ if (!campaignTemplate || typeof campaignTemplate !== 'object') continue;
1038
+ for (const [k, v] of Object.entries(campaignTemplate)) {
1039
+ ev[k] = u.choose(v);
1040
+ }
1041
+ }
1042
+ }
1043
+
1044
+ /**
1045
+ * Weighted-without-replacement sampler. Samples `count` items from `items` without
1046
+ * replacement, with selection probability proportional to `weights`.
1047
+ *
1048
+ * @param {*[]} items
1049
+ * @param {number[]} weights
1050
+ * @param {number} count
1051
+ * @param {Object} chance - Seeded chance instance
1052
+ * @returns {*[]}
1053
+ */
1054
+ function weightedSampleNoReplacement(items, weights, count, chance) {
1055
+ if (count >= items.length) return items.slice();
1056
+ const pool = items.slice();
1057
+ const w = weights.slice();
1058
+ const result = [];
1059
+ for (let i = 0; i < count; i++) {
1060
+ const total = w.reduce((s, x) => s + x, 0);
1061
+ let chosen;
1062
+ if (total <= 0) {
1063
+ chosen = chance.integer({ min: 0, max: pool.length - 1 });
1064
+ } else {
1065
+ let roll = chance.floating({ min: 0, max: total });
1066
+ chosen = 0;
1067
+ for (let j = 0; j < w.length; j++) {
1068
+ roll -= w[j];
1069
+ if (roll <= 0) { chosen = j; break; }
1070
+ }
1071
+ }
1072
+ result.push(pool[chosen]);
1073
+ pool.splice(chosen, 1);
1074
+ w.splice(chosen, 1);
1075
+ }
1076
+ return result;
1077
+ }
1078
+
586
1079
  // ── Advanced Feature Helper Functions ──
587
1080
 
588
1081
  /**
589
- * Feature 3: Apply engagement decay to a user's events
1082
+ * Feature 3: Apply engagement decay to a user's events.
1083
+ *
1084
+ * Filters events probabilistically per the configured decay model
1085
+ * (`exponential` / `linear` / `step`). Late events drop more often.
1086
+ *
1087
+ * **v1.5 follow-up (`reccomendations-agent-1.md` Fix #1):** when
1088
+ * `pickedDayBuckets` is provided (active-day mode), the filter NEVER drops the
1089
+ * last surviving event on any picked day. Without this, decay can silently
1090
+ * undershoot the configured `avgActiveDaysPerUser` by killing all events on
1091
+ * sparse late days (e.g., exponential decay with short half-life). The protect-
1092
+ * last-event logic preserves the v1.5 distinct-day contract.
1093
+ *
1094
+ * @param {Object[]} events - User's events (mutated by filter; new array returned)
1095
+ * @param {Object} decay - `engagementDecay` config (model/halfLife/floor/etc.)
1096
+ * @param {*} userCreated - dayjs object or ISO string of user's creation time
1097
+ * @param {Object} context - Engine context (unused; kept for future use)
1098
+ * @param {Object} chance - Seeded chance instance
1099
+ * @param {Set<number> | null} [pickedDayBuckets=null] - From `buildActiveDayPlan`.
1100
+ * Each bucket = `Math.floor(timestamp_ms / 86400000)`. When non-null, the
1101
+ * filter protects the last surviving event on each bucket.
1102
+ * @returns {Object[]} Filtered events array
590
1103
  */
591
- function applyEngagementDecay(events, decay, userCreated, context, chance) {
1104
+ function applyEngagementDecay(events, decay, userCreated, context, chance, pickedDayBuckets = null) {
592
1105
  if (!events.length) return events;
593
1106
  // Perf 2: Use Date.parse instead of dayjs for hot loop
594
1107
  const userStartUnix = new Date(userCreated.toISOString ? userCreated.toISOString() : userCreated).getTime() / 1000;
@@ -597,6 +1110,23 @@ function applyEngagementDecay(events, decay, userCreated, context, chance) {
597
1110
  const reactivationChance = decay.reactivationChance || 0;
598
1111
  const reactivationMult = decay.reactivationMultiplier || 2.0;
599
1112
 
1113
+ // v1.5 Fix #1: pre-compute per-bucket counts so we can enforce "at least one
1114
+ // event survives on each picked day". Only counts events that fall within a
1115
+ // picked-day bucket (events spilled to other days don't count toward the
1116
+ // per-bucket survivor budget).
1117
+ const dayMs = 86400000;
1118
+ const dayCounts = new Map(); // bucket → remaining (mutable) event count
1119
+ if (pickedDayBuckets && pickedDayBuckets.size) {
1120
+ for (const ev of events) {
1121
+ const t = new Date(ev.time).getTime();
1122
+ if (!Number.isFinite(t)) continue;
1123
+ const bucket = Math.floor(t / dayMs);
1124
+ if (pickedDayBuckets.has(bucket)) {
1125
+ dayCounts.set(bucket, (dayCounts.get(bucket) || 0) + 1);
1126
+ }
1127
+ }
1128
+ }
1129
+
600
1130
  return events.filter(ev => {
601
1131
  const evUnix = new Date(ev.time).getTime() / 1000;
602
1132
  const daysSinceBirth = Math.max(0, (evUnix - userStartUnix) / 86400);
@@ -621,7 +1151,27 @@ function applyEngagementDecay(events, decay, userCreated, context, chance) {
621
1151
  retention = Math.min(1.0, retention * reactivationMult);
622
1152
  }
623
1153
 
624
- return chance.bool({ likelihood: retention * 100 });
1154
+ const keep = chance.bool({ likelihood: retention * 100 });
1155
+
1156
+ // v1.5 Fix #1: protect the last surviving event on each picked day.
1157
+ // Determinism preserved: no extra RNG calls, just a count check.
1158
+ if (pickedDayBuckets && pickedDayBuckets.size) {
1159
+ const bucket = Math.floor(new Date(ev.time).getTime() / dayMs);
1160
+ if (pickedDayBuckets.has(bucket)) {
1161
+ const remaining = dayCounts.get(bucket) || 0;
1162
+ if (!keep) {
1163
+ if (remaining <= 1) {
1164
+ // Last event on this picked day — protect it. Leave dayCounts
1165
+ // at 1 so subsequent events on this bucket can still drop.
1166
+ return true;
1167
+ }
1168
+ dayCounts.set(bucket, remaining - 1);
1169
+ }
1170
+ // If keep===true, no decrement — the survivor still counts.
1171
+ }
1172
+ }
1173
+
1174
+ return keep;
625
1175
  });
626
1176
  }
627
1177