Upload 3 files
Browse files- LICENSE +21 -0
- README.md +89 -6
- index.html +108 -18
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 LinkResolve contributors
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,12 +1,95 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
|
|
|
| 7 |
pinned: false
|
| 8 |
license: mit
|
| 9 |
-
short_description: Offline entity matching and record linkage studio
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: "LinkResolve | Entity Resolution Studio"
|
| 3 |
+
emoji: "🔗"
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: static
|
| 7 |
+
app_file: index.html
|
| 8 |
pinned: false
|
| 9 |
license: mit
|
| 10 |
+
short_description: "Offline entity matching and record linkage studio"
|
| 11 |
+
tags:
|
| 12 |
+
- data-quality
|
| 13 |
+
- entity-resolution
|
| 14 |
+
- record-linkage
|
| 15 |
+
- data-cleaning
|
| 16 |
+
- decision-support
|
| 17 |
+
- javascript
|
| 18 |
---
|
| 19 |
|
| 20 |
+
# LinkResolve | Entity Resolution Studio
|
| 21 |
+
|
| 22 |
+
**Project 71 · Local-first record matching, human review and reproducible data audits**
|
| 23 |
+
|
| 24 |
+
LinkResolve is an interactive, fully offline, explainable entity resolution tool that reconciles two lists of organizations, institutions or business partners. It compares fields, proposes one-to-one links, highlights ambiguous records, lets an analyst confirm or reject links, and exports the complete audit trail.
|
| 25 |
+
|
| 26 |
+
## Features
|
| 27 |
+
|
| 28 |
+
1. **Command Center:** linked entities, source coverage, confidence distribution, unmatched records.
|
| 29 |
+
2. **Matching Workbench:** matching scores, searchable pairs, field-by-field similarity explanation.
|
| 30 |
+
3. **Review Queue:** manual approval/rejection, including candidates not assigned automatically.
|
| 31 |
+
4. **Quality Diagnostics:** precision, recall and F1 for **synthetic demonstration reference labels only**.
|
| 32 |
+
5. **Rules & Sensitivity:** tunable threshold, strong-match threshold, feature weights and sensitivity analysis.
|
| 33 |
+
6. **Data & Audit:** source A/B CSV import, validation and eight export formats.
|
| 34 |
+
|
| 35 |
+
## Running the app
|
| 36 |
+
|
| 37 |
+
This is a completely static project. Open `index.html` in a recent browser or publish these files at the root of a **Hugging Face Static Space**:
|
| 38 |
+
|
| 39 |
+
- `README.md`
|
| 40 |
+
- `index.html`
|
| 41 |
+
- `LICENSE`
|
| 42 |
+
|
| 43 |
+
No Python, Docker, Gradio, remote requests, API keys, external JavaScript, build steps or payments are required.
|
| 44 |
+
|
| 45 |
+
## Import your own CSV
|
| 46 |
+
|
| 47 |
+
Prepare two files, one for each source. Each file must have a header and the required columns `id` and `name`. Optional columns are `city`, `email`, `code`, `sector`.
|
| 48 |
+
|
| 49 |
+
```csv
|
| 50 |
+
id,name,city,email,code,sector
|
| 51 |
+
A01,"Fundación Horizonte Andino",Jujuy,contacto@example.test,ORG-011,Nonprofit
|
| 52 |
+
A02,Universidad del Norte,Salta,enlace@example.test,UNI-102,Education
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
- UTF-8, maximum **80 records per source**, 1 MB file-size limit.
|
| 56 |
+
- IDs must be unique within each source.
|
| 57 |
+
- Quoted commas, escaped double quotes and newlines are supported.
|
| 58 |
+
- Invalid imports are rejected without replacing the current source.
|
| 59 |
+
- Data remains in the browser. Refreshing the page clears imports and review edits unless exported.
|
| 60 |
+
- Importing files removes synthetic reference labels, so no precision or recall is claimed for real data without independently verified ground truth.
|
| 61 |
+
|
| 62 |
+
## How scoring works
|
| 63 |
+
|
| 64 |
+
The algorithm normalizes casing and accents, then computes:
|
| 65 |
+
|
| 66 |
+
- Name: `0.55 × normalized Levenshtein similarity + 0.45 × token Jaccard`.
|
| 67 |
+
- City: normalized Levenshtein similarity.
|
| 68 |
+
- Email: normalized exact match.
|
| 69 |
+
- Code: normalized exact match.
|
| 70 |
+
|
| 71 |
+
The overall score is a weighted average of attributes available **in both records** (default 50% name, 25% email, 10% city and 15% code). The acceptance threshold is 70% and the strong-confidence threshold is 87% by default. **Scores are similarity measures, not calibrated probabilities.**
|
| 72 |
+
|
| 73 |
+
The rectangular Hungarian algorithm maximizes the sum of score improvements above threshold with at most one match per record. Dummy columns allow entities to remain unlinked. Accepted manual links reserve their two records; rejected pairs are excluded from subsequent optimization. The Review Queue also shows plausible unassigned links for manual approval.
|
| 74 |
+
|
| 75 |
+
## Example datasets
|
| 76 |
+
|
| 77 |
+
Three **entirely fictional** scenarios are bundled: Regional partner registry, Research directory and Supplier master data. Fictional emails use `.test`. The examples include exact matches, spelling variations, abbreviations, difficult cases and intentionally unmatched records.
|
| 78 |
+
|
| 79 |
+
## Exports
|
| 80 |
+
|
| 81 |
+
Full JSON audit, assigned matches CSV, unmatched source A CSV, unmatched source B CSV, manual decisions CSV, source A CSV template, source B CSV template and methodology Markdown. CSV fields beginning with spreadsheet formula syntax are escaped when exported.
|
| 82 |
+
|
| 83 |
+
## Validation
|
| 84 |
+
|
| 85 |
+
The accompanying technical ZIP contains independent Hungarian-algorithm comparisons, parser checks, browser interaction tests, desktop/mobile screenshots and a test protocol.
|
| 86 |
+
|
| 87 |
+
## Important limitations
|
| 88 |
+
|
| 89 |
+
- Human review is needed before any production database merge. Similar names or shared identifiers do not prove two records refer to the same legal entity.
|
| 90 |
+
- Missing fields change weight normalization and can overstate apparent similarity.
|
| 91 |
+
- Source records are assumed to have one-to-one relationships. Subsidiaries, cross-border branches and changing legal names can violate this assumption.
|
| 92 |
+
- The application does not verify company registration or access external authority databases.
|
| 93 |
+
- Synthetic quality metrics should not be presented as results on real-world datasets.
|
| 94 |
+
|
| 95 |
+
**License:** MIT. This is an illustrative and educational tool, not an identity verification service.
|
index.html
CHANGED
|
@@ -1,19 +1,109 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>LinkResolve | Entity Resolution Studio</title>
|
| 3 |
+
<style>
|
| 4 |
+
: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%}}
|
| 5 |
+
</style></head><body>
|
| 6 |
+
<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>
|
| 7 |
+
<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>
|
| 8 |
+
<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>
|
| 9 |
+
<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>
|
| 10 |
+
<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>
|
| 11 |
+
<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>
|
| 12 |
+
<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>
|
| 13 |
+
<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>
|
| 14 |
+
<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>
|
| 15 |
+
<script>
|
| 16 |
+
'use strict';
|
| 17 |
+
const DEMOS={
|
| 18 |
+
registry:{label:'Regional partner registry',left:[
|
| 19 |
+
{id:'A01',name:'Fundación Horizonte Andino',city:'Jujuy',email:'contacto@horizonte.test',code:'ORG-011',sector:'Nonprofit'},
|
| 20 |
+
{id:'A02',name:'Universidad del Norte',city:'Salta',email:'enlace@unorte.test',code:'UNI-102',sector:'Education'},
|
| 21 |
+
{id:'A03',name:'Centro de Desarrollo Sur',city:'Tarija',email:'info@cdesur.test',code:'ORG-084',sector:'Development'},
|
| 22 |
+
{id:'A04',name:'Cooperativa Río Claro',city:'Jujuy',email:'admin@rioclaro.test',code:'COOP-33',sector:'Agriculture'},
|
| 23 |
+
{id:'A05',name:'Andes Logistics Group',city:'Asunción',email:'ventas@andeslog.test',code:'LOG-88',sector:'Logistics'},
|
| 24 |
+
{id:'A06',name:'Instituto de Estudios Regionales',city:'Córdoba',email:'hola@ier.test',code:'IER-19',sector:'Research'},
|
| 25 |
+
{id:'A07',name:'Fundación Nueva Ruta',city:'Salta',email:'team@nuevaruta.test',code:'FNR-22',sector:'Nonprofit'},
|
| 26 |
+
{id:'A08',name:'Red de Innovación Social',city:'Tarija',email:'red@innovasocial.test',code:'RIS-15',sector:'Development'},
|
| 27 |
+
{id:'A09',name:'Laboratorio Terra',city:'Asunción',email:'admin@terra.test',code:'TERRA-1',sector:'Research'}],right:[
|
| 28 |
+
{id:'B01',name:'Fundacion Horizonte Andino',city:'San Salvador de Jujuy',email:'contacto@horizonte.test',code:'ORG-011',sector:'Nonprofit'},
|
| 29 |
+
{id:'B02',name:'Univ. del Norte',city:'Salta',email:'enlace@unorte.test',code:'UNI-102',sector:'Education'},
|
| 30 |
+
{id:'B03',name:'Centro Desarrollo del Sur',city:'Tarija',email:'info@cdesur.test',code:'ORG-084',sector:'Development'},
|
| 31 |
+
{id:'B04',name:'Coop Rio Claro',city:'Jujuy',email:'admin@rioclaro.test',code:'COOP-33',sector:'Agriculture'},
|
| 32 |
+
{id:'B05',name:'Andes Logistic Group',city:'Asuncion',email:'ventas@andeslog.test',code:'LOG-88',sector:'Logistics'},
|
| 33 |
+
{id:'B06',name:'Instituto Estudios Regionales',city:'Cordoba',email:'hola@ier.test',code:'IER-19',sector:'Research'},
|
| 34 |
+
{id:'B07',name:'Fundación Ruta Nueva',city:'Salta',email:'inbox@nuevaruta.test',code:'',sector:'Nonprofit'},
|
| 35 |
+
{id:'B08',name:'Centro de Tecnología del Valle',city:'Tarija',email:'ctv@valle.test',code:'CTV-18',sector:'Technology'},
|
| 36 |
+
{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']]},
|
| 37 |
+
research:{label:'Research directory',left:[
|
| 38 |
+
{id:'A01',name:'Centro de Políticas Públicas',city:'Córdoba',email:'cpp@publica.test',code:'R-10',sector:'Policy'},
|
| 39 |
+
{id:'A02',name:'Observatorio Agro Andino',city:'Jujuy',email:'oaa@agro.test',code:'R-11',sector:'Agriculture'},
|
| 40 |
+
{id:'A03',name:'Laboratorio de Lenguaje Aplicado',city:'Córdoba',email:'lla@lengua.test',code:'R-12',sector:'Linguistics'},
|
| 41 |
+
{id:'A04',name:'Instituto de Economía Digital',city:'Buenos Aires',email:'ied@digital.test',code:'R-13',sector:'Economics'},
|
| 42 |
+
{id:'A05',name:'Red Fronteriza de Educación',city:'Tarija',email:'rfe@frontera.test',code:'R-14',sector:'Education'},
|
| 43 |
+
{id:'A06',name:'Centro de Datos del Sur',city:'Salta',email:'info@cdsur.test',code:'R-15',sector:'Technology'},
|
| 44 |
+
{id:'A07',name:'Instituto Prisma',city:'La Paz',email:'prisma@inst.test',code:'R-16',sector:'Research'}],right:[
|
| 45 |
+
{id:'B01',name:'Centro Politicas Publicas',city:'Cordoba',email:'cpp@publica.test',code:'R-10',sector:'Policy'},
|
| 46 |
+
{id:'B02',name:'Observatorio Agroandino',city:'Jujuy',email:'oaa@agro.test',code:'R-11',sector:'Agriculture'},
|
| 47 |
+
{id:'B03',name:'Lab Lenguaje Aplicado',city:'Córdoba',email:'lla@lengua.test',code:'R-12',sector:'Linguistics'},
|
| 48 |
+
{id:'B04',name:'Instituto Economia Digital',city:'Buenos Aires',email:'ied@digital.test',code:'R-13',sector:'Economics'},
|
| 49 |
+
{id:'B05',name:'Red Fronteriza Educación',city:'Tarija',email:'rfe@frontera.test',code:'R-14',sector:'Education'},
|
| 50 |
+
{id:'B06',name:'Centro de Datos del Norte',city:'Salta',email:'norte@datos.test',code:'R-20',sector:'Technology'},
|
| 51 |
+
{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']]},
|
| 52 |
+
suppliers:{label:'Supplier master data',left:[
|
| 53 |
+
{id:'A01',name:'Nordex Packaging',city:'Asunción',email:'orders@nordex.test',code:'S-301',sector:'Packaging'},
|
| 54 |
+
{id:'A02',name:'Bio Andes Export',city:'Tarija',email:'office@bioandes.test',code:'S-302',sector:'Food'},
|
| 55 |
+
{id:'A03',name:'Pampa Transportes',city:'Salta',email:'fleet@pampa.test',code:'S-303',sector:'Freight'},
|
| 56 |
+
{id:'A04',name:'Insumos Central S.R.L.',city:'Jujuy',email:'central@insumos.test',code:'S-304',sector:'Materials'},
|
| 57 |
+
{id:'A05',name:'Delta Tech Services',city:'Córdoba',email:'service@delta.test',code:'S-305',sector:'Technology'},
|
| 58 |
+
{id:'A06',name:'Vía Verde Logística',city:'Asunción',email:'via@verde.test',code:'S-306',sector:'Freight'},
|
| 59 |
+
{id:'A07',name:'Soluciones Cumbre',city:'Tarija',email:'sales@cumbre.test',code:'S-307',sector:'Materials'}],right:[
|
| 60 |
+
{id:'B01',name:'Nordex Packagings',city:'Asuncion',email:'orders@nordex.test',code:'S-301',sector:'Packaging'},
|
| 61 |
+
{id:'B02',name:'BioAndes Exportaciones',city:'Tarija',email:'office@bioandes.test',code:'S-302',sector:'Food'},
|
| 62 |
+
{id:'B03',name:'Transportes Pampa',city:'Salta',email:'fleet@pampa.test',code:'S-303',sector:'Freight'},
|
| 63 |
+
{id:'B04',name:'Insumos Central SRL',city:'Jujuy',email:'central@insumos.test',code:'S-304',sector:'Materials'},
|
| 64 |
+
{id:'B05',name:'Delta Technology Services',city:'Cordoba',email:'service@delta.test',code:'S-305',sector:'Technology'},
|
| 65 |
+
{id:'B06',name:'Via Verde Logistics',city:'Asunción',email:'via@verde.test',code:'S-306',sector:'Freight'},
|
| 66 |
+
{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']]}};
|
| 67 |
+
const DEFAULT={threshold:0.70,strong:0.87,w:{name:50,email:25,city:10,code:15}};
|
| 68 |
+
let S={scenario:'registry',left:[],right:[],gold:null,custom:false,threshold:DEFAULT.threshold,strong:DEFAULT.strong,w:{...DEFAULT.w},decisions:{},selected:null,tab:'overview'};
|
| 69 |
+
const $=id=>document.getElementById(id);const esc=x=>String(x??'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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,' ');
|
| 70 |
+
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)}
|
| 71 |
+
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}
|
| 72 |
+
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};}
|
| 73 |
+
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;}
|
| 74 |
+
const key=(a,b)=>a+'|'+b;
|
| 75 |
+
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))};}
|
| 76 |
+
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};}
|
| 77 |
+
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};}
|
| 78 |
+
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>`;
|
| 79 |
+
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();}
|
| 80 |
+
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>`}
|
| 81 |
+
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>';}
|
| 82 |
+
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>`}
|
| 83 |
+
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>`;}
|
| 84 |
+
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>';}
|
| 85 |
+
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>';}
|
| 86 |
+
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('');}
|
| 87 |
+
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>`:''}`;}
|
| 88 |
+
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);}
|
| 89 |
+
function render(){renderOverview();renderMatch();renderReview();renderQuality();renderRules();renderData();}
|
| 90 |
+
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();}
|
| 91 |
+
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);}
|
| 92 |
+
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.');}
|
| 93 |
+
// Stateful RFC4180-style CSV parser; rejects non-delimiter text after a closing quote.
|
| 94 |
+
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;}
|
| 95 |
+
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;});}
|
| 96 |
+
function safeCsvCell(value){let v=String(value??'');if(/^[\s\uFEFF\u200B]*[=+@\-]/.test(v))v="'"+v;return '"'+v.replace(/"/g,'""')+'"';}
|
| 97 |
+
function toCsv(rows,cols){return [cols.map(safeCsvCell).join(','),...rows.map(r=>cols.map(c=>safeCsvCell(r[c])).join(','))].join('\r\n')+'\r\n';}
|
| 98 |
+
const recordFields=['id','name','city','email','code','sector'];
|
| 99 |
+
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';}
|
| 100 |
+
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']);}
|
| 101 |
+
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);}
|
| 102 |
+
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']);}
|
| 103 |
+
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);}
|
| 104 |
+
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';}
|
| 105 |
+
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};}
|
| 106 |
+
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();}
|
| 107 |
+
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();
|
| 108 |
+
window.LinkResolveTest={norm,nameSim,scorePair,optimal,resolveAt,qualityAt,csvParse,parseRecords,safeCsvCell,toCsv,exportFile,loadDemo,state:()=>S};
|
| 109 |
+
</script></body></html>
|