@officexapp/vidfarm-devcli 0.21.37 → 0.21.39

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,906 @@
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/experiments.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
+ /** The starting format menu, offered during planning — NOT discovered at build
29
+ * time. Deliberately copywriting-led: in every one of these the words carry the
30
+ * persuasion and the footage only has to hold attention, which is what makes
31
+ * them fast, cheap and repeatable. All seven are sourceable for ~$0 from
32
+ * `vidfarm public-raws --categories`. */
33
+ export const EASY_FORMATS = [
34
+ { key: "b-roll", label: "kinetic captions over b-roll footage", note: "the workhorse — start here" },
35
+ { key: "talking-head", label: "talking head", note: "only if the user will film themselves; strongest trust signal" },
36
+ { key: "process", label: "process footage", note: "high watch-through, needs no narration" },
37
+ { key: "loop-background", label: "loop background footage", note: "cheapest of all; the copy is the entire video" },
38
+ { key: "satisfying", label: "satisfying footage", note: "strong retention, weak topical fit" },
39
+ { key: "lifestyle", label: "lifestyle footage", note: "best for identity and status angles" },
40
+ { key: "pov-quote", label: "POV quote aesthetic", note: "pure copywriting; a natural fit for hook tests" }
41
+ ];
42
+ /** What to take when the director has no opinion — never stall on this choice. */
43
+ export const DEFAULT_FORMAT = EASY_FORMATS[0].label;
44
+ /** A variant only counts as tested once it has this many posts behind it. One
45
+ * post is noise in short form, so nothing is promoted off a single result. */
46
+ export const MIN_POSTS_PER_VARIANT = 2;
47
+ /** Beat the round median by this much to be an outlier worth chasing. */
48
+ export const DEFAULT_OUTLIER_RATIO = 3;
49
+ const ROUND_HEADING_RE = /^##[ \t]+round\b/i;
50
+ const EPOCH_HEADING_RE = /^###[ \t]+epoch\b/i;
51
+ const RESULTS_HEADING_RE = /^###[ \t]+results?\b/i;
52
+ const SETUP_HEADING_RE = /^##[ \t]+setup\b/i;
53
+ const ANY_H2_RE = /^##[ \t]+/;
54
+ const ANY_H3_RE = /^###[ \t]+/;
55
+ // A metadata list item. The key half deliberately accepts anything but a colon,
56
+ // because the template's own keys carry parentheses and slashes — e.g.
57
+ // "- Channels (capacity 4/epoch): a, b, c" and
58
+ // "- North-star metric: comments (secondary: views, clicks)".
59
+ const META_RE = /^\s*[-*]\s+([^:|]{1,60}?)\s*:\s*(.+?)\s*$/;
60
+ const TABLE_ROW_RE = /^\s*\|(.+)\|\s*$/;
61
+ const TABLE_SEP_RE = /^\s*\|[\s:|-]+\|\s*$/;
62
+ const LEADING_INT_RE = /^(\d+)/;
63
+ const TITLE_SEP_RE = /^[\s.:—-]+/;
64
+ const DATE_RE = /(\d{4}-\d{2}-\d{2})/;
65
+ const AGE_RE = /\bage\s*:?\s*([0-9]+\s*(?:h|hr|hrs|hours|d|day|days|w|wk|weeks)\b)/i;
66
+ const SOURCE_RE = /\bsource\s*:?\s*([^,·|]+)/i;
67
+ // A "posted" cell is written by hand as often as by `experiment log --posted`, so
68
+ // accept a tick, a word, or a bare date — and accept them WITH a date appended
69
+ // ("✅ 2026-08-15"), which is what this CLI itself writes.
70
+ const TRUTHY_CELL = /(?:✅|✔|\byes\b|\btrue\b|\bdone\b|\bposted\b|\blive\b|^\s*x\s*$|\d{4}-\d{2}-\d{2})/i;
71
+ function splitList(value) {
72
+ return value
73
+ .split(/\s*[;,]\s*/)
74
+ .map((s) => s.trim())
75
+ .filter(Boolean);
76
+ }
77
+ function cells(line) {
78
+ const m = TABLE_ROW_RE.exec(line);
79
+ if (!m)
80
+ return [];
81
+ return (m[1] ?? "").split("|").map((c) => c.trim());
82
+ }
83
+ /** "14,200" → 14200 · "—" / "" / "n/a" → undefined. Diaries are hand-pasted, so
84
+ * thousands separators and em-dash placeholders are normal, not errors. */
85
+ function parseMetricNumber(raw) {
86
+ const t = raw.replace(/[,\s_]/g, "");
87
+ if (!t || /^(?:—|-|–|n\/a|na|\?)$/i.test(t))
88
+ return undefined;
89
+ const n = Number.parseFloat(t.replace(/[^0-9.eE+-]/g, ""));
90
+ return Number.isFinite(n) ? n : undefined;
91
+ }
92
+ /** Posting frequency written next to a channel. Accepts the shapes a director
93
+ * actually types: "tiktok_a x2", "tiktok_a ×2", "yt_a 1/day", "li_a 3/week",
94
+ * "fb_a 2/month", and a bare "ig_a" (= once per epoch). "paused" or a 0 rate
95
+ * contributes no capacity but stays listed. */
96
+ const CHANNEL_RATE_RE = /^(.*?)[\s(]*(?:[x×]\s*(\d+(?:\.\d+)?)|(\d+(?:\.\d+)?)\s*\/\s*(day|epoch|wk|week|mo|month)|(paused|off))\)?\s*$/i;
97
+ export function parseChannelPlan(entry) {
98
+ const raw = entry.trim();
99
+ const m = CHANNEL_RATE_RE.exec(raw);
100
+ if (!m || (!m[2] && !m[3] && !m[5]))
101
+ return { id: raw, perEpoch: 1, raw };
102
+ const id = (m[1] ?? "").trim() || raw;
103
+ if (m[5])
104
+ return { id, perEpoch: 0, raw };
105
+ if (m[2])
106
+ return { id, perEpoch: Number.parseFloat(m[2]), raw };
107
+ const n = Number.parseFloat(m[3] ?? "1");
108
+ const unit = (m[4] ?? "day").toLowerCase();
109
+ const perEpoch = unit.startsWith("w") ? n / 7 : unit.startsWith("m") ? n / 30 : n;
110
+ return { id, perEpoch, raw };
111
+ }
112
+ function isMode(value) {
113
+ return EXPERIMENT_MODES.includes(value);
114
+ }
115
+ /**
116
+ * Parse an `EXPERIMENTS_DIARY.md`. Tolerant by design: unknown keys are kept in
117
+ * `extra`, malformed tables degrade to zero rows, and anything surprising becomes
118
+ * a warning. A diary an agent half-wrote by hand must still read back.
119
+ */
120
+ export function parseDiary(source) {
121
+ const warnings = [];
122
+ const lines = source.split(/\r?\n/);
123
+ const setup = { secondary: [], channels: [], channelPlans: [], extra: {} };
124
+ const rounds = [];
125
+ let inSetup = false;
126
+ let round = null;
127
+ let epoch = null;
128
+ let results = null;
129
+ let tableHeader = null;
130
+ const closeTable = () => {
131
+ tableHeader = null;
132
+ };
133
+ for (let i = 0; i < lines.length; i++) {
134
+ const line = lines[i] ?? "";
135
+ const lineNo = i + 1;
136
+ if (SETUP_HEADING_RE.test(line)) {
137
+ inSetup = true;
138
+ round = null;
139
+ epoch = null;
140
+ results = null;
141
+ closeTable();
142
+ continue;
143
+ }
144
+ if (ROUND_HEADING_RE.test(line)) {
145
+ inSetup = false;
146
+ epoch = null;
147
+ results = null;
148
+ closeTable();
149
+ const headingText = line.replace(/^##[ \t]+round\b/i, "").replace(TITLE_SEP_RE, "").trim();
150
+ round = {
151
+ index: rounds.length + 1,
152
+ constants: [],
153
+ epochs: [],
154
+ results: [],
155
+ extra: {},
156
+ line: lineNo,
157
+ endLine: lineNo
158
+ };
159
+ const intMatch = LEADING_INT_RE.exec(headingText);
160
+ if (intMatch) {
161
+ round.number = Number.parseInt(intMatch[1] ?? "", 10);
162
+ const rest = headingText.slice((intMatch[0] ?? "").length).replace(TITLE_SEP_RE, "").trim();
163
+ if (rest)
164
+ round.title = rest;
165
+ }
166
+ else if (headingText) {
167
+ round.title = headingText;
168
+ }
169
+ rounds.push(round);
170
+ continue;
171
+ }
172
+ // Any other H2 closes the current round.
173
+ if (ANY_H2_RE.test(line)) {
174
+ inSetup = false;
175
+ round = null;
176
+ epoch = null;
177
+ results = null;
178
+ closeTable();
179
+ continue;
180
+ }
181
+ if (round && EPOCH_HEADING_RE.test(line)) {
182
+ results = null;
183
+ closeTable();
184
+ const text = line.replace(/^###[ \t]+epoch\b/i, "").replace(TITLE_SEP_RE, "").trim();
185
+ epoch = { label: text || undefined, date: DATE_RE.exec(text)?.[1], rows: [], line: lineNo, endLine: lineNo };
186
+ round.epochs.push(epoch);
187
+ round.endLine = lineNo;
188
+ continue;
189
+ }
190
+ if (round && RESULTS_HEADING_RE.test(line)) {
191
+ epoch = null;
192
+ closeTable();
193
+ const text = line.replace(/^###[ \t]+results?\b/i, "").replace(TITLE_SEP_RE, "").trim();
194
+ results = {
195
+ readDate: DATE_RE.exec(text)?.[1],
196
+ source: SOURCE_RE.exec(text)?.[1]?.trim(),
197
+ age: AGE_RE.exec(text)?.[1]?.replace(/\s+/g, ""),
198
+ rows: [],
199
+ line: lineNo,
200
+ endLine: lineNo
201
+ };
202
+ round.results.push(results);
203
+ round.endLine = lineNo;
204
+ continue;
205
+ }
206
+ if (round && ANY_H3_RE.test(line)) {
207
+ epoch = null;
208
+ results = null;
209
+ closeTable();
210
+ round.endLine = lineNo;
211
+ continue;
212
+ }
213
+ // ---- table rows ---------------------------------------------------------
214
+ if ((epoch || results) && TABLE_ROW_RE.test(line)) {
215
+ if (TABLE_SEP_RE.test(line))
216
+ continue;
217
+ const row = cells(line);
218
+ if (!tableHeader) {
219
+ tableHeader = row.map((c) => c.toLowerCase());
220
+ if (epoch)
221
+ epoch.endLine = lineNo;
222
+ if (results)
223
+ results.endLine = lineNo;
224
+ if (round)
225
+ round.endLine = lineNo;
226
+ continue;
227
+ }
228
+ const get = (...names) => {
229
+ for (const name of names) {
230
+ const idx = tableHeader?.indexOf(name) ?? -1;
231
+ if (idx >= 0 && row[idx] !== undefined)
232
+ return row[idx];
233
+ }
234
+ return undefined;
235
+ };
236
+ if (epoch) {
237
+ const postedCell = (get("posted", "live", "status") ?? "").trim();
238
+ epoch.rows.push({
239
+ slot: get("slot"),
240
+ video: get("video", "id", "clip"),
241
+ variant: get("variant", "angle", "format", "hook", "testing"),
242
+ channel: get("channel", "account", "destination"),
243
+ posted: TRUTHY_CELL.test(postedCell),
244
+ postedNote: postedCell || undefined,
245
+ line: lineNo
246
+ });
247
+ epoch.endLine = lineNo;
248
+ }
249
+ else if (results) {
250
+ const video = (get("video", "id", "clip") ?? "").trim();
251
+ const metrics = {};
252
+ tableHeader.forEach((name, idx) => {
253
+ if (!name || name === "video" || name === "id" || name === "clip" || name === "note" || name === "notes" || name === "channel" || name === "account")
254
+ return;
255
+ const value = parseMetricNumber(row[idx] ?? "");
256
+ if (value !== undefined)
257
+ metrics[name] = value;
258
+ });
259
+ if (video) {
260
+ results.rows.push({ video, metrics, channel: get("channel", "account")?.trim() || undefined, note: get("note", "notes"), line: lineNo });
261
+ }
262
+ else {
263
+ warnings.push({ message: "Result row with no video id — skipped.", line: lineNo });
264
+ }
265
+ results.endLine = lineNo;
266
+ }
267
+ if (round)
268
+ round.endLine = lineNo;
269
+ continue;
270
+ }
271
+ if (tableHeader && line.trim() === "")
272
+ closeTable();
273
+ // ---- metadata list items ------------------------------------------------
274
+ const meta = META_RE.exec(line);
275
+ if (meta) {
276
+ // "Channels (capacity 4/epoch)" → "channels"; "**Win condition**" → "win_condition".
277
+ const key = (meta[1] ?? "")
278
+ .toLowerCase()
279
+ .replace(/\([^)]*\)/g, "")
280
+ .replace(/\*/g, "")
281
+ .trim()
282
+ .replace(/[\s-]+/g, "_");
283
+ const value = (meta[2] ?? "").replace(/\*\*/g, "").trim();
284
+ if (inSetup) {
285
+ if (key.startsWith("north") || key === "metric" || key === "kpi") {
286
+ // "comments (secondary: views, clicks)"
287
+ const secondary = /\(secondary:\s*([^)]+)\)/i.exec(value);
288
+ if (secondary)
289
+ setup.secondary = splitList(secondary[1] ?? "");
290
+ setup.metric = value.replace(/\(secondary:[^)]*\)/i, "").trim().toLowerCase();
291
+ }
292
+ else if (key === "channels" || key.startsWith("channels")) {
293
+ const inner = /\(([^)]*)\)/.exec(value);
294
+ // Strip only a LEADING "(capacity N/epoch)" label — a per-channel rate
295
+ // in parentheses further along is data, not a label.
296
+ const entries = splitList(value.replace(/^\s*\([^)]*\)\s*/, ""));
297
+ setup.channelPlans = (entries.length ? entries : splitList(inner?.[1] ?? "")).map(parseChannelPlan);
298
+ setup.channels = setup.channelPlans.map((p) => p.id);
299
+ }
300
+ else if (key === "mode") {
301
+ const normalized = value.toLowerCase();
302
+ if (isMode(normalized))
303
+ setup.mode = normalized;
304
+ else {
305
+ setup.extra.mode = value;
306
+ warnings.push({ message: `Unknown mode "${value}" — expected creative or structured.`, line: lineNo });
307
+ }
308
+ }
309
+ else if (key === "format" || key === "video_format")
310
+ setup.format = value;
311
+ else if (key === "product")
312
+ setup.product = value;
313
+ else if (key.startsWith("baseline"))
314
+ setup.baseline = value;
315
+ else if (key === "editors" || key === "edited_by")
316
+ setup.editors = value;
317
+ else if (key.startsWith("analytics") || key === "source")
318
+ setup.source = value;
319
+ else
320
+ setup.extra[key] = value;
321
+ continue;
322
+ }
323
+ if (round) {
324
+ round.endLine = lineNo;
325
+ if (key === "mode") {
326
+ const normalized = value.toLowerCase().split(/[\s·|]/)[0] ?? "";
327
+ if (isMode(normalized))
328
+ round.mode = normalized;
329
+ // A single "- Mode: structured · Variable: angle · Constants: …" line is
330
+ // the compact form the doc's template uses. Split it out.
331
+ const compact = value.split(/\s*[·|]\s*/).slice(1);
332
+ for (const part of compact) {
333
+ const colon = part.indexOf(":");
334
+ if (colon === -1)
335
+ continue;
336
+ const k = part.slice(0, colon).trim().toLowerCase();
337
+ const v = part.slice(colon + 1).trim();
338
+ if (k === "variable")
339
+ round.variable = v;
340
+ else if (k === "constants")
341
+ round.constants = splitList(v);
342
+ else if (k === "format")
343
+ round.format = v;
344
+ else
345
+ round.extra[k] = v;
346
+ }
347
+ }
348
+ else if (key === "variable")
349
+ round.variable = value;
350
+ else if (key === "constants")
351
+ round.constants = splitList(value);
352
+ else if (key.startsWith("justification") || key === "why")
353
+ round.justification = value;
354
+ else if (key.startsWith("win"))
355
+ round.win = value;
356
+ else if (key === "videos") {
357
+ const n = parseMetricNumber(value.split(/[^\d,]/)[0] ?? value);
358
+ if (n !== undefined)
359
+ round.videos = n;
360
+ else
361
+ round.extra.videos = value;
362
+ }
363
+ else if (key === "editors" || key === "edited_by")
364
+ round.editors = value;
365
+ else if (key === "finding" || key === "findings")
366
+ round.finding = value;
367
+ else if (key === "next")
368
+ round.next = value;
369
+ else
370
+ round.extra[key] = value;
371
+ continue;
372
+ }
373
+ }
374
+ // Bold prose lines the template uses for conclusions: "**Finding:** …".
375
+ if (round) {
376
+ const bold = /^\s*\*\*(finding|next)s?:?\*\*\s*(.+?)\s*$/i.exec(line);
377
+ if (bold) {
378
+ const key = (bold[1] ?? "").toLowerCase();
379
+ if (key === "finding")
380
+ round.finding = (bold[2] ?? "").trim();
381
+ else
382
+ round.next = (bold[2] ?? "").trim();
383
+ round.endLine = lineNo;
384
+ }
385
+ }
386
+ }
387
+ return { setup, rounds, warnings };
388
+ }
389
+ /** Read + parse a work root's diary. Missing file is NOT an error — it returns
390
+ * `exists:false` so a caller can say "no campaign yet" and offer `--init`. */
391
+ export function readDiary(dir) {
392
+ const absPath = path.join(dir, EXPERIMENTS_FILENAME);
393
+ if (!existsSync(absPath)) {
394
+ return {
395
+ exists: false,
396
+ path: EXPERIMENTS_FILENAME,
397
+ absPath,
398
+ manifest: { setup: { secondary: [], channels: [], channelPlans: [], extra: {} }, rounds: [], warnings: [] }
399
+ };
400
+ }
401
+ return { exists: true, path: EXPERIMENTS_FILENAME, absPath, manifest: parseDiary(readFileSync(absPath, "utf8")) };
402
+ }
403
+ // ── Arithmetic ───────────────────────────────────────────────────────────────
404
+ // The part a weaker model gets quietly wrong: capacity, epochs, medians, and
405
+ // which number actually beat the pack.
406
+ /** Videos per epoch = channels held. One channel carries about one test post a
407
+ * day before it reads as spam. */
408
+ export function capacityOf(setup) {
409
+ if (setup.channelPlans.length === 0)
410
+ return setup.channels.length;
411
+ const total = setup.channelPlans.reduce((sum, p) => sum + (Number.isFinite(p.perEpoch) ? p.perEpoch : 0), 0);
412
+ // Round to 2dp so "3/week" style fractions don't print as 0.4285714285714286.
413
+ return Math.round(total * 100) / 100;
414
+ }
415
+ export function epochsNeeded(videos, capacity) {
416
+ if (!Number.isFinite(videos) || videos <= 0)
417
+ return 0;
418
+ if (!Number.isFinite(capacity) || capacity <= 0)
419
+ return Number.POSITIVE_INFINITY;
420
+ return Math.ceil(videos / capacity);
421
+ }
422
+ /** Deal N video slots out across channels, epoch by epoch, respecting each
423
+ * channel's posting frequency.
424
+ *
425
+ * Fractional rates (a channel that can only take 3 posts a week) are handled by
426
+ * carrying credit forward: the channel accrues `perEpoch` each epoch and takes a
427
+ * slot whenever its credit reaches 1. That keeps a 3/week channel on a real
428
+ * every-other-day cadence instead of either over-posting it daily or dropping it.
429
+ * Deterministic — same inputs, same schedule. */
430
+ export function allocateSlots(plans, videos) {
431
+ const active = plans.filter((p) => Number.isFinite(p.perEpoch) && p.perEpoch > 0);
432
+ if (active.length === 0 || videos <= 0)
433
+ return [];
434
+ const credit = new Map(active.map((p) => [p.id, 0]));
435
+ const out = [];
436
+ // Bound the loop: even the slowest channel mix can't need more epochs than
437
+ // videos / smallest-rate, and the +2 covers the first accrual.
438
+ const slowest = Math.min(...active.map((p) => p.perEpoch));
439
+ const maxEpochs = Math.ceil(videos / slowest) + 2;
440
+ for (let epoch = 1; epoch <= maxEpochs && out.length < videos; epoch++) {
441
+ for (const plan of active) {
442
+ let c = (credit.get(plan.id) ?? 0) + plan.perEpoch;
443
+ while (c >= 1 && out.length < videos) {
444
+ out.push({ epoch, channel: plan.id });
445
+ c -= 1;
446
+ }
447
+ credit.set(plan.id, c);
448
+ }
449
+ }
450
+ return out;
451
+ }
452
+ export function median(values) {
453
+ const sorted = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b);
454
+ if (sorted.length === 0)
455
+ return undefined;
456
+ const mid = Math.floor(sorted.length / 2);
457
+ return sorted.length % 2 === 1 ? sorted[mid] : ((sorted[mid - 1] + sorted[mid]) / 2);
458
+ }
459
+ export function analyzeRound(round, setup, opts) {
460
+ const metric = (opts?.metric ?? setup.metric ?? DEFAULT_METRIC).toLowerCase();
461
+ const outlierRatio = opts?.outlierRatio ?? DEFAULT_OUTLIER_RATIO;
462
+ const capacity = capacityOf(setup);
463
+ const slotRows = round.epochs.flatMap((e) => e.rows);
464
+ const variantOf = new Map();
465
+ const postsOf = new Map();
466
+ // A video can occupy more than one slot — that IS the cross-account retest.
467
+ const channelsOf = new Map();
468
+ for (const row of slotRows) {
469
+ if (!row.video)
470
+ continue;
471
+ if (row.variant)
472
+ variantOf.set(row.video, row.variant);
473
+ if (row.posted)
474
+ postsOf.set(row.video, (postsOf.get(row.video) ?? 0) + 1);
475
+ if (row.channel) {
476
+ const set = channelsOf.get(row.video) ?? new Set();
477
+ set.add(row.channel);
478
+ channelsOf.set(row.video, set);
479
+ }
480
+ }
481
+ const resultRows = round.results.flatMap((block) => block.rows.map((row) => ({ row, block })));
482
+ const scored = [];
483
+ for (const { row, block } of resultRows) {
484
+ const value = row.metrics[metric];
485
+ if (value === undefined)
486
+ continue;
487
+ scored.push({
488
+ video: row.video,
489
+ value,
490
+ channel: row.channel ?? (channelsOf.get(row.video)?.size === 1 ? Array.from(channelsOf.get(row.video) ?? [])[0] : undefined),
491
+ variant: variantOf.get(row.video),
492
+ posts: postsOf.get(row.video) ?? 0,
493
+ age: block.age,
494
+ source: block.source
495
+ });
496
+ }
497
+ // A RETEST is the same video MEASURED on two accounts — not a video whose slot
498
+ // was planned for one account and whose result came back from another (that is
499
+ // a plan change, and counting it would clear the confound warning for free).
500
+ // So: prefer distinct channels across RESULT rows; fall back to distinct
501
+ // channels across slots that were actually marked posted.
502
+ const measuredOn = new Map();
503
+ for (const s of scored) {
504
+ if (!s.channel)
505
+ continue;
506
+ const set = measuredOn.get(s.video) ?? new Set();
507
+ set.add(s.channel);
508
+ measuredOn.set(s.video, set);
509
+ }
510
+ for (const row of slotRows) {
511
+ if (!row.video || !row.channel || !row.posted)
512
+ continue;
513
+ if (resultRows.some(({ row: r }) => r.video === row.video && r.channel))
514
+ continue;
515
+ const set = measuredOn.get(row.video) ?? new Set();
516
+ set.add(row.channel);
517
+ measuredOn.set(row.video, set);
518
+ }
519
+ const med = median(scored.map((s) => s.value));
520
+ for (const s of scored)
521
+ s.ratio = med && med > 0 ? s.value / med : undefined;
522
+ // Normalize per ACCOUNT. Account health moves numbers by multiples, so a video
523
+ // is only fairly judged against its own channel's median — and a channel needs
524
+ // at least two results before it has one.
525
+ const byChannel = new Map();
526
+ for (const s of scored) {
527
+ if (!s.channel)
528
+ continue;
529
+ byChannel.set(s.channel, [...(byChannel.get(s.channel) ?? []), s.value]);
530
+ }
531
+ const channelMedians = {};
532
+ for (const [channel, values] of byChannel) {
533
+ if (values.length < 2)
534
+ continue;
535
+ const m = median(values);
536
+ if (m !== undefined && m > 0)
537
+ channelMedians[channel] = m;
538
+ }
539
+ for (const s of scored) {
540
+ const m = s.channel ? channelMedians[s.channel] : undefined;
541
+ s.channelRatio = m ? s.value / m : undefined;
542
+ }
543
+ const ranked = [...scored].sort((a, b) => b.value - a.value);
544
+ const reported = new Set(resultRows.map(({ row }) => row.video));
545
+ return {
546
+ round,
547
+ metric,
548
+ capacity,
549
+ videosPlanned: round.videos,
550
+ epochsNeeded: round.videos !== undefined ? epochsNeeded(round.videos, capacity) : undefined,
551
+ epochsRun: round.epochs.length,
552
+ postedCount: slotRows.filter((r) => r.posted).length,
553
+ // Every planned slot still dark, across all epochs — not just the newest one.
554
+ unposted: slotRows.filter((r) => !r.posted && r.video).map((r) => r.video),
555
+ awaiting: slotRows.filter((r) => r.posted && r.video && !reported.has(r.video)).map((r) => r.video),
556
+ scored: ranked,
557
+ median: med,
558
+ channelMedians,
559
+ retested: Array.from(measuredOn.entries()).filter(([, set]) => set.size > 1).map(([video]) => video),
560
+ // Prefer the account-normalized ratio when the channel has a baseline; fall
561
+ // back to the round median otherwise.
562
+ outliers: med && med > 0 ? ranked.filter((s) => (s.channelRatio ?? s.ratio ?? 0) >= outlierRatio) : [],
563
+ weakest: ranked[ranked.length - 1],
564
+ readContexts: Array.from(new Set(round.results.map((b) => `${b.source ?? "?"}@${b.age ?? "?"}`)))
565
+ };
566
+ }
567
+ export function lintDiary(manifest, analyses) {
568
+ const findings = [];
569
+ const { setup, rounds } = manifest;
570
+ const capacity = capacityOf(setup);
571
+ if (!setup.metric) {
572
+ findings.push({
573
+ level: "warn",
574
+ code: "no-north-star",
575
+ message: "No north-star metric in Setup — every round is then judged by vibes.",
576
+ fix: `Add "- North-star metric: ${DEFAULT_METRIC}" (comments is the richest early-stage intel).`
577
+ });
578
+ }
579
+ if (!setup.format) {
580
+ findings.push({
581
+ level: "warn",
582
+ code: "no-format",
583
+ message: "No starting video format in Setup — the format is a PLANNING decision, not something to discover while editing.",
584
+ fix: `Pick one in the interview and write it down: ${EASY_FORMATS.map((f) => f.label).join(" · ")}. No opinion? Take "${DEFAULT_FORMAT}" and move — don't stall.`
585
+ });
586
+ }
587
+ if (capacity === 0) {
588
+ findings.push({
589
+ level: "warn",
590
+ code: "no-channels",
591
+ message: "No channels in Setup, so testing capacity is unknown and no round can be sized.",
592
+ fix: "Run `vidfarm channels` and list them: \"- Channels (capacity N/epoch): a, b, c\"."
593
+ });
594
+ }
595
+ const buysTotal = analyses
596
+ .filter((a) => a.metric === "buys")
597
+ .flatMap((a) => a.scored.map((s) => s.value))
598
+ .reduce((sum, v) => sum + v, 0);
599
+ if ((setup.metric ?? "").includes("buy") && buysTotal < 10) {
600
+ findings.push({
601
+ level: "warn",
602
+ code: "end-of-funnel-north-star",
603
+ message: `North star is buys, but only ${buysTotal} recorded so far — at this volume every round is statistically indistinguishable from noise.`,
604
+ 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."
605
+ });
606
+ }
607
+ for (const analysis of analyses) {
608
+ const round = analysis.round;
609
+ const at = { round: round.number ?? round.index, line: round.line };
610
+ const mode = round.mode ?? setup.mode;
611
+ if (mode === "structured") {
612
+ const variables = round.variable ? splitList(round.variable.replace(/\s+and\s+/gi, ",")).filter((v) => v.length > 0) : [];
613
+ if (variables.length > 1) {
614
+ findings.push({
615
+ ...at,
616
+ level: "error",
617
+ code: "two-variables",
618
+ message: `Round ${at.round} is structured but varies ${variables.length} params (${variables.join(", ")}) — the result is unreadable.`,
619
+ fix: "Split it into parallel rounds on separate channel slots, one variable each."
620
+ });
621
+ }
622
+ if (!round.variable) {
623
+ findings.push({ ...at, level: "error", code: "no-variable", message: `Round ${at.round} is structured but names no variable.`, fix: "Add \"- Variable: <param>\"." });
624
+ }
625
+ // Format is a constant in most rounds. If the round neither pins one nor
626
+ // inherits one from Setup, its "constants" were never actually constant.
627
+ if (!round.format && !setup.format && !/format/i.test(round.variable ?? "")) {
628
+ findings.push({
629
+ ...at,
630
+ level: "warn",
631
+ code: "round-no-format",
632
+ message: `Round ${at.round} names no video format, so every video in it can drift to a different one.`,
633
+ fix: `Add "- Format: <choice>" to the round (or to Setup, which it inherits). Default: "${DEFAULT_FORMAT}".`
634
+ });
635
+ }
636
+ if (round.constants.length === 0) {
637
+ findings.push({
638
+ ...at,
639
+ level: "warn",
640
+ code: "no-constants",
641
+ message: `Round ${at.round} is structured but pins no constants, so nothing is actually held.`,
642
+ fix: "Write them down and enforce them: `vidfarm harness init short-form --out ./work/HARNESS.md`, then `vidfarm qa ./work --harness ./work/HARNESS.md`."
643
+ });
644
+ }
645
+ const editors = round.editors ?? setup.editors ?? "";
646
+ if (/gig|crowd|task ?force|worker/i.test(editors)) {
647
+ findings.push({
648
+ ...at,
649
+ level: "error",
650
+ code: "structured-with-gigworkers",
651
+ message: `Round ${at.round} is structured but is edited by a distributed task force — between-editor variance swamps the effect being measured.`,
652
+ 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."
653
+ });
654
+ }
655
+ }
656
+ if (!round.win && analysis.scored.length === 0) {
657
+ 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\"." });
658
+ }
659
+ if (analysis.readContexts.length > 1) {
660
+ findings.push({
661
+ ...at,
662
+ level: "error",
663
+ code: "mixed-read-ages",
664
+ message: `Round ${at.round} mixes results read at different times/sources (${analysis.readContexts.join(", ")}) — a 24h number is not comparable to a 7d one.`,
665
+ fix: "Re-read the whole round at one age, or compare only within a single results block."
666
+ });
667
+ }
668
+ // Account health is the biggest confounder in the method: it moves numbers by
669
+ // multiples, often more than the variable under test. A round whose videos each
670
+ // sat on a different account has measured the accounts as much as the videos.
671
+ const channelsUsed = new Set(analysis.scored.map((s) => s.channel).filter(Boolean));
672
+ if (channelsUsed.size > 1 && analysis.retested.length === 0 && analysis.scored.length > 1) {
673
+ const normalized = Object.keys(analysis.channelMedians).length;
674
+ findings.push({
675
+ ...at,
676
+ level: normalized > 0 ? "warn" : "error",
677
+ code: "account-health-confound",
678
+ message: `Round ${at.round} ranks ${analysis.scored.length} videos across ${channelsUsed.size} accounts with no variant retested on a second account — account health can outweigh the variable you are testing.`,
679
+ fix: normalized > 0
680
+ ? "Per-account medians exist for some channels — judge by those, not the round median. Then retest the leader on a different account to confirm the ordering holds."
681
+ : "Re-post the apparent winner on a DIFFERENT account (deduped) and the apparent loser on the winner's account. If the ordering survives the swap, the effect is real; if it flips, you measured account health."
682
+ });
683
+ }
684
+ for (const outlier of analysis.outliers) {
685
+ if (outlier.posts < MIN_POSTS_PER_VARIANT) {
686
+ findings.push({
687
+ ...at,
688
+ level: "warn",
689
+ code: "single-post-winner",
690
+ message: `${outlier.video} looks like an outlier (${outlier.ratio?.toFixed(1)}x median ${analysis.metric}) off ${outlier.posts || "?"} post(s).`,
691
+ fix: `Post it again before you promote it — ${MIN_POSTS_PER_VARIANT} minimum, 3 is better.`
692
+ });
693
+ }
694
+ }
695
+ if (capacity > 0 && analysis.epochsRun > 0) {
696
+ const latest = round.epochs[round.epochs.length - 1];
697
+ const used = latest?.rows.length ?? 0;
698
+ // Only whole slots are postable, and fractional capacity ("3/week") leaves a
699
+ // remainder that isn't a usable slot — don't report 0.43 free.
700
+ const free = Math.floor(capacity - used);
701
+ if (used > 0 && free >= 1) {
702
+ findings.push({
703
+ ...at,
704
+ level: "info",
705
+ code: "unspent-capacity",
706
+ message: `Latest epoch of round ${at.round} uses ${used} of ${capacity} slots — ${free} free.`,
707
+ 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."
708
+ });
709
+ }
710
+ }
711
+ // A channel scheduled beyond its own stated frequency is how an account gets
712
+ // throttled or flagged — and a throttled account poisons every number on it.
713
+ for (const epoch of round.epochs) {
714
+ const counts = new Map();
715
+ for (const row of epoch.rows) {
716
+ if (!row.channel)
717
+ continue;
718
+ counts.set(row.channel, (counts.get(row.channel) ?? 0) + 1);
719
+ }
720
+ for (const [channel, used] of counts) {
721
+ const plan = setup.channelPlans.find((p) => p.id === channel);
722
+ if (!plan)
723
+ continue;
724
+ const allowed = Math.max(1, Math.ceil(plan.perEpoch));
725
+ if (plan.perEpoch === 0) {
726
+ findings.push({ ...at, level: "warn", code: "paused-channel-scheduled", message: `Round ${at.round}, epoch ${epoch.label ?? "?"}: ${channel} is marked paused but has ${used} slot(s).`, fix: "Un-pause it in Setup, or move those slots to an active channel." });
727
+ }
728
+ else if (used > allowed) {
729
+ // A bare channel name carries no explicit rate — say "the default" rather
730
+ // than echoing the id back as if it were a frequency.
731
+ const stated = plan.raw.trim() === plan.id ? "once per epoch (the default)" : `${plan.raw.trim()} (~${allowed}/epoch)`;
732
+ findings.push({
733
+ ...at,
734
+ level: "warn",
735
+ code: "channel-overposted",
736
+ message: `Round ${at.round}, epoch ${epoch.label ?? "?"}: ${channel} has ${used} slots but its stated frequency is ${stated}.`,
737
+ fix: "Spread them over more epochs, or raise the channel's frequency in Setup if it really can take that volume."
738
+ });
739
+ }
740
+ }
741
+ }
742
+ if (analysis.awaiting.length > 0) {
743
+ 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`." });
744
+ }
745
+ }
746
+ if (rounds.length > 1) {
747
+ const structured = rounds.filter((r) => (r.mode ?? setup.mode) === "structured").length;
748
+ if (structured === rounds.length && !rounds.some((r) => r.finding)) {
749
+ 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." });
750
+ }
751
+ }
752
+ return findings;
753
+ }
754
+ export function renderDiaryScaffold(input) {
755
+ const plans = input.channelPlans?.length
756
+ ? input.channelPlans
757
+ : (input.channels ?? []).map((id) => parseChannelPlan(id));
758
+ const channelList = plans.map((p) => p.raw.trim()).join(", ");
759
+ const capacity = plans.length
760
+ ? Math.round(plans.reduce((sum, p) => sum + p.perEpoch, 0) * 100) / 100
761
+ : 0;
762
+ const metric = input.metric ?? DEFAULT_METRIC;
763
+ const secondary = input.secondary?.length ? input.secondary : CORE_METRICS.filter((m) => m !== metric).slice(0, 2);
764
+ return [
765
+ `# Experiments Diary — ${input.product ?? "<product>"}`,
766
+ "",
767
+ `Method: https://vidfarm.cc/experiments.md · started ${input.date ?? ""}`.trim(),
768
+ "",
769
+ "## Setup",
770
+ `- North-star metric: ${metric} (secondary: ${secondary.join(", ")})`,
771
+ `- Channels (capacity ${capacity || "?"}/epoch): ${channelList || "<run `vidfarm channels` and list them>"}`,
772
+ " <!-- per-channel frequency: \"name x2\" = twice an epoch · \"name 3/week\" · \"name paused\" · bare name = once -->",
773
+ `- Mode: ${input.mode ?? "creative"}`,
774
+ `- Format: ${input.format ?? DEFAULT_FORMAT}`,
775
+ `- Editors: ${input.editors ?? "agent"}`,
776
+ `- Baseline checkpoint: ${input.baseline ?? "kinetic captions over b-roll, no VO, ~20s"}`,
777
+ `- Analytics source: ${input.source ?? "<flockposter | manual | email-channel | gigworkers>"}`,
778
+ "",
779
+ "",
780
+ "Format menu (copywriting-led — the words do the work; all ~$0 via `vidfarm public-raws --categories`):",
781
+ ...EASY_FORMATS.map((f) => `- ${f.label} — ${f.note}`),
782
+ "",
783
+ "Append only. Never rewrite history here — correct it with a later entry.",
784
+ ""
785
+ ].join("\n");
786
+ }
787
+ export function renderRoundScaffold(input) {
788
+ const videos = input.videos ?? input.slots?.length ?? 0;
789
+ const epochs = epochsNeeded(videos, input.capacity);
790
+ const slots = input.slots?.length
791
+ ? input.slots
792
+ : Array.from({ length: videos }, (_, i) => `v${String(i + 1).padStart(3, "0")}`);
793
+ // Deal the slots out by each channel's OWN posting frequency, not round-robin —
794
+ // a 2/day channel earns two slots an epoch and a 3/week channel skips epochs.
795
+ const plans = input.channelPlans?.length
796
+ ? input.channelPlans
797
+ : (input.channels ?? []).map((id) => ({ id, perEpoch: 1, raw: id }));
798
+ const allocation = allocateSlots(plans, slots.length);
799
+ const byEpoch = new Map();
800
+ slots.forEach((raw, i) => {
801
+ const [video, variant] = raw.split("|").map((s) => s.trim());
802
+ // No channel plan at all → still emit the row, with the channel left blank.
803
+ const slot = allocation[i];
804
+ const epoch = slot?.epoch ?? Math.floor(i / Math.max(1, input.capacity || slots.length)) + 1;
805
+ const list = byEpoch.get(epoch) ?? [];
806
+ list.push({ video: video ?? "", variant, channel: slot?.channel ?? "" });
807
+ byEpoch.set(epoch, list);
808
+ });
809
+ const epochBlocks = [];
810
+ for (const [epoch, rows] of Array.from(byEpoch.entries()).sort((a, b) => a[0] - b[0])) {
811
+ epochBlocks.push([
812
+ `### Epoch ${epoch} — ${epoch === 1 ? (input.date ?? "<date>") : "<date>"}`,
813
+ "",
814
+ "| slot | video | variant | channel | posted |",
815
+ "|---|---|---|---|---|",
816
+ ...rows.map((r, i) => `| ${i + 1} | ${r.video} | ${r.variant ?? ""} | ${r.channel} | |`),
817
+ ""
818
+ ].join("\n"));
819
+ }
820
+ return [
821
+ `## Round ${input.number}${input.title ? ` — ${input.title}` : input.variable ? ` — ${input.variable}` : ""}`,
822
+ `- Mode: ${input.mode ?? "creative"}`,
823
+ ...(input.variable ? [`- Variable: ${input.variable}`] : []),
824
+ `- Format: ${input.format ?? DEFAULT_FORMAT}`,
825
+ `- Constants: ${input.constants?.length ? input.constants.join(", ") : "—"}`,
826
+ `- Videos: ${videos} · Capacity ${input.capacity || "?"} slots/epoch → ${Number.isFinite(epochs) ? epochs : "?"} epoch(s)`,
827
+ `- Editors: ${input.editors ?? "agent"}`,
828
+ `- Justification: ${input.justification ?? "<why this variable is worth the capacity before the others>"}`,
829
+ `- Win condition: ${input.win ?? `any variant at >=${DEFAULT_OUTLIER_RATIO}x median`}`,
830
+ "",
831
+ ...epochBlocks,
832
+ ""
833
+ ].join("\n");
834
+ }
835
+ /** Splice text into a file's line array at `afterLine` (1-indexed; 0 = top). */
836
+ function spliceLines(source, afterLine, block) {
837
+ const lines = source.split(/\r?\n/);
838
+ const at = Math.max(0, Math.min(lines.length, afterLine));
839
+ const insert = block.replace(/\s+$/, "").split("\n");
840
+ lines.splice(at, 0, ...insert, "");
841
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n");
842
+ }
843
+ /** Append a round block at the end of the diary. */
844
+ export function appendRound(source, input) {
845
+ const body = source.replace(/\s+$/, "");
846
+ return `${body}\n\n${renderRoundScaffold(input).replace(/\s+$/, "")}\n`;
847
+ }
848
+ /** Append a result row to a round, reusing an existing results block when its
849
+ * (date, source, age) match — because rows read at different ages must never
850
+ * land in the same table. Returns the new file text. */
851
+ export function appendResult(source, manifest, roundIndex, input) {
852
+ const round = manifest.rounds[roundIndex];
853
+ if (!round)
854
+ throw new Error("No such round in the diary.");
855
+ const columns = Object.keys(input.metrics);
856
+ const match = round.results.find((b) => b.readDate === input.date && (b.source ?? "") === (input.source ?? "") && (b.age ?? "") === (input.age ?? ""));
857
+ if (match && match.rows.length > 0) {
858
+ // Reuse the block's own column order so the table stays aligned.
859
+ const header = manifest.rounds[roundIndex]?.results.find((b) => b === match);
860
+ const known = header ? Object.keys(match.rows[0]?.metrics ?? {}) : columns;
861
+ const ordered = known.length ? known : columns;
862
+ const row = `| ${input.video} | ${input.channel ?? ""} | ${ordered.map((c) => (input.metrics[c] !== undefined ? String(input.metrics[c]) : "")).join(" | ")} | ${input.note ?? ""} |`;
863
+ return { text: spliceLines(source, match.endLine, row), block: "existing" };
864
+ }
865
+ const ordered = columns.length ? columns : [...CORE_METRICS];
866
+ const block = [
867
+ `### Results — read ${input.date}${input.source ? `, source: ${input.source}` : ""}${input.age ? `, age: ${input.age}` : ""}`,
868
+ "",
869
+ `| video | channel | ${ordered.join(" | ")} | note |`,
870
+ `|---|---|${ordered.map(() => "---|").join("")}---|`,
871
+ `| ${input.video} | ${input.channel ?? ""} | ${ordered.map((c) => (input.metrics[c] !== undefined ? String(input.metrics[c]) : "")).join(" | ")} | ${input.note ?? ""} |`
872
+ ].join("\n");
873
+ return { text: spliceLines(source, round.endLine, `\n${block}`), block: "new" };
874
+ }
875
+ /** Mark a planned slot as posted. This records what already happened — the
876
+ * posting itself is `vidfarm approve` + `vidfarm schedule`. */
877
+ export function markPosted(source, manifest, video, opts) {
878
+ for (const round of manifest.rounds) {
879
+ for (const epoch of round.epochs) {
880
+ const row = epoch.rows.find((r) => r.video === video);
881
+ if (!row)
882
+ continue;
883
+ const lines = source.split(/\r?\n/);
884
+ const raw = lines[row.line - 1] ?? "";
885
+ const parts = cells(raw);
886
+ if (parts.length >= 5) {
887
+ if (opts.channel)
888
+ parts[3] = opts.channel;
889
+ parts[4] = `✅ ${opts.date}`;
890
+ lines[row.line - 1] = `| ${parts.join(" | ")} |`;
891
+ return { text: lines.join("\n"), line: row.line };
892
+ }
893
+ }
894
+ }
895
+ throw new Error(`No planned slot for "${video}" in the diary. Add the round first: vidfarm experiment round …`);
896
+ }
897
+ export function findRoundIndex(manifest, wanted) {
898
+ if (wanted === undefined)
899
+ return manifest.rounds.length - 1;
900
+ const idx = manifest.rounds.findIndex((r) => (r.number ?? r.index) === wanted);
901
+ return idx;
902
+ }
903
+ export function writeDiary(absPath, text) {
904
+ writeFileSync(absPath, text.endsWith("\n") ? text : `${text}\n`, "utf8");
905
+ }
906
+ //# sourceMappingURL=experiments.js.map