@ak--47/dungeon-master 1.5.4 → 1.6.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.
Files changed (149) 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 +1 -1
  4. package/.claude/skills/create-project/provision.mjs +57 -10
  5. package/.claude/skills/powertools/SKILL.md +75 -0
  6. package/.claude/skills/powertools/pt.mjs +91 -0
  7. package/.claude/skills/powertools/snapshot-project.mjs +124 -0
  8. package/.claude/skills/verify-dungeon/SKILL.md +56 -25
  9. package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
  10. package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
  11. package/.claude/skills/write-hooks/SKILL.md +156 -18
  12. package/CHANGELOG.md +224 -0
  13. package/HOOKS.md +566 -112
  14. package/README.md +30 -9
  15. package/docs/guides/1.3.0-upgrade-guide.md +262 -0
  16. package/docs/guides/1.3.1-upgrade-guide.md +328 -0
  17. package/docs/guides/1.4.0-upgrade-guide.md +130 -0
  18. package/docs/guides/1.4.1-upgrade-guide.md +125 -0
  19. package/docs/guides/1.4.3-upgrade-guide.md +200 -0
  20. package/docs/guides/1.4.4-upgrade-guide.md +95 -0
  21. package/docs/guides/1.5.0-upgrade-guide.md +426 -0
  22. package/docs/guides/1.5.1-upgrade-guide.md +178 -0
  23. package/docs/guides/1.5.3-upgrade-guide.md +117 -0
  24. package/docs/guides/1.6.0-upgrade-guide.md +204 -0
  25. package/dungeons/technical/stories-verify.js +152 -0
  26. package/dungeons/vertical/README.md +154 -0
  27. package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
  28. package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
  29. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
  30. package/dungeons/vertical/community/community.js +1589 -0
  31. package/dungeons/vertical/community/community.sql +216 -0
  32. package/dungeons/vertical/community/community.verify.mjs +72 -0
  33. package/dungeons/vertical/crypto/crypto.js +1483 -0
  34. package/dungeons/vertical/crypto/crypto.sql +194 -0
  35. package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
  36. package/dungeons/vertical/dating/dating.js +1540 -0
  37. package/dungeons/vertical/dating/dating.sql +288 -0
  38. package/dungeons/vertical/dating/dating.verify.mjs +72 -0
  39. package/dungeons/vertical/devtools/devtools.js +1524 -0
  40. package/dungeons/vertical/devtools/devtools.sql +161 -0
  41. package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
  42. package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
  43. package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
  44. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
  45. package/dungeons/vertical/education/education.js +1763 -0
  46. package/dungeons/vertical/education/education.sql +243 -0
  47. package/dungeons/vertical/education/education.verify.mjs +75 -0
  48. package/dungeons/vertical/fintech/fintech.js +1410 -0
  49. package/dungeons/vertical/fintech/fintech.sql +175 -0
  50. package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
  51. package/dungeons/vertical/fitness/fitness.js +1495 -0
  52. package/dungeons/vertical/fitness/fitness.sql +206 -0
  53. package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
  54. package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
  55. package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
  56. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
  57. package/dungeons/vertical/gaming/gaming.js +1741 -0
  58. package/dungeons/vertical/gaming/gaming.sql +249 -0
  59. package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
  60. package/dungeons/vertical/healthcare/healthcare.js +1488 -0
  61. package/dungeons/vertical/healthcare/healthcare.sql +193 -0
  62. package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
  63. package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
  64. package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
  65. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
  66. package/dungeons/vertical/logistics/logistics.js +1541 -0
  67. package/dungeons/vertical/logistics/logistics.sql +205 -0
  68. package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
  69. package/dungeons/vertical/marketplace/marketplace.js +1438 -0
  70. package/dungeons/vertical/marketplace/marketplace.sql +148 -0
  71. package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
  72. package/dungeons/vertical/{media.js → media/media.js} +557 -58
  73. package/dungeons/vertical/media/media.sql +173 -0
  74. package/dungeons/vertical/media/media.verify.mjs +74 -0
  75. package/dungeons/vertical/real-estate/real-estate.js +1629 -0
  76. package/dungeons/vertical/real-estate/real-estate.sql +167 -0
  77. package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
  78. package/dungeons/vertical/sass/sass.js +1733 -0
  79. package/dungeons/vertical/sass/sass.sql +173 -0
  80. package/dungeons/vertical/sass/sass.verify.mjs +74 -0
  81. package/dungeons/vertical/social/social.js +1456 -0
  82. package/dungeons/vertical/social/social.sql +160 -0
  83. package/dungeons/vertical/social/social.verify.mjs +76 -0
  84. package/dungeons/vertical/streaming/streaming.js +677 -0
  85. package/dungeons/vertical/streaming/streaming.sql +101 -0
  86. package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
  87. package/dungeons/vertical/support-desk/support-desk.js +741 -0
  88. package/dungeons/vertical/support-desk/support-desk.sql +137 -0
  89. package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
  90. package/dungeons/vertical/travel/travel.js +1603 -0
  91. package/dungeons/vertical/travel/travel.sql +236 -0
  92. package/dungeons/vertical/travel/travel.verify.mjs +72 -0
  93. package/index.js +7 -3
  94. package/lib/core/config-validator.js +37 -2
  95. package/lib/core/context.js +4 -2
  96. package/lib/core/dungeon-loader.js +20 -15
  97. package/lib/core/dungeon-to-json.js +22 -4
  98. package/lib/core/storage.js +3 -3
  99. package/lib/generators/funnels.js +36 -9
  100. package/lib/hook-helpers/cohort.js +53 -0
  101. package/lib/hook-helpers/index.js +8 -0
  102. package/lib/hook-helpers/mutate.js +1 -1
  103. package/lib/hook-helpers/shape.js +319 -0
  104. package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
  105. package/lib/hook-patterns/attributed-by-source.js +60 -58
  106. package/lib/hook-patterns/frequency-by-frequency.js +14 -4
  107. package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
  108. package/lib/hook-patterns/index.js +1 -1
  109. package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
  110. package/lib/orchestrators/user-loop.js +41 -0
  111. package/lib/templates/story-spec.schema.json +175 -0
  112. package/lib/utils/json-evaluator.js +1 -1
  113. package/lib/utils/utils.js +151 -59
  114. package/lib/verify/coerce.js +186 -0
  115. package/lib/verify/counting.js +270 -32
  116. package/lib/verify/emulate-breakdown.js +1285 -203
  117. package/lib/verify/first-time.js +89 -0
  118. package/lib/verify/flows.js +679 -0
  119. package/lib/verify/formula.js +259 -0
  120. package/lib/verify/funnel-engine.js +697 -113
  121. package/lib/verify/index.js +32 -4
  122. package/lib/verify/sessionize.js +275 -0
  123. package/lib/verify/story-runner.js +483 -0
  124. package/lib/verify/verify-dungeon.js +59 -37
  125. package/package.json +2 -1
  126. package/scripts/extract-dungeon-schema.mjs +2 -2
  127. package/scripts/run-many.mjs +2 -2
  128. package/scripts/verify-runner.mjs +2 -2
  129. package/scripts/verify-stories.mjs +244 -0
  130. package/types.d.ts +298 -30
  131. package/dungeons/vertical/ai-platform.js +0 -818
  132. package/dungeons/vertical/community.js +0 -966
  133. package/dungeons/vertical/crypto.js +0 -895
  134. package/dungeons/vertical/dating.js +0 -830
  135. package/dungeons/vertical/devtools.js +0 -1050
  136. package/dungeons/vertical/ecommerce.js +0 -811
  137. package/dungeons/vertical/education.js +0 -964
  138. package/dungeons/vertical/fintech.js +0 -946
  139. package/dungeons/vertical/fitness.js +0 -935
  140. package/dungeons/vertical/food-delivery.js +0 -934
  141. package/dungeons/vertical/gaming.js +0 -1120
  142. package/dungeons/vertical/healthcare.js +0 -945
  143. package/dungeons/vertical/insurance-application.js +0 -1039
  144. package/dungeons/vertical/logistics.js +0 -986
  145. package/dungeons/vertical/marketplace.js +0 -1019
  146. package/dungeons/vertical/real-estate.js +0 -872
  147. package/dungeons/vertical/sass.js +0 -995
  148. package/dungeons/vertical/social.js +0 -904
  149. package/dungeons/vertical/travel.js +0 -930
@@ -6,8 +6,10 @@
6
6
  * Insights view shows a deliberate shape (e.g., users with 5–20 cohort events
7
7
  * have 2x the metric event count of users with <5).
8
8
  *
9
- * Mechanism: classify the user into a bin based on their `cohortEvent` count,
10
- * then `scaleEventCount(events, targetEvent, multipliers[bin])` to scale that
9
+ * Mechanism: classify the user into a bin from their `cohortEvent` activity —
10
+ * distinct calendar days by default (v1.6, matching Mixpanel's frequency axis;
11
+ * see `binByDistinctPeriods`), or total events with `binBy: 'events'` — then
12
+ * `scaleEventCount(events, targetEvent, multipliers[bin])` to scale that
11
13
  * user's count of the target event up or down.
12
14
  *
13
15
  * Identity & schema constraints:
@@ -19,6 +21,7 @@
19
21
  */
20
22
 
21
23
  import { binUsersByEventCount } from '../hook-helpers/cohort.js';
24
+ import { binByDistinctPeriods } from '../verify/counting.js';
22
25
  import { scaleEventCount } from '../hook-helpers/mutate.js';
23
26
 
24
27
  /**
@@ -30,14 +33,21 @@ import { scaleEventCount } from '../hook-helpers/mutate.js';
30
33
  * @param {string} opts.targetEvent - Event whose count is scaled per bin.
31
34
  * @param {Record<string, number>} opts.multipliers - Bin name → multiplier (1 = no-op,
32
35
  * 2 = double, 0.5 = halve). Bins absent from this map use multiplier 1.
36
+ * @param {('events'|'distinctDays')} [opts.binBy='distinctDays'] - Cohort axis.
37
+ * `'distinctDays'` (default since v1.6) bins by distinct calendar days with
38
+ * `cohortEvent` via `binByDistinctPeriods` — the axis Mixpanel's frequency
39
+ * breakdown and the local emulator actually use. `'events'` restores the
40
+ * pre-1.6 total-event-count axis.
33
41
  * @returns {{ bin: string|null, delta: number }} Bin assigned + signed delta from
34
42
  * scaleEventCount (positive = clones added; negative = events dropped).
35
43
  */
36
- export function applyFrequencyByFrequency(events, _profile, { cohortEvent, bins, targetEvent, multipliers }) {
44
+ export function applyFrequencyByFrequency(events, _profile, { cohortEvent, bins, targetEvent, multipliers, binBy = 'distinctDays' }) {
37
45
  if (!events || !cohortEvent || !bins || !targetEvent || !multipliers) {
38
46
  return { bin: null, delta: 0 };
39
47
  }
40
- const bin = binUsersByEventCount(events, cohortEvent, bins);
48
+ const bin = binBy === 'events'
49
+ ? binUsersByEventCount(events, cohortEvent, bins)
50
+ : binByDistinctPeriods(events, cohortEvent, bins, 'day');
41
51
  if (!bin) return { bin: null, delta: 0 };
42
52
  const factor = multipliers[bin];
43
53
  if (typeof factor !== 'number' || factor === 1) return { bin, delta: 0 };
@@ -2,10 +2,11 @@
2
2
  * Pattern: Funnel Frequency Breakdown.
3
3
  *
4
4
  * Inside a `funnel-post` hook, vary the user's completion of the funnel by their
5
- * count of `cohortEvent` (anywhere in the dataset, not just the funnel). Used
6
- * when you want Mixpanel's funnel report broken down by per-user count of an
7
- * activity event to show e.g. "users who did 5+ X are 1.4x as likely to
8
- * complete this funnel."
5
+ * `cohortEvent` activity (anywhere in the dataset, not just the funnel)
6
+ * distinct calendar days by default (v1.6), or total events with
7
+ * `binBy: 'events'`. Used when you want Mixpanel's funnel report broken down
8
+ * by per-user frequency of an activity event — to show e.g. "users active 5+
9
+ * days with X are 1.4x as likely to complete this funnel."
9
10
  *
10
11
  * Mechanism: for users in a "drop-prone" bin, drop the funnel's final step
11
12
  * event(s) per the `dropMultipliers` config (1 = drop none, 0 = drop all).
@@ -15,13 +16,17 @@
15
16
  */
16
17
 
17
18
  import { binUsersByEventCount } from '../hook-helpers/cohort.js';
19
+ import { binByDistinctPeriods } from '../verify/counting.js';
18
20
  import { dropEventsWhere } from '../hook-helpers/mutate.js';
19
21
 
20
22
  /**
21
23
  * @param {Array<Object>} allUserEvents - Full per-user event history (read-only;
22
24
  * used to count `cohortEvent`). When called inside `funnel-post`, derive this
23
25
  * from `meta.profile` or pass the user's accumulated events from a closure.
24
- * When `null`, falls back to counting cohortEvent inside `funnelEvents`.
26
+ * When `null`, falls back to counting cohortEvent inside `funnelEvents` —
27
+ * note the fallback makes the default `'distinctDays'` axis degenerate (a
28
+ * single funnel run rarely spans more than one calendar day); pass
29
+ * `binBy: 'events'` when only funnel events are available.
25
30
  * @param {Object} _profile
26
31
  * @param {Array<Object>} funnelEvents - Funnel events produced by `makeFunnel`
27
32
  * (mutated in place).
@@ -32,15 +37,22 @@ import { dropEventsWhere } from '../hook-helpers/mutate.js';
32
37
  * for the FINAL step event. 1 = always keep, 0 = always drop.
33
38
  * @param {string} [opts.finalStep] - Event name of the final step. Defaults to the
34
39
  * last event in `funnelEvents` (in time order).
40
+ * @param {('events'|'distinctDays')} [opts.binBy='distinctDays'] - Cohort axis.
41
+ * `'distinctDays'` (default since v1.6) bins by distinct calendar days with
42
+ * `cohortEvent` via `binByDistinctPeriods` — the axis Mixpanel's funnel
43
+ * frequency breakdown and the local emulator use. `'events'` restores the
44
+ * pre-1.6 total-event-count axis.
35
45
  * @returns {{ bin: string|null, droppedFinal: boolean }}
36
46
  */
37
47
  export function applyFunnelFrequencyBreakdown(allUserEvents, _profile, funnelEvents, opts) {
38
- const { cohortEvent, bins, dropMultipliers, finalStep } = opts || {};
48
+ const { cohortEvent, bins, dropMultipliers, finalStep, binBy = 'distinctDays' } = opts || {};
39
49
  if (!funnelEvents || !cohortEvent || !bins || !dropMultipliers) {
40
50
  return { bin: null, droppedFinal: false };
41
51
  }
42
52
  const sourceForBin = allUserEvents || funnelEvents;
43
- const bin = binUsersByEventCount(sourceForBin, cohortEvent, bins);
53
+ const bin = binBy === 'events'
54
+ ? binUsersByEventCount(sourceForBin, cohortEvent, bins)
55
+ : binByDistinctPeriods(sourceForBin, cohortEvent, bins, 'day');
44
56
  if (!bin) return { bin: null, droppedFinal: false };
45
57
  const keepRate = dropMultipliers[bin];
46
58
  if (typeof keepRate !== 'number' || keepRate >= 1) return { bin, droppedFinal: false };
@@ -54,11 +66,11 @@ export function applyFunnelFrequencyBreakdown(allUserEvents, _profile, funnelEve
54
66
  }
55
67
  if (!stepName) return { bin, droppedFinal: false };
56
68
 
57
- // Coin-flip drop using a deterministic-ish heuristic this is called from a
58
- // non-RNG context (funnel-post), so use Math.random would break determinism.
59
- // Instead, use a hash on the funnel's first event's insert_id (deterministic
60
- // per-call) modulo 1000 / 1000 vs. (1 - keepRate). For simplicity we use
61
- // chance from utils when available.
69
+ // Deterministic drop decision without consuming the seeded RNG stream:
70
+ // hash the funnel's first insert_id (fallback: time) through
71
+ // simpleHashFloat FNV-1a over the full string, quantized to 1/1000 —
72
+ // and drop when the hash lands under (1 - keepRate). Per-funnel stable,
73
+ // so reruns with the same seed drop the same funnels.
62
74
  const dropProb = 1 - keepRate;
63
75
  const seed = funnelEvents[0] && (funnelEvents[0].insert_id || funnelEvents[0].time) || '';
64
76
  const det = simpleHashFloat(String(seed));
@@ -10,5 +10,5 @@
10
10
  export { applyFrequencyByFrequency } from './frequency-by-frequency.js';
11
11
  export { applyFunnelFrequencyBreakdown } from './funnel-frequency-breakdown.js';
12
12
  export { applyAggregateByBin } from './aggregate-per-user-by-bin.js';
13
- export { applyTTCBySegment } from './time-to-convert-by-segment.js';
13
+ export { applyTTCBySegment, applyTTCBySegmentV2 } from './time-to-convert-by-segment.js';
14
14
  export { applyAttributedBySource } from './attributed-by-source.js';
@@ -1,25 +1,33 @@
1
1
  /**
2
2
  * Pattern: Time to Convert, broken down by user segment.
3
3
  *
4
- * Inside a `funnel-post` hook, scale the funnel's time-to-convert by a factor
5
- * keyed off of a user-profile property value (e.g., trial users convert 3x
6
- * slower than enterprise). Lets Mixpanel's TTC funnel report — broken down by
7
- * a profile property — show a deliberate spread.
4
+ * Two generations:
8
5
  *
9
- * Mechanism: read `profile[segmentKey]`, look up the multiplier in `factors`,
10
- * and `scaleFunnelTTC(funnelEvents, factor)`. The first event's time is the
11
- * anchor (unchanged); subsequent steps' offsets from it are scaled.
6
+ * - `applyTTCBySegmentV2` (v1.6, use this) `everything` hook. Finds the
7
+ * user's FIRST in-order occurrence of the funnel steps across their whole
8
+ * stream and scales those gaps. This is the sequence Mixpanel's greedy
9
+ * funnel engine actually measures (HOOKS.md §2.2 / recipe 4.14), so the
10
+ * scaling shows up in the TTC report and in the local verifier.
12
11
  *
13
- * Caveat: Mixpanel's "Time to Convert" funnel report uses the time between the
14
- * FIRST event of step A and the FIRST event of step B per user, not the actual
15
- * gap inside any one funnel run. The scaled funnel run will reflect in TTC only
16
- * when this funnel is the user's first occurrence of those steps — which it is
17
- * for an `isFirstFunnel`. For usage funnels, document this caveat to authors.
12
+ * - `applyTTCBySegment` (deprecated) `funnel-post` hook. Scales one funnel
13
+ * run's internal gaps. Mixpanel's TTC uses the FIRST occurrence of each
14
+ * step per user, not the gaps inside any one run so for usage funnels
15
+ * (and any run that isn't the user's first) the scaling never reaches the
16
+ * report. Kept for compatibility; emits a one-time console warning.
18
17
  */
19
18
 
20
- import { scaleFunnelTTC } from '../hook-helpers/timing.js';
19
+ import { scaleFunnelTTC, findFirstSequence } from '../hook-helpers/timing.js';
20
+
21
+ let warnedDeprecated = false;
21
22
 
22
23
  /**
24
+ * @deprecated since v1.6 — use `applyTTCBySegmentV2` from an `everything`
25
+ * hook. This funnel-post variant scales gaps inside ONE funnel run, but
26
+ * Mixpanel's TTC report measures the FIRST occurrence of each step per user
27
+ * (greedy engine, HOOKS.md §2.2) — so unless the run happens to be the
28
+ * user's first occurrence of those steps (`isFirstFunnel`), the scaling
29
+ * never moves the report.
30
+ *
23
31
  * @param {Array<Object>} funnelEvents - Mutated in place.
24
32
  * @param {Object} profile - The user's profile (must contain `segmentKey`).
25
33
  * @param {Object} opts
@@ -28,6 +36,10 @@ import { scaleFunnelTTC } from '../hook-helpers/timing.js';
28
36
  * @returns {{ segmentValue: any, factor: number, shifted: number }}
29
37
  */
30
38
  export function applyTTCBySegment(funnelEvents, profile, { segmentKey, factors }) {
39
+ if (!warnedDeprecated) {
40
+ warnedDeprecated = true;
41
+ console.warn('[dungeon-master] applyTTCBySegment is deprecated — use applyTTCBySegmentV2 from an `everything` hook (funnel-post scaling only reaches Mixpanel TTC for isFirstFunnel runs). This warning fires once.');
42
+ }
31
43
  if (!funnelEvents || !funnelEvents.length || !profile || !segmentKey || !factors) {
32
44
  return { segmentValue: null, factor: 1, shifted: 0 };
33
45
  }
@@ -39,3 +51,53 @@ export function applyTTCBySegment(funnelEvents, profile, { segmentKey, factors }
39
51
  const shifted = scaleFunnelTTC(funnelEvents, factor);
40
52
  return { segmentValue, factor, shifted };
41
53
  }
54
+
55
+ /**
56
+ * Scale the user's funnel time-to-convert by a profile-segment factor —
57
+ * operating on the FIRST in-order occurrence of `steps` across the user's
58
+ * whole stream, which is what Mixpanel's greedy funnel engine measures
59
+ * (recipe 4.14). Call from the `everything` hook.
60
+ *
61
+ * Mechanism: `findFirstSequence(events, steps, maxGapMinutes)` locates the
62
+ * greedy first-occurrence chain; `scaleFunnelTTC` multiplies each matched
63
+ * step's offset from the first step by `factors[profile[segmentKey]]`. The
64
+ * anchor step's time is unchanged; only the matched step events move.
65
+ *
66
+ * Conversion-window caveat: a factor > 1 can push the final step past the
67
+ * funnel's `conversionWindowDays` cap, where Mixpanel's strict-`<` rule
68
+ * excludes the conversion entirely — clamp your factors the way recipe 4.28
69
+ * does when scaling up. Timestamp moves are session-safe in v1.6 (P2.1
70
+ * re-derives `session_id` after the everything hook).
71
+ *
72
+ * @param {Array<Object>} events - User's FULL event stream (mutated in place).
73
+ * @param {Object} profile - The user's profile (must contain `segmentKey`).
74
+ * @param {Object} opts
75
+ * @param {string} opts.segmentKey - Profile property name to look up.
76
+ * @param {Record<string, number>} opts.factors - Profile-value → TTC factor
77
+ * (0.5 halves the TTC, 2 doubles it; 1 or missing = no-op).
78
+ * @param {string[]} opts.steps - Ordered funnel step event names.
79
+ * @param {number} [opts.maxGapMinutes=43200] - Max gap between CONSECUTIVE
80
+ * matched steps, in minutes (default 30 days). NOTE this is a per-gap
81
+ * bound, NOT Mixpanel's conversion window: Mixpanel's window caps the
82
+ * TOTAL time from step 0 to the final step, so a k-step sequence matched
83
+ * here can span up to (k−1) × maxGapMinutes — well past a same-length
84
+ * conversion window. When targeting a funnel report, keep total intended
85
+ * TTC (gaps × factor) under the funnel's `conversionWindowDays`.
86
+ * @returns {{ segmentValue: any, factor: number, shifted: number }} `shifted`
87
+ * = number of step events whose timestamps moved (0 when the user has no
88
+ * qualifying sequence or the factor is a no-op).
89
+ */
90
+ export function applyTTCBySegmentV2(events, profile, { segmentKey, factors, steps, maxGapMinutes = 43200 }) {
91
+ if (!events || !events.length || !profile || !segmentKey || !factors || !Array.isArray(steps) || steps.length < 2) {
92
+ return { segmentValue: null, factor: 1, shifted: 0 };
93
+ }
94
+ const segmentValue = profile[segmentKey];
95
+ const factor = factors[segmentValue];
96
+ if (typeof factor !== 'number' || factor === 1) {
97
+ return { segmentValue, factor: 1, shifted: 0 };
98
+ }
99
+ const seq = findFirstSequence(events, steps, maxGapMinutes);
100
+ if (!seq) return { segmentValue, factor, shifted: 0 };
101
+ const shifted = scaleFunnelTTC(seq, factor);
102
+ return { segmentValue, factor, shifted };
103
+ }
@@ -459,6 +459,7 @@ export async function userLoop(context) {
459
459
 
460
460
  // ALL SUBSEQUENT EVENTS (funnels for converted users, standalone for all)
461
461
  let userChurned = false;
462
+ let userChurnTimeMs = null; // P2.2: timestamp of the churn event that ended the user
462
463
  const sessionTimeout = config.sessionTimeout || 30;
463
464
 
464
465
  // Standalone identity stamping mode: pre-existing or post-auth users get both
@@ -600,6 +601,8 @@ export async function userLoop(context) {
600
601
  const userReturns = returnLikelihood > 0 && chance.bool({ likelihood: returnLikelihood * 100 });
601
602
  if (!userReturns) {
602
603
  userChurned = true;
604
+ const t = typeof ev.time === 'string' ? Date.parse(ev.time) : Number(ev.time);
605
+ if (Number.isFinite(t)) userChurnTimeMs = t;
603
606
  break;
604
607
  }
605
608
  }
@@ -611,6 +614,20 @@ export async function userLoop(context) {
611
614
  // Remove events flagged as future timestamps (before dungeon hooks see them)
612
615
  usersEvents = usersEvents.filter(e => !e._drop);
613
616
 
617
+ // v1.6 (P2.2): churn is a hard activity boundary. Breaking the budget
618
+ // loop stopped GENERATION, but already-generated events carry
619
+ // independent TimeSoup timestamps over a shuffled active-day plan — so
620
+ // churned users kept events dated after their churn. Filter to
621
+ // time <= churnTime (the churn event itself survives: its own
622
+ // timestamp satisfies <=).
623
+ if (userChurned && userChurnTimeMs !== null) {
624
+ usersEvents = usersEvents.filter(e => {
625
+ if (!e || !e.time) return true;
626
+ const t = typeof e.time === 'string' ? Date.parse(e.time) : Number(e.time);
627
+ return Number.isFinite(t) ? t <= userChurnTimeMs : true;
628
+ });
629
+ }
630
+
614
631
  // Feature 3: Engagement decay — filter behavioral events
615
632
  const userDecay = persona?.engagementDecay || globalEngagementDecay;
616
633
  if (userDecay && userDecay.model !== 'none' && usersEvents.length > 0) {
@@ -751,6 +768,30 @@ export async function userLoop(context) {
751
768
  console.warn(`⚠️ Dropped ${droppedFuture} future-dated event(s) for user ${distinct_id}`);
752
769
  }
753
770
 
771
+ // v1.6 (P2.1): re-derive session_ids on the FINAL event set. The first
772
+ // assignSessionIds pass (above) runs before the everything hook so hooks
773
+ // can read session_id — but time-mutating hooks (TTC scaling, injected
774
+ // bursts) leave stale labels that disagree with what Mixpanel computes
775
+ // from timestamps at query time. Relabeling is deterministic: IDs hash
776
+ // from (userKey + first event time of the session), so sessions whose
777
+ // events didn't move keep their exact IDs. The per-session sticky-device
778
+ // rewrite is intentionally NOT re-run — device stamping is hook-visible
779
+ // identity state, and relabeling sessions must not mutate identity fields.
780
+ if (hasSessionIds && usersEvents.length > 0) {
781
+ // assignSessionIds requires ascending time order. Relabel over a
782
+ // sorted shallow copy so `autoSortAfterEverything: false` users keep
783
+ // their chosen array order — stamps land on the shared event objects.
784
+ const sortedView = [...usersEvents].sort((a, b) => {
785
+ const ta = (a && typeof a.time === 'string') ? Date.parse(a.time) : Number(a && a.time);
786
+ const tb = (b && typeof b.time === 'string') ? Date.parse(b.time) : Number(b && b.time);
787
+ if (!Number.isFinite(ta) && !Number.isFinite(tb)) return 0;
788
+ if (!Number.isFinite(ta)) return 1;
789
+ if (!Number.isFinite(tb)) return -1;
790
+ return ta - tb;
791
+ });
792
+ u.assignSessionIds(sortedView, sessionTimeout);
793
+ }
794
+
754
795
  // Store all user data (skip profile push when a hook returned null
755
796
  // for type='user' — see dropUserProfile above).
756
797
  if (!dropUserProfile) {
@@ -0,0 +1,175 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://github.com/ak--47/dungeon-master/lib/templates/story-spec.schema.json",
4
+ "title": "Dungeon stories export (v1.6 story-spec)",
5
+ "description": "Schema for the `stories` named export of a JS dungeon file. Stories pin each engineered hook effect to a mechanical assertion over emulateBreakdown output (or a DuckDB SQL escape hatch), so verification is computed — not re-derived by an LLM. Verdict tiers per assertion: NAILED = observed within ±10% of target; STRONG = passes floor (or target when no floor); WEAK = fails floor but effect direction correct, or cohort < minCohort; NONE = no measurable effect / selection empty; INVERSE = effect direction opposite the assertion. Story verdict = worst assertion.",
6
+ "type": "array",
7
+ "minItems": 1,
8
+ "items": { "$ref": "#/definitions/story" },
9
+ "definitions": {
10
+ "story": {
11
+ "type": "object",
12
+ "required": ["id", "hook", "archetype", "narrative", "assertions"],
13
+ "additionalProperties": false,
14
+ "properties": {
15
+ "id": {
16
+ "type": "string",
17
+ "minLength": 1,
18
+ "description": "Unique story id, conventionally '<hook>-<slug>' (e.g. 'H3-fraud-bursts')."
19
+ },
20
+ "hook": {
21
+ "type": "string",
22
+ "pattern": "^(?:H|Hook\\s*)?\\d+$",
23
+ "description": "Numbered hook this story verifies — matches the dungeon's HOOK STORIES doc block ('H3' or 'Hook 3' styles)."
24
+ },
25
+ "archetype": {
26
+ "type": "string",
27
+ "enum": [
28
+ "cohort-count-scale",
29
+ "cohort-prop-scale",
30
+ "temporal-inflection",
31
+ "funnel-conversion-by-segment",
32
+ "funnel-ttc-by-segment",
33
+ "retention-divergence",
34
+ "frequency-sweet-spot",
35
+ "attribution-bias",
36
+ "experiment-lift",
37
+ "lifecycle-wave",
38
+ "path-share",
39
+ "session-shape",
40
+ "composition-drift",
41
+ "bespoke"
42
+ ],
43
+ "description": "Closed enum of engineered-effect shapes, seeded from the corpus's real story types."
44
+ },
45
+ "narrative": {
46
+ "type": "string",
47
+ "minLength": 1,
48
+ "description": "One-to-three sentence human story the data tells."
49
+ },
50
+ "mixpanelReport": {
51
+ "type": "object",
52
+ "description": "Free-form report pointer for humans and /create-project — NOT interpreted by the runner."
53
+ },
54
+ "assertions": {
55
+ "type": "array",
56
+ "minItems": 1,
57
+ "items": { "$ref": "#/definitions/assertion" }
58
+ },
59
+ "intentionalDeviations": {
60
+ "type": "array",
61
+ "items": { "type": "string" },
62
+ "description": "Documented strict-bar / limitation notes (e.g. 'tail_ratio < 0.4 by design — churn story')."
63
+ }
64
+ }
65
+ },
66
+ "assertion": {
67
+ "type": "object",
68
+ "required": ["breakdown"],
69
+ "additionalProperties": false,
70
+ "anyOf": [
71
+ { "required": ["expect"] },
72
+ { "required": ["assert"] }
73
+ ],
74
+ "properties": {
75
+ "breakdown": { "$ref": "#/definitions/breakdown" },
76
+ "select": {
77
+ "type": "object",
78
+ "minProperties": 1,
79
+ "description": "Named row-sets over the breakdown result rows. Keys are the names referenced by expect.metric.",
80
+ "propertyNames": { "pattern": "^[A-Za-z_$][A-Za-z0-9_$]*$" },
81
+ "additionalProperties": {
82
+ "type": "object",
83
+ "required": ["where"],
84
+ "additionalProperties": false,
85
+ "properties": {
86
+ "where": {
87
+ "type": "object",
88
+ "minProperties": 1,
89
+ "description": "Column → value (equality) or { op, value } comparison. All clauses must match (AND).",
90
+ "additionalProperties": {
91
+ "oneOf": [
92
+ { "type": ["string", "number", "boolean", "null"] },
93
+ {
94
+ "type": "object",
95
+ "required": ["op", "value"],
96
+ "additionalProperties": false,
97
+ "properties": {
98
+ "op": { "type": "string", "enum": ["==", "!=", ">=", "<=", ">", "<"] },
99
+ "value": { "type": ["string", "number", "boolean", "null"] }
100
+ }
101
+ }
102
+ ]
103
+ }
104
+ }
105
+ }
106
+ }
107
+ },
108
+ "expect": { "$ref": "#/definitions/expect" },
109
+ "assert": {
110
+ "description": "JS-only escape hatch: (rows, ctx) => { pass, detail?, verdict? }. Function-valued — JSON Schema cannot validate it; every use requires a comment justifying why the declarative grammar can't express it. JSON dungeons cannot carry it (dungeon-to-json drops extra exports).",
111
+ "not": { "type": ["string", "number", "boolean", "array", "null"] }
112
+ },
113
+ "minCohort": {
114
+ "type": "number",
115
+ "exclusiveMinimum": 0,
116
+ "description": "Population floor — when any named selection's cohort is below this, the verdict caps at WEAK."
117
+ }
118
+ }
119
+ },
120
+ "breakdown": {
121
+ "type": "object",
122
+ "required": ["type"],
123
+ "properties": {
124
+ "type": { "type": "string", "minLength": 1 }
125
+ },
126
+ "if": {
127
+ "properties": { "type": { "const": "duckdb" } }
128
+ },
129
+ "then": {
130
+ "required": ["type", "sql"],
131
+ "properties": {
132
+ "type": { "const": "duckdb" },
133
+ "sql": {
134
+ "type": "string",
135
+ "minLength": 1,
136
+ "description": "DuckDB SQL escape hatch, for bespoke shapes only. The runner shells out to the `duckdb` CLI (no npm dep) and substitutes the literal token {{PREFIX}} with the run's data prefix path (e.g. data/verify-<name>), so globs read read_json_auto('{{PREFIX}}-EVENTS*.json'). Result rows feed select/expect like emulator rows. Disk mode only — skipped (with a warning) under --in-memory."
137
+ }
138
+ }
139
+ },
140
+ "else": {
141
+ "description": "Anything other than 'duckdb' is passed byte-compatible to emulateBreakdown / verifyDungeon (frequencyByFrequency, funnelFrequency, aggregatePerUser, timeToConvert, attributedBy, sessionMetrics, retention, distinctCount, eventBreakdown, uniques, lifecycle, topPaths)."
142
+ }
143
+ },
144
+ "expect": {
145
+ "type": "object",
146
+ "required": ["metric", "op", "target"],
147
+ "additionalProperties": false,
148
+ "properties": {
149
+ "metric": {
150
+ "type": "string",
151
+ "pattern": "^\\s*([A-Za-z_$][A-Za-z0-9_$]*\\.[A-Za-z_$][A-Za-z0-9_$]*|[0-9]+(?:\\.[0-9]+)?)(\\s*[/\\-]\\s*([A-Za-z_$][A-Za-z0-9_$]*\\.[A-Za-z_$][A-Za-z0-9_$]*|[0-9]+(?:\\.[0-9]+)?))?\\s*$",
152
+ "description": "Exactly three forms: single ref '<name>.<column>', ratio '<a>.<col> / <b>.<col>', or difference '<a>.<col> - <b>.<col>'. At most one binary operator; operands are refs or numeric literals (at least one ref — runtime-enforced). If a selection matches multiple rows, count-like columns sum and value-like columns error (single-row selection enforced). No free-form expressions."
153
+ },
154
+ "op": { "type": "string", "enum": [">=", "<=", ">", "<", "between"] },
155
+ "target": {
156
+ "oneOf": [
157
+ { "type": "number" },
158
+ {
159
+ "type": "array",
160
+ "items": { "type": "number" },
161
+ "minItems": 2,
162
+ "maxItems": 2,
163
+ "description": "[lo, hi] — only valid with op 'between'."
164
+ }
165
+ ],
166
+ "description": "The designed value — derive it from the same exported knob constants the hook uses so the assertion cannot drift from the mechanism."
167
+ },
168
+ "floor": {
169
+ "type": "number",
170
+ "description": "Optional STRONG bound — the minimum acceptable effect. Omitted → target doubles as the floor."
171
+ }
172
+ }
173
+ }
174
+ }
175
+ }
@@ -7,7 +7,7 @@ import { validateFunctionCall } from './function-registry.js';
7
7
 
8
8
  /**
9
9
  * Evaluate a value that might contain function calls
10
- * @param {any} value - The value to evaluate (could be object with functionName, array, or primitive)
10
+ * @param {unknown} value - The value to evaluate (could be object with functionName, array, or primitive)
11
11
  * @returns {string} - JavaScript code string
12
12
  */
13
13
  export function evaluateValue(value) {