@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.
- package/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/bin/promo-kit.mjs +150 -0
- package/index.mjs +9 -0
- package/kit.config.example.json +19 -0
- package/package.json +45 -0
- package/scripts/apply-control-patch.mjs +205 -0
- package/scripts/apply-submission-status.mjs +225 -0
- package/scripts/gen-baseline.mjs +402 -0
- package/scripts/gen-decision-drift.mjs +253 -0
- package/scripts/gen-experiment-decisions.mjs +282 -0
- package/scripts/gen-feedback-summary.mjs +278 -0
- package/scripts/gen-promo-decisions.mjs +507 -0
- package/scripts/gen-queue-health.mjs +223 -0
- package/scripts/gen-recommendation-patch.mjs +352 -0
- package/scripts/gen-recommendations.mjs +409 -0
- package/scripts/gen-telemetry-aggregate.mjs +266 -0
- package/scripts/gen-trust-receipt.mjs +184 -0
- package/scripts/kit-bootstrap.mjs +246 -0
- package/scripts/kit-migrate.mjs +111 -0
- package/scripts/kit-selftest.mjs +207 -0
- package/scripts/lib/config.mjs +124 -0
|
@@ -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
|
+
}
|