Hugdrigo's picture
Upload 3 files
4466788 verified
Raw History Blame Contribute Delete
47.8 kB
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="theme-color" content="#0b101c"/><meta name="description" content="PolicyForge: local-first off-policy evaluation with statistical diagnostics and reproducible reports."/>
<title>PolicyForge | Counterfactual Decision Studio</title>
<style>
:root{--bg:#080e19;--panel:#111a29;--raised:#172235;--stroke:#29354c;--muted:#91a3ba;--text:#eff5fc;--mint:#62e5bd;--violet:#a693ff;--rose:#ffa5ac;--amber:#ffd088;--shadow:0 16px 48px #02070f4d;--sans:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{background:radial-gradient(ellipse at 50% -40%,#192c45 0%,transparent 55%),var(--bg);color:var(--text);font-family:var(--sans);line-height:1.52;margin:0}button,input,select{font:inherit}button,select{cursor:pointer}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid var(--mint);outline-offset:3px}button:disabled{opacity:.5;cursor:not-allowed}a{color:var(--mint)}.sr-only{position:absolute;clip:rect(0,0,0,0);width:1px;height:1px;overflow:hidden}.shell{display:grid;grid-template-columns:244px minmax(0,1fr);min-height:100vh}.side{background:#0a1220;border-right:1px solid #253249;padding:26px 17px;display:flex;flex-direction:column;gap:24px;position:sticky;height:100vh;top:0}.brand{display:flex;align-items:center;gap:11px;padding:0 8px}.brand-symbol{height:43px;width:43px;border-radius:14px;background:linear-gradient(135deg,#53e5bf,#a8a3ff);color:#051018;display:grid;place-items:center;font-weight:950;font-size:25px;box-shadow:0 8px 25px #54e4be28}.brand b{font-size:20px;letter-spacing:-.8px}.brand small{display:block;color:var(--muted);font-size:10px;letter-spacing:1.5px;text-transform:uppercase}.side-title{font-size:10px;text-transform:uppercase;letter-spacing:2px;color:#72859f;padding:0 13px;margin:8px 0}.nav-list{display:flex;flex-direction:column;gap:6px}.nav-btn{border:none;background:transparent;color:#9aaac0;padding:13px 13px;border-radius:11px;text-align:left;display:flex;gap:12px;align-items:center;font-size:13px;font-weight:650;width:100%}.nav-btn span:first-child{font-size:17px;width:20px;text-align:center}.nav-btn.active{background:#20364a;color:var(--mint);box-shadow:inset 3px 0 var(--mint)}.nav-btn:hover{background:#1a283a;color:white}.side-foot{margin-top:auto;padding:14px;border:1px solid #293d4f;border-radius:13px;background:#0e1b29}.side-foot strong{display:block;color:var(--mint);font-size:12px}.side-foot p{font-size:11px;color:var(--muted);margin:6px 0 0}.main{padding:28px clamp(18px,4vw,64px) 65px;min-width:0;max-width:1690px;width:100%;margin:0 auto}.topbar{display:flex;justify-content:space-between;gap:15px;align-items:center;border-bottom:1px solid #28364b;padding-bottom:20px}.breadcrumb{font-size:12px;letter-spacing:1.4px;color:#8696ad;text-transform:uppercase}.live{color:#a6efd6;border:1px solid #245b50;background:#12362d;border-radius:999px;padding:8px 12px;font-size:11px;font-weight:800}.live:before{content:'● ';color:var(--mint)}.hero{padding:32px 0 26px}.eyebrow{text-transform:uppercase;font-size:11px;font-weight:800;letter-spacing:2px;color:var(--mint);display:flex;gap:10px;align-items:center}.dot{width:23px;height:1px;background:var(--mint)}h1{font-size:clamp(32px,3.4vw,54px);letter-spacing:-2px;line-height:1.08;margin:14px 0}h1 em{font-style:normal;color:var(--mint)}.hero p{color:var(--muted);max-width:840px;margin:0;font-size:14px}.toolbar{margin:25px 0 23px;display:flex;gap:12px;align-items:center;flex-wrap:wrap}.field{display:flex;flex-direction:column;gap:6px}.field label{font-size:10px;letter-spacing:1.25px;color:#91a5be;text-transform:uppercase;font-weight:800}select,input[type=number]{background:#131f2f;color:white;border:1px solid #344357;border-radius:9px;padding:10px 12px;min-height:42px;min-width:160px}input[type=range]{accent-color:var(--mint);width:100%}.btn{background:#1d2a3b;border:1px solid #344457;color:#f0f6fc;display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:11px 16px;border-radius:10px;font-weight:780;font-size:12px;text-decoration:none;min-height:42px}.btn:hover{background:#2b3d54}.btn.primary{background:var(--mint);border-color:var(--mint);color:#07151a}.btn.primary:hover{background:#9cefd9}.btn.secondary{color:var(--mint);border-color:#306558}.push{margin-left:auto}.grid{display:grid;gap:16px}.kpis{grid-template-columns:repeat(4,minmax(0,1fr))}.panel{background:linear-gradient(160deg,#142035,#101926);border:1px solid #29374c;border-radius:15px;box-shadow:var(--shadow);min-width:0}.kpi{padding:20px 22px}.kpi .label{color:#97abc1;font-size:11px;letter-spacing:.8px;text-transform:uppercase;font-weight:800}.kpi .number{font-size:clamp(22px,2.2vw,36px);font-weight:820;margin:8px 0;letter-spacing:-1.2px}.kpi .foot{color:var(--muted);font-size:11px;line-height:1.5}.kpi:nth-child(2) .number{color:var(--mint)}.kpi:nth-child(3) .number{color:var(--violet)}.kpi:nth-child(4) .number{color:var(--amber)}.cols{grid-template-columns:minmax(0,1.65fr) minmax(260px,.85fr)}.section-head{display:flex;justify-content:space-between;gap:15px;align-items:center;margin:26px 0 12px}.section-head h2{font-size:19px;letter-spacing:-.3px;margin:0}.section-head p{color:var(--muted);font-size:12px;margin:3px 0 0}.panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;border-bottom:1px solid #29384e;padding:19px 20px 16px}.panel-head b{font-size:14px}.panel-head small{display:block;font-size:11px;color:var(--muted);margin-top:3px}.panel-body{padding:18px 20px}.chart{width:100%;height:270px;display:block}.chart text{fill:#899eb6;font:11px var(--sans)}.chart .grid-line{stroke:#2a3c52;stroke-dasharray:3 6}.chart .axis{stroke:#43506a}.chart .legend-label{fill:#c4d3e0}.legend{display:flex;gap:17px;align-items:center;flex-wrap:wrap;color:var(--muted);font-size:11px}.legend i{width:8px;height:8px;display:inline-block;border-radius:50%;margin-right:5px}.table-scroll{overflow:auto}.data-table{width:100%;border-collapse:collapse;min-width:610px;font-size:12px}.data-table th{text-align:left;color:#92a7bf;padding:12px 13px;font-weight:750;border-bottom:1px solid #2b3b51;white-space:nowrap}.data-table td{padding:14px 13px;border-bottom:1px solid #253449;white-space:nowrap;color:#dce8f3}.data-table tr:last-child td{border-bottom:0}.data-table tr:hover td{background:#ffffff08}.data-table td.highlight{font-weight:850;color:var(--mint)}.tag{padding:5px 8px;border-radius:999px;font-size:10px;font-weight:900;display:inline-block;white-space:nowrap}.tag.good{background:#123a34;color:#8ef9d7;border:1px solid #2e685c}.tag.warn{background:#4b3420;color:#ffd5a3;border:1px solid #7e5735}.tag.bad{background:#4a2531;color:#ffc0c5;border:1px solid #75424b}.note{padding:13px 15px;margin:14px 0 0;border:1px solid #476056;background:#142d2d;border-radius:11px;color:#bfe7d7;font-size:12px}.note.warning{border-color:#75593f;background:#33271f;color:#ffdbb4}.note.danger{border-color:#7f3f4c;background:#352029;color:#ffd0d1}.metric-list{display:grid;gap:12px}.metric-row{display:flex;justify-content:space-between;gap:9px;align-items:baseline;font-size:12px}.metric-row .val{font-weight:860;color:#f2f6ff}.metric-row span:first-child{color:#99b0c5}.mini-bar{height:7px;border-radius:10px;background:#2b3d50;overflow:hidden;margin-top:5px}.mini-bar>div{height:100%;border-radius:10px;background:linear-gradient(90deg,#6ae5bd,#a49af7)}.stat-callout{font-size:32px;font-weight:850;letter-spacing:-1px;color:var(--mint);margin:9px 0}.alert{border-left:3px solid var(--amber);padding:8px 12px;color:#e6cbaa;font-size:12px;background:#2d261e;margin:8px 0;border-radius:5px}.view[hidden]{display:none!important}.controls{display:flex;gap:12px;flex-wrap:wrap;align-items:end}.control-row{padding:13px 0;border-bottom:1px solid #28364c}.control-row:last-child{border-bottom:0}.control-row label{display:block;font-size:12px;font-weight:760}.control-row .sub{color:var(--muted);font-size:11px}.hint{color:var(--muted);font-size:12px}.pill{padding:6px 9px;border:1px solid #405b5e;border-radius:7px;color:var(--mint);font-size:11px}.codebox{background:#0b1421;border:1px solid #344359;border-radius:10px;padding:12px 15px;white-space:pre-wrap;word-break:break-word;color:#b9dbc8;font-size:11px;line-height:1.8}.drop{border:2px dashed #4a6076;border-radius:14px;background:#101e2b;text-align:center;padding:28px 16px}.drop strong{display:block;margin-bottom:10px;font-size:15px}.drop input{display:block;margin:0 auto 12px;font-size:12px;max-width:260px;color:#a5bcd0}.badges{display:flex;gap:8px;flex-wrap:wrap}.tiny{font-size:10px;color:var(--muted)}.footer{margin-top:34px;border-top:1px solid #26394e;padding-top:14px;font-size:11px;color:#7c91a8}.mobile-bar{display:none}.spark{display:inline-flex;gap:4px;align-items:flex-end;height:30px}.spark i{display:block;width:4px;background:var(--mint);border-radius:2px}.svgbox{position:relative}.svgbox .cap{position:absolute;bottom:8px;right:12px;color:#718a9e;font-size:10px}.small-cards{grid-template-columns:repeat(3,1fr)}.small-card{padding:16px}.small-card .v{font-size:24px;font-weight:850;margin-top:3px;color:var(--mint)}.small-card .t{color:var(--muted);font-size:11px}.split-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.hbar{display:flex;align-items:center;gap:8px;margin:11px 0;font-size:11px}.hbar .name{width:96px;color:#a8c0d3}.hbar .track{flex:1;background:#25354b;height:13px;border-radius:5px;overflow:hidden}.hbar .fill{background:linear-gradient(90deg,#5ee4b7,#8e8ef4);height:100%}.hbar .num{width:55px;text-align:right;font-weight:750}
@media(max-width:1120px){.shell{grid-template-columns:70px minmax(0,1fr)}.side{padding:23px 9px}.brand{justify-content:center}.brand b,.brand small,.side-title,.nav-btn .nav-text,.side-foot{display:none}.nav-btn{justify-content:center;padding:12px 5px}.nav-btn span:first-child{font-size:20px}.cols{grid-template-columns:1fr}.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:640px){.shell{display:block}.side{display:none}.mobile-bar{position:sticky;top:0;z-index:20;display:flex;gap:7px;overflow-x:auto;scrollbar-width:none;background:#0b1626ee;backdrop-filter:blur(12px);border-bottom:1px solid #32415a;padding:12px}.mobile-bar button{border:1px solid #36465b;border-radius:8px;background:#132235;color:#becede;padding:9px 12px;font-size:11px;white-space:nowrap}.mobile-bar button.active{background:#275249;color:#c8ffee;border-color:#4c9383}.main{padding:16px 14px 45px}.topbar{padding-bottom:14px}.breadcrumb{font-size:10px}.live{font-size:10px;padding:6px 8px}.hero{padding:25px 0 12px}.hero h1{font-size:36px;letter-spacing:-1.2px}.hero p{font-size:12px}.toolbar{gap:10px}.toolbar .field{flex:1}.toolbar select{min-width:140px;width:100%}.push{margin-left:0}.kpis{gap:9px}.kpi{padding:14px}.kpi .number{font-size:26px}.kpi .label{font-size:10px}.kpi .foot{font-size:10px}.grid{gap:10px}.section-head{margin-top:22px}.panel-head{padding:15px}.panel-body{padding:13px}.chart{height:205px}.split-2,.small-cards{grid-template-columns:1fr}.btn{padding:10px 12px}.controls .field{flex:1}.controls select{width:100%;min-width:120px}}
</style></head><body>
<div class="mobile-bar" id="mobileNav" aria-label="Sections"></div>
<div class="shell"><aside class="side"><div class="brand"><div class="brand-symbol">∴</div><div><b>PolicyForge</b><small>Decision Intelligence</small></div></div><div><div class="side-title">Workspace</div><nav id="desktopNav" class="nav-list" aria-label="Main navigation"></nav></div><div class="side-foot"><strong>● LOCAL-FIRST ENGINE</strong><p>Run analyses in your browser. Data never leaves this page.</p></div></aside>
<main class="main"><header class="topbar"><div class="breadcrumb">LABORATORIES / OFF-POLICY EVALUATION / 033</div><div class="live">STATIC / NO API</div></header>
<section class="hero"><div class="eyebrow"><span class="dot"></span> PROBABILISTIC POLICY LAB</div><h1>Better decisions.<br><em>Before deployment.</em></h1><p>Evaluate alternative decision policies from logged outcomes using inverse propensity weighting, doubly robust estimation and explicit overlap diagnostics. Inspect the uncertainty before trusting a result.</p></section>
<div class="toolbar"><div class="field"><label for="dataset">Example dataset</label><select id="dataset"><option value="retail">01 / Retail offers</option><option value="service">02 / Service triage</option><option value="overlap">03 / Poor overlap stress test</option></select></div><div class="field"><label for="policy">Focus policy</label><select id="policy"><option value="context">Context-aware</option><option value="always_a">A for everyone</option><option value="always_b">B for everyone</option><option value="cautious">Conservative B</option><option value="balanced">50 / 50 mix</option></select></div><button id="reset" class="btn secondary">↻ Reset demo</button><button id="downloadJson" class="btn primary push">↓ Export report</button></div>
<div id="overview" class="view"></div><div id="laboratory" class="view" hidden></div><div id="reliability" class="view" hidden></div><div id="segments" class="view" hidden></div><div id="economics" class="view" hidden></div><div id="data" class="view" hidden></div>
<footer class="footer">© 2026 PolicyForge · Original educational analytics implementation · Synthetic demonstration data · No claims of causal certainty · Runs entirely in your browser</footer>
</main></div>
<script id="policy-engine">
/* PolicyForge Analytics Engine 2.0 - browser-compatible, zero dependencies. */
var PFEngine = (() => {
const POLICY_NAMES = {
always_a:'A for everyone', always_b:'B for everyone',
context:'Context-aware', cautious:'Conservative B', balanced:'50 / 50 mix'
};
const fmtNum = (n,d=3) => Number.isFinite(n)?n.toFixed(d):'N/A';
const clamp=(x,a,b)=>Math.max(a,Math.min(b,x));
function rng(seed) {let s = (seed>>>0)||1;return ()=>{s=(Math.imul(s,1664525)+1013904223)>>>0;return s/4294967296;};}
function policyProb(row,policy){
switch(policy){
case 'always_a':return 0;
case 'always_b':return 1;
case 'context':return row.segment==='high'?1:0;
case 'cautious':return row.segment==='high'?0.72:0.12;
case 'balanced':return 0.5;
default:throw new Error('Unknown policy');
}
}
function validate(rows){
if(!Array.isArray(rows)||rows.length<20) throw new Error('Provide at least 20 observations.');
if(rows.length>5000) throw new Error('Maximum 5,000 observations per analysis.');
rows.forEach((r,i)=>{
if(!r||!['high','low'].includes(r.segment)||!['A','B'].includes(r.action)||![0,1].includes(r.reward)||!Number.isFinite(r.propensity)||!(r.propensity>0&&r.propensity<=1))
throw new Error(`Invalid row ${i+1}: segment high/low, action A/B, reward 0/1, propensity (0,1].`);
if(!(Number.isInteger(r.id)||typeof r.id==='string'))throw new Error('Missing valid observation ID.');
});
return true;
}
function makeDemo(key='retail'){
const opts={
retail:{name:'Retail offers',note:'Moderately imbalanced assignment',n:680,seed:3041,prob:{high:.64,low:.36},bump:{high:.15,low:-.04},base:{high:.29,low:.15},group:.45},
service:{name:'Service triage',note:'Useful B among complex cases',n:780,seed:8260,prob:{high:.72,low:.22},bump:{high:.19,low:-.015},base:{high:.25,low:.20},group:.36},
overlap:{name:'Poor overlap stress test',note:'Strong logging bias and sparse alternatives',n:700,seed:9977,prob:{high:.97,low:.035},bump:{high:.11,low:-.055},base:{high:.25,low:.19},group:.5}
}[key];
if(!opts)throw new Error('Unknown dataset');
const rand=rng(opts.seed), rows=[];
for(let i=0;i<opts.n;i++){
const segment=rand()<opts.group?'high':'low';
const pB=opts.prob[segment];const action=rand()<pB?'B':'A';
const chance=clamp(opts.base[segment]+(action==='B'?opts.bump[segment]:0),0,1);
rows.push({id:i+1,segment,action,reward:rand()<chance?1:0,propensity:action==='B'?pB:1-pB});
}
return {rows,metadata:{name:opts.name,note:opts.note,source:'Synthetic demonstration',seed:opts.seed}};
}
// Each row's outcome prediction comes from the other parity fold; training group/action smoothing.
function predictions(rows){
const foldStats=[{},{}],foldTotals=[{},{}];
for(let i=0;i<rows.length;i++){
const r=rows[i],fold=i%2,k=r.segment+'|'+r.action;
const cell=foldStats[fold][k]||(foldStats[fold][k]={n:0,sum:0});cell.n++;cell.sum+=r.reward;
const tot=foldTotals[fold][r.action]||(foldTotals[fold][r.action]={n:0,sum:0});tot.n++;tot.sum+=r.reward;
}
return rows.map((r,i)=>{
const trainFold=1-(i%2);return ['A','B'].map(a=>{
const k=r.segment+'|'+a,c=foldStats[trainFold][k]||{n:0,sum:0},t=foldTotals[trainFold][a]||{n:0,sum:0};
const prior=(t.sum+1)/(t.n+2);
return (c.sum+8*prior)/(c.n+8);
});
});
}
function rowTerms(row,q,policy,cap=Infinity){
const pB=policyProb(row,policy),target=row.action==='B'?pB:1-pB;
const w=target/row.propensity,used=Math.min(w,cap),qPi=(1-pB)*q[0]+pB*q[1];
const qObs=row.action==='A'?q[0]:q[1];
return {w,used, ips:used*row.reward, dr:qPi+used*(row.reward-qObs),dm:qPi,weighted:used*row.reward};
}
function mean(a){return a.length?a.reduce((x,y)=>x+y,0)/a.length:NaN;}
function sum(a){return a.reduce((x,y)=>x+y,0);}
function metrics(rows,qs,policy,cap=Infinity){
const terms=rows.map((r,i)=>rowTerms(r,qs[i],policy,cap));
const ws=terms.map(x=>x.used),weights=sum(ws),weights2=sum(ws.map(x=>x*x));
const support=[];
for(const seg of ['high','low']){
for(const act of ['A','B']){
const sub=rows.filter(r=>r.segment===seg);
const count=sub.filter(r=>r.action===act).length;
const needed=policyProb({segment:seg},policy);const need=act==='B'?needed:1-needed;
if(need>.0001&&count===0)support.push(`${seg} / ${act}: target action not observed`);
}
}
return {
ips:mean(terms.map(x=>x.ips)),snips:weights>0?sum(terms.map(x=>x.weighted))/weights:NaN,
dm:mean(terms.map(x=>x.dm)),dr:mean(terms.map(x=>x.dr)),
ess:weights2?weights*weights/weights2:0,maxWeight:Math.max(0,...ws),
n:rows.length,support,terms,weights,pctESS:weights2?100*weights*weights/(weights2*rows.length):0
};
}
function bootCI(terms,seed=23,reps=180){
const r=rng(seed),n=terms.length;
if(n<20)return [NaN,NaN];
const vals=[];
for(let b=0;b<reps;b++){
let v=0;for(let j=0;j<n;j++)v+=terms[Math.floor(r()*n)].dr;
vals.push(v/n);
}
vals.sort((a,b)=>a-b);
return [vals[Math.floor((reps-1)*.025)],vals[Math.ceil((reps-1)*.975)]];
}
function actionCounts(rows){
const o={high:{A:0,B:0},low:{A:0,B:0}};
for(const r of rows)o[r.segment][r.action]++;
return o;
}
function analyze(rows,cap=Infinity){
validate(rows);const qs=predictions(rows),table=[];
for(const name of Object.keys(POLICY_NAMES)){
const m=metrics(rows,qs,name,cap),ci=bootCI(m.terms,rows.length+Object.keys(POLICY_NAMES).indexOf(name)*201);
table.push({key:name,name:POLICY_NAMES[name],...m,ci});
}
const loggedRate=mean(rows.map(x=>x.reward)), counts=actionCounts(rows);
const best=[...table].filter(x=>x.support.length===0 && x.pctESS>=12).sort((a,b)=>b.dr-a.dr)[0]||null;
const globalWarnings=[];
if(Math.min(...rows.map(r=>r.propensity))<.05)globalWarnings.push('Very low logging propensities (< 0.05): large weights possible.');
if(table.some(x=>x.pctESS<15))globalWarnings.push('Some policies have low effective sample size (<15%); estimates may be unstable.');
if(table.some(x=>x.support.length))globalWarnings.push('Positivity/overlap violation detected for at least one policy.');
return {rows,qs,table,loggedRate,counts,best,globalWarnings,cap};
}
function csvEscape(v){if(typeof v==='number'&&Number.isFinite(v))return String(v);let s=String(v??'');if(/^[=+@\-\t\r]/.test(s))s="'"+s;return /[",\n\r]/.test(s)?'"'+s.replace(/"/g,'""')+'"':s;}
function toCSV(rows,columns){return [columns.map(csvEscape).join(','),...rows.map(r=>columns.map(k=>csvEscape(r[k])).join(','))].join('\r\n')+'\r\n';}
// RFC 4180-style basic CSV parser with quoted fields and escaped double quotes.
function parseCSV(text){
if(typeof text!=='string'||text.length>1600000)throw new Error('CSV is empty or too large (max 1.6 MB).');
const recs=[],row=[];let cell='',quoted=false;
for(let i=0;i<text.length;i++){
const c=text[i];
if(c==='"'){if(quoted&&text[i+1]==='"'){cell+='"';i++;}else if(!quoted&&cell!=='')throw new Error('Malformed CSV quote');else quoted=!quoted;}
else if(c===','&&!quoted){row.push(cell);cell='';}
else if((c==='\n'||c==='\r')&&!quoted){row.push(cell);if(row.some(x=>x.trim()!==''))recs.push([...row]);row.length=0;cell='';if(c==='\r'&&text[i+1]==='\n')i++;}
else cell+=c;
}
if(quoted)throw new Error('Unterminated CSV quote');
row.push(cell);if(row.some(x=>x.trim()!==''))recs.push(row);
if(recs.length<2)throw new Error('CSV must contain a header and at least 20 data rows.');
const header=recs[0].map((c,i)=>i===0?c.replace(/^\uFEFF/,'').trim().toLowerCase():c.trim().toLowerCase());
const fields=['segment','action','reward','propensity'];
for(const k of fields)if(!header.includes(k))throw new Error('Missing CSV column: '+k);
if(new Set(header).size!==header.length)throw new Error('Duplicate CSV header');
const rows=recs.slice(1).map((line,i)=>{
if(line.length!==header.length)throw new Error(`Row ${i+2} has ${line.length} cells, expected ${header.length}.`);
const obj=Object.fromEntries(header.map((h,j)=>[h,line[j].trim()]));
if(!/^([01])$/.test(obj.reward)||!/^([AB])$/.test(obj.action)||!['high','low'].includes(obj.segment)||obj.propensity==='')
throw new Error(`Invalid data at CSV row ${i+2}.`);
const p=Number(obj.propensity);if(!Number.isFinite(p)||p<=0||p>1)throw new Error(`Invalid propensity at CSV row ${i+2}.`);
return {id:obj.id||String(i+1),segment:obj.segment,action:obj.action,reward:Number(obj.reward),propensity:p};
});
validate(rows);return rows;
}
function segmentMetrics(rows,qs,policy,cap=Infinity){return ['high','low'].map(seg=>{
const idx=rows.map((r,i)=>r.segment===seg?i:-1).filter(i=>i>=0),rr=idx.map(i=>rows[i]),qq=idx.map(i=>qs[i]);
return {segment:seg, ...metrics(rr,qq,policy,cap)};
});}
function ratePct(n){return Number.isFinite(n)?`${(100*n).toFixed(1)}%`:'N/A';}
function stress(rows,cap,policy){const qs=predictions(rows);return [Infinity,25,10,5,3,2,1].map(c=>({cap:Number.isFinite(c)?c:'None',...metrics(rows,qs,policy,c)}));}
return {POLICY_NAMES,rng,validate,makeDemo,predictions,metrics,bootCI,analyze,parseCSV,toCSV,segmentMetrics,policyProb,ratePct,fmtNum,stress};
})();
</script>
<script id="policy-app">
/* PolicyForge UX. All user data remains inside this tab, no external calls. */
(() => {
'use strict';
const E=PFEngine;
const views=[['overview','◈','Overview'],['laboratory','⌁','Policy laboratory'],['reliability','◉','Overlap & reliability'],['segments','▦','Segment explorer'],['economics','◇','Decision scenarios'],['data','▤','Data & methodology']];
const state={dataset:'retail',...E.makeDemo('retail'),policy:'context',cap:Infinity,view:'overview',population:50000,value:45,costB:2};
const by=id=>document.getElementById(id);
const f=(x,d=3)=>E.fmtNum(x,d),pct=x=>E.ratePct(x);
const escapeHTML=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const stat=(label,val,foot)=>`<article class="panel kpi"><div class="label">${label}</div><div class="number">${val}</div><div class="foot">${foot}</div></article>`;
const panel=(title,subtitle,body)=>`<section class="panel"><div class="panel-head"><div><b>${title}</b><small>${subtitle}</small></div><span class="pill">LIVE CALC</span></div><div class="panel-body">${body}</div></section>`;
const section=(title,desc)=>`<div class="section-head"><div><h2>${title}</h2><p>${desc}</p></div></div>`;
const block=(label,val,max=100)=>`<div class="hbar"><span class="name">${label}</span><div class="track"><div class="fill" style="width:${Math.max(0,Math.min(100,max))}%"></div></div><span class="num">${val}</span></div>`;
const status = m => (m.support.length?'bad':m.pctESS<15?'warn':m.pctESS<35?'warn':'good');
const statusLabel=m=>m.support.length?'NO SUPPORT':m.pctESS<15?'FRAGILE':m.pctESS<35?'CAUTION':'SCREENED';
const notice=(str,type='warning')=>`<div class="note ${type}">${escapeHTML(str)}</div>`;
function safeNum(n){return Number.isFinite(n)?n:null;}
function getFocus(){return state.analysis.table.find(x=>x.key===state.policy);}
function yCoord(n,min,max){return 228-(n-min)/(max-min)*175;}
function chartPolicy(table){
const values=table.flatMap(t=>[t.dr,...t.ci].filter(Number.isFinite));let low=Math.min(0,...values)-.05,high=Math.max(...values)+.05;if(high<=low)high=low+.1;
let z=`<svg class="chart" viewBox="0 0 750 275" role="img" aria-label="Doubly robust policy estimates with bootstrap confidence intervals">`;
for(let k=0;k<=4;k++){let v=low+(high-low)*k,x=125+603*k/4;z+=`<line class="grid-line" x1="${x}" x2="${x}" y1="15" y2="244"/><text x="${x}" y="264" text-anchor="middle">${pct(v)}</text>`;}
table.forEach((t,i)=>{const y=28+i*45,c=t.key===state.policy?'#62e5bd':'#9a92ef',v=Math.max(low,Math.min(high,t.dr));const x=125+(v-low)/(high-low)*603,x0=125+(0-low)/(high-low)*603;
const xlo=125+(t.ci[0]-low)/(high-low)*603,xhi=125+(t.ci[1]-low)/(high-low)*603;
z+=`<text x="11" y="${y+13}" fill="#c6d6e4">${escapeHTML(t.name)}</text><line x1="${x0}" x2="${x}" y1="${y+9}" y2="${y+9}" stroke="${c}" stroke-width="15" opacity=".32"/>`;
if(Number.isFinite(xlo)&&Number.isFinite(xhi))z+=`<line x1="${xlo}" x2="${xhi}" y1="${y+9}" y2="${y+9}" stroke="${c}" stroke-width="2.6"/><line x1="${xlo}" x2="${xlo}" y1="${y+3}" y2="${y+15}" stroke="${c}"/><line x1="${xhi}" x2="${xhi}" y1="${y+3}" y2="${y+15}" stroke="${c}"/>`;
z+=`<circle cx="${x}" cy="${y+9}" r="5.5" fill="${c}"/>`;
});return z+`</svg><div class="legend"><span><i style="background:var(--mint)"></i>Focused policy</span><span><i style="background:var(--violet)"></i>Other policies</span><span>● DR point estimate, whiskers: conditional bootstrap 95%</span></div>`;
}
function tableHTML(table){return `<div class="table-scroll"><table class="data-table"><thead><tr><th>POLICY</th><th>IPS</th><th>SNIPS</th><th>DM</th><th>DR</th><th>95% bootstrap interval</th><th>ESS / n</th><th>DIAGNOSTIC</th></tr></thead><tbody>${table.map(t=>`<tr><td><b>${escapeHTML(t.name)}</b></td><td>${pct(t.ips)}</td><td>${pct(t.snips)}</td><td>${pct(t.dm)}</td><td class="highlight">${pct(t.dr)}</td><td>${pct(t.ci[0])} – ${pct(t.ci[1])}</td><td>${f(t.pctESS,1)}%</td><td><span class="tag ${status(t)}">${statusLabel(t)}</span></td></tr>`).join('')}</tbody></table></div>`;}
function alertsBox(){return state.analysis.globalWarnings.length?state.analysis.globalWarnings.map(t=>`<div class="alert">⚠ ${escapeHTML(t)}</div>`).join(''):notice('No global diagnostics triggered. Absence of flags is not evidence of causal validity.','');}
function datasetInfo(){return `Dataset: <b>${escapeHTML(state.metadata.name)}</b> · ${state.rows.length} observations · ${escapeHTML(state.metadata.source)}`;}
function overview(){const a=state.analysis,m=getFocus(),best=a.best;
return `<div class="grid kpis">${stat('Logged conversion',pct(a.loggedRate),'Observed factual outcomes')}${stat('Focused DR estimate',pct(m.dr),`Policy: ${escapeHTML(m.name)}`)}${stat('Effective sample size',f(m.ess,0),`${f(m.pctESS,1)}% of ${a.rows.length} observations`)}${stat('Evidence screen',statusLabel(m),m.support.length?m.support[0]:'Policy-level overlap heuristic')}</div>
${section('Executive intelligence','A transparent comparison of the five target decision policies')}
<div class="grid cols">${panel('Off-policy value comparison','Doubly robust estimates and conditional bootstrap uncertainty',chartPolicy(a.table))}${panel('Decision snapshot','Key diagnostics for selected policy',`<div class="metric-list"><div class="metric-row"><span>IPS estimator</span><span class="val">${pct(m.ips)}</span></div><div class="metric-row"><span>Self-normalized IPS</span><span class="val">${pct(m.snips)}</span></div><div class="metric-row"><span>Direct method (DM)</span><span class="val">${pct(m.dm)}</span></div><div class="metric-row"><span>Doubly robust (DR)</span><span class="val">${pct(m.dr)}</span></div><div class="metric-row"><span>Maximum importance weight</span><span class="val">${f(m.maxWeight,2)}×</span></div><div class="metric-row"><span>Approximate bootstrap interval</span><span class="val">${pct(m.ci[0])} – ${pct(m.ci[1])}</span></div></div>${notice(best?`Highest screened DR point estimate: ${best.name}. This is not an automatically approved rollout.`:'No policy met the preliminary support screen. Collect additional data before a rollout.','warning')}`)}</div>
${section('Reliability first','Screen the evidence before making a decision')}${panel('Statistical validity monitor',datasetInfo(),alertsBox()+notice('The confidence intervals are illustrative percentile bootstraps holding cross-fitted outcome predictions fixed. They do not account for all sources of uncertainty, logging confounding, or nonstationarity.',''))}`;
}
function laboratory(){const a=state.analysis,m=getFocus();return `${section('Policy laboratory','Compare estimators and examine the assumptions for every target policy')}${panel('Five alternative policies',datasetInfo(),tableHTML(a.table))}<div class="grid cols" style="margin-top:16px">${panel('Estimator agreement','Smaller differences are reassuring but not proof of validity',`${block('IPS',pct(m.ips),m.ips*100)}${block('SNIPS',pct(m.snips),m.snips*100)}${block('DM',pct(m.dm),m.dm*100)}${block('DR',pct(m.dr),m.dr*100)}${notice('The outcome model is fitted on the other parity fold with smoothed segment-by-action rates. IPS and DR require correctly logged propensities and no unmeasured confounding.','')}`)}${panel('Current policy specification','Probabilities of assigning action B by segment',`<div class="metric-row"><span>High segment → B</span><span class="val">${pct(E.policyProb({segment:'high'},state.policy))}</span></div>${block('High',pct(E.policyProb({segment:'high'},state.policy)),100*E.policyProb({segment:'high'},state.policy))}<div class="metric-row" style="margin-top:20px"><span>Low segment → B</span><span class="val">${pct(E.policyProb({segment:'low'},state.policy))}</span></div>${block('Low',pct(E.policyProb({segment:'low'},state.policy)),100*E.policyProb({segment:'low'},state.policy))}<p class="hint">All policies apply to the same historical records. We do not claim that their estimated rankings guarantee better performance in deployment.</p>`)}</div>`;}
function reliability(){const a=state.analysis,m=getFocus(),ct=a.counts;
const groupHTML=['high','low'].map(s=>`<div class="control-row"><div class="metric-row"><strong>${s.toUpperCase()} SEGMENT</strong><span>${ct[s].A+ct[s].B} records</span></div>${block('Logged A',`${ct[s].A} (${pct(ct[s].A/(ct[s].A+ct[s].B||1))})`,100*ct[s].A/(ct[s].A+ct[s].B||1))}${block('Logged B',`${ct[s].B} (${pct(ct[s].B/(ct[s].A+ct[s].B||1))})`,100*ct[s].B/(ct[s].A+ct[s].B||1))}</div>`).join('');
const rows=E.stress(state.rows,state.cap,state.policy);
return `${section('Overlap & reliability','Understand whether the historical log supports the decision you want to evaluate')}<div class="grid small-cards"><div class="panel small-card"><div class="t">Effective sample size</div><div class="v">${f(m.ess,0)} / ${m.n}</div><div class="tiny">Weighted ESS; higher is usually more stable</div></div><div class="panel small-card"><div class="t">Largest importance weight</div><div class="v">${f(m.maxWeight,2)}×</div><div class="tiny">Focus policy / capped weights</div></div><div class="panel small-card"><div class="t">Zero-support cells</div><div class="v">${m.support.length}</div><div class="tiny">Segment/action pairs needed but absent</div></div></div><div class="grid cols" style="margin-top:17px">${panel('Observed action coverage','Counts are shown by context and logged action',groupHTML)}${panel('Reliability assessment',escapeHTML(m.name),`${m.support.length?m.support.map(s=>notice(s,'danger')).join(''):notice('Both actions are observed in each segment required by this policy. Support counts alone do not ensure reliable estimation.','')}<div class="metric-list" style="margin-top:16px"><div class="metric-row"><span>Weighted ESS ratio</span><span class="val">${f(m.pctESS,1)}%</span></div><div class="mini-bar"><div style="width:${Math.min(100,m.pctESS)}%"></div></div><div class="metric-row"><span>Minimum logged propensity</span><span class="val">${f(Math.min(...state.rows.map(r=>r.propensity)),3)}</span></div></div>${alertsBox()}`)}</div>
${section('Weight-capping sensitivity','Large changes after clipping indicate potentially unstable estimates')}${panel('Sensitivity table','This reruns IPS and DR with capped target importance weights',`<div class="table-scroll"><table class="data-table"><thead><tr><th>MAX WEIGHT</th><th>IPS</th><th>DR</th><th>ESS %</th><th>MAX OBSERVED WEIGHT</th></tr></thead><tbody>${rows.map(x=>`<tr><td>${x.cap==='None'?'No cap':x.cap+'×'}</td><td>${pct(x.ips)}</td><td class="highlight">${pct(x.dr)}</td><td>${f(x.pctESS,1)}%</td><td>${f(x.maxWeight,2)}×</td></tr>`).join('')}</tbody></table></div>${notice('Weight clipping is a sensitivity analysis, not a free statistical fix. It changes the estimand or introduces bias.','warning')}`)}`;
}
function segments(){const a=state.analysis,rows=E.segmentMetrics(state.rows,a.qs,state.policy,state.cap);return `${section('Segment explorer','Understand performance across two synthetic context groups')}<div class="grid split-2">${rows.map(r=>panel(`${r.segment.toUpperCase()} segment`,'Policy: '+E.POLICY_NAMES[state.policy],`<div class="stat-callout">${pct(r.dr)}</div><p class="hint">Segment-specific DR point estimate</p><div class="metric-list"><div class="metric-row"><span>Records</span><span class="val">${r.n}</span></div><div class="metric-row"><span>IPS</span><span class="val">${pct(r.ips)}</span></div><div class="metric-row"><span>SNIPS</span><span class="val">${pct(r.snips)}</span></div><div class="metric-row"><span>Effective sample size</span><span class="val">${f(r.ess,0)} (${f(r.pctESS,1)}%)</span></div></div>${r.support.length?notice(r.support[0],'danger'):notice('Required segment/action pairs are observed. Small groups may still be unstable.','')}`)).join('')}</div>${section('Group disparity','Observed difference is descriptive, not evidence of discriminatory treatment')}${panel('Difference in DR point estimates','High segment minus low segment',`<div class="stat-callout">${pct(rows[0].dr-rows[1].dr)}</div><p class="hint">Differences may reflect unequal context, underlying response rates, logging policy and sampling. A causal or fairness interpretation requires more evidence.</p>`)}`;}
function economics(){const m=getFocus(),rate=m.dr,logged=state.analysis.loggedRate,n=state.population;const avgPB=(state.rows.reduce((s,r)=>s+E.policyProb(r,state.policy),0)/state.rows.length);const impliedB=avgPB*n;const netPolicy=n*rate*state.value-impliedB*state.costB;const netLogged=n*logged*state.value-n*(state.rows.filter(x=>x.action==='B').length/state.rows.length)*state.costB;const delta=netPolicy-netLogged;
const money=x=>Number.isFinite(x)?new Intl.NumberFormat('en-US',{style:'currency',currency:'USD',maximumFractionDigits:0}).format(x):'N/A';
return `${section('Decision scenarios','Translate uncertain rates into illustrative financial outcomes')}
<div class="grid cols">${panel('Scenario assumptions','Change the inputs to update all calculated values',`<div class="control-row"><label for="population">Projected decisions: <span id="popVal">${n.toLocaleString('en-US')}</span></label><input id="population" type="range" min="1000" max="150000" step="1000" value="${n}"/></div><div class="control-row"><label for="value">Value per successful outcome (USD)</label><input id="value" type="number" min="0" max="100000" step="1" value="${state.value}" /></div><div class="control-row"><label for="costB">Incremental cost per B decision (USD)</label><input id="costB" type="number" min="0" max="100000" step="0.5" value="${state.costB}" /></div>${notice('This is a scenario calculation, not a forecast. Do not use it to make deployment decisions without additional experimental validation.','warning')}`)}${panel('Economic illustration','Calculated from the selected policy point estimate',`<div class="metric-row"><span>Policy net value</span><span class="val">${money(netPolicy)}</span></div><div class="stat-callout">${money(delta)}</div><p class="hint">Illustrative difference vs logged-policy baseline</p><div class="metric-list"><div class="metric-row"><span>Policy success rate (DR)</span><span class="val">${pct(rate)}</span></div><div class="metric-row"><span>Baseline observed rate</span><span class="val">${pct(logged)}</span></div><div class="metric-row"><span>Estimated B share</span><span class="val">${pct(avgPB)}</span></div><div class="metric-row"><span>Expected number of B decisions</span><span class="val">${f(impliedB,0)}</span></div></div>${m.pctESS<15?notice('Fragile overlap: the financial figure is highly uncertain.','danger'):''}`)}</div>`;
}
function dataView(){return `${section('Data & methodology','Analyze local CSV records and export reproducible reports')}
<div class="grid cols">${panel('Import your own dataset','File processing happens locally in the browser',`<div class="drop"><strong>CSV with 20 to 5,000 rows</strong><input aria-label="Choose a CSV file" id="csvFile" type="file" accept=".csv,text/csv"/><p class="hint">Required columns: segment, action, reward, propensity</p><p class="tiny">Two contexts: high / low · Two actions: A / B · Binary reward 0 / 1 · Propensity = probability of the logged action.</p></div><div class="controls" style="margin-top:12px"><button id="template" class="btn">↓ CSV template</button><button id="downloadRows" class="btn">↓ Current observations</button></div>${notice('CSV may contain sensitive data. Never commit private datasets to a public Space. Uploaded rows stay in your current browser session until you leave or reset.','')}`)}${panel('Reproducibility & method','Transparent modeling assumptions',`<div class="metric-list"><div class="metric-row"><span>Estimator family</span><span class="val">IPS / SNIPS / DM / DR</span></div><div class="metric-row"><span>Outcome model</span><span class="val">2-fold cross-fitted</span></div><div class="metric-row"><span>Calibration</span><span class="val">Smoothed group rates</span></div><div class="metric-row"><span>Confidence interval</span><span class="val">Bootstrap (180 reps)</span></div><div class="metric-row"><span>Missing values</span><span class="val">Rejected on import</span></div><div class="metric-row"><span>External network calls</span><span class="val">None</span></div></div>${notice('Cross-fitting alone cannot fix hidden confounding, incorrect logged propensities, interference, nonstationarity or missing action support. The bootstraps are descriptive approximations only.','warning')}`)}</div>
${section('Exports','Create a dataset snapshot or share methodology and summary results')}${panel('Reproducible artifacts',datasetInfo(),`<div class="controls"><button id="downloadJson2" class="btn primary">↓ Analysis JSON</button><button id="downloadTable" class="btn">↓ Policy estimates CSV</button><button id="downloadMd" class="btn">↓ Markdown brief</button><button id="downloadRows2" class="btn">↓ Dataset CSV</button></div><p class="hint">Reports include source metadata, policy assumptions, model diagnostics and estimates; individual observation records are exported only through the Dataset CSV action.</p>`)}
${section('CSV schema','Column names are case-insensitive; actions and segment values must match exactly')}${panel('Input format example','propensity is P(logged action | context), never simply P(B)',`<pre class="codebox">id,segment,action,reward,propensity\n1,high,B,1,0.70\n2,high,A,0,0.30\n3,low,A,1,0.80\n4,low,B,0,0.20\n...at least 20 observations</pre>`)}`;}
function buildReport(){const a=state.analysis;
return {project:'PolicyForge 2.0',project_number:33,created_at:new Date().toISOString(),data:{name:state.metadata.name,source:state.metadata.source,rows:state.rows.length,seed:state.metadata.seed??null},settings:{selected_policy:state.policy,cap:Number.isFinite(state.cap)?state.cap:'uncapped',population:state.population,value_per_success_usd:state.value,cost_per_b_usd:state.costB},diagnostics:{counts:a.counts,logged_rate:a.loggedRate,warnings:a.globalWarnings},estimators:a.table.map(t=>({policy:t.key,ips:safeNum(t.ips),snips:safeNum(t.snips),dm:safeNum(t.dm),dr:safeNum(t.dr),bootstrap_95_ci:t.ci.map(safeNum),effective_n:t.ess,effective_n_ratio:t.pctESS,largest_weight:t.maxWeight,support_warnings:t.support})),methods:{q_model:'2-fold cross-fitted smoothed segment-by-action empirical rates',bootstrap:'Conditional percentile resampling, 180 repetitions with fixed nuisance predictions',assumptions:['known correct logging propensities','conditional exchangeability/no hidden confounding','positivity','stable data-generating process'],limitations:['synthetic samples illustrative','bootstrap intervals do not include outcome model re-fitting','not a live or distributed system','no guaranteed uplift']}};
}
function reportMd(){const r=buildReport();return `# PolicyForge 2.0 — Evaluation brief\n\nDataset: ${r.data.name} (${r.data.source}); n=${r.data.rows}.\nGenerated (UTC): ${r.created_at}.\nFocus policy: ${r.settings.selected_policy}.\n\n## Policy estimates\n\n| Policy | IPS | SNIPS | DM | DR | ESS% | Bootstrap 95% CI |\n|---|---:|---:|---:|---:|---:|---|\n${r.estimators.map(t=>`| ${t.policy} | ${pct(t.ips)} | ${pct(t.snips)} | ${pct(t.dm)} | ${pct(t.dr)} | ${f(t.effective_n_ratio,1)} | ${pct(t.bootstrap_95_ci[0])} to ${pct(t.bootstrap_95_ci[1])} |`).join('\n')}\n\n## Warnings\n\n${r.diagnostics.warnings.length?r.diagnostics.warnings.map(s=>'- '+s).join('\n'):'- No automated warnings. This does not certify causal validity.'}\n\n## Method and caveats\n\nTwo-fold cross-fitted smoothed group/action outcome rates. IPS, SNIPS, DM, DR. Fixed-nuisance percentile bootstrap (180 draws), not a fully valid general inferential guarantee. Correct propensities, positivity and no unmeasured confounding are required for causal interpretation. This tool is educational, not a recommendation to deploy a policy.\n`;}
function download(name,content,type){const blob=new Blob([content],{type}),url=URL.createObjectURL(blob),el=document.createElement('a');el.href=url;el.download=name;document.body.append(el);el.click();el.remove();setTimeout(()=>URL.revokeObjectURL(url),3000);toast('Downloaded '+name);}
function toast(s,error=false){let box=by('toast');if(!box){box=document.createElement('div');box.id='toast';box.setAttribute('role','status');box.style.cssText='position:fixed;bottom:24px;right:22px;z-index:99;max-width:380px;padding:14px 20px;border-radius:10px;background:#183c37;border:1px solid #63c4a9;color:#e8fff6;font-size:13px;box-shadow:0 14px 40px #0009;';document.body.append(box);}box.style.background=error?'#51262e':'#183c37';box.style.borderColor=error?'#c56c7a':'#63c4a9';box.textContent=s;}
function render(){state.analysis=E.analyze(state.rows,state.cap);const a=state.analysis;
const fns={overview,laboratory,reliability,segments,economics,data:dataView};for(const [id] of views){const el=by(id);if(id===state.view){el.innerHTML=fns[id]();el.hidden=false;}else el.hidden=true;}
for(const b of document.querySelectorAll('[data-page]')){const is=b.dataset.page===state.view;b.classList.toggle('active',is);b.setAttribute('aria-current',is?'page':'false');}
}
function setView(v){if(!views.find(t=>t[0]===v))return;state.view=v;render();}
function loadDemo(k){const d=E.makeDemo(k);state.dataset=k;state.rows=d.rows;state.metadata=d.metadata;state.cap=Infinity;state.policy='context';by('policy').value='context';by('dataset').value=k;render();toast('Loaded '+d.metadata.name);}
function init(){for(const [id,ico,name] of views){const d=document.createElement('button');d.className='nav-btn';d.dataset.page=id;d.innerHTML=`<span aria-hidden="true">${ico}</span><span class="nav-text">${name}</span>`;by('desktopNav').append(d);const m=document.createElement('button');m.dataset.page=id;m.textContent=name;by('mobileNav').append(m);}
by('dataset').addEventListener('change',ev=>{if(ev.target.value!=='custom')loadDemo(ev.target.value);});by('policy').addEventListener('change',ev=>{state.policy=ev.target.value;render();});by('reset').addEventListener('click',()=>loadDemo(['retail','service','overlap'].includes(state.dataset)?state.dataset:'retail'));
document.addEventListener('click',ev=>{
const btn=ev.target.closest('button');if(!btn)return;
if(btn.dataset.page){setView(btn.dataset.page);return;}
const id=btn.id;
try{
if(id==='downloadJson'||id==='downloadJson2')download('policyforge-analysis.json',JSON.stringify(buildReport(),null,2),'application/json');
else if(id==='downloadMd')download('policyforge-methodology.md',reportMd(),'text/markdown');
else if(id==='downloadTable'){const a=state.analysis;download('policyforge-policy-estimates.csv',E.toCSV(a.table.map(x=>({policy:x.key,ips:x.ips,snips:x.snips,dm:x.dm,dr:x.dr,ci_low:x.ci[0],ci_high:x.ci[1],effective_n:x.ess,ess_pct:x.pctESS,largest_weight:x.maxWeight,flags:x.support.join(';')})),['policy','ips','snips','dm','dr','ci_low','ci_high','effective_n','ess_pct','largest_weight','flags']),'text/csv');}
else if(id==='downloadRows'||id==='downloadRows2')download('policyforge-dataset.csv',E.toCSV(state.rows,['id','segment','action','reward','propensity']),'text/csv');
else if(id==='template'){const t=E.makeDemo('retail').rows.slice(0,25);download('policyforge-template.csv',E.toCSV(t,['id','segment','action','reward','propensity']),'text/csv');}
}catch(e){toast(e.message,true);}
});
document.addEventListener('input',ev=>{
const id=ev.target.id;if(id==='population'){state.population=Number(ev.target.value);render();}
});
document.addEventListener('change',async ev=>{
const id=ev.target.id;
if(id==='value'||id==='costB'){
const n=Number(ev.target.value);if(!Number.isFinite(n)||n<0||n>100000){toast('Enter a value between $0 and $100,000.',true);render();return;}
state[id]=n;render();
}
if(id==='csvFile'){
try{const file=ev.target.files?.[0];if(!file)return;if(file.size>1600000)throw new Error('File exceeds 1.6 MB maximum.');
const rows=E.parseCSV(await file.text());state.rows=rows;state.metadata={name:file.name.slice(0,100),note:'User-supplied data',source:'Local user CSV'};state.dataset='custom';const sel=by('dataset');if(!sel.querySelector('option[value="custom"]')){const o=document.createElement('option');o.value='custom';o.textContent='04 / Imported data';sel.append(o);}sel.value='custom';state.cap=Infinity;render();toast(`Imported ${rows.length} observations. Nothing was uploaded to a server.`);
}catch(e){toast('CSV rejected: '+e.message,true);ev.target.value='';}
}
});render();}
try{init();}catch(e){document.querySelector('main').insertAdjacentHTML('afterbegin','<p class="note danger">Application initialization failed. Inspect browser console.</p>');console.error(e);}
})();
</script></body></html>