import { VERSION, parseCSV, validateRows, evaluate, fairness, rocCurve, thresholdSweep, bootstrapInterval, syntheticRows, toCSV, round } from './core.mjs'; const $ = id => document.getElementById(id); const state = { preset:'support', rows:syntheticRows('support'), threshold:.5, source:'synthetic', fileName:null, sweep:[], current:null, group:null, interval:null, page:'overview' }; const pct = v => v === null || !Number.isFinite(v) ? '—' : `${(v*100).toFixed(1)}%`; const score = v => v === null || !Number.isFinite(v) ? '—' : v.toFixed(3); const number = n => n.toLocaleString('en-US'); const h = t => String(t).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const color = {green:'#b4f887',purple:'#b2a1ff',blue:'#70d5f7',amber:'#ffc277',dim:'#566d7b',grid:'#2b3c47'}; let toastTimer; function toast(message,isError=false){ const el=$('toast');el.textContent=message;el.className=`toast show${isError?' error':''}`;clearTimeout(toastTimer);toastTimer=setTimeout(()=>el.classList.remove('show'),3900); } function alertMessage(message){const el=$('globalAlert');el.textContent=message;el.hidden=false;window.scrollTo({top:0,behavior:'smooth'});} function clearAlert(){$('globalAlert').hidden=true;} function svgBase(content,label=''){return `${content}`;} function svgAxes(xlabel,ylabel){let out='';for(let i=0;i<=5;i++){const x=48+(620/5)*i,y=230-(196/5)*i;out+=`${(i/5).toFixed(1)}${(i/5).toFixed(1)}`;}return out+`${h(xlabel)}${h(ylabel)}`;} const X = x=>48+620*x, Y=y=>230-196*y; function pathFor(points, xkey, ykey){let active=false;return points.map(p=>{const x=p[xkey],y=p[ykey];if(y===null || !Number.isFinite(y)){active=false;return '';}const prefix=active?'L':'M';active=true;return `${prefix}${X(x).toFixed(2)},${Y(y).toFixed(2)}`;}).filter(Boolean).join(' ');} function renderRoc(){const points=rocCurve(state.rows);let content=svgAxes('False positive rate','True positive rate');content+=``;if(points.length){content+=``;const tail=points[points.length-1];content+=``;}else content+=`ROC undefined: only one ground-truth class`;$('rocChart').innerHTML=svgBase(content,'ROC curve');} function renderCalibration(){const bins=state.current.bins;let content=svgAxes('Mean predicted score','Observed positive frequency');content+=``;const active=bins.filter(b=>b.n);content+=active.map(b=>`Bin ${b.bin}: n=${b.n}, mean score=${score(b.confidence)}, observed=${pct(b.outcome)}`).join('');if(active.length>1)content+=``;$('calChart').innerHTML=svgBase(content,'Calibration chart');} function renderThreshold(){const sw=state.sweep;let content=svgAxes('Decision threshold','Metric score');for(const [key,tone] of [['f1','green'],['precision','purple'],['recall','blue']])content+=``;content+=``;const y = state.current.f1===null?.5:state.current.f1;content+=``;$('thresholdChart').innerHTML=svgBase(content,'F1, precision and recall versus threshold');} function renderInsights(){const m=state.current,f=state.group;const notes=[];if(m.n<100)notes.push(['Small evaluation set',`Only ${number(m.n)} rows are available. Estimates may be unstable.`,true]);if(m.auc===null)notes.push(['Ranking unavailable','ROC AUC requires both positive and negative ground-truth observations.',true]);else if(m.auc<.6)notes.push(['Limited discrimination',`ROC AUC is ${score(m.auc)}. Check signal quality and whether the scores rank outcomes usefully.`,true]);else notes.push(['Discrimination snapshot',`ROC AUC of ${score(m.auc)} describes ranking on this dataset, not performance in production.`,false]);if(m.ece>=.10)notes.push(['Calibration needs review',`Estimated calibration error is ${pct(m.ece)} across ten equal-width bins.`,true]);else notes.push(['Calibration diagnostic',`Estimated calibration error is ${pct(m.ece)}. Validate calibration on an independent sample.`,false]);if(f.groups.length<2)notes.push(['One observed cohort','At least two groups are required for between-cohort comparisons.',true]);else if(!f.reliable)notes.push(['Cohort uncertainty','Some cohorts have fewer than 30 records or lack a required observed outcome class. Gaps need cautious interpretation.',true]);else notes.push(['Cohort differences',`Observed positive decision rate gap is ${pct(f.demographicParityGap)} across ${f.groups.length} cohorts; this is not a fairness verdict.`,f.demographicParityGap>.15]);if(m.prevalence<.1||m.prevalence>.9)notes.push(['Class imbalance',`Only ${pct(m.prevalence)} of the observed records are positive. Accuracy alone may be misleading.`,true]);$('insightList').innerHTML=notes.slice(0,4).map(([title,body,warn])=>`
${warn?'!':'✓'}
${h(title)}

${h(body)}

`).join('');} function renderGroupBars(){const groups=state.group.groups;const max=Math.max(.01,...groups.map(g=>g.positiveRate));$('groupBars').innerHTML=groups.map(g=>`
${h(g.group)}${g.n<30?' ⚠':''}
${pct(g.positiveRate)}
`).join('');} function renderTable(){const threshold=state.threshold;const rows=[...state.sweep].sort((a,b)=>Math.abs(a.threshold-threshold)-Math.abs(b.threshold-threshold)).slice(0,9).sort((a,b)=>a.threshold-b.threshold);$('sweepBody').innerHTML=rows.map(r=>`${r.threshold.toFixed(2)}${pct(r.f1)}${pct(r.precision)}${pct(r.recall)}${pct(r.accuracy)}${pct(r.positiveRate)}`).join('');$('groupsBody').innerHTML=state.group.groups.map(g=>`${h(g.group)}${g.n<30?' ⚠':''}${number(g.n)}${pct(g.prevalence)}${pct(g.positiveRate)}${pct(g.recall)}${pct(g.fpr)}${pct(g.accuracy)}`).join('');} function renderProfile(){const m=state.current;const groups=state.group.groups;$('dataProfile').innerHTML=[['Records',number(m.n)],['Cohorts',number(groups.length)],['Positive labels',pct(m.prevalence)],['Score range',`${Math.min(...state.rows.map(x=>x.score)).toFixed(2)}–${Math.max(...state.rows.map(x=>x.score)).toFixed(2)}`],['Positive decisions',number(m.tp+m.fp)],['Data origin',state.source==='synthetic'?'Synthetic':'CSV import']].map(([k,v])=>`
${h(k)}${h(v)}
`).join('');} function updatePage(){document.querySelectorAll('[data-page]').forEach(btn=>{const active=btn.dataset.page===state.page;btn.classList.toggle('active',active);active?btn.setAttribute('aria-current','page'):btn.removeAttribute('aria-current');});document.querySelectorAll('.page').forEach(p=>p.hidden=p.id!==`page-${state.page}`);} function renderAll(){const m=state.current=evaluate(state.rows,state.threshold), f=state.group=fairness(state.rows,state.threshold);$('thresholdValue').textContent=state.threshold.toFixed(2);$('thresholdRange').value=Math.round(state.threshold*100);$('metricAccuracy').textContent=pct(m.accuracy);$('metricF1').textContent=score(m.f1);$('metricAuc').textContent=score(m.auc);$('metricBrier').textContent=score(m.brier);$('metricPrecision').textContent=pct(m.precision);$('metricRecall').textContent=pct(m.recall);$('metricSpecificity').textContent=pct(m.specificity);$('metricBalanced').textContent=pct(m.balancedAccuracy);$('tpCount').textContent=number(m.tp);$('fpCount').textContent=number(m.fp);$('tnCount').textContent=number(m.tn);$('fnCount').textContent=number(m.fn);$('confusionFoot').textContent=`Predicted positive at score ≥ ${state.threshold.toFixed(2)}. Total = ${number(m.n)} observations.`;$('sampleSize').textContent=`${number(m.n)} OBSERVATIONS`;$('aucTag').textContent=score(m.auc);$('eceTag').textContent=score(m.ece);$('gapParity').textContent=pct(f.demographicParityGap);$('gapRecall').textContent=pct(f.equalOpportunityGap);$('gapFpr').textContent=pct(f.falsePositiveRateGap);$('minGroup').textContent=number(f.minimumGroupN);$('fairnessWarning').className=`fairness-warning${f.reliable?' good':''}`;$('fairnessWarning').textContent=f.groups.length<2?'Only one group is present: between-cohort parity comparisons are undefined.':f.reliable?'All cohorts have at least 30 rows and both outcome classes. Statistical uncertainty and context still matter.':'Caution: one or more groups have fewer than 30 records, or one group lacks an observed outcome class. Some conditional rate gaps may be undefined or unstable.';renderRoc();renderCalibration();renderThreshold();renderInsights();renderGroupBars();renderTable();renderProfile();const eligible=state.sweep.filter(s=>s.f1!==null).sort((a,b)=>b.f1-a.f1 || a.threshold-b.threshold);const best=eligible[0];$('bestThreshold').textContent=best?best.threshold.toFixed(2):'N/A';$('bestNote').textContent=best?`Highest displayed F1: ${pct(best.f1)} at cutoff ${best.threshold.toFixed(2)}. Calculated on the same data; not validated for deployment.`:'No defined F1 score is available for this sample.';$('applyBest').disabled=!best;state.interval=null;$('bootstrapResult').textContent='Awaiting calculation';} function loadRows(rows,source,fileName){state.rows=validateRows(rows);state.source=source;state.fileName=fileName||null;state.threshold=.5;state.sweep=thresholdSweep(rows);if(source==='uploaded'){$('datasetSelect').value='uploaded';$('datasetMeta').textContent=`CSV · ${number(rows.length)} rows`;$('modeNotice').innerHTML='Imported dataset. Evaluation happens entirely in your browser. Verify labels, consent and data provenance before interpreting results.';}else{$('datasetSelect').value=state.preset;$('datasetMeta').textContent=`Synthetic · ${number(rows.length)} records`;$('modeNotice').innerHTML='Demonstration mode. All records are fictional and algorithmically generated. No ML training happens in this application.';}renderAll();clearAlert();} function showPage(page){if(!['overview','threshold','fairness','data'].includes(page))return;state.page=page;updatePage();window.scrollTo({top:0,behavior:'smooth'});} function saveFile(filename,content,type){const blob=new Blob([content],{type});const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download=filename;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),5000);toast(`Saved ${filename}`);} function safeReport(){const m=state.current,f=state.group;const summary={app:'PrismAudit',version:VERSION,project:24,createdAt:new Date().toISOString(),dataSource:state.source,fileName:state.fileName,recordCount:state.rows.length,threshold:state.threshold,metrics:{n:m.n,tp:m.tp,fp:m.fp,tn:m.tn,fn:m.fn,prevalence:m.prevalence,accuracy:m.accuracy,precision:m.precision,recall:m.recall,specificity:m.specificity,f1:m.f1,balancedAccuracy:m.balancedAccuracy,fpr:m.fpr,fnr:m.fnr,positiveRate:m.positiveRate,auc:m.auc,brier:m.brier,ece:m.ece},calibration:m.bins,fairness:{demographicParityGap:f.demographicParityGap,equalOpportunityGap:f.equalOpportunityGap,falsePositiveRateGap:f.falsePositiveRateGap,reliable:f.reliable,groups:f.groups.map(({group,n,prevalence,positiveRate,recall,fpr,accuracy})=>({group,n,prevalence,positiveRate,recall,fpr,accuracy}))},thresholdSweep:state.sweep,bootstrap:state.interval,disclaimer:'Descriptive audit only. Not training, causal assessment, compliance certification or deployment recommendation. Missing rates are represented as null. Synthetic examples are fictional.'};return summary;} async function handleFile(file){if(!file)return;if(file.size>2*1024*1024){toast('File exceeds the 2 MB limit.',true);return;}if(!file.name.toLowerCase().endsWith('.csv')){toast('Please use a .csv file.',true);return;}try{const rows=parseCSV(await file.text());let custom=$('datasetSelect').querySelector('option[value="uploaded"]');if(!custom){custom=document.createElement('option');custom.value='uploaded';$('datasetSelect').appendChild(custom);}custom.textContent=`Imported: ${file.name.slice(0,28)}`;loadRows(rows,'uploaded',file.name);toast(`Imported ${number(rows.length)} records. Nothing was uploaded to a server.`);showPage('overview');}catch(error){toast(error.message,true);alertMessage(`Import failed: ${error.message}`);}finally{$('fileInput').value='';}} document.querySelectorAll('[data-page]').forEach(btn=>btn.addEventListener('click',()=>showPage(btn.dataset.page))); $('thresholdRange').addEventListener('input',event=>{state.threshold=Number(event.target.value)/100;renderAll();}); $('resetThreshold').addEventListener('click',()=>{state.threshold=.5;renderAll();}); $('datasetSelect').addEventListener('change',event=>{if(event.target.value==='uploaded')return;state.preset=event.target.value;loadRows(syntheticRows(state.preset),'synthetic');toast(`Switched to ${event.target.options[event.target.selectedIndex].text}.`);}); $('applyBest').addEventListener('click',()=>{const best=state.sweep.filter(s=>s.f1!==null).sort((a,b)=>b.f1-a.f1 || a.threshold-b.threshold)[0];if(!best)return;state.threshold=best.threshold;renderAll();toast(`Evaluating cutoff ${best.threshold.toFixed(2)}. This is not a deployment recommendation.`);}); $('runBootstrap').addEventListener('click',()=>{const btn=$('runBootstrap');btn.disabled=true;$('bootstrapResult').textContent='Calculating 240 seeded resamples…';setTimeout(()=>{try{const res=state.interval=bootstrapInterval(state.rows,state.threshold);$('bootstrapResult').textContent=res.lower===null?'Interval unavailable: too many undefined replicate metrics.':`95% percentile interval for F1: [${score(res.lower)}, ${score(res.upper)}] · ${res.valid}/${res.repeats} valid repeats · seed 24`;}catch(error){toast(error.message,true);$('bootstrapResult').textContent=error.message;}finally{btn.disabled=false;}},40);}); $('exportReport').addEventListener('click',()=>saveFile('prismaudit_report.json',JSON.stringify(safeReport(),null,2),'application/json')); $('exportSweep').addEventListener('click',()=>saveFile('prismaudit_thresholds.csv',toCSV(state.sweep,['threshold','f1','accuracy','balancedAccuracy','precision','recall','positiveRate','parityGap']),'text/csv;charset=utf-8')); $('exportGroups').addEventListener('click',()=>saveFile('prismaudit_groups.csv',toCSV(state.group.groups,['group','n','prevalence','positiveRate','recall','fpr','accuracy']),'text/csv;charset=utf-8')); $('downloadSample').addEventListener('click',()=>saveFile('prismaudit_example.csv',toCSV(syntheticRows('support',120),['id','score','label','group']),'text/csv;charset=utf-8')); $('restoreDemo').addEventListener('click',()=>{state.preset='support';loadRows(syntheticRows('support'),'synthetic');toast('Restored fictional example data.');}); $('importTop').addEventListener('click',()=>$('fileInput').click());$('browseData').addEventListener('click',event=>{event.stopPropagation();$('fileInput').click();});$('fileInput').addEventListener('change',event=>handleFile(event.target.files[0])); const drop=$('dropZone');drop.addEventListener('click',()=>$('fileInput').click());drop.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();$('fileInput').click();}});for(const ev of ['dragenter','dragover'])drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add('dragging');});for(const ev of ['dragleave','drop'])drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove('dragging');});drop.addEventListener('drop',e=>handleFile(e.dataTransfer?.files?.[0])); loadRows(state.rows,'synthetic');updatePage();