@officexapp/vidfarm-devcli 0.21.37 → 0.21.38

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,685 @@
1
+ // Experiments — Vidfarm's wrapper over the EXPERIMENTS_DIARY.md ledger format.
2
+ //
3
+ // An ad campaign is an evolution, not a delivery: rounds of videos that vary ONE
4
+ // composition param (structured) or vary everything (creative), posted across
5
+ // however many channels the director holds, then read back against one north-star
6
+ // metric. The method lives at `https://vidfarm.cc/experiment.md`; the ledger lives
7
+ // at the work root as one markdown file, `EXPERIMENTS_DIARY.md`.
8
+ //
9
+ // This module owns ONLY the parts nothing else in the CLI owns:
10
+ // 1. the ledger format (parse / scaffold / append),
11
+ // 2. the arithmetic (capacity → epochs, median, outliers),
12
+ // 3. the method lint (two variables in one round, a winner off one post, …).
13
+ // It deliberately does NOT wrap posting, channels, briefs or constants — those are
14
+ // `vidfarm schedule`, `vidfarm channels`, `vidfarm handoff` and `vidfarm harness`
15
+ // already, and duplicating them would give an agent two ways to do one thing.
16
+ //
17
+ // Backend-free (Node built-ins only) so it ships in the public cloud-only CLI, and
18
+ // LENIENT like storyboard.ts — a half-written diary is still worth reading, so
19
+ // nothing here throws on a malformed file; it reports warnings.
20
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
21
+ import path from "node:path";
22
+ /** Canonical filename for the ledger at a work root. */
23
+ export const EXPERIMENTS_FILENAME = "EXPERIMENTS_DIARY.md";
24
+ export const EXPERIMENT_MODES = ["creative", "structured"];
25
+ /** The four core metrics, in funnel order. Any of them can be the north star. */
26
+ export const CORE_METRICS = ["views", "comments", "clicks", "buys"];
27
+ export const DEFAULT_METRIC = "comments";
28
+ /** A variant only counts as tested once it has this many posts behind it. One
29
+ * post is noise in short form, so nothing is promoted off a single result. */
30
+ export const MIN_POSTS_PER_VARIANT = 2;
31
+ /** Beat the round median by this much to be an outlier worth chasing. */
32
+ export const DEFAULT_OUTLIER_RATIO = 3;
33
+ const ROUND_HEADING_RE = /^##[ \t]+round\b/i;
34
+ const EPOCH_HEADING_RE = /^###[ \t]+epoch\b/i;
35
+ const RESULTS_HEADING_RE = /^###[ \t]+results?\b/i;
36
+ const SETUP_HEADING_RE = /^##[ \t]+setup\b/i;
37
+ const ANY_H2_RE = /^##[ \t]+/;
38
+ const ANY_H3_RE = /^###[ \t]+/;
39
+ // A metadata list item. The key half deliberately accepts anything but a colon,
40
+ // because the template's own keys carry parentheses and slashes — e.g.
41
+ // "- Channels (capacity 4/epoch): a, b, c" and
42
+ // "- North-star metric: comments (secondary: views, clicks)".
43
+ const META_RE = /^\s*[-*]\s+([^:|]{1,60}?)\s*:\s*(.+?)\s*$/;
44
+ const TABLE_ROW_RE = /^\s*\|(.+)\|\s*$/;
45
+ const TABLE_SEP_RE = /^\s*\|[\s:|-]+\|\s*$/;
46
+ const LEADING_INT_RE = /^(\d+)/;
47
+ const TITLE_SEP_RE = /^[\s.:—-]+/;
48
+ const DATE_RE = /(\d{4}-\d{2}-\d{2})/;
49
+ const AGE_RE = /\bage\s*:?\s*([0-9]+\s*(?:h|hr|hrs|hours|d|day|days|w|wk|weeks)\b)/i;
50
+ const SOURCE_RE = /\bsource\s*:?\s*([^,·|]+)/i;
51
+ // A "posted" cell is written by hand as often as by `experiment log --posted`, so
52
+ // accept a tick, a word, or a bare date — and accept them WITH a date appended
53
+ // ("✅ 2026-08-15"), which is what this CLI itself writes.
54
+ const TRUTHY_CELL = /(?:✅|✔|\byes\b|\btrue\b|\bdone\b|\bposted\b|\blive\b|^\s*x\s*$|\d{4}-\d{2}-\d{2})/i;
55
+ function splitList(value) {
56
+ return value
57
+ .split(/\s*[;,]\s*/)
58
+ .map((s) => s.trim())
59
+ .filter(Boolean);
60
+ }
61
+ function cells(line) {
62
+ const m = TABLE_ROW_RE.exec(line);
63
+ if (!m)
64
+ return [];
65
+ return (m[1] ?? "").split("|").map((c) => c.trim());
66
+ }
67
+ /** "14,200" → 14200 · "—" / "" / "n/a" → undefined. Diaries are hand-pasted, so
68
+ * thousands separators and em-dash placeholders are normal, not errors. */
69
+ function parseMetricNumber(raw) {
70
+ const t = raw.replace(/[,\s_]/g, "");
71
+ if (!t || /^(?:—|-|–|n\/a|na|\?)$/i.test(t))
72
+ return undefined;
73
+ const n = Number.parseFloat(t.replace(/[^0-9.eE+-]/g, ""));
74
+ return Number.isFinite(n) ? n : undefined;
75
+ }
76
+ function isMode(value) {
77
+ return EXPERIMENT_MODES.includes(value);
78
+ }
79
+ /**
80
+ * Parse an `EXPERIMENTS_DIARY.md`. Tolerant by design: unknown keys are kept in
81
+ * `extra`, malformed tables degrade to zero rows, and anything surprising becomes
82
+ * a warning. A diary an agent half-wrote by hand must still read back.
83
+ */
84
+ export function parseDiary(source) {
85
+ const warnings = [];
86
+ const lines = source.split(/\r?\n/);
87
+ const setup = { secondary: [], channels: [], extra: {} };
88
+ const rounds = [];
89
+ let inSetup = false;
90
+ let round = null;
91
+ let epoch = null;
92
+ let results = null;
93
+ let tableHeader = null;
94
+ const closeTable = () => {
95
+ tableHeader = null;
96
+ };
97
+ for (let i = 0; i < lines.length; i++) {
98
+ const line = lines[i] ?? "";
99
+ const lineNo = i + 1;
100
+ if (SETUP_HEADING_RE.test(line)) {
101
+ inSetup = true;
102
+ round = null;
103
+ epoch = null;
104
+ results = null;
105
+ closeTable();
106
+ continue;
107
+ }
108
+ if (ROUND_HEADING_RE.test(line)) {
109
+ inSetup = false;
110
+ epoch = null;
111
+ results = null;
112
+ closeTable();
113
+ const headingText = line.replace(/^##[ \t]+round\b/i, "").replace(TITLE_SEP_RE, "").trim();
114
+ round = {
115
+ index: rounds.length + 1,
116
+ constants: [],
117
+ epochs: [],
118
+ results: [],
119
+ extra: {},
120
+ line: lineNo,
121
+ endLine: lineNo
122
+ };
123
+ const intMatch = LEADING_INT_RE.exec(headingText);
124
+ if (intMatch) {
125
+ round.number = Number.parseInt(intMatch[1] ?? "", 10);
126
+ const rest = headingText.slice((intMatch[0] ?? "").length).replace(TITLE_SEP_RE, "").trim();
127
+ if (rest)
128
+ round.title = rest;
129
+ }
130
+ else if (headingText) {
131
+ round.title = headingText;
132
+ }
133
+ rounds.push(round);
134
+ continue;
135
+ }
136
+ // Any other H2 closes the current round.
137
+ if (ANY_H2_RE.test(line)) {
138
+ inSetup = false;
139
+ round = null;
140
+ epoch = null;
141
+ results = null;
142
+ closeTable();
143
+ continue;
144
+ }
145
+ if (round && EPOCH_HEADING_RE.test(line)) {
146
+ results = null;
147
+ closeTable();
148
+ const text = line.replace(/^###[ \t]+epoch\b/i, "").replace(TITLE_SEP_RE, "").trim();
149
+ epoch = { label: text || undefined, date: DATE_RE.exec(text)?.[1], rows: [], line: lineNo, endLine: lineNo };
150
+ round.epochs.push(epoch);
151
+ round.endLine = lineNo;
152
+ continue;
153
+ }
154
+ if (round && RESULTS_HEADING_RE.test(line)) {
155
+ epoch = null;
156
+ closeTable();
157
+ const text = line.replace(/^###[ \t]+results?\b/i, "").replace(TITLE_SEP_RE, "").trim();
158
+ results = {
159
+ readDate: DATE_RE.exec(text)?.[1],
160
+ source: SOURCE_RE.exec(text)?.[1]?.trim(),
161
+ age: AGE_RE.exec(text)?.[1]?.replace(/\s+/g, ""),
162
+ rows: [],
163
+ line: lineNo,
164
+ endLine: lineNo
165
+ };
166
+ round.results.push(results);
167
+ round.endLine = lineNo;
168
+ continue;
169
+ }
170
+ if (round && ANY_H3_RE.test(line)) {
171
+ epoch = null;
172
+ results = null;
173
+ closeTable();
174
+ round.endLine = lineNo;
175
+ continue;
176
+ }
177
+ // ---- table rows ---------------------------------------------------------
178
+ if ((epoch || results) && TABLE_ROW_RE.test(line)) {
179
+ if (TABLE_SEP_RE.test(line))
180
+ continue;
181
+ const row = cells(line);
182
+ if (!tableHeader) {
183
+ tableHeader = row.map((c) => c.toLowerCase());
184
+ if (epoch)
185
+ epoch.endLine = lineNo;
186
+ if (results)
187
+ results.endLine = lineNo;
188
+ if (round)
189
+ round.endLine = lineNo;
190
+ continue;
191
+ }
192
+ const get = (...names) => {
193
+ for (const name of names) {
194
+ const idx = tableHeader?.indexOf(name) ?? -1;
195
+ if (idx >= 0 && row[idx] !== undefined)
196
+ return row[idx];
197
+ }
198
+ return undefined;
199
+ };
200
+ if (epoch) {
201
+ const postedCell = (get("posted", "live", "status") ?? "").trim();
202
+ epoch.rows.push({
203
+ slot: get("slot"),
204
+ video: get("video", "id", "clip"),
205
+ variant: get("variant", "angle", "format", "hook", "testing"),
206
+ channel: get("channel", "account", "destination"),
207
+ posted: TRUTHY_CELL.test(postedCell),
208
+ postedNote: postedCell || undefined,
209
+ line: lineNo
210
+ });
211
+ epoch.endLine = lineNo;
212
+ }
213
+ else if (results) {
214
+ const video = (get("video", "id", "clip") ?? "").trim();
215
+ const metrics = {};
216
+ tableHeader.forEach((name, idx) => {
217
+ if (!name || name === "video" || name === "id" || name === "clip" || name === "note" || name === "notes")
218
+ return;
219
+ const value = parseMetricNumber(row[idx] ?? "");
220
+ if (value !== undefined)
221
+ metrics[name] = value;
222
+ });
223
+ if (video) {
224
+ results.rows.push({ video, metrics, note: get("note", "notes"), line: lineNo });
225
+ }
226
+ else {
227
+ warnings.push({ message: "Result row with no video id — skipped.", line: lineNo });
228
+ }
229
+ results.endLine = lineNo;
230
+ }
231
+ if (round)
232
+ round.endLine = lineNo;
233
+ continue;
234
+ }
235
+ if (tableHeader && line.trim() === "")
236
+ closeTable();
237
+ // ---- metadata list items ------------------------------------------------
238
+ const meta = META_RE.exec(line);
239
+ if (meta) {
240
+ // "Channels (capacity 4/epoch)" → "channels"; "**Win condition**" → "win_condition".
241
+ const key = (meta[1] ?? "")
242
+ .toLowerCase()
243
+ .replace(/\([^)]*\)/g, "")
244
+ .replace(/\*/g, "")
245
+ .trim()
246
+ .replace(/[\s-]+/g, "_");
247
+ const value = (meta[2] ?? "").replace(/\*\*/g, "").trim();
248
+ if (inSetup) {
249
+ if (key.startsWith("north") || key === "metric" || key === "kpi") {
250
+ // "comments (secondary: views, clicks)"
251
+ const secondary = /\(secondary:\s*([^)]+)\)/i.exec(value);
252
+ if (secondary)
253
+ setup.secondary = splitList(secondary[1] ?? "");
254
+ setup.metric = value.replace(/\(secondary:[^)]*\)/i, "").trim().toLowerCase();
255
+ }
256
+ else if (key === "channels" || key.startsWith("channels")) {
257
+ const inner = /\(([^)]*)\)/.exec(value);
258
+ setup.channels = splitList(value.replace(/\([^)]*\)/g, ""));
259
+ if (inner && setup.channels.length === 0)
260
+ setup.channels = splitList(inner[1] ?? "");
261
+ }
262
+ else if (key === "mode") {
263
+ const normalized = value.toLowerCase();
264
+ if (isMode(normalized))
265
+ setup.mode = normalized;
266
+ else {
267
+ setup.extra.mode = value;
268
+ warnings.push({ message: `Unknown mode "${value}" — expected creative or structured.`, line: lineNo });
269
+ }
270
+ }
271
+ else if (key === "product")
272
+ setup.product = value;
273
+ else if (key.startsWith("baseline"))
274
+ setup.baseline = value;
275
+ else if (key === "editors" || key === "edited_by")
276
+ setup.editors = value;
277
+ else if (key.startsWith("analytics") || key === "source")
278
+ setup.source = value;
279
+ else
280
+ setup.extra[key] = value;
281
+ continue;
282
+ }
283
+ if (round) {
284
+ round.endLine = lineNo;
285
+ if (key === "mode") {
286
+ const normalized = value.toLowerCase().split(/[\s·|]/)[0] ?? "";
287
+ if (isMode(normalized))
288
+ round.mode = normalized;
289
+ // A single "- Mode: structured · Variable: angle · Constants: …" line is
290
+ // the compact form the doc's template uses. Split it out.
291
+ const compact = value.split(/\s*[·|]\s*/).slice(1);
292
+ for (const part of compact) {
293
+ const colon = part.indexOf(":");
294
+ if (colon === -1)
295
+ continue;
296
+ const k = part.slice(0, colon).trim().toLowerCase();
297
+ const v = part.slice(colon + 1).trim();
298
+ if (k === "variable")
299
+ round.variable = v;
300
+ else if (k === "constants")
301
+ round.constants = splitList(v);
302
+ else
303
+ round.extra[k] = v;
304
+ }
305
+ }
306
+ else if (key === "variable")
307
+ round.variable = value;
308
+ else if (key === "constants")
309
+ round.constants = splitList(value);
310
+ else if (key.startsWith("justification") || key === "why")
311
+ round.justification = value;
312
+ else if (key.startsWith("win"))
313
+ round.win = value;
314
+ else if (key === "videos") {
315
+ const n = parseMetricNumber(value.split(/[^\d,]/)[0] ?? value);
316
+ if (n !== undefined)
317
+ round.videos = n;
318
+ else
319
+ round.extra.videos = value;
320
+ }
321
+ else if (key === "editors" || key === "edited_by")
322
+ round.editors = value;
323
+ else if (key === "finding" || key === "findings")
324
+ round.finding = value;
325
+ else if (key === "next")
326
+ round.next = value;
327
+ else
328
+ round.extra[key] = value;
329
+ continue;
330
+ }
331
+ }
332
+ // Bold prose lines the template uses for conclusions: "**Finding:** …".
333
+ if (round) {
334
+ const bold = /^\s*\*\*(finding|next)s?:?\*\*\s*(.+?)\s*$/i.exec(line);
335
+ if (bold) {
336
+ const key = (bold[1] ?? "").toLowerCase();
337
+ if (key === "finding")
338
+ round.finding = (bold[2] ?? "").trim();
339
+ else
340
+ round.next = (bold[2] ?? "").trim();
341
+ round.endLine = lineNo;
342
+ }
343
+ }
344
+ }
345
+ return { setup, rounds, warnings };
346
+ }
347
+ /** Read + parse a work root's diary. Missing file is NOT an error — it returns
348
+ * `exists:false` so a caller can say "no campaign yet" and offer `--init`. */
349
+ export function readDiary(dir) {
350
+ const absPath = path.join(dir, EXPERIMENTS_FILENAME);
351
+ if (!existsSync(absPath)) {
352
+ return {
353
+ exists: false,
354
+ path: EXPERIMENTS_FILENAME,
355
+ absPath,
356
+ manifest: { setup: { secondary: [], channels: [], extra: {} }, rounds: [], warnings: [] }
357
+ };
358
+ }
359
+ return { exists: true, path: EXPERIMENTS_FILENAME, absPath, manifest: parseDiary(readFileSync(absPath, "utf8")) };
360
+ }
361
+ // ── Arithmetic ───────────────────────────────────────────────────────────────
362
+ // The part a weaker model gets quietly wrong: capacity, epochs, medians, and
363
+ // which number actually beat the pack.
364
+ /** Videos per epoch = channels held. One channel carries about one test post a
365
+ * day before it reads as spam. */
366
+ export function capacityOf(setup) {
367
+ return setup.channels.length;
368
+ }
369
+ export function epochsNeeded(videos, capacity) {
370
+ if (!Number.isFinite(videos) || videos <= 0)
371
+ return 0;
372
+ if (!Number.isFinite(capacity) || capacity <= 0)
373
+ return Number.POSITIVE_INFINITY;
374
+ return Math.ceil(videos / capacity);
375
+ }
376
+ export function median(values) {
377
+ const sorted = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b);
378
+ if (sorted.length === 0)
379
+ return undefined;
380
+ const mid = Math.floor(sorted.length / 2);
381
+ return sorted.length % 2 === 1 ? sorted[mid] : ((sorted[mid - 1] + sorted[mid]) / 2);
382
+ }
383
+ export function analyzeRound(round, setup, opts) {
384
+ const metric = (opts?.metric ?? setup.metric ?? DEFAULT_METRIC).toLowerCase();
385
+ const outlierRatio = opts?.outlierRatio ?? DEFAULT_OUTLIER_RATIO;
386
+ const capacity = capacityOf(setup);
387
+ const slotRows = round.epochs.flatMap((e) => e.rows);
388
+ const variantOf = new Map();
389
+ const postsOf = new Map();
390
+ for (const row of slotRows) {
391
+ if (!row.video)
392
+ continue;
393
+ if (row.variant)
394
+ variantOf.set(row.video, row.variant);
395
+ if (row.posted)
396
+ postsOf.set(row.video, (postsOf.get(row.video) ?? 0) + 1);
397
+ }
398
+ const resultRows = round.results.flatMap((block) => block.rows.map((row) => ({ row, block })));
399
+ const scored = [];
400
+ for (const { row, block } of resultRows) {
401
+ const value = row.metrics[metric];
402
+ if (value === undefined)
403
+ continue;
404
+ scored.push({
405
+ video: row.video,
406
+ value,
407
+ variant: variantOf.get(row.video),
408
+ posts: postsOf.get(row.video) ?? 0,
409
+ age: block.age,
410
+ source: block.source
411
+ });
412
+ }
413
+ const med = median(scored.map((s) => s.value));
414
+ for (const s of scored)
415
+ s.ratio = med && med > 0 ? s.value / med : undefined;
416
+ const ranked = [...scored].sort((a, b) => b.value - a.value);
417
+ const reported = new Set(resultRows.map(({ row }) => row.video));
418
+ return {
419
+ round,
420
+ metric,
421
+ capacity,
422
+ videosPlanned: round.videos,
423
+ epochsNeeded: round.videos !== undefined ? epochsNeeded(round.videos, capacity) : undefined,
424
+ epochsRun: round.epochs.length,
425
+ postedCount: slotRows.filter((r) => r.posted).length,
426
+ // Every planned slot still dark, across all epochs — not just the newest one.
427
+ unposted: slotRows.filter((r) => !r.posted && r.video).map((r) => r.video),
428
+ awaiting: slotRows.filter((r) => r.posted && r.video && !reported.has(r.video)).map((r) => r.video),
429
+ scored: ranked,
430
+ median: med,
431
+ outliers: med && med > 0 ? ranked.filter((s) => (s.ratio ?? 0) >= outlierRatio) : [],
432
+ weakest: ranked[ranked.length - 1],
433
+ readContexts: Array.from(new Set(round.results.map((b) => `${b.source ?? "?"}@${b.age ?? "?"}`)))
434
+ };
435
+ }
436
+ export function lintDiary(manifest, analyses) {
437
+ const findings = [];
438
+ const { setup, rounds } = manifest;
439
+ const capacity = capacityOf(setup);
440
+ if (!setup.metric) {
441
+ findings.push({
442
+ level: "warn",
443
+ code: "no-north-star",
444
+ message: "No north-star metric in Setup — every round is then judged by vibes.",
445
+ fix: `Add "- North-star metric: ${DEFAULT_METRIC}" (comments is the richest early-stage intel).`
446
+ });
447
+ }
448
+ if (capacity === 0) {
449
+ findings.push({
450
+ level: "warn",
451
+ code: "no-channels",
452
+ message: "No channels in Setup, so testing capacity is unknown and no round can be sized.",
453
+ fix: "Run `vidfarm channels` and list them: \"- Channels (capacity N/epoch): a, b, c\"."
454
+ });
455
+ }
456
+ const buysTotal = analyses
457
+ .filter((a) => a.metric === "buys")
458
+ .flatMap((a) => a.scored.map((s) => s.value))
459
+ .reduce((sum, v) => sum + v, 0);
460
+ if ((setup.metric ?? "").includes("buy") && buysTotal < 10) {
461
+ findings.push({
462
+ level: "warn",
463
+ code: "end-of-funnel-north-star",
464
+ message: `North star is buys, but only ${buysTotal} recorded so far — at this volume every round is statistically indistinguishable from noise.`,
465
+ fix: "Optimize views/comments/clicks until traffic is real, then switch to buys. If the director heard this and still wants buys, keep going and note it here."
466
+ });
467
+ }
468
+ for (const analysis of analyses) {
469
+ const round = analysis.round;
470
+ const at = { round: round.number ?? round.index, line: round.line };
471
+ const mode = round.mode ?? setup.mode;
472
+ if (mode === "structured") {
473
+ const variables = round.variable ? splitList(round.variable.replace(/\s+and\s+/gi, ",")).filter((v) => v.length > 0) : [];
474
+ if (variables.length > 1) {
475
+ findings.push({
476
+ ...at,
477
+ level: "error",
478
+ code: "two-variables",
479
+ message: `Round ${at.round} is structured but varies ${variables.length} params (${variables.join(", ")}) — the result is unreadable.`,
480
+ fix: "Split it into parallel rounds on separate channel slots, one variable each."
481
+ });
482
+ }
483
+ if (!round.variable) {
484
+ findings.push({ ...at, level: "error", code: "no-variable", message: `Round ${at.round} is structured but names no variable.`, fix: "Add \"- Variable: <param>\"." });
485
+ }
486
+ if (round.constants.length === 0) {
487
+ findings.push({
488
+ ...at,
489
+ level: "warn",
490
+ code: "no-constants",
491
+ message: `Round ${at.round} is structured but pins no constants, so nothing is actually held.`,
492
+ fix: "Write them down and enforce them: `vidfarm harness init short-form --out ./work/HARNESS.md`, then `vidfarm qa ./work --harness ./work/HARNESS.md`."
493
+ });
494
+ }
495
+ const editors = round.editors ?? setup.editors ?? "";
496
+ if (/gig|crowd|task ?force|worker/i.test(editors)) {
497
+ findings.push({
498
+ ...at,
499
+ level: "error",
500
+ code: "structured-with-gigworkers",
501
+ message: `Round ${at.round} is structured but is edited by a distributed task force — between-editor variance swamps the effect being measured.`,
502
+ fix: "Run structured rounds with your own agent, or ship every worker the identical base fork and let them change only the one variable. Creative Mode is what gigworkers are for."
503
+ });
504
+ }
505
+ }
506
+ if (!round.win && analysis.scored.length === 0) {
507
+ findings.push({ ...at, level: "warn", code: "no-win-condition", message: `Round ${at.round} declares no win condition, and results are not in yet.`, fix: "Decide the number BEFORE posting: \"- Win condition: any variant at >=3x median comments\"." });
508
+ }
509
+ if (analysis.readContexts.length > 1) {
510
+ findings.push({
511
+ ...at,
512
+ level: "error",
513
+ code: "mixed-read-ages",
514
+ message: `Round ${at.round} mixes results read at different times/sources (${analysis.readContexts.join(", ")}) — a 24h number is not comparable to a 7d one.`,
515
+ fix: "Re-read the whole round at one age, or compare only within a single results block."
516
+ });
517
+ }
518
+ for (const outlier of analysis.outliers) {
519
+ if (outlier.posts < MIN_POSTS_PER_VARIANT) {
520
+ findings.push({
521
+ ...at,
522
+ level: "warn",
523
+ code: "single-post-winner",
524
+ message: `${outlier.video} looks like an outlier (${outlier.ratio?.toFixed(1)}x median ${analysis.metric}) off ${outlier.posts || "?"} post(s).`,
525
+ fix: `Post it again before you promote it — ${MIN_POSTS_PER_VARIANT} minimum, 3 is better.`
526
+ });
527
+ }
528
+ }
529
+ if (capacity > 0 && analysis.epochsRun > 0) {
530
+ const latest = round.epochs[round.epochs.length - 1];
531
+ const used = latest?.rows.length ?? 0;
532
+ if (used > 0 && used < capacity) {
533
+ findings.push({
534
+ ...at,
535
+ level: "info",
536
+ code: "unspent-capacity",
537
+ message: `Latest epoch of round ${at.round} uses ${used} of ${capacity} slots — ${capacity - used} free.`,
538
+ fix: "Fill them: more samples of this variable, a parallel round on another param (formats or hooks are the usual next), or a one-off theory logged as a one-off."
539
+ });
540
+ }
541
+ }
542
+ if (analysis.awaiting.length > 0) {
543
+ findings.push({ ...at, level: "info", code: "awaiting-results", message: `Round ${at.round}: ${analysis.awaiting.length} posted video(s) have no results logged (${analysis.awaiting.join(", ")}).`, fix: "Log them: `vidfarm experiment log <video> --views N --comments N --source <where> --age 48h`." });
544
+ }
545
+ }
546
+ if (rounds.length > 1) {
547
+ const structured = rounds.filter((r) => (r.mode ?? setup.mode) === "structured").length;
548
+ if (structured === rounds.length && !rounds.some((r) => r.finding)) {
549
+ findings.push({ level: "info", code: "all-structured-no-findings", message: "Every round is structured and none records a finding — structured rounds only pay off when you write down what they proved.", fix: "Add \"**Finding:** …\" to each finished round." });
550
+ }
551
+ }
552
+ return findings;
553
+ }
554
+ export function renderDiaryScaffold(input) {
555
+ const channels = input.channels ?? [];
556
+ const metric = input.metric ?? DEFAULT_METRIC;
557
+ const secondary = input.secondary?.length ? input.secondary : CORE_METRICS.filter((m) => m !== metric).slice(0, 2);
558
+ return [
559
+ `# Experiments Diary — ${input.product ?? "<product>"}`,
560
+ "",
561
+ `Method: https://vidfarm.cc/experiment.md · started ${input.date ?? ""}`.trim(),
562
+ "",
563
+ "## Setup",
564
+ `- North-star metric: ${metric} (secondary: ${secondary.join(", ")})`,
565
+ `- Channels (capacity ${channels.length || "?"}/epoch): ${channels.length ? channels.join(", ") : "<run `vidfarm channels` and list them>"}`,
566
+ `- Mode: ${input.mode ?? "creative"}`,
567
+ `- Editors: ${input.editors ?? "agent"}`,
568
+ `- Baseline checkpoint: ${input.baseline ?? "kinetic captions over b-roll, no VO, ~20s"}`,
569
+ `- Analytics source: ${input.source ?? "<flockposter | manual | email-channel | gigworkers>"}`,
570
+ "",
571
+ "Append only. Never rewrite history here — correct it with a later entry.",
572
+ ""
573
+ ].join("\n");
574
+ }
575
+ export function renderRoundScaffold(input) {
576
+ const videos = input.videos ?? input.slots?.length ?? 0;
577
+ const epochs = epochsNeeded(videos, input.capacity);
578
+ const slots = input.slots?.length
579
+ ? input.slots
580
+ : Array.from({ length: videos }, (_, i) => `v${String(i + 1).padStart(3, "0")}`);
581
+ const channels = input.channels ?? [];
582
+ const perEpoch = input.capacity > 0 ? input.capacity : slots.length;
583
+ const epochBlocks = [];
584
+ for (let e = 0; e < Math.max(1, Number.isFinite(epochs) ? epochs : 1); e++) {
585
+ const slice = slots.slice(e * perEpoch, (e + 1) * perEpoch);
586
+ if (slice.length === 0)
587
+ break;
588
+ epochBlocks.push([
589
+ `### Epoch ${e + 1} — ${e === 0 ? (input.date ?? "<date>") : "<date>"}`,
590
+ "",
591
+ "| slot | video | variant | channel | posted |",
592
+ "|---|---|---|---|---|",
593
+ ...slice.map((raw, i) => {
594
+ const [video, variant] = raw.split("|").map((s) => s.trim());
595
+ return `| ${i + 1} | ${video ?? ""} | ${variant ?? ""} | ${channels[i % Math.max(1, channels.length)] ?? ""} | |`;
596
+ }),
597
+ ""
598
+ ].join("\n"));
599
+ }
600
+ return [
601
+ `## Round ${input.number}${input.title ? ` — ${input.title}` : input.variable ? ` — ${input.variable}` : ""}`,
602
+ `- Mode: ${input.mode ?? "creative"}`,
603
+ ...(input.variable ? [`- Variable: ${input.variable}`] : []),
604
+ `- Constants: ${input.constants?.length ? input.constants.join(", ") : "—"}`,
605
+ `- Videos: ${videos} · Capacity ${input.capacity || "?"}/epoch → ${Number.isFinite(epochs) ? epochs : "?"} epoch(s)`,
606
+ `- Editors: ${input.editors ?? "agent"}`,
607
+ `- Justification: ${input.justification ?? "<why this variable is worth the capacity before the others>"}`,
608
+ `- Win condition: ${input.win ?? `any variant at >=${DEFAULT_OUTLIER_RATIO}x median`}`,
609
+ "",
610
+ ...epochBlocks,
611
+ ""
612
+ ].join("\n");
613
+ }
614
+ /** Splice text into a file's line array at `afterLine` (1-indexed; 0 = top). */
615
+ function spliceLines(source, afterLine, block) {
616
+ const lines = source.split(/\r?\n/);
617
+ const at = Math.max(0, Math.min(lines.length, afterLine));
618
+ const insert = block.replace(/\s+$/, "").split("\n");
619
+ lines.splice(at, 0, ...insert, "");
620
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n");
621
+ }
622
+ /** Append a round block at the end of the diary. */
623
+ export function appendRound(source, input) {
624
+ const body = source.replace(/\s+$/, "");
625
+ return `${body}\n\n${renderRoundScaffold(input).replace(/\s+$/, "")}\n`;
626
+ }
627
+ /** Append a result row to a round, reusing an existing results block when its
628
+ * (date, source, age) match — because rows read at different ages must never
629
+ * land in the same table. Returns the new file text. */
630
+ export function appendResult(source, manifest, roundIndex, input) {
631
+ const round = manifest.rounds[roundIndex];
632
+ if (!round)
633
+ throw new Error("No such round in the diary.");
634
+ const columns = Object.keys(input.metrics);
635
+ const match = round.results.find((b) => b.readDate === input.date && (b.source ?? "") === (input.source ?? "") && (b.age ?? "") === (input.age ?? ""));
636
+ if (match && match.rows.length > 0) {
637
+ // Reuse the block's own column order so the table stays aligned.
638
+ const header = manifest.rounds[roundIndex]?.results.find((b) => b === match);
639
+ const known = header ? Object.keys(match.rows[0]?.metrics ?? {}) : columns;
640
+ const ordered = known.length ? known : columns;
641
+ const row = `| ${input.video} | ${ordered.map((c) => (input.metrics[c] !== undefined ? String(input.metrics[c]) : "")).join(" | ")} | ${input.note ?? ""} |`;
642
+ return { text: spliceLines(source, match.endLine, row), block: "existing" };
643
+ }
644
+ const ordered = columns.length ? columns : [...CORE_METRICS];
645
+ const block = [
646
+ `### Results — read ${input.date}${input.source ? `, source: ${input.source}` : ""}${input.age ? `, age: ${input.age}` : ""}`,
647
+ "",
648
+ `| video | ${ordered.join(" | ")} | note |`,
649
+ `|---|${ordered.map(() => "---|").join("")}---|`,
650
+ `| ${input.video} | ${ordered.map((c) => (input.metrics[c] !== undefined ? String(input.metrics[c]) : "")).join(" | ")} | ${input.note ?? ""} |`
651
+ ].join("\n");
652
+ return { text: spliceLines(source, round.endLine, `\n${block}`), block: "new" };
653
+ }
654
+ /** Mark a planned slot as posted. This records what already happened — the
655
+ * posting itself is `vidfarm approve` + `vidfarm schedule`. */
656
+ export function markPosted(source, manifest, video, opts) {
657
+ for (const round of manifest.rounds) {
658
+ for (const epoch of round.epochs) {
659
+ const row = epoch.rows.find((r) => r.video === video);
660
+ if (!row)
661
+ continue;
662
+ const lines = source.split(/\r?\n/);
663
+ const raw = lines[row.line - 1] ?? "";
664
+ const parts = cells(raw);
665
+ if (parts.length >= 5) {
666
+ if (opts.channel)
667
+ parts[3] = opts.channel;
668
+ parts[4] = `✅ ${opts.date}`;
669
+ lines[row.line - 1] = `| ${parts.join(" | ")} |`;
670
+ return { text: lines.join("\n"), line: row.line };
671
+ }
672
+ }
673
+ }
674
+ throw new Error(`No planned slot for "${video}" in the diary. Add the round first: vidfarm experiment round …`);
675
+ }
676
+ export function findRoundIndex(manifest, wanted) {
677
+ if (wanted === undefined)
678
+ return manifest.rounds.length - 1;
679
+ const idx = manifest.rounds.findIndex((r) => (r.number ?? r.index) === wanted);
680
+ return idx;
681
+ }
682
+ export function writeDiary(absPath, text) {
683
+ writeFileSync(absPath, text.endsWith("\n") ? text : `${text}\n`, "utf8");
684
+ }
685
+ //# sourceMappingURL=experiments.js.map