> ## Documentation Index
> Fetch the complete documentation index at: https://www.pagent.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Bayesian statistics

> How the Bayesian engine decides a test with chance to beat control, priors and expected loss

export const StatisticsKit = (() => {
  const COLORS = {
    ink: "#14310D",
    accent: "#07C983",
    win: "#15803D",
    loss: "#C2410C",
    neutral: "#9FA09E",
    timeout: "#DFDFDE",
    grid: "#EEEFEE",
    muted: "#707170",
    surface: "#F5F6F0",
    band: "rgba(7, 201, 131, 0.14)"
  };
  const CHEVRON = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 4.5 6 7.5 9 4.5' fill='none' stroke='%23707170' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\")";
  const CSS = `
.pgw { font-family: inherit; color: #3F403E; background: #FFFFFF; border: 1px solid rgba(10, 11, 10, 0.1); border-radius: 16px; padding: 20px 24px; margin: 24px 0; }
.pgw-eyebrow { font-size: 14px; line-height: 20px; font-weight: 600; color: ${COLORS.ink}; }
.pgw-title { font-size: 16px; line-height: 24px; font-weight: 600; color: #252625; margin-top: 2px; }
.pgw-desc { font-size: 14px; line-height: 20px; color: #707170; margin-top: 2px; }
.pgw-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 14px 16px; margin: 20px 0; }
.pgw-field { display: flex; flex-direction: column; justify-content: space-between; gap: 6px; }
.pgw-label { font-size: 14px; line-height: 20px; font-weight: 600; color: #252625; }
.pgw-label span { font-weight: 400; color: #707170; }
.pgw input, .pgw select { width: 100%; height: 36px; padding: 0 12px; border: none; border-radius: 12px; background-color: ${COLORS.surface}; box-shadow: 0 0 0 1px rgba(159, 160, 158, 0.3); font: inherit; font-size: 14px; color: #171817; transition: box-shadow 0.15s; }
.pgw select { appearance: none; -webkit-appearance: none; background-image: ${CHEVRON}; background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px; cursor: pointer; }
.pgw input:hover, .pgw select:hover { box-shadow: 0 0 0 1px rgba(112, 113, 112, 0.5); }
.pgw input:focus, .pgw select:focus { outline: none; box-shadow: 0 0 0 2px ${COLORS.ink}; }
.pgw-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
.pgw-stat { background: ${COLORS.surface}; border-radius: 12px; padding: 12px 14px; }
.pgw-stat-label { font-size: 13px; line-height: 18px; color: #707170; }
.pgw-stat-value { font-size: 20px; line-height: 28px; font-weight: 600; color: #171817; margin-top: 4px; font-variant-numeric: tabular-nums; }
.pgw-chart { display: block; width: 100%; height: auto; margin-top: 20px; }
.pgw-chart text { font-family: inherit; font-size: 13px; fill: #707170; }
.pgw-legend { display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 13px; line-height: 20px; color: #707170; margin-top: 8px; }
.pgw-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; box-shadow: inset 0 0 0 1px rgba(10, 11, 10, 0.08); }
.pgw-line { display: inline-block; width: 16px; height: 0; margin-right: 6px; vertical-align: 3px; border-top: 2px solid; }
.pgw-verdict { font-size: 16px; line-height: 24px; font-weight: 600; margin-top: 16px; }
.pgw-note { font-size: 14px; line-height: 22px; color: #707170; margin-top: 12px; }
.pgw-note strong { color: #252625; font-weight: 600; }
@media (max-width: 640px) { .pgw { padding: 16px; } }
`;
  const Card = ({title, description, children}) => <div className="not-prose pgw">
      <style>{CSS}</style>
      <div className="pgw-eyebrow">Try it</div>
      <div className="pgw-title">{title}</div>
      {description ? <div className="pgw-desc">{description}</div> : null}
      {children}
    </div>;
  const Legend = ({items}) => <div className="pgw-legend">
      {items.map(([label, color, kind]) => <span key={label}>
          <span className={kind === "line" || kind === "dashed" ? "pgw-line" : "pgw-swatch"} style={kind === "line" || kind === "dashed" ? {
    borderTopColor: color,
    borderTopStyle: kind === "dashed" ? "dashed" : "solid"
  } : {
    background: color
  }} />
          {label}
        </span>)}
    </div>;
  const ERFC = [-1.26551223, 1.00002368, 0.37409196, 0.09678418, -0.18628806, 0.27886807, -1.13520398, 1.48851587, -0.82215223, 0.17087277];
  const erfc = x => {
    const z = Math.abs(x);
    const t = 1 / (1 + 0.5 * z);
    let poly = 0;
    for (let i = ERFC.length - 1; i >= 0; i--) poly = ERFC[i] + t * poly;
    const tail = t * Math.exp(-z * z + poly);
    return x >= 0 ? tail : 2 - tail;
  };
  const normalCdf = z => {
    if (z === Infinity) return 1;
    if (z === -Infinity) return 0;
    return 0.5 * erfc(-z / Math.SQRT2);
  };
  const normalPdf = z => 0.3989422804014327 * Math.exp(-z * z / 2);
  const QA = [-3.969683028665376e1, 2.209460984245205e2, -2.759285104469687e2, 1.38357751867269e2, -3.066479806614716e1, 2.506628277459239];
  const QB = [-5.447609879822406e1, 1.615858368580409e2, -1.556989798598866e2, 6.680131188771972e1, -1.328068155288572e1];
  const QC = [-7.784894002430293e-3, -3.223964580411365e-1, -2.400758277161838, -2.549732539343734, 4.374664141464968, 2.938163982698783];
  const QD = [7.784695709041462e-3, 3.224671290700398e-1, 2.445134137142996, 3.754408661907416];
  const lowerTail = p => {
    const q = Math.sqrt(-2 * Math.log(p));
    return (((((QC[0] * q + QC[1]) * q + QC[2]) * q + QC[3]) * q + QC[4]) * q + QC[5]) / ((((QD[0] * q + QD[1]) * q + QD[2]) * q + QD[3]) * q + 1);
  };
  const normalQuantile = p => {
    if (p <= 0) return -Infinity;
    if (p >= 1) return Infinity;
    if (p < 0.02425) return lowerTail(p);
    if (p > 1 - 0.02425) return -lowerTail(1 - p);
    const q = p - 0.5;
    const r = q * q;
    return (((((QA[0] * r + QA[1]) * r + QA[2]) * r + QA[3]) * r + QA[4]) * r + QA[5]) * q / (((((QB[0] * r + QB[1]) * r + QB[2]) * r + QB[3]) * r + QB[4]) * r + 1);
  };
  const betaRate = (conversions, visitors) => {
    const a = 1 + conversions;
    const b = 1 + Math.max(0, visitors - conversions);
    const total = a + b;
    return {
      mean: a / total,
      variance: a * b / (total * total * (total + 1))
    };
  };
  const bayesianEvidence = (cv, cc, vv, vc, prior, width) => {
    const control = betaRate(cc, cv);
    const variation = betaRate(vc, vv);
    const ratio = variation.mean / control.mean;
    const liftVariance = ratio * ratio * (variation.variance / (variation.mean * variation.mean) + control.variance / (control.mean * control.mean));
    let mean = ratio - 1;
    let sd = Math.sqrt(liftVariance);
    let chance;
    let lossShip;
    if (prior === "skeptical") {
      const dataPrecision = 1 / liftVariance;
      const precision = dataPrecision + 1 / (width * width);
      mean = mean * dataPrecision / precision;
      sd = 1 / Math.sqrt(precision);
      chance = normalCdf(mean / sd);
      lossShip = control.mean * (sd * normalPdf(mean / sd) - mean * normalCdf(-mean / sd));
    } else {
      const diff = variation.mean - control.mean;
      const diffSd = Math.sqrt(variation.variance + control.variance);
      chance = normalCdf(diff / diffSd);
      lossShip = diffSd * normalPdf(diff / diffSd) - diff * normalCdf(-diff / diffSd);
    }
    const inBand = band => normalCdf((band - mean) / sd) - normalCdf((-band - mean) / sd);
    return {
      mean,
      sd,
      chance,
      lossShip,
      controlRate: control.mean,
      inBand
    };
  };
  const pooledZ = (cv, cc, vv, vc) => {
    if (cv <= 0 || vv <= 0) return 0;
    const pooled = (cc + vc) / (cv + vv);
    const se = Math.sqrt(pooled * (1 - pooled) * (1 / cv + 1 / vv));
    return se === 0 ? 0 : (vc / vv - cc / cv) / se;
  };
  const correctedRate = (c, n) => c <= 0 || c >= n ? (c + 0.5) / (n + 1) : c / n;
  const relativeLiftInterval = (cv, cc, vv, vc, level) => {
    const pc = correctedRate(cc, cv);
    const pv = correctedRate(vc, vv);
    const ratio = pv / pc;
    const se = ratio * Math.sqrt(pv * (1 - pv) / vv / (pv * pv) + pc * (1 - pc) / cv / (pc * pc));
    const lift = cc > 0 ? vc / vv / (cc / cv) - 1 : ratio - 1;
    const z = normalQuantile(1 - (1 - level) / 2);
    return {
      lower: lift - z * se,
      upper: lift + z * se
    };
  };
  const INTERVALS = 160;
  const simpsonWeight = i => i === 0 || i === INTERVALS ? 1 : i % 2 === 1 ? 4 : 2;
  const obrienFleming = (alpha, t) => {
    if (!(t > 0)) return 0;
    if (t >= 1) return alpha;
    return Math.min(alpha, 2 - 2 * normalCdf(normalQuantile(1 - alpha / 2) / Math.sqrt(t)));
  };
  const crossing = (prev, c, t) => {
    if (!prev) return 2 * normalCdf(-c / Math.sqrt(t));
    const sd = Math.sqrt(t - prev.t);
    const h = 2 * prev.w / INTERVALS;
    let total = 0;
    for (let i = 0; i <= INTERVALS; i++) {
      const y = -prev.w + i * h;
      total += simpsonWeight(i) * prev.d[i] * (normalCdf((-c - y) / sd) + normalCdf((y - c) / sd));
    }
    return total * h / 3;
  };
  const propagate = (prev, w, t, spent) => {
    const d = new Float64Array(INTERVALS + 1);
    const step = 2 * w / INTERVALS;
    if (!prev) {
      const sd = Math.sqrt(t);
      for (let j = 0; j <= INTERVALS; j++) d[j] = normalPdf((-w + j * step) / sd) / sd;
      return {
        t,
        w,
        d,
        spent
      };
    }
    const sd = Math.sqrt(t - prev.t);
    const h = 2 * prev.w / INTERVALS;
    for (let j = 0; j <= INTERVALS; j++) {
      const x = -w + j * step;
      let total = 0;
      for (let i = 0; i <= INTERVALS; i++) {
        total += simpsonWeight(i) * prev.d[i] * (normalPdf((x - (-prev.w + i * h)) / sd) / sd);
      }
      d[j] = total * h / 3;
    }
    return {
      t,
      w,
      d,
      spent
    };
  };
  const spendingBoundaries = (alpha, fractions) => {
    const result = [];
    let prev = null;
    for (const requested of fractions) {
      const t = Math.min(1, Math.max(prev ? prev.t : 0, requested));
      if (t - (prev ? prev.t : 0) < 1e-9) {
        result.push(Infinity);
        continue;
      }
      const spent = obrienFleming(alpha, t);
      const increment = Math.max(0, spent - (prev ? prev.spent : 0));
      const maxW = 8 * Math.sqrt(t);
      let w;
      if (increment <= 0) w = maxW; else if (!prev) w = Math.min(maxW, Math.sqrt(t) * normalQuantile(1 - increment / 2)); else if (crossing(prev, maxW, t) >= increment) w = maxW; else {
        let lo = 0;
        let hi = maxW;
        for (let s = 0; s < 40; s++) {
          const mid = (lo + hi) / 2;
          if (crossing(prev, mid, t) > increment) lo = mid; else hi = mid;
        }
        w = (lo + hi) / 2;
      }
      result.push(increment <= 0 ? Infinity : w / Math.sqrt(t));
      prev = propagate(prev, w, t, spent);
    }
    return result;
  };
  const pct = (value, digits = 1) => Number.isFinite(value) ? `${(value * 100).toFixed(digits)} %` : "–";
  const signedPct = (value, digits = 1) => Number.isFinite(value) ? `${value >= 0 ? "+" : "−"}${Math.abs(value * 100).toFixed(digits)} %` : "–";
  const toNumber = (value, fallback) => {
    const parsed = Number(value);
    return Number.isFinite(parsed) ? parsed : fallback;
  };
  const NumberField = ({label, value, onChange, min, max, step, suffix}) => <label className="pgw-field">
      <span className="pgw-label">
        {label}
        {suffix ? <span> ({suffix})</span> : null}
      </span>
      <input type="number" inputMode="decimal" value={value} min={min} max={max} step={step} onChange={event => onChange(event.target.value)} />
    </label>;
  const SelectField = ({label, value, onChange, options}) => <label className="pgw-field">
      <span className="pgw-label">{label}</span>
      <select value={value} onChange={event => onChange(event.target.value)}>
        {options.map(option => <option key={option.value} value={option.value}>
            {option.label}
          </option>)}
      </select>
    </label>;
  const Stat = ({label, value, color}) => <div className="pgw-stat">
      <div className="pgw-stat-label">{label}</div>
      <div className="pgw-stat-value" style={color ? {
    color
  } : undefined}>{value}</div>
    </div>;
  const mulberry32 = seed => () => {
    seed |= 0;
    seed = seed + 0x6d2b79f5 | 0;
    let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
    t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  };
  const gaussian = rng => {
    const u = Math.max(rng(), 1e-12);
    return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * rng());
  };
  const drawConversions = (rng, visitors, rate) => {
    const mean = visitors * rate;
    if (mean < 30 && rate < 0.1) {
      const limit = Math.exp(-mean);
      let k = 0;
      let p = rng();
      while (p > limit) {
        k++;
        p *= rng();
      }
      return k;
    }
    return Math.min(Math.floor(visitors), Math.max(0, Math.round(mean + Math.sqrt(mean * (1 - rate)) * gaussian(rng))));
  };
  const LEVELS = [{
    id: "explore",
    label: "Explore",
    odds: "1 in 7",
    rate: 0.15,
    threshold: 0.93,
    significance: 0.3,
    minDays: 7,
    maxDays: 14,
    minConversions: 30,
    band: 0.15
  }, {
    id: "fast",
    label: "Fast",
    odds: "1 in 10",
    rate: 0.1,
    threshold: 0.96,
    significance: 0.2,
    minDays: 7,
    maxDays: 14,
    minConversions: 50,
    band: 0.1
  }, {
    id: "balanced",
    label: "Balanced",
    odds: "1 in 20",
    rate: 0.05,
    threshold: 0.985,
    significance: 0.1,
    minDays: 7,
    maxDays: 21,
    minConversions: 100,
    band: 0.075
  }, {
    id: "careful",
    label: "Careful",
    odds: "1 in 40",
    rate: 0.025,
    threshold: 0.995,
    significance: 0.05,
    minDays: 7,
    maxDays: 28,
    minConversions: 200,
    band: 0.05
  }, {
    id: "strict",
    label: "Strict",
    odds: "1 in 100",
    rate: 0.01,
    threshold: 0.9975,
    significance: 0.02,
    minDays: 14,
    maxDays: 35,
    minConversions: 400,
    band: 0.03
  }];
  const simulate = options => {
    const {engine, runs, dailyVisitors, rate, trueLift, checksPerDay, minDays, maxDays, minConversions, ropeBand, threshold, prior, priorWidth, significance, correction, oneSided = false, stopLosing = 0.05} = options;
    const rng = mulberry32(1455);
    const slots = Math.round(maxDays * checksPerDay);
    const perSlot = dailyVisitors / checksPerDay;
    const fractions = [];
    for (let s = 1; s <= slots; s++) fractions.push(s / slots);
    const boundaries = engine === "frequentist" && correction ? spendingBoundaries(significance, fractions) : null;
    const fixedZ = normalQuantile(1 - significance / 2);
    const equivalenceLevel = 1 - 2 * significance;
    const counts = {
      win: 0,
      loss: 0,
      equivalent: 0,
      timeout: 0
    };
    const stopDays = {
      win: [],
      loss: [],
      equivalent: [],
      timeout: []
    };
    for (let run = 0; run < runs; run++) {
      let cv = 0;
      let cc = 0;
      let vv = 0;
      let vc = 0;
      let outcome = "timeout";
      let stopDay = maxDays;
      for (let s = 1; s <= slots; s++) {
        cv += perSlot;
        vv += perSlot;
        cc += drawConversions(rng, perSlot, rate);
        vc += drawConversions(rng, perSlot, rate * (1 + trueLift));
        const day = s / checksPerDay;
        const atHorizon = s === slots;
        const enoughData = cc + vc >= minConversions && day >= minDays;
        if (!enoughData && !atHorizon) continue;
        let winSide = 0;
        let equivalent = false;
        if (engine === "bayesian") {
          const evidence = bayesianEvidence(cv, cc, vv, vc, prior, priorWidth);
          if (evidence.chance >= threshold) winSide = 1; else if (evidence.chance <= (oneSided ? stopLosing : 1 - threshold)) winSide = -1;
          equivalent = ropeBand > 0 && evidence.inBand(ropeBand) >= 0.95;
        } else {
          const z = pooledZ(cv, cc, vv, vc);
          const critical = boundaries ? boundaries[s - 1] : fixedZ;
          if (Math.abs(z) >= critical) winSide = z > 0 ? 1 : -1;
          if (ropeBand > 0) {
            const interval = relativeLiftInterval(cv, cc, vv, vc, equivalenceLevel);
            equivalent = interval.lower > -ropeBand && interval.upper < ropeBand;
          }
        }
        if (atHorizon) {
          outcome = enoughData && winSide !== 0 ? winSide > 0 ? "win" : "loss" : "timeout";
          stopDay = day;
          break;
        }
        if (equivalent) {
          outcome = "equivalent";
          stopDay = day;
          break;
        }
        if (winSide !== 0) {
          outcome = winSide > 0 ? "win" : "loss";
          stopDay = day;
          break;
        }
      }
      counts[outcome]++;
      stopDays[outcome].push(stopDay);
    }
    const histogram = [];
    for (let day = 1; day <= Math.ceil(maxDays); day++) histogram.push({
      day,
      win: 0,
      loss: 0,
      equivalent: 0,
      timeout: 0
    });
    for (const key of Object.keys(stopDays)) {
      for (const value of stopDays[key]) {
        const index = Math.min(histogram.length - 1, Math.max(0, Math.ceil(value) - 1));
        histogram[index][key]++;
      }
    }
    const all = [].concat(stopDays.win, stopDays.loss, stopDays.equivalent, stopDays.timeout);
    const averageDays = all.reduce((sum, value) => sum + value, 0) / Math.max(1, all.length);
    return {
      counts,
      runs,
      histogram,
      averageDays
    };
  };
  return {
    COLORS,
    Card,
    Legend,
    normalCdf,
    normalPdf,
    normalQuantile,
    bayesianEvidence,
    spendingBoundaries,
    pct,
    signedPct,
    toNumber,
    NumberField,
    SelectField,
    Stat,
    simulate,
    LEVELS
  };
})();

export const FalseWinnerSimulator = ({engine: initialEngine = "bayesian"}) => {
  const {COLORS, Card, Legend, pct, toNumber, NumberField, SelectField, Stat, simulate, LEVELS} = StatisticsKit;
  const balanced = LEVELS.find(level => level.id === "balanced");
  const [engine, setEngine] = useState(initialEngine);
  const [level, setLevel] = useState("balanced");
  const [visitors, setVisitors] = useState("1000");
  const [rate, setRate] = useState("3");
  const [lift, setLift] = useState("0");
  const [threshold, setThreshold] = useState(String(balanced.threshold * 100));
  const [policy, setPolicy] = useState("one");
  const [stopLosing, setStopLosing] = useState("5");
  const [prior, setPrior] = useState("skeptical");
  const [width, setWidth] = useState("30");
  const [significance, setSignificance] = useState(String(balanced.significance * 100));
  const [correction, setCorrection] = useState("on");
  const [checks, setChecks] = useState("1");
  const [minDays, setMinDays] = useState(String(balanced.minDays));
  const [maxDays, setMaxDays] = useState(String(balanced.maxDays));
  const [minConversions, setMinConversions] = useState(String(balanced.minConversions));
  const [band, setBand] = useState(String(balanced.band * 100));
  const chooseLevel = id => {
    setLevel(id);
    const chosen = LEVELS.find(entry => entry.id === id);
    if (!chosen) return;
    setThreshold(String(chosen.threshold * 100));
    setSignificance(String(chosen.significance * 100));
    setMinDays(String(chosen.minDays));
    setMaxDays(String(chosen.maxDays));
    setMinConversions(String(chosen.minConversions));
    setBand(String(chosen.band * 100));
    setPolicy("one");
    setStopLosing("5");
    setPrior("skeptical");
    setWidth("30");
    setCorrection("on");
  };
  const custom = setter => value => {
    setLevel("custom");
    setter(value);
  };
  const oneSided = engine === "bayesian" && policy === "one";
  const options = {
    engine,
    runs: 1000,
    dailyVisitors: Math.max(10, toNumber(visitors, 1000)),
    rate: Math.min(0.9, Math.max(0.001, toNumber(rate, 3) / 100)),
    trueLift: Math.max(-0.9, toNumber(lift, 0) / 100),
    checksPerDay: Number(checks),
    minDays: Math.max(0, toNumber(minDays, 7)),
    maxDays: Math.min(60, Math.max(1, Math.round(toNumber(maxDays, 21)))),
    minConversions: Math.max(0, toNumber(minConversions, 100)),
    ropeBand: Math.max(0, toNumber(band, 7.5) / 100),
    threshold: Math.min(0.9999, Math.max(0.5, toNumber(threshold, 98.5) / 100)),
    oneSided,
    stopLosing: Math.min(0.49, Math.max(0.001, toNumber(stopLosing, 5) / 100)),
    prior,
    priorWidth: Math.max(0.01, toNumber(width, 30) / 100),
    significance: Math.min(0.5, Math.max(0.001, toNumber(significance, 10) / 100)),
    correction: correction === "on"
  };
  const result = useMemo(() => simulate(options), [JSON.stringify(options)]);
  const share = key => result.counts[key] / result.runs;
  const noEffect = options.trueLift === 0;
  const lossLabel = oneSided ? "Stopped: losing" : noEffect ? "Loss (false loser)" : "Loss";
  const W = 520;
  const H = 180;
  const pad = {
    l: 12,
    r: 12,
    t: 12,
    b: 30
  };
  const maxBar = Math.max(1, ...result.histogram.map(b => b.win + b.loss + b.equivalent + b.timeout));
  const barWidth = (W - pad.l - pad.r) / result.histogram.length;
  const order = [["win", COLORS.win], ["loss", COLORS.loss], ["equivalent", COLORS.neutral], ["timeout", COLORS.timeout]];
  return <Card title="Test simulator" description="Runs 1,000 tests with your settings right in your browser.">
      <div className="pgw-fields">
        <SelectField label="Method" value={engine} onChange={setEngine} options={[{
    value: "bayesian",
    label: "Bayesian"
  }, {
    value: "frequentist",
    label: "Frequentist"
  }]} />
        <SelectField label="Level" value={level} onChange={chooseLevel} options={[...LEVELS.map(entry => ({
    value: entry.id,
    label: `${entry.label} (${entry.odds})`
  })), {
    value: "custom",
    label: "Custom settings"
  }]} />
        <NumberField label="Visitors per variation per day" value={visitors} onChange={setVisitors} min={10} step={100} />
        <NumberField label="Control conversion rate" suffix="%" value={rate} onChange={setRate} min={0.1} max={90} step={0.5} />
        <NumberField label="True lift of the variation" suffix="%" value={lift} onChange={setLift} step={1} />
        {engine === "bayesian" ? <>
            <NumberField label="Chance to beat control threshold" suffix="%" value={threshold} onChange={custom(setThreshold)} min={50} max={99.99} step={0.5} />
            <SelectField label="Decision policy" value={policy} onChange={custom(setPolicy)} options={[{
    value: "one",
    label: "One-sided"
  }, {
    value: "two",
    label: "Two-sided"
  }]} />
            {policy === "one" ? <NumberField label="Stop losing variations at" suffix="%" value={stopLosing} onChange={custom(setStopLosing)} min={0.1} max={49} step={1} /> : null}
            <SelectField label="Prior" value={prior} onChange={custom(setPrior)} options={[{
    value: "skeptical",
    label: "Skeptical"
  }, {
    value: "flat",
    label: "Flat"
  }]} />
            {prior === "skeptical" ? <NumberField label="Prior width" suffix="%" value={width} onChange={custom(setWidth)} min={1} max={500} step={5} /> : null}
          </> : <>
            <NumberField label="Significance level (two-sided)" suffix="%" value={significance} onChange={custom(setSignificance)} min={0.1} max={50} step={0.5} />
            <SelectField label="Sequential correction" value={correction} onChange={custom(setCorrection)} options={[{
    value: "on",
    label: "On"
  }, {
    value: "off",
    label: "Off"
  }]} />
          </>}
        <SelectField label="Automatic checks per day" value={checks} onChange={setChecks} options={[{
    value: "1",
    label: "Once a day"
  }, {
    value: "2",
    label: "Twice a day"
  }, {
    value: "4",
    label: "Every 6 hours"
  }, {
    value: "24",
    label: "Every hour"
  }]} />
        <NumberField label="Minimum runtime" suffix="days" value={minDays} onChange={custom(setMinDays)} min={0} step={1} />
        <NumberField label="Maximum runtime" suffix="days" value={maxDays} onChange={custom(setMaxDays)} min={1} max={60} step={1} />
        <NumberField label="Minimum total conversions" value={minConversions} onChange={custom(setMinConversions)} min={0} step={10} />
        <NumberField label="Relative ROPE band" suffix="% · 0 = off" value={band} onChange={custom(setBand)} min={0} step={0.5} />
      </div>
      <div className="pgw-stats">
        <Stat label={noEffect ? "Win (false winner)" : "Win"} value={pct(share("win"))} color={COLORS.win} />
        <Stat label={lossLabel} value={pct(share("loss"))} color={COLORS.loss} />
        <Stat label="Inconclusive: no meaningful difference" value={pct(share("equivalent"))} />
        <Stat label="Inconclusive at maximum runtime" value={pct(share("timeout"))} />
        <Stat label="Average runtime" value={`${result.averageDays.toFixed(1)} days`} />
      </div>
      <div className="pgw-label" style={{
    marginTop: 20
  }}>Day each simulated test ended</div>
      <svg viewBox={`0 0 ${W} ${H}`} className="pgw-chart" style={{
    marginTop: 8
  }} role="img" aria-label="Day on which simulated tests ended">
        <line x1={pad.l} x2={W - pad.r} y1={H - pad.b} y2={H - pad.b} stroke={COLORS.grid} />
        {result.histogram.map((bucket, index) => {
    let offset = 0;
    return <g key={bucket.day}>
              {order.map(([key, color]) => {
      const height = bucket[key] / maxBar * (H - pad.t - pad.b);
      offset += height;
      return height > 0 ? <rect key={key} x={pad.l + index * barWidth + 1} y={H - pad.b - offset} width={Math.max(1, barWidth - 2)} height={height} fill={color} /> : null;
    })}
              {bucket.day === 1 || bucket.day % 7 === 0 ? <text x={pad.l + (index + 0.5) * barWidth} y={H - 12} textAnchor="middle">
                  {bucket.day}
                </text> : null}
            </g>;
  })}
      </svg>
      <Legend items={[["Win", COLORS.win], [oneSided ? "Stopped: losing" : "Loss", COLORS.loss], ["No meaningful difference", COLORS.neutral], ["Inconclusive at maximum runtime", COLORS.timeout]]} />
      <p className="pgw-note">
        {noEffect ? `With a true lift of 0 %, every win is a false winner.${oneSided ? " Stopped tests are not results, so they add no false losers." : " Every loss is a false loser."} Set a true lift to see how often a real effect is found.` : "With a real lift, the win share is how often the test finds it before its maximum runtime."}{" "}
        The simulation assumes the same traffic every day and one variation. Real traffic changes by weekday and season, so treat the numbers as a guide.
      </p>
    </Card>;
};

export const PriorShrinkageChart = () => {
  const {COLORS, Card, Legend, normalCdf, normalPdf, normalQuantile, bayesianEvidence, spendingBoundaries, pct, signedPct, toNumber, NumberField, SelectField, Stat, simulate, LEVELS} = StatisticsKit;
  const [lift, setLift] = useState("20");
  const [rate, setRate] = useState("3");
  const [width, setWidth] = useState("30");
  const observed = toNumber(lift, 20) / 100;
  const baseRate = Math.min(0.9, Math.max(0.001, toNumber(rate, 3) / 100));
  const priorWidth = Math.max(0.01, toNumber(width, 30) / 100);
  const W = 520;
  const H = 230;
  const pad = {
    l: 48,
    r: 16,
    t: 16,
    b: 46
  };
  const minLog = 2;
  const maxLog = 5.3;
  const xs = [];
  for (let i = 0; i <= 80; i++) xs.push(minLog + (maxLog - minLog) * i / 80);
  const series = xs.map(logN => {
    const n = Math.pow(10, logN);
    const evidence = bayesianEvidence(n, n * baseRate, n, n * baseRate * (1 + observed), "skeptical", priorWidth);
    return {
      logN,
      mean: evidence.mean,
      chance: evidence.chance
    };
  });
  const top = Math.max(observed, 0) * 1.15 || 0.05;
  const bottom = Math.min(observed, 0) * 1.15;
  const x = logN => pad.l + (logN - minLog) / (maxLog - minLog) * (W - pad.l - pad.r);
  const y = value => pad.t + (top - value) / (top - bottom) * (H - pad.t - pad.b);
  const line = series.map(point => `${x(point.logN)},${y(point.mean)}`).join(" L");
  const xTicks = [100, 1000, 10000, 100000];
  return <Card title="How the skeptical prior treats an early lift" description="Compare the raw lift with the lift pagent works with as visitors grow.">
      <div className="pgw-fields">
        <NumberField label="Observed lift" suffix="%" value={lift} onChange={setLift} step={1} />
        <NumberField label="Control conversion rate" suffix="%" value={rate} onChange={setRate} min={0.1} max={90} step={0.5} />
        <NumberField label="Prior width" suffix="%" value={width} onChange={setWidth} min={1} max={500} step={5} />
      </div>
      <svg viewBox={`0 0 ${W} ${H}`} className="pgw-chart" role="img" aria-label="Estimated lift by number of visitors">
        <line x1={pad.l} x2={W - pad.r} y1={y(0)} y2={y(0)} stroke={COLORS.grid} />
        <line x1={pad.l} x2={W - pad.r} y1={y(observed)} y2={y(observed)} stroke={COLORS.neutral} strokeDasharray="5 4" strokeWidth={2} />
        <path d={"M" + line} fill="none" stroke={COLORS.ink} strokeWidth={2.5} />
        {xTicks.map(n => <text key={n} x={x(Math.log10(n))} y={H - 26} fontSize={11} textAnchor="middle" fill={COLORS.muted}>
            {n.toLocaleString("en-US")}
          </text>)}
        <text x={pad.l - 6} y={y(observed) + 4} fontSize={11} textAnchor="end" fill={COLORS.muted}>{signedPct(observed, 0)}</text>
        <text x={pad.l - 6} y={y(0) + 4} fontSize={11} textAnchor="end" fill={COLORS.muted}>0 %</text>
        <text x={(W + pad.l) / 2} y={H - 6} fontSize={11} textAnchor="middle" fill={COLORS.muted}>Visitors per variation</text>
      </svg>
      <Legend items={[["Lift in the raw data", COLORS.neutral, "dashed"], ["Lift pagent works with", COLORS.ink, "line"]]} />
      <p className="pgw-note">
        With few visitors, the prior pulls the lift toward zero. With more visitors, the data takes over.
      </p>
    </Card>;
};

export const ResultCalculator = () => {
  const {COLORS, Card, Legend, normalCdf, normalPdf, normalQuantile, bayesianEvidence, spendingBoundaries, pct, signedPct, toNumber, NumberField, SelectField, Stat, simulate, LEVELS} = StatisticsKit;
  const [cv, setCv] = useState("20000");
  const [cc, setCc] = useState("600");
  const [vv, setVv] = useState("20000");
  const [vc, setVc] = useState("690");
  const [prior, setPrior] = useState("skeptical");
  const [width, setWidth] = useState("30");
  const [threshold, setThreshold] = useState("98.5");
  const [policy, setPolicy] = useState("one");
  const [stopLosing, setStopLosing] = useState("5");
  const [band, setBand] = useState("7.5");
  const controlVisitors = Math.max(1, toNumber(cv, 1));
  const variationVisitors = Math.max(1, toNumber(vv, 1));
  const controlConversions = Math.min(controlVisitors, Math.max(0, toNumber(cc, 0)));
  const variationConversions = Math.min(variationVisitors, Math.max(0, toNumber(vc, 0)));
  const priorWidth = Math.max(0.01, toNumber(width, 30) / 100);
  const bar = Math.min(0.9999, Math.max(0.5, toNumber(threshold, 98.5) / 100));
  const ropeBand = Math.max(0, toNumber(band, 7.5) / 100);
  const losingAt = policy === "one" ? Math.min(0.49, Math.max(0.001, toNumber(stopLosing, 5) / 100)) : 1 - bar;
  const evidence = bayesianEvidence(controlVisitors, controlConversions, variationVisitors, variationConversions, prior, priorWidth);
  const observedLift = controlConversions > 0 ? variationConversions / variationVisitors / (controlConversions / controlVisitors) - 1 : NaN;
  const lower = evidence.mean - 1.959964 * evidence.sd;
  const upper = evidence.mean + 1.959964 * evidence.sd;
  const inRope = evidence.inBand(ropeBand);
  let verdict = "Keep testing";
  let verdictColor = "#252625";
  if (ropeBand > 0 && inRope >= 0.95) {
    verdict = "Inconclusive: no meaningful difference";
  } else if (evidence.chance >= bar) {
    verdict = "Win";
    verdictColor = COLORS.win;
  } else if (evidence.chance <= losingAt) {
    verdict = policy === "one" ? "Stopped: variation was losing (not a result)" : "Loss";
    verdictColor = COLORS.loss;
  }
  const W = 520;
  const H = 180;
  const pad = {
    l: 12,
    r: 12,
    t: 12,
    b: 28
  };
  const spread = Math.max(Math.abs(lower), Math.abs(upper), ropeBand, 0.02) * 1.35;
  const xMin = Math.min(-spread, evidence.mean - 4 * evidence.sd);
  const xMax = Math.max(spread, evidence.mean + 4 * evidence.sd);
  const x = value => pad.l + (value - xMin) / (xMax - xMin) * (W - pad.l - pad.r);
  const peak = normalPdf(0) / evidence.sd;
  const y = density => H - pad.b - density / peak * (H - pad.t - pad.b);
  const points = [];
  for (let i = 0; i <= 160; i++) {
    const value = xMin + (xMax - xMin) * i / 160;
    points.push([value, normalPdf((value - evidence.mean) / evidence.sd) / evidence.sd]);
  }
  const path = filter => {
    const selected = points.filter(([value]) => filter(value));
    if (selected.length < 2) return "";
    const first = selected[0][0];
    const last = selected[selected.length - 1][0];
    return `M${x(first)},${y(0)} ` + selected.map(([value, d]) => `L${x(value)},${y(d)}`).join(" ") + ` L${x(last)},${y(0)} Z`;
  };
  const ticks = [];
  const tickStep = xMax - xMin > 0.6 ? 0.2 : xMax - xMin > 0.25 ? 0.1 : 0.05;
  for (let t = Math.ceil(xMin / tickStep) * tickStep; t <= xMax; t += tickStep) ticks.push(t);
  return <Card title="Results calculator" description="Enter a test's numbers to see what a single check would decide.">
      <div className="pgw-fields">
        <NumberField label="Control visitors" value={cv} onChange={setCv} min={1} step={100} />
        <NumberField label="Control conversions" value={cc} onChange={setCc} min={0} step={1} />
        <NumberField label="Variation visitors" value={vv} onChange={setVv} min={1} step={100} />
        <NumberField label="Variation conversions" value={vc} onChange={setVc} min={0} step={1} />
        <SelectField label="Prior" value={prior} onChange={setPrior} options={[{
    value: "skeptical",
    label: "Skeptical"
  }, {
    value: "flat",
    label: "Flat"
  }]} />
        {prior === "skeptical" ? <NumberField label="Prior width" suffix="%" value={width} onChange={setWidth} min={1} max={500} step={5} /> : null}
        <NumberField label="Threshold" suffix="%" value={threshold} onChange={setThreshold} min={50} max={99.99} step={0.5} />
        <SelectField label="Decision policy" value={policy} onChange={setPolicy} options={[{
    value: "one",
    label: "One-sided"
  }, {
    value: "two",
    label: "Two-sided"
  }]} />
        {policy === "one" ? <NumberField label="Stop losing variations at" suffix="%" value={stopLosing} onChange={setStopLosing} min={0.1} max={49} step={1} /> : null}
        <NumberField label="Relative ROPE band" suffix="%" value={band} onChange={setBand} min={0} step={0.5} />
      </div>
      <div className="pgw-stats">
        <Stat label="Chance to beat control" value={pct(evidence.chance)} color={COLORS.ink} />
        <Stat label="Observed lift" value={signedPct(observedLift)} />
        <Stat label={prior === "skeptical" ? "Lift after the prior" : "Estimated lift"} value={signedPct(evidence.mean)} />
        <Stat label="95 % credible interval" value={`${signedPct(lower)} to ${signedPct(upper)}`} />
        <Stat label="Expected loss if you ship" value={`${(evidence.lossShip * 100).toFixed(3)} pp`} />
        <Stat label="Chance inside the ROPE band" value={pct(inRope)} />
      </div>
      <svg viewBox={`0 0 ${W} ${H}`} className="pgw-chart" role="img" aria-label="Distribution of the lift estimate">
        {ropeBand > 0 ? <rect x={x(-ropeBand)} y={pad.t} width={Math.max(0, x(ropeBand) - x(-ropeBand))} height={H - pad.t - pad.b} fill={COLORS.band} /> : null}
        <path d={path(value => value <= 0)} fill={COLORS.loss} opacity={0.3} />
        <path d={path(value => value >= 0)} fill={COLORS.win} opacity={0.3} />
        <path d={"M" + points.map(([value, d]) => `${x(value)},${y(d)}`).join(" L")} fill="none" stroke={COLORS.ink} strokeWidth={2} />
        <line x1={x(0)} x2={x(0)} y1={pad.t} y2={H - pad.b} stroke={COLORS.ink} strokeDasharray="4 3" />
        <line x1={pad.l} x2={W - pad.r} y1={H - pad.b} y2={H - pad.b} stroke={COLORS.grid} />
        {ticks.map(t => <text key={t.toFixed(3)} x={x(t)} y={H - 8} fontSize={11} textAnchor="middle" fill={COLORS.muted}>
            {signedPct(t, 0)}
          </text>)}
      </svg>
      <Legend items={[["Variation better", COLORS.win], ["Variation worse", COLORS.loss], ["ROPE band", COLORS.band]]} />
      <div className="pgw-verdict" style={{
    color: verdictColor
  }}>
        At a check: {verdict}
      </div>
      <p className="pgw-note">
        A check only decides once the test has the minimum conversions and runtime. This calculator uses a close approximation; the
        results page shows pagent's exact values.
      </p>
    </Card>;
};

The Bayesian engine is pagent's default. It answers the question most people actually ask: **how likely is it that the variation is better than the original?**

## The numbers you see

| Number | What it means in plain words |
| - | - |
| **Chance to beat control** | How likely it is, given the data so far, that the variation converts better than control. 94 % means: in 94 of 100 cases like this, the variation really is better. |
| **Lift** | How much better or worse the variation converts, relative to control. +8 % at a 3 % conversion rate means about 3.24 %. |
| **95 % credible interval** | The range the true lift lies in with 95 % probability. A narrow range means pagent is sure about the size of the effect. |
| **Expected loss** | How much conversion rate you would lose on average if you shipped the variation and it was in fact worse. Measured in percentage points (pp). |

## How a result is called

At each [scheduled check](/docs/statistics/overview#when-pagent-checks-a-test), after the [shared rules](/docs/statistics/overview#the-order-of-the-checks) for data and runtime pass. The numbers are the Balanced level:

1. **Practical equivalence.** If [ROPE](/docs/statistics/settings#practical-equivalence-rope) is on and there is at least a 95 % chance that the true lift is inside the band (±7.5 %), the test stops as **inconclusive**.
2. **Winner.** If the chance to beat control reaches the [threshold](/docs/statistics/settings#chance-to-beat-control-threshold) (98.5 %), the variation **wins**.
3. **Losing variation.** If the chance falls to the [stop losing threshold](/docs/statistics/settings#stop-losing-variations-at) (5 %) or below, the test stops as **Stopped: variation was losing**. This protects your conversions but is not counted as a result.
4. **Expected loss (Careful and Strict).** If the [expected loss check](/docs/statistics/settings#enable-expected-loss-threshold) is on, the test only stops when the expected loss of the leading side is below the [expected loss threshold](/docs/statistics/settings#expected-loss-threshold).
5. Otherwise the test keeps running.

This is the **one-sided** [decision policy](/docs/statistics/settings#decision-policy), the Bayesian default. Only a winner is a statistical result. Under the **two-sided** policy, a variation whose chance falls to 100 % minus the threshold (1.5 % at Balanced) is a **Loss**, and both directions count as results.

Try it with your own numbers. The calculator shows what a single check would decide once the minimum data and runtime are met:

<ResultCalculator />

## Priors

Before a test has data, pagent has to start from some assumption. That starting point is the **prior**.

### Skeptical prior (default)

Most changes to a website move conversion by a few percent at most. A +40 % lift on day two is almost always noise. The skeptical prior builds this in: it starts from "this change probably does little" and pulls early, extreme lifts toward zero.

* With little data, the pull is strong. A big early jump counts for less.
* With more data, the pull fades. The data decides.

The [prior width](/docs/statistics/settings#prior-width) (30 % in every level) sets how strong the pull is. At 30 %, pagent expects about two thirds of true lifts to fall between −30 % and +30 %. A smaller width pulls harder.

See how the lift pagent works with approaches the raw lift as visitors grow:

<PriorShrinkageChart />

### Flat prior

The flat prior assumes nothing. Every conversion rate between 0 % and 100 % is equally likely before the test starts, so the chance to beat control follows the raw data from the first visitor on.

Websites that existed before October 2026 were kept on the flat prior when the skeptical prior was introduced. Choosing a level switches them to the skeptical prior.

## How often a false winner is called

pagent checks a running test many times, by default once a day. Every check is another chance for noise to cross the threshold, so a single threshold does not tell you how often a whole test ends with a false winner. That is why the levels use high thresholds: each was set with simulations of tests where the variation does nothing, checked daily, at 100 to 50,000 visitors per variation per day, so that even the worst traffic stays within the level's promise.

Results of 20,000 simulated tests per cell with each level's Bayesian settings, a 3 % conversion rate and one check a day:

| Level (promise) | False winners at 300 / 3,000 / 30,000 visitors a day | Real +5 % lift found | Real +10 % lift found |
| - | - | - | - |
| Explore (1 in 7) | 5.1 % / 8.2 % / 0.0 % | 11 % / 33 % / 0 % | 19 % / 69 % / 16 % |
| Fast (1 in 10) | 1.5 % / 7.4 % / 0.0 % | 4 % / 39 % / 14 % | 9 % / 81 % / 95 % |
| Balanced (1 in 20) | 0.6 % / 3.7 % / 0.7 % | 2 % / 34 % / 60 % | 6 % / 84 % / 100 % |
| Careful (1 in 40) | 0.1 % / 1.5 % / 1.1 % | 1 % / 27 % / 89 % | 3 % / 85 % / 100 % |
| Strict (1 in 100) | 0.1 % / 0.7 % / 0.9 % | 1 % / 25 % / 100 % | 3 % / 89 % / 100 % |

Read the found columns left to right as 300, 3,000 and 30,000 visitors per variation per day.

* **Every level keeps its promise** at every traffic level.
* **Low traffic finds little.** At 300 visitors a day, a 5 % lift is rarely found within the runtime at any level. Test bolder changes, or pages with more traffic.
* **Fast levels skip small lifts at high traffic.** Their wide no-difference band (±15 % for Explore, ±10 % for Fast) ends tests with a real but small lift as "no meaningful difference". If lifts around 5 % matter, use Balanced or stricter.
* **About 1 in 7 tests that change nothing stop as losing** at Balanced and 3,000 visitors a day. These are not results, so they add no false losers.

Run your own scenario. Pick a level, set the true lift to 0 % to count false winners, or to a real lift to see how often it is found and how fast:

<FalseWinnerSimulator engine="bayesian" />

## Expected loss

The chance to beat control tells you how likely the variation is better. It does not tell you how much is at stake if it is not. Expected loss covers that: it is the average conversion rate you give up by shipping the variation, counting only the cases where it is actually worse.

Example: control converts at 3.0 %. An expected loss of 0.02 pp means that shipping the variation costs, on average, 3.0 % → 2.98 % in the unlucky cases. That is a small risk.

At Explore, Fast and Balanced, expected loss is shown but does not stop tests. Careful and Strict require it to be small, 0.1 pp and 0.05 pp, before a test stops. You can turn the check on for any setup with [Enable expected loss threshold](/docs/statistics/settings#enable-expected-loss-threshold).

## Sequential correction (deprecated)

Older websites may still have the Bayesian **Sequential correction** on. It raises the threshold at early checks, following the same O'Brien-Fleming schedule as the [frequentist correction](/docs/statistics/frequentist#sequential-correction), and the results page then shows the raised threshold.

It is being retired. You can switch it off, but not back on, and tests are two-sided while it is on. Choosing a level switches it off.

## What the Bayesian engine does not do

* It has no p-value. The chance to beat control is not 1 minus a p-value.
* It has no exact false-winner rate for a custom threshold. The levels' promises come from simulation; for an exact rate, use the [frequentist engine](/docs/statistics/frequentist).
* It has no power setting. The [maximum runtime](/docs/statistics/settings#maximum-runtime-days) sets the end of a test.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.