@ak--47/dungeon-master 1.8.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -90,8 +90,10 @@
90
90
  * trend-interval end and the data-pull range — fix-round C6).
91
91
  * @property {boolean} [graceperiod=true] - Enable the 2-second grace window
92
92
  * on ordering checks. Disable only for tests that need strict ordering.
93
- * @property {boolean} [reentry=false] - When true, after completing all steps,
94
- * reset to step 0 and continue scanning. Increments `completions`.
93
+ * @property {boolean} [reentry=false] - When true, restart after the inclusive
94
+ * 2-second completion grace. An event recording the ordered last step and
95
+ * matching the ordered first step also anchors the next attempt immediately.
96
+ * `graceperiod: false` disables the completion wait. Increments `completions`.
95
97
  * Attempts ALSO restart when the conversion window expires (fix-round
96
98
  * B2+C5): an incoming event past the window from step 0 finalizes the
97
99
  * live attempt as a drop-off and processes against a fresh one — ARB
@@ -124,8 +126,8 @@
124
126
  * fresh one processes the same event (funnel_query.cpp:1608-1613 — for
125
127
  * GENERAL_WO_REPEAT termination checks ONLY history_is_past_conversion_
126
128
  * 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
+ * `reentry: true` (GENERAL), which also restarts after completion grace or
130
+ * exclusion, permitting repeat conversions within one window.
129
131
  * Requires `countMode: 'totals'`; mutually exclusive with `reentry` and
130
132
  * `sessionScoped`.
131
133
  * @property {boolean} [sessionScoped=false] - **@deprecated — verifier-only, NOT
@@ -373,7 +375,7 @@ function emptyResult(trackStepProperties) {
373
375
  * @returns {{ result: FunnelResult, nextIdx: number, terminatedByExclusion: boolean, expiredByWindow: boolean }}
374
376
  */
375
377
  function runOneAttempt(sorted, startIdx, steps, exclusionSteps, options) {
376
- const { windowCheck, graceperiod, trackStepProperties, anchorOk, isAnyOrder, prevAnchor, nextAnchor, expireOnWindow = false, woRepeat = false } = options;
378
+ const { windowCheck, graceperiod, trackStepProperties, anchorOk, isAnyOrder, prevAnchor, nextAnchor, expireOnWindow = false, woRepeat = false, restartSharedEdge = false } = options;
377
379
  const numSteps = steps.length;
378
380
  // Per-SLOT recorded candidates (history->steps): the latest match for
379
381
  // anchors, the first eligible match for active any-order chunk members.
@@ -402,8 +404,9 @@ function runOneAttempt(sorted, startIdx, steps, exclusionSteps, options) {
402
404
  let reached = -1;
403
405
  let terminatedByExclusion = false;
404
406
  let expiredByWindow = false;
407
+ let sharedEdgeCompleted = false;
405
408
  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
409
+ let endIdx = -1; // first event eligible for the next attempt
407
410
  let i = startIdx;
408
411
 
409
412
  // Does exclusion `ex` apply to gap g? afterStep/beforeStep bound the
@@ -586,8 +589,10 @@ function runOneAttempt(sorted, startIdx, steps, exclusionSteps, options) {
586
589
 
587
590
  if (reached === numSteps - 1 && !terminatedByExclusion) {
588
591
  tailAnchorMs = timeAtPos(numSteps - 1);
589
- endIdx = i + 1;
590
- // No break the 2s tail may still kill the completion.
592
+ sharedEdgeCompleted = !woRepeat && numSteps > 1 && s === numSteps - 1
593
+ && !isAnyOrder[0] && !isAnyOrder[numSteps - 1] && eventMatchesStep(ev, steps[0]);
594
+ endIdx = sharedEdgeCompleted && restartSharedEdge ? i : i + 1;
595
+ // Ordinary completions remain open for the 2s exclusion tail.
591
596
  }
592
597
  return true;
593
598
  };
@@ -606,10 +611,10 @@ function runOneAttempt(sorted, startIdx, steps, exclusionSteps, options) {
606
611
  // nextIdx = i, not i + 1. For GENERAL_WO_REPEAT (woRepeat) expiry is
607
612
  // the ONLY termination — it fires even on completed/excluded attempts
608
613
  // (: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).
614
+ // keep their own restart below; independent window expiry also
615
+ // finalizes a completion still inside its grace period.
611
616
  if (expireOnWindow && reached >= 0
612
- && (woRepeat || (!terminatedByExclusion && reached !== numSteps - 1))
617
+ && (woRepeat || !terminatedByExclusion)
613
618
  && !windowCheck(t, timeAtPos(0), ev, eventAtPos(0))
614
619
  ) {
615
620
  expiredByWindow = true;
@@ -654,9 +659,15 @@ function runOneAttempt(sorted, startIdx, steps, exclusionSteps, options) {
654
659
  // the event on their own: anchors need reached < slot; claimed
655
660
  // any-order chunk members are first-match-sealed — so
656
661
  // 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;
662
+ if (!graceperiod || t > tailAnchorMs + OUT_OF_ORDER_MS) {
663
+ endIdx = i;
664
+ break;
665
+ }
666
+ endIdx = i + 1;
667
+ if (hasExclusions) {
668
+ for (let g = 0; g < numSteps - 1; g++) {
669
+ if (tryExclusionAtGap(g, ev, t)) break;
670
+ }
660
671
  }
661
672
  continue;
662
673
  }
@@ -693,6 +704,7 @@ function runOneAttempt(sorted, startIdx, steps, exclusionSteps, options) {
693
704
  break;
694
705
  }
695
706
  }
707
+ if (sharedEdgeCompleted) break;
696
708
  }
697
709
 
698
710
  // Result surfaces the PATH (positions), not the slot table: position p was
@@ -772,6 +784,17 @@ function runOneAttempt(sorted, startIdx, steps, exclusionSteps, options) {
772
784
  * @returns {FunnelResult | FunnelResult[]}
773
785
  */
774
786
  export function evaluateFunnel(events, steps, options = {}) {
787
+ return evaluateFunnelWithContext(events, steps, options);
788
+ }
789
+
790
+ /**
791
+ * @param {Array<Object>} events
792
+ * @param {FunnelStep[]} steps
793
+ * @param {FunnelOptions} options
794
+ * @param {Map<Object, number>} [fullStreamOrdinals]
795
+ * @returns {FunnelResult | FunnelResult[]}
796
+ */
797
+ function evaluateFunnelWithContext(events, steps, options, fullStreamOrdinals) {
775
798
  if (options.countMode === 'sessions') {
776
799
  // Mixpanel's funnel "count by Sessions" is an API rewrite, not an
777
800
  // engine mode: count_type session REQUIRES window (session, 1) and
@@ -790,14 +813,14 @@ export function evaluateFunnel(events, steps, options = {}) {
790
813
  if (typeof options.conversionWindowMs === 'number' || (cw != null && !(cw.unit === 'sessions' && cw.n === 1))) {
791
814
  throw new Error("evaluateFunnel: cannot use countMode 'sessions' without conversion window = 1 session");
792
815
  }
793
- return evaluateFunnel(events, steps, {
816
+ return evaluateFunnelWithContext(events, steps, {
794
817
  ...options,
795
818
  countMode: 'totals',
796
819
  reentry: false,
797
820
  woRepeat: true,
798
821
  conversionWindowMs: undefined,
799
822
  conversionWindow: { unit: 'sessions', n: 1 },
800
- });
823
+ }, fullStreamOrdinals);
801
824
  }
802
825
  if (!Array.isArray(steps) || steps.length === 0) {
803
826
  const empty = emptyResult(options.trackStepProperties);
@@ -871,7 +894,7 @@ export function evaluateFunnel(events, steps, options = {}) {
871
894
  // the data-pull range (:402, :1408-1412) — the timeBucket wrapper's
872
895
  // [start, stop + n×day) spill slice mirrors exactly that. Spec P1.6.1's
873
896
  // dual per-step condition was a misreading; dropped per fix-round C6.
874
- const ordinals = sessionOrdinals(sorted);
897
+ const ordinals = fullStreamOrdinals ?? sessionOrdinals(sorted);
875
898
  windowCheck = (t, t0, ev, step0Ev) => {
876
899
  const o = ordinals.get(ev);
877
900
  const o0 = ordinals.get(step0Ev);
@@ -891,7 +914,7 @@ export function evaluateFunnel(events, steps, options = {}) {
891
914
  }
892
915
  const allResults = [];
893
916
  for (const [sid, evs] of bySession) {
894
- const sub = evaluateFunnel(evs, steps, { ...options, sessionScoped: false });
917
+ const sub = evaluateFunnelWithContext(evs, steps, { ...options, sessionScoped: false }, fullStreamOrdinals);
895
918
  if (Array.isArray(sub)) {
896
919
  for (const r of sub) { r.sessionId = sid; allResults.push(r); }
897
920
  } else {
@@ -952,7 +975,7 @@ export function evaluateFunnel(events, steps, options = {}) {
952
975
  const completedAttempts = [];
953
976
  let idx = 0;
954
977
  let lastResult = emptyResult(trackStepProperties);
955
- const reentryOpts = { ...opts, expireOnWindow: true };
978
+ const reentryOpts = { ...opts, expireOnWindow: true, restartSharedEdge: true };
956
979
  while (idx < sorted.length) {
957
980
  const { result, nextIdx, terminatedByExclusion } = runOneAttempt(sorted, idx, normSteps, exclusionSteps, reentryOpts);
958
981
  // Always advance — runOneAttempt returns nextIdx > idx when it processed an event.
@@ -990,7 +1013,8 @@ export function evaluateFunnel(events, steps, options = {}) {
990
1013
  * `Map<propertyValue, FunnelResult>`.
991
1014
  *
992
1015
  * Each sub-funnel runs independently (a user CAN convert in one HPC value
993
- * group and drop off in another simultaneously).
1016
+ * group and drop off in another simultaneously). Session windows derive
1017
+ * ordinals from the full user stream before routing events to HPC buckets.
994
1018
  *
995
1019
  * Reference: `funnel_query.cpp` lines 749-784 (`aggregate_hash_get_key_cursor`).
996
1020
  *
@@ -1017,6 +1041,9 @@ export function evaluateFunnelHPC(events, steps, holdProperty, options = {}) {
1017
1041
  throw new Error('evaluateFunnelHPC: an anyOrder block cannot be the first funnel step');
1018
1042
  }
1019
1043
  const step0Name = flat[0].event;
1044
+ const fullStreamOrdinals = options.countMode === 'sessions' || options.conversionWindow?.unit === 'sessions'
1045
+ ? sessionOrdinals((events || []).filter(ev => ev && typeof ev.event === 'string'))
1046
+ : undefined;
1020
1047
 
1021
1048
  // Bucket events by HPC value. The step-0 events define the universe of
1022
1049
  // HPC values for this user; later events only populate buckets whose
@@ -1042,15 +1069,15 @@ export function evaluateFunnelHPC(events, steps, holdProperty, options = {}) {
1042
1069
 
1043
1070
  const out = new Map();
1044
1071
  for (const [v, evs] of valueBuckets) {
1045
- out.set(v, evaluateFunnel(evs, steps, options));
1072
+ out.set(v, evaluateFunnelWithContext(evs, steps, options, fullStreamOrdinals));
1046
1073
  }
1047
1074
  return out;
1048
1075
  }
1049
1076
 
1050
1077
  /**
1051
- * Resolve the property snapshot for a given segment mode against a result's
1052
- * `stepProperties`. Use to mimic Mixpanel's FIRST_TOUCH / LAST_TOUCH / STEP
1053
- * funnel segment modes.
1078
+ * Merge reached `stepProperties` in recorded path order for FIRST_TOUCH or
1079
+ * LAST_TOUCH. Undefined never replaces a defined value; null never replaces
1080
+ * a non-null defined value. STEP returns its selected snapshot unchanged.
1054
1081
  *
1055
1082
  * @param {FunnelResult} result
1056
1083
  * @param {'first'|'last' | { step: number }} mode
@@ -1058,8 +1085,21 @@ export function evaluateFunnelHPC(events, steps, holdProperty, options = {}) {
1058
1085
  */
1059
1086
  export function resolveFunnelSegment(result, mode) {
1060
1087
  if (!result || !Array.isArray(result.stepProperties) || !result.stepProperties.length) return undefined;
1061
- if (mode === 'first') return result.stepProperties[0];
1062
- if (mode === 'last') return result.stepProperties[result.reached >= 0 ? result.reached : result.stepProperties.length - 1];
1088
+ if (mode === 'first' || mode === 'last') {
1089
+ const merged = {};
1090
+ const snapshots = result.stepProperties.slice(0, result.reached + 1);
1091
+ if (mode === 'first') snapshots.reverse();
1092
+ for (const snapshot of snapshots) {
1093
+ for (const [key, value] of Object.entries(snapshot || {})) {
1094
+ const current = Object.prototype.hasOwnProperty.call(merged, key) ? merged[key] : undefined;
1095
+ if (current === undefined || (current === null && value !== undefined)
1096
+ || (value !== undefined && value !== null)) {
1097
+ Object.defineProperty(merged, key, { value, enumerable: true, configurable: true, writable: true });
1098
+ }
1099
+ }
1100
+ }
1101
+ return merged;
1102
+ }
1063
1103
  if (mode && typeof mode === 'object' && typeof mode.step === 'number') {
1064
1104
  return result.stepProperties[mode.step];
1065
1105
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ak--47/dungeon-master",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
4
4
  "description": "generate fancy datasets",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/types.d.ts CHANGED
@@ -2683,10 +2683,10 @@ declare module '@ak--47/dungeon-master/hook-helpers' {
2683
2683
  export function injectOnNewDays(events: EventSchema[], eventName: string, targetDays: number, options?: { timeRange?: 'active'; overrides?: Partial<EventSchema> }): EventSchema[];
2684
2684
  /** v1.6.0 — carve a dormant window (drop value moments, or all events with `dropAll`) then append a resurrection burst cloned from the surviving value-moment template. Returns a NEW array. */
2685
2685
  export function applyLifecycleWave(events: EventSchema[], uid: string, opts: { dormantFromDay: number; dormantDays: number; valueMomentEvent: string; resurrectBurst?: number; dropAll?: boolean }): EventSchema[];
2686
- /** v1.6.0 — inject an ordered event path after each anchor for a deterministic `share` of users (hash-gated). Augments in place; engine auto-sort handles ordering. */
2686
+ /** v1.6.0 — append an ordered path after the FIRST chronological anchor for a deterministic `share` of users. Original traffic is untouched and can interrupt the immediate branch; share is an injection gate, not a measured Flows share. */
2687
2687
  export function applyPathBias(events: EventSchema[], uid: string, opts: { anchor: string; path: string[]; share: number; gapSeconds?: [number, number] }): EventSchema[];
2688
- /** v1.6.0 rewrite the user's timestamps into deterministic session clusters (n/week, m events, bounded span) that survive query-time re-derivation. */
2689
- export function applySessionShape(events: EventSchema[], uid: string, opts: { sessionsPerWeek: number; eventsPerSession: number; sessionMinutes: number }): EventSchema[];
2688
+ /** Retiming only; preserves every record. Both bounds omitted retain legacy full-UTC-day placement, including nonthrowing overfull requests whose clusters may merge. Optional inclusive bounds accept ISO, unix seconds or milliseconds (hook meta directly); an omitted side uses its original UTC day edge. The helper cannot infer datasetEnd. Explicit-bound mode throws RangeError before mutation for invalid bounds or insufficient per-week 30-minute-session capacity. Pass known metadata bounds to prevent later engine clipping. */
2689
+ export function applySessionShape(events: EventSchema[], uid: string, opts: { sessionsPerWeek: number; eventsPerSession: number; sessionMinutes: number; datasetStart?: string | number; datasetEnd?: string | number }): EventSchema[];
2690
2690
  }
2691
2691
 
2692
2692
  declare module '@ak--47/dungeon-master/hook-patterns' {
@@ -2926,6 +2926,9 @@ declare module '@ak--47/dungeon-master/verify' {
2926
2926
  */
2927
2927
  conversionWindow?: { unit: 'sessions'; n: number };
2928
2928
  graceperiod?: boolean;
2929
+ /** Defaults to false, including totals. Restart after inclusive 2s completion grace;
2930
+ * ordered shared last/first edges restart on the completion event itself.
2931
+ * graceperiod=false disables the wait; window expiry can restart earlier. */
2929
2932
  reentry?: boolean;
2930
2933
  exclusionSteps?: ExclusionStep[];
2931
2934
  trackStepProperties?: boolean | string[];
@@ -2958,9 +2961,10 @@ declare module '@ak--47/dungeon-master/verify' {
2958
2961
  }
2959
2962
  /** Evaluate a funnel against a user's events. Returns FunnelResult or array (totals mode). */
2960
2963
  export function evaluateFunnel(events: Array<Record<string, unknown>>, steps: FunnelStep[], options?: FunnelOptions): FunnelResult | FunnelResult[];
2961
- /** Hold Property Constant — runs parallel sub-funnels per unique value of `holdProperty`. */
2964
+ /** Hold Property Constant: parallel sub-funnels per held value; session ordinals use the full user stream. */
2962
2965
  export function evaluateFunnelHPC(events: Array<Record<string, unknown>>, steps: FunnelStep[], holdProperty: string, options?: FunnelOptions): Map<string | number, FunnelResult | FunnelResult[]>;
2963
- /** Pick a property snapshot from a FunnelResult for the given segment mode. */
2966
+ /** Merge reached snapshots in path order for first/last touch. Undefined and null preserve
2967
+ * defined non-null fallback values. Explicit step selection returns its snapshot unchanged. */
2964
2968
  export function resolveFunnelSegment(result: FunnelResult, mode: 'first' | 'last' | { step: number }): Record<string, unknown> | undefined;
2965
2969
  /** Normalize a FunnelStep to the `{ event, where? }` canonical shape. */
2966
2970
  export function normalizeStep(step: FunnelStep): { event: string; where?: { prop: string; op: string; value: unknown } };