abtestresult-mcp 1.1.0 → 1.3.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 +1 -1
  2. package/dist/index.js +364 -81
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -36,7 +36,7 @@ That's it. No API keys, no authentication. The server starts automatically when
36
36
  | Tool | Description |
37
37
  |------|-------------|
38
38
  | `analyze_ab_test` | Z-test for rate metrics (conversion rate, CTR, etc.) |
39
- | `analyze_ab_test_average` | T-test for continuous metrics (revenue, time on page, etc.) |
39
+ | `analyze_ab_test_average` | Welch's T-test for continuous metrics (revenue, time on page, etc.); no equal-variance assumption |
40
40
  | `calculate_sample_size` | Required sample size for rate or average metrics |
41
41
  | `calculate_mde` | Minimum detectable effect given a fixed sample size |
42
42
  | `bayesian_ab_test` | Bayesian analysis with probability to be best & expected loss |
package/dist/index.js CHANGED
@@ -33997,10 +33997,24 @@ function normalInterval(confidence, mean, std) {
33997
33997
  const z2 = normalPpf(1 - alpha / 2);
33998
33998
  return [mean - z2 * std, mean + z2 * std];
33999
33999
  }
34000
+ var LARGE_DF = 1e4;
34000
34001
  function tCdf(x, df) {
34002
+ if (df > LARGE_DF) {
34003
+ const z2 = x * (1 - 1 / (4 * df)) / Math.sqrt(1 + x * x / (2 * df));
34004
+ return normalCdf(z2);
34005
+ }
34001
34006
  return import_jstat.default.studentt.cdf(x, df);
34002
34007
  }
34003
34008
  function tPpf(p, df) {
34009
+ if (df > LARGE_DF) {
34010
+ const z2 = normalPpf(p);
34011
+ const z22 = z2 * z2;
34012
+ const g1 = (z22 + 1) * z2 / 4;
34013
+ const g2 = ((5 * z22 + 16) * z22 + 3) * z2 / 96;
34014
+ const g3 = (((3 * z22 + 19) * z22 + 17) * z22 - 15) * z2 / 384;
34015
+ const g4 = ((((79 * z22 + 776) * z22 + 1482) * z22 - 1920) * z22 - 945) * z2 / 92160;
34016
+ return z2 + g1 / df + g2 / df ** 2 + g3 / df ** 3 + g4 / df ** 4;
34017
+ }
34004
34018
  return import_jstat.default.studentt.inv(p, df);
34005
34019
  }
34006
34020
  function chiSquareCdf(x, df) {
@@ -34064,12 +34078,23 @@ function zTestForProportions(controlUsers, controlConversions, variantUsers, var
34064
34078
  };
34065
34079
  }
34066
34080
  function tTestForAverages(controlUsers, controlMean, controlStdDev, variantUsers, variantMean, variantStdDev, confidenceLevelAdj, sided, niMarginPercent = 0) {
34081
+ if (!(controlUsers >= 2) || !(variantUsers >= 2)) {
34082
+ throw new RangeError("Welch T-test needs at least 2 users in each group");
34083
+ }
34084
+ if (!(controlStdDev >= 0) || !(variantStdDev >= 0) || !Number.isFinite(controlStdDev) || !Number.isFinite(variantStdDev)) {
34085
+ throw new RangeError("Standard deviations must be finite and non-negative");
34086
+ }
34087
+ if (controlStdDev === 0 && variantStdDev === 0) {
34088
+ throw new RangeError("At least one group must have a standard deviation above 0");
34089
+ }
34067
34090
  const niMarginDecimal = niMarginPercent / 100;
34091
+ const controlVarMean = controlStdDev ** 2 / controlUsers;
34092
+ const variantVarMean = variantStdDev ** 2 / variantUsers;
34093
+ const sem = Math.sqrt(controlVarMean + variantVarMean);
34094
+ const df = (controlVarMean + variantVarMean) ** 2 / (controlVarMean ** 2 / (controlUsers - 1) + variantVarMean ** 2 / (variantUsers - 1));
34068
34095
  const pooledStd = Math.sqrt(
34069
34096
  ((controlUsers - 1) * controlStdDev ** 2 + (variantUsers - 1) * variantStdDev ** 2) / (controlUsers + variantUsers - 2)
34070
34097
  );
34071
- const sem = pooledStd * Math.sqrt(1 / controlUsers + 1 / variantUsers);
34072
- const df = controlUsers + variantUsers - 2;
34073
34098
  let tScore = (variantMean - controlMean + niMarginDecimal * controlMean) / sem;
34074
34099
  if (sided === 2) {
34075
34100
  tScore = Math.abs(tScore);
@@ -34081,7 +34106,8 @@ function tTestForAverages(controlUsers, controlMean, controlStdDev, variantUsers
34081
34106
  pValue = 1 - tCdf(tScore, df);
34082
34107
  }
34083
34108
  const alphaT = 1 - confidenceLevelAdj;
34084
- const tCritical = sided === 1 ? tPpf(1 - alphaT, df) : tPpf(1 - alphaT / 2, df);
34109
+ const criticalT = (dof) => sided === 1 ? tPpf(1 - alphaT, dof) : tPpf(1 - alphaT / 2, dof);
34110
+ const tCritical = criticalT(df);
34085
34111
  const isSignificant = tScore > tCritical;
34086
34112
  const delta = variantMean - controlMean;
34087
34113
  const deltaRelative = controlMean !== 0 ? delta / controlMean : 0;
@@ -34089,18 +34115,21 @@ function tTestForAverages(controlUsers, controlMean, controlStdDev, variantUsers
34089
34115
  const meanCriticalLess = controlMean - tCritical * sem;
34090
34116
  const deltaCritical = tCritical * sem;
34091
34117
  const deltaCriticalLess = -tCritical * sem;
34118
+ const controlSe = controlStdDev / Math.sqrt(controlUsers);
34119
+ const variantSe = variantStdDev / Math.sqrt(variantUsers);
34120
+ const controlT = criticalT(controlUsers - 1);
34121
+ const variantT = criticalT(variantUsers - 1);
34092
34122
  const controlCi = [
34093
- controlMean - tCritical * (controlStdDev / Math.sqrt(controlUsers)),
34094
- controlMean + tCritical * (controlStdDev / Math.sqrt(controlUsers))
34123
+ controlMean - controlT * controlSe,
34124
+ controlMean + controlT * controlSe
34095
34125
  ];
34096
34126
  const variationCi = [
34097
- variantMean - tCritical * (variantStdDev / Math.sqrt(variantUsers)),
34098
- variantMean + tCritical * (variantStdDev / Math.sqrt(variantUsers))
34127
+ variantMean - variantT * variantSe,
34128
+ variantMean + variantT * variantSe
34099
34129
  ];
34100
- const diffSe = Math.sqrt(controlStdDev ** 2 / controlUsers + variantStdDev ** 2 / variantUsers);
34101
34130
  const diffCi = [
34102
- variantMean - controlMean - tCritical * diffSe,
34103
- variantMean - controlMean + tCritical * diffSe
34131
+ delta - tCritical * sem,
34132
+ delta + tCritical * sem
34104
34133
  ];
34105
34134
  return {
34106
34135
  tScore,
@@ -34112,6 +34141,8 @@ function tTestForAverages(controlUsers, controlMean, controlStdDev, variantUsers
34112
34141
  pooledStd,
34113
34142
  sem,
34114
34143
  df,
34144
+ controlSe,
34145
+ variantSe,
34115
34146
  controlCi,
34116
34147
  variationCi,
34117
34148
  diffCi,
@@ -34123,16 +34154,18 @@ function tTestForAverages(controlUsers, controlMean, controlStdDev, variantUsers
34123
34154
  variantMean
34124
34155
  };
34125
34156
  }
34126
- function checkSRM(controlUsers, variantUsers) {
34157
+ function checkSRM(controlUsers, variantUsers, expectedControlShare = 0.5) {
34127
34158
  const total = controlUsers + variantUsers;
34128
- const expected = total / 2;
34129
- const chiSquare = Math.pow(controlUsers - expected, 2) / expected + Math.pow(variantUsers - expected, 2) / expected;
34159
+ const expectedControl = total * expectedControlShare;
34160
+ const expectedVariant = total - expectedControl;
34161
+ const chiSquare = Math.pow(controlUsers - expectedControl, 2) / expectedControl + Math.pow(variantUsers - expectedVariant, 2) / expectedVariant;
34130
34162
  const pValue = 1 - chiSquareCdf(chiSquare, 1);
34131
34163
  const hasMismatch = pValue < 0.01;
34132
34164
  return {
34133
34165
  chiSquare,
34134
34166
  pValue,
34135
- hasMismatch
34167
+ hasMismatch,
34168
+ expectedControlShare
34136
34169
  };
34137
34170
  }
34138
34171
  function calculateSampleSizeRate(baseline, mde, confidenceLevel, power, sided, numVariants = 1, successMetrics = 1, guardrailMetrics = 0, niMarginPercent = 0, dailyTraffic = null) {
@@ -34201,15 +34234,30 @@ function calculateSampleSizeAverage(baseline, mde, stdDev, confidenceLevel, powe
34201
34234
  mdeAbsolute: p2 - baseline
34202
34235
  };
34203
34236
  }
34237
+ function allocateSampleSize(samplesEachBalanced, numVariants, controlShare) {
34238
+ const variantShare = (1 - controlShare) / numVariants;
34239
+ const total = samplesEachBalanced / 2 * (1 / controlShare + 1 / variantShare);
34240
+ const control = Math.ceil(total * controlShare - 1e-9);
34241
+ const perVariant = Math.ceil(total * variantShare - 1e-9);
34242
+ const allocatedTotal = control + perVariant * numVariants;
34243
+ return {
34244
+ control,
34245
+ perVariant,
34246
+ total: allocatedTotal,
34247
+ multiplier: allocatedTotal / (samplesEachBalanced * (numVariants + 1))
34248
+ };
34249
+ }
34204
34250
  function calculateSurveySampleSize(population, marginOfError, confidenceLevel) {
34205
34251
  const estimatedProportion = 0.5;
34206
34252
  const zScore = Math.abs(normalPpf((1 - confidenceLevel) / 2));
34207
34253
  const sampleSize = population * Math.pow(zScore, 2) * estimatedProportion * (1 - estimatedProportion) / (Math.pow(marginOfError, 2) * (population - 1) + Math.pow(zScore, 2) * estimatedProportion * (1 - estimatedProportion));
34208
34254
  return Math.ceil(sampleSize);
34209
34255
  }
34210
- function estimateMDE(traffic, stdDev, power, confidenceLevel, numVariants = 1, successMetrics = 1, guardrailMetrics = 0) {
34256
+ function estimateMDE(traffic, stdDev, power, confidenceLevel, numVariants = 1, successMetrics = 1, guardrailMetrics = 0, controlShare) {
34211
34257
  const nGroups = numVariants + 1;
34212
- const n = traffic / nGroups;
34258
+ const cShare = controlShare ?? 1 / nGroups;
34259
+ const nControl = traffic * cShare;
34260
+ const nVariant = traffic * (1 - cShare) / numVariants;
34213
34261
  let alpha = 1 - confidenceLevel;
34214
34262
  const comparisons = numVariants + (successMetrics - 1);
34215
34263
  if (comparisons > 0) {
@@ -34222,9 +34270,68 @@ function estimateMDE(traffic, stdDev, power, confidenceLevel, numVariants = 1, s
34222
34270
  }
34223
34271
  const zAlpha = normalPpf(1 - alpha / 2);
34224
34272
  const zPower = normalPpf(adjustedPower);
34225
- const mde = (zPower + zAlpha) * Math.sqrt(2) * stdDev / Math.sqrt(n);
34273
+ const mde = (zPower + zAlpha) * stdDev * Math.sqrt(1 / nControl + 1 / nVariant);
34226
34274
  return mde;
34227
34275
  }
34276
+ function twoProportionPower(nControl, nVariant, baselineRate, lift, alpha, sided = 2) {
34277
+ const p1 = baselineRate;
34278
+ const p2 = baselineRate + lift;
34279
+ const pooled = (nControl * p1 + nVariant * p2) / (nControl + nVariant);
34280
+ const se0 = Math.sqrt(pooled * (1 - pooled) * (1 / nControl + 1 / nVariant));
34281
+ const se1 = Math.sqrt(p1 * (1 - p1) / nControl + p2 * (1 - p2) / nVariant);
34282
+ if (sided === 1) {
34283
+ return normalCdf((lift - normalPpf(1 - alpha) * se0) / se1);
34284
+ }
34285
+ const z2 = normalPpf(1 - alpha / 2);
34286
+ return normalCdf((lift - z2 * se0) / se1) + normalCdf((-lift - z2 * se0) / se1);
34287
+ }
34288
+ function estimateMDERate({
34289
+ traffic,
34290
+ baselineRate,
34291
+ power,
34292
+ confidenceLevel,
34293
+ numVariants = 1,
34294
+ successMetrics = 1,
34295
+ guardrailMetrics = 0,
34296
+ controlShare,
34297
+ sided = 2
34298
+ }) {
34299
+ const p = baselineRate;
34300
+ if (!(traffic > 0) || !(p > 0 && p < 1)) return NaN;
34301
+ const cShare = controlShare ?? 1 / (numVariants + 1);
34302
+ const nControl = traffic * cShare;
34303
+ const nVariant = traffic * (1 - cShare) / numVariants;
34304
+ let alpha = 1 - confidenceLevel;
34305
+ const comparisons = numVariants + (successMetrics - 1);
34306
+ if (comparisons > 0) {
34307
+ alpha = sidakCorrection(alpha, comparisons);
34308
+ }
34309
+ let adjustedPower = power;
34310
+ if (guardrailMetrics > 0) {
34311
+ adjustedPower = 1 - (1 - power) / (guardrailMetrics + 1);
34312
+ }
34313
+ const powerAt = (d) => twoProportionPower(nControl, nVariant, p, d, alpha, sided);
34314
+ const maxLift = 1 - p - 1e-12;
34315
+ const steps = 200;
34316
+ const minLift = maxLift * 1e-6;
34317
+ let lo = 0;
34318
+ let hi = NaN;
34319
+ for (let i = 0; i <= steps; i++) {
34320
+ const d = minLift * Math.pow(maxLift / minLift, i / steps);
34321
+ if (powerAt(d) >= adjustedPower) {
34322
+ hi = d;
34323
+ break;
34324
+ }
34325
+ lo = d;
34326
+ }
34327
+ if (Number.isNaN(hi)) return Infinity;
34328
+ for (let i = 0; i < 100; i++) {
34329
+ const mid = (lo + hi) / 2;
34330
+ if (powerAt(mid) >= adjustedPower) hi = mid;
34331
+ else lo = mid;
34332
+ }
34333
+ return hi;
34334
+ }
34228
34335
  function pairedTTest(sample1, sample2, confidenceLevel) {
34229
34336
  if (sample1.length !== sample2.length) {
34230
34337
  throw new Error("Samples must have the same length");
@@ -34420,6 +34527,31 @@ function betaPosterior(priorAlpha, priorBeta, successes, trials) {
34420
34527
  beta: priorBeta + (trials - successes)
34421
34528
  };
34422
34529
  }
34530
+ function computeVariantLifts(samples, tail) {
34531
+ const EPSILON = 1e-10;
34532
+ const control = samples[0];
34533
+ const percentileCi = (sorted) => sorted.length > 0 ? [sorted[Math.floor(tail * sorted.length)], sorted[Math.floor((1 - tail) * sorted.length)]] : [0, 0];
34534
+ const mean = (arr) => arr.length > 0 ? arr.reduce((s, v) => s + v, 0) / arr.length : 0;
34535
+ const lifts = [];
34536
+ for (let v = 1; v < samples.length; v++) {
34537
+ const abs = [];
34538
+ const rel = [];
34539
+ for (let i = 0; i < control.length; i++) {
34540
+ abs.push(samples[v][i] - control[i]);
34541
+ if (Math.abs(control[i]) > EPSILON) rel.push((samples[v][i] - control[i]) / control[i]);
34542
+ }
34543
+ abs.sort((a, b) => a - b);
34544
+ rel.sort((a, b) => a - b);
34545
+ lifts.push({
34546
+ variantIndex: v,
34547
+ mean: mean(abs),
34548
+ ci: percentileCi(abs),
34549
+ relativeMean: mean(rel),
34550
+ relativeCi: percentileCi(rel)
34551
+ });
34552
+ }
34553
+ return lifts;
34554
+ }
34423
34555
  function bayesianMonteCarlo(variants, simulations = 1e5, credibility = 0.95) {
34424
34556
  const n = variants.length;
34425
34557
  const samples = [];
@@ -34531,7 +34663,8 @@ function bayesianMonteCarlo(variants, simulations = 1e5, credibility = 0.95) {
34531
34663
  credibleIntervals,
34532
34664
  posteriorMeans,
34533
34665
  liftDistribution: { mean: liftMean, ci: liftCi, histogram },
34534
- relativeLiftDistribution: { mean: relLiftMean, ci: relLiftCi, histogram: relHistogram }
34666
+ relativeLiftDistribution: { mean: relLiftMean, ci: relLiftCi, histogram: relHistogram },
34667
+ variantLifts: computeVariantLifts(samples, tail)
34535
34668
  };
34536
34669
  }
34537
34670
  function normalPosterior(sampleMean, sampleStdDev, sampleSize) {
@@ -34651,7 +34784,8 @@ function bayesianMonteCarloNormal(variants, simulations = 1e5, credibility = 0.9
34651
34784
  credibleIntervals,
34652
34785
  posteriorMeans,
34653
34786
  liftDistribution: { mean: liftMean, ci: liftCi, histogram },
34654
- relativeLiftDistribution: { mean: relLiftMean, ci: relLiftCi, histogram: relHistogram }
34787
+ relativeLiftDistribution: { mean: relLiftMean, ci: relLiftCi, histogram: relHistogram },
34788
+ variantLifts: computeVariantLifts(samples, tail)
34655
34789
  };
34656
34790
  }
34657
34791
 
@@ -34719,7 +34853,7 @@ function trackToolUsage(toolName) {
34719
34853
  }
34720
34854
  var server = new McpServer({
34721
34855
  name: "ABTestResult",
34722
- version: "1.1.0",
34856
+ version: "1.3.0",
34723
34857
  description: "Statistical tools for A/B testing \u2014 significance testing, sample size calculation, Bayesian analysis, and more. Powered by abtestresult.com"
34724
34858
  });
34725
34859
  var SITE_URL = (process.env.ABTESTRESULT_BASE_URL || "https://abtestresult.com").replace(/\/+$/, "");
@@ -34755,13 +34889,15 @@ Example: "Control had 5000 visitors with 250 conversions, variant had 5000 visit
34755
34889
  variant_conversions: external_exports3.number().int().min(0).describe("Number of conversions in the variant group"),
34756
34890
  confidence_level: external_exports3.number().min(0.5).max(0.999).default(0.95).describe("Confidence level (e.g. 0.95 for 95%). Default: 0.95"),
34757
34891
  test_type: external_exports3.enum(["two-sided", "one-sided"]).default("two-sided").describe("Two-sided tests for any difference, one-sided for improvement only. Default: two-sided"),
34758
- non_inferiority_margin: external_exports3.number().min(0).default(0).describe("Non-inferiority margin as percentage (e.g. 5 for 5%). Default: 0 (superiority test)")
34892
+ non_inferiority_margin: external_exports3.number().min(0).default(0).describe("Non-inferiority margin as a RELATIVE percentage of the control rate (e.g. 2 means the variant may be up to 2% of the control rate worse: at an 80% control rate that is 1.6 percentage points, not 2 points). When > 0, a one-sided non-inferiority test is run and test_type is ignored. Default: 0 (superiority test)"),
34893
+ expected_control_share: external_exports3.number().gt(0).lt(1).optional().describe("Optional planned share of users in control, as a decimal, for intentionally unequal splits (e.g. 0.05 for a 5% holdout). When set, the response includes a sample ratio mismatch check against this planned split. Analyse the real user counts; never rescale a group to equal size.")
34759
34894
  },
34760
34895
  async (params) => {
34761
34896
  const rl = checkRateLimit();
34762
34897
  if (rl) return { content: rl, isError: true };
34763
34898
  trackToolUsage("analyze_ab_test");
34764
- const sided = params.test_type === "one-sided" ? 1 : 2;
34899
+ const isNonInferiority = params.non_inferiority_margin > 0;
34900
+ const sided = isNonInferiority || params.test_type === "one-sided" ? 1 : 2;
34765
34901
  const result = zTestForProportions(
34766
34902
  params.control_users,
34767
34903
  params.control_conversions,
@@ -34771,7 +34907,9 @@ Example: "Control had 5000 visitors with 250 conversions, variant had 5000 visit
34771
34907
  sided,
34772
34908
  params.non_inferiority_margin
34773
34909
  );
34774
- const summary = result.isSignificant ? `\u2705 SIGNIFICANT \u2014 The variant ${result.delta > 0 ? "outperforms" : "underperforms"} the control.` : `\u23F3 NOT SIGNIFICANT \u2014 No statistically significant difference detected.`;
34910
+ const marginAbsolute = params.non_inferiority_margin / 100 * result.controlRate;
34911
+ const marginText = `${params.non_inferiority_margin}% of the control rate (${round(marginAbsolute * 100, 4)} percentage points; variant must stay above ${toPercent(result.controlRate - marginAbsolute)})`;
34912
+ const summary = isNonInferiority ? nonInferioritySummary(result.isSignificant, marginText) : superioritySummary(result.isSignificant, result.delta);
34775
34913
  const ctrlRate = (params.control_conversions / params.control_users * 100).toFixed(2);
34776
34914
  const varRate = (params.variant_conversions / params.variant_users * 100).toFixed(2);
34777
34915
  const viewUrl = buildDeeplink("/statistical-significance-calculator", {
@@ -34780,7 +34918,8 @@ Example: "Control had 5000 visitors with 250 conversions, variant had 5000 visit
34780
34918
  m: "r",
34781
34919
  t: tailCode(params.test_type, params.non_inferiority_margin),
34782
34920
  l: String(Math.round(params.confidence_level * 100)),
34783
- n: params.non_inferiority_margin > 0 ? String(params.non_inferiority_margin) : ""
34921
+ n: params.non_inferiority_margin > 0 ? String(params.non_inferiority_margin) : "",
34922
+ r: params.expected_control_share !== void 0 ? String(round(params.expected_control_share * 100, 4)) : ""
34784
34923
  });
34785
34924
  return {
34786
34925
  content: [{
@@ -34789,7 +34928,8 @@ Example: "Control had 5000 visitors with 250 conversions, variant had 5000 visit
34789
34928
  summary,
34790
34929
  view_url: viewUrl,
34791
34930
  significant: result.isSignificant,
34792
- p_value: round(result.pValue, 6),
34931
+ p_value: roundPValue(result.pValue),
34932
+ p_value_display: formatPValue(result.pValue),
34793
34933
  z_score: round(result.zScore, 4),
34794
34934
  control_rate: toPercent(result.controlRate),
34795
34935
  variant_rate: toPercent(result.variantRate),
@@ -34808,7 +34948,15 @@ Example: "Control had 5000 visitors with 250 conversions, variant had 5000 visit
34808
34948
  upper: toPercent(result.ciVariation[1])
34809
34949
  },
34810
34950
  confidence_level: toPercent(params.confidence_level),
34811
- test_type: params.test_type
34951
+ test_type: isNonInferiority ? "non-inferiority (one-sided)" : params.test_type,
34952
+ ...isNonInferiority && {
34953
+ non_inferiority_margin: {
34954
+ relative_to_control: `${params.non_inferiority_margin}%`,
34955
+ absolute_percentage_points: round(marginAbsolute * 100, 4),
34956
+ threshold_rate: toPercent(result.controlRate - marginAbsolute)
34957
+ }
34958
+ },
34959
+ ...plannedSplitSRM(params.control_users, params.variant_users, params.expected_control_share)
34812
34960
  }, null, 2)
34813
34961
  }]
34814
34962
  };
@@ -34817,25 +34965,27 @@ Example: "Control had 5000 visitors with 250 conversions, variant had 5000 visit
34817
34965
  server.tool(
34818
34966
  "analyze_ab_test_average",
34819
34967
  `Analyze an A/B test with continuous/average metrics (e.g. revenue per user, time on page, order value).
34820
- Uses a pooled T-test. Returns significance, p-value, confidence intervals, and lift.
34968
+ Uses Welch's T-test (unequal variances), which stays accurate when groups differ in size or spread (e.g. 95/5 holdouts). Returns significance, p-value, confidence intervals, and lift.
34821
34969
 
34822
34970
  Example: "Control had 1000 users with mean $45.20 (std $12.50), variant had 1000 users with mean $48.80 (std $13.10)"`,
34823
34971
  {
34824
- control_users: external_exports3.number().int().positive().describe("Number of users in the control group"),
34972
+ control_users: external_exports3.number().int().min(2).describe("Number of users in the control group (at least 2)"),
34825
34973
  control_mean: external_exports3.number().describe("Mean value for the control group"),
34826
34974
  control_std_dev: external_exports3.number().positive().describe("Standard deviation for the control group"),
34827
- variant_users: external_exports3.number().int().positive().describe("Number of users in the variant group"),
34975
+ variant_users: external_exports3.number().int().min(2).describe("Number of users in the variant group (at least 2)"),
34828
34976
  variant_mean: external_exports3.number().describe("Mean value for the variant group"),
34829
34977
  variant_std_dev: external_exports3.number().positive().describe("Standard deviation for the variant group"),
34830
34978
  confidence_level: external_exports3.number().min(0.5).max(0.999).default(0.95).describe("Confidence level (e.g. 0.95 for 95%). Default: 0.95"),
34831
34979
  test_type: external_exports3.enum(["two-sided", "one-sided"]).default("two-sided").describe("Two-sided or one-sided test. Default: two-sided"),
34832
- non_inferiority_margin: external_exports3.number().min(0).default(0).describe("Non-inferiority margin as percentage. Default: 0")
34980
+ non_inferiority_margin: external_exports3.number().min(0).default(0).describe("Non-inferiority margin as a RELATIVE percentage of the control mean (e.g. 2 means the variant mean may be up to 2% of the control mean lower). When > 0, a one-sided non-inferiority test is run and test_type is ignored. Default: 0 (superiority test)"),
34981
+ expected_control_share: external_exports3.number().gt(0).lt(1).optional().describe("Optional planned share of users in control, as a decimal, for intentionally unequal splits (e.g. 0.05 for a 5% holdout). When set, the response includes a sample ratio mismatch check against this planned split. Analyse the real user counts; never rescale a group to equal size.")
34833
34982
  },
34834
34983
  async (params) => {
34835
34984
  const rl = checkRateLimit();
34836
34985
  if (rl) return { content: rl, isError: true };
34837
34986
  trackToolUsage("analyze_ab_test_average");
34838
- const sided = params.test_type === "one-sided" ? 1 : 2;
34987
+ const isNonInferiority = params.non_inferiority_margin > 0;
34988
+ const sided = isNonInferiority || params.test_type === "one-sided" ? 1 : 2;
34839
34989
  const result = tTestForAverages(
34840
34990
  params.control_users,
34841
34991
  params.control_mean,
@@ -34847,14 +34997,17 @@ Example: "Control had 1000 users with mean $45.20 (std $12.50), variant had 1000
34847
34997
  sided,
34848
34998
  params.non_inferiority_margin
34849
34999
  );
34850
- const summary = result.isSignificant ? `\u2705 SIGNIFICANT \u2014 The variant ${result.delta > 0 ? "outperforms" : "underperforms"} the control.` : `\u23F3 NOT SIGNIFICANT \u2014 No statistically significant difference detected.`;
35000
+ const marginAbsolute = params.non_inferiority_margin / 100 * result.controlMean;
35001
+ const marginText = `${params.non_inferiority_margin}% of the control mean (${round(marginAbsolute, 4)} in metric units; variant mean must stay above ${round(result.controlMean - marginAbsolute, 4)})`;
35002
+ const summary = isNonInferiority ? nonInferioritySummary(result.isSignificant, marginText) : superioritySummary(result.isSignificant, result.delta);
34851
35003
  const viewUrl = buildDeeplink("/statistical-significance-calculator", {
34852
35004
  c: pipeJoin([params.control_users, params.control_mean, "", params.control_std_dev]),
34853
35005
  v: pipeJoin([params.variant_users, params.variant_mean, "", params.variant_std_dev]),
34854
35006
  m: "a",
34855
35007
  t: tailCode(params.test_type, params.non_inferiority_margin),
34856
35008
  l: String(Math.round(params.confidence_level * 100)),
34857
- n: params.non_inferiority_margin > 0 ? String(params.non_inferiority_margin) : ""
35009
+ n: params.non_inferiority_margin > 0 ? String(params.non_inferiority_margin) : "",
35010
+ r: params.expected_control_share !== void 0 ? String(round(params.expected_control_share * 100, 4)) : ""
34858
35011
  });
34859
35012
  return {
34860
35013
  content: [{
@@ -34863,9 +35016,10 @@ Example: "Control had 1000 users with mean $45.20 (std $12.50), variant had 1000
34863
35016
  summary,
34864
35017
  view_url: viewUrl,
34865
35018
  significant: result.isSignificant,
34866
- p_value: round(result.pValue, 6),
35019
+ p_value: roundPValue(result.pValue),
35020
+ p_value_display: formatPValue(result.pValue),
34867
35021
  t_score: round(result.tScore, 4),
34868
- degrees_of_freedom: result.df,
35022
+ degrees_of_freedom: round(result.df, 2),
34869
35023
  control_mean: round(result.controlMean, 4),
34870
35024
  variant_mean: round(result.variantMean, 4),
34871
35025
  absolute_lift: round(result.delta, 4),
@@ -34883,7 +35037,15 @@ Example: "Control had 1000 users with mean $45.20 (std $12.50), variant had 1000
34883
35037
  upper: round(result.variationCi[1], 4)
34884
35038
  },
34885
35039
  confidence_level: toPercent(params.confidence_level),
34886
- test_type: params.test_type
35040
+ test_type: isNonInferiority ? "non-inferiority (one-sided)" : params.test_type,
35041
+ ...isNonInferiority && {
35042
+ non_inferiority_margin: {
35043
+ relative_to_control: `${params.non_inferiority_margin}%`,
35044
+ absolute: round(marginAbsolute, 4),
35045
+ threshold_mean: round(result.controlMean - marginAbsolute, 4)
35046
+ }
35047
+ },
35048
+ ...plannedSplitSRM(params.control_users, params.variant_users, params.expected_control_share)
34887
35049
  }, null, 2)
34888
35050
  }]
34889
35051
  };
@@ -34905,7 +35067,8 @@ Example: "How many users do I need to detect a 10% relative lift on a 5% baselin
34905
35067
  power: external_exports3.number().min(0.5).max(0.999).default(0.8).describe("Statistical power. Default: 0.80"),
34906
35068
  test_type: external_exports3.enum(["two-sided", "one-sided"]).default("two-sided").describe("Test sidedness. Default: two-sided"),
34907
35069
  num_variants: external_exports3.number().int().min(1).default(1).describe("Number of variant groups (not counting control). Default: 1"),
34908
- daily_traffic: external_exports3.number().int().positive().optional().describe("Daily traffic to estimate test duration in days")
35070
+ daily_traffic: external_exports3.number().int().positive().optional().describe("Daily traffic to estimate test duration in days"),
35071
+ control_share: external_exports3.number().gt(0).lt(1).optional().describe("Planned share of traffic in control, as a decimal, for unequal splits (e.g. 0.05 for a 5% holdout, 0.95 for a 5% canary variant). Omit for an equal split.")
34909
35072
  },
34910
35073
  async (params) => {
34911
35074
  const rl = checkRateLimit();
@@ -34958,17 +35121,27 @@ Example: "How many users do I need to detect a 10% relative lift on a 5% baselin
34958
35121
  s: params.metric_type === "average" && params.std_dev ? String(params.std_dev) : "",
34959
35122
  c: String(Math.round(params.confidence_level * 100)),
34960
35123
  p: String(Math.round(params.power * 100)),
34961
- v: params.num_variants !== 1 ? String(params.num_variants) : ""
35124
+ v: params.num_variants !== 1 ? String(params.num_variants) : "",
35125
+ r: params.control_share !== void 0 ? String(round(params.control_share * 100, 4)) : ""
34962
35126
  });
35127
+ const equalShare = 1 / (params.num_variants + 1);
35128
+ const allocation = params.control_share !== void 0 && Math.abs(params.control_share - equalShare) > 1e-9 ? allocateSampleSize(result.samplesEach, params.num_variants, params.control_share) : null;
35129
+ const totalSamples = allocation ? allocation.total : result.samplesTotal;
35130
+ const estimatedDays = params.daily_traffic ? Math.ceil(totalSamples / params.daily_traffic) : null;
34963
35131
  return {
34964
35132
  content: [{
34965
35133
  type: "text",
34966
35134
  text: JSON.stringify({
34967
35135
  view_url: viewUrl,
34968
- samples_per_group: result.samplesEach,
34969
- total_samples: result.samplesTotal,
35136
+ ...allocation ? {
35137
+ control_samples: allocation.control,
35138
+ samples_per_variant: allocation.perVariant,
35139
+ equal_split_samples_per_group: result.samplesEach,
35140
+ traffic_multiplier_vs_equal_split: round(allocation.multiplier, 3)
35141
+ } : { samples_per_group: result.samplesEach },
35142
+ total_samples: totalSamples,
34970
35143
  number_of_groups: params.num_variants + 1,
34971
- estimated_days: result.days,
35144
+ estimated_days: estimatedDays,
34972
35145
  effect_size: round(result.effectSize, 4),
34973
35146
  absolute_mde: round(result.mdeAbsolute, 6),
34974
35147
  settings: {
@@ -34997,7 +35170,8 @@ Example: "I have 50,000 total users and a 5% conversion rate. What's my MDE?"`,
34997
35170
  std_dev: external_exports3.number().positive().optional().describe("Standard deviation. Required for average metrics. For rate metrics, auto-calculated from baseline if omitted."),
34998
35171
  confidence_level: external_exports3.number().min(0.5).max(0.999).default(0.95).describe("Confidence level. Default: 0.95"),
34999
35172
  power: external_exports3.number().min(0.5).max(0.999).default(0.8).describe("Statistical power. Default: 0.80"),
35000
- num_variants: external_exports3.number().int().min(1).default(1).describe("Number of variant groups. Default: 1")
35173
+ num_variants: external_exports3.number().int().min(1).default(1).describe("Number of variant groups. Default: 1"),
35174
+ control_share: external_exports3.number().gt(0).lt(1).optional().describe("Share of total traffic in control, as a decimal, for unequal splits (e.g. 0.05 for a 5% holdout). Omit for an equal split.")
35001
35175
  },
35002
35176
  async (params) => {
35003
35177
  const rl = checkRateLimit();
@@ -35019,13 +35193,33 @@ Example: "I have 50,000 total users and a 5% conversion rate. What's my MDE?"`,
35019
35193
  }
35020
35194
  stdDev = params.std_dev;
35021
35195
  }
35022
- const mdeAbsolute = estimateMDE(
35196
+ const mdeAbsolute = params.metric_type === "rate" ? estimateMDERate({
35197
+ traffic: params.total_traffic,
35198
+ baselineRate: params.baseline / 100,
35199
+ power: params.power,
35200
+ confidenceLevel: params.confidence_level,
35201
+ numVariants: params.num_variants,
35202
+ controlShare: params.control_share
35203
+ }) : estimateMDE(
35023
35204
  params.total_traffic,
35024
35205
  stdDev,
35025
35206
  params.power,
35026
35207
  params.confidence_level,
35027
- params.num_variants
35208
+ params.num_variants,
35209
+ 1,
35210
+ 0,
35211
+ params.control_share
35028
35212
  );
35213
+ if (!Number.isFinite(mdeAbsolute)) {
35214
+ return {
35215
+ content: [{
35216
+ type: "text",
35217
+ text: params.metric_type === "rate" && !(params.baseline > 0 && params.baseline < 100) ? "Error: baseline must be between 0 and 100 (percent) for rate metrics." : "No detectable effect: with this traffic, even a lift to a 100% conversion rate would not reach the target power. Increase traffic or lower the power or confidence level."
35218
+ }],
35219
+ isError: true
35220
+ };
35221
+ }
35222
+ const controlShare = params.control_share ?? 1 / (params.num_variants + 1);
35029
35223
  const baselineValue = params.metric_type === "rate" ? params.baseline / 100 : params.baseline;
35030
35224
  const mdeRelative = baselineValue !== 0 ? mdeAbsolute / baselineValue : 0;
35031
35225
  const viewUrl = buildDeeplink("/mde-calculator", {
@@ -35034,7 +35228,9 @@ Example: "I have 50,000 total users and a 5% conversion rate. What's my MDE?"`,
35034
35228
  r: String(params.baseline),
35035
35229
  p: String(params.power),
35036
35230
  c: String(params.confidence_level),
35037
- m: params.metric_type === "average" ? "c" : "r"
35231
+ m: params.metric_type === "average" ? "c" : "r",
35232
+ k: params.control_share !== void 0 ? String(round(params.control_share * 100, 4)) : "",
35233
+ v: params.num_variants !== 1 ? String(params.num_variants) : ""
35038
35234
  });
35039
35235
  return {
35040
35236
  content: [{
@@ -35046,7 +35242,10 @@ Example: "I have 50,000 total users and a 5% conversion rate. What's my MDE?"`,
35046
35242
  interpretation: params.metric_type === "rate" ? `You can detect an absolute change of ${toPercent(mdeAbsolute)} (${toPercent(mdeRelative)} relative lift) from the ${params.baseline}% baseline.` : `You can detect an absolute change of ${round(mdeAbsolute, 4)} (${toPercent(mdeRelative)} relative lift) from the ${params.baseline} baseline.`,
35047
35243
  settings: {
35048
35244
  total_traffic: params.total_traffic,
35049
- traffic_per_group: Math.floor(params.total_traffic / (params.num_variants + 1)),
35245
+ ...params.control_share === void 0 ? { traffic_per_group: Math.floor(params.total_traffic / (params.num_variants + 1)) } : {
35246
+ control_traffic: Math.round(params.total_traffic * controlShare),
35247
+ traffic_per_variant: Math.round(params.total_traffic * (1 - controlShare) / params.num_variants)
35248
+ },
35050
35249
  baseline: params.baseline,
35051
35250
  confidence_level: toPercent(params.confidence_level),
35052
35251
  power: toPercent(params.power)
@@ -35058,10 +35257,17 @@ Example: "I have 50,000 total users and a 5% conversion rate. What's my MDE?"`,
35058
35257
  );
35059
35258
  server.tool(
35060
35259
  "bayesian_ab_test",
35061
- `Run Bayesian A/B test analysis. Returns probability to be best, expected loss, and credible intervals.
35260
+ `Run Bayesian A/B test analysis. Returns probability to be best, expected loss, credible intervals,
35261
+ and the lift of every variant vs the control (first variant).
35062
35262
  Supports both rate metrics (Beta-Binomial model) and average metrics (Normal model).
35063
35263
  Supports multiple variants.
35064
35264
 
35265
+ Units: for rate metrics, raw values (posterior_mean, credible_interval, expected_loss, absolute lift) are
35266
+ proportions (0.05 = 5%), so a difference of 0.0006 is 0.06 percentage points. For average metrics they are
35267
+ in the metric's own units. Each variant also includes expected_loss_display in plain terms.
35268
+ Expected loss = the average amount you give up by shipping that variant if it is not actually the best.
35269
+ A common stopping rule: ship when the leader's expected loss is below the smallest difference you care about.
35270
+
35065
35271
  Example: "Control: 5000 visitors, 250 conversions. Variant: 5000 visitors, 300 conversions."`,
35066
35272
  {
35067
35273
  metric_type: external_exports3.enum(["rate", "average"]).describe('Metric type: "rate" for proportions, "average" for continuous'),
@@ -35089,18 +35295,32 @@ Example: "Control: 5000 visitors, 250 conversions. Variant: 5000 visitors, 300 c
35089
35295
  });
35090
35296
  result = bayesianMonteCarlo(posteriors, params.simulations, params.credibility);
35091
35297
  } else {
35092
- const posteriors = params.variants.map((v) => {
35093
- if (v.mean === void 0 || v.std_dev === void 0) {
35094
- throw new Error(`Mean and std_dev are required for average metrics (variant: ${v.name})`);
35095
- }
35096
- return normalPosterior(v.mean, v.std_dev, v.users);
35097
- });
35298
+ const missing = params.variants.find((v) => v.mean === void 0 || v.std_dev === void 0);
35299
+ if (missing) {
35300
+ return {
35301
+ content: [{
35302
+ type: "text",
35303
+ text: `Error: mean and std_dev are required for average metrics (variant: ${missing.name}).`
35304
+ }],
35305
+ isError: true
35306
+ };
35307
+ }
35308
+ const posteriors = params.variants.map((v) => normalPosterior(v.mean, v.std_dev, v.users));
35098
35309
  result = bayesianMonteCarloNormal(posteriors, params.simulations, params.credibility);
35099
35310
  }
35311
+ const isRate = params.metric_type === "rate";
35312
+ const controlMean = result.posteriorMeans[0];
35313
+ const lossDisplay = (loss) => {
35314
+ const relPct = controlMean !== 0 ? loss / Math.abs(controlMean) * 100 : null;
35315
+ const relText = relPct === null ? "" : relPct > 0 && relPct < 0.01 ? "< 0.01%" : `\u2248 ${round(relPct, 2)}%`;
35316
+ const relative = relPct === null ? "" : ` (${relText} of the control ${isRate ? "rate" : "mean"})`;
35317
+ return isRate ? `\u2248 ${round(loss * 100, 3)} percentage points${relative}` : `\u2248 ${round(loss, 4)}${relative}`;
35318
+ };
35100
35319
  const variantResults = params.variants.map((v, i) => ({
35101
35320
  name: v.name,
35102
35321
  probability_to_be_best: toPercent(result.probabilityToBeBest[i]),
35103
35322
  expected_loss: round(result.expectedLoss[i], 6),
35323
+ expected_loss_display: lossDisplay(result.expectedLoss[i]),
35104
35324
  credible_interval: {
35105
35325
  lower: round(result.credibleIntervals[i].lower, 6),
35106
35326
  upper: round(result.credibleIntervals[i].upper, 6)
@@ -35108,8 +35328,23 @@ Example: "Control: 5000 visitors, 250 conversions. Variant: 5000 visitors, 300 c
35108
35328
  posterior_mean: round(result.posteriorMeans[i], 6)
35109
35329
  }));
35110
35330
  const bestIdx = result.probabilityToBeBest.indexOf(Math.max(...result.probabilityToBeBest));
35111
- const summary = result.probabilityToBeBest[bestIdx] > 0.95 ? `\u{1F3C6} ${params.variants[bestIdx].name} is the winner with ${toPercent(result.probabilityToBeBest[bestIdx])} probability of being best.` : result.probabilityToBeBest[bestIdx] > 0.8 ? `\u{1F4CA} ${params.variants[bestIdx].name} is leading with ${toPercent(result.probabilityToBeBest[bestIdx])} probability, but more data may be needed.` : `\u23F3 No clear winner yet. The leading variant has only ${toPercent(result.probabilityToBeBest[bestIdx])} probability of being best.`;
35112
- const isRate = params.metric_type === "rate";
35331
+ const summary = result.probabilityToBeBest[bestIdx] > 0.95 ? `\u{1F3C6} ${params.variants[bestIdx].name} is the winner with ${toPercent(result.probabilityToBeBest[bestIdx])} probability of being best.` : result.probabilityToBeBest[bestIdx] > 0.8 ? `\u{1F4CA} ${params.variants[bestIdx].name} is leading with ${toPercent(result.probabilityToBeBest[bestIdx])} probability, but more data may be needed.` : `\u23F3 No clear winner yet. The leading variant (${params.variants[bestIdx].name}) has only ${toPercent(result.probabilityToBeBest[bestIdx])} probability of being best.`;
35332
+ const summaryWithLoss = `${summary} Expected loss if you ship ${params.variants[bestIdx].name}: ${lossDisplay(result.expectedLoss[bestIdx])}.`;
35333
+ const liftVsControl = result.variantLifts.map((l) => ({
35334
+ variant: params.variants[l.variantIndex].name,
35335
+ vs: params.variants[0].name,
35336
+ absolute_lift: isRate ? {
35337
+ mean: toPercentagePoints(l.mean),
35338
+ credible_interval: { lower: toPercentagePoints(l.ci[0]), upper: toPercentagePoints(l.ci[1]) }
35339
+ } : {
35340
+ mean: round(l.mean, 6),
35341
+ credible_interval: { lower: round(l.ci[0], 6), upper: round(l.ci[1], 6) }
35342
+ },
35343
+ relative_lift: {
35344
+ mean: toPercent(l.relativeMean),
35345
+ credible_interval: { lower: toPercent(l.relativeCi[0]), upper: toPercent(l.relativeCi[1]) }
35346
+ }
35347
+ }));
35113
35348
  const bayesPart = (v, withName) => {
35114
35349
  const rate = isRate && v.conversions !== void 0 && v.users ? (v.conversions / v.users * 100).toFixed(2) : "";
35115
35350
  return pipeJoin(isRate ? [v.users, v.conversions, rate, "", withName ? v.name : ""] : [v.users, v.mean, "", v.std_dev, withName ? v.name : ""]);
@@ -35126,23 +35361,10 @@ Example: "Control: 5000 visitors, 250 conversions. Variant: 5000 visitors, 300 c
35126
35361
  content: [{
35127
35362
  type: "text",
35128
35363
  text: JSON.stringify({
35129
- summary,
35364
+ summary: summaryWithLoss,
35130
35365
  view_url: viewUrl,
35131
35366
  variants: variantResults,
35132
- lift_distribution: {
35133
- mean: round(result.liftDistribution.mean, 6),
35134
- credible_interval: {
35135
- lower: round(result.liftDistribution.ci[0], 6),
35136
- upper: round(result.liftDistribution.ci[1], 6)
35137
- }
35138
- },
35139
- relative_lift_distribution: {
35140
- mean: toPercent(result.relativeLiftDistribution.mean),
35141
- credible_interval: {
35142
- lower: toPercent(result.relativeLiftDistribution.ci[0]),
35143
- upper: toPercent(result.relativeLiftDistribution.ci[1])
35144
- }
35145
- },
35367
+ lift_vs_control: liftVsControl,
35146
35368
  simulations: params.simulations,
35147
35369
  credibility: toPercent(params.credibility)
35148
35370
  }, null, 2)
@@ -35153,25 +35375,29 @@ Example: "Control: 5000 visitors, 250 conversions. Variant: 5000 visitors, 300 c
35153
35375
  server.tool(
35154
35376
  "check_srm",
35155
35377
  `Check for Sample Ratio Mismatch (SRM) in an A/B test.
35156
- SRM occurs when the traffic split between control and variant doesn't match the expected ratio (usually 50/50).
35378
+ SRM occurs when the traffic split between control and variant doesn't match the planned ratio (50/50 unless you say otherwise).
35157
35379
  SRM is a sign of a bug in the experiment setup and invalidates results.
35380
+ For intentionally unequal splits (holdouts, canary tests) pass the planned control share, e.g. 0.05 for a 5% holdout.
35158
35381
 
35159
35382
  Example: "Control has 10,234 users, variant has 9,766 users"`,
35160
35383
  {
35161
35384
  control_users: external_exports3.number().int().positive().describe("Number of users in the control group"),
35162
- variant_users: external_exports3.number().int().positive().describe("Number of users in the variant group")
35385
+ variant_users: external_exports3.number().int().positive().describe("Number of users in the variant group"),
35386
+ expected_control_share: external_exports3.number().gt(0).lt(1).default(0.5).describe("Planned share of users in control, as a decimal. 0.5 for 50/50, 0.1 for a 10% holdout, 0.95 for a 5% canary variant. Default: 0.5")
35163
35387
  },
35164
35388
  async (params) => {
35165
35389
  const rl = checkRateLimit();
35166
35390
  if (rl) return { content: rl, isError: true };
35167
35391
  trackToolUsage("check_srm");
35168
- const result = checkSRM(params.control_users, params.variant_users);
35392
+ const result = checkSRM(params.control_users, params.variant_users, params.expected_control_share);
35169
35393
  const total = params.control_users + params.variant_users;
35170
35394
  const actualRatio = params.control_users / total;
35171
- const summary = result.hasMismatch ? `\u{1F6A8} SRM DETECTED (p=${round(result.pValue, 6)}) \u2014 The traffic split is ${toPercent(actualRatio)}/${toPercent(1 - actualRatio)} instead of the expected 50/50. Your experiment may have a bug. DO NOT trust the results.` : `\u2705 No SRM detected (p=${round(result.pValue, 4)}) \u2014 The traffic split of ${toPercent(actualRatio)}/${toPercent(1 - actualRatio)} is consistent with a 50/50 split.`;
35395
+ const expectedSplit = `${toPercent(params.expected_control_share)}/${toPercent(1 - params.expected_control_share)}`;
35396
+ const summary = result.hasMismatch ? `\u{1F6A8} SRM DETECTED (${formatPValueEq(result.pValue)}) \u2014 The traffic split is ${toPercent(actualRatio)}/${toPercent(1 - actualRatio)} instead of the expected ${expectedSplit}. Your experiment may have a bug. DO NOT trust the results.` : `\u2705 No SRM detected (${formatPValueEq(result.pValue)}) \u2014 The traffic split of ${toPercent(actualRatio)}/${toPercent(1 - actualRatio)} is consistent with the expected ${expectedSplit} split.`;
35172
35397
  const viewUrl = buildDeeplink("/sample-ratio-mismatch", {
35173
35398
  c: String(params.control_users),
35174
- v: String(params.variant_users)
35399
+ v: String(params.variant_users),
35400
+ e: params.expected_control_share !== 0.5 ? String(round(params.expected_control_share * 100, 4)) : ""
35175
35401
  });
35176
35402
  return {
35177
35403
  content: [{
@@ -35180,12 +35406,17 @@ Example: "Control has 10,234 users, variant has 9,766 users"`,
35180
35406
  summary,
35181
35407
  view_url: viewUrl,
35182
35408
  has_mismatch: result.hasMismatch,
35183
- p_value: round(result.pValue, 6),
35409
+ p_value: roundPValue(result.pValue),
35410
+ p_value_display: formatPValue(result.pValue),
35184
35411
  chi_square: round(result.chiSquare, 4),
35185
35412
  actual_split: {
35186
35413
  control: toPercent(actualRatio),
35187
35414
  variant: toPercent(1 - actualRatio)
35188
35415
  },
35416
+ expected_split: {
35417
+ control: toPercent(params.expected_control_share),
35418
+ variant: toPercent(1 - params.expected_control_share)
35419
+ },
35189
35420
  total_users: total
35190
35421
  }, null, 2)
35191
35422
  }]
@@ -35222,7 +35453,7 @@ Example: "Before: [10, 12, 8, 15, 11], After: [12, 14, 9, 18, 13]"`,
35222
35453
  params.sample_after,
35223
35454
  params.confidence_level
35224
35455
  );
35225
- const summary = result.isSignificant ? `\u2705 SIGNIFICANT (${result.testUsed}) \u2014 There is a statistically significant difference (p=${round(result.pValue, 6)}).` : `\u23F3 NOT SIGNIFICANT (${result.testUsed}) \u2014 No statistically significant difference detected (p=${round(result.pValue, 6)}).`;
35456
+ const summary = result.isSignificant ? `\u2705 SIGNIFICANT (${result.testUsed}) \u2014 There is a statistically significant difference (${formatPValueEq(result.pValue)}).` : `\u23F3 NOT SIGNIFICANT (${result.testUsed}) \u2014 No statistically significant difference detected (${formatPValueEq(result.pValue)}).`;
35226
35457
  return {
35227
35458
  content: [{
35228
35459
  type: "text",
@@ -35231,7 +35462,8 @@ Example: "Before: [10, 12, 8, 15, 11], After: [12, 14, 9, 18, 13]"`,
35231
35462
  test_used: result.testUsed,
35232
35463
  test_statistic_name: result.testStatisticName,
35233
35464
  test_statistic: round(result.testStatistic, 4),
35234
- p_value: round(result.pValue, 6),
35465
+ p_value: roundPValue(result.pValue),
35466
+ p_value_display: formatPValue(result.pValue),
35235
35467
  significant: result.isSignificant,
35236
35468
  mean_before: round(result.mean1, 4),
35237
35469
  mean_after: round(result.mean2, 4),
@@ -35253,13 +35485,15 @@ Example: "Before: [10, 12, 8, 15, 11], After: [12, 14, 9, 18, 13]"`,
35253
35485
  );
35254
35486
  server.tool(
35255
35487
  "survey_sample_size",
35256
- `Calculate the required sample size for a survey using Cochran's formula with finite population correction.
35488
+ `Calculate the required number of completed survey responses using Cochran's formula with finite population correction.
35489
+ Optionally pass expected_response_rate to get how many people to invite.
35257
35490
 
35258
35491
  Example: "How many responses do I need from a population of 10,000 with 5% margin of error at 95% confidence?"`,
35259
35492
  {
35260
35493
  population: external_exports3.number().int().positive().describe("Total population size"),
35261
35494
  margin_of_error: external_exports3.number().positive().max(50).describe("Desired margin of error as percentage (e.g. 5 for \xB15%)"),
35262
- confidence_level: external_exports3.number().min(0.5).max(0.999).default(0.95).describe("Confidence level. Default: 0.95")
35495
+ confidence_level: external_exports3.number().min(0.5).max(0.999).default(0.95).describe("Confidence level. Default: 0.95"),
35496
+ expected_response_rate: external_exports3.number().positive().max(100).optional().describe("Optional: expected survey response rate as percentage (e.g. 20 for 20%). When given, returns how many people to invite to get the required completed responses.")
35263
35497
  },
35264
35498
  async (params) => {
35265
35499
  const rl = checkRateLimit();
@@ -35287,7 +35521,9 @@ Example: "How many responses do I need from a population of 10,000 with 5% margi
35287
35521
  population: params.population,
35288
35522
  margin_of_error: `\xB1${params.margin_of_error}%`,
35289
35523
  confidence_level: toPercent(params.confidence_level),
35290
- response_rate_needed: toPercent(sampleSize / params.population)
35524
+ // Share of the population that must complete the survey — not a response rate.
35525
+ share_of_population_needed: toPercent(sampleSize / params.population),
35526
+ ...invitationEstimate(sampleSize, params.population, params.expected_response_rate)
35291
35527
  }, null, 2)
35292
35528
  }]
35293
35529
  };
@@ -35298,6 +35534,53 @@ function round(value, decimals) {
35298
35534
  const factor = Math.pow(10, decimals);
35299
35535
  return Math.round(value * factor) / factor;
35300
35536
  }
35537
+ function superioritySummary(isSignificant, delta) {
35538
+ return isSignificant ? `\u2705 SIGNIFICANT \u2014 The variant ${delta > 0 ? "outperforms" : "underperforms"} the control.` : `\u23F3 NOT SIGNIFICANT \u2014 No statistically significant difference detected.`;
35539
+ }
35540
+ function nonInferioritySummary(isSignificant, marginText) {
35541
+ return isSignificant ? `\u2705 NON-INFERIORITY CONFIRMED \u2014 The variant is not worse than the control by more than ${marginText}.` : `\u23F3 NON-INFERIORITY NOT SHOWN \u2014 We cannot rule out that the variant is worse than the control by more than ${marginText}.`;
35542
+ }
35543
+ function toPercentagePoints(value) {
35544
+ if (!isFinite(value)) return "0 pp";
35545
+ const pts = round(value * 100, 3);
35546
+ return `${pts > 0 ? "+" : ""}${pts} pp`;
35547
+ }
35548
+ function invitationEstimate(sampleSize, population, responseRatePercent) {
35549
+ if (responseRatePercent === void 0) return {};
35550
+ const invitations = Math.ceil(sampleSize / (responseRatePercent / 100));
35551
+ const exceedsPopulation = invitations > population;
35552
+ return {
35553
+ expected_response_rate: `${responseRatePercent}%`,
35554
+ invitations_needed: Math.min(invitations, population),
35555
+ ...exceedsPopulation && {
35556
+ warning: `At a ${responseRatePercent}% response rate you would need to invite ${invitations.toLocaleString("en-US")} people, more than the population of ${population.toLocaleString("en-US")}. Even inviting everyone, expect about ${Math.floor(population * responseRatePercent / 100).toLocaleString("en-US")} responses, short of the ${sampleSize.toLocaleString("en-US")} needed. Accept a wider margin of error or improve the response rate.`
35557
+ }
35558
+ };
35559
+ }
35560
+ function formatPValue(p) {
35561
+ if (!isFinite(p)) return "n/a";
35562
+ return p < 1e-4 ? "< 0.0001" : String(round(p, 4));
35563
+ }
35564
+ function formatPValueEq(p) {
35565
+ const display = formatPValue(p);
35566
+ return display.startsWith("<") ? `p ${display}` : `p = ${display}`;
35567
+ }
35568
+ function roundPValue(p) {
35569
+ if (!isFinite(p)) return 0;
35570
+ return p > 0 && p < 1e-6 ? Number(p.toPrecision(3)) : round(p, 6);
35571
+ }
35572
+ function plannedSplitSRM(controlUsers, variantUsers, expectedControlShare) {
35573
+ if (expectedControlShare === void 0) return {};
35574
+ const srm = checkSRM(controlUsers, variantUsers, expectedControlShare);
35575
+ return {
35576
+ sample_ratio_mismatch: {
35577
+ has_mismatch: srm.hasMismatch,
35578
+ p_value: roundPValue(srm.pValue),
35579
+ expected_split: { control: toPercent(expectedControlShare), variant: toPercent(1 - expectedControlShare) },
35580
+ actual_split: { control: toPercent(controlUsers / (controlUsers + variantUsers)), variant: toPercent(variantUsers / (controlUsers + variantUsers)) }
35581
+ }
35582
+ };
35583
+ }
35301
35584
  function toPercent(value) {
35302
35585
  if (!isFinite(value)) return "0%";
35303
35586
  return `${round(value * 100, 2)}%`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "abtestresult-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "MCP server for A/B test statistical analysis — significance testing, sample size calculation, Bayesian analysis, and more",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",