@ak--47/dungeon-master 1.5.4 → 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 +1 -1
- package/.claude/skills/create-project/provision.mjs +57 -10
- 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 +183 -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 +2 -1
- package/scripts/extract-dungeon-schema.mjs +2 -2
- package/scripts/run-many.mjs +2 -2
- package/scripts/verify-runner.mjs +2 -2
- package/scripts/verify-stories.mjs +244 -0
- package/types.d.ts +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
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
*
|
|
26
26
|
* NOT implemented:
|
|
27
27
|
* - Aggressive/optimized reentry (`enable_early_reentry`)
|
|
28
|
-
* - Any-order step blocks (`is_any_order_step`)
|
|
29
28
|
* - Selector expressions beyond eq/neq/gt/lt/gte/lte/contains/not_contains
|
|
30
29
|
*
|
|
31
30
|
* @typedef {Object} StepFilter
|
|
@@ -33,56 +32,163 @@
|
|
|
33
32
|
* @property {'eq'|'neq'|'gt'|'lt'|'gte'|'lte'|'contains'|'not_contains'} op
|
|
34
33
|
* @property {*} value
|
|
35
34
|
*
|
|
36
|
-
* @typedef {string | { event: string, where?: StepFilter }} FunnelStep
|
|
35
|
+
* @typedef {string | { event: string, where?: StepFilter } | AnyOrderBlock} FunnelStep
|
|
36
|
+
*
|
|
37
|
+
* @typedef {Object} AnyOrderBlock
|
|
38
|
+
* @property {Array<string | { event: string, where?: StepFilter }>} anyOrder -
|
|
39
|
+
* v1.6.0 (P1.6.6): a contiguous block of steps the user may complete in ANY
|
|
40
|
+
* internal order, mixed freely with ordered "anchor" steps. Mixpanel's
|
|
41
|
+
* any-order is an anchor/chunk greedy pass, not a permutation search
|
|
42
|
+
* (history.cpp:490-519 advance, :538-589 cascade, :926-963
|
|
43
|
+
* can_record_any_order_step):
|
|
44
|
+
* - Anchor steps sit at fixed positions; a chunk of N any-order steps
|
|
45
|
+
* between two anchors fills its N positions in event-arrival order.
|
|
46
|
+
* - Within the ACTIVE chunk, the FIRST eligible match per step wins — no
|
|
47
|
+
* latest-match overwrite (history.cpp:951-957).
|
|
48
|
+
* - Steps in chunks past a not-yet-seen anchor buffer the LATEST match
|
|
49
|
+
* (:958-962), credited when the anchor is crossed — and only if the
|
|
50
|
+
* buffered time comes after the anchor per the 2-second rule.
|
|
51
|
+
* - The cascade advances through anchors and fully-satisfied chunks in
|
|
52
|
+
* one pass; a partially-satisfied chunk claims its present members and
|
|
53
|
+
* stops (:559-587).
|
|
54
|
+
* Blocks cannot nest and must not be empty.
|
|
37
55
|
*
|
|
38
56
|
* @typedef {Object} ExclusionStep
|
|
39
|
-
* @property {string} event - Event name that terminates the attempt
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
57
|
+
* @property {string} event - Event name that terminates the attempt when it
|
|
58
|
+
* fires in a qualifying gap. ARB semantics (history.cpp
|
|
59
|
+
* history_record_exclusion_step + funnel_query_is_step_terminated):
|
|
60
|
+
* exclusions live in the GAPS between consecutive steps — one slot per gap
|
|
61
|
+
* g = (step g, step g+1). They can never terminate an attempt before step 0
|
|
62
|
+
* or more than 2s after completion, must fall within the conversion window
|
|
63
|
+
* from step 0, and ties within the 2-second grace are resolved AGAINST the
|
|
64
|
+
* user (anti-conversion bias). Termination keeps `reached` (clamping it
|
|
65
|
+
* back to the killed gap when a buffered exclusion fires late) and freezes
|
|
66
|
+
* the attempt — no restart from the exclusion event.
|
|
67
|
+
* @property {number} [afterStep] - First gap index this exclusion applies to.
|
|
68
|
+
* Default 0. `{ afterStep: 1, beforeStep: 2 }` = "between step 1 and step 2" only.
|
|
69
|
+
* @property {number} [beforeStep] - Exclusive upper bound on applicable gap
|
|
70
|
+
* indexes. Default `steps.length` (every gap).
|
|
71
|
+
*
|
|
72
|
+
* @typedef {Object} SessionConversionWindow
|
|
73
|
+
* @property {'sessions'} unit - Only 'sessions' is supported here; time-based
|
|
74
|
+
* windows use `conversionWindowMs`.
|
|
75
|
+
* @property {number} n - Session count, 1–12. `n = 1` means "same session";
|
|
76
|
+
* `n = 2` means "same or next session". Mixpanel's API caps session windows
|
|
77
|
+
* at 12 (`_MAX_LENGTHS["session"]`, api/version_2_0/arb_funnels/validate.py).
|
|
47
78
|
*
|
|
48
79
|
* @typedef {Object} FunnelOptions
|
|
49
80
|
* @property {number} [conversionWindowMs] - Max time from step 0 to last
|
|
50
81
|
* step (strict `<`). Omit for no window check.
|
|
82
|
+
* @property {SessionConversionWindow} [conversionWindow] - Session-count
|
|
83
|
+
* conversion window (Mixpanel funnel "conversion window: N sessions").
|
|
84
|
+
* Mutually exclusive with `conversionWindowMs`. Semantics
|
|
85
|
+
* (conversion_window.cpp WINDOW_TYPE_SESSIONS + funnel_query.cpp session_id
|
|
86
|
+
* plumbing): each event gets a per-user session ordinal (count of session
|
|
87
|
+
* ENDS before it, seeded 0 — see `sessionOrdinals`); a step passes iff
|
|
88
|
+
* `ordinal(step) < ordinal(step0) + n` — ordinal-ONLY, no wall-clock term
|
|
89
|
+
* (conversion_window.cpp:50; ARB's n×1-day bound binds only against the
|
|
90
|
+
* trend-interval end and the data-pull range — fix-round C6).
|
|
51
91
|
* @property {boolean} [graceperiod=true] - Enable the 2-second grace window
|
|
52
92
|
* on ordering checks. Disable only for tests that need strict ordering.
|
|
53
93
|
* @property {boolean} [reentry=false] - When true, after completing all steps,
|
|
54
94
|
* reset to step 0 and continue scanning. Increments `completions`.
|
|
95
|
+
* Attempts ALSO restart when the conversion window expires (fix-round
|
|
96
|
+
* B2+C5): an incoming event past the window from step 0 finalizes the
|
|
97
|
+
* live attempt as a drop-off and processes against a fresh one — ARB
|
|
98
|
+
* checks expiry before processing each event, then re-births a history
|
|
99
|
+
* for that same event (funnel_query.cpp:1608-1617, :1663-1680;
|
|
100
|
+
* history_is_past_conversion_window, history.cpp:785-793). This models
|
|
101
|
+
* COUNT_TYPE_GENERAL, whose `allow_simultaneous_histories` and
|
|
102
|
+
* `allow_record_multiple_history` are both true (funnel_query.cpp:592-610).
|
|
55
103
|
* @property {ExclusionStep[]} [exclusionSteps] - Exclusion events that
|
|
56
104
|
* terminate the current attempt when fired between specified steps.
|
|
57
105
|
* @property {boolean | string[]} [trackStepProperties=false] - When truthy,
|
|
58
106
|
* `result.stepProperties[i]` contains the matched event's properties at
|
|
59
107
|
* each step. Pass an array to filter to specific property names.
|
|
60
|
-
* @property {'uniques'|'totals'} [countMode='uniques'] - `'totals'` returns an
|
|
108
|
+
* @property {'uniques'|'totals'|'sessions'} [countMode='uniques'] - `'totals'` returns an
|
|
61
109
|
* ARRAY of FunnelResult — one per attempt (Mixpanel funnel_query.cpp:2055-2100).
|
|
62
110
|
* Includes incomplete attempts (drop-offs contribute to per-step counts).
|
|
63
|
-
* Without `reentry: true`, the array has at most one
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
111
|
+
* Without `reentry: true` or `woRepeat: true`, the array has at most one
|
|
112
|
+
* entry (the single attempt).
|
|
113
|
+
* `'sessions'` is Mixpanel's "count by Sessions" — a documented API-rewrite
|
|
114
|
+
* preset, NOT an engine mode: expands to exactly `countMode: 'totals'` +
|
|
115
|
+
* `woRepeat: true` (the `general_wo_repeat` rewrite) +
|
|
116
|
+
* `conversionWindow: { unit: 'sessions', n: 1 }` (__validate_sessions in
|
|
117
|
+
* api/version_2_0/arb_funnels/validate.py). Throws if combined with
|
|
118
|
+
* `reentry: true` or any other conversion window.
|
|
119
|
+
* @property {boolean} [woRepeat=false] - COUNT_TYPE_GENERAL_WO_REPEAT
|
|
120
|
+
* (fix-round B2): totals counting where window expiry is the ONLY restart —
|
|
121
|
+
* at most one attempt per window span. A decided attempt (completed OR
|
|
122
|
+
* excluded) stays open, absorbing events, until one arrives past the
|
|
123
|
+
* conversion window from step 0; that event finalizes the attempt and a
|
|
124
|
+
* fresh one processes the same event (funnel_query.cpp:1608-1613 — for
|
|
125
|
+
* GENERAL_WO_REPEAT termination checks ONLY history_is_past_conversion_
|
|
126
|
+
* window, not history_is_mutable; re-birth :1663-1680). Contrast
|
|
127
|
+
* `reentry: true` (GENERAL), which also restarts right after each
|
|
128
|
+
* completion/exclusion, permitting repeat conversions within one window.
|
|
129
|
+
* Requires `countMode: 'totals'`; mutually exclusive with `reentry` and
|
|
130
|
+
* `sessionScoped`.
|
|
131
|
+
* @property {boolean} [sessionScoped=false] - **@deprecated — verifier-only, NOT
|
|
132
|
+
* Mixpanel-comparable.** Partitions events by generator-stamped `session_id` and runs
|
|
133
|
+
* the matcher independently per session, returning the best result (or all results
|
|
134
|
+
* when `countMode: 'totals'`). Mixpanel does NOT partition funnels per session — it
|
|
135
|
+
* bounds them by session COUNT via `WINDOW_TYPE_SESSIONS` on the conversion window
|
|
136
|
+
* (`conversion_window.cpp:9-13`). Use `conversionWindow: { unit: 'sessions', n }`
|
|
137
|
+
* for Mixpanel-comparable results; results from `sessionScoped: true` are NOT
|
|
138
|
+
* reproducible in the Mixpanel UI.
|
|
139
|
+
* @property {{fromMs?: number, toMs?: number}} [anchorRange] - v1.6.0 (P1.6.5):
|
|
140
|
+
* step-0 anchor bounds for trend intervals. Mixpanel funnel trends evaluate
|
|
141
|
+
* each interval as "step 0 in [start, stop); steps 1+ in
|
|
142
|
+
* [start, stop + conversion window)" (funnel_query.cpp:1398-1401 — the
|
|
143
|
+
* query scans the extended range but only anchors histories whose step-0
|
|
144
|
+
* event falls inside the interval proper). Events outside
|
|
145
|
+
* `[fromMs, toMs)` cannot record step 0 (a new attempt can never anchor
|
|
146
|
+
* there, including under `reentry`); they remain eligible for steps 1+,
|
|
147
|
+
* exclusion gaps, and session-ordinal derivation. Callers are expected to
|
|
148
|
+
* pre-slice the stream to [fromMs, toMs + window) — the engine only
|
|
149
|
+
* enforces the anchor bound.
|
|
71
150
|
*
|
|
72
151
|
* @typedef {Object} FunnelResult
|
|
73
152
|
* @property {boolean} completed - Reached every step.
|
|
74
153
|
* @property {number} reached - Highest step index reached (0-based). `-1` if no steps reached.
|
|
75
154
|
* @property {Array<Object|null>} stepEvents - The event assigned to each reached step.
|
|
76
155
|
* @property {Array<number|null>} stepTimes - Timestamp (ms) of each reached step.
|
|
77
|
-
* @property {number|null} ttcMs - Time-to-convert
|
|
156
|
+
* @property {number|null} ttcMs - Time-to-convert in ms: `$ttc` at ms
|
|
157
|
+
* resolution — MAX over recorded slot times minus position-0 time
|
|
158
|
+
* (history.cpp:914-918 uses history_get_last_time, :843-847 = max over the
|
|
159
|
+
* slot array, NOT the last position — under any-order grace claims the last
|
|
160
|
+
* position's time can precede an earlier position's). Always >= 0 on
|
|
161
|
+
* completion. `null` if not completed.
|
|
162
|
+
* @property {number|null} ttcSeconds - ARB's `$ttc` computed property: floor
|
|
163
|
+
* seconds of ttcMs, defined ONLY on full conversion (history.cpp:914-922 —
|
|
164
|
+
* `$ttc` is value_create_undefined() otherwise). `null` if not completed.
|
|
165
|
+
* @property {number[]} gapSeconds - Per-gap deltas for positions 1..reached:
|
|
166
|
+
* `t_p <= t_{p-1} ? 0 : floor((t_p - t_{p-1}) / 1000)` — integer seconds,
|
|
167
|
+
* clamped to 0 per gap when timestamps are non-increasing
|
|
168
|
+
* (funnel_query.cpp:3374, int arithmetic). Recorded for every attempt that
|
|
169
|
+
* reached position 1+, converted or not (:3359 loops `i <= reached`).
|
|
170
|
+
* Length = max(reached, 0).
|
|
171
|
+
* @property {number[]} gapSecondsFromStart - Running sum of the CLAMPED
|
|
172
|
+
* per-gap deltas (`time_from_start += delta`, funnel_query.cpp:3375) — NOT
|
|
173
|
+
* `t_p - t_0`; the two differ whenever a gap clamps. Same length as
|
|
174
|
+
* gapSeconds.
|
|
78
175
|
* @property {number} completions - Total completions (1 if no reentry; 0 if not completed).
|
|
79
176
|
* @property {Array<Object>|undefined} stepProperties - Per-step property snapshots when `trackStepProperties` set.
|
|
80
177
|
* @property {string|undefined} sessionId - Set when result came from a session-scoped slice.
|
|
178
|
+
* @property {boolean} terminatedByExclusion - Attempt was DISQUALIFIED by an
|
|
179
|
+
* exclusion event. `reached` is kept (possibly clamped back to the killed
|
|
180
|
+
* gap): the user still counts at steps 0..reached AND in the excluded
|
|
181
|
+
* bucket at step reached+1 (funnel_query.cpp `fr->excluded[reached + 1]`).
|
|
182
|
+
* @property {number|null} excludedAtStep - `reached + 1` when terminated by
|
|
183
|
+
* exclusion — the index ARB's `excluded[]` aggregation uses — else null.
|
|
81
184
|
*/
|
|
82
185
|
|
|
83
186
|
import { toMs } from '../hook-helpers/_internal.js';
|
|
187
|
+
import { sessionOrdinals } from './sessionize.js';
|
|
188
|
+
import { matchesWhere } from './coerce.js';
|
|
84
189
|
|
|
85
190
|
const OUT_OF_ORDER_MS = 2000;
|
|
191
|
+
const DAY_MS = 86400 * 1000;
|
|
86
192
|
|
|
87
193
|
/**
|
|
88
194
|
* Returns true if `t1` is "after" `t2` by Mixpanel's funnel rules.
|
|
@@ -116,9 +222,10 @@ export function withinConversionWindow(eventTime, step0Time, windowMs) {
|
|
|
116
222
|
|
|
117
223
|
/**
|
|
118
224
|
* Normalize a funnel step (string OR `{ event, where? }`) into the canonical
|
|
119
|
-
* `{ event, where }` shape.
|
|
225
|
+
* `{ event, where }` shape. AnyOrder blocks are NOT accepted here — flatten
|
|
226
|
+
* them with `normalizeFunnelSteps` first.
|
|
120
227
|
*
|
|
121
|
-
* @param {
|
|
228
|
+
* @param {string | { event: string, where?: StepFilter }} step
|
|
122
229
|
* @returns {{ event: string, where?: StepFilter }}
|
|
123
230
|
*/
|
|
124
231
|
export function normalizeStep(step) {
|
|
@@ -129,29 +236,79 @@ export function normalizeStep(step) {
|
|
|
129
236
|
throw new Error(`normalizeStep: invalid step ${JSON.stringify(step)}`);
|
|
130
237
|
}
|
|
131
238
|
|
|
239
|
+
/**
|
|
240
|
+
* v1.6.0 (P1.6.6): flatten a step list that may contain `{ anyOrder: [...] }`
|
|
241
|
+
* blocks into per-slot arrays plus the anchor topology ARB precomputes per
|
|
242
|
+
* history (history.cpp:214-228): `prevAnchor[i]` = nearest anchor slot ≤ i
|
|
243
|
+
* (−1 if none), `nextAnchor[i]` = nearest anchor slot > i (numSteps if none).
|
|
244
|
+
*
|
|
245
|
+
* @param {FunnelStep[]} steps
|
|
246
|
+
* @returns {{
|
|
247
|
+
* flat: Array<{ event: string, where?: StepFilter }>,
|
|
248
|
+
* isAnyOrder: boolean[],
|
|
249
|
+
* prevAnchor: number[],
|
|
250
|
+
* nextAnchor: number[],
|
|
251
|
+
* hasAnyOrder: boolean,
|
|
252
|
+
* }}
|
|
253
|
+
*/
|
|
254
|
+
export function normalizeFunnelSteps(steps) {
|
|
255
|
+
const flat = [];
|
|
256
|
+
const isAnyOrder = [];
|
|
257
|
+
for (const s of steps) {
|
|
258
|
+
if (s && typeof s === 'object' && 'anyOrder' in s) {
|
|
259
|
+
const block = /** @type {AnyOrderBlock} */ (s).anyOrder;
|
|
260
|
+
if (!Array.isArray(block) || !block.length) {
|
|
261
|
+
throw new Error('normalizeFunnelSteps: anyOrder block must be a non-empty array');
|
|
262
|
+
}
|
|
263
|
+
for (const inner of block) {
|
|
264
|
+
if (inner && typeof inner === 'object' && 'anyOrder' in inner) {
|
|
265
|
+
throw new Error('normalizeFunnelSteps: anyOrder blocks cannot nest');
|
|
266
|
+
}
|
|
267
|
+
flat.push(normalizeStep(inner));
|
|
268
|
+
isAnyOrder.push(true);
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
flat.push(normalizeStep(/** @type {string | { event: string, where?: StepFilter }} */ (s)));
|
|
272
|
+
isAnyOrder.push(false);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const n = flat.length;
|
|
276
|
+
const prevAnchor = new Array(n);
|
|
277
|
+
const nextAnchor = new Array(n);
|
|
278
|
+
// Single loop walking both directions — history.cpp:214-228.
|
|
279
|
+
let prev = -1;
|
|
280
|
+
let next = n;
|
|
281
|
+
for (let i = 0; i < n; i++) {
|
|
282
|
+
if (!isAnyOrder[i]) prev = i;
|
|
283
|
+
prevAnchor[i] = prev;
|
|
284
|
+
const rev = n - 1 - i;
|
|
285
|
+
nextAnchor[rev] = next;
|
|
286
|
+
if (!isAnyOrder[rev]) next = rev;
|
|
287
|
+
}
|
|
288
|
+
return { flat, isAnyOrder, prevAnchor, nextAnchor, hasAnyOrder: isAnyOrder.includes(true) };
|
|
289
|
+
}
|
|
290
|
+
|
|
132
291
|
/**
|
|
133
292
|
* Apply a step filter against a candidate event's flat property map.
|
|
134
293
|
* Supports eq / neq / gt / lt / gte / lte / contains / not_contains.
|
|
135
294
|
*
|
|
295
|
+
* Delegates to the shared WHERE-filter rulebook (fix-round B6 — coerce.js
|
|
296
|
+
* `matchesWhere`): funnel step selectors compile to the same eval_node
|
|
297
|
+
* machinery as every other WHERE filter, so string equality is
|
|
298
|
+
* case-insensitive (value.c:285 value_equal → arb_strcasecmp), `contains`
|
|
299
|
+
* uses arb_strcaseinstr (eval_node.c:2914), string relational ops go
|
|
300
|
+
* through arb_strcasecmp (eval_node.c:2931), relational ops on mixed types
|
|
301
|
+
* fail, and list-valued properties test per-item membership
|
|
302
|
+
* (eval_node.c:2949-2959). The pre-fix implementation used `===` and
|
|
303
|
+
* case-sensitive `.includes`, diverging from every other filter site.
|
|
304
|
+
*
|
|
136
305
|
* @param {Object} ev
|
|
137
306
|
* @param {StepFilter | undefined} where
|
|
138
307
|
* @returns {boolean}
|
|
139
308
|
*/
|
|
140
309
|
export function matchesStepFilter(ev, where) {
|
|
141
310
|
if (!where || !where.prop) return true;
|
|
142
|
-
|
|
143
|
-
const target = where.value;
|
|
144
|
-
switch (where.op) {
|
|
145
|
-
case 'eq': return v === target;
|
|
146
|
-
case 'neq': return v !== target;
|
|
147
|
-
case 'gt': return typeof v === 'number' && v > target;
|
|
148
|
-
case 'lt': return typeof v === 'number' && v < target;
|
|
149
|
-
case 'gte': return typeof v === 'number' && v >= target;
|
|
150
|
-
case 'lte': return typeof v === 'number' && v <= target;
|
|
151
|
-
case 'contains': return typeof v === 'string' && v.includes(String(target));
|
|
152
|
-
case 'not_contains': return !(typeof v === 'string' && v.includes(String(target)));
|
|
153
|
-
default: throw new Error(`matchesStepFilter: unsupported op "${where.op}"`);
|
|
154
|
-
}
|
|
311
|
+
return matchesWhere(ev || {}, { [where.prop]: { op: where.op, value: where.value } });
|
|
155
312
|
}
|
|
156
313
|
|
|
157
314
|
/**
|
|
@@ -193,7 +350,7 @@ function snapshotProperties(ev, mode) {
|
|
|
193
350
|
* @returns {FunnelResult}
|
|
194
351
|
*/
|
|
195
352
|
function emptyResult(trackStepProperties) {
|
|
196
|
-
const r = { completed: false, reached: -1, stepEvents: [], stepTimes: [], ttcMs: null, completions: 0, stepProperties: undefined, sessionId: undefined };
|
|
353
|
+
const r = { completed: false, reached: -1, stepEvents: [], stepTimes: [], ttcMs: null, ttcSeconds: null, gapSeconds: [], gapSecondsFromStart: [], completions: 0, stepProperties: undefined, sessionId: undefined, terminatedByExclusion: false, excludedAtStep: null };
|
|
197
354
|
if (trackStepProperties) r.stepProperties = [];
|
|
198
355
|
return r;
|
|
199
356
|
}
|
|
@@ -206,107 +363,399 @@ function emptyResult(trackStepProperties) {
|
|
|
206
363
|
* @param {number} startIdx - Index to begin scanning from.
|
|
207
364
|
* @param {{ event: string, where?: StepFilter }[]} steps
|
|
208
365
|
* @param {ExclusionStep[]} exclusionSteps
|
|
209
|
-
* @param {Object} options
|
|
210
|
-
*
|
|
366
|
+
* @param {Object} options - Engine internals plus two restart-machinery
|
|
367
|
+
* flags: `expireOnWindow` finalizes the attempt when an event past the
|
|
368
|
+
* conversion window from step 0 arrives (returning `expiredByWindow: true`
|
|
369
|
+
* and `nextIdx` = that event's index, so the caller re-births AT it —
|
|
370
|
+
* funnel_query.cpp:1663-1680); `woRepeat` additionally makes expiry the
|
|
371
|
+
* ONLY termination — decided attempts idle until the window expires
|
|
372
|
+
* (funnel_query.cpp:1608-1613).
|
|
373
|
+
* @returns {{ result: FunnelResult, nextIdx: number, terminatedByExclusion: boolean, expiredByWindow: boolean }}
|
|
211
374
|
*/
|
|
212
375
|
function runOneAttempt(sorted, startIdx, steps, exclusionSteps, options) {
|
|
213
|
-
const {
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
376
|
+
const { windowCheck, graceperiod, trackStepProperties, anchorOk, isAnyOrder, prevAnchor, nextAnchor, expireOnWindow = false, woRepeat = false } = options;
|
|
377
|
+
const numSteps = steps.length;
|
|
378
|
+
// Per-SLOT recorded candidates (history->steps): the latest match for
|
|
379
|
+
// anchors, the first eligible match for active any-order chunk members.
|
|
380
|
+
const slotTimes = new Array(numSteps).fill(0);
|
|
381
|
+
const slotEvents = new Array(numSteps).fill(null);
|
|
382
|
+
const slotProps = trackStepProperties ? new Array(numSteps).fill(null) : null;
|
|
383
|
+
// Per-POSITION path (history->step_loc_idx): which slot filled position p.
|
|
384
|
+
// Anchor slots always land at position === slot; an any-order chunk fills
|
|
385
|
+
// its slot range in event-arrival order (history.cpp:929-945). For
|
|
386
|
+
// anchor-only funnels positions and slots coincide throughout.
|
|
387
|
+
const stepLocIdx = new Array(numSteps).fill(-1);
|
|
388
|
+
// history_get_time_at_step (history.cpp:839-841) — 0 when unfilled.
|
|
389
|
+
const timeAtPos = (p) => stepLocIdx[p] === -1 ? 0 : slotTimes[stepLocIdx[p]];
|
|
390
|
+
const eventAtPos = (p) => stepLocIdx[p] === -1 ? null : slotEvents[stepLocIdx[p]];
|
|
391
|
+
|
|
392
|
+
// Exclusion state — one slot per GAP g between step g and step g+1
|
|
393
|
+
// (`history->exclusion_steps[]`). ARB: "Exclusion steps can only exist
|
|
394
|
+
// between first and last step (and not before or after the funnel)"
|
|
395
|
+
// (funnel_query_is_step_terminated, history.cpp) — so an exclusion can
|
|
396
|
+
// never terminate an attempt that hasn't reached step 0, and there are
|
|
397
|
+
// no gaps at all for a single-step funnel.
|
|
398
|
+
const hasExclusions = !!(exclusionSteps && exclusionSteps.length && numSteps > 1);
|
|
399
|
+
const excTimes = hasExclusions ? new Array(numSteps - 1).fill(0) : null;
|
|
400
|
+
const excEvents = hasExclusions ? new Array(numSteps - 1).fill(null) : null;
|
|
401
|
+
|
|
217
402
|
let reached = -1;
|
|
218
|
-
let i = startIdx;
|
|
219
403
|
let terminatedByExclusion = false;
|
|
404
|
+
let expiredByWindow = false;
|
|
405
|
+
let tailAnchorMs = 0; // terminating exclusion time, or last-step time on completion
|
|
406
|
+
let endIdx = -1; // consumption boundary — frozen the moment the attempt is decided
|
|
407
|
+
let i = startIdx;
|
|
220
408
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
409
|
+
// Does exclusion `ex` apply to gap g? afterStep/beforeStep bound the
|
|
410
|
+
// applicable gap range [afterStep, beforeStep); default = every gap.
|
|
411
|
+
const gapApplies = (ex, g) => {
|
|
412
|
+
const after = typeof ex.afterStep === 'number' ? ex.afterStep : 0;
|
|
413
|
+
const before = typeof ex.beforeStep === 'number' ? ex.beforeStep : numSteps;
|
|
414
|
+
return g >= after && g < before;
|
|
415
|
+
};
|
|
416
|
+
const excludesAtGap = (ev, g) => {
|
|
417
|
+
for (const ex of exclusionSteps) {
|
|
418
|
+
if (ev.event === ex.event && gapApplies(ex, g)) return true;
|
|
419
|
+
}
|
|
420
|
+
return false;
|
|
421
|
+
};
|
|
422
|
+
// funnel_query_is_step_terminated (history.cpp): the exclusion recorded in
|
|
423
|
+
// gap g kills the attempt iff it is (a) within the conversion window from
|
|
424
|
+
// step 0, (b) at/after step g per the 2-second rule — the grace that
|
|
425
|
+
// forgives step ordering CONDEMNS exclusion ties ("we assume they were
|
|
426
|
+
// ordered in such a way as to prevent the conversion") — and (c) still in
|
|
427
|
+
// the open gap: the user sits exactly at step g, or reached is past it and
|
|
428
|
+
// step g+1 does not come more than 2s before the exclusion. Gaps sit
|
|
429
|
+
// between POSITIONS (history_get_time_at_step reads through step_loc_idx),
|
|
430
|
+
// so with any-order blocks the gap bounds are the claimed positions'
|
|
431
|
+
// times, not the slot-declaration order.
|
|
432
|
+
const gapTerminates = (g) => {
|
|
433
|
+
if (reached < 0 || g < 0 || g >= numSteps - 1) return false;
|
|
434
|
+
const et = excTimes[g];
|
|
435
|
+
if (!et) return false;
|
|
436
|
+
if (!windowCheck(et, timeAtPos(0), excEvents[g], eventAtPos(0))) return false;
|
|
437
|
+
if (!timestampComesAfter(et, timeAtPos(g), graceperiod)) return false;
|
|
438
|
+
return reached === g
|
|
439
|
+
|| (reached > g && timestampComesAfter(timeAtPos(g + 1), et, graceperiod));
|
|
440
|
+
};
|
|
441
|
+
// history_record_exclusion_step (history.cpp): record the event into gap
|
|
442
|
+
// g, unless reached is past the gap and the event falls more than 2s
|
|
443
|
+
// after position g+1 — the gap closed cleanly, and the event "may be
|
|
444
|
+
// useful for a future exclusion step". Returns true when the event was
|
|
445
|
+
// consumed. Callers interleave this per gap index with positive-step
|
|
446
|
+
// records (funnel_query.cpp:1307-1385 — positive step g before exclusion
|
|
447
|
+
// gap g; first use consumes the event).
|
|
448
|
+
const tryExclusionAtGap = (g, ev, t) => {
|
|
449
|
+
if (!excludesAtGap(ev, g)) return false;
|
|
450
|
+
if (reached > g && !timestampComesAfter(timeAtPos(g + 1), t, graceperiod)) return false;
|
|
451
|
+
excTimes[g] = t;
|
|
452
|
+
excEvents[g] = ev;
|
|
453
|
+
if (gapTerminates(g)) {
|
|
454
|
+
terminatedByExclusion = true;
|
|
455
|
+
reached = g; // DISQUALIFY-and-freeze: clamp back to the killed gap
|
|
456
|
+
tailAnchorMs = t;
|
|
457
|
+
}
|
|
458
|
+
return true;
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
// can_record_any_order_step (history.cpp:926-963). Assumes slot s is an
|
|
462
|
+
// any-order step.
|
|
463
|
+
const prevAnchorTimeMs = () => {
|
|
464
|
+
// previous_anchor_time_ms (history.cpp:967-970): time at the last
|
|
465
|
+
// anchor POSITION at/below reached; 0 when the active chunk has no
|
|
466
|
+
// anchor below it. Anchors always claim position === slot, and a
|
|
467
|
+
// chunk's position range equals its slot range, so indexing the
|
|
468
|
+
// slot-built topology by the position `reached` is exact.
|
|
469
|
+
const pa = prevAnchor[reached];
|
|
470
|
+
return pa >= 0 ? timeAtPos(pa) : 0;
|
|
471
|
+
};
|
|
472
|
+
const canRecordAnyOrderSlot = (s, t) => {
|
|
473
|
+
if (reached >= 0 && s < prevAnchor[reached]) {
|
|
474
|
+
// Chunk already sealed behind a crossed anchor.
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
if (reached >= 0 && s < nextAnchor[reached]) {
|
|
478
|
+
// Active chunk: FIRST eligible match wins — no latest overwrite
|
|
479
|
+
// (history.cpp:951-957).
|
|
480
|
+
const eligible = slotTimes[s] !== 0
|
|
481
|
+
&& timestampComesAfter(slotTimes[s], prevAnchorTimeMs(), graceperiod);
|
|
482
|
+
return !eligible && timestampComesAfter(t, prevAnchorTimeMs(), graceperiod);
|
|
483
|
+
}
|
|
484
|
+
// Chunk past a not-yet-seen anchor: always buffer the latest
|
|
485
|
+
// (history.cpp:958-962).
|
|
486
|
+
return true;
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
// history_record_step (history.cpp:413-628): record the slot, maybe
|
|
490
|
+
// advance the position pointer, then cascade. Returns true (consumed).
|
|
491
|
+
const recordSlot = (s, ev, t, i) => {
|
|
492
|
+
slotTimes[s] = t;
|
|
493
|
+
slotEvents[s] = ev;
|
|
494
|
+
if (slotProps) slotProps[s] = snapshotProperties(ev, trackStepProperties);
|
|
495
|
+
|
|
496
|
+
let canAdvanceStep = false;
|
|
497
|
+
if (isAnyOrder[s]) {
|
|
498
|
+
// Direct advance (history.cpp:490-519): an any-order record claims
|
|
499
|
+
// the next position when it lands in the OPEN chunk — the first
|
|
500
|
+
// chunk before anything anchored, or the active chunk within the
|
|
501
|
+
// conversion window from position 0.
|
|
502
|
+
if ((reached === -1 && isAnyOrder[0] && s < nextAnchor[0])
|
|
503
|
+
|| (reached >= 0 && s < nextAnchor[reached]
|
|
504
|
+
&& windowCheck(slotTimes[s], timeAtPos(0), slotEvents[s], eventAtPos(0)))
|
|
505
|
+
) {
|
|
506
|
+
reached++;
|
|
507
|
+
stepLocIdx[reached] = s;
|
|
508
|
+
// Cascade only once the chunk is closed: this record filled the
|
|
509
|
+
// funnel's last position, or the next position is an anchor
|
|
510
|
+
// (history.cpp:509-517 — earlier chunk members already advanced
|
|
511
|
+
// the funnel when they recorded).
|
|
512
|
+
canAdvanceStep = reached + 1 === numSteps || !isAnyOrder[reached + 1];
|
|
513
|
+
}
|
|
514
|
+
} else if (s > 0 && reached !== s - 1) {
|
|
515
|
+
// Future anchor: latest recorded, no advance (history.cpp:519-523).
|
|
516
|
+
} else {
|
|
517
|
+
canAdvanceStep = true;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ARB captures original_reached AFTER the direct advance (:525) — the
|
|
521
|
+
// exclusion second pass starts at the just-claimed position's gap.
|
|
522
|
+
const originalReached = reached;
|
|
225
523
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
524
|
+
if (canAdvanceStep) {
|
|
525
|
+
// Cascade (history.cpp:534-589): claim anchors one at a time; claim
|
|
526
|
+
// an any-order chunk's present members in slot order against the
|
|
527
|
+
// PRE-CHUNK time (members need not order among themselves), and
|
|
528
|
+
// cross to the next anchor only when the chunk fully filled. A
|
|
529
|
+
// partially-filled chunk claims its present members and stops.
|
|
530
|
+
let reachedTimeMs = reached >= 0 ? timeAtPos(reached) : 0;
|
|
531
|
+
let ns = originalReached + 1;
|
|
532
|
+
while (ns < numSteps) {
|
|
533
|
+
if (!isAnyOrder[ns]) {
|
|
534
|
+
if (timestampComesAfter(slotTimes[ns], reachedTimeMs, graceperiod)
|
|
535
|
+
&& (ns === 0 || windowCheck(slotTimes[ns], timeAtPos(0), slotEvents[ns], eventAtPos(0)))
|
|
536
|
+
) {
|
|
537
|
+
reached++;
|
|
538
|
+
stepLocIdx[reached] = ns; // anchors: position === slot
|
|
539
|
+
reachedTimeMs = slotTimes[ns];
|
|
540
|
+
ns++;
|
|
541
|
+
} else {
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
} else {
|
|
545
|
+
const na = nextAnchor[ns];
|
|
546
|
+
let missing = false;
|
|
547
|
+
let latestTimeMs = reachedTimeMs;
|
|
548
|
+
for (; ns < na; ns++) {
|
|
549
|
+
if (timestampComesAfter(slotTimes[ns], reachedTimeMs, graceperiod)
|
|
550
|
+
&& windowCheck(slotTimes[ns], timeAtPos(0), slotEvents[ns], eventAtPos(0))
|
|
551
|
+
) {
|
|
552
|
+
reached++;
|
|
553
|
+
stepLocIdx[reached] = ns;
|
|
554
|
+
if (timestampComesAfter(slotTimes[ns], latestTimeMs, graceperiod)) {
|
|
555
|
+
latestTimeMs = slotTimes[ns];
|
|
556
|
+
}
|
|
557
|
+
} else {
|
|
558
|
+
missing = true;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (!missing) {
|
|
562
|
+
reachedTimeMs = latestTimeMs;
|
|
563
|
+
} else {
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
243
566
|
}
|
|
244
567
|
}
|
|
245
|
-
if (excluded) { terminatedByExclusion = true; i++; break; }
|
|
246
568
|
}
|
|
247
569
|
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
570
|
+
// Buffered-exclusion second pass over [originalReached, reached]
|
|
571
|
+
// (history.cpp:608-618 "Second pass: Check if early termination funnel
|
|
572
|
+
// based on -ve steps are possible"): an exclusion recorded BEFORE its
|
|
573
|
+
// gap opened can retroactively kill steps the cascade just claimed.
|
|
574
|
+
// `reached` is clamped back to the killed gap.
|
|
575
|
+
if (hasExclusions) {
|
|
576
|
+
for (let r = originalReached; r <= reached; r++) {
|
|
577
|
+
if (gapTerminates(r)) {
|
|
578
|
+
terminatedByExclusion = true;
|
|
579
|
+
reached = r;
|
|
580
|
+
tailAnchorMs = excTimes[r];
|
|
581
|
+
endIdx = i + 1;
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
253
585
|
}
|
|
254
|
-
if (matchedStep < 0) continue;
|
|
255
586
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
587
|
+
if (reached === numSteps - 1 && !terminatedByExclusion) {
|
|
588
|
+
tailAnchorMs = timeAtPos(numSteps - 1);
|
|
589
|
+
endIdx = i + 1;
|
|
590
|
+
// No break — the 2s tail may still kill the completion.
|
|
591
|
+
}
|
|
592
|
+
return true;
|
|
593
|
+
};
|
|
260
594
|
|
|
261
|
-
|
|
595
|
+
for (; i < sorted.length; i++) {
|
|
596
|
+
const ev = sorted[i];
|
|
597
|
+
const t = toMs(ev.time);
|
|
598
|
+
if (!Number.isFinite(t)) continue;
|
|
599
|
+
|
|
600
|
+
// Window expiry (fix-round B2+C5): ARB checks a born history against
|
|
601
|
+
// the conversion window BEFORE processing each event
|
|
602
|
+
// (history_is_past_conversion_window — window measured from step 0,
|
|
603
|
+
// history.cpp:785-793); an expired history finalizes via
|
|
604
|
+
// funnel_query_handle_funnel_expiry and a fresh unbirthed history
|
|
605
|
+
// processes THIS event (funnel_query.cpp:1608-1617, :1663-1680), so
|
|
606
|
+
// nextIdx = i, not i + 1. For GENERAL_WO_REPEAT (woRepeat) expiry is
|
|
607
|
+
// the ONLY termination — it fires even on completed/excluded attempts
|
|
608
|
+
// (:1611-1613). For GENERAL (the reentry loop) completion/exclusion
|
|
609
|
+
// keep their own restart below, so expiry only finalizes LIVE
|
|
610
|
+
// attempts (C5: failed-attempt windows).
|
|
611
|
+
if (expireOnWindow && reached >= 0
|
|
612
|
+
&& (woRepeat || (!terminatedByExclusion && reached !== numSteps - 1))
|
|
613
|
+
&& !windowCheck(t, timeAtPos(0), ev, eventAtPos(0))
|
|
614
|
+
) {
|
|
615
|
+
expiredByWindow = true;
|
|
616
|
+
endIdx = i;
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
262
619
|
|
|
263
|
-
if (
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
620
|
+
if (terminatedByExclusion || reached === numSteps - 1) {
|
|
621
|
+
if (woRepeat) {
|
|
622
|
+
// GENERAL_WO_REPEAT: the decided attempt is NOT finalized — it
|
|
623
|
+
// idles, absorbing events, until the window expires (checked
|
|
624
|
+
// above). The 2-second out-of-order exclusion tail still
|
|
625
|
+
// applies to completions (the processing gate is count-type-
|
|
626
|
+
// independent — funnel_query.cpp: "If terminated (for any
|
|
627
|
+
// reason) or reached the last step AND current event_time is
|
|
628
|
+
// further than 2 seconds ... do not do this processing").
|
|
629
|
+
if (!terminatedByExclusion && hasExclusions && graceperiod && t <= tailAnchorMs + OUT_OF_ORDER_MS) {
|
|
630
|
+
for (let g = 0; g < numSteps - 1; g++) {
|
|
631
|
+
if (tryExclusionAtGap(g, ev, t)) break;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
// Attempt decided. A history terminated BY EXCLUSION is immediately
|
|
637
|
+
// immutable — history_is_mutable short-circuits on
|
|
638
|
+
// terminated_by_exclusion_step (history.cpp:771-773) BEFORE the
|
|
639
|
+
// 2s out-of-order wait, which covers only COMPLETED histories
|
|
640
|
+
// (reached == num_steps-1 && timestamp_comes_after && should_wait
|
|
641
|
+
// _for_out_of_order_exclusion). So the tail scan below runs only
|
|
642
|
+
// for completions; there is no earlier-termination revision after
|
|
643
|
+
// an exclusion has landed (fix-round nit 2 — the old scan-after-
|
|
644
|
+
// termination path was also unreachable: later tail events sit
|
|
645
|
+
// monotonically farther from every step time, and the clamped
|
|
646
|
+
// `reached` can never equal an earlier gap's prev-step).
|
|
647
|
+
if (endIdx < 0) endIdx = i;
|
|
648
|
+
if (terminatedByExclusion) break;
|
|
649
|
+
// Completed: keep scanning ONLY the 2-second out-of-order tail,
|
|
650
|
+
// and only for exclusions — funnel_query.cpp: "If terminated (for
|
|
651
|
+
// any reason) or reached the last step AND current event_time is
|
|
652
|
+
// further than 2 seconds from the termination step time or last
|
|
653
|
+
// step time, do not do this processing." (Positive slots refuse
|
|
654
|
+
// the event on their own: anchors need reached < slot; claimed
|
|
655
|
+
// any-order chunk members are first-match-sealed — so
|
|
656
|
+
// exclusions-only here matches ARB.)
|
|
657
|
+
if (!hasExclusions || !graceperiod || t > tailAnchorMs + OUT_OF_ORDER_MS) break;
|
|
658
|
+
for (let g = 0; g < numSteps - 1; g++) {
|
|
659
|
+
if (tryExclusionAtGap(g, ev, t)) break;
|
|
660
|
+
}
|
|
267
661
|
continue;
|
|
268
662
|
}
|
|
269
663
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
664
|
+
// v1.6.0 (P1.6.5): trend-interval anchor — an event outside
|
|
665
|
+
// [fromMs, toMs) can never record step 0. ARB drops ALL positive
|
|
666
|
+
// records for out-of-range events while the funnel is unstarted
|
|
667
|
+
// (history.cpp:436-440 `is_outside_first_event_range && reached < 0`);
|
|
668
|
+
// exclusion gaps still see the event (funnel_query.cpp:1360-1362 runs
|
|
669
|
+
// whenever the positive step didn't consume it). Once reached >= 0,
|
|
670
|
+
// no record can move position 0 (anchors need reached < slot; the
|
|
671
|
+
// position-0 chunk member is first-match-sealed), so no gate is
|
|
672
|
+
// needed there.
|
|
673
|
+
const positiveBlocked = reached === -1 && anchorOk && !anchorOk(t);
|
|
674
|
+
|
|
675
|
+
// Interleaved per-index scan (funnel_query.cpp:1307-1385): positive
|
|
676
|
+
// slot g is checked before exclusion gap g, which is checked before
|
|
677
|
+
// slot g+1; the first use consumes the event. Anchors record the
|
|
678
|
+
// latest match while unclaimed (reached < slot — positions and slots
|
|
679
|
+
// coincide for anchors); any-order slots go through
|
|
680
|
+
// can_record_any_order_step. A slot BELOW the current position can
|
|
681
|
+
// still record when it's an unfilled active-chunk member, so the scan
|
|
682
|
+
// starts at 0 (for anchor-only funnels this degenerates to the old
|
|
683
|
+
// reached+1 start).
|
|
684
|
+
for (let s = 0; s < numSteps; s++) {
|
|
685
|
+
if (!positiveBlocked && eventMatchesStep(ev, steps[s])) {
|
|
686
|
+
const recordable = isAnyOrder[s]
|
|
687
|
+
? canRecordAnyOrderSlot(s, t)
|
|
688
|
+
: reached < s;
|
|
689
|
+
if (recordable && recordSlot(s, ev, t, i)) break;
|
|
690
|
+
}
|
|
691
|
+
if (hasExclusions && s < numSteps - 1 && tryExclusionAtGap(s, ev, t)) {
|
|
692
|
+
if (terminatedByExclusion) endIdx = i + 1; // no restart FROM the exclusion event
|
|
286
693
|
break;
|
|
287
694
|
}
|
|
288
695
|
}
|
|
696
|
+
}
|
|
289
697
|
|
|
290
|
-
|
|
698
|
+
// Result surfaces the PATH (positions), not the slot table: position p was
|
|
699
|
+
// filled by slot stepLocIdx[p] (history_get_time_at_step reads through the
|
|
700
|
+
// same indirection). Anchor-only funnels: identity mapping.
|
|
701
|
+
const reachedStepEvents = [];
|
|
702
|
+
const reachedStepTimes = [];
|
|
703
|
+
for (let p = 0; p <= reached; p++) {
|
|
704
|
+
reachedStepEvents.push(eventAtPos(p));
|
|
705
|
+
reachedStepTimes.push(timeAtPos(p));
|
|
291
706
|
}
|
|
707
|
+
const completed = reached === numSteps - 1;
|
|
292
708
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
709
|
+
// Per-gap TTC deltas (funnel_query.cpp:3355-3380): for every attempt —
|
|
710
|
+
// converted or not — positions 1..reached each record
|
|
711
|
+
// `delta = t_p <= t_{p-1} ? 0 : (t_p - t_{p-1}) / 1000` (int arithmetic →
|
|
712
|
+
// floor seconds, clamped to 0 per gap, :3374) and the running sum
|
|
713
|
+
// `time_from_start += delta` (:3375). The cumulative track sums CLAMPED
|
|
714
|
+
// deltas, so it can exceed t_p - t_0 when any-order grace claims produce
|
|
715
|
+
// non-monotonic position times.
|
|
716
|
+
const gapSeconds = [];
|
|
717
|
+
const gapSecondsFromStart = [];
|
|
718
|
+
let ttcFromStart = 0;
|
|
719
|
+
for (let p = 1; p <= reached; p++) {
|
|
720
|
+
const prev = reachedStepTimes[p - 1], cur = reachedStepTimes[p];
|
|
721
|
+
const delta = cur <= prev ? 0 : Math.floor((cur - prev) / 1000);
|
|
722
|
+
ttcFromStart += delta;
|
|
723
|
+
gapSeconds.push(delta);
|
|
724
|
+
gapSecondsFromStart.push(ttcFromStart);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// `$ttc` (history.cpp:914-922): defined ONLY on full conversion. End time
|
|
728
|
+
// is history_get_last_time (:843-847) — MAX over the slot array, not the
|
|
729
|
+
// last position: with grace-claimed chunks the last position can hold an
|
|
730
|
+
// EARLIER time than a mid-chunk slot. On completion every slot is filled,
|
|
731
|
+
// so the max ranges over real times and ttcMs is always >= 0 (the :918
|
|
732
|
+
// clamp is defensive). ttcMs surfaces the same quantity at ms resolution.
|
|
733
|
+
let ttcMs = null, ttcSeconds = null;
|
|
734
|
+
if (completed && numSteps > 0) {
|
|
735
|
+
let lastTimeMs = 0;
|
|
736
|
+
for (let s = 0; s < numSteps; s++) if (slotTimes[s] > lastTimeMs) lastTimeMs = slotTimes[s];
|
|
737
|
+
const t0 = timeAtPos(0);
|
|
738
|
+
ttcMs = t0 > lastTimeMs ? 0 : lastTimeMs - t0;
|
|
739
|
+
ttcSeconds = Math.floor(ttcMs / 1000);
|
|
740
|
+
}
|
|
299
741
|
const result = {
|
|
300
742
|
completed,
|
|
301
743
|
reached,
|
|
302
744
|
stepEvents: reachedStepEvents,
|
|
303
745
|
stepTimes: reachedStepTimes,
|
|
304
746
|
ttcMs,
|
|
747
|
+
ttcSeconds,
|
|
748
|
+
gapSeconds,
|
|
749
|
+
gapSecondsFromStart,
|
|
305
750
|
completions: completed ? 1 : 0,
|
|
306
|
-
stepProperties:
|
|
751
|
+
stepProperties: slotProps
|
|
752
|
+
? reachedStepTimes.map((_, p) => (stepLocIdx[p] === -1 ? {} : slotProps[stepLocIdx[p]]) || {})
|
|
753
|
+
: undefined,
|
|
307
754
|
sessionId: undefined,
|
|
755
|
+
terminatedByExclusion,
|
|
756
|
+
excludedAtStep: terminatedByExclusion ? reached + 1 : null,
|
|
308
757
|
};
|
|
309
|
-
return { result, nextIdx: i, terminatedByExclusion };
|
|
758
|
+
return { result, nextIdx: endIdx >= 0 ? endIdx : i, terminatedByExclusion, expiredByWindow };
|
|
310
759
|
}
|
|
311
760
|
|
|
312
761
|
/**
|
|
@@ -323,26 +772,116 @@ function runOneAttempt(sorted, startIdx, steps, exclusionSteps, options) {
|
|
|
323
772
|
* @returns {FunnelResult | FunnelResult[]}
|
|
324
773
|
*/
|
|
325
774
|
export function evaluateFunnel(events, steps, options = {}) {
|
|
775
|
+
if (options.countMode === 'sessions') {
|
|
776
|
+
// Mixpanel's funnel "count by Sessions" is an API rewrite, not an
|
|
777
|
+
// engine mode: count_type session REQUIRES window (session, 1) and
|
|
778
|
+
// rewrites to general_wo_repeat (__validate_sessions,
|
|
779
|
+
// api/version_2_0/arb_funnels/validate.py "Cannot use count_type
|
|
780
|
+
// session without length = 1 session"; get_default_conversion_window,
|
|
781
|
+
// arb_funnels/util.py; no COUNT_TYPE_SESSIONS branch exists in
|
|
782
|
+
// funnel_query.cpp — grep-confirmed). general_wo_repeat = totals
|
|
783
|
+
// counting where window expiry is the only restart (fix-round B2 —
|
|
784
|
+
// the old "no restart after a completed pass" reading missed the
|
|
785
|
+
// re-birth at expiry, capping every user at one conversion).
|
|
786
|
+
if (options.reentry) {
|
|
787
|
+
throw new Error("evaluateFunnel: countMode 'sessions' disables re-entry (general_wo_repeat rewrite)");
|
|
788
|
+
}
|
|
789
|
+
const cw = options.conversionWindow;
|
|
790
|
+
if (typeof options.conversionWindowMs === 'number' || (cw != null && !(cw.unit === 'sessions' && cw.n === 1))) {
|
|
791
|
+
throw new Error("evaluateFunnel: cannot use countMode 'sessions' without conversion window = 1 session");
|
|
792
|
+
}
|
|
793
|
+
return evaluateFunnel(events, steps, {
|
|
794
|
+
...options,
|
|
795
|
+
countMode: 'totals',
|
|
796
|
+
reentry: false,
|
|
797
|
+
woRepeat: true,
|
|
798
|
+
conversionWindowMs: undefined,
|
|
799
|
+
conversionWindow: { unit: 'sessions', n: 1 },
|
|
800
|
+
});
|
|
801
|
+
}
|
|
326
802
|
if (!Array.isArray(steps) || steps.length === 0) {
|
|
327
803
|
const empty = emptyResult(options.trackStepProperties);
|
|
328
804
|
return options.countMode === 'totals' ? [] : empty;
|
|
329
805
|
}
|
|
330
|
-
|
|
806
|
+
// v1.6.0 (P1.6.6): flatten { anyOrder: [...] } blocks and build the anchor
|
|
807
|
+
// topology (history.cpp:214-228). Anchor-only funnels get an all-false
|
|
808
|
+
// isAnyOrder mask and behave exactly as before.
|
|
809
|
+
const { flat: normSteps, isAnyOrder, prevAnchor, nextAnchor } = normalizeFunnelSteps(steps);
|
|
331
810
|
const {
|
|
332
811
|
conversionWindowMs,
|
|
812
|
+
conversionWindow,
|
|
333
813
|
graceperiod = true,
|
|
334
814
|
reentry = false,
|
|
815
|
+
woRepeat = false,
|
|
335
816
|
exclusionSteps,
|
|
336
817
|
trackStepProperties = false,
|
|
337
818
|
countMode = 'uniques',
|
|
338
819
|
sessionScoped = false,
|
|
820
|
+
anchorRange,
|
|
339
821
|
} = options;
|
|
340
822
|
|
|
823
|
+
if (woRepeat) {
|
|
824
|
+
// GENERAL_WO_REPEAT is a totals count type; reentry is GENERAL's own
|
|
825
|
+
// restart machinery and sessionScoped predates both.
|
|
826
|
+
if (countMode !== 'totals') {
|
|
827
|
+
throw new Error("evaluateFunnel: woRepeat requires countMode 'totals' (general_wo_repeat is a totals count type)");
|
|
828
|
+
}
|
|
829
|
+
if (reentry) {
|
|
830
|
+
throw new Error('evaluateFunnel: woRepeat and reentry are mutually exclusive — woRepeat restarts ONLY at window expiry');
|
|
831
|
+
}
|
|
832
|
+
if (sessionScoped) {
|
|
833
|
+
throw new Error('evaluateFunnel: woRepeat cannot combine with sessionScoped');
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
341
837
|
const sorted = (events || [])
|
|
342
838
|
.filter(e => e && typeof e.event === 'string')
|
|
343
839
|
.slice()
|
|
344
840
|
.sort((a, b) => toMs(a.time) - toMs(b.time));
|
|
345
841
|
|
|
842
|
+
// Window predicate: (t, step0Time, event, step0Event) → boolean.
|
|
843
|
+
let windowCheck;
|
|
844
|
+
if (conversionWindow != null) {
|
|
845
|
+
if (typeof conversionWindowMs === 'number') {
|
|
846
|
+
throw new Error('evaluateFunnel: conversionWindow and conversionWindowMs are mutually exclusive');
|
|
847
|
+
}
|
|
848
|
+
if (typeof conversionWindow !== 'object' || conversionWindow.unit !== 'sessions') {
|
|
849
|
+
throw new Error(`evaluateFunnel: unsupported conversionWindow unit "${conversionWindow && conversionWindow.unit}" — only { unit: 'sessions', n } (use conversionWindowMs for time windows)`);
|
|
850
|
+
}
|
|
851
|
+
const n = conversionWindow.n;
|
|
852
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
853
|
+
throw new Error('evaluateFunnel: conversionWindow.n must be a positive integer');
|
|
854
|
+
}
|
|
855
|
+
if (n > 12) {
|
|
856
|
+
// _MAX_LENGTHS["session"] = 12, api/version_2_0/arb_funnels/validate.py
|
|
857
|
+
throw new Error(`evaluateFunnel: conversionWindow.n = ${n} exceeds Mixpanel's 12-session API cap`);
|
|
858
|
+
}
|
|
859
|
+
// Session ordinals over this user's stream (count of session ENDS
|
|
860
|
+
// before each event — funnel_query.cpp's per-user session_id counter).
|
|
861
|
+
// Step check per conversion_window.cpp:50 WINDOW_TYPE_SESSIONS is
|
|
862
|
+
// ordinal-ONLY: `session_id1 < session_id2 + length_sessions`. No
|
|
863
|
+
// wall-clock term — is_within_conversion_window's t1_ms/t2_ms args are
|
|
864
|
+
// unused in the SESSIONS branch, and every per-step call site
|
|
865
|
+
// (history.cpp:502,543,568,788,1004) goes through that switch.
|
|
866
|
+
// The n×1-day wall-clock bound (conversion_window_max_length_seconds =
|
|
867
|
+
// n × seconds_per_unit[QUERY_UNIT_SESSION] = n × SECONDS_PER_DAY,
|
|
868
|
+
// libquery/time/unit.c:14) exists in ARB but binds elsewhere: history
|
|
869
|
+
// termination against the INTERVAL END (funnel_query.cpp:1620,
|
|
870
|
+
// is_within_max_conversion_window_seconds vs multi_intervals_end) and
|
|
871
|
+
// the data-pull range (:402, :1408-1412) — the timeBucket wrapper's
|
|
872
|
+
// [start, stop + n×day) spill slice mirrors exactly that. Spec P1.6.1's
|
|
873
|
+
// dual per-step condition was a misreading; dropped per fix-round C6.
|
|
874
|
+
const ordinals = sessionOrdinals(sorted);
|
|
875
|
+
windowCheck = (t, t0, ev, step0Ev) => {
|
|
876
|
+
const o = ordinals.get(ev);
|
|
877
|
+
const o0 = ordinals.get(step0Ev);
|
|
878
|
+
if (o === undefined || o0 === undefined) return false;
|
|
879
|
+
return o < o0 + n;
|
|
880
|
+
};
|
|
881
|
+
} else {
|
|
882
|
+
windowCheck = (t, t0) => withinConversionWindow(t, t0, conversionWindowMs);
|
|
883
|
+
}
|
|
884
|
+
|
|
346
885
|
if (sessionScoped) {
|
|
347
886
|
const bySession = new Map();
|
|
348
887
|
for (const ev of sorted) {
|
|
@@ -366,7 +905,37 @@ export function evaluateFunnel(events, steps, options = {}) {
|
|
|
366
905
|
return allResults[0];
|
|
367
906
|
}
|
|
368
907
|
|
|
369
|
-
|
|
908
|
+
// v1.6.0 (P1.6.5): trend-interval anchor bound — step 0 only in
|
|
909
|
+
// [fromMs, toMs) (funnel_query.cpp:1398-1401). Later steps / exclusions
|
|
910
|
+
// are unaffected: the window check from step 0 already bounds them.
|
|
911
|
+
const anchorOk = anchorRange
|
|
912
|
+
? (t) => (anchorRange.fromMs == null || t >= anchorRange.fromMs)
|
|
913
|
+
&& (anchorRange.toMs == null || t < anchorRange.toMs)
|
|
914
|
+
: null;
|
|
915
|
+
|
|
916
|
+
const opts = { windowCheck, graceperiod, trackStepProperties, anchorOk, isAnyOrder, prevAnchor, nextAnchor };
|
|
917
|
+
|
|
918
|
+
if (woRepeat) {
|
|
919
|
+
// GENERAL_WO_REPEAT (fix-round B2): one attempt per window span.
|
|
920
|
+
// Each attempt runs until an event past the conversion window from
|
|
921
|
+
// its step 0 arrives; that finalizes it (complete or drop-off — ARB
|
|
922
|
+
// aggregates both, funnel_query_handle_funnel_expiry) and the NEXT
|
|
923
|
+
// attempt starts AT the expiring event (funnel_query.cpp:1663-1680).
|
|
924
|
+
// Stream exhaustion finalizes the outstanding attempt with no restart
|
|
925
|
+
// (funnel_query_record_outstanding_funnels).
|
|
926
|
+
const attempts = [];
|
|
927
|
+
let idx = 0;
|
|
928
|
+
while (idx < sorted.length) {
|
|
929
|
+
const { result, nextIdx, expiredByWindow } = runOneAttempt(
|
|
930
|
+
sorted, idx, normSteps, exclusionSteps, { ...opts, expireOnWindow: true, woRepeat: true });
|
|
931
|
+
if (result.reached >= 0) attempts.push(result);
|
|
932
|
+
if (!expiredByWindow) break;
|
|
933
|
+
// Progress guaranteed: the expiring event sits strictly after the
|
|
934
|
+
// attempt's birth event, so nextIdx >= idx + 1.
|
|
935
|
+
idx = nextIdx;
|
|
936
|
+
}
|
|
937
|
+
return attempts;
|
|
938
|
+
}
|
|
370
939
|
|
|
371
940
|
if (!reentry) {
|
|
372
941
|
const { result } = runOneAttempt(sorted, 0, normSteps, exclusionSteps, opts);
|
|
@@ -376,13 +945,16 @@ export function evaluateFunnel(events, steps, options = {}) {
|
|
|
376
945
|
return countMode === 'totals' ? [result] : result;
|
|
377
946
|
}
|
|
378
947
|
|
|
379
|
-
// Reentry: keep running attempts after each completion or
|
|
948
|
+
// Reentry (GENERAL): keep running attempts after each completion or
|
|
949
|
+
// exclusion — and after window expiry of a live attempt (fix-round C5),
|
|
950
|
+
// which finalizes it as a drop-off and restarts AT the expiring event.
|
|
380
951
|
const allAttempts = [];
|
|
381
952
|
const completedAttempts = [];
|
|
382
953
|
let idx = 0;
|
|
383
954
|
let lastResult = emptyResult(trackStepProperties);
|
|
955
|
+
const reentryOpts = { ...opts, expireOnWindow: true };
|
|
384
956
|
while (idx < sorted.length) {
|
|
385
|
-
const { result, nextIdx, terminatedByExclusion } = runOneAttempt(sorted, idx, normSteps, exclusionSteps,
|
|
957
|
+
const { result, nextIdx, terminatedByExclusion } = runOneAttempt(sorted, idx, normSteps, exclusionSteps, reentryOpts);
|
|
386
958
|
// Always advance — runOneAttempt returns nextIdx > idx when it processed an event.
|
|
387
959
|
const advanced = nextIdx > idx ? nextIdx : idx + 1;
|
|
388
960
|
idx = advanced;
|
|
@@ -437,8 +1009,14 @@ export function evaluateFunnel(events, steps, options = {}) {
|
|
|
437
1009
|
export function evaluateFunnelHPC(events, steps, holdProperty, options = {}) {
|
|
438
1010
|
if (!holdProperty) throw new Error('evaluateFunnelHPC: holdProperty is required');
|
|
439
1011
|
if (!Array.isArray(steps) || !steps.length) return new Map();
|
|
440
|
-
const
|
|
441
|
-
|
|
1012
|
+
const { flat, isAnyOrder } = normalizeFunnelSteps(steps);
|
|
1013
|
+
if (isAnyOrder[0]) {
|
|
1014
|
+
// HPC keys off the step-0 event's property value; with an any-order
|
|
1015
|
+
// first chunk any member can claim position 0, so there is no single
|
|
1016
|
+
// seeding event name to bucket by.
|
|
1017
|
+
throw new Error('evaluateFunnelHPC: an anyOrder block cannot be the first funnel step');
|
|
1018
|
+
}
|
|
1019
|
+
const step0Name = flat[0].event;
|
|
442
1020
|
|
|
443
1021
|
// Bucket events by HPC value. The step-0 events define the universe of
|
|
444
1022
|
// HPC values for this user; later events only populate buckets whose
|
|
@@ -491,9 +1069,15 @@ export function resolveFunnelSegment(result, mode) {
|
|
|
491
1069
|
/**
|
|
492
1070
|
* Set-membership funnel completion check for non-sequential funnel modes.
|
|
493
1071
|
*
|
|
1072
|
+
* @deprecated v1.6.0 (P1.6.6) — NOT Mixpanel semantics. Mixpanel's any-order
|
|
1073
|
+
* is an anchor/chunk greedy pass, not a set-membership check: use
|
|
1074
|
+
* `evaluateFunnel(events, [{ anyOrder: [...] }], options)` for
|
|
1075
|
+
* Mixpanel-comparable results (conversion window, 2s rule, exclusions, and
|
|
1076
|
+
* reentry all apply). This helper ignores all of those. Kept for callers that
|
|
1077
|
+
* want a cheap "did the user ever fire all of these" signal.
|
|
1078
|
+
*
|
|
494
1079
|
* Returns true when the user fired all `steps` event names at least once,
|
|
495
|
-
* regardless of order.
|
|
496
|
-
* `sequential` / `interrupt` where Mixpanel's greedy single-pass doesn't apply.
|
|
1080
|
+
* regardless of order.
|
|
497
1081
|
*
|
|
498
1082
|
* `completionTimeMs` = `lastSeenTime - firstSeenTime` (proxy for "how long to
|
|
499
1083
|
* hit all steps in any order"). Mixpanel's funnel TTC analog doesn't exist for
|