@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/.claude/skills/create-project/provision.mjs +16 -3
- package/.claude/skills/headless-build/SKILL.md +324 -0
- package/.claude/skills/powertools/SKILL.md +21 -1
- package/CHANGELOG.md +170 -0
- package/HOOKS.md +68 -5
- package/README.md +59 -2
- package/index.js +7 -2
- package/lib/core/config-validator.js +63 -2
- package/lib/generators/funnels.js +4 -1
- package/lib/hook-helpers/_internal.js +45 -0
- package/lib/hook-helpers/inject.js +7 -7
- package/lib/hook-helpers/mutate.js +48 -12
- package/lib/hook-helpers/shape.js +4 -4
- package/lib/hook-patterns/frequency-by-frequency.js +2 -2
- package/lib/orchestrators/user-loop.js +27 -0
- package/lib/templates/abbreviated.d.ts +7 -25
- package/lib/templates/schema.d.ts +10 -31
- package/lib/utils/utils.js +0 -1
- package/package.json +2 -2
- package/types.d.ts +74 -25
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
|
|
670
|
-
the
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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`
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
61
|
-
*
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
20
|
-
*
|
|
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.
|