@ak--47/dungeon-master 1.8.0 → 1.8.2
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 +9 -0
- package/.claude/skills/create-dungeon/SKILL.md +35 -34
- package/.claude/skills/create-project/SKILL.md +6 -0
- package/.claude/skills/headless-build/SKILL.md +21 -11
- package/.claude/skills/powertools/SKILL.md +6 -2
- package/.claude/skills/release-check/SKILL.md +27 -2
- package/.claude/skills/verify-dungeon/SKILL.md +32 -13
- package/.claude/skills/verify-dungeon/references/alignment-contract.md +110 -0
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +29 -16
- package/.claude/skills/verify-dungeon/references/report-format.md +23 -9
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +135 -225
- package/.claude/skills/warehouse-metrics/SKILL.md +6 -0
- package/.claude/skills/write-hooks/SKILL.md +61 -48
- package/CHANGELOG.md +82 -0
- package/HOOKS.md +105 -47
- package/README.md +41 -1
- package/docs/guides/1.8.1-upgrade-guide.md +153 -0
- package/docs/guides/1.8.2-upgrade-guide.md +110 -0
- package/lib/generators/events.js +6 -0
- package/lib/generators/funnels.js +16 -0
- package/lib/hook-helpers/shape.js +73 -17
- package/lib/hook-patterns/attributed-by-source.js +4 -3
- package/lib/hook-patterns/funnel-frequency-breakdown.js +4 -7
- package/lib/orchestrators/user-loop.js +82 -15
- package/lib/verify/counting.js +7 -10
- package/lib/verify/emulate-breakdown.js +48 -29
- package/lib/verify/funnel-engine.js +93 -40
- package/lib/verify/identity.js +32 -9
- package/lib/verify/story-runner.js +93 -30
- package/lib/verify/verify-dungeon.js +4 -1
- package/package.json +1 -1
- package/scripts/verify-stories.mjs +3 -3
- package/types.d.ts +9 -5
|
@@ -331,6 +331,21 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
331
331
|
dataQuality: featureCtx.dataQuality || context.config.dataQuality || null,
|
|
332
332
|
latestTime: Number.isFinite(featureCtx.latestTime) ? featureCtx.latestTime : undefined,
|
|
333
333
|
};
|
|
334
|
+
if (attemptInfo.isFirstFunnel && attemptInfo.isBorn && funnelEventsWithTiming.length) {
|
|
335
|
+
const spanMs = Math.max(0, ...funnelEventsWithTiming.map(event => event.relativeTimeMs || 0));
|
|
336
|
+
const latestStart = Math.min(funnelFeatureCtx.latestTime ?? context.FIXED_NOW, context.FIXED_NOW - spanMs / 1000);
|
|
337
|
+
if (firstEventTime > latestStart) {
|
|
338
|
+
context.addWarning({
|
|
339
|
+
key: 'lifecycle.firstFunnelClipped',
|
|
340
|
+
requested: spanMs,
|
|
341
|
+
applied: Math.max(0, (context.FIXED_NOW - firstEventTime) * 1000),
|
|
342
|
+
reason: 'First-funnel timing exceeds the remaining dataset window; emit the in-window prefix and omit future steps without compressing configured TTC.',
|
|
343
|
+
severity: 'warn',
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
funnelFeatureCtx.latestTime = Math.max(firstEventTime, latestStart);
|
|
347
|
+
if (featureCtx.pinFirstTime) funnelFeatureCtx.fixedTimeMs = firstEventTime * 1000;
|
|
348
|
+
}
|
|
334
349
|
|
|
335
350
|
// Pre-compute per-step stamping modes for execution order. For isFirstFunnel + isBorn
|
|
336
351
|
// runs, the first event in execution order whose config has `isAuthEvent: true` is
|
|
@@ -505,6 +520,7 @@ function buildFunnelEvents(context, sequence, chosenFunnelProps, bindPropsIndex,
|
|
|
505
520
|
return {
|
|
506
521
|
event: "$experiment_started",
|
|
507
522
|
properties: {
|
|
523
|
+
...config.superProps,
|
|
508
524
|
"Experiment name": experimentName,
|
|
509
525
|
"Variant name": experimentVariant
|
|
510
526
|
}
|
|
@@ -114,6 +114,9 @@ export function applyLifecycleWave(events, uid, opts) {
|
|
|
114
114
|
* For users where `hashFloat(uid) < share`, injects the `path` sequence —
|
|
115
115
|
* each step cloned from the user's OWN existing event of that name — right
|
|
116
116
|
* after the user's FIRST `anchor` occurrence, with tight monotonic gaps.
|
|
117
|
+
* This is append-only: original traffic is never moved or removed. Events
|
|
118
|
+
* already between the anchor and clones can interrupt the immediate branch.
|
|
119
|
+
* `share` selects injection recipients, not the measured Flows branch share.
|
|
117
120
|
*
|
|
118
121
|
* Why these rules (HOOKS.md §2.17): Flows' unique mode reads only the FIRST
|
|
119
122
|
* flow per user, so the injection anchors on the first occurrence; gaps are
|
|
@@ -192,12 +195,23 @@ export function applyPathBias(events, uid, opts) {
|
|
|
192
195
|
* Boundary guarantees (what makes derived sessions deterministic against
|
|
193
196
|
* jitter — HOOKS.md §2.13): intra-session gaps stay well under Mixpanel's
|
|
194
197
|
* 30-min timeout (even spacing capped at 20min + bounded jitter, worst case
|
|
195
|
-
* <28min); inter-session gaps stay
|
|
196
|
-
* days when possible; same-day
|
|
197
|
-
*
|
|
198
|
+
* <28min); with explicit bounds, inter-session gaps stay strictly over it (sessions land on distinct
|
|
199
|
+
* days when possible; same-day slots reserve at least 30min + 1ms between
|
|
200
|
+
* clusters, compressing their spans when necessary);
|
|
198
201
|
* and no engineered session crosses UTC midnight (a day-boundary split would
|
|
199
202
|
* cut it — `session_query.cpp` daySplit). Valid precisely because of P2.1:
|
|
200
203
|
* session_ids are re-derived after the everything hook.
|
|
204
|
+
* With both bounds omitted, placement keeps the legacy full-UTC-day slots
|
|
205
|
+
* between the user's first and last active days. Overfull legacy requests
|
|
206
|
+
* still run, but their clusters can merge under the 30-minute timeout.
|
|
207
|
+
* Optional bounds are additive: supply known dataset limits to constrain
|
|
208
|
+
* placement; the helper cannot infer datasetEnd from a user's last event.
|
|
209
|
+
* An omitted side defaults to the corresponding full UTC day edge. Partial
|
|
210
|
+
* days compress clusters, including zero-span clusters. Only explicit-bound
|
|
211
|
+
* mode throws for insufficient per-week capacity, before any record changes.
|
|
212
|
+
* The helper never silently reduces the cluster target or drops events;
|
|
213
|
+
* legacy full-day placement can exceed an unknown dataset end and be clipped
|
|
214
|
+
* later by the engine. Pass metadata bounds to prevent that clipping.
|
|
201
215
|
*
|
|
202
216
|
* @param {Array<Object>} events - Full user event array (everything hook).
|
|
203
217
|
* @param {string} uid - Unused for hashing here; kept for atom-signature
|
|
@@ -207,10 +221,15 @@ export function applyPathBias(events, uid, opts) {
|
|
|
207
221
|
* @param {number} opts.sessionsPerWeek - Target clusters per week (≥1).
|
|
208
222
|
* @param {number} opts.eventsPerSession - Target events per cluster (≥1).
|
|
209
223
|
* @param {number} opts.sessionMinutes - Max cluster span in minutes.
|
|
224
|
+
* @param {string|number} [opts.datasetStart] - Inclusive lower bound (ISO,
|
|
225
|
+
* unix seconds, or unix milliseconds); accepts meta.datasetStart directly.
|
|
226
|
+
* @param {string|number} [opts.datasetEnd] - Inclusive upper bound (same units);
|
|
227
|
+
* accepts meta.datasetEnd directly.
|
|
228
|
+
* @throws {RangeError} Invalid explicit bounds or insufficient bounded 30-minute-session capacity.
|
|
210
229
|
* @returns {Array<Object>} The SAME array, timestamps rewritten.
|
|
211
230
|
*/
|
|
212
231
|
export function applySessionShape(events, uid, opts) {
|
|
213
|
-
const { sessionsPerWeek, eventsPerSession, sessionMinutes } = opts || {};
|
|
232
|
+
const { sessionsPerWeek, eventsPerSession, sessionMinutes, datasetStart, datasetEnd } = opts || {};
|
|
214
233
|
if (!Array.isArray(events) || !events.length) return events;
|
|
215
234
|
if (!isPos(sessionsPerWeek) || !isPos(eventsPerSession) || !isPos(sessionMinutes)) return events;
|
|
216
235
|
|
|
@@ -221,6 +240,13 @@ export function applySessionShape(events, uid, opts) {
|
|
|
221
240
|
const N = timed.length;
|
|
222
241
|
const firstMs = toMs(timed[0].time);
|
|
223
242
|
const lastMs = toMs(timed[N - 1].time);
|
|
243
|
+
const bounded = datasetStart !== undefined || datasetEnd !== undefined;
|
|
244
|
+
const lowerMs = datasetStart === undefined ? Math.floor(firstMs / DAY_MS) * DAY_MS : toMs(datasetStart);
|
|
245
|
+
const upperMs = datasetEnd === undefined ? (Math.floor(lastMs / DAY_MS) + 1) * DAY_MS - 1 : toMs(datasetEnd);
|
|
246
|
+
if (!Number.isFinite(lowerMs) || !Number.isFinite(upperMs) || lowerMs > upperMs ||
|
|
247
|
+
Math.abs(lowerMs) > 8.64e15 || Math.abs(upperMs) > 8.64e15) {
|
|
248
|
+
throw new RangeError('applySessionShape: invalid dataset bounds');
|
|
249
|
+
}
|
|
224
250
|
const weeks = Math.max(1, Math.ceil((lastMs - firstMs + 1) / WEEK_MS));
|
|
225
251
|
const numSessions = Math.max(1, Math.min(
|
|
226
252
|
Math.floor(sessionsPerWeek) * weeks,
|
|
@@ -235,6 +261,15 @@ export function applySessionShape(events, uid, opts) {
|
|
|
235
261
|
const chance = getChance();
|
|
236
262
|
const firstDay = Math.floor(firstMs / DAY_MS);
|
|
237
263
|
const lastDay = Math.floor(lastMs / DAY_MS);
|
|
264
|
+
const separationMs = 30 * MIN_MS + 1;
|
|
265
|
+
const perDayCount = new Map();
|
|
266
|
+
const dayBounds = day => [Math.ceil(Math.max(day * DAY_MS, lowerMs)),
|
|
267
|
+
Math.floor(Math.min((day + 1) * DAY_MS - 1, upperMs))];
|
|
268
|
+
const remainingCapacity = day => {
|
|
269
|
+
if (!bounded) return Infinity;
|
|
270
|
+
const [start, end] = dayBounds(day);
|
|
271
|
+
return (end >= start ? Math.floor((end - start) / separationMs) + 1 : 0) - (perDayCount.get(day) || 0);
|
|
272
|
+
};
|
|
238
273
|
|
|
239
274
|
// Pick one day per session, week by week: prefer the user's original
|
|
240
275
|
// active days in the week, fill from the rest of the week's days, and
|
|
@@ -251,22 +286,38 @@ export function applySessionShape(events, uid, opts) {
|
|
|
251
286
|
const t = toMs(ev.time);
|
|
252
287
|
if (t >= weekStartMs && t < weekStartMs + WEEK_MS) originalDays.add(Math.floor(t / DAY_MS));
|
|
253
288
|
}
|
|
254
|
-
const pool = [...originalDays].filter(d => d >= dayLo && d <= dayHi);
|
|
289
|
+
const pool = [...originalDays].filter(d => d >= dayLo && d <= dayHi && remainingCapacity(d) > 0);
|
|
255
290
|
const others = [];
|
|
256
|
-
for (let d = dayLo; d <= dayHi; d++) if (!originalDays.has(d)) others.push(d);
|
|
291
|
+
for (let d = dayLo; d <= dayHi; d++) if (!originalDays.has(d) && remainingCapacity(d) > 0) others.push(d);
|
|
257
292
|
const picked = chance.pickset(pool, Math.min(need, pool.length));
|
|
258
293
|
if (picked.length < need) picked.push(...chance.pickset(others, Math.min(need - picked.length, others.length)));
|
|
259
|
-
|
|
260
|
-
|
|
294
|
+
for (const day of picked) perDayCount.set(day, (perDayCount.get(day) || 0) + 1);
|
|
295
|
+
const candidates = [...pool, ...others];
|
|
296
|
+
while (picked.length < need) {
|
|
297
|
+
if (!bounded) {
|
|
298
|
+
let reuseIndex = 0;
|
|
299
|
+
while (picked.length < need) {
|
|
300
|
+
const day = picked[reuseIndex++ % Math.max(1, picked.length)];
|
|
301
|
+
picked.push(day);
|
|
302
|
+
perDayCount.set(day, (perDayCount.get(day) || 0) + 1);
|
|
303
|
+
}
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
const available = candidates.filter(day => remainingCapacity(day) > 0);
|
|
307
|
+
if (!available.length) {
|
|
308
|
+
throw new RangeError(`applySessionShape: insufficient session capacity in week ${w + 1} for ${need} clusters`);
|
|
309
|
+
}
|
|
310
|
+
for (const day of available) {
|
|
311
|
+
picked.push(day);
|
|
312
|
+
perDayCount.set(day, (perDayCount.get(day) || 0) + 1);
|
|
313
|
+
if (picked.length === need) break;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
261
316
|
picked.sort((a, b) => a - b);
|
|
262
317
|
sessionDays.push(...picked);
|
|
263
318
|
}
|
|
264
319
|
|
|
265
|
-
//
|
|
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);
|
|
320
|
+
// Bound each day's slots and reserve the strict timeout gap between them.
|
|
270
321
|
const perDaySeen = new Map();
|
|
271
322
|
const sesMs = sessionMinutes * MIN_MS;
|
|
272
323
|
|
|
@@ -280,10 +331,15 @@ export function applySessionShape(events, uid, opts) {
|
|
|
280
331
|
const j = perDaySeen.get(day) || 0;
|
|
281
332
|
perDaySeen.set(day, j + 1);
|
|
282
333
|
|
|
283
|
-
const
|
|
284
|
-
const
|
|
285
|
-
const
|
|
286
|
-
const
|
|
334
|
+
const [dayStart, dayEnd] = dayBounds(day);
|
|
335
|
+
const seg = (dayEnd - dayStart - (m - 1) * separationMs) / m;
|
|
336
|
+
const slotStart = Math.floor(dayStart + j * (seg + separationMs));
|
|
337
|
+
const slotEnd = Math.floor(dayStart + (j + 1) * seg + j * separationMs);
|
|
338
|
+
const slotWidth = bounded ? slotEnd - slotStart : DAY_MS / m;
|
|
339
|
+
const span = bounded ? Math.floor(Math.min(sesMs, slotWidth * 0.5)) : Math.min(sesMs, slotWidth * 0.5);
|
|
340
|
+
const center = bounded ? slotStart + Math.floor((slotWidth - span) / 2)
|
|
341
|
+
: day * DAY_MS + j * slotWidth + (slotWidth - span) / 2;
|
|
342
|
+
const q = Math.floor((slotWidth - span) / 4);
|
|
287
343
|
const start = center + (q > 0 ? chance.integer({ min: -q, max: q }) : 0);
|
|
288
344
|
|
|
289
345
|
if (size === 1) {
|
|
@@ -43,9 +43,10 @@ import { getChance } from '../utils/utils.js';
|
|
|
43
43
|
* last touch BEFORE THE CONVERSION EVENT (lookback bounded by the
|
|
44
44
|
* conversion timestamp — get_last_value, whoval/read.cpp:643-655). If the
|
|
45
45
|
* user has stamped touches AFTER their conversion, the touch this pattern
|
|
46
|
-
* biases is not the one the report reads. `firstTouch`
|
|
47
|
-
*
|
|
48
|
-
*
|
|
46
|
+
* biases is not the one the report reads. `firstTouch` also requires the
|
|
47
|
+
* lifetime-first touch to remain inside the report's lookback. Callers must
|
|
48
|
+
* select eligible history when a conversion-bounded report is intended.
|
|
49
|
+
* Equal-time conflicting values have no stable cross-ingestion ordering.
|
|
49
50
|
* @returns {{ overwritten: number, touches: number }} `touches` = stamped
|
|
50
51
|
* events found; `overwritten` = touches whose value was replaced.
|
|
51
52
|
*/
|
|
@@ -66,14 +66,11 @@ export function applyFunnelFrequencyBreakdown(allUserEvents, _profile, funnelEve
|
|
|
66
66
|
}
|
|
67
67
|
if (!stepName) return { bin, droppedFinal: false };
|
|
68
68
|
|
|
69
|
-
// Deterministic drop decision without consuming the seeded RNG stream:
|
|
70
|
-
// hash the funnel's first insert_id (fallback: time) through
|
|
71
|
-
// simpleHashFloat — FNV-1a over the full string, quantized to 1/1000 —
|
|
72
|
-
// and drop when the hash lands under (1 - keepRate). Per-funnel stable,
|
|
73
|
-
// so reruns with the same seed drop the same funnels.
|
|
74
69
|
const dropProb = 1 - keepRate;
|
|
75
|
-
const
|
|
76
|
-
const
|
|
70
|
+
const anchor = funnelEvents[0] || {};
|
|
71
|
+
const seed = JSON.stringify([anchor.user_id || anchor.device_id || anchor.distinct_id || '',
|
|
72
|
+
anchor.event || '', anchor.time || '', stepName]);
|
|
73
|
+
const det = simpleHashFloat(seed);
|
|
77
74
|
if (det < dropProb) {
|
|
78
75
|
const before = funnelEvents.length;
|
|
79
76
|
dropEventsWhere(funnelEvents, e => e && e.event === stepName);
|
|
@@ -11,7 +11,7 @@ import pLimit from 'p-limit';
|
|
|
11
11
|
import os from 'os';
|
|
12
12
|
import * as u from "../utils/utils.js";
|
|
13
13
|
import * as t from 'ak-tools';
|
|
14
|
-
import { makeEvent } from "../generators/events.js";
|
|
14
|
+
import { makeEvent, engineIdentity } from "../generators/events.js";
|
|
15
15
|
import { makeFunnel } from "../generators/funnels.js";
|
|
16
16
|
import { makeUserProfile } from "../generators/profiles.js";
|
|
17
17
|
import { makeSCD } from "../generators/scd.js";
|
|
@@ -403,6 +403,8 @@ export async function userLoop(context) {
|
|
|
403
403
|
}
|
|
404
404
|
|
|
405
405
|
let userFirstEventTime;
|
|
406
|
+
let userUsageStartTime = context.FIXED_BEGIN;
|
|
407
|
+
const promisedAttemptEntries = new Set();
|
|
406
408
|
|
|
407
409
|
// ── v1.5 Active-day scheduling ──
|
|
408
410
|
// When `avgActiveDaysPerUser` is set, build a per-user day plan: a list
|
|
@@ -493,13 +495,9 @@ export async function userLoop(context) {
|
|
|
493
495
|
// Active-day mode: anchor the first funnel on a picked day so the user's
|
|
494
496
|
// signup lands within their planned active window. Legacy mode: anchor at
|
|
495
497
|
// adjustedCreated minus a noise offset.
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
cursor = bounds ? bounds.earliest : adjustedCreated.subtract(noise(), 'seconds').unix();
|
|
500
|
-
} else {
|
|
501
|
-
cursor = adjustedCreated.subtract(noise(), 'seconds').unix();
|
|
502
|
-
}
|
|
498
|
+
const lifecycleStart = Math.max(adjustedCreated.valueOf() / 1000, context.FIXED_BEGIN);
|
|
499
|
+
const firstDayBounds = dayPlan && !hasRetentionCurve ? nextDayBounds() : null;
|
|
500
|
+
let cursor = Math.max(lifecycleStart, firstDayBounds?.earliest ?? lifecycleStart);
|
|
503
501
|
|
|
504
502
|
// Resolve attempts plan. `attempts.{min,max}` count FAILED PRIORS; total
|
|
505
503
|
// passes = failedPriors + 1. Validator coerced bounds; default both 0.
|
|
@@ -509,6 +507,7 @@ export async function userLoop(context) {
|
|
|
509
507
|
const failedPriors = (maxA > 0) ? chance.integer({ min: minA, max: maxA }) : 0;
|
|
510
508
|
const totalAttempts = failedPriors + 1;
|
|
511
509
|
let firstAttemptFirstEventTime = null;
|
|
510
|
+
if (failedPriors > 0) cursor = lifecycleStart;
|
|
512
511
|
|
|
513
512
|
for (let attemptNum = 1; attemptNum <= totalAttempts; attemptNum++) {
|
|
514
513
|
const isFinal = attemptNum === totalAttempts;
|
|
@@ -528,16 +527,23 @@ export async function userLoop(context) {
|
|
|
528
527
|
truncateBeforeAuth: !isFinal,
|
|
529
528
|
devicePool: userDevicePool,
|
|
530
529
|
};
|
|
530
|
+
const firstFeatureCtx = {
|
|
531
|
+
...featureCtx,
|
|
532
|
+
latestTime: firstDayBounds && failedPriors === 0 ? firstDayBounds.latest : context.FIXED_NOW,
|
|
533
|
+
pinFirstTime: hasRetentionCurve || failedPriors > 0,
|
|
534
|
+
};
|
|
531
535
|
const [data, converted, authMs] = await makeFunnel(
|
|
532
|
-
context, funnelToRun, user, cursor, profile, userSCD, persona,
|
|
536
|
+
context, funnelToRun, user, cursor, profile, userSCD, persona, firstFeatureCtx, attemptMeta
|
|
533
537
|
);
|
|
534
538
|
if (isFinal) userConverted = converted;
|
|
535
539
|
if (data && data.length) {
|
|
540
|
+
if (failedPriors > 0) promisedAttemptEntries.add(data[0].insert_id);
|
|
536
541
|
if (firstAttemptFirstEventTime === null) {
|
|
537
542
|
firstAttemptFirstEventTime = dayjs(data[0].time).unix();
|
|
538
543
|
}
|
|
539
544
|
// Advance the cursor for the next attempt by a small abandon-and-retry gap.
|
|
540
|
-
const lastTime =
|
|
545
|
+
const lastTime = Math.max(...data.map(event => Date.parse(event.time))) / 1000;
|
|
546
|
+
userUsageStartTime = Math.max(userUsageStartTime, lastTime + 0.001);
|
|
541
547
|
cursor = lastTime + chance.integer({ min: 60, max: 30 * 60 }); // 1–30 min later
|
|
542
548
|
numEventsPreformed += data.length;
|
|
543
549
|
usersEvents = usersEvents.concat(data);
|
|
@@ -547,6 +553,13 @@ export async function userLoop(context) {
|
|
|
547
553
|
if (userAuthTimeMs === null) userAuthTimeMs = authMs;
|
|
548
554
|
userAuthed = true;
|
|
549
555
|
}
|
|
556
|
+
if (!isFinal && !data.length && config.events.find(event => event.event === firstFunnel.sequence[0])?.isAuthEvent) {
|
|
557
|
+
context.addWarning({
|
|
558
|
+
key: 'lifecycle.emptyPreAuthAttempt',
|
|
559
|
+
requested: 1, applied: 0, severity: 'warn',
|
|
560
|
+
reason: 'A failed prior has no pre-auth step because auth starts the funnel; the prior emits no rows and the final attempt still runs.',
|
|
561
|
+
});
|
|
562
|
+
}
|
|
550
563
|
}
|
|
551
564
|
|
|
552
565
|
userFirstEventTime = firstAttemptFirstEventTime !== null
|
|
@@ -614,6 +627,8 @@ export async function userLoop(context) {
|
|
|
614
627
|
// gets re-anchored to the picked day's start (subsequent funnel steps
|
|
615
628
|
// spill within `timeToConvert` hours; this is intentional).
|
|
616
629
|
const dayBounds = dayPlan ? nextDayBounds() : null;
|
|
630
|
+
const usageEarliest = Math.max(dayBounds?.earliest ?? userFirstEventTime, userUsageStartTime);
|
|
631
|
+
if (usageEarliest > (dayBounds?.latest ?? context.FIXED_NOW)) continue;
|
|
617
632
|
// Compute step1's `latestTime` so the funnel's relative span fits before
|
|
618
633
|
// FIXED_NOW. Without this, born-late users + long-ttc funnels generate
|
|
619
634
|
// large numbers of `_drop`'d events that consume budget cycles. The
|
|
@@ -639,7 +654,7 @@ export async function userLoop(context) {
|
|
|
639
654
|
const ttcSec = (currentFunnel.timeToConvert || 0) * 3600;
|
|
640
655
|
// Anchor cursor at picked day's start when active-day mode is on,
|
|
641
656
|
// otherwise pass userFirstEventTime (constant). NO cursor accumulation.
|
|
642
|
-
const funnelCursor =
|
|
657
|
+
const funnelCursor = usageEarliest;
|
|
643
658
|
// Constrain funnel step1's TimeSoup latestTime so the full funnel fits in
|
|
644
659
|
// window. Without this, late steps spill past FIXED_NOW and get `_drop`'d.
|
|
645
660
|
//
|
|
@@ -683,7 +698,7 @@ export async function userLoop(context) {
|
|
|
683
698
|
// — passing `true` here would pin every standalone event to the same
|
|
684
699
|
// `earliestTime`, which the now-deleted bunchIntoSessions used to paper
|
|
685
700
|
// over. With bunchIntoSessions removed, TimeSoup is the time source.
|
|
686
|
-
const standaloneEarliest =
|
|
701
|
+
const standaloneEarliest = usageEarliest;
|
|
687
702
|
// v1.5.1: pass `config.superProps` so standalone events get super-property
|
|
688
703
|
// stamping. Pre-1.5.1, standalone events received `{}` here, but the
|
|
689
704
|
// validator's auto-funnel (catch-all) consumed all non-strict events so
|
|
@@ -763,6 +778,8 @@ export async function userLoop(context) {
|
|
|
763
778
|
for (const ev of usersEvents) {
|
|
764
779
|
if (chance.bool({ likelihood: dataQuality.duplicateRate * 100 })) {
|
|
765
780
|
const dupe = { ...ev };
|
|
781
|
+
const identityDescriptor = Object.getOwnPropertyDescriptor(ev, engineIdentity);
|
|
782
|
+
if (identityDescriptor) Object.defineProperty(dupe, engineIdentity, identityDescriptor);
|
|
766
783
|
dupe.time = dayjs(ev.time).add(chance.integer({ min: 1, max: 60 }), 'seconds').toISOString();
|
|
767
784
|
dupe.insert_id = randomUUID();
|
|
768
785
|
dupes.push(dupe);
|
|
@@ -828,6 +845,13 @@ export async function userLoop(context) {
|
|
|
828
845
|
profile._drop = true;
|
|
829
846
|
}
|
|
830
847
|
|
|
848
|
+
const identityBeforeEverything = new Map(usersEvents.map(event => [event.insert_id, {
|
|
849
|
+
user_id: event.user_id, device_id: event.device_id,
|
|
850
|
+
original: event[engineIdentity],
|
|
851
|
+
}]));
|
|
852
|
+
const profileDropBeforeEverything = profile._drop;
|
|
853
|
+
let profileDropOverridden = false;
|
|
854
|
+
|
|
831
855
|
// Hook for processing all user events (hooks override everything)
|
|
832
856
|
if (config.hook) {
|
|
833
857
|
// `meta.isPreAuth(event)` predicate bound to this user's auth state.
|
|
@@ -843,7 +867,16 @@ export async function userLoop(context) {
|
|
|
843
867
|
return Number.isFinite(t) ? t < userAuthTimeMsLocal : false;
|
|
844
868
|
};
|
|
845
869
|
const newEvents = await config.hook(usersEvents, "everything", {
|
|
846
|
-
profile,
|
|
870
|
+
profile: new Proxy(profile, {
|
|
871
|
+
deleteProperty(target, key) {
|
|
872
|
+
if (key === '_drop') profileDropOverridden = true;
|
|
873
|
+
return Reflect.deleteProperty(target, key);
|
|
874
|
+
},
|
|
875
|
+
set(target, key, value) {
|
|
876
|
+
if (key === '_drop') profileDropOverridden = true;
|
|
877
|
+
return Reflect.set(target, key, value);
|
|
878
|
+
},
|
|
879
|
+
}),
|
|
847
880
|
scd: userSCD,
|
|
848
881
|
config,
|
|
849
882
|
userIsBornInDataset,
|
|
@@ -944,13 +977,44 @@ export async function userLoop(context) {
|
|
|
944
977
|
// stream to the remaining room with a seeded uniform sample (keeps the
|
|
945
978
|
// time shape; preserves array order). Only under the flag.
|
|
946
979
|
if (strictEventCount && usersEvents.length > 0) {
|
|
980
|
+
const promisedEntries = usersEvents.filter(event => promisedAttemptEntries.has(event.insert_id));
|
|
947
981
|
const room = numEvents - context.getStoredEventCount();
|
|
948
982
|
if (room <= 0) {
|
|
949
983
|
usersEvents = [];
|
|
950
984
|
} else if (usersEvents.length > room) {
|
|
951
|
-
const
|
|
985
|
+
const reserved = promisedEntries.slice(0, room);
|
|
986
|
+
const others = usersEvents.filter(event => !promisedAttemptEntries.has(event.insert_id));
|
|
987
|
+
const keep = new Set([...reserved, ...chance.pickset(others, Math.max(0, room - reserved.length))]);
|
|
952
988
|
usersEvents = usersEvents.filter(e => keep.has(e));
|
|
953
989
|
}
|
|
990
|
+
if (promisedEntries.some(event => !usersEvents.includes(event))) {
|
|
991
|
+
context.addWarning({
|
|
992
|
+
key: 'lifecycle.strictAttemptBudget',
|
|
993
|
+
requested: promisedEntries.length, applied: Math.max(0, room), severity: 'warn',
|
|
994
|
+
reason: 'The remaining strict event budget is smaller than the surviving first-funnel attempt entries; strictEventCount takes precedence and later entries are omitted.',
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (userIsBornInDataset && userDevicePool?.length) {
|
|
1000
|
+
const authNames = new Set(config.events.filter(event => event.isAuthEvent).map(event => event.event));
|
|
1001
|
+
const stitches = usersEvents.filter(event => authNames.has(event.event) && event.user_id && event.device_id);
|
|
1002
|
+
const emittedAuthTime = stitches.length ? Math.min(...stitches.map(event => Date.parse(event.time))) : null;
|
|
1003
|
+
for (const event of usersEvents) {
|
|
1004
|
+
if (event.event === '$experiment_started') continue;
|
|
1005
|
+
if (emittedAuthTime === null || Date.parse(event.time) < emittedAuthTime) {
|
|
1006
|
+
const before = identityBeforeEverything.get(event.insert_id);
|
|
1007
|
+
const original = event[engineIdentity] || before?.original;
|
|
1008
|
+
if (!original) continue;
|
|
1009
|
+
const explicitUser = event.user_id !== original.user_id;
|
|
1010
|
+
const explicitDevice = before
|
|
1011
|
+
? event.device_id !== before.device_id || (!before.device_id && !!original.device_id)
|
|
1012
|
+
: event.device_id !== original.device_id;
|
|
1013
|
+
if (!explicitUser) delete event.user_id;
|
|
1014
|
+
if (!explicitUser && !explicitDevice) event.device_id ||= userDevicePool[0];
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
if (emittedAuthTime === null && !profileDropOverridden && profile._drop === profileDropBeforeEverything) profile._drop = true;
|
|
954
1018
|
}
|
|
955
1019
|
|
|
956
1020
|
// Store all user data (skip profile push when a hook returned null
|
|
@@ -1108,7 +1172,10 @@ export function amplifyWorldEvents(events, worldEvents, chance, fixedNow) {
|
|
|
1108
1172
|
if (frac > 0 && chance.bool({ likelihood: frac * 100 })) copies++;
|
|
1109
1173
|
for (let c = 0; c < copies; c++) {
|
|
1110
1174
|
const t = chance.integer({ min: windowStart, max: windowEnd - 1 });
|
|
1111
|
-
|
|
1175
|
+
const clone = { ...ev, time: dayjs.unix(t).toISOString(), insert_id: randomUUID() };
|
|
1176
|
+
const identityDescriptor = Object.getOwnPropertyDescriptor(ev, engineIdentity);
|
|
1177
|
+
if (identityDescriptor) Object.defineProperty(clone, engineIdentity, identityDescriptor);
|
|
1178
|
+
clones.push(clone);
|
|
1112
1179
|
}
|
|
1113
1180
|
}
|
|
1114
1181
|
}
|
package/lib/verify/counting.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { toMs } from '../hook-helpers/_internal.js';
|
|
14
14
|
import { matchesWhere } from './coerce.js';
|
|
15
|
-
import {
|
|
15
|
+
import { buildEventIdentityMap, resolveUserId } from './identity.js';
|
|
16
16
|
|
|
17
17
|
const SECONDS_PER_UNIT = {
|
|
18
18
|
hour: 3600,
|
|
@@ -27,8 +27,8 @@ const SECONDS_PER_UNIT = {
|
|
|
27
27
|
* them for what they are:
|
|
28
28
|
*
|
|
29
29
|
* 1. **`'ui-bucket'`** (default): `COUNT(DISTINCT date_trunc(unit, time))`
|
|
30
|
-
* in UTC
|
|
31
|
-
*
|
|
30
|
+
* in UTC. This measures calendar activity and remains the compatibility
|
|
31
|
+
* default; raw-count histograms and rolling frequency use other rules.
|
|
32
32
|
*
|
|
33
33
|
* 2. **`'mixpanel-rolling'`**: the addiction_query.cpp rule,
|
|
34
34
|
* `qtz_time >= interval->last_counted + seconds_for_unit(unit)`
|
|
@@ -39,8 +39,7 @@ const SECONDS_PER_UNIT = {
|
|
|
39
39
|
* calendar days.
|
|
40
40
|
*
|
|
41
41
|
* **The default does NOT match addiction_query.cpp's rolling rule.** It is
|
|
42
|
-
*
|
|
43
|
-
* aligns with `injectOnNewDays` (which classifies days by
|
|
42
|
+
* retained for compatibility with `injectOnNewDays` (which classifies days by
|
|
44
43
|
* `Math.floor(t / DAY_MS)`); mixing the two algorithms makes the atom and
|
|
45
44
|
* verifier disagree at boundaries. For the actual Frequency report output
|
|
46
45
|
* shape (per-interval rolling counters + histogram), use
|
|
@@ -69,8 +68,6 @@ export function countDistinctPeriods(events, eventName, unit = 'day', options =
|
|
|
69
68
|
const unitMs = seconds * 1000;
|
|
70
69
|
|
|
71
70
|
if (algorithm === 'ui-bucket') {
|
|
72
|
-
// Calendar bucket — UTC floor by unit. Matches what Mixpanel's UI
|
|
73
|
-
// shows and what `injectOnNewDays` uses internally.
|
|
74
71
|
const buckets = new Set();
|
|
75
72
|
for (const t of matches) buckets.add(Math.floor(t / unitMs));
|
|
76
73
|
return buckets.size;
|
|
@@ -500,13 +497,13 @@ export function binByDistinctPeriods(events, eventName, bins, unit = 'day', opti
|
|
|
500
497
|
* @param {string} [options.event] - Event name to count. Required at runtime — throws if missing.
|
|
501
498
|
* @param {('hour'|'day'|'week')} [options.unit='day'] - Rolling unit.
|
|
502
499
|
* @param {number} [options.intervalDays] - Report interval length in days (positive integer). Required at runtime.
|
|
503
|
-
* @param {Object[]} [options.profiles] -
|
|
500
|
+
* @param {Object[]} [options.profiles] - Accepted for compatibility; identity uses emitted both-ID events.
|
|
504
501
|
* @returns {Array<{ interval: string, histogram: number[] }>} One row per
|
|
505
502
|
* interval (label = ISO date of the interval start); `histogram[i]` =
|
|
506
503
|
* number of users with rolling count `i + 1` in that interval.
|
|
507
504
|
*/
|
|
508
505
|
export function frequencyHistogram(events, options = {}) {
|
|
509
|
-
const { event, unit = 'day', intervalDays
|
|
506
|
+
const { event, unit = 'day', intervalDays } = options;
|
|
510
507
|
if (typeof event !== 'string' || !event) {
|
|
511
508
|
throw new Error('frequencyHistogram: event is required');
|
|
512
509
|
}
|
|
@@ -517,7 +514,7 @@ export function frequencyHistogram(events, options = {}) {
|
|
|
517
514
|
}
|
|
518
515
|
if (!Array.isArray(events) || !events.length) return [];
|
|
519
516
|
|
|
520
|
-
const identityMap =
|
|
517
|
+
const identityMap = buildEventIdentityMap(events);
|
|
521
518
|
let minMs = Infinity, maxMs = -Infinity;
|
|
522
519
|
const matches = [];
|
|
523
520
|
for (const e of events) {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { toMs } from '../hook-helpers/_internal.js';
|
|
25
25
|
import { evaluateFunnel, evaluateFunnelHPC, evaluateAnyOrderCompletion } from './funnel-engine.js';
|
|
26
|
-
import {
|
|
26
|
+
import { buildEventIdentityMap, resolveUserId } from './identity.js';
|
|
27
27
|
import { coerceToBreakdownKey, breakdownSegmentKey, matchesWhere } from './coerce.js';
|
|
28
28
|
import { filterFirstTimeEver } from './first-time.js';
|
|
29
29
|
import { sessionize } from './sessionize.js';
|
|
@@ -277,18 +277,7 @@ export function emulateBreakdown(events, config) {
|
|
|
277
277
|
if (!Array.isArray(events)) throw new Error('emulateBreakdown: events must be an array');
|
|
278
278
|
if (!config || !config.type) throw new Error('emulateBreakdown: config.type is required');
|
|
279
279
|
|
|
280
|
-
|
|
281
|
-
// breakdown type AND every time-bucket recursive call so pre-auth (device_id
|
|
282
|
-
// only) events resolve to the same canonical user as post-auth (user_id)
|
|
283
|
-
// events. Hoisted above the timeBucket dispatch to avoid rebuilding the
|
|
284
|
-
// map per-bucket on large datasets.
|
|
285
|
-
const identityMap = config.identityMap
|
|
286
|
-
|| (Array.isArray(config.profiles)
|
|
287
|
-
&& config.profiles.some(p =>
|
|
288
|
-
p && ((Array.isArray(p.device_ids) && p.device_ids.length)
|
|
289
|
-
|| (Array.isArray(p.anonymousIds) && p.anonymousIds.length)))
|
|
290
|
-
? buildIdentityMap(config.profiles)
|
|
291
|
-
: undefined);
|
|
280
|
+
const identityMap = config.identityMap || buildEventIdentityMap(events);
|
|
292
281
|
|
|
293
282
|
// v1.5: time-bucketed wrapper. Partition events by UTC bucket, run the
|
|
294
283
|
// underlying breakdown per partition, tag rows with `period`.
|
|
@@ -318,12 +307,35 @@ export function emulateBreakdown(events, config) {
|
|
|
318
307
|
throw new Error('emulateBreakdown: type "topPaths" does not compose with timeBucket — flows aggregate one path universe over the range');
|
|
319
308
|
}
|
|
320
309
|
const range = config.timeBucketRange || {};
|
|
321
|
-
const
|
|
310
|
+
const bucketEvents = config.type === 'attributedBy'
|
|
311
|
+
? events.filter(event => event && event.event === config.conversionEvent)
|
|
312
|
+
: events;
|
|
313
|
+
const buckets = partitionByTimeBucket(bucketEvents, config.timeBucket, range);
|
|
322
314
|
// Pass the pre-built identityMap into recursive calls so the auto-build
|
|
323
315
|
// branch above is a no-op per bucket (would otherwise rebuild N times).
|
|
324
316
|
const inner = { ...config, timeBucket: undefined, timeBucketRange: undefined, identityMap };
|
|
325
317
|
const out = [];
|
|
326
318
|
|
|
319
|
+
if (config.type === 'attributedBy') {
|
|
320
|
+
attributedBy([], {
|
|
321
|
+
conversionEvent: config.conversionEvent,
|
|
322
|
+
attributionEvent: config.attributionEvent,
|
|
323
|
+
attributionProperty: config.attributionProperty,
|
|
324
|
+
model: config.model,
|
|
325
|
+
perConversion: config.perConversion,
|
|
326
|
+
identityMap,
|
|
327
|
+
});
|
|
328
|
+
for (const { period } of buckets) {
|
|
329
|
+
const rows = attributedBy(events, /** @type {*} */ (inner), bucketBoundsMs(period, config.timeBucket));
|
|
330
|
+
if (rows.length) {
|
|
331
|
+
for (const row of rows) out.push({ period, ...row });
|
|
332
|
+
} else {
|
|
333
|
+
out.push({ period, _empty: true });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
|
|
327
339
|
// v1.6.0 (P1.6.5): step-0-anchored trend types. Mixpanel evaluates each
|
|
328
340
|
// trend interval as "step 0 in [start, stop); steps 1+ in
|
|
329
341
|
// [start, stop + conversion window)" (funnel_query.cpp:1398-1401), and
|
|
@@ -483,13 +495,6 @@ function funnelFrequency(events, { steps, breakdownByFrequencyOf, conversionWind
|
|
|
483
495
|
if ((isTotalsMode || holdPropertyConstant) && !isSequentialOrder) {
|
|
484
496
|
throw new Error(`funnelFrequency: countMode '${countMode}' / holdPropertyConstant require a sequential funnel order (got '${funnelOrder}')`);
|
|
485
497
|
}
|
|
486
|
-
// HPC buckets events by property value BEFORE evaluation (evaluateFunnelHPC),
|
|
487
|
-
// but Mixpanel derives session boundaries from the user's FULL event stream —
|
|
488
|
-
// sessionizing each bucket independently would merge across the gaps left by
|
|
489
|
-
// removed events and produce wrong ordinals. Refuse rather than mis-count.
|
|
490
|
-
if (holdPropertyConstant && (countMode === 'sessions' || conversionWindow)) {
|
|
491
|
-
throw new Error('funnelFrequency: holdPropertyConstant cannot combine with session-count conversion windows — session boundaries derive from the full event stream, but HPC evaluates per-property-value event subsets');
|
|
492
|
-
}
|
|
493
498
|
|
|
494
499
|
const userEvents = groupByUser(events, identityMap);
|
|
495
500
|
const result = [];
|
|
@@ -787,29 +792,43 @@ function attributedBy(events, {
|
|
|
787
792
|
model = 'firstTouch',
|
|
788
793
|
perConversion = 'first',
|
|
789
794
|
identityMap,
|
|
790
|
-
}) {
|
|
795
|
+
}, conversionRange = null) {
|
|
791
796
|
if (!conversionEvent || !attributionEvent || !attributionProperty) {
|
|
792
797
|
throw new Error('attributedBy requires conversionEvent, attributionEvent, attributionProperty');
|
|
793
798
|
}
|
|
794
799
|
if (perConversion !== 'first' && perConversion !== 'all') {
|
|
795
800
|
throw new Error(`attributedBy: unknown perConversion "${perConversion}" — use 'first' or 'all'`);
|
|
796
801
|
}
|
|
802
|
+
// Attribution is unbounded over supplied history; this API has no finite
|
|
803
|
+
// lookback. The private bucket interval limits conversions, never touches.
|
|
804
|
+
// Backend transitions also compress case-insensitive equal values within
|
|
805
|
+
// 30 minutes (value/transitions.cpp, whoval/util.cpp). This raw-value model
|
|
806
|
+
// does not emulate that compression, which can retain earlier string casing.
|
|
797
807
|
const userEvents = groupByUser(events, identityMap);
|
|
798
808
|
const counts = new Map();
|
|
799
809
|
for (const [, evs] of userEvents) {
|
|
800
810
|
const sorted = sortByTime(evs);
|
|
811
|
+
const inRange = sorted.filter(event => {
|
|
812
|
+
if (!event || event.event !== conversionEvent) return false;
|
|
813
|
+
const time = toMs(event.time);
|
|
814
|
+
return !conversionRange || (time >= conversionRange.startMs && time < conversionRange.endMs);
|
|
815
|
+
});
|
|
801
816
|
const conversions = perConversion === 'all'
|
|
802
|
-
?
|
|
803
|
-
:
|
|
817
|
+
? inRange
|
|
818
|
+
: inRange.slice(0, 1);
|
|
804
819
|
for (const conversion of conversions) {
|
|
805
820
|
const conversionTime = toMs(conversion.time);
|
|
806
|
-
|
|
807
|
-
|
|
821
|
+
// attribution.py's defined-property filter excludes null and undefined;
|
|
822
|
+
// whoval/read.cpp first/last include the conversion timestamp.
|
|
823
|
+
const allTouches = sorted.filter(event =>
|
|
824
|
+
event && event.event === attributionEvent && event[attributionProperty] != null
|
|
825
|
+
&& toMs(event.time) <= conversionTime
|
|
808
826
|
);
|
|
809
|
-
if (!allTouches.length) continue;
|
|
810
827
|
const touch = model === 'lastTouch' ? allTouches[allTouches.length - 1] : allTouches[0];
|
|
811
|
-
|
|
812
|
-
|
|
828
|
+
// Backend missing attribution is undefined. Preserve this API's existing
|
|
829
|
+
// 'unknown' label, not coerceToBreakdownKey's general 'undefined' label.
|
|
830
|
+
const value = touch?.[attributionProperty] ?? 'unknown';
|
|
831
|
+
counts.set(value, (counts.get(value) || 0) + 1);
|
|
813
832
|
}
|
|
814
833
|
}
|
|
815
834
|
return [...counts.entries()].map(([source, count]) => ({
|