File size: 44,882 Bytes
48d8e79 1ab750f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 | <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="description" content="Offline A/B testing, randomized experiment analysis, SRM diagnostics and statistical power planning.">
<title>ExperimentForge | Experiment Intelligence Studio</title>
<style>
:root{--bg:#0b111c;--panel:#131f30;--panel2:#101a2a;--stroke:#273a51;--stroke2:#31435a;--text:#e9f1fc;--soft:#92a4bd;--mint:#36c9ae;--violet:#a997ff;--blue:#81abda;--amber:#f5bd6d;--red:#f68e9c}*{box-sizing:border-box}html{font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:var(--bg);color:var(--text)}body{margin:0;font-size:13px;line-height:1.5}button,input,select{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--mint);outline-offset:2px}p{margin:0 0 12px}a{color:var(--mint)}.shell{display:grid;grid-template-columns:244px minmax(0,1fr);min-height:100vh}.sidebar{background:#0d1725;border-right:1px solid #223246;display:flex;flex-direction:column;padding:27px 17px 22px;min-height:100vh;position:sticky;top:0;max-height:100vh}.brand{display:flex;align-items:center;gap:10px;padding:0 7px 35px}.brandicon{width:38px;height:38px;border-radius:12px;background:linear-gradient(145deg,#4bdac5,#327baa);display:grid;place-items:center;color:#052329;font-weight:900;letter-spacing:-1px;font-size:14px;box-shadow:0 5px 18px #1db09f21}.brand strong{display:block;font-size:15px;letter-spacing:-.4px}.brand small{display:block;color:#7c92ae;font-size:10px;letter-spacing:.04em}.navlabel,.tinyeyebrow{font-size:10px;font-weight:800;letter-spacing:.16em;color:#6e87a6}.navlabel{padding:0 13px;margin-bottom:14px}.sidebar nav{display:grid;gap:5px}.navitem{width:100%;display:flex;align-items:center;gap:12px;padding:12px 13px;background:none;border:1px solid transparent;border-radius:9px;text-align:left;color:#a2b2c9;font-weight:650;font-size:12px}.navitem span{font-size:19px;min-width:18px;line-height:1}.navitem.active{color:#c6fff3;background:#193a42;border-color:#246c68}.navitem:not(.active):hover{background:#172538;color:#fff}.swipehint{display:none}.sidebox{margin-top:auto;background:linear-gradient(150deg,#183449,#132638);border:1px solid #2c4f61;border-radius:13px;padding:18px;margin-bottom:20px}.sidebox b{display:block;font-size:13px;margin:8px 0}.sidebox p{font-size:11px;color:#b2bfd0;margin:0}.sidebarfoot{color:#8295b0;font-size:10px;display:flex;gap:8px;align-items:center}.online{display:inline-block;width:7px;height:7px;border-radius:100%;background:#42d5a9;box-shadow:0 0 9px #42d5a977}.main{min-width:0;background:radial-gradient(ellipse 64% 45% at 90% 1%,#16293b 0%,transparent 90%),#0c1421}.topbar{height:65px;padding:0 40px;border-bottom:1px solid #253548;display:flex;align-items:center;justify-content:space-between}.breadcrumbs{font-size:10px;letter-spacing:.09em;color:#8195b0}.breadcrumbs b{color:#c1d0e0}.topright{display:flex;align-items:center;gap:20px}.tag{border:1px solid #285b56;border-radius:40px;color:#5ee0ba;padding:5px 10px;font-size:9px;font-weight:800;letter-spacing:.12em}.avatar{display:grid;place-items:center;width:29px;height:29px;border-radius:100%;background:#253d55;border:1px solid #49667e;color:#c7def5;font-size:10px;font-weight:900}.content{padding:31px 39px 32px;max-width:1510px;margin:auto}.hero{min-height:190px;background:linear-gradient(125deg,#172d42 0%,#18313f 50%,#133f45 100%);border:1px solid #2c485d;border-radius:16px;display:flex;overflow:hidden;justify-content:space-between;position:relative;margin-bottom:19px}.heroleft{padding:32px 34px;z-index:2}.eyebrow{font-size:10px;letter-spacing:.17em;color:#61dbc3;font-weight:800;margin-bottom:11px}.hero h1{font-size:33px;line-height:1.1;letter-spacing:-1.4px;margin:0 0 13px}.hero p{font-size:12px;max-width:550px;color:#bbcede;margin:0}.heroart{width:31%;display:flex;align-items:center;justify-content:center;flex-direction:column;position:relative;background:radial-gradient(ellipse at center,#2678796b,transparent 68%);gap:7px}.heroartgrid{position:absolute;inset:0;background-image:linear-gradient(#8fc4c410 1px,transparent 1px),linear-gradient(90deg,#8fc4c410 1px,transparent 1px);background-size:23px 23px;mask-image:radial-gradient(circle,#000,transparent 90%)}.bignum{font-weight:900;color:#78eac9;font-size:45px;letter-spacing:-4px;position:relative}.bignum span{color:#d9e3f3}.heroart>span{font-size:10px;letter-spacing:.25em;color:#a7d1d1;position:relative}.toolbar{display:flex;flex-wrap:wrap;gap:12px;margin-bottom:20px;align-items:end}.toolbar label{font-size:10px;color:#91a4bc;display:flex;flex-direction:column;gap:5px;flex:1;min-width:140px;max-width:250px}.toolbar select,.controls2 select{min-width:0;width:100%;color:#dce9fa;background:#162638;border:1px solid #314861;border-radius:8px;padding:9px 10px;outline-offset:2px}.toolbarbutton{min-width:116px}.btn{border-radius:8px;border:1px solid transparent;padding:10px 13px;font-weight:750;white-space:nowrap;font-size:11px}.btn.primary{background:var(--mint);color:#08302e}.btn.primary:hover{background:#63e0c7}.btn.outline{background:#1b2a3c;color:#d8e9fc;border-color:#344a60}.btn.outline:hover{background:#263b51}.btn span{margin-left:13px}.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:18px}.stats.three{grid-template-columns:repeat(3,minmax(0,1fr))}.stat{background:var(--panel);border:1px solid var(--stroke);border-radius:12px;padding:19px 19px 16px;min-width:0}.stat p{font-size:10px;color:#95a8c0;margin:0 0 10px}.stat strong{font-size:27px;line-height:1.3;letter-spacing:-1px;color:#f0f6ff;display:block;overflow-wrap:anywhere}.stat small{font-size:10px;color:#859ab3;display:block;margin-top:8px}.stat.accent strong{color:var(--violet)}.stat.positive strong{color:var(--mint)}.stat.danger strong{color:var(--red)}.split{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:17px;margin-bottom:18px}.split.wideleft{grid-template-columns:minmax(0,1.35fr) minmax(0,1fr)}.panel{border:1px solid var(--stroke);border-radius:13px;background:#131f30;padding:22px;margin-bottom:18px;min-width:0}.split .panel{margin-bottom:0}.panelhead{display:flex;align-items:center;justify-content:space-between;margin-bottom:18px}.panel h3{font-size:14px;font-weight:800;margin:0;letter-spacing:-.3px}.bars{display:grid;gap:24px;padding:8px 1px}.barrow{display:grid;grid-template-columns:106px minmax(100px,1fr) 58px 94px;gap:14px;align-items:center}.barrow .bname{font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.round{display:inline-block;width:8px;height:8px;margin-right:8px;border-radius:50%;vertical-align:middle}.track{height:9px;border-radius:6px;background:#253b52;overflow:hidden}.trackfill{height:100%;border-radius:6px}.barrow b{text-align:right;color:#e1f4fc;font-size:12px}.muted,.hint{color:#91a4bc}.barrow .muted{font-size:10px;text-align:right}.hint{font-size:10px;line-height:1.6;margin:16px 0 0}.readyrow{display:flex;align-items:center;justify-content:space-between;gap:10px;min-height:36px;border-bottom:1px solid #283a50}.readyrow:last-child{border:none}.readyrow>span{color:#bdcbda;font-size:11px}.pill{display:inline-flex;align-items:center;border-radius:25px;padding:4px 9px;font-size:10px;font-weight:800;letter-spacing:.01em;white-space:nowrap}.pill.good{color:#75e9c9;background:#17433d}.pill.bad{color:#ffa6b0;background:#543036}.pill.warn{color:#f4c982;background:#503b2c}.pill.neutral{color:#becce0;background:#28384d}.stack{display:grid;gap:14px}.insight{padding:12px;background:#18283b;border:1px solid #2b3d52;border-radius:10px}.insight>b{display:block;font-size:12px}.insight p{margin:8px 0 0;color:#9eb0c7;font-size:11px}.forestset{display:grid;gap:22px;padding:10px 3px 4px}.forestrow{display:grid;grid-template-columns:185px 1fr 74px;gap:14px;align-items:center}.forestlabel b{display:block;font-size:11px}.forestlabel small{font-size:9px;color:#8799b0;line-height:1.3;display:block;margin-top:4px}.forest{height:22px;position:relative}.forest .zero{position:absolute;top:-5px;bottom:-5px;left:50%;border-left:1px dashed #6f86a0}.interval{position:absolute;background:#6ed8ba;top:9px;height:4px;border-radius:4px}.point{position:absolute;top:5px;width:12px;height:12px;border-radius:50%;background:#b2f8de;border:3px solid #29715f;transform:translateX(-50%)}.effect{text-align:right;color:#80e6c8;font-weight:800;font-size:11px}.forestaxes{display:flex;justify-content:space-between;font-size:10px;color:#8398b0;padding-left:199px;padding-right:78px;margin-top:-12px}.tablewrap{overflow:auto;max-width:100%}table{border-collapse:collapse;width:100%;min-width:510px}th{text-align:left;color:#93a5bd;text-transform:uppercase;font-size:9px;letter-spacing:.09em;font-weight:800;padding:12px 11px;border-bottom:1px solid #33465b;white-space:nowrap}td{padding:12px 11px;border-bottom:1px solid #26374a;color:#c6d5e8;font-size:11px}tr:last-child td{border-bottom:0}td b{color:#e7f3ff}td .pill{font-size:9px}.hr{height:1px;background:#304359;margin:20px 0}.mint{color:#7fe7c7}.rose{color:#f5a5b3}.controls2{display:grid;gap:19px}.controls2 label{display:grid;gap:10px;color:#aebfd2}.controls2 label b{color:#e7f9fc}.controls2 input{width:100%;accent-color:var(--mint)}.controls2 select{max-width:190px}.upload{display:flex;align-items:center;gap:14px;flex-wrap:wrap;background:#15283a;border:1px dashed #4a6279;border-radius:12px;padding:18px}.uploadlabel{font-size:12px;font-weight:800}.uploadlabel span{display:block;color:#91a6bd;font-size:10px;margin-top:3px;font-weight:400}input[type=file]{color:#b8cadc;font-size:11px;width:260px;max-width:100%}input[type=file]::file-selector-button{background:#314960;border:none;color:#fff;border-radius:5px;padding:7px;margin-right:10px}.downloadgrid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.downloadgrid .btn{white-space:normal;text-align:left}.alert{background:#23433e;color:#bbf2dd;padding:12px 15px;border:1px solid #327a69;border-radius:9px;display:flex;justify-content:space-between;gap:15px;margin-bottom:17px}.alert button{background:none;border:none;color:white;font-size:20px}.footer{font-size:10px;color:#6f829d;border-top:1px solid #2b394d;padding-top:20px;margin-top:30px}.footer span{margin:0 7px}.fatal{padding:40px;color:#ffabb6}@media(max-width:1180px){.shell{grid-template-columns:205px minmax(0,1fr)}.sidebar{padding:22px 12px}.content{padding:26px 22px}.topbar{padding:0 22px}.barrow{grid-template-columns:90px 1fr 58px;gap:10px}.barrow>.muted{display:none}.forestrow{grid-template-columns:132px 1fr 67px}.forestaxes{padding-left:146px;padding-right:72px}.stat strong{font-size:22px}}@media(max-width:850px){.shell{display:block}.sidebar{position:relative;max-height:none;min-height:0;padding:13px 17px;border-right:0;border-bottom:1px solid #24364c}.brand{padding:0 3px 13px}.sidebar .navlabel,.sidebox,.sidebarfoot{display:none}.sidebar nav{display:flex;overflow-x:auto;gap:7px;scrollbar-width:thin;padding-bottom:2px}.navitem{flex:0 0 auto;font-size:11px;padding:8px 10px;white-space:nowrap}.navitem span{font-size:14px;min-width:10px}.swipehint{display:block;text-align:right;font-size:9px;padding-top:3px;color:#7991a9}.topbar{height:45px}.content{padding:19px 16px}.hero{min-height:162px}.hero h1{font-size:27px}.heroleft{padding:22px}.heroart{width:27%}.bignum{font-size:30px}.stats{grid-template-columns:repeat(2,minmax(0,1fr))}.split,.split.wideleft{grid-template-columns:1fr}.toolbar label{min-width:145px;max-width:none}.forestrow{grid-template-columns:130px 1fr 60px}.forestaxes{padding-left:143px;padding-right:67px}}@media(max-width:520px){.topbar{padding:0 15px}.breadcrumbs{font-size:9px}.tag{display:none}.content{padding:15px 12px}.hero{min-height:150px}.hero h1{font-size:24px}.heroleft{padding:19px 17px}.hero p{font-size:11px}.heroart{display:none}.toolbar{gap:8px}.toolbar label{min-width:calc(50% - 8px);font-size:9px}.toolbar select{font-size:11px;padding:8px}.toolbarbutton{width:100%}.panel{padding:15px}.stat{padding:15px 12px}.stat strong{font-size:21px}.stats{gap:9px}.stats.three{grid-template-columns:repeat(2,minmax(0,1fr))}.barrow{grid-template-columns:80px 1fr 55px;gap:8px}.barrow .bname,.barrow b{font-size:10px}.forestrow{grid-template-columns:94px 1fr 59px;gap:7px}.forestlabel b{font-size:10px}.forestlabel small{display:none}.forestaxes{padding-left:101px;padding-right:66px}.downloadgrid{grid-template-columns:1fr}.footer{line-height:1.8}}
</style>
</head><body><div id="app"><p style="padding:30px">Loading ExperimentForge…</p></div><script>
'use strict';
const VARIANTS=['A','B','C'];
const COLORS={A:'#7e9cc1',B:'#36c9ae',C:'#a997ff'};
const DATASETS={
commerce:{name:'Commerce checkout',sector:'Digital commerce',description:'Checkout conversion across device and audience cohorts',allocation:[.5,.25,.25],cells:[
['Mobile','A',3400,354],['Mobile','B',1700,203],['Mobile','C',1700,187],
['Desktop','A',2600,346],['Desktop','B',1300,194],['Desktop','C',1300,177],
['Returning','A',1600,256],['Returning','B',800,143],['Returning','C',800,133]]},
education:{name:'Student outreach',sector:'Education',description:'Completion of an onboarding milestone after three email variants',allocation:[.5,.25,.25],cells:[
['First-year','A',2400,732],['First-year','B',1200,419],['First-year','C',1200,394],
['Continuing','A',1800,731],['Continuing','B',900,401],['Continuing','C',900,346],
['Remote','A',1000,261],['Remote','B',500,159],['Remote','C',500,144]]},
service:{name:'Customer activation',sector:'Operations',description:'Early activation rates under three service journey designs',allocation:[.5,.25,.25],cells:[
['SMB','A',3200,612],['SMB','B',1600,344],['SMB','C',1600,318],
['Mid-market','A',2200,589],['Mid-market','B',1100,319],['Mid-market','C',1100,302],
['Enterprise','A',1200,426],['Enterprise','B',600,225],['Enterprise','C',600,207]]}
};
function assert(flag,msg){if(!flag)throw Error(msg)}
function validateRows(rows){
assert(Array.isArray(rows)&&rows.length>0&&rows.length<=200,'Provide between 1 and 200 rows.');
const seen=new Set(), result=[];
for(const [i,r] of rows.entries()){
const segment=String(r.segment??'').trim(),variant=String(r.variant??'').trim().toUpperCase();
const visitors=Number(r.visitors), conversions=Number(r.conversions);
assert(segment.length>0&&segment.length<=60,`Row ${i+1}: invalid segment.`);
assert(VARIANTS.includes(variant),`Row ${i+1}: variant must be A, B or C.`);
assert(Number.isSafeInteger(visitors)&&visitors>0&&visitors<=10000000,`Row ${i+1}: invalid visitors.`);
assert(Number.isSafeInteger(conversions)&&conversions>=0&&conversions<=visitors,`Row ${i+1}: conversions must be between 0 and visitors.`);
const key=segment.toLowerCase()+'\u0000'+variant;
assert(!seen.has(key),`Row ${i+1}: duplicate segment / variant.`); seen.add(key);
result.push({segment,variant,visitors,conversions});
}
const segments=[...new Set(result.map(r=>r.segment))];
assert(segments.length<=30,'No more than 30 segments.');
for(const s of segments){for(const v of VARIANTS){assert(result.some(r=>r.segment===s&&r.variant===v),`Missing ${v} in segment "${s}".`)}}
return result;
}
function fromPreset(name){const p=DATASETS[name];assert(!!p,'Unknown demo.');return validateRows(p.cells.map(([segment,variant,visitors,conversions])=>({segment,variant,visitors,conversions})));}
function grouped(rows,segment='All segments'){
const filtered=rows.filter(r=>segment==='All segments'||r.segment===segment);
return VARIANTS.map(variant=>filtered.filter(r=>r.variant===variant).reduce((a,r)=>({variant,visitors:a.visitors+r.visitors,conversions:a.conversions+r.conversions}),{variant,visitors:0,conversions:0}));
}
function erf(x){const sign=x<0?-1:1; x=Math.abs(x);const t=1/(1+.3275911*x);const y=1-(((((1.061405429*t-1.453152027)*t)+1.421413741)*t-.284496736)*t+.254829592)*t*Math.exp(-x*x);return sign*y;}
function normalCDF(z){return .5*(1+erf(z/Math.sqrt(2)));}
function wilson(k,n,z=1.959963984540054){
if(n<=0)return [NaN,NaN];const p=k/n,d=1+z*z/n,mid=(p+z*z/(2*n))/d;
const spread=z*Math.sqrt(p*(1-p)/n+z*z/(4*n*n))/d;return [Math.max(0,mid-spread),Math.min(1,mid+spread)];
}
function compare(control,treat,alpha=.05){
const p0=control.conversions/control.visitors,p1=treat.conversions/treat.visitors,diff=p1-p0;
const pooled=(control.conversions+treat.conversions)/(control.visitors+treat.visitors);
const se=Math.sqrt(pooled*(1-pooled)*(1/control.visitors+1/treat.visitors));
const z=se===0?0:diff/se;
const pvalue=se===0?(diff===0?1:0):Math.min(1,2*(1-normalCDF(Math.abs(z))));
const zcrit=alpha===.1?1.6448536269514722:alpha===.01?2.5758293035489004:1.959963984540054;
const [l0,u0]=wilson(control.conversions,control.visitors,zcrit),[l1,u1]=wilson(treat.conversions,treat.visitors,zcrit);
const low=diff-Math.sqrt((p1-l1)**2+(u0-p0)**2),high=diff+Math.sqrt((u1-p1)**2+(p0-l0)**2);
return {variant:treat.variant,n:treat.visitors,rate:p1,baseline:p0,diff,low,high,pvalue,z,lift:p0>0?diff/p0:null};
}
function holm(comparisons,alpha=.05){
const sorted=comparisons.map((c,i)=>({...c,idx:i})).sort((a,b)=>a.pvalue-b.pvalue);
let previous=0;const adjusted=[];
for(let i=0;i<sorted.length;i++){const c=sorted[i];previous=Math.max(previous,Math.min(1,(sorted.length-i)*c.pvalue));adjusted[c.idx]=previous;}
return comparisons.map((c,i)=>({...c,pAdjusted:adjusted[i],significant:adjusted[i]<alpha}));
}
function chi2SRM(totals,allocation){
const n=totals.reduce((acc,t)=>acc+t.visitors,0);
const fractions=allocation||[.5,.25,.25];assert(Math.abs(fractions.reduce((a,b)=>a+b,0)-1)<1e-8,'Allocation must sum to one.');
const chi2=totals.reduce((s,t,i)=>{const exp=n*fractions[i];return s+(t.visitors-exp)**2/exp},0);
return {chi2,pvalue:Math.exp(-chi2/2),observed:totals.map(x=>x.visitors),expected:fractions.map(f=>Math.round(f*n)),n}; // df=2 for three variants
}
function report(rows,segment='All segments',alpha=.05,allocation=[.5,.25,.25]){
const totals=grouped(rows,segment);assert(totals.every(t=>t.visitors>0),'All variants require a positive audience.');
const comparisons=holm([compare(totals[0],totals[1],alpha),compare(totals[0],totals[2],alpha)],alpha);
return {totals,comparisons,srm:chi2SRM(totals,allocation),segments:[...new Set(rows.map(r=>r.segment))],alpha,segment};
}
// Acklam's rational approximation of the standard-normal inverse CDF.
function normalQuantile(p){
assert(p>0&&p<1,'Normal quantile probability outside (0,1).');
const a=[-39.69683028665376,220.9460984245205,-275.9285104469687,138.357751867269,-30.66479806614716,2.506628277459239],
b=[-54.47609879822406,161.5858368580409,-155.6989798598866,66.80131188771972,-13.28068155288572],
c=[-.007784894002430293,-.3223964580411365,-2.400758277161838,-2.549732539343734,4.374664141464968,2.938163982698783],
d=[.007784695709041462,.3224671290700398,2.445134137142996,3.754408661907416];
let q,r;
if(p<.02425){q=Math.sqrt(-2*Math.log(p));return (((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5])/((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1)}
if(p>.97575){q=Math.sqrt(-2*Math.log(1-p));return -(((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5])/((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1)}
q=p-.5;r=q*q;
return (((((a[0]*r+a[1])*r+a[2])*r+a[3])*r+a[4])*r+a[5])*q/(((((b[0]*r+b[1])*r+b[2])*r+b[3])*r+b[4])*r+1);
}
function sampleSize(p0,relativeMde,power=.8,alpha=.05,comparisons=2){
assert(p0>.005&&p0<.8,'Baseline must be between 0.5% and 80%.');
assert(relativeMde>.005&&relativeMde<=1,'Relative MDE must be between 0.5% and 100%.');
const p1=p0*(1+relativeMde);assert(p1<1,'Target rate cannot exceed 100%.');
const effectiveAlpha=alpha/comparisons;
// sufficient quantiles for alpha .05: two comparisons -> z(0.9875) = 2.2414027276; one -> 1.95996398
const zalpha=normalQuantile(1-effectiveAlpha/2);
const zpower=normalQuantile(power);
const m=(p0+p1)/2;
const numerator=(zalpha*Math.sqrt(2*m*(1-m))+zpower*Math.sqrt(p0*(1-p0)+p1*(1-p1)))**2;
return {perArm:Math.ceil(numerator/(p1-p0)**2),p0,p1,power,alpha,effectiveAlpha};
}
function parseCSV(text){
text=String(text).replace(/^\uFEFF/,'');assert(text.length<=1000000,'CSV must be under 1 MB.');
const rows=[],row=[];let field='',quote=false,afterQuote=false;
for(let i=0;i<text.length;i++){
const c=text[i];
if(quote){if(c==='"'&&text[i+1]==='"'){field+='"';i++;}else if(c==='"'){quote=false;afterQuote=true;}else field+=c;continue;}
if(afterQuote){if(c===','){row.push(field);field='';afterQuote=false;continue;}if(c==='\n'||c==='\r'){row.push(field);rows.push([...row]);row.length=0;field='';afterQuote=false;if(c==='\r'&&text[i+1]==='\n')i++;continue;}if(c===' '||c==='\t')continue;throw Error('Unexpected character after closing quote.');}
if(c==='"'){if(field.trim()!=='')throw Error('Unexpected quote in unquoted field.');quote=true;continue;}
if(c===','){row.push(field);field='';continue;}
if(c==='\n'||c==='\r'){row.push(field);rows.push([...row]);row.length=0;field='';if(c==='\r'&&text[i+1]==='\n')i++;continue;}
field+=c;
}
assert(!quote,'Unclosed CSV quote.');if(afterQuote||row.length||field.trim()) {row.push(field);rows.push([...row]);}
return rows.filter(r=>r.some(s=>s.trim()));
}
function importCSV(text){
const grid=parseCSV(text);assert(grid.length>1,'CSV needs a header and data.');
const h=grid.shift().map(x=>x.trim().toLowerCase());
for(const col of ['segment','variant','visitors','conversions'])assert(h.includes(col),'Missing column: '+col);
const rows=grid.map((r,i)=>{assert(r.length===h.length,`CSV line ${i+2}: wrong number of fields.`);const get=key=>r[h.indexOf(key)].trim();return {segment:get('segment'),variant:get('variant'),visitors:get('visitors'),conversions:get('conversions')}});
return validateRows(rows);
}
function csvCell(value){let s=String(value??'');if(/^[=+@\-\t\r]/.test(s))s="'"+s;return /[",\n\r]/.test(s)?'"'+s.replace(/"/g,'""')+'"':s;}
function exportCSV(rows,columns){return [columns.join(','),...rows.map(r=>columns.map(k=>typeof r[k]==='number'?String(r[k]):csvCell(r[k])).join(','))].join('\r\n')+'\r\n';}
function fmtPct(x,dp=1){return (x*100).toFixed(dp)+'%'}
function fmtNum(x){return Math.round(x).toLocaleString('en-US')}
'use strict';
const state={page:'Overview',scenario:'commerce',rows:fromPreset('commerce'),segment:'All segments',alpha:.05,allocation:'50/25/25',baseline:12,mde:12,power:.8,activeVariant:'B',message:'',reportReady:false};
const PAGES=[['Overview','◈'],['Variant benchmark','▤'],['Segment explorer','◉'],['Quality & SRM','◇'],['Power planner','⚑'],['Data & reports','⇩']];
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const moneyFmt=n=>new Intl.NumberFormat('en-US',{maximumFractionDigits:0}).format(n);
function selectOptions(opts,value){return opts.map(o=>`<option value="${esc(o.value)}" ${String(o.value)===String(value)?'selected':''}>${esc(o.label)}</option>`).join('')}
const basePreset=key=>DATASETS[key]?DATASETS[key].name:'Imported study';
const allocation=()=>state.allocation==='33/33/34'?[.33,.33,.34]:[.5,.25,.25];
const analysis=()=>report(state.rows,state.segment,state.alpha,allocation());
function card(title,content,extra=''){return `<section class="panel ${extra}"><div class="panelhead"><h3>${title}</h3></div>${content}</section>`}
function stat(label,value,info,theme=''){return `<div class="stat ${theme}"><p>${esc(label)}</p><strong>${value}</strong><small>${info}</small></div>`}
function pill(text,kind='neutral'){return `<span class="pill ${kind}">${esc(text)}</span>`}
function tbl(headers,rows){return `<div class="tablewrap"><table><thead><tr>${headers.map(h=>`<th>${esc(h)}</th>`).join('')}</tr></thead><tbody>${rows.join('')}</tbody></table></div>`}
function footerHint(text){return `<p class="hint">${text}</p>`}
function rateBar(t,max){const rate=t.conversions/t.visitors;return `<div class="barrow"><div class="bname"><span class="round" style="background:${COLORS[t.variant]}"></span>Variant ${t.variant}</div><div class="track"><div class="trackfill" style="width:${Math.max(1,100*rate/max)}%;background:${COLORS[t.variant]}"></div></div><b>${fmtPct(rate,2)}</b><span class="muted">${fmtNum(t.conversions)} / ${fmtNum(t.visitors)}</span></div>`}
function effectScale(comparisons){let max=Math.max(.025,...comparisons.map(c=>Math.max(Math.abs(c.low),Math.abs(c.high))));max=Math.min(.35,max*1.18);return comparisons.map(c=>{
const left=50+(Math.max(-max,c.low)/max)*50,right=50+(Math.min(max,c.high)/max)*50,center=50+Math.max(-max,Math.min(max,c.diff))/max*50;
return `<div class="forestrow"><div class="forestlabel"><b>Variant ${c.variant} vs A</b><small>${(100*c.diff).toFixed(2)} pp absolute change</small></div><div class="forest"><span class="zero"></span><span class="interval" style="left:${left}%;width:${Math.max(1,right-left)}%"></span><span class="point" style="left:${center}%"></span></div><span class="effect">${(c.diff*100>=0?'+':'')+(c.diff*100).toFixed(2)} pp</span></div>`}).join('')+`<div class="forestaxes"><span>−${(max*100).toFixed(1)} pp</span><span>0</span><span>+${(max*100).toFixed(1)} pp</span></div>`}
function summary(){
const r=analysis(),total=r.totals.reduce((s,t)=>s+t.visitors,0);
const best=r.totals.slice().sort((a,b)=>b.conversions/b.visitors-a.conversions/a.visitors)[0];
const winning=r.comparisons.filter(c=>c.significant&&c.diff>0);
const top=`<div class="stats">${stat('Participants',fmtNum(total),'All observed participants')}${stat('Control conversion',fmtPct(r.totals[0].conversions/r.totals[0].visitors,2),'Variant A reference')}${stat('Highest observed rate','Variant '+best.variant,fmtPct(best.conversions/best.visitors,2),'accent')}${stat('Confirmed uplift',winning.length?winning.map(c=>c.variant).join(', '):'None',`Holm-adjusted at ${(r.alpha*100).toFixed(0)}%`,winning.length?'positive':'')}</div>`;
const bars=`<div class="bars">${r.totals.map(t=>rateBar(t,Math.max(...r.totals.map(x=>x.conversions/x.visitors)))).join('')}</div>${footerHint('Conversion = completions / participants. All charts and tables use the same underlying data.')}`;
const readiness=`<div class="stack"><div class="readyrow"><span>Allocation quality</span>${pill(r.srm.pvalue<.001?'Investigate SRM':'No strong SRM signal',r.srm.pvalue<.001?'bad':'good')}</div><div class="readyrow"><span>Comparisons controlled</span>${pill('Holm adjustment','good')}</div><div class="readyrow"><span>Segments analyzed</span><b>${r.segments.length}</b></div><div class="readyrow"><span>Monitoring rule</span>${pill('Fixed-horizon only','warn')}</div></div>${footerHint('A non-significant result is inconclusive, not proof of equivalence. Repeated peeking inflates false positives.')}`;
const tableRows=r.comparisons.map(c=>`<tr><td><b>${esc(c.variant)} vs A</b></td><td>${c.lift===null?'Not defined':(c.lift>0?'+':'')+fmtPct(c.lift,1)}</td><td>${c.pvalue<.0001?'<0.0001':c.pvalue.toFixed(4)}</td><td>${c.pAdjusted<.0001?'<0.0001':c.pAdjusted.toFixed(4)}</td><td>${pill(c.significant?(c.diff>0?'Higher':'Lower'):'Inconclusive',c.significant?(c.diff>0?'good':'bad'):'neutral')}</td></tr>`);
const differences=`<div class="forestset">${effectScale(r.comparisons)}</div><div class="hr"></div>${tbl(['Comparison','Relative lift','Raw p','Holm p','Decision'],tableRows)}${footerHint('Differences and CIs use Newcombe-Wilson intervals. Two-sided pooled z-tests and Holm adjustment are approximate frequentist methods.')}`;
return top+`<div class="split wideleft">${card('Conversion performance',bars)}${card('Decision readiness',readiness)}</div>`+card('Treatment effect vs control',differences);
}
function benchmark(){const r=analysis();return `${card('Variant benchmark',`<div class="eyebrow">Exact values from currently selected sample</div>${tbl(['Variant','Participants','Completions','Rate',`${(100*(1-r.alpha)).toFixed(0)}% Wilson CI`],r.totals.map(t=>{const [l,u]=wilson(t.conversions,t.visitors,normalQuantile(1-r.alpha/2));return `<tr><td><span class="round" style="background:${COLORS[t.variant]}"></span> ${t.variant}${t.variant==='A'?' · Control':''}</td><td>${fmtNum(t.visitors)}</td><td>${fmtNum(t.conversions)}</td><td><b>${fmtPct(t.conversions/t.visitors,2)}</b></td><td>${fmtPct(l,2)} to ${fmtPct(u,2)}</td></tr>`}))}`)}<div class="split">${card('Compare treatments to control',`<div class="forestset">${effectScale(r.comparisons)}</div>${footerHint('The difference interval is an unpooled Newcombe-Wilson approximation, separate from the hypothesis-test p-value.')}`)}${card('Statistical interpretation',`<div class="stack">${r.comparisons.map(c=>`<div class="insight"><b>${esc(c.variant)} vs A ${pill(c.significant?'Adjusted p < α':'Adjusted p ≥ α',c.significant?'good':'neutral')}</b><p>${(c.diff*100>=0?'+':'')+(c.diff*100).toFixed(2)} percentage points, interval ${(c.low*100).toFixed(2)} to ${(c.high*100).toFixed(2)} pp. Holm p = ${c.pAdjusted.toFixed(4)}.</p></div>`).join('')}</div>${footerHint('The intervals are individual confidence intervals. Holm-adjusted p-values address the two prespecified pairwise comparisons; the intervals themselves are not familywise-adjusted.')}`)}</div>`}
function segments(){const segments=[...new Set(state.rows.map(x=>x.segment))];const rows=segments.flatMap(s=>{const r=report(state.rows,s,state.alpha,allocation());return r.comparisons.map(c=>({segment:s,...c,controls:r.totals[0].visitors}))});
return `${card('Segment effects',`<p class="muted">Explore how outcomes vary by subgroup. Do not interpret this exploratory table as a formal treatment-by-segment interaction test.</p>${tbl(['Segment','Comparison','Lift (pp)','Adjusted p*','Sample'],rows.map(c=>`<tr><td>${esc(c.segment)}</td><td>${c.variant} vs A</td><td><b class="${c.diff>0?'mint':'rose'}">${(c.diff>=0?'+':'')+(c.diff*100).toFixed(2)}</b></td><td>${c.pAdjusted.toFixed(4)}</td><td>${fmtNum(c.n+c.controls)}</td></tr>`))}${footerHint('*Holm correction applies to B and C within each segment only, not across every segment and view.')}`)}<div class="split">${card('Cohort sizes',`<div class="bars">${segments.map(s=>{const size=state.rows.filter(x=>x.segment===s).reduce((a,x)=>a+x.visitors,0);const max=Math.max(...segments.map(g=>state.rows.filter(x=>x.segment===g).reduce((a,x)=>a+x.visitors,0)));return `<div class="barrow"><div class="bname">${esc(s)}</div><div class="track"><div class="trackfill" style="width:${100*size/max}%;background:var(--violet)"></div></div><b>${fmtNum(size)}</b></div>`}).join('')}</div>`)}${card('Interpret with caution',`<p>Subgroups often have smaller samples, creating more uncertainty. A significant result in one subgroup and a non-significant result in another <b>does not</b> prove an interaction.</p><p class="muted">For formal heterogeneous treatment effects, plan and power an interaction test prospectively and adjust for the broader exploration family.</p>`)}</div>`}
function quality(){const r=analysis();let bad=r.srm.pvalue<.001;
return `<div class="stats three">${stat('SRM χ²',r.srm.chi2.toFixed(2),'2 degrees of freedom')}${stat('SRM p-value',r.srm.pvalue<.0001?'<0.0001':r.srm.pvalue.toFixed(4),'Alert threshold p < 0.001',bad?'danger':'positive')}${stat('Expected allocation',state.allocation,'A / B / C')}</div><div class="split">${card('Sample ratio mismatch',`${tbl(['Variant','Observed','Expected','Difference'],r.totals.map((t,i)=>`<tr><td>${t.variant}</td><td>${fmtNum(t.visitors)}</td><td>${fmtNum(r.srm.expected[i])}</td><td>${(t.visitors-r.srm.expected[i]>0?'+':'')+fmtNum(t.visitors-r.srm.expected[i])}</td></tr>`))}${footerHint('Goodness-of-fit χ² test with 2 degrees of freedom. The allocation ratio must reflect the experiment design, not be chosen after results are seen.')}`)}${card('Design diagnostics',`<div class="stack"><div class="insight"><b>${pill(bad?'SRM alert':'Allocation check passed',bad?'bad':'good')}</b><p>${bad?'Check instrumentation, randomization, eligibility and missing events before making decisions.':'No strong evidence of a ratio mismatch at the conservative 0.001 threshold.'}</p></div><div class="insight"><b>Do not repeatedly peek</b><p>Fixed-horizon p-values assume a planned analysis point. Monitoring repeatedly without sequential correction inflates false positives.</p></div><div class="insight"><b>Independent observations</b><p>Users should be randomized once, and counted once. Clustered, repeated or dependent observations need alternative inference.</p></div></div>`)} </div>`}
function power(){const p0=state.baseline/100,mde=state.mde/100,p=sampleSize(p0,mde,state.power,state.alpha,2);const total=p.perArm*3;const r=analysis();const actual=r.totals;
return `<div class="stats three">${stat('Needed per arm',fmtNum(p.perArm),'Equal-size approximation')}${stat('Needed overall',fmtNum(total),'Three arms, 1:1:1 allocation')}${stat('Target absolute lift',((p.p1-p.p0)*100).toFixed(2)+' pp',`Target conversion ${fmtPct(p.p1,2)}`)}</div><div class="split">${card('Power planning controls',`<div class="controls2"><label>Baseline conversion <b>${state.baseline}%</b><input id="baseline" type="range" min="1" max="60" step="1" value="${state.baseline}"></label><label>Minimum relative detectable lift <b>${state.mde}%</b><input id="mde" type="range" min="2" max="65" step="1" value="${state.mde}"></label><label>Desired power<select id="power">${selectOptions([{value:.8,label:'80%'},{value:.9,label:'90%'}],state.power)}</select></label></div>${footerHint('Normal approximation for two-sided proportions tests, with Bonferroni allocation of α across two planned comparisons. Use a professional power tool for small or extreme samples.')}`)}${card('Available vs planned sample',`${tbl(['Variant','Observed','Target','Share'],actual.map(t=>`<tr><td>${t.variant}</td><td>${fmtNum(t.visitors)}</td><td>${fmtNum(p.perArm)}</td><td>${fmtPct(Math.min(1,t.visitors/p.perArm),0)}</td></tr>`))}<p class="muted">Power is a pre-experiment design concept, not a guarantee of significance. The table compares observed counts with an equal-allocation reference plan, even if the current demo uses another ratio.</p>`)} </div>`}
function reports(){const r=analysis();return `${card('Import your experiment',`<p class="muted">Load an aggregated CSV. Each row must contain one unique segment and variant pair. All segments require A, B and C rows. The file is processed locally in this browser.</p><div class="upload"><label for="csv-file" class="uploadlabel">Choose your CSV file <span>Maximum 1 MB · 200 data rows</span></label><input type="file" id="csv-file" accept=".csv,text/csv"/><button id="load-csv" class="btn primary">Import selected CSV</button></div>${footerHint('Required columns: segment,variant,visitors,conversions. Participants must be positive integers and conversions cannot exceed participants.')}`)}<div class="split">${card('Download reports',`<div class="downloadgrid"><button class="btn outline" data-export="json">Full JSON audit ↓</button><button class="btn outline" data-export="csv">Current study CSV ↓</button><button class="btn outline" data-export="comparison">Variant comparison CSV ↓</button><button class="btn outline" data-export="segments">Segment comparison CSV ↓</button><button class="btn outline" data-export="markdown">Research memo ↓</button><button class="btn outline" data-export="template">Empty CSV template ↓</button></div>${footerHint('Reports capture the selected dataset, segment, confidence setting and allocation. Export files before leaving the page; changes are not saved to a cloud account.')}`)}${card('Methodology and responsible use',`<div class="stack"><div class="insight"><b>Unit of analysis</b><p>One randomized participant contributes one binary outcome.</p></div><div class="insight"><b>Uncertainty</b><p>Newcombe-Wilson differences, pooled two-sided z-test, Holm adjusted p-values.</p></div><div class="insight"><b>Limitations</b><p>Not suitable for nonrandom allocation, repeated measures, cluster trials or continuous outcomes. No automated causal guarantees.</p></div></div>`)} </div>${card('Current dataset',`${tbl(['Segment','Variant','Participants','Completions'],state.rows.map(x=>`<tr><td>${esc(x.segment)}</td><td>${x.variant}</td><td>${fmtNum(x.visitors)}</td><td>${fmtNum(x.conversions)}</td></tr>`))}`)}`}
function mainContent(){switch(state.page){case 'Variant benchmark':return benchmark();case 'Segment explorer':return segments();case 'Quality & SRM':return quality();case 'Power planner':return power();case 'Data & reports':return reports();default:return summary();}}
function render(){try{
const r=analysis();const subtitle=state.scenario==='custom'?'Custom imported experiment':DATASETS[state.scenario].description;
document.getElementById('app').innerHTML=`<div class="shell"><aside class="sidebar"><div class="brand"><span class="brandicon">EF</span><div><strong>ExperimentForge</strong><small>Experiment intelligence lab</small></div></div><div class="navlabel">WORKSPACE</div><nav aria-label="Sections">${PAGES.map(([p,ico])=>`<button data-page="${esc(p)}" class="navitem ${state.page===p?'active':''}"><span>${ico}</span>${esc(p)}</button>`).join('')}</nav><div class="swipehint">Swipe to explore all modules →</div><div class="sidebox"><span class="tinyeyebrow">RESEARCH STANDARD</span><b>Evidence before action.</b><p>Randomization checks, multiplicity control, and uncertainty shown in the open.</p></div><div class="sidebarfoot"><span class="online"></span> Fully local · No API keys</div></aside><main class="main"><div class="topbar"><div class="breadcrumbs">PORTFOLIO / PROJECT 63 / <b>${esc(state.page.toUpperCase())}</b></div><div class="topright"><span class="tag">● SIMULATED STUDY</span><span class="avatar">EF</span></div></div><div class="content"><div class="hero"><div class="heroleft"><div class="eyebrow">EXPERIMENTATION & PRODUCT RESEARCH</div><h1>${esc(state.page==='Overview'?'Experiment intelligence':state.page)}</h1><p>${esc(subtitle)}</p></div><div class="heroart"><div class="heroartgrid"></div><div class="bignum">A<span>/</span>B<span>/</span>C</div><span>TEST • MEASURE • LEARN</span></div></div><div class="toolbar"><label>Demo dataset<select id="scenario">${selectOptions([...Object.keys(DATASETS).map(k=>({value:k,label:DATASETS[k].name})),...(state.scenario==='custom'?[{value:'custom',label:'Imported CSV'}]:[])],state.scenario)}</select></label><label>Population slice<select id="segment">${selectOptions([{value:'All segments',label:'All segments'},...r.segments.map(s=>({value:s,label:s}))],state.segment)}</select></label><label>Significance level<select id="alpha">${selectOptions([{value:.05,label:'α = 0.05'},{value:.1,label:'α = 0.10'},{value:.01,label:'α = 0.01'}],state.alpha)}</select></label><label>Planned allocation<select id="allocation">${selectOptions([{value:'50/25/25',label:'50 / 25 / 25'},{value:'33/33/34',label:'33 / 33 / 34'}],state.allocation)}</select></label><button class="btn primary toolbarbutton" data-action="export-json">Export audit <span>↗</span></button></div>${state.message?`<div class="alert" role="status">${esc(state.message)} <button data-action="dismiss" aria-label="Dismiss">×</button></div>`:''}<div class="sectionbody">${mainContent()}</div><div class="footer">ExperimentForge v1.0 <span>·</span> Synthetic examples <span>·</span> Educational inference, not causal certification <span>·</span> 100% client-side</div></div></main></div>`;
bindEvents();
}catch(err){document.getElementById('app').innerHTML=`<div class="fatal">Cannot display dashboard: ${esc(err.message)}</div>`;console.error(err)}}
function bindEvents(){
document.querySelectorAll('[data-page]').forEach(el=>el.addEventListener('click',()=>{state.page=el.dataset.page;state.message='';render();window.scrollTo(0,0)}));
document.getElementById('scenario').addEventListener('change',e=>{state.scenario=e.target.value;if(state.scenario!=='custom')state.rows=fromPreset(state.scenario);state.segment='All segments';state.message='';render()});
document.getElementById('segment').addEventListener('change',e=>{state.segment=e.target.value;render()});
document.getElementById('alpha').addEventListener('change',e=>{state.alpha=Number(e.target.value);render()});
document.getElementById('allocation').addEventListener('change',e=>{state.allocation=e.target.value;render()});
document.querySelectorAll('[data-action="export-json"]').forEach(el=>el.addEventListener('click',()=>downloadReport('json')));
document.querySelectorAll('[data-action="dismiss"]').forEach(el=>el.addEventListener('click',()=>{state.message='';render()}));
for(const k of ['baseline','mde']){const el=document.getElementById(k);if(el){el.addEventListener('input',e=>{state[k]=Number(e.target.value);render()})}}
const power=document.getElementById('power');if(power)power.addEventListener('change',e=>{state.power=Number(e.target.value);render()});
document.querySelectorAll('[data-export]').forEach(el=>el.addEventListener('click',()=>downloadReport(el.dataset.export)));
const load=document.getElementById('load-csv');if(load)load.addEventListener('click',async()=>{const file=document.getElementById('csv-file').files[0];if(!file){state.message='Select a CSV file first.';render();return}if(file.size>1000000){state.message='Import rejected: file exceeds 1 MB.';render();return}try{const candidate=importCSV(await file.text());state.rows=candidate;state.scenario='custom';state.segment='All segments';state.page='Overview';state.message=`Imported ${candidate.length} rows across ${new Set(candidate.map(x=>x.segment)).size} segments.`;render();}catch(err){state.message='Import rejected: '+err.message;render()}});
}
function download(name,content,mime='text/plain'){const blob=new Blob([content],{type:mime+';charset=utf-8'}),url=URL.createObjectURL(blob),link=document.createElement('a');link.href=url;link.download=name;document.body.appendChild(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),1000);}
function downloadReport(type){const r=analysis();const stamp={project:'ExperimentForge',version:'1.0',generated_at:new Date().toISOString(),demo:state.scenario,synthetic:state.scenario!=='custom',selected_segment:state.segment,alpha:state.alpha,planned_allocation:allocation(),assumptions:['independent binary outcomes','randomization assumed','fixed horizon','Holm over B vs A and C vs A only','Newcombe-Wilson confidence intervals are not multiplicity-adjusted']};
const current=state.rows;let name='',content='',mime='text/plain';
if(type==='json'){name='experimentforge-audit.json';content=JSON.stringify({metadata:stamp,summary:r,observations:current},null,2);mime='application/json'}
else if(type==='csv'){name='experimentforge-study.csv';content=exportCSV(current,['segment','variant','visitors','conversions']);mime='text/csv'}
else if(type==='comparison'){name='experimentforge-variant-comparison.csv';content=exportCSV(r.comparisons.map(c=>({variant:c.variant,baseline_rate:c.baseline,treatment_rate:c.rate,difference:c.diff,ci_low:c.low,ci_high:c.high,raw_p:c.pvalue,holm_p:c.pAdjusted,significant:c.significant?'yes':'no'})),['variant','baseline_rate','treatment_rate','difference','ci_low','ci_high','raw_p','holm_p','significant']);mime='text/csv'}
else if(type==='segments'){name='experimentforge-segments.csv';let rows=[];for(const s of r.segments)rows.push(...report(current,s,state.alpha,allocation()).comparisons.map(c=>({segment:s,variant:c.variant,difference:c.diff,holm_p_within_segment:c.pAdjusted,participants:c.n})));content=exportCSV(rows,['segment','variant','difference','holm_p_within_segment','participants']);mime='text/csv'}
else if(type==='template'){name='experimentforge-template.csv';content='segment,variant,visitors,conversions\r\nGroup 1,A,500,40\r\nGroup 1,B,250,25\r\nGroup 1,C,250,23\r\n';mime='text/csv'}
else if(type==='markdown'){name='experimentforge-research-memo.md';content=`# ExperimentForge audit\n\nGenerated: ${stamp.generated_at}\nDataset: ${basePreset(state.scenario)}\nSegment: ${state.segment}\nAlpha: ${state.alpha}\n\n## Rates\n\n${r.totals.map(t=>`- Variant ${t.variant}: ${t.conversions}/${t.visitors}, ${fmtPct(t.conversions/t.visitors,2)}`).join('\n')}\n\n## Pairwise comparisons (Holm correction)\n\n${r.comparisons.map(c=>`- ${c.variant} vs A: ${(100*c.diff).toFixed(2)} pp (Newcombe CI: ${(100*c.low).toFixed(2)} to ${(100*c.high).toFixed(2)} pp), raw p ${c.pvalue.toFixed(5)}, Holm p ${c.pAdjusted.toFixed(5)}`).join('\n')}\n\nSample ratio mismatch p: ${r.srm.pvalue.toFixed(6)}\n\n## Limitations\n\nBinary independent randomized outcomes assumed; fixed-horizon analysis only; no sequential monitoring correction. CIs not multiplicity-adjusted. Segment results exploratory; no causal certification.\n`}
else throw Error('Unknown report.');download(name,content,mime);}
render();
</script></body></html>
|