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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +21 -12
  2. package/.claude/skills/create-dungeon/SKILL.md +65 -5
  3. package/.claude/skills/create-project/SKILL.md +96 -0
  4. package/.claude/skills/create-project/provision.mjs +336 -0
  5. package/.claude/skills/verify-dungeon/SKILL.md +56 -25
  6. package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
  7. package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
  8. package/.claude/skills/write-hooks/SKILL.md +156 -18
  9. package/CHANGELOG.md +211 -0
  10. package/HOOKS.md +566 -112
  11. package/README.md +28 -9
  12. package/docs/guides/1.3.0-upgrade-guide.md +262 -0
  13. package/docs/guides/1.3.1-upgrade-guide.md +328 -0
  14. package/docs/guides/1.4.0-upgrade-guide.md +130 -0
  15. package/docs/guides/1.4.1-upgrade-guide.md +125 -0
  16. package/docs/guides/1.4.3-upgrade-guide.md +200 -0
  17. package/docs/guides/1.4.4-upgrade-guide.md +95 -0
  18. package/docs/guides/1.5.0-upgrade-guide.md +426 -0
  19. package/docs/guides/1.5.1-upgrade-guide.md +178 -0
  20. package/docs/guides/1.5.3-upgrade-guide.md +117 -0
  21. package/docs/guides/1.6.0-upgrade-guide.md +204 -0
  22. package/dungeons/technical/stories-verify.js +152 -0
  23. package/dungeons/vertical/README.md +154 -0
  24. package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
  25. package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
  26. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
  27. package/dungeons/vertical/community/community.js +1589 -0
  28. package/dungeons/vertical/community/community.sql +216 -0
  29. package/dungeons/vertical/community/community.verify.mjs +72 -0
  30. package/dungeons/vertical/crypto/crypto.js +1483 -0
  31. package/dungeons/vertical/crypto/crypto.sql +194 -0
  32. package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
  33. package/dungeons/vertical/dating/dating.js +1540 -0
  34. package/dungeons/vertical/dating/dating.sql +288 -0
  35. package/dungeons/vertical/dating/dating.verify.mjs +72 -0
  36. package/dungeons/vertical/devtools/devtools.js +1524 -0
  37. package/dungeons/vertical/devtools/devtools.sql +161 -0
  38. package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
  39. package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
  40. package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
  41. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
  42. package/dungeons/vertical/education/education.js +1763 -0
  43. package/dungeons/vertical/education/education.sql +243 -0
  44. package/dungeons/vertical/education/education.verify.mjs +75 -0
  45. package/dungeons/vertical/fintech/fintech.js +1410 -0
  46. package/dungeons/vertical/fintech/fintech.sql +175 -0
  47. package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
  48. package/dungeons/vertical/fitness/fitness.js +1495 -0
  49. package/dungeons/vertical/fitness/fitness.sql +206 -0
  50. package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
  51. package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
  52. package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
  53. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
  54. package/dungeons/vertical/gaming/gaming.js +1741 -0
  55. package/dungeons/vertical/gaming/gaming.sql +249 -0
  56. package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
  57. package/dungeons/vertical/healthcare/healthcare.js +1488 -0
  58. package/dungeons/vertical/healthcare/healthcare.sql +193 -0
  59. package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
  60. package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
  61. package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
  62. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
  63. package/dungeons/vertical/logistics/logistics.js +1541 -0
  64. package/dungeons/vertical/logistics/logistics.sql +205 -0
  65. package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
  66. package/dungeons/vertical/marketplace/marketplace.js +1438 -0
  67. package/dungeons/vertical/marketplace/marketplace.sql +148 -0
  68. package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
  69. package/dungeons/vertical/{media.js → media/media.js} +557 -58
  70. package/dungeons/vertical/media/media.sql +173 -0
  71. package/dungeons/vertical/media/media.verify.mjs +74 -0
  72. package/dungeons/vertical/real-estate/real-estate.js +1629 -0
  73. package/dungeons/vertical/real-estate/real-estate.sql +167 -0
  74. package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
  75. package/dungeons/vertical/sass/sass.js +1733 -0
  76. package/dungeons/vertical/sass/sass.sql +173 -0
  77. package/dungeons/vertical/sass/sass.verify.mjs +74 -0
  78. package/dungeons/vertical/social/social.js +1456 -0
  79. package/dungeons/vertical/social/social.sql +160 -0
  80. package/dungeons/vertical/social/social.verify.mjs +76 -0
  81. package/dungeons/vertical/streaming/streaming.js +677 -0
  82. package/dungeons/vertical/streaming/streaming.sql +101 -0
  83. package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
  84. package/dungeons/vertical/support-desk/support-desk.js +741 -0
  85. package/dungeons/vertical/support-desk/support-desk.sql +137 -0
  86. package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
  87. package/dungeons/vertical/travel/travel.js +1603 -0
  88. package/dungeons/vertical/travel/travel.sql +236 -0
  89. package/dungeons/vertical/travel/travel.verify.mjs +72 -0
  90. package/index.js +2 -2
  91. package/lib/core/config-validator.js +37 -2
  92. package/lib/core/context.js +4 -2
  93. package/lib/core/dungeon-loader.js +20 -15
  94. package/lib/core/dungeon-to-json.js +22 -4
  95. package/lib/core/storage.js +3 -3
  96. package/lib/generators/funnels.js +36 -9
  97. package/lib/hook-helpers/cohort.js +53 -0
  98. package/lib/hook-helpers/index.js +8 -0
  99. package/lib/hook-helpers/mutate.js +1 -1
  100. package/lib/hook-helpers/shape.js +319 -0
  101. package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
  102. package/lib/hook-patterns/attributed-by-source.js +60 -58
  103. package/lib/hook-patterns/frequency-by-frequency.js +14 -4
  104. package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
  105. package/lib/hook-patterns/index.js +1 -1
  106. package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
  107. package/lib/orchestrators/user-loop.js +41 -0
  108. package/lib/templates/story-spec.schema.json +175 -0
  109. package/lib/utils/json-evaluator.js +1 -1
  110. package/lib/utils/utils.js +5 -4
  111. package/lib/verify/coerce.js +186 -0
  112. package/lib/verify/counting.js +270 -32
  113. package/lib/verify/emulate-breakdown.js +1285 -203
  114. package/lib/verify/first-time.js +89 -0
  115. package/lib/verify/flows.js +679 -0
  116. package/lib/verify/formula.js +259 -0
  117. package/lib/verify/funnel-engine.js +697 -113
  118. package/lib/verify/index.js +32 -4
  119. package/lib/verify/sessionize.js +275 -0
  120. package/lib/verify/story-runner.js +483 -0
  121. package/lib/verify/verify-dungeon.js +59 -37
  122. package/package.json +3 -2
  123. package/scripts/extract-dungeon-schema.mjs +2 -2
  124. package/scripts/run-many.mjs +2 -2
  125. package/scripts/verify-runner.mjs +2 -2
  126. package/scripts/verify-stories.mjs +244 -0
  127. package/types.d.ts +296 -30
  128. package/dungeons/vertical/ai-platform.js +0 -818
  129. package/dungeons/vertical/community.js +0 -966
  130. package/dungeons/vertical/crypto.js +0 -895
  131. package/dungeons/vertical/dating.js +0 -830
  132. package/dungeons/vertical/devtools.js +0 -1050
  133. package/dungeons/vertical/ecommerce.js +0 -811
  134. package/dungeons/vertical/education.js +0 -964
  135. package/dungeons/vertical/fintech.js +0 -946
  136. package/dungeons/vertical/fitness.js +0 -935
  137. package/dungeons/vertical/food-delivery.js +0 -934
  138. package/dungeons/vertical/gaming.js +0 -1120
  139. package/dungeons/vertical/healthcare.js +0 -945
  140. package/dungeons/vertical/insurance-application.js +0 -1039
  141. package/dungeons/vertical/logistics.js +0 -986
  142. package/dungeons/vertical/marketplace.js +0 -1019
  143. package/dungeons/vertical/real-estate.js +0 -872
  144. package/dungeons/vertical/sass.js +0 -995
  145. package/dungeons/vertical/social.js +0 -904
  146. package/dungeons/vertical/travel.js +0 -930
@@ -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
- * @property {number} [afterStep] - Exclusion active when `reached >= afterStep`. Mixpanel
41
- * `funnel_query.cpp` exclusion `i` fires when user has reached step `i` (between step
42
- * `i` and step `i+1`). Default `-Infinity` fires anywhere in the attempt (including
43
- * before step 0), useful for the simple `[{event: 'X'}]` shape used by
44
- * `Funnel.exclusionEvents` ("X anywhere kills").
45
- * @property {number} [beforeStep] - Exclusion active when `reached < beforeStep`.
46
- * Default `steps.length` fires until completion.
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 entry (the single attempt).
64
- * @property {boolean} [sessionScoped=false] - **Verifier-only convenience** — partitions
65
- * events by `session_id` and runs the matcher independently per session, returning the
66
- * best result (or all results when `countMode: 'totals'`). Mixpanel does NOT have an
67
- * exact equivalent; the closest production analog is `WINDOW_TYPE_SESSIONS` on the
68
- * conversion window (`conversion_window.cpp:9-13`), which bounds the funnel by session
69
- * COUNT, not by partitioning per session. Results from `sessionScoped: true` are NOT
70
- * directly reproducible in the Mixpanel UI.
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: stepTimes[last] - stepTimes[0]. `null` if not completed.
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 {FunnelStep} step
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
- const v = ev ? ev[where.prop] : undefined;
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
- * @returns {{ result: FunnelResult, nextIdx: number, terminatedByExclusion: boolean }}
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 { conversionWindowMs, graceperiod, trackStepProperties } = options;
214
- const stepTimes = new Array(steps.length).fill(0);
215
- const stepEvents = new Array(steps.length).fill(null);
216
- const stepProps = trackStepProperties ? new Array(steps.length).fill(null) : null;
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
- for (; i < sorted.length; i++) {
222
- const ev = sorted[i];
223
- const t = toMs(ev.time);
224
- if (!Number.isFinite(t)) continue;
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
- // Exclusion check FIRST — if this event matches any active exclusion,
227
- // terminate the attempt. Mixpanel `funnel_query.cpp` exclusion `i`
228
- // fires when the user has reached step `i` (between step `i` and step
229
- // `i+1`). API: `afterStep` is the index of the step that must have
230
- // been reached; `beforeStep` is the index of the step that must NOT
231
- // have been reached yet. Range check: `reached >= after && reached < before`.
232
- // Defaults (afterStep=-Infinity, beforeStep=steps.length) mean
233
- // "fires anywhere in the attempt" — used by the simple
234
- // `[{event: 'X'}]` shape that `Funnel.exclusionEvents` produces.
235
- if (exclusionSteps && exclusionSteps.length) {
236
- let excluded = false;
237
- for (const ex of exclusionSteps) {
238
- if (ev.event !== ex.event) continue;
239
- const after = typeof ex.afterStep === 'number' ? ex.afterStep : -Infinity;
240
- const before = typeof ex.beforeStep === 'number' ? ex.beforeStep : steps.length;
241
- if (reached >= after && reached < before) {
242
- excluded = true; break;
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
- // Greedy assignment: this event goes to the first not-yet-reached
249
- // step whose name + filter matches.
250
- let matchedStep = -1;
251
- for (let s = reached + 1; s < steps.length; s++) {
252
- if (eventMatchesStep(ev, steps[s])) { matchedStep = s; break; }
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
- // "Always record the latest matching event for this step" — history.cpp.
257
- stepTimes[matchedStep] = t;
258
- stepEvents[matchedStep] = ev;
259
- if (stepProps) stepProps[matchedStep] = snapshotProperties(ev, trackStepProperties);
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
- if (matchedStep !== reached + 1) continue;
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 (matchedStep > 0 && !withinConversionWindow(t, stepTimes[0], conversionWindowMs)) {
264
- stepTimes[matchedStep] = 0;
265
- stepEvents[matchedStep] = null;
266
- if (stepProps) stepProps[matchedStep] = null;
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
- reached = matchedStep;
271
-
272
- // Cascade through pre-recorded later steps.
273
- const step0Time = stepTimes[0];
274
- let lastReachedTime = t;
275
- let ns = reached + 1;
276
- while (ns < steps.length) {
277
- const recorded = stepTimes[ns];
278
- if (recorded > 0
279
- && timestampComesAfter(recorded, lastReachedTime, graceperiod)
280
- && withinConversionWindow(recorded, step0Time, conversionWindowMs)
281
- ) {
282
- reached = ns;
283
- lastReachedTime = recorded;
284
- ns++;
285
- } else {
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
- if (reached === steps.length - 1) { i++; break; }
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
- const reachedStepEvents = stepEvents.slice(0, reached + 1);
294
- const reachedStepTimes = stepTimes.slice(0, reached + 1);
295
- const completed = reached === steps.length - 1;
296
- const ttcMs = completed && reachedStepTimes.length > 1
297
- ? reachedStepTimes[reachedStepTimes.length - 1] - reachedStepTimes[0]
298
- : null;
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: stepProps ? stepProps.slice(0, reached + 1).map(p => p || {}) : undefined,
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
- const normSteps = steps.map(normalizeStep);
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
- const opts = { conversionWindowMs, graceperiod, trackStepProperties };
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 exclusion.
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, opts);
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 normSteps = steps.map(normalizeStep);
441
- const step0Name = normSteps[0].event;
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. Used for funnels generated with order modes other than
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