@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.
- 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 +96 -0
- package/.claude/skills/create-project/provision.mjs +336 -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 +211 -0
- package/HOOKS.md +566 -112
- package/README.md +28 -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 +2 -2
- 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 +5 -4
- 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 +3 -2
- 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 +296 -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
|
@@ -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
|
+
}
|