@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.
Files changed (149) 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/powertools/SKILL.md +75 -0
  6. package/.claude/skills/powertools/pt.mjs +91 -0
  7. package/.claude/skills/powertools/snapshot-project.mjs +124 -0
  8. package/.claude/skills/verify-dungeon/SKILL.md +56 -25
  9. package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
  10. package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
  11. package/.claude/skills/write-hooks/SKILL.md +156 -18
  12. package/CHANGELOG.md +224 -0
  13. package/HOOKS.md +566 -112
  14. package/README.md +30 -9
  15. package/docs/guides/1.3.0-upgrade-guide.md +262 -0
  16. package/docs/guides/1.3.1-upgrade-guide.md +328 -0
  17. package/docs/guides/1.4.0-upgrade-guide.md +130 -0
  18. package/docs/guides/1.4.1-upgrade-guide.md +125 -0
  19. package/docs/guides/1.4.3-upgrade-guide.md +200 -0
  20. package/docs/guides/1.4.4-upgrade-guide.md +95 -0
  21. package/docs/guides/1.5.0-upgrade-guide.md +426 -0
  22. package/docs/guides/1.5.1-upgrade-guide.md +178 -0
  23. package/docs/guides/1.5.3-upgrade-guide.md +117 -0
  24. package/docs/guides/1.6.0-upgrade-guide.md +204 -0
  25. package/dungeons/technical/stories-verify.js +152 -0
  26. package/dungeons/vertical/README.md +154 -0
  27. package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
  28. package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
  29. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
  30. package/dungeons/vertical/community/community.js +1589 -0
  31. package/dungeons/vertical/community/community.sql +216 -0
  32. package/dungeons/vertical/community/community.verify.mjs +72 -0
  33. package/dungeons/vertical/crypto/crypto.js +1483 -0
  34. package/dungeons/vertical/crypto/crypto.sql +194 -0
  35. package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
  36. package/dungeons/vertical/dating/dating.js +1540 -0
  37. package/dungeons/vertical/dating/dating.sql +288 -0
  38. package/dungeons/vertical/dating/dating.verify.mjs +72 -0
  39. package/dungeons/vertical/devtools/devtools.js +1524 -0
  40. package/dungeons/vertical/devtools/devtools.sql +161 -0
  41. package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
  42. package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
  43. package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
  44. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
  45. package/dungeons/vertical/education/education.js +1763 -0
  46. package/dungeons/vertical/education/education.sql +243 -0
  47. package/dungeons/vertical/education/education.verify.mjs +75 -0
  48. package/dungeons/vertical/fintech/fintech.js +1410 -0
  49. package/dungeons/vertical/fintech/fintech.sql +175 -0
  50. package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
  51. package/dungeons/vertical/fitness/fitness.js +1495 -0
  52. package/dungeons/vertical/fitness/fitness.sql +206 -0
  53. package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
  54. package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
  55. package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
  56. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
  57. package/dungeons/vertical/gaming/gaming.js +1741 -0
  58. package/dungeons/vertical/gaming/gaming.sql +249 -0
  59. package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
  60. package/dungeons/vertical/healthcare/healthcare.js +1488 -0
  61. package/dungeons/vertical/healthcare/healthcare.sql +193 -0
  62. package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
  63. package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
  64. package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
  65. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
  66. package/dungeons/vertical/logistics/logistics.js +1541 -0
  67. package/dungeons/vertical/logistics/logistics.sql +205 -0
  68. package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
  69. package/dungeons/vertical/marketplace/marketplace.js +1438 -0
  70. package/dungeons/vertical/marketplace/marketplace.sql +148 -0
  71. package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
  72. package/dungeons/vertical/{media.js → media/media.js} +557 -58
  73. package/dungeons/vertical/media/media.sql +173 -0
  74. package/dungeons/vertical/media/media.verify.mjs +74 -0
  75. package/dungeons/vertical/real-estate/real-estate.js +1629 -0
  76. package/dungeons/vertical/real-estate/real-estate.sql +167 -0
  77. package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
  78. package/dungeons/vertical/sass/sass.js +1733 -0
  79. package/dungeons/vertical/sass/sass.sql +173 -0
  80. package/dungeons/vertical/sass/sass.verify.mjs +74 -0
  81. package/dungeons/vertical/social/social.js +1456 -0
  82. package/dungeons/vertical/social/social.sql +160 -0
  83. package/dungeons/vertical/social/social.verify.mjs +76 -0
  84. package/dungeons/vertical/streaming/streaming.js +677 -0
  85. package/dungeons/vertical/streaming/streaming.sql +101 -0
  86. package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
  87. package/dungeons/vertical/support-desk/support-desk.js +741 -0
  88. package/dungeons/vertical/support-desk/support-desk.sql +137 -0
  89. package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
  90. package/dungeons/vertical/travel/travel.js +1603 -0
  91. package/dungeons/vertical/travel/travel.sql +236 -0
  92. package/dungeons/vertical/travel/travel.verify.mjs +72 -0
  93. package/index.js +7 -3
  94. package/lib/core/config-validator.js +37 -2
  95. package/lib/core/context.js +4 -2
  96. package/lib/core/dungeon-loader.js +20 -15
  97. package/lib/core/dungeon-to-json.js +22 -4
  98. package/lib/core/storage.js +3 -3
  99. package/lib/generators/funnels.js +36 -9
  100. package/lib/hook-helpers/cohort.js +53 -0
  101. package/lib/hook-helpers/index.js +8 -0
  102. package/lib/hook-helpers/mutate.js +1 -1
  103. package/lib/hook-helpers/shape.js +319 -0
  104. package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
  105. package/lib/hook-patterns/attributed-by-source.js +60 -58
  106. package/lib/hook-patterns/frequency-by-frequency.js +14 -4
  107. package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
  108. package/lib/hook-patterns/index.js +1 -1
  109. package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
  110. package/lib/orchestrators/user-loop.js +41 -0
  111. package/lib/templates/story-spec.schema.json +175 -0
  112. package/lib/utils/json-evaluator.js +1 -1
  113. package/lib/utils/utils.js +151 -59
  114. package/lib/verify/coerce.js +186 -0
  115. package/lib/verify/counting.js +270 -32
  116. package/lib/verify/emulate-breakdown.js +1285 -203
  117. package/lib/verify/first-time.js +89 -0
  118. package/lib/verify/flows.js +679 -0
  119. package/lib/verify/formula.js +259 -0
  120. package/lib/verify/funnel-engine.js +697 -113
  121. package/lib/verify/index.js +32 -4
  122. package/lib/verify/sessionize.js +275 -0
  123. package/lib/verify/story-runner.js +483 -0
  124. package/lib/verify/verify-dungeon.js +59 -37
  125. package/package.json +2 -1
  126. package/scripts/extract-dungeon-schema.mjs +2 -2
  127. package/scripts/run-many.mjs +2 -2
  128. package/scripts/verify-runner.mjs +2 -2
  129. package/scripts/verify-stories.mjs +244 -0
  130. package/types.d.ts +298 -30
  131. package/dungeons/vertical/ai-platform.js +0 -818
  132. package/dungeons/vertical/community.js +0 -966
  133. package/dungeons/vertical/crypto.js +0 -895
  134. package/dungeons/vertical/dating.js +0 -830
  135. package/dungeons/vertical/devtools.js +0 -1050
  136. package/dungeons/vertical/ecommerce.js +0 -811
  137. package/dungeons/vertical/education.js +0 -964
  138. package/dungeons/vertical/fintech.js +0 -946
  139. package/dungeons/vertical/fitness.js +0 -935
  140. package/dungeons/vertical/food-delivery.js +0 -934
  141. package/dungeons/vertical/gaming.js +0 -1120
  142. package/dungeons/vertical/healthcare.js +0 -945
  143. package/dungeons/vertical/insurance-application.js +0 -1039
  144. package/dungeons/vertical/logistics.js +0 -986
  145. package/dungeons/vertical/marketplace.js +0 -1019
  146. package/dungeons/vertical/real-estate.js +0 -872
  147. package/dungeons/vertical/sass.js +0 -995
  148. package/dungeons/vertical/social.js +0 -904
  149. package/dungeons/vertical/travel.js +0 -930
@@ -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
- // Lives for the lifetime of the node process; key is the function source string.
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 {any} start
293
- * @param {any} end
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, use pickAWinner
391
- const quickList = pickAWinner(value, 0)();
392
- const theChosenOne = chance.pickone(quickList);
393
- return theChosenOne;
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
- if (value && typeof /** @type {any} */ (value).next === 'function') {
403
- return /** @type {any} */ (value).next();
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) mostChosenIndex = chance.integer({ min: 0, max: items.length - 1 });
1005
- if (mostChosenIndex >= items.length) mostChosenIndex = items.length - 1;
1006
-
1007
- // Calculate second and third most chosen indices
1008
- const secondMostChosenIndex = (mostChosenIndex + 1) % items.length;
1009
- const thirdMostChosenIndex = (mostChosenIndex + 2) % items.length;
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
- // Return a function that generates a weighted list
1012
- return function () {
1013
- const weighted = [];
1014
- for (let i = 0; i < 10; i++) {
1015
- const rand = chance.d10(); // Random number between 1 and 10
1016
-
1017
- // 35% chance to favor the most chosen index
1018
- if (chance.bool({ likelihood: 35 })) {
1019
- // 50% chance to slightly alter the index
1020
- if (chance.bool({ likelihood: 50 })) {
1021
- weighted.push(items[mostChosenIndex]);
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
- // Ensure newIndex is within bounds
1027
- if (newIndex < 0) newIndex = 0;
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
+ }