@ak--47/dungeon-master 1.5.3 → 1.6.0
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/analyze-soup/SKILL.md +21 -12
- package/.claude/skills/create-dungeon/SKILL.md +65 -5
- package/.claude/skills/create-project/SKILL.md +96 -0
- package/.claude/skills/create-project/provision.mjs +336 -0
- package/.claude/skills/verify-dungeon/SKILL.md +56 -25
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
- package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
- package/.claude/skills/write-hooks/SKILL.md +156 -18
- package/CHANGELOG.md +211 -0
- package/HOOKS.md +566 -112
- package/README.md +28 -9
- package/docs/guides/1.3.0-upgrade-guide.md +262 -0
- package/docs/guides/1.3.1-upgrade-guide.md +328 -0
- package/docs/guides/1.4.0-upgrade-guide.md +130 -0
- package/docs/guides/1.4.1-upgrade-guide.md +125 -0
- package/docs/guides/1.4.3-upgrade-guide.md +200 -0
- package/docs/guides/1.4.4-upgrade-guide.md +95 -0
- package/docs/guides/1.5.0-upgrade-guide.md +426 -0
- package/docs/guides/1.5.1-upgrade-guide.md +178 -0
- package/docs/guides/1.5.3-upgrade-guide.md +117 -0
- package/docs/guides/1.6.0-upgrade-guide.md +204 -0
- package/dungeons/technical/stories-verify.js +152 -0
- package/dungeons/vertical/README.md +154 -0
- package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
- package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
- package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
- package/dungeons/vertical/community/community.js +1589 -0
- package/dungeons/vertical/community/community.sql +216 -0
- package/dungeons/vertical/community/community.verify.mjs +72 -0
- package/dungeons/vertical/crypto/crypto.js +1483 -0
- package/dungeons/vertical/crypto/crypto.sql +194 -0
- package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
- package/dungeons/vertical/dating/dating.js +1540 -0
- package/dungeons/vertical/dating/dating.sql +288 -0
- package/dungeons/vertical/dating/dating.verify.mjs +72 -0
- package/dungeons/vertical/devtools/devtools.js +1524 -0
- package/dungeons/vertical/devtools/devtools.sql +161 -0
- package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
- package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
- package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
- package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
- package/dungeons/vertical/education/education.js +1763 -0
- package/dungeons/vertical/education/education.sql +243 -0
- package/dungeons/vertical/education/education.verify.mjs +75 -0
- package/dungeons/vertical/fintech/fintech.js +1410 -0
- package/dungeons/vertical/fintech/fintech.sql +175 -0
- package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
- package/dungeons/vertical/fitness/fitness.js +1495 -0
- package/dungeons/vertical/fitness/fitness.sql +206 -0
- package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
- package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
- package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
- package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
- package/dungeons/vertical/gaming/gaming.js +1741 -0
- package/dungeons/vertical/gaming/gaming.sql +249 -0
- package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
- package/dungeons/vertical/healthcare/healthcare.js +1488 -0
- package/dungeons/vertical/healthcare/healthcare.sql +193 -0
- package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
- package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
- package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
- package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
- package/dungeons/vertical/logistics/logistics.js +1541 -0
- package/dungeons/vertical/logistics/logistics.sql +205 -0
- package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
- package/dungeons/vertical/marketplace/marketplace.js +1438 -0
- package/dungeons/vertical/marketplace/marketplace.sql +148 -0
- package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
- package/dungeons/vertical/{media.js → media/media.js} +557 -58
- package/dungeons/vertical/media/media.sql +173 -0
- package/dungeons/vertical/media/media.verify.mjs +74 -0
- package/dungeons/vertical/real-estate/real-estate.js +1629 -0
- package/dungeons/vertical/real-estate/real-estate.sql +167 -0
- package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
- package/dungeons/vertical/sass/sass.js +1733 -0
- package/dungeons/vertical/sass/sass.sql +173 -0
- package/dungeons/vertical/sass/sass.verify.mjs +74 -0
- package/dungeons/vertical/social/social.js +1456 -0
- package/dungeons/vertical/social/social.sql +160 -0
- package/dungeons/vertical/social/social.verify.mjs +76 -0
- package/dungeons/vertical/streaming/streaming.js +677 -0
- package/dungeons/vertical/streaming/streaming.sql +101 -0
- package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
- package/dungeons/vertical/support-desk/support-desk.js +741 -0
- package/dungeons/vertical/support-desk/support-desk.sql +137 -0
- package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
- package/dungeons/vertical/travel/travel.js +1603 -0
- package/dungeons/vertical/travel/travel.sql +236 -0
- package/dungeons/vertical/travel/travel.verify.mjs +72 -0
- package/index.js +2 -2
- package/lib/core/config-validator.js +37 -2
- package/lib/core/context.js +4 -2
- package/lib/core/dungeon-loader.js +20 -15
- package/lib/core/dungeon-to-json.js +22 -4
- package/lib/core/storage.js +3 -3
- package/lib/generators/funnels.js +36 -9
- package/lib/hook-helpers/cohort.js +53 -0
- package/lib/hook-helpers/index.js +8 -0
- package/lib/hook-helpers/mutate.js +1 -1
- package/lib/hook-helpers/shape.js +319 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
- package/lib/hook-patterns/attributed-by-source.js +60 -58
- package/lib/hook-patterns/frequency-by-frequency.js +14 -4
- package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
- package/lib/hook-patterns/index.js +1 -1
- package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
- package/lib/orchestrators/user-loop.js +41 -0
- package/lib/templates/story-spec.schema.json +175 -0
- package/lib/utils/json-evaluator.js +1 -1
- package/lib/utils/utils.js +5 -4
- package/lib/verify/coerce.js +186 -0
- package/lib/verify/counting.js +270 -32
- package/lib/verify/emulate-breakdown.js +1285 -203
- package/lib/verify/first-time.js +89 -0
- package/lib/verify/flows.js +679 -0
- package/lib/verify/formula.js +259 -0
- package/lib/verify/funnel-engine.js +697 -113
- package/lib/verify/index.js +32 -4
- package/lib/verify/sessionize.js +275 -0
- package/lib/verify/story-runner.js +483 -0
- package/lib/verify/verify-dungeon.js +59 -37
- package/package.json +3 -2
- package/scripts/extract-dungeon-schema.mjs +2 -2
- package/scripts/run-many.mjs +2 -2
- package/scripts/verify-runner.mjs +2 -2
- package/scripts/verify-stories.mjs +244 -0
- package/types.d.ts +296 -30
- package/dungeons/vertical/ai-platform.js +0 -818
- package/dungeons/vertical/community.js +0 -966
- package/dungeons/vertical/crypto.js +0 -895
- package/dungeons/vertical/dating.js +0 -830
- package/dungeons/vertical/devtools.js +0 -1050
- package/dungeons/vertical/ecommerce.js +0 -811
- package/dungeons/vertical/education.js +0 -964
- package/dungeons/vertical/fintech.js +0 -946
- package/dungeons/vertical/fitness.js +0 -935
- package/dungeons/vertical/food-delivery.js +0 -934
- package/dungeons/vertical/gaming.js +0 -1120
- package/dungeons/vertical/healthcare.js +0 -945
- package/dungeons/vertical/insurance-application.js +0 -1039
- package/dungeons/vertical/logistics.js +0 -986
- package/dungeons/vertical/marketplace.js +0 -1019
- package/dungeons/vertical/real-estate.js +0 -872
- package/dungeons/vertical/sass.js +0 -995
- package/dungeons/vertical/social.js +0 -904
- package/dungeons/vertical/travel.js +0 -930
|
@@ -59,7 +59,9 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
59
59
|
funnel = { ...funnel };
|
|
60
60
|
|
|
61
61
|
// Experiment handling: resolved by config-validator into funnel._experiment.
|
|
62
|
-
//
|
|
62
|
+
// Sticky (default): variant is a deterministic per-user hash of
|
|
63
|
+
// userId + experiment name — same variant on every pass. sticky: false
|
|
64
|
+
// re-rolls the variant per funnel pass via the seeded RNG.
|
|
63
65
|
let experimentVariant = null;
|
|
64
66
|
let experimentName = null;
|
|
65
67
|
let experimentMeta = null;
|
|
@@ -79,7 +81,9 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
79
81
|
experimentName = expCfg.name;
|
|
80
82
|
const userId = user.distinct_id || '';
|
|
81
83
|
const totalWeight = expCfg.variants.reduce((s, v) => s + v.weight, 0);
|
|
82
|
-
const hashVal =
|
|
84
|
+
const hashVal = expCfg.sticky === false
|
|
85
|
+
? chance.integer({ min: 0, max: totalWeight - 1 })
|
|
86
|
+
: Number(u.quickHash(`${userId}:${experimentName}`)) % totalWeight;
|
|
83
87
|
let cumWeight = 0;
|
|
84
88
|
let chosenVariant = expCfg.variants[0];
|
|
85
89
|
let chosenIdx = 0;
|
|
@@ -234,13 +238,25 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
234
238
|
funnelStepsUserWillTake = processedEvents.slice(0, Math.max(0, numStepsUserWillTake));
|
|
235
239
|
}
|
|
236
240
|
|
|
237
|
-
// Apply ordering strategy
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
241
|
+
// Apply ordering strategy. When experiment mode prepended $experiment_started,
|
|
242
|
+
// shuffle only the real funnel steps: the exposure event must precede every
|
|
243
|
+
// step it governs (Mixpanel experiment semantics — variant lift is measured
|
|
244
|
+
// exposure → outcome), and strategies like first-fixed should pin the true
|
|
245
|
+
// first step, not the synthetic exposure marker. Pre-v1.6 the whole array was
|
|
246
|
+
// shuffled, which landed $experiment_started mid-funnel for non-sequential
|
|
247
|
+
// orders and truncated the measured exposure→conversion TTC.
|
|
248
|
+
let funnelActualOrder;
|
|
249
|
+
if (expName && funnelStepsUserWillTake.length > 1) {
|
|
250
|
+
const [exposureEvent, ...realSteps] = funnelStepsUserWillTake;
|
|
251
|
+
funnelActualOrder = [exposureEvent, ...applyOrderingStrategy(realSteps, order, config, sequence)];
|
|
252
|
+
} else {
|
|
253
|
+
funnelActualOrder = applyOrderingStrategy(
|
|
254
|
+
funnelStepsUserWillTake,
|
|
255
|
+
order,
|
|
256
|
+
config,
|
|
257
|
+
sequence
|
|
258
|
+
);
|
|
259
|
+
}
|
|
244
260
|
|
|
245
261
|
// Add timing offsets to events
|
|
246
262
|
const funnelEventsWithTiming = addTimingOffsets(
|
|
@@ -312,6 +328,17 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
312
328
|
}
|
|
313
329
|
}
|
|
314
330
|
|
|
331
|
+
// $experiment_started is a synthetic experiment-assignment marker, NOT a real
|
|
332
|
+
// pre-auth UI event. Mixpanel keys experiment exposure on the user (distinct_id),
|
|
333
|
+
// so it must carry user_id even when prepended to an isFirstFunnel (born-in,
|
|
334
|
+
// pre-auth) sequence — otherwise the exposure is device-only and shows zero
|
|
335
|
+
// users in experiment reports. Force `both` regardless of auth position.
|
|
336
|
+
if (expName) {
|
|
337
|
+
for (let i = 0; i < funnelEventsWithTiming.length; i++) {
|
|
338
|
+
if (funnelEventsWithTiming[i].event === "$experiment_started") stampingByIndex[i] = 'both';
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
315
342
|
// Generate actual events with timing
|
|
316
343
|
const finalEvents = await generateFunnelEvents(
|
|
317
344
|
context,
|
|
@@ -99,6 +99,59 @@ export function countEventsBetween(events, eventA, eventB) {
|
|
|
99
99
|
return n;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* FNV-1a 32-bit hash of the FULL id string, mapped to [0, 1).
|
|
104
|
+
*
|
|
105
|
+
* The determinism primitive for hidden cohorts (v1.6). Hashing the whole id
|
|
106
|
+
* matters: the `uid.charCodeAt(0) % N` idiom taught in older recipes biases
|
|
107
|
+
* cohort rates because id alphabets don't cover charcode space uniformly —
|
|
108
|
+
* hex-ish first chars (0-9, a-f) reach only ~2 of 50 residues under `% 50`,
|
|
109
|
+
* so a "2% whale cohort" lands anywhere from 0% to ~12% depending on the id
|
|
110
|
+
* format. FNV-1a diffuses every character, so bucket shares track the target
|
|
111
|
+
* closely regardless of id alphabet.
|
|
112
|
+
*
|
|
113
|
+
* Published FNV-1a 32-bit vectors (draft-eastlake-fnv test suite):
|
|
114
|
+
* `''` → 0x811c9dc5, `'a'` → 0xe40c292c, `'foobar'` → 0xbf9cf968.
|
|
115
|
+
*
|
|
116
|
+
* Uniformity caveat: shares track the target tightly on high-entropy ids —
|
|
117
|
+
* engine-stamped GUIDs measure 5.0% / 19.7% for 5 / 20 targets at n=10k.
|
|
118
|
+
* Short SEQUENTIAL synthetic ids (`usr_1`, `usr_2`, …) can drift a few
|
|
119
|
+
* points (FNV-1a has weak avalanche on short correlated inputs); that's
|
|
120
|
+
* fine for hook use, where ids are GUIDs.
|
|
121
|
+
*
|
|
122
|
+
* @param {string|number} id - User id. Numbers are stringified; null/undefined hash as ''.
|
|
123
|
+
* @returns {number} Deterministic float in [0, 1).
|
|
124
|
+
*/
|
|
125
|
+
export function hashFloat(id) {
|
|
126
|
+
const s = String(id ?? '');
|
|
127
|
+
let h = 0x811c9dc5;
|
|
128
|
+
for (let i = 0; i < s.length; i++) {
|
|
129
|
+
h ^= s.charCodeAt(i);
|
|
130
|
+
h = Math.imul(h, 0x01000193);
|
|
131
|
+
}
|
|
132
|
+
return (h >>> 0) / 4294967296; // 2^32 → [0, 1)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Deterministic percent-based cohort membership: true for ~`pct`% of ids.
|
|
137
|
+
* Same id + same pct always agree, and membership nests — every member of
|
|
138
|
+
* `hashCohort(id, 5)` is also a member of `hashCohort(id, 20)`, so tiered
|
|
139
|
+
* cohorts (whale ⊂ engaged) come free.
|
|
140
|
+
*
|
|
141
|
+
* @example
|
|
142
|
+
* // In an everything hook:
|
|
143
|
+
* const isWhale = hashCohort(uid, 2); // ~2% of users
|
|
144
|
+
* if (isWhale && e.event === 'swap') e.trade_amount_usd *= 50;
|
|
145
|
+
*
|
|
146
|
+
* @param {string|number} id - User id.
|
|
147
|
+
* @param {number} pct - Cohort share as a PERCENTAGE in [0, 100].
|
|
148
|
+
* @returns {boolean}
|
|
149
|
+
*/
|
|
150
|
+
export function hashCohort(id, pct) {
|
|
151
|
+
if (typeof pct !== 'number' || !Number.isFinite(pct)) return false;
|
|
152
|
+
return hashFloat(id) * 100 < pct;
|
|
153
|
+
}
|
|
154
|
+
|
|
102
155
|
/**
|
|
103
156
|
* Profile-based cohort check. Returns true if `profile[segmentKey]` matches one of
|
|
104
157
|
* `segmentValues` (array) or equals the single value passed.
|
|
@@ -16,6 +16,8 @@ export {
|
|
|
16
16
|
binUsersByEventInRange,
|
|
17
17
|
countEventsBetween,
|
|
18
18
|
userInProfileSegment,
|
|
19
|
+
hashFloat,
|
|
20
|
+
hashCohort,
|
|
19
21
|
} from './cohort.js';
|
|
20
22
|
|
|
21
23
|
export {
|
|
@@ -43,3 +45,9 @@ export {
|
|
|
43
45
|
isPreAuthEvent,
|
|
44
46
|
splitByAuth,
|
|
45
47
|
} from './identity.js';
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
applyLifecycleWave,
|
|
51
|
+
applyPathBias,
|
|
52
|
+
applySessionShape,
|
|
53
|
+
} from './shape.js';
|
|
@@ -25,7 +25,7 @@ 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 {
|
|
28
|
+
return /** @type {T} */ ({ ...template, ...overrides });
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook helpers — analysis-shape atoms (v1.6).
|
|
3
|
+
*
|
|
4
|
+
* Higher-order atoms that sculpt a user's FULL event stream into the shapes
|
|
5
|
+
* specific Mixpanel analyses read: a lifecycle dormancy-resurrection wave
|
|
6
|
+
* (HOOKS.md §2.16), a biased Flows path branch (§2.17), and a deterministic
|
|
7
|
+
* session cadence (§2.13). All three are `everything`-hook-only — they need
|
|
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,
|
|
10
|
+
* seeded `chance` for all randomness. Timestamp rewrites are safe because
|
|
11
|
+
* the engine re-derives `session_id` on the final event set (v1.6 P2.1).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getChance } from '../utils/utils.js';
|
|
15
|
+
import { toMs, writeTime } from './_internal.js';
|
|
16
|
+
import { hashFloat } from './cohort.js';
|
|
17
|
+
|
|
18
|
+
const DAY_MS = 86400000;
|
|
19
|
+
const WEEK_MS = 7 * DAY_MS;
|
|
20
|
+
const MIN_MS = 60000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Engineer a clean dormancy gap + resurrection burst for one user.
|
|
24
|
+
*
|
|
25
|
+
* Drops the user's `valueMomentEvent` events (or ALL events with
|
|
26
|
+
* `dropAll: true`) inside the window
|
|
27
|
+
* `[birth + dormantFromDay days, birth + dormantFromDay + dormantDays days]`
|
|
28
|
+
* (both ends inclusive; birth = the user's earliest event time), then clones
|
|
29
|
+
* a burst of `resurrectBurst` value-moment events shortly after the window.
|
|
30
|
+
*
|
|
31
|
+
* Gap discipline is the whole point: Mixpanel's lifecycle "dormant" state is
|
|
32
|
+
* an `EqualTo 0` filter over the period (HOOKS.md §2.16) — ONE stray value
|
|
33
|
+
* moment inside the window destroys the Resurrected classification. The
|
|
34
|
+
* sweep therefore filters by TIMESTAMP over the array as passed, which
|
|
35
|
+
* includes events other hooks injected earlier in the same `everything`
|
|
36
|
+
* pass. Size `dormantDays` to cover at least two whole lifecycle periods so
|
|
37
|
+
* tiling can't clip the gap.
|
|
38
|
+
*
|
|
39
|
+
* Burst template: the surviving `valueMomentEvent` occurrence closest to the
|
|
40
|
+
* window (preferring one that carries `user_id`). No template → no burst
|
|
41
|
+
* (schema-first: never fabricate). Burst lands 1-3h after the window end
|
|
42
|
+
* with 1-10min gaps — one tight resurrection session. If the window extends
|
|
43
|
+
* past the dataset end, the engine's future-time guard will drop the clones;
|
|
44
|
+
* keep `dormantFromDay + dormantDays` inside the user's lifespan.
|
|
45
|
+
*
|
|
46
|
+
* @param {Array<Object>} events - Full user event array (everything hook).
|
|
47
|
+
* @param {string} uid - User id; stamped on clones that lack `user_id`.
|
|
48
|
+
* @param {Object} opts
|
|
49
|
+
* @param {number} opts.dormantFromDay - Window start, in days after birth.
|
|
50
|
+
* @param {number} opts.dormantDays - Window length in days.
|
|
51
|
+
* @param {number} [opts.resurrectBurst=3] - Clones in the post-gap burst.
|
|
52
|
+
* @param {string} opts.valueMomentEvent - Event name that marks value; the
|
|
53
|
+
* drop target (unless `dropAll`) and the burst template.
|
|
54
|
+
* @param {boolean} [opts.dropAll=false] - Drop ALL events in the window,
|
|
55
|
+
* not just value moments.
|
|
56
|
+
* @returns {Array<Object>} NEW array: filtered + burst clones appended.
|
|
57
|
+
*/
|
|
58
|
+
export function applyLifecycleWave(events, uid, opts) {
|
|
59
|
+
const { dormantFromDay, dormantDays, resurrectBurst = 3, valueMomentEvent, dropAll = false } = opts || {};
|
|
60
|
+
if (!Array.isArray(events) || !events.length) return events;
|
|
61
|
+
if (typeof dormantFromDay !== 'number' || typeof dormantDays !== 'number' || dormantDays <= 0) return events;
|
|
62
|
+
if (!valueMomentEvent && !dropAll) return events;
|
|
63
|
+
|
|
64
|
+
let birthMs = Infinity;
|
|
65
|
+
for (const ev of events) {
|
|
66
|
+
if (!ev) continue;
|
|
67
|
+
const t = toMs(ev.time);
|
|
68
|
+
if (Number.isFinite(t) && t < birthMs) birthMs = t;
|
|
69
|
+
}
|
|
70
|
+
if (!Number.isFinite(birthMs)) return events;
|
|
71
|
+
|
|
72
|
+
const windowStart = birthMs + dormantFromDay * DAY_MS;
|
|
73
|
+
const windowEnd = windowStart + dormantDays * DAY_MS;
|
|
74
|
+
|
|
75
|
+
const kept = events.filter(ev => {
|
|
76
|
+
if (!ev) return false;
|
|
77
|
+
const t = toMs(ev.time);
|
|
78
|
+
if (!Number.isFinite(t) || t < windowStart || t > windowEnd) return true;
|
|
79
|
+
return !(dropAll || ev.event === valueMomentEvent);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Burst template: surviving value moment closest to the window.
|
|
83
|
+
let template = null;
|
|
84
|
+
let bestDist = Infinity;
|
|
85
|
+
for (const ev of kept) {
|
|
86
|
+
if (!ev || ev.event !== valueMomentEvent) continue;
|
|
87
|
+
const t = toMs(ev.time);
|
|
88
|
+
if (!Number.isFinite(t)) continue;
|
|
89
|
+
const dist = t < windowStart ? windowStart - t : t - windowEnd;
|
|
90
|
+
// Prefer a template with user_id at equal-or-better distance.
|
|
91
|
+
if (dist < bestDist || (dist === bestDist && !template?.user_id && ev.user_id)) {
|
|
92
|
+
bestDist = dist;
|
|
93
|
+
template = ev;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!template || resurrectBurst <= 0) return kept;
|
|
97
|
+
|
|
98
|
+
const chance = getChance();
|
|
99
|
+
let t = windowEnd + chance.integer({ min: 60 * MIN_MS, max: 180 * MIN_MS });
|
|
100
|
+
for (let i = 0; i < resurrectBurst; i++) {
|
|
101
|
+
const clone = { ...template };
|
|
102
|
+
writeTime(clone, t);
|
|
103
|
+
delete clone.insert_id;
|
|
104
|
+
if (!clone.user_id && uid) clone.user_id = uid;
|
|
105
|
+
kept.push(clone);
|
|
106
|
+
t += chance.integer({ min: MIN_MS, max: 10 * MIN_MS });
|
|
107
|
+
}
|
|
108
|
+
return kept;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Bias a share of users onto a specific Flows path after an anchor event.
|
|
113
|
+
*
|
|
114
|
+
* For users where `hashFloat(uid) < share`, injects the `path` sequence —
|
|
115
|
+
* each step cloned from the user's OWN existing event of that name — right
|
|
116
|
+
* after the user's FIRST `anchor` occurrence, with tight monotonic gaps.
|
|
117
|
+
*
|
|
118
|
+
* Why these rules (HOOKS.md §2.17): Flows' unique mode reads only the FIRST
|
|
119
|
+
* flow per user, so the injection anchors on the first occurrence; gaps are
|
|
120
|
+
* clamped to ≥1s because sub-second jitter scrambles step order in the
|
|
121
|
+
* Sankey; and the engineered branch needs roughly ≥20-25% `share` to survive
|
|
122
|
+
* Sankey's top-3-per-level pruning. If the user lacks a source event for ANY
|
|
123
|
+
* path step, the user is skipped entirely — a partial path would pollute the
|
|
124
|
+
* engineered share.
|
|
125
|
+
*
|
|
126
|
+
* @param {Array<Object>} events - Full user event array (everything hook).
|
|
127
|
+
* @param {string} uid - User id (hashed for the share gate; stamped on
|
|
128
|
+
* clones that lack `user_id`).
|
|
129
|
+
* @param {Object} opts
|
|
130
|
+
* @param {string} opts.anchor - Event name the path follows.
|
|
131
|
+
* @param {string[]} opts.path - Ordered event names to inject.
|
|
132
|
+
* @param {number} opts.share - FRACTION of users in [0, 1] (unlike
|
|
133
|
+
* `hashCohort`'s 0-100 pct scale).
|
|
134
|
+
* @param {[number, number]} [opts.gapSeconds=[2,30]] - Per-step gap range in
|
|
135
|
+
* seconds; lower bound clamped to ≥1.
|
|
136
|
+
* @returns {Array<Object>} The SAME array, augmented in place for selected
|
|
137
|
+
* users (engine auto-sorts after the everything hook).
|
|
138
|
+
*/
|
|
139
|
+
export function applyPathBias(events, uid, opts) {
|
|
140
|
+
const { anchor, path, share, gapSeconds = [2, 30] } = opts || {};
|
|
141
|
+
if (!Array.isArray(events) || !events.length || !anchor) return events;
|
|
142
|
+
if (!Array.isArray(path) || !path.length || typeof share !== 'number') return events;
|
|
143
|
+
if (hashFloat(uid) >= share) return events;
|
|
144
|
+
|
|
145
|
+
// First anchor occurrence in TIME order.
|
|
146
|
+
let anchorMs = Infinity;
|
|
147
|
+
for (const ev of events) {
|
|
148
|
+
if (!ev || ev.event !== anchor) continue;
|
|
149
|
+
const t = toMs(ev.time);
|
|
150
|
+
if (Number.isFinite(t) && t < anchorMs) anchorMs = t;
|
|
151
|
+
}
|
|
152
|
+
if (!Number.isFinite(anchorMs)) return events;
|
|
153
|
+
|
|
154
|
+
// One template per path step, from the user's own stream. Any missing →
|
|
155
|
+
// skip the user entirely.
|
|
156
|
+
const templates = [];
|
|
157
|
+
for (const name of path) {
|
|
158
|
+
const tpl = events.find(e => e && e.event === name);
|
|
159
|
+
if (!tpl) return events;
|
|
160
|
+
templates.push(tpl);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const lo = Math.max(1, Number(gapSeconds[0]) || 1);
|
|
164
|
+
const hi = Math.max(lo, Number(gapSeconds[1]) || lo);
|
|
165
|
+
const chance = getChance();
|
|
166
|
+
let t = anchorMs;
|
|
167
|
+
for (const tpl of templates) {
|
|
168
|
+
t += chance.integer({ min: lo, max: hi }) * 1000;
|
|
169
|
+
const clone = { ...tpl };
|
|
170
|
+
writeTime(clone, t);
|
|
171
|
+
delete clone.insert_id;
|
|
172
|
+
if (!clone.user_id && uid) clone.user_id = uid;
|
|
173
|
+
events.push(clone);
|
|
174
|
+
}
|
|
175
|
+
return events;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Rewrite a user's event TIMESTAMPS into a deterministic session cadence:
|
|
180
|
+
* `sessionsPerWeek` clusters per week, each compressed into a
|
|
181
|
+
* `[start, start + sessionMinutes]` window.
|
|
182
|
+
*
|
|
183
|
+
* Retiming only — no events are added or dropped; the same objects are
|
|
184
|
+
* mutated in place. Session count = `min(sessionsPerWeek × weeks,
|
|
185
|
+
* ceil(N / eventsPerSession))` (weeks tile forward from the user's first
|
|
186
|
+
* event): plentiful streams get exactly `sessionsPerWeek` clusters per week
|
|
187
|
+
* with events split evenly; scarce streams get fewer, `eventsPerSession`-
|
|
188
|
+
* sized clusters spread across weeks. Session days prefer the user's
|
|
189
|
+
* ORIGINAL active days in each week, so the engine's day distribution
|
|
190
|
+
* survives where possible.
|
|
191
|
+
*
|
|
192
|
+
* Boundary guarantees (what makes derived sessions deterministic against
|
|
193
|
+
* jitter — HOOKS.md §2.13): intra-session gaps stay well under Mixpanel's
|
|
194
|
+
* 30-min timeout (even spacing capped at 20min + bounded jitter, worst case
|
|
195
|
+
* <28min); inter-session gaps stay well over it (sessions land on distinct
|
|
196
|
+
* days when possible; same-day sessions are centered in equal partitions of
|
|
197
|
+
* the day, spaced ≥¼ partition — guaranteed >30min up to 8 sessions/day);
|
|
198
|
+
* and no engineered session crosses UTC midnight (a day-boundary split would
|
|
199
|
+
* cut it — `session_query.cpp` daySplit). Valid precisely because of P2.1:
|
|
200
|
+
* session_ids are re-derived after the everything hook.
|
|
201
|
+
*
|
|
202
|
+
* @param {Array<Object>} events - Full user event array (everything hook).
|
|
203
|
+
* @param {string} uid - Unused for hashing here; kept for atom-signature
|
|
204
|
+
* symmetry (cohort gating belongs to the caller — combine with
|
|
205
|
+
* `hashCohort`).
|
|
206
|
+
* @param {Object} opts
|
|
207
|
+
* @param {number} opts.sessionsPerWeek - Target clusters per week (≥1).
|
|
208
|
+
* @param {number} opts.eventsPerSession - Target events per cluster (≥1).
|
|
209
|
+
* @param {number} opts.sessionMinutes - Max cluster span in minutes.
|
|
210
|
+
* @returns {Array<Object>} The SAME array, timestamps rewritten.
|
|
211
|
+
*/
|
|
212
|
+
export function applySessionShape(events, uid, opts) {
|
|
213
|
+
const { sessionsPerWeek, eventsPerSession, sessionMinutes } = opts || {};
|
|
214
|
+
if (!Array.isArray(events) || !events.length) return events;
|
|
215
|
+
if (!isPos(sessionsPerWeek) || !isPos(eventsPerSession) || !isPos(sessionMinutes)) return events;
|
|
216
|
+
|
|
217
|
+
const timed = events.filter(e => e && Number.isFinite(toMs(e.time)));
|
|
218
|
+
if (!timed.length) return events;
|
|
219
|
+
timed.sort((a, b) => toMs(a.time) - toMs(b.time));
|
|
220
|
+
|
|
221
|
+
const N = timed.length;
|
|
222
|
+
const firstMs = toMs(timed[0].time);
|
|
223
|
+
const lastMs = toMs(timed[N - 1].time);
|
|
224
|
+
const weeks = Math.max(1, Math.ceil((lastMs - firstMs + 1) / WEEK_MS));
|
|
225
|
+
const numSessions = Math.max(1, Math.min(
|
|
226
|
+
Math.floor(sessionsPerWeek) * weeks,
|
|
227
|
+
Math.ceil(N / Math.floor(eventsPerSession)),
|
|
228
|
+
));
|
|
229
|
+
|
|
230
|
+
// Sessions per week and events per session, distributed evenly with the
|
|
231
|
+
// remainder going to the EARLIEST weeks/sessions (chronological bias).
|
|
232
|
+
const sessionsInWeek = splitEvenly(numSessions, weeks);
|
|
233
|
+
const chunkSizes = splitEvenly(N, numSessions).filter(n => n > 0);
|
|
234
|
+
|
|
235
|
+
const chance = getChance();
|
|
236
|
+
const firstDay = Math.floor(firstMs / DAY_MS);
|
|
237
|
+
const lastDay = Math.floor(lastMs / DAY_MS);
|
|
238
|
+
|
|
239
|
+
// Pick one day per session, week by week: prefer the user's original
|
|
240
|
+
// active days in the week, fill from the rest of the week's days, and
|
|
241
|
+
// only reuse days when a week has more sessions than days.
|
|
242
|
+
const sessionDays = [];
|
|
243
|
+
for (let w = 0; w < weeks; w++) {
|
|
244
|
+
const need = sessionsInWeek[w];
|
|
245
|
+
if (!need) continue;
|
|
246
|
+
const weekStartMs = firstMs + w * WEEK_MS;
|
|
247
|
+
const dayLo = Math.max(firstDay, Math.floor(weekStartMs / DAY_MS));
|
|
248
|
+
const dayHi = Math.min(lastDay, Math.floor((weekStartMs + WEEK_MS - 1) / DAY_MS));
|
|
249
|
+
const originalDays = new Set();
|
|
250
|
+
for (const ev of timed) {
|
|
251
|
+
const t = toMs(ev.time);
|
|
252
|
+
if (t >= weekStartMs && t < weekStartMs + WEEK_MS) originalDays.add(Math.floor(t / DAY_MS));
|
|
253
|
+
}
|
|
254
|
+
const pool = [...originalDays].filter(d => d >= dayLo && d <= dayHi);
|
|
255
|
+
const others = [];
|
|
256
|
+
for (let d = dayLo; d <= dayHi; d++) if (!originalDays.has(d)) others.push(d);
|
|
257
|
+
const picked = chance.pickset(pool, Math.min(need, pool.length));
|
|
258
|
+
if (picked.length < need) picked.push(...chance.pickset(others, Math.min(need - picked.length, others.length)));
|
|
259
|
+
let i = 0;
|
|
260
|
+
while (picked.length < need) picked.push(picked[i++ % Math.max(1, picked.length)]); // reuse days: > days/week sessions
|
|
261
|
+
picked.sort((a, b) => a - b);
|
|
262
|
+
sessionDays.push(...picked);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Place sessions within days. Same-day sessions get equal partitions of
|
|
266
|
+
// the day; the session is centered in its partition with bounded jitter,
|
|
267
|
+
// which guarantees the inter-session and midnight invariants above.
|
|
268
|
+
const perDayCount = new Map();
|
|
269
|
+
for (const d of sessionDays) perDayCount.set(d, (perDayCount.get(d) || 0) + 1);
|
|
270
|
+
const perDaySeen = new Map();
|
|
271
|
+
const sesMs = sessionMinutes * MIN_MS;
|
|
272
|
+
|
|
273
|
+
let cursor = 0;
|
|
274
|
+
for (let s = 0; s < chunkSizes.length; s++) {
|
|
275
|
+
const size = chunkSizes[s];
|
|
276
|
+
const chunk = timed.slice(cursor, cursor + size);
|
|
277
|
+
cursor += size;
|
|
278
|
+
const day = sessionDays[Math.min(s, sessionDays.length - 1)];
|
|
279
|
+
const m = perDayCount.get(day) || 1;
|
|
280
|
+
const j = perDaySeen.get(day) || 0;
|
|
281
|
+
perDaySeen.set(day, j + 1);
|
|
282
|
+
|
|
283
|
+
const seg = DAY_MS / m;
|
|
284
|
+
const span = Math.min(sesMs, seg * 0.5);
|
|
285
|
+
const center = day * DAY_MS + j * seg + (seg - span) / 2;
|
|
286
|
+
const q = Math.floor((seg - span) / 4);
|
|
287
|
+
const start = center + (q > 0 ? chance.integer({ min: -q, max: q }) : 0);
|
|
288
|
+
|
|
289
|
+
if (size === 1) {
|
|
290
|
+
writeTime(chunk[0], start);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
// Even spacing capped at 20min so gap + jitter stays < 30min. Jitter is
|
|
294
|
+
// also bounded by the window slack so no event escapes [start, start+span].
|
|
295
|
+
const gap = Math.min(span / (size - 1), 20 * MIN_MS);
|
|
296
|
+
const slack = span - (size - 1) * gap;
|
|
297
|
+
const jitterMax = Math.floor(Math.max(0, Math.min(gap * 0.4, 5 * MIN_MS, slack)));
|
|
298
|
+
for (let i = 0; i < size; i++) {
|
|
299
|
+
const jitter = i > 0 && jitterMax > 0 ? chance.integer({ min: 0, max: jitterMax }) : 0;
|
|
300
|
+
writeTime(chunk[i], start + i * gap + jitter);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return events;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── internal helpers ──
|
|
307
|
+
|
|
308
|
+
function isPos(n) {
|
|
309
|
+
return typeof n === 'number' && Number.isFinite(n) && n >= 1;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Split `total` into `parts` integers, remainder to the earliest parts. */
|
|
313
|
+
function splitEvenly(total, parts) {
|
|
314
|
+
const base = Math.floor(total / parts);
|
|
315
|
+
const extra = total % parts;
|
|
316
|
+
const out = [];
|
|
317
|
+
for (let i = 0; i < parts; i++) out.push(base + (i < extra ? 1 : 0));
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
@@ -5,12 +5,15 @@
|
|
|
5
5
|
* cohort bin (derived from `count(cohortEvent)`). Used for "Avg Order Value by
|
|
6
6
|
* per-user count of Sessions" Insights views — engaged users skew avg up.
|
|
7
7
|
*
|
|
8
|
-
* Mechanism: classify
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Mechanism: classify — by distinct calendar days of `cohortEvent` by default
|
|
9
|
+
* (v1.6, matching Mixpanel's frequency axis; see `binByDistinctPeriods`), or
|
|
10
|
+
* total events with `binBy: 'events'` — then `scalePropertyValue` with
|
|
11
|
+
* `deltas[bin]` as the multiplier. Property must already be defined on the
|
|
12
|
+
* event in the dungeon schema and carry numeric values.
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
import { binUsersByEventCount } from '../hook-helpers/cohort.js';
|
|
16
|
+
import { binByDistinctPeriods } from '../verify/counting.js';
|
|
14
17
|
import { scalePropertyValue } from '../hook-helpers/mutate.js';
|
|
15
18
|
|
|
16
19
|
/**
|
|
@@ -23,13 +26,18 @@ import { scalePropertyValue } from '../hook-helpers/mutate.js';
|
|
|
23
26
|
* @param {string} opts.propertyName
|
|
24
27
|
* @param {Record<string, number>} opts.deltas - Bin name → multiplier (1 = no-op,
|
|
25
28
|
* 1.5 = 50% lift, 0.7 = 30% drop).
|
|
29
|
+
* @param {('events'|'distinctDays')} [opts.binBy='distinctDays'] - Cohort axis.
|
|
30
|
+
* `'distinctDays'` (default since v1.6) bins by distinct calendar days with
|
|
31
|
+
* `cohortEvent`; `'events'` restores the pre-1.6 total-event-count axis.
|
|
26
32
|
* @returns {{ bin: string|null, scaled: number }}
|
|
27
33
|
*/
|
|
28
|
-
export function applyAggregateByBin(events, _profile, { cohortEvent, bins, event, propertyName, deltas }) {
|
|
34
|
+
export function applyAggregateByBin(events, _profile, { cohortEvent, bins, event, propertyName, deltas, binBy = 'distinctDays' }) {
|
|
29
35
|
if (!events || !cohortEvent || !bins || !event || !propertyName || !deltas) {
|
|
30
36
|
return { bin: null, scaled: 0 };
|
|
31
37
|
}
|
|
32
|
-
const bin =
|
|
38
|
+
const bin = binBy === 'events'
|
|
39
|
+
? binUsersByEventCount(events, cohortEvent, bins)
|
|
40
|
+
: binByDistinctPeriods(events, cohortEvent, bins, 'day');
|
|
33
41
|
if (!bin) return { bin: null, scaled: 0 };
|
|
34
42
|
const factor = deltas[bin];
|
|
35
43
|
if (typeof factor !== 'number' || factor === 1) return { bin, scaled: 0 };
|
|
@@ -1,72 +1,74 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pattern:
|
|
2
|
+
* Pattern: Bias attribution by overwriting engine-stamped touches.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* v1.6 rewrite (recipe 4.26 as code). Under v1.5+ the ENGINE stamps UTMs on
|
|
5
|
+
* up to `maxTouchpointsPerUser` events per user (default 10), sampled across
|
|
6
|
+
* the user's lifetime. Attribution hooks must therefore OVERWRITE the values
|
|
7
|
+
* on already-stamped events, not stamp fresh ones: FIRST/LAST attribution is
|
|
8
|
+
* UNCAPPED (hard LIMIT-1 statements, whoval/read.cpp:173-192 — the 10-cap
|
|
9
|
+
* only serves multi-touch list models), so a fresh stamp EARLIER than the
|
|
10
|
+
* engine's first touch silently WINS first-touch instead of biasing it, and
|
|
11
|
+
* fresh stamps change the user's total touch count (HOOKS.md principle 29).
|
|
10
12
|
*
|
|
11
|
-
* Mechanism:
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* Mechanism: collect the user's engine-stamped touches (events where
|
|
14
|
+
* `property` is non-null), sort by time, and overwrite the touch the chosen
|
|
15
|
+
* attribution model reads — the FIRST touch for `firstTouch`, the LAST for
|
|
16
|
+
* `lastTouch`, or both — with a weighted pick from `weights` (seeded
|
|
17
|
+
* `chance.weighted`, reproducible per run).
|
|
15
18
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
19
|
+
* Schema-first: only events the engine already stamped are touched; the
|
|
20
|
+
* pattern never adds `property` to an unstamped event, so total touch count
|
|
21
|
+
* is unchanged.
|
|
18
22
|
*/
|
|
19
23
|
|
|
24
|
+
import { getChance } from '../utils/utils.js';
|
|
25
|
+
|
|
20
26
|
/**
|
|
21
|
-
* @param {Array<Object>} events - User's event stream (
|
|
27
|
+
* @param {Array<Object>} events - User's event stream (stamped touches
|
|
28
|
+
* mutated in place). Call from the `everything` hook — the engine's UTM
|
|
29
|
+
* stamping has already run by then.
|
|
22
30
|
* @param {Object} _profile
|
|
23
31
|
* @param {Object} opts
|
|
24
|
-
* @param {string} opts.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* @param {string} [opts.
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
+
* @param {Record<string, number>} opts.weights - Attribution value → relative
|
|
33
|
+
* weight (e.g. `{ google: 10, facebook: 5, twitter: 1 }`). The overwrite
|
|
34
|
+
* value is drawn with probability weight ÷ sum(weights).
|
|
35
|
+
* @param {string} [opts.property='utm_source'] - Engine-stamped property to
|
|
36
|
+
* overwrite.
|
|
37
|
+
* @param {('firstTouch'|'lastTouch'|'both')} [opts.model='firstTouch'] -
|
|
38
|
+
* Which stamped touch to overwrite: the one Mixpanel's first-touch model
|
|
39
|
+
* reads, the last-touch one, or both.
|
|
40
|
+
*
|
|
41
|
+
* CAVEAT (fix-round C16): `lastTouch`/`both` overwrite the user's
|
|
42
|
+
* LIFETIME-last stamped touch, but Mixpanel's last-touch model reads the
|
|
43
|
+
* last touch BEFORE THE CONVERSION EVENT (lookback bounded by the
|
|
44
|
+
* conversion timestamp — get_last_value, whoval/read.cpp:643-655). If the
|
|
45
|
+
* user has stamped touches AFTER their conversion, the touch this pattern
|
|
46
|
+
* biases is not the one the report reads. `firstTouch` (the default) has
|
|
47
|
+
* no such gap: the lifetime-first touch is exactly what FIRST reads.
|
|
48
|
+
* Conversion-aware target selection is planned for 1.6.1.
|
|
49
|
+
* @returns {{ overwritten: number, touches: number }} `touches` = stamped
|
|
50
|
+
* events found; `overwritten` = touches whose value was replaced.
|
|
32
51
|
*/
|
|
33
52
|
export function applyAttributedBySource(events, _profile, opts) {
|
|
34
|
-
const {
|
|
35
|
-
if (!events || !
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
const destProp = downstreamProperty || sourceProperty;
|
|
39
|
-
const sorted = events.slice().sort((a, b) => Date.parse(a.time) - Date.parse(b.time));
|
|
40
|
-
const maxWeight = Math.max(...Object.values(weights), 0);
|
|
41
|
-
if (maxWeight <= 0) return { stamped: 0, skipped: 0 };
|
|
53
|
+
const { weights, property = 'utm_source', model = 'firstTouch' } = opts || {};
|
|
54
|
+
if (!events || !events.length || !weights) return { overwritten: 0, touches: 0 };
|
|
55
|
+
const values = Object.keys(weights).filter(k => typeof weights[k] === 'number' && weights[k] > 0);
|
|
56
|
+
if (!values.length) return { overwritten: 0, touches: 0 };
|
|
42
57
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
// Deterministic per-user pseudo-RNG keyed on the downstream event's
|
|
58
|
-
// insert_id (or time fallback) — keeps verification reproducible.
|
|
59
|
-
const seed = ev.insert_id || ev.time || '';
|
|
60
|
-
const r = simpleHashFloat(String(seed));
|
|
61
|
-
if (r < prob) {
|
|
62
|
-
ev[destProp] = val;
|
|
63
|
-
stamped++;
|
|
64
|
-
} else {
|
|
65
|
-
skipped++;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
58
|
+
const stamped = events
|
|
59
|
+
.filter(e => e && e[property] !== undefined && e[property] !== null)
|
|
60
|
+
.sort((a, b) => Date.parse(a.time) - Date.parse(b.time));
|
|
61
|
+
if (!stamped.length) return { overwritten: 0, touches: 0 };
|
|
62
|
+
|
|
63
|
+
const chance = getChance();
|
|
64
|
+
const targets = new Set();
|
|
65
|
+
if (model === 'firstTouch' || model === 'both') targets.add(stamped[0]);
|
|
66
|
+
if (model === 'lastTouch' || model === 'both') targets.add(stamped[stamped.length - 1]);
|
|
67
|
+
|
|
68
|
+
let overwritten = 0;
|
|
69
|
+
for (const touch of targets) {
|
|
70
|
+
touch[property] = chance.weighted(values, values.map(v => weights[v]));
|
|
71
|
+
overwritten++;
|
|
68
72
|
}
|
|
69
|
-
return {
|
|
73
|
+
return { overwritten, touches: stamped.length };
|
|
70
74
|
}
|
|
71
|
-
|
|
72
|
-
import { simpleHashFloat } from '../hook-helpers/_internal.js';
|