marketing-mindset 1.12.0 → 1.13.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 +25 -0
  2. package/bin/cli.js +124 -0
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -200,3 +200,28 @@ The floor comes from the same formula as `sample`, and the arms split the earlie
200
200
  so a unit chosen here, a queue planned with `queue` and a result read with `verdict` stay consistent.
201
201
  Two things it refuses to hide: the earliest step reads soonest and owns the least, and money always reads
202
202
  one step behind whatever unit you chose.
203
+
204
+ ## `fit` — which channel can actually fund a verdict
205
+
206
+ Two channels can both be worth testing and only one of them can be read this quarter. `fit` takes
207
+ `name:contacts_per_month:cost_per_contact` for each channel, applies the same floor as `sample`, and
208
+ says which channel reaches that floor inside the horizon and what one readable test costs there.
209
+
210
+ ```bash
211
+ npx marketing-mindset fit --channels email:9000:0.02,linkedin:1200:0.35,seo:30000:0.005
212
+ ```
213
+ ```
214
+ email: 9,000 contacts a month at 0.02 per contact -> floor 13,914 per arm, 4,500 events per arm a month, 4 month(s) to a verdict, one test 556.56, fits: yes
215
+ linkedin: 1,200 contacts a month at 0.35 per contact -> floor 13,914 per arm, 600 events per arm a month, 24 month(s) to a verdict, one test 9739.8, fits: no
216
+ seo: 30,000 contacts a month at 0.005 per contact -> floor 13,914 per arm, 15,000 events per arm a month, 1 month(s) to a verdict, one test 139.14, fits: yes
217
+ first to run: seo - 139.14 for one readable test in 1 month(s)
218
+ ```
219
+
220
+ At a 3% base rate and a +20% relative lift the floor is 13,914 contacts per arm on every channel, so the
221
+ channel is not chosen by a better conversion rate but by how fast it can deliver those contacts and what
222
+ each contact costs. `--json` returns the same object: one entry per channel with `floor_per_arm`,
223
+ `events_per_arm_per_month`, `months_to_verdict`, `cost_per_arm`, `cost_per_test` and `fits`, plus
224
+ `first_to_run`, `unreadable_channels` and three gates. `first_to_run` is the fitting channel with the
225
+ lowest `cost_per_test`, ties broken by fewest months; when no channel fits it is a sentence saying so.
226
+ Two things it refuses to hide: the floor is set by the base rate and the lift rather than by the channel,
227
+ and a cheap contact list that cannot reach the floor inside the horizon funds nothing.
package/bin/cli.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * marketing-mindset verdict read a finished test: interval, floor, honest verdict
8
8
  * marketing-mindset queue volume into a queue of honest tests with read-out dates
9
9
  * marketing-mindset cost the cost of one readable verdict, and funded tests per budget
10
+ * marketing-mindset fit which channel can actually fund a verdict inside a horizon
10
11
  * marketing-mindset unit which funnel step can fund a verdict, and in how many months
11
12
  * marketing-mindset sections list every section of SKILL.md
12
13
  * marketing-mindset read <text> print one section by fuzzy title match
@@ -730,6 +731,125 @@ function runUnit(opts) {
730
731
  return 0;
731
732
  }
732
733
 
734
+ var FIT_ZA = { "0.05": 1.9599639845, "0.01": 2.5758293036, "0.1": 1.6448536270 };
735
+ var FIT_ZB = { "0.8": 0.8416212336, "0.9": 1.2815515655 };
736
+ function fitRound2(x) { return Math.round(x * 100) / 100; }
737
+ function fitFloor(rate, lift, power, alpha) {
738
+ const p1 = Math.min(Math.max(rate, 0.000001), 0.999999);
739
+ const p2 = Math.min(p1 * (1 + lift), 0.999999);
740
+ const pbar = (p1 + p2) / 2;
741
+ const za = FIT_ZA[String(alpha)] || FIT_ZA["0.05"];
742
+ const zb = FIT_ZB[String(power)] || FIT_ZB["0.8"];
743
+ const num = Math.pow(za * Math.sqrt(2 * pbar * (1 - pbar)) + zb * Math.sqrt(p1 * (1 - p1) + p2 * (1 - p2)), 2);
744
+ return Math.ceil(num / Math.pow(p2 - p1, 2));
745
+ }
746
+ function planFit(input) {
747
+ const channels = input.channels || [];
748
+ const base = Number(input.base !== undefined ? input.base : 0.03);
749
+ const lift = Number(input.lift !== undefined ? input.lift : 0.2);
750
+ const power = Number(input.power !== undefined ? input.power : 0.8);
751
+ const alpha = Number(input.alpha !== undefined ? input.alpha : 0.05);
752
+ const horizon = Number(input.horizon !== undefined ? input.horizon : 6);
753
+ const problems = [];
754
+ if (channels.length === 0) { problems.push("channels: pass --channels email:9000:0.02,linkedin:1200:0.35,seo:30000:0.005"); }
755
+ channels.forEach(function (c) {
756
+ if (!(Number(c.contacts_per_month) > 0)) { problems.push("contacts per month for '" + c.name + "' must be a positive number of contacts a month"); }
757
+ if (!(Number(c.cost_per_contact) >= 0)) { problems.push("cost per contact for '" + c.name + "' cannot be negative"); }
758
+ });
759
+ if (!(base > 0) || !(base < 1)) { problems.push("base rate: a number between 0 and 1 (0.03 = 3%)"); }
760
+ if (!(lift > 0) || !(lift < 5)) { problems.push("lift worth detecting: a fraction, 0.2 = +20% relative"); }
761
+ if (!FIT_ZB[String(power)]) { problems.push("power must be 0.8 or 0.9"); }
762
+ if (!FIT_ZA[String(alpha)]) { problems.push("alpha must be 0.05, 0.01 or 0.1"); }
763
+ if (!(horizon > 0)) { problems.push("horizon: months you are willing to wait, a positive number"); }
764
+ if (problems.length) { return { problems: problems }; }
765
+
766
+ const floor = fitFloor(base, lift, power, alpha);
767
+ const rows = channels.map(function (c) {
768
+ const perMonth = Number(c.contacts_per_month);
769
+ const cost = Number(c.cost_per_contact);
770
+ const events = perMonth / 2;
771
+ const months = Math.ceil(floor / events);
772
+ const costArm = fitRound2(floor * cost);
773
+ return {
774
+ channel: c.name, contacts_per_month: perMonth, cost_per_contact: cost,
775
+ floor_per_arm: floor, events_per_arm_per_month: events, months_to_verdict: months,
776
+ cost_per_arm: costArm, cost_per_test: fitRound2(costArm * 2), fits: months <= horizon
777
+ };
778
+ });
779
+ let best = null;
780
+ rows.forEach(function (c) {
781
+ if (!c.fits) { return; }
782
+ if (!best || c.cost_per_test < best.cost_per_test ||
783
+ (c.cost_per_test === best.cost_per_test && c.months_to_verdict < best.months_to_verdict)) { best = c; }
784
+ });
785
+ const none = "none of the listed channels can finish a verdict inside " + horizon + " month(s)";
786
+ const unreadable = rows.filter(function (c) { return !c.fits; }).map(function (c) { return c.channel; });
787
+ const gates = [
788
+ "the floor does not depend on the channel: " + fmtNum(floor) + " contacts per arm in every row, because it comes from the base rate and the lift",
789
+ best
790
+ ? "fund " + best.channel + " first: " + best.cost_per_test + " for one readable test (" + best.months_to_verdict + " month(s) at " + best.cost_per_contact + " per contact)"
791
+ : none + " - move the horizon or find a channel with more contacts",
792
+ unreadable.length
793
+ ? "out of reach inside " + horizon + " month(s): " + unreadable.join(", ")
794
+ : "every channel listed reaches a verdict inside " + horizon + " month(s)"
795
+ ];
796
+ return {
797
+ base_rate: base, lift: lift, power: power, alpha: alpha, horizon_months: horizon, floor_per_arm: floor,
798
+ channels: rows,
799
+ first_to_run: best ? best.channel : none,
800
+ first_to_run_cost_per_test: best ? best.cost_per_test : 0,
801
+ first_to_run_months: best ? best.months_to_verdict : 0,
802
+ unreadable_channels: unreadable, gates: gates
803
+ };
804
+ }
805
+
806
+ function runFit(opts) {
807
+ const spec = opts.channels;
808
+ const base = Number(opts.base !== undefined ? opts.base : 0.03);
809
+ const lift = Number(opts.lift !== undefined ? opts.lift : 0.2);
810
+ const power = Number(opts.power !== undefined ? opts.power : 0.8);
811
+ const alpha = Number(opts.alpha !== undefined ? opts.alpha : 0.05);
812
+ const horizon = Number(opts.horizon !== undefined ? opts.horizon : 6);
813
+ const problems = [];
814
+ let channels = [];
815
+ if (typeof spec !== "string" || !spec.trim()) {
816
+ problems.push("channels: pass --channels email:9000:0.02,linkedin:1200:0.35,seo:30000:0.005");
817
+ } else {
818
+ spec.split(",").forEach(function (part) {
819
+ const bits = part.trim().split(":");
820
+ if (bits.length !== 3 || !bits[0].trim() || !bits[1].trim() || !bits[2].trim() ||
821
+ !isFinite(Number(bits[1])) || !isFinite(Number(bits[2]))) {
822
+ problems.push("channels: '" + part.trim() + "' must look like name:contacts_per_month:cost_per_contact");
823
+ } else {
824
+ channels.push({ name: bits[0].trim(), contacts_per_month: Number(bits[1]), cost_per_contact: Number(bits[2]) });
825
+ }
826
+ });
827
+ }
828
+ if (problems.length) { console.error("fit: " + problems.join("; ")); return 1; }
829
+ const out = planFit({ channels: channels, base: base, lift: lift, power: power, alpha: alpha, horizon: horizon });
830
+ if (out.problems) { console.error("fit: " + out.problems.join("; ")); return 1; }
831
+ if (opts.json) { console.log(JSON.stringify(out, null, 2)); } else {
832
+ const head = ["channel", "contacts/mo", "cost/contact", "floor/arm", "events/arm/mo", "months", "cost/test", "fits"];
833
+ const rows = out.channels.map(function (c) {
834
+ return [c.channel, fmtNum(c.contacts_per_month), String(c.cost_per_contact), fmtNum(c.floor_per_arm),
835
+ fmtNum(c.events_per_arm_per_month), String(c.months_to_verdict), String(c.cost_per_test),
836
+ c.fits ? "yes" : "no"];
837
+ });
838
+ const widths = head.map(function (h, i) {
839
+ return Math.max(h.length, Math.max.apply(null, rows.map(function (r) { return r[i].length; })));
840
+ });
841
+ function pad(s, i) { return s + " ".repeat(widths[i] - s.length); }
842
+ console.log(head.map(pad).join(" "));
843
+ console.log(widths.map(function (w) { return "-".repeat(w); }).join(" "));
844
+ rows.forEach(function (r) { console.log(r.map(pad).join(" ")); });
845
+ console.log(out.first_to_run_cost_per_test
846
+ ? "first to run: " + out.first_to_run + " - " + out.first_to_run_cost_per_test + " for one readable test in " + out.first_to_run_months + " month(s)"
847
+ : "first to run: " + out.first_to_run);
848
+ out.gates.forEach(function (g) { console.log("gate: " + g); });
849
+ }
850
+ return 0;
851
+ }
852
+
733
853
  function fmtNum(n) {
734
854
  return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
735
855
  }
@@ -866,6 +986,8 @@ if (cmd === "sample") {
866
986
  process.exit(runQueue(flags(rest)));
867
987
  } else if (cmd === "unit") {
868
988
  process.exit(runUnit(flags(rest)));
989
+ } else if (cmd === "fit") {
990
+ process.exit(runFit(flags(rest)));
869
991
  } else if (cmd === "cost") {
870
992
  process.exit(runCost(flags(rest)));
871
993
  } else if (cmd === "aeo") {
@@ -913,6 +1035,8 @@ Usage:
913
1035
  npx marketing-mindset verdict was the difference real, and was the volume ever enough?
914
1036
  npx marketing-mindset queue how many honest tests your volume can finish, and when each reads
915
1037
  npx marketing-mindset cost what one readable verdict costs to finish, and how many a budget funds
1038
+ npx marketing-mindset fit which channel can actually fund a verdict, and what one test costs there
1039
+ --channels email:9000:0.02,linkedin:1200:0.35,seo:30000:0.005 [--base 0.03] [--lift 0.2] [--power 0.8] [--alpha 0.05] [--horizon 6] [--json]
916
1040
  npx marketing-mindset unit which step of your funnel can fund a verdict, and how many months it needs
917
1041
  --steps visits:12000,signups:900,activations:270,paid:40 [--lift 0.2] [--power 0.8] [--alpha 0.05] [--horizon 6]
918
1042
  --cost 0.02 --daily 500 [--budget 5000] [--base 0.03] [--lift 0.2] [--power 0.8] [--alpha 0.05]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "marketing-mindset",
3
- "version": "1.12.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, which funnel step can fund a verdict, the lift a volume can read, a queue of readable tests with read-out dates, the cost of one readable verdict. npx marketing-mindset sample|kill|verdict|unit|queue|cost|mde|budget|brief|plan|warmup|aeo|jd|sections|read|install.",
3
+ "version": "1.13.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, which funnel step can fund a verdict, which channel can actually fund a verdict, the lift a volume can read, a queue of readable tests with read-out dates, the cost of one readable verdict. npx marketing-mindset sample|kill|verdict|unit|fit|queue|cost|mde|budget|brief|plan|warmup|aeo|jd|sections|read|install.",
5
5
  "keywords": [
6
6
  "agent-skill",
7
7
  "skill",
@@ -15,6 +15,7 @@
15
15
  "sample-size",
16
16
  "test-queue",
17
17
  "cost-per-verdict",
18
+ "channel-fit",
18
19
  "funnel-unit",
19
20
  "ab-test-verdict",
20
21
  "minimum-detectable-effect",