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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +21 -12
  2. package/.claude/skills/create-dungeon/SKILL.md +65 -5
  3. package/.claude/skills/create-project/SKILL.md +1 -1
  4. package/.claude/skills/create-project/provision.mjs +57 -10
  5. package/.claude/skills/verify-dungeon/SKILL.md +56 -25
  6. package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
  7. package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
  8. package/.claude/skills/write-hooks/SKILL.md +156 -18
  9. package/CHANGELOG.md +183 -0
  10. package/HOOKS.md +566 -112
  11. package/README.md +28 -9
  12. package/docs/guides/1.3.0-upgrade-guide.md +262 -0
  13. package/docs/guides/1.3.1-upgrade-guide.md +328 -0
  14. package/docs/guides/1.4.0-upgrade-guide.md +130 -0
  15. package/docs/guides/1.4.1-upgrade-guide.md +125 -0
  16. package/docs/guides/1.4.3-upgrade-guide.md +200 -0
  17. package/docs/guides/1.4.4-upgrade-guide.md +95 -0
  18. package/docs/guides/1.5.0-upgrade-guide.md +426 -0
  19. package/docs/guides/1.5.1-upgrade-guide.md +178 -0
  20. package/docs/guides/1.5.3-upgrade-guide.md +117 -0
  21. package/docs/guides/1.6.0-upgrade-guide.md +204 -0
  22. package/dungeons/technical/stories-verify.js +152 -0
  23. package/dungeons/vertical/README.md +154 -0
  24. package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
  25. package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
  26. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
  27. package/dungeons/vertical/community/community.js +1589 -0
  28. package/dungeons/vertical/community/community.sql +216 -0
  29. package/dungeons/vertical/community/community.verify.mjs +72 -0
  30. package/dungeons/vertical/crypto/crypto.js +1483 -0
  31. package/dungeons/vertical/crypto/crypto.sql +194 -0
  32. package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
  33. package/dungeons/vertical/dating/dating.js +1540 -0
  34. package/dungeons/vertical/dating/dating.sql +288 -0
  35. package/dungeons/vertical/dating/dating.verify.mjs +72 -0
  36. package/dungeons/vertical/devtools/devtools.js +1524 -0
  37. package/dungeons/vertical/devtools/devtools.sql +161 -0
  38. package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
  39. package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
  40. package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
  41. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
  42. package/dungeons/vertical/education/education.js +1763 -0
  43. package/dungeons/vertical/education/education.sql +243 -0
  44. package/dungeons/vertical/education/education.verify.mjs +75 -0
  45. package/dungeons/vertical/fintech/fintech.js +1410 -0
  46. package/dungeons/vertical/fintech/fintech.sql +175 -0
  47. package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
  48. package/dungeons/vertical/fitness/fitness.js +1495 -0
  49. package/dungeons/vertical/fitness/fitness.sql +206 -0
  50. package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
  51. package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
  52. package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
  53. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
  54. package/dungeons/vertical/gaming/gaming.js +1741 -0
  55. package/dungeons/vertical/gaming/gaming.sql +249 -0
  56. package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
  57. package/dungeons/vertical/healthcare/healthcare.js +1488 -0
  58. package/dungeons/vertical/healthcare/healthcare.sql +193 -0
  59. package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
  60. package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
  61. package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
  62. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
  63. package/dungeons/vertical/logistics/logistics.js +1541 -0
  64. package/dungeons/vertical/logistics/logistics.sql +205 -0
  65. package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
  66. package/dungeons/vertical/marketplace/marketplace.js +1438 -0
  67. package/dungeons/vertical/marketplace/marketplace.sql +148 -0
  68. package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
  69. package/dungeons/vertical/{media.js → media/media.js} +557 -58
  70. package/dungeons/vertical/media/media.sql +173 -0
  71. package/dungeons/vertical/media/media.verify.mjs +74 -0
  72. package/dungeons/vertical/real-estate/real-estate.js +1629 -0
  73. package/dungeons/vertical/real-estate/real-estate.sql +167 -0
  74. package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
  75. package/dungeons/vertical/sass/sass.js +1733 -0
  76. package/dungeons/vertical/sass/sass.sql +173 -0
  77. package/dungeons/vertical/sass/sass.verify.mjs +74 -0
  78. package/dungeons/vertical/social/social.js +1456 -0
  79. package/dungeons/vertical/social/social.sql +160 -0
  80. package/dungeons/vertical/social/social.verify.mjs +76 -0
  81. package/dungeons/vertical/streaming/streaming.js +677 -0
  82. package/dungeons/vertical/streaming/streaming.sql +101 -0
  83. package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
  84. package/dungeons/vertical/support-desk/support-desk.js +741 -0
  85. package/dungeons/vertical/support-desk/support-desk.sql +137 -0
  86. package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
  87. package/dungeons/vertical/travel/travel.js +1603 -0
  88. package/dungeons/vertical/travel/travel.sql +236 -0
  89. package/dungeons/vertical/travel/travel.verify.mjs +72 -0
  90. package/index.js +2 -2
  91. package/lib/core/config-validator.js +37 -2
  92. package/lib/core/context.js +4 -2
  93. package/lib/core/dungeon-loader.js +20 -15
  94. package/lib/core/dungeon-to-json.js +22 -4
  95. package/lib/core/storage.js +3 -3
  96. package/lib/generators/funnels.js +36 -9
  97. package/lib/hook-helpers/cohort.js +53 -0
  98. package/lib/hook-helpers/index.js +8 -0
  99. package/lib/hook-helpers/mutate.js +1 -1
  100. package/lib/hook-helpers/shape.js +319 -0
  101. package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
  102. package/lib/hook-patterns/attributed-by-source.js +60 -58
  103. package/lib/hook-patterns/frequency-by-frequency.js +14 -4
  104. package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
  105. package/lib/hook-patterns/index.js +1 -1
  106. package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
  107. package/lib/orchestrators/user-loop.js +41 -0
  108. package/lib/templates/story-spec.schema.json +175 -0
  109. package/lib/utils/json-evaluator.js +1 -1
  110. package/lib/utils/utils.js +5 -4
  111. package/lib/verify/coerce.js +186 -0
  112. package/lib/verify/counting.js +270 -32
  113. package/lib/verify/emulate-breakdown.js +1285 -203
  114. package/lib/verify/first-time.js +89 -0
  115. package/lib/verify/flows.js +679 -0
  116. package/lib/verify/formula.js +259 -0
  117. package/lib/verify/funnel-engine.js +697 -113
  118. package/lib/verify/index.js +32 -4
  119. package/lib/verify/sessionize.js +275 -0
  120. package/lib/verify/story-runner.js +483 -0
  121. package/lib/verify/verify-dungeon.js +59 -37
  122. package/package.json +2 -1
  123. package/scripts/extract-dungeon-schema.mjs +2 -2
  124. package/scripts/run-many.mjs +2 -2
  125. package/scripts/verify-runner.mjs +2 -2
  126. package/scripts/verify-stories.mjs +244 -0
  127. package/types.d.ts +296 -30
  128. package/dungeons/vertical/ai-platform.js +0 -818
  129. package/dungeons/vertical/community.js +0 -966
  130. package/dungeons/vertical/crypto.js +0 -895
  131. package/dungeons/vertical/dating.js +0 -830
  132. package/dungeons/vertical/devtools.js +0 -1050
  133. package/dungeons/vertical/ecommerce.js +0 -811
  134. package/dungeons/vertical/education.js +0 -964
  135. package/dungeons/vertical/fintech.js +0 -946
  136. package/dungeons/vertical/fitness.js +0 -935
  137. package/dungeons/vertical/food-delivery.js +0 -934
  138. package/dungeons/vertical/gaming.js +0 -1120
  139. package/dungeons/vertical/healthcare.js +0 -945
  140. package/dungeons/vertical/insurance-application.js +0 -1039
  141. package/dungeons/vertical/logistics.js +0 -986
  142. package/dungeons/vertical/marketplace.js +0 -1019
  143. package/dungeons/vertical/real-estate.js +0 -872
  144. package/dungeons/vertical/sass.js +0 -995
  145. package/dungeons/vertical/social.js +0 -904
  146. package/dungeons/vertical/travel.js +0 -930
@@ -0,0 +1,288 @@
1
+ -- ============================================================
2
+ -- dating.js — v1.6 human-inspection queries (DuckDB)
3
+ --
4
+ -- Every query is keyed to a story id in dating.js's `stories` export;
5
+ -- the machine-checked verdicts come from:
6
+ -- node scripts/verify-stories.mjs dungeons/vertical/dating/dating.js --data-prefix verify-dating
7
+ -- Generate first:
8
+ -- node scripts/verify-runner.mjs dungeons/vertical/dating/dating.js verify-dating
9
+ -- Run this file:
10
+ -- duckdb -c ".read dungeons/vertical/dating/dating.sql"
11
+ -- ============================================================
12
+
13
+ -- ── identity-resolution prelude ─────────────────────────────
14
+ -- avgDevicePerUser: 2 + profile 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-dating-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-dating-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. Deletions-only hooks (H5 ghosting, H8 off-app drop,
34
+ -- future-time guard) make hook-time cohort recovery ONE-SIDED: output
35
+ -- count >= threshold implies hook-time count >= threshold, but not the
36
+ -- reverse. Stories handle this with output-implies-hook cohort choices;
37
+ -- read these tables the same way.
38
+ CREATE OR REPLACE VIEW per_user AS
39
+ SELECT uid,
40
+ count(*) FILTER (WHERE event = 'photo uploaded') AS photos,
41
+ count(*) FILTER (WHERE event = 'match received') AS matches,
42
+ count(*) FILTER (WHERE event = 'swipe right') AS swipes,
43
+ count(*) FILTER (WHERE event = 'swipe right' AND is_super_like = true) AS sls,
44
+ count(*) FILTER (WHERE event = 'message sent') AS msgs,
45
+ count(*) FILTER (WHERE event = 'date scheduled') AS dates
46
+ FROM ev GROUP BY 1;
47
+
48
+ -- output-visible timely pair (match → message within 48h) proves the user
49
+ -- was NOT ghosted at hook time (H5 deletes but never adds)
50
+ CREATE OR REPLACE VIEW timely AS
51
+ SELECT DISTINCT a.uid FROM ev a
52
+ JOIN ev b ON b.uid = a.uid AND b.event = 'message sent'
53
+ WHERE a.event = 'match received' AND b.t > a.t AND b.t < a.t + INTERVAL 48 HOUR;
54
+
55
+ -- output-visible early milestone (phone/date inside first 14 days)
56
+ CREATE OR REPLACE VIEW first_ev AS
57
+ SELECT uid, min(t) AS f FROM ev GROUP BY 1;
58
+
59
+ CREATE OR REPLACE VIEW milestone AS
60
+ SELECT DISTINCT e.uid FROM ev e JOIN first_ev fe ON fe.uid = e.uid
61
+ WHERE e.event IN ('phone number exchanged', 'date scheduled')
62
+ AND e.t < fe.f + INTERVAL 14 DAY;
63
+
64
+
65
+ -- ── H1-photo-magic-number ───────────────────────────────────
66
+ -- score cut: 6+ uploaders' match_score × 0.65 applied at the END of the
67
+ -- everything hook (covers H3/H4-injected matches too). Compare vs 0-1
68
+ -- uploaders, NOT vs sweet (sweet users' H1 clones redraw score U[60,98]).
69
+ SELECT CASE WHEN p.photos >= 6 THEN 'over' WHEN p.photos <= 1 THEN 'low' ELSE 'sweet' END AS grp,
70
+ count(DISTINCT p.uid) AS users, round(avg(e.match_score), 2) AS avg_score
71
+ FROM per_user p JOIN ev e ON e.uid = p.uid AND e.event = 'match received'
72
+ GROUP BY 1 ORDER BY 1;
73
+ -- read: over/low avg_score ≈ 0.65 (knob); sweet reads high (clone redraws)
74
+
75
+ -- count lift: activity-normalized double ratio, Free tier, H3's additive
76
+ -- term subtracted arithmetically (adj = matches − 3·super_likes).
77
+ -- Conditioning on sls=0 instead would select the near-inactive tail
78
+ -- (P(no SL) ≈ 0.9^swipes) and starve the sweet cell. DD ≈ 1+E[U{2..4}] = 4.
79
+ SELECT CASE WHEN p.photos BETWEEN 2 AND 5 THEN 'sweet' WHEN p.photos <= 1 THEN 'low' END AS arm,
80
+ count(*) AS users, round(avg(p.matches - 3 * p.sls), 3) AS avg_adj_m, round(avg(p.swipes), 3) AS avg_s
81
+ FROM per_user p JOIN users u ON u.distinct_id::VARCHAR = p.uid
82
+ WHERE u.subscription = 'Free' AND p.swipes > 0
83
+ AND (p.photos BETWEEN 2 AND 5 OR p.photos <= 1)
84
+ GROUP BY 1 ORDER BY 1;
85
+ -- read: (avg_adj_m_sweet/avg_adj_m_low) ÷ (avg_s_sweet/avg_s_low) ≈ 4
86
+
87
+
88
+ -- ── H2-sunday-swipe-surge ───────────────────────────────────
89
+ -- Sunday swipes cloned in place: evening (18-23 UTC) ×6, daytime ×3.
90
+ -- dayofweek(): Sunday = 0 (matches JS getUTCDay).
91
+ SELECT dayofweek(t) AS dow, count(*) AS swipes
92
+ FROM ev WHERE event = 'swipe right' GROUP BY 1 ORDER BY 1;
93
+ -- read: dow 0 strict max; Sunday / mean(other six) in [2, 6]
94
+
95
+ -- hour-of-day mix inside Sunday (evening share drives where the
96
+ -- multiplier lands between 3 and 6)
97
+ SELECT CASE WHEN extract(hour FROM t) >= 18 THEN 'evening' ELSE 'daytime' END AS bucket,
98
+ count(*) AS sunday_swipes
99
+ FROM ev WHERE event = 'swipe right' AND dayofweek(t) = 0 GROUP BY 1 ORDER BY 1;
100
+
101
+
102
+ -- ── H3-super-like-effect ────────────────────────────────────
103
+ -- each super-like injects exactly 3 cloned matches (additive, scores
104
+ -- U[70,99]). Cohort: Free, photos outside sweet 2-5 (isolates from H1/H4).
105
+ SELECT CASE WHEN p.sls >= 1 THEN 'super_liker' ELSE 'no_sl' END AS arm,
106
+ count(*) AS users, round(avg(p.matches), 3) AS avg_m,
107
+ round(avg(p.swipes), 3) AS avg_s, round(avg(p.sls), 3) AS avg_sl
108
+ FROM per_user p JOIN users u ON u.distinct_id::VARCHAR = p.uid
109
+ WHERE u.subscription = 'Free' AND p.photos NOT BETWEEN 2 AND 5 AND p.swipes > 0
110
+ GROUP BY 1 ORDER BY 1;
111
+ -- read: measured lift avg_m(sl)/[avg_m(none) × avg_s(sl)/avg_s(none)]
112
+ -- vs predicted (organic + 3·avg_sl)/organic — ratio ≈ 1
113
+
114
+ -- injected-match score floor: H3 clones draw match_score U[70,99]; for
115
+ -- non-over-6 users nothing lowers scores, so super-liker matches skew high
116
+ SELECT CASE WHEN p.sls >= 1 THEN 'super_liker' ELSE 'no_sl' END AS arm,
117
+ round(avg(e.match_score), 2) AS avg_score, median(e.match_score) AS med_score
118
+ FROM per_user p JOIN ev e ON e.uid = p.uid AND e.event = 'match received'
119
+ WHERE p.photos < 6 GROUP BY 1 ORDER BY 1;
120
+
121
+
122
+ -- ── H4-premium-match-boost ──────────────────────────────────
123
+ -- H4 runs after H5's churn: surviving matches × 2 (Premium) / × 4 (Elite),
124
+ -- toAdd = base×mult − base exactly. Tier ⊥ activity → cross-tier avg ratio
125
+ -- reads the multiplier (diluted only by zero-hook-match users).
126
+ SELECT u.subscription AS tier, count(*) AS users,
127
+ round(avg(coalesce(p.matches, 0)), 3) AS avg_matches
128
+ FROM users u LEFT JOIN per_user p ON p.uid = u.distinct_id::VARCHAR
129
+ GROUP BY 1 ORDER BY avg_matches;
130
+ -- read: Elite/Free ≈ 4, Premium/Free ≈ 2 (both slightly diluted)
131
+
132
+ -- structural signature: timely ∩ milestone Elite users (non-ghosted, H8
133
+ -- add-branch) have matches ≡ 0 mod 4 except the ~1-2% future-guard tail;
134
+ -- Free is the placebo (~0.25 random).
135
+ SELECT u.subscription AS tier, count(*) AS users,
136
+ round(count(*) FILTER (WHERE p.matches % 4 = 0)::DOUBLE / count(*), 4) AS mod4_share
137
+ FROM users u
138
+ JOIN per_user p ON p.uid = u.distinct_id::VARCHAR
139
+ JOIN timely tp ON tp.uid = p.uid
140
+ JOIN milestone ms ON ms.uid = p.uid
141
+ WHERE p.matches >= 4 AND u.subscription IN ('Elite', 'Free')
142
+ GROUP BY 1 ORDER BY 1;
143
+ -- read: Elite mod4_share ≥ 0.9, Free ≈ 0.25
144
+
145
+
146
+ -- ── H5-ghosting-churn ───────────────────────────────────────
147
+ -- no timely message within 48h of any match → 80% of post-first-match
148
+ -- events dropped (keep 0.2). Non-milestone restriction: H8's post-day-30
149
+ -- drop applies to BOTH arms and cancels in the ρ ratio (milestone users
150
+ -- get H8 ADDS, which would inflate the timely arm only).
151
+ -- The raw ρ ratio is confounded DOWNWARD by activity selection (the
152
+ -- ghosted arm is the least-engaged matched tail on a flatter organic
153
+ -- trajectory), so the story self-calibrates on the PRE-first-match
154
+ -- half-split (rho_pre) — H5 never touches pre-match events.
155
+ WITH fm AS (SELECT uid, min(t) AS first_match FROM ev WHERE event = 'match received' GROUP BY 1),
156
+ per AS (
157
+ SELECT fm.uid,
158
+ count(*) FILTER (WHERE e.t <= fm.first_match) AS pre,
159
+ count(*) FILTER (WHERE e.t > fm.first_match) AS post,
160
+ count(*) FILTER (WHERE e.t <= to_timestamp((epoch(fe.f) + epoch(fm.first_match)) / 2)) AS pre_a,
161
+ count(*) FILTER (WHERE e.t > to_timestamp((epoch(fe.f) + epoch(fm.first_match)) / 2) AND e.t <= fm.first_match) AS pre_b
162
+ FROM fm JOIN first_ev fe ON fe.uid = fm.uid JOIN ev e ON e.uid = fm.uid GROUP BY 1
163
+ )
164
+ SELECT CASE WHEN tp.uid IS NOT NULL THEN 'timely' ELSE 'ghosted' END AS arm,
165
+ count(*) AS users, round(sum(post)::DOUBLE / nullif(sum(pre), 0), 4) AS rho,
166
+ round(sum(pre_b)::DOUBLE / nullif(sum(pre_a), 0), 4) AS rho_pre
167
+ FROM per LEFT JOIN timely tp ON tp.uid = per.uid
168
+ WHERE per.uid NOT IN (SELECT uid FROM milestone)
169
+ GROUP BY 1 ORDER BY 1;
170
+ -- read: (rho_g/rho_t) ÷ (rho_pre_g/rho_pre_t) ≈ 0.2 keep rate (±40%)
171
+
172
+
173
+ -- ── H6-bio-prompt-power-users ───────────────────────────────
174
+ -- bio ≥1 ∧ prompts ≥3 → 3 cloned dates per existing date (×4 at hook
175
+ -- time). At ~190 events/user the power cohort is ~80% of users; the rest
176
+ -- arm is the low-activity tail whose messages come disproportionately
177
+ -- from Date Funnel instances (which co-emit dates), so its ORGANIC
178
+ -- dates-per-message runs ~2× the power arm's. Population rate ratio is a
179
+ -- composite: 4× mechanism × 0.4-0.85 composition → [1.5, 3.4].
180
+ WITH pw AS (
181
+ SELECT uid,
182
+ count(*) FILTER (WHERE event = 'bio updated') AS bios,
183
+ count(*) FILTER (WHERE event = 'prompt answered') AS prompts,
184
+ count(*) FILTER (WHERE event = 'date scheduled') AS dates,
185
+ count(*) FILTER (WHERE event = 'message sent') AS msgs
186
+ FROM ev GROUP BY 1
187
+ )
188
+ SELECT CASE WHEN bios >= 1 AND prompts >= 3 THEN 'power' ELSE 'rest' END AS arm,
189
+ count(*) AS users, round(sum(dates)::DOUBLE / sum(msgs), 5) AS date_rate
190
+ FROM pw WHERE msgs > 0 GROUP BY 1 ORDER BY 1;
191
+ -- read: power/rest date_rate in [1.5, 3.4] (composite, see above)
192
+
193
+ -- exact mechanism: timely ∩ milestone power users have no post-H6 date
194
+ -- deletions → output dates ≡ 0 (mod 4) except the ~9% future-guard tail
195
+ -- (H6 clones stamped source+1..72h past datasetEnd silently dropped);
196
+ -- clean-cohort rest arm is the placebo (~0.25 random).
197
+ WITH pw AS (
198
+ SELECT uid,
199
+ count(*) FILTER (WHERE event = 'bio updated') AS bios,
200
+ count(*) FILTER (WHERE event = 'prompt answered') AS prompts,
201
+ count(*) FILTER (WHERE event = 'date scheduled') AS dates
202
+ FROM ev GROUP BY 1
203
+ )
204
+ SELECT CASE WHEN p.bios >= 1 AND p.prompts >= 3 THEN 'power' ELSE 'rest' END AS arm,
205
+ count(*) AS users,
206
+ round(count(*) FILTER (WHERE p.dates % 4 = 0)::DOUBLE / count(*), 4) AS mod4_share
207
+ FROM pw p
208
+ JOIN timely tp ON tp.uid = p.uid
209
+ JOIN milestone ms ON ms.uid = p.uid
210
+ WHERE p.dates >= 4 GROUP BY 1 ORDER BY 1;
211
+ -- read: power mod4_share ≥ 0.9, rest ≈ 0.25
212
+
213
+
214
+ -- ── H7-vday-spike ───────────────────────────────────────────
215
+ -- V-Day window = dataset days 58-63 (2026-02-28 → 2026-03-05): signups
216
+ -- ×3 total (clones +U[1,48]h, E[leak] 20%), upgrades ×5 total (clones
217
+ -- +U[1,24]h, E[leak] 10%). Baseline flanks skip 3 days post-window so
218
+ -- clone spill can't inflate them.
219
+ SELECT date_trunc('day', t) AS day, count(*) AS signups
220
+ FROM ev WHERE event = 'profile created'
221
+ AND t >= TIMESTAMP '2026-02-21' AND t < TIMESTAMP '2026-03-12'
222
+ GROUP BY 1 ORDER BY 1;
223
+ -- read: 02-28..03-04 daily ≈ 2.6× the flanking days
224
+
225
+ SELECT
226
+ count(*) FILTER (WHERE t >= TIMESTAMP '2026-02-28' AND t < TIMESTAMP '2026-03-05') / 5.0 AS window_daily,
227
+ count(*) FILTER (WHERE (t >= TIMESTAMP '2026-02-14' AND t < TIMESTAMP '2026-02-28')
228
+ OR (t >= TIMESTAMP '2026-03-08' AND t < TIMESTAMP '2026-03-22')) / 28.0 AS baseline_daily
229
+ FROM ev WHERE event = 'premium upgrade';
230
+ -- read: window/baseline ≈ 4.6 (band [3.5, 5.6])
231
+
232
+
233
+ -- ── H8-offapp-retention ─────────────────────────────────────
234
+ -- milestone users (early phone/date) get post-day-30 top-up clones toward
235
+ -- 30% share; non-milestone lose 80% of post-day-30 events. Cohort: born
236
+ -- before day 30 (clone support day30+U[1,60] fits the 121-day window) AND
237
+ -- timely-or-match-free (removes the H5 confound).
238
+ WITH per AS (
239
+ SELECT fe.uid,
240
+ count(*) FILTER (WHERE e.t > fe.f + INTERVAL 30 DAY) AS post30,
241
+ count(*) AS total
242
+ FROM first_ev fe JOIN ev e ON e.uid = fe.uid
243
+ WHERE fe.f < TIMESTAMP '2026-01-31' GROUP BY 1
244
+ )
245
+ SELECT CASE WHEN ms.uid IS NOT NULL THEN 'milestone' ELSE 'rest' END AS arm,
246
+ count(*) AS users, round(sum(post30)::DOUBLE / sum(total), 4) AS post30_share
247
+ FROM per
248
+ LEFT JOIN milestone ms ON ms.uid = per.uid
249
+ LEFT JOIN timely tp ON tp.uid = per.uid
250
+ LEFT JOIN per_user pu ON pu.uid = per.uid
251
+ WHERE tp.uid IS NOT NULL OR coalesce(pu.matches, 0) = 0
252
+ GROUP BY 1 ORDER BY 1;
253
+ -- read: with keep k=0.2 and milestone share s (≈ organic), predicted rest
254
+ -- share = k·s / (1 − (1−k)·s); measured rest / predicted ≈ 1
255
+
256
+
257
+ -- ── H9-match-flow-ttc ───────────────────────────────────────
258
+ -- funnel-post stretches Match Flow gaps by tier: Elite ×0.71, Free ×1.4,
259
+ -- Premium untouched (v1.6 scopes the hook to Match Flow only).
260
+ -- CAUTION: cross-event TTC SQL here is the documented greedy-single-pass
261
+ -- limitation — it pairs swipes/matches across funnel instances and buries
262
+ -- the signal. The story asserts TTC through the Mixpanel-aligned emulator
263
+ -- (timeToConvert, 33.6h window = 24h generative × 1.4 max stretch); trust
264
+ -- the story verdict, not ad-hoc pair SQL.
265
+ SELECT u.subscription AS tier, count(*) AS matches
266
+ FROM ev e JOIN users u ON u.distinct_id::VARCHAR = e.uid
267
+ WHERE e.event = 'match received' GROUP BY 1 ORDER BY 1;
268
+
269
+
270
+ -- ── H10-age-date-conversion ─────────────────────────────────
271
+ -- funnel-pre scales Date Funnel completion: 25-29/30-34 ×1.3, 40+ ×0.6.
272
+ -- Emulator story reads step_counts at the 72h generative window; this
273
+ -- query approximates the same read with a sequenced 72h pairing per user
274
+ -- (close enough for eyeballing, not for the verdict).
275
+ WITH msg AS (SELECT uid, min(t) AS m0 FROM ev WHERE event = 'message sent' GROUP BY 1),
276
+ done AS (
277
+ SELECT DISTINCT m.uid FROM msg m
278
+ JOIN ev d ON d.uid = m.uid AND d.event = 'date scheduled'
279
+ WHERE d.t > m.m0 AND d.t < m.m0 + INTERVAL 72 HOUR
280
+ )
281
+ SELECT u.age_range, count(*) AS msg_users,
282
+ round(count(*) FILTER (WHERE dn.uid IS NOT NULL)::DOUBLE / count(*), 4) AS date_rate_72h
283
+ FROM msg m
284
+ JOIN users u ON u.distinct_id::VARCHAR = m.uid
285
+ LEFT JOIN done dn ON dn.uid = m.uid
286
+ GROUP BY 1 ORDER BY 1;
287
+ -- read: 25-29/30-34 rates > 18-24/35-39 > 40+ (compressed vs the
288
+ -- 1.3/0.6 knobs by organic age-independent dates)
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dating.verify.mjs — thin wrapper around the story runner.
4
+ *
5
+ * All verification logic lives in the `stories` export of ./dating.js;
6
+ * this script just streams the shards and delegates. It is equivalent to:
7
+ *
8
+ * node scripts/verify-stories.mjs dungeons/vertical/dating/dating.js --data-prefix verify-dating
9
+ *
10
+ * Generate first:
11
+ * node scripts/verify-runner.mjs dungeons/vertical/dating/dating.js verify-dating
12
+ * Run:
13
+ * node dungeons/vertical/dating/dating.verify.mjs [prefix] # default verify-dating
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 './dating.js';
22
+
23
+ const PREFIX = process.argv[2] || 'verify-dating';
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/dating/dating.js ${PREFIX}`);
44
+ process.exit(1);
45
+ }
46
+ console.log(`dating — 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 H9 stories carry their own explicit
55
+ // conversion window (24h Match Flow generative window × the 1.4 max stretch
56
+ // factor = 33.6h, covering the stretched support), so no funnel-default
57
+ // 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);