@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
@@ -0,0 +1,236 @@
1
+ -- ============================================================
2
+ -- travel.js — v1.6 human-inspection queries (DuckDB)
3
+ --
4
+ -- Every query is keyed to a story id in travel.js's `stories` export;
5
+ -- the machine-checked verdicts come from:
6
+ -- node scripts/verify-stories.mjs dungeons/vertical/travel/travel.js --data-prefix verify-travel
7
+ -- Generate first:
8
+ -- node scripts/verify-runner.mjs dungeons/vertical/travel/travel.js verify-travel
9
+ -- Run this file:
10
+ -- duckdb -c ".read dungeons/vertical/travel/travel.sql"
11
+ -- ============================================================
12
+
13
+ -- ── identity-resolution prelude ─────────────────────────────
14
+ -- avgDevicePerUser: 2 + account created is both isAuthEvent and
15
+ -- isFirstEvent, so born users auth on their first event; the device-pool
16
+ -- resolve is belt-and-braces for any device-only edge.
17
+ CREATE OR REPLACE VIEW users AS
18
+ SELECT * FROM read_json_auto('data/verify-travel-USERS*.json', sample_size=-1, union_by_name=true);
19
+
20
+ CREATE OR REPLACE VIEW device_map AS
21
+ -- profiles store the device pool under the legacy "anonymousIds" key
22
+ SELECT unnest("anonymousIds") AS device_id, distinct_id FROM users;
23
+
24
+ CREATE OR REPLACE VIEW ev AS
25
+ -- ::VARCHAR casts — user_id sniffs as UUID, device_id as VARCHAR; DuckDB
26
+ -- refuses to coalesce mixed types
27
+ SELECT coalesce(m.distinct_id::VARCHAR, e.user_id::VARCHAR, e.device_id::VARCHAR) AS uid,
28
+ e.time::TIMESTAMP AS t,
29
+ e.*
30
+ FROM read_json_auto('data/verify-travel-EVENTS*.json', sample_size=-1, union_by_name=true) e
31
+ LEFT JOIN device_map m ON e.device_id = m.device_id;
32
+
33
+ -- Per-user counts. Bookings are only ever DELETED post-generation (H8
34
+ -- all-or-nothing per user, H10 post-day-60, future-time guard), so
35
+ -- booking-count cohorts are ONE-SIDED: output >= threshold implies
36
+ -- hook-time >= threshold. hotel viewed / searches / sessions / reviews /
37
+ -- upgrades are never deleted — exact hook-time recovery.
38
+ CREATE OR REPLACE VIEW per_user AS
39
+ SELECT uid,
40
+ count(*) FILTER (WHERE event = 'hotel viewed') AS hv,
41
+ count(*) FILTER (WHERE event = 'booking completed') AS bookings,
42
+ count(*) FILTER (WHERE event = 'destination searched') AS searches,
43
+ count(*) FILTER (WHERE event = 'app session') AS sessions,
44
+ count(*) FILTER (WHERE event = 'room upgrade selected') AS upgrades,
45
+ count(*) FILTER (WHERE event = 'review submitted') AS reviews
46
+ FROM ev GROUP BY 1;
47
+
48
+ CREATE OR REPLACE VIEW first_ev AS
49
+ SELECT uid, min(t) AS f FROM ev GROUP BY 1;
50
+
51
+ -- Calendar landmarks (dataset 2026-01-01 → 2026-05-01 23:59:59 UTC):
52
+ -- advance-stamped region: t < '2026-04-08 23:59:59' (H2, >21d before end + margin)
53
+ -- organic middle band: t in ['2026-04-11 23:59:59', '2026-04-27 23:59:59']
54
+ -- last_minute-stamped region: t >= '2026-04-29 00:59:59' (<3d before end + margin)
55
+ -- H10 fatigue cutoff (day 60): t >= '2026-03-02 00:00:00'
56
+
57
+
58
+ -- ── H1-weekend-rate-surge ───────────────────────────────────
59
+ -- Fri/Sat/Sun 'booking completed' nightly_rate ×1.3 (floored). 'hotel
60
+ -- viewed' carries the same nightly_rate pool untouched — the placebo arm.
61
+ -- dayofweek(): Sunday = 0 (matches JS getUTCDay).
62
+ SELECT event,
63
+ CASE WHEN dayofweek(t) IN (0, 5, 6) THEN 'wkn' ELSE 'wkd' END AS bucket,
64
+ count(*) AS n, round(avg(nightly_rate), 1) AS avg_rate
65
+ FROM ev WHERE event IN ('booking completed', 'hotel viewed') AND nightly_rate IS NOT NULL
66
+ GROUP BY 1, 2 ORDER BY 1, 2;
67
+ -- read: booking wkn/wkd ≈ 1.3; hotel viewed ≈ 1.0
68
+
69
+
70
+ -- ── H2-booking-window ───────────────────────────────────────
71
+ -- H2 stamps by calendar distance to dataset end: advance (>21d, rate ×0.8),
72
+ -- last_minute (<3d, rate ×1.4). The 3-21d middle band keeps ORGANIC labels
73
+ -- (2/5 advance, 2/5 standard, 1/5 last_minute) and untouched rates — a
74
+ -- label breakdown DILUTES both treated labels; read by calendar REGION
75
+ -- with the middle band as baseline instead.
76
+ SELECT CASE WHEN t < TIMESTAMP '2026-04-08 23:59:59' THEN 'adv'
77
+ WHEN t >= TIMESTAMP '2026-04-29 00:59:59' THEN 'lm'
78
+ WHEN t >= TIMESTAMP '2026-04-11 23:59:59' AND t <= TIMESTAMP '2026-04-27 23:59:59' THEN 'mid' END AS region,
79
+ count(*) AS n, round(avg(nightly_rate), 1) AS avg_rate,
80
+ round(count(*) FILTER (WHERE booking_window = 'advance')::DOUBLE / count(*), 4) AS adv_share,
81
+ round(count(*) FILTER (WHERE booking_window = 'last_minute')::DOUBLE / count(*), 4) AS lm_share
82
+ FROM ev WHERE event = 'booking completed'
83
+ GROUP BY 1 ORDER BY 1;
84
+ -- read: adv/mid avg_rate ≈ 0.80 (weekend-mix corrected); lm/mid ≈ 1.36;
85
+ -- stamped regions label-pure (adv_share / lm_share = 1.0), middle
86
+ -- band keeps organic adv_share ≈ 0.4
87
+
88
+
89
+ -- ── H3-loyalty-boost ────────────────────────────────────────
90
+ -- >=5 bookings at hook time → loyalty_points ×(2.5 + U[0,1]) floored,
91
+ -- E[mult] = 3.0. Output >=5 IMPLIES treatment (deletions-only); control
92
+ -- 1-4 bookings AND hv <= 10 (H10 never drops their bookings).
93
+ SELECT CASE WHEN p.bookings >= 5 THEN 'big'
94
+ WHEN p.bookings BETWEEN 1 AND 4 AND p.hv <= 10 THEN 'small' END AS arm,
95
+ count(DISTINCT p.uid) AS users, round(avg(e.loyalty_points), 2) AS avg_lp
96
+ FROM per_user p JOIN ev e ON e.uid = p.uid AND e.event = 'booking completed'
97
+ WHERE p.bookings >= 5 OR (p.bookings BETWEEN 1 AND 4 AND p.hv <= 10)
98
+ GROUP BY 1 ORDER BY 1;
99
+ -- read: big/small avg_lp ≈ 3.0
100
+
101
+
102
+ -- ── H4-cancel-by-window ─────────────────────────────────────
103
+ -- Each 'booking cancelled' is stamped with the booking_window of the
104
+ -- user's nearest PRECEDING booking, then 60% of last_minute-stamped
105
+ -- cancels are dropped. Replicate the matching with an ASOF JOIN; restrict
106
+ -- to matched bookings in the organic middle band (labels iid 0.4/0.4/0.2)
107
+ -- for hv <= 10 users (their output booking set = hook-time set).
108
+ WITH bk AS (SELECT uid, t, booking_window FROM ev WHERE event = 'booking completed'),
109
+ cn AS (
110
+ SELECT e.uid, e.t, e.booking_window AS stamped
111
+ FROM ev e JOIN per_user p ON p.uid = e.uid AND p.hv <= 10
112
+ WHERE e.event = 'booking cancelled'
113
+ ),
114
+ m AS (
115
+ SELECT cn.uid, cn.stamped, bk.booking_window AS matched, bk.t AS bt
116
+ FROM cn ASOF JOIN bk ON cn.uid = bk.uid AND cn.t >= bk.t
117
+ )
118
+ SELECT matched AS label, count(*) AS n,
119
+ round(count(*) FILTER (WHERE stamped = matched)::DOUBLE / count(*), 4) AS agree
120
+ FROM m
121
+ WHERE bt >= TIMESTAMP '2026-04-11 23:59:59' AND bt <= TIMESTAMP '2026-04-27 23:59:59'
122
+ GROUP BY 1 ORDER BY 1;
123
+ -- read: keep = (lm_n / std_n) / 0.5 ≈ 0.4 (the 40% keep rate);
124
+ -- adv_n / std_n ≈ 1.0 (placebo); agree ≈ 1.0 (ASOF replicates hook)
125
+
126
+
127
+ -- ── H5-luxury-upsell ────────────────────────────────────────
128
+ -- luxury_seeker with an upgrade template: 50% cloned 'room upgrade
129
+ -- selected' per booking → +0.5 upgrades-per-booking ADDITIVE. Both arms
130
+ -- restricted to template owners and PRE-day-60 counts (H10's post-day-60
131
+ -- booking drop would shrink over-viewers' denominators).
132
+ SELECT u.customer_segment AS seg, count(DISTINCT pr.uid) AS users,
133
+ round(sum(pr.upgrades_pre)::DOUBLE / sum(pr.bookings_pre), 4) AS upgrades_per_booking
134
+ FROM (
135
+ SELECT uid,
136
+ count(*) FILTER (WHERE event = 'booking completed' AND t < TIMESTAMP '2026-03-02') AS bookings_pre,
137
+ count(*) FILTER (WHERE event = 'room upgrade selected' AND t < TIMESTAMP '2026-03-02') AS upgrades_pre
138
+ FROM ev GROUP BY 1
139
+ ) pr
140
+ JOIN per_user p ON p.uid = pr.uid AND p.upgrades >= 1
141
+ JOIN users u ON u.distinct_id::VARCHAR = pr.uid
142
+ WHERE u.customer_segment IN ('luxury_seeker', 'budget_hunter') AND pr.bookings_pre >= 1
143
+ GROUP BY 1 ORDER BY 1;
144
+ -- read: lux − budget ≈ +0.5 (plus small positive organic funnel-gap confound)
145
+
146
+ -- doc-level Insights view: upgrades per user across ALL users
147
+ SELECT u.customer_segment AS seg, count(*) AS users,
148
+ round(avg(coalesce(p.upgrades, 0)), 3) AS avg_upgrades
149
+ FROM users u LEFT JOIN per_user p ON p.uid = u.distinct_id::VARCHAR
150
+ WHERE u.customer_segment IN ('luxury_seeker', 'budget_hunter')
151
+ GROUP BY 1 ORDER BY 1;
152
+ -- read: lux/budget ≈ 12x — the +0.5 additive clone term dominates the thin
153
+ -- organic upsell base (~0.08 upb); the knob-clean read is the
154
+ -- upgrades-per-booking DIFFERENCE above
155
+
156
+
157
+ -- ── H6-review-quality ───────────────────────────────────────
158
+ -- Per-user avg stay_rating >= 4 → review_length ×1.5; <= 2 → ×0.5.
159
+ -- Reviews are never deleted/cloned → output avg EXACTLY reproduces the
160
+ -- hook-time classification (arm = the treatment variable itself).
161
+ WITH rv AS (SELECT uid, avg(stay_rating) AS avg_r FROM ev WHERE event = 'review submitted' GROUP BY 1)
162
+ SELECT CASE WHEN rv.avg_r >= 4 THEN 'high' WHEN rv.avg_r <= 2 THEN 'low' ELSE 'mid' END AS arm,
163
+ count(DISTINCT rv.uid) AS users, round(avg(e.review_length), 1) AS avg_len
164
+ FROM rv JOIN ev e ON e.uid = rv.uid AND e.event = 'review submitted'
165
+ GROUP BY 1 ORDER BY 1;
166
+ -- read: high/mid ≈ 1.5; low/mid ≈ 0.5
167
+
168
+
169
+ -- ── H7-business-profile ─────────────────────────────────────
170
+ -- user hook overwrites per segment: business gets company_name (never
171
+ -- 'none') + travel_frequency 'weekly'; luxury avg_budget U-int[250,500];
172
+ -- budget U-int[50,120]; everyone else keeps company_name 'none'.
173
+ SELECT customer_segment AS seg, count(*) AS users,
174
+ round(avg((travel_frequency = 'weekly')::INT), 4) AS weekly_share,
175
+ round(avg((company_name IS NOT NULL AND company_name <> 'none')::INT), 4) AS company_share,
176
+ min(avg_budget_per_night) AS min_budget, max(avg_budget_per_night) AS max_budget
177
+ FROM users GROUP BY 1 ORDER BY 1;
178
+ -- read: biz weekly_share = company_share = 1.0; others company_share = 0;
179
+ -- lux budget within [250, 500]; budget_hunter within [50, 120]
180
+
181
+
182
+ -- ── H8-casual-booking-drop ──────────────────────────────────
183
+ -- leisure_family/budget_hunter: 25% per-user chance ALL bookings spliced
184
+ -- out. Among users with >= 10 searches (exact activity floor), treated
185
+ -- zero-booking share inflates by ~0.25 plus the organic conversion gap.
186
+ SELECT CASE WHEN u.customer_segment IN ('leisure_family', 'budget_hunter') THEN 'treated' ELSE 'control' END AS arm,
187
+ count(*) AS users, round(avg((p.bookings = 0)::INT), 4) AS zero_booking_share
188
+ FROM per_user p JOIN users u ON u.distinct_id::VARCHAR = p.uid
189
+ WHERE p.searches >= 10
190
+ GROUP BY 1 ORDER BY 1;
191
+ -- read: treated − control ≈ +0.22-0.30; control ≈ <= 0.12
192
+
193
+
194
+ -- ── H9-booking-ttc ──────────────────────────────────────────
195
+ -- funnel-post scales Search to Book gaps: business ×0.74, budget/leisure
196
+ -- ×1.25, luxury untouched (v1.6 scopes the hook to Search to Book only).
197
+ -- CAUTION: cross-event TTC SQL here is the documented greedy-single-pass
198
+ -- limitation — it pairs searches/bookings across funnel instances and
199
+ -- buries the signal. The story asserts TTC through the Mixpanel-aligned
200
+ -- emulator (timeToConvert, 60h window = 48h generative × 1.25 max
201
+ -- stretch); trust the story verdict, not ad-hoc pair SQL.
202
+ SELECT u.customer_segment AS seg, count(*) AS booking_events
203
+ FROM ev e JOIN users u ON u.distinct_id::VARCHAR = e.uid
204
+ WHERE e.event = 'booking completed' GROUP BY 1 ORDER BY 1;
205
+
206
+
207
+ -- ── H10-hotel-view-magic ────────────────────────────────────
208
+ -- Sweet spot 5-10 hotel views → nightly_rate ×1.3 on all bookings; 11+
209
+ -- views → 35% of bookings dropped on/after day 60 (2026-03-02). Value
210
+ -- read restricted to the ADVANCE region where H2's ×0.8 is constant on
211
+ -- both arms and cancels.
212
+ SELECT CASE WHEN p.hv BETWEEN 5 AND 10 THEN 'sweet' WHEN p.hv < 5 THEN 'low' END AS arm,
213
+ count(DISTINCT p.uid) AS users, round(avg(e.nightly_rate), 1) AS avg_rate
214
+ FROM per_user p JOIN ev e ON e.uid = p.uid AND e.event = 'booking completed'
215
+ WHERE (p.hv <= 10) AND e.t < TIMESTAMP '2026-04-08 23:59:59'
216
+ GROUP BY 1 ORDER BY 1;
217
+ -- read: sweet/low avg_rate ≈ 1.3
218
+
219
+ -- volume read: hotel-view count is intrinsically coupled to activity, so
220
+ -- NO cross-arm level comparison works. The day-60 calendar edge makes it
221
+ -- a difference-in-differences: each arm's own after/before
222
+ -- bookings-per-session ratio cancels its activity composition; worldEvents
223
+ -- (summer sale ×2, hurricane ×0.2) are arm-invariant and cancel cross-arm.
224
+ -- Users born before day 45 only, so both arms have real before-exposure.
225
+ SELECT CASE WHEN p.hv BETWEEN 5 AND 10 THEN 'sweet' WHEN p.hv >= 11 THEN 'over' END AS arm,
226
+ CASE WHEN e.t >= TIMESTAMP '2026-03-02' THEN 'after' ELSE 'before' END AS period,
227
+ count(DISTINCT p.uid) AS users,
228
+ count(*) FILTER (WHERE e.event = 'booking completed') AS bookings,
229
+ count(*) FILTER (WHERE e.event = 'app session') AS sessions
230
+ FROM per_user p
231
+ JOIN first_ev fe ON fe.uid = p.uid AND fe.f < TIMESTAMP '2026-02-15'
232
+ JOIN ev e ON e.uid = p.uid
233
+ WHERE p.hv >= 5
234
+ GROUP BY 1, 2 ORDER BY 1, 2;
235
+ -- read: DiD = (over after/before bookings-per-session) ÷ (sweet after/before)
236
+ -- ≈ 0.65 (the keep rate)
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * travel.verify.mjs — thin wrapper around the story runner.
4
+ *
5
+ * All verification logic lives in the `stories` export of ./travel.js;
6
+ * this script just streams the shards and delegates. It is equivalent to:
7
+ *
8
+ * node scripts/verify-stories.mjs dungeons/vertical/travel/travel.js --data-prefix verify-travel
9
+ *
10
+ * Generate first:
11
+ * node scripts/verify-runner.mjs dungeons/vertical/travel/travel.js verify-travel
12
+ * Run:
13
+ * node dungeons/vertical/travel/travel.verify.mjs [prefix] # default verify-travel
14
+ */
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import readline from 'node:readline';
18
+ import { execFile } from 'node:child_process';
19
+ import { promisify } from 'node:util';
20
+ import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
+ import config, { stories } from './travel.js';
22
+
23
+ const PREFIX = process.argv[2] || 'verify-travel';
24
+ const prefixPath = path.join('data', PREFIX);
25
+
26
+ async function loadShards(suffix) {
27
+ // streaming load: full-fidelity EVENTS shards exceed the readFileSync cap
28
+ const dir = path.dirname(prefixPath), base = path.basename(prefixPath);
29
+ const out = [];
30
+ if (!fs.existsSync(dir)) return out;
31
+ for (const f of fs.readdirSync(dir).filter(f => f.startsWith(`${base}-${suffix}`) && f.endsWith('.json')).sort()) {
32
+ const rl = readline.createInterface({ input: fs.createReadStream(path.join(dir, f)), crlfDelay: Infinity });
33
+ for await (const line of rl) {
34
+ if (line.trim()) out.push(JSON.parse(line));
35
+ }
36
+ }
37
+ return out;
38
+ }
39
+
40
+ const events = await loadShards('EVENTS');
41
+ const profiles = await loadShards('USERS');
42
+ if (!events.length) {
43
+ console.error(`no shards at ${prefixPath}-EVENTS*.json — run: node scripts/verify-runner.mjs dungeons/vertical/travel/travel.js ${PREFIX}`);
44
+ process.exit(1);
45
+ }
46
+ console.log(`travel — events=${events.length} users=${profiles.length} (${prefixPath})`);
47
+
48
+ const execFileP = promisify(execFile);
49
+ const runSql = async (sql) => {
50
+ const { stdout } = await execFileP('duckdb', ['-json', '-c', sql.replaceAll('{{PREFIX}}', prefixPath)], { maxBuffer: 64 * 1024 * 1024 });
51
+ return stdout.trim() ? JSON.parse(stdout) : [];
52
+ };
53
+
54
+ // funnels passed raw (unvalidated) — the H8/H9 emulator stories carry their
55
+ // own explicit conversion windows (72h Onboarding; 48h Search to Book
56
+ // generative window × the 1.25 budget/leisure stretch = 60h, covering the
57
+ // stretched support), so no funnel-default threading is needed
58
+ const results = await evaluateStories(stories, events, {
59
+ profiles,
60
+ funnels: config.funnels,
61
+ identityMap: buildIdentityMap(profiles),
62
+ runSql,
63
+ });
64
+
65
+ let worst = 'NAILED';
66
+ for (const r of results) {
67
+ console.log(`${r.verdict.padEnd(7)} ${r.id}`);
68
+ for (const a of r.assertions) console.log(` ${a.verdict.padEnd(7)} ${a.detail}`);
69
+ if (VERDICT_RANK[r.verdict] < VERDICT_RANK[worst]) worst = r.verdict;
70
+ }
71
+ console.log(`\nworst verdict: ${worst}`);
72
+ process.exit(VERDICT_RANK[worst] >= VERDICT_RANK.STRONG ? 0 : 1);
package/index.js CHANGED
@@ -27,7 +27,7 @@ import { makeMirror } from './lib/generators/mirror.js';
27
27
  import { makeGroupProfile, makeProfile } from './lib/generators/profiles.js';
28
28
 
29
29
  // Utilities
30
- import { initChance, initUserChance, resetUserChance, setDatasetNow, setDatasetBegin, deleteFile } from './lib/utils/utils.js';
30
+ import { initChance, initUserChance, resetUserChance, resetValueCaches, setDatasetNow, setDatasetBegin, deleteFile } from './lib/utils/utils.js';
31
31
  import { runWithDataset } from './lib/utils/dataset-context.js';
32
32
 
33
33
  // External dependencies
@@ -68,7 +68,7 @@ dayjs.extend(utc);
68
68
  *
69
69
  * @example
70
70
  * // multiple dungeons
71
- * const results = await DUNGEON_MASTER(['./dungeons/vertical/gaming.js', './dungeons/vertical/media.js']);
71
+ * const results = await DUNGEON_MASTER(['./dungeons/vertical/gaming/gaming.js', './dungeons/vertical/media/media.js']);
72
72
  *
73
73
  * @example
74
74
  * // raw JS text
@@ -130,6 +130,10 @@ async function runDungeon(config) {
130
130
  // Initialize seeded RNG BEFORE validation — config-validator captures a
131
131
  // chance reference for default userProps (spiritAnimal). If we init after,
132
132
  // run 1 binds an unseeded instance while run 2 binds a stale one → non-deterministic.
133
+ // v1.6.1: clear per-run winner/weighted-array caches unconditionally —
134
+ // a prior in-process run must never leak its winners into this one.
135
+ // (initChance also clears them, but only fires when a seed is set.)
136
+ resetValueCaches();
133
137
  if (config.seed) {
134
138
  initChance(config.seed);
135
139
  }
@@ -568,7 +572,7 @@ function extractFileInfo(storage) {
568
572
  /**
569
573
  * Count profiles that would be pushed to Mixpanel (anonymous non-converters carry
570
574
  * `_drop: true` and are skipped by mixpanel-sender). v1.5.1.
571
- * @param {any} profilesContainer
575
+ * @param {Iterable<{ _drop?: boolean }> | null | undefined} profilesContainer
572
576
  * @returns {number}
573
577
  */
574
578
  function countProfilesPushed(profilesContainer) {
@@ -378,7 +378,10 @@ function normalizeExperiments(funnels, datasetEndUnix) {
378
378
  const startDays = Number.isFinite(raw.startDaysBeforeEnd) && raw.startDaysBeforeEnd > 0
379
379
  ? raw.startDaysBeforeEnd : 0;
380
380
  const startUnix = startDays > 0 ? datasetEndUnix - startDays * 86400 : null;
381
- f._experiment = { name, variants, startUnix };
381
+ // Sticky bucketing defaults true the per-user hash was the only pre-1.6
382
+ // behavior, so existing dungeons stay byte-identical.
383
+ const sticky = raw.sticky === undefined ? true : !!raw.sticky;
384
+ f._experiment = { name, variants, startUnix, sticky };
382
385
  }
383
386
  }
384
387
 
@@ -568,7 +571,25 @@ export function validateDungeonConfig(config) {
568
571
  // only avgEventsPerUserPerDay would never trigger auto-batch.
569
572
  if (numEvents >= 2_000_000 && config.batchSize === undefined) {
570
573
  batchSize = 1_000_000;
571
- if (verbose) console.warn(`⚠️ Auto-enabling batch mode: numEvents (${numEvents.toLocaleString()}) >= 2M. Using batchSize of ${batchSize.toLocaleString()}.`);
574
+ // Width-aware shrink: wide event schemas (many props/event) cost far more
575
+ // live memory per buffered event, so a 1M-event batch can exceed the default
576
+ // ~4GB heap and OOM mid-generation (batch mode is supposed to PREVENT that).
577
+ // Estimate a volume-weighted props/event and cap the batch so its peak buffer
578
+ // stays ~1.4GB. Narrow schemas keep the full 1M batch (no behavior change).
579
+ const evs = Array.isArray(config.events) ? config.events : [];
580
+ const superN = config.superProps ? Object.keys(config.superProps).length : 0;
581
+ let wSum = 0, wpSum = 0;
582
+ for (const e of evs) {
583
+ const w = (e && typeof e.weight === "number" && e.weight > 0) ? e.weight : 1;
584
+ const pN = (e && e.properties && typeof e.properties === "object") ? Object.keys(e.properties).length : 0;
585
+ wSum += w; wpSum += w * pN;
586
+ }
587
+ const avgOwnProps = wSum > 0 ? wpSum / wSum : 0;
588
+ const avgPropsPerEvent = avgOwnProps + superN + 10; // + core/identity/location/device estimate
589
+ const estBytesPerEvent = Math.max(400, Math.round(avgPropsPerEvent * 150));
590
+ const memSafeBatch = Math.max(100_000, Math.floor(1_400_000_000 / estBytesPerEvent));
591
+ if (avgPropsPerEvent > 18 && memSafeBatch < batchSize) batchSize = memSafeBatch;
592
+ if (verbose) console.warn(`⚠️ Auto-enabling batch mode: numEvents (${numEvents.toLocaleString()}) >= 2M (~${Math.round(avgPropsPerEvent)} props/event). Using batchSize of ${batchSize.toLocaleString()}.`);
572
593
  }
573
594
 
574
595
  // Resolve soup presets (intra-week / intra-day shape — must happen after numDays is computed)
@@ -1067,6 +1088,12 @@ function transformSCDPropsWithoutCredentials(config) {
1067
1088
 
1068
1089
  // ── Advanced Feature Validation Functions ──
1069
1090
 
1091
+ // P2.5 (v1.6): churnRate / activeWindow / soupOverride are declared Persona
1092
+ // surface but unimplemented — nothing in lib/ reads them after validation.
1093
+ // Deprecated no-ops; warn once per process when a dungeon sets any of them.
1094
+ const DEAD_PERSONA_FIELDS = ['churnRate', 'activeWindow', 'soupOverride'];
1095
+ let warnedDeadPersonaFields = false;
1096
+
1070
1097
  /**
1071
1098
  * Validates persona configurations
1072
1099
  * @param {import('../../types').Persona[]} personas
@@ -1074,14 +1101,22 @@ function transformSCDPropsWithoutCredentials(config) {
1074
1101
  */
1075
1102
  function validatePersonas(personas) {
1076
1103
  if (!Array.isArray(personas) || personas.length === 0) return null;
1104
+ const deadFieldsSet = new Set();
1077
1105
  for (const p of personas) {
1078
1106
  if (!p.name) throw new Error('Each persona must have a name');
1079
1107
  if (typeof p.weight !== 'number' || p.weight <= 0) throw new Error(`Persona "${p.name}" must have a positive weight`);
1108
+ for (const field of DEAD_PERSONA_FIELDS) {
1109
+ if (p[field] !== undefined) deadFieldsSet.add(field);
1110
+ }
1080
1111
  if (p.eventMultiplier === undefined) p.eventMultiplier = 1.0;
1081
1112
  if (p.conversionModifier === undefined) p.conversionModifier = 1.0;
1082
1113
  if (p.churnRate === undefined) p.churnRate = 0;
1083
1114
  if (p.properties === undefined) p.properties = {};
1084
1115
  }
1116
+ if (deadFieldsSet.size > 0 && !warnedDeadPersonaFields) {
1117
+ warnedDeadPersonaFields = true;
1118
+ console.warn(`[dungeon-master] Persona field(s) ${[...deadFieldsSet].join(', ')} are deprecated and unimplemented — they have no effect on generation. This warning fires once per process.`);
1119
+ }
1085
1120
  return personas;
1086
1121
  }
1087
1122
 
@@ -109,10 +109,12 @@ function createProgressReporter(config) {
109
109
  }
110
110
 
111
111
  try {
112
- const result = /** @type {any} */ (callback(update));
112
+ // onProgress is declared `=> void`, but async callbacks returning a
113
+ // promise are supported — duck-type the thenable at runtime.
114
+ const result = /** @type {Promise<unknown> | undefined} */ (/** @type {unknown} */ (callback(update)));
113
115
  totalUpdates++;
114
116
  if (result && typeof result.then === 'function') {
115
- result.then(undefined, (/** @type {any} */ err) => {
117
+ result.then(undefined, (/** @type {Error} */ err) => {
116
118
  errorCount++;
117
119
  if (verbose) console.warn(`[dungeon-master] onProgress async error (${errorCount}/3): ${err?.message || err}`);
118
120
  if (errorCount >= 3) disabled = true;
@@ -11,8 +11,11 @@ import Chance from 'chance';
11
11
 
12
12
  /**
13
13
  * Detect what kind of input was passed and normalize it
14
- * @param {any} input - The raw input to DUNGEON_MASTER
15
- * @returns {{ type: 'object' | 'file' | 'files' | 'text', value: any }}
14
+ * @param {unknown} input - The raw input to DUNGEON_MASTER
15
+ * @returns {{ type: 'object', value: import('../../types').Dungeon }
16
+ * | { type: 'file', value: string }
17
+ * | { type: 'files', value: string[] }
18
+ * | { type: 'text', value: string }}
16
19
  */
17
20
  export function detectInputType(input) {
18
21
  if (input === null || input === undefined) {
@@ -29,12 +32,12 @@ export function detectInputType(input) {
29
32
  throw new Error(`dungeon-master: array items must be file path strings. got ${typeof item}.`);
30
33
  }
31
34
  }
32
- return { type: 'files', value: input };
35
+ return { type: 'files', value: /** @type {string[]} */ (input) };
33
36
  }
34
37
 
35
38
  // Plain config object
36
39
  if (typeof input === 'object') {
37
- return { type: 'object', value: input };
40
+ return { type: 'object', value: /** @type {import('../../types').Dungeon} */ (input) };
38
41
  }
39
42
 
40
43
  // String: file path or raw JS text
@@ -128,7 +131,7 @@ export function parseJSONDungeon(json) {
128
131
  const hooksString = json.hooks || null;
129
132
 
130
133
  // Reconstruct the config from the JSON schema
131
- const config = reviveJSONConfig(schema);
134
+ const config = /** @type {Record<string, unknown>} */ (reviveJSONConfig(schema));
132
135
 
133
136
  // Attach hook if present
134
137
  if (hooksString && typeof hooksString === 'string') {
@@ -136,14 +139,14 @@ export function parseJSONDungeon(json) {
136
139
  }
137
140
 
138
141
  validateDungeonShape(config);
139
- return config;
142
+ return /** @type {import('../../types').Dungeon} */ (config);
140
143
  }
141
144
 
142
145
  /**
143
146
  * Revive JSON config by converting function-call objects back to functions
144
147
  * JSON dungeons store functions as { functionName: "...", body: "...", args: [...] }
145
- * @param {any} value
146
- * @returns {any}
148
+ * @param {unknown} value
149
+ * @returns {unknown}
147
150
  */
148
151
  function reviveJSONConfig(value) {
149
152
  if (value === null || value === undefined) return value;
@@ -152,8 +155,8 @@ function reviveJSONConfig(value) {
152
155
  if (typeof value !== 'object') return value;
153
156
 
154
157
  // Function-call objects → actual functions
155
- if (value.functionName) {
156
- return reviveFunctionObject(value);
158
+ if ('functionName' in value && value.functionName) {
159
+ return reviveFunctionObject(/** @type {{ functionName: string, body?: string, args?: unknown[] }} */ (value));
157
160
  }
158
161
 
159
162
  // Arrays
@@ -162,6 +165,7 @@ function reviveJSONConfig(value) {
162
165
  }
163
166
 
164
167
  // Objects
168
+ /** @type {Record<string, unknown>} */
165
169
  const result = {};
166
170
  for (const [key, val] of Object.entries(value)) {
167
171
  result[key] = reviveJSONConfig(val);
@@ -178,8 +182,8 @@ function reviveJSONConfig(value) {
178
182
  * mostChosenIndex, etc.) that don't exist at revival time. These will fail to eval
179
183
  * and fall back to null, which is handled gracefully by the config validator.
180
184
  *
181
- * @param {{ functionName: string, body?: string, args?: any[] }} obj
182
- * @returns {Function|any[]|null}
185
+ * @param {{ functionName: string, body?: string, args?: unknown[] }} obj
186
+ * @returns {Function|unknown[]|null}
183
187
  */
184
188
  function reviveFunctionObject(obj) {
185
189
  const { functionName, body, args = [] } = obj;
@@ -257,13 +261,14 @@ export async function loadFromText(code) {
257
261
  /**
258
262
  * Validate that a config object has the minimum shape of a dungeon
259
263
  * This is a pre-flight check before passing to the full config validator
260
- * @param {any} config
264
+ * @param {unknown} input
261
265
  * @throws {Error} if the config is clearly not a valid dungeon
262
266
  */
263
- export function validateDungeonShape(config) {
264
- if (!config || typeof config !== 'object' || Array.isArray(config)) {
267
+ export function validateDungeonShape(input) {
268
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
265
269
  throw new Error('dungeon-master: config must be a plain object.');
266
270
  }
271
+ const config = /** @type {Record<string, unknown>} */ (input);
267
272
 
268
273
  // Must have at least one recognizable dungeon property
269
274
  const dungeonKeys = [
@@ -16,6 +16,10 @@
16
16
  * is sampled at serialization time (when its closure is still live) and the inferred
17
17
  * output type is recorded as `dataType` (e.g. "number", "string", "boolean", "number[]").
18
18
  * So a field that loses its `weighNumRange(1,10)` generator still records `dataType: "number"`.
19
+ *
20
+ * A dungeon's `stories` NAMED export (v1.6 story layer) is NOT carried into the JSON —
21
+ * only the default-export config is serialized. The .js file remains the source of
22
+ * record for stories; verify-stories.mjs reads them from the module directly.
19
23
  */
20
24
 
21
25
  import { detectInputType, loadFromFile, loadFromText } from './dungeon-loader.js';
@@ -75,18 +79,31 @@ function serializeConfig(config, includeCredentials) {
75
79
  }
76
80
 
77
81
  return {
78
- schema: convertToJSON(cleanConfig),
82
+ // cleanConfig is an object, so convertToJSON returns the map arm of JSONReady.
83
+ schema: /** @type {Record<string, unknown>} */ (convertToJSON(cleanConfig)),
79
84
  hooks,
80
85
  timestamp: new Date().toISOString(),
81
86
  version: '4.0'
82
87
  };
83
88
  }
84
89
 
90
+ /**
91
+ * A JSON-serializable value: what `convertToJSON` produces. Functions become
92
+ * `SerializedFunction` markers; everything else is plain JSON.
93
+ * (Recursion goes through the named array/map aliases — TS rejects a JSDoc
94
+ * type alias that references itself directly in its own union.)
95
+ * @typedef {null | boolean | number | string
96
+ * | import('../../types').SerializedFunction
97
+ * | JSONReadyArray | JSONReadyMap} JSONReady
98
+ * @typedef {JSONReady[]} JSONReadyArray
99
+ * @typedef {{ [key: string]: JSONReady }} JSONReadyMap
100
+ */
101
+
85
102
  /**
86
103
  * Convert a JavaScript value to a JSON-serializable form, turning functions into
87
104
  * `{ functionName, body | args }` objects that `reviveJSONConfig` can revive.
88
- * @param {any} value
89
- * @returns {any}
105
+ * @param {unknown} value
106
+ * @returns {JSONReady}
90
107
  */
91
108
  export function convertToJSON(value) {
92
109
  // Null/undefined
@@ -111,6 +128,7 @@ export function convertToJSON(value) {
111
128
 
112
129
  // Objects
113
130
  if (typeof value === 'object') {
131
+ /** @type {{ [key: string]: JSONReady }} */
114
132
  const result = {};
115
133
  for (const [key, val] of Object.entries(value)) {
116
134
  result[key] = convertToJSON(val);
@@ -202,7 +220,7 @@ function inferDataType(func) {
202
220
  /**
203
221
  * Map a sampled value to a type label: "number" | "string" | "boolean" | "date" |
204
222
  * "object" | "<elementType>[]" | "array". Returns undefined for null/undefined/functions.
205
- * @param {any} value
223
+ * @param {unknown} value
206
224
  * @returns {string | undefined}
207
225
  */
208
226
  function classifyValue(value) {
@@ -245,9 +245,9 @@ export async function createHookArray(arr = [], opts) {
245
245
  }
246
246
  }
247
247
 
248
- // Enhance the array with our methods
249
- /** @type {HookedArray} */
250
- const enrichedArray = /** @type {any} */ (arr);
248
+ // Enhance the array with our methods (plain array → HookedArray, methods
249
+ // attached just below — hence the through-unknown assertion)
250
+ const enrichedArray = /** @type {HookedArray} */ (/** @type {unknown} */ (arr));
251
251
  enrichedArray.hookPush = transformThenPush;
252
252
  enrichedArray.flush = flush;
253
253
  enrichedArray.getWriteDir = getWriteDir;