Download index.html from Hugdrigo/experimentforge-ab-testing-lab: direct link, hf CLI and curl.
- Browser
- Download file 44.9 kB
-
https://huggingface.co/spaces/Hugdrigo/experimentforge-ab-testing-lab/resolve/main/index.html
- Command line
-
hf download hf://spaces/Hugdrigo/experimentforge-ab-testing-lab/index.html
-
curl -L -o index.html https://huggingface.co/spaces/Hugdrigo/experimentforge-ab-testing-lab/resolve/main/index.html
44.9 kB
| <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> | |
| ; | |
| 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')} | |
| ; | |
| 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> | |