Download index.html from Hugdrigo/fairnessscope-model-evaluation-studio: direct link, hf CLI and curl.
- Browser
- Download file 36.8 kB
-
https://huggingface.co/spaces/Hugdrigo/fairnessscope-model-evaluation-studio/resolve/main/index.html
- Command line
-
hf download hf://spaces/Hugdrigo/fairnessscope-model-evaluation-studio/index.html
-
curl -L -o index.html https://huggingface.co/spaces/Hugdrigo/fairnessscope-model-evaluation-studio/resolve/main/index.html
36.8 kB
| <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#101d35"><title>FairnessScope | Model Evaluation Studio</title> | |
| <style> | |
| :root{--bg:#f3f6fb;--paper:#fff;--side:#101d35;--side2:#172a49;--ink:#1b2945;--muted:#71819b;--line:#e3eaf3;--soft:#eff3fc;--brand:#5149bd;--brand2:#8e83e7;--good:#1e9c81;--orange:#e7a24e;--red:#c95470;--radius:16px;--shadow:0 18px 40px rgba(27,47,77,.045);color-scheme:light} | |
| *{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;font:13px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;color:var(--ink);background:var(--bg)}button,input,select{font:inherit}button{cursor:pointer}input,select{min-width:0}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #ecb750;outline-offset:2px}a{color:var(--brand)}.shell{min-height:100vh;display:flex}.side{width:235px;background:var(--side);position:fixed;inset:0 auto 0 0;color:#b2c4da;padding:27px 15px;display:flex;flex-direction:column;z-index:12}.logo{display:flex;gap:10px;align-items:center;margin:4px 6px 35px}.logoMark{height:44px;width:44px;display:grid;place-items:center;border-radius:13px;color:#131c44;background:linear-gradient(140deg,#c5c0ff,#76d8dc);font-weight:900;font-size:25px}.logo strong{font-size:19px;color:white;letter-spacing:-.7px}.logo small{display:block;color:#92a9c3;font-size:8px;letter-spacing:1.8px;font-weight:800}.sideHead{color:#7589ad;font-size:9px;font-weight:900;letter-spacing:1.9px;text-transform:uppercase;margin:0 10px 12px}.nav{display:grid;gap:7px}.nav button{color:#acbfd9;display:flex;align-items:center;gap:11px;width:100%;text-align:left;background:transparent;border:0;padding:13px;border-radius:9px;font-weight:700}.nav button:hover{background:#273b60;color:#fff}.nav button.active{background:#2c386c;color:#fff;box-shadow:inset 3px 0 #b8aeff}.nav span{font-size:17px;width:22px;text-align:center}.sidefoot{margin-top:auto;background:#1b2c4b;border:1px solid #304364;border-radius:13px;padding:14px;font-size:11px;line-height:1.7}.sidefoot b{color:#c3c0ff}.main{margin-left:235px;width:calc(100% - 235px);max-width:1800px;padding:25px 35px 45px}.top{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:27px}.crumb{color:var(--muted);font-size:11px;font-weight:700}.crumb b{color:var(--ink)}.actions{display:flex;gap:8px;flex-wrap:wrap}.btn{border:1px solid var(--line);border-radius:10px;background:var(--paper);padding:10px 13px;font-size:11px;font-weight:800;color:var(--ink);white-space:nowrap}.btn:hover{background:var(--soft);border-color:#c2c8ec}.btn.primary{background:var(--brand);border-color:var(--brand);color:#fff}.btn.primary:hover{background:#4035a3}.btn.small{padding:7px 10px;font-size:10px}.btn.danger{color:var(--red)}.header{display:flex;gap:15px;justify-content:space-between;align-items:start;margin-bottom:24px}.eyebrow{color:var(--brand);text-transform:uppercase;letter-spacing:1.9px;font-weight:900;font-size:10px;margin-bottom:8px}.header h1{font-size:clamp(27px,3.3vw,40px);letter-spacing:-1.6px;line-height:1.1;margin:0 0 7px}.header p{margin:0;color:var(--muted);font-size:13px;max-width:780px}.tag{background:#e9f9f5;color:#178b76;border-radius:30px;font-size:10px;font-weight:900;padding:8px 12px;white-space:nowrap}.kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:16px}.kpi,.panel{background:var(--paper);border:1px solid var(--line);box-shadow:var(--shadow);border-radius:var(--radius)}.kpi{padding:18px;min-height:137px}.kTop{display:flex;align-items:center;justify-content:space-between;color:var(--muted);font-size:11px;font-weight:800;gap:8px}.ico{width:34px;height:34px;border-radius:10px;display:grid;place-items:center;background:#f0efff;color:var(--brand);font-size:17px}.kpi strong{display:block;font-size:30px;font-weight:850;letter-spacing:-1px;margin:11px 0 2px}.kpi .note{font-size:10px;color:var(--muted)}.grid{display:grid;grid-template-columns:1.55fr 1fr;gap:15px;margin-bottom:16px}.grid.equal{grid-template-columns:1fr 1fr}.panel{padding:20px;min-width:0}.panelHeader{display:flex;justify-content:space-between;align-items:start;gap:10px;margin-bottom:18px}.panelHeader h2{font-size:16px;margin:0;letter-spacing:-.4px}.panelHeader p{margin:4px 0 0;color:var(--muted);font-size:11px}.chip{border-radius:30px;background:#f0effd;color:var(--brand);font-weight:850;font-size:10px;padding:6px 9px;white-space:nowrap}.plot{width:100%;display:block;overflow:visible}.legend{display:flex;gap:16px;flex-wrap:wrap;font-size:11px;color:var(--muted);margin-top:8px}.dot{height:8px;width:8px;border-radius:50%;display:inline-block;margin-right:6px}.rowBars{display:grid;gap:15px}.barrow{display:grid;grid-template-columns:105px 1fr 55px;gap:9px;align-items:center;font-size:11px}.track{background:#eef0f7;height:10px;border-radius:7px;overflow:hidden}.track i{height:100%;border-radius:7px;display:block}.stats{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px}.stat{padding:15px;border:1px solid var(--line);background:var(--soft);border-radius:12px}.stat b{display:block;font-size:21px;letter-spacing:-.5px}.stat small{font-size:10px;color:var(--muted)}.hint{background:#f3f2ff;color:#464082;border-left:3px solid var(--brand2);font-size:11px;padding:13px;border-radius:7px;line-height:1.65}.tabs{display:flex;gap:7px;flex-wrap:wrap}.tab{border-radius:25px;border:1px solid var(--line);background:var(--paper);padding:9px 13px;color:var(--muted);font-weight:800;font-size:11px}.tab.active{color:#fff;border-color:var(--brand);background:var(--brand)}.control{display:grid;gap:7px;color:var(--muted);font-weight:750;font-size:11px}.control input:not([type=range]),.control select,.control textarea{border:1px solid #d6e0ed;background:var(--paper);color:var(--ink);padding:11px;border-radius:9px;width:100%}.control input[type=range]{accent-color:var(--brand);width:100%;min-height:20px}.controls{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:13px}.hr{border:0;border-top:1px solid var(--line);margin:18px 0}.three{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:13px}.miniStat{padding:14px;border:1px solid var(--line);border-radius:12px}.miniStat b{display:block;font-size:21px}.miniStat small{color:var(--muted)}.tableWrap{width:100%;overflow-x:auto;border:1px solid var(--line);border-radius:12px}table{width:100%;border-collapse:collapse;text-align:left;min-width:600px}th{font-size:10px;letter-spacing:.7px;text-transform:uppercase;color:#8491a5;background:#f7f9fc}th,td{padding:12px 13px;border-bottom:1px solid var(--line)}td{font-size:12px}tr:last-child td{border-bottom:0}tr:hover td{background:#fafbff}.status{padding:4px 8px;border-radius:20px;background:#e6f8f2;color:#1b9577;display:inline-block;font-size:10px;font-weight:800}.status.off{background:#fff0f0;color:#ba6374}.metric{font-variant-numeric:tabular-nums}.empty{color:var(--muted);padding:26px;text-align:center}.labelText{font-size:11px;color:var(--muted)}.footer{color:var(--muted);font-size:10px;margin-top:20px}.formula{font-size:12px;line-height:1.8;color:var(--ink)}.formula code{background:var(--soft);padding:3px 5px;border-radius:4px}.stack{display:grid;gap:12px}.test{display:flex;align-items:start;gap:12px;border:1px solid var(--line);padding:14px;border-radius:12px}.test strong{font-size:12px}.test small{display:block;color:var(--muted);margin-top:4px}.toast{position:fixed;right:22px;bottom:22px;z-index:30;background:#111d35;color:#fff;border-radius:12px;padding:13px 16px;box-shadow:0 18px 55px #11203450;max-width:min(90vw,420px);display:none}.toast.show{display:block}.view{display:none}.view.active{display:block}.hide{display:none!important}.opt{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--muted)}.opt input{accent-color:var(--brand)}.dangerText{color:var(--red)}.goodText{color:var(--good)}body.dark{--bg:#0e1628;--paper:#18243a;--ink:#e6ecfc;--muted:#9aabc3;--line:#2b3a53;--soft:#202e49;--brand:#a89ffa;--brand2:#a89ffa;--shadow:none;color-scheme:dark}body.dark .chip,body.dark .ico{background:#302f59}body.dark th{background:#202b43;color:#aabbd2}body.dark tr:hover td{background:#202b43}body.dark .track{background:#34415a}body.dark .hint{background:#242a54;color:#ddd8ff}body.dark .tag{background:#16453e;color:#a3e9d0}body.dark .status{background:#18443a;color:#a3e6d2}body.dark .status.off{background:#4b2938;color:#ffc0ca} | |
| @media(max-width:1050px){.side{width:78px;padding:20px 10px}.logo{justify-content:center;margin:5px 0 30px}.logo strong,.logo small,.sideHead,.nav button .navName,.sidefoot{display:none}.nav button{justify-content:center}.nav span{width:auto}.main{margin-left:78px;width:calc(100% - 78px);padding:22px}.grid{grid-template-columns:1fr}.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:600px){.shell{display:block}.side{position:sticky;inset:0 0 auto;width:100%;height:auto;padding:9px 12px;display:flex;flex-direction:row;align-items:center;gap:11px;overflow-x:auto}.logo{margin:0;flex:none}.logoMark{width:34px;height:34px;border-radius:9px;font-size:18px}.nav{display:flex;gap:3px}.nav button{width:auto;padding:9px;border-radius:8px;flex:none}.nav span{font-size:16px}.main{margin:0;width:100%;padding:16px 13px 40px}.top{align-items:start;flex-wrap:wrap;margin-bottom:20px}.actions{width:100%}.header{flex-wrap:wrap;gap:10px}.kpis{gap:9px}.kpi{padding:13px;min-height:117px}.kpi strong{font-size:24px}.grid,.grid.equal{grid-template-columns:1fr;gap:11px}.panel{padding:15px}.controls,.three{grid-template-columns:1fr}.stats{grid-template-columns:1fr 1fr}.barrow{grid-template-columns:83px 1fr 47px}.tag{margin-bottom:0}.header h1{font-size:30px}.btn{padding:9px 10px}.kpi .ico{height:28px;width:28px}.panelHeader{flex-wrap:wrap}} | |
| </style></head> | |
| <body><div class="shell"><aside class="side"><div class="logo"><div class="logoMark">◈</div><div><strong>FairnessScope</strong><small>HUGDRIGO · #112</small></div></div><div class="sideHead">Intelligence studio</div><nav class="nav" id="nav"><button class="active" data-view="overview"><span>◫</span><span class="navName">Executive overview</span></button><button data-view="lab"><span>◈</span><span class="navName">Threshold lab</span></button><button data-view="groups"><span>☷</span><span class="navName">Group explorer</span></button><button data-view="records"><span>▦</span><span class="navName">Record library</span></button><button data-view="method"><span>◎</span><span class="navName">Methodology</span></button></nav><div class="sidefoot"><b>EDUCATIONAL SIMULATOR</b><div>Entirely synthetic records. No protected attributes, personal information, APIs or external tracking.</div></div></aside><main class="main"><div class="top"><div class="crumb">Hugdrigo Portfolio / Model Governance / <b>FairnessScope</b></div><div class="actions"><button class="btn" id="themeBtn">◐ Theme</button><button class="btn" id="csvBtn">↓ CSV</button><button class="btn" id="jsonBtn">↓ Backup</button><button class="btn" id="importBtn">↑ Restore JSON</button><input id="fileInput" type="file" accept=".json,application/json" hidden></div></div> | |
| <section class="view active" id="overview"><div class="header"><div><div class="eyebrow">Decision systems · fairness diagnostics</div><h1>Measure the disparity.<br>Understand the trade-off.</h1><p>Explore transparent classification metrics across two synthetic groups. Inspect threshold choices, errors and group outcomes without claiming that any single metric proves fairness.</p></div><div class="tag">● SIMULATED DATA</div></div><div class="kpis" id="kpis"></div><div class="grid"><article class="panel"><div class="panelHeader"><div><h2>Outcome balance</h2><p>Selection rates and true-positive rates at the active threshold</p></div><span class="chip" id="thresholdChip">Threshold 50%</span></div><div class="rowBars" id="mainBars"></div><hr class="hr"><div class="legend"><span><i class="dot" style="background:#6557d7"></i>Group A</span><span><i class="dot" style="background:#29a3aa"></i>Group B</span><span>Equal bars do not imply equal error rates</span></div></article><article class="panel"><div class="panelHeader"><div><h2>Diagnostic summary</h2><p>Absolute gaps between groups</p></div><span class="chip">Percentage points</span></div><div class="stats" id="gaps"></div><div class="hint" style="margin-top:13px">A disparity is a prompt to investigate, not proof of unlawful discrimination. Base rates, labels and measurement quality matter.</div></article></div><div class="grid equal"><article class="panel"><div class="panelHeader"><div><h2>Group confusion matrices</h2><p>Errors and correct predictions by cohort</p></div></div><div class="stats" id="confusions"></div></article><article class="panel"><div class="panelHeader"><div><h2>Threshold sensitivity</h2><p>Compare selection disparity as the decision cutoff moves</p></div></div><svg class="plot" id="overviewChart" viewBox="0 0 560 232" aria-label="Line chart showing group selection rates across thresholds" role="img"></svg><div class="legend"><span><i class="dot" style="background:#6557d7"></i>Group A selection</span><span><i class="dot" style="background:#29a3aa"></i>Group B selection</span></div></article></div></section> | |
| <section class="view" id="lab"><div class="header"><div><div class="eyebrow">Scenario testing</div><h1>Threshold laboratory</h1><p>Set a decision cutoff, assign relative error costs and compare policy choices on the same synthetic population.</p></div><span class="tag">REPRODUCIBLE</span></div><div class="grid"><article class="panel"><div class="panelHeader"><div><h2>Decision controls</h2><p>Changes update every metric immediately</p></div></div><div class="controls"><label class="control">Scenario<select id="scenario"><option value="balanced">Balanced · 50%</option><option value="conservative">Conservative · 70%</option><option value="inclusive">Inclusive · 35%</option><option value="custom">Custom</option></select></label><label class="control">False positive cost (units)<input id="fpCost" type="number" min="1" max="100" step="1" value="2"></label><label class="control">False negative cost (units)<input id="fnCost" type="number" min="1" max="100" step="1" value="4"></label></div><div style="margin-top:20px"><label class="control" for="threshold">Decision threshold: <b id="thresholdRead">50%</b><input type="range" id="threshold" min="5" max="95" step="1" value="50"></label></div><hr class="hr"><div class="three" id="labStats"></div><div class="hint" style="margin-top:17px">This tool compares threshold-based decisions. It does not establish legal compliance, recommend deployment, or evaluate causes of group disparities.</div></article><article class="panel"><div class="panelHeader"><div><h2>Cost vs disparity</h2><p>Changing the threshold changes both error costs and selection gap</p></div></div><svg class="plot" id="labChart" viewBox="0 0 540 302" role="img" aria-label="Cost and group disparity chart"></svg><div class="legend"><span><i class="dot" style="background:#6557d7"></i>Normalized error cost</span><span><i class="dot" style="background:#e6a04e"></i>Selection gap, pp</span></div></article></div><div class="panel"><div class="panelHeader"><div><h2>Threshold comparison</h2><p>Compare preconfigured options and current custom setting</p></div></div><div class="tableWrap"><table><thead><tr><th>Policy</th><th>Threshold</th><th>Selection gap</th><th>TPR gap</th><th>Overall accuracy</th><th>Weighted error cost</th></tr></thead><tbody id="scenarioTable"></tbody></table></div></div></section> | |
| <section class="view" id="groups"><div class="header"><div><div class="eyebrow">Cohort diagnostics</div><h1>Group explorer</h1><p>Compare observed performance and error distributions. The fictional groups are generic operational cohorts, not demographics.</p></div></div><div class="panel" style="margin-bottom:15px"><div class="panelHeader"><div><h2>Classification outcomes</h2><p>Counts and rates at the active threshold</p></div></div><div class="tableWrap"><table><thead><tr><th>Group</th><th>N</th><th>Base rate</th><th>Selection</th><th>TPR</th><th>FPR</th><th>Precision</th><th>Accuracy</th></tr></thead><tbody id="groupTable"></tbody></table></div></div><div class="grid equal"><article class="panel"><div class="panelHeader"><div><h2>Observed outcome composition</h2><p>TP, FP, FN and TN counts</p></div></div><div id="outcomeBars" class="stack"></div></article><article class="panel"><div class="panelHeader"><div><h2>Notes for interpretation</h2><p>Questions worth asking before acting</p></div></div><div class="stack"><div class="test"><div class="ico">1</div><div><strong>How were labels collected?</strong><small>Historical outcomes can contain missingness, noise and pre-existing bias.</small></div></div><div class="test"><div class="ico">2</div><div><strong>Do base rates differ?</strong><small>Conflicting group-fairness criteria may not all be jointly achievable.</small></div></div><div class="test"><div class="ico">3</div><div><strong>Is the sample representative?</strong><small>Small groups and sampling bias can distort apparent gaps.</small></div></div><div class="test"><div class="ico">4</div><div><strong>Are effects measured after deployment?</strong><small>Real governance requires stakeholders, monitoring and impact review.</small></div></div></div></article></div></section> | |
| <section class="view" id="records"><div class="header"><div><div class="eyebrow">Data stewardship</div><h1>Synthetic record library</h1><p>Create, edit, archive or restore fictional classifier observations. Your modifications remain in this browser unless you export a JSON backup.</p></div><button class="btn primary" id="newBtn">+ Add record</button></div><div class="panel" style="margin-bottom:15px"><div class="controls"><label class="control">Search by record ID<input id="search" type="search" placeholder="e.g. FS-001"></label><label class="control">Group<select id="filterGroup"><option value="all">All groups</option><option value="A">Group A</option><option value="B">Group B</option></select></label><label class="control">Status<select id="filterStatus"><option value="active">Active records</option><option value="archived">Archived records</option><option value="all">All records</option></select></label></div></div><div class="panel"><div class="panelHeader"><div><h2>Record registry</h2><p id="recordCount">0 entries</p></div><span class="chip">Max 500 records</span></div><div class="tableWrap"><table><thead><tr><th>ID</th><th>Group</th><th>Model score</th><th>Observed label</th><th>Decision</th><th>Status</th><th>Actions</th></tr></thead><tbody id="recordTable"></tbody></table></div></div><div class="panel hide" id="editor" style="margin-top:15px"><div class="panelHeader"><h2 id="editTitle">Add synthetic record</h2><button class="btn small" id="cancelEdit">Close</button></div><form id="editForm"><div class="controls"><label class="control">Record ID<input id="recId" maxlength="32" required placeholder="FS-201" pattern="[A-Za-z0-9_-]{2,32}"></label><label class="control">Group<select id="recGroup"><option value="A">Group A</option><option value="B">Group B</option></select></label><label class="control">Score (0 to 100)<input id="recScore" type="number" min="0" max="100" step="1" required></label></div><div style="margin-top:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap"><label class="opt">Observed positive label <input id="recLabel" type="checkbox"></label><button type="submit" class="btn primary">Save record</button></div></form></div></section> | |
| <section class="view" id="method"><div class="header"><div><div class="eyebrow">Transparency & limitations</div><h1>Methodology</h1><p>Audit the assumptions behind this educational classifier-comparison application.</p></div><span class="tag">NO LIVE MODEL</span></div><div class="grid equal"><article class="panel"><div class="panelHeader"><h2>Metric definitions</h2></div><div class="formula"><p><b>Prediction:</b> positive when <code>score ≥ threshold</code>.</p><p><b>Selection rate:</b> predicted positives / group total.</p><p><b>True positive rate (TPR):</b> TP / (TP + FN).</p><p><b>False positive rate (FPR):</b> FP / (FP + TN).</p><p><b>Precision:</b> TP / (TP + FP).</p><p><b>Accuracy:</b> (TP + TN) / group total.</p><p><b>Demographic parity gap:</b> absolute difference in group selection rates.</p><p><b>Equal opportunity gap:</b> absolute difference in group TPR.</p><p><b>Equalized odds diagnostic:</b> max of absolute TPR and FPR differences.</p><p><b>Error cost:</b> FP × FP cost + FN × FN cost (illustrative units).</p></div></article><article class="panel"><div class="panelHeader"><h2>Safeguards and limitations</h2></div><div class="stack"><div class="test"><span class="ico">✓</span><div><strong>Synthetic data only</strong><small>Generated with a deterministic pseudorandom function. No sensitive attributes or real persons.</small></div></div><div class="test"><span class="ico">✓</span><div><strong>No fairness certification</strong><small>Different definitions can conflict. Metrics depend on label quality, setting and social context.</small></div></div><div class="test"><span class="ico">✓</span><div><strong>Not a trained classifier</strong><small>Only a simulated score and binary label dataset is analyzed. There is no online inference.</small></div></div><div class="test"><span class="ico">✓</span><div><strong>Local-first</strong><small>No network calls, cookies, ads, external fonts, telemetry or servers. JSON backup is user-controlled.</small></div></div><div class="test"><span class="ico">✓</span><div><strong>Defensive inputs</strong><small>Validates bounded JSON datasets; escapes HTML and neutralizes common spreadsheet formulas in CSV exports.</small></div></div></div></article></div><div class="panel"><div class="panelHeader"><h2>Portfolio project #112</h2></div><p style="color:var(--muted)">Built as a reproducible demonstration of transparent AI evaluation for the Hugdrigo portfolio. Threshold experiments are descriptive, not policy advice. The datasets are fictional and should never be used as evidence about real groups or individuals.</p><button class="btn" id="resetBtn">Reset all synthetic data</button></div></section><div class="footer">FairnessScope · Hugdrigo #112 · Static educational application · MIT License · No external services</div></main></div><div class="toast" id="toast" role="status" aria-live="polite"></div> | |
| <script> | |
| ; | |
| (()=>{ | |
| const KEY='fairnessscope112.v1',VALID=['A','B'];let editKey=null,view='overview'; | |
| const $=id=>document.getElementById(id),round=(n,d=1)=>Number.isFinite(n)?n.toFixed(d):'—',pct=n=>Number.isFinite(n)?round(n*100,1)+'%':'—',safe=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); | |
| function seedData(){let z=1122026;const rnd=()=>((z^=z<<13,z^=z>>>17,z^=z<<5)>>>0)/4294967296;const out=[];for(let i=0;i<160;i++){const group=i%2===0?'A':'B';const base=group==='A'?0.53:0.43;const pos=rnd()<base?1:0;const score=Math.max(0,Math.min(100,Math.round((pos?67:35)+(rnd()+rnd()+rnd()-1.5)*33+(group==='B'?-5:3))));out.push({id:'FS-'+String(i+1).padStart(3,'0'),group,score,label:pos,archived:false});}return out;} | |
| const DEFAULT=()=>({version:1,records:seedData(),threshold:50,scenario:'balanced',fpCost:2,fnCost:4,dark:false}); | |
| function validate(v){if(!v||v.version!==1||!Array.isArray(v.records)||v.records.length>500||v.records.length<1)throw Error('Invalid backup format or record count (1–500).');const ids=new Set();const records=v.records.map(x=>{if(!x||typeof x.id!=='string'||!/^[A-Za-z0-9_-]{2,32}$/.test(x.id)||ids.has(x.id)||!VALID.includes(x.group)||!Number.isInteger(x.score)||x.score<0||x.score>100||![0,1].includes(x.label)||typeof x.archived!=='boolean')throw Error('Invalid, duplicated, or out-of-range record.');ids.add(x.id);return {id:x.id,group:x.group,score:x.score,label:x.label,archived:x.archived};});const threshold=v.threshold,fp=v.fpCost,fn=v.fnCost;if(!Number.isInteger(threshold)||threshold<5||threshold>95||!Number.isInteger(fp)||fp<1||fp>100||!Number.isInteger(fn)||fn<1||fn>100||typeof v.dark!=='boolean'||!['balanced','inclusive','conservative','custom'].includes(v.scenario))throw Error('Invalid settings.');return {version:1,records,threshold,scenario:v.scenario,fpCost:fp,fnCost:fn,dark:v.dark};} | |
| let state=DEFAULT();try{const raw=localStorage.getItem(KEY);if(raw)state=validate(JSON.parse(raw));}catch(e){try{localStorage.removeItem(KEY);}catch(_){} } | |
| function save(){try{localStorage.setItem(KEY,JSON.stringify(state));}catch(e){toast('Local storage unavailable. Export a JSON backup to keep your work.');}} | |
| function toast(message){$('toast').textContent=message;$('toast').classList.add('show');clearTimeout(toast.timer);toast.timer=setTimeout(()=>$('toast').classList.remove('show'),3500);} | |
| function active(){return state.records.filter(r=>!r.archived)} | |
| function cm(recs,t){const m={tp:0,tn:0,fp:0,fn:0,n:recs.length,positive:0,selected:0};for(const r of recs){const y=r.label===1,hat=r.score>=t;if(y)m.positive++;if(hat)m.selected++;if(y&&hat)m.tp++;else if(!y&&hat)m.fp++;else if(y)m.fn++;else m.tn++;}const div=(a,b)=>b?a/b:null;m.selection=div(m.selected,m.n);m.base=div(m.positive,m.n);m.tpr=div(m.tp,m.tp+m.fn);m.fpr=div(m.fp,m.fp+m.tn);m.ppv=div(m.tp,m.tp+m.fp);m.acc=div(m.tp+m.tn,m.n);return m;} | |
| const abs=(a,b)=>Number.isFinite(a)&&Number.isFinite(b)?Math.abs(a-b):null; | |
| function analyze(t=state.threshold){const rs=active(),a=cm(rs.filter(x=>x.group==='A'),t),b=cm(rs.filter(x=>x.group==='B'),t),all=cm(rs,t);return {a,b,all,dp:abs(a.selection,b.selection),eo:abs(a.tpr,b.tpr),fprGap:abs(a.fpr,b.fpr),odds:Math.max(abs(a.tpr,b.tpr)??0,abs(a.fpr,b.fpr)??0),cost:all.fp*state.fpCost+all.fn*state.fnCost};} | |
| function barRow(label,n,color){return `<div class="barrow"><b>${safe(label)}</b><div class="track"><i style="width:${Math.max(0,Math.min(100,(n??0)*100))}%;background:${color}"></i></div><b class="metric" style="text-align:right">${pct(n)}</b></div>`;} | |
| function kpi(label,val,note,icon){return `<div class="kpi"><div class="kTop">${label}<span class="ico">${icon}</span></div><strong class="metric">${val}</strong><div class="note">${note}</div></div>`;} | |
| function chart(id,series,maxY=100){const svg=$(id),w=id==='labChart'?540:560,h=id==='labChart'?302:232,L=43,R=12,T=17,B=32,W=w-L-R,H=h-T-B,pts=series.map(s=>({color:s.color,values:s.values.map((p,i)=>({x:L+(i/(s.values.length-1))*W,y:T+H-(Math.min(maxY,Math.max(0,p))/maxY)*H}))}));let html='';for(let k=0;k<=4;k++){const y=T+H-H*k/4;html+=`<line x1="${L}" x2="${w-R}" y1="${y}" y2="${y}" stroke="var(--line)"/><text x="${L-9}" y="${y+4}" fill="var(--muted)" text-anchor="end" font-size="10">${Math.round(maxY*k/4)}</text>`;}for(let k=0;k<=4;k++){const x=L+W*k/4;html+=`<text x="${x}" y="${h-8}" fill="var(--muted)" text-anchor="middle" font-size="10">${Math.round(5+90*k/4)}%</text>`;}for(const s of pts){const d=s.values.map((p,i)=>(i?'L':'M')+p.x.toFixed(2)+' '+p.y.toFixed(2)).join(' ');html+=`<path d="${d}" stroke="${s.color}" stroke-width="3" fill="none" stroke-linecap="round"/>`; }const x=L+W*(state.threshold-5)/90;html+=`<line x1="${x}" x2="${x}" y1="${T}" y2="${T+H}" stroke="var(--orange)" stroke-width="1.3" stroke-dasharray="5 4"/>`;svg.innerHTML=html;} | |
| function renderOverview(){const o=analyze();$('kpis').innerHTML=kpi('Active observations',String(o.all.n),'Synthetic binary-score records','▦')+kpi('Overall accuracy',pct(o.all.acc),'Correct decisions at current cutoff','◎')+kpi('Selection rate gap',pct(o.dp),'Absolute between-group difference','◈')+kpi('Opportunity gap',pct(o.eo),'Absolute TPR difference','⇌');$('thresholdChip').textContent='Threshold '+state.threshold+'%';$('mainBars').innerHTML='<h3 style="font-size:11px;margin:0 0 2px">Selection rate</h3>'+barRow('Group A',o.a.selection,'#6557d7')+barRow('Group B',o.b.selection,'#29a3aa')+'<h3 style="font-size:11px;margin:7px 0 2px">True-positive rate</h3>'+barRow('Group A',o.a.tpr,'#6557d7')+barRow('Group B',o.b.tpr,'#29a3aa');$('gaps').innerHTML=[['Selection gap',o.dp],['TPR gap',o.eo],['FPR gap',o.fprGap],['Odds diagnostic',o.odds]].map(([l,v])=>`<div class="stat"><small>${l}</small><b class="metric">${v===null?'—':round(v*100,1)+' pp'}</b></div>`).join('');$('confusions').innerHTML=[['A',o.a],['B',o.b]].map(([k,g])=>`<div class="stat"><b style="font-size:14px">Group ${k}</b><small>TP ${g.tp} · FP ${g.fp}</small><br><small>FN ${g.fn} · TN ${g.tn}</small><br><small>N = ${g.n}</small></div>`).join('');const th=Array.from({length:19},(_,i)=>5+i*5);chart('overviewChart',[{color:'#6557d7',values:th.map(t=>(analyze(t).a.selection??0)*100)},{color:'#29a3aa',values:th.map(t=>(analyze(t).b.selection??0)*100)}]);} | |
| function renderLab(){if(document.activeElement!==$('threshold'))$('threshold').value=state.threshold;$('thresholdRead').textContent=state.threshold+'%';$('fpCost').value=state.fpCost;$('fnCost').value=state.fnCost;$('scenario').value=state.scenario;const d=analyze();$('labStats').innerHTML=`<div class="miniStat"><small>Error cost</small><b>${d.cost.toLocaleString('en-US')}</b></div><div class="miniStat"><small>Correct predictions</small><b>${pct(d.all.acc)}</b></div><div class="miniStat"><small>Selection gap</small><b>${pct(d.dp)}</b></div>`;const ts=Array.from({length:91},(_,i)=>5+i),a=ts.map(t=>analyze(t)),costs=a.map(x=>x.cost),mx=Math.max(1,...costs),costLine=ts.filter((_,i)=>i%5===0).map((_,j)=>costs[j*5]/mx*100),gapLine=ts.filter((_,i)=>i%5===0).map((_,j)=>(a[j*5].dp??0)*100);chart('labChart',[{color:'#6557d7',values:costLine},{color:'#e6a04e',values:gapLine}],100);const rows=[['Inclusive',35],['Balanced',50],['Conservative',70],['Current',state.threshold]];$('scenarioTable').innerHTML=rows.map(([name,t])=>{const v=analyze(t);return `<tr><td><b>${name}</b></td><td>${t}%</td><td>${pct(v.dp)}</td><td>${pct(v.eo)}</td><td>${pct(v.all.acc)}</td><td>${v.cost.toLocaleString('en-US')}</td></tr>`}).join('');} | |
| function renderGroups(){const o=analyze();$('groupTable').innerHTML=[['Group A',o.a],['Group B',o.b],['Overall',o.all]].map(([s,m])=>`<tr><td><b>${s}</b></td><td>${m.n}</td><td>${pct(m.base)}</td><td>${pct(m.selection)}</td><td>${pct(m.tpr)}</td><td>${pct(m.fpr)}</td><td>${pct(m.ppv)}</td><td>${pct(m.acc)}</td></tr>`).join('');$('outcomeBars').innerHTML=[['A',o.a],['B',o.b]].map(([g,m])=>{const parts=[['TP',m.tp,'#269c82'],['FP',m.fp,'#df9b4c'],['FN',m.fn,'#c95673'],['TN',m.tn,'#5f57c5']];return `<div><b>Group ${g} · ${m.n} records</b><div style="display:flex;height:20px;overflow:hidden;border-radius:6px;margin-top:8px">${parts.map(([lab,v,c])=>`<div title="${lab}: ${v}" style="width:${m.n?100*v/m.n:0}%;background:${c}"></div>`).join('')}</div><div class="legend">${parts.map(([lab,v,c])=>`<span><i class="dot" style="background:${c}"></i>${lab} ${v}</span>`).join('')}</div></div>`}).join('');} | |
| function renderRecords(){const q=$('search').value.trim().toLowerCase(),grp=$('filterGroup').value,st=$('filterStatus').value,rr=state.records.filter(r=>(!q||r.id.toLowerCase().includes(q))&&(grp==='all'||r.group===grp)&&(st==='all'||(st==='archived')===r.archived));$('recordCount').textContent=rr.length+' of '+state.records.length+' records';$('recordTable').innerHTML=rr.slice(0,500).map(r=>`<tr><td><b>${safe(r.id)}</b></td><td>Group ${r.group}</td><td>${r.score}%</td><td>${r.label?'Positive':'Negative'}</td><td>${r.score>=state.threshold?'Positive':'Negative'}</td><td><span class="status ${r.archived?'off':''}">${r.archived?'Archived':'Active'}</span></td><td><div style="display:flex;gap:6px"><button class="btn small" data-edit="${safe(r.id)}">Edit</button><button class="btn small" data-archive="${safe(r.id)}">${r.archived?'Restore':'Archive'}</button></div></td></tr>`).join('')||`<tr><td colspan="7" class="empty">No matching records</td></tr>`;} | |
| function render(){document.body.classList.toggle('dark',state.dark);renderOverview();renderLab();renderGroups();renderRecords();} | |
| function switchView(v){if(!['overview','lab','groups','records','method'].includes(v))return;view=v;document.querySelectorAll('.view').forEach(el=>el.classList.toggle('active',el.id===v));document.querySelectorAll('#nav button').forEach(b=>b.classList.toggle('active',b.dataset.view===v));window.scrollTo({top:0,behavior:'instant'});} | |
| function openEditor(id){switchView('records');const r=state.records.find(r=>r.id===id);editKey=id||null;$('editTitle').textContent=r?'Edit synthetic record':'Add synthetic record';$('recId').value=r?.id||'';$('recId').disabled=!!r;$('recGroup').value=r?.group||'A';$('recScore').value=r?.score??50;$('recLabel').checked=!!r?.label;$('editor').classList.remove('hide');$('editor').scrollIntoView({behavior:'smooth',block:'nearest'});} | |
| function download(name,type,text){const blob=new Blob([text],{type}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1000);} | |
| function csvEscape(v){let s=String(v);if(/^[\s\uFEFF]*[=+\-@\t\r]/.test(s))s="'"+s;return '"'+s.replace(/"/g,'""')+'"';} | |
| $('nav').addEventListener('click',e=>{const b=e.target.closest('[data-view]');if(b)switchView(b.dataset.view)}); | |
| $('themeBtn').addEventListener('click',()=>{state.dark=!state.dark;save();render();}); | |
| $('threshold').addEventListener('input',e=>{state.threshold=Number(e.target.value);state.scenario='custom';save();render();}); | |
| $('scenario').addEventListener('change',e=>{state.scenario=e.target.value;const t={balanced:50,conservative:70,inclusive:35}[state.scenario];if(t!==undefined)state.threshold=t;save();render();}); | |
| for(const [id,field] of [['fpCost','fpCost'],['fnCost','fnCost']]){$(id).addEventListener('change',e=>{const n=Number(e.target.value);if(!Number.isInteger(n)||n<1||n>100){toast('Enter an integer cost from 1 to 100.');renderLab();return;}state[field]=n;save();render();});} | |
| for(const id of ['search','filterGroup','filterStatus'])$(id).addEventListener('input',renderRecords); | |
| $('newBtn').addEventListener('click',()=>openEditor(null));$('cancelEdit').addEventListener('click',()=>$('editor').classList.add('hide')); | |
| $('recordTable').addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.edit){openEditor(b.dataset.edit);return;}if(b.dataset.archive){const r=state.records.find(x=>x.id===b.dataset.archive);if(!r)return;r.archived=!r.archived;save();render();toast(r.archived?'Record archived; can be restored.':'Record restored.');}}); | |
| $('editForm').addEventListener('submit',e=>{e.preventDefault();const id=$('recId').value.trim(),group=$('recGroup').value,score=Number($('recScore').value),label=$('recLabel').checked?1:0;if(!/^[A-Za-z0-9_-]{2,32}$/.test(id)||!VALID.includes(group)||!Number.isInteger(score)||score<0||score>100){toast('Invalid record values.');return;}if(editKey){const r=state.records.find(x=>x.id===editKey);if(!r)return;r.group=group;r.score=score;r.label=label;}else{if(state.records.length>=500){toast('Maximum 500 records reached.');return;}if(state.records.some(x=>x.id===id)){toast('Record ID already exists.');return;}state.records.push({id,group,score,label,archived:false});}$('editor').classList.add('hide');editKey=null;save();render();toast('Record saved.');}); | |
| $('csvBtn').addEventListener('click',()=>{const r=[['id','group','score','label','archived'],...state.records.map(x=>[x.id,x.group,x.score,x.label,x.archived])];download('fairnessscope112-records.csv','text/csv;charset=utf-8',r.map(row=>row.map(csvEscape).join(',')).join('\r\n'));toast('CSV export downloaded.');}); | |
| $('jsonBtn').addEventListener('click',()=>{download('fairnessscope112-backup.json','application/json',JSON.stringify(state,null,2));toast('JSON backup downloaded.');}); | |
| $('importBtn').addEventListener('click',()=>$('fileInput').click()); | |
| $('fileInput').addEventListener('change',async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;if(f.size>250000){toast('Backup exceeds 250 KB.');return;}try{const raw=await f.text(),next=validate(JSON.parse(raw));if(!confirm(`Replace current dataset with ${next.records.length} records from this backup?`))return;state=next;save();render();toast('Backup successfully restored.');}catch(err){toast('Import rejected: '+String(err.message||err).slice(0,160));}}); | |
| $('resetBtn').addEventListener('click',()=>{if(!confirm('Reset all synthetic records and settings? Export a backup first if needed.'))return;state=DEFAULT();save();render();switchView('overview');toast('Synthetic dataset restored.');}); | |
| render();window.FairnessScopeQA={getState:()=>structuredClone(state),analyze,cm,validate,seedData}; | |
| })(); | |
| </script></body></html> | |