@mcptoolshop/promo-kit 0.1.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.
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Decision Drift Detector
5
+ *
6
+ * Compares current vs previous promo-decisions.json to detect
7
+ * week-over-week drift in promotion decisions — new entrants,
8
+ * exits, score deltas, and action changes.
9
+ *
10
+ * Usage:
11
+ * node scripts/gen-decision-drift.mjs [--dry-run]
12
+ *
13
+ * Reads:
14
+ * site/src/data/promo-decisions.json
15
+ * site/src/data/decision-drift-snapshot.json
16
+ * site/src/data/governance.json
17
+ *
18
+ * Writes:
19
+ * site/src/data/decision-drift.json
20
+ * site/public/lab/decisions/decision-drift.md
21
+ * site/src/data/decision-drift-snapshot.json (updated snapshot)
22
+ */
23
+
24
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
25
+ import { resolve, join } from "node:path";
26
+ import { getConfig, getRoot } from "./lib/config.mjs";
27
+
28
+ const ROOT = getRoot();
29
+ const config = getConfig();
30
+ const DATA_DIR = join(ROOT, config.paths.dataDir);
31
+ const DECISIONS_DIR = join(ROOT, config.paths.publicDir, "lab", "decisions");
32
+
33
+ // ── Helpers ─────────────────────────────────────────────────
34
+
35
+ function safeParseJson(filePath, fallback = null) {
36
+ try {
37
+ return JSON.parse(readFileSync(filePath, "utf8"));
38
+ } catch {
39
+ return fallback;
40
+ }
41
+ }
42
+
43
+ // ── Core ────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * Build drift report comparing previous and current promo decisions.
47
+ *
48
+ * @param {object|null} previous - Previous promo-decisions object (null on first run)
49
+ * @param {object|null} current - Current promo-decisions object
50
+ * @returns {{ entrants: string[], exits: string[], scoreDeltas: Array, reasonChanges: Array, summary: object }}
51
+ */
52
+ export function buildDrift(previous, current) {
53
+ const prevDecisions = previous?.decisions ?? [];
54
+ const currDecisions = current?.decisions ?? [];
55
+
56
+ const prevMap = new Map();
57
+ for (const d of prevDecisions) {
58
+ if (d && d.slug) prevMap.set(d.slug, d);
59
+ }
60
+
61
+ const currMap = new Map();
62
+ for (const d of currDecisions) {
63
+ if (d && d.slug) currMap.set(d.slug, d);
64
+ }
65
+
66
+ const prevSlugs = new Set(prevMap.keys());
67
+ const currSlugs = new Set(currMap.keys());
68
+
69
+ // Entrants: in current but not in previous
70
+ const entrants = [...currSlugs].filter((s) => !prevSlugs.has(s));
71
+
72
+ // Exits: in previous but not in current
73
+ const exits = [...prevSlugs].filter((s) => !currSlugs.has(s));
74
+
75
+ // Common slugs: compute score deltas and action changes
76
+ const commonSlugs = [...currSlugs].filter((s) => prevSlugs.has(s));
77
+
78
+ const scoreDeltas = [];
79
+ const reasonChanges = [];
80
+
81
+ for (const slug of commonSlugs) {
82
+ const prev = prevMap.get(slug);
83
+ const curr = currMap.get(slug);
84
+
85
+ const prevScore = prev.score ?? 0;
86
+ const currScore = curr.score ?? 0;
87
+ const delta = currScore - prevScore;
88
+
89
+ scoreDeltas.push({ slug, prevScore, currScore, delta });
90
+
91
+ if (prev.action !== curr.action) {
92
+ reasonChanges.push({ slug, prevAction: prev.action, currAction: curr.action });
93
+ }
94
+ }
95
+
96
+ // Summary
97
+ const deltasWithChange = scoreDeltas.filter((d) => d.delta !== 0).length;
98
+ const actionOnlyChanges = reasonChanges.filter((rc) => {
99
+ const sd = scoreDeltas.find((d) => d.slug === rc.slug);
100
+ return sd && sd.delta === 0;
101
+ }).length;
102
+ const totalChanged = entrants.length + exits.length + deltasWithChange + actionOnlyChanges;
103
+ const totalStable = commonSlugs.length - deltasWithChange - actionOnlyChanges;
104
+
105
+ return {
106
+ entrants,
107
+ exits,
108
+ scoreDeltas,
109
+ reasonChanges,
110
+ summary: { totalChanged, totalStable },
111
+ };
112
+ }
113
+
114
+ // ── Markdown generator ──────────────────────────────────────
115
+
116
+ /**
117
+ * Build a markdown summary of decision drift.
118
+ *
119
+ * @param {object} drift - Output from buildDrift()
120
+ * @returns {string}
121
+ */
122
+ function generateDriftMd(drift) {
123
+ const { entrants, exits, scoreDeltas, reasonChanges, summary } = drift;
124
+ const lines = [];
125
+
126
+ lines.push("# Decision Drift Report");
127
+ lines.push("");
128
+ lines.push(`*Generated: ${new Date().toISOString().slice(0, 10)}*`);
129
+ lines.push("");
130
+
131
+ // Entrants
132
+ lines.push("## Entrants");
133
+ lines.push("");
134
+ if (entrants.length > 0) {
135
+ for (const slug of entrants) {
136
+ lines.push(`- ${slug}`);
137
+ }
138
+ } else {
139
+ lines.push("No new entrants.");
140
+ }
141
+ lines.push("");
142
+
143
+ // Exits
144
+ lines.push("## Exits");
145
+ lines.push("");
146
+ if (exits.length > 0) {
147
+ for (const slug of exits) {
148
+ lines.push(`- ${slug}`);
149
+ }
150
+ } else {
151
+ lines.push("No exits.");
152
+ }
153
+ lines.push("");
154
+
155
+ // Score Deltas
156
+ lines.push("## Score Deltas");
157
+ lines.push("");
158
+ if (scoreDeltas.length > 0) {
159
+ lines.push("| Slug | Prev Score | Curr Score | Delta |");
160
+ lines.push("|------|-----------|-----------|-------|");
161
+ for (const d of scoreDeltas) {
162
+ const sign = d.delta > 0 ? "+" : "";
163
+ lines.push(`| ${d.slug} | ${d.prevScore} | ${d.currScore} | ${sign}${d.delta} |`);
164
+ }
165
+ } else {
166
+ lines.push("No common slugs to compare.");
167
+ }
168
+ lines.push("");
169
+
170
+ // Action Changes
171
+ lines.push("## Action Changes");
172
+ lines.push("");
173
+ if (reasonChanges.length > 0) {
174
+ for (const rc of reasonChanges) {
175
+ lines.push(`- **${rc.slug}**: ${rc.prevAction} \u2192 ${rc.currAction}`);
176
+ }
177
+ } else {
178
+ lines.push("No action changes.");
179
+ }
180
+ lines.push("");
181
+
182
+ // Summary
183
+ lines.push("## Summary");
184
+ lines.push("");
185
+ lines.push(`- **Total changed:** ${summary.totalChanged}`);
186
+ lines.push(`- **Total stable:** ${summary.totalStable}`);
187
+ lines.push("");
188
+
189
+ return lines.join("\n");
190
+ }
191
+
192
+ // ── Pipeline ─────────────────────────────────────────────────
193
+
194
+ /**
195
+ * Generate decision drift artifacts.
196
+ * @param {{ dataDir?: string, decisionsDir?: string, dryRun?: boolean }} opts
197
+ */
198
+ export function generateDecisionDrift(opts = {}) {
199
+ const {
200
+ dataDir = DATA_DIR,
201
+ decisionsDir = DECISIONS_DIR,
202
+ dryRun = false,
203
+ } = opts;
204
+
205
+ const current = safeParseJson(join(dataDir, "promo-decisions.json"), { decisions: [] });
206
+ const previous = safeParseJson(join(dataDir, "decision-drift-snapshot.json"), null);
207
+ const governance = safeParseJson(join(dataDir, "governance.json"), {});
208
+
209
+ const drift = buildDrift(previous, current);
210
+
211
+ const output = {
212
+ generatedAt: new Date().toISOString(),
213
+ ...drift,
214
+ };
215
+
216
+ if (dryRun) {
217
+ console.log(" [dry-run] Decision drift computed.");
218
+ console.log(` Entrants: ${drift.entrants.length}, Exits: ${drift.exits.length}`);
219
+ console.log(` Score deltas: ${drift.scoreDeltas.length}, Action changes: ${drift.reasonChanges.length}`);
220
+ return output;
221
+ }
222
+
223
+ // Write drift JSON
224
+ const driftPath = join(dataDir, "decision-drift.json");
225
+ writeFileSync(driftPath, JSON.stringify(output, null, 2) + "\n", "utf8");
226
+
227
+ // Write drift markdown
228
+ mkdirSync(decisionsDir, { recursive: true });
229
+ const md = generateDriftMd(drift);
230
+ writeFileSync(join(decisionsDir, "decision-drift.md"), md + "\n", "utf8");
231
+
232
+ // Update snapshot (unless frozen)
233
+ if (governance.decisionsFrozen !== true) {
234
+ writeFileSync(
235
+ join(dataDir, "decision-drift-snapshot.json"),
236
+ JSON.stringify(current, null, 2) + "\n",
237
+ "utf8",
238
+ );
239
+ }
240
+
241
+ console.log(` Decision drift written → ${driftPath}`);
242
+ return output;
243
+ }
244
+
245
+ // ── Entry point ──────────────────────────────────────────────
246
+
247
+ const isMain = process.argv[1] && resolve(process.argv[1]).endsWith("gen-decision-drift.mjs");
248
+ if (isMain) {
249
+ const dryRun = process.argv.includes("--dry-run");
250
+ console.log("Generating decision drift...");
251
+ if (dryRun) console.log(" Mode: DRY RUN");
252
+ generateDecisionDrift({ dryRun });
253
+ }
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Experiment Decisions Generator
5
+ *
6
+ * Reads experiments.json, feedback-summary.json, and governance.json,
7
+ * evaluates active experiments for winner/loser/insufficient-data status,
8
+ * writes a separate decisions file. Does NOT modify experiments.json.
9
+ *
10
+ * Usage:
11
+ * node scripts/gen-experiment-decisions.mjs [--dry-run]
12
+ *
13
+ * Reads:
14
+ * site/src/data/experiments.json
15
+ * site/src/data/feedback-summary.json
16
+ * site/src/data/governance.json
17
+ *
18
+ * Writes:
19
+ * site/src/data/experiment-decisions.json
20
+ * site/public/lab/decisions/experiment-decisions.md
21
+ */
22
+
23
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
24
+ import { resolve, join } from "node:path";
25
+ import { getConfig, getRoot } from "./lib/config.mjs";
26
+
27
+ const ROOT = getRoot();
28
+ const config = getConfig();
29
+ const DATA_DIR = join(ROOT, config.paths.dataDir);
30
+ const PUBLIC_DIR = join(ROOT, config.paths.publicDir, "lab", "decisions");
31
+
32
+ // -- Helpers ---------------------------------------------------------
33
+
34
+ function safeParseJson(filePath, fallback = null) {
35
+ try {
36
+ return JSON.parse(readFileSync(filePath, "utf8"));
37
+ } catch {
38
+ return fallback;
39
+ }
40
+ }
41
+
42
+ // -- Core ------------------------------------------------------------
43
+
44
+ /**
45
+ * Evaluate active experiments against feedback data and governance thresholds.
46
+ *
47
+ * @param {{ schemaVersion?: number, experiments: Array<{ id: string, name: string, status: string, control: { key: string }, variant: { key: string } }> }} experiments
48
+ * @param {{ perExperiment: Record<string, Record<string, { sent: number, opened: number, replied: number, ignored: number, bounced: number }>> }} feedbackSummary
49
+ * @param {{ minExperimentDataThreshold: number }} governance
50
+ * @returns {{
51
+ * evaluations: Array<{
52
+ * experimentId: string,
53
+ * name: string,
54
+ * status: "needs-more-data"|"winner-found"|"no-decision",
55
+ * controlEntries: number,
56
+ * variantEntries: number,
57
+ * controlReplyRate: number,
58
+ * variantReplyRate: number,
59
+ * winnerKey: string|null,
60
+ * recommendation: string
61
+ * }>,
62
+ * warnings: string[]
63
+ * }}
64
+ */
65
+ export function evaluateExperiments(experiments, feedbackSummary, governance) {
66
+ const evaluations = [];
67
+ const warnings = [];
68
+
69
+ const threshold = governance.minExperimentDataThreshold || 10;
70
+ const perExp = feedbackSummary.perExperiment || {};
71
+ const allExperiments = experiments.experiments || [];
72
+
73
+ // Filter to active experiments only (skip draft and concluded)
74
+ const active = allExperiments.filter((exp) => exp.status === "active");
75
+
76
+ if (active.length === 0) {
77
+ warnings.push("No active experiments found");
78
+ }
79
+
80
+ for (const exp of active) {
81
+ const expData = perExp[exp.id];
82
+
83
+ // No feedback data at all for this experiment
84
+ if (!expData) {
85
+ evaluations.push({
86
+ experimentId: exp.id,
87
+ name: exp.name,
88
+ status: "needs-more-data",
89
+ controlEntries: 0,
90
+ variantEntries: 0,
91
+ controlReplyRate: 0,
92
+ variantReplyRate: 0,
93
+ winnerKey: null,
94
+ recommendation: `No feedback data yet for experiment ${exp.id}`,
95
+ });
96
+ continue;
97
+ }
98
+
99
+ const controlKey = exp.control.key;
100
+ const variantKey = exp.variant.key;
101
+
102
+ const controlCounts = expData[controlKey] || { sent: 0, opened: 0, replied: 0, ignored: 0, bounced: 0 };
103
+ const variantCounts = expData[variantKey] || { sent: 0, opened: 0, replied: 0, ignored: 0, bounced: 0 };
104
+
105
+ // Compute entry counts per arm: total = sum of all outcome fields
106
+ const controlEntries = controlCounts.sent + controlCounts.opened + controlCounts.replied + controlCounts.ignored + controlCounts.bounced;
107
+ const variantEntries = variantCounts.sent + variantCounts.opened + variantCounts.replied + variantCounts.ignored + variantCounts.bounced;
108
+
109
+ // Compute reply rates
110
+ const controlReplyRate = controlCounts.replied / Math.max(controlEntries, 1);
111
+ const variantReplyRate = variantCounts.replied / Math.max(variantEntries, 1);
112
+
113
+ // Insufficient data check
114
+ if (controlEntries < threshold || variantEntries < threshold) {
115
+ evaluations.push({
116
+ experimentId: exp.id,
117
+ name: exp.name,
118
+ status: "needs-more-data",
119
+ controlEntries,
120
+ variantEntries,
121
+ controlReplyRate: Math.round(controlReplyRate * 10000) / 10000,
122
+ variantReplyRate: Math.round(variantReplyRate * 10000) / 10000,
123
+ winnerKey: null,
124
+ recommendation: `Insufficient data: ${controlEntries} control, ${variantEntries} variant (threshold: ${threshold})`,
125
+ });
126
+ continue;
127
+ }
128
+
129
+ // Winner detection: variant outperforms control at 2x+
130
+ if (variantReplyRate > 0 && variantReplyRate / Math.max(controlReplyRate, 0.001) > 2) {
131
+ const ratio = Math.round(variantReplyRate / Math.max(controlReplyRate, 0.001) * 10) / 10;
132
+ evaluations.push({
133
+ experimentId: exp.id,
134
+ name: exp.name,
135
+ status: "winner-found",
136
+ controlEntries,
137
+ variantEntries,
138
+ controlReplyRate: Math.round(controlReplyRate * 10000) / 10000,
139
+ variantReplyRate: Math.round(variantReplyRate * 10000) / 10000,
140
+ winnerKey: variantKey,
141
+ recommendation: `Variant '${variantKey}' outperforms control at ${ratio}x reply rate`,
142
+ });
143
+ continue;
144
+ }
145
+
146
+ // Winner detection: control outperforms variant at 2x+
147
+ if (controlReplyRate > 0 && controlReplyRate / Math.max(variantReplyRate, 0.001) > 2) {
148
+ const ratio = Math.round(controlReplyRate / Math.max(variantReplyRate, 0.001) * 10) / 10;
149
+ evaluations.push({
150
+ experimentId: exp.id,
151
+ name: exp.name,
152
+ status: "winner-found",
153
+ controlEntries,
154
+ variantEntries,
155
+ controlReplyRate: Math.round(controlReplyRate * 10000) / 10000,
156
+ variantReplyRate: Math.round(variantReplyRate * 10000) / 10000,
157
+ winnerKey: controlKey,
158
+ recommendation: `Control '${controlKey}' outperforms variant at ${ratio}x reply rate`,
159
+ });
160
+ continue;
161
+ }
162
+
163
+ // No clear winner -- keep collecting
164
+ evaluations.push({
165
+ experimentId: exp.id,
166
+ name: exp.name,
167
+ status: "no-decision",
168
+ controlEntries,
169
+ variantEntries,
170
+ controlReplyRate: Math.round(controlReplyRate * 10000) / 10000,
171
+ variantReplyRate: Math.round(variantReplyRate * 10000) / 10000,
172
+ winnerKey: null,
173
+ recommendation: `Performance is similar (control: ${Math.round(controlReplyRate * 10000) / 10000}, variant: ${Math.round(variantReplyRate * 10000) / 10000}). Keep collecting data.`,
174
+ });
175
+ }
176
+
177
+ return { evaluations, warnings };
178
+ }
179
+
180
+ /**
181
+ * Generate markdown report from experiment evaluations.
182
+ *
183
+ * @param {{ evaluations: Array<object>, warnings: string[] }} result
184
+ * @returns {string} Markdown string
185
+ */
186
+ export function generateDecisionsMd(result) {
187
+ const lines = [];
188
+
189
+ lines.push("# Experiment Decisions Report");
190
+ lines.push("");
191
+ lines.push(`*Generated: ${new Date().toISOString().slice(0, 10)}*`);
192
+ lines.push("");
193
+
194
+ if (result.evaluations.length === 0) {
195
+ lines.push("No active experiments to evaluate.");
196
+ lines.push("");
197
+ } else {
198
+ lines.push("## Evaluations");
199
+ lines.push("");
200
+ lines.push("| Experiment | Status | Control (n) | Variant (n) | Control Rate | Variant Rate | Winner | Recommendation |");
201
+ lines.push("|------------|--------|-------------|-------------|--------------|--------------|--------|----------------|");
202
+
203
+ for (const ev of result.evaluations) {
204
+ const winner = ev.winnerKey || "--";
205
+ lines.push(`| ${ev.name} | ${ev.status} | ${ev.controlEntries} | ${ev.variantEntries} | ${ev.controlReplyRate} | ${ev.variantReplyRate} | ${winner} | ${ev.recommendation} |`);
206
+ }
207
+ lines.push("");
208
+ }
209
+
210
+ if (result.warnings.length > 0) {
211
+ lines.push("## Warnings");
212
+ lines.push("");
213
+ for (const w of result.warnings) {
214
+ lines.push(`- ${w}`);
215
+ }
216
+ lines.push("");
217
+ }
218
+
219
+ return lines.join("\n");
220
+ }
221
+
222
+ // -- Pipeline --------------------------------------------------------
223
+
224
+ /**
225
+ * Full pipeline: load data, evaluate experiments, write outputs.
226
+ *
227
+ * @param {{ dataDir?: string, publicDir?: string, dryRun?: boolean }} opts
228
+ * @returns {{ evaluationCount: number, outputPath: string }}
229
+ */
230
+ export function generateExperimentDecisions(opts = {}) {
231
+ const { dataDir = DATA_DIR, publicDir = PUBLIC_DIR, dryRun = false } = opts;
232
+
233
+ const experiments = safeParseJson(join(dataDir, "experiments.json"), { experiments: [] });
234
+ const feedbackSummary = safeParseJson(join(dataDir, "feedback-summary.json"), { perExperiment: {} });
235
+ const governance = safeParseJson(join(dataDir, "governance.json"), { minExperimentDataThreshold: 10 });
236
+
237
+ // Freeze check — preserve existing evaluations when frozen
238
+ const outputPath = join(dataDir, "experiment-decisions.json");
239
+ if (governance.experimentsFrozen === true) {
240
+ console.log(" Experiments frozen (governance.experimentsFrozen=true). Skipping regeneration.");
241
+ const existing = safeParseJson(outputPath, { evaluations: [] });
242
+ return { evaluationCount: (existing.evaluations || []).length, outputPath, frozen: true };
243
+ }
244
+
245
+ const result = evaluateExperiments(experiments, feedbackSummary, governance);
246
+
247
+ if (dryRun) {
248
+ console.log(` [dry-run] Would write ${result.evaluations.length} evaluations`);
249
+ for (const ev of result.evaluations) {
250
+ console.log(` [dry-run] ${ev.experimentId}: ${ev.status} -- ${ev.recommendation}`);
251
+ }
252
+ return { evaluationCount: result.evaluations.length, outputPath };
253
+ }
254
+
255
+ // Write experiment-decisions.json
256
+ const jsonOut = {
257
+ generatedAt: new Date().toISOString(),
258
+ ...result,
259
+ };
260
+ writeFileSync(outputPath, JSON.stringify(jsonOut, null, 2) + "\n", "utf8");
261
+ console.log(` Wrote experiment-decisions.json (${result.evaluations.length} evaluations)`);
262
+
263
+ // Write markdown report
264
+ mkdirSync(publicDir, { recursive: true });
265
+ const md = generateDecisionsMd(result);
266
+ writeFileSync(join(publicDir, "experiment-decisions.md"), md, "utf8");
267
+ console.log(` Wrote experiment-decisions.md`);
268
+
269
+ return { evaluationCount: result.evaluations.length, outputPath };
270
+ }
271
+
272
+ // -- Entry point -----------------------------------------------------
273
+
274
+ const isMain = process.argv[1] && resolve(process.argv[1]).endsWith("gen-experiment-decisions.mjs");
275
+
276
+ if (isMain) {
277
+ const dryRun = process.argv.includes("--dry-run");
278
+ console.log("Evaluating experiments...");
279
+ if (dryRun) console.log(" Mode: DRY RUN");
280
+ const result = generateExperimentDecisions({ dryRun });
281
+ console.log(` Evaluations: ${result.evaluationCount}`);
282
+ }