@ak--47/dungeon-master 1.6.2 → 1.6.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.
package/HOOKS.md CHANGED
@@ -88,6 +88,58 @@ users between frequency bins. Both `injectOnNewDays` and the default
88
88
  `countDistinctPeriods` algorithm use calendar-bucket math, so they agree
89
89
  at boundaries.
90
90
 
91
+ One second is also below Mixpanel's 30-minute session gap, so the default
92
+ spread cannot create a new session either. The full list of metrics a default
93
+ `scaleEventCount` call **cannot** move: active days per user, DAU, stickiness
94
+ (DAU÷MAU), sessions per user, frequency bins, and retention. It moves event
95
+ volume, and nothing else.
96
+
97
+ As of v1.6.4 you can pass `{ spreadDays: N }` to scatter the clones across the
98
+ next N days instead:
99
+
100
+ ```js
101
+ // volume only — same day, same session
102
+ scaleEventCount(record, "commit pushed", 3);
103
+
104
+ // volume AND active days AND sessions
105
+ scaleEventCount(record, "commit pushed", 3, { spreadDays: 7 });
106
+ ```
107
+
108
+ `injectOnNewDays` remains the better tool when you want a specific target
109
+ day count rather than a multiplier.
110
+
111
+ ### 2.1.1 Cloned events MUST carry a fresh `insert_id`
112
+
113
+ Mixpanel deduplicates on `insert_id` at ingest. A clone that keeps its source's
114
+ `insert_id` is silently discarded — the surge you engineered never appears in the
115
+ project, no error is raised, and local verification does not catch it because
116
+ `emulateBreakdown` never inspects `insert_id`.
117
+
118
+ Leaving `insert_id` blank is not safe either. The importer content-hashes events
119
+ that lack one, so identical clones hash to the same value and collide the same way.
120
+
121
+ ```js
122
+ // PREFERRED — cloneEvent stamps a fresh insert_id
123
+ record.push(cloneEvent(sourceEvent, { time: newTime }));
124
+
125
+ // ALSO FINE — the engine re-stamps the duplicate id
126
+ record.push({ ...sourceEvent, time: newTime });
127
+
128
+ // HISTORICALLY BROKEN — pre-1.6.3 this deduped the whole surge away
129
+ const clone = JSON.parse(JSON.stringify(sourceEvent));
130
+ clone.time = newTime;
131
+ record.push(clone);
132
+ ```
133
+
134
+ Since v1.6.3 the engine re-stamps any duplicate or missing `insert_id` across each
135
+ user's final stream ([user-loop.js:753-776](lib/orchestrators/user-loop.js#L753-L776)),
136
+ so all three shapes now survive ingest. Prefer
137
+ [`cloneEvent`](lib/hook-helpers/mutate.js) anyway: the engine pass is a per-user
138
+ last resort, and it cannot help a clone that a hook moves onto a different user.
139
+
140
+ If you generated data with a hand-rolled deep-copy clone on **1.6.2 or earlier**,
141
+ that data is wrong in the project. Regenerate and re-import.
142
+
91
143
  ### 2.2 Funnels are GREEDY single-pass with a 2-second grace
92
144
 
93
145
  Mixpanel processes events in chronological order, single pass. Each event is
@@ -217,6 +269,15 @@ count BELOW the configured target. Pick one. If you need both effects, set
217
269
  `avgActiveDaysPerUser` and write decay logic in an `everything` hook scoped
218
270
  to specific cohorts (gives explicit control over the interaction).
219
271
 
272
+ As of v1.6.4 the validator warns **unconditionally** when both are set — the
273
+ warning is not gated behind `verbose`, because the combination silently returns
274
+ an active-day count the author did not ask for.
275
+
276
+ **Precedence with `retentionCurve`:** when `retentionCurve` is set, it wins.
277
+ The active-day planner runs from the curve and `avgActiveDaysPerUser` is ignored
278
+ ([user-loop.js:326-337](lib/orchestrators/user-loop.js#L326-L337)). Set one or
279
+ the other, not both.
280
+
220
281
  ### 2.6 Sessions are query-time computed (30-min gap, 24h max, day-boundary split)
221
282
 
222
283
  Reference: `backend/arb/reader/queries/session_query.cpp:828-830, 905-928`.
@@ -666,10 +727,12 @@ Reference: `flows_query.cpp:988-994` (next-anchor-only), `flows.cpp:680-717`
666
727
  still correct.
667
728
 
668
729
  22. **`scaleEventCount` does not move users between frequency bins.** Cloning
669
- Buy events at sub-second offsets places them on the same calendar day, so
670
- the user's distinct-day count is unchanged. To shift frequency bins use
730
+ Buy events at sub-second offsets places them on the same calendar day and
731
+ inside the same 30-minute session window, so distinct days, sessions, DAU,
732
+ stickiness, and retention are all unchanged. It moves event volume only. To
733
+ shift any of the others, pass `{ spreadDays: N }` (v1.6.4) or use
671
734
  [`injectOnNewDays`](lib/hook-helpers/inject.js), which spreads injections
672
- across previously empty days within the user's active window.
735
+ across previously empty days within the user's active window. See §2.1.
673
736
 
674
737
  23. **Out-of-order injected events get consumed by the funnel engine.** Adding
675
738
  a "step C" event before "step B" in the stream causes Mixpanel's greedy
@@ -1705,9 +1768,9 @@ Import from `@ak--47/dungeon-master/hook-helpers`:
1705
1768
  | `userInProfileSegment` | cohort | `(profile, key, values) -> boolean` | Profile property match |
1706
1769
  | **`hashFloat`** | cohort | `(id) -> number` | FNV-1a over the FULL id string → [0,1). Deterministic bucketing primitive (v1.6) — replaces `charCodeAt(0) % N` idioms, which bias cohort rates on hex-ish id alphabets |
1707
1770
  | **`hashCohort`** | cohort | `(id, pct) -> boolean` | True for ~`pct`% of ids (pct on a 0–100 scale). Membership nests: `pct=5` ⊂ `pct=20` |
1708
- | `cloneEvent` | mutate | `(template, overrides?) -> event` | Shallow clone with overrides |
1771
+ | `cloneEvent` | mutate | `(template, overrides?) -> event` | Shallow clone with overrides **and a fresh `insert_id`** — never hand-roll this (see Section 2.1) |
1709
1772
  | `dropEventsWhere` | mutate | `(events, predicate) -> number` | Remove matching events in-place |
1710
- | `scaleEventCount` | mutate | `(events, eventName, factor) -> number` | Scale total count via clones at sub-second offsets (does NOT move frequency-distribution bins — see Section 2.1) |
1773
+ | `scaleEventCount` | mutate | `(events, eventName, factor, options?) -> number` | Scale total count via clones. Default 1s offsets do NOT move frequency, session, active-day, or retention metrics — see Section 2.1. Pass `{ spreadDays: N }` (v1.6.4) or use `injectOnNewDays` |
1711
1774
  | `scalePropertyValue` | mutate | `(events, predicate, prop, factor) -> number` | Multiply numeric property; null-aware safe |
1712
1775
  | `shiftEventTime` | mutate | `(event, deltaMs) -> event` | Shift one timestamp |
1713
1776
  | `scaleTimingBetween` | timing | `(events, eventA, eventB, factor) -> boolean` | Scale gap between first A and first B |
package/README.md CHANGED
@@ -584,6 +584,15 @@ all randomness is seeded. same seed + same config + concurrency=1 = identical ou
584
584
  }
585
585
  ```
586
586
 
587
+ pin `datasetStart` and `datasetEnd` too, or the dataset window moves with the
588
+ calendar and every timestamp shifts.
589
+
590
+ **one exception: `insert_id`.** since 1.4.0 it is a `randomUUID()`, so it differs
591
+ on every run by design — that is what keeps Mixpanel from deduping re-imports of
592
+ the same dataset. strip `insert_id` before diffing two runs. everything else
593
+ (event count, order, timestamps, every property, profiles, groups) is
594
+ byte-identical.
595
+
587
596
  ## what gets generated
588
597
 
589
598
  the result object contains everything:
@@ -689,6 +698,53 @@ engine tests are NOT shipped in the npm package and NOT run as part of `npm test
689
698
 
690
699
  ## config reference
691
700
 
701
+ ### one config surface, not two
702
+
703
+ three groups of keys accept both a nested sub-object and a flat top-level form:
704
+
705
+ | sub-object | keys it groups |
706
+ |---|---|
707
+ | `credentials` | `token`, `region`, `serviceAccount`, `serviceSecret`, `projectId` |
708
+ | `switches` | `hasLocation`, `hasCampaigns`, `hasAdSpend`, `hasSessionIds`, `hasAvatar`, `hasIOSDevices`, `hasAndroidDevices`, `hasDesktopDevices`, `hasBrowser`, `isAnonymous`, `alsoInferFunnels` |
709
+ | `identity` | `avgDevicePerUser`, `sessionTimeout` |
710
+
711
+ **the sub-object form is canonical.** the flat top-level keys are a back-compat
712
+ alias and stay supported. emit one form or the other — never both. when a key is
713
+ set in both places the **top-level value wins**, with a `verbose`-gated warning
714
+ you will not see unless `verbose: true`.
715
+
716
+ ```javascript
717
+ // canonical
718
+ { credentials: { token: process.env.MIXPANEL_TOKEN, region: 'US' },
719
+ switches: { hasCampaigns: true, hasAdSpend: true },
720
+ identity: { avgDevicePerUser: 2 } }
721
+
722
+ // back-compat alias — still works
723
+ { token: process.env.MIXPANEL_TOKEN, region: 'US',
724
+ hasCampaigns: true, hasAdSpend: true, avgDevicePerUser: 2 }
725
+ ```
726
+
727
+ `hasAttributionFlags` is **not** a switch. the validator derives it from
728
+ `events[].isAttributionEvent`; setting it has no effect.
729
+
730
+ ### group keys
731
+
732
+ `groupKeys` accepts a positional tuple or a named object. both normalize to the
733
+ tuple internally, so hooks and the verifier see one shape:
734
+
735
+ ```javascript
736
+ groupKeys: [
737
+ ['company_id', 50], // tuple
738
+ ['team_id', 200, ['Deploy', 'Merge PR']], // tuple + scoped events
739
+ { key: 'org_id', cardinality: 25 }, // named (v1.6.4)
740
+ { key: 'workspace_id', cardinality: 80, events: ['Save'] },
741
+ ]
742
+ ```
743
+
744
+ an omitted or empty `events` list means every event carries that group key.
745
+
746
+ ### commonly used properties
747
+
692
748
  see [types.d.ts](types.d.ts) for the complete `Dungeon` interface. here are the most commonly used properties:
693
749
 
694
750
  | property | type | default | description |
@@ -702,7 +758,7 @@ see [types.d.ts](types.d.ts) for the complete `Dungeon` interface. here are the
702
758
  | `seed` | string | random | RNG seed for reproducibility |
703
759
  | `format` | string | `'csv'` | output format (csv, json, parquet) |
704
760
  | `token` | string | null | mixpanel project token (triggers import) |
705
- | `region` | string | `'US'` | mixpanel data residency |
761
+ | `region` | string | `'US'` | mixpanel data residency (`US` / `EU` / `IN`) |
706
762
  | `writeToDisk` | boolean/string | false | write files to ./data/ or a gs:// path |
707
763
  | `gzip` | boolean | false | compress output files |
708
764
  | `verbose` | boolean | false | print progress |
@@ -714,7 +770,8 @@ see [types.d.ts](types.d.ts) for the complete `Dungeon` interface. here are the
714
770
  | `bornRecentBias` | number | 0 (from macro `flat`) | user birth date skew (safe range [-0.5, 0.5]; user-explicit values outside the band are clamped) |
715
771
  | `percentUsersBornInDataset` | number | 12 (from macro `flat`) | % of users born in window (clamped per-macro when both `macro` and this field are explicit) |
716
772
  | `preExistingSpread` | string | `'uniform'` (from macro `flat`) | placement of pre-existing users' first event |
717
- | `avgActiveDaysPerUser` | number | undefined | concentrate events onto N distinct UTC days per user (preserves total event count) |
773
+ | `avgActiveDaysPerUser` | number | undefined | concentrate events onto N distinct UTC days per user (preserves total event count). ignored when `retentionCurve` is set; warns when combined with `engagementDecay` |
774
+ | `retentionCurve` | object | undefined | per-day return probabilities. **wins over `avgActiveDaysPerUser`** when both are set |
718
775
  | `maxTouchpointsPerUser` | number | 10 | UTM stamping cap per user (Mixpanel `TOUCHPOINTS_LIMIT` parity) |
719
776
  | `autoSortAfterEverything` | boolean | true | sort events by time after `everything` hook (defends greedy funnel engine) |
720
777
  | `hook` | function/string | passthrough | data transformation function |
package/index.js CHANGED
@@ -347,7 +347,10 @@ async function generateAdSpendData(context) {
347
347
  */
348
348
  async function generateGroupProfiles(context) {
349
349
  const { config, storage } = context;
350
- const { groupKeys, groupProps = {} } = config;
350
+ // `config` here is the VALIDATED config, so `normalizeGroupKeys` has already
351
+ // converted any `{ key, cardinality }` entries to positional tuples.
352
+ const groupKeys = /** @type {import('./types').GroupKeyTuple[]} */ (config.groupKeys);
353
+ const { groupProps = {} } = config;
351
354
 
352
355
  if (config.verbose) {
353
356
  logger.info('Generating group profiles...');
@@ -430,7 +433,9 @@ async function generateLookupTables(context) {
430
433
  */
431
434
  async function generateGroupSCDs(context) {
432
435
  const { config, storage } = context;
433
- const { scdProps, groupKeys } = config;
436
+ const { scdProps } = config;
437
+ // Validated config: `normalizeGroupKeys` has already flattened named entries.
438
+ const groupKeys = /** @type {import('./types').GroupKeyTuple[]} */ (config.groupKeys);
434
439
 
435
440
  if (config.verbose) {
436
441
  logger.info('Generating group SCDs...');
@@ -181,7 +181,11 @@ const KILLED_CONFIG_KEYS = ['subscription', 'attribution', 'geo', 'features', 'a
181
181
  // v1.5.1 — migration is gradual.
182
182
  const CONFIG_SUBOBJECTS = {
183
183
  credentials: ['token', 'region', 'serviceAccount', 'serviceSecret', 'projectId'],
184
- switches: ['hasLocation', 'hasCampaigns', 'hasAdSpend', 'hasSessionIds', 'hasAvatar', 'hasIOSDevices', 'hasAndroidDevices', 'hasDesktopDevices', 'hasBrowser', 'isAnonymous', 'alsoInferFunnels', 'hasAttributionFlags'],
184
+ // v1.6.4: `hasAttributionFlags` removed from this list. It is derived, not
185
+ // settable — line ~942 unconditionally overwrites it with
186
+ // `validatedEvents.some(e => e.isAttributionEvent)`. Listing it here presented
187
+ // a knob that never did anything.
188
+ switches: ['hasLocation', 'hasCampaigns', 'hasAdSpend', 'hasSessionIds', 'hasAvatar', 'hasIOSDevices', 'hasAndroidDevices', 'hasDesktopDevices', 'hasBrowser', 'isAnonymous', 'alsoInferFunnels'],
185
189
  identity: ['avgDevicePerUser', 'sessionTimeout'],
186
190
  };
187
191
 
@@ -744,6 +748,23 @@ export function validateDungeonConfig(config) {
744
748
  if (verbose && numDays < 14) {
745
749
  console.warn(`⚠️ numDays=${numDays} < 14. Strict-bar engine-validation metrics use 14-day windows; results are noisy below 14 days. Consider increasing numDays.`);
746
750
  }
751
+
752
+ // v1.6.4 (P3-9): warn when `avgActiveDaysPerUser` and `engagementDecay` are both
753
+ // set. The active-day planner picks a fixed number of distinct days; decay then
754
+ // filters events off the late ones. v1.5 Fix #1 protects the last surviving event
755
+ // on each picked day, so the loss is bounded — but the realized distinct-day count
756
+ // still lands at or below the configured value, never above. This warning is NOT
757
+ // verbose-gated: the combination quietly returns a number the author did not ask for.
758
+ if (
759
+ avgActiveDaysPerUser !== undefined && avgActiveDaysPerUser !== null &&
760
+ Number.isFinite(avgActiveDaysPerUser) && config.engagementDecay
761
+ ) {
762
+ console.warn(
763
+ `⚠️ [dungeon-master] avgActiveDaysPerUser=${avgActiveDaysPerUser} is set together with engagementDecay. ` +
764
+ `Decay erodes the effective active-day count, so the realized value will be at or below ` +
765
+ `${avgActiveDaysPerUser}, never above. Prefer one knob or the other. See HOOKS.md §2.5.`
766
+ );
767
+ }
747
768
  // ──────────────────────────────────────────────────────────────────────
748
769
 
749
770
  // Use provided name if non-empty string, otherwise generate one
@@ -909,6 +930,9 @@ export function validateDungeonConfig(config) {
909
930
  dataQuality = validateDataQuality(dataQuality);
910
931
  }
911
932
 
933
+ // v1.6.4: accept the named-object groupKeys form alongside the legacy tuple.
934
+ const normalizedGroupKeys = normalizeGroupKeys(groupKeys);
935
+
912
936
  // Phase 1: validate Funnel.attempts on every funnel (additive — most have none).
913
937
  validateAttempts(funnels);
914
938
 
@@ -971,7 +995,7 @@ export function validateDungeonConfig(config) {
971
995
  userProps,
972
996
  scdProps,
973
997
  mirrorProps,
974
- groupKeys,
998
+ groupKeys: normalizedGroupKeys,
975
999
  groupProps,
976
1000
  lookupTables,
977
1001
  hasAnonIds: hasAnonIdsResolved,
@@ -1104,6 +1128,43 @@ function transformSCDPropsWithoutCredentials(config) {
1104
1128
  if (config.verbose === true) console.log('\u2713 SCD properties converted to static properties\n');
1105
1129
  }
1106
1130
 
1131
+ /**
1132
+ * v1.6.4: normalize `groupKeys` to the positional tuple form the generators,
1133
+ * hooks, and verifier all expect.
1134
+ *
1135
+ * Accepts either form, mixed freely in one array:
1136
+ * - `["company_id", 50]` → unchanged
1137
+ * - `["company_id", 50, ["Purchase"]]` → unchanged
1138
+ * - `{ key: "company_id", cardinality: 50 }` → `["company_id", 50]`
1139
+ * - `{ key, cardinality, events: [...] }` → `[key, cardinality, events]`
1140
+ *
1141
+ * The named form exists so a form-driven generator never has to emit an untyped
1142
+ * positional tuple. Nothing downstream needs to know which form the author used.
1143
+ *
1144
+ * @param {Array} groupKeys
1145
+ * @returns {Array} tuple-form group keys
1146
+ */
1147
+ function normalizeGroupKeys(groupKeys) {
1148
+ if (!Array.isArray(groupKeys)) return [];
1149
+ return groupKeys.map((entry, i) => {
1150
+ if (Array.isArray(entry)) return entry;
1151
+ if (entry && typeof entry === 'object') {
1152
+ const { key, cardinality, events } = entry;
1153
+ if (typeof key !== 'string' || !key) {
1154
+ throw new Error(`groupKeys[${i}]: named form requires a non-empty string "key" (got ${JSON.stringify(key)})`);
1155
+ }
1156
+ if (!Number.isFinite(cardinality) || cardinality < 1) {
1157
+ throw new Error(`groupKeys[${i}] ("${key}"): named form requires a "cardinality" >= 1 (got ${JSON.stringify(cardinality)})`);
1158
+ }
1159
+ if (events !== undefined && !Array.isArray(events)) {
1160
+ throw new Error(`groupKeys[${i}] ("${key}"): "events" must be an array of event names when present (got ${typeof events})`);
1161
+ }
1162
+ return events && events.length ? [key, cardinality, events] : [key, cardinality];
1163
+ }
1164
+ throw new Error(`groupKeys[${i}]: expected a [key, cardinality] tuple or a { key, cardinality } object (got ${typeof entry})`);
1165
+ });
1166
+ }
1167
+
1107
1168
  // ── Advanced Feature Validation Functions ──
1108
1169
 
1109
1170
  // P2.5 (v1.6): churnRate / activeWindow / soupOverride are declared Persona
@@ -376,7 +376,10 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
376
376
  if (Number.isFinite(lastTimeMs)) {
377
377
  const IDENTITY_KEYS = ['user_id', 'device_id', 'distinct_id', 'session_id', 'insert_id'];
378
378
  const superPropKeys = Object.keys(superProps || {});
379
- const groupKeyNames = (groupKeys || []).map(gk => Array.isArray(gk) ? gk[0] : gk).filter(Boolean);
379
+ // Validated config: group keys are always positional tuples here.
380
+ const groupKeyNames = /** @type {string[]} */ (
381
+ (groupKeys || []).map(gk => Array.isArray(gk) ? gk[0] : gk).filter(gk => typeof gk === 'string')
382
+ );
380
383
  const numToInject = chance.integer({ min: 1, max: 2 });
381
384
  for (let i = 0; i < numToInject; i++) {
382
385
  const excName = chance.pickone(funnel.exclusionEvents);
@@ -1,3 +1,48 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ /**
4
+ * Give a cloned event its own `insert_id`.
5
+ *
6
+ * Clones MUST NOT inherit their template's id, and stripping the id is not
7
+ * enough either: mixpanel-import runs with `fixData: true`, which synthesizes a
8
+ * missing id by content-hashing the record — so two clones that differ only in
9
+ * ways the hash ignores collapse into one on ingest. Mixpanel then dedupes them
10
+ * server-side and the engineered volume silently disappears from the project
11
+ * while local verification (which never looks at `insert_id`) still passes.
12
+ *
13
+ * `insert_id` is already assigned via `randomUUID()` in the event generator
14
+ * (`lib/generators/events.js`), so it sits outside the seeded
15
+ * byte-identical-output guarantee; using it here keeps clone ids consistent with
16
+ * engine-generated ones.
17
+ *
18
+ * @template {{insert_id?: string}} T
19
+ * @param {T} clone
20
+ * @returns {T} the same object, with a fresh `insert_id`
21
+ */
22
+ export function stampFreshInsertId(clone) {
23
+ clone.insert_id = randomUUID();
24
+ return clone;
25
+ }
26
+
27
+ /**
28
+ * Spread a template into a new event, merge `overrides`, and give it a fresh
29
+ * `insert_id` — unless the caller pinned one explicitly.
30
+ *
31
+ * Every clone site should go through this so the override contract is uniform:
32
+ * a caller that passes `insert_id` in `overrides` keeps it, and everyone else
33
+ * gets a unique id rather than a duplicate of the template's.
34
+ *
35
+ * @template {Record<string, any>} T
36
+ * @param {T} template - Event to clone from.
37
+ * @param {Record<string, any>} [overrides] - Fields to merge on top.
38
+ * @returns {T} the new event
39
+ */
40
+ export function cloneWithFreshId(template, overrides = {}) {
41
+ const clone = /** @type {T} */ ({ ...template, ...overrides });
42
+ if (!(overrides && 'insert_id' in overrides)) stampFreshInsertId(clone);
43
+ return clone;
44
+ }
45
+
1
46
  export function toMs(t) {
2
47
  if (typeof t === 'number') return t > 1e12 ? t : t > 1e9 ? t * 1000 : t;
3
48
  return Date.parse(t);
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { getChance } from '../utils/utils.js';
13
- import { toMs, writeTime } from './_internal.js';
13
+ import { toMs, writeTime, stampFreshInsertId, cloneWithFreshId } from './_internal.js';
14
14
 
15
15
  /**
16
16
  * Splice a cloned event into `events` immediately after `sourceEvent`. Time is
@@ -29,7 +29,7 @@ export function injectAfterEvent(events, sourceEvent, templateEvent, gapMs, over
29
29
  if (!events || !sourceEvent || !templateEvent) return null;
30
30
  const baseT = toMs(sourceEvent.time);
31
31
  if (!Number.isFinite(baseT)) return null;
32
- const newEv = { ...templateEvent, ...overrides };
32
+ const newEv = cloneWithFreshId(templateEvent, overrides);
33
33
  writeTime(newEv, baseT + gapMs);
34
34
  const idx = events.indexOf(sourceEvent);
35
35
  if (idx >= 0) events.splice(idx + 1, 0, newEv);
@@ -60,7 +60,7 @@ export function injectBetween(events, eventA, eventB, templateEvent, overrides =
60
60
  const aT = toMs(a.time);
61
61
  const bT = toMs(b.time);
62
62
  if (!Number.isFinite(aT) || !Number.isFinite(bT)) return null;
63
- const newEv = { ...templateEvent, ...overrides };
63
+ const newEv = cloneWithFreshId(templateEvent, overrides);
64
64
  writeTime(newEv, (aT + bT) / 2);
65
65
  const bIdxOrig = events.indexOf(b);
66
66
  if (bIdxOrig >= 0) events.splice(bIdxOrig, 0, newEv);
@@ -89,7 +89,7 @@ export function injectBurst(events, templateEvent, count, anchorTime, spreadMs,
89
89
  const created = [];
90
90
  for (let i = 0; i < count; i++) {
91
91
  const offset = chance.floating({ min: -spreadMs, max: spreadMs });
92
- const newEv = { ...templateEvent, ...overrides };
92
+ const newEv = cloneWithFreshId(templateEvent, overrides);
93
93
  writeTime(newEv, anchorMs + offset);
94
94
  events.push(newEv);
95
95
  created.push(newEv);
@@ -119,7 +119,8 @@ const DAY_MS = 86400000;
119
119
  * 5. Find a template event of `eventName`; if none exist for this user,
120
120
  * return unchanged (we honor schema-first: don't fabricate events).
121
121
  * 6. Clone the template onto each picked day at a random hour within the
122
- * day. `insert_id` is stripped (Mixpanel re-deduplicates on import).
122
+ * day. Each clone gets a FRESH `insert_id` (inheriting or omitting it lets
123
+ * Mixpanel dedupe the clones away on ingest).
123
124
  * 7. Append clones to the array. Caller's downstream sort handles ordering.
124
125
  *
125
126
  * @param {Object[]} events - Full user event array (from `everything` hook).
@@ -183,9 +184,8 @@ export function injectOnNewDays(events, eventName, targetDays, options = {}) {
183
184
  const lo = Math.max(dayStart, minMs);
184
185
  const hi = Math.min(dayEnd, maxMs);
185
186
  const newMs = lo >= hi ? lo : chance.integer({ min: lo, max: hi });
186
- const clone = { ...template, ...overrides };
187
+ const clone = cloneWithFreshId(template, overrides);
187
188
  writeTime(clone, newMs);
188
- delete clone.insert_id;
189
189
  events.push(clone);
190
190
  }
191
191
 
@@ -8,7 +8,7 @@
8
8
  * `Math.random()` — so dungeon runs stay reproducible.
9
9
  */
10
10
 
11
- import { toMs } from './_internal.js';
11
+ import { toMs, stampFreshInsertId, cloneWithFreshId } from './_internal.js';
12
12
 
13
13
  import { getChance } from '../utils/utils.js';
14
14
 
@@ -25,7 +25,9 @@ import { getChance } from '../utils/utils.js';
25
25
  */
26
26
  export function cloneEvent(template, overrides = {}) {
27
27
  if (!template) throw new Error('cloneEvent: template is required');
28
- return /** @type {T} */ ({ ...template, ...overrides });
28
+ // A clone that keeps its template's insert_id is deduped away by Mixpanel on
29
+ // ingest. Callers may still pin one explicitly via overrides.
30
+ return /** @type {T} */ (cloneWithFreshId(template, overrides));
29
31
  }
30
32
 
31
33
  /**
@@ -51,35 +53,69 @@ export function dropEventsWhere(events, predicate) {
51
53
  * Scale the count of events with name `eventName` in `events` by `factor`. Mutates
52
54
  * `events` in place.
53
55
  *
54
- * - factor > 1: clones existing matches with small monotonic time offsets (1s steps)
55
- * so duplicates land just after their source. Returns positive integer = clones added.
56
+ * - factor > 1: clones existing matches. Returns positive integer = clones added.
56
57
  * - factor < 1: drops matches at random using the seeded RNG. Returns negative
57
58
  * integer = -dropped.
58
59
  * - factor === 1 or no matches: no-op, returns 0.
59
60
  *
60
- * Note: the `insert_id` of cloned events is removed so a downstream pass can
61
- * regenerate it (otherwise Mixpanel will dedupe on import).
61
+ * ## WARNING the default spread cannot move frequency or session metrics
62
+ *
63
+ * By default clones land 1 second after their source (monotonic 1s steps). One
64
+ * second is not enough to create a new distinct active day, and not enough to
65
+ * open a new session under Mixpanel's 30-minute inactivity gap. So a default
66
+ * `scaleEventCount` call raises **event volume only**. It does NOT raise:
67
+ *
68
+ * - active days per user / DAU / stickiness (DAU÷MAU)
69
+ * - sessions per user or session count
70
+ * - "days active" or frequency-of-use reports
71
+ * - retention, which is computed on distinct days
72
+ *
73
+ * If your story claims any of those, pass `{ spreadDays: N }` to scatter clones
74
+ * across the following N days, or use `injectOnNewDays` — the helper built for
75
+ * exactly this.
76
+ *
77
+ * Note: cloned events get a FRESH `insert_id` — they must not inherit the
78
+ * source's, and leaving it blank is not safe either (the importer content-hashes
79
+ * missing ids, so identical clones collide and Mixpanel dedupes them away).
62
80
  *
63
81
  * @param {Array<{event:string,time:string|number,insert_id?:string}>} events
64
82
  * @param {string} eventName
65
83
  * @param {number} factor
84
+ * @param {{spreadDays?: number}} [options] - `spreadDays`: scatter clones uniformly
85
+ * over `[source_time, source_time + spreadDays]` instead of stepping 1s at a time.
86
+ * Use this when the clones must land on new calendar days or in new sessions.
87
+ * Clones that land past `FIXED_NOW` are dropped by the engine's future-time guard,
88
+ * so a large `spreadDays` on late-window events yields fewer surviving clones than
89
+ * the return value reports.
66
90
  * @returns {number}
67
91
  */
68
- export function scaleEventCount(events, eventName, factor) {
92
+ export function scaleEventCount(events, eventName, factor, options = {}) {
69
93
  if (!events || !eventName || typeof factor !== 'number' || factor === 1) return 0;
94
+ const { spreadDays } = options || {};
95
+ const spreadMs = (Number.isFinite(spreadDays) && spreadDays > 0)
96
+ ? spreadDays * 86_400_000
97
+ : 0;
70
98
  if (factor > 1) {
71
99
  const matches = events.filter(e => e && e.event === eventName);
72
100
  if (!matches.length) return 0;
73
101
  const additionalNeeded = Math.round(matches.length * (factor - 1));
102
+ const chance = spreadMs ? getChance() : null;
74
103
  let added = 0;
75
104
  for (let i = 0; i < additionalNeeded; i++) {
76
105
  const src = matches[i % matches.length];
77
106
  const baseMs = toMs(src.time);
78
- const newTime = Number.isFinite(baseMs)
79
- ? new Date(baseMs + (i + 1) * 1000).toISOString()
80
- : src.time;
81
- const clone = { ...src, time: newTime };
82
- delete clone.insert_id;
107
+ let newTime;
108
+ if (!Number.isFinite(baseMs)) {
109
+ newTime = src.time;
110
+ } else if (spreadMs) {
111
+ // Seeded uniform offset across the spread window. Floor at 1s so a
112
+ // clone never collides exactly with its source's timestamp.
113
+ const offset = Math.max(1000, Math.round(chance.floating({ min: 0, max: spreadMs })));
114
+ newTime = new Date(baseMs + offset).toISOString();
115
+ } else {
116
+ newTime = new Date(baseMs + (i + 1) * 1000).toISOString();
117
+ }
118
+ const clone = stampFreshInsertId({ ...src, time: newTime });
83
119
  events.push(clone);
84
120
  added++;
85
121
  }
@@ -6,13 +6,13 @@
6
6
  * (HOOKS.md §2.16), a biased Flows path branch (§2.17), and a deterministic
7
7
  * session cadence (§2.13). All three are `everything`-hook-only — they need
8
8
  * the whole stream — and obey the schema-first rules: clones only (spread
9
- * from the user's own events, `insert_id` stripped), no fabricated events,
9
+ * from the user's own events, each given a fresh `insert_id`), no fabricated events,
10
10
  * seeded `chance` for all randomness. Timestamp rewrites are safe because
11
11
  * the engine re-derives `session_id` on the final event set (v1.6 P2.1).
12
12
  */
13
13
 
14
14
  import { getChance } from '../utils/utils.js';
15
- import { toMs, writeTime } from './_internal.js';
15
+ import { toMs, writeTime, stampFreshInsertId } from './_internal.js';
16
16
  import { hashFloat } from './cohort.js';
17
17
 
18
18
  const DAY_MS = 86400000;
@@ -100,7 +100,7 @@ export function applyLifecycleWave(events, uid, opts) {
100
100
  for (let i = 0; i < resurrectBurst; i++) {
101
101
  const clone = { ...template };
102
102
  writeTime(clone, t);
103
- delete clone.insert_id;
103
+ stampFreshInsertId(clone);
104
104
  if (!clone.user_id && uid) clone.user_id = uid;
105
105
  kept.push(clone);
106
106
  t += chance.integer({ min: MIN_MS, max: 10 * MIN_MS });
@@ -168,7 +168,7 @@ export function applyPathBias(events, uid, opts) {
168
168
  t += chance.integer({ min: lo, max: hi }) * 1000;
169
169
  const clone = { ...tpl };
170
170
  writeTime(clone, t);
171
- delete clone.insert_id;
171
+ stampFreshInsertId(clone);
172
172
  if (!clone.user_id && uid) clone.user_id = uid;
173
173
  events.push(clone);
174
174
  }
@@ -16,8 +16,8 @@
16
16
  * - Operates on the user's full event stream — call from the `everything` hook.
17
17
  * - Does NOT add new properties; uses existing event names defined in the dungeon
18
18
  * schema.
19
- * - Cloned events have their `insert_id` stripped (mutate.scaleEventCount handles
20
- * that), so the engine's batch writer can re-stamp them downstream.
19
+ * - Cloned events get a fresh `insert_id` (mutate.scaleEventCount handles that),
20
+ * so Mixpanel does not dedupe them away on ingest.
21
21
  */
22
22
 
23
23
  import { binUsersByEventCount } from '../hook-helpers/cohort.js';
@@ -750,6 +750,33 @@ export async function userLoop(context) {
750
750
  });
751
751
  }
752
752
 
753
+ // v1.6.3: guarantee unique `insert_id` across the user's final stream.
754
+ //
755
+ // Hooks clone events by spreading an existing one — the documented way
756
+ // to inject — and a spread copies `insert_id` along with everything
757
+ // else. Mixpanel deduplicates on `insert_id` at ingest, so those clones
758
+ // are accepted, reported as successful, and then silently dropped: the
759
+ // engineered volume never appears in the project. Nothing downstream
760
+ // catches it, because local verification never inspects `insert_id`.
761
+ //
762
+ // The hook-helper clone atoms stamp fresh ids themselves, but hooks are
763
+ // free to hand-roll a spread (and many shipped dungeons do), so the
764
+ // only reliable place to enforce this is here, over the finished set.
765
+ // A legitimate stream never contains two events with the same id, so
766
+ // any collision at this point is a clone that needs its own id.
767
+ // `> 0`, not `> 1`: a lone event cannot collide, but it can still be
768
+ // missing an id if a hook replaced it with a constructed object.
769
+ if (usersEvents.length > 0) {
770
+ const seenInsertIds = new Set();
771
+ for (const ev of usersEvents) {
772
+ if (!ev) continue;
773
+ if (!ev.insert_id || seenInsertIds.has(ev.insert_id)) {
774
+ ev.insert_id = randomUUID();
775
+ }
776
+ seenInsertIds.add(ev.insert_id);
777
+ }
778
+ }
779
+
753
780
  // Defensive guard: drop any events whose timestamp landed past the
754
781
  // configured dataset end. Hooks that duplicate events with time offsets
755
782
  // (weekend surges, viral spreads) can leak a few past the boundary.