@koda-sl/baker-cli 0.328.0 → 0.329.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/README.md +2 -0
- package/dist/cli.js +286 -244
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1626,11 +1626,11 @@ var trackingParamsSetSchema = z3.object({
|
|
|
1626
1626
|
message: `${both.join(", ")} is set as both a dynamic and a fixed parameter \u2014 LinkedIn appends each key once, so keep only one`
|
|
1627
1627
|
});
|
|
1628
1628
|
}
|
|
1629
|
-
const
|
|
1630
|
-
if (
|
|
1629
|
+
const count2 = Object.keys(p.dynamicValueParameters ?? {}).length + Object.keys(p.customValueParameters ?? {}).length;
|
|
1630
|
+
if (count2 > LINKEDIN_LIMITS.trackingParams.parametersMax) {
|
|
1631
1631
|
ctx.addIssue({
|
|
1632
1632
|
code: "custom",
|
|
1633
|
-
message: `${
|
|
1633
|
+
message: `${count2} parameters \u2014 keep it under ${LINKEDIN_LIMITS.trackingParams.parametersMax}`
|
|
1634
1634
|
});
|
|
1635
1635
|
}
|
|
1636
1636
|
});
|
|
@@ -1937,8 +1937,8 @@ var leadFormQuestionSchema = z3.object({
|
|
|
1937
1937
|
});
|
|
1938
1938
|
}
|
|
1939
1939
|
if (q.questionType === "MULTIPLE_CHOICE") {
|
|
1940
|
-
const
|
|
1941
|
-
if (
|
|
1940
|
+
const count2 = q.options?.length ?? 0;
|
|
1941
|
+
if (count2 < LEAD.choiceOptionsMin || count2 > LEAD.choiceOptionsMax) {
|
|
1942
1942
|
ctx.addIssue({
|
|
1943
1943
|
code: "custom",
|
|
1944
1944
|
path: ["options"],
|
|
@@ -10672,23 +10672,23 @@ var FALSE_RANGE_PATTERNS = [
|
|
|
10672
10672
|
/\bdesde\s+([^,.;]{3,30}?)\s+hasta\s+([^,.;]{3,30}?)(?=[,.;]|$)/gi
|
|
10673
10673
|
];
|
|
10674
10674
|
function countFalseRanges(text2) {
|
|
10675
|
-
let
|
|
10675
|
+
let count2 = 0;
|
|
10676
10676
|
let first = "";
|
|
10677
10677
|
for (const re2 of FALSE_RANGE_PATTERNS) {
|
|
10678
10678
|
for (const m of text2.matchAll(re2)) {
|
|
10679
10679
|
const left = m[1] ?? "";
|
|
10680
10680
|
const right = m[2] ?? "";
|
|
10681
10681
|
if (/\d/.test(left) || /\d/.test(right)) continue;
|
|
10682
|
-
|
|
10682
|
+
count2++;
|
|
10683
10683
|
if (!first) first = m[0] ?? "";
|
|
10684
10684
|
}
|
|
10685
10685
|
}
|
|
10686
|
-
return { count, first };
|
|
10686
|
+
return { count: count2, first };
|
|
10687
10687
|
}
|
|
10688
10688
|
function countStaccato(text2) {
|
|
10689
10689
|
const sentences = text2.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
|
|
10690
10690
|
let run = 0;
|
|
10691
|
-
let
|
|
10691
|
+
let count2 = 0;
|
|
10692
10692
|
let first = "";
|
|
10693
10693
|
let runStart = 0;
|
|
10694
10694
|
for (const [i, s] of sentences.entries()) {
|
|
@@ -10697,17 +10697,17 @@ function countStaccato(text2) {
|
|
|
10697
10697
|
if (run === 0) runStart = i;
|
|
10698
10698
|
run++;
|
|
10699
10699
|
if (run === STACCATO_RUN) {
|
|
10700
|
-
|
|
10700
|
+
count2++;
|
|
10701
10701
|
if (!first) first = sentences.slice(runStart, i + 1).join(" ");
|
|
10702
10702
|
}
|
|
10703
10703
|
} else {
|
|
10704
10704
|
run = 0;
|
|
10705
10705
|
}
|
|
10706
10706
|
}
|
|
10707
|
-
return { count, first };
|
|
10707
|
+
return { count: count2, first };
|
|
10708
10708
|
}
|
|
10709
10709
|
function countTitleCaseHeadings(text2) {
|
|
10710
|
-
let
|
|
10710
|
+
let count2 = 0;
|
|
10711
10711
|
let first = "";
|
|
10712
10712
|
for (const m of text2.matchAll(/^\s{0,3}#{1,6}\s+(.+)$/gm)) {
|
|
10713
10713
|
const heading = (m[1] ?? "").trim();
|
|
@@ -10715,10 +10715,10 @@ function countTitleCaseHeadings(text2) {
|
|
|
10715
10715
|
if (words2.length < 4) continue;
|
|
10716
10716
|
const capitalized = words2.filter((w) => new RegExp("^\\p{Lu}", "u").test(w)).length;
|
|
10717
10717
|
if (capitalized / words2.length < 0.8) continue;
|
|
10718
|
-
|
|
10718
|
+
count2++;
|
|
10719
10719
|
if (!first) first = heading;
|
|
10720
10720
|
}
|
|
10721
|
-
return { count, first };
|
|
10721
|
+
return { count: count2, first };
|
|
10722
10722
|
}
|
|
10723
10723
|
function countInlineHeaderList(text2) {
|
|
10724
10724
|
const matches = [...text2.matchAll(/^\s*(?:[-*+]|\d+\.)\s+\*\*[^*\n]{2,40}:?\*\*:?\s/gm)];
|
|
@@ -10728,15 +10728,15 @@ function countInlineHeaderList(text2) {
|
|
|
10728
10728
|
var EMOJI = /[\u{1F300}-\u{1FAFF}]|[\u{2600}-\u{27BF}]|[\u{1F000}-\u{1F2FF}]|\u{FE0F}/gu;
|
|
10729
10729
|
var CURLY = /[‘’“”]/g;
|
|
10730
10730
|
function countPhraseTell(text2, id, phrases) {
|
|
10731
|
-
let
|
|
10731
|
+
let count2 = 0;
|
|
10732
10732
|
let first = "";
|
|
10733
10733
|
for (const phrase of phrases) {
|
|
10734
10734
|
for (const m of text2.matchAll(matcherFor(id, phrase))) {
|
|
10735
|
-
|
|
10735
|
+
count2++;
|
|
10736
10736
|
if (!first) first = m[0] ?? "";
|
|
10737
10737
|
}
|
|
10738
10738
|
}
|
|
10739
|
-
return { count, first };
|
|
10739
|
+
return { count: count2, first };
|
|
10740
10740
|
}
|
|
10741
10741
|
function countPattern(text2, re2) {
|
|
10742
10742
|
const matches = text2.match(re2) ?? [];
|
|
@@ -10974,7 +10974,7 @@ function renderOutcome(data, lines) {
|
|
|
10974
10974
|
const status = op.result?.status ?? "pending";
|
|
10975
10975
|
counts.set(status, (counts.get(status) ?? 0) + 1);
|
|
10976
10976
|
}
|
|
10977
|
-
const summary = [...counts].map(([status,
|
|
10977
|
+
const summary = [...counts].map(([status, count2]) => `${count2} ${status}`).join(", ");
|
|
10978
10978
|
lines.push(`Google Ads \u2014 published \xB7 ${summary || `${data.count} op(s)`} \xB7 ${data.mode}`);
|
|
10979
10979
|
for (const op of data.ops) {
|
|
10980
10980
|
const status = op.result?.status;
|
|
@@ -11976,11 +11976,11 @@ function renderKeywordLimits(limits) {
|
|
|
11976
11976
|
const sentences = REASON_ORDER.filter((reason) => limits.detailsByReason.has(reason)).map(
|
|
11977
11977
|
(reason) => describeLimit(reason, limits.detailsByReason.get(reason) ?? [])
|
|
11978
11978
|
);
|
|
11979
|
-
const
|
|
11979
|
+
const count2 = limits.keywords.size;
|
|
11980
11980
|
return [
|
|
11981
11981
|
{
|
|
11982
11982
|
code: "KEYWORD_SERVING_LIMITED",
|
|
11983
|
-
message: `${
|
|
11983
|
+
message: `${count2} ${count2 === 1 ? "keyword is" : "keywords are"} limited and not getting the traffic they could. ${sentences.join(" ")} Amounts are in the account currency (baker ads google currency).`
|
|
11984
11984
|
}
|
|
11985
11985
|
];
|
|
11986
11986
|
}
|
|
@@ -16678,11 +16678,11 @@ function nestAddressFlags(row) {
|
|
|
16678
16678
|
}
|
|
16679
16679
|
function readNumericFlags(args4, row) {
|
|
16680
16680
|
if (args4["conversion-count"] !== void 0 && args4["conversion-count"] !== "") {
|
|
16681
|
-
const
|
|
16682
|
-
if (!Number.isInteger(
|
|
16681
|
+
const count2 = Number(args4["conversion-count"]);
|
|
16682
|
+
if (!Number.isInteger(count2) || count2 < 1) {
|
|
16683
16683
|
failWriteValidation("--conversion-count must be a whole number of conversions, e.g. 3");
|
|
16684
16684
|
}
|
|
16685
|
-
row.conversionCount =
|
|
16685
|
+
row.conversionCount = count2;
|
|
16686
16686
|
}
|
|
16687
16687
|
if (args4.value !== void 0 && args4.value !== "") {
|
|
16688
16688
|
const numeric = Number(args4.value);
|
|
@@ -17243,12 +17243,12 @@ function keywordKillHints(status) {
|
|
|
17243
17243
|
const kills = typeof status === "string" && ["PAUSED", "REMOVED"].includes(status.toUpperCase());
|
|
17244
17244
|
return kills ? KEYWORD_KILL_HINTS : [];
|
|
17245
17245
|
}
|
|
17246
|
-
function keywordFinalUrlHints(finalUrl,
|
|
17246
|
+
function keywordFinalUrlHints(finalUrl, count2) {
|
|
17247
17247
|
if (typeof finalUrl !== "string" || finalUrl.length === 0) {
|
|
17248
17248
|
return [];
|
|
17249
17249
|
}
|
|
17250
|
-
return
|
|
17251
|
-
`--final-url applied the same landing page to all ${
|
|
17250
|
+
return count2 > 1 ? [
|
|
17251
|
+
`--final-url applied the same landing page to all ${count2} keywords \u2014 the flag has no per-keyword form. A keyword-level URL only earns its place when that keyword must land somewhere the ad doesn't, so one URL shared by every keyword belongs on the ad instead: set the ad's final URL to this page and re-stage the keywords without --final-url. Keep it only if this page is genuinely each keyword's own destination and not the ad's.`
|
|
17252
17252
|
] : [
|
|
17253
17253
|
"Keep --final-url only if this keyword's destination differs from the ad's final URL \u2014 otherwise drop it and let the keyword inherit the ad's landing page."
|
|
17254
17254
|
];
|
|
@@ -21849,7 +21849,7 @@ function renderOutcome2(data, lines) {
|
|
|
21849
21849
|
const status = op.result?.status ?? "pending";
|
|
21850
21850
|
counts.set(status, (counts.get(status) ?? 0) + 1);
|
|
21851
21851
|
}
|
|
21852
|
-
const summary = [...counts].map(([status,
|
|
21852
|
+
const summary = [...counts].map(([status, count2]) => `${count2} ${status}`).join(", ");
|
|
21853
21853
|
lines.push(`LinkedIn Ads \u2014 published \xB7 ${summary || `${data.count} op(s)`} \xB7 ${data.mode}`);
|
|
21854
21854
|
for (const op of data.ops ?? []) {
|
|
21855
21855
|
const status = op.result?.status;
|
|
@@ -26986,12 +26986,12 @@ function compact(payload) {
|
|
|
26986
26986
|
return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== void 0));
|
|
26987
26987
|
}
|
|
26988
26988
|
function batchNudge2(data) {
|
|
26989
|
-
const
|
|
26990
|
-
if (!
|
|
26989
|
+
const count2 = data?.sameKindStaged;
|
|
26990
|
+
if (!count2) {
|
|
26991
26991
|
return [];
|
|
26992
26992
|
}
|
|
26993
26993
|
return [
|
|
26994
|
-
`${
|
|
26994
|
+
`${count2} other change${count2 === 1 ? "" : "s"} of this kind and payload ${count2 === 1 ? "is" : "are"} already staged \u2014 pass every id to one call (\`--ids a,b,c\`) instead of one call each.`
|
|
26995
26995
|
];
|
|
26996
26996
|
}
|
|
26997
26997
|
async function stageXOp(raw, hints2) {
|
|
@@ -35848,14 +35848,14 @@ function canvasDims(ar) {
|
|
|
35848
35848
|
function fillPanel(label, w, h, out) {
|
|
35849
35849
|
return `[${label}]scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h},setsar=1,fps=30[${out}]`;
|
|
35850
35850
|
}
|
|
35851
|
-
function splitStackArgs(
|
|
35851
|
+
function splitStackArgs(count2, axis, dims) {
|
|
35852
35852
|
const span = axis === "horizontal" ? dims.w : dims.h;
|
|
35853
|
-
const base = Math.floor(span /
|
|
35854
|
-
const sizeAt = (i) => i ===
|
|
35853
|
+
const base = Math.floor(span / count2 / 2) * 2;
|
|
35854
|
+
const sizeAt = (i) => i === count2 - 1 ? span - base * (count2 - 1) : base;
|
|
35855
35855
|
const inputs = [];
|
|
35856
35856
|
const filt = [];
|
|
35857
35857
|
let labels = "";
|
|
35858
|
-
for (let i = 0; i <
|
|
35858
|
+
for (let i = 0; i < count2; i++) {
|
|
35859
35859
|
inputs.push("-i", `{{in.c${i}}}`);
|
|
35860
35860
|
const pw = axis === "horizontal" ? sizeAt(i) : dims.w;
|
|
35861
35861
|
const ph = axis === "vertical" ? sizeAt(i) : dims.h;
|
|
@@ -35863,7 +35863,7 @@ function splitStackArgs(count, axis, dims) {
|
|
|
35863
35863
|
labels += `[p${i}]`;
|
|
35864
35864
|
}
|
|
35865
35865
|
const stack = axis === "vertical" ? "vstack" : "hstack";
|
|
35866
|
-
filt.push(`${labels}${stack}=inputs=${
|
|
35866
|
+
filt.push(`${labels}${stack}=inputs=${count2}[v]`);
|
|
35867
35867
|
return [...inputs, "-filter_complex", filt.join(";"), "-map", "[v]", "{{out.video}}"];
|
|
35868
35868
|
}
|
|
35869
35869
|
function overlayXY(position, marginPx) {
|
|
@@ -42537,13 +42537,13 @@ async function markDirectoryIsReal(dir) {
|
|
|
42537
42537
|
var INK_ALPHA = 128;
|
|
42538
42538
|
function inkLuma(rgba) {
|
|
42539
42539
|
let sum = 0;
|
|
42540
|
-
let
|
|
42540
|
+
let count2 = 0;
|
|
42541
42541
|
for (let i = 0; i + 3 < rgba.length; i += 4) {
|
|
42542
42542
|
if (rgba[i + 3] < INK_ALPHA) continue;
|
|
42543
42543
|
sum += 0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2];
|
|
42544
|
-
|
|
42544
|
+
count2 += 1;
|
|
42545
42545
|
}
|
|
42546
|
-
return
|
|
42546
|
+
return count2 === 0 ? null : sum / count2;
|
|
42547
42547
|
}
|
|
42548
42548
|
var LIGHT_INK_LUMA = 170;
|
|
42549
42549
|
async function markInkIsLight(markPath) {
|
|
@@ -45008,185 +45008,6 @@ async function foldExperiment(root, target) {
|
|
|
45008
45008
|
};
|
|
45009
45009
|
}
|
|
45010
45010
|
|
|
45011
|
-
// src/commands/experiment/hints.ts
|
|
45012
|
-
var LIVE_HINT = {
|
|
45013
|
-
keep_running: (row) => `${row.experimentId} cannot yet tell the two pages apart \u2014 do not read its numbers as a result, and do not end it hoping for one. ${describeWait(row)}`,
|
|
45014
|
-
winner: (row) => row.winner === "variant" ? `${row.experimentId} has a winner: the new variant. Run \`baker experiment finish --id ${row.experimentId} --keep variant\` to show it to everyone, then \`baker experiment fold\`.` : `${row.experimentId} has a winner: the page as it is. Run \`baker experiment finish --id ${row.experimentId} --keep original\` to put all the traffic back on it.`,
|
|
45015
|
-
no_difference: (row) => `${row.experimentId} measured the two pages as alike \u2014 it collected enough traffic to see the change it was looking for, and there was none. End it with \`--keep original\` and test a bigger change \u2014 a headline, an offer, the shape of the page, not a button colour.`,
|
|
45016
|
-
stopped_early_harmful: (row) => `${row.experimentId} is doing damage and is STILL being shown to visitors. Run \`baker experiment finish --id ${row.experimentId} --keep original\` now.`,
|
|
45017
|
-
// Unreachable on a live test — only ending one produces it. The Record is
|
|
45018
|
-
// exhaustive by type so a verdict added upstream fails to compile here rather
|
|
45019
|
-
// than printing nothing.
|
|
45020
|
-
inconclusive: (row) => `${row.experimentId} was ended before it concluded and found nothing.`,
|
|
45021
|
-
invalid: (row) => `${row.experimentId} cannot be read: ${row.invalidReason === "crossover" ? "too many visitors saw both versions" : "the traffic did not split evenly"}. End it with \`--keep original\` and start again rather than reporting anything from it.`
|
|
45022
|
-
};
|
|
45023
|
-
function describeWait(row) {
|
|
45024
|
-
if (row.willNotConclude) {
|
|
45025
|
-
return "This page's traffic will not settle it at any point, so end it with `--keep original` and test a bigger change.";
|
|
45026
|
-
}
|
|
45027
|
-
if (row.earliestDecisionAt === null) {
|
|
45028
|
-
return "This page has produced no visitors yet, so there is nothing to project a finish date from.";
|
|
45029
|
-
}
|
|
45030
|
-
return `Earliest it could say anything: ${new Date(row.earliestDecisionAt).toISOString().slice(0, 10)}.`;
|
|
45031
|
-
}
|
|
45032
|
-
function pausedNote(row) {
|
|
45033
|
-
return row.status === "paused" ? `${row.experimentId} is paused \u2014 everyone sees the original. \`baker experiment resume --id ${row.experimentId}\` restarts the split with the same sides. ` : "";
|
|
45034
|
-
}
|
|
45035
|
-
function unlearnedHint(experiments) {
|
|
45036
|
-
const unlearned = experiments.filter(
|
|
45037
|
-
(row) => row.status === "finished" && row.learning === null && row.verdict !== "inconclusive"
|
|
45038
|
-
);
|
|
45039
|
-
if (unlearned.length === 0) return null;
|
|
45040
|
-
const ids = unlearned.map((row) => row.experimentId).join(", ");
|
|
45041
|
-
return `${unlearned.length === 1 ? "One finished test has" : `${unlearned.length} finished tests have`} no learning written down yet (${ids}). Say what it taught in one or two sentences \u2014 what the client should believe about this page now \u2014 with \`baker experiment update --id <id> --learning "\u2026"\`. It is what \`plan\` hands the next test.`;
|
|
45042
|
-
}
|
|
45043
|
-
function buildStatusHints(experiments) {
|
|
45044
|
-
const hints2 = experiments.filter((row) => row.status !== "finished").map((row) => pausedNote(row) + LIVE_HINT[row.verdict](row));
|
|
45045
|
-
const unlearned = unlearnedHint(experiments);
|
|
45046
|
-
if (unlearned) hints2.push(unlearned);
|
|
45047
|
-
if (experiments.length === 0) {
|
|
45048
|
-
hints2.push(
|
|
45049
|
-
"No tests yet. `baker experiment plan --landing <slug> --variant <slug>` says whether a page has enough traffic for one before you build the alternative."
|
|
45050
|
-
);
|
|
45051
|
-
}
|
|
45052
|
-
return hints2;
|
|
45053
|
-
}
|
|
45054
|
-
function asLift(value) {
|
|
45055
|
-
return `+${Math.round(value * 100)}%`;
|
|
45056
|
-
}
|
|
45057
|
-
function horizonsHint(plan) {
|
|
45058
|
-
const settled = plan.horizons.filter((horizon) => horizon.detectableLift !== null);
|
|
45059
|
-
if (settled.length === 0) {
|
|
45060
|
-
return "At this page's traffic no window up to 90 days would gather enough visitors to settle anything, at any size of change. The constraint is the traffic, not the test.";
|
|
45061
|
-
}
|
|
45062
|
-
const parts = settled.map((horizon) => `${horizon.days} days \u2192 ${asLift(horizon.detectableLift ?? 0)}`);
|
|
45063
|
-
return `The smallest change each window could settle on this page's traffic: ${parts.join(", ")}. Design the alternative to be at least that different.`;
|
|
45064
|
-
}
|
|
45065
|
-
function conversionsHint(plan) {
|
|
45066
|
-
if (plan.conversions.length <= 1) return null;
|
|
45067
|
-
const others = plan.conversions.filter((entry) => entry.eventKey !== plan.goal);
|
|
45068
|
-
return `This page's visitors also went on to: ${others.map((entry) => `\`${entry.eventKey}\`${entry.name ? ` (\u201C${entry.name}\u201D)` : ""} \u2014 ${entry.convertingVisitors}`).join(
|
|
45069
|
-
", "
|
|
45070
|
-
)}. Any of them can be the goal (--goal <key>); the named ones are reported beside it on every result, and decide nothing.`;
|
|
45071
|
-
}
|
|
45072
|
-
function pastTestLine(test) {
|
|
45073
|
-
const outcome = {
|
|
45074
|
-
supported: "the idea held",
|
|
45075
|
-
refuted_opposite: "the idea was WRONG \u2014 the change made it worse",
|
|
45076
|
-
refuted_no_effect: "no difference \u2014 the change did not matter",
|
|
45077
|
-
unanswered: "never answered"
|
|
45078
|
-
};
|
|
45079
|
-
const kept = test.survivor === "variant" ? "the new version was kept" : "the original was kept";
|
|
45080
|
-
const learning = test.learning ? ` Learning: ${test.learning}` : "";
|
|
45081
|
-
return `\u201C${test.hypothesis.change}\u201D (${test.experimentId}, ${test.ranForDays} days): ${outcome[test.hypothesisOutcome]}; ${kept}${test.againstVerdict ? " against the numbers" : ""}.${learning}`;
|
|
45082
|
-
}
|
|
45083
|
-
function pastTestsHint(plan) {
|
|
45084
|
-
if (plan.pastTests.length === 0) return null;
|
|
45085
|
-
const lines = plan.pastTests.slice(0, 5).map(pastTestLine);
|
|
45086
|
-
const more = plan.pastTests.length > 5 ? ` \u2026and ${plan.pastTests.length - 5} more \u2014 \`baker experiment history --landing <slug>\` lists them all.` : "";
|
|
45087
|
-
return `This page has been tested before \u2014 read this before choosing what to change, and build on it rather than repeating it: ${lines.join(" ")}${more}`;
|
|
45088
|
-
}
|
|
45089
|
-
function buildPlanHints(plan) {
|
|
45090
|
-
const horizons = horizonsHint(plan);
|
|
45091
|
-
const past = pastTestsHint(plan);
|
|
45092
|
-
if (!plan.canRun) {
|
|
45093
|
-
return [
|
|
45094
|
-
plan.reason ?? "This test cannot run on this page.",
|
|
45095
|
-
...horizons && plan.visitors > 0 ? [horizons] : [],
|
|
45096
|
-
...past ? [past] : [],
|
|
45097
|
-
"A test that cannot conclude is worse than no test \u2014 it still produces a number, and somebody acts on it."
|
|
45098
|
-
];
|
|
45099
|
-
}
|
|
45100
|
-
const hints2 = [
|
|
45101
|
-
...past ? [past] : [],
|
|
45102
|
-
// First, because it changes how every number below should be read: a plan
|
|
45103
|
-
// sized on a stand-in is a guide, and the agent must say so to the client
|
|
45104
|
-
// rather than quote "about 70 days" as though it were measured.
|
|
45105
|
-
...plan.warning ? [plan.warning] : [],
|
|
45106
|
-
`This test is read on \u201C${plan.goal}\u201D. That is fixed when it starts and cannot be changed later.`,
|
|
45107
|
-
// Fires at the exact moment the hypothesis gets invented, whether or not
|
|
45108
|
-
// the agent read the family doc. `plan` says a question CAN be settled here
|
|
45109
|
-
// and says nothing about which question is worth asking — and a well-run
|
|
45110
|
-
// test of a bad idea spends the page's traffic and returns `no_difference`.
|
|
45111
|
-
"Now decide WHAT to change, from evidence rather than taste. These numbers say which page and which step; they cannot say why. A second opinion answers why \u2014 Microsoft Clarity for what visitors actually do on the page, `baker gsc` for what they searched, `baker ads <platform>` for what the ad promised, `baker ga4` for the client's own measurement. `baker capabilities` lists everything this client has in one call. Message match \u2014 what the ad or the search promised against what the page opens with \u2014 is the cheapest hypothesis worth testing and is wrong surprisingly often.",
|
|
45112
|
-
// The base always works, so a missing integration is never a reason to
|
|
45113
|
-
// stop. Naming what it would have shown is worth more to the client than
|
|
45114
|
-
// either a silent gap or a run that waited.
|
|
45115
|
-
"If none of that is connected, this page's own drop-off is enough to test on \u2014 say which source you lacked and what it would have told you, then run the test anyway. A test never waits on an integration."
|
|
45116
|
-
];
|
|
45117
|
-
const conversions = conversionsHint(plan);
|
|
45118
|
-
if (conversions) hints2.push(conversions);
|
|
45119
|
-
if (plan.estimatedDays !== null && plan.estimatedDays > 60) {
|
|
45120
|
-
hints2.push(
|
|
45121
|
-
`At this page's recent traffic it would take about ${plan.estimatedDays} days \u2014 a guide, not a limit: it ends when the numbers are clear, and more traffic (a campaign, ads) shortens it. If the traffic will stay as it is, test a bigger change, which needs fewer visitors to detect.`
|
|
45122
|
-
);
|
|
45123
|
-
}
|
|
45124
|
-
if (horizons) hints2.push(horizons);
|
|
45125
|
-
return hints2;
|
|
45126
|
-
}
|
|
45127
|
-
function buildHistoryHints(tests, landing) {
|
|
45128
|
-
if (tests.length === 0) {
|
|
45129
|
-
return [
|
|
45130
|
-
landing ? `No finished test on \u201C${landing}\u201D yet \u2014 nothing to build on, and nothing to avoid repeating. \`baker experiment plan\` says whether the page can settle one.` : "No finished tests yet. `baker experiment plan --landing <slug> --variant <slug>` says whether a page can settle one."
|
|
45131
|
-
];
|
|
45132
|
-
}
|
|
45133
|
-
const hints2 = [];
|
|
45134
|
-
const supported = tests.filter((test) => test.hypothesisOutcome === "supported");
|
|
45135
|
-
const refuted = tests.filter((test) => test.hypothesisOutcome === "refuted_opposite");
|
|
45136
|
-
const noEffect = tests.filter((test) => test.hypothesisOutcome === "refuted_no_effect");
|
|
45137
|
-
if (supported.length > 0) {
|
|
45138
|
-
hints2.push(
|
|
45139
|
-
`Held: ${supported.map((test) => `\u201C${test.hypothesis.change}\u201D on ${test.landingSlug}`).join("; ")}. Build on these \u2014 the same insight usually has more in it.`
|
|
45140
|
-
);
|
|
45141
|
-
}
|
|
45142
|
-
if (refuted.length > 0) {
|
|
45143
|
-
hints2.push(
|
|
45144
|
-
`Made it worse: ${refuted.map((test) => `\u201C${test.hypothesis.change}\u201D on ${test.landingSlug}`).join("; ")}. Do not propose these again as they were; the observation behind each may still be right, the change was not.`
|
|
45145
|
-
);
|
|
45146
|
-
}
|
|
45147
|
-
if (noEffect.length > 0) {
|
|
45148
|
-
hints2.push(
|
|
45149
|
-
`Did not matter: ${noEffect.map((test) => `\u201C${test.hypothesis.change}\u201D on ${test.landingSlug}`).join("; ")}. The next change on these pages has to be bigger \u2014 a headline, an offer, the shape of the page.`
|
|
45150
|
-
);
|
|
45151
|
-
}
|
|
45152
|
-
const unlearned = tests.filter((test) => test.learning === null && test.verdict !== "inconclusive");
|
|
45153
|
-
if (unlearned.length > 0) {
|
|
45154
|
-
hints2.push(
|
|
45155
|
-
`${unlearned.length === 1 ? "One test has" : `${unlearned.length} tests have`} no learning written down (${unlearned.map((test) => test.experimentId).join(", ")}). Read its numbers and write one: \`baker experiment update --id <id> --learning "\u2026"\`.`
|
|
45156
|
-
);
|
|
45157
|
-
}
|
|
45158
|
-
return hints2;
|
|
45159
|
-
}
|
|
45160
|
-
function buildStartHints(staged) {
|
|
45161
|
-
const hints2 = [];
|
|
45162
|
-
for (const entry of staged.drift) {
|
|
45163
|
-
hints2.push(driftHint(staged, entry));
|
|
45164
|
-
}
|
|
45165
|
-
if (staged.composition && staged.composition.forked.length === 0) {
|
|
45166
|
-
hints2.push(
|
|
45167
|
-
`\u201C${staged.variant}\u201D does not have its own copy of any section \u2014 it renders exactly the same page as \u201C${staged.landing}\u201D, so this test cannot find anything. Fork the section you want to test (\`baker landing variant ${staged.landing} --fork Hero.astro\`) and edit only that file.`
|
|
45168
|
-
);
|
|
45169
|
-
}
|
|
45170
|
-
if (staged.newForms.length > 0 && staged.variantGoal === void 0) {
|
|
45171
|
-
const forms = staged.newForms.map((flow) => `\u201C${flow}\u201D`).join(", ");
|
|
45172
|
-
hints2.push(
|
|
45173
|
-
`\u201C${staged.variant}\u201D uses ${staged.newForms.length === 1 ? "a Form" : "Forms"} the page does not: ${forms}. The test reads the new version on \`${staged.goal}\`, the page's event \u2014 if the new version converts by reaching this Form's ending instead, it can never convert on that key and the test will call it harmful. Say its own event: \`baker experiment update --id <id> --variant-goal submit:${staged.newForms[0]}\` (or the confirmation step's \`form:${staged.newForms[0]}:<node>:<trigger>\` key, from \`baker analytics conversions --candidates\` once the Form has been opened in the preview).`
|
|
45174
|
-
);
|
|
45175
|
-
}
|
|
45176
|
-
return hints2;
|
|
45177
|
-
}
|
|
45178
|
-
function driftHint(staged, entry) {
|
|
45179
|
-
const suffix = ` Check both versions before reading that number, and give the new version's step its own name if it is not the same action (\`baker analytics conversions --candidates\`).`;
|
|
45180
|
-
if (entry.reason === "trigger_differs") {
|
|
45181
|
-
return `\u201C${staged.landing}\u201D and \u201C${staged.variant}\u201D both count \`${entry.name}\`, but one fires it when the element is **scrolled into view** and the other when the visitor **does something**. Those are two different questions with one answer, and the test will report a difference that is the trigger rather than the page.${suffix}`;
|
|
45182
|
-
}
|
|
45183
|
-
if (entry.reason === "variant_only_missing" || entry.reason === "control_only_missing") {
|
|
45184
|
-
const missing = entry.reason === "variant_only_missing" ? staged.variant : staged.landing;
|
|
45185
|
-
return `Only one version fires \`${entry.name}\` \u2014 \u201C${missing}\u201D does not declare it at all, so it can only ever read zero for that conversion and the comparison will look like a collapse.${suffix}`;
|
|
45186
|
-
}
|
|
45187
|
-
return `Both versions count \`${entry.name}\`, and the section the test changed is what fires it \u2014 the markup around it is not the same on the two pages. If the new version fires it on a different action (a block that is visible from the start rather than one the visitor opens), that conversion is comparing two different things.${suffix}`;
|
|
45188
|
-
}
|
|
45189
|
-
|
|
45190
45011
|
// ../api/src/experiments/composition.ts
|
|
45191
45012
|
import { z as z41 } from "zod";
|
|
45192
45013
|
var experimentCompositionSchema = z41.object({
|
|
@@ -45251,6 +45072,25 @@ var experimentHypothesisOutcomeSchema = z43.enum([
|
|
|
45251
45072
|
"unanswered"
|
|
45252
45073
|
]);
|
|
45253
45074
|
|
|
45075
|
+
// ../api/src/experiments/wait.ts
|
|
45076
|
+
var DAY_MS5 = 24 * 60 * 60 * 1e3;
|
|
45077
|
+
function count(value, unit) {
|
|
45078
|
+
return `about ${value} ${unit}${value === 1 ? "" : "s"}`;
|
|
45079
|
+
}
|
|
45080
|
+
function describeWait(earliestDecisionAt, now) {
|
|
45081
|
+
const days = (earliestDecisionAt - now) / DAY_MS5;
|
|
45082
|
+
if (days <= 0) return "any moment now";
|
|
45083
|
+
if (days < 1) return "within a day";
|
|
45084
|
+
if (days < 14) return count(Math.round(days), "day");
|
|
45085
|
+
if (days < 60) return count(Math.round(days / 7), "week");
|
|
45086
|
+
return count(Math.round(days / 30), "month");
|
|
45087
|
+
}
|
|
45088
|
+
function roughCount(count2) {
|
|
45089
|
+
if (count2 < 10) return `about ${Math.max(1, Math.round(count2)).toLocaleString("en-US")}`;
|
|
45090
|
+
const magnitude = 10 ** Math.max(1, Math.floor(Math.log10(count2)) - 1);
|
|
45091
|
+
return `about ${(Math.round(count2 / magnitude) * magnitude).toLocaleString("en-US")}`;
|
|
45092
|
+
}
|
|
45093
|
+
|
|
45254
45094
|
// ../api/src/experiments/wire.ts
|
|
45255
45095
|
import { z as z44 } from "zod";
|
|
45256
45096
|
var slugSchema = z44.string().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use a page slug, like `oferta`");
|
|
@@ -45470,7 +45310,17 @@ var experimentVerdictSchema = z44.enum([
|
|
|
45470
45310
|
"keep_running",
|
|
45471
45311
|
"winner",
|
|
45472
45312
|
"no_difference",
|
|
45313
|
+
/**
|
|
45314
|
+
* Even the best case is under the lift the test was built to find, so there
|
|
45315
|
+
* is nothing left for it to discover: keep the original. The "is this a
|
|
45316
|
+
* failure" answer — not proven worse, but proven not worth running.
|
|
45317
|
+
*/
|
|
45318
|
+
"cannot_win",
|
|
45473
45319
|
"invalid",
|
|
45320
|
+
/**
|
|
45321
|
+
* Only on tests read before the stopping rule was one interval: a version
|
|
45322
|
+
* proven worse is now `winner` with `winner: "control"`, whenever it is seen.
|
|
45323
|
+
*/
|
|
45474
45324
|
"stopped_early_harmful",
|
|
45475
45325
|
/**
|
|
45476
45326
|
* Ended before the numbers could say anything. Never produced by the
|
|
@@ -45615,6 +45465,14 @@ var experimentStatusRowSchema = z44.object({
|
|
|
45615
45465
|
precision: z44.number(),
|
|
45616
45466
|
/** When the test could first say something. `null` with no traffic to project from. */
|
|
45617
45467
|
earliestDecisionAt: z44.number().nullable(),
|
|
45468
|
+
/**
|
|
45469
|
+
* What the test is still missing before its verdict changes, if the gap it
|
|
45470
|
+
* is seeing holds: visitors across both arms, and the conversions they bring.
|
|
45471
|
+
* A test is gated on data, never on days — `earliestDecisionAt` is only this
|
|
45472
|
+
* divided by the page's pace. Null once it has spoken, before anyone has
|
|
45473
|
+
* arrived, or when no amount of traffic would settle it.
|
|
45474
|
+
*/
|
|
45475
|
+
stillNeeded: z44.object({ visitors: z44.number(), conversions: z44.number() }).nullable().default(null),
|
|
45618
45476
|
/**
|
|
45619
45477
|
* True when no amount of this page's traffic would settle the comparison.
|
|
45620
45478
|
* A different thing from a page that is merely slow, and the reason to end
|
|
@@ -45699,6 +45557,190 @@ var experimentFinishResponseSchema = z44.object({
|
|
|
45699
45557
|
summary: z44.string()
|
|
45700
45558
|
});
|
|
45701
45559
|
|
|
45560
|
+
// src/commands/experiment/hints.ts
|
|
45561
|
+
var LIVE_HINT = {
|
|
45562
|
+
keep_running: (row, now) => `${row.experimentId} cannot yet tell the two pages apart \u2014 do not end it hoping for a result. ${describeWait2(row, now)} To the user, say only this in plain words: "${row.summary}" plus how much more data it needs and roughly when. Never quote confidence, chance-better or intervals to them \u2014 those numbers confuse marketers and decide nothing.`,
|
|
45563
|
+
winner: (row) => row.winner === "variant" ? `${row.experimentId} has a winner: the new variant. Run \`baker experiment finish --id ${row.experimentId} --keep variant\` to show it to everyone, then \`baker experiment fold\`.` : `${row.experimentId}: the new version is proven worse and is STILL being shown to half the visitors. Run \`baker experiment finish --id ${row.experimentId} --keep original\` now.`,
|
|
45564
|
+
no_difference: (row) => `${row.experimentId} measured the two pages as alike \u2014 it collected enough traffic to see the change it was looking for, and there was none. End it with \`--keep original\` and test a bigger change \u2014 a headline, an offer, the shape of the page, not a button colour.`,
|
|
45565
|
+
cannot_win: (row) => `${row.experimentId}: the new version cannot win \u2014 even its best case is under the lift this test was looking for, so there is nothing left to find. Run \`baker experiment finish --id ${row.experimentId} --keep original --reason numbers_clear\` and write down what it taught.`,
|
|
45566
|
+
stopped_early_harmful: (row) => `${row.experimentId} is doing damage and is STILL being shown to visitors. Run \`baker experiment finish --id ${row.experimentId} --keep original\` now.`,
|
|
45567
|
+
// Unreachable on a live test — only ending one produces it. The Record is
|
|
45568
|
+
// exhaustive by type so a verdict added upstream fails to compile here rather
|
|
45569
|
+
// than printing nothing.
|
|
45570
|
+
inconclusive: (row) => `${row.experimentId} was ended before it concluded and found nothing.`,
|
|
45571
|
+
invalid: (row) => `${row.experimentId} cannot be read: ${row.invalidReason === "crossover" ? "too many visitors saw both versions" : "the traffic did not split evenly"}. End it with \`--keep original\` and start again rather than reporting anything from it.`
|
|
45572
|
+
};
|
|
45573
|
+
function describeWait2(row, now) {
|
|
45574
|
+
if (row.willNotConclude) {
|
|
45575
|
+
return "This page's traffic will not settle it at any point, so end it with `--keep original` and test a bigger change.";
|
|
45576
|
+
}
|
|
45577
|
+
if (row.earliestDecisionAt === null) {
|
|
45578
|
+
return "This page has produced no visitors yet, so there is nothing to project a finish date from.";
|
|
45579
|
+
}
|
|
45580
|
+
if (row.stillNeeded === null) {
|
|
45581
|
+
return `It already has the data it needs; the verdict waits only until it has seen a full day of visitors \u2014 ${describeWait(row.earliestDecisionAt, now)}.`;
|
|
45582
|
+
}
|
|
45583
|
+
const needed = `It needs ${roughCount(row.stillNeeded.visitors)} more visitors (${roughCount(row.stillNeeded.conversions)} more conversions) for an answer if the gap holds \u2014 `;
|
|
45584
|
+
return `${needed}${describeWait(row.earliestDecisionAt, now)} at today's traffic (${new Date(row.earliestDecisionAt).toISOString().slice(0, 10)}). A test is gated on this data, never on days.`;
|
|
45585
|
+
}
|
|
45586
|
+
function pausedNote(row) {
|
|
45587
|
+
return row.status === "paused" ? `${row.experimentId} is paused \u2014 everyone sees the original. \`baker experiment resume --id ${row.experimentId}\` restarts the split with the same sides. ` : "";
|
|
45588
|
+
}
|
|
45589
|
+
function unlearnedHint(experiments) {
|
|
45590
|
+
const unlearned = experiments.filter(
|
|
45591
|
+
(row) => row.status === "finished" && row.learning === null && row.verdict !== "inconclusive"
|
|
45592
|
+
);
|
|
45593
|
+
if (unlearned.length === 0) return null;
|
|
45594
|
+
const ids = unlearned.map((row) => row.experimentId).join(", ");
|
|
45595
|
+
return `${unlearned.length === 1 ? "One finished test has" : `${unlearned.length} finished tests have`} no learning written down yet (${ids}). Say what it taught in one or two sentences \u2014 what the client should believe about this page now \u2014 with \`baker experiment update --id <id> --learning "\u2026"\`. It is what \`plan\` hands the next test.`;
|
|
45596
|
+
}
|
|
45597
|
+
function buildStatusHints(experiments, now = Date.now()) {
|
|
45598
|
+
const hints2 = experiments.filter((row) => row.status !== "finished").map((row) => pausedNote(row) + LIVE_HINT[row.verdict](row, now));
|
|
45599
|
+
const unlearned = unlearnedHint(experiments);
|
|
45600
|
+
if (unlearned) hints2.push(unlearned);
|
|
45601
|
+
if (experiments.length === 0) {
|
|
45602
|
+
hints2.push(
|
|
45603
|
+
"No tests yet. `baker experiment plan --landing <slug> --variant <slug>` says whether a page has enough traffic for one before you build the alternative."
|
|
45604
|
+
);
|
|
45605
|
+
}
|
|
45606
|
+
return hints2;
|
|
45607
|
+
}
|
|
45608
|
+
function asLift(value) {
|
|
45609
|
+
return `+${Math.round(value * 100)}%`;
|
|
45610
|
+
}
|
|
45611
|
+
function horizonsHint(plan) {
|
|
45612
|
+
const settled = plan.horizons.filter((horizon) => horizon.detectableLift !== null);
|
|
45613
|
+
if (settled.length === 0) {
|
|
45614
|
+
return "At this page's traffic no window up to 90 days would gather enough visitors to settle anything, at any size of change. The constraint is the traffic, not the test.";
|
|
45615
|
+
}
|
|
45616
|
+
const parts = settled.map((horizon) => `${horizon.days} days \u2192 ${asLift(horizon.detectableLift ?? 0)}`);
|
|
45617
|
+
return `The smallest change each window could settle on this page's traffic: ${parts.join(", ")}. Design the alternative to be at least that different.`;
|
|
45618
|
+
}
|
|
45619
|
+
function conversionsHint(plan) {
|
|
45620
|
+
if (plan.conversions.length <= 1) return null;
|
|
45621
|
+
const others = plan.conversions.filter((entry) => entry.eventKey !== plan.goal);
|
|
45622
|
+
return `This page's visitors also went on to: ${others.map((entry) => `\`${entry.eventKey}\`${entry.name ? ` (\u201C${entry.name}\u201D)` : ""} \u2014 ${entry.convertingVisitors}`).join(
|
|
45623
|
+
", "
|
|
45624
|
+
)}. Any of them can be the goal (--goal <key>); the named ones are reported beside it on every result, and decide nothing.`;
|
|
45625
|
+
}
|
|
45626
|
+
function pastTestLine(test) {
|
|
45627
|
+
const outcome = {
|
|
45628
|
+
supported: "the idea held",
|
|
45629
|
+
refuted_opposite: "the idea was WRONG \u2014 the change made it worse",
|
|
45630
|
+
refuted_no_effect: "no difference \u2014 the change did not matter",
|
|
45631
|
+
unanswered: "never answered"
|
|
45632
|
+
};
|
|
45633
|
+
const kept = test.survivor === "variant" ? "the new version was kept" : "the original was kept";
|
|
45634
|
+
const learning = test.learning ? ` Learning: ${test.learning}` : "";
|
|
45635
|
+
return `\u201C${test.hypothesis.change}\u201D (${test.experimentId}, ${test.ranForDays} days): ${outcome[test.hypothesisOutcome]}; ${kept}${test.againstVerdict ? " against the numbers" : ""}.${learning}`;
|
|
45636
|
+
}
|
|
45637
|
+
function pastTestsHint(plan) {
|
|
45638
|
+
if (plan.pastTests.length === 0) return null;
|
|
45639
|
+
const lines = plan.pastTests.slice(0, 5).map(pastTestLine);
|
|
45640
|
+
const more = plan.pastTests.length > 5 ? ` \u2026and ${plan.pastTests.length - 5} more \u2014 \`baker experiment history --landing <slug>\` lists them all.` : "";
|
|
45641
|
+
return `This page has been tested before \u2014 read this before choosing what to change, and build on it rather than repeating it: ${lines.join(" ")}${more}`;
|
|
45642
|
+
}
|
|
45643
|
+
function buildPlanHints(plan) {
|
|
45644
|
+
const horizons = horizonsHint(plan);
|
|
45645
|
+
const past = pastTestsHint(plan);
|
|
45646
|
+
if (!plan.canRun) {
|
|
45647
|
+
return [
|
|
45648
|
+
plan.reason ?? "This test cannot run on this page.",
|
|
45649
|
+
...horizons && plan.visitors > 0 ? [horizons] : [],
|
|
45650
|
+
...past ? [past] : [],
|
|
45651
|
+
"A test that cannot conclude is worse than no test \u2014 it still produces a number, and somebody acts on it."
|
|
45652
|
+
];
|
|
45653
|
+
}
|
|
45654
|
+
const hints2 = [
|
|
45655
|
+
...past ? [past] : [],
|
|
45656
|
+
// First, because it changes how every number below should be read: a plan
|
|
45657
|
+
// sized on a stand-in is a guide, and the agent must say so to the client
|
|
45658
|
+
// rather than quote "about 70 days" as though it were measured.
|
|
45659
|
+
...plan.warning ? [plan.warning] : [],
|
|
45660
|
+
`This test is read on \u201C${plan.goal}\u201D. That is fixed when it starts and cannot be changed later.`,
|
|
45661
|
+
// Fires at the exact moment the hypothesis gets invented, whether or not
|
|
45662
|
+
// the agent read the family doc. `plan` says a question CAN be settled here
|
|
45663
|
+
// and says nothing about which question is worth asking — and a well-run
|
|
45664
|
+
// test of a bad idea spends the page's traffic and returns `no_difference`.
|
|
45665
|
+
"Now decide WHAT to change, from evidence rather than taste. These numbers say which page and which step; they cannot say why. A second opinion answers why \u2014 Microsoft Clarity for what visitors actually do on the page, `baker gsc` for what they searched, `baker ads <platform>` for what the ad promised, `baker ga4` for the client's own measurement. `baker capabilities` lists everything this client has in one call. Message match \u2014 what the ad or the search promised against what the page opens with \u2014 is the cheapest hypothesis worth testing and is wrong surprisingly often.",
|
|
45666
|
+
// The base always works, so a missing integration is never a reason to
|
|
45667
|
+
// stop. Naming what it would have shown is worth more to the client than
|
|
45668
|
+
// either a silent gap or a run that waited.
|
|
45669
|
+
"If none of that is connected, this page's own drop-off is enough to test on \u2014 say which source you lacked and what it would have told you, then run the test anyway. A test never waits on an integration."
|
|
45670
|
+
];
|
|
45671
|
+
const conversions = conversionsHint(plan);
|
|
45672
|
+
if (conversions) hints2.push(conversions);
|
|
45673
|
+
if (plan.estimatedDays !== null && plan.estimatedDays > 60) {
|
|
45674
|
+
hints2.push(
|
|
45675
|
+
`At this page's recent traffic it would take about ${plan.estimatedDays} days \u2014 a guide, not a limit: it ends when the numbers are clear, and more traffic (a campaign, ads) shortens it. If the traffic will stay as it is, test a bigger change, which needs fewer visitors to detect.`
|
|
45676
|
+
);
|
|
45677
|
+
}
|
|
45678
|
+
if (horizons) hints2.push(horizons);
|
|
45679
|
+
return hints2;
|
|
45680
|
+
}
|
|
45681
|
+
function buildHistoryHints(tests, landing) {
|
|
45682
|
+
if (tests.length === 0) {
|
|
45683
|
+
return [
|
|
45684
|
+
landing ? `No finished test on \u201C${landing}\u201D yet \u2014 nothing to build on, and nothing to avoid repeating. \`baker experiment plan\` says whether the page can settle one.` : "No finished tests yet. `baker experiment plan --landing <slug> --variant <slug>` says whether a page can settle one."
|
|
45685
|
+
];
|
|
45686
|
+
}
|
|
45687
|
+
const hints2 = [];
|
|
45688
|
+
const supported = tests.filter((test) => test.hypothesisOutcome === "supported");
|
|
45689
|
+
const refuted = tests.filter((test) => test.hypothesisOutcome === "refuted_opposite");
|
|
45690
|
+
const noEffect = tests.filter((test) => test.hypothesisOutcome === "refuted_no_effect");
|
|
45691
|
+
if (supported.length > 0) {
|
|
45692
|
+
hints2.push(
|
|
45693
|
+
`Held: ${supported.map((test) => `\u201C${test.hypothesis.change}\u201D on ${test.landingSlug}`).join("; ")}. Build on these \u2014 the same insight usually has more in it.`
|
|
45694
|
+
);
|
|
45695
|
+
}
|
|
45696
|
+
if (refuted.length > 0) {
|
|
45697
|
+
hints2.push(
|
|
45698
|
+
`Made it worse: ${refuted.map((test) => `\u201C${test.hypothesis.change}\u201D on ${test.landingSlug}`).join("; ")}. Do not propose these again as they were; the observation behind each may still be right, the change was not.`
|
|
45699
|
+
);
|
|
45700
|
+
}
|
|
45701
|
+
if (noEffect.length > 0) {
|
|
45702
|
+
hints2.push(
|
|
45703
|
+
`Did not matter: ${noEffect.map((test) => `\u201C${test.hypothesis.change}\u201D on ${test.landingSlug}`).join("; ")}. The next change on these pages has to be bigger \u2014 a headline, an offer, the shape of the page.`
|
|
45704
|
+
);
|
|
45705
|
+
}
|
|
45706
|
+
const unlearned = tests.filter((test) => test.learning === null && test.verdict !== "inconclusive");
|
|
45707
|
+
if (unlearned.length > 0) {
|
|
45708
|
+
hints2.push(
|
|
45709
|
+
`${unlearned.length === 1 ? "One test has" : `${unlearned.length} tests have`} no learning written down (${unlearned.map((test) => test.experimentId).join(", ")}). Read its numbers and write one: \`baker experiment update --id <id> --learning "\u2026"\`.`
|
|
45710
|
+
);
|
|
45711
|
+
}
|
|
45712
|
+
return hints2;
|
|
45713
|
+
}
|
|
45714
|
+
function buildStartHints(staged) {
|
|
45715
|
+
const hints2 = [];
|
|
45716
|
+
for (const entry of staged.drift) {
|
|
45717
|
+
hints2.push(driftHint(staged, entry));
|
|
45718
|
+
}
|
|
45719
|
+
if (staged.composition && staged.composition.forked.length === 0) {
|
|
45720
|
+
hints2.push(
|
|
45721
|
+
`\u201C${staged.variant}\u201D does not have its own copy of any section \u2014 it renders exactly the same page as \u201C${staged.landing}\u201D, so this test cannot find anything. Fork the section you want to test (\`baker landing variant ${staged.landing} --fork Hero.astro\`) and edit only that file.`
|
|
45722
|
+
);
|
|
45723
|
+
}
|
|
45724
|
+
if (staged.newForms.length > 0 && staged.variantGoal === void 0) {
|
|
45725
|
+
const forms = staged.newForms.map((flow) => `\u201C${flow}\u201D`).join(", ");
|
|
45726
|
+
hints2.push(
|
|
45727
|
+
`\u201C${staged.variant}\u201D uses ${staged.newForms.length === 1 ? "a Form" : "Forms"} the page does not: ${forms}. The test reads the new version on \`${staged.goal}\`, the page's event \u2014 if the new version converts by reaching this Form's ending instead, it can never convert on that key and the test will call it harmful. Say its own event: \`baker experiment update --id <id> --variant-goal submit:${staged.newForms[0]}\` (or the confirmation step's \`form:${staged.newForms[0]}:<node>:<trigger>\` key, from \`baker analytics conversions --candidates\` once the Form has been opened in the preview).`
|
|
45728
|
+
);
|
|
45729
|
+
}
|
|
45730
|
+
return hints2;
|
|
45731
|
+
}
|
|
45732
|
+
function driftHint(staged, entry) {
|
|
45733
|
+
const suffix = ` Check both versions before reading that number, and give the new version's step its own name if it is not the same action (\`baker analytics conversions --candidates\`).`;
|
|
45734
|
+
if (entry.reason === "trigger_differs") {
|
|
45735
|
+
return `\u201C${staged.landing}\u201D and \u201C${staged.variant}\u201D both count \`${entry.name}\`, but one fires it when the element is **scrolled into view** and the other when the visitor **does something**. Those are two different questions with one answer, and the test will report a difference that is the trigger rather than the page.${suffix}`;
|
|
45736
|
+
}
|
|
45737
|
+
if (entry.reason === "variant_only_missing" || entry.reason === "control_only_missing") {
|
|
45738
|
+
const missing = entry.reason === "variant_only_missing" ? staged.variant : staged.landing;
|
|
45739
|
+
return `Only one version fires \`${entry.name}\` \u2014 \u201C${missing}\u201D does not declare it at all, so it can only ever read zero for that conversion and the comparison will look like a collapse.${suffix}`;
|
|
45740
|
+
}
|
|
45741
|
+
return `Both versions count \`${entry.name}\`, and the section the test changed is what fires it \u2014 the markup around it is not the same on the two pages. If the new version fires it on a different action (a block that is visible from the start rather than one the visitor opens), that conversion is comparing two different things.${suffix}`;
|
|
45742
|
+
}
|
|
45743
|
+
|
|
45702
45744
|
// src/commands/experiment/hypothesis.ts
|
|
45703
45745
|
var SOURCES = experimentEvidenceSourceSchema.options.join(", ");
|
|
45704
45746
|
function parseOne(raw) {
|
|
@@ -46056,7 +46098,7 @@ registerSchema({
|
|
|
46056
46098
|
var statusCommand6 = defineCommand123({
|
|
46057
46099
|
meta: {
|
|
46058
46100
|
name: "status",
|
|
46059
|
-
description: "The verdict on each test. Read `verdict` and nothing else to decide: keep_running | winner | no_difference |
|
|
46101
|
+
description: "The verdict on each test. Read `verdict` and nothing else to decide: keep_running | winner | no_difference | cannot_win | invalid. `cannot_win` means even the best case is under the lift the test was looking for \u2014 end it and keep the original. `stillNeeded` is the data (visitors, conversions) the test still needs; a test is gated on data, never on days. `summary` is one sentence you can show the client, and `secondary` lists every other conversion the company counts on the same two arms \u2014 context, never a second decision. The numbers under `--full` are for printing, never for deciding \u2014 a test that says keep_running has not finished, however good its numbers look."
|
|
46060
46102
|
},
|
|
46061
46103
|
args: {
|
|
46062
46104
|
id: { type: "string", description: "One test, by its id", required: false },
|
|
@@ -52809,7 +52851,7 @@ async function runMediaGroupLookup(input) {
|
|
|
52809
52851
|
const data = await apiGet(`${input.path}?${query.toString()}`);
|
|
52810
52852
|
const images = data.images.map((image) => compactImage(image));
|
|
52811
52853
|
const videos = (data.videos ?? []).map((video) => compactVideo(video));
|
|
52812
|
-
const
|
|
52854
|
+
const count2 = images.length + videos.length;
|
|
52813
52855
|
writeJson({
|
|
52814
52856
|
ok: true,
|
|
52815
52857
|
data: {
|
|
@@ -52819,11 +52861,11 @@ async function runMediaGroupLookup(input) {
|
|
|
52819
52861
|
// place the campaign is named.
|
|
52820
52862
|
groupTitle: data.groupTitle ?? null,
|
|
52821
52863
|
groupUrl: data.groupUrl ?? null,
|
|
52822
|
-
count,
|
|
52864
|
+
count: count2,
|
|
52823
52865
|
images,
|
|
52824
52866
|
videos
|
|
52825
52867
|
},
|
|
52826
|
-
hints:
|
|
52868
|
+
hints: count2 > 1 ? ["These arrived together and are ordered. Read them in sequence before using any one of them alone."] : void 0
|
|
52827
52869
|
});
|
|
52828
52870
|
} catch (err) {
|
|
52829
52871
|
if (err instanceof ApiError) {
|
|
@@ -53523,9 +53565,9 @@ function getDominantEdgeColor(data, width, height) {
|
|
|
53523
53565
|
let maxCount = 0;
|
|
53524
53566
|
let dominantColor = { r: 0, g: 0, b: 0 };
|
|
53525
53567
|
for (const key in colorCount) {
|
|
53526
|
-
const
|
|
53527
|
-
if (
|
|
53528
|
-
maxCount =
|
|
53568
|
+
const count2 = colorCount[key];
|
|
53569
|
+
if (count2 > maxCount) {
|
|
53570
|
+
maxCount = count2;
|
|
53529
53571
|
const parts = key.split(",").map(Number);
|
|
53530
53572
|
dominantColor = { r: parts[0], g: parts[1], b: parts[2] };
|
|
53531
53573
|
}
|
|
@@ -55969,10 +56011,10 @@ var ANALYZERS = [
|
|
|
55969
56011
|
scope: "page",
|
|
55970
56012
|
run: (text2, file) => {
|
|
55971
56013
|
const body = stripHtmlToText(text2).replace(/—|�*8212;|�*2014;/gi, "\u2014");
|
|
55972
|
-
const
|
|
55973
|
-
if (
|
|
55974
|
-
if (body.length >
|
|
55975
|
-
return [{ id: "em-dash-overuse", snippet: `${
|
|
56014
|
+
const count2 = (body.match(/[—]|(?<!-)--(?!-)(?=\S)/g) ?? []).length;
|
|
56015
|
+
if (count2 < EM_DASH_FLOOR) return [];
|
|
56016
|
+
if (body.length > count2 * EM_DASH_CHARS_PER_DASH) return [];
|
|
56017
|
+
return [{ id: "em-dash-overuse", snippet: `${count2} em-dashes in body copy`, file }];
|
|
55976
56018
|
}
|
|
55977
56019
|
},
|
|
55978
56020
|
// Agent washing: an autonomy claim the page never scaffolds with a signal about
|
|
@@ -55999,25 +56041,25 @@ var ANALYZERS = [
|
|
|
55999
56041
|
run: (text2, file) => {
|
|
56000
56042
|
const body = stripHtmlToText(text2);
|
|
56001
56043
|
const lower = body.toLowerCase();
|
|
56002
|
-
let
|
|
56044
|
+
let count2 = 0;
|
|
56003
56045
|
let firstSample = "";
|
|
56004
56046
|
for (const phrase of BUZZWORDS) {
|
|
56005
56047
|
let from = 0;
|
|
56006
56048
|
for (; ; ) {
|
|
56007
56049
|
const idx = lower.indexOf(phrase, from);
|
|
56008
56050
|
if (idx === -1) break;
|
|
56009
|
-
|
|
56051
|
+
count2++;
|
|
56010
56052
|
if (!firstSample) {
|
|
56011
56053
|
firstSample = body.slice(Math.max(0, idx - 12), Math.min(body.length, idx + phrase.length + 12)).trim();
|
|
56012
56054
|
}
|
|
56013
56055
|
from = idx + phrase.length;
|
|
56014
56056
|
}
|
|
56015
56057
|
}
|
|
56016
|
-
if (
|
|
56058
|
+
if (count2 === 0) return [];
|
|
56017
56059
|
return [
|
|
56018
56060
|
{
|
|
56019
56061
|
id: "marketing-buzzword",
|
|
56020
|
-
snippet: `${
|
|
56062
|
+
snippet: `${count2} buzzword phrase${count2 === 1 ? "" : "s"}: "${firstSample}"`,
|
|
56021
56063
|
file
|
|
56022
56064
|
}
|
|
56023
56065
|
];
|
|
@@ -56029,18 +56071,18 @@ var ANALYZERS = [
|
|
|
56029
56071
|
scope: "page",
|
|
56030
56072
|
run: (text2, file) => {
|
|
56031
56073
|
const body = stripHtmlToText(text2);
|
|
56032
|
-
let
|
|
56074
|
+
let count2 = 0;
|
|
56033
56075
|
let firstSample = "";
|
|
56034
56076
|
for (const m of body.matchAll(/\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g)) {
|
|
56035
|
-
|
|
56077
|
+
count2++;
|
|
56036
56078
|
if (!firstSample) firstSample = cap2(m, 0).trim().slice(0, 80);
|
|
56037
56079
|
}
|
|
56038
56080
|
for (const m of body.matchAll(/\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g)) {
|
|
56039
|
-
|
|
56081
|
+
count2++;
|
|
56040
56082
|
if (!firstSample) firstSample = cap2(m, 0).trim().slice(0, 80);
|
|
56041
56083
|
}
|
|
56042
|
-
if (
|
|
56043
|
-
return [{ id: "aphoristic-cadence", snippet: `${
|
|
56084
|
+
if (count2 < 3) return [];
|
|
56085
|
+
return [{ id: "aphoristic-cadence", snippet: `${count2} aphoristic constructions: "${firstSample}"`, file }];
|
|
56044
56086
|
}
|
|
56045
56087
|
},
|
|
56046
56088
|
// Shared AI copy tells (engine/copy-tells) — the Wikipedia "Signs of AI
|
|
@@ -62810,7 +62852,7 @@ function clipImageLimitFor(args4) {
|
|
|
62810
62852
|
if (args4.from !== "references") {
|
|
62811
62853
|
return {
|
|
62812
62854
|
max: 1,
|
|
62813
|
-
refuse: (
|
|
62855
|
+
refuse: (count2) => `Animating one exact frame uses a single image \u2014 you passed ${count2}. Pick one, or switch --from references to guide the shot with all of them.`
|
|
62814
62856
|
};
|
|
62815
62857
|
}
|
|
62816
62858
|
if (!supportsReferenceToVideo(model)) {
|
|
@@ -62823,7 +62865,7 @@ function clipImageLimitFor(args4) {
|
|
|
62823
62865
|
if (max === void 0) return void 0;
|
|
62824
62866
|
return {
|
|
62825
62867
|
max,
|
|
62826
|
-
refuse: (
|
|
62868
|
+
refuse: (count2) => `${label} takes up to ${max} reference images \u2014 you passed ${count2}. Drop the weakest ones.`
|
|
62827
62869
|
};
|
|
62828
62870
|
}
|
|
62829
62871
|
function clipModeFor(from) {
|
|
@@ -62848,7 +62890,7 @@ function buildAnimateBody(args4, prompt, imageIds) {
|
|
|
62848
62890
|
return body;
|
|
62849
62891
|
}
|
|
62850
62892
|
function costHintsFor(body) {
|
|
62851
|
-
const
|
|
62893
|
+
const count2 = body.count ?? 1;
|
|
62852
62894
|
const model = body.model ?? DEFAULT_VIDEO_GENERATE_MODEL;
|
|
62853
62895
|
const perTake = estimateVideoCredits({
|
|
62854
62896
|
model,
|
|
@@ -62865,9 +62907,9 @@ function costHintsFor(body) {
|
|
|
62865
62907
|
// that gets re-routed — a frame-started clip never does.
|
|
62866
62908
|
mayRouteToFal: body.clipMode === "references" && body.imageIds.length > 0
|
|
62867
62909
|
});
|
|
62868
|
-
const total = perTake *
|
|
62910
|
+
const total = perTake * count2;
|
|
62869
62911
|
const hints2 = [
|
|
62870
|
-
|
|
62912
|
+
count2 === 1 ? `This clip costs about ${total} credits \u2014 roughly 45x an image.` : `${count2} takes at about ${perTake} credits each \u2014 about ${total} credits.`
|
|
62871
62913
|
];
|
|
62872
62914
|
const pinned = pinnedModelHint({ avatar: body.avatar, model: body.model, generateAudio: body.generateAudio });
|
|
62873
62915
|
if (pinned) hints2.push(pinned);
|
|
@@ -62875,7 +62917,7 @@ function costHintsFor(body) {
|
|
|
62875
62917
|
if (pace) hints2.push(pace);
|
|
62876
62918
|
const silent = silentModelHint(model, body.prompt);
|
|
62877
62919
|
if (silent) hints2.push(silent);
|
|
62878
|
-
if (
|
|
62920
|
+
if (count2 > 1) {
|
|
62879
62921
|
hints2.push(
|
|
62880
62922
|
"Ask for more than one take only when the MOTION is the risk \u2014 for a look you are unsure about, iterate on the still first."
|
|
62881
62923
|
);
|
|
@@ -63128,7 +63170,7 @@ function referenceLimitFor(model) {
|
|
|
63128
63170
|
const label = MODEL_REGISTRY.image_generate[chosen]?.label ?? chosen;
|
|
63129
63171
|
return {
|
|
63130
63172
|
max,
|
|
63131
|
-
refuse: (
|
|
63173
|
+
refuse: (count2) => max === 1 ? `${label} takes exactly one reference image \u2014 you passed ${count2}. Pick the one that matters, or switch to a model that reads several.` : `${label} takes up to ${max} reference images \u2014 you passed ${count2}. Drop the weakest ones.`
|
|
63132
63174
|
};
|
|
63133
63175
|
}
|
|
63134
63176
|
async function effectiveModel(pinned, skillId, fetchSkills = () => apiGet("/api/studio/skills")) {
|