marketing-mindset 1.7.0 → 1.9.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.
Files changed (3) hide show
  1. package/README.md +43 -0
  2. package/bin/cli.js +135 -0
  3. package/package.json +4 -2
package/README.md CHANGED
@@ -94,3 +94,46 @@ the dated read, a week-by-week table with the percentage of the floor covered, t
94
94
  kill rule. `--json` returns the same object for an agent or a script.
95
95
 
96
96
  Method, packages and the paid version of this work: https://axelfreeman.com/marketing-engineer.html
97
+
98
+ ## `mde` — what a volume you can afford can actually read
99
+
100
+ The other direction of the same arithmetic: instead of the volume a lift requires, the lift a volume can
101
+ read. Useful when the channel caps the volume (a small list, a niche audience, one store's traffic) and the
102
+ honest question is not "how much do we need" but "what is the smallest win this test could ever show".
103
+
104
+ ```bash
105
+ npx marketing-mindset mde --base 0.03 --per-arm 5000
106
+ # base rate 3.00% · per arm 5,000 · power 0.8 · alpha 0.05
107
+ # smallest readable relative lift: 33.4% (1pp absolute)
108
+ # verdict: at this volume only a difference of 33.4% or more is readable ...
109
+
110
+ npx marketing-mindset mde --base 0.03 --per-arm 5000 --json
111
+ ```
112
+
113
+ `--per-arm` takes the volume you can actually buy per variant; `--power` (0.8 or 0.9) and `--alpha`
114
+ (0.05, 0.01, 0.1) are the same knobs as `sample`. When no lift is readable at that volume the command
115
+ says so instead of printing a number, and names the three ways out: more volume, a higher base rate, or a
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.
package/bin/cli.js CHANGED
@@ -3,6 +3,8 @@
3
3
  * marketing-mindset help + the test-size floors
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
+ * 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
6
8
  * marketing-mindset sections list every section of SKILL.md
7
9
  * marketing-mindset read <text> print one section by fuzzy title match
8
10
  * marketing-mindset install copy the skill into your agent's skills directory
@@ -383,6 +385,131 @@ function runPlan(opts) {
383
385
 
384
386
  /* brief — the whole plan as one markdown page a client can keep: the floor, what reaching it costs,
385
387
  * the dates, the gate for each week and the kill rule. --json returns the same object for an agent. */
388
+ function mdeLift(rate, perArm, power, alpha) {
389
+ if (sampleSize(rate, 5, power, alpha) > perArm) { return null; }
390
+ let lo = 1e-6, hi = 5;
391
+ for (let i = 0; i < 100; i++) {
392
+ const mid = (lo + hi) / 2;
393
+ if (sampleSize(rate, mid, power, alpha) <= perArm) { hi = mid; } else { lo = mid; }
394
+ }
395
+ return hi;
396
+ }
397
+
398
+ function runMde(opts) {
399
+ const rate = Number(opts.base !== undefined ? opts.base : 0.03);
400
+ const perArm = Number(opts["per-arm"] !== undefined ? opts["per-arm"] : (opts.n !== undefined ? opts.n : 5000));
401
+ const power = Number(opts.power !== undefined ? opts.power : 0.8);
402
+ const alpha = Number(opts.alpha !== undefined ? opts.alpha : 0.05);
403
+ const problems = [];
404
+ if (!(rate > 0 && rate < 1)) { problems.push("base must be between 0 and 1 (0.03 = 3%)"); }
405
+ if (!(perArm > 0)) { problems.push("per-arm must be a positive volume (contacts, sends or visitors)"); }
406
+ if (!ZB[String(power)]) { problems.push("power must be 0.8 or 0.9 - this CLI carries exact quantiles for those two"); }
407
+ if (!ZA[String(alpha)]) { problems.push("alpha must be 0.05, 0.01 or 0.1"); }
408
+ if (problems.length) { console.error("mde: " + problems.join("; ")); return 1; }
409
+ const lift = mdeLift(rate, perArm, power, alpha);
410
+ if (lift === null) {
411
+ const out = {
412
+ base_rate: rate, per_arm: perArm, power, alpha,
413
+ min_relative_lift: null, min_absolute_pp: null, readable: false,
414
+ verdict: "no lift is readable at this volume: even a five-fold difference needs more than " + fmtNum(perArm) + " per arm at a " + (rate * 100).toFixed(2) + "% base rate. Raise the volume, raise the base rate (tighter list or sharper promise), or measure a cheaper unit (for cold email the readable unit is the reply rate, floor ~1,500-2,000 sends per variant)."
415
+ };
416
+ if (opts.json) { console.log(JSON.stringify(out, null, 2)); } else {
417
+ console.log("base rate " + (rate * 100).toFixed(2) + "% · per arm " + fmtNum(perArm) + " · power " + power + " · alpha " + alpha);
418
+ console.log("smallest readable relative lift: none");
419
+ console.log("verdict: " + out.verdict);
420
+ }
421
+ return 0;
422
+ }
423
+ const pct = Math.round(lift * 1000) / 10;
424
+ const pp = Math.round(rate * lift * 100 * 100) / 100;
425
+ const verdict = pct >= 20
426
+ ? "at this volume only a difference of " + pct + "% or more is readable - a smaller win will not separate from noise, so the honest plan is a bigger volume before the first read"
427
+ : "at this volume a difference of " + pct + "% or more is readable - a plan that promises less than that is promising a number this test cannot produce";
428
+ const out = {
429
+ base_rate: rate, per_arm: perArm, power, alpha,
430
+ min_relative_lift: Math.round(lift * 1e9) / 1e9,
431
+ min_absolute_pp: pp, readable: true, verdict
432
+ };
433
+ if (opts.json) { console.log(JSON.stringify(out, null, 2)); } else {
434
+ console.log("base rate " + (rate * 100).toFixed(2) + "% · per arm " + fmtNum(perArm) + " · power " + power + " · alpha " + alpha);
435
+ console.log("smallest readable relative lift: " + pct + "% (" + pp + "pp absolute)");
436
+ console.log("contacts needed at that lift: " + fmtNum(sampleSize(rate, lift, power, alpha)) + " per arm");
437
+ console.log("verdict: " + verdict);
438
+ }
439
+ return 0;
440
+ }
441
+
442
+ function wilsonInterval(conv, n, alpha) {
443
+ const z = ZA[String(alpha)] || ZA["0.05"];
444
+ const p = conv / n;
445
+ const d = 1 + (z * z) / n;
446
+ const centre = (p + (z * z) / (2 * n)) / d;
447
+ const half = (z * Math.sqrt((p * (1 - p)) / n + (z * z) / (4 * n * n))) / d;
448
+ return { low: Math.max(0, centre - half), high: Math.min(1, centre + half) };
449
+ }
450
+
451
+ function parseArm(v) {
452
+ const s = String(v === undefined ? "" : v).trim();
453
+ const m = s.match(/^(\d+)\s*\/\s*(\d+)$/);
454
+ if (!m) { return null; }
455
+ return { conv: Number(m[1]), n: Number(m[2]) };
456
+ }
457
+
458
+ function r6(x) { return Math.round(x * 1e6) / 1e6; }
459
+
460
+ function runVerdict(opts) {
461
+ const alpha = Number(opts.alpha !== undefined ? opts.alpha : 0.05);
462
+ let aRaw = opts.a;
463
+ if (aRaw === undefined && opts["a-conv"] !== undefined && opts["a-n"] !== undefined) {
464
+ aRaw = opts["a-conv"] + "/" + opts["a-n"];
465
+ }
466
+ let bRaw = opts.b;
467
+ if (bRaw === undefined && opts["b-conv"] !== undefined && opts["b-n"] !== undefined) {
468
+ bRaw = opts["b-conv"] + "/" + opts["b-n"];
469
+ }
470
+ const problems = [];
471
+ const a = parseArm(aRaw), b = parseArm(bRaw);
472
+ if (!a) { problems.push("control arm: pass --a 120/4000 (conversions/sample)"); }
473
+ if (!b) { problems.push("variant arm: pass --b 168/4000 (conversions/sample)"); }
474
+ if (a && (a.n <= 0 || a.conv > a.n)) { problems.push("control arm must be conversions <= sample and a positive sample"); }
475
+ if (b && (b.n <= 0 || b.conv > b.n)) { problems.push("variant arm must be conversions <= sample and a positive sample"); }
476
+ if (!ZA[String(alpha)]) { problems.push("alpha must be 0.05, 0.01 or 0.1"); }
477
+ if (problems.length) { console.error("verdict: " + problems.join("; ")); return 1; }
478
+
479
+ const pa = a.conv / a.n, pb = b.conv / b.n;
480
+ const ia = wilsonInterval(a.conv, a.n, alpha), ib = wilsonInterval(b.conv, b.n, alpha);
481
+ const diff = pb - pa;
482
+ const lo = diff - Math.sqrt(Math.pow(pa - ia.low, 2) + Math.pow(ib.high - pb, 2));
483
+ const hi = diff + Math.sqrt(Math.pow(ia.high - pa, 2) + Math.pow(pb - ib.low, 2));
484
+ const decisive = lo > 0 || hi < 0;
485
+ const rel = pa > 0 ? diff / pa : null;
486
+ const floor = rel !== null && rel !== 0 ? sampleSize(pa, Math.abs(rel), 0.8, alpha) : null;
487
+ const underpowered = floor !== null && b.n < floor;
488
+ let verdict;
489
+ if (!decisive) {
490
+ 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.";
491
+ } else if (underpowered) {
492
+ 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.";
493
+ } else {
494
+ 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.";
495
+ }
496
+ const out = {
497
+ control: { conversions: a.conv, n: a.n, rate: r6(pa), wilson_low: r6(ia.low), wilson_high: r6(ia.high) },
498
+ variant: { conversions: b.conv, n: b.n, rate: r6(pb), wilson_low: r6(ib.low), wilson_high: r6(ib.high) },
499
+ absolute_lift_pp: r6(diff * 100),
500
+ relative_lift: rel === null ? null : r6(rel),
501
+ difference_interval: [r6(lo), r6(hi)],
502
+ decisive, floor_per_arm: floor, underpowered, alpha, verdict
503
+ };
504
+ if (opts.json) { console.log(JSON.stringify(out, null, 2)); } else {
505
+ console.log("control " + a.conv + "/" + fmtNum(a.n) + " = " + (pa * 100).toFixed(2) + "% · variant " + b.conv + "/" + fmtNum(b.n) + " = " + (pb * 100).toFixed(2) + "%");
506
+ 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");
507
+ console.log("separated from noise: " + decisive + " · volume clears the floor: " + !underpowered);
508
+ console.log("verdict: " + verdict);
509
+ }
510
+ return 0;
511
+ }
512
+
386
513
  function fmtNum(n) {
387
514
  return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
388
515
  }
@@ -513,12 +640,16 @@ if (cmd === "sample") {
513
640
  ? "keep running — below the required volume, a verdict now is noise"
514
641
  : "read it — volume is sufficient; kill if conversions are at or under the floor"
515
642
  }, null, 2));
643
+ } else if (cmd === "verdict") {
644
+ process.exit(runVerdict(flags(rest)));
516
645
  } else if (cmd === "aeo") {
517
646
  process.exit(runAeo(flags(rest)));
518
647
  } else if (cmd === "brief") {
519
648
  process.exit(runBrief(flags(rest)));
520
649
  } else if (cmd === "plan") {
521
650
  process.exit(runPlan(flags(rest)));
651
+ } else if (cmd === "mde") {
652
+ process.exit(runMde(flags(rest)));
522
653
  } else if (cmd === "budget") {
523
654
  process.exit(runBudget(flags(rest)));
524
655
  } else if (cmd === "warmup") {
@@ -553,6 +684,10 @@ Usage:
553
684
  --base 0.03 --lift 0.2 --cpl 4 [--daily 500]
554
685
  npx marketing-mindset plan dated plan: weekly volume, gates, first honest read
555
686
  --base 0.03 --lift 0.2 --daily 500 [--weeks 4] [--start 2026-09-21]
687
+ npx marketing-mindset verdict was the difference real, and was the volume ever enough?
688
+ --a 120/4000 --b 168/4000 [--alpha 0.05] [--json]
689
+ npx marketing-mindset mde what a volume you can afford can actually read
690
+ --base 0.03 --per-arm 5000 [--power 0.8] [--alpha 0.05] [--json]
556
691
  npx marketing-mindset warmup day-by-day sending ramp with stop rules
557
692
  --start 2026-09-21 --target 200 --days 21 --mailboxes 4
558
693
  npx marketing-mindset jd marketing engineer job description
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "marketing-mindset",
3
- "version": "1.7.0",
4
- "description": "A marketer's operating mindset as an installable agent skill and CLI: test-size floors, kill rules, agent-readiness checklist, marketing-engineer job description. npx marketing-mindset sample|kill|budget|brief|plan|warmup|aeo|jd|sections|read|install.",
3
+ "version": "1.9.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, agent-readiness checklist, marketing-engineer job description. npx marketing-mindset sample|kill|verdict|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
+ "ab-test-verdict",
17
+ "minimum-detectable-effect",
16
18
  "job-description",
17
19
  "cold-email",
18
20
  "deliverability",