| <!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> |
| |
| |
| |
| |
| |
| |
| :root{ |
| --ink:#0B0E14; |
| --ink-2:#0F1421; |
| --slate:#141B29; |
| --graphite:#1B2333; |
| --edge:#28324a; |
| --edge-soft:#1f2839; |
| |
| --bone:#E9ECF3; |
| --ash:#8C97AE; |
| --ash-dim:#5D6880; |
| |
| --c:#AAB3C5; |
| --n:#5B7CFF; |
| --o:#FF6B6B; |
| --p:#FF9F45; |
| --s:#F2C94C; |
| --x:#6EE7A0; |
| --h:#FFFFFF; |
| |
| --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} |
| |
| |
| *::-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} |
| |
| |
| |
| |
| .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} |
| |
| |
| |
| |
| |
| |
| |
| .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{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} |
| |
| |
| |
| |
| .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} |
| |
| |
| |
| |
| .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} |
| |
| |
| .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} |
| |
| |
| .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} |
| |
| |
| |
| |
| @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"> |
|
|
| |
| <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 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> |
|
|
| |
| <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"> </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> |
|
|
| |
| <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> |
|
|
| |
| <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"> </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> |
|
|
| |
| <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> |
|
|
| |
| <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"> </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> |
|
|
| |
| <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"> </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"; |
| |
| |
| |
| |
| |
| |
| const $ = (s, r = document) => r.querySelector(s); |
| const $$ = (s, r = document) => Array.from(r.querySelectorAll(s)); |
| const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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' }, |
| ]; |
| |
| |
| |
| const SAME_ORIGIN = (location.protocol === 'http:' || location.protocol === 'https:') ? location.origin : ''; |
| |
| |
| |
| |
| |
| const DEPLOYED_BACKEND = 'https://braid-9wc2.onrender.com'; |
| |
| const S = { |
| mols: [], nextId: 1, |
| rdkit: null, rdkitErr: '', |
| hfToken: '', |
| 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, |
| tokenizers: {}, tokOrder: [], |
| bench: null, |
| emb: {}, |
| probe: null, |
| active: 'load', |
| adapterTab: 'braid', |
| }; |
| |
| |
| |
| 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], |
| }; |
| |
| |
| 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(); }); |
| |
| |
| 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'; |
| } |
| |
| |
| 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(); |
| } |
| |
| |
| 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(); |
| } |
| |
| |
| 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) { } |
| 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'); |
| } |
| |
| |
| 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], |
| ], |
| }; |
| |
| |
| 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(''); |
| } |
| |
| |
| 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; |
| |
| |
| |
| |
| 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; } |
| 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 PowerShell Β· $env:ALLOWED_ORIGINS="*"; python backend_CONFORMER.py<br> |
| macOS / Linux Β· 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 || ''; |
| |
| 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 ?? ''); |
| } |
| |
| 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'; |
| } |
| |
| |
| 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) { } |
| 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'); |
| }); |
| } |
| |
| |
| 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>`; |
| } |
| |
| |
| |
| |
| 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]; |
| 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; |
| } |
| |
| |
| |
| 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 = []; |
| |
| |
| |
| 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'); |
| |
| |
| 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 : ''); |
| |
| |
| 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' }; |
| |
| 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'; |
| } |
| |
| |
| 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(); |
| } |
| |
| |
| 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; |
| |
| |
| |
| |
| 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'); |
| } |
| 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) { |
| |
| |
| 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(); |
| } |
| } |
| |
| |
| 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 }; |
| } |
| |
| |
| 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>'; |
| } |
| |
| 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(); |
| } |
| |
| |
| 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'); |
| } |
| |
| |
| 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; |
| } |
| |
| |
| 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(); } |
| } |
| |
| |
| 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'); |
| } |
| |
| |
| $('#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; |
| |
| |
| $('#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'); |
| }; |
| |
| |
| $('#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; |
| |
| |
| $$('#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; |
| |
| |
| $('#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; |
| |
| |
| $('#btn-embed').onclick = embedSet; |
| $('#cmp-k').onchange = renderKNN; |
| $('#btn-mlm').onclick = runMLM; |
| |
| |
| $('#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 = ''; |
| }; |
| |
| |
| $('#btn-hf').onclick = openSettings; |
| $('#btn-status').onclick = openStatus; |
| |
| |
| (async function boot() { |
| setAdapterTab('braid'); |
| baselineTokenizers(); |
| $('#ad-backend').value = S.backend.base; |
| connectBackend(S.backend.base, true); |
| 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> |
|
|