@ak--47/dungeon-master 1.5.4 → 1.6.1
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 +1 -1
- package/.claude/skills/create-project/provision.mjs +57 -10
- package/.claude/skills/powertools/SKILL.md +75 -0
- package/.claude/skills/powertools/pt.mjs +91 -0
- package/.claude/skills/powertools/snapshot-project.mjs +124 -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 +224 -0
- package/HOOKS.md +566 -112
- package/README.md +30 -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 +7 -3
- 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 +151 -59
- 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 +2 -1
- 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 +298 -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
package/lib/utils/utils.js
CHANGED
|
@@ -36,9 +36,35 @@ let globalUserChance;
|
|
|
36
36
|
let userChanceInitialized = false;
|
|
37
37
|
|
|
38
38
|
// Module-scoped memoization cache for weighted-array resolvers in `choose()`.
|
|
39
|
-
//
|
|
39
|
+
// Key is the function source string; cleared per run by resetValueCaches().
|
|
40
|
+
// Functions tagged `noCache = true` (e.g. pickAWinner closures, whose source
|
|
41
|
+
// strings are identical across instances) bypass this cache entirely.
|
|
40
42
|
const weightedArrayCache = new Map();
|
|
41
43
|
|
|
44
|
+
// v1.6.1: per-run winner memo for unweighted string arrays. Two layers:
|
|
45
|
+
// a WeakMap keyed by array IDENTITY holding the full {idx, weights} entry
|
|
46
|
+
// (property arrays are stable references across a run, so the steady state is
|
|
47
|
+
// a single WeakMap hit per event — no join, no weight rebuild), plus a Map
|
|
48
|
+
// keyed by array CONTENTS so distinct array instances with equal contents
|
|
49
|
+
// share one winner. Guarantees ONE stable winner per array per run so
|
|
50
|
+
// aggregate distributions stay visibly skewed instead of cancelling to
|
|
51
|
+
// uniform. Cleared by resetValueCaches() at run start / initChance so winners
|
|
52
|
+
// never leak across runs or seeds.
|
|
53
|
+
let winnerEntryCache = new WeakMap();
|
|
54
|
+
const winnerCache = new Map();
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Clear the per-run value-resolution caches (winner memo + weighted-array
|
|
58
|
+
* resolver cache). Called by initChance() and at the top of every runDungeon
|
|
59
|
+
* so in-process back-to-back runs (different seeds, different users) never
|
|
60
|
+
* inherit a prior run's winners.
|
|
61
|
+
*/
|
|
62
|
+
function resetValueCaches() {
|
|
63
|
+
winnerEntryCache = new WeakMap();
|
|
64
|
+
winnerCache.clear();
|
|
65
|
+
weightedArrayCache.clear();
|
|
66
|
+
}
|
|
67
|
+
|
|
42
68
|
// v1.5.1: dataset-window state moved to AsyncLocalStorage scope
|
|
43
69
|
// (`lib/utils/dataset-context.js`). Each `generate()` call wraps the pipeline
|
|
44
70
|
// in `runWithDataset(begin, now, fn)`, and factory thunks read via the
|
|
@@ -146,6 +172,7 @@ function initChance(seed) {
|
|
|
146
172
|
if (!seed && process.env.SEED) seed = process.env.SEED;
|
|
147
173
|
globalChance = new Chance(seed);
|
|
148
174
|
chanceInitialized = true;
|
|
175
|
+
resetValueCaches();
|
|
149
176
|
return globalChance;
|
|
150
177
|
}
|
|
151
178
|
|
|
@@ -289,8 +316,8 @@ function datesBetween(start, end) {
|
|
|
289
316
|
|
|
290
317
|
/**
|
|
291
318
|
* returns a random date
|
|
292
|
-
* @param {
|
|
293
|
-
* @param {
|
|
319
|
+
* @param {string | number | Date | dayjs.Dayjs | null} [start]
|
|
320
|
+
* @param {string | number | Date | dayjs.Dayjs | null} [end]
|
|
294
321
|
*/
|
|
295
322
|
function day(start, end) {
|
|
296
323
|
const chance = getChance();
|
|
@@ -387,10 +414,10 @@ function choose(value) {
|
|
|
387
414
|
// check to make sure that each element in the array only occurs once...
|
|
388
415
|
const uniqueItems = new Set(value);
|
|
389
416
|
if (uniqueItems.size === value.length) {
|
|
390
|
-
// Array has no duplicates
|
|
391
|
-
|
|
392
|
-
const
|
|
393
|
-
return
|
|
417
|
+
// Array has no duplicates → power-law draw with ONE stable
|
|
418
|
+
// seed-deterministic winner per array per run (v1.6.1)
|
|
419
|
+
const entry = getWinnerEntry(/** @type {string[]} */ (value));
|
|
420
|
+
return chance.weighted(value, entry.weights);
|
|
394
421
|
}
|
|
395
422
|
|
|
396
423
|
}
|
|
@@ -399,16 +426,26 @@ function choose(value) {
|
|
|
399
426
|
|
|
400
427
|
// if the thing has a .next() method, call that (e.g., generators/iterators)
|
|
401
428
|
try {
|
|
402
|
-
|
|
403
|
-
|
|
429
|
+
const iterCandidate = /** @type {{ next?: unknown }} */ (/** @type {unknown} */ (value));
|
|
430
|
+
if (value && typeof iterCandidate.next === 'function') {
|
|
431
|
+
return iterCandidate.next();
|
|
404
432
|
}
|
|
405
433
|
} catch (e) {
|
|
406
434
|
console.error(`Error occurred while calling next(): ${e}`);
|
|
407
435
|
}
|
|
408
436
|
|
|
409
437
|
try {
|
|
410
|
-
// Keep resolving the value if it's a function (with caching)
|
|
438
|
+
// Keep resolving the value if it's a function (with caching).
|
|
439
|
+
// Functions tagged noCache (pickAWinner closures) skip the source-string
|
|
440
|
+
// cache: their toString() is identical across instances, so caching by
|
|
441
|
+
// source would hand one property's expansion to every other property.
|
|
411
442
|
while (typeof value === 'function') {
|
|
443
|
+
if (/** @type {any} */ (value).noCache === true) {
|
|
444
|
+
const result = value();
|
|
445
|
+
if (result instanceof ListValue) return result;
|
|
446
|
+
value = result;
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
412
449
|
const funcString = value.toString();
|
|
413
450
|
|
|
414
451
|
if (weightedArrayCache.has(funcString)) {
|
|
@@ -451,11 +488,6 @@ function choose(value) {
|
|
|
451
488
|
}
|
|
452
489
|
}
|
|
453
490
|
|
|
454
|
-
// ["","",""] should pick-a-winner
|
|
455
|
-
if (Array.isArray(value) && typeof value[0] === "string") {
|
|
456
|
-
value = pickAWinner(value)();
|
|
457
|
-
}
|
|
458
|
-
|
|
459
491
|
// [0,1,2] should pick one
|
|
460
492
|
if (Array.isArray(value) && typeof value[0] === "number") {
|
|
461
493
|
return chance.pickone(value);
|
|
@@ -987,63 +1019,122 @@ function weighChoices(items) {
|
|
|
987
1019
|
};
|
|
988
1020
|
}
|
|
989
1021
|
|
|
1022
|
+
/**
|
|
1023
|
+
* Resolve the stable per-run winner index for an array of items. Memoized in
|
|
1024
|
+
* `winnerCache` keyed by array contents (raw joined string — no hashing, so
|
|
1025
|
+
* no collision risk), so every event drawing from the same array favors the
|
|
1026
|
+
* SAME winner for the whole run. Winner is rolled from the seeded chance →
|
|
1027
|
+
* same seed = same winner; resetValueCaches() clears the memo between runs.
|
|
1028
|
+
*
|
|
1029
|
+
* @param {Array} items - The list of items to pick a winner from.
|
|
1030
|
+
* @returns {number} - The stable winner index for this run.
|
|
1031
|
+
*/
|
|
1032
|
+
function getStableWinnerIndex(items) {
|
|
1033
|
+
const key = items.join('\u0000');
|
|
1034
|
+
if (winnerCache.has(key)) return winnerCache.get(key);
|
|
1035
|
+
const chance = getChance();
|
|
1036
|
+
const winner = chance.integer({ min: 0, max: items.length - 1 });
|
|
1037
|
+
winnerCache.set(key, winner);
|
|
1038
|
+
return winner;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Full winner entry ({idx, weights}) for an array, memoized by array identity
|
|
1043
|
+
* in a WeakMap. Steady state for the choose() hot path is one WeakMap hit per
|
|
1044
|
+
* event — no join, no weight rebuild. Distinct array instances with equal
|
|
1045
|
+
* contents converge on the same winner via getStableWinnerIndex.
|
|
1046
|
+
*
|
|
1047
|
+
* @param {string[]} items
|
|
1048
|
+
* @returns {{idx: number, weights: number[]}}
|
|
1049
|
+
*/
|
|
1050
|
+
function getWinnerEntry(items) {
|
|
1051
|
+
let entry = winnerEntryCache.get(items);
|
|
1052
|
+
if (entry) return entry;
|
|
1053
|
+
const idx = getStableWinnerIndex(items);
|
|
1054
|
+
entry = { idx, weights: winnerWeights(items.length, idx) };
|
|
1055
|
+
winnerEntryCache.set(items, entry);
|
|
1056
|
+
return entry;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* Build a power-law weight vector aligned to item indices: winner ~45%,
|
|
1061
|
+
* second ~25%, third ~15%, remainder split over the tail with geometric decay.
|
|
1062
|
+
* Rank order follows the rotational convention: second = (winner+1) % n,
|
|
1063
|
+
* third = (winner+2) % n. This is THE place to tune the curve.
|
|
1064
|
+
*
|
|
1065
|
+
* @param {number} n - Number of items.
|
|
1066
|
+
* @param {number} winnerIndex - Index of the winning item.
|
|
1067
|
+
* @returns {number[]} - Weights (unnormalized; chance.weighted normalizes).
|
|
1068
|
+
*/
|
|
1069
|
+
function winnerWeights(n, winnerIndex) {
|
|
1070
|
+
if (n <= 0) return [];
|
|
1071
|
+
if (n === 1) return [1];
|
|
1072
|
+
const HEAD = [45, 25, 15];
|
|
1073
|
+
const TAIL_BUDGET = 15;
|
|
1074
|
+
const TAIL_DECAY = 0.7;
|
|
1075
|
+
const tailCount = Math.max(0, n - HEAD.length);
|
|
1076
|
+
let tailWeights = [];
|
|
1077
|
+
if (tailCount > 0) {
|
|
1078
|
+
const raw = [];
|
|
1079
|
+
let w = 1;
|
|
1080
|
+
for (let k = 0; k < tailCount; k++) { raw.push(w); w *= TAIL_DECAY; }
|
|
1081
|
+
const sum = raw.reduce((a, b) => a + b, 0);
|
|
1082
|
+
tailWeights = raw.map(r => (r / sum) * TAIL_BUDGET);
|
|
1083
|
+
}
|
|
1084
|
+
const weights = new Array(n).fill(0);
|
|
1085
|
+
for (let rank = 0; rank < n; rank++) {
|
|
1086
|
+
const idx = (winnerIndex + rank) % n;
|
|
1087
|
+
weights[idx] = rank < HEAD.length ? HEAD[rank] : tailWeights[rank - HEAD.length];
|
|
1088
|
+
}
|
|
1089
|
+
return weights;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
990
1092
|
/**
|
|
991
1093
|
* Creates a function that generates a weighted list of items
|
|
992
1094
|
* with a higher likelihood of picking a specified index and clear second and third place indices.
|
|
993
|
-
*
|
|
1095
|
+
*
|
|
1096
|
+
* v1.6.1: the returned closure yields a DETERMINISTIC weighted expansion
|
|
1097
|
+
* (winner ~45%, second ~25%, third ~15%, geometric tail) built once — no
|
|
1098
|
+
* per-call sampling variance. When no index is passed, the winner is the
|
|
1099
|
+
* stable per-run memoized winner for the array (see getStableWinnerIndex),
|
|
1100
|
+
* so direct dungeon-file usage is stable and seed-deterministic too.
|
|
1101
|
+
*
|
|
994
1102
|
* @param {Array} items - The list of items to pick from.
|
|
995
1103
|
* @param {number} [mostChosenIndex] - The index of the item to be most favored.
|
|
996
1104
|
* @returns {function} - A function that returns a weighted list of items.
|
|
997
1105
|
*/
|
|
998
1106
|
function pickAWinner(items, mostChosenIndex) {
|
|
999
|
-
const chance = getChance();
|
|
1000
|
-
|
|
1001
|
-
// Ensure mostChosenIndex is within the bounds of the items array
|
|
1002
1107
|
if (!items) return () => { return ""; };
|
|
1003
1108
|
if (!items.length) return () => { return ""; };
|
|
1004
|
-
if (!mostChosenIndex)
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1109
|
+
if (!Number.isFinite(mostChosenIndex)) {
|
|
1110
|
+
// undefined/null/NaN → stable per-run memoized winner
|
|
1111
|
+
mostChosenIndex = getStableWinnerIndex(items);
|
|
1112
|
+
} else {
|
|
1113
|
+
mostChosenIndex = Math.floor(mostChosenIndex);
|
|
1114
|
+
if (mostChosenIndex >= items.length) mostChosenIndex = items.length - 1;
|
|
1115
|
+
if (mostChosenIndex < 0) mostChosenIndex = 0;
|
|
1116
|
+
}
|
|
1010
1117
|
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
} else {
|
|
1023
|
-
const addOrSubtract = chance.bool({ likelihood: 50 }) ? -rand : rand;
|
|
1024
|
-
let newIndex = mostChosenIndex + addOrSubtract;
|
|
1118
|
+
const weights = winnerWeights(items.length, mostChosenIndex);
|
|
1119
|
+
const total = weights.reduce((a, b) => a + b, 0);
|
|
1120
|
+
// scale slots with n so large arrays keep the ~45% winner share instead of
|
|
1121
|
+
// having Math.max(1, ...) floors dilute it
|
|
1122
|
+
const SLOTS = Math.max(20, items.length * 4);
|
|
1123
|
+
const expansion = [];
|
|
1124
|
+
items.forEach((item, i) => {
|
|
1125
|
+
// every item keeps at least one slot so no value becomes unreachable
|
|
1126
|
+
const count = Math.max(1, Math.round((weights[i] / total) * SLOTS));
|
|
1127
|
+
for (let j = 0; j < count; j++) expansion.push(item);
|
|
1128
|
+
});
|
|
1025
1129
|
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
if (newIndex >= items.length) newIndex = items.length - 1;
|
|
1029
|
-
weighted.push(items[newIndex]);
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
// 25% chance to favor the second most chosen index
|
|
1033
|
-
else if (chance.bool({ likelihood: 25 })) {
|
|
1034
|
-
weighted.push(items[secondMostChosenIndex]);
|
|
1035
|
-
}
|
|
1036
|
-
// 15% chance to favor the third most chosen index
|
|
1037
|
-
else if (chance.bool({ likelihood: 15 })) {
|
|
1038
|
-
weighted.push(items[thirdMostChosenIndex]);
|
|
1039
|
-
}
|
|
1040
|
-
// Otherwise, pick a random item from the list
|
|
1041
|
-
else {
|
|
1042
|
-
weighted.push(chance.pickone(items));
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
return weighted;
|
|
1130
|
+
const resolver = function () {
|
|
1131
|
+
return expansion;
|
|
1046
1132
|
};
|
|
1133
|
+
// all pickAWinner closures share one source string; without this tag,
|
|
1134
|
+
// choose()'s weightedArrayCache would cache the first closure's expansion
|
|
1135
|
+
// under that shared key and serve it for every other pickAWinner property
|
|
1136
|
+
resolver.noCache = true;
|
|
1137
|
+
return resolver;
|
|
1047
1138
|
}
|
|
1048
1139
|
|
|
1049
1140
|
function quickHash(str, seed = 0) {
|
|
@@ -1773,6 +1864,7 @@ export {
|
|
|
1773
1864
|
getUniqueKeys,
|
|
1774
1865
|
person,
|
|
1775
1866
|
pickAWinner,
|
|
1867
|
+
resetValueCaches,
|
|
1776
1868
|
quickHash,
|
|
1777
1869
|
weighArray,
|
|
1778
1870
|
validateEventConfig,
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared value-coercion + filter-comparison helpers matching Mixpanel's ARB
|
|
3
|
+
* engine semantics. Two DIFFERENT rulebooks live here — do not mix them up:
|
|
4
|
+
*
|
|
5
|
+
* 1. SEGMENT IDENTITY (breakdown bucketing) is case-SENSITIVE and type-tagged.
|
|
6
|
+
* ARB hashes the raw typed value: strings hash their raw bytes with no case
|
|
7
|
+
* folding (hash_value.c:114-115 → hash_string_value_with_seed, raw XXH3
|
|
8
|
+
* over strlen bytes), and segment ordering uses arb_strcmp (cmp.c:24-32).
|
|
9
|
+
* Each value type mixes a distinct tag into the hash (number_tag,
|
|
10
|
+
* string_tag, true/false_tag — hash_value.c:92-97), so the number 1 and
|
|
11
|
+
* the string "1" are DIFFERENT segments even though both display as "1".
|
|
12
|
+
*
|
|
13
|
+
* 2. WHERE-FILTER string comparison is case-INSENSITIVE. value_equal uses
|
|
14
|
+
* arb_strcasecmp (value.c:285), `contains` uses arb_strcaseinstr
|
|
15
|
+
* (eval_node.c:2914), and string relational operators use arb_strcasecmp
|
|
16
|
+
* (eval_node.c:2931). The filter helpers below implement that rulebook and
|
|
17
|
+
* must NEVER be used to bucket breakdown segments.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Coerce a property value to its Mixpanel display/bucket label.
|
|
22
|
+
*
|
|
23
|
+
* - `null` and `undefined` both become the literal string "undefined" — the
|
|
24
|
+
* string-typecast default (`string(prop, "undefined")`) that produces the
|
|
25
|
+
* "undefined" segment in every Insights breakdown (arb_selector.py:889-916).
|
|
26
|
+
* - Booleans → "true"/"false"; numbers → decimal string with -0 normalized to
|
|
27
|
+
* 0 (hash_value.c:111 `v.d = v.d == -0.0 ? 0.0 : v.d`).
|
|
28
|
+
* - Strings pass through UNCHANGED — case-preserving (rulebook 1 above).
|
|
29
|
+
* - Objects JSON-stringify (rare; ARB hashes structurally — hash_object).
|
|
30
|
+
* - Lists are NOT handled here: callers fan out per item (ACTION_TYPE_FOR_EACH,
|
|
31
|
+
* normal_query.cpp:1718-1776) before coercing each item.
|
|
32
|
+
*
|
|
33
|
+
* @param {*} value
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
export function coerceToBreakdownKey(value) {
|
|
37
|
+
if (value === null || value === undefined) return 'undefined';
|
|
38
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
39
|
+
if (typeof value === 'number') return String(Object.is(value, -0) ? 0 : value);
|
|
40
|
+
if (typeof value === 'string') return value;
|
|
41
|
+
return JSON.stringify(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Internal segment-identity key: type-tagged so number 1 ≠ string "1"
|
|
46
|
+
* (hash_value.c type tag mixins). Case-preserving for strings.
|
|
47
|
+
*
|
|
48
|
+
* @param {*} value
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
export function breakdownSegmentKey(value) {
|
|
52
|
+
if (value === null || value === undefined) return 'u:';
|
|
53
|
+
if (typeof value === 'boolean') return `b:${value}`;
|
|
54
|
+
if (typeof value === 'number') return `n:${Object.is(value, -0) ? 0 : value}`;
|
|
55
|
+
if (typeof value === 'string') return `s:${value}`;
|
|
56
|
+
return `o:${JSON.stringify(value)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* WHERE-filter equality. Strings compare case-insensitively
|
|
61
|
+
* (value.c:285 — value_equal → arb_strcasecmp). null == null and
|
|
62
|
+
* undefined == undefined are each equal (value.c VALUE_TYPE_UNDEFINED/NULL
|
|
63
|
+
* cases return equal=true) but null != undefined (type mismatch short-circuits
|
|
64
|
+
* before the switch). Cross-type comparisons are never equal.
|
|
65
|
+
*
|
|
66
|
+
* @param {*} a
|
|
67
|
+
* @param {*} b
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
export function filterEquals(a, b) {
|
|
71
|
+
if (typeof a === 'string' && typeof b === 'string') {
|
|
72
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
73
|
+
}
|
|
74
|
+
if (a === null || a === undefined || b === null || b === undefined) {
|
|
75
|
+
return a === b || (a === null && b === null) || (a === undefined && b === undefined);
|
|
76
|
+
}
|
|
77
|
+
return a === b;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* WHERE-filter relational comparison for two strings, case-insensitive
|
|
82
|
+
* (eval_node.c:2931 — string relational operators go through arb_strcasecmp).
|
|
83
|
+
*
|
|
84
|
+
* @param {string} a
|
|
85
|
+
* @param {string} b
|
|
86
|
+
* @returns {number} negative / 0 / positive like strcmp
|
|
87
|
+
*/
|
|
88
|
+
export function filterCompare(a, b) {
|
|
89
|
+
const la = a.toLowerCase(), lb = b.toLowerCase();
|
|
90
|
+
return la < lb ? -1 : la > lb ? 1 : 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* WHERE-filter substring test, case-insensitive
|
|
95
|
+
* (eval_node.c:2914 — BINARY_OPERATOR_IN uses arb_strcaseinstr).
|
|
96
|
+
*
|
|
97
|
+
* @param {string} haystack
|
|
98
|
+
* @param {string} needle
|
|
99
|
+
* @returns {boolean}
|
|
100
|
+
*/
|
|
101
|
+
export function filterContains(haystack, needle) {
|
|
102
|
+
return haystack.toLowerCase().includes(needle.toLowerCase());
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* List-valued property membership: ARB's BINARY_OPERATOR_IN with a LIST
|
|
107
|
+
* right operand walks the list and tests per-item value_equal
|
|
108
|
+
* (eval_node.c:2949-2959) — typed equality, case-insensitive for strings
|
|
109
|
+
* (value.c:285). This is the ONLY list operator the query builder emits for
|
|
110
|
+
* list-typed properties ("contains"/"does not contain" compile to in/not-in;
|
|
111
|
+
* LIST_CONTAINS_STRING is never generated by the API layer). Substring
|
|
112
|
+
* matching does NOT apply per item.
|
|
113
|
+
*
|
|
114
|
+
* @param {Array<*>} list
|
|
115
|
+
* @param {*} needle
|
|
116
|
+
* @returns {boolean}
|
|
117
|
+
*/
|
|
118
|
+
function listMembership(list, needle) {
|
|
119
|
+
return list.some(item => filterEquals(item, needle));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Evaluate a `where` filter object against a flat record using the
|
|
124
|
+
* WHERE-filter rulebook (case-insensitive strings). Shape mirrors funnel step
|
|
125
|
+
* filters: `{ prop: value }` (equality) or `{ prop: { op, value } }` with
|
|
126
|
+
* op ∈ eq | neq | gt | lt | gte | lte | contains | not_contains.
|
|
127
|
+
*
|
|
128
|
+
* Relational ops: numbers compare numerically, strings via filterCompare
|
|
129
|
+
* (eval_node.c:2870-2906 numeric branch, :2931 string branch). Mixed-type or
|
|
130
|
+
* null/undefined operands fail relational tests (value_cmp orders by type
|
|
131
|
+
* first — cross-type never satisfies a within-type comparison here).
|
|
132
|
+
*
|
|
133
|
+
* List-valued record properties: eq/contains test per-item membership and
|
|
134
|
+
* neq/not_contains its negation (see listMembership). Raw `==` on
|
|
135
|
+
* list-vs-scalar is a type-mismatch false in ARB, but the query builder never
|
|
136
|
+
* emits it for list props — it emits IN — so the DSL's bare `{ prop: value }`
|
|
137
|
+
* maps to membership rather than silently never matching. Relational ops on
|
|
138
|
+
* list values stay false (scalar-vs-list has no per-item relational in ARB).
|
|
139
|
+
*
|
|
140
|
+
* @param {Object} record flat event/profile record
|
|
141
|
+
* @param {Object<string, *>} [where]
|
|
142
|
+
* @returns {boolean}
|
|
143
|
+
*/
|
|
144
|
+
export function matchesWhere(record, where) {
|
|
145
|
+
if (!where) return true;
|
|
146
|
+
for (const [prop, cond] of Object.entries(where)) {
|
|
147
|
+
const actual = record[prop];
|
|
148
|
+
const { op, value } = (cond && typeof cond === 'object' && !Array.isArray(cond) && 'op' in cond)
|
|
149
|
+
? cond
|
|
150
|
+
: { op: 'eq', value: cond };
|
|
151
|
+
let pass;
|
|
152
|
+
switch (op) {
|
|
153
|
+
case 'eq':
|
|
154
|
+
pass = Array.isArray(actual) ? listMembership(actual, value) : filterEquals(actual, value);
|
|
155
|
+
break;
|
|
156
|
+
case 'neq':
|
|
157
|
+
pass = Array.isArray(actual) ? !listMembership(actual, value) : !filterEquals(actual, value);
|
|
158
|
+
break;
|
|
159
|
+
case 'gt':
|
|
160
|
+
case 'lt':
|
|
161
|
+
case 'gte':
|
|
162
|
+
case 'lte': {
|
|
163
|
+
let cmp;
|
|
164
|
+
if (typeof actual === 'number' && typeof value === 'number') cmp = actual - value;
|
|
165
|
+
else if (typeof actual === 'string' && typeof value === 'string') cmp = filterCompare(actual, value);
|
|
166
|
+
else { pass = false; break; }
|
|
167
|
+
pass = op === 'gt' ? cmp > 0 : op === 'lt' ? cmp < 0 : op === 'gte' ? cmp >= 0 : cmp <= 0;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
case 'contains':
|
|
171
|
+
pass = Array.isArray(actual)
|
|
172
|
+
? listMembership(actual, value) // membership, NOT per-item substring
|
|
173
|
+
: (typeof actual === 'string' && typeof value === 'string' && filterContains(actual, value));
|
|
174
|
+
break;
|
|
175
|
+
case 'not_contains':
|
|
176
|
+
pass = Array.isArray(actual)
|
|
177
|
+
? !listMembership(actual, value)
|
|
178
|
+
: !(typeof actual === 'string' && typeof value === 'string' && filterContains(actual, value));
|
|
179
|
+
break;
|
|
180
|
+
default:
|
|
181
|
+
throw new Error(`matchesWhere: unknown op "${op}"`);
|
|
182
|
+
}
|
|
183
|
+
if (!pass) return false;
|
|
184
|
+
}
|
|
185
|
+
return true;
|
|
186
|
+
}
|