@ak--47/dungeon-master 1.5.3 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +21 -12
  2. package/.claude/skills/create-dungeon/SKILL.md +65 -5
  3. package/.claude/skills/create-project/SKILL.md +96 -0
  4. package/.claude/skills/create-project/provision.mjs +336 -0
  5. package/.claude/skills/verify-dungeon/SKILL.md +56 -25
  6. package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
  7. package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
  8. package/.claude/skills/write-hooks/SKILL.md +156 -18
  9. package/CHANGELOG.md +211 -0
  10. package/HOOKS.md +566 -112
  11. package/README.md +28 -9
  12. package/docs/guides/1.3.0-upgrade-guide.md +262 -0
  13. package/docs/guides/1.3.1-upgrade-guide.md +328 -0
  14. package/docs/guides/1.4.0-upgrade-guide.md +130 -0
  15. package/docs/guides/1.4.1-upgrade-guide.md +125 -0
  16. package/docs/guides/1.4.3-upgrade-guide.md +200 -0
  17. package/docs/guides/1.4.4-upgrade-guide.md +95 -0
  18. package/docs/guides/1.5.0-upgrade-guide.md +426 -0
  19. package/docs/guides/1.5.1-upgrade-guide.md +178 -0
  20. package/docs/guides/1.5.3-upgrade-guide.md +117 -0
  21. package/docs/guides/1.6.0-upgrade-guide.md +204 -0
  22. package/dungeons/technical/stories-verify.js +152 -0
  23. package/dungeons/vertical/README.md +154 -0
  24. package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
  25. package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
  26. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
  27. package/dungeons/vertical/community/community.js +1589 -0
  28. package/dungeons/vertical/community/community.sql +216 -0
  29. package/dungeons/vertical/community/community.verify.mjs +72 -0
  30. package/dungeons/vertical/crypto/crypto.js +1483 -0
  31. package/dungeons/vertical/crypto/crypto.sql +194 -0
  32. package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
  33. package/dungeons/vertical/dating/dating.js +1540 -0
  34. package/dungeons/vertical/dating/dating.sql +288 -0
  35. package/dungeons/vertical/dating/dating.verify.mjs +72 -0
  36. package/dungeons/vertical/devtools/devtools.js +1524 -0
  37. package/dungeons/vertical/devtools/devtools.sql +161 -0
  38. package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
  39. package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
  40. package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
  41. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
  42. package/dungeons/vertical/education/education.js +1763 -0
  43. package/dungeons/vertical/education/education.sql +243 -0
  44. package/dungeons/vertical/education/education.verify.mjs +75 -0
  45. package/dungeons/vertical/fintech/fintech.js +1410 -0
  46. package/dungeons/vertical/fintech/fintech.sql +175 -0
  47. package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
  48. package/dungeons/vertical/fitness/fitness.js +1495 -0
  49. package/dungeons/vertical/fitness/fitness.sql +206 -0
  50. package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
  51. package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
  52. package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
  53. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
  54. package/dungeons/vertical/gaming/gaming.js +1741 -0
  55. package/dungeons/vertical/gaming/gaming.sql +249 -0
  56. package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
  57. package/dungeons/vertical/healthcare/healthcare.js +1488 -0
  58. package/dungeons/vertical/healthcare/healthcare.sql +193 -0
  59. package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
  60. package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
  61. package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
  62. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
  63. package/dungeons/vertical/logistics/logistics.js +1541 -0
  64. package/dungeons/vertical/logistics/logistics.sql +205 -0
  65. package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
  66. package/dungeons/vertical/marketplace/marketplace.js +1438 -0
  67. package/dungeons/vertical/marketplace/marketplace.sql +148 -0
  68. package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
  69. package/dungeons/vertical/{media.js → media/media.js} +557 -58
  70. package/dungeons/vertical/media/media.sql +173 -0
  71. package/dungeons/vertical/media/media.verify.mjs +74 -0
  72. package/dungeons/vertical/real-estate/real-estate.js +1629 -0
  73. package/dungeons/vertical/real-estate/real-estate.sql +167 -0
  74. package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
  75. package/dungeons/vertical/sass/sass.js +1733 -0
  76. package/dungeons/vertical/sass/sass.sql +173 -0
  77. package/dungeons/vertical/sass/sass.verify.mjs +74 -0
  78. package/dungeons/vertical/social/social.js +1456 -0
  79. package/dungeons/vertical/social/social.sql +160 -0
  80. package/dungeons/vertical/social/social.verify.mjs +76 -0
  81. package/dungeons/vertical/streaming/streaming.js +677 -0
  82. package/dungeons/vertical/streaming/streaming.sql +101 -0
  83. package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
  84. package/dungeons/vertical/support-desk/support-desk.js +741 -0
  85. package/dungeons/vertical/support-desk/support-desk.sql +137 -0
  86. package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
  87. package/dungeons/vertical/travel/travel.js +1603 -0
  88. package/dungeons/vertical/travel/travel.sql +236 -0
  89. package/dungeons/vertical/travel/travel.verify.mjs +72 -0
  90. package/index.js +2 -2
  91. package/lib/core/config-validator.js +37 -2
  92. package/lib/core/context.js +4 -2
  93. package/lib/core/dungeon-loader.js +20 -15
  94. package/lib/core/dungeon-to-json.js +22 -4
  95. package/lib/core/storage.js +3 -3
  96. package/lib/generators/funnels.js +36 -9
  97. package/lib/hook-helpers/cohort.js +53 -0
  98. package/lib/hook-helpers/index.js +8 -0
  99. package/lib/hook-helpers/mutate.js +1 -1
  100. package/lib/hook-helpers/shape.js +319 -0
  101. package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
  102. package/lib/hook-patterns/attributed-by-source.js +60 -58
  103. package/lib/hook-patterns/frequency-by-frequency.js +14 -4
  104. package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
  105. package/lib/hook-patterns/index.js +1 -1
  106. package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
  107. package/lib/orchestrators/user-loop.js +41 -0
  108. package/lib/templates/story-spec.schema.json +175 -0
  109. package/lib/utils/json-evaluator.js +1 -1
  110. package/lib/utils/utils.js +5 -4
  111. package/lib/verify/coerce.js +186 -0
  112. package/lib/verify/counting.js +270 -32
  113. package/lib/verify/emulate-breakdown.js +1285 -203
  114. package/lib/verify/first-time.js +89 -0
  115. package/lib/verify/flows.js +679 -0
  116. package/lib/verify/formula.js +259 -0
  117. package/lib/verify/funnel-engine.js +697 -113
  118. package/lib/verify/index.js +32 -4
  119. package/lib/verify/sessionize.js +275 -0
  120. package/lib/verify/story-runner.js +483 -0
  121. package/lib/verify/verify-dungeon.js +59 -37
  122. package/package.json +3 -2
  123. package/scripts/extract-dungeon-schema.mjs +2 -2
  124. package/scripts/run-many.mjs +2 -2
  125. package/scripts/verify-runner.mjs +2 -2
  126. package/scripts/verify-stories.mjs +244 -0
  127. package/types.d.ts +296 -30
  128. package/dungeons/vertical/ai-platform.js +0 -818
  129. package/dungeons/vertical/community.js +0 -966
  130. package/dungeons/vertical/crypto.js +0 -895
  131. package/dungeons/vertical/dating.js +0 -830
  132. package/dungeons/vertical/devtools.js +0 -1050
  133. package/dungeons/vertical/ecommerce.js +0 -811
  134. package/dungeons/vertical/education.js +0 -964
  135. package/dungeons/vertical/fintech.js +0 -946
  136. package/dungeons/vertical/fitness.js +0 -935
  137. package/dungeons/vertical/food-delivery.js +0 -934
  138. package/dungeons/vertical/gaming.js +0 -1120
  139. package/dungeons/vertical/healthcare.js +0 -945
  140. package/dungeons/vertical/insurance-application.js +0 -1039
  141. package/dungeons/vertical/logistics.js +0 -986
  142. package/dungeons/vertical/marketplace.js +0 -1019
  143. package/dungeons/vertical/real-estate.js +0 -872
  144. package/dungeons/vertical/sass.js +0 -995
  145. package/dungeons/vertical/social.js +0 -904
  146. package/dungeons/vertical/travel.js +0 -930
@@ -0,0 +1,679 @@
1
+ /**
2
+ * Flows ("Top Paths") — the per-user flow-extraction state machine matching
3
+ * Mixpanel's ARB flows reader (P1.9).
4
+ *
5
+ * This module extracts per-user flows exactly the way the ARB flows query
6
+ * builds them; aggregation into the prefix tree / sankey output lives in the
7
+ * companion aggregation layer wired through emulateBreakdown.
8
+ *
9
+ * ARB semantics implemented here (source: mixpanel/analytics):
10
+ *
11
+ * - NEXT-ANCHOR-ONLY matching: "Regular flows queries are always in order,
12
+ * so check only against the next anchor step filter" — the candidate is
13
+ * always `reached_anchor + 1` (flows_query.cpp:988-994, flow_get_reached_anchor).
14
+ * An event that matches a LATER anchor (or an earlier one again) is a plain
15
+ * non-anchor step.
16
+ * - BUFFERS: flow_create (flows.cpp:680-717) allocates one anchor buffer, one
17
+ * linear forward buffer per anchor, one CIRCULAR reverse buffer per anchor;
18
+ * reached_anchor starts at -1 and the active buffer starts at reverse[0].
19
+ * Circular buffers are only ever "full" when their capacity is 0 and keep
20
+ * the LAST N steps (flows.cpp:575-600 flow_buffer_is_full / flow_buffer_add).
21
+ * - ADMISSION: visible/hidden event filters apply ONLY to non-anchor steps
22
+ * (flows_query.cpp:1019-1036 — both filters sit under `if (!is_anchor)`).
23
+ * A non-anchor is added iff the active buffer can accept it
24
+ * (flow_can_accept_non_anchor_step); pure in-order flows are never
25
+ * out-of-order. Anchor names are stripped from the hidden list
26
+ * (dqs/query/flows.go filterAnchorEventNames) and hidden names are stripped
27
+ * from the visible list (dqs/query/flows.go filteredVisibleEventSelectors)
28
+ * — so hidden wins over visible, and anchors are exempt from both.
29
+ * - ADDING STEPS: flow_add_step (flows.cpp:878-943). Anchors always add
30
+ * (no collapse check) and switch the active buffer to forward[reached].
31
+ * Non-anchors are suppressed under collapseRepeated iff equal to the LAST
32
+ * ADDED step — which may be an anchor — comparing event name and every
33
+ * segment value (flows.cpp:849-876 flow_is_step_repeated); a suppressed add
34
+ * does NOT update the last-step pointer. After EVERY flow_add_step call
35
+ * (even a suppressed one) a full linear forward buffer hands the active
36
+ * pointer to reverse[reached + 1] unless all anchors are seen
37
+ * (flows.cpp:936-943) — this also covers capacity-0 forward buffers, which
38
+ * are full at the moment their anchor lands.
39
+ * - COUNT TYPES:
40
+ * - 'unique': one flow per user across the whole stream, flushed at end of
41
+ * data. The started_any_flow ignore-branch (flows_query.cpp:1100-1111)
42
+ * never fires for pure flows because nothing resets the flow mid-stream.
43
+ * - 'general': alias of 'unique' here. The restart branch
44
+ * (flows_query.cpp:1006-1009) is gated on flow_reached_funnel_end, which
45
+ * "For flows not driven by funnels, always returns false"
46
+ * (flows.cpp:790-801) — so general and unique coincide for pure flows.
47
+ * - 'sessions': every session end flushes all flows in progress and resets
48
+ * (flows_query.cpp:1156-1161) — one flow universe per session. Session
49
+ * boundaries derive from the user's FULL event stream (session triggers
50
+ * per session_query.cpp via sessionize/sessionOrdinals — P1.7.1), not
51
+ * from the filtered steps.
52
+ * - FLUSH: end of data flushes all partial flows (flows_query.cpp:1369-1374
53
+ * print_results); a flow with zero anchors contributes nothing
54
+ * (flow_is_empty guard, flows_query.cpp:844-848; flow_is_empty = empty
55
+ * anchor buffer, flows.cpp:756-761).
56
+ * - $ttc: last reached anchor time minus first anchor time, clamped at 0
57
+ * (flows.cpp:828-839). converted: `num_anchors > 1 && reached_anchor + 1 ==
58
+ * num_anchors` (flows.cpp:841-847) — single-anchor flows are NEVER converted.
59
+ * - STEP NUMBERING (flow_aggregate, flows.cpp:943-1146): capacity-slotted,
60
+ * not dense. Under alignment LEFT (flows_query.cpp:664-668 fallback loop)
61
+ * and anchor_position FUNNEL — the PROTO enum default
62
+ * (request_params.proto:59, FUNNEL = 0), which is what the Top Paths list
63
+ * view sends (set_flows_defaults_for_top_paths_chart_type, bookmark.py:82-96,
64
+ * never sets anchor_position). NOTE: the product's default SANKEY view
65
+ * overrides to STEP_AND_FUNNEL = 1 (set_flows_defaults_for_sankey_chart_type,
66
+ * bookmark.py:109) — this emulator implements FUNNEL numbering ONLY and so
67
+ * matches the list/Top Paths flavor, not the sankey default —
68
+ * anchor i sits at the fixed slot
69
+ * `Σ_{j<=i} reverseCap[j] + Σ_{j<i} forwardCap[j] + i`; forward steps run
70
+ * contiguously after their anchor (LEFT); reverse steps pack flush AGAINST
71
+ * their anchor slot (the C++ LEFT-alignment branch for reverse requires
72
+ * anchor_position != FUNNEL, so FUNNEL takes the packed start
73
+ * `prev_step_number + fwd_cap[i-1] + available + 1`, i.e. slots
74
+ * `slot(i) - size .. slot(i) - 1`); there is no relative-anchor duplication
75
+ * (both duplication blocks require anchor_position != FUNNEL). A non-empty
76
+ * trailing reverse buffer reverse[reached + 1] is emitted after the last
77
+ * forward block (the aggregate loop breaks after filling reverse at
78
+ * i == reached_anchor + 1). Assembly order is chronological:
79
+ * rev[0], anchor0, fwd[0], rev[1], anchor1, fwd[1], ..., trailing rev.
80
+ * - forward/reverse per-anchor step counts pass through verbatim from the
81
+ * bookmark (dqs/query/flows.go step.Forward/step.Reverse; bookmark.py
82
+ * carries per-step "forward"/"reverse" keys with no server default). The
83
+ * defaults here (forward 4, reverse 0) are this emulator's contract
84
+ * mirroring the Flows UI's default view — they are NOT ARB constants.
85
+ */
86
+
87
+ import { toMs } from '../hook-helpers/_internal.js';
88
+ import { resolveUserId } from './identity.js';
89
+ import { coerceToBreakdownKey, matchesWhere } from './coerce.js';
90
+ import { sessionOrdinals } from './sessionize.js';
91
+
92
+ /**
93
+ * @typedef {Object} FlowAnchor
94
+ * @property {string} event
95
+ * @property {Object<string, *>} [where] step filter (matchesWhere shape)
96
+ */
97
+
98
+ /**
99
+ * @typedef {Object} FlowStep
100
+ * @property {string} label event name
101
+ * @property {number} timeMs
102
+ * @property {number} stepNumber capacity slot (see header — NOT a dense index)
103
+ * @property {boolean} isAnchor
104
+ * @property {number} [anchorIndex] present on anchor steps
105
+ * @property {string} [value] coerced breakdown segment value (when breakdownProperty set)
106
+ */
107
+
108
+ /**
109
+ * @typedef {Object} FlowRecord
110
+ * @property {string} userId
111
+ * @property {Array<FlowStep>} steps chronological
112
+ * @property {number} reachedAnchor highest anchor index reached (>= 0)
113
+ * @property {boolean} converted all anchors reached AND more than one anchor
114
+ * @property {number} ttcMs last reached anchor time - first anchor time, >= 0
115
+ */
116
+
117
+ /**
118
+ * Broadcast a scalar per-anchor knob to an array, or validate a supplied array.
119
+ * @param {number|Array<number>|undefined} v
120
+ * @param {number} defaultValue
121
+ * @param {number} count
122
+ * @param {string} name
123
+ * @returns {Array<number>}
124
+ */
125
+ function broadcastCaps(v, defaultValue, count, name) {
126
+ const arr = Array.isArray(v)
127
+ ? v.slice()
128
+ : new Array(count).fill(v == null ? defaultValue : v);
129
+ if (arr.length !== count) {
130
+ throw new Error(`extractFlows: ${name} array length ${arr.length} does not match anchors length ${count}`);
131
+ }
132
+ for (const n of arr) {
133
+ if (!Number.isInteger(n) || n < 0) {
134
+ throw new Error(`extractFlows: ${name} entries must be non-negative integers, got ${n}`);
135
+ }
136
+ }
137
+ return arr;
138
+ }
139
+
140
+ /**
141
+ * Extract per-user flows from raw events.
142
+ *
143
+ * @param {Array<Object>} events flat event records
144
+ * @param {Object} options
145
+ * @param {Array<string|FlowAnchor>} options.anchors ordered anchor steps (>= 1)
146
+ * @param {number|Array<number>} [options.forward=4] steps kept AFTER each anchor (per-anchor array or scalar broadcast)
147
+ * @param {number|Array<number>} [options.reverse=0] steps kept BEFORE each anchor (ring — keeps the LAST N)
148
+ * @param {'unique'|'general'|'sessions'} [options.countType='unique']
149
+ * @param {Array<string>} [options.hiddenEvents] dropped from non-anchor steps (anchor names exempt)
150
+ * @param {Array<string>} [options.visibleEvents] when non-empty, non-anchor allow-list (hidden wins)
151
+ * @param {boolean} [options.collapseRepeated=false]
152
+ * @param {string} [options.breakdownProperty] stamps a coerced segment value on every step
153
+ * @param {number} [options.sessionTimeoutMs] sessions countType only (default 30 min)
154
+ * @param {number} [options.maxSessionMs] sessions countType only (default 24 h)
155
+ * @param {Map<string,string>} [options.identityMap] device_id → canonical id
156
+ * @returns {Array<FlowRecord>} deterministic order: first-step time, then userId
157
+ */
158
+ export function extractFlows(events, {
159
+ anchors,
160
+ forward,
161
+ reverse,
162
+ countType = 'unique',
163
+ hiddenEvents,
164
+ visibleEvents,
165
+ collapseRepeated = false,
166
+ breakdownProperty,
167
+ sessionTimeoutMs,
168
+ maxSessionMs,
169
+ identityMap,
170
+ } = /** @type {*} */ ({})) {
171
+ if (!Array.isArray(events)) throw new Error('extractFlows: events must be an array');
172
+ if (!Array.isArray(anchors) || anchors.length === 0) {
173
+ throw new Error('extractFlows: anchors must be a non-empty array');
174
+ }
175
+ /** @type {Array<FlowAnchor>} */
176
+ const anchorDefs = anchors.map((a) => {
177
+ if (typeof a === 'string') return { event: a };
178
+ if (a && typeof a === 'object' && typeof a.event === 'string') return { event: a.event, where: a.where };
179
+ throw new Error('extractFlows: each anchor must be an event name or { event, where }');
180
+ });
181
+ if (countType !== 'unique' && countType !== 'general' && countType !== 'sessions') {
182
+ throw new Error(`extractFlows: countType must be "unique", "general", or "sessions", got "${countType}"`);
183
+ }
184
+
185
+ const A = anchorDefs.length;
186
+ const fwdCaps = broadcastCaps(forward, 4, A, 'forward');
187
+ const revCaps = broadcastCaps(reverse, 0, A, 'reverse');
188
+
189
+ // Anchor names are exempt from hiding (query/flows.go filterAnchorEventNames);
190
+ // hidden names are removed from the visible list (filteredVisibleEventSelectors).
191
+ const anchorNames = new Set(anchorDefs.map((a) => a.event));
192
+ const hiddenSet = new Set((hiddenEvents || []).filter((n) => !anchorNames.has(n)));
193
+ const visibleSet = (visibleEvents && visibleEvents.length)
194
+ ? new Set(visibleEvents.filter((n) => !hiddenSet.has(n)))
195
+ : null;
196
+
197
+ // Capacity slots: slot(i) = Σ_{j<=i} revCaps[j] + Σ_{j<i} fwdCaps[j] + i
198
+ // (flow_aggregate step numbering under LEFT + FUNNEL — see header).
199
+ const anchorSlots = [];
200
+ {
201
+ let acc = 0;
202
+ for (let i = 0; i < A; i++) {
203
+ acc += revCaps[i];
204
+ anchorSlots.push(acc + i);
205
+ acc += fwdCaps[i];
206
+ }
207
+ }
208
+
209
+ /** flow_create (flows.cpp:680-717): reached -1, active = reverse[0]. */
210
+ const makeFlow = () => ({
211
+ /** @type {Array<Object|null>} */ anchors: new Array(A).fill(null),
212
+ reached: -1,
213
+ /** @type {Array<Array<Object>>} */ fwd: fwdCaps.map(() => []),
214
+ /** @type {Array<Array<Object>>} */ rev: revCaps.map(() => []),
215
+ active: { kind: 'rev', idx: 0 },
216
+ /** @type {Object|null} */ lastStep: null,
217
+ });
218
+
219
+ /** @param {ReturnType<typeof makeFlow>} f */
220
+ const activeCanAccept = (f) => (f.active.kind === 'rev'
221
+ // circular buffers are full only at capacity 0 (flows.cpp:575-600)
222
+ ? revCaps[f.active.idx] > 0
223
+ : f.fwd[f.active.idx].length < fwdCaps[f.active.idx]);
224
+
225
+ /**
226
+ * Post-add switch (flows.cpp:936-943): a full linear forward buffer hands
227
+ * the active pointer to the NEXT anchor's reverse ring — runs after every
228
+ * flow_add_step, including collapse-suppressed ones.
229
+ * @param {ReturnType<typeof makeFlow>} f
230
+ */
231
+ const tailSwitch = (f) => {
232
+ if (f.active.kind === 'fwd'
233
+ && f.fwd[f.active.idx].length >= fwdCaps[f.active.idx]
234
+ && f.reached + 1 < A) {
235
+ f.active = { kind: 'rev', idx: f.reached + 1 };
236
+ }
237
+ };
238
+
239
+ /** @param {Object} e @param {number} ms */
240
+ const mkStep = (e, ms) => {
241
+ /** @type {Object} */ const s = { label: e.event, timeMs: ms };
242
+ if (breakdownProperty != null) s.value = coerceToBreakdownKey(e[breakdownProperty]);
243
+ return s;
244
+ };
245
+
246
+ /** Segment-aware repeat check (flows.cpp:849-876): event name + segment values. */
247
+ const stepEquals = (a, b) => a.label === b.label && a.value === b.value;
248
+
249
+ /**
250
+ * @param {ReturnType<typeof makeFlow>} f
251
+ * @param {Object} e
252
+ * @param {number} ms
253
+ */
254
+ const processEvent = (f, e, ms) => {
255
+ // Next-anchor-only (flows_query.cpp:988-994)
256
+ const cand = f.reached + 1;
257
+ const def = cand < A ? anchorDefs[cand] : null;
258
+ if (def && e.event === def.event && matchesWhere(e, def.where)) {
259
+ const step = mkStep(e, ms);
260
+ f.anchors[cand] = step;
261
+ f.reached = cand;
262
+ f.active = { kind: 'fwd', idx: cand };
263
+ f.lastStep = step; // buffer_of_last_step updates on anchor adds too
264
+ tailSwitch(f);
265
+ return;
266
+ }
267
+ // Non-anchor path: filters apply only here (flows_query.cpp:1019-1036)
268
+ if (hiddenSet.has(e.event)) return;
269
+ if (visibleSet && !visibleSet.has(e.event)) return;
270
+ if (!activeCanAccept(f)) return; // flow_can_accept_non_anchor_step
271
+ const step = mkStep(e, ms);
272
+ if (!(collapseRepeated && f.lastStep && stepEquals(f.lastStep, step))) {
273
+ if (f.active.kind === 'rev') {
274
+ const buf = f.rev[f.active.idx];
275
+ buf.push(step);
276
+ if (buf.length > revCaps[f.active.idx]) buf.shift(); // ring keeps the LAST N
277
+ } else {
278
+ f.fwd[f.active.idx].push(step);
279
+ }
280
+ f.lastStep = step;
281
+ }
282
+ tailSwitch(f);
283
+ };
284
+
285
+ /** @type {Array<FlowRecord>} */
286
+ const flows = [];
287
+
288
+ /**
289
+ * @param {ReturnType<typeof makeFlow>} f
290
+ * @param {string} userId
291
+ */
292
+ const flush = (f, userId) => {
293
+ // Zero-anchor flows contribute nothing (flows_query.cpp:844-848; flows.cpp:756-761)
294
+ if (f.reached < 0) return;
295
+ /** @type {Array<FlowStep>} */
296
+ const steps = [];
297
+ for (let i = 0; i <= f.reached; i++) {
298
+ const rev = f.rev[i];
299
+ for (let k = 0; k < rev.length; k++) {
300
+ steps.push({ ...rev[k], stepNumber: anchorSlots[i] - rev.length + k, isAnchor: false });
301
+ }
302
+ steps.push({ .../** @type {Object} */ (f.anchors[i]), stepNumber: anchorSlots[i], isAnchor: true, anchorIndex: i });
303
+ const fwd = f.fwd[i];
304
+ for (let k = 0; k < fwd.length; k++) {
305
+ steps.push({ ...fwd[k], stepNumber: anchorSlots[i] + 1 + k, isAnchor: false });
306
+ }
307
+ }
308
+ // Trailing reverse buffer of the never-reached next anchor
309
+ if (f.reached + 1 < A) {
310
+ const trail = f.rev[f.reached + 1];
311
+ for (let k = 0; k < trail.length; k++) {
312
+ steps.push({ ...trail[k], stepNumber: anchorSlots[f.reached + 1] - trail.length + k, isAnchor: false });
313
+ }
314
+ }
315
+ const first = /** @type {Object} */ (f.anchors[0]);
316
+ const last = /** @type {Object} */ (f.anchors[f.reached]);
317
+ flows.push({
318
+ userId,
319
+ steps,
320
+ reachedAnchor: f.reached,
321
+ converted: A > 1 && f.reached === A - 1, // flows.cpp:841-847
322
+ ttcMs: Math.max(0, last.timeMs - first.timeMs), // flows.cpp:828-839
323
+ });
324
+ };
325
+
326
+ // Group by resolved user; empty-uid and unparseable-time events are skipped
327
+ // (ARB's per-user state container behavior, matching sessionize).
328
+ /** @type {Map<string, Array<{e: Object, ms: number, idx: number}>>} */
329
+ const byUser = new Map();
330
+ for (let idx = 0; idx < events.length; idx++) {
331
+ const e = events[idx];
332
+ if (!e) continue;
333
+ const uid = resolveUserId(e, identityMap);
334
+ if (!uid) continue;
335
+ const ms = toMs(e.time);
336
+ if (!Number.isFinite(ms)) continue;
337
+ if (!byUser.has(uid)) byUser.set(uid, []);
338
+ byUser.get(uid).push({ e, ms, idx });
339
+ }
340
+
341
+ for (const [uid, list] of byUser) {
342
+ list.sort((a, b) => a.ms - b.ms || a.idx - b.idx);
343
+ if (countType === 'sessions') {
344
+ // Session boundaries derive from the user's FULL stream; a session
345
+ // end flushes + resets (flows_query.cpp:1156-1161).
346
+ const ordinals = sessionOrdinals(list.map((x) => x.e), {
347
+ ...(sessionTimeoutMs !== undefined ? { timeoutMs: sessionTimeoutMs } : {}),
348
+ ...(maxSessionMs !== undefined ? { maxSessionMs } : {}),
349
+ });
350
+ let f = makeFlow();
351
+ let curOrd = null;
352
+ for (const { e, ms } of list) {
353
+ const ord = ordinals.get(e);
354
+ if (ord === undefined) continue;
355
+ if (curOrd !== null && ord !== curOrd) {
356
+ flush(f, uid);
357
+ f = makeFlow();
358
+ }
359
+ curOrd = ord;
360
+ processEvent(f, e, ms);
361
+ }
362
+ flush(f, uid); // end of data flushes partial flows (flows_query.cpp:1369-1374)
363
+ } else {
364
+ // 'unique' and 'general' coincide for pure flows (see header)
365
+ const f = makeFlow();
366
+ for (const { e, ms } of list) processEvent(f, e, ms);
367
+ flush(f, uid);
368
+ }
369
+ }
370
+
371
+ flows.sort((a, b) => a.steps[0].timeMs - b.steps[0].timeMs
372
+ || cmpStr(String(a.userId), String(b.userId)));
373
+ return flows;
374
+ }
375
+
376
+ // ── Aggregation: prefix tree → prune → list / sankey ──────────────────────
377
+ //
378
+ // ARB packs each step (event + segments + step_number + type, with time
379
+ // zeroed first — flows.cpp:230-232) into the prefix-tree node key
380
+ // (flows_prefix_tree_add_with_segments, flows.cpp:207-311). Counters:
381
+ // total_count on every node, converted_total_count on every node of a
382
+ // converted flow's path, drop_off_total_count on the LAST node of EVERY flow
383
+ // ("In the last step of this flow the user will drop off" — converted flows
384
+ // included). Pruning (flows_prefix_tree_trim, flows.cpp:1189-1320) sums
385
+ // total_count per (step_number level, node key) across the WHOLE tree
386
+ // (flows_prefix_tree_calculate_counts, flows.cpp:1167-1187 — anchors
387
+ // included), keeps the top cardinality_threshold keys per level (count desc,
388
+ // node_count_compare at flows.cpp:1329-1337; equal counts tie-break by hash
389
+ // order there — this emulator uses label-then-value ascending for
390
+ // determinism), and rebuilds: a node survives if its key is top OR its step
391
+ // type is ANCHOR (flows_prefix_node_merge_into_other, flows.cpp:1259-1260);
392
+ // otherwise it is renamed to $mp_uncommon_flows_events with segments CLEARED
393
+ // (flows_prefix_node_set_uncommon_event_step; UNCOMMON_FLOWS_EVENT at
394
+ // flows.hpp:14), same-key siblings merge counters
395
+ // (flows_prefix_node_merge_counts_from) and their children re-parent under
396
+ // the merged node (the trim stack pushes children with the merged
397
+ // new_parent).
398
+ //
399
+ // CAVEAT (fix-round C3): the product SANKEY view never displays
400
+ // $mp_uncommon_flows_events — the graph merge path prunes low-cardinality
401
+ // branches into a PRUNED node via a separate mechanism, so this bucket is a
402
+ // LIST-merge artifact. Top-N kept nodes/edges agree with the product; do NOT
403
+ // write dungeon assertions against the "Other"/uncommon bucket on sankey
404
+ // output.
405
+
406
+ /** The coalesced-step rename target (flows.hpp:14 UNCOMMON_FLOWS_EVENT). */
407
+ export const UNCOMMON_FLOWS_EVENT = '$mp_uncommon_flows_events';
408
+
409
+ // NUL separator keeps packed keys collision-free for multi-word event names;
410
+ // the SOH sentinel distinguishes "no segment" from a real empty-string value.
411
+ /** Codepoint-order compare - localeCompare ignores control chars. @param {string} a @param {string} b */
412
+ const cmpStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
413
+ const KEY_SEP = '\u0000';
414
+ const NO_VALUE = '\u0001';
415
+
416
+ /** @param {{stepNumber: number, label: string, value?: string, isAnchor: boolean}} s */
417
+ const nodeKey = (s) => [s.stepNumber, s.isAnchor ? 'A' : 'E', s.label, s.value === undefined ? NO_VALUE : s.value].join(KEY_SEP);
418
+
419
+ /**
420
+ * @typedef {Object} FlowTreeNode
421
+ * @property {string} key
422
+ * @property {number} stepNumber
423
+ * @property {string} label
424
+ * @property {string} [value]
425
+ * @property {boolean} isAnchor
426
+ * @property {number} total total_count
427
+ * @property {number} converted converted_total_count (flows passing through that converted)
428
+ * @property {number} dropoff drop_off_total_count (flows ENDING here — converted included)
429
+ * @property {Map<string, FlowTreeNode>} children
430
+ */
431
+
432
+ /**
433
+ * Aggregate extracted flows into Mixpanel's Top Paths output.
434
+ *
435
+ * @param {Array<FlowRecord>} flows output of extractFlows
436
+ * @param {Object} [options]
437
+ * @param {'list'|'sankey'} [options.output='list']
438
+ * @param {number} [options.cardinalityThreshold] per-level top-N before
439
+ * coalescing into $mp_uncommon_flows_events. Defaults follow the API layer:
440
+ * 50 for the list view (`bookmark.get("maxResults", 50)`, bookmark.py:96),
441
+ * 3 for sankey (`bookmark.get("cardinality", 3)`, bookmark.py:110).
442
+ * In LIST mode the same option is ARB's `cardinality_threshold`, which the
443
+ * merger reuses as the ROW cap (flows_merger.cpp:406-410) — 0 disables row
444
+ * truncation (the `> 0` guard there), but still empties the per-level keep
445
+ * sets (anchors survive on type).
446
+ * @param {'end'|'start'} [options.listSortPriority='end'] list row ordering
447
+ * (`bookmark.get("list_sort_priority", "end")`, bookmark.py:95): 'end'
448
+ * sorts by the leaf's total_count, 'start' by the slot-0 step's
449
+ * total_count (flows_merger.cpp:392-404). List mode only.
450
+ * @returns {Object} list → `{ paths, totalEntered, overallConversionRate,
451
+ * foundCount, returnedCount }` — ONE row per LEAF of the trimmed prefix
452
+ * tree (flows_merger.cpp:358-382), each `{ steps, count, converted }`
453
+ * where steps carry the full root→leaf path with per-step `count`
454
+ * (total_count, flows_merger.cpp:198), row `count` = the leaf's
455
+ * total_count (ended_total_count, :174), and `converted` = converted
456
+ * flows ending at the leaf (emulator bookkeeping — the ARB Flow proto
457
+ * carries no converted counter). Flows ending at INTERIOR nodes get no
458
+ * row of their own; they are visible only as per-step count
459
+ * differentials. `foundCount`/`returnedCount` mirror the response's
460
+ * found_count/returned_count (:420-421). sankey →
461
+ * `{ levels, edges, totalEntered, overallConversionRate }` where
462
+ * `levels[stepNumber]` holds coalesced nodes `{ label, value?, isAnchor,
463
+ * count, dropoff, converted }` sorted count-desc, and each edge is
464
+ * `{ fromLevel, from, toLevel, to, count }` with from/to INDICES into the
465
+ * sorted level arrays (edges may skip levels — capacity slots can be empty
466
+ * on a given path, so `toLevel` is explicit).
467
+ */
468
+ export function aggregateFlows(flows, { output = 'list', cardinalityThreshold, listSortPriority = 'end' } = {}) {
469
+ if (output !== 'list' && output !== 'sankey') {
470
+ throw new Error(`aggregateFlows: output must be "list" or "sankey", got "${output}"`);
471
+ }
472
+ if (listSortPriority !== 'end' && listSortPriority !== 'start') {
473
+ // "invalid result sort priority" (flows_merger.cpp:402-403)
474
+ throw new Error(`aggregateFlows: listSortPriority must be "end" or "start", got "${listSortPriority}"`);
475
+ }
476
+ const threshold = cardinalityThreshold !== undefined
477
+ ? cardinalityThreshold
478
+ : (output === 'sankey' ? 3 : 50); // bookmark.py:110 / :96
479
+ if (!Number.isInteger(threshold) || threshold < 0) {
480
+ throw new Error(`aggregateFlows: cardinalityThreshold must be a non-negative integer, got ${threshold}`);
481
+ }
482
+
483
+ const totalEntered = flows.length;
484
+ const convertedFlows = flows.reduce((n, f) => n + (f.converted ? 1 : 0), 0);
485
+ const overallConversionRate = totalEntered ? convertedFlows / totalEntered : 0;
486
+
487
+ // Build the prefix tree (flows.cpp:207-311).
488
+ /** @type {{children: Map<string, FlowTreeNode>}} */
489
+ const root = { children: new Map() };
490
+ for (const f of flows) {
491
+ let cur = root;
492
+ for (let i = 0; i < f.steps.length; i++) {
493
+ const s = f.steps[i];
494
+ const key = nodeKey(s);
495
+ let node = cur.children.get(key);
496
+ if (!node) {
497
+ node = {
498
+ key, stepNumber: s.stepNumber, label: s.label, value: s.value,
499
+ isAnchor: s.isAnchor, total: 0, converted: 0, dropoff: 0,
500
+ children: new Map(),
501
+ };
502
+ cur.children.set(key, node);
503
+ }
504
+ node.total += 1;
505
+ if (f.converted) node.converted += 1;
506
+ if (i === f.steps.length - 1) node.dropoff += 1;
507
+ cur = node;
508
+ }
509
+ }
510
+
511
+ // Per-level totals across the WHOLE tree (flows.cpp:1167-1187), then the
512
+ // per-level keep set (top `threshold` by summed total_count).
513
+ /** @type {Map<number, Map<string, {count: number, label: string, value: string}>>} */
514
+ const levelCounts = new Map();
515
+ (function tally(children) {
516
+ for (const node of children.values()) {
517
+ let lvl = levelCounts.get(node.stepNumber);
518
+ if (!lvl) { lvl = new Map(); levelCounts.set(node.stepNumber, lvl); }
519
+ const entry = lvl.get(node.key);
520
+ if (entry) entry.count += node.total;
521
+ else lvl.set(node.key, { count: node.total, label: node.label, value: node.value === undefined ? NO_VALUE : node.value });
522
+ tally(node.children);
523
+ }
524
+ })(root.children);
525
+ /** @type {Map<number, Set<string>>} */
526
+ const keepSets = new Map();
527
+ for (const [level, lvl] of levelCounts) {
528
+ const ranked = [...lvl.entries()].sort((a, b) => b[1].count - a[1].count
529
+ || (a[1].label < b[1].label ? -1 : a[1].label > b[1].label ? 1 : 0)
530
+ || (a[1].value < b[1].value ? -1 : a[1].value > b[1].value ? 1 : 0));
531
+ keepSets.set(level, new Set(ranked.slice(0, threshold).map(([k]) => k)));
532
+ }
533
+
534
+ /** @param {FlowTreeNode} dst @param {FlowTreeNode} src */
535
+ const mergeNode = (dst, src) => {
536
+ // flows_prefix_node_merge_counts_from + child re-parenting
537
+ dst.total += src.total;
538
+ dst.converted += src.converted;
539
+ dst.dropoff += src.dropoff;
540
+ for (const child of src.children.values()) {
541
+ const existing = dst.children.get(child.key);
542
+ if (existing) mergeNode(existing, child);
543
+ else dst.children.set(child.key, child);
544
+ }
545
+ };
546
+
547
+ /**
548
+ * Bottom-up rebuild — equivalent to ARB's top-down stack because the keep
549
+ * sets are computed on the PRE-trim tree and merging only sums counters.
550
+ * @param {Map<string, FlowTreeNode>} children
551
+ * @returns {Map<string, FlowTreeNode>}
552
+ */
553
+ const trim = (children) => {
554
+ /** @type {Map<string, FlowTreeNode>} */
555
+ const out = new Map();
556
+ for (const node of children.values()) {
557
+ node.children = trim(node.children);
558
+ let target = node;
559
+ if (!node.isAnchor && !(keepSets.get(node.stepNumber) || new Set()).has(node.key)) {
560
+ target = {
561
+ ...node,
562
+ label: UNCOMMON_FLOWS_EVENT,
563
+ value: undefined, // segments cleared (flows_prefix_node_set_uncommon_event_step)
564
+ key: nodeKey({ stepNumber: node.stepNumber, label: UNCOMMON_FLOWS_EVENT, isAnchor: false }),
565
+ };
566
+ }
567
+ const existing = out.get(target.key);
568
+ if (existing) mergeNode(existing, target);
569
+ else out.set(target.key, target);
570
+ }
571
+ return out;
572
+ };
573
+ const trimmed = trim(root.children);
574
+
575
+ /** @param {FlowTreeNode} node */
576
+ const stepOut = (node) => {
577
+ /** @type {Object} */ const s = { label: node.label, stepNumber: node.stepNumber, isAnchor: node.isAnchor };
578
+ if (node.value !== undefined) s.value = node.value;
579
+ return s;
580
+ };
581
+
582
+ if (output === 'list') {
583
+ // One row per LEAF of the trimmed tree: the merger DFSes the tree and
584
+ // builds a flow only at `children == nullptr && parent != nullptr`
585
+ // (flows_merger.cpp:358-382, leaf test :362). Each row carries the
586
+ // full root→leaf path with every step's total_count (:198); the row
587
+ // count is the leaf's total_count (ended_total_count, :174) — a leaf
588
+ // has no children, so every flow reaching it ended there and leaf
589
+ // total == leaf dropoff by tree construction (the same holds after
590
+ // uncommon-merging: a trimmed leaf only absorbs other leaves, so
591
+ // `converted` ≡ converted flows ending at the leaf). ARB pads every
592
+ // row to the query's full slot capacity with EMPTY steps
593
+ // (flows_merger.cpp:171, :185-189); the emulator omits the
594
+ // placeholders — slot gaps stay visible through each step's
595
+ // stepNumber.
596
+ /** @type {Array<{steps: Array<Object>, count: number, converted: number}>} */
597
+ const rows = [];
598
+ (function walk(children, prefix) {
599
+ for (const node of children.values()) {
600
+ const steps = [...prefix, { ...stepOut(node), count: node.total }];
601
+ if (node.children.size === 0) {
602
+ rows.push({ steps, count: node.total, converted: node.converted });
603
+ }
604
+ walk(node.children, steps);
605
+ }
606
+ })(trimmed, []);
607
+ // started_total_count is only assigned when a REAL node occupies slot
608
+ // 0 (flows_merger.cpp:249-250) — a path whose shallowest node sits at
609
+ // a deeper slot sorts as 0 under 'start'.
610
+ /** @param {{steps: Array<*>}} r */
611
+ const startedOf = (r) => (r.steps[0].stepNumber === 0 ? r.steps[0].count : 0);
612
+ /** @param {{steps: Array<*>}} r */
613
+ const pathKey = (r) => r.steps.map((s) => s.label + KEY_SEP + (s.value ?? NO_VALUE)).join(KEY_SEP);
614
+ // DL_SORT ties keep DFS/hash insertion order in ARB
615
+ // (flows_merger.cpp:392-404); the emulator tie-breaks on the joined
616
+ // (label, value) path ascending for determinism.
617
+ rows.sort((a, b) => (listSortPriority === 'start'
618
+ ? startedOf(b) - startedOf(a)
619
+ : b.count - a.count)
620
+ || cmpStr(pathKey(a), pathKey(b)));
621
+ // cardinality_threshold doubles as the row cap in list mode
622
+ // (maxResults, bookmark.py:96); 0 disables truncation — the merger
623
+ // only truncates when the threshold is > 0 (flows_merger.cpp:406-410).
624
+ const foundCount = rows.length;
625
+ const paths = threshold > 0 && rows.length > threshold ? rows.slice(0, threshold) : rows;
626
+ return { paths, totalEntered, overallConversionRate, foundCount, returnedCount: paths.length };
627
+ }
628
+
629
+ // sankey: coalesce nodes per (level, label, value, type) across branches;
630
+ // outgoing edges come from the coalescing children (finalize_level,
631
+ // flows.cpp:1818+); a node's terminations are its dropoff
632
+ // (total − Σ outgoing = drop_off_total_count by tree construction).
633
+ /** @type {Map<string, {node: Object, level: number}>} */
634
+ const coalesced = new Map();
635
+ /** @type {Map<string, {fromKey: string, toKey: string, count: number}>} */
636
+ const edgeMap = new Map();
637
+ (function walk(children, parentKey) {
638
+ for (const node of children.values()) {
639
+ let c = coalesced.get(node.key);
640
+ if (!c) {
641
+ const obj = { label: node.label, isAnchor: node.isAnchor, count: 0, dropoff: 0, converted: 0 };
642
+ if (node.value !== undefined) /** @type {*} */ (obj).value = node.value;
643
+ c = { node: obj, level: node.stepNumber };
644
+ coalesced.set(node.key, c);
645
+ }
646
+ /** @type {*} */ (c.node).count += node.total;
647
+ /** @type {*} */ (c.node).dropoff += node.dropoff;
648
+ /** @type {*} */ (c.node).converted += node.converted;
649
+ if (parentKey !== null) {
650
+ const ek = parentKey.length + ':' + parentKey + node.key; // length-prefix: collision-free join
651
+ const e = edgeMap.get(ek);
652
+ if (e) e.count += node.total;
653
+ else edgeMap.set(ek, { fromKey: parentKey, toKey: node.key, count: node.total });
654
+ }
655
+ walk(node.children, node.key);
656
+ }
657
+ })(trimmed, null);
658
+
659
+ const maxLevel = Math.max(-1, ...[...coalesced.values()].map(c => c.level));
660
+ /** @type {Array<Array<Object>>} */
661
+ const levels = Array.from({ length: maxLevel + 1 }, () => []);
662
+ /** @type {Map<string, {level: number, index: number}>} */
663
+ const position = new Map();
664
+ for (const [key, { node, level }] of coalesced) levels[level].push({ key, node });
665
+ for (let i = 0; i <= maxLevel; i++) {
666
+ levels[i].sort((a, b) => /** @type {*} */ (b).node.count - /** @type {*} */ (a).node.count
667
+ || cmpStr(String(/** @type {*} */ (a).node.label), String(/** @type {*} */ (b).node.label))
668
+ || cmpStr(String(/** @type {*} */ (a).node.value ?? ""), String(/** @type {*} */ (b).node.value ?? "")));
669
+ levels[i].forEach((entry, index) => position.set(/** @type {*} */ (entry).key, { level: i, index }));
670
+ levels[i] = levels[i].map(entry => /** @type {*} */ (entry).node);
671
+ }
672
+ const edges = [...edgeMap.values()].map(({ fromKey, toKey, count }) => {
673
+ const from = /** @type {{level: number, index: number}} */ (position.get(fromKey));
674
+ const to = /** @type {{level: number, index: number}} */ (position.get(toKey));
675
+ return { fromLevel: from.level, from: from.index, toLevel: to.level, to: to.index, count };
676
+ });
677
+ edges.sort((a, b) => a.fromLevel - b.fromLevel || a.from - b.from || a.toLevel - b.toLevel || a.to - b.to);
678
+ return { levels, edges, totalEntered, overallConversionRate };
679
+ }