@ak--47/dungeon-master 1.8.0 → 1.8.2
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 +9 -0
- package/.claude/skills/create-dungeon/SKILL.md +35 -34
- package/.claude/skills/create-project/SKILL.md +6 -0
- package/.claude/skills/headless-build/SKILL.md +21 -11
- package/.claude/skills/powertools/SKILL.md +6 -2
- package/.claude/skills/release-check/SKILL.md +27 -2
- package/.claude/skills/verify-dungeon/SKILL.md +32 -13
- package/.claude/skills/verify-dungeon/references/alignment-contract.md +110 -0
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +29 -16
- package/.claude/skills/verify-dungeon/references/report-format.md +23 -9
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +135 -225
- package/.claude/skills/warehouse-metrics/SKILL.md +6 -0
- package/.claude/skills/write-hooks/SKILL.md +61 -48
- package/CHANGELOG.md +82 -0
- package/HOOKS.md +105 -47
- package/README.md +41 -1
- package/docs/guides/1.8.1-upgrade-guide.md +153 -0
- package/docs/guides/1.8.2-upgrade-guide.md +110 -0
- package/lib/generators/events.js +6 -0
- package/lib/generators/funnels.js +16 -0
- package/lib/hook-helpers/shape.js +73 -17
- package/lib/hook-patterns/attributed-by-source.js +4 -3
- package/lib/hook-patterns/funnel-frequency-breakdown.js +4 -7
- package/lib/orchestrators/user-loop.js +82 -15
- package/lib/verify/counting.js +7 -10
- package/lib/verify/emulate-breakdown.js +48 -29
- package/lib/verify/funnel-engine.js +93 -40
- package/lib/verify/identity.js +32 -9
- package/lib/verify/story-runner.js +93 -30
- package/lib/verify/verify-dungeon.js +4 -1
- package/package.json +1 -1
- package/scripts/verify-stories.mjs +3 -3
- package/types.d.ts +9 -5
|
@@ -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
|
|
94
|
-
*
|
|
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
|
|
128
|
-
*
|
|
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
|
|
@@ -185,7 +187,7 @@
|
|
|
185
187
|
|
|
186
188
|
import { toMs } from '../hook-helpers/_internal.js';
|
|
187
189
|
import { sessionOrdinals } from './sessionize.js';
|
|
188
|
-
import { matchesWhere } from './coerce.js';
|
|
190
|
+
import { coerceToBreakdownKey, matchesWhere } from './coerce.js';
|
|
189
191
|
|
|
190
192
|
const OUT_OF_ORDER_MS = 2000;
|
|
191
193
|
const DAY_MS = 86400 * 1000;
|
|
@@ -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; //
|
|
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
|
-
|
|
590
|
-
|
|
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
|
|
610
|
-
//
|
|
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 ||
|
|
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 (!
|
|
658
|
-
|
|
659
|
-
|
|
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
|
|
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 =
|
|
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,15 +1013,24 @@ 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
|
+
* Analytics 717286d2: backend/arb/reader/queries/funnel_query.cpp:749
|
|
1020
|
+
* processes each key from backend/libquery/aggregate.cpp:36,113. Ordinary
|
|
1021
|
+
* HPC expands lists one level and accepts scalars. Both use value_to_string
|
|
1022
|
+
* (backend/libquery/value/value.c:184), not typed breakdown/filter equality.
|
|
1023
|
+
* Empty lists emit no visits; null/missing items use the "undefined" label.
|
|
1024
|
+
* Duplicate labels share a history but retain cursor visits, which can
|
|
1025
|
+
* advance repeated steps (funnel_query.cpp:1257; funnels/history.cpp:387).
|
|
996
1026
|
*
|
|
997
|
-
*
|
|
998
|
-
*
|
|
999
|
-
* property
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1027
|
+
* Compatibility: all-scalar streams retain raw keys and omit null/missing
|
|
1028
|
+
* values. List-bearing streams use string labels for all routed values.
|
|
1029
|
+
* This property-name API does not expose the separate explicit-list mode,
|
|
1030
|
+
* which rejects non-lists (aggregate.cpp:126). Events and their copy-data
|
|
1031
|
+
* properties remain unchanged; only the routing key is normalized.
|
|
1032
|
+
* The shared label helper does not reproduce ARB's numeric %.16g fallback,
|
|
1033
|
+
* structured-value serialization/truncation, or aggregate cardinality cap.
|
|
1002
1034
|
*
|
|
1003
1035
|
* @param {Array<Object>} events
|
|
1004
1036
|
* @param {FunnelStep[]} steps
|
|
@@ -1017,6 +1049,14 @@ export function evaluateFunnelHPC(events, steps, holdProperty, options = {}) {
|
|
|
1017
1049
|
throw new Error('evaluateFunnelHPC: an anyOrder block cannot be the first funnel step');
|
|
1018
1050
|
}
|
|
1019
1051
|
const step0Name = flat[0].event;
|
|
1052
|
+
const fullStreamOrdinals = options.countMode === 'sessions' || options.conversionWindow?.unit === 'sessions'
|
|
1053
|
+
? sessionOrdinals((events || []).filter(ev => ev && typeof ev.event === 'string'))
|
|
1054
|
+
: undefined;
|
|
1055
|
+
const hasListValues = (events || []).some(ev => ev && typeof ev.event === 'string' && Array.isArray(ev[holdProperty]));
|
|
1056
|
+
const heldValues = (value) => {
|
|
1057
|
+
if (!hasListValues) return value == null ? [] : [value];
|
|
1058
|
+
return (Array.isArray(value) ? value : [value]).map(coerceToBreakdownKey);
|
|
1059
|
+
};
|
|
1020
1060
|
|
|
1021
1061
|
// Bucket events by HPC value. The step-0 events define the universe of
|
|
1022
1062
|
// HPC values for this user; later events only populate buckets whose
|
|
@@ -1026,31 +1066,31 @@ export function evaluateFunnelHPC(events, steps, holdProperty, options = {}) {
|
|
|
1026
1066
|
if (!ev || typeof ev.event !== 'string') continue;
|
|
1027
1067
|
// Step-0 events seed the bucket on their own value.
|
|
1028
1068
|
if (ev.event === step0Name) {
|
|
1029
|
-
const
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1069
|
+
for (const value of heldValues(ev[holdProperty])) {
|
|
1070
|
+
if (!valueBuckets.has(value)) valueBuckets.set(value, []);
|
|
1071
|
+
valueBuckets.get(value).push(ev);
|
|
1072
|
+
}
|
|
1033
1073
|
}
|
|
1034
1074
|
}
|
|
1035
1075
|
// Now route every event with a known HPC value into its bucket.
|
|
1036
1076
|
for (const ev of events || []) {
|
|
1037
1077
|
if (!ev || typeof ev.event !== 'string' || ev.event === step0Name) continue;
|
|
1038
|
-
const
|
|
1039
|
-
|
|
1040
|
-
|
|
1078
|
+
for (const value of heldValues(ev[holdProperty])) {
|
|
1079
|
+
if (valueBuckets.has(value)) valueBuckets.get(value).push(ev);
|
|
1080
|
+
}
|
|
1041
1081
|
}
|
|
1042
1082
|
|
|
1043
1083
|
const out = new Map();
|
|
1044
1084
|
for (const [v, evs] of valueBuckets) {
|
|
1045
|
-
out.set(v,
|
|
1085
|
+
out.set(v, evaluateFunnelWithContext(evs, steps, options, fullStreamOrdinals));
|
|
1046
1086
|
}
|
|
1047
1087
|
return out;
|
|
1048
1088
|
}
|
|
1049
1089
|
|
|
1050
1090
|
/**
|
|
1051
|
-
*
|
|
1052
|
-
*
|
|
1053
|
-
*
|
|
1091
|
+
* Merge reached `stepProperties` in recorded path order for FIRST_TOUCH or
|
|
1092
|
+
* LAST_TOUCH. Undefined never replaces a defined value; null never replaces
|
|
1093
|
+
* a non-null defined value. STEP returns its selected snapshot unchanged.
|
|
1054
1094
|
*
|
|
1055
1095
|
* @param {FunnelResult} result
|
|
1056
1096
|
* @param {'first'|'last' | { step: number }} mode
|
|
@@ -1058,8 +1098,21 @@ export function evaluateFunnelHPC(events, steps, holdProperty, options = {}) {
|
|
|
1058
1098
|
*/
|
|
1059
1099
|
export function resolveFunnelSegment(result, mode) {
|
|
1060
1100
|
if (!result || !Array.isArray(result.stepProperties) || !result.stepProperties.length) return undefined;
|
|
1061
|
-
if (mode === 'first')
|
|
1062
|
-
|
|
1101
|
+
if (mode === 'first' || mode === 'last') {
|
|
1102
|
+
const merged = {};
|
|
1103
|
+
const snapshots = result.stepProperties.slice(0, result.reached + 1);
|
|
1104
|
+
if (mode === 'first') snapshots.reverse();
|
|
1105
|
+
for (const snapshot of snapshots) {
|
|
1106
|
+
for (const [key, value] of Object.entries(snapshot || {})) {
|
|
1107
|
+
const current = Object.prototype.hasOwnProperty.call(merged, key) ? merged[key] : undefined;
|
|
1108
|
+
if (current === undefined || (current === null && value !== undefined)
|
|
1109
|
+
|| (value !== undefined && value !== null)) {
|
|
1110
|
+
Object.defineProperty(merged, key, { value, enumerable: true, configurable: true, writable: true });
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
return merged;
|
|
1115
|
+
}
|
|
1063
1116
|
if (mode && typeof mode === 'object' && typeof mode.step === 'number') {
|
|
1064
1117
|
return result.stepProperties[mode.step];
|
|
1065
1118
|
}
|
package/lib/verify/identity.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Identity resolution for verifier
|
|
3
|
-
*
|
|
4
|
-
* semantics: pre-auth events stamped with `device_id` and post-auth events
|
|
5
|
-
* stamped with `user_id` belong to the same canonical identity.
|
|
2
|
+
* Identity resolution for verifier. Automatic links come from emitted
|
|
3
|
+
* both-ID events; profile pools are only an explicit caller override.
|
|
6
4
|
*
|
|
7
|
-
* Reference:
|
|
8
|
-
*
|
|
9
|
-
* `Map<device_id, canonical_user_id>` so query-time event grouping resolves
|
|
10
|
-
* pre-auth touches alongside post-auth events.
|
|
5
|
+
* Reference: analytics identity-manager v3 lookup_and_update_handler.go
|
|
6
|
+
* and identity/device_and_user_records.go at 717286d2d3ed03e9e3f9cb4346e4c6b2e561fb9a.
|
|
11
7
|
*/
|
|
12
8
|
|
|
9
|
+
const emittedIdentityMap = Symbol('emittedIdentityMap');
|
|
10
|
+
|
|
13
11
|
/**
|
|
14
12
|
* Build a `Map<device_id, canonical_user_id>` by inverting each profile's
|
|
15
13
|
* device-pool array. Reads `device_ids` first, falling back to the legacy
|
|
@@ -41,12 +39,36 @@ export function buildIdentityMap(profiles) {
|
|
|
41
39
|
return map;
|
|
42
40
|
}
|
|
43
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Build automatic device-to-user links from the full emitted stream.
|
|
44
|
+
* Event names do not gate linking. The first valid pair for a device wins;
|
|
45
|
+
* v3 does not reassign an already-linked device to a conflicting user.
|
|
46
|
+
* User IDs with the reserved $device: prefix cannot establish a link.
|
|
47
|
+
*
|
|
48
|
+
* @param {Array<Object>} events
|
|
49
|
+
* @returns {Map<string, string>}
|
|
50
|
+
*/
|
|
51
|
+
export function buildEventIdentityMap(events) {
|
|
52
|
+
const map = new Map();
|
|
53
|
+
Object.defineProperty(map, emittedIdentityMap, { value: true });
|
|
54
|
+
if (!Array.isArray(events)) return map;
|
|
55
|
+
for (const event of events) {
|
|
56
|
+
const deviceId = event?.device_id;
|
|
57
|
+
const userId = event?.user_id;
|
|
58
|
+
if (typeof deviceId !== 'string' || !deviceId.trim()
|
|
59
|
+
|| typeof userId !== 'string' || !userId.trim()
|
|
60
|
+
|| userId.startsWith('$device:') || map.has(deviceId)) continue;
|
|
61
|
+
map.set(deviceId, userId);
|
|
62
|
+
}
|
|
63
|
+
return map;
|
|
64
|
+
}
|
|
65
|
+
|
|
44
66
|
/**
|
|
45
67
|
* Resolve the canonical user id for an event. Lookup order:
|
|
46
68
|
* 1. `event.distinct_id` — Mixpanel's canonical post-merge identifier. When
|
|
47
69
|
* a downstream pipeline has already stitched the cluster, this is the
|
|
48
70
|
* ground truth; never override it.
|
|
49
|
-
* 2. `identityMap.get(event.device_id)` —
|
|
71
|
+
* 2. `identityMap.get(event.device_id)` — emitted link or explicit override.
|
|
50
72
|
* 3. `event.user_id` — already authed (pre-merge analog of distinct_id).
|
|
51
73
|
* 4. `event.device_id` — anonymous fallback.
|
|
52
74
|
*
|
|
@@ -70,6 +92,7 @@ export function buildIdentityMap(profiles) {
|
|
|
70
92
|
export function resolveUserId(event, identityMap) {
|
|
71
93
|
if (!event) return undefined;
|
|
72
94
|
if (event.distinct_id) return event.distinct_id;
|
|
95
|
+
if (event.user_id && identityMap && Object.getOwnPropertyDescriptor(identityMap, emittedIdentityMap)) return event.user_id;
|
|
73
96
|
if (identityMap && event.device_id) {
|
|
74
97
|
const merged = identityMap.get(event.device_id);
|
|
75
98
|
if (merged) return merged;
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
* neutral. For 'between' the wanted side is where the band's midpoint sits
|
|
21
21
|
* relative to the neutral point.
|
|
22
22
|
*
|
|
23
|
-
* Cohort
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* Cohort evidence per selection is an independent-user lower bound: sum only
|
|
24
|
+
* proven disjoint bins within a period, then take the largest period. Other
|
|
25
|
+
* user counts use the largest row. Missing evidence caps passes at WEAK.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { emulateBreakdown } from './emulate-breakdown.js';
|
|
@@ -166,11 +166,59 @@ function resolveRef(ref, selected) {
|
|
|
166
166
|
return sum;
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
function cohortOf(rows) {
|
|
170
|
-
if (
|
|
171
|
-
|
|
169
|
+
function cohortOf(rows, breakdown, sourceRows) {
|
|
170
|
+
if (!Array.isArray(rows)) return null;
|
|
171
|
+
let evidenceRows = (rows || []).filter(row => row && !row._empty);
|
|
172
|
+
if (!evidenceRows.length) return null;
|
|
173
|
+
const isCount = value => Number.isSafeInteger(value) && value >= 0;
|
|
174
|
+
const hasPeriod = row => typeof row.period === 'string' && row.period.length > 0;
|
|
175
|
+
let column = 'user_count';
|
|
176
|
+
let binColumns = [];
|
|
177
|
+
if (breakdown.type === 'funnelFrequency') {
|
|
178
|
+
if (breakdown.countMode !== undefined && breakdown.countMode !== 'uniques') return null;
|
|
179
|
+
const periodRequired = breakdown.timeBucket !== undefined || evidenceRows.some(row => row.period !== undefined);
|
|
180
|
+
if (evidenceRows.some(row => !isCount(row.step_index) || !isCount(row.breakdown_freq)
|
|
181
|
+
|| (periodRequired && !hasPeriod(row)))) return null;
|
|
182
|
+
const binKey = row => JSON.stringify([row.period, row.breakdown_freq]);
|
|
183
|
+
const selectedBins = new Set(evidenceRows.map(binKey));
|
|
184
|
+
evidenceRows = (sourceRows || []).filter(row => row && !row._empty && row.step_index === 0
|
|
185
|
+
&& isCount(row.breakdown_freq) && (row.period === undefined || hasPeriod(row))
|
|
186
|
+
&& selectedBins.has(binKey(row)));
|
|
187
|
+
if (new Set(evidenceRows.map(binKey)).size !== selectedBins.size) return null;
|
|
188
|
+
column = 'conversions';
|
|
189
|
+
binColumns = ['breakdown_freq'];
|
|
190
|
+
} else if (breakdown.type === 'eventBreakdown') {
|
|
191
|
+
column = 'total_users';
|
|
192
|
+
} else if (breakdown.type === 'uniques') {
|
|
193
|
+
if (breakdown.countType !== undefined && breakdown.countType !== 'unique') return null;
|
|
194
|
+
column = 'uniques';
|
|
195
|
+
} else if (breakdown.type === 'retention') {
|
|
196
|
+
column = 'cohort_size';
|
|
197
|
+
} else if (breakdown.type === 'frequencyByFrequency') {
|
|
198
|
+
binColumns = ['metric_freq', 'breakdown_freq'];
|
|
199
|
+
} else if (breakdown.type === 'aggregatePerUser') {
|
|
200
|
+
binColumns = ['breakdown_freq'];
|
|
172
201
|
}
|
|
173
|
-
return
|
|
202
|
+
if (evidenceRows.some(row => !isCount(row[column]))) return null;
|
|
203
|
+
const largestRow = evidenceRows.reduce((largest, row) => Math.max(largest, row[column]), 0);
|
|
204
|
+
const periodRequired = breakdown.timeBucket !== undefined || evidenceRows.some(row => row.period !== undefined);
|
|
205
|
+
if (!binColumns.length || evidenceRows.some(row => binColumns.some(bin => !isCount(row[bin]))
|
|
206
|
+
|| (periodRequired && !hasPeriod(row)))) return largestRow;
|
|
207
|
+
const periods = new Map();
|
|
208
|
+
for (const row of evidenceRows) {
|
|
209
|
+
if (!periods.has(row.period)) periods.set(row.period, new Map());
|
|
210
|
+
const bins = periods.get(row.period);
|
|
211
|
+
const key = JSON.stringify(binColumns.map(bin => row[bin]));
|
|
212
|
+
bins.set(key, Math.max(bins.get(key) || 0, row[column]));
|
|
213
|
+
}
|
|
214
|
+
let largestPeriod = 0;
|
|
215
|
+
for (const bins of periods.values()) {
|
|
216
|
+
let total = 0;
|
|
217
|
+
for (const count of bins.values()) total += count;
|
|
218
|
+
if (!Number.isSafeInteger(total)) return largestRow;
|
|
219
|
+
largestPeriod = Math.max(largestPeriod, total);
|
|
220
|
+
}
|
|
221
|
+
return largestPeriod;
|
|
174
222
|
}
|
|
175
223
|
|
|
176
224
|
// ── verdicts ────────────────────────────────────────────────────────────────
|
|
@@ -243,40 +291,55 @@ const fmt = (n) => (typeof n === 'number' && Number.isFinite(n)) ? (Math.abs(n)
|
|
|
243
291
|
*/
|
|
244
292
|
export function evaluateAssertion(rows, assertion, ctx) {
|
|
245
293
|
try {
|
|
294
|
+
const minCohort = assertion.minCohort;
|
|
295
|
+
let result, selected, cohorts;
|
|
246
296
|
if (typeof assertion.assert === 'function') {
|
|
297
|
+
if (typeof minCohort === 'number') {
|
|
298
|
+
selected = assertion.select === undefined ? { all: rows } : selectRows(rows, assertion.select);
|
|
299
|
+
cohorts = Object.values(selected).map(selection => cohortOf(selection, assertion.breakdown, rows));
|
|
300
|
+
}
|
|
247
301
|
/** @type {{ pass?: boolean, detail?: string, verdict?: import('../../types').StoryVerdict }} */
|
|
248
302
|
const res = assertion.assert(rows, ctx) || {};
|
|
249
303
|
if (res.verdict && VERDICT_RANK[res.verdict] !== undefined) {
|
|
250
|
-
|
|
304
|
+
result = { verdict: res.verdict, observed: null, detail: res.detail || 'custom assert' };
|
|
305
|
+
} else {
|
|
306
|
+
result = res.pass
|
|
307
|
+
? { verdict: 'STRONG', observed: null, detail: res.detail || 'custom assert passed' }
|
|
308
|
+
: { verdict: 'NONE', observed: null, detail: res.detail || 'custom assert failed' };
|
|
251
309
|
}
|
|
252
|
-
return res.pass
|
|
253
|
-
? { verdict: 'STRONG', observed: null, detail: res.detail || 'custom assert passed' }
|
|
254
|
-
: { verdict: 'NONE', observed: null, detail: res.detail || 'custom assert failed' };
|
|
255
|
-
}
|
|
256
|
-
const parsed = parseMetric(assertion.expect.metric);
|
|
257
|
-
const selected = selectRows(rows, assertion.select);
|
|
258
|
-
const left = resolveRef(parsed.left, selected);
|
|
259
|
-
let observed, neutral;
|
|
260
|
-
if (parsed.kind === 'single') {
|
|
261
|
-
observed = left; neutral = null;
|
|
262
310
|
} else {
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
311
|
+
const parsed = parseMetric(assertion.expect.metric);
|
|
312
|
+
selected = selectRows(rows, assertion.select);
|
|
313
|
+
const left = resolveRef(parsed.left, selected);
|
|
314
|
+
let observed, neutral;
|
|
315
|
+
if (parsed.kind === 'single') {
|
|
316
|
+
observed = left; neutral = null;
|
|
267
317
|
} else {
|
|
268
|
-
|
|
318
|
+
const right = resolveRef(parsed.right, selected);
|
|
319
|
+
if (parsed.kind === 'ratio') {
|
|
320
|
+
if (right === 0) throw new Error('ratio denominator is 0');
|
|
321
|
+
observed = left / right; neutral = 1;
|
|
322
|
+
} else {
|
|
323
|
+
observed = left - right; neutral = 0;
|
|
324
|
+
}
|
|
269
325
|
}
|
|
326
|
+
const { verdict, detail } = verdictFor(observed, assertion.expect, neutral);
|
|
327
|
+
result = { verdict, observed, detail };
|
|
270
328
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
329
|
+
if (typeof minCohort === 'number' && VERDICT_RANK[result.verdict] > VERDICT_RANK.WEAK) {
|
|
330
|
+
cohorts ??= Object.values(selected).map(selection => cohortOf(selection, assertion.breakdown, rows));
|
|
331
|
+
if (!cohorts.length || cohorts.some(cohort => cohort === null)) {
|
|
332
|
+
result.verdict = 'WEAK';
|
|
333
|
+
result.detail += ` - capped: insufficient evidence for minCohort ${minCohort}; independent-user denominator unavailable`;
|
|
334
|
+
} else {
|
|
335
|
+
const smallest = Math.min(...cohorts);
|
|
336
|
+
if (smallest < minCohort) {
|
|
337
|
+
result.verdict = 'WEAK';
|
|
338
|
+
result.detail += ` - capped: insufficient evidence; smallest independent-user lower bound ${smallest} < minCohort ${minCohort}`;
|
|
339
|
+
}
|
|
277
340
|
}
|
|
278
341
|
}
|
|
279
|
-
return
|
|
342
|
+
return result;
|
|
280
343
|
} catch (err) {
|
|
281
344
|
return { verdict: 'NONE', observed: null, detail: `error: ${err.message}` };
|
|
282
345
|
}
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import DUNGEON_MASTER from '../../index.js';
|
|
23
23
|
import { emulateBreakdown } from './emulate-breakdown.js';
|
|
24
|
+
import { buildEventIdentityMap } from './identity.js';
|
|
24
25
|
import { validateSchema } from './schema-validator.js';
|
|
25
26
|
|
|
26
27
|
/**
|
|
@@ -83,7 +84,7 @@ export function applyFunnelDefaults(breakdownArgs, funnels, profiles) {
|
|
|
83
84
|
// is the author choosing a window — injecting conversionWindowMs on top
|
|
84
85
|
// would trip evaluateFunnel's mutual-exclusion throw. Only inject when
|
|
85
86
|
// NEITHER window form is present.
|
|
86
|
-
if (args.conversionWindowMs === undefined && args.conversionWindow === undefined && Number.isFinite(matched.conversionWindowDays)) {
|
|
87
|
+
if (args.countMode !== 'sessions' && args.conversionWindowMs === undefined && args.conversionWindow === undefined && Number.isFinite(matched.conversionWindowDays)) {
|
|
87
88
|
args.conversionWindowMs = matched.conversionWindowDays * 86400000;
|
|
88
89
|
}
|
|
89
90
|
if (args.funnelOrder === undefined && matched.order) {
|
|
@@ -137,6 +138,7 @@ export async function verifyDungeon(config, checks, overrides) {
|
|
|
137
138
|
}
|
|
138
139
|
const events = Array.isArray(result.eventData) ? result.eventData : Array.from(result.eventData);
|
|
139
140
|
const profiles = Array.isArray(result.userProfilesData) ? result.userProfilesData : Array.from(result.userProfilesData);
|
|
141
|
+
const identityMap = buildEventIdentityMap(events);
|
|
140
142
|
// v1.6.2: schema-check against the config the run actually used. `config` may be a
|
|
141
143
|
// PATH STRING (no fields at all → every property reads as unexpected), and even for
|
|
142
144
|
// an object input a v1.5.1 dungeon keeps `hasAndroidDevices` / `hasBrowser` under
|
|
@@ -155,6 +157,7 @@ export async function verifyDungeon(config, checks, overrides) {
|
|
|
155
157
|
for (const check of checks) {
|
|
156
158
|
try {
|
|
157
159
|
const breakdownArgs = applyFunnelDefaults(check.breakdown, validatedFunnels, profiles);
|
|
160
|
+
if (breakdownArgs.identityMap === undefined) breakdownArgs.identityMap = identityMap;
|
|
158
161
|
const rows = emulateBreakdown(events, breakdownArgs);
|
|
159
162
|
const verdict = check.assert(rows, ctx);
|
|
160
163
|
results.push({ name: check.name, pass: !!verdict.pass, detail: verdict.detail, rows });
|
package/package.json
CHANGED
|
@@ -38,8 +38,8 @@ import { parse as parseCsv } from 'csv-parse';
|
|
|
38
38
|
import generate from '../index.js';
|
|
39
39
|
import { extractComments } from '../lib/core/extract-comments.js';
|
|
40
40
|
import { validateDungeonConfig } from '../lib/core/config-validator.js';
|
|
41
|
+
import { buildEventIdentityMap } from '../lib/verify/identity.js';
|
|
41
42
|
import {
|
|
42
|
-
buildIdentityMap,
|
|
43
43
|
VERDICT_RANK,
|
|
44
44
|
validateStories,
|
|
45
45
|
validateSchema,
|
|
@@ -149,7 +149,7 @@ if (inMemory) {
|
|
|
149
149
|
? await evaluateStories(stories, events, {
|
|
150
150
|
profiles,
|
|
151
151
|
funnels: Array.isArray(validated.funnels) ? validated.funnels : [],
|
|
152
|
-
identityMap:
|
|
152
|
+
identityMap: buildEventIdentityMap(events),
|
|
153
153
|
warehouseRows,
|
|
154
154
|
warehouseSpecs,
|
|
155
155
|
datasetStart: validated.datasetStart,
|
|
@@ -187,7 +187,7 @@ if (inMemory) {
|
|
|
187
187
|
// here and use the RETURN value — as of v1.6.2 validateDungeonConfig does not
|
|
188
188
|
// enrich the object you hand it.
|
|
189
189
|
const validated = validateDungeonConfig({ ...config, token: '' });
|
|
190
|
-
const identityMap =
|
|
190
|
+
const identityMap = buildEventIdentityMap(events);
|
|
191
191
|
schemaPass = !!validateSchema(events, validated)?.pass;
|
|
192
192
|
const warehouseSpecs = Object.fromEntries((validated.warehouseMetrics || []).map((spec) => [spec.name, spec]));
|
|
193
193
|
const warehouseManifest = loadWarehouseManifest(prefixPath);
|
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 —
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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 } };
|