Hugdrigo's picture
Upload 3 files
fe1289b verified
Raw History Blame Contribute Delete
49.8 kB
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>LinkResolve | Entity Resolution Studio</title>
<style>
:root{color-scheme:dark;--bg:#09131c;--rail:#0d1923;--panel:#112330;--panel2:#152a38;--stroke:#284151;--muted:#95acb9;--text:#ecf5f4;--mint:#62f0bb;--blue:#76bafa;--red:#ff8b96;--amber:#ffcf86;--soft:#1b3540}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Arial,sans-serif}button,select,input{font:inherit}button{cursor:pointer}button:focus-visible,select:focus-visible,input:focus-visible{outline:2px solid var(--mint);outline-offset:2px}.layout{min-height:100vh;display:grid;grid-template-columns:245px minmax(0,1fr)}.sidebar{background:var(--rail);border-right:1px solid var(--stroke);padding:27px 16px;display:flex;flex-direction:column;gap:25px;min-width:0}.brand{padding:4px 10px;display:flex;align-items:center;gap:12px;font-weight:850;font-size:20px;letter-spacing:-.6px}.symbol{height:37px;width:37px;background:linear-gradient(135deg,var(--mint),#71b8fa);border-radius:12px;color:#071622;display:grid;place-items:center;font-size:25px}.small-label{font-size:10px;font-weight:800;letter-spacing:2px;text-transform:uppercase;color:#7d9aa9;margin:0 10px 12px}.nav{display:flex;flex-direction:column;gap:5px}.nav button{width:100%;background:transparent;color:#a9bdc9;border:1px solid transparent;border-radius:10px;padding:11px;text-align:left;display:flex;align-items:center;gap:12px;font-weight:650}.nav button.active{color:var(--mint);background:#17362f;border-color:#2c6153}.nav button:hover:not(.active){background:#1b3040;color:white}.nav button .glyph{width:22px;text-align:center;font-size:16px}.side-note{margin-top:auto;padding:15px;background:#112c2a;border:1px solid #2a5045;border-radius:13px;color:#c0d9d5;font-size:12px}.side-note strong{display:block;color:var(--mint);margin-bottom:5px}.sidebar-foot{font-size:11px;padding-left:9px;color:#728e9e}.main{min-width:0;padding:27px clamp(18px,3.4vw,48px) 50px}.top{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:space-between;margin-bottom:23px}.eyebrow{font-size:11px;color:var(--mint);font-weight:850;text-transform:uppercase;letter-spacing:1.9px}.top h1{font-size:clamp(25px,3vw,37px);margin:3px 0 4px;letter-spacing:-1.15px;line-height:1.2}.subtitle{margin:0;color:var(--muted);font-size:13px}.top-right{display:flex;align-items:center;gap:9px;flex-wrap:wrap}.pill{border:1px solid #2d6458;border-radius:50px;padding:7px 11px;font-size:11px;color:#abf8d7;font-weight:800;background:#113329}.btn,.select{background:#1a3242;border:1px solid #355262;border-radius:9px;padding:10px 13px;color:var(--text);font-weight:680}.btn:hover{border-color:var(--mint)}.btn.primary{background:var(--mint);border-color:var(--mint);color:#092119}.btn.small{font-size:12px;padding:8px 10px}.select{max-width:230px;min-width:160px}.page{display:none}.page.active{display:block}.hero{border:1px solid #2f5558;background:linear-gradient(110deg,#173c3b,#133443 65%,#152f42);padding:24px 27px;border-radius:17px;display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:19px}.hero h2{font-size:21px;letter-spacing:-.5px;margin:2px 0 7px}.hero p{color:#c6d7de;max-width:690px;margin:0;font-size:13px}.hero .stamp{color:#77e8bd;border:1px solid #488b77;padding:12px 14px;border-radius:12px;text-align:center;font-weight:800;font-size:12px;white-space:nowrap}.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:13px;margin-bottom:19px}.metric{border-radius:14px;background:var(--panel);border:1px solid var(--stroke);padding:19px;min-width:0}.metric .label{color:#9cb6c4;font-size:11px;text-transform:uppercase;letter-spacing:.95px;font-weight:850}.metric .value{font-size:30px;font-weight:850;line-height:1.35;letter-spacing:-1px;margin-top:6px}.metric .detail{font-size:11px;color:#a2b8c1}.mint{color:var(--mint)}.amber{color:var(--amber)}.red{color:var(--red)}.blue{color:var(--blue)}.grid2{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(265px,1fr);gap:15px;margin-bottom:16px}.grid-equal{grid-template-columns:repeat(2,minmax(0,1fr))}.panel{background:var(--panel);border:1px solid var(--stroke);border-radius:15px;padding:19px;min-width:0;margin-bottom:15px}.panel .p-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:15px}.panel h3{font-size:15px;letter-spacing:-.2px;margin:0}.panel .intro{font-size:12px;color:var(--muted);margin:6px 0 0}.legend{font-size:11px;color:#a4b7be}.bar-row{margin:14px 0}.bar-top{display:flex;justify-content:space-between;color:#c7d8de;font-size:12px;margin-bottom:6px;gap:8px}.bar-bg{background:#263f4c;border-radius:8px;height:11px;overflow:hidden}.bar-fill{height:100%;border-radius:8px;background:linear-gradient(90deg,#36c89b,#77f3c0);transition:width .25s}.bar-fill.amberbar{background:linear-gradient(90deg,#bf8459,#f8c98a)}.bar-fill.bluebar{background:linear-gradient(90deg,#327cab,#84c9ff)}.mini-kpi{padding:12px;background:#1b3240;border:1px solid #314b5b;border-radius:11px;display:flex;justify-content:space-between;gap:12px;margin-bottom:9px}.mini-kpi small{color:var(--muted)}.mini-kpi strong{color:white}.table-wrap{overflow:auto;max-width:100%}table{width:100%;border-collapse:collapse;font-size:12px}th{text-align:left;color:#83a3b6;text-transform:uppercase;letter-spacing:.6px;font-size:10px;font-weight:850;white-space:nowrap;padding:12px 11px;border-bottom:1px solid #345161}td{padding:12px 11px;border-bottom:1px solid #213a48;vertical-align:middle}tr:last-child td{border-bottom:none}td strong{display:block;color:#f2f7f5}td small{color:#8fa9b8}td.num,th.num{text-align:right}tr.row-click{cursor:pointer}tr.row-click:hover{background:#1d3645}.status{display:inline-flex;border-radius:50px;padding:4px 8px;font-size:10px;font-weight:800;border:1px solid #356252;color:#a8f5d0;background:#1d4137;white-space:nowrap}.status.review{border-color:#705b40;background:#453425;color:#ffcc8c}.status.danger{border-color:#6e4852;background:#442a37;color:#ffb1ad}.controlbar{display:flex;flex-wrap:wrap;align-items:end;gap:12px;margin-bottom:15px}.control{display:flex;flex-direction:column;gap:5px;min-width:120px}.control label{font-size:11px;font-weight:750;color:#a6bfcd}.control input[type=range]{accent-color:var(--mint);width:190px;max-width:100%}.control input[type=number],.control input[type=text],.control select,.control input[type=file]{background:#132a38;border:1px solid #375264;color:var(--text);padding:9px;border-radius:9px;width:100%;font-size:12px}.control .readout{font-size:12px;color:var(--mint);font-weight:850}.row-actions{display:flex;gap:7px;flex-wrap:wrap}.empty{padding:25px 10px;color:#9bb0bd;text-align:center;border:1px dashed #355062;border-radius:10px;font-size:12px}.info{border-left:3px solid var(--mint);background:#1a363e;padding:10px 12px;border-radius:7px;color:#c2d8dc;font-size:12px;margin:12px 0}.warning{border-left-color:var(--amber)}.codeblock{background:#0b1b28;padding:14px;border:1px solid #315061;border-radius:10px;color:#b1f9d5;font-family:ui-monospace,monospace;font-size:12px;white-space:pre-wrap;overflow:auto}.footer{margin-top:24px;color:#6e8a9d;font-size:11px;display:flex;gap:15px;flex-wrap:wrap}.hint{font-size:11px;color:#9bb3c0}.review-card{border:1px solid #355362;border-radius:13px;background:#152d3b;padding:16px;display:grid;grid-template-columns:1fr 50px 1fr;gap:12px;align-items:center;margin:11px 0}.review-card h4{margin:0 0 4px;font-size:13px}.review-card p{margin:2px 0;font-size:12px;color:#a1b9c5}.review-card .score{text-align:center;font-size:21px;color:var(--mint);font-weight:850}.review-card .arr{text-align:center;color:var(--mint);font-size:25px}.review-card .actions{grid-column:1/-1;border-top:1px solid #31505d;padding-top:10px;display:flex;align-items:center;gap:9px;flex-wrap:wrap}.small-chart{width:100%;height:auto;display:block}.sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}#toast{position:fixed;bottom:18px;right:18px;background:#1c463d;color:#d8ffeb;border:1px solid #55b397;border-radius:11px;padding:12px 16px;display:none;z-index:12;max-width:340px;box-shadow:0 12px 30px #0005}#toast.error{background:#4b2932;border-color:#d27683;color:#ffe0e1}.spaced{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}.divider{border:0;border-top:1px solid #30505d;margin:16px 0}input[type=checkbox]{accent-color:var(--mint)}a{color:var(--mint)}@media(max-width:980px){.layout{grid-template-columns:1fr}.sidebar{padding:14px 16px;border-right:none;border-bottom:1px solid var(--stroke);gap:9px}.brand{font-size:18px}.small-label,.side-note,.sidebar-foot{display:none}.nav{flex-direction:row;overflow:auto;padding-bottom:4px}.nav button{flex:none;white-space:nowrap;padding:9px}.main{padding:24px 17px}.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.grid2{grid-template-columns:1fr}}@media(max-width:550px){.nav{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));overflow:visible}.nav button{min-width:0;white-space:normal;font-size:11px;padding:9px 6px;gap:6px}.nav button .glyph{width:17px}.hero{padding:17px;display:block}.hero .stamp{display:inline-block;margin-top:13px}.top h1{font-size:27px}.top-right{width:100%}.top-right .select{flex:1;min-width:100px}.metric{padding:14px}.metric .value{font-size:25px}.panel{padding:14px}.review-card{grid-template-columns:1fr 35px 1fr;gap:7px;padding:12px}.review-card h4{font-size:11px}.review-card p{font-size:10px}.review-card .score{font-size:16px}.controlbar{align-items:stretch}.control{flex:1}.control input[type=range]{width:100%}}
</style></head><body>
<div class="layout"><aside class="sidebar"><div class="brand"><span class="symbol">⤳</span> LinkResolve</div><div><div class="small-label">Intelligence workspace</div><nav class="nav" id="nav"><button class="active" data-tab="overview"><span class="glyph">▦</span> Command Center</button><button data-tab="match"><span class="glyph">⤨</span> Matching Workbench</button><button data-tab="review"><span class="glyph">◎</span> Review Queue</button><button data-tab="quality"><span class="glyph">◫</span> Quality Diagnostics</button><button data-tab="rules"><span class="glyph">≋</span> Rules & Sensitivity</button><button data-tab="data"><span class="glyph">⇵</span> Data & Audit</button></nav></div><div class="side-note"><strong>● LOCAL-FIRST BY DESIGN</strong>Record matching runs entirely inside your browser. No API, database, subscription, or network access is required.</div><div class="sidebar-foot">Project 71 · Entity Resolution Studio · MIT</div></aside>
<main class="main"><header class="top"><div><div class="eyebrow">Data operations / Entity intelligence</div><h1 id="pageTitle">Entity Resolution Command Center</h1><p class="subtitle" id="pageSubtitle">Resolve duplicates, reconcile sources and audit every decision.</p></div><div class="top-right"><span class="pill">● LOCAL ENGINE</span><select id="scenario" class="select" aria-label="Demo scenario"><option value="registry">Regional partner registry</option><option value="research">Research directory</option><option value="suppliers">Supplier master data</option></select></div></header>
<section class="page active" id="page-overview"><div class="hero"><div><div class="eyebrow">RECORD LINKAGE / IDENTITY QUALITY</div><h2>Turn fragmented records into trusted connections.</h2><p>Compare two source lists, identify strong and ambiguous pairs, assign matches one-to-one and preserve a transparent review trail.</p></div><div class="stamp">RULE-BASED MATCHING<br>EXPLAINABLE SCORES</div></div><div class="metrics" id="overviewMetrics"></div><div class="grid2"><section class="panel"><div class="p-head"><h3>Match confidence distribution</h3><span class="legend">Computed from current assignment</span></div><div id="confidenceBars"></div></section><section class="panel"><div class="p-head"><h3>Reconciliation summary</h3><span class="legend">Two-source comparison</span></div><div id="summaryBox"></div></section></div><section class="panel"><div class="p-head"><h3>Highest scoring linked records</h3><button class="btn small" data-goto="match">Open matching workbench ↗</button></div><div id="previewTable"></div></section></section>
<section class="page" id="page-match"><div class="panel"><div class="p-head"><div><h3>Pairwise matches and evidence</h3><p class="intro">One-to-one optimal assignment using normalized similarity. Click a row to inspect fields.</p></div><span id="matchCount" class="pill"></span></div><div class="controlbar"><div class="control"><label for="matchFilter">Show results</label><select id="matchFilter"><option value="all">All assigned pairs</option><option value="strong">Strong confidence</option><option value="review">Needs review</option></select></div><div class="control" style="min-width:180px"><label for="matchSearch">Find an entity</label><input id="matchSearch" type="text" placeholder="Search records…"></div></div><div id="matchesTable"></div></div><div class="panel"><h3>Explain this pair</h3><p class="intro">Evidence is algorithmic and must not be treated as verified identity.</p><div id="detailPanel" class="empty">Choose a match from the table to inspect its evidence.</div></div></section>
<section class="page" id="page-review"><div class="panel"><div class="p-head"><div><h3>Human-in-the-loop verification</h3><p class="intro">Accept, reject or undo proposed links. Rejected pairs are removed from the optimization; accepted pairs reserve both records.</p></div><button class="btn small" id="clearReview">Reset decisions</button></div><div id="reviewMetrics"></div><div id="reviewCards"></div></div><section class="panel"><h3>Manual decisions and audit trail</h3><div id="decisionTrail"></div></section></section>
<section class="page" id="page-quality"><div class="grid2 grid-equal"><section class="panel"><h3>Reference-label evaluation</h3><p class="intro">Demo scenarios include synthetic reference links. Imported datasets have no assumed ground truth.</p><div id="qualityMetrics"></div></section><section class="panel"><h3>Quality at alternate thresholds</h3><p class="intro">Precision, recall and F1 for generated one-to-one matches.</p><div id="qualityChart"></div></section></div><section class="panel"><h3>False matches and missed links</h3><p class="intro">Comparison against reference labels, where available.</p><div id="errorsTable"></div></section></section>
<section class="page" id="page-rules"><div class="panel"><div class="p-head"><div><h3>Matching policy editor</h3><p class="intro">Set the matching threshold and relative importance of each attribute. Scores recalculate immediately.</p></div><button id="resetRules" class="btn small">Restore defaults</button></div><div class="controlbar"><div class="control" style="min-width:195px"><label for="threshold">Minimum score <span class="readout" id="thresholdVal"></span></label><input type="range" id="threshold" min="45" max="97" value="70" step="1"></div><div class="control" style="min-width:170px"><label for="strong">Strong confidence <span class="readout" id="strongVal"></span></label><input type="range" id="strong" min="75" max="99" value="87" step="1"></div><div class="control"><label for="nameWeight">Name weight <span class="readout" id="nameWeightVal"></span></label><input type="range" id="nameWeight" min="0" max="100" value="50"></div><div class="control"><label for="emailWeight">Email weight <span class="readout" id="emailWeightVal"></span></label><input type="range" id="emailWeight" min="0" max="100" value="25"></div><div class="control"><label for="cityWeight">City weight <span class="readout" id="cityWeightVal"></span></label><input type="range" id="cityWeight" min="0" max="100" value="10"></div><div class="control"><label for="codeWeight">Identifier weight <span class="readout" id="codeWeightVal"></span></label><input type="range" id="codeWeight" min="0" max="100" value="15"></div></div><div class="info" id="rulesWarning"></div></div><div class="grid2"><section class="panel"><h3>Threshold sensitivity</h3><p class="intro">How many pairs would remain at alternative thresholds?</p><div id="sensitivityBars"></div></section><section class="panel"><h3>Important limitations</h3><div class="info warning">Similarity is not proof of identity. A single identifier or email can be shared or erroneous. Review ambiguous links before merging systems.</div><div class="mini-kpi"><span>Matching method</span><strong>Optimal one-to-one assignment</strong></div><div class="mini-kpi"><span>Scoring</span><strong>Weighted normalized similarity</strong></div><div class="mini-kpi"><span>Maximum per source</span><strong>80 records</strong></div></section></div></section>
<section class="page" id="page-data"><div class="grid2 grid-equal"><section class="panel"><h3>Import source A</h3><p class="intro">CSV with id,name and optional city,email,code,sector. UTF-8; maximum 80 records.</p><div class="control"><label for="leftFile">Choose source A CSV</label><input id="leftFile" type="file" accept=".csv,text/csv"></div><div class="info">Imports are processed locally. Invalid files leave the existing source unchanged.</div></section><section class="panel"><h3>Import source B</h3><p class="intro">Separate source for reconciliation. Identifiers must be unique within each file.</p><div class="control"><label for="rightFile">Choose source B CSV</label><input id="rightFile" type="file" accept=".csv,text/csv"></div><div class="info">Export your work before refreshing or closing the page. No data is uploaded or persisted.</div></section></div><section class="panel"><div class="p-head"><h3>Data inventory</h3><span id="sourceCounts" class="pill"></span></div><div class="grid2 grid-equal"><div id="dataLeft"></div><div id="dataRight"></div></div></section><section class="panel"><h3>Downloads and reproducibility</h3><p class="intro">Export a complete audit, approved links, unmatched records, manual decisions, templates and methodology.</p><div class="row-actions" id="downloads"><button class="btn primary" data-export="audit">↓ Full audit JSON</button><button class="btn" data-export="matches">↓ Matches CSV</button><button class="btn" data-export="unmatchedA">↓ Unmatched A CSV</button><button class="btn" data-export="unmatchedB">↓ Unmatched B CSV</button><button class="btn" data-export="decisions">↓ Review decisions CSV</button><button class="btn" data-export="templateA">↓ Source A template</button><button class="btn" data-export="templateB">↓ Source B template</button><button class="btn" data-export="methods">↓ Methodology MD</button></div></section></section>
<div class="footer"><span>LinkResolve v1.0 · Project 71</span><span>Private, local-first processing</span><span>All demonstration records are fictional</span><span>Not a verified identity registry</span></div></main></div><div id="toast" role="status" aria-live="polite"></div>
<script>
'use strict';
const DEMOS={
registry:{label:'Regional partner registry',left:[
{id:'A01',name:'Fundación Horizonte Andino',city:'Jujuy',email:'contacto@horizonte.test',code:'ORG-011',sector:'Nonprofit'},
{id:'A02',name:'Universidad del Norte',city:'Salta',email:'enlace@unorte.test',code:'UNI-102',sector:'Education'},
{id:'A03',name:'Centro de Desarrollo Sur',city:'Tarija',email:'info@cdesur.test',code:'ORG-084',sector:'Development'},
{id:'A04',name:'Cooperativa Río Claro',city:'Jujuy',email:'admin@rioclaro.test',code:'COOP-33',sector:'Agriculture'},
{id:'A05',name:'Andes Logistics Group',city:'Asunción',email:'ventas@andeslog.test',code:'LOG-88',sector:'Logistics'},
{id:'A06',name:'Instituto de Estudios Regionales',city:'Córdoba',email:'hola@ier.test',code:'IER-19',sector:'Research'},
{id:'A07',name:'Fundación Nueva Ruta',city:'Salta',email:'team@nuevaruta.test',code:'FNR-22',sector:'Nonprofit'},
{id:'A08',name:'Red de Innovación Social',city:'Tarija',email:'red@innovasocial.test',code:'RIS-15',sector:'Development'},
{id:'A09',name:'Laboratorio Terra',city:'Asunción',email:'admin@terra.test',code:'TERRA-1',sector:'Research'}],right:[
{id:'B01',name:'Fundacion Horizonte Andino',city:'San Salvador de Jujuy',email:'contacto@horizonte.test',code:'ORG-011',sector:'Nonprofit'},
{id:'B02',name:'Univ. del Norte',city:'Salta',email:'enlace@unorte.test',code:'UNI-102',sector:'Education'},
{id:'B03',name:'Centro Desarrollo del Sur',city:'Tarija',email:'info@cdesur.test',code:'ORG-084',sector:'Development'},
{id:'B04',name:'Coop Rio Claro',city:'Jujuy',email:'admin@rioclaro.test',code:'COOP-33',sector:'Agriculture'},
{id:'B05',name:'Andes Logistic Group',city:'Asuncion',email:'ventas@andeslog.test',code:'LOG-88',sector:'Logistics'},
{id:'B06',name:'Instituto Estudios Regionales',city:'Cordoba',email:'hola@ier.test',code:'IER-19',sector:'Research'},
{id:'B07',name:'Fundación Ruta Nueva',city:'Salta',email:'inbox@nuevaruta.test',code:'',sector:'Nonprofit'},
{id:'B08',name:'Centro de Tecnología del Valle',city:'Tarija',email:'ctv@valle.test',code:'CTV-18',sector:'Technology'},
{id:'B09',name:'Terra Research Laboratory',city:'Asunción',email:'admin@terra.test',code:'TERRA-1',sector:'Research'}],gold:[['A01','B01'],['A02','B02'],['A03','B03'],['A04','B04'],['A05','B05'],['A06','B06'],['A07','B07'],['A09','B09']]},
research:{label:'Research directory',left:[
{id:'A01',name:'Centro de Políticas Públicas',city:'Córdoba',email:'cpp@publica.test',code:'R-10',sector:'Policy'},
{id:'A02',name:'Observatorio Agro Andino',city:'Jujuy',email:'oaa@agro.test',code:'R-11',sector:'Agriculture'},
{id:'A03',name:'Laboratorio de Lenguaje Aplicado',city:'Córdoba',email:'lla@lengua.test',code:'R-12',sector:'Linguistics'},
{id:'A04',name:'Instituto de Economía Digital',city:'Buenos Aires',email:'ied@digital.test',code:'R-13',sector:'Economics'},
{id:'A05',name:'Red Fronteriza de Educación',city:'Tarija',email:'rfe@frontera.test',code:'R-14',sector:'Education'},
{id:'A06',name:'Centro de Datos del Sur',city:'Salta',email:'info@cdsur.test',code:'R-15',sector:'Technology'},
{id:'A07',name:'Instituto Prisma',city:'La Paz',email:'prisma@inst.test',code:'R-16',sector:'Research'}],right:[
{id:'B01',name:'Centro Politicas Publicas',city:'Cordoba',email:'cpp@publica.test',code:'R-10',sector:'Policy'},
{id:'B02',name:'Observatorio Agroandino',city:'Jujuy',email:'oaa@agro.test',code:'R-11',sector:'Agriculture'},
{id:'B03',name:'Lab Lenguaje Aplicado',city:'Córdoba',email:'lla@lengua.test',code:'R-12',sector:'Linguistics'},
{id:'B04',name:'Instituto Economia Digital',city:'Buenos Aires',email:'ied@digital.test',code:'R-13',sector:'Economics'},
{id:'B05',name:'Red Fronteriza Educación',city:'Tarija',email:'rfe@frontera.test',code:'R-14',sector:'Education'},
{id:'B06',name:'Centro de Datos del Norte',city:'Salta',email:'norte@datos.test',code:'R-20',sector:'Technology'},
{id:'B07',name:'Prisma Research Institute',city:'La Paz',email:'prisma@inst.test',code:'R-16',sector:'Research'}],gold:[['A01','B01'],['A02','B02'],['A03','B03'],['A04','B04'],['A05','B05'],['A07','B07']]},
suppliers:{label:'Supplier master data',left:[
{id:'A01',name:'Nordex Packaging',city:'Asunción',email:'orders@nordex.test',code:'S-301',sector:'Packaging'},
{id:'A02',name:'Bio Andes Export',city:'Tarija',email:'office@bioandes.test',code:'S-302',sector:'Food'},
{id:'A03',name:'Pampa Transportes',city:'Salta',email:'fleet@pampa.test',code:'S-303',sector:'Freight'},
{id:'A04',name:'Insumos Central S.R.L.',city:'Jujuy',email:'central@insumos.test',code:'S-304',sector:'Materials'},
{id:'A05',name:'Delta Tech Services',city:'Córdoba',email:'service@delta.test',code:'S-305',sector:'Technology'},
{id:'A06',name:'Vía Verde Logística',city:'Asunción',email:'via@verde.test',code:'S-306',sector:'Freight'},
{id:'A07',name:'Soluciones Cumbre',city:'Tarija',email:'sales@cumbre.test',code:'S-307',sector:'Materials'}],right:[
{id:'B01',name:'Nordex Packagings',city:'Asuncion',email:'orders@nordex.test',code:'S-301',sector:'Packaging'},
{id:'B02',name:'BioAndes Exportaciones',city:'Tarija',email:'office@bioandes.test',code:'S-302',sector:'Food'},
{id:'B03',name:'Transportes Pampa',city:'Salta',email:'fleet@pampa.test',code:'S-303',sector:'Freight'},
{id:'B04',name:'Insumos Central SRL',city:'Jujuy',email:'central@insumos.test',code:'S-304',sector:'Materials'},
{id:'B05',name:'Delta Technology Services',city:'Cordoba',email:'service@delta.test',code:'S-305',sector:'Technology'},
{id:'B06',name:'Via Verde Logistics',city:'Asunción',email:'via@verde.test',code:'S-306',sector:'Freight'},
{id:'B07',name:'Soluciones Cordillera',city:'Tarija',email:'office@cordillera.test',code:'S-399',sector:'Materials'}],gold:[['A01','B01'],['A02','B02'],['A03','B03'],['A04','B04'],['A05','B05'],['A06','B06']]}};
const DEFAULT={threshold:0.70,strong:0.87,w:{name:50,email:25,city:10,code:15}};
let S={scenario:'registry',left:[],right:[],gold:null,custom:false,threshold:DEFAULT.threshold,strong:DEFAULT.strong,w:{...DEFAULT.w},decisions:{},selected:null,tab:'overview'};
const $=id=>document.getElementById(id);const esc=x=>String(x??'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));const norm=x=>String(x??'').normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLowerCase().replace(/\b(univ|universidad)\.?\b/g,'universidad').replace(/\b(coop|cooperativa)\.?\b/g,'cooperativa').replace(/\b(lab|laboratorio)\.?\b/g,'laboratorio').replace(/\bs\s*r\s*l\b/g,'srl').replace(/[^a-z0-9]+/g,' ').trim().replace(/\s+/g,' ');
function lev(a,b){if(a===b)return 1;if(!a||!b)return 0;const prev=Array.from({length:b.length+1},(_,i)=>i);for(let i=1;i<=a.length;i++){let left=i,diag=i-1;for(let j=1;j<=b.length;j++){const old=prev[j];prev[j]=Math.min(prev[j]+1,left+1,diag+(a[i-1]===b[j-1]?0:1));left=prev[j];diag=old;}}return 1-prev[b.length]/Math.max(a.length,b.length)}
function nameSim(a,b){const x=norm(a),y=norm(b);if(!x||!y)return 0;const xs=new Set(x.split(' ')),ys=new Set(y.split(' '));let common=0;for(const t of xs)if(ys.has(t))common++;const jac=common/(xs.size+ys.size-common||1);return .55*lev(x,y)+.45*jac}
function scorePair(a,b,w=S.w){let total=0,den=0;const attrs={};const vals={name:[a.name,b.name],email:[a.email,b.email],city:[a.city,b.city],code:[a.code,b.code]};for(const k of Object.keys(vals)){let [x,y]=vals[k];if(!x||!y)continue;const sim=k==='name'?nameSim(x,y):k==='city'?lev(norm(x),norm(y)):(norm(x)===norm(y)?1:0);attrs[k]=sim;if(w[k]>0){total+=w[k]*sim;den+=w[k]}}return {score:den?total/den:0,attrs};}
function optimal(weights){const n=weights.length;if(!n)return [];const m=weights[0].length;if(m<n)throw Error('Assignment needs columns >= rows');const u=Array(n+1).fill(0),v=Array(m+1).fill(0),p=Array(m+1).fill(0),way=Array(m+1).fill(0);for(let i=1;i<=n;i++){p[0]=i;let j0=0;const minv=Array(m+1).fill(Infinity),used=Array(m+1).fill(false);do{used[j0]=true;const i0=p[j0];let delta=Infinity,j1=0;for(let j=1;j<=m;j++)if(!used[j]){const cur=-weights[i0-1][j-1]-u[i0]-v[j];if(cur<minv[j]-1e-12){minv[j]=cur;way[j]=j0;}if(minv[j]<delta){delta=minv[j];j1=j;}}for(let j=0;j<=m;j++){if(used[j]){u[p[j]]+=delta;v[j]-=delta;}else minv[j]-=delta;}j0=j1;}while(p[j0]!==0);do{const j1=way[j0];p[j0]=p[j1];j0=j1}while(j0!==0)}const res=Array(n).fill(-1);for(let j=1;j<=m;j++)if(p[j]>0)res[p[j]-1]=j-1;return res;}
const key=(a,b)=>a+'|'+b;
function resolveAt(threshold=S.threshold, useDecisions=true){const pairs=[],fixed=[],leftTaken=new Set(),rightTaken=new Set();if(useDecisions){for(const [k,d] of Object.entries(S.decisions))if(d==='approved'){const [aid,bid]=k.split('|');const a=S.left.find(x=>x.id===aid),b=S.right.find(x=>x.id===bid);if(!a||!b||leftTaken.has(aid)||rightTaken.has(bid))continue;const sc=scorePair(a,b);fixed.push({...sc,a,b,decision:'approved'});leftTaken.add(aid);rightTaken.add(bid)}}const aa=S.left.filter(a=>!leftTaken.has(a.id)),bb=S.right.filter(b=>!rightTaken.has(b.id));if(aa.length){const weights=aa.map(a=>[...bb.map(b=>{const k=key(a.id,b.id);const score=scorePair(a,b).score;return score>=threshold&&(!useDecisions||S.decisions[k]!=='rejected')?score-threshold+1e-6:0}),...Array(aa.length).fill(0)]);const assign=optimal(weights);assign.forEach((col,i)=>{if(col<0||col>=bb.length||weights[i][col]<=0)return;const a=aa[i],b=bb[col];pairs.push({...scorePair(a,b),a,b,decision:S.decisions[key(a.id,b.id)]||'proposed'});});}const matched=[...fixed,...pairs].sort((x,y)=>y.score-x.score||x.a.id.localeCompare(y.a.id));const leftUsed=new Set(matched.map(x=>x.a.id)),rightUsed=new Set(matched.map(x=>x.b.id));return {pairs:matched,unmatchedA:S.left.filter(x=>!leftUsed.has(x.id)),unmatchedB:S.right.filter(x=>!rightUsed.has(x.id))};}
function qualityAt(thresh=S.threshold,useDecisions=true){if(!S.gold)return null;const result=resolveAt(thresh,useDecisions);const truth=new Set(S.gold.map(x=>key(...x))),pred=new Set(result.pairs.map(x=>key(x.a.id,x.b.id)));let tp=0;for(const k of pred)if(truth.has(k))tp++;const fp=pred.size-tp,fn=truth.size-tp;const precision=pred.size?tp/pred.size:0,recall=truth.size?tp/truth.size:0;return {tp,fp,fn,precision,recall,f1:precision+recall?2*precision*recall/(precision+recall):0,truth,pred};}
function stats(){const r=resolveAt();const suggested=[];for(const a of r.unmatchedA)for(const b of r.unmatchedB){if(S.decisions[key(a.id,b.id)]==='rejected')continue;const sc=scorePair(a,b);if(sc.score>=Math.max(.45,S.threshold-.20))suggested.push({...sc,a,b,decision:'candidate'});}suggested.sort((a,b)=>b.score-a.score);return {r,strong:r.pairs.filter(p=>p.score>=S.strong).length,review:r.pairs.filter(p=>p.score<S.strong&&p.decision!=='approved').length+suggested.slice(0,12).length,suggested:suggested.slice(0,12),approved:r.pairs.filter(p=>p.decision==='approved').length};}
const pct=x=>(x*100).toFixed(1)+'%';const bar=(label,n,tot,color='')=>`<div class="bar-row"><div class="bar-top"><span>${esc(label)}</span><strong>${n} <span class="legend">/ ${tot}</span></strong></div><div class="bar-bg"><div class="bar-fill ${color}" style="width:${tot?Math.min(100,n/tot*100):0}%"></div></div></div>`;
function show(tab){S.tab=tab;for(const p of document.querySelectorAll('.page'))p.classList.toggle('active',p.id==='page-'+tab);for(const b of document.querySelectorAll('#nav button'))b.classList.toggle('active',b.dataset.tab===tab);const titles={overview:['Entity Resolution Command Center','Resolve duplicates, reconcile sources and audit every decision.'],match:['Matching Workbench','Explainable links, scores and field-level evidence.'],review:['Human Review Queue','Confirm ambiguous links and document corrections.'],quality:['Quality Diagnostics','Evaluate synthetic reference labels and review missed links.'],rules:['Rules & Sensitivity','Configure thresholds, feature weights and confidence policies.'],data:['Data & Audit Exports','Import sources, validate records and download reproducible files.']};$('pageTitle').textContent=titles[tab][0];$('pageSubtitle').textContent=titles[tab][1];render();}
function metric(label,value,detail,color=''){return `<div class="metric"><div class="label">${label}</div><div class="value ${color}">${value}</div><div class="detail">${detail}</div></div>`}
function renderOverview(){const {r,strong,review}=stats();$('overviewMetrics').innerHTML=metric('Records in source A',S.left.length,'Candidate entities in first source')+metric('Assigned record pairs',r.pairs.length,'One-to-one resolved links','mint')+metric('Needs human review',review,'Includes unmatched candidate links','amber')+metric('Unmatched records',r.unmatchedA.length+r.unmatchedB.length,'Across both sources','blue');$('confidenceBars').innerHTML=bar('Strong confidence',strong,r.pairs.length)+bar('Review recommended',review,r.pairs.length+stats().suggested.length,'amberbar')+bar('Human approved',r.pairs.filter(x=>x.decision==='approved').length,r.pairs.length,'bluebar');$('summaryBox').innerHTML=`<div class="mini-kpi"><small>Available records in source B</small><strong>${S.right.length}</strong></div><div class="mini-kpi"><small>Source A not matched</small><strong>${r.unmatchedA.length}</strong></div><div class="mini-kpi"><small>Source B not matched</small><strong>${r.unmatchedB.length}</strong></div><div class="mini-kpi"><small>Minimum acceptance score</small><strong>${pct(S.threshold)}</strong></div><div class="info">All records and affiliations are synthetic. Review links before updating real systems.</div>`;$('previewTable').innerHTML=r.pairs.length?tableMatches(r.pairs.slice(0,5),false):'<div class="empty">No eligible pairs. Lower the threshold or load different sources.</div>';}
function tableMatches(pairs,clickable=true){return `<div class="table-wrap"><table><thead><tr><th>Source A</th><th>Source B</th><th>Score</th><th>Status</th><th class="num">Evidence</th></tr></thead><tbody>${pairs.map(p=>`<tr class="${clickable?'row-click':''}" ${clickable?`data-pair="${esc(key(p.a.id,p.b.id))}"`:''}><td><strong>${esc(p.a.name)}</strong><small>${esc(p.a.id)} · ${esc(p.a.city)}</small></td><td><strong>${esc(p.b.name)}</strong><small>${esc(p.b.id)} · ${esc(p.b.city)}</small></td><td><strong class="${p.score>=S.strong?'mint':'amber'}">${pct(p.score)}</strong></td><td><span class="status ${p.decision==='approved'?'':p.score>=S.strong?'':'review'}">${p.decision==='approved'?'Approved':p.score>=S.strong?'Strong match':'Review'}</span></td><td class="num">${Object.keys(p.attrs).length} fields</td></tr>`).join('')}</tbody></table></div>`}
function renderMatch(){const r=stats().r;const term=norm($('matchSearch').value);const f=$('matchFilter').value;const filtered=r.pairs.filter(p=>(f==='all'||(f==='strong'&&p.score>=S.strong)||(f==='review'&&p.score<S.strong))&&(!term||norm(p.a.name+' '+p.b.name+' '+p.a.id+' '+p.b.id).includes(term)));$('matchCount').textContent=filtered.length+' matches';$('matchesTable').innerHTML=filtered.length?tableMatches(filtered):'<div class="empty">No links match the current filters.</div>';const chosen=r.pairs.find(p=>key(p.a.id,p.b.id)===S.selected);if(!chosen){$('detailPanel').innerHTML='<div class="empty">Select a pair in the table to inspect scores and evidence.</div>';return}$('detailPanel').innerHTML=`<div class="grid2 grid-equal"><div class="mini-kpi"><span>${esc(chosen.a.name)}</span><small>${esc(chosen.a.id)}</small></div><div class="mini-kpi"><span>${esc(chosen.b.name)}</span><small>${esc(chosen.b.id)}</small></div></div>${Object.entries(chosen.attrs).map(([k,v])=>bar(k.toUpperCase()+' · '+pct(v),Math.round(v*100),100)).join('')}<div class="info">Combined score: <strong>${pct(chosen.score)}</strong>. Missing attributes are omitted from the weighted average, never scored as automatic matches.</div>`;}
function renderReview(){const {r,suggested}=stats();let queue=[...r.pairs.filter(p=>p.score<S.strong&&p.decision!=='approved'),...suggested];$('reviewMetrics').innerHTML=`<div class="spaced"><span class="pill">${queue.length} proposed links to review</span><span class="hint">Review queue also includes any lower-confidence pair found by the optimizer.</span></div>`;$('reviewCards').innerHTML=queue.length?queue.map(p=>`<div class="review-card"><div><h4>${esc(p.a.name)}</h4><p>${esc(p.a.city)} · ${esc(p.a.email)}</p><p>${esc(p.a.code||'No identifier')}</p></div><div class="arr">↔</div><div><h4>${esc(p.b.name)}</h4><p>${esc(p.b.city)} · ${esc(p.b.email)}</p><p>${esc(p.b.code||'No identifier')}</p></div><div class="actions"><span class="score">${pct(p.score)}</span><span class="hint">${p.decision==='candidate'?'Unassigned; manual approval only':'Algorithmic candidate only'}</span><button class="btn small primary" data-decision="approved" data-id="${esc(key(p.a.id,p.b.id))}">✓ Confirm link</button><button class="btn small" data-decision="rejected" data-id="${esc(key(p.a.id,p.b.id))}">× Reject link</button></div></div>`).join(''):'<div class="empty">No open lower-confidence matches at this threshold.</div>';const decisions=Object.entries(S.decisions);$('decisionTrail').innerHTML=decisions.length?`<div class="table-wrap"><table><thead><tr><th>Record pair</th><th>Human decision</th><th>Action</th></tr></thead><tbody>${decisions.map(([k,v])=>`<tr><td>${esc(k.replace('|',' ↔ '))}</td><td><span class="status ${v==='rejected'?'danger':''}">${esc(v)}</span></td><td><button class="btn small" data-decision="undo" data-id="${esc(k)}">Undo</button></td></tr>`).join('')}</tbody></table></div>`:'<p class="hint">No manual decisions yet. Review a candidate to create an auditable override.</p>';}
function renderQuality(){const q=qualityAt();if(!q){$('qualityMetrics').innerHTML='<div class="empty">Not available for imported files. Provide independently verified labels before evaluating model accuracy.</div>';$('qualityChart').innerHTML='<div class="empty">Threshold chart requires known reference matches.</div>';$('errorsTable').innerHTML='<div class="empty">No ground-truth links supplied with these data.</div>';return;}$('qualityMetrics').innerHTML=`<div class="metrics" style="grid-template-columns:repeat(2,1fr)">${metric('Precision',pct(q.precision),'Correct among predicted','mint')}${metric('Recall',pct(q.recall),'Detected among true links','blue')}${metric('F1 score',pct(q.f1),'Balance of precision and recall','mint')}${metric('Missed true links',q.fn,'Reference links not recovered','amber')}</div><div class="info warning">Scores compare against fictional demonstration labels, not real-world identity evidence.</div>`;const points=[.50,.60,.70,.80,.87,.93];$('qualityChart').innerHTML=points.map(t=>{const x=qualityAt(t,false);return `<div><div class="spaced hint"><strong>${pct(t)} threshold</strong><span>Precision ${pct(x.precision)} · Recall ${pct(x.recall)}</span></div>${bar('F1',Math.round(x.f1*100),100)}</div>`}).join('');const fp=[...q.pred].filter(k=>!q.truth.has(k)),fn=[...q.truth].filter(k=>!q.pred.has(k));$('errorsTable').innerHTML=(fp.length||fn.length)?`<div class="table-wrap"><table><thead><tr><th>Reference discrepancy</th><th>Record link</th></tr></thead><tbody>${fp.map(k=>`<tr><td><span class="status danger">False match</span></td><td>${esc(k.replace('|',' ↔ '))}</td></tr>`).join('')}${fn.map(k=>`<tr><td><span class="status review">Missed link</span></td><td>${esc(k.replace('|',' ↔ '))}</td></tr>`).join('')}</tbody></table></div>`:'<div class="info">No discrepancies detected with the supplied synthetic labels at this threshold.</div>';}
function renderRules(){for(const id of ['threshold','strong','nameWeight','emailWeight','cityWeight','codeWeight']){const el=$(id);el.value=(id==='threshold'?S.threshold*100:id==='strong'?S.strong*100:S.w[id.replace('Weight','')]);$(id+'Val').textContent=id==='threshold'||id==='strong'?pct(+el.value/100):el.value+'%';}if(S.strong<S.threshold){$('rulesWarning').textContent='Strong confidence threshold is lower than acceptance threshold. Consider increasing it.'}else if(Object.values(S.w).every(v=>v===0)){$('rulesWarning').textContent='At least one field must have positive weight; the previous valid configuration is retained.'}else $('rulesWarning').textContent='Weights are normalized over attributes present in both records. Weak or missing fields can increase uncertainty and should trigger manual verification.';$('sensitivityBars').innerHTML=[.5,.6,.7,.8,.87,.93].map(t=>bar('Threshold '+pct(t),resolveAt(t,false).pairs.length,Math.min(S.left.length,S.right.length))).join('');}
function safeDataTable(records){return `<div class="table-wrap"><table><thead><tr><th>ID</th><th>Organization</th><th>City</th></tr></thead><tbody>${records.slice(0,12).map(x=>`<tr><td>${esc(x.id)}</td><td>${esc(x.name)}</td><td>${esc(x.city)}</td></tr>`).join('')}</tbody></table></div>${records.length>12?`<div class="hint">Showing first 12 of ${records.length}</div>`:''}`;}
function renderData(){ $('sourceCounts').textContent=`A: ${S.left.length} · B: ${S.right.length}`;$('dataLeft').innerHTML='<h3>Source A</h3>'+safeDataTable(S.left);$('dataRight').innerHTML='<h3>Source B</h3>'+safeDataTable(S.right);}
function render(){renderOverview();renderMatch();renderReview();renderQuality();renderRules();renderData();}
function loadDemo(id){const d=DEMOS[id];S={...S,scenario:id,left:d.left.map(x=>({...x})),right:d.right.map(x=>({...x})),gold:d.gold.map(x=>[...x]),custom:false,decisions:{},selected:null};$('scenario').value=id;render();}
function flash(msg,err=false){const t=$('toast');t.textContent=msg;t.className=err?'error':'';t.style.display='block';clearTimeout(flash.timer);flash.timer=setTimeout(()=>t.style.display='none',3600);}
function decide(pair,decision){if(decision==='undo')delete S.decisions[pair];else S.decisions[pair]=decision;S.selected=null;render();flash('Review decision updated. Matching and audit refreshed.');}
// Stateful RFC4180-style CSV parser; rejects non-delimiter text after a closing quote.
function csvParse(text){if(text.charCodeAt(0)===0xfeff)text=text.slice(1);const rows=[],row=[];let s='',quoted=false,closed=false,start=true;for(let i=0;i<text.length;i++){const c=text[i];if(quoted){if(c==='"'){if(text[i+1]==='"'){s+='"';i++}else{quoted=false;closed=true}}else s+=c;}else if(c===','){row.push(s);s='';closed=false;start=true;}else if(c==='\n'||c==='\r'){if(c==='\r'&&text[i+1]==='\n')i++;row.push(s);if(row.some(x=>x.trim()!==''))rows.push([...row]);row.length=0;s='';closed=false;start=true;}else if(c==='"'){if(start&&s===''){quoted=true;start=false}else throw Error('Misplaced quote in CSV.');}else{if(closed&&!/\s/.test(c))throw Error('Text after closing quotation mark.');s+=c;if(!/\s/.test(c))start=false;}}if(quoted)throw Error('Unclosed CSV quotation.');row.push(s);if(row.some(x=>x.trim()!==''))rows.push(row);return rows;}
function parseRecords(text){if(text.length>1000000)throw Error('File exceeds 1 MB.');const rows=csvParse(text);if(rows.length<2)throw Error('CSV requires a header and at least one record.');const header=rows[0].map(x=>x.trim().toLowerCase());if(new Set(header).size!==header.length)throw Error('Duplicate column heading.');for(const col of ['id','name'])if(!header.includes(col))throw Error('Missing required column: '+col);const data=rows.slice(1);if(data.length>80)throw Error('Maximum 80 records per source.');const ids=new Set();return data.map((r,i)=>{if(r.length!==header.length)throw Error('Incorrect number of fields at line '+(i+2));const o={};for(const col of ['id','name','city','email','code','sector']){o[col]=header.includes(col)?r[header.indexOf(col)].trim():'';if(o[col].length>220)throw Error('Field exceeds 220 characters at line '+(i+2));}if(!o.id||!o.name)throw Error('Missing id or name at line '+(i+2));if(ids.has(o.id))throw Error('Duplicate identifier '+o.id);ids.add(o.id);return o;});}
function safeCsvCell(value){let v=String(value??'');if(/^[\s\uFEFF\u200B]*[=+@\-]/.test(v))v="'"+v;return '"'+v.replace(/"/g,'""')+'"';}
function toCsv(rows,cols){return [cols.map(safeCsvCell).join(','),...rows.map(r=>cols.map(c=>safeCsvCell(r[c])).join(','))].join('\r\n')+'\r\n';}
const recordFields=['id','name','city','email','code','sector'];
function exportFile(type){const result=resolveAt();let filename='',data='',mime='text/csv;charset=utf-8';const matchRows=result.pairs.map(p=>({source_a_id:p.a.id,source_a_name:p.a.name,source_b_id:p.b.id,source_b_name:p.b.name,score:Number(p.score.toFixed(6)),status:p.decision,confidence:p.score>=S.strong?'strong':'review'}));if(type==='audit'){filename='linkresolve_audit.json';data=JSON.stringify({project:'LinkResolve v1.0',scenario:S.custom?'Imported':S.scenario,sourceA:S.left,sourceB:S.right,settings:{threshold:S.threshold,strong:S.strong,weights:S.w},matches:matchRows,unmatchedA:result.unmatchedA,unmatchedB:result.unmatchedB,decisions:S.decisions,referenceEvaluation:qualityAt(S.threshold,true)?(({tp,fp,fn,precision,recall,f1})=>({tp,fp,fn,precision,recall,f1}))(qualityAt(S.threshold,true)):null,disclaimer:'Algorithmic scores are not verified identities. Synthetic examples.'},null,2);mime='application/json;charset=utf-8';}
else if(type==='matches'){filename='linkresolve_matches.csv';data=toCsv(matchRows,['source_a_id','source_a_name','source_b_id','source_b_name','score','status','confidence']);}
else if(type==='unmatchedA'||type==='unmatchedB'){filename=type==='unmatchedA'?'linkresolve_unmatched_a.csv':'linkresolve_unmatched_b.csv';data=toCsv(type==='unmatchedA'?result.unmatchedA:result.unmatchedB,recordFields);}
else if(type==='decisions'){filename='linkresolve_review_decisions.csv';data=toCsv(Object.entries(S.decisions).map(([k,status])=>({source_a_id:k.split('|')[0],source_b_id:k.split('|')[1],status})),['source_a_id','source_b_id','status']);}
else if(type==='templateA'||type==='templateB'){filename=type==='templateA'?'linkresolve_template_a.csv':'linkresolve_template_b.csv';data=toCsv([{id:'EX01',name:'Example Entity',city:'Sample City',email:'demo@example.test',code:'CODE-1',sector:'Research'}],recordFields);}
else if(type==='methods'){filename='linkresolve_methods.md';mime='text/markdown;charset=utf-8';data='# LinkResolve v1.0 | Methods and limitations\n\n## Data\nLocal browser-only CSV imports. Each source <=80 records. Fields: id, name, city, email, code, sector.\n\n## Similarity\nUnicode accent and case normalization. Name similarity = 0.55 normalized Levenshtein + 0.45 token Jaccard. City similarity uses normalized Levenshtein. Email and identifier matches use normalized exact equality. Missing on either side => omit feature and renormalize positive weights.\n\n## Allocation\nRectangular Hungarian method finds maximum sum of (score - threshold) across disjoint source pairs; unmatched dummy columns are allowed. Manually accepted links reserve endpoints; rejected links cannot be selected.\n\n## Quality\nDemonstration sets have synthetic reference matches for descriptive precision/recall; imported data have no labels and no estimated precision. Sensitivity results ignore manual decisions.\n\n## Limitations\nSimilar names, reused emails and reused identifiers can lead to false matches. Scores are not calibrated probabilities, matches are not verified identities and one-to-one assumptions may fail for legal entities with subsidiaries. Human approval is required before operational merging. No cloud saving.\n';}
else throw Error('Unknown export.');const blob=new Blob([data],{type:mime});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),1000);flash(filename+' downloaded.');return {filename,data};}
function updateRules(){const threshold=Number($('threshold').value)/100,strong=Number($('strong').value)/100;const w={name:+$('nameWeight').value,email:+$('emailWeight').value,city:+$('cityWeight').value,code:+$('codeWeight').value};if(Object.values(w).every(v=>v===0)){flash('At least one attribute weight must be greater than zero.',true);return;}S.threshold=threshold;S.strong=strong;S.w=w;S.selected=null;render();}
function init(){loadDemo('registry');document.querySelectorAll('#nav button').forEach(b=>b.addEventListener('click',()=>show(b.dataset.tab)));document.querySelectorAll('[data-goto]').forEach(b=>b.addEventListener('click',()=>show(b.dataset.goto)));$('scenario').addEventListener('change',e=>{if(S.custom&&!confirm('This will replace your imported records and manual decisions. Continue?')){$('scenario').value=S.scenario;return;}if(Object.keys(S.decisions).length&&!confirm('Switching scenarios will discard manual review decisions. Continue?')){$('scenario').value=S.scenario;return;}loadDemo(e.target.value);});$('matchFilter').addEventListener('change',renderMatch);$('matchSearch').addEventListener('input',renderMatch);$('matchesTable').addEventListener('click',e=>{const row=e.target.closest('[data-pair]');if(row){S.selected=row.dataset.pair;renderMatch();}});$('reviewCards').addEventListener('click',e=>{const b=e.target.closest('[data-decision]');if(b)decide(b.dataset.id,b.dataset.decision);});$('decisionTrail').addEventListener('click',e=>{const b=e.target.closest('[data-decision]');if(b)decide(b.dataset.id,b.dataset.decision);});$('clearReview').addEventListener('click',()=>{if(!Object.keys(S.decisions).length)return;if(confirm('Reset all manual decisions?')){S.decisions={};render();flash('Manual decisions cleared.')}});for(const id of ['threshold','strong','nameWeight','emailWeight','cityWeight','codeWeight'])$(id).addEventListener('input',updateRules);$('resetRules').addEventListener('click',()=>{S.threshold=DEFAULT.threshold;S.strong=DEFAULT.strong;S.w={...DEFAULT.w};render();flash('Default matching policy restored.');});for(const [id,source] of [['leftFile','left'],['rightFile','right']])$(id).addEventListener('change',async e=>{const file=e.target.files[0];if(!file)return;try{const records=parseRecords(await file.text());if(S.gold&&Object.keys(S.decisions).length&&!confirm('Import will clear the current review decisions. Continue?'))return;S[source]=records;S.gold=null;S.custom=true;S.decisions={};S.selected=null;render();flash('Imported '+records.length+' records into source '+(source==='left'?'A':'B')+'.');}catch(err){flash('Import rejected: '+err.message,true)}finally{e.target.value='';}});$('downloads').addEventListener('click',e=>{const b=e.target.closest('[data-export]');if(b)exportFile(b.dataset.export);});}init();
window.LinkResolveTest={norm,nameSim,scorePair,optimal,resolveAt,qualityAt,csvParse,parseRecords,safeCsvCell,toCsv,exportFile,loadDemo,state:()=>S};
</script></body></html>