Conformer / index.html
Aayush Kothari
Fix encoder status badge
b7be313
Raw
History Blame Contribute Delete
157 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Conformer β€” workbench for BRAIDBERTa + DeepBERTa</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
/* ============================================================
TOKENS β€” palette is CPK (the chemists' universal element code)
desaturated for screen. Every hue carries a job, and these same
values are handed to RDKit as the atom colour palette, so the
depictions and the interface share one system.
============================================================ */
:root{
--ink:#0B0E14;
--ink-2:#0F1421;
--slate:#141B29;
--graphite:#1B2333;
--edge:#28324a;
--edge-soft:#1f2839;
--bone:#E9ECF3;
--ash:#8C97AE;
--ash-dim:#5D6880;
--c:#AAB3C5; /* carbon Β· load */
--n:#5B7CFF; /* nitrogen Β· convert */
--o:#FF6B6B; /* oxygen Β· draw */
--p:#FF9F45; /* phosphorus Β· predict */
--s:#F2C94C; /* sulfur Β· tokenize */
--x:#6EE7A0; /* halogen Β· compare */
--h:#FFFFFF; /* hydrogen Β· export */
--ok:#6EE7A0;
--warn:#F2C94C;
--bad:#FF6B6B;
--r-sm:4px; --r:7px; --r-lg:11px;
--rail-w:212px;
--head-h:54px;
--ff-display:'Space Grotesk','Inter',system-ui,sans-serif;
--ff-body:'Inter',system-ui,-apple-system,sans-serif;
--ff-mono:'IBM Plex Mono','SFMono-Regular',Consolas,monospace;
--sh-1:0 1px 0 rgba(255,255,255,.03) inset, 0 1px 2px rgba(0,0,0,.4);
--sh-2:0 10px 34px rgba(0,0,0,.5);
}
*,*::before,*::after{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;background:var(--ink);color:var(--bone);
font-family:var(--ff-body);font-size:14px;line-height:1.5;
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
}
::selection{background:rgba(91,124,255,.32)}
:focus-visible{outline:2px solid var(--n);outline-offset:2px;border-radius:3px}
button,input,select,textarea{font:inherit;color:inherit}
a{color:var(--n);text-decoration:none}
a:hover{text-decoration:underline}
/* scrollbars */
*::-webkit-scrollbar{width:10px;height:10px}
*::-webkit-scrollbar-track{background:transparent}
*::-webkit-scrollbar-thumb{background:var(--edge);border-radius:20px;border:3px solid var(--ink)}
*::-webkit-scrollbar-thumb:hover{background:#37425c}
/* ============================================================
SHELL
============================================================ */
.app{display:grid;grid-template-rows:var(--head-h) 1fr;height:100vh;height:100dvh;overflow:hidden}
.head{
display:flex;align-items:center;gap:14px;padding:0 16px;
border-bottom:1px solid var(--edge-soft);background:var(--ink-2);
position:relative;z-index:40;
}
.brand{display:flex;align-items:baseline;gap:9px;margin-right:2px}
.brand-mark{
font-family:var(--ff-display);font-weight:700;font-size:15px;letter-spacing:.16em;
color:var(--bone);
}
.brand-sub{
font-family:var(--ff-mono);font-size:11px;color:var(--ash-dim);letter-spacing:.02em;
white-space:nowrap;
}
.head-spacer{flex:1}
.head-stat{
display:flex;align-items:center;gap:7px;padding:5px 10px;border-radius:var(--r);
border:1px solid var(--edge-soft);background:var(--slate);
font-family:var(--ff-mono);font-size:11px;color:var(--ash);cursor:pointer;
}
.head-stat:hover{border-color:var(--edge);color:var(--bone)}
.dot{width:7px;height:7px;border-radius:50%;background:var(--ash-dim);flex:none}
.dot.is-ok{background:var(--ok);box-shadow:0 0 0 3px rgba(110,231,160,.14)}
.dot.is-bad{background:var(--bad);box-shadow:0 0 0 3px rgba(255,107,107,.14)}
.dot.is-wait{background:var(--warn);box-shadow:0 0 0 3px rgba(242,201,76,.14);animation:pulse 1.4s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
.body{display:grid;grid-template-columns:var(--rail-w) 1fr;min-height:0}
/* ============================================================
SIGNATURE β€” the pipeline rail.
Seven stages, each an element hue. The connector between two
completed stages is drawn as a double bond; incomplete stages
are joined by a single one. Progress reads as chemistry.
============================================================ */
.rail{
border-right:1px solid var(--edge-soft);background:var(--ink-2);
padding:16px 0 12px;overflow-y:auto;display:flex;flex-direction:column;
}
.rail-eyebrow{
font-family:var(--ff-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;
color:var(--ash-dim);padding:0 18px 12px;
}
.rail-list{display:flex;flex-direction:column;padding:0 14px;flex:none}
.rail-item{
--hue:var(--c);
display:grid;grid-template-columns:26px 1fr;align-items:center;gap:10px;
padding:7px 8px;border:0;background:none;cursor:pointer;text-align:left;
border-radius:var(--r);width:100%;color:var(--ash);
transition:background .15s ease,color .15s ease;
}
.rail-item:hover{background:var(--slate);color:var(--bone)}
.rail-item.is-active{background:var(--slate);color:var(--bone)}
.rail-node{
width:26px;height:26px;border-radius:50%;display:grid;place-items:center;
border:1px solid var(--edge);background:var(--ink);
font-family:var(--ff-mono);font-size:10px;color:var(--ash-dim);
transition:all .18s ease;position:relative;
}
.rail-item.is-ready .rail-node{border-color:var(--hue);color:var(--hue)}
.rail-item.is-active .rail-node{
border-color:var(--hue);color:var(--ink);background:var(--hue);
box-shadow:0 0 0 4px color-mix(in srgb,var(--hue) 16%,transparent);
}
.rail-label{display:flex;flex-direction:column;gap:1px;min-width:0}
.rail-name{font-size:13px;font-weight:500;letter-spacing:-.005em}
.rail-meta{font-family:var(--ff-mono);font-size:10px;color:var(--ash-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.rail-item.is-active .rail-meta{color:color-mix(in srgb,var(--hue) 70%,var(--ash))}
.rail-link{position:relative;height:14px;margin-left:22px;width:6px;flex:none}
.rail-link::before{content:'';position:absolute;left:2px;top:-1px;bottom:-1px;width:1px;background:var(--edge)}
.rail-link.is-done::before{left:1px;background:var(--hue,var(--c))}
.rail-link.is-done::after{content:'';position:absolute;right:1px;top:-1px;bottom:-1px;width:1px;background:var(--hue,var(--c))}
.rail-foot{margin-top:auto;padding:14px 18px 0;border-top:1px solid var(--edge-soft);margin-left:14px;margin-right:14px}
.rail-foot-row{display:flex;justify-content:space-between;gap:8px;font-family:var(--ff-mono);font-size:10.5px;color:var(--ash-dim);padding:3px 0}
.rail-foot-row b{color:var(--ash);font-weight:500}
/* ============================================================
MAIN / PANELS
============================================================ */
.main{overflow-y:auto;min-width:0;position:relative}
.panel{display:none;padding:26px 30px 90px;max-width:1420px}
.panel.is-active{display:block;animation:rise .22s ease both}
@keyframes rise{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}}
.panel-head{display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap;margin-bottom:22px}
.panel-index{
font-family:var(--ff-mono);font-size:11px;letter-spacing:.16em;color:var(--hue,var(--c));
border:1px solid color-mix(in srgb,var(--hue,var(--c)) 40%,transparent);
padding:3px 8px;border-radius:var(--r-sm);margin-bottom:6px;
}
.panel-title{font-family:var(--ff-display);font-size:27px;font-weight:600;letter-spacing:-.028em;margin:0;line-height:1.1}
.panel-note{color:var(--ash);font-size:13.5px;max-width:62ch;margin:6px 0 0}
.panel-head-actions{margin-left:auto;display:flex;gap:8px;flex-wrap:wrap}
.card{
background:var(--slate);border:1px solid var(--edge-soft);border-radius:var(--r-lg);
padding:18px;box-shadow:var(--sh-1);
}
.card + .card{margin-top:16px}
.card-head{display:flex;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap}
.card-title{font-family:var(--ff-display);font-size:14px;font-weight:600;letter-spacing:-.01em;margin:0}
.card-actions{margin-left:auto;display:flex;gap:7px;flex-wrap:wrap}
.eyebrow{font-family:var(--ff-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--ash-dim)}
.grid{display:grid;gap:16px}
.g-2{grid-template-columns:repeat(2,minmax(0,1fr))}
.g-3{grid-template-columns:repeat(3,minmax(0,1fr))}
.g-4{grid-template-columns:repeat(4,minmax(0,1fr))}
.split{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(0,1fr);gap:16px;align-items:start}
/* ============================================================
CONTROLS
============================================================ */
.btn{
display:inline-flex;align-items:center;gap:7px;padding:7px 13px;border-radius:var(--r);
border:1px solid var(--edge);background:var(--graphite);color:var(--bone);
font-size:12.5px;font-weight:500;cursor:pointer;white-space:nowrap;
transition:border-color .15s ease,background .15s ease,transform .06s ease;
}
.btn:hover{border-color:#3a4761;background:#212a3c}
.btn:active{transform:translateY(1px)}
.btn[disabled]{opacity:.4;cursor:not-allowed}
.btn.is-primary{
background:color-mix(in srgb,var(--hue,var(--n)) 16%,var(--graphite));
border-color:color-mix(in srgb,var(--hue,var(--n)) 52%,transparent);
color:var(--bone);
}
.btn.is-primary:hover{background:color-mix(in srgb,var(--hue,var(--n)) 26%,var(--graphite))}
.btn.is-ghost{background:transparent;border-color:transparent;color:var(--ash);padding:6px 8px}
.btn.is-ghost:hover{background:var(--graphite);color:var(--bone)}
.btn.is-sm{padding:4px 9px;font-size:11.5px}
.select.is-sm{padding:5px 26px 5px 9px;font-size:11.5px;background-position:right 8px center}
.btn-row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.field{display:flex;flex-direction:column;gap:6px;min-width:0}
.field-label{font-family:var(--ff-mono);font-size:10px;letter-spacing:.13em;text-transform:uppercase;color:var(--ash-dim)}
.input,.select,.area{
background:var(--ink);border:1px solid var(--edge-soft);border-radius:var(--r);
padding:8px 10px;font-size:13px;width:100%;transition:border-color .15s ease;
}
.input:focus,.select:focus,.area:focus{border-color:var(--n);outline:none}
.area{font-family:var(--ff-mono);font-size:12.5px;line-height:1.65;resize:vertical;min-height:132px}
.select{cursor:pointer;appearance:none;
background-image:linear-gradient(45deg,transparent 50%,var(--ash) 50%),linear-gradient(135deg,var(--ash) 50%,transparent 50%);
background-position:calc(100% - 15px) 50%,calc(100% - 10px) 50%;
background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:30px}
.input.is-mono{font-family:var(--ff-mono)}
.range{width:100%;accent-color:var(--n)}
.check{display:flex;align-items:center;gap:7px;font-size:12.5px;color:var(--ash);cursor:pointer;user-select:none}
.check input{accent-color:var(--n);width:14px;height:14px;cursor:pointer}
.check:hover{color:var(--bone)}
.drop{
border:1px dashed var(--edge);border-radius:var(--r-lg);padding:22px;text-align:center;
color:var(--ash);transition:all .16s ease;cursor:pointer;background:var(--ink)
}
.drop:hover,.drop.is-over{border-color:var(--c);background:var(--ink-2);color:var(--bone)}
.drop b{display:block;font-size:13.5px;color:var(--bone);font-weight:500;margin-bottom:3px}
.drop span{font-size:12px}
/* ============================================================
DATA
============================================================ */
.tbl-wrap{overflow:auto;border:1px solid var(--edge-soft);border-radius:var(--r);max-height:560px}
table{border-collapse:collapse;width:100%;font-size:12.5px}
th{
position:sticky;top:0;z-index:2;background:var(--graphite);text-align:left;
font-family:var(--ff-mono);font-size:10px;letter-spacing:.11em;text-transform:uppercase;
color:var(--ash);font-weight:500;padding:9px 11px;white-space:nowrap;
border-bottom:1px solid var(--edge);
}
td{padding:8px 11px;border-bottom:1px solid var(--edge-soft);vertical-align:middle}
tbody tr:last-child td{border-bottom:0}
tbody tr:hover td{background:rgba(255,255,255,.017)}
td.num,th.num{text-align:right;font-family:var(--ff-mono);font-variant-numeric:tabular-nums}
.mono{font-family:var(--ff-mono)}
.trunc{max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block}
.dim{color:var(--ash-dim)}
.ash{color:var(--ash)}
.badge{
display:inline-flex;align-items:center;gap:5px;padding:2px 7px;border-radius:20px;
font-family:var(--ff-mono);font-size:10px;letter-spacing:.04em;
border:1px solid var(--edge);color:var(--ash);white-space:nowrap;
}
.badge.is-ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 34%,transparent);background:color-mix(in srgb,var(--ok) 8%,transparent)}
.badge.is-bad{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 34%,transparent);background:color-mix(in srgb,var(--bad) 8%,transparent)}
.badge.is-warn{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 34%,transparent);background:color-mix(in srgb,var(--warn) 8%,transparent)}
.stat-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(124px,1fr));gap:1px;background:var(--edge-soft);border:1px solid var(--edge-soft);border-radius:var(--r);overflow:hidden}
.stat{background:var(--slate);padding:12px 14px}
.stat-k{font-family:var(--ff-mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash-dim);margin-bottom:5px}
.stat-v{font-family:var(--ff-display);font-size:21px;font-weight:600;letter-spacing:-.02em;font-variant-numeric:tabular-nums;line-height:1.1}
.stat-v small{font-size:11px;color:var(--ash);font-weight:400;font-family:var(--ff-mono);margin-left:3px}
/* token chips β€” the tokenizer panel's core read */
.toks{display:flex;flex-wrap:wrap;gap:3px;font-family:var(--ff-mono);font-size:12.5px;line-height:1}
.tok{
padding:5px 6px;border-radius:var(--r-sm);background:var(--graphite);
border:1px solid var(--edge-soft);white-space:pre;
}
.tok.is-unk{background:color-mix(in srgb,var(--bad) 20%,var(--graphite));border-color:var(--bad);color:var(--bad)}
.tok.is-special{background:color-mix(in srgb,var(--n) 18%,var(--graphite));border-color:color-mix(in srgb,var(--n) 45%,transparent);color:#a9bcff}
.tok.is-sub{border-style:dashed;color:var(--s)}
.strip{display:flex;flex-direction:column;gap:12px}
.strip-row{border:1px solid var(--edge-soft);border-radius:var(--r);padding:12px 13px;background:var(--ink)}
.strip-head{display:flex;align-items:center;gap:9px;margin-bottom:9px;flex-wrap:wrap}
.strip-name{font-family:var(--ff-mono);font-size:11.5px;color:var(--bone)}
.repr{border:1px solid var(--edge-soft);border-radius:var(--r);background:var(--ink);padding:12px 13px;min-width:0}
.repr-head{display:flex;align-items:center;gap:8px;margin-bottom:7px}
.repr-name{font-family:var(--ff-mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash-dim)}
.repr-val{font-family:var(--ff-mono);font-size:12px;word-break:break-all;white-space:pre-wrap;max-height:132px;overflow:auto;color:var(--bone);line-height:1.6}
.repr-val.is-empty{color:var(--ash-dim)}
.mol-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:12px}
.mol-cell{border:1px solid var(--edge-soft);border-radius:var(--r);background:var(--ink);padding:9px;cursor:pointer;transition:border-color .15s ease}
.mol-cell:hover{border-color:var(--edge)}
.mol-cell.is-sel{border-color:var(--o)}
.mol-art{display:grid;place-items:center;min-height:132px;border-radius:var(--r-sm);overflow:hidden}
.mol-art svg{max-width:100%;height:auto;display:block}
.mol-cap{font-family:var(--ff-mono);font-size:10.5px;color:var(--ash);margin-top:7px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.thumb{width:52px;height:38px;display:grid;place-items:center;background:var(--ink-2);border-radius:var(--r-sm);overflow:hidden}
.thumb svg{max-width:100%;max-height:100%}
.bars{display:flex;flex-direction:column;gap:6px}
.bar-row{display:grid;grid-template-columns:76px 1fr 52px;align-items:center;gap:10px;font-family:var(--ff-mono);font-size:11px}
.bar-track{height:8px;background:var(--ink);border-radius:20px;overflow:hidden;border:1px solid var(--edge-soft)}
.bar-fill{height:100%;border-radius:20px;background:var(--hue,var(--s));transition:width .4s ease}
.heat{display:grid;gap:1px}
.heat-c{aspect-ratio:1;border-radius:1px;min-width:5px}
.plot{width:100%;border:1px solid var(--edge-soft);border-radius:var(--r);background:var(--ink);display:block}
.empty{text-align:center;padding:44px 20px;color:var(--ash-dim)}
.empty b{display:block;color:var(--ash);font-weight:500;font-size:14px;margin-bottom:5px}
.empty span{font-size:12.5px}
.note{
border-left:2px solid var(--hue,var(--edge));padding:9px 0 9px 12px;
font-size:12.5px;color:var(--ash);background:linear-gradient(90deg,rgba(255,255,255,.014),transparent);
}
.note b{color:var(--bone);font-weight:500}
.tabs{display:flex;gap:2px;border-bottom:1px solid var(--edge-soft);margin-bottom:16px;flex-wrap:wrap}
.tab{
padding:8px 13px;border:0;background:none;color:var(--ash);cursor:pointer;font-size:12.5px;
border-bottom:2px solid transparent;margin-bottom:-1px;transition:color .15s ease
}
.tab:hover{color:var(--bone)}
.tab.is-active{color:var(--bone);border-bottom-color:var(--hue,var(--n))}
.tabpane{display:none}
.tabpane.is-active{display:block}
/* overlays */
.sheet{position:fixed;inset:0;background:rgba(6,8,13,.72);backdrop-filter:blur(3px);display:none;place-items:center;z-index:80;padding:20px}
.sheet.is-open{display:grid}
.sheet-box{background:var(--slate);border:1px solid var(--edge);border-radius:var(--r-lg);max-width:520px;width:100%;padding:20px;box-shadow:var(--sh-2);max-height:86vh;overflow:auto}
.sheet-box.is-wide{max-width:900px}
.toasts{position:fixed;right:16px;bottom:16px;display:flex;flex-direction:column;gap:8px;z-index:120;pointer-events:none}
.toast{
background:var(--graphite);border:1px solid var(--edge);border-left:2px solid var(--hue,var(--n));
border-radius:var(--r);padding:9px 13px;font-size:12.5px;box-shadow:var(--sh-2);
animation:slidein .2s ease both;max-width:340px
}
@keyframes slidein{from{opacity:0;transform:translateX(12px)}to{opacity:1;transform:none}}
.spin{width:13px;height:13px;border:2px solid var(--edge);border-top-color:var(--n);border-radius:50%;animation:spin .7s linear infinite;display:inline-block;flex:none}
@keyframes spin{to{transform:rotate(360deg)}}
.hide{display:none !important}
/* ============================================================
RESPONSIVE β€” rail folds to a horizontal strip
============================================================ */
@media (max-width:1180px){
.split{grid-template-columns:minmax(0,1fr)}
.g-4{grid-template-columns:repeat(2,minmax(0,1fr))}
.g-3{grid-template-columns:repeat(2,minmax(0,1fr))}
}
@media (max-width:860px){
.body{grid-template-columns:1fr;grid-template-rows:auto 1fr}
.rail{
flex-direction:row;align-items:center;border-right:0;border-bottom:1px solid var(--edge-soft);
overflow-x:auto;overflow-y:hidden;padding:9px 12px;gap:0
}
.rail-eyebrow,.rail-foot{display:none}
.rail-list{flex-direction:row;align-items:center;padding:0}
.rail-item{grid-template-columns:auto;gap:0;padding:5px}
.rail-label{display:none}
.rail-link{height:6px;width:16px;margin:0 2px}
.rail-link::before{left:0;right:0;width:auto;top:2px;bottom:auto;height:1px}
.rail-link.is-done::before{top:1px;left:0;right:0;width:auto;height:1px}
.rail-link.is-done::after{top:auto;bottom:1px;left:0;right:0;width:auto;height:1px}
.panel{padding:20px 16px 80px}
.g-2,.g-3,.g-4{grid-template-columns:minmax(0,1fr)}
.panel-title{font-size:23px}
.brand-sub{display:none}
}
@media (prefers-reduced-motion:reduce){
*,*::before,*::after{animation-duration:.001ms !important;transition-duration:.001ms !important}
}
</style>
</head>
<body>
<div class="app">
<header class="head">
<div class="brand">
<span class="brand-mark">CONFORMER</span>
<span class="brand-sub">BRAIDBERTa Β· DeepBERTa</span>
</div>
<div class="head-spacer"></div>
<button class="head-stat" id="btn-status" title="Runtime status">
<span class="dot is-wait" id="dot-rdkit"></span><span id="lbl-rdkit">RDKit</span>
</button>
<button class="head-stat" id="btn-hf" title="Hugging Face access">
<span class="dot" id="dot-hf"></span><span id="lbl-hf">Encoders offline</span>
</button>
</header>
<div class="body">
<nav class="rail" id="rail" aria-label="Pipeline stages">
<div class="rail-eyebrow">Pipeline</div>
<div class="rail-list" id="rail-list"></div>
<div class="rail-foot">
<div class="rail-foot-row"><span>Molecules</span><b id="f-mols">0</b></div>
<div class="rail-foot-row"><span>Valid</span><b id="f-valid">0</b></div>
<div class="rail-foot-row"><span>Embedded</span><b id="f-emb">0</b></div>
<div class="rail-foot-row"><span>Labelled</span><b id="f-lab">0</b></div>
</div>
</nav>
<main class="main" id="main">
<!-- ═══════════ 01 LOAD ═══════════ -->
<section class="panel" id="p-load" style="--hue:var(--c)">
<div class="panel-head">
<span class="panel-index">01</span>
<div>
<h1 class="panel-title">Load molecules</h1>
<p class="panel-note">Paste SMILES, drop a file, or start from a reference set. Every structure is parsed by RDKit as it arrives, so anything malformed is flagged here rather than failing quietly three stages later.</p>
</div>
<div class="panel-head-actions">
<button class="btn is-sm" id="btn-dedupe">Remove duplicates</button>
<button class="btn is-sm" id="btn-clear">Clear all</button>
</div>
</div>
<div class="split">
<div>
<div class="card">
<div class="card-head">
<h2 class="card-title">Input</h2>
<div class="card-actions">
<button class="btn is-sm" data-starter="drugs">Approved drugs</button>
<button class="btn is-sm" data-starter="zinc">ZINC-like fragments</button>
<button class="btn is-sm" data-starter="edge">Edge cases</button>
</div>
</div>
<div class="drop" id="drop">
<b>Drop a .smi, .csv, .txt or .sdf file</b>
<span>or click to browse β€” one molecule per line, optional ID and label columns</span>
<input type="file" id="file" class="hide" accept=".smi,.csv,.txt,.tsv,.sdf,.mol">
</div>
<div style="height:14px"></div>
<div class="field">
<label class="field-label" for="ta-smiles">Paste SMILES</label>
<textarea class="area" id="ta-smiles" spellcheck="false" placeholder="CC(=O)Oc1ccccc1C(=O)O aspirin 1.19&#10;Cn1cnc2c1c(=O)n(C)c(=O)n2C caffeine -0.07"></textarea>
</div>
<div style="height:12px"></div>
<div class="btn-row">
<button class="btn is-primary" id="btn-add">Add molecules</button>
<label class="check"><input type="checkbox" id="cb-canon" checked> Store canonical SMILES</label>
<span class="dim mono" style="font-size:11px">Columns: SMILES Β· ID Β· label</span>
</div>
</div>
</div>
<div>
<div class="card">
<div class="card-head"><h2 class="card-title">Set summary</h2></div>
<div class="stat-row" id="load-stats"></div>
<div style="height:14px"></div>
<div class="note" style="--hue:var(--c)">
<b>What runs where.</b> Parsing, drawing, descriptors and fingerprints are computed locally by RDKit in your browser. Nothing here is uploaded. The encoder stages call Hugging Face directly.
</div>
</div>
</div>
</div>
<div class="card" style="margin-top:16px">
<div class="card-head">
<h2 class="card-title">Molecule set</h2>
<div class="card-actions">
<input class="input is-mono is-sm" id="q-mols" placeholder="Filter…" style="width:180px;padding:5px 9px;font-size:12px">
</div>
</div>
<div id="mol-table"></div>
</div>
</section>
<!-- ═══════════ 02 CONVERT ═══════════ -->
<section class="panel" id="p-convert" style="--hue:var(--n)">
<div class="panel-head">
<span class="panel-index">02</span>
<div>
<h1 class="panel-title">Convert representations</h1>
<p class="panel-note">Canonical SMILES, InChI, molfiles and fingerprints come straight from RDKit. BRAID and SELFIES are yours β€” register a converter below and they become first-class everywhere else in the pipeline.</p>
</div>
<div class="panel-head-actions">
<button class="btn is-primary" id="btn-convert-all">Convert whole set</button>
</div>
</div>
<div class="card">
<div class="card-head">
<h2 class="card-title">Inspect one molecule</h2>
<div class="card-actions">
<select class="select" id="sel-conv" style="width:300px"></select>
</div>
</div>
<div class="grid g-2" id="repr-grid"></div>
</div>
<div class="card">
<div class="card-head">
<h2 class="card-title">Representation adapters</h2>
<span class="eyebrow" id="adapter-status">not registered</span>
</div>
<p class="panel-note" style="margin:0 0 14px">
I don't know the BRAID grammar β€” it's your work, not something I can infer β€” so this stage stays open rather than guessing at it. Register the converter once and BRAID flows into tokenization, embedding and export automatically.
</p>
<div class="note" style="--hue:var(--n);margin-bottom:16px">
<b>Reference implementations, not reimplementations.</b> BRAID comes from
<span class="mono">braids/codec.py</span> and SELFIES from the <span class="mono">selfies</span>
package, both executed by <span class="mono">backend.py</span>. The codec needs RDKit's
<span class="mono">RWMol</span>, <span class="mono">GetValenceList</span> and
<span class="mono">AssignCIPLabels</span>, which the WebAssembly build does not expose β€” so a
browser-side port would be a different algorithm wearing the same name.
</div>
<div class="grid g-2">
<div class="field">
<label class="field-label" for="ad-backend">Backend base URL</label>
<input class="input is-mono" id="ad-backend" value="http://127.0.0.1:8000" spellcheck="false">
</div>
<div class="field">
<label class="field-label">&nbsp;</label>
<div class="btn-row">
<button class="btn is-primary" id="btn-ad-connect">Connect</button>
<span class="mono dim" id="ad-health" style="font-size:11.5px">not connected</span>
</div>
</div>
</div>
<div style="height:18px"></div>
<div class="tabs" id="adapter-tabs">
<button class="tab is-active" data-atab="braid">BRAID</button>
<button class="tab" data-atab="selfies">SELFIES</button>
</div>
<div class="grid g-2">
<div class="field">
<label class="field-label">Mode</label>
<select class="select" id="ad-mode">
<option value="backend">Local backend (runs your reference code)</option>
<option value="http">Custom HTTP endpoint (POST)</option>
<option value="js">JavaScript function (runs in this tab)</option>
</select>
</div>
<div class="field">
<label class="field-label">Endpoint URL</label>
<input class="input is-mono" id="ad-url" placeholder="https://your-service/convert" disabled>
</div>
</div>
<div style="height:12px"></div>
<label class="check" id="ad-arom-wrap"><input type="checkbox" id="ad-aromatic"> BRAID aromatic mode β€” <span class="mono">cccccc^5</span> rather than <span class="mono">C=CC=CC=C^5</span></label>
<div class="note" style="--hue:var(--o);margin-top:10px" id="ad-arom-note">
The two BRAID modes are not interchangeable. BRAIDBERTa was pretrained on one of them; encode with the other and every token is off-distribution.
</div>
<div style="height:12px"></div>
<div class="field">
<label class="field-label" for="ad-code">Converter body β€” receives <span class="mono">smiles</span>, returns a string</label>
<textarea class="area" id="ad-code" spellcheck="false" disabled></textarea>
</div>
<div style="height:12px"></div>
<div class="btn-row">
<button class="btn is-primary" id="btn-ad-save">Register converter</button>
<button class="btn" id="btn-ad-test">Test on aspirin</button>
<button class="btn is-ghost" id="btn-ad-clear">Remove</button>
<span class="mono dim" id="ad-out" style="font-size:11.5px"></span>
</div>
</div>
<div class="card">
<div class="card-head">
<h2 class="card-title">Decode</h2>
<span class="eyebrow">string back to structure</span>
</div>
<div class="grid g-2">
<div class="field">
<label class="field-label">Notation</label>
<select class="select" id="dec-kind">
<option value="braid">BRAID</option>
<option value="selfies">SELFIES</option>
</select>
</div>
<div class="field">
<label class="field-label" for="dec-in">String</label>
<input class="input is-mono" id="dec-in" value="CC>1=OOcccccc^5C>1=OO" spellcheck="false">
</div>
</div>
<div style="height:12px"></div>
<div class="btn-row">
<button class="btn" id="btn-decode">Decode</button>
<button class="btn is-ghost" id="btn-decode-add">Decode and add to set</button>
</div>
<div id="dec-out" style="margin-top:14px"></div>
</div>
</section>
<!-- ═══════════ 03 DRAW ═══════════ -->
<section class="panel" id="p-draw" style="--hue:var(--o)">
<div class="panel-head">
<span class="panel-index">03</span>
<div>
<h1 class="panel-title">Draw structures</h1>
<p class="panel-note">RDKit renders these as vectors, using the same element colours as the interface around them. Type a SMARTS pattern to highlight a substructure across the whole set.</p>
</div>
</div>
<div class="card">
<div class="card-head"><h2 class="card-title">Drawing controls</h2></div>
<div class="grid g-4">
<div class="field">
<label class="field-label" for="dw-smarts">SMARTS highlight</label>
<input class="input is-mono" id="dw-smarts" placeholder="c1ccccc1" spellcheck="false">
</div>
<div class="field">
<label class="field-label" for="dw-size">Tile size β€” <span id="dw-size-v">210</span>px</label>
<input type="range" class="range" id="dw-size" min="130" max="360" value="210">
</div>
<div class="field">
<label class="field-label">Annotations</label>
<label class="check"><input type="checkbox" id="dw-idx"> Atom indices</label>
<label class="check"><input type="checkbox" id="dw-stereo" checked> Stereo labels</label>
</div>
<div class="field">
<label class="field-label">Bonds</label>
<label class="check"><input type="checkbox" id="dw-kek" checked> Kekulise rings</label>
<label class="check"><input type="checkbox" id="dw-me"> Explicit methyls</label>
</div>
</div>
<div style="height:12px"></div>
<div class="btn-row">
<button class="btn is-primary" id="btn-draw">Redraw</button>
<span class="mono dim" id="dw-match" style="font-size:11.5px"></span>
</div>
</div>
<div class="card">
<div class="card-head">
<h2 class="card-title">Structures</h2>
<div class="card-actions"><button class="btn is-sm" id="btn-svg-all">Download all as SVG</button></div>
</div>
<div id="draw-grid"></div>
</div>
</section>
<!-- ═══════════ 04 PREDICT ═══════════ -->
<section class="panel" id="p-predict" style="--hue:var(--p)">
<div class="panel-head">
<span class="panel-index">04</span>
<div>
<h1 class="panel-title">Predict properties</h1>
<p class="panel-note">Two different things, kept apart on purpose. Descriptors are exact values RDKit computes from the graph. The probe is a ridge head trained live on your labels over encoder embeddings β€” the standard way to ask what a representation actually carries.</p>
</div>
</div>
<div class="tabs" id="pred-tabs">
<button class="tab is-active" data-ptab="desc">Descriptors</button>
<button class="tab" data-ptab="alerts">Structural alerts</button>
<button class="tab" data-ptab="probe">Linear probe</button>
</div>
<div class="tabpane is-active" data-ppane="desc">
<div class="card">
<div class="card-head">
<h2 class="card-title">Computed descriptors</h2>
<div class="card-actions"><button class="btn is-sm is-primary" id="btn-desc">Compute for set</button></div>
</div>
<div id="desc-table"></div>
</div>
</div>
<div class="tabpane" data-ppane="alerts">
<div class="card">
<div class="card-head">
<h2 class="card-title">Rule filters and reactive groups</h2>
<div class="card-actions"><button class="btn is-sm is-primary" id="btn-alerts">Screen set</button></div>
</div>
<p class="panel-note" style="margin:0 0 14px">Lipinski, Veber and a small set of well-known reactive substructures, matched as SMARTS. This is a demonstration set, not a curated PAINS catalogue β€” treat a clean result as "nothing obvious here", not as a pass.</p>
<div id="alert-table"></div>
</div>
</div>
<div class="tabpane" data-ppane="probe">
<div class="card">
<div class="card-head"><h2 class="card-title">Train a probe</h2></div>
<div class="grid g-4">
<div class="field">
<label class="field-label" for="pb-enc">Representation</label>
<select class="select" id="pb-enc"></select>
</div>
<div class="field">
<label class="field-label" for="pb-alpha">Ridge Ξ±</label>
<input class="input is-mono" id="pb-alpha" value="1.0">
</div>
<div class="field">
<label class="field-label" for="pb-folds">CV folds</label>
<input class="input is-mono" id="pb-folds" value="5">
</div>
<div class="field">
<label class="field-label">&nbsp;</label>
<button class="btn is-primary" id="btn-probe">Run cross-validation</button>
</div>
</div>
<div style="height:14px"></div>
<div class="note" style="--hue:var(--p)">
<b>Labels come from you.</b> Add a value in the label column on stage 01, or include a third column when pasting. The probe needs at least ten labelled molecules to say anything meaningful.
</div>
</div>
<div class="card" id="probe-results"><div class="empty"><b>No probe trained yet</b><span>Embed a set on stage 06, add labels, then run cross-validation.</span></div></div>
</div>
</section>
<!-- ═══════════ 05 TOKENIZE ═══════════ -->
<section class="panel" id="p-tokenize" style="--hue:var(--s)">
<div class="panel-head">
<span class="panel-index">05</span>
<div>
<h1 class="panel-title">Benchmark tokenizations</h1>
<p class="panel-note">Real tokenizers, pulled from the model repos and run in your browser β€” WordPiece and BPE, including byte-level. Two reference schemes sit alongside them so the numbers have something to mean.</p>
</div>
<div class="panel-head-actions">
<button class="btn is-primary" id="btn-load-toks">Fetch tokenizers</button>
</div>
</div>
<div class="card">
<div class="card-head"><h2 class="card-title">Sources</h2></div>
<div id="tok-sources"></div>
<div style="height:12px"></div>
<div class="btn-row">
<input class="input is-mono" id="tok-extra" placeholder="add another repo, e.g. seyonec/ChemBERTa-zinc-base-v1" style="max-width:340px">
<button class="btn is-sm" id="btn-tok-add">Add repo</button>
<button class="btn is-sm is-ghost" id="btn-tok-upload">Load tokenizer.json…</button>
<button class="btn is-sm is-ghost" id="btn-tok-diag">Diagnose</button>
<input type="file" id="tok-file" accept=".json,.txt,application/json" hidden>
</div>
</div>
<div class="card">
<div class="card-head">
<h2 class="card-title">Token-by-token</h2>
<div class="card-actions"><select class="select" id="sel-tok" style="width:300px"></select></div>
</div>
<div class="strip" id="tok-strips"><div class="empty"><b>No tokenizers loaded</b><span>Fetch them above to see how each one cuts a molecule apart.</span></div></div>
</div>
<div class="split">
<div class="card">
<div class="card-head">
<h2 class="card-title">Comparison over the set</h2>
<div class="card-actions"><button class="btn is-sm is-primary" id="btn-bench">Run benchmark</button></div>
</div>
<div id="bench-table"></div>
</div>
<div class="card">
<div class="card-head"><h2 class="card-title">Token length distribution</h2></div>
<div id="tok-hist"><div class="empty"><span>Run the benchmark to see the distribution.</span></div></div>
</div>
</div>
</section>
<!-- ═══════════ 06 COMPARE ═══════════ -->
<section class="panel" id="p-compare" style="--hue:var(--x)">
<div class="panel-head">
<span class="panel-index">06</span>
<div>
<h1 class="panel-title">Compare encoders</h1>
<p class="panel-note">Embed the same molecules with both models and an ECFP4 baseline, then look at what actually differs: how the space is laid out, whether the two agree on what counts as similar, and how much a linear head can pull out.</p>
</div>
</div>
<div class="card">
<div class="card-head"><h2 class="card-title">Run</h2></div>
<div class="grid g-4">
<div class="field">
<label class="field-label" for="cmp-pool">Pooling</label>
<select class="select" id="cmp-pool">
<option value="mean">Mean over tokens</option>
<option value="cls">First token (CLS)</option>
</select>
</div>
<div class="field">
<label class="field-label" for="cmp-batch">Batch size</label>
<input class="input is-mono" id="cmp-batch" value="8">
</div>
<div class="field">
<label class="field-label" for="cmp-repr">Input representation</label>
<select class="select" id="cmp-repr">
<option value="smiles">SMILES</option>
<option value="braid">BRAID</option>
<option value="selfies">SELFIES</option>
</select>
</div>
<div class="field">
<label class="field-label">&nbsp;</label>
<button class="btn is-primary" id="btn-embed">Embed set</button>
</div>
</div>
<div style="height:12px"></div>
<div id="embed-log" class="mono dim" style="font-size:11.5px"></div>
</div>
<div class="card">
<div class="card-head"><h2 class="card-title">Embedding summary</h2></div>
<div id="cmp-stats"><div class="empty"><span>Embed a set to compare.</span></div></div>
</div>
<div class="split">
<div class="card">
<div class="card-head"><h2 class="card-title">Principal components</h2></div>
<div id="cmp-pca"><div class="empty"><span>Two components of each embedding space, computed exactly.</span></div></div>
</div>
<div class="card">
<div class="card-head">
<h2 class="card-title">Neighbourhood agreement</h2>
<div class="card-actions"><input class="input is-mono" id="cmp-k" value="5" style="width:64px;padding:5px 9px;font-size:12px"></div>
</div>
<div id="cmp-knn"><div class="empty"><span>Mean Jaccard overlap of each molecule's k nearest neighbours between encoders.</span></div></div>
</div>
</div>
<div class="card">
<div class="card-head">
<h2 class="card-title">Masked-token probe</h2>
<div class="card-actions"><button class="btn is-sm is-primary" id="btn-mlm">Run</button></div>
</div>
<div class="grid g-2">
<div class="field">
<label class="field-label" for="mlm-in">Input</label>
<input class="input is-mono" id="mlm-in" value="CC(=O)Oc1ccccc1C(=O)O">
</div>
<div class="grid g-2">
<div class="field">
<label class="field-label" for="mlm-mode">Mode</label>
<select class="select" id="mlm-mode">
<option value="auto">SMILES β€” build each model's input</option>
<option value="raw">Literal β€” send exactly what I typed</option>
</select>
</div>
<div class="field">
<label class="field-label" for="mlm-pos">Mask token index</label>
<input class="input is-mono" id="mlm-pos" value="4">
</div>
</div>
</div>
<div class="note" style="--hue:var(--p);margin-top:12px">
<b>The two models read different notations.</b> BRAIDBERTa consumes a BRAID token stream,
DeepBERTa consumes SMILES. In <span class="mono">SMILES</span> mode this panel converts your
molecule for each model, masks the token at the given index in that model's own tokenisation,
and shows you the exact string it sent. One shared masked string cannot be correct for both.
</div>
<div style="height:14px"></div>
<div id="mlm-out"></div>
</div>
</section>
<!-- ═══════════ 07 EXPORT ═══════════ -->
<section class="panel" id="p-export" style="--hue:var(--h)">
<div class="panel-head">
<span class="panel-index">07</span>
<div>
<h1 class="panel-title">Export datasets</h1>
<p class="panel-note">Everything computed upstream, written out in a shape you can hand to someone else. The dataset card records which models, which tokenizers and which settings produced the file.</p>
</div>
</div>
<div class="split">
<div class="card">
<div class="card-head"><h2 class="card-title">Columns</h2></div>
<div class="grid g-3" id="ex-cols"></div>
<div style="height:16px"></div>
<div class="grid g-3">
<div class="field">
<label class="field-label" for="ex-fmt">Format</label>
<select class="select" id="ex-fmt">
<option value="csv">CSV</option>
<option value="jsonl">JSONL</option>
<option value="smi">SMILES (.smi)</option>
<option value="sdf">SDF</option>
</select>
</div>
<div class="field">
<label class="field-label" for="ex-name">File name</label>
<input class="input is-mono" id="ex-name" value="conformer-export">
</div>
<div class="field">
<label class="field-label">&nbsp;</label>
<label class="check" style="padding-top:9px"><input type="checkbox" id="ex-valid" checked> Valid molecules only</label>
</div>
</div>
<div style="height:16px"></div>
<div class="btn-row">
<button class="btn is-primary" id="btn-export">Download dataset</button>
<button class="btn" id="btn-card">Download dataset card</button>
<button class="btn" id="btn-session">Save session</button>
<button class="btn is-ghost" id="btn-session-load">Load session</button>
<input type="file" id="file-session" class="hide" accept=".json">
</div>
</div>
<div class="card">
<div class="card-head"><h2 class="card-title">Preview</h2><span class="eyebrow" id="ex-count"></span></div>
<div class="repr-val" id="ex-preview" style="max-height:420px">Pick columns to preview the file.</div>
</div>
</div>
</section>
</main>
</div>
</div>
<div class="sheet" id="sheet"><div class="sheet-box" id="sheet-box"></div></div>
<div class="toasts" id="toasts"></div>
<script src="https://unpkg.com/@rdkit/rdkit/dist/RDKit_minimal.js"></script>
<script>
"use strict";
/* ══════════════════════════════════════════════════════════════
CONFORMER β€” a workbench for BRAIDBERTa and DeepBERTa.
Chemistry runs locally through RDKit/WASM. Encoder calls go
straight from this page to Hugging Face; no server in between.
══════════════════════════════════════════════════════════════ */
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fmt = (v, d = 2) => (v == null || !isFinite(v)) ? 'β€”' : (Math.abs(v) >= 1e5 || (Math.abs(v) < 1e-3 && v !== 0) ? v.toExponential(1) : (+v).toFixed(d));
const STAGES = [
{ id:'load', el:'p-load', name:'Load', hue:'--c', meta:'molecules in' },
{ id:'convert', el:'p-convert', name:'Convert', hue:'--n', meta:'representations' },
{ id:'draw', el:'p-draw', name:'Draw', hue:'--o', meta:'2D structures' },
{ id:'predict', el:'p-predict', name:'Predict', hue:'--p', meta:'descriptors Β· probe' },
{ id:'tokenize', el:'p-tokenize', name:'Tokenize', hue:'--s', meta:'vocab benchmark' },
{ id:'compare', el:'p-compare', name:'Compare', hue:'--x', meta:'encoder geometry' },
{ id:'export', el:'p-export', name:'Export', hue:'--h', meta:'csv Β· jsonl Β· sdf' },
];
/* Served from a real host -> talk to that host (no CORS, no token needed).
Opened as a local file -> fall back to a dev server on localhost. */
const SAME_ORIGIN = (location.protocol === 'http:' || location.protocol === 'https:') ? location.origin : '';
/* Deployed split: this page is a Static Space, the converters and encoders
live on a separate service. Hardcode it so a first-time visitor gets a
working workbench without touching settings. Override in settings to point
at a local backend.py. */
const DEPLOYED_BACKEND = 'https://braid-9wc2.onrender.com';
const S = {
mols: [], nextId: 1,
rdkit: null, rdkitErr: '',
hfToken: '', // memory only, never persisted
hfBase: DEPLOYED_BACKEND + '/models',
hfLegacy: 'https://api-inference.huggingface.co/models',
models: { braid: 'aakothari/BRAIDBERTa', deep: 'aakothari/DeepBERTa_zinc_base_100k_v4' },
adapters: { braid: null, selfies: null },
backend: { base: DEPLOYED_BACKEND, ok: false, converters: {}, device: '' },
braidAromatic: false, // must match what BRAIDBERTa was pretrained on
tokenizers: {}, tokOrder: [],
bench: null,
emb: {}, // key -> { dim, vecs: Map(molId -> Float64Array) }
probe: null,
active: 'load',
adapterTab: 'braid',
};
/* CPK, matched to the interface tokens. RDKit wants 0–1 floats,
so the drawings and the CSS are driven by the same numbers. */
const CPK = {
0:[0.914,0.925,0.953], 1:[1,1,1], 5:[1,0.71,0.71], 6:[0.914,0.925,0.953],
7:[0.357,0.486,1.0], 8:[1,0.42,0.42], 9:[0.431,0.906,0.627], 14:[0.75,0.78,0.85],
15:[1,0.624,0.271], 16:[0.949,0.788,0.298], 17:[0.431,0.906,0.627],
34:[1,0.63,0.0], 35:[0.88,0.55,0.42], 53:[0.725,0.514,1.0],
};
/* ── notices ─────────────────────────────────────────────── */
function toast(msg, hue = '--n', ms = 3600) {
const t = document.createElement('div');
t.className = 'toast'; t.style.setProperty('--hue', `var(${hue})`);
t.innerHTML = msg;
$('#toasts').appendChild(t);
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 220); }, ms);
}
function sheet(html) { $('#sheet-box').innerHTML = html; $('#sheet').classList.add('is-open'); }
function closeSheet() { $('#sheet').classList.remove('is-open'); }
$('#sheet').addEventListener('click', e => { if (e.target.id === 'sheet') closeSheet(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeSheet(); });
/* ── RDKit ───────────────────────────────────────────────── */
async function initRDKit() {
if (typeof window.initRDKitModule !== 'function') {
S.rdkitErr = 'The RDKit script did not load.'; paintStatus(); return;
}
try {
S.rdkit = await window.initRDKitModule({
locateFile: f => `https://unpkg.com/@rdkit/rdkit/dist/${f}`
});
paintStatus();
if (S.mols.length) { revalidateAll(); }
} catch (err) {
S.rdkitErr = String(err && err.message || err); paintStatus();
}
}
function paintStatus() {
const d = $('#dot-rdkit'), l = $('#lbl-rdkit');
d.className = 'dot ' + (S.rdkit ? 'is-ok' : (S.rdkitErr ? 'is-bad' : 'is-wait'));
l.textContent = S.rdkit ? `RDKit ${S.rdkit.version ? S.rdkit.version() : 'ready'}` : (S.rdkitErr ? 'RDKit failed' : 'RDKit loading');
const hd = $('#dot-hf'), hl = $('#lbl-hf');
const encOK = S.hfToken || (S.backend.ok && S.hfBase.indexOf(S.backend.base) === 0);
hd.className = 'dot ' + (encOK ? 'is-ok' : '');
hl.textContent = encOK ? 'Encoders ready' : 'Encoders offline';
}
/** Run fn with a live RDKit mol, always freeing the WASM handle. */
function withMol(smiles, fn) {
if (!S.rdkit || !smiles) return null;
let m = null;
try {
m = S.rdkit.get_mol(smiles);
if (!m || (m.is_valid && !m.is_valid())) return null;
return fn(m);
} catch (e) { return null; }
finally { if (m) { try { m.delete(); } catch (e) {} } }
}
function analyse(smiles) {
if (!S.rdkit) return { ok: null, error: 'unchecked' };
let m = null;
try {
m = S.rdkit.get_mol(smiles);
if (!m || (m.is_valid && !m.is_valid())) return { ok: false, error: 'RDKit could not parse this' };
return { ok: true, error: '', canonical: m.get_smiles() };
} catch (e) {
return { ok: false, error: (String(e.message || e).split('\n')[0] || 'parse error').slice(0, 90) };
} finally { if (m) { try { m.delete(); } catch (e) {} } }
}
function revalidateAll() {
S.mols.forEach(m => {
const r = analyse(m.input);
m.valid = r.ok; m.error = r.error;
if (r.ok) { m.canonical = r.canonical; if (m.useCanon) m.smiles = r.canonical; }
});
renderAll();
}
/* ── rail ────────────────────────────────────────────────── */
function stageReady(id) {
switch (id) {
case 'load': return S.mols.length > 0;
case 'convert': return S.mols.some(m => m.canonical || Object.keys(m.repr).length);
case 'draw': return !!S.rdkit && S.mols.some(m => m.valid);
case 'predict': return S.mols.some(m => m.desc) || !!S.probe;
case 'tokenize': return S.tokOrder.length > 0;
case 'compare': return Object.keys(S.emb).length > 0;
case 'export': return S.mols.length > 0;
}
return false;
}
function renderRail() {
const host = $('#rail-list');
host.innerHTML = STAGES.map((st, i) => {
const ready = stageReady(st.id), active = S.active === st.id;
const link = i < STAGES.length - 1
? `<div class="rail-link ${ready && stageReady(STAGES[i+1].id) ? 'is-done' : ''}" style="--hue:var(${st.hue})"></div>` : '';
return `<button class="rail-item ${active ? 'is-active' : ''} ${ready ? 'is-ready' : ''}" style="--hue:var(${st.hue})" data-stage="${st.id}" aria-current="${active}">
<span class="rail-node">${String(i + 1).padStart(2, '0')}</span>
<span class="rail-label"><span class="rail-name">${st.name}</span><span class="rail-meta">${st.meta}</span></span>
</button>${link}`;
}).join('');
$$('[data-stage]', host).forEach(b => b.onclick = () => go(b.dataset.stage));
$('#f-mols').textContent = S.mols.length;
$('#f-valid').textContent = S.mols.filter(m => m.valid).length;
$('#f-emb').textContent = Object.keys(S.emb).length ? Object.values(S.emb)[0].vecs.size : 0;
$('#f-lab').textContent = S.mols.filter(m => m.label != null).length;
}
function go(id) {
S.active = id;
STAGES.forEach(st => $('#' + st.el).classList.toggle('is-active', st.id === id));
renderRail();
$('#main').scrollTop = 0;
if (id === 'convert') renderConvert();
if (id === 'draw') renderDraw();
if (id === 'tokenize') renderTokSources();
if (id === 'export') renderExport();
if (id === 'compare') refreshEncoderOptions();
}
/* ── input parsing ───────────────────────────────────────── */
function splitCells(line) {
if (line.includes('\t')) return line.split('\t').map(s => s.trim());
if (line.includes(',')) return line.split(',').map(s => s.trim().replace(/^"|"$/g, ''));
return line.split(/\s+/).map(s => s.trim());
}
const RX_SMI = /^(smiles|canonical_smiles|smi|structure)$/;
const RX_ID = /^(id|name|title|zinc_id|mol_id|compound|cid)$/;
const RX_LBL = /^(label|y|target|value|activity|logp|logs|solubility|pic50|expt|measured)$/;
function parseMoleculeText(text) {
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(l => l && !l.startsWith('#'));
if (!lines.length) return [];
let header = null, start = 0;
const first = splitCells(lines[0]).map(c => c.toLowerCase());
if (first.some(c => RX_SMI.test(c))) { header = first; start = 1; }
const si = header ? header.findIndex(h => RX_SMI.test(h)) : 0;
const ni = header ? header.findIndex(h => RX_ID.test(h)) : 1;
const li = header ? header.findIndex(h => RX_LBL.test(h)) : 2;
const out = [];
for (let i = start; i < lines.length; i++) {
const c = splitCells(lines[i]);
const smiles = c[si];
if (!smiles) continue;
const raw = li >= 0 ? c[li] : '';
const num = parseFloat(raw);
out.push({
smiles,
name: (ni >= 0 ? c[ni] : '') || '',
label: (raw !== '' && raw != null && isFinite(num)) ? num : null,
});
}
return out;
}
function parseSDF(text) {
if (!S.rdkit) { toast('SDF needs RDKit, which has not loaded.', '--o'); return []; }
const blocks = text.split(/\$\$\$\$/).map(b => b.trim()).filter(Boolean);
const out = [];
for (const b of blocks) {
let m = null;
try {
m = S.rdkit.get_mol(b);
if (m && (!m.is_valid || m.is_valid())) {
out.push({ smiles: m.get_smiles(), name: (b.split(/\r?\n/)[0] || '').trim(), label: null });
}
} catch (e) { /* skip unreadable record */ }
finally { if (m) { try { m.delete(); } catch (e) {} } }
}
return out;
}
function addMolecules(items) {
const canon = $('#cb-canon').checked;
let ok = 0, bad = 0;
for (const it of items) {
const m = {
id: S.nextId++, name: it.name || '', input: it.smiles, smiles: it.smiles,
label: it.label ?? null, valid: null, error: '', useCanon: canon,
repr: {}, desc: null, alerts: null, canonical: '',
};
const r = analyse(it.smiles);
m.valid = r.ok; m.error = r.error;
if (r.ok) { m.canonical = r.canonical; if (canon) m.smiles = r.canonical; ok++; }
else if (r.ok === false) bad++;
S.mols.push(m);
}
renderAll();
toast(`Added ${items.length} molecule${items.length === 1 ? '' : 's'}${bad ? ` Β· ${bad} could not be parsed` : ''}`, bad ? '--s' : '--x');
}
/* ── reference sets ──────────────────────────────────────── */
const STARTERS = {
drugs: [
['CC(=O)Oc1ccccc1C(=O)O', 'aspirin', 1.19],
['Cn1cnc2c1c(=O)n(C)c(=O)n2C', 'caffeine', -0.07],
['CC(=O)Nc1ccc(O)cc1', 'paracetamol', 0.46],
['CC(C)Cc1ccc(cc1)C(C)C(=O)O', 'ibuprofen', 3.97],
['CN1CCC[C@H]1c1cccnc1', 'nicotine', 1.17],
['CN1C(=O)CN=C(c2ccccc2)c2cc(Cl)ccc21', 'diazepam', 2.82],
['OC[C@H]1OC(O)[C@H](O)[C@@H](O)[C@@H]1O', 'glucose', -3.24],
['Cc1ccc(NC(=O)c2ccc(CN3CCN(C)CC3)cc2)cc1Nc1nccc(-c2cccnc2)n1', 'imatinib', 3.00],
['CC1([C@@H](N2[C@H](S1)[C@@H](C2=O)NC(=O)Cc1ccccc1)C(=O)O)C', 'penicillin G', 1.83],
['Clc1ccccc1C(=O)Nc1ccccc1', 'benzanilide analogue', 3.10],
['NC(=O)c1ccccc1O', 'salicylamide', 1.28],
['CCN(CC)CCNC(=O)c1ccc(N)cc1', 'procainamide', 0.88],
],
zinc: [
['CCO', 'ethanol', -0.31], ['c1ccccc1', 'benzene', 2.13], ['CC(C)O', 'isopropanol', 0.05],
['CCOC(=O)C', 'ethyl acetate', 0.73], ['c1ccncc1', 'pyridine', 0.65], ['c1cc[nH]c1', 'pyrrole', 0.75],
['C1CCOC1', 'thf', 0.46], ['c1ccsc1', 'thiophene', 1.81], ['CC(=O)N', 'acetamide', -1.26],
['OCCO', 'ethylene glycol', -1.36], ['c1ccc2ccccc2c1', 'naphthalene', 3.30], ['C1CCNCC1', 'piperidine', 0.84],
['CS(=O)(=O)C', 'dmso2', -1.40], ['FC(F)(F)c1ccccc1', 'benzotrifluoride', 3.01],
['Nc1ccccc1', 'aniline', 0.90], ['O=Cc1ccccc1', 'benzaldehyde', 1.48],
],
edge: [
['[Na+].[Cl-]', 'salt Β· disconnected', null],
['C[C@H](N)C(=O)O', 'L-alanine Β· stereo', null],
['c1ccc2c(c1)[nH]c1ccccc12', 'carbazole Β· fused aromatic', null],
['[13CH4]', 'isotope', null],
['O=[N+]([O-])c1ccccc1', 'charged nitro', null],
['C1=CC=C(C=C1)C1=CC=CC=C1', 'kekulΓ© biphenyl', null],
['CC(C)(C)OC(=O)N1CCC(CC1)N', 'boc amine', null],
['this-is-not-a-molecule', 'deliberately broken', null],
['C1CC1C1CC1C1CC1C1CC1C1CC1', 'strained chain', null],
],
};
/* ── molecule table ──────────────────────────────────────── */
function thumbSVG(m) {
if (!S.rdkit || !m.valid) return '<span class="dim mono" style="font-size:10px">β€”</span>';
const svg = withMol(m.smiles, mol => {
try {
return mol.get_svg_with_highlights(JSON.stringify({
width: 52, height: 38, backgroundColour: [0, 0, 0, 0],
atomColourPalette: CPK, bondLineWidth: 1, minFontSize: 5,
}));
} catch (e) { return mol.get_svg(52, 38); }
});
return svg || '<span class="dim">β€”</span>';
}
function renderMolTable() {
const host = $('#mol-table');
if (!S.mols.length) {
host.innerHTML = `<div class="empty"><b>No molecules yet</b><span>Paste SMILES above, drop a file, or load a reference set.</span></div>`;
return;
}
const q = ($('#q-mols').value || '').toLowerCase();
const rows = S.mols.filter(m => !q || m.smiles.toLowerCase().includes(q) || (m.name || '').toLowerCase().includes(q));
const shown = rows.slice(0, 200);
host.innerHTML = `
<div class="tbl-wrap"><table>
<thead><tr>
<th style="width:38px">#</th><th style="width:62px">Structure</th><th>SMILES</th>
<th style="width:130px">Name</th><th style="width:96px">Label</th>
<th style="width:104px">Status</th><th style="width:36px"></th>
</tr></thead>
<tbody>${shown.map((m, i) => `
<tr>
<td class="num dim">${i + 1}</td>
<td><div class="thumb">${thumbSVG(m)}</div></td>
<td><span class="mono trunc" title="${esc(m.smiles)}">${esc(m.smiles)}</span></td>
<td class="dim">${esc(m.name || 'β€”')}</td>
<td><input class="input is-mono" data-lab="${m.id}" value="${m.label ?? ''}" placeholder="β€”" style="padding:3px 7px;font-size:11.5px"></td>
<td>${m.valid === true ? '<span class="badge is-ok">valid</span>'
: m.valid === false ? `<span class="badge is-bad" title="${esc(m.error)}">invalid</span>`
: '<span class="badge">unchecked</span>'}</td>
<td><button class="btn is-ghost is-sm" data-del="${m.id}" title="Remove">βœ•</button></td>
</tr>`).join('')}
</tbody>
</table></div>
${rows.length > shown.length ? `<div class="eyebrow" style="padding-top:10px">showing ${shown.length} of ${rows.length}</div>` : ''}`;
$$('[data-del]', host).forEach(b => b.onclick = () => {
S.mols = S.mols.filter(x => x.id !== +b.dataset.del);
Object.values(S.emb).forEach(e => e.vecs.delete(+b.dataset.del));
renderAll();
});
$$('[data-lab]', host).forEach(inp => inp.onchange = () => {
const m = S.mols.find(x => x.id === +inp.dataset.lab);
const v = parseFloat(inp.value);
m.label = (inp.value.trim() !== '' && isFinite(v)) ? v : null;
renderRail(); renderLoadStats();
});
}
function renderLoadStats() {
const v = S.mols.filter(m => m.valid === true).length;
const bad = S.mols.filter(m => m.valid === false).length;
const lab = S.mols.filter(m => m.label != null).length;
const uniq = new Set(S.mols.map(m => m.canonical || m.smiles)).size;
$('#load-stats').innerHTML = [
['Total', S.mols.length], ['Valid', v], ['Invalid', bad],
['Unique', uniq], ['Labelled', lab],
].map(([k, n]) => `<div class="stat"><div class="stat-k">${k}</div><div class="stat-v">${n}</div></div>`).join('');
}
/* ══════════════ 02 Β· REPRESENTATIONS ══════════════ */
function bitsToHex(bits) {
let hex = '';
for (let i = 0; i < bits.length; i += 4) hex += parseInt(bits.slice(i, i + 4).padEnd(4, '0'), 2).toString(16);
return hex;
}
function reprsFor(m) {
const out = {};
if (!S.rdkit || !m.valid) return out;
withMol(m.smiles, mol => {
const t = (k, f) => { try { const v = f(); if (v) out[k] = v; } catch (e) {} };
t('canonical', () => mol.get_smiles());
t('cxsmiles', () => mol.get_cxsmiles());
t('inchi', () => mol.get_inchi());
t('molblock', () => mol.get_molblock());
t('molblock3k',() => mol.get_v3Kmolblock());
if (out.inchi) t('inchikey', () => S.rdkit.get_inchikey_for_inchi(out.inchi));
t('scaffold', () => {
if (typeof mol.get_murcko_scaffold === 'function') {
const sc = mol.get_murcko_scaffold();
const s = typeof sc === 'string' ? sc : sc.get_smiles();
if (sc && sc.delete) sc.delete();
return s;
}
return null;
});
t('ecfp4', () => mol.get_morgan_fp(JSON.stringify({ radius: 2, nBits: 2048 })) || null);
return null;
});
return out;
}
async function applyAdapters(m) {
for (const kind of ['braid', 'selfies']) {
if (!S.adapters[kind]) continue;
try { const v = await runAdapter(kind, m.smiles); if (v) m.repr[kind] = v; }
catch (e) { m.repr[kind] = ''; }
}
}
const backendURL = (path) => S.backend.base.replace(/\/+$/, '') + path;
async function connectBackend(base, quiet) {
S.backend.base = (base || S.backend.base).replace(/\/+$/, '');
const el = $('#ad-health');
el.innerHTML = '<span class="spin"></span>';
let j;
try {
const res = await fetch(backendURL('/health'), { method: 'GET' });
if (!res.ok) throw new Error('HTTP ' + res.status);
j = await res.json();
} catch (e) {
S.backend.ok = false;
// fetch() throws identically whether nothing is listening or the server
// answered and the browser discarded the response for lacking CORS headers.
// A no-cors probe tells them apart: it resolves opaquely if anything
// replied at all, and rejects only if the connection genuinely failed.
let responded = false;
try { await fetch(backendURL('/health'), { mode: 'no-cors', cache: 'no-store' }); responded = true; }
catch (_) { responded = false; }
if (responded) {
el.innerHTML = `<span class="badge is-warn">blocked by CORS</span> the server replied but the browser discarded it`;
if (quiet) { paintAdapterStatus(); return false; } // no modal on page load
sheet(`<div class="card-head"><h2 class="card-title">The server is running β€” the browser is blocking it</h2>
<div class="card-actions"><button class="btn is-sm is-ghost" id="x-cors">Close</button></div></div>
<p style="color:var(--ash);font-size:13.5px;line-height:1.65;margin:0 0 14px">
Your terminal is logging <span class="mono">GET /health 200 OK</span>, so the request arrived and
succeeded. This page was opened from disk, which makes its origin <span class="mono">null</span> β€”
cross-origin to <span class="mono">${esc(S.backend.base)}</span>. Without an
<span class="mono">Access-Control-Allow-Origin</span> header the browser throws the response away
before this page can read it.</p>
<div class="note" style="--hue:var(--n)"><b>Easiest fix β€” open the workbench through the server.</b>
<div style="margin-top:8px"><a class="mono" href="${esc(S.backend.base)}/" style="color:var(--n)">${esc(S.backend.base)}/</a></div>
<div style="margin-top:8px">backend.py serves this same page at <span class="mono">/</span>. Going through it is
same-origin, so there is no CORS at all β€” and it is exactly how the deployed version runs.
Keep <span class="mono">conformer.html</span> next to <span class="mono">backend_CONFORMER.py</span>.</div></div>
<div class="note" style="--hue:var(--s);margin-top:12px"><b>Or allow this origin explicitly.</b>
<div class="mono" style="margin-top:8px;font-size:11.5px">Windows&nbsp;PowerShell&nbsp;Β· $env:ALLOWED_ORIGINS="*"; python backend_CONFORMER.py<br>
macOS / Linux&nbsp;Β· ALLOWED_ORIGINS="*" python backend_CONFORMER.py</div>
<div style="margin-top:8px">Recent builds default to <span class="mono">*</span>, so updating backend.py alone fixes it.</div></div>`);
const x = $('#x-cors'); if (x) x.onclick = closeSheet;
} else {
el.innerHTML = `<span class="badge is-bad">unreachable</span> nothing is listening on that address`;
if (!quiet) toast('Could not reach the backend β€” start it with python backend.py', '--o', 6000);
}
paintAdapterStatus(); return false;
}
S.backend.ok = true;
S.backend.converters = j.converters || {};
S.backend.device = j.device || '';
// register whichever converters the server says it can actually do
let n = 0;
['braid', 'selfies'].forEach(k => {
if (S.backend.converters[k]) { S.adapters[k] = { mode: 'backend', kind: k }; n++; }
else if (S.adapters[k] && S.adapters[k].mode === 'backend') S.adapters[k] = null;
});
const miss = ['braid', 'selfies'].filter(k => !S.backend.converters[k]);
el.innerHTML = `<span class="badge is-ok">connected</span> ${esc(S.backend.device)}`
+ (miss.length ? ` Β· <span class="badge is-warn">${esc(miss.join(' and '))} not installed</span>` : '');
setAdapterTab(S.adapterTab); paintAdapterStatus(); paintReprs();
if (!quiet) toast(n ? `Backend connected β€” ${n} converter${n === 1 ? '' : 's'} live` : 'Backend up, but no converters installed', n ? '--n' : '--o', 5000);
return true;
}
function registerAdapter(kind, mode, code, url) {
if (mode === 'backend') {
if (!S.backend.ok) return { ok: false, err: 'connect to the backend first' };
if (!S.backend.converters[kind]) return { ok: false, err: kind + ' is not installed on the backend' };
S.adapters[kind] = { mode: 'backend', kind };
return { ok: true };
}
if (mode === 'js') {
let fn;
try { fn = new Function('smiles', code); fn('CCO'); }
catch (e) { return { ok: false, err: String(e.message || e) }; }
S.adapters[kind] = { mode: 'js', fn, code };
return { ok: true };
}
if (!/^https?:\/\//.test(url || '')) return { ok: false, err: 'Endpoint must be an http(s) URL' };
S.adapters[kind] = { mode: 'http', url };
return { ok: true };
}
async function postJSON(url, body) {
const res = await fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
let detail = '';
try { const j = await res.json(); detail = j.detail || ''; } catch (e) {}
throw new Error(detail || 'HTTP ' + res.status);
}
return res.json();
}
async function runAdapter(kind, smiles) {
const a = S.adapters[kind];
if (!a) return null;
if (a.mode === 'js') { const r = a.fn(smiles); return r == null ? null : String(r); }
const url = a.mode === 'backend' ? backendURL('/convert/' + kind) : a.url;
const body = { smiles };
if (kind === 'braid') body.aromatic = S.braidAromatic;
const j = await postJSON(url, body);
if (kind === 'braid' && j.tokens) S._lastBraidTokens = j.tokens;
return String(j.result ?? j.output ?? j[kind] ?? j.text ?? j.data ?? '');
}
async function runDecode(kind, text) {
if (!S.backend.ok) throw new Error('decoding runs on the backend β€” connect first');
if (!S.backend.converters[kind]) throw new Error(kind + ' is not installed on the backend');
const j = await postJSON(backendURL('/convert/' + kind + '/decode'), { text, clamp: true });
return String(j.result ?? '');
}
/* One request for the whole set when the backend is doing the work. */
async function batchConvert(kind, mols) {
const a = S.adapters[kind];
if (!a) return 0;
if (a.mode !== 'backend') {
let n = 0;
for (const m of mols) {
try {
S._lastBraidTokens = '';
const v = await runAdapter(kind, m.smiles);
if (v) { m.repr[kind] = v; n++; }
if (kind === 'braid') m.repr.braidTokens = S._lastBraidTokens || '';
} catch (e) { m.repr[kind] = ''; }
}
return n;
}
const body = { smiles: mols.map(m => m.smiles) };
if (kind === 'braid') body.aromatic = S.braidAromatic;
const j = await postJSON(backendURL('/convert/' + kind + '/batch'), body);
let n = 0;
(j.results || []).forEach((v, i) => {
if (v) { mols[i].repr[kind] = v; n++; } else mols[i].repr[kind] = '';
if (kind === 'braid') mols[i].repr.braidTokens = (j.tokens || [])[i] || '';
});
const errs = (j.errors || []).filter(Boolean);
if (errs.length) toast(`${kind}: ${errs.length} molecule${errs.length === 1 ? '' : 's'} failed to encode`, '--o', 5000);
return n;
}
const REPR_META = [
['canonical', 'Canonical SMILES'], ['cxsmiles', 'CXSMILES'],
['inchi', 'InChI'], ['inchikey', 'InChIKey'],
['scaffold', 'Murcko scaffold'], ['ecfp4', 'ECFP4 Β· Morgan r=2, 2048 bit'],
['braid', 'BRAID'], ['braidTokens', 'BRAID tokens Β· model input'],
['selfies', 'SELFIES'],
['molblock', 'Molfile V2000'], ['molblock3k','Molfile V3000'],
];
function renderConvert() {
const sel = $('#sel-conv');
const valid = S.mols.filter(m => m.valid);
sel.innerHTML = valid.map(m => `<option value="${m.id}">${esc(m.name || m.smiles.slice(0, 44))}</option>`).join('')
|| '<option value="">no valid molecules</option>';
if (S._convSel && valid.some(m => m.id === +S._convSel)) sel.value = S._convSel;
paintReprs();
paintAdapterStatus();
}
function paintReprs() {
const id = +$('#sel-conv').value;
const m = S.mols.find(x => x.id === id);
const host = $('#repr-grid');
if (!m) { host.innerHTML = `<div class="empty" style="grid-column:1/-1"><b>Nothing to convert</b><span>Load at least one valid molecule on stage 01.</span></div>`; return; }
S._convSel = id;
const r = Object.assign({}, reprsFor(m), m.repr);
host.innerHTML = REPR_META.map(([k, label]) => {
let v = r[k], extra = '';
if (k === 'ecfp4' && v) { const on = (v.match(/1/g) || []).length; extra = `<span class="badge">${on} bits set</span>`; v = bitsToHex(v); }
const missing = !v;
const hint = (k === 'braid' || k === 'selfies' || k === 'braidTokens') ? 'register a converter below'
: (S.rdkit ? 'not available in this RDKit build' : 'needs RDKit');
return `<div class="repr">
<div class="repr-head"><span class="repr-name">${label}</span>${extra}
<span style="margin-left:auto"></span>
${missing ? '' : `<button class="btn is-ghost is-sm" data-copy="${esc(k)}">copy</button>`}</div>
<div class="repr-val ${missing ? 'is-empty' : ''}">${missing ? esc(hint) : esc(String(v).length > 900 ? String(v).slice(0, 900) + '\n…' : v)}</div>
</div>`;
}).join('');
$$('[data-copy]', host).forEach(b => b.onclick = () => {
const k = b.dataset.copy;
let v = r[k]; if (k === 'ecfp4') v = bitsToHex(v);
copyText(String(v), k);
});
}
function copyText(text, what) {
const done = () => toast('Copied ' + what, '--n');
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(done).catch(() => fallbackCopy(text, done));
} else fallbackCopy(text, done);
}
function fallbackCopy(text, done) {
const ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); done(); }
catch (e) { toast('Copy blocked by the browser β€” select the text instead', '--o'); }
ta.remove();
}
async function convertAll() {
const targets = S.mols.filter(m => m.valid);
if (!targets.length) { toast('No valid molecules to convert', '--o'); return; }
targets.forEach(m => { m.repr = Object.assign({}, reprsFor(m), m.repr); });
const done = [];
for (const kind of ['braid', 'selfies']) {
if (!S.adapters[kind]) continue;
try {
const n = await batchConvert(kind, targets);
done.push(`${n} ${kind}`);
} catch (e) {
toast(`${kind}: ${String(e.message || e)}`, '--o', 6000);
}
}
renderAll();
toast(`Converted ${targets.length} molecule${targets.length === 1 ? '' : 's'}`
+ (done.length ? ' Β· ' + done.join(', ') : ''), '--n');
}
function paintAdapterStatus() {
const bits = ['braid', 'selfies'].filter(k => S.adapters[k])
.map(k => k + (S.adapters[k].mode === 'backend' ? '' : ' (' + S.adapters[k].mode + ')'));
$('#adapter-status').textContent = bits.length
? bits.join(' Β· ') + (S.backend.ok ? ' Β· backend up' : '')
: 'not registered';
}
/* ══════════════ 03 Β· DRAWING ══════════════ */
function drawOpts(size) {
return {
width: size, height: Math.round(size * 0.78),
backgroundColour: [0, 0, 0, 0], atomColourPalette: CPK,
addAtomIndices: $('#dw-idx').checked,
addStereoAnnotation: $('#dw-stereo').checked,
kekulize: $('#dw-kek').checked,
explicitMethyl: $('#dw-me').checked,
bondLineWidth: 1.5, multipleBondOffset: 0.14, minFontSize: 9,
annotationColour: [0.55, 0.6, 0.68],
};
}
function drawMol(m, size, smarts) {
if (!S.rdkit || !m.valid) return { svg: '', hit: false };
let hit = false;
const svg = withMol(m.smiles, mol => {
const d = drawOpts(size);
if (smarts) {
let q = null;
try {
q = S.rdkit.get_qmol(smarts);
if (q && (!q.is_valid || q.is_valid())) {
const res = JSON.parse(mol.get_substruct_matches(q) || '[]');
if (res.length) {
hit = true;
const A = new Set(), B = new Set();
res.forEach(r => { (r.atoms || []).forEach(a => A.add(a)); (r.bonds || []).forEach(b => B.add(b)); });
d.atoms = Array.from(A); d.bonds = Array.from(B);
d.highlightColour = [1, 0.42, 0.42, 0.32];
}
}
} catch (e) { /* unparseable SMARTS β€” draw plainly */ }
finally { if (q) { try { q.delete(); } catch (e) {} } }
}
try { return mol.get_svg_with_highlights(JSON.stringify(d)); }
catch (e) { return mol.get_svg(d.width, d.height); }
});
return { svg: svg || '', hit };
}
function renderDraw() {
const host = $('#draw-grid');
if (!S.rdkit) { host.innerHTML = `<div class="empty"><b>RDKit is not available</b><span>Drawing needs it. Tokenisation on stage 05 still works without it.</span></div>`; return; }
const valid = S.mols.filter(m => m.valid);
if (!valid.length) { host.innerHTML = `<div class="empty"><b>Nothing to draw</b><span>Load molecules on stage 01 first.</span></div>`; return; }
const size = +$('#dw-size').value;
const smarts = $('#dw-smarts').value.trim();
let hits = 0;
const cells = valid.slice(0, 240).map(m => {
const r = drawMol(m, size, smarts);
if (r.hit) hits++;
return `<div class="mol-cell" data-big="${m.id}">
<div class="mol-art" style="min-height:${Math.round(size * 0.78)}px">${r.svg}</div>
<div class="mol-cap" title="${esc(m.smiles)}">${esc(m.name || m.smiles)}</div>
</div>`;
}).join('');
host.innerHTML = `<div class="mol-grid" style="grid-template-columns:repeat(auto-fill,minmax(${size}px,1fr))">${cells}</div>`;
$('#dw-match').textContent = smarts ? `${hits} of ${valid.length} match ${smarts}` : '';
$$('[data-big]', host).forEach(c => c.onclick = () => {
const m = S.mols.find(x => x.id === +c.dataset.big);
const r = drawMol(m, 520, smarts);
sheet(`<div class="card-head"><h2 class="card-title">${esc(m.name || 'Structure')}</h2>
<div class="card-actions"><button class="btn is-sm" id="dl-svg">Download SVG</button><button class="btn is-sm is-ghost" id="x-sheet">Close</button></div></div>
<div class="mol-art">${r.svg}</div>
<div class="repr-val" style="margin-top:12px">${esc(m.smiles)}</div>`);
$('#x-sheet').onclick = closeSheet;
$('#dl-svg').onclick = () => download(`${(m.name || 'molecule').replace(/\W+/g, '-')}.svg`, r.svg, 'image/svg+xml');
});
}
/* ══════════════ 04 Β· DESCRIPTORS AND ALERTS ══════════════ */
const DESC_COLS = [
[['amw', 'exactmw'], 'MW', 1], [['CrippenClogP'], 'cLogP', 2], [['tpsa'], 'TPSA', 1],
[['lipinskiHBD', 'NumHBD'], 'HBD', 0], [['lipinskiHBA', 'NumHBA'], 'HBA', 0],
[['NumRotatableBonds'], 'RotB', 0], [['NumHeavyAtoms'], 'Heavy', 0],
[['NumRings'], 'Rings', 0], [['NumAromaticRings'], 'ArRings', 0],
[['FractionCSP3'], 'FracCsp3', 2], [['NumAtomStereoCenters'], 'Stereo', 0],
];
const pick = (d, keys) => { for (const k of keys) if (d && d[k] != null) return d[k]; return null; };
function computeDescriptors() {
if (!S.rdkit) { toast('Descriptors need RDKit', '--o'); return; }
let n = 0;
S.mols.forEach(m => {
if (!m.valid) return;
const d = withMol(m.smiles, mol => { try { return JSON.parse(mol.get_descriptors()); } catch (e) { return null; } });
if (d) { m.desc = d; n++; }
});
renderDescTable(); renderRail(); renderExport();
toast(`Computed descriptors for ${n} molecule${n === 1 ? '' : 's'}`, '--p');
}
function renderDescTable() {
const host = $('#desc-table');
const rows = S.mols.filter(m => m.desc);
if (!rows.length) { host.innerHTML = `<div class="empty"><b>No descriptors yet</b><span>Compute them for the current set.</span></div>`; return; }
host.innerHTML = `<div class="tbl-wrap"><table>
<thead><tr><th>Molecule</th>${DESC_COLS.map(c => `<th class="num">${c[1]}</th>`).join('')}<th class="num">Ro5</th><th class="num">Veber</th></tr></thead>
<tbody>${rows.map(m => {
const d = m.desc;
const mw = pick(d, ['amw', 'exactmw']), lp = pick(d, ['CrippenClogP']), hbd = pick(d, ['lipinskiHBD', 'NumHBD']),
hba = pick(d, ['lipinskiHBA', 'NumHBA']), rot = pick(d, ['NumRotatableBonds']), tpsa = pick(d, ['tpsa']);
const ro5 = [mw > 500, lp > 5, hbd > 5, hba > 10].filter(Boolean).length;
const veber = (rot <= 10 && tpsa <= 140);
return `<tr><td><span class="mono trunc" style="max-width:220px" title="${esc(m.smiles)}">${esc(m.name || m.smiles)}</span></td>
${DESC_COLS.map(c => `<td class="num">${fmt(pick(d, c[0]), c[2])}</td>`).join('')}
<td class="num"><span class="badge ${ro5 === 0 ? 'is-ok' : ro5 <= 1 ? 'is-warn' : 'is-bad'}">${ro5}</span></td>
<td class="num"><span class="badge ${veber ? 'is-ok' : 'is-warn'}">${veber ? 'pass' : 'fail'}</span></td></tr>`;
}).join('')}</tbody></table></div>`;
}
const ALERTS = [
['Nitro', '[N+](=O)[O-]'], ['Aldehyde', '[CX3H1](=O)[#6]'], ['Enone / Michael acceptor', '[CX3]=[CX3][CX3]=[OX1]'],
['Epoxide', 'C1OC1'], ['Azide', 'N=[N+]=[N-]'], ['Thiol', '[SX2H]'],
['Acyl halide', '[CX3](=O)[F,Cl,Br,I]'], ['Alkyl halide', '[CX4][Cl,Br,I]'],
['Isocyanate', '[NX2]=[CX2]=[OX1]'], ['Hydrazine', '[NX3][NX3]'],
['Peroxide', '[OX2][OX2]'], ['Quaternary N', '[NX4+]'],
];
function screenAlerts() {
if (!S.rdkit) { toast('Substructure screening needs RDKit', '--o'); return; }
const qs = ALERTS.map(pair => {
let q = null;
try { q = S.rdkit.get_qmol(pair[1]); if (q && q.is_valid && !q.is_valid()) { q.delete(); q = null; } }
catch (e) { q = null; }
return { name: pair[0], q };
});
S.mols.forEach(m => {
if (!m.valid) return;
m.alerts = withMol(m.smiles, mol => qs.filter(x => {
if (!x.q) return false;
try { return JSON.parse(mol.get_substruct_matches(x.q) || '[]').length > 0; } catch (e) { return false; }
}).map(x => x.name)) || [];
});
qs.forEach(x => { if (x.q) { try { x.q.delete(); } catch (e) {} } });
renderAlertTable(); renderExport();
toast('Screened against ' + ALERTS.length + ' substructures', '--p');
}
function renderAlertTable() {
const host = $('#alert-table');
const rows = S.mols.filter(m => m.alerts);
if (!rows.length) { host.innerHTML = `<div class="empty"><b>Not screened yet</b><span>Run the screen to flag reactive substructures.</span></div>`; return; }
const clean = rows.filter(m => !m.alerts.length).length;
host.innerHTML = `<div class="stat-row" style="margin-bottom:14px">
<div class="stat"><div class="stat-k">Screened</div><div class="stat-v">${rows.length}</div></div>
<div class="stat"><div class="stat-k">Nothing flagged</div><div class="stat-v">${clean}</div></div>
<div class="stat"><div class="stat-k">Flagged</div><div class="stat-v">${rows.length - clean}</div></div>
</div>
<div class="tbl-wrap"><table><thead><tr><th>Molecule</th><th>Flags</th></tr></thead>
<tbody>${rows.map(m => `<tr>
<td><span class="mono trunc" style="max-width:280px">${esc(m.name || m.smiles)}</span></td>
<td>${m.alerts.length ? m.alerts.map(a => `<span class="badge is-warn" style="margin-right:4px">${esc(a)}</span>`).join('') : '<span class="dim">β€”</span>'}</td>
</tr>`).join('')}</tbody></table></div>`;
}
/* ══════════════ 05 Β· TOKENIZERS ══════════════
WordPiece and BPE (byte-level included) run here, so the benchmark
reflects the real vocabularies rather than an approximation. */
function bytesToUnicode() {
const bs = [];
for (let i = 33; i <= 126; i++) bs.push(i);
for (let i = 161; i <= 172; i++) bs.push(i);
for (let i = 174; i <= 255; i++) bs.push(i);
const cs = bs.slice(); let n = 0;
for (let b = 0; b < 256; b++) if (bs.indexOf(b) === -1) { bs.push(b); cs.push(256 + n); n++; }
const map = {}; bs.forEach((b, i) => map[b] = String.fromCodePoint(cs[i]));
return map;
}
const B2U = bytesToUnicode();
function byteLevelEncode(str) {
const bytes = new TextEncoder().encode(str);
let out = ''; for (const b of bytes) out += B2U[b];
return out;
}
const BL_RX = /'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+/gu;
function wpWord(word, vocab, unk, prefix, maxChars) {
if (word.length > maxChars) return [unk];
const out = []; let start = 0;
while (start < word.length) {
let end = word.length, cur = null;
while (start < end) {
let sub = word.slice(start, end);
if (start > 0) sub = prefix + sub;
if (vocab.has(sub)) { cur = sub; break; }
end--;
}
if (cur === null) return [unk];
out.push(cur); start = end;
}
return out;
}
function bpeWord(word, ranks, suffix) {
const sym = Array.from(word);
if (!sym.length) return [];
if (suffix) sym[sym.length - 1] = sym[sym.length - 1] + suffix;
while (sym.length > 1) {
let bi = -1, br = Infinity;
for (let i = 0; i < sym.length - 1; i++) {
const r = ranks.get(sym[i] + ' ' + sym[i + 1]);
if (r !== undefined && r < br) { br = r; bi = i; }
}
if (bi < 0) break;
sym.splice(bi, 2, sym[bi] + sym[bi + 1]);
}
return sym;
}
function findPre(pt) {
if (!pt) return { kind: 'none' };
if (pt.type === 'Sequence' && Array.isArray(pt.pretokenizers)) {
for (const p of pt.pretokenizers) { const r = findPre(p); if (r.kind !== 'none') return r; }
return { kind: 'none' };
}
if (pt.type === 'ByteLevel') return { kind: 'bytelevel' };
if (pt.type === 'Whitespace' || pt.type === 'WhitespaceSplit') return { kind: 'ws' };
if (pt.type === 'Split' && pt.pattern && pt.pattern.Regex) return { kind: 'regex', rx: pt.pattern.Regex };
if (pt.type === 'Metaspace') return { kind: 'metaspace', rep: pt.replacement || '\u2581' };
return { kind: 'none' };
}
function hasLowercase(nz) {
if (!nz) return false;
if (nz.type === 'Lowercase') return true;
if (nz.type === 'BertNormalizer') return !!nz.lowercase;
if (nz.type === 'Sequence' && Array.isArray(nz.normalizers)) return nz.normalizers.some(hasLowercase);
return false;
}
function buildTokenizer(name, tj, vocabTxt) {
let vocab = new Map(), kind = 'WordPiece', unk = '[UNK]', prefix = '##', maxChars = 200;
let ranks = null, suffix = '', pre = { kind: 'ws' }, lower = false;
const specials = new Set();
if (tj) {
const M = tj.model || {};
kind = M.type || 'WordPiece';
lower = hasLowercase(tj.normalizer);
pre = findPre(tj.pre_tokenizer);
(tj.added_tokens || []).forEach(t => { if (t && t.content) specials.add(t.content); });
if (M.vocab) {
if (Array.isArray(M.vocab)) M.vocab.forEach((e, i) => vocab.set(Array.isArray(e) ? e[0] : e, i));
else Object.entries(M.vocab).forEach(kv => vocab.set(kv[0], kv[1]));
}
unk = M.unk_token || (specials.has('[UNK]') ? '[UNK]' : '<unk>');
prefix = (M.continuing_subword_prefix != null) ? M.continuing_subword_prefix : '##';
suffix = M.end_of_word_suffix || '';
maxChars = M.max_input_chars_per_word || 200;
if (kind === 'BPE') {
ranks = new Map();
(M.merges || []).forEach((mg, i) => ranks.set(Array.isArray(mg) ? mg.join(' ') : mg, i));
}
} else if (vocabTxt) {
vocabTxt.split(/\r?\n/).forEach((t, i) => { if (t !== '') vocab.set(t, i); });
['[UNK]', '[CLS]', '[SEP]', '[PAD]', '[MASK]'].forEach(t => { if (vocab.has(t)) specials.add(t); });
}
const cache = new Map();
function encodeWord(w) {
if (cache.has(w)) return cache.get(w);
let toks;
if (kind === 'BPE') toks = bpeWord(w, ranks, suffix).map(s => vocab.has(s) ? s : unk);
else if (kind === 'WordLevel') toks = [vocab.has(w) ? w : unk]; // whole pre-token, no sub-word split
else toks = wpWord(w, vocab, unk, prefix, maxChars);
cache.set(w, toks);
return toks;
}
function encode(text) {
if (!text) return [];
const s = lower ? String(text).toLowerCase() : String(text);
const parts = specials.size
? s.split(new RegExp('(' + Array.from(specials).map(x => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')'))
: [s];
const out = [];
for (const part of parts) {
if (!part) continue;
if (specials.has(part)) { out.push({ t: part, special: true, unk: false }); continue; }
let words;
if (pre.kind === 'bytelevel') words = (part.match(BL_RX) || []).map(byteLevelEncode);
else if (pre.kind === 'regex') {
try { words = part.split(new RegExp(pre.rx, 'gu')).filter(Boolean); }
catch (e) { words = part.split(/\s+/).filter(Boolean); }
}
else if (pre.kind === 'metaspace') words = (pre.rep + part.replace(/ /g, pre.rep)).split(/(?=\u2581)/).filter(Boolean);
else if (pre.kind === 'ws') words = part.split(/\s+/).filter(Boolean);
else words = [part];
for (const w of words) for (const t of encodeWord(w)) out.push({ t, special: false, unk: t === unk });
}
return out;
}
// Sniff the notation from the vocabulary rather than the filename: BRAID
// vocabularies carry relative-ring (^d) and branch-count (>k) tokens, which
// no SMILES vocabulary contains.
let braidish = 0;
vocab.forEach((_v, t) => { if (/^\^\d+$/.test(t) || /^>\d+$/.test(t)) braidish++; });
const inputRepr = braidish >= 3 ? 'braid' : 'smiles';
return { name, kind, vocabSize: vocab.size, unk, prefix, lower, pre: pre.kind,
encode, vocab, specials, inputRepr, braidish };
}
const ATOM_RX = /(\[[^\]]+]|Br?|Cl?|N|O|S|P|F|I|b|c|n|o|s|p|\(|\)|\.|=|#|-|\+|\\|\/|:|~|@|\?|>|\*|\$|%[0-9]{2}|[0-9])/g;
function baselineTokenizers() {
if (!S.tokenizers.atomwise) {
S.tokenizers.atomwise = {
name: 'Atom-level regex', kind: 'reference', vocabSize: 0, unk: '', pre: 'regex', source: 'built in', inputRepr: 'smiles',
encode: t => (String(t).match(ATOM_RX) || []).map(x => ({ t: x, special: false, unk: false })),
};
S.tokOrder.push('atomwise');
}
if (!S.tokenizers.charwise) {
S.tokenizers.charwise = {
name: 'Character', kind: 'reference', vocabSize: 0, unk: '', pre: 'none', source: 'built in', inputRepr: 'smiles',
encode: t => Array.from(String(t)).map(x => ({ t: x, special: false, unk: false })),
};
S.tokOrder.push('charwise');
}
}
async function fetchTokenizer(repo, key, label) {
let tj = null, vt = null, via = '';
const tried = [];
// 1. Through our own backend. It has huggingface_hub and HF_TOKEN, so this
// works for private repos and for layouts a URL guess would never find.
if (S.backend.ok) {
try {
const r = await fetch(backendURL('/tokenizer/' + repo));
if (r.ok) { tj = await r.json(); via = 'backend'; }
else { let d = ''; try { d = (await r.json()).detail || ''; } catch (e) {} tried.push('backend: ' + (d || 'HTTP ' + r.status)); }
} catch (e) { tried.push('backend: unreachable'); }
} else tried.push('backend: not connected');
// 2. Straight from the Hub.
if (!tj) {
const base = 'https://huggingface.co/' + repo + '/resolve/main/';
for (const [file, isJson] of [['tokenizer.json', true], ['vocab.txt', false]]) {
try {
const r = await fetch(base + file);
if (r.ok) { if (isJson) { tj = await r.json(); } else { vt = await r.text(); } via = 'hub'; break; }
tried.push(`hub ${file}: HTTP ${r.status}${r.status === 401 ? ' (private?)' : r.status === 404 ? ' (no such file or repo)' : ''}`);
} catch (e) { tried.push('hub ' + file + ': blocked by the browser'); }
}
}
if (!tj && !vt) throw new Error('no tokenizer for ' + repo + ' β€” ' + tried.join(' Β· '));
const tk = buildTokenizer(label || repo, tj, vt);
tk.repo = repo; tk.source = (tj ? 'tokenizer.json' : 'vocab.txt') + (via ? ' via ' + via : '');
// A tokenizer only makes sense against the representation it was trained on.
// BRAIDBERTa's vocabulary is BRAID tokens; feeding it SMILES yields <unk>.
if (tk.braidish < 3 && /braid/i.test(repo + ' ' + (label || ''))) tk.inputRepr = 'braid';
S.tokenizers[key] = tk;
if (S.tokOrder.indexOf(key) === -1) S.tokOrder.push(key);
return tk;
}
const REPR_LABEL = { smiles: 'SMILES', braid: 'BRAID tokens', selfies: 'SELFIES' };
/* The single place that decides what text a tokenizer sees. */
function tokenizerInput(m, tk) {
const r = (tk && tk.inputRepr) || 'smiles';
if (r === 'braid') return m.repr.braidTokens || null;
if (r === 'selfies') return m.repr.selfies || null;
return m.smiles;
}
function missingReprHint(r) {
return r === 'braid'
? 'needs the BRAID token stream β€” connect the backend on stage 02 and run Convert all'
: 'needs SELFIES β€” register the converter on stage 02 and run Convert all';
}
/* ── tokenizer UI ────────────────────────────────────────── */
function renderTokSources() {
baselineTokenizers();
const host = $('#tok-sources');
host.innerHTML = `<div class="tbl-wrap"><table>
<thead><tr><th>Tokenizer</th><th>Kind</th><th>Reads</th><th>Source</th><th class="num">Vocab</th><th>Pre-tokeniser</th><th style="width:36px"></th></tr></thead>
<tbody>${S.tokOrder.map(k => {
const t = S.tokenizers[k];
return `<tr>
<td><span class="mono">${esc(t.name)}</span>${t.repo ? `<br><span class="dim mono" style="font-size:10px">${esc(t.repo)}</span>` : ''}</td>
<td><span class="badge ${t.kind === 'reference' ? '' : 'is-ok'}">${esc(t.kind)}</span></td>
<td><select class="select is-sm" data-tokrepr="${esc(k)}" style="min-width:130px">
${['smiles', 'braid', 'selfies'].map(r => `<option value="${r}" ${(t.inputRepr || 'smiles') === r ? 'selected' : ''}>${REPR_LABEL[r]}</option>`).join('')}
</select></td>
<td class="dim mono" style="font-size:11px">${esc(t.source || 'β€”')}</td>
<td class="num">${t.vocabSize ? t.vocabSize.toLocaleString() : 'β€”'}</td>
<td class="dim mono" style="font-size:11px">${esc(t.pre || 'β€”')}${t.lower ? ' Β· lowercase' : ''}</td>
<td>${t.kind === 'reference' ? '' : `<button class="btn is-ghost is-sm" data-tokdel="${esc(k)}">βœ•</button>`}</td>
</tr>`;
}).join('')}</tbody></table></div>`;
$$('[data-tokrepr]', host).forEach(sel => sel.onchange = () => {
S.tokenizers[sel.dataset.tokrepr].inputRepr = sel.value;
S.bench = null; renderTokStrips(); renderBench(); renderRail();
});
$$('[data-tokdel]', host).forEach(b => b.onclick = () => {
delete S.tokenizers[b.dataset.tokdel];
S.tokOrder = S.tokOrder.filter(x => x !== b.dataset.tokdel);
S.bench = null; renderTokSources(); renderTokStrips(); renderRail();
});
const sel = $('#sel-tok');
const valid = S.mols.filter(m => m.valid);
const prev = sel.value;
sel.innerHTML = valid.map(m => `<option value="${m.id}">${esc(m.name || m.smiles.slice(0, 40))}</option>`).join('')
|| '<option value="">no molecules loaded</option>';
if (prev && valid.some(m => m.id === +prev)) sel.value = prev;
renderTokStrips();
}
function tokClass(t) {
if (t.special) return 'tok is-special';
if (t.unk) return 'tok is-unk';
if (/^##/.test(t.t) || /<\/w>$/.test(t.t)) return 'tok is-sub';
return 'tok';
}
function renderTokStrips() {
const host = $('#tok-strips');
const m = S.mols.find(x => x.id === +$('#sel-tok').value);
if (!m || !S.tokOrder.length) {
host.innerHTML = `<div class="empty"><b>Nothing to show yet</b><span>Load molecules and fetch the tokenizers.</span></div>`;
return;
}
host.innerHTML = S.tokOrder.map(k => {
const t = S.tokenizers[k];
const repr = t.inputRepr || 'smiles';
const text = tokenizerInput(m, t);
if (text == null) {
return `<div class="strip-row">
<div class="strip-head"><span class="strip-name">${esc(t.name)}</span>
<span class="badge">reads ${esc(REPR_LABEL[repr])}</span>
<span class="badge is-warn">not available</span></div>
<div class="dim" style="font-size:12px;padding:4px 2px">${esc(missingReprHint(repr))}</div>
</div>`;
}
let toks = [];
try { toks = t.encode(text); } catch (e) { toks = []; }
const unk = toks.filter(x => x.unk).length;
const ratio = toks.length ? unk / toks.length : 0;
return `<div class="strip-row">
<div class="strip-head">
<span class="strip-name">${esc(t.name)}</span>
<span class="badge">reads ${esc(REPR_LABEL[repr])}</span>
<span class="badge">${toks.length} tokens</span>
<span class="badge">${(text.length / Math.max(toks.length, 1)).toFixed(2)} chars/token</span>
${unk ? `<span class="badge is-bad">${unk} unknown</span>` : ''}
</div>
<div class="repr-val" style="margin:2px 0 8px;font-size:11px;opacity:.7">${esc(text.length > 160 ? text.slice(0, 160) + '…' : text)}</div>
<div class="toks">${toks.map(x => `<span class="${tokClass(x)}">${esc(x.t)}</span>`).join('')}</div>
${ratio > 0.2 ? `<div class="note" style="--hue:var(--o);margin-top:10px"><b>Mostly unknown tokens.</b> This vocabulary does not match the input it is being given. Check the <span class="mono">reads</span> setting for this tokenizer in the table above.</div>` : ''}
</div>`;
}).join('');
}
function runBenchmark() {
const mols = S.mols.filter(m => m.valid);
if (!mols.length || !S.tokOrder.length) { toast('Load molecules and tokenizers first', '--o'); return; }
const rows = S.tokOrder.map(k => {
const t = S.tokenizers[k];
let totTok = 0, totChar = 0, unk = 0, withUnk = 0, maxLen = 0, heavy = 0, heavyKnown = 0, covered = 0;
const uniq = new Set(), lens = [], seqLens = [];
for (const m of mols) {
const text = tokenizerInput(m, t);
if (text == null) continue;
covered++;
let toks = [];
try { toks = t.encode(text); } catch (e) {}
totTok += toks.length; totChar += text.length;
seqLens.push(toks.length);
maxLen = Math.max(maxLen, toks.length);
let bad = false;
for (const x of toks) { uniq.add(x.t); lens.push(x.t.length); if (x.unk) { unk++; bad = true; } }
if (bad) withUnk++;
const h = m.desc ? pick(m.desc, ['NumHeavyAtoms']) : null;
if (h) { heavy += h; heavyKnown += toks.length; }
}
seqLens.sort((a, b) => a - b);
const p95 = seqLens[Math.min(seqLens.length - 1, Math.floor(seqLens.length * 0.95))];
return {
key: k, name: t.name, kind: t.kind, vocabSize: t.vocabSize,
repr: t.inputRepr || 'smiles', covered, total: mols.length,
meanTok: totTok / Math.max(covered, 1), charsPerTok: totChar / Math.max(totTok, 1),
unk, withUnk, uniq: uniq.size,
util: t.vocabSize ? uniq.size / t.vocabSize : null,
maxLen, p95, medTokLen: lens.length ? lens.sort((a, b) => a - b)[Math.floor(lens.length / 2)] : 0,
fertility: heavy ? heavyKnown / heavy : null,
lens,
};
});
S.bench = { rows, n: mols.length };
renderBench(); renderHist(); renderRail();
toast(`Benchmarked ${S.tokOrder.length} tokenizers over ${mols.length} molecules`, '--s');
}
function renderBench() {
const host = $('#bench-table');
if (!S.bench) { host.innerHTML = `<div class="empty"><b>No benchmark yet</b><span>Run it to compare the schemes over your set.</span></div>`; return; }
const withData = S.bench.rows.filter(r => r.covered);
const best = withData.length ? Math.min.apply(null, withData.map(r => r.meanTok)) : null;
host.innerHTML = `<div class="tbl-wrap"><table>
<thead><tr>
<th>Tokenizer</th><th>Reads</th><th class="num">Tokens/mol</th><th class="num">Chars/token</th>
<th class="num">Tokens/heavy atom</th><th class="num">Max len</th><th class="num">p95 len</th>
<th class="num">Vocab used</th><th class="num">Utilisation</th><th class="num">Unknown</th>
</tr></thead>
<tbody>${S.bench.rows.map(r => `<tr>
<td><span class="mono">${esc(r.name)}</span></td>
<td><span class="badge">${esc(REPR_LABEL[r.repr] || r.repr)}</span>${r.covered < r.total ? `<br><span class="dim" style="font-size:10px">${r.covered}/${r.total} available</span>` : ''}</td>
<td class="num">${r.covered ? r.meanTok.toFixed(2) : 'β€”'}${r.covered && r.meanTok === best ? ' <span class="badge is-ok">shortest</span>' : ''}</td>
<td class="num">${r.charsPerTok.toFixed(2)}</td>
<td class="num">${r.fertility ? r.fertility.toFixed(2) : 'β€”'}</td>
<td class="num">${r.maxLen}</td>
<td class="num">${r.p95}</td>
<td class="num">${r.uniq.toLocaleString()}</td>
<td class="num">${r.util != null ? (r.util * 100).toFixed(1) + '%' : 'β€”'}</td>
<td class="num">${r.unk ? `<span class="badge is-bad">${r.unk}</span>` : '<span class="dim">0</span>'}</td>
</tr>`).join('')}</tbody></table></div>
<div class="note" style="--hue:var(--s);margin-top:14px">
<b>Reading this.</b> Rows are only comparable when each tokenizer reads the representation it was trained on β€” check the <span class="mono">Reads</span> column. <b>Chars/token is not comparable across representations</b>, because the underlying strings differ in length; <b>tokens per heavy atom is</b>, which makes it the honest cross-notation metric. It needs descriptors from stage 04. Max length is what you would set <span class="mono">max_position_embeddings</span> against; p95 is the more practical padding target. Utilisation is how much of the vocabulary this set touches β€” low numbers on a small set are expected.
</div>`;
}
function renderHist() {
const host = $('#tok-hist');
if (!S.bench) return;
const cap = 12;
host.innerHTML = S.bench.rows.map(r => {
const buckets = new Array(cap).fill(0);
r.lens.forEach(L => buckets[Math.min(L, cap) - 1 >= 0 ? Math.min(L, cap) - 1 : 0]++);
const mx = Math.max.apply(null, buckets) || 1;
return `<div style="margin-bottom:16px">
<div class="eyebrow" style="margin-bottom:8px">${esc(r.name)}</div>
<div class="bars">${buckets.map((v, i) => `
<div class="bar-row">
<span class="dim">${i + 1 === cap ? cap + '+' : i + 1} char</span>
<div class="bar-track"><div class="bar-fill" style="width:${(v / mx * 100).toFixed(1)}%"></div></div>
<span class="dim" style="text-align:right">${v}</span>
</div>`).join('')}</div>
</div>`;
}).join('');
}
async function loadTokenizers() {
baselineTokenizers();
const jobs = [
[S.models.braid, 'braid', 'BRAIDBERTa'],
[S.models.deep, 'deep', 'DeepBERTa'],
];
for (const j of jobs) {
try { await fetchTokenizer(j[0], j[1], j[2]); toast(`Loaded ${j[2]} tokenizer`, '--s'); }
catch (e) { toast(`${j[2]}: ${esc(String(e.message || e))}`, '--o', 6000); }
}
renderTokSources(); renderRail();
}
/* ══════════════ 06 Β· ENCODERS ══════════════ */
function describeStatus(status, detail) {
const d = String(detail || '').replace(/\s+/g, ' ').slice(0, 220);
if (status === 501) return new Error('backend is missing a dependency β€” ' + d);
if (status === 503) return new Error(d || 'model still loading β€” retry in ~20s');
if (status === 404) return new Error('404 β€” no such repo or route. ' + d);
if (status === 401 || status === 403) return new Error('not authorised (' + status + ') β€” ' + d);
if (status === 413) return new Error(d || 'batch too large');
return new Error('HTTP ' + status + (d ? ' Β· ' + d : ''));
}
async function hfPost(repo, pipeline, body) {
const heads = { 'Content-Type': 'application/json' };
if (S.hfToken) heads.Authorization = 'Bearer ' + S.hfToken;
const local = S.hfBase.replace(/\/+$/, '') + '/' + repo + '/pipeline/' + pipeline;
// When we are talking to our own server, do NOT fall back to Hugging Face.
// The fallback's generic CORS failure would overwrite the specific, useful
// error our backend just returned β€” which is exactly how a wrong repo name
// ends up reported as "network error".
const selfHosted = !!SAME_ORIGIN && S.hfBase.indexOf(SAME_ORIGIN) === 0;
const urls = selfHosted ? [local] : [local, S.hfLegacy + '/' + repo];
const errs = [];
for (const url of urls) {
try {
const res = await fetch(url, { method: 'POST', headers: heads, body: JSON.stringify(body) });
if (res.ok) return await res.json();
const txt = await res.text();
let detail = txt;
try { const j = JSON.parse(txt); detail = j.detail || j.error || txt; } catch (e) {}
errs.push(describeStatus(res.status, detail));
} catch (e) {
errs.push(new Error(selfHosted
? 'could not reach the backend at ' + S.hfBase
: 'network or CORS error β€” run backend.py, or open this page through it'));
}
}
throw errs[0] || new Error('request failed'); // the first is our own server
}
const depth = a => Array.isArray(a) ? 1 + depth(a[0]) : 0;
function poolVec(x, pooling) {
if (!Array.isArray(x)) return null;
if (typeof x[0] === 'number') return Float64Array.from(x);
if (pooling === 'cls') return Float64Array.from(x[0]);
const d = x[0].length, out = new Float64Array(d);
for (const row of x) for (let i = 0; i < d; i++) out[i] += row[i];
for (let i = 0; i < d; i++) out[i] /= x.length;
return out;
}
function inputTextFor(m, kind) {
// BRAIDBERTa's tokenizer is WordLevel + WhitespaceSplit: it consumes the
// space-joined token stream. The raw BRAID string would be one unknown word.
if (kind === 'braid') return m.repr.braidTokens || null;
if (kind === 'selfies') return m.repr.selfies || null;
return m.smiles;
}
function ecfpEncoder() {
const vecs = new Map();
S.mols.filter(m => m.valid).forEach(m => {
let bits = m.repr.ecfp4;
if (!bits) bits = withMol(m.smiles, mol => { try { return mol.get_morgan_fp(JSON.stringify({ radius: 2, nBits: 2048 })); } catch (e) { return null; } });
if (bits) {
const v = new Float64Array(bits.length);
for (let i = 0; i < bits.length; i++) v[i] = bits[i] === '1' ? 1 : 0;
vecs.set(m.id, v);
}
});
return { name: 'ECFP4 (local)', dim: 2048, vecs, binary: true };
}
async function embedSet() {
const log = $('#embed-log');
const kind = $('#cmp-repr').value;
const pooling = $('#cmp-pool').value;
const batch = Math.max(1, Math.min(32, +$('#cmp-batch').value || 8));
const mols = S.mols.filter(m => m.valid).slice(0, 120);
if (!mols.length) { toast('Load molecules first', '--o'); return; }
S.emb.ecfp = ecfpEncoder();
log.innerHTML = `<span class="badge is-ok">ECFP4 ready Β· ${S.emb.ecfp.vecs.size} vectors, 2048 dim</span>`;
renderCompare();
const selfHosted = !!SAME_ORIGIN;
if (!S.hfToken && !selfHosted) {
log.innerHTML += `<div style="margin-top:8px">Add a Hugging Face token to embed with the two encoders, or run backend.py. ECFP4 works without either.</div>`;
toast('ECFP4 baseline computed locally. Add a token or run the backend.', '--x', 5000);
renderRail(); refreshEncoderOptions(); return;
}
for (const spec of [['braid', S.models.braid, 'BRAIDBERTa'], ['deep', S.models.deep, 'DeepBERTa']]) {
const key = spec[0], repo = spec[1], label = spec[2];
const vecs = new Map();
let dim = 0, failed = '';
for (let i = 0; i < mols.length; i += batch) {
const slice = mols.slice(i, i + batch);
const texts = slice.map(m => inputTextFor(m, kind)).filter(Boolean);
if (texts.length !== slice.length) { failed = `${kind} is missing for some molecules β€” register the adapter on stage 02`; break; }
log.innerHTML = `<span class="spin"></span> ${label} Β· ${Math.min(i + batch, mols.length)} of ${mols.length}`;
try {
const j = await hfPost(repo, 'feature-extraction', { inputs: texts, options: { wait_for_model: true } });
const arr = (depth(j) >= 3 || (Array.isArray(j) && Array.isArray(j[0]) && Array.isArray(j[0][0]))) ? j : [j];
slice.forEach((m, n) => {
const v = poolVec(arr[n], pooling);
if (v) { vecs.set(m.id, v); dim = v.length; }
});
} catch (e) { failed = String(e.message || e); break; }
}
if (failed) {
log.innerHTML = `<span class="badge is-bad">${esc(label)}</span> ${esc(failed)}`;
toast(`${label}: ${esc(failed)}`, '--o', 7000);
} else {
S.emb[key] = { name: label, dim, vecs, binary: false };
log.innerHTML = `<span class="badge is-ok">${esc(label)} Β· ${vecs.size} vectors, ${dim} dim</span>`;
}
renderCompare(); renderRail(); refreshEncoderOptions();
}
}
/* ── linear algebra ──────────────────────────────────────── */
function jacobiEigen(Ain, sweeps) {
const n = Ain.length;
const A = Ain.map(r => Float64Array.from(r));
const V = [];
for (let i = 0; i < n; i++) { const r = new Float64Array(n); r[i] = 1; V.push(r); }
for (let s = 0; s < (sweeps || 40); s++) {
let off = 0;
for (let p = 0; p < n; p++) for (let q = p + 1; q < n; q++) off += A[p][q] * A[p][q];
if (off < 1e-14) break;
for (let p = 0; p < n; p++) for (let q = p + 1; q < n; q++) {
if (Math.abs(A[p][q]) < 1e-15) continue;
const theta = (A[q][q] - A[p][p]) / (2 * A[p][q]);
const t = theta >= 0 ? 1 / (theta + Math.sqrt(theta * theta + 1)) : -1 / (-theta + Math.sqrt(theta * theta + 1));
const c = 1 / Math.sqrt(t * t + 1), sn = t * c;
for (let k = 0; k < n; k++) { const a = A[k][p], b = A[k][q]; A[k][p] = c * a - sn * b; A[k][q] = sn * a + c * b; }
for (let k = 0; k < n; k++) { const a = A[p][k], b = A[q][k]; A[p][k] = c * a - sn * b; A[q][k] = sn * a + c * b; }
for (let k = 0; k < n; k++) { const a = V[k][p], b = V[k][q]; V[k][p] = c * a - sn * b; V[k][q] = sn * a + c * b; }
}
}
const vals = []; for (let i = 0; i < n; i++) vals.push(A[i][i]);
return { values: vals, vectors: V };
}
function pca2(mat) {
const n = mat.length; if (n < 3) return null;
const d = mat[0].length;
const mu = new Float64Array(d);
for (const r of mat) for (let i = 0; i < d; i++) mu[i] += r[i];
for (let i = 0; i < d; i++) mu[i] /= n;
const X = mat.map(r => { const o = new Float64Array(d); for (let i = 0; i < d; i++) o[i] = r[i] - mu[i]; return o; });
const G = [];
for (let i = 0; i < n; i++) {
const row = new Float64Array(n);
for (let j = 0; j <= i; j++) { let s = 0; for (let k = 0; k < d; k++) s += X[i][k] * X[j][k]; row[j] = s; }
G.push(row);
}
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) G[i][j] = G[j][i];
const eig = jacobiEigen(G, 40);
const order = eig.values.map((v, i) => [v, i]).sort((a, b) => b[0] - a[0]);
const total = eig.values.reduce((a, b) => a + Math.max(b, 0), 0) || 1;
const comp = [0, 1].map(c => {
const idx = order[c][1], val = Math.max(order[c][0], 0), sc = Math.sqrt(val);
return { idx, val, sc, ratio: val / total };
});
const pts = [];
for (let i = 0; i < n; i++) pts.push([eig.vectors[i][comp[0].idx] * comp[0].sc, eig.vectors[i][comp[1].idx] * comp[1].sc]);
return { pts, ratios: [comp[0].ratio, comp[1].ratio] };
}
function solveSym(A, b) {
const n = b.length;
const M = A.map((r, i) => { const o = Float64Array.from(r); return o; });
const y = Float64Array.from(b);
for (let c = 0; c < n; c++) {
let p = c;
for (let r = c + 1; r < n; r++) if (Math.abs(M[r][c]) > Math.abs(M[p][c])) p = r;
if (Math.abs(M[p][c]) < 1e-12) continue;
if (p !== c) { const t = M[p]; M[p] = M[c]; M[c] = t; const ty = y[p]; y[p] = y[c]; y[c] = ty; }
for (let r = c + 1; r < n; r++) {
const f = M[r][c] / M[c][c];
if (!f) continue;
for (let k = c; k < n; k++) M[r][k] -= f * M[c][k];
y[r] -= f * y[c];
}
}
const x = new Float64Array(n);
for (let i = n - 1; i >= 0; i--) {
let s = y[i];
for (let k = i + 1; k < n; k++) s -= M[i][k] * x[k];
x[i] = Math.abs(M[i][i]) < 1e-12 ? 0 : s / M[i][i];
}
return x;
}
const dot = (a, b) => { let s = 0; for (let i = 0; i < a.length; i++) s += a[i] * b[i]; return s; };
const norm = a => Math.sqrt(dot(a, a));
function cosine(a, b) { const n = norm(a) * norm(b); return n ? dot(a, b) / n : 0; }
function tanimoto(a, b) {
let both = 0, any = 0;
for (let i = 0; i < a.length; i++) { const x = a[i] > 0, y = b[i] > 0; if (x && y) both++; if (x || y) any++; }
return any ? both / any : 0;
}
function pearson(a, b) {
const n = a.length, ma = a.reduce((x, y) => x + y, 0) / n, mb = b.reduce((x, y) => x + y, 0) / n;
let sab = 0, sa = 0, sb = 0;
for (let i = 0; i < n; i++) { const da = a[i] - ma, db = b[i] - mb; sab += da * db; sa += da * da; sb += db * db; }
return (sa && sb) ? sab / Math.sqrt(sa * sb) : 0;
}
function ridgeCV(rows, ys, alpha, folds) {
const n = rows.length;
const idx = rows.map((_, i) => i);
let seed = 42;
const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
for (let i = n - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); const t = idx[i]; idx[i] = idx[j]; idx[j] = t; }
const preds = new Array(n).fill(0);
const F = Math.max(2, Math.min(folds, n));
for (let f = 0; f < F; f++) {
const te = idx.filter((_, i) => i % F === f), tr = idx.filter((_, i) => i % F !== f);
if (tr.length < 2) continue;
const d = rows[0].length;
const mu = new Float64Array(d);
for (const i of tr) for (let k = 0; k < d; k++) mu[k] += rows[i][k];
for (let k = 0; k < d; k++) mu[k] /= tr.length;
const Xtr = tr.map(i => { const o = new Float64Array(d); for (let k = 0; k < d; k++) o[k] = rows[i][k] - mu[k]; return o; });
const ymu = tr.reduce((s, i) => s + ys[i], 0) / tr.length;
const yc = tr.map(i => ys[i] - ymu);
const K = [];
for (let i = 0; i < tr.length; i++) {
const row = new Float64Array(tr.length);
for (let j = 0; j < tr.length; j++) row[j] = dot(Xtr[i], Xtr[j]);
row[i] += alpha;
K.push(row);
}
const a = solveSym(K, yc);
for (const i of te) {
const xc = new Float64Array(d);
for (let k = 0; k < d; k++) xc[k] = rows[i][k] - mu[k];
let p = ymu;
for (let j = 0; j < tr.length; j++) p += a[j] * dot(xc, Xtr[j]);
preds[i] = p;
}
}
const ymean = ys.reduce((x, y) => x + y, 0) / n;
let ssr = 0, sst = 0, mae = 0;
for (let i = 0; i < n; i++) { const e = ys[i] - preds[i]; ssr += e * e; mae += Math.abs(e); sst += (ys[i] - ymean) ** 2; }
return { preds, r2: sst ? 1 - ssr / sst : 0, rmse: Math.sqrt(ssr / n), mae: mae / n, r: pearson(ys, preds), n };
}
/* ── comparison views ────────────────────────────────────── */
function encoderKeys() { return Object.keys(S.emb).filter(k => S.emb[k] && S.emb[k].vecs.size > 1); }
function matrixFor(key) {
const e = S.emb[key];
const ids = S.mols.filter(m => m.valid && e.vecs.has(m.id)).map(m => m.id);
return { ids, rows: ids.map(i => e.vecs.get(i)), sim: e.binary ? tanimoto : cosine };
}
function renderCompare() {
const keys = encoderKeys();
const host = $('#cmp-stats');
if (!keys.length) { host.innerHTML = `<div class="empty"><span>Embed a set to compare.</span></div>`; return; }
host.innerHTML = `<div class="stat-row">${keys.map(k => {
const e = S.emb[k], M = matrixFor(k);
const norms = M.rows.map(norm);
const meanNorm = norms.reduce((a, b) => a + b, 0) / norms.length;
let s = 0, c = 0;
for (let i = 0; i < M.rows.length; i++) for (let j = i + 1; j < M.rows.length; j++) { s += M.sim(M.rows[i], M.rows[j]); c++; }
return `<div class="stat"><div class="stat-k">${esc(e.name)}</div>
<div class="stat-v">${e.dim}<small>dim</small></div>
<div class="dim mono" style="font-size:10.5px;margin-top:6px">${M.rows.length} vectors<br>β€–vβ€– ${meanNorm.toFixed(2)}<br>mean sim ${(c ? s / c : 0).toFixed(3)}</div></div>`;
}).join('')}</div>`;
renderPCA(); renderKNN();
}
function ramp(t) {
t = Math.max(0, Math.min(1, t));
const stops = [[0.36, 0.49, 1], [0.95, 0.79, 0.30], [1, 0.42, 0.42]];
const i = t < 0.5 ? 0 : 1, u = t < 0.5 ? t * 2 : (t - 0.5) * 2;
const a = stops[i], b = stops[i + 1];
return `rgb(${Math.round((a[0] + (b[0] - a[0]) * u) * 255)},${Math.round((a[1] + (b[1] - a[1]) * u) * 255)},${Math.round((a[2] + (b[2] - a[2]) * u) * 255)})`;
}
function renderPCA() {
const keys = encoderKeys();
const host = $('#cmp-pca');
if (!keys.length) return;
const logps = {}; let lo = Infinity, hi = -Infinity;
S.mols.forEach(m => { if (m.desc) { const v = pick(m.desc, ['CrippenClogP']); if (v != null) { logps[m.id] = v; lo = Math.min(lo, v); hi = Math.max(hi, v); } } });
const haveLogP = isFinite(lo) && hi > lo;
host.innerHTML = keys.map(k => {
const M = matrixFor(k);
const p = pca2(M.rows);
if (!p) return `<div class="eyebrow">${esc(S.emb[k].name)} β€” needs at least three molecules</div>`;
const xs = p.pts.map(q => q[0]), ys = p.pts.map(q => q[1]);
const xr = [Math.min.apply(null, xs), Math.max.apply(null, xs)], yr = [Math.min.apply(null, ys), Math.max.apply(null, ys)];
const W = 400, H = 250, pad = 18;
const sx = v => pad + (xr[1] - xr[0] ? (v - xr[0]) / (xr[1] - xr[0]) : 0.5) * (W - 2 * pad);
const sy = v => H - pad - (yr[1] - yr[0] ? (v - yr[0]) / (yr[1] - yr[0]) : 0.5) * (H - 2 * pad);
return `<div style="margin-bottom:18px">
<div class="eyebrow" style="margin-bottom:8px">${esc(S.emb[k].name)} β€” PC1 ${(p.ratios[0] * 100).toFixed(1)}% Β· PC2 ${(p.ratios[1] * 100).toFixed(1)}%</div>
<svg class="plot" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">
<line x1="${pad}" y1="${H - pad}" x2="${W - pad}" y2="${H - pad}" stroke="#28324a" stroke-width="1"/>
<line x1="${pad}" y1="${pad}" x2="${pad}" y2="${H - pad}" stroke="#28324a" stroke-width="1"/>
${p.pts.map((q, i) => {
const id = M.ids[i];
const col = haveLogP && logps[id] != null ? ramp((logps[id] - lo) / (hi - lo)) : '#AAB3C5';
const m = S.mols.find(x => x.id === id);
return `<circle cx="${sx(q[0]).toFixed(1)}" cy="${sy(q[1]).toFixed(1)}" r="4" fill="${col}" fill-opacity="0.8" stroke="#0B0E14" stroke-width="0.8"><title>${esc(m ? (m.name || m.smiles) : id)}</title></circle>`;
}).join('')}
</svg>
</div>`;
}).join('') + (haveLogP ? `<div class="eyebrow">colour Β· cLogP from ${lo.toFixed(1)} to ${hi.toFixed(1)}</div>` : `<div class="eyebrow">compute descriptors on stage 04 to colour these by cLogP</div>`);
}
function knnSet(M, i, k) {
const sims = [];
for (let j = 0; j < M.rows.length; j++) if (j !== i) sims.push([M.sim(M.rows[i], M.rows[j]), j]);
sims.sort((a, b) => b[0] - a[0]);
return new Set(sims.slice(0, k).map(s => s[1]));
}
function renderKNN() {
const keys = encoderKeys();
const host = $('#cmp-knn');
if (keys.length < 2) { host.innerHTML = `<div class="empty"><span>Needs at least two encoders. ECFP4 counts.</span></div>`; return; }
const k = Math.max(1, +$('#cmp-k').value || 5);
const pairs = [];
for (let a = 0; a < keys.length; a++) for (let b = a + 1; b < keys.length; b++) {
const A = matrixFor(keys[a]), B = matrixFor(keys[b]);
const common = A.ids.filter(id => B.ids.indexOf(id) !== -1);
if (common.length < k + 1) { pairs.push([keys[a], keys[b], null, 0]); continue; }
const Ai = common.map(id => A.ids.indexOf(id)), Bi = common.map(id => B.ids.indexOf(id));
let tot = 0;
for (let i = 0; i < common.length; i++) {
const sa = knnSet(A, Ai[i], k), sb = knnSet(B, Bi[i], k);
const saIds = new Set(Array.from(sa).map(x => A.ids[x])), sbIds = new Set(Array.from(sb).map(x => B.ids[x]));
let inter = 0; saIds.forEach(x => { if (sbIds.has(x)) inter++; });
const uni = new Set([...saIds, ...sbIds]).size;
tot += uni ? inter / uni : 0;
}
pairs.push([keys[a], keys[b], tot / common.length, common.length]);
}
host.innerHTML = `<div class="bars">${pairs.map(p => `
<div class="bar-row" style="grid-template-columns:1fr 90px 48px">
<span class="dim mono" style="font-size:10.5px">${esc(S.emb[p[0]].name)} ↔ ${esc(S.emb[p[1]].name)}</span>
<div class="bar-track"><div class="bar-fill" style="width:${p[2] != null ? (p[2] * 100).toFixed(1) : 0}%;background:var(--x)"></div></div>
<span class="mono" style="text-align:right">${p[2] != null ? p[2].toFixed(3) : 'β€”'}</span>
</div>`).join('')}</div>
<div class="note" style="--hue:var(--x);margin-top:14px">
<b>What this measures.</b> For every molecule, the ${k} nearest neighbours under each encoder, compared as a Jaccard overlap and averaged. 1.0 means the two encoders order chemical space identically; near 0 means they disagree about what "similar" means.
</div>`;
}
function refreshEncoderOptions() {
const sel = $('#pb-enc');
const keys = encoderKeys();
const prev = sel.value;
sel.innerHTML = keys.map(k => `<option value="${esc(k)}">${esc(S.emb[k].name)}</option>`).join('')
|| '<option value="">no embeddings yet</option>';
if (prev && keys.indexOf(prev) !== -1) sel.value = prev;
renderCompare();
}
function maskTokenFor(key) {
const tk = S.tokenizers[key];
if (tk && tk.specials) for (const c of ['<mask>', '[MASK]']) if (tk.specials.has(c)) return c;
return '<mask>';
}
/* Build the masked string each model actually expects. */
async function buildMaskedInput(smiles, repr, tokKey, idx) {
let text = smiles;
if (repr === 'braid') {
if (!S.adapters.braid) return { err: 'no BRAID converter β€” connect the backend on stage 02' };
S._lastBraidTokens = '';
try { await runAdapter('braid', smiles); } catch (e) { return { err: 'BRAID conversion failed: ' + (e.message || e) }; }
text = S._lastBraidTokens;
if (!text) return { err: 'backend returned no BRAID token stream' };
}
const mask = maskTokenFor(tokKey);
const tk = S.tokenizers[tokKey];
let toks;
if (repr === 'braid') toks = text.split(/\s+/).filter(Boolean);
else if (tk) { try { toks = tk.encode(text).filter(x => !x.special).map(x => x.t); } catch (e) { toks = null; } }
if (!toks || !toks.length) toks = text.match(ATOM_RX) || Array.from(text);
if (idx < 0 || idx >= toks.length) return { err: `index ${idx} is out of range β€” this molecule has ${toks.length} tokens (0–${toks.length - 1})` };
const original = toks[idx];
toks[idx] = mask;
return { text: repr === 'braid' ? toks.join(' ') : toks.join(''), original, n: toks.length, mask };
}
async function runMLM() {
const host = $('#mlm-out');
const raw = $('#mlm-in').value.trim();
const mode = $('#mlm-mode').value;
const idx = parseInt($('#mlm-pos').value, 10);
if (!S.hfToken && !SAME_ORIGIN) { host.innerHTML = `<div class="empty"><b>Needs a backend or a token</b><span>Run backend.py and open this page through it, or add a token from the header.</span></div>`; return; }
host.innerHTML = `<span class="spin"></span>`;
const out = [];
for (const spec of [['BRAIDBERTa', S.models.braid, 'braid', 'braid'], ['DeepBERTa', S.models.deep, 'smiles', 'deep']]) {
let text = raw, meta = null;
if (mode === 'auto') {
const built = await buildMaskedInput(raw, spec[2], spec[3], isNaN(idx) ? 0 : idx);
if (built.err) {
out.push(`<div class="strip-row"><div class="strip-head"><span class="strip-name">${esc(spec[0])}</span><span class="badge is-warn">skipped</span></div>
<div class="dim" style="font-size:12px">${esc(built.err)}</div></div>`);
continue;
}
text = built.text; meta = built;
}
try {
const j = await hfPost(spec[1], 'fill-mask', { inputs: text, options: { wait_for_model: true } });
const list = Array.isArray(j) ? (Array.isArray(j[0]) ? j[0] : j) : [];
const hit = meta ? list.findIndex(p => String(p.token_str || '').trim() === meta.original) : -1;
out.push(`<div class="strip-row"><div class="strip-head"><span class="strip-name">${esc(spec[0])}</span>
<span class="badge">reads ${spec[2] === 'braid' ? 'BRAID tokens' : 'SMILES'}</span>
${meta ? `<span class="badge">masked <span class="mono">${esc(meta.original)}</span> at ${idx} of ${meta.n}</span>` : ''}
${hit === 0 ? '<span class="badge is-ok">top-1 correct</span>' : hit > 0 ? `<span class="badge is-warn">correct at rank ${hit + 1}</span>` : meta ? '<span class="badge is-bad">not in top 8</span>' : ''}</div>
${meta ? `<div class="repr-val" style="margin:2px 0 8px;font-size:11px;opacity:.7">${esc(text)}</div>` : ''}
<div class="toks">${list.slice(0, 8).map(p => {
const t = p.token_str != null ? String(p.token_str).trim() : '?';
return `<span class="tok${meta && t === meta.original ? ' is-special' : ''}">${esc(t)}<span class="dim" style="margin-left:6px">${(p.score * 100).toFixed(1)}%</span></span>`;
}).join('')}</div></div>`);
} catch (e) {
out.push(`<div class="strip-row"><div class="strip-head"><span class="strip-name">${esc(spec[0])}</span><span class="badge is-bad">failed</span></div>
<div class="dim" style="font-size:12px">${esc(String(e.message || e))}</div></div>`);
}
}
const hints = S.tokOrder.filter(k => S.tokenizers[k].specials).map(k => Array.from(S.tokenizers[k].specials).join(' ')).filter(Boolean);
host.innerHTML = `<div class="strip">${out.join('')}</div>` +
(hints.length ? `<div class="eyebrow" style="margin-top:12px">special tokens seen in the fetched vocabularies Β· ${esc(hints.join(' | ').slice(0, 200))}</div>` : '');
}
function runProbe() {
const key = $('#pb-enc').value;
const host = $('#probe-results');
if (!key || !S.emb[key]) { toast('Embed a set on stage 06 first', '--o'); return; }
const alpha = Math.max(1e-6, parseFloat($('#pb-alpha').value) || 1);
const folds = Math.max(2, parseInt($('#pb-folds').value) || 5);
const e = S.emb[key];
const used = S.mols.filter(m => m.valid && m.label != null && e.vecs.has(m.id));
if (used.length < 10) {
host.innerHTML = `<div class="empty"><b>Not enough labels</b><span>Found ${used.length}. Add labels on stage 01 β€” ten is the minimum for cross-validation to mean anything.</span></div>`;
return;
}
const rows = used.map(m => e.vecs.get(m.id)), ys = used.map(m => m.label);
const res = ridgeCV(rows, ys, alpha, folds);
S.probe = { key, name: e.name, alpha, folds, res, ids: used.map(m => m.id) };
const W = 380, H = 260, pad = 34;
const lo = Math.min.apply(null, ys.concat(res.preds)), hi = Math.max.apply(null, ys.concat(res.preds));
const sc = v => pad + (hi - lo ? (v - lo) / (hi - lo) : 0.5) * (W - 2 * pad);
const sy = v => H - pad - (hi - lo ? (v - lo) / (hi - lo) : 0.5) * (H - 2 * pad);
host.innerHTML = `<div class="card-head"><h2 class="card-title">${esc(e.name)} Β· ridge probe</h2>
<span class="eyebrow">Ξ± ${alpha} Β· ${folds}-fold Β· ${res.n} labelled</span></div>
<div class="stat-row" style="margin-bottom:16px">
<div class="stat"><div class="stat-k">RΒ²</div><div class="stat-v">${res.r2.toFixed(3)}</div></div>
<div class="stat"><div class="stat-k">RMSE</div><div class="stat-v">${res.rmse.toFixed(3)}</div></div>
<div class="stat"><div class="stat-k">MAE</div><div class="stat-v">${res.mae.toFixed(3)}</div></div>
<div class="stat"><div class="stat-k">Pearson r</div><div class="stat-v">${res.r.toFixed(3)}</div></div>
</div>
<svg class="plot" viewBox="0 0 ${W} ${H}" style="max-width:420px">
<line x1="${sc(lo)}" y1="${sy(lo)}" x2="${sc(hi)}" y2="${sy(hi)}" stroke="#28324a" stroke-dasharray="3 3"/>
<line x1="${pad}" y1="${H - pad}" x2="${W - pad}" y2="${H - pad}" stroke="#28324a"/>
<line x1="${pad}" y1="${pad}" x2="${pad}" y2="${H - pad}" stroke="#28324a"/>
${ys.map((v, i) => `<circle cx="${sc(v).toFixed(1)}" cy="${sy(res.preds[i]).toFixed(1)}" r="3.5" fill="var(--p)" fill-opacity="0.75"><title>${esc(used[i].name || used[i].smiles)} Β· actual ${v.toFixed(2)} predicted ${res.preds[i].toFixed(2)}</title></circle>`).join('')}
<text x="${W / 2}" y="${H - 8}" fill="#5D6880" font-size="10" text-anchor="middle" font-family="IBM Plex Mono">actual</text>
<text x="10" y="${H / 2}" fill="#5D6880" font-size="10" text-anchor="middle" font-family="IBM Plex Mono" transform="rotate(-90 10 ${H / 2})">predicted</text>
</svg>
<div class="note" style="--hue:var(--p);margin-top:14px">
Out-of-fold predictions only β€” every point was predicted by a model that never saw it. Run this for each encoder in turn to compare them on equal footing.
</div>`;
renderRail();
}
/* ══════════════ 07 Β· EXPORT ══════════════ */
function download(name, text, mime) {
const blob = new Blob([text], { type: mime || 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = name;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 2000);
}
function exportColumns() {
const cols = [['id', 'ID'], ['name', 'Name'], ['smiles', 'SMILES'], ['canonical', 'Canonical'],
['inchi', 'InChI'], ['inchikey', 'InChIKey'], ['scaffold', 'Scaffold'], ['ecfp4', 'ECFP4 hex'],
['braid', 'BRAID'], ['braidTokens', 'BRAID tokens'], ['selfies', 'SELFIES'], ['label', 'Label']];
if (S.mols.some(m => m.desc)) DESC_COLS.forEach(c => cols.push(['desc:' + c[0][0], c[1]]));
if (S.mols.some(m => m.alerts)) cols.push(['alerts', 'Alerts']);
S.tokOrder.forEach(k => cols.push(['ntok:' + k, 'ntokens Β· ' + S.tokenizers[k].name]));
encoderKeys().forEach(k => cols.push(['emb:' + k, 'embedding Β· ' + S.emb[k].name]));
return cols;
}
function valueFor(m, key) {
if (key === 'id') return m.id;
if (key === 'name') return m.name || '';
if (key === 'smiles') return m.smiles;
if (key === 'canonical') return m.canonical || '';
if (key === 'label') return m.label != null ? m.label : '';
if (key === 'alerts') return (m.alerts || []).join('; ');
if (key === 'ecfp4') return m.repr.ecfp4 ? bitsToHex(m.repr.ecfp4) : '';
if (key.startsWith('desc:')) return m.desc ? (pick(m.desc, [key.slice(5)]) ?? '') : '';
if (key.startsWith('ntok:')) {
const t = S.tokenizers[key.slice(5)];
if (!t) return '';
const text = tokenizerInput(m, t);
if (text == null) return '';
try { return t.encode(text).length; } catch (e) { return ''; }
}
if (key.startsWith('emb:')) {
const e = S.emb[key.slice(4)];
const v = e && e.vecs.get(m.id);
return v ? '[' + Array.from(v).map(x => +x.toFixed(6)).join(',') + ']' : '';
}
return m.repr[key] || '';
}
function selectedCols() { return $$('#ex-cols input:checked').map(i => i.value); }
function exportRows() {
return S.mols.filter(m => !$('#ex-valid').checked || m.valid);
}
function buildExport(fmt) {
const cols = selectedCols(), rows = exportRows();
const labels = {}; exportColumns().forEach(c => labels[c[0]] = c[1]);
if (fmt === 'csv') {
const q = v => { const s = String(v ?? ''); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; };
return [cols.map(c => q(labels[c])).join(',')].concat(rows.map(m => cols.map(c => q(valueFor(m, c))).join(','))).join('\n');
}
if (fmt === 'jsonl') {
return rows.map(m => { const o = {}; cols.forEach(c => o[c.replace(/[:.]/g, '_')] = valueFor(m, c)); return JSON.stringify(o); }).join('\n');
}
if (fmt === 'smi') {
return rows.map(m => m.smiles + '\t' + (m.name || m.id)).join('\n');
}
if (fmt === 'sdf') {
return rows.map(m => {
const mb = m.repr.molblock || withMol(m.smiles, mol => { try { return mol.get_molblock(); } catch (e) { return null; } });
if (!mb) return '';
const props = cols.filter(c => c !== 'smiles').map(c => `> <${labels[c] || c}>\n${valueFor(m, c)}\n`).join('\n');
return mb.replace(/\n$/, '') + '\n' + props + '\n$$$$';
}).filter(Boolean).join('\n');
}
return '';
}
function renderExport() {
const cols = exportColumns();
const host = $('#ex-cols');
const chosen = new Set(selectedCols().length ? selectedCols() : ['id', 'name', 'smiles', 'canonical', 'label']);
host.innerHTML = cols.map(c => `<label class="check"><input type="checkbox" value="${esc(c[0])}" ${chosen.has(c[0]) ? 'checked' : ''}> ${esc(c[1])}</label>`).join('');
$$('input', host).forEach(i => i.onchange = paintPreview);
paintPreview();
}
function paintPreview() {
const fmt = $('#ex-fmt').value;
const text = buildExport(fmt);
const lines = text.split('\n');
$('#ex-preview').textContent = lines.slice(0, 14).join('\n') + (lines.length > 14 ? `\n… ${lines.length - 14} more lines` : '');
$('#ex-count').textContent = `${exportRows().length} rows Β· ${selectedCols().length} columns`;
}
function datasetCard() {
const rows = exportRows();
const toks = S.tokOrder.map(k => `- \`${S.tokenizers[k].name}\`${S.tokenizers[k].repo ? ` β€” ${S.tokenizers[k].repo}, ${S.tokenizers[k].kind}, vocab ${S.tokenizers[k].vocabSize}` : ' β€” built-in reference'}`).join('\n');
const embs = encoderKeys().map(k => `- ${S.emb[k].name} β€” ${S.emb[k].dim} dimensions, ${S.emb[k].vecs.size} vectors`).join('\n') || '- none computed';
return `# Dataset card
Generated by Conformer on ${new Date().toISOString()}.
## Contents
- ${rows.length} molecules${$('#ex-valid').checked ? ' (RDKit-valid only)' : ''}
- Columns: ${selectedCols().join(', ')}
## Provenance
Structures were parsed, canonicalised and described with RDKit (MinimalLib, WebAssembly) in the browser. No structure data left the machine except where encoder embeddings were requested.
### Models
- BRAIDBERTa β€” \`${S.models.braid}\`
- DeepBERTa β€” \`${S.models.deep}\`
### Tokenizers
${toks || '- none loaded'}
### Embeddings
${embs}
Pooling: ${$('#cmp-pool').value === 'cls' ? 'first token (CLS)' : 'mean over tokens'}. Input representation: ${$('#cmp-repr').value}.
### Probe
${S.probe ? `Ridge regression on ${S.probe.name}, Ξ±=${S.probe.alpha}, ${S.probe.folds}-fold CV over ${S.probe.res.n} labelled molecules. RΒ²=${S.probe.res.r2.toFixed(3)}, RMSE=${S.probe.res.rmse.toFixed(3)}, MAE=${S.probe.res.mae.toFixed(3)}.` : 'Not run.'}
## Caveats
- Descriptors are RDKit values, exact and reproducible.
- Structural alerts use a small demonstration SMARTS set, not a curated PAINS catalogue.
- BRAID via braids/codec.py (github.com/AayushK-othari/braid), ${S.braidAromatic ? 'aromatic' : 'Kekule'} mode. The two modes are not interchangeable.
- SELFIES via the reference selfies package.
`;
}
function serialiseAdapter(k) {
const a = S.adapters[k];
if (!a) return null;
if (a.mode === 'js') return { mode: 'js', code: a.code };
if (a.mode === 'http') return { mode: 'http', url: a.url };
return { mode: 'backend' };
}
function saveSession() {
const data = {
version: 1, saved: new Date().toISOString(),
models: S.models,
adapters: {
braid: serialiseAdapter('braid'), selfies: serialiseAdapter('selfies'),
},
backend: { base: S.backend.base }, braidAromatic: S.braidAromatic,
mols: S.mols.map(m => ({ name: m.name, input: m.input, smiles: m.smiles, label: m.label, repr: m.repr, desc: m.desc, alerts: m.alerts })),
};
download('conformer-session.json', JSON.stringify(data, null, 2), 'application/json');
toast('Session saved β€” the token is never written to this file', '--h');
}
function loadSession(text) {
let d;
try { d = JSON.parse(text); } catch (e) { toast('That file is not valid JSON', '--o'); return; }
if (!d || !Array.isArray(d.mols)) { toast('No molecules found in that session file', '--o'); return; }
S.mols = []; S.nextId = 1; S.emb = {}; S.probe = null;
d.mols.forEach(x => S.mols.push({
id: S.nextId++, name: x.name || '', input: x.input || x.smiles, smiles: x.smiles,
label: x.label ?? null, valid: null, error: '', useCanon: false,
repr: x.repr || {}, desc: x.desc || null, alerts: x.alerts || null, canonical: '',
}));
if (d.models) S.models = Object.assign(S.models, d.models);
if (d.backend && d.backend.base) { S.backend.base = d.backend.base; $('#ad-backend').value = d.backend.base; }
if (typeof d.braidAromatic === 'boolean') S.braidAromatic = d.braidAromatic;
['braid', 'selfies'].forEach(k => {
const a = d.adapters && d.adapters[k];
if (a && a.mode === 'js' && a.code) registerAdapter(k, 'js', a.code);
else if (a && a.mode === 'http') registerAdapter(k, 'http', null, a.url);
else if (a && a.mode === 'backend') connectBackend(S.backend.base, true);
});
if (S.rdkit) revalidateAll(); else renderAll();
toast(`Loaded ${S.mols.length} molecules from session`, '--h');
}
/* ── settings ────────────────────────────────────────────── */
function openSettings() {
sheet(`<div class="card-head"><h2 class="card-title">Encoder access</h2>
<div class="card-actions"><button class="btn is-sm is-ghost" id="x-set">Close</button></div></div>
<div class="field"><label class="field-label" for="set-token">Hugging Face token</label>
<input class="input is-mono" type="password" id="set-token" value="${esc(S.hfToken)}" placeholder="hf_…" autocomplete="off"></div>
<div class="note" style="--hue:var(--x);margin:12px 0">
<b>Where this goes.</b> The token is held in this tab's memory for as long as the page is open. It is never written to disk, never included in a saved session, and is sent only to Hugging Face as an Authorization header.
</div>
<div class="grid g-2">
<div class="field"><label class="field-label">BRAIDBERTa repo</label><input class="input is-mono" id="set-braid" value="${esc(S.models.braid)}"></div>
<div class="field"><label class="field-label">DeepBERTa repo</label><input class="input is-mono" id="set-deep" value="${esc(S.models.deep)}"></div>
</div>
<div style="height:12px"></div>
<div class="field"><label class="field-label">Inference endpoint</label><input class="input is-mono" id="set-base" value="${esc(S.hfBase)}"></div>
<div style="height:8px"></div>
<div class="eyebrow">a legacy endpoint is tried automatically if the first returns an error</div>
<div style="height:16px"></div>
<div class="btn-row"><button class="btn is-primary" id="set-save">Save</button>
<button class="btn" id="set-test">Test connection</button>
<span class="mono dim" id="set-out" style="font-size:11.5px"></span></div>`);
$('#x-set').onclick = closeSheet;
$('#set-save').onclick = () => {
S.hfToken = $('#set-token').value.trim();
S.models.braid = $('#set-braid').value.trim() || S.models.braid;
S.models.deep = $('#set-deep').value.trim() || S.models.deep;
S.hfBase = $('#set-base').value.trim() || S.hfBase;
paintStatus(); closeSheet();
toast(S.hfToken ? 'Token stored in memory for this tab' : 'Token cleared', '--x');
};
$('#set-test').onclick = async () => {
const out = $('#set-out');
S.hfToken = $('#set-token').value.trim();
out.innerHTML = '<span class="spin"></span>';
try {
const j = await hfPost($('#set-braid').value.trim(), 'feature-extraction', { inputs: ['CCO'], options: { wait_for_model: true } });
const d = depth(j);
out.innerHTML = `<span class="badge is-ok">reachable Β· ${d}D response</span>`;
} catch (e) { out.innerHTML = `<span class="badge is-bad">${esc(String(e.message || e).slice(0, 90))}</span>`; }
};
}
function openStatus() {
sheet(`<div class="card-head"><h2 class="card-title">Runtime</h2>
<div class="card-actions"><button class="btn is-sm is-ghost" id="x-st">Close</button></div></div>
<div class="tbl-wrap"><table><tbody>
<tr><td>RDKit</td><td>${S.rdkit ? `<span class="badge is-ok">${esc(S.rdkit.version ? S.rdkit.version() : 'ready')}</span>` : `<span class="badge is-bad">${esc(S.rdkitErr || 'loading')}</span>`}</td></tr>
<tr><td>Molecules</td><td class="mono">${S.mols.length} loaded Β· ${S.mols.filter(m => m.valid).length} valid</td></tr>
<tr><td>Tokenizers</td><td class="mono">${S.tokOrder.length}</td></tr>
<tr><td>Embeddings</td><td class="mono">${encoderKeys().length} space${encoderKeys().length === 1 ? '' : 's'}</td></tr>
<tr><td>Token</td><td>${S.hfToken ? '<span class="badge is-ok">in memory</span>' : '<span class="badge">not set</span>'}</td></tr>
</tbody></table></div>
<div class="note" style="--hue:var(--c);margin-top:14px">
Chemistry runs locally through WebAssembly. Encoder calls go from this page straight to Hugging Face. There is no server in between, and no telemetry.
</div>`);
$('#x-st').onclick = closeSheet;
}
/* ── aggregate render ────────────────────────────────────── */
function renderAll() {
renderRail(); renderMolTable(); renderLoadStats();
if (S.active === 'convert') renderConvert();
if (S.active === 'draw') renderDraw();
if (S.active === 'tokenize') renderTokSources();
if (S.active === 'export') renderExport();
if (S.active === 'compare') renderCompare();
if (S.active === 'predict') { renderDescTable(); renderAlertTable(); }
}
/* ══════════════ WIRING ══════════════ */
const AD_STUBS = {
braid: "// BRAID converter β€” runs locally in your browser, nothing is sent anywhere.\n// 'smiles' arrives as the canonical SMILES. Return the BRAID string.\n// Replace the line below with your grammar, or switch to an HTTP\n// endpoint above if the converter already lives in Python.\n\nreturn smiles;",
selfies: "// SELFIES converter.\n// The reference implementation is Python (pip install selfies).\n// Either port the encoder here, or expose it over HTTP and switch\n// the mode above to point at it.\n\nreturn smiles;",
};
function setAdapterTab(kind) {
S.adapterTab = kind;
$$('#adapter-tabs .tab').forEach(t => t.classList.toggle('is-active', t.dataset.atab === kind));
const a = S.adapters[kind];
$('#ad-mode').value = a ? a.mode : 'backend';
$('#ad-url').value = a && a.mode === 'http' ? a.url : '';
$('#ad-code').value = a && a.mode === 'js' ? a.code : AD_STUBS[kind];
paintAdapterMode();
$('#ad-out').innerHTML = a
? `<span class="badge is-ok">registered</span> ${esc(a.mode === 'backend' ? 'via backend' : a.mode)}`
: '';
}
function paintAdapterMode() {
const mode = $('#ad-mode').value;
$('#ad-url').disabled = mode !== 'http';
$('#ad-code').disabled = mode !== 'js';
const isBraid = S.adapterTab === 'braid';
$('#ad-arom-wrap').style.display = isBraid ? '' : 'none';
$('#ad-arom-note').style.display = isBraid ? '' : 'none';
$('#ad-aromatic').checked = S.braidAromatic;
}
function downloadAllSVG() {
if (!S.rdkit) { toast('Needs RDKit', '--o'); return; }
const size = +$('#dw-size').value, smarts = $('#dw-smarts').value.trim();
const valid = S.mols.filter(m => m.valid).slice(0, 240);
if (!valid.length) { toast('Nothing to export', '--o'); return; }
const cols = Math.ceil(Math.sqrt(valid.length)) || 1;
const w = size, h = Math.round(size * 0.78) + 18;
const rows = Math.ceil(valid.length / cols);
const parts = valid.map((m, i) => {
const svg = drawMol(m, size, smarts).svg.replace(/<\?xml[^>]*\?>/, '').replace(/<!DOCTYPE[^>]*>/, '');
const x = (i % cols) * w, y = Math.floor(i / cols) * h;
return `<g transform="translate(${x},${y})">${svg}<text x="4" y="${h - 5}" font-size="9" fill="#8C97AE" font-family="monospace">${esc((m.name || m.smiles).slice(0, 30))}</text></g>`;
});
download('conformer-structures.svg',
`<svg xmlns="http://www.w3.org/2000/svg" width="${cols * w}" height="${rows * h}" viewBox="0 0 ${cols * w} ${rows * h}"><rect width="100%" height="100%" fill="#0B0E14"/>${parts.join('')}</svg>`,
'image/svg+xml');
}
/* 01 */
$('#btn-add').onclick = () => {
const items = parseMoleculeText($('#ta-smiles').value);
if (!items.length) { toast('Nothing to parse β€” paste one SMILES per line', '--o'); return; }
addMolecules(items); $('#ta-smiles').value = '';
};
$('#drop').onclick = () => $('#file').click();
$('#drop').addEventListener('dragover', e => { e.preventDefault(); $('#drop').classList.add('is-over'); });
$('#drop').addEventListener('dragleave', () => $('#drop').classList.remove('is-over'));
$('#drop').addEventListener('drop', e => {
e.preventDefault(); $('#drop').classList.remove('is-over');
const f = e.dataTransfer.files[0]; if (f) readFile(f);
});
$('#file').onchange = e => { const f = e.target.files[0]; if (f) readFile(f); e.target.value = ''; };
function readFile(f) {
const r = new FileReader();
r.onload = () => {
const text = String(r.result);
const items = /\$\$\$\$/.test(text) || /V[23]000/.test(text) ? parseSDF(text) : parseMoleculeText(text);
if (!items.length) { toast('No molecules found in that file', '--o'); return; }
addMolecules(items);
};
r.onerror = () => toast('Could not read that file', '--o');
r.readAsText(f);
}
$$('[data-starter]').forEach(b => b.onclick = () => {
addMolecules(STARTERS[b.dataset.starter].map(r => ({ smiles: r[0], name: r[1], label: r[2] })));
});
$('#btn-clear').onclick = () => {
if (!S.mols.length) return;
S.mols = []; S.emb = {}; S.probe = null; S.bench = null; renderAll();
toast('Set cleared', '--c');
};
$('#btn-dedupe').onclick = () => {
const seen = new Set(); const before = S.mols.length;
S.mols = S.mols.filter(m => { const k = m.canonical || m.smiles; if (seen.has(k)) return false; seen.add(k); return true; });
renderAll();
toast(`Removed ${before - S.mols.length} duplicate${before - S.mols.length === 1 ? '' : 's'}`, '--c');
};
$('#q-mols').oninput = renderMolTable;
/* 02 */
$('#sel-conv').onchange = paintReprs;
$('#btn-convert-all').onclick = convertAll;
$$('#adapter-tabs .tab').forEach(t => t.onclick = () => setAdapterTab(t.dataset.atab));
$('#ad-mode').onchange = paintAdapterMode;
$('#btn-ad-connect').onclick = () => connectBackend($('#ad-backend').value.trim());
$('#ad-backend').onkeydown = e => { if (e.key === 'Enter') connectBackend($('#ad-backend').value.trim()); };
$('#ad-aromatic').onchange = () => {
S.braidAromatic = $('#ad-aromatic').checked;
S.mols.forEach(m => { delete m.repr.braid; });
paintReprs();
toast('BRAID mode: ' + (S.braidAromatic ? 'aromatic' : 'KekulΓ©') + ' β€” re-run Convert all', '--n', 5000);
};
$('#btn-decode').onclick = async () => {
const kind = $('#dec-kind').value, s = $('#dec-in').value.trim();
const out = $('#dec-out');
if (!s) return;
out.innerHTML = '<span class="spin"></span>';
try {
const smi = await runDecode(kind, s);
const svg = S.rdkit ? drawMol({ smiles: smi, valid: true }, 300, '').svg : '';
out.innerHTML = `<div class="repr"><div class="repr-head"><span class="repr-name">SMILES</span>
<span style="margin-left:auto"></span><button class="btn is-ghost is-sm" id="dec-copy">copy</button></div>
<div class="repr-val">${esc(smi)}</div></div>
${svg ? `<div class="mol-art" style="margin-top:12px">${svg}</div>` : ''}`;
const c = $('#dec-copy'); if (c) c.onclick = () => copyText(smi, 'SMILES');
S._decoded = smi;
} catch (e) {
out.innerHTML = `<div class="note" style="--hue:var(--o)">${esc(String(e.message || e))}</div>`;
S._decoded = null;
}
};
$('#btn-decode-add').onclick = async () => {
if (!S._decoded) { $('#btn-decode').click(); await new Promise(r => setTimeout(r, 400)); }
if (!S._decoded) { toast('Decode something first', '--o'); return; }
addMolecules([{ smiles: S._decoded, name: 'decoded', label: null }]);
};
$('#btn-ad-save').onclick = () => {
const r = registerAdapter(S.adapterTab, $('#ad-mode').value, $('#ad-code').value, $('#ad-url').value.trim());
if (!r.ok) { $('#ad-out').innerHTML = `<span class="badge is-bad">${esc(r.err)}</span>`; return; }
$('#ad-out').innerHTML = `<span class="badge is-ok">registered</span>`;
paintAdapterStatus(); paintReprs();
toast(S.adapterTab.toUpperCase() + ' converter registered', '--n');
};
$('#btn-ad-test').onclick = async () => {
const r = registerAdapter(S.adapterTab, $('#ad-mode').value, $('#ad-code').value, $('#ad-url').value.trim());
if (!r.ok) { $('#ad-out').innerHTML = `<span class="badge is-bad">${esc(r.err)}</span>`; return; }
$('#ad-out').innerHTML = '<span class="spin"></span>';
try {
const v = await runAdapter(S.adapterTab, 'CC(=O)Oc1ccccc1C(=O)O');
$('#ad-out').innerHTML = `<span class="badge is-ok">β†’</span> ${esc(String(v).slice(0, 80))}`;
} catch (e) { $('#ad-out').innerHTML = `<span class="badge is-bad">${esc(String(e.message || e).slice(0, 90))}</span>`; }
};
$('#btn-ad-clear').onclick = () => {
S.adapters[S.adapterTab] = null;
S.mols.forEach(m => { delete m.repr[S.adapterTab]; });
setAdapterTab(S.adapterTab); paintAdapterStatus(); paintReprs();
toast('Converter removed', '--c');
};
/* 03 */
$('#dw-size').oninput = () => { $('#dw-size-v').textContent = $('#dw-size').value; };
$('#dw-size').onchange = renderDraw;
$('#btn-draw').onclick = renderDraw;
$('#dw-smarts').onkeydown = e => { if (e.key === 'Enter') renderDraw(); };
['dw-idx', 'dw-stereo', 'dw-kek', 'dw-me'].forEach(id => { $('#' + id).onchange = renderDraw; });
$('#btn-svg-all').onclick = downloadAllSVG;
/* 04 */
$$('#pred-tabs .tab').forEach(t => t.onclick = () => {
$$('#pred-tabs .tab').forEach(x => x.classList.toggle('is-active', x === t));
$$('[data-ppane]').forEach(p => p.classList.toggle('is-active', p.dataset.ppane === t.dataset.ptab));
});
$('#btn-desc').onclick = computeDescriptors;
$('#btn-alerts').onclick = screenAlerts;
$('#btn-probe').onclick = runProbe;
/* 05 */
$('#btn-load-toks').onclick = loadTokenizers;
$('#btn-tok-add').onclick = async () => {
const repo = $('#tok-extra').value.trim();
if (!repo) return;
try { await fetchTokenizer(repo, 'x' + Date.now(), repo.split('/').pop()); $('#tok-extra').value = ''; renderTokSources(); renderRail(); toast('Added ' + repo, '--s'); }
catch (e) { toast(esc(String(e.message || e)), '--o', 6000); }
};
$('#btn-tok-upload').onclick = () => $('#tok-file').click();
$('#tok-file').onchange = e => {
const f = e.target.files[0]; e.target.value = '';
if (!f) return;
const r = new FileReader();
r.onload = () => {
const raw = String(r.result);
let tj = null, vt = null;
try { tj = JSON.parse(raw); } catch (err) { vt = raw; }
try {
const key = 'file' + Date.now();
const tk = buildTokenizer(f.name.replace(/\.(json|txt)$/, ''), tj, vt);
tk.source = 'uploaded ' + f.name;
if (tk.braidish < 3 && /braid/i.test(f.name)) tk.inputRepr = 'braid';
S.tokenizers[key] = tk; S.tokOrder.push(key);
S.bench = null; renderTokSources(); renderRail();
toast(`Loaded ${tk.kind} tokenizer Β· ${tk.vocabSize} tokens`, '--s');
} catch (err) { toast('Could not read that as a tokenizer: ' + String(err.message || err), '--o', 6000); }
};
r.readAsText(f);
};
$('#btn-tok-diag').onclick = async () => {
const repos = [[S.models.braid, 'BRAIDBERTa'], [S.models.deep, 'DeepBERTa']];
const extra = $('#tok-extra').value.trim();
if (extra) repos.push([extra, extra]);
sheet(`<div class="card-head"><h2 class="card-title">Tokenizer diagnosis</h2>
<div class="card-actions"><button class="btn is-sm is-ghost" id="x-diag">Close</button></div></div>
<div id="diag-body"><span class="spin"></span></div>`);
$('#x-diag').onclick = closeSheet;
const rows = [];
for (const [repo, label] of repos) {
let line = `<tr><td><span class="mono">${esc(repo)}</span></td>`;
if (S.backend.ok) {
try {
const r = await fetch(backendURL('/repo-check/' + repo));
const j = await r.json();
line += j.exists
? `<td><span class="badge is-ok">exists</span>${j.private ? ' <span class="badge is-warn">private</span>' : ''}</td>
<td class="dim mono" style="font-size:10.5px">${esc((j.files || []).filter(f => /tokenizer|vocab|merges/i.test(f)).join(', ') || 'no tokenizer files listed')}</td>`
: `<td><span class="badge is-bad">not visible</span></td><td class="dim" style="font-size:11px">${esc(String(j.error).slice(0, 160))}</td>`;
} catch (e) { line += `<td><span class="badge is-bad">check failed</span></td><td class="dim">${esc(String(e.message || e))}</td>`; }
} else {
line += `<td><span class="badge is-warn">backend off</span></td><td class="dim" style="font-size:11px">connect on stage 02 for a definitive answer</td>`;
}
rows.push(line + '</tr>');
}
$('#diag-body').innerHTML = `<div class="tbl-wrap"><table>
<thead><tr><th>Repo</th><th>Status</th><th>Tokenizer files present</th></tr></thead>
<tbody>${rows.join('')}</tbody></table></div>
<div class="note" style="--hue:var(--s);margin-top:14px">
<b>If a repo is not visible</b>, the name is wrong or it is private. Fix the name in the header
settings, or set <span class="mono">HF_TOKEN</span> as an environment variable before starting
backend.py so the server can read it on your behalf.
<div style="margin-top:8px"><b>Offline route:</b> your braid repository ships the trained tokenizer at
<span class="mono">braid_tok/tokenizer.json</span>. <span class="mono">Load tokenizer.json…</span>
takes it directly, no Hub access needed.</div>
</div>`;
};
$('#sel-tok').onchange = renderTokStrips;
$('#btn-bench').onclick = runBenchmark;
/* 06 */
$('#btn-embed').onclick = embedSet;
$('#cmp-k').onchange = renderKNN;
$('#btn-mlm').onclick = runMLM;
/* 07 */
$('#ex-fmt').onchange = paintPreview;
$('#ex-valid').onchange = paintPreview;
$('#btn-export').onclick = () => {
const fmt = $('#ex-fmt').value;
const name = ($('#ex-name').value.trim() || 'conformer-export') + '.' + fmt;
const mime = fmt === 'csv' ? 'text/csv' : fmt === 'jsonl' ? 'application/x-ndjson' : 'chemical/x-mdl-sdfile';
download(name, buildExport(fmt), mime);
toast('Exported ' + exportRows().length + ' rows', '--h');
};
$('#btn-card').onclick = () => download('README.md', datasetCard(), 'text/markdown');
$('#btn-session').onclick = saveSession;
$('#btn-session-load').onclick = () => $('#file-session').click();
$('#file-session').onchange = e => {
const f = e.target.files[0]; if (!f) return;
const r = new FileReader(); r.onload = () => loadSession(String(r.result)); r.readAsText(f);
e.target.value = '';
};
/* header */
$('#btn-hf').onclick = openSettings;
$('#btn-status').onclick = openStatus;
/* ══════════════ BOOT ══════════════ */
(async function boot() {
setAdapterTab('braid');
baselineTokenizers();
$('#ad-backend').value = S.backend.base;
connectBackend(S.backend.base, true); // silent probe; no toast if absent
go('load');
renderAll();
await initRDKit();
if (!S.mols.length) {
addMolecules(STARTERS.drugs.slice(0, 8).map(r => ({ smiles: r[0], name: r[1], label: r[2] })));
}
if (!S.rdkit) {
toast('RDKit could not load from the CDN. Drawing and descriptors are off; tokenisation still works.', '--o', 9000);
}
})();
</script>
</body>
</html>