@ak--47/dungeon-master 1.5.4 → 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.
Files changed (146) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +21 -12
  2. package/.claude/skills/create-dungeon/SKILL.md +65 -5
  3. package/.claude/skills/create-project/SKILL.md +1 -1
  4. package/.claude/skills/create-project/provision.mjs +57 -10
  5. package/.claude/skills/verify-dungeon/SKILL.md +56 -25
  6. package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
  7. package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
  8. package/.claude/skills/write-hooks/SKILL.md +156 -18
  9. package/CHANGELOG.md +183 -0
  10. package/HOOKS.md +566 -112
  11. package/README.md +28 -9
  12. package/docs/guides/1.3.0-upgrade-guide.md +262 -0
  13. package/docs/guides/1.3.1-upgrade-guide.md +328 -0
  14. package/docs/guides/1.4.0-upgrade-guide.md +130 -0
  15. package/docs/guides/1.4.1-upgrade-guide.md +125 -0
  16. package/docs/guides/1.4.3-upgrade-guide.md +200 -0
  17. package/docs/guides/1.4.4-upgrade-guide.md +95 -0
  18. package/docs/guides/1.5.0-upgrade-guide.md +426 -0
  19. package/docs/guides/1.5.1-upgrade-guide.md +178 -0
  20. package/docs/guides/1.5.3-upgrade-guide.md +117 -0
  21. package/docs/guides/1.6.0-upgrade-guide.md +204 -0
  22. package/dungeons/technical/stories-verify.js +152 -0
  23. package/dungeons/vertical/README.md +154 -0
  24. package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
  25. package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
  26. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
  27. package/dungeons/vertical/community/community.js +1589 -0
  28. package/dungeons/vertical/community/community.sql +216 -0
  29. package/dungeons/vertical/community/community.verify.mjs +72 -0
  30. package/dungeons/vertical/crypto/crypto.js +1483 -0
  31. package/dungeons/vertical/crypto/crypto.sql +194 -0
  32. package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
  33. package/dungeons/vertical/dating/dating.js +1540 -0
  34. package/dungeons/vertical/dating/dating.sql +288 -0
  35. package/dungeons/vertical/dating/dating.verify.mjs +72 -0
  36. package/dungeons/vertical/devtools/devtools.js +1524 -0
  37. package/dungeons/vertical/devtools/devtools.sql +161 -0
  38. package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
  39. package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
  40. package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
  41. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
  42. package/dungeons/vertical/education/education.js +1763 -0
  43. package/dungeons/vertical/education/education.sql +243 -0
  44. package/dungeons/vertical/education/education.verify.mjs +75 -0
  45. package/dungeons/vertical/fintech/fintech.js +1410 -0
  46. package/dungeons/vertical/fintech/fintech.sql +175 -0
  47. package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
  48. package/dungeons/vertical/fitness/fitness.js +1495 -0
  49. package/dungeons/vertical/fitness/fitness.sql +206 -0
  50. package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
  51. package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
  52. package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
  53. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
  54. package/dungeons/vertical/gaming/gaming.js +1741 -0
  55. package/dungeons/vertical/gaming/gaming.sql +249 -0
  56. package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
  57. package/dungeons/vertical/healthcare/healthcare.js +1488 -0
  58. package/dungeons/vertical/healthcare/healthcare.sql +193 -0
  59. package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
  60. package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
  61. package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
  62. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
  63. package/dungeons/vertical/logistics/logistics.js +1541 -0
  64. package/dungeons/vertical/logistics/logistics.sql +205 -0
  65. package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
  66. package/dungeons/vertical/marketplace/marketplace.js +1438 -0
  67. package/dungeons/vertical/marketplace/marketplace.sql +148 -0
  68. package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
  69. package/dungeons/vertical/{media.js → media/media.js} +557 -58
  70. package/dungeons/vertical/media/media.sql +173 -0
  71. package/dungeons/vertical/media/media.verify.mjs +74 -0
  72. package/dungeons/vertical/real-estate/real-estate.js +1629 -0
  73. package/dungeons/vertical/real-estate/real-estate.sql +167 -0
  74. package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
  75. package/dungeons/vertical/sass/sass.js +1733 -0
  76. package/dungeons/vertical/sass/sass.sql +173 -0
  77. package/dungeons/vertical/sass/sass.verify.mjs +74 -0
  78. package/dungeons/vertical/social/social.js +1456 -0
  79. package/dungeons/vertical/social/social.sql +160 -0
  80. package/dungeons/vertical/social/social.verify.mjs +76 -0
  81. package/dungeons/vertical/streaming/streaming.js +677 -0
  82. package/dungeons/vertical/streaming/streaming.sql +101 -0
  83. package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
  84. package/dungeons/vertical/support-desk/support-desk.js +741 -0
  85. package/dungeons/vertical/support-desk/support-desk.sql +137 -0
  86. package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
  87. package/dungeons/vertical/travel/travel.js +1603 -0
  88. package/dungeons/vertical/travel/travel.sql +236 -0
  89. package/dungeons/vertical/travel/travel.verify.mjs +72 -0
  90. package/index.js +2 -2
  91. package/lib/core/config-validator.js +37 -2
  92. package/lib/core/context.js +4 -2
  93. package/lib/core/dungeon-loader.js +20 -15
  94. package/lib/core/dungeon-to-json.js +22 -4
  95. package/lib/core/storage.js +3 -3
  96. package/lib/generators/funnels.js +36 -9
  97. package/lib/hook-helpers/cohort.js +53 -0
  98. package/lib/hook-helpers/index.js +8 -0
  99. package/lib/hook-helpers/mutate.js +1 -1
  100. package/lib/hook-helpers/shape.js +319 -0
  101. package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
  102. package/lib/hook-patterns/attributed-by-source.js +60 -58
  103. package/lib/hook-patterns/frequency-by-frequency.js +14 -4
  104. package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
  105. package/lib/hook-patterns/index.js +1 -1
  106. package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
  107. package/lib/orchestrators/user-loop.js +41 -0
  108. package/lib/templates/story-spec.schema.json +175 -0
  109. package/lib/utils/json-evaluator.js +1 -1
  110. package/lib/utils/utils.js +5 -4
  111. package/lib/verify/coerce.js +186 -0
  112. package/lib/verify/counting.js +270 -32
  113. package/lib/verify/emulate-breakdown.js +1285 -203
  114. package/lib/verify/first-time.js +89 -0
  115. package/lib/verify/flows.js +679 -0
  116. package/lib/verify/formula.js +259 -0
  117. package/lib/verify/funnel-engine.js +697 -113
  118. package/lib/verify/index.js +32 -4
  119. package/lib/verify/sessionize.js +275 -0
  120. package/lib/verify/story-runner.js +483 -0
  121. package/lib/verify/verify-dungeon.js +59 -37
  122. package/package.json +2 -1
  123. package/scripts/extract-dungeon-schema.mjs +2 -2
  124. package/scripts/run-many.mjs +2 -2
  125. package/scripts/verify-runner.mjs +2 -2
  126. package/scripts/verify-stories.mjs +244 -0
  127. package/types.d.ts +296 -30
  128. package/dungeons/vertical/ai-platform.js +0 -818
  129. package/dungeons/vertical/community.js +0 -966
  130. package/dungeons/vertical/crypto.js +0 -895
  131. package/dungeons/vertical/dating.js +0 -830
  132. package/dungeons/vertical/devtools.js +0 -1050
  133. package/dungeons/vertical/ecommerce.js +0 -811
  134. package/dungeons/vertical/education.js +0 -964
  135. package/dungeons/vertical/fintech.js +0 -946
  136. package/dungeons/vertical/fitness.js +0 -935
  137. package/dungeons/vertical/food-delivery.js +0 -934
  138. package/dungeons/vertical/gaming.js +0 -1120
  139. package/dungeons/vertical/healthcare.js +0 -945
  140. package/dungeons/vertical/insurance-application.js +0 -1039
  141. package/dungeons/vertical/logistics.js +0 -986
  142. package/dungeons/vertical/marketplace.js +0 -1019
  143. package/dungeons/vertical/real-estate.js +0 -872
  144. package/dungeons/vertical/sass.js +0 -995
  145. package/dungeons/vertical/social.js +0 -904
  146. 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
- // Variant assignment is deterministic per-user (hash of userId + experiment name).
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 = Number(u.quickHash(`${userId}:${experimentName}`)) % totalWeight;
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
- const funnelActualOrder = applyOrderingStrategy(
239
- funnelStepsUserWillTake,
240
- order,
241
- config,
242
- sequence
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 {any} */ ({ ...template, ...overrides });
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, then `scalePropertyValue` with `deltas[bin]` as the
9
- * multiplier. Property must already be defined on the event in the dungeon
10
- * schema and carry numeric values.
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 = binUsersByEventCount(events, cohortEvent, bins);
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: Attribute conversions by source.
2
+ * Pattern: Bias attribution by overwriting engine-stamped touches.
3
3
  *
4
- * For each user, copy a property value from a "touch" event onto a downstream
5
- * "conversion" event so Mixpanel's "Conversions by Source" attribution analysis
6
- * shows the configured weighted distribution. The pattern doesn't invent the
7
- * source distribution it preserves whatever the touch events already carry —
8
- * BUT it lets you bias the conversion completion rate per source via `weights`
9
- * (probability of stamping = weight ÷ max(weight) ).
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: walk the user's event stream; when a `downstreamEvent` event fires,
12
- * look back at the most-recent (or first) `sourceEvent` and copy
13
- * `sourceEvent[sourceProperty]` onto the downstream event. Skip stamping
14
- * probabilistically per `weights[sourceValue]`.
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
- * Identity & schema: the destination property must already exist on
17
- * `downstreamEvent` in the dungeon schema (we OVERWRITE the value, not invent it).
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 (mutated in place).
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.sourceEvent - Event whose property we copy from.
25
- * @param {string} opts.sourceProperty - Property on `sourceEvent` to copy.
26
- * @param {string} opts.downstreamEvent - Event whose property we overwrite.
27
- * @param {string} [opts.downstreamProperty] - Defaults to `sourceProperty`.
28
- * @param {Record<string, number>} opts.weights - Source value → relative weight
29
- * (probability of stamping = weight / maxWeight; missing entries = 0).
30
- * @param {'firstTouch'|'lastTouch'} [opts.model] - Default 'firstTouch'.
31
- * @returns {{ stamped: number, skipped: number }}
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 { sourceEvent, sourceProperty, downstreamEvent, downstreamProperty, weights, model = 'firstTouch' } = opts || {};
35
- if (!events || !sourceEvent || !sourceProperty || !downstreamEvent || !weights) {
36
- return { stamped: 0, skipped: 0 };
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
- let stamped = 0;
44
- let skipped = 0;
45
- const touches = []; // accumulated source events in time order
46
- for (const ev of sorted) {
47
- if (!ev) continue;
48
- if (ev.event === sourceEvent && ev[sourceProperty] !== undefined) {
49
- touches.push(ev);
50
- continue;
51
- }
52
- if (ev.event === downstreamEvent && touches.length) {
53
- const touch = model === 'lastTouch' ? touches[touches.length - 1] : touches[0];
54
- const val = touch[sourceProperty];
55
- const w = weights[val] || 0;
56
- const prob = w / maxWeight; // 0..1
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 { stamped, skipped };
73
+ return { overwritten, touches: stamped.length };
70
74
  }
71
-
72
- import { simpleHashFloat } from '../hook-helpers/_internal.js';