marketing-mindset 1.8.0 → 1.10.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 +42 -0
- package/bin/cli.js +137 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -114,3 +114,45 @@ npx marketing-mindset mde --base 0.03 --per-arm 5000 --json
|
|
|
114
114
|
(0.05, 0.01, 0.1) are the same knobs as `sample`. When no lift is readable at that volume the command
|
|
115
115
|
says so instead of printing a number, and names the three ways out: more volume, a higher base rate, or a
|
|
116
116
|
cheaper measurable unit (for cold email the reply rate, floor ~1,500–2,000 sends per variant).
|
|
117
|
+
|
|
118
|
+
## `verdict` — was the difference real, and was the volume ever enough
|
|
119
|
+
|
|
120
|
+
Reading a finished test is a different job from planning one, and the two honest failure modes are opposite:
|
|
121
|
+
a difference that is inside the noise, and a difference that is real but was never readable at that volume.
|
|
122
|
+
`verdict` answers both from two counts.
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
npx marketing-mindset verdict --a 120/4000 --b 168/4000
|
|
126
|
+
# control 120/4,000 = 3.00% · variant 168/4,000 = 4.20%
|
|
127
|
+
# difference: 1.2pp (40.0% relative) · interval 0.44pp .. 1.96pp
|
|
128
|
+
# separated from noise: true · volume clears the floor: true
|
|
129
|
+
# verdict: readable: the interval excludes zero and the volume clears the 4,994 the observed lift requires ...
|
|
130
|
+
|
|
131
|
+
npx marketing-mindset verdict --a 120/4000 --b 168/4000 --json
|
|
132
|
+
npx marketing-mindset verdict --a-conv 120 --a-n 4000 --b-conv 168 --b-n 4000
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Each arm is `conversions/sample`. The command prints a Wilson interval per arm, a Newcombe interval for the
|
|
136
|
+
difference, and decides: not readable (the interval includes zero), a lead rather than a verdict (separated,
|
|
137
|
+
but below the per-arm volume the observed lift would need), or readable. `--alpha` takes the same three
|
|
138
|
+
values as `sample` (0.05, 0.01, 0.1) and the floor is computed with the same formula, so the answer stays
|
|
139
|
+
consistent with the plan that produced the test.
|
|
140
|
+
|
|
141
|
+
## `queue` — how many honest tests your volume can finish
|
|
142
|
+
|
|
143
|
+
Ideas are not the constraint. At a 3% base rate and a +20% relative lift a strict two-arm test needs 13,914
|
|
144
|
+
contacts per arm, so the number of tests a quarter can hold is set by the volume, not the backlog. `queue`
|
|
145
|
+
turns that volume into a dated queue.
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
npx marketing-mindset queue --daily 500 --days 90 --start 2026-09-21
|
|
149
|
+
# floor: 13,914 contacts per arm (27,828 in both arms) at a 3.00% base rate, +20% relative lift, power 0.8, alpha 0.05
|
|
150
|
+
# one honest test: 56 days at 500 contacts a day
|
|
151
|
+
# queue: 1 test in 90 days (62% of the horizon inside a test, 34 days outside one)
|
|
152
|
+
# read-out dates: 2026-11-16
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`--json` returns the same object: `floor_per_arm`, `total_contacts`, `days_per_test`, `tests_in_horizon`,
|
|
156
|
+
`read_dates`, `idle_days`, `used_pct` and the three gates. The floor uses the same formula as `sample`, so a
|
|
157
|
+
queue planned here and a test read with `verdict` stay consistent. Two things it refuses to hide: the read-out
|
|
158
|
+
is one look (peeking daily is not covered by alpha), and two live tests in the same arm split the volume.
|
package/bin/cli.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* marketing-mindset sample required per-arm sample size for a two-proportion test
|
|
5
5
|
* marketing-mindset kill the kill rule for a running test
|
|
6
6
|
* marketing-mindset mde smallest relative lift a fixed per-arm volume can read
|
|
7
|
+
* marketing-mindset verdict read a finished test: interval, floor, honest verdict
|
|
8
|
+
* marketing-mindset queue volume into a queue of honest tests with read-out dates
|
|
7
9
|
* marketing-mindset sections list every section of SKILL.md
|
|
8
10
|
* marketing-mindset read <text> print one section by fuzzy title match
|
|
9
11
|
* marketing-mindset install copy the skill into your agent's skills directory
|
|
@@ -438,6 +440,133 @@ function runMde(opts) {
|
|
|
438
440
|
return 0;
|
|
439
441
|
}
|
|
440
442
|
|
|
443
|
+
function wilsonInterval(conv, n, alpha) {
|
|
444
|
+
const z = ZA[String(alpha)] || ZA["0.05"];
|
|
445
|
+
const p = conv / n;
|
|
446
|
+
const d = 1 + (z * z) / n;
|
|
447
|
+
const centre = (p + (z * z) / (2 * n)) / d;
|
|
448
|
+
const half = (z * Math.sqrt((p * (1 - p)) / n + (z * z) / (4 * n * n))) / d;
|
|
449
|
+
return { low: Math.max(0, centre - half), high: Math.min(1, centre + half) };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function parseArm(v) {
|
|
453
|
+
const s = String(v === undefined ? "" : v).trim();
|
|
454
|
+
const m = s.match(/^(\d+)\s*\/\s*(\d+)$/);
|
|
455
|
+
if (!m) { return null; }
|
|
456
|
+
return { conv: Number(m[1]), n: Number(m[2]) };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function r6(x) { return Math.round(x * 1e6) / 1e6; }
|
|
460
|
+
|
|
461
|
+
function runVerdict(opts) {
|
|
462
|
+
const alpha = Number(opts.alpha !== undefined ? opts.alpha : 0.05);
|
|
463
|
+
let aRaw = opts.a;
|
|
464
|
+
if (aRaw === undefined && opts["a-conv"] !== undefined && opts["a-n"] !== undefined) {
|
|
465
|
+
aRaw = opts["a-conv"] + "/" + opts["a-n"];
|
|
466
|
+
}
|
|
467
|
+
let bRaw = opts.b;
|
|
468
|
+
if (bRaw === undefined && opts["b-conv"] !== undefined && opts["b-n"] !== undefined) {
|
|
469
|
+
bRaw = opts["b-conv"] + "/" + opts["b-n"];
|
|
470
|
+
}
|
|
471
|
+
const problems = [];
|
|
472
|
+
const a = parseArm(aRaw), b = parseArm(bRaw);
|
|
473
|
+
if (!a) { problems.push("control arm: pass --a 120/4000 (conversions/sample)"); }
|
|
474
|
+
if (!b) { problems.push("variant arm: pass --b 168/4000 (conversions/sample)"); }
|
|
475
|
+
if (a && (a.n <= 0 || a.conv > a.n)) { problems.push("control arm must be conversions <= sample and a positive sample"); }
|
|
476
|
+
if (b && (b.n <= 0 || b.conv > b.n)) { problems.push("variant arm must be conversions <= sample and a positive sample"); }
|
|
477
|
+
if (!ZA[String(alpha)]) { problems.push("alpha must be 0.05, 0.01 or 0.1"); }
|
|
478
|
+
if (problems.length) { console.error("verdict: " + problems.join("; ")); return 1; }
|
|
479
|
+
|
|
480
|
+
const pa = a.conv / a.n, pb = b.conv / b.n;
|
|
481
|
+
const ia = wilsonInterval(a.conv, a.n, alpha), ib = wilsonInterval(b.conv, b.n, alpha);
|
|
482
|
+
const diff = pb - pa;
|
|
483
|
+
const lo = diff - Math.sqrt(Math.pow(pa - ia.low, 2) + Math.pow(ib.high - pb, 2));
|
|
484
|
+
const hi = diff + Math.sqrt(Math.pow(ia.high - pa, 2) + Math.pow(pb - ib.low, 2));
|
|
485
|
+
const decisive = lo > 0 || hi < 0;
|
|
486
|
+
const rel = pa > 0 ? diff / pa : null;
|
|
487
|
+
const floor = rel !== null && rel !== 0 ? sampleSize(pa, Math.abs(rel), 0.8, alpha) : null;
|
|
488
|
+
const underpowered = floor !== null && b.n < floor;
|
|
489
|
+
let verdict;
|
|
490
|
+
if (!decisive) {
|
|
491
|
+
verdict = "not readable: the " + ((1 - alpha) * 100) + "% interval for the difference includes zero, so the point estimate is noise dressed as a result. The honest moves are more volume, a higher base rate, or a bigger difference - not a rebuild.";
|
|
492
|
+
} else if (underpowered) {
|
|
493
|
+
verdict = "a lead, not a verdict: the interval excludes zero, but " + fmtNum(b.n) + " per arm is below the " + fmtNum(floor) + " the observed lift would need. Keep it running to the floor before anything is rebuilt on it.";
|
|
494
|
+
} else {
|
|
495
|
+
verdict = "readable: the interval excludes zero and the volume clears the " + fmtNum(floor) + " the observed lift requires - this is the number a decision can be taken on.";
|
|
496
|
+
}
|
|
497
|
+
const out = {
|
|
498
|
+
control: { conversions: a.conv, n: a.n, rate: r6(pa), wilson_low: r6(ia.low), wilson_high: r6(ia.high) },
|
|
499
|
+
variant: { conversions: b.conv, n: b.n, rate: r6(pb), wilson_low: r6(ib.low), wilson_high: r6(ib.high) },
|
|
500
|
+
absolute_lift_pp: r6(diff * 100),
|
|
501
|
+
relative_lift: rel === null ? null : r6(rel),
|
|
502
|
+
difference_interval: [r6(lo), r6(hi)],
|
|
503
|
+
decisive, floor_per_arm: floor, underpowered, alpha, verdict
|
|
504
|
+
};
|
|
505
|
+
if (opts.json) { console.log(JSON.stringify(out, null, 2)); } else {
|
|
506
|
+
console.log("control " + a.conv + "/" + fmtNum(a.n) + " = " + (pa * 100).toFixed(2) + "% · variant " + b.conv + "/" + fmtNum(b.n) + " = " + (pb * 100).toFixed(2) + "%");
|
|
507
|
+
console.log("difference: " + out.absolute_lift_pp + "pp" + (rel === null ? "" : " (" + (rel * 100).toFixed(1) + "% relative)") + " · interval " + (lo * 100).toFixed(2) + "pp .. " + (hi * 100).toFixed(2) + "pp");
|
|
508
|
+
console.log("separated from noise: " + decisive + " · volume clears the floor: " + !underpowered);
|
|
509
|
+
console.log("verdict: " + verdict);
|
|
510
|
+
}
|
|
511
|
+
return 0;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function qAddDays(fromIso, days) {
|
|
515
|
+
const d = new Date(fromIso + "T00:00:00Z");
|
|
516
|
+
d.setUTCDate(d.getUTCDate() + days);
|
|
517
|
+
return d.toISOString().slice(0, 10);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function runQueue(opts) {
|
|
521
|
+
const base = Number(opts.base !== undefined ? opts.base : 0.03);
|
|
522
|
+
const lift = Number(opts.lift !== undefined ? opts.lift : 0.2);
|
|
523
|
+
const daily = Number(opts.daily);
|
|
524
|
+
const days = Number(opts.days !== undefined ? opts.days : 90);
|
|
525
|
+
const power = Number(opts.power !== undefined ? opts.power : 0.8);
|
|
526
|
+
const alpha = Number(opts.alpha !== undefined ? opts.alpha : 0.05);
|
|
527
|
+
const start = String(opts.start !== undefined ? opts.start : "");
|
|
528
|
+
const problems = [];
|
|
529
|
+
if (!(base > 0) || !(base < 1)) { problems.push("base rate: a number between 0 and 1 (0.03 = 3%)"); }
|
|
530
|
+
if (!(lift > 0) || !(lift < 5)) { problems.push("lift worth detecting: a fraction, 0.2 = +20% relative"); }
|
|
531
|
+
if (!(daily > 0)) { problems.push("contacts a day: pass --daily 500"); }
|
|
532
|
+
if (!(days > 0)) { problems.push("horizon in days: pass --days 90"); }
|
|
533
|
+
if (!ZB[String(power)]) { problems.push("power must be 0.8 or 0.9"); }
|
|
534
|
+
if (!ZA[String(alpha)]) { problems.push("alpha must be 0.05, 0.01 or 0.1"); }
|
|
535
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(start)) { problems.push("start date: pass --start 2026-09-21"); }
|
|
536
|
+
if (problems.length) { console.error("queue: " + problems.join("; ")); return 1; }
|
|
537
|
+
|
|
538
|
+
const per_arm = sampleSize(base, lift, power, alpha);
|
|
539
|
+
const total_contacts = per_arm * 2;
|
|
540
|
+
const days_per_test = Math.ceil(total_contacts / daily);
|
|
541
|
+
const tests_in_horizon = Math.floor(days / days_per_test);
|
|
542
|
+
const read_dates = [];
|
|
543
|
+
for (let i = 0; i < tests_in_horizon; i++) { read_dates.push(qAddDays(start, days_per_test * (i + 1))); }
|
|
544
|
+
const idle_days = days - tests_in_horizon * days_per_test;
|
|
545
|
+
const used_pct = Math.round((tests_in_horizon * days_per_test * 100) / days);
|
|
546
|
+
const gates = [
|
|
547
|
+
"one look only: the floor date is the look that alpha " + alpha + " priced in - reading the same test daily is not covered by it",
|
|
548
|
+
"split audiences: two live tests in one arm halve the contacts a day, so neither reaches its floor",
|
|
549
|
+
tests_in_horizon === 0
|
|
550
|
+
? "capacity: one honest test does not fit in " + days + " days at " + daily + " contacts a day - plan the queue, not the ideas"
|
|
551
|
+
: "capacity: " + tests_in_horizon + " honest test" + (tests_in_horizon === 1 ? "" : "s") + " fit in " + days + " days, " + idle_days + " days outside a test"
|
|
552
|
+
];
|
|
553
|
+
const out = {
|
|
554
|
+
base_rate: base, lift: lift, power: power, alpha: alpha, daily: daily, horizon_days: days, start: start,
|
|
555
|
+
floor_per_arm: per_arm, total_contacts: total_contacts, days_per_test: days_per_test,
|
|
556
|
+
tests_in_horizon: tests_in_horizon, read_dates: read_dates, idle_days: idle_days, used_pct: used_pct,
|
|
557
|
+
gates: gates
|
|
558
|
+
};
|
|
559
|
+
if (opts.json) { console.log(JSON.stringify(out, null, 2)); } else {
|
|
560
|
+
console.log("floor: " + fmtNum(per_arm) + " contacts per arm (" + fmtNum(total_contacts) + " in both arms) at a " + (base * 100).toFixed(2) + "% base rate, +" + (lift * 100).toFixed(0) + "% relative lift, power " + power + ", alpha " + alpha);
|
|
561
|
+
console.log("one honest test: " + days_per_test + " days at " + fmtNum(daily) + " contacts a day");
|
|
562
|
+
console.log("queue: " + tests_in_horizon + " test" + (tests_in_horizon === 1 ? "" : "s") + " in " + days + " days (" + used_pct + "% of the horizon inside a test, " + idle_days + " days outside one)");
|
|
563
|
+
if (read_dates.length) { console.log("read-out dates: " + read_dates.join(", ")); }
|
|
564
|
+
else { console.log("read-out date: does not fit in this horizon"); }
|
|
565
|
+
gates.forEach(function (g) { console.log("gate: " + g); });
|
|
566
|
+
}
|
|
567
|
+
return 0;
|
|
568
|
+
}
|
|
569
|
+
|
|
441
570
|
function fmtNum(n) {
|
|
442
571
|
return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
443
572
|
}
|
|
@@ -568,6 +697,10 @@ if (cmd === "sample") {
|
|
|
568
697
|
? "keep running — below the required volume, a verdict now is noise"
|
|
569
698
|
: "read it — volume is sufficient; kill if conversions are at or under the floor"
|
|
570
699
|
}, null, 2));
|
|
700
|
+
} else if (cmd === "verdict") {
|
|
701
|
+
process.exit(runVerdict(flags(rest)));
|
|
702
|
+
} else if (cmd === "queue") {
|
|
703
|
+
process.exit(runQueue(flags(rest)));
|
|
571
704
|
} else if (cmd === "aeo") {
|
|
572
705
|
process.exit(runAeo(flags(rest)));
|
|
573
706
|
} else if (cmd === "brief") {
|
|
@@ -610,6 +743,10 @@ Usage:
|
|
|
610
743
|
--base 0.03 --lift 0.2 --cpl 4 [--daily 500]
|
|
611
744
|
npx marketing-mindset plan dated plan: weekly volume, gates, first honest read
|
|
612
745
|
--base 0.03 --lift 0.2 --daily 500 [--weeks 4] [--start 2026-09-21]
|
|
746
|
+
npx marketing-mindset verdict was the difference real, and was the volume ever enough?
|
|
747
|
+
npx marketing-mindset queue how many honest tests your volume can finish, and when each reads
|
|
748
|
+
--daily 500 --days 90 --start 2026-09-21 [--base 0.03] [--lift 0.2] [--power 0.8] [--alpha 0.05]
|
|
749
|
+
--a 120/4000 --b 168/4000 [--alpha 0.05] [--json]
|
|
613
750
|
npx marketing-mindset mde what a volume you can afford can actually read
|
|
614
751
|
--base 0.03 --per-arm 5000 [--power 0.8] [--alpha 0.05] [--json]
|
|
615
752
|
npx marketing-mindset warmup day-by-day sending ramp with stop rules
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "marketing-mindset",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "A marketer's operating mindset as an installable agent skill and CLI: test-size floors, kill rules, the lift a volume can
|
|
3
|
+
"version": "1.10.0",
|
|
4
|
+
"description": "A marketer's operating mindset as an installable agent skill and CLI: test-size floors, kill rules, honest verdicts on finished tests, the lift a volume can read, a queue of readable tests with read-out dates, agent-readiness checklist, marketing-engineer job description. npx marketing-mindset sample|kill|verdict|queue|mde|budget|brief|plan|warmup|aeo|jd|sections|read|install.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent-skill",
|
|
7
7
|
"skill",
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
"llm",
|
|
14
14
|
"prompt",
|
|
15
15
|
"sample-size",
|
|
16
|
+
"test-queue",
|
|
17
|
+
"ab-test-verdict",
|
|
16
18
|
"minimum-detectable-effect",
|
|
17
19
|
"job-description",
|
|
18
20
|
"cold-email",
|