Skip to content

Technical documentationInternal

For the team and anyone taking the product over. Not linked from the site and not indexed.

Technical documentation pages

Molecule

Clearing the query and current molecule now clears any open saved-work reference as well. The URL therefore keeps the empty discovery start across a reload, including when the cleared molecule had been opened from saved work. The pending-work guard runs before that explicit clear.

Molecule (registry slug molecule-workbench, addresses /molecule and /molecule/app) takes one small molecule as a drawing, a SMILES string or a name, and computes descriptors, Lipinski's and Veber's rules, QED, PAINS alerts, one 3D conformer, the ten most similar of 2,197 approved drugs and the molecule's place on a PCA map of those drugs, all in the browser with RDKit.js, OpenChemLib and 3Dmol.js. Since Wave 4 (the discovery line, 11 October 2026) similarity runs under one versioned fingerprint policy, morgan-r2-2048-described-v1, with every same-compound library row left out of the neighbours, and a person's own compound series can be described in a table (D3). This page describes how the code does that, where each part lives, how it is checked and where it is weak.

Purpose and users

The app answers one question for one structure: how does this molecule compare with approved oral small-molecule drugs on the standard medicinal-chemistry heuristics, and which approved drugs does it resemble? The intended users, from docs/design/prd-molecule-workbench.md §1.2 (design intent), are medicinal and computational chemists, students and people moving between Drybench apps (a drug row in Protein, a product in Pipeline, a peptide in Sequence, a compound on an Assay plate).

What the code delivers today:

  • One molecule at a time, entered by name, ChEMBL id, SMILES or the OpenChemLib structure editor.
  • Descriptors, rule checks, QED with its eight inputs, PAINS alerts, a 2D depiction in both themes, InChI and InChIKey.
  • Ten nearest approved drugs by Morgan/Tanimoto under the fingerprint policy, with the library rows that are the same compound (a salt, a parent, a redrawing) left out and listed apart; the molecule's projection on a fixed PCA map of the library, and the ten nearest drugs on that map.
  • One seeded 3D conformer, MMFF94s+ minimised.
  • A side-by-side comparison of the molecule with up to four library drugs (the Bench layout), and a third Bench section, "Series": a pasted or loaded table of up to 200 compounds with the person's own values, each row's identities, descriptors and three nearest approved drugs.
  • Exports (CSV bundles, SVG, PNG, SDF) with a provenance block and, on every similarity row, the RDKit, descriptor and fingerprint-policy versions; shared objects, Send to, Add to Study and saved research views.
  • The walk "From a compound to its nearest approved drugs" (six steps, all in Molecule; reachable only by its link).

What it does not do, although the PRD and build specification describe it: project files, user-facing substructure or similarity-threshold search, maximum-common-substructure alignment, a PubChem consent step, XLSX or PDF reports. The series table covers part of the PRD's batch import (A2) but not its molecule-table actions. See Known gaps, defects and divergences.

The registry marks the app status: "preview", version: "0.2.0", updated: "2026-10-08" (content/apps/molecule-workbench.ts); Wave 4 did not change these. Nothing in it predicts activity, absorption or safety; the copy says so, and notMedicalAdvice: true is set. The Wave 4 build record is docs/progress/wave4-discovery.md (steps 0, 1, 5 and 6); the rulings are the CSO's pre-rulings of 10 October 2026 (docs/board/memos/2026-10-10-cso-wave4-prerulings.md) and the CSO's, CPO's and CTO's merge reviews of 11 October 2026.

Addresses and entry points

Address What it serves Code
/molecule Product page (copy from content/apps/molecule-workbench.ts page and entry) src/app/(marketing)/[app]/page.tsx
/molecule/app The workspace src/app/(workspace)/[app]/app/page.tsx, which loads src/apps/molecule-workbench/entry.tsx through src/apps/definitions.ts
/molecule/app/about The shell's About page; the app adds no pages of its own (PAGE_IDS is empty in src/apps/molecule-workbench/page-ids.ts) src/app/(workspace)/[app]/app/[page]/page.tsx
/docs/molecule Public methods and verification page content/apps/molecule-workbench.docs.ts
/changelog#molecule-workbench Changelog entries changelogHref() in content/apps/routes.ts
/apps/molecule-workbench, /apps/molecule-workbench/app, /tools/molecule-workbench Permanent redirects to /molecule and /molecule/app legacyRedirects() in content/redirects.ts; legacyPath in content/apps/identity.ts

The URL segment molecule comes from APP_SEGMENTS in content/apps/routes.ts; the product name ("Molecule", long form "Drybench Molecule") from content/app-names.ts. See Registry and content.

Ways a molecule arrives in the workspace, in the order initialState() in src/components/tools/molecule-workbench/use-workbench.tsx checks them:

  1. A legacy ?smiles= or ?name= query is read, checked and rewritten to the #s= fragment with history.replaceState before any request is made.
  2. A SMILES over 2,000 characters in the link is refused and reported, not loaded.
  3. A smiles in the fragment opens that structure. If the history entry already records the molecule (a reload, Back or Forward), its saved origin is restored.
  4. A name without a SMILES is looked up as a typed name once the library has loaded.
  5. No molecule: the shared discovery start (D-0044), with seven example chips. Imatinib is the first and opens from precomputed results without starting RDKit. The registry's example link (example.params, name=imatinib, used by the product page's "Try with example data", its live preview and the poster capture) is a name lookup, so it starts RDKit; the product page's how-it-works line says only the start page's chip is precomputed (E6, 24 October 2026; the CMO's question in docs/board/memos/2026-10-24-cmo-molecule-posters.md).

Other entries: an obj= parameter from Send to (read by the shell as arrivedObject), a mine= parameter naming a saved research view, a compound's object page (the Molecule tab, src/apps/molecule-workbench/object-tab.tsx), and the product page's live preview, which runs the workspace with preview set and never reads or writes the address.

File map

App definition and views (src/apps/molecule-workbench/)

File What it does
entry.tsx Client entry; hands definition and the full registry entry (content/apps/molecule-workbench.entry.ts) to the shell. displayName drybench-entry:molecule-workbench is the marker scripts/size-check.mjs finds the chunk by
definition.ts AppDefinition: Main, object tabs, inspector, engine status chip, keyboard shortcuts (v, d, 3, m), palette commands (two views, "Use my series" molecule.series, seven examples), / search focus, share link (available with a molecule or a series; a series is shared as its file, fileKind "series", onShareFile, notInLink) and source list
workspace.tsx MoleculeMain: the workspace loaded with next/dynamic (ssr: false) inside ToolBoundary, with a skeleton
bench-store.ts Module-level store (publishBench, readBench, useBench) through which the shell-rendered parts (inspector, engine chip, keys, commands) read and drive the workspace; it also carries the series state for the share dialog
series-store.ts The series on screen (SeriesState: its record, the rows it reads, progress, savedRef), written by the series section and read by the workspace's share, export and reopen; and an assay's results table arriving for review (SeriesArrival, writeSeriesArrival, useSeriesArrival, H22)
views/workspace.tsx MoleculeWorkspace: discovery start (with "Use my series") or loaded header, one tab row (the Scorecard's five tabs, then Comparison and Series, the CEO, 14 October 2026; the layout follows the tab), exports, exports, rail, work capture controls, guided-walk actions and targets. Its displayName drybench-island:molecule-workspace is the workspace chunk's size marker
views/same-compound.tsx LeftOutLine (the library forms left out of the neighbours, each a ChEMBL link, never truncated), SameFormFlag ("Same compound as {name}, another form.") and DescribedNote; used in the Similar tab, Compare's closest-drugs aside and the series row detail
views/bench/series.tsx, series-loader.ts The Series section (its own chunk, drybench-island:molecule-series, imported only through the loader): paste or file, column mapping review, describing, the table, row detail and saving; the JSON download of the original record shows for a withheld series only (E3)
views/bench/series-example.ts, series-example-loader.ts The walk's example series, out of the series chunk (drybench-island:molecule-example-series): loadExampleSeries writes the walk's note before describing and puts the series on screen with pendingNote; completeExampleNote writes the described record into that note once (only while it still holds the same table and mapping without results) and only then marks the series saved (CSO E3 point 8, the CPO's choice (a), 24 October 2026)
series-objects.ts One series row as a compound (E4): seriesRowInput (the whole input as entered, inchikey:<whole key>, the user's name or ID as label only, no aliases or facts; null for a malformed or unread row or an empty whole key), seriesRowRecord/seriesRowPayload (the CompoundPayload: SMILES as entered, the whole formula and weight, the described fragment as parent, source: "series"), rowsSharingRecord. Values are the retained row's; nothing is recalculated. The hand-off does not carry the series note, row number or recorded versions: the compound payload has no field for them and its readers are unchanged (CTO E4)
views/bench/series-row-detail.tsx, series-row-detail-loader.ts The row detail (E4), loaded on a row click
views/bench/series-repeat.ts, series-repeat-loader.ts A deliberate repeat (E5, drybench-island:molecule-repeat-series, loaded on intent): repeatDraft opens the review prefilled from the record (its text and columns; the label {label}, described {date, time}, an earlier suffix replaced and the whole within 80 characters; and "The saved series “{label}” is unchanged. This new series is not saved yet.") with a repeatOf.finish that, on describing, returns the source (marked derived: true when repeatEdited finds the table or columns changed) and that line; downloadOriginal (a withheld series' original record). An edited example's line, seriesCopy.exampleEdited ("Adapted from an example: …"), renders with the series chunk, so a reopened record's licence notice needs no load (the CTO's E5 size ruling, docs/board/memos/2026-10-24-cto-molecule-e5-size-miss.md). Typing into the table and a re-read keep the repeat (the CDO's built review, condition 1)
views/bench/series-review.tsx, series-review-loader.ts The review form, "Check the columns" (drybench-island:molecule-review-form), out of the series chunk on the CTO's ruling of 24 October 2026 (MNT-211): it shows only after a table is read, so it loads on intent and is preloaded on the first typing, a file drop or an Assay arrival. While it loads, a silent pending InlineStatus, "Loading the column check" (content/apps/molecule-workbench.series-review.ts, kept out of the series copy the CSV writer reads), with no reserved box; a failed load shows the shared retryingControl ("These controls could not be loaded. Try again."). The draft lives in the series section, so a failed or slow load never clears the pasted table (the CDO's and CPO's memos 2026-10-24-cdo-molecule-series-review-loading.md, 2026-10-24-cpo-molecule-series-review-loading.md)
series-export.ts, series-export-loader.ts seriesCsv, the CSV projection of the retained series (drybench-export:molecule-series-csv)
series-evidence.ts, series-evidence-loader.ts The series ZIP's retained record (E3, drybench-export:molecule-series-evidence): seriesUnchanged (the refusal when the series changed between asking and delivery), seriesProvenance (the series as subject, the record's versions, methods under the record's RDKit and fingerprint policy, every library receipt or mixed: …), seriesReadmeLines, seriesRowProvenance (the five per-row provenance columns from each row's own origin: an unchanged example's ChEMBL release and retrieval date, otherwise RDKit; the shared writer's columns are switched off for the series, SCI-280), seriesRecordJson and withSeriesRecord (the ZIP again with <base>.series-record.json). Never calls the worker or reads the installed library
views/use-molecule-workbench.tsx Wraps useWorkbench with the host's URL, subject, live region and early library load; readQuery, runQuery, suggestions; useMoleculeObject (learning the compound as a shared object)
views/query-bar.tsx The single combobox (name, ChEMBL id or SMILES), Analyse, Draw, the editor drawer, bundled catalogue suggestions
views/scorecard/view.tsx, details.tsx, scorecard.tsx, profile.tsx, rule-rows.tsx, molecule-description.tsx Scorecard layout: Overview (the structure panel with the canonical SMILES, the current molecule's record card, the QED histogram panel, the quick readout strip, the screening panel with the rule rows, the attributed description panel), Properties (stat cards, the rule evaluation and interpretation panels, profile strips and tables), Similar drugs grid, map and 3D tabs. rule-rows.tsx builds the rule rows (each rule's sentence from rules.ts and a status pill) and the rule-of-5 stat card for both tabs
comparison-model.ts, views/bench/use-comparison.ts, views/bench/use-analyses.ts E2's one comparison snapshot: useComparison (owned by views/workspace.tsx, handed to the table and the comparison export) analyses each pick once per worker client and projects every column through projectColumn (status ready/pending/failed/unavailable with reason codes, the domain gate, QED and Rule of 5 from the column's own result, whole/described identity); useAnalyses draws the inspector's selected drug
views/bench/view.tsx, space-stage.tsx, space-map.tsx, compare.tsx, drug-inspector.tsx, store.ts Bench layout: an SVG chemical-space map with a ranked list, the comparison table (query plus up to four drugs), the picked drug's inspector, the comparison store (MAX_PICKS = 4, cleared on an account scope change), and the Series section's place (view.tsx)
panels/map-panel.tsx The Scorecard's canvas map with pan, zoom, route-group toggles, hit testing and the nearest-drug list
panels/shape-panel.tsx The 3D shape panel: heading, frame and the on-demand ShapeViewer (next/dynamic, ssr: false), which shows ToolLoading at the stage's height (stageHeights, "Loading the 3D viewer") while its code downloads and retryingControl if the download fails (CDO memo of 24 October 2026)
panels/shape-viewer.tsx, shape-viewer-loader.ts The 3D viewer (its own chunk, drybench-island:molecule-shape-viewer, imported only through the loader): conformer client, 3Dmol.js loading and the shared viewer, moved unchanged from shape-panel.tsx under the CTO's size contract of 24 October 2026
panels/properties-panel.tsx Descriptor table and QED input/desirability table
panels/scope-notes.tsx Notes on salts, unspecified stereocentres and non-organic structures
panels/route-swatch.tsx Route-group colour swatches
inspector.tsx The molecule's record: origin, formula, canonical SMILES, InChI, InChIKey, library match, engine version
engine-chip.tsx Status-bar chip showing the RDKit worker's state, with a retry on failure
parts.tsx Depiction (SVG as a data: image, so nothing in it runs as markup; on its own plate, or with bare straight on the Overview's graph-paper stage without RDKit's ground rectangle, D-0050), copy button, verdict glyphs and pills (VerdictPill: a pass on the accent tint, a failure or warning on the status tints, "not applied" quiet), the record card's rows (RecordRow), reveal animations
science.ts Pure helpers: profile rows and strip scales, the screening verdict, QED histogram and percentile
objects.ts Inbound hand-off rules (objectInbound), compound records (moleculeInputs, libraryDrugInput) and the Send to payload (moleculePayload)
object-tab.tsx, object-tabs.ts The Molecule tab on a compound's object page
workspace-inputs.ts Saved research view schema (MOLECULE_WORK_SCHEMA, readMoleculeWorkspace, restoreMoleculeComparisons)
views/use-saved-work.ts Opens and saves research views (mine=)
description-identity.ts Decides which public identifier, if any, may fetch a description
exports.ts The Export menu items
provenance.ts Provenance chips for the library, computed values and the molecule's origin
palettes.ts Re-exports ROUTE_PALETTE and DEPICTION_PALETTE for the contrast gate in src/components/suite/tokens.test.ts
views/library-actions.tsx Pin, Send to and Add to Study on each library drug shown
views/not-found.tsx The "PubChem has no compound called ..." message, with a link to Protein
views/mark-poster.tsx MarkPoster; not imported anywhere (dead code)

Domain code (src/components/tools/molecule-workbench/)

File What it does
chem.ts RDKit.js wrappers shared by the worker, the pipeline and the tests: descriptorsOf, formulaFromJson, parseSmiles, checkSize, prepareInput (parent selection), inchiKeyOf, drawSvg (CoordGen choice), compilePains, painsHits, analyse, morganBytes
engine.ts What the worker computes: prepareLibrary, analyseSmiles, depictSmiles, neighboursOf, describeSeriesRow (with the shared rankAgainstLibrary), referencedRows
versions.ts The three version constants, written once: FINGERPRINT_POLICY (morgan-r2-2048-described-v1), DESCRIPTOR_VERSION (2.0.0-num-hbd-hba), RDKIT_VERSION (2026.03.6, the vendored 2026.3.6 with the month zero-padded). The worker, the pipeline, the series and the exports read them here; the methods copy re-exports them
library-checks.ts libraryCompatibility (refuses a library whose descriptorVersion, fingerprintPolicy or rdkit is missing or differs) and sameCompoundIndex (the shipped sameCompound pairs as a map); kept out of library.ts, which the definition chunk carries
series.ts Pure series logic: readSeriesTable, guessMapping, parseSeriesValue, seriesRows (200-row cap), activeValues, pIC50, sameCompoundRows, bitsTanimoto, the saved SeriesRecord (seriesRecord, resultsCurrent, readSeriesRecord)
qed.ts TypeScript port of RDKit's QED.py (Release_2026_03_6) with its Novartis notice
pains-data.ts 480 PAINS SMARTS (families A 16, B 55, C 409), generated by pipeline/molecule-workbench/rdkit_reference.py pains
rules.ts lipinski() and veber()
domain.ts Organic element set, counter-ion metals, domainOf, hillOrder
limits.ts Input limits and a heavy-atom count from SMILES text
similarity.ts Popcount, Tanimoto and top-k search on packed bits
map-model.ts PCA fit (Jacobi eigen-decomposition), projection, axis descriptions, nearest in plane
map-view.ts Map viewport maths, route colours, hit testing, edge clamping
library.ts Library file shape, DrugRow, route groups, display names, titleFor, inboundLibraryChoice
lookup.ts PubChem name lookup request and response reading; nameKey
address.ts The URL schema (moleculeSchema), readAddress, view and section names
use-workbench.tsx The state hook: engine, library, precomputed example, name lookups, SMILES, drawing, address and history, derived view states
clients.ts ConformerClient; re-exports the shared RDKit client
conformer.worker.ts OpenChemLib conformer generation and MMFF94s+ minimisation
three-dmol.ts One shared 3Dmol.js viewer per visit
structure-editor.tsx OpenChemLib CanvasEditor wrapper
depiction-palette.ts RDKit drawing options per theme
examples.ts Seven example chips; DEFAULT_EXAMPLE (Imatinib) and DEFAULT_RESULT_URL
protocol.ts AnalysisResult, NeighbourResult (with forms?: SameCompoundForm[]), SeriesRowResult, DefaultExampleFile, conformer messages (a conformer now carries OpenChemLib's runtime version)
states.ts View state types
ui.tsx Small shared UI pieces, Formula (subscripted, Hill order)
fixtures/rdkit-reference.json Python RDKit reference values for 30 molecules

Shared and server code the app uses

File Role
src/components/suite/engines/rdkit/rdkit.worker.ts, client.ts, protocol.ts The shared RDKit Web Worker, its main-thread client and message protocol (also used by Assay)
src/lib/vendor.ts URLs of the vendored RDKit.js and 3Dmol.js builds
src/lib/search/compounds.ts, catalog.ts, catalog-client.ts, use-catalogs.ts The clinical compound catalogue (ChEMBL names, aliases, phase, molecule type) used for suggestions and name canonicalisation
src/lib/search/curated-structures.ts One curated structure: retatrutide from NIH GSRS (UNII NOP2Y096GV)
src/lib/search/pubchem.ts, use-pubchem-suggestions.ts Autocomplete parser and the retained idle hook; typing no longer starts a PubChem request (SEC-02)
src/lib/search/molecule-description.ts Description identity checks, upstream URLs and response readers
src/app/api/search/compounds/route.ts PubChem autocomplete proxy
src/app/api/search/molecule/route.ts ChEMBL structure fallback for a catalogued name
src/app/api/search/molecule-description/route.ts ChEMBL mechanisms or PubChem description for one identifier
src/lib/units/formula-mass.ts CIAAW 2021 formula weights for the salt line
src/lib/seq/peptide.ts peptideSmilesForLink for peptides arriving from Sequence

src/app/api/search/compound/route.ts (singular) is not called by Molecule.

Content, pipeline and data

Path Role
content/apps/molecule-workbench.ts Registry entry and product page copy: data policy, endpoints, sources and licence facts, excluded sources, objects
content/tools/molecule-workbench.ts The methods write-up, sources and limits (detail), used by the registry entry and the docs page
content/apps/molecule-workbench.workspace.ts Workspace copy carried by the definition chunk (the Wave 4 similarity and series copy is kept out of it)
content/apps/molecule-workbench.methods.ts Re-exports RDKIT_VERSION, DESCRIPTOR_VERSION and FINGERPRINT_POLICY from versions.ts; METHODS (method references and verification states); verifiedNote; METHOD_VERSION (1.0.0), the methods' own semver version, bumped when a method's definition or results change. A method's version is never the RDKit release, which each record names in its implementation and provenance names in sources (the CTO's ruling 11 of 20 October 2026; molecule-workbench.methods.test.ts). Results saved before carry 2026.03.6 as the method version (MNT-27)
content/apps/molecule-workbench.similarity.ts similarityCopy: the described-structure sentence (A), the left-out line, the same-form flag (B), the empty-result and incompatible-library sentences, the export column names (relation, rdkit_version, descriptor_version, fingerprint_policy), the Compare note and "Largest fragment" pill, the README line on forms. Imported by the Similar, Compare and series chunks, never by molecule-workbench.workspace.ts
content/apps/molecule-workbench.series.ts, molecule-workbench.series-link.ts The series panel's copy (with its chunk), and the few series words the workspace itself shows (section name, "Use my series", the share dialog's line, the export item)
content/apps/molecule-workbench.docs.ts The /docs/molecule page
pipeline/molecule-workbench/ ChEMBL fetch, library build, policy recompute, map rebuild, precomputed example, OpenChemLib tables, Python RDKit reference, the fingerprint-policy and series oracles, whole-library check
public/data/molecule-workbench/approved-drugs.json The library (256,945 bytes; 78,836 gzip -9), with the Wave 4 policy fields
public/data/molecule-workbench/default-example.json Precomputed Imatinib (472,035 bytes; 36,295 gzip -9)
public/data/molecule-workbench/ocl-resources.json OpenChemLib conformer and MMFF94 tables (953,937 bytes; 286,503 gzip)
public/tools/molecule-workbench/qed-notice.txt The QED.py copyright notice
public/vendor/rdkit@2026.3.6/ RDKit_minimal.js (105,843 bytes) and RDKit_minimal.wasm (7,333,095 bytes; 2,394,041 gzip)
public/vendor/3dmol@2.5.5/3Dmol-min.js 3Dmol.js (156,977 bytes gzip)

Sizes above were measured on the working tree on 10 October 2026, except the library and the default example, re-measured on 11 October 2026 after the Wave 4 regeneration.

Runtime architecture

The shell renders MoleculeMain, which loads views/workspace.tsx after the shell. Everything with workers, canvases or WebGL runs only in the browser.

useWorkbench() owns the state. Its sequence on a first visit:

  1. initialState() derives state and any canonical address without changing browser history. A layout effect replaces the address only after that state commits, so a discarded React render cannot consume a legacy query and lose its overlong-input refusal. Nothing heavy loads.
  2. useMoleculeWorkbench calls onMapNear() on mount, so the library file (/data/molecule-workbench/approved-drugs.json, 45 s timeout) is fetched on every visit, including the discovery start, for the suggestions, histograms and map. The query bar calls useCatalogs(true), which loads the search catalogues from public/data/search/.
  3. RDKit starts only when engineOn becomes true: a pick, a submitted name or SMILES, a drawing, focus in the query field, opening the Draw drawer, mounting the Bench layout, or a link naming a molecule other than the precomputed one. The precomputed Imatinib does not start it.
  4. Choosing Imatinib sets wantPrecomputed; /data/molecule-workbench/default-example.json is fetched (20 s timeout). If its smiles differs from DEFAULT_EXAMPLE.smiles or the fetch fails, the engine starts and computes it instead.
  5. Once the engine is on and the library is ready, client.library() makes the worker fetch the library (from the HTTP cache, cache: "force-cache"), check it (below) and fingerprint all 2,197 drugs; the time taken is shown. The worker refuses a library whose descriptorVersion, fingerprintPolicy or rdkit field is missing or differs from the engine's (libraryCompatibility), or whose rdkit differs from the running module's rdkit.version(), with the error code incompatible; the page makes the same check (libraryCompatibility) and turns Similar, Compare and the similarity exports off with "The approved-drug library was computed with another fingerprint policy, RDKit or descriptor version; similarity and comparisons are unavailable until it is rebuilt."
  6. For each new SMILES the hook asks the worker for analyse and, after the library, neighbours. Each request takes a sequence number from requestSeq; a result from an older request is discarded.

analysisState, neighbourState and mapState (states.ts) are derived from these results and drive every panel. The status sentence (status) is announced once through the host's single live region.

The page's actions sit at the right end of the content tab row (ViewTabs actions, built as pageActions in workspace.tsx): in the workspace, WorkCaptureControls (Save, its details with the research context, Study membership) wrapping ObjectActions variant="subject", whose Send to includes Calc when canSendToCalc holds; in the live preview, ObjectActions alone. The line saying the subject is still being prepared, or could not be, sits between the header and the tabs. Clearing the query bar first flushes pending work (flushPendingWorkspaceWork) and then drops the opened saved reference.

Every library drug a result points at is addressed by its index in the library file. The precomputed example ships only the 21 rows its neighbours reference (referencedRows in engine.ts), placed at their library indices.

UI structure

Discovery start

With no molecule, views/workspace.tsx renders DiscoveryStart (src/components/suite/layout/discovery-start.tsx) with the large query bar, the seven examples from examples.ts (Imatinib, Aspirin, Paracetamol, Ibuprofen, Caffeine, Sildenafil, Atorvastatin; ChEMBL parent SMILES copied from the library and checked against it by logic.test.ts), a privacy line (body text) and the limits line; the start is centred in the window's height (UI-19). The title and body differ by layout (copy.poster).

Loaded header

DiscoveryHeader is one subject panel: the query bar as its first strip (its label spoken; its Analyse key keeps the accent, as every app's search key does since the CEO's feedback of 14 October 2026), the title (wb.label: the label, a library match's display name, or "Your molecule"), MoleculeDetails (subscripted formula, RDKit average mass in g/mol, heavy atoms, and for a salt the CIAAW 2021 formula weights of the whole input and the parent), MoleculeSource (a provenance chip for a ChEMBL, GSRS or PubChem origin, else "Your input"), and on the subject row WorkCaptureControls (save, Pin, Send to, Add to Study, research context). There is no Layout select (removed 14 October 2026). The content tabs follow; the limits line (led by "Not medical advice.") sits at the foot of the workspace (the CEO's feedback of 14 October 2026).

Scorecard layout (view=scorecard, the default)

Content tabs are DETAIL_TABS in views/scorecard/details.tsx. Each tab mounts on first visit and stays mounted (hidden), so map and 3D state survive tab changes.

Tab (section=) Contents
Overview (overview) The UI redesign of 13 October 2026 (mock 03). A structure panel: the depiction with PAINS atoms shaded on its graph-paper stage, the canonical SMILES in a strip beneath with Copy, the RDKit chip in the panel's aside. Beside it, a "Current molecule" record card: the source chip, the name in the serif face, the library record line (ChEMBL id, "approved drug", first approval year, a withdrawal flag), rows for formula, molecular weight, exact mass, heavy atoms, InChIKey and what computed them, and a quiet "Full profile" key (actions.goToProperties, only inside the workspace); under it the QED panel: the value in the headline face with a 25-bin histogram of library QED, the percentile and the library median (the histogram stays to the right of the depiction on wide screens; the columns share the width 1.2 to 1 from a 52rem main, the record column at least 18rem, so the depiction is smaller and the record column wider than before the CEO's feedback of 14 October 2026). A quick readout strip (ReadoutStrip): molecular weight, Crippen logP, polar surface area and Lipinski N/O hydrogens with their rule limits, and the rule-of-5 word as the key value with its reason. Then the "Oral absorption screening" panel ([data-molecule-screening]): the six-criterion verdict, the three rule rows (rule of 5, Veber, PAINS) with their sentences from rules.ts and status pills, the PAINS filter names and families, the scope notes; beside it the attributed description panel
Properties (properties) Mock 04. Five stat cards (molecular weight, Crippen logP and polar surface area with their limits, QED with the library median, the rule-of-5 word); a "Rule evaluation" panel with the three rule rows and a scope-checks row (Review with the scope notes, else Clear) beside an "Interpretation" panel (what a pass means, the screening note, a "Methods visible" pill and a key that runs the same descriptors CSV export as the Export menu, through the export registry); then the profile strips (rule limits, library mean ± 1 SD band, the molecule's value) and the descriptor and QED-input tables. Panels on these two tabs carry titles without eyebrows, as the mocks draw them
Similar drugs (similar) Ten most similar library drugs: thumbnail, name, rank, Tanimoto, approval year, route group, ChEMBL link, withdrawn flag, object actions
Chemical space (space) Canvas PCA map (panels/map-panel.tsx): route-group toggles, "nearest on the map" or "most similar" markers, keyboard pan and zoom, axis descriptions, nearest-drug list with distances, an off-map indicator
3D shape (shape) The conformer on the suite Stage with sticks or spheres, hydrogens and rotation

Bench layout (view=bench)

Two tabs in the one row since 14 October 2026: Comparison (compare) shows the bench's SVG map above the comparison table, so drugs are picked where they are compared, and Series (series). A link's section=space opens on Comparison. The bench sections (BENCH_SECTIONS) remain: Chemical space (space), an SVG map (views/bench/space-map.tsx) with lines from the query to its ten most similar drugs and a ranked list beside it (the left-out line sits in the aside's header, outside the scroll region); Comparison (compare), a table of the query and up to four drugs with differences from the query; and Series (series, below). A new query seeds the comparison with its three most similar drugs. Under the comparison table, beside its delta note, a second line reads "Similarity is shared substructure by one fingerprint method, not shared activity, target or safety."; when only the largest fragment of a salt is described, the query column's head carries a focusable "Largest fragment" pill whose tooltip gives the salt's weight as entered (CPO merge review, condition 1). Picking a drug opens it in the inspector (views/bench/drug-inspector.tsx). The display name of this layout is "Compare" in parts of the copy while its fragment value stays bench. The comparison assembles the first time it is shown for a query in a page session (compare.tsx, B3 of the motion pass, expressive("compareAssembly")): the column heads slide in 400 ms with a 30 ms step and the body fades after them; a drug added later only tints its column (data-col). Under reduced motion or with the effects off the table appears at once; the values are never animated.

Comparison states (E2, the CSO, CTO and CPO comparison memos of 23 October 2026). Each column of the Side by side table is projected by comparison-model.ts from the analysis of its exact SMILES. A ready column outside the scoring domain (domainOf(described formula).organic false: no carbon, or an element outside the organic set, such as a metal complex) keeps its raw descriptors without rule-limit danger marks, shows Not scored for QED and Not applied for the Rule of 5, and says Outside the scoring domain. Rules and QED are not applied to this described structure. under its identity. Pending columns keep the placeholder bar and say Computing properties…; a failed analysis says Analysis failed. Properties are unavailable. and an engine that cannot run Analysis unavailable., with Unavailable cells and no motion. QED and Rule of 5 always come from the column's own ready analysis; the library's stored QED is reference data and never stands in. A salt or mixture says Properties describe the analysed fragment. (query and picks alike), and a fragment-described result without its recorded described identity says Described-structure identity was not recorded. Differences need both values. An outcome is tied to the submitted SMILES and to the worker client that produced it; picks kept for another query are not shown. No threshold, element set, fragment policy, engine or number changes: the tests (comparison-model.test.ts) take ready values from the Python RDKit 2026.03.6 reference and the series fixture and hand-author the categories; e2e/molecule-workbench/comparison-states.spec.ts walks a ready comparison and its CSV, an engine that cannot load, a no-carbon query and a salt query.

The series table (D3)

views/bench/series.tsx (its own chunk), logic in series.ts, rows described by the worker's series request (describeSeriesRow). The Series section is reachable with no molecule ("Use my series" on the start and in the palette).

  1. Input. Paste a table or choose a file. readSeriesTable strips a BOM, detects the delimiter and parses RFC 4180 fields with src/lib/table/delimited.ts; the first line is the header and blank rows are dropped. guessMapping proposes the SMILES column (a header matching smiles), an id and a name column, and offers every other column as a value column; the person confirms. A value column counts only once it has an endpoint and a unit. More than SERIES_MAX_ROWS = 200 rows is refused with the count; a row with no SMILES or one over 2,000 characters is marked and not described.
  2. Values as typed. parseSeriesValue keeps the cell's text and reads a number with an optional qualifier (>=, <=, >, <, ~, =); a value with a qualifier other than = is censored. Text that is not a number stays text; a unit in a cell is not guessed. pIC50 is computed only for an endpoint matching IC50 in an explicit molar unit (pM, nM, µM, μM, uM, mM, M), as −log10(value × unit), with the qualifier inverted (> 10 µM gives pIC50 < 5.0); never averaged or ranked across endpoints.
  3. Each row (SeriesRowResult): the whole input's canonical SMILES, InChIKey, formula and weight; the described fragment's canonical SMILES, InChIKey and dropped fragments; the described fragment's descriptors, QED and PAINS count; its Morgan bits as set-bit indices; and its three nearest approved drugs with every same-compound row left out (the same ranking as neighboursOf). No drawings: a row is drawn only when its detail opens.
  4. Within the series. sameCompoundRows pairs rows whose described fragments share an InChIKey (else canonical SMILES): "same" when the whole inputs also share one, else "another form". bitsTanimoto gives the within-series matrix, which is checked against the oracle but not displayed.
  5. The table is a focusable named region with a sticky ID column, header sort buttons with aria-sort (rows without a measured value, and censored cells, sort last), status flags (including "In the library as {name}"), an "All properties" toggle, values as typed and pIC50. One announcement ("Series ready: {n} rows, {k} could not be read."). A row's name opens its detail in place under the table, with focus moved to it: drawing, identities, the three nearest with the left-out forms, "Analyse as the query" and, on a ready series' described row, the row's own ⋯ menu (ObjectActions variant="row": Open in …, Pin, Add molecule to Study, Open its page; E4). The detail is its own chunk (views/bench/series-row-detail.tsx, drybench-island:molecule-row-detail, through series-row-detail-loader.ts), keyed to the series record so a new table, repeat or reopened note closes it. A row with no described result shows the table's own reason (no SMILES, too long, "RDKit cannot read this SMILES", withheld, or the describing line) and no actions or Analyse; a described row whose whole input has no standard InChIKey says so and has no actions; rows whose whole inputs share a standard InChIKey (tautomers) say they share one compound record. The limits block is always shown.
  6. Saving and repeating. "Save the series in this browser" creates one of the person's own note objects whose data is { text, moleculeSeries: SeriesRecord } (see Saved research views). Reopening a record whose versions differ from the engine's keeps its retained results unchanged, read against its own recorded versions; resultsCurrent only shows a notice, and "Describe as a new series" is the explicit way to describe it again under the current methods (corrected 24 October 2026; the code never re-described a reopened record). "Describe as a new series" (offered for a saved, withheld or older-methods series) opens the review prefilled from the record rather than describing at once; describing makes a new record under the current methods with savedRef: null, and its Save creates a new note, so the saved note is never written, replaced or relabelled (E5, 24 October 2026). The new series carries the line saying the saved series is unchanged until it is saved or put away; an example whose table or columns changed keeps its licence notice but reads "Adapted from an example: …", and its ZIP README says its rows may not match the public records.
  7. From an assay (H22; roadmap-v0-depth A6). mine= naming a saved assay (useSavedMoleculeWork, views/use-saved-work.ts) loads Assay's own projector, assaySeries (src/components/tools/assay-analyser/series-export.ts, on demand), on the stored record: its results opened as Assay opens them (upgradeToV6: verified by their hash, or withheld), one row per curve (compound, SMILES, the C50 in M, status, reason, tab-separated, RFC 4180 quoting). The mapping is explicit, never guessed: the C50 column is the recorded value <IC50|EC50|C50> (<relative|absolute>) in M, so pIC50 follows only for an IC50; a withheld, refused or unresolved C50 is an empty cell, never zero; a curve without a structure stays as a row with "no SMILES". The table and mapping reach the section through writeSeriesArrival (series-store.ts) and open in the review with a line naming the assay; nothing is described until the person presses Describe. More than 200 curves is refused with the count before anything is read (fromAssayRefused), as are withheld results and an assay with no fit. Tested by src/components/tools/assay-analyser/series-export.test.ts and two cases in e2e/objects/send-to.spec.ts.
  8. Share. A series never goes in a link. With a series on screen the share dialog leads with "Your series is not in this link. Export Series to send it as a file." and Share works with a series and no molecule; the link records the Compare section.

Declared deviations from the CPO's §2.3, accepted at the merge review: the row detail opens in place, not in the inspector; LibraryDrugActions on the detail's nearest drugs are not built ("Analyse as the query" is the route to them; per-row Pin, Send to and Add to Study were built in E4, 24 October 2026); structure thumbnails are not drawn in the table.

The Scorecard map is a canvas and the Bench map is SVG; they are separate implementations of the same data.

Inspector, status chip, keys and commands

The inspector starts hidden, following the shell default (D-0060), and opens on an explicit request. It shows the molecule's record, or on the Bench the picked drug. The status-bar chip (engine-chip.tsx) shows EngineState from the RDKit client: idle, starting, ready (with RDKIT_VERSION from content, not the worker's reported version), restarting or failed. Keys: v toggles the layout, d opens the editor, 3 goes to the 3D tab, m to the map. Palette commands switch layouts and open each example.

Address and fragment state

moleculeSchema in address.ts defines the fragment keys, written as #s= followed by an encoded query string (shared contracts §3; see Storage and state):

Key Check Meaning
smiles trimmed text, at most 2,000 characters The subject. A new molecule pushes a history entry
name trimmed, cut to 120 characters A label for the SMILES; without a SMILES, a name to look up
view scorecard or bench Layout
section overview, properties, similar, space, shape, compare or series Content tab; older links default to Overview or Chemical space (sectionFromState). A different molecule opened on the page starts on Overview (Back and Forward keep their entry's section), and the last address keeps no section (the CEO's feedback of 14 October 2026)
mine HANDOFF_CHECKS.mine A saved research view to open

The share link (definition.ts shareLink) carries smiles, name and view, and section when the view is the Bench. A series is never in the address.

If a link carries both smiles and name, the SMILES decides the structure. A link's name is never verified: when the structure matches a library drug under another name, titleFor() in library.ts shows the drug's name, unless the match is a salt's parent and the label is the parent's name followed by more words (imatinib mesylate stays imatinib mesylate). A hand-typed legacy ?smiles= cut at # is detected (cutAtHashState) and the reconstructed SMILES is offered.

The layout choice is also stored per device in localStorage under suite:molecule-workbench:view (never in the product page preview).

History

Explicit picks push a history entry; drawing, clearing, the first molecule and a change of view or section replace it. Each entry stores the molecule, including its origin, under history.state.moleculeWorkbench, so Back and Forward restore where a molecule came from as well as what it is. popstate handling compares stateKey(window.location) with the key on screen, so a plain anchor jump does not reload the molecule.

Data flow and external services

Every request the app makes:

Request From Destination Carries Timeout and caching
RDKit.js and WASM RDKit worker, importScripts /vendor/rdkit@2026.3.6/ nothing client init/analyse 120 s
Library page and worker /data/molecule-workbench/approved-drugs.json nothing 45 s; worker uses force-cache
Precomputed example page /data/molecule-workbench/default-example.json nothing 20 s
OpenChemLib tables conformer worker /data/molecule-workbench/ocl-resources.json nothing 45 s
3Dmol.js page, loadScript /vendor/3dmol@2.5.5/3Dmol-min.js nothing loadScript default; slow notice at 8 s
OpenChemLib editor page, dynamic import("openchemlib") a bundled chunk nothing none
Search catalogues page /data/search/*.<hash>.json nothing immutable cache rule
PubChem name lookup page, plain fetch POST https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/property/SMILES,MolecularFormula,Title/JSON, form body name= the canonicalised name 15 s abort, slow notice at 6 s; per-visit in-memory cache of non-error outcomes; no retry
Name suggestions page, bundled catalogues local catalogue matching nothing sent to PubChem merely by typing usePubchemSuggestions returns an empty idle supplement; the retained proxy uses private/no-store responses and upstream fetches
ChEMBL structure fallback page GET /api/search/molecule?name= a catalogued name client 12 s; server fetches https://www.ebi.ac.uk/chembl/api/data/molecule/<id>.json (10 s, revalidate: 86400) and checks the returned id
Description page, MoleculeDescription GET /api/search/molecule-description?source=&id= one ChEMBL id or PubChem CID client 12 s; server 8 s, redirect: "error", response body at most 262,144 bytes, in-process cache of 128 entries for one hour, errors not cached

The registry declares two endpoint hosts (pubchem.ncbi.nlm.nih.gov and www.ebi.ac.uk), both with consent: "none", and wasm: true, which adds 'wasm-unsafe-eval' to the app's Content Security Policy. The browser contacts PubChem directly only for the name lookup; ChEMBL and description traffic go through the site's own routes. Compound-name suggestions use the bundled catalogues. The name lookup does not use the suite's client network layer, so it has no consent prompt, rate limiting or backoff; that consent remainder stays open under SEC-02. See Security and network and Server, accounts and billing.

Name resolution

lookUp() in use-workbench.tsx:

  1. Loads the catalogues (best effort) and canonicalises the name with canonicalCompoundName (a brand or code becomes the catalogue's preferred label).
  2. If the library has not loaded, the lookup waits (pendingName) so an approved drug is answered offline and never sent to PubChem.
  3. Library match by nameKey on ChEMBL preferred names, and on the name without a trailing "anhydrous" (libraryNameKeys).
  4. A catalogue name whose ChEMBL id has a curated structure (CURATED_STRUCTURES: retatrutide) uses that structure and skips PubChem.
  5. PubChem PUG REST. readPubchemResponse() treats 404 or PUGREST.NotFound as not found, 503 or PUGREST.ServerBusy as busy, and more than one returned compound as an error asking for a specific form.
  6. If PubChem did not find a structure and the name is in the catalogue, /api/search/molecule asks ChEMBL. The answer is used only when its id equals the catalogue id.
  7. Not found: a message saying the app covers small molecules, with a link to Protein. Error: a retry.
  8. The SMILES is length-checked and analysed in the worker before it is shown. An unreadable structure is reported as "<source> returned a structure RDKit cannot read".

The molecule's origin is recorded as example or library (ChEMBL id), pubchem (CID, title, and a retrieval time on newer receipts), chembl (ChEMBL id, retrieval time), gsrs (ChEMBL id, UNII, definition id, retrieval date), or smiles, drawn and link (no identifiers). The dates mean different things: a gsrs date is the curated record's date in src/lib/search/curated-structures.ts, not the lookup time; a chembl date is the browser's clock when the answer arrived, and the server route may have served it from a cache filled by an earlier request: revalidate: 86400 marks the entry stale after a day, but Next.js serves the stale entry on the first request after that and keeps the last good entry when revalidation fails, so the answer is usually less than a day old but can be older (the CSO's closing review of Stage E, 24 October 2026). A limits line says so (SCI-239).

What the query field treats as a name (readQuery() in views/use-molecule-workbench.tsx): a ChEMBL id; anything the catalogue resolves; or text matching ^[A-Za-z][A-Za-z0-9\s'-]{2,}$ that contains a lower-case letter or a space and is not made only of SMILES organic-subset letters. Everything else is submitted as SMILES. A ChEMBL id in the library opens that drug directly.

Descriptions

moleculeDescriptionSubject() (description-identity.ts) allows a description only for an exact public identity: the origin's PubChem CID, the origin's ChEMBL id (example, library, chembl, gsrs), or a library drug whose canonical SMILES equals the whole input or its described largest fragment (the latter labelled as the matching parent). A drawn, pasted or linked structure without such a match, and an InChIKey-only match (sameCompound), get no description. The server (readChemblDescription, readPubchemDescription) keeps ChEMBL mechanism rows whose parent_molecule_chembl_id equals the id, deduplicated, three shown; and for PubChem prefers ChEBI, then NCI, then others, ignores hazard-only text, rejects HTML and control characters, and shortens to at most 500 characters at a sentence boundary. Descriptions are live context: they are not saved, exported or recorded.

Chemistry and science

All numbers below come from RDKit.js 2026.03.6 (@rdkit/rdkit, the MinimalLib WebAssembly build) unless the table says otherwise. The same chem.ts and engine.ts code runs in the browser worker, the pipeline and the Node tests.

Input handling and limits

limits.ts and chem.ts:

Limit Value Enforced by
SMILES length 2,000 characters checkSize() (throws TooLargeError), the address schema, the query field's maxLength, submitSmiles
Heavy atoms 500, counted from the SMILES text before RDKit sees it checkSize(). A 600-carbon chain traps RDKit.js with "memory access out of bounds" and leaves the module unusable; 550 still works (comment in limits.ts)
Name length 120 characters address schema; server routes
Drawing editor 150 heavy atoms (larger structures are not pushed into the editor) MAX_DRAW_ATOMS
3D refused above 100 heavy atoms in the largest fragment; confirmation asked above 60 MAX_3D_ATOMS, CONFIRM_3D_ATOMS

heavyAtomsPerFragment() tokenises SMILES text with one regular expression (bracket atoms, two-letter organic symbols, organic-subset and aromatic atoms) and skips bracketed hydrogens; chem.test.ts checks it equals RDKit's heavy-atom count for every library drug.

parseSmiles() returns null when RDKit rejects the SMILES or it has no atoms. Exceptions RDKit throws are not caught there: the worker treats them as a crash (see Engines).

Parent selection for salts, mixtures and metal complexes

prepareInput() in chem.ts decides what the numbers describe:

  • One fragment: the whole input.
  • Several fragments: RDKit's get_frags() splits them. The "largest" fragment has the most heavy atoms, ties broken by more carbons, then by order.
  • The whole input is kept instead when the largest fragment is not organic (domainOf(formula).organic is false), when any other fragment contains a "complex metal", or when the fragment count and mapping count disagree.
  • A complex metal is any element outside the organic set and outside the counter-ion metals Li, Na, K, Rb, Cs, Mg, Ca, Sr, Ba (domain.ts). So imatinib mesylate is described as imatinib and atorvastatin calcium as atorvastatin, while PubChem's carboplatin (its ligand, two [NH2-] and [Pt+2]) is kept whole and falls outside the scoring domain.
  • No neutralisation, tautomer canonicalisation or charge-parent step is applied: RDKit.js MinimalLib has none, and the described fragment keeps its charges as drawn.

AnalysisResult.input records the whole input (canonical SMILES, formula, RDKit average mass, fragment count, Standard InChI and InChIKey); AnalysisResult.largest, when a fragment was dropped, records the dropped formulae and the described parent's InChIKey, SMILES, formula and mass. PAINS atom indices are mapped back to the whole input for highlighting.

Descriptors

descriptorsOf() reads mol.get_descriptors() and mol.get_json(). Units and definitions (method texts are in content/apps/molecule-workbench.methods.ts):

Field RDKit.js key Definition Unit Reference
mw amw Average molecular weight from RDKit's atomic weights g/mol
exactMass exactmw Monoisotopic mass Da
clogp CrippenClogP Wildman-Crippen atom-contribution log P (not BioByte CLOGP) dimensionless Wildman and Crippen, J Chem Inf Comput Sci 1999;39:868-873
tpsa tpsa Topological polar surface area from N and O contributions Ų Ertl, Rohde and Selzer, J Med Chem 2000;43:3714-3717
hbd NumHBD RDKit hydrogen-bond donor sites count
hba NumHBA RDKit hydrogen-bond acceptor sites count
lipinskiHBD lipinskiHBD Hydrogens on N and O (NHOH count), used for the rule of 5 count Lipinski et al. 1997
lipinskiHBA lipinskiHBA N and O atoms (NO count), used for the rule of 5 count Lipinski et al. 1997
rotatableBonds NumRotatableBonds Rotatable bonds, RDKit strict definition (checked equal to Python's NumRotatableBondsOptions.Strict on the library) count
rings NumRings Ring count (SSSR) count
aromaticRings NumAromaticRings Rings whose atoms are all aromatic count
fsp3 FractionCSP3 Fraction of carbons that are sp3 0 to 1
heavyAtoms NumHeavyAtoms Non-hydrogen atoms count
stereoCentres, unspecifiedStereoCentres NumAtomStereoCenters, NumUnspecifiedAtomStereoCenters Tetrahedral stereocentres and those left unspecified count
formalCharge computed in formulaFromJson Sum of atom formal charges e
fragments computed Number of .-separated parts of the canonical SMILES count
canonicalSmiles get_smiles() RDKit canonical SMILES
formula computed in formulaFromJson See below

The split between generic donor/acceptor sites (NumHBD/NumHBA) and the historical Lipinski counts is versioned as DESCRIPTOR_VERSION = "2.0.0-num-hbd-hba" (scientific audit of October 2026, finding B6; audit of 10 October 2026, finding C2). The library's map and the exports carry the same version. chem.test.ts pins the distinction with acetamide (generic 1/1, Lipinski 2/2), methylammonium (1/0 against 3/1) and dimethyl sulfide (HBA 1 against 0).

Two masses appear on screen. The header's mass is RDKit's amw of the whole input. For a multi-fragment input whose parent was selected, a second line gives formula weights for the whole input and the parent computed from their formulae with CIAAW 2021 atomic weights (formulaMass in src/lib/units/formula-mass.ts), so they can differ slightly from RDKit's value.

Molecular formula

RDKit.js has no CalcMolFormula, so formulaFromJson() rebuilds it from RDKit's JSON: carbon, then hydrogen (implicit hydrogens added from impHs), then other elements alphabetically, with RDKit's quirk of keeping hydrogen second even without carbon (NH4Cl gives H4ClN), isotopes counted as their element, and the net charge appended (+, -, +2). It matches Python's rdMolDescriptors.CalcMolFormula on every library drug. For display, hillOrder() reorders carbon-free formulae alphabetically, as PubChem does (Cl2H6N2Pt).

Applicability domain

domainOf() in domain.ts: a structure is organic when its formula contains carbon and only H, B, C, N, O, F, Si, P, S, Cl, Br and I. The check runs on the described fragment's formula. For a non-organic structure the Scorecard shows "Outside the scoring domain", QED is shown as "–" with "Not given for this structure", the rule cells read "Not applied", and the descriptor export writes null for QED and both rules. PAINS alerts and the descriptors are still shown. The Bench comparison table does not apply this check (see the gaps section).

Lipinski's rule of 5

lipinski() in rules.ts (Lipinski et al., Adv Drug Deliv Rev 1997;23:3-25, reprinted 2001;46:3-26):

criteria:  mw <= 500 g/mol
           clogp <= 5            (Crippen logP)
           lipinskiHBD <= 5      (N-H and O-H hydrogens)
           lipinskiHBA <= 10     (N and O atoms)
pass  <=>  number of failed criteria <= 1

Limits are inclusive (clogp = 5 passes; tested). The input type requires the Lipinski counts, so no caller can judge the rule on generic sites by mistake. The summary sentence names each failed criterion with value and limit.

Veber's rules

veber() (Veber et al., J Med Chem 2002;45:2615-2623):

criteria:  rotatableBonds <= 10
           tpsa <= 140 Ų
pass  <=>  both met

The screening summary

verdictOf() in science.ts combines the six criteria (four Lipinski, two Veber):

Kind Condition Heading (content/apps/molecule-workbench.workspace.ts)
domain structure not organic "Outside the scoring domain"
within no criterion failed "All six criteria met"
one rule of 5 passes and Veber passes, but a criterion failed (so exactly one Lipinski criterion) "One rule-of-5 exception"
outside otherwise "n of 6 criteria exceeded"

The copy calls both rule sets small-molecule screening heuristics, not measured absorption or a safety assessment.

QED

qed.ts ports RDKit's rdkit/Chem/QED.py (Release_2026_03_6) because RDKit.js has no QED (Bickerton et al., Nat Chem 2012;4:90-98). SMARTS, asymmetric double sigmoid (ADS) parameters and weights are copied verbatim.

Desirability of property x with parameters A to F and DMAX:

d(x) = [ A + B / (1 + exp(-(x - C + D/2) / E)) * (1 - 1 / (1 + exp(-(x - C - D/2) / F))) ] / DMAX
QED  = exp( sum_i w_i * ln d_i / sum_i w_i )      with the "mean" weights

Weights (WEIGHT_MEAN): MW 0.66, ALOGP 0.46, HBA 0.05, HBD 0.61, PSA 0.06, ROTB 0.65, AROM 0.48, ALERTS 0.95. WEIGHT_MAX and WEIGHT_NONE are defined but unused by the app.

The eight inputs (qedProperties()):

Input How it is computed in the port
MW RDKit amw
ALOGP CrippenClogP
HBA Total number of matches of the 11 acceptor SMARTS (ACCEPTOR_SMARTS)
HBD NumHBD
PSA tpsa
ROTB NumRotatableBonds (strict)
AROM Rings left after deleting atoms matched by [$([A;R][!a])]. QED.py uses len(Chem.GetSSSR(...)) on the deleted molecule; RDKit.js does not expose SSSR, so cycleRankWithout() computes the cycle rank bonds - atoms + connected components of the remaining graph
ALERTS Number of the 116 structural-alert SMARTS (STRUCTURAL_ALERT_SMARTS) with at least one match

RDKit.js removes explicit hydrogens when parsing SMILES, which stands in for Chem.RemoveHs in QED.py. The cycle-rank substitution is the one deliberate algorithmic difference; it agrees with Python on every library drug and on the three ring-count molecules flagged in QED.py's comments, which are in the fixture. The Properties tab shows each input with its desirability.

PAINS

pains-data.ts holds the 480 PAINS filters (Baell and Holloway, J Med Chem 2010;53:2719-2740) from RDKit's FilterCatalog source files pains_a.in, pains_b.in and pains_c.in at tag Release_2026_03_6. FilterCatalog parses them with mergeHs=True; RDKit.js cannot, so rdkit_reference.py pains writes each merged query out with Python RDKit and refuses to run if the names differ from FilterCatalog's. Each filter is compiled once per worker with get_qmol. painsHits() reports every filter with a match and the set of matched atoms. 124 of the 2,197 library drugs match at least one filter (stats.painsMatches), which the overview quotes for context.

Lead-optimisation efficiency (LE, LLE, LELP, cLogD)

A disclosure under the screening panel (src/components/suite/molecule/medchem-efficiency-panel.tsx, engine src/lib/chem/medchem-sar.ts, copy content/apps/molecule-medchem.ts) turns a measured biochemical potency the user enters, plus the RDKit descriptors of the described structure, into ligand-efficiency metrics:

pActivity = −log10(potency in mol/L)
LE   = F × pActivity / heavy atoms         (Hopkins et al. 2004; F = ln 10·R·T = 1.3642 kcal/mol at 298.15 K, R = 1.9872)
LLE  = pActivity − cLogP (Crippen)          (Leeson & Springthorpe 2007)
LELP = cLogP / LE                           (Keserű & Makara 2009)
ΔG   = −F × pActivity  (kcal/mol)
cLogD = cLogP − log10(1 + 10^(±(pH − pKa))) (Henderson–Hasselbalch, one named group)

Scope follows the CSO's ligand-efficiency ruling of 13 October 2026: biochemical IC₅₀, Kᵢ or K_d only (not cell-based assays or activators); LLE is labelled "LLE (Crippen cLogP)" because RDKit's Crippen logP is not BioByte ClogP. LE and LLE are withheld, each with a reason, below 6 heavy atoms, for a structure kept whole (a metal complex, inorganic salt or mixture) or with an atom outside C, H, N, O, S, P, F, Cl, Br, I, B, Si, Se; a non-zero net formal charge withholds LLE (and LELP), LE still given. LELP is withheld, not reported as 0, when LE is not positive. Nothing is computed from a guessed value: the panel starts with no potency and no ionisable group, so no LE/LLE appears until a potency is entered, and cLogD is withheld until a pKa is entered for the one acidic or basic group named — a defaulted pKa never produces a cLogD. cLogD is a monoprotic estimate (one group; no zwitterion, polyprotic compound or tautomer). The panel records two property observations, both using Crippen cLogP and stated as observations, not pass/fail: a 3/75 flag (cLogP > 3 and TPSA < 75 Ų, Hughes et al. 2008) and whether the compound is within 4/400 (MW ≤ 400 and cLogP ≤ 4, Gleeson 2008). The Pfizer CNS MPO score was removed: it required a most-basic pKa the suite does not predict, so it would have scored from a defaulted value, and the board declined inventing CNS MPO scores. F mirrors Assay's reviewed engine (src/components/tools/assay-analyser/efficiency.ts, LE_FACTOR, MIN_HEAVY_ATOMS, ORGANIC_ELEMENTS), so one compound has one LE across the suite. Verification: src/lib/chem/medchem-sar.test.ts against the independent RDKit + primary-definition oracle pipeline/molecule-workbench/medchem/reference.py (pipeline/molecule-workbench/fixtures/medchem-v1.json), covering withheld cases (5 heavy atoms, a quaternary-ammonium cation with LLE withheld, a platinum complex); e.g. imatinib at IC₅₀ 25 nM gives LE 0.280, LLE 3.01, LELP 16.38.

Substructure matching is used internally for the QED acceptor and alert counts, the aliphatic-ring deletion and PAINS, all with get_substruct_match or get_substruct_matches on precompiled query molecules. There is no user-facing substructure, SMARTS or similarity-threshold search in Molecule. PRD A6 and build specification §5.2 describe one (SubstructLibrary, Butina clustering); none of it is in the code.

"Fragment" in this app means a disconnected part of a multi-fragment SMILES (see parent selection). The "Find DNA fragment" search (docs/design/fragment-search-increment.md, e2e/fragment-search.spec.ts) is a Bench feature for saved DNA sequences and constructs; it does not touch Molecule's code or data.

Identity: canonical SMILES, InChI and InChIKey

Canonical SMILES come from RDKit. Standard InChI and InChIKey come from the InChI library compiled into RDKit.js (mol.get_inchi(), rdkit.get_inchikey_for_inchi()); inchiKeyOf() returns an empty string if InChI fails. Standard InChI merges only the tautomer classes it recognises: chem.test.ts checks that 2-pyridone and 2-hydroxypyridine share a key while acetone and its enol do not. The shared compound object is keyed on the whole input's InChIKey; the parent fragment gets its own key and a form-of link.

Morgan fingerprints and Tanimoto similarity

The fingerprint policy (versions.ts FINGERPRINT_POLICY = morgan-r2-2048-described-v1; CSO pre-rulings of 10 October 2026, ruling 1) applies to the query, the library and a series alike: an RDKit Morgan bit vector, radius 2, 2,048 bits, connectivity invariants, bond types used, chirality not used, no redundant environments, no counts, computed on the described fragment as drawn (prepareInput). MORGAN_OPTIONS passes every controllable setting: { radius: 2, nBits: 2048, useChirality: false, useBondTypes: true, includeRedundantEnvironments: false }. onlyNonzeroInvariants and includeRingMembership changed no bit on 2,205 molecules in RDKit.js or Python RDKit (the key spike, docs/science/evidence/molecule-fingerprint-policy-2026-10-10/), so they are treated as fixed by RDKit.js, not passed and not named in copy. The suffix -v1 increments when any setting or the fragment rule changes. Fingerprints are taken as 256 bytes from get_morgan_fp_as_uint8array (Rogers and Hahn, J Chem Inf Model 2010;50:742-754). Because chirality is not in the fingerprint, stereoisomers score 1.0 and stay ranked; the copy says so (similarityCopy.described). RDKit.js has no similarity function, so similarity.ts implements

Tanimoto(a, b) = |a AND b| / (|a| + |b| - |a AND b|),   0 when both are empty

with a 256-entry popcount table. Library fingerprints are not shipped: the worker computes them after RDKit loads (prepareLibrary()), each on the row's described fragment, so a salt in the library is compared by its parent, not its counter-ions (CSO DEF-W4-1). Of the 75 library rows ChEMBL stores as salts or mixtures, 44 acquire a described-fragment fingerprint and 31 inorganic salts and complexes stay whole. prepareLibrary indexes both the whole and the described canonical SMILES, several rows per key. The pipeline README records 0.7 s in Node and 1.6 to 2.5 s in headless Chromium for this step (measured before the policy change).

The same compound (ruling 2): a library row is the same compound as the input when their described fragments share a full standard InChIKey, or their canonical SMILES. neighboursOf() (and the series' describeSeriesRow, through the shared rankAgainstLibrary):

  1. Prepares the input (parent selection), fingerprints the described molecule and computes its InChIKey and map features.
  2. For the title rule (titleFor), looks for an identical library drug by the whole input's canonical SMILES, then the described fragment's (identical, identicalVia: "whole" | "largest").
  3. Collects the forms: rows whose whole canonical SMILES equals the input's (relation: "whole"), rows whose described SMILES equals the input's described fragment ("largest" for a salt input, "form" when the row is a salt or mixture of the input), and the rows the shipped sameCompound pairs join to any of those ("inchikey").
  4. Ranks the top 50 by Tanimoto with every form excluded (topSimilar with a set; ties keep library order), computes InChIKeys for those 50 and moves every row with the input's InChIKey into the forms (a redrawing such as a tautomer standard InChI merges); the first such row, when nothing was identical, is also reported as sameCompound for the identity statement. Only the top 50 are checked, to avoid InChI for all 2,197 drugs.
  5. Returns the top 10 of what is left with light and dark thumbnails, forms (index, relation and Tanimoto, no rank, in library order), the map point and the ten nearest drugs on the map (which excludes only the identical row).

On screen. The Similar tab, Compare's closest-drugs aside and the series row detail show sentence A ("Similarity is between described structures: …"), and the forms as LeftOutLine: "Left out of the neighbours:" with every name as a ChEMBL link, untruncated (the CPO ruled that groups of four, from acetate, citrate and carbonate salts, are shown in full), then "By standard InChIKey they are this compound or another form of it, and a compound is not its own neighbour." A ranked drug that is a form of another listed drug carries "Same compound as {name}, another form." When the top score is 0 the list is replaced by "No approved drug in the library shares a fingerprint feature with this structure." The inspector gives one line per form.

Library context. approved-drugs.json records fingerprintPolicy, rdkit (2026.03.6), descriptorVersion, built (2026-10-10, the date the policy fields were computed; retrieved stays the ChEMBL download date) and sameCompound (26 index pairs, equal to the Python oracle's). Under the policy the median pairwise Tanimoto is 0.09 and the 99th percentile 0.232 (0.231 before); neither is shown until reproduced by Python (CSO, 10 October 2026). Regenerating the opening example under the policy changed no neighbour; the diff was the new metadata and forms.

Chemical-space map

map-model.ts. Features, in order (MAP_FEATURES): mw, clogp, tpsa, hbd, hba, rotatableBonds, rings, aromaticRings, fsp3. The generic hbd/hba are used, not the Lipinski counts.

fit (pipeline):  z_ij = (x_ij - mean_j) / sd_j        sd is the population standard deviation
                 C = Z^T Z / n                        (correlation matrix)
                 eigenvectors of C by cyclic Jacobi rotations (stop when off-diagonal sum of squares < 1e-22, at most 100 sweeps)
                 keep two components; flip each so its largest-magnitude loading is positive
project:         PC_k = sum_j L_kj * (x_j - mean_j) / sd_j

The shipped model explains 41.94 % (PC1) and 24.59 % (PC2) of the variance. The browser projects a query with the stored means, standard deviations and loadings, so a library drug lands on its own point up to the three-decimal rounding of the stored coordinates. "Nearest on the map" is Euclidean distance in the PC1/PC2 plane, ten points, excluding an identical drug. describeAxis() turns loadings with magnitude of at least 0.3 into a plain sentence. coverageBounds() fits the default view to the central 98 % of points; a query more than 25 % of the box beyond it is pinned to the edge with an arrow (outsideBox, clampToEdge, arrowFor). The map compares properties, not structures or targets.

Route colours: ROUTE_STYLE and ROUTE_PALETTE in map-view.ts use chart slots 1 to 3 with darker edges so dots reach 3:1 contrast on white; the dark palette is gate-tested at 4.5:1 on the dark panel by src/components/suite/tokens.test.ts.

Property profile and QED histogram

profileRows() in science.ts places each value on a strip with its rule limit and, where the map model holds the descriptor, the library mean ± 1 SD from the model's standardisation. The Lipinski N/O rows have no library band, because the map model was built on the generic counts (audit of 10 October 2026, C2: "needs a library rebuild"). Rows are grouped Rule of 5 (MW, Crippen logP, Lipinski N/O hydrogens, Lipinski N/O atoms), Veber (TPSA, rotatable bonds) and Shape (generic donor and acceptor sites, rings, aromatic rings, Fsp3; no limits). rule-of-5-rows.test.ts checks that every Rule-of-5 strip agrees with lipinski() for famotidine and bosentan, whose generic and Lipinski counts fall on opposite sides of the limits.

qedHistogram() bins the library's QED values (stored rounded to two decimals) into 25 equal bins over 0 to 1. qedPercentile() is the share of library drugs with QED at or below the query's, rounded to a whole percent. The median line uses stats.qedMedian (0.56).

The rule rows on both tabs (views/scorecard/rule-rows.tsx) show lipinski().summary and veber().summary word for word, the sentences the descriptor CSV carries; the pill words (Pass, Pass, 1 exception, Fail, Not applied, No alerts, n alerts, Review, Clear) are copy.rules.status, shared with the Bench comparison table's rule-of-5 cells. The stat cards show the same values and decimals as the descriptor table (formatQuantity without its unit, the unit in the card's own slot).

3D conformer

conformer.worker.ts (OpenChemLib 9.25.0, in its own worker):

  1. Fetches and registers ocl-resources.json (22 tables: torsion and bond-length tables under /resources/cod/, MMFF94 tables under /resources/forcefield/mmff94/).
  2. Parses the SMILES with Molecule.fromSmiles. Any complex metal in the whole input is refused before small fragments are stripped, so a metal complex written as separate ions is not built from its ligand alone.
  3. Strips small fragments (stripSmallFragments), refuses more than 100 heavy atoms, refuses any element outside the organic set and a structure with no carbon.
  4. Counts stereocentres with unknown parity; each is given an arbitrary configuration, and the note says so.
  5. new ConformerGenerator(42).getOneConformerAsMolecule(mol): one collision-free conformer from the torsion library, seeded so a molecule always gives the same shape.
  6. ForceFieldMMFF94(conformer, MMFF94SPLUS).minimise({ maxIts: 4000 }): "converged" when it returns 0, "stopped" otherwise, "skipped" when MMFF94 has no parameters (boron, for example), in which case the unminimised geometry is shown with a warning.
  7. Returns a molfile, atom count, minimisation state, whether fragments were dropped, unspecified stereocentres and elapsed time.

OpenChemLib's own fragment stripping chooses the largest fragment by its own rule; it is not the same code as prepareInput() in RDKit, so for an unusual mixture the 3D shape and the 2D numbers could describe different fragments (not observed; noted as a risk). The conformer is a vacuum, single-conformer model and the copy says it is one shape among many.

2D depiction

drawSvg() draws with get_svg_with_highlights. The worker sets prefer_coordgen(true) once; coordgenSuits() switches to RDKit's own layout for a molecule with a ring over 30 atoms, more than 25 rings or more than 150 heavy atoms, because CoordGen took from 17 s to over 100 s on such structures (COORDGEN_LIMITS comment). chem.test.ts checks that every library drug except large rings keeps CoordGen. Every depiction is drawn twice: RDKit's default palette on white, and a dark palette (DEPICTION_PALETTE.dark, at least 4.5:1 on the dark panel) on a transparent ground. Sizes: main 420 × 300, thumbnail 220 × 160, table 80 × 56.

The approved-drug library

public/data/molecule-workbench/approved-drugs.json, built by pipeline/molecule-workbench/ from ChEMBL 37 (released 1 May 2026, retrieved 28 September 2026; CC BY-SA 3.0, share-alike):

  • Query: molecule.json?max_phase=4&molecule_type=Small%20molecule, 3,475 records; kept parent molecules (2,306) with a structure (2,197). RDKit.js parses all of them.
  • Row (DrugRow): [chemblId, prefName, smiles, firstApproval | null, routes, withdrawn 0/1, pc1, pc2, qed]; PC coordinates rounded to three decimals, QED to two.
  • Route letters O, P, T from ChEMBL's oral, parenteral and topical flags, including flags set only on a salt or other form of the parent (this adds one drug, bortezomib). routeGroup(): oral wins, then parenteral, then topical, else "other or unknown". Counts: 1,268 oral, 371 parenteral but not oral, 221 topical only, 337 other.
  • first_approval is ChEMBL's earliest known approval year (often the US year; 313 drugs have none). 268 drugs carry ChEMBL's withdrawn flag and stay in the library.
  • Drugs ChEMBL types as protein, oligosaccharide or unknown, and drugs without a structure, are absent (theophylline, ciclosporin, carfilzomib, acarbose and cisplatin are examples given in the limits).
  • Excluded sources (registry): DrugBank (non-commercial licence), WHO ATC index (no commercial copying), Open Babel (GPL).

Licence handling for derived exports is in Exports; licence facts are in Data sources. Whether the share-alike terms reach a user's export is a legal question held for the General Counsel.

The small-molecule rule (D-0042)

biologic() in objects.ts refuses an inbound compound whose facts carry an Open Targets drugType other than "small molecule" (an antibody, a protein-typed peptide such as semaglutide, an oligonucleotide), so Send to never offers Molecule for it. A record carrying its own SMILES still opens as that structure. D-0042 (10 October 2026) keeps this rule and closed Q-22. The rule applies to hand-offs and the object-page tab; Molecule's own query field has no molecule-type check (see the gaps section).

Engines: how RDKit.js, OpenChemLib and 3Dmol.js are loaded and driven

General loading of vendored engines is in Architecture and Engines.

RDKit worker

src/components/suite/engines/rdkit/rdkit.worker.ts is created by RdkitClient (client.ts) with new Worker(new URL("./rdkit.worker.ts", import.meta.url)). On its first request it runs importScripts(vendor.rdkitJs), then initRDKitModule({ locateFile: () => vendor.rdkitWasm }), sets prefer_coordgen(true) and compiles the QED and PAINS queries (compileAll). Requests (protocol.ts):

Request Result Client timeout
init version and start-up time 120 s (never called by Molecule)
analyse AnalysisResult 120 s; cached per SMILES, 40 entries
library count and fingerprint time 150 s
neighbours NeighbourResult 150 s
series SeriesRowResult for one series row (identities, descriptors, bits, three nearest, forms; no drawings) 150 s
depict light and dark SVG plus InChIKey 30 s; cached, 400 entries (used by Assay)

Error codes: invalid (unreadable), too-large, network (a download failed), engine (RDKit did not start), crashed, and since Wave 4 incompatible (the library's versions differ from the engine's; no similarity is computed, and the client treats it as final, like invalid). After RDKit has started, any exception is treated as a possibly corrupted WebAssembly module: the worker replies crashed and closes itself, and the client fails every request in flight and starts a fresh worker on the next call. A timeout after RDKit has loaded also restarts the worker, because a running calculation cannot be interrupted; a timeout during the download does not. EngineState (idle, starting, ready, restarting, failed) feeds the status chip.

Conformer worker

ConformerClient in clients.ts runs one request at a time; a new request cancels (terminates) the one in flight. Timeout 90 s, with one "Try again with more time" at 240 s (shape-viewer.tsx), a 300 ms start delay, and a cache of the last 20 conformers by SMILES. Generation starts only when the 3D panel is within 200 px of the viewport. "Stop now" terminates the worker.

Structure editor

structure-editor.tsx imports openchemlib dynamically on a mouse or pen press in the box or on "Load the drawing editor" (about 340 KB compressed, per the copy). It opens in the query bar's drawer on one paper sheet (8 px corners); its keys are the suite's flat keys (buttonPrimary, buttonSmall from src/components/suite/recipes.ts). Touch screens need "Tap to draw" first so a swipe scrolls. Drawings are read with toIsomericSmiles() and reported after a 450 ms pause; RDKit then analyses them. The editor canvas is removed from the Tab order; the query field is the keyboard route.

3Dmol.js

shape-viewer.tsx loads vendor.threeDmolJs with loadScript when the panel is near the screen, then attachViewer() (three-dmol.ts). 3Dmol.js 2.5.5 cannot dispose of a viewer (its listeners and observers cannot be removed), so one viewer per visit lives on its own div, moved into the stage on mount and emptied and stopped on unmount. The background follows the suite theme; rotation is off by default and unavailable under reduced motion.

Objects, Studies and hand-off

The general mechanisms (object store, Send to, Studies, saved work) are in Storage and state.

Registry: objects: { owns: ["compound"], reads: ["compound", "sequence", "product"], writes: ["compound"], tabs: ["compound"] }.

Inbound rules (objectInbound in objects.ts)

Type Rule
compound A record with a SMILES opens that structure (by hand-off record, private: true, never in a URL query), titled with its label unless the label is only a formula or InChIKey. Otherwise: refused if a public source types it as a biologic (D-0042) or if it is the person's own (mine) compound; else opened by a name taken from its label or aliases (lookupName: letters, digits, spaces and a few punctuation marks, at least three letters, not an InChIKey, ChEMBL id or formula)
product (from Pipeline) Opened by the ingredient name its sender gives; refused without one
sequence (from Sequence) A protein sequence opens as a peptide SMILES (the sender's, or peptideSmilesForLink from the residues); nucleotide sequences are refused
assay (from Assay, H22) A saved assay (mine:assay/<uuid>) opens by its ref, mine, by hand-off record (private: true), and only when the sender's row says it holds fitted results (seriesRows above zero, a planning field the Assay subject row gives through its payload hook; it is not a hand-off key and never travels). From an object page, which gives no such field, Molecule is not offered

inboundLibraryChoice() makes an inbound name wait for the library, and opens the exact ChEMBL record when the link named obj=chembl:<id> and the library holds it.

What the app writes

useMoleculeObject() builds compound records with moleculeInputs() once the catalogues have loaded. The whole input is keyed inchikey:<key>, with its canonical SMILES in data. Facts are added only when a public source holds the structure: ChEMBL (id, approval year, release), PubChem (CID), GSRS (UNII, definition id), and then an rdkit block with formula and mass. A user's own structure gets no facts, so Send to never puts its InChIKey in a query. For a salt, the parent is a separate record linked with form-of. Opening a molecule records nothing new (D-0045): the app refreshes records the person already has (upsert(..., { as: "refresh" })), and carries the parent once the salt is kept. The delayed carryStructureParent operation reads the stored salt’s run provenance, including explicit personal provenance, and commits a carried parent and new or revived form-of link together. Existing live links stay unchanged; a removed salt cannot recreate its dependencies through a late callback in the same-tab write queue. This fixes the D-0045 navigation race without changing general public-object provenance or cleanup rules.

Library drugs shown as similar or nearest carry their own object actions (libraryDrugInput, keyed chembl:<id>).

moleculePayload() is the Send to payload: smiles, name and cpd, a compressed JSON compound record (name, SMILES, formula, formula weight, parent mass and formula, both InChIKeys, dropped counter-ions, origin kind). The receiving app recomputes masses with its own atomic weights. The J3 walk registers molecule.inputs, molecule.show-scorecard and molecule.send-to-calc (Send to Calc).

The walk "From a compound to its nearest approved drugs"

Wave 4 step 6 (CPO Wave 4 §2.7; CSO pre-rulings, ruling 4). WALKS.nearest in src/lib/walk/key.ts: version 1, six steps, all hosted by Molecule. It starts from J3's compound and SMILES (erlotinib hydrochloride, D-0024; START_INPUTS.nearest in src/lib/walk/href.ts), and is reachable only by walkHref(WALKS.nearest, 1) until the CMO's landing-band line lands (CPO condition 6; docs/NEXT.md).

  • Words in content/walks/nearest.ts; slots filled by src/components/suite/walk/slots.nearest.ts (pure, so the Node test fills them as the bar does); the definition definitions.nearest.ts loads through WALK_DEF_LOADERS only while the walk runs (chunk marker drybench-steps:nearest@1). Step 5 has two texts, chosen by the series' censored count (0 in the fixture, so textCensored is never shown). The Next labels were shortened to fit the 300 px bar at 375 px ("Next: nearest drugs", "Next: the map", "Next: side by side", "Next: a series", "Next: the file"), "Go to the part" replaces "Go to the number", and the text toggle reads "Hide text" / "Show text".
  • Values only from the Python oracle. content/walks/nearest.values.ts is generated by pipeline/walks/nearest/walk_values.py (RDKit 2026.03.6, the policy): the parent Erlotinib (CHEMBL553) left out, HCl dropped, the ten nearest from Gefitinib (0.407895) down, the library count and release (ChEMBL 37). The step texts quote no Tanimoto; they point at highlighted rows. content/walks/nearest.test.ts holds every filled text to 280 characters and forbids slot values or Tanimoto typed in the words.
  • The example series (step 5) is content/walks/nearest.series.ts, the 28 EGFR IC50 rows of ChEMBL document CHEMBL1134804 (Barker et al. 2001; all =, nM, assay CHEMBL677210) as served by ChEMBL 37, generated from the write-once fixture pipeline/walks/nearest/fixtures/chembl1134804-egfr-ic50@ChEMBL_37.json with the CC BY-SA 3.0 notice. An example series says its source, release and licence with "share-alike" on screen, and its Series (CSV) README adds the source, release, retrieval date, EMBL-EBI and the share-alike line.
  • Actions and targets. Actions molecule.show-similar, molecule.show-bench {section} and molecule.load-series-example (series-example.ts's loadExampleSeries: reads the rows with the mapping their header names, writes one note under the walk's label or reuses the run's record after Back, describes it, and writes the described record into that note before the series reads as saved). Targets molecule.identity (the scope notes), molecule.similar-list, molecule.space-stage, molecule.compare-table, molecule.series-table and molecule.export-button (a "Download the series (ZIP)" control on the Series panel, because the top bar's export menu has one button per breakpoint and sits in the menu sheet on a phone).
  • J3's own definition is still imported statically in the bar's chunk (WALK_DEFS in src/components/suite/walk/definitions.ts), and the walk-chunk budget (10 KB) was not lowered (CTO-3, accepted as a follow-up).

Saved research views

WorkCaptureControls saves a note object whose data is a MoleculeWorkspaceData (workspace-inputs.ts, schema molecule-research-view version 1): the input (Current: SMILES, label, origin), layout, section (kept as the work was saved: the tab on screen is not research state, so switching tabs is never an edit, and saved work opens on Overview, the CEO's feedback of 14 October 2026), Bench comparisons (up to 4) and retained comparisons (up to 100) as exact { id, name, smiles }, the selected drug, research context text (at most 20,000 characters) and the library reference (release, retrieval date, RDKit version, descriptor version). Values are not stored: they are recomputed on reopening, and the copy says a saved view is not an immutable result. restoreMoleculeComparisons() restores a comparison only when both the ChEMBL id and the SMILES still match the installed library; otherwise it is kept as "retained" and shown, never silently substituted. use-saved-work.ts writes mine=<ref> into the fragment after saving and checks the workspace scope before applying any asynchronous result. A "molecule-research-inputs" JSON export offers the same payload.

Kept edits. Edits to a named task are kept as its draft beside the record, never written into it by the timer or by leaving (D-0068 rule 8; Storage and state). When use-saved-work.ts opens a research view from mine=, it looks up the live task that owns the record (readOpenWork) and that task's draft (readOpenWorkDraft); it shows the draft when readMoleculeWorkspace accepts it, else the record, through show, which sets the input, layout, the Bench section, context (the scorecard opens on Overview unless the call is a reload), comparisons and appliedDraft in one update (a refused draft is offered as unshown). The owning task is put in the research context, so a bookmark of #s=mine= alone opens the task with its offer. reloadOpened serves onDraftResolved: Discard puts the saved view back, its Undo the draft, each checking the open ref and the scope first; with the library already answered, a reload restores the comparisons at once instead of showing the opening line. The payload rebuilt from the screen carries the library's current release in reference and a selection an older record may not store, so it can differ from the stored data with no edit: a saved record put on screen is held in savedShown until it has opened with its own input and query, then savedOnScreen is bumped so the control takes it as the task's baseline. A library that failed on open and loads after a retry changes reference, which then counts as an edit. Molecule has no autosave of the research view; its object actions act on the compound object. e2e/objects/work-draft-molecule.spec.ts covers the offer, Discard and Undo, a resume with no edit and the address alone.

One Molecule-local commit-phase publisher owns the breadcrumb and chemistry/saved-work status after saved-loader state is available. Opening and unavailable recovery clear the previous molecule subject and publish the existing saved-work sentences; completed restoration republishes current chemistry under an identity containing reference, arrival generation, scope, phase and sentence. It retracts only its own obsolete sentence, preserves initial ordinary discovery silence, and does not reannounce on an unchanged render. Ordinary save adoption updates the owned reference silently when generation/scope/phase/sentence have not changed, preserving the explicit save acknowledgement. The shared announcer's existing coalescing and same-text deduplication remain unchanged; rapid identical-ready restoration is separately tested and cannot be assumed solved by app-level ownership (REL-517).

Each checked same-document arrival that names saved work (mine) advances a local saved-work generation, including arrival at the same mine reference without SMILES. Back and Forward between entries without mine advance nothing, so moving between unsaved molecules never shows the opening state or clears the research context (REL-530). The saved loader's opened/loaded identity includes reference, generation and workspace scope. Until that identity finishes, existing loading/recovery UI gates the workspace and save payload. The loader rereads the authoritative record and compatible kept draft; history-state chemistry never substitutes for a private reference. Completion after another reference, arrival or scope change is ignored after every asynchronous seam, including Assay projection, saved-series loading, draft reads and comparison restoration. A stable owner-current predicate checks the committed arrival reference and live workspace scope at each existing completion boundary. Immediate and queued comparison restoration share one guarded state transition inside the workspace bundle; the queued path keeps its microtask cancellation and the answered-library error path still retains exact saved inputs. Ordinary save acknowledgement does not manufacture an arrival. Missing or deleted same-reference records retain unavailable recovery without falling back to the previous molecule (REL-516).

Molecule captures a checked initial readAddress result before canonicalisation. Saved-work lookup and initial view/section selection use that checked state, so a query-only or malformed mine never enters the saved-record loader. readAddress retains the original dropped-parameter reasons and via report, which the wrapper forwards through the existing shell arrival notice after commit. Subsequent molecule/view writes preserve only checked app-owned state. Back/Forward revalidates the arrival, reports refusals and replaces its canonical URL without adding history entries; a retained history molecule must still match the checked SMILES. Initial committed cleanup, live-address cleanup and history-arrival cleanup share the module-local replaceCanonicalAddress helper, which reads current history state at invocation and keeps replacement nonfatal if the browser refuses it. Each caller retains its guard and layout/event position; validated-state application continues even when cleanup cannot run. The separate history-state write that adds moleculeWorkbench metadata remains distinct. Preview memory and trusted reference seeds retain their existing ownership. REL-515 tracks this app-local validation repair; source access, saved-record recovery, cancellation and public Copy link projection are unchanged.

The registry and generated light index declare the already-consumed mine saved-view key alongside smiles and name (REL-514). This receiving declaration prevents the shell from falsely reporting a valid saved-view fragment as unread. It grants no sharing or access permission: the app's existing parser rejects query-string mine, removes it from the canonical query and reports its fragment-only reason; a mixed arrival uses only the checked fragment reference. Malformed and unknown fragment values retain their diagnostics. Copy link projects structure/name/view/section without mine; public link builders still reject private query parameters outside production and strip them in production. Existing scoped saved-record and unavailable-record guards remain authoritative.

The v1 saved-view reader admits all eight existing origins. ChEMBL receipts require the existing ^CHEMBL\d+$ identifier and a nonblank retrieval-date string of at most 50 characters with a finite Date.parse result. GSRS additionally requires nonblank opaque unii and definitionId strings of at most 100 characters. Date-only and offset timestamps retain their original spelling; identifiers with surrounding whitespace retain that whitespace. These checks establish transport compatibility, not registry authenticity or strict calendar verification. No current-curation whitelist, source refresh, trimming or missing-date inference is applied. Existing origin guards remain unchanged, including historical PubChem receipts without a retrieval date. REL-513 records the repaired producer/reader mismatch.

A saved series is a separate note object created with createMine("note", { label, data: { text, moleculeSeries } }, { by: "molecule-workbench" }). moleculeSeries is a SeriesRecord: { v: 2, kind: "molecule-series", label, text, mapping, results, fingerprintPolicy, rdkit, descriptorVersion, source? }, where text is the table as given, mapping the confirmed columns (smiles, id, name, up to 50 value columns with endpoint and unit), results up to 200 SeriesRowResults (or null), and source is set only for the walk's example ({ name, release, retrieved, licence, document, example: true }). Each non-null row retains its exact SMILES input and matching method-version receipt, reference-library release/retrieval/build receipt, and the ID/name/SMILES of nearest drugs and excluded forms. readSeriesRecord deeply validates descriptors, fingerprints, similarities, reference identities and input correspondence. Valid historical results remain as recorded even if engine versions differ; they are never mapped through the currently installed library. Legacy v1 or malformed/unbound results are withheld, with the original results preserved in unverifiedResults; the original record can be downloaded and the input can explicitly be described as a new series. No source note is overwritten by that action. resultsCurrent only controls an informational notice.

The table, QED sort and CSV use seriesQed: QED is blank/not applied outside the ordinary-organic domain, with an explicit exported qualification. series-export.ts is a pure projection of the retained series; CSV rows include the saved method versions and library receipt. Bundle provenance describes the series and its recorded methods/reference, not the currently active single molecule: the subject is the series (its note ref when saved), the query names the record's RDKit, descriptor and fingerprint-policy versions, the descriptor, QED, PAINS and Morgan methods carry the record's RDKit release and policy, and the ChEMBL source lists every distinct library receipt of the readable rows (mixed: … when they differ). The file name comes from the series label. The ZIP also holds <base>.series-record.json, the stored record exactly as held (a withheld record's original results under results, as the recovery download writes them), and the README says whether the series is saved, that the CSV is a partial projection (the first of up to three neighbours; bits and the other descriptors in the record only), which columns are worked out at export (status, flags, nearest_name, QED applicability under today's domain rule, pIC50, the re-read input columns), what reference_release means and that nothing was recalculated or checked against the installed library. The export re-reads the series after its writers load and refuses with "The series changed. Export it again." when the record or its readiness changed; a save landing meanwhile is not a change (CSO, CTO and CPO E3, 24 October 2026).

Object page tab

object-tab.tsx runs useWorkbench with address: false and an in-memory URL, opening the record's SMILES or the name its inbound rule would send, and shows the Scorecard and Profile. A record that the rule refuses shows an empty state.

Exports

exportItems() in exports.ts registers these with the shell's Export menu. Tabular exports use the shared CSV bundle writer (src/components/suite/data/csv-bundle.ts); every file carries a provenance block built by buildProvenanceBlock (app and version, subject InChIKey, canonical link in #s= form, sources with versions, methods, limits, regulatory and not-medical-advice statements). See Storage and state.

Item Format Contents Source marking
Descriptors CSV bundle Canonical SMILES, InChI, InChIKey, formula, every descriptor with unit, limit, rule and within/over, descriptor_version, stereocentres, fragments, QED, rule-of-5 and Veber results with summaries, PAINS names rdkit, inchi
Similar drugs CSV bundle (zip) relation (neighbour, then for each left-out form same-compound for a whole-SMILES match or same-compound-parent otherwise), rank (empty for a form), name, ChEMBL id, SMILES, Tanimoto, year, route group, withdrawn, library QED, rdkit_version, descriptor_version, fingerprint_policy. No ranked rows when the top score is 0; the README adds the line on forms rows chembl, with the ChEMBL notice
Comparison CSV bundle (zip) The table's own snapshot: query and picked drugs, every comparison row and the difference from the query where both values are available and applicable; analysis_status, analysis_reason, scoring_applicability, qed_reason, ro5_reason, computational_origin (worker or shipped-precomputed, naming the bundled method, not a per-call check), whole and described SMILES and formula, dropped_fragments, structure_qualification, descriptor_source (rdkit) and record_source (chembl for library rows); the three version columns. Withheld values are empty. An export whose comparison changes before delivery is refused ("The comparison changed. Export it again.") mixed rows: no single row source; RDKit and ChEMBL both cited, with the ChEMBL notice
Series CSV bundle (zip) with <base>.series-record.json Every row as given, status and flags, both identities, descriptors, the nearest drug, library_forms_excluded, each value column with endpoint, unit, qualifier, value_k_as_given (text), _censored (boolean) and pIC50, and the three version columns rdkit; rows of the walk's example series chembl, with the share-alike line
Depiction SVG RDKit SVG for the current theme with <title>, <desc> caption and the provenance block in <metadata> rdkit
Depiction PNG The same SVG rasterised at 2× on white or the dark surface rdkit
Conformer SDF and .provenance.json The molfile with a <provenance> data field and $$$$ openchemlib
Research view inputs JSON The saved-view payload

The OpenChemLib version in the conformer provenance is the one the conformer worker reports at run time (version from openchemlib), falling back to bundledVersions.openchemlib (src/lib/vendor.ts), which src/lib/vendor.test.ts checks against package.json, the installed package, OpenChemLib's runtime version and the credit line in content/tools/molecule-workbench.ts. METHODS.morgan names the policy and its version string (2026.03.6; fingerprint morgan-r2-2048-described-v1), so every provenance block that cites it carries the policy.

Methods and checks dialog

The methods and checks dialog (D-0085; Shared shell, Registry and content) reads content/apps/molecule-workbench.methods-sheet.ts: Scorecard and Compare, each with every method record, and the docs page's Map and Engine entries; the docs page's verification entries are the app-wide checks. The dialog does not hold the library, so verified records take methodWithLibraryNote ("every approved drug" in place of the count methodWithNote reads at runtime). definition.methods.group() returns the bench store's view.

Verification

Unit tests run under npm test (node --test). On 10 October 2026, running the test files listed below directly with Node 22.22.0 gave 113 passing tests (97 in the Molecule and content files, 16 in molecule-description.test.ts) and no failures. See Build, test and CI and Oracles and verification.

Test file What it checks
src/components/tools/molecule-workbench/chem.test.ts Against fixtures/rdkit-reference.json (Python RDKit 2026.03.6, 30 molecules: 15 ChEMBL drugs including all seven examples, the three QED.py doctest molecules, three ring-count cases and nine edge cases): canonical SMILES, formula, masses, logP, TPSA, Fsp3, generic and Lipinski counts, rotatable bonds, rings, heavy atoms, charge, all eight QED inputs, QED to 1e-9, PAINS names and Morgan bits. Also: invalid and oversized input, the heavy-atom limit, the SMILES atom count against RDKit for every library drug, stereocentres, salt and metal-complex parent selection, CoordGen choice for every library drug, tautomer InChIKeys
src/components/tools/molecule-workbench/logic.test.ts Popcount and Tanimoto (including against Python's Morgan bits), top-k ranking, Jacobi eigen-decomposition, PCA on degenerate data, the shipped map model (unit loadings, centred), axis text, nearest in plane, viewport maths, route groups and library route counts with hand-checked examples, map contrast, Lipinski and Veber, the address and legacy rewrite, PubChem response reading, example structures against the library, library file shape, display names, formula ordering, heavy-atom counting, the organic domain, counter-ion rules, Hill order, off-map detection, titleFor, "anhydrous" names, inboundLibraryChoice
src/components/tools/molecule-workbench/default-example.test.ts The shipped precomputed example equals what analyseSmiles and neighboursOf compute now, with the current library and RDKit release
src/components/tools/molecule-workbench/sections.test.ts Section links keep the subject and layout
src/apps/molecule-workbench/rule-of-5-rows.test.ts Profile strips agree with lipinski() (audit C2)
src/apps/molecule-workbench/description-identity.test.ts Only exact public identities fetch descriptions
src/apps/molecule-workbench/arrival.test.ts Generated-index saved-view declaration, valid and malformed fragment reporting, query-only rejection and canonical removal, mixed query/fragment precedence and existing public-link privacy guards
src/apps/molecule-workbench/workspace-inputs.test.ts Saved-view validation and exact restoration of comparisons
src/apps/molecule-workbench/views/bench/store.test.ts Comparison picks survive view changes and clear on an account change
content/apps/molecule-workbench.workspace.test.ts Copy rules (the comparison is never "the bench"; messages fit the single query field)
src/lib/search/molecule-description.test.ts Description readers and identity checks
src/components/tools/molecule-workbench/policy.test.ts (10 tests) Against the fingerprint-policy oracle's write-once fixtures (pipeline/molecule-workbench/fixtures/): one policy id on the library, fixtures and versions; every controllable Morgan setting passed and the two ignored keys not named; the 75 multi-fragment rows' described fragment and bits exactly; the shipped sameCompound pairs equal Python's; for the oracle's queries the forms and the top ten equal Python's (shared and union counts exact, Tanimoto within 1e-12); ruling 2's named cases (benzylpenicillin leaves out CHEMBL29 and CHEMBL1628385; theophylline leaves out CHEMBL1208646; erlotinib hydrochloride leaves out CHEMBL553; esomeprazole keeps omeprazole ranked); the default example's neighbours; libraryCompatibility; topSimilar with a set; RDKIT_VERSION against vendorVersions (drift test)
src/components/tools/molecule-workbench/series.test.ts (6 tests) Against fixtures/series-example.json (series_reference.py): the table read with qualifiers kept; each row's identities, descriptors (1e-6; QED 1e-9) and nearest drugs; the within-series matrix and same-compound rows (shared and union exact); pIC50 (1e-9); text kept as text; the 200-row refusal and endpoint-and-unit rule; retained versions, deep input/result validation, recoverable old records, saved-reference export identities and withheld out-of-domain QED
src/apps/molecule-workbench/series-evidence.test.ts (9 tests; the README test also checks the edited-example line) The series ZIP (E3), records built from fixtures/series-example.json with describeSeriesRow: the file order with the record last; the record deep-equals the stored one and reads back verified; row 9 stays null; row 1's three neighbours in fixture order and its left-out form CHEMBL553; an older-methods record written unchanged with its own versions, methods and subject; a swapped library leaves the neighbours alone; a withheld record's results restored as the recovery download writes them; mixed library receipts all listed; the README's saved and unsaved lines and the forbidden words; the refusal when the series changed (not when it was only saved). Withheld, mixed-receipt, unsaved and changed cases are synthetic Two more (SCI-280): each row's provenance is its own origin (an unchanged example's ChEMBL receipt; RDKit for a changed example or a person's series); every CSV column is copied from its record path (checked value by value), named as worked out in the README, or a provenance column
src/apps/molecule-workbench/science.test.ts (1 test) formatValue: a value that rounds to zero shows "0.00", not "-0.00"; other negatives keep their sign (SCI-261)
src/apps/molecule-workbench/series-objects.test.ts (5 tests) E4, rows from fixtures/series-example.json: row 1 (the salt) is inchikey:GTTBEUCJPZQMDZ-UHFFFAOYSA-N with its SMILES as entered, whole formula weight and the described fragment as parent (AAKJLRGGTJKAMG-UHFFFAOYSA-N, HCl dropped), no facts or aliases, and its payload passes readCompoundPayload with the formula; a one-fragment parent mass; sodium benzoate's keys; no compound for row 9, no-SMILES, too-long or keyless rows (synthetic); rows 3 and 4 share one record, each with its own SMILES, and 7 and 8 do not; the second S9 is row 10
src/apps/molecule-workbench/views/bench/series-repeat.test.ts (3 tests) E5, synthetic records: the repeat's draft keeps the table and columns under a new label and the unchanged line, finish returns the source as held when unedited, and the record stays deep-equal; edits to the text or a unit are detected; an edited example's source is marked derived and its line keeps the licence without claiming the source's rows; a person's own series carries no source; a repeat of a repeat replaces the time and stays within 80 characters
content/walks/nearest.test.ts (3 tests) The walk's texts at most 280 characters filled; every slot filled; no slot value or Tanimoto typed; the compound is J3's; the parent is not among the ten; the series is the fixture's rows
src/lib/vendor.test.ts Also, since Wave 4, the bundled OpenChemLib version against package.json, the installed package, the runtime version and the credit line

The three Wave 4 files above were run singly with node --test on 11 October 2026; all passed.

Whole-library oracle

pipeline/molecule-workbench/rdkit_reference.py library writes Python RDKit values for every library drug and verify-library.mts compares them with the browser code (tolerance 1e-6 for real numbers, exact for counts, strings and bits; the QED gate is 0.01, though the recorded largest difference is 2.78e-16). The last run, 10 October 2026, is committed in docs/science/evidence/molecule-library-2026-10-10/ with the reference file (gzip), the log and the library's SHA-256 (73855eac...), which matches the file in the working tree: all 2,197 drugs match, 124 match a PAINS filter. The PCA was last cross-checked against an independent NumPy fit on 7 October 2026 (loadings to 4.22e-15, per the pipeline README); that check is not committed as evidence. The commands and pins are in Data pipeline. methods.ts verifiedNote dates that check 10 October 2026 and adds the described-fragment bits of the 75 multi-fragment rows against the fingerprint-policy fixture (CSO condition C6). Since the CSO's condition C3, rdkit_reference.py states every Morgan generator argument, as fingerprint_policy.py does.

Fingerprint-policy and series oracles

pipeline/molecule-workbench/fingerprint_policy.py (Python RDKit, refuses any release but 2026.03.6) re-implements the described-fragment rule and ruling 2 from their documentation, imports no TypeScript, and wrote three write-once fixtures before the engine changed (commit 091523d): library-multifragment-bits.json, library-same-compound.json and neighbours-under-policy.json. series_reference.py wrote series-example.json: a made-up series with a salt and its parent, a tautomer pair standard InChI merges (2-pyridone and 2-hydroxypyridine), a keto-enol pair it does not, stereoisomers, an unreadable SMILES, a duplicate label and censored values, with hand pIC50 values. See Oracles and verification.

Browser tests

Spec Coverage
src/apps/molecule-workbench/arrival.test.ts Generated-index saved-view declaration, valid and malformed fragment reporting, query-only rejection and canonical removal, mixed query/fragment precedence and existing public-link privacy guards
src/apps/molecule-workbench/workspace-inputs.test.ts Exact JSON round-trips for all eight origins, production curated GSRS and synthetic historical receipts, identifier/date bounds, malformed-field rejection without throwing, unchanged workspace guards and exact comparison restoration. Semantic persistence contracts, not numerical scientific validation
e2e/molecule-workbench/origin-save.spec.ts Deterministic actual ChEMBL fallback (CHEMBL1355736 with ChEMBL_37's recorded monohydrate SMILES) and curated GSRS lookup → Save work → actual Study → reopen; exact input/receipt, destination and canonical membership assertions, Overview entry and existing recomputation qualification; captures enabled-save and reopened states. Execution evidence belongs to the Stage E record
e2e/molecule-workbench/arrival-save.spec.ts Saved-work arrivals on actual saved records: query-only and mixed query/fragment mine refused and never revived; malformed, unknown and unavailable references; Back/Forward through rejected history; same-reference saved, kept-draft, missing and deleted recovery; a deferred read that cannot overwrite a newer arrival; rapid restoration within the shared timing policy; Back/Forward between unsaved molecules without the opening state (REL-530); and a refused canonical cleanup (history refusing the query-changing replaceState) that still applies the checked arrival and later molecule writes
e2e/molecule-workbench/workspace.spec.ts Start and precomputed Imatinib with no RDKit request and no CSP violation (QED 0.389, "All six criteria met"); layout switching and memory; v; pasted SMILES in the fragment only; legacy ?smiles= rewrite and the 2,001-character refusal; keyboard combobox and ChEMBL id; unreadable SMILES; clearing; theme-following depictions; the editor drawer; 3 and m; descriptor CSV and SVG exports with provenance; reduced motion; Bench map theme; work capture layout. Runs offline (strictOffline) in the desktop, phone and desktop-dark projects
e2e/molecule-workbench/content-tabs.spec.ts Section links in both layouts; 3D mounted on demand with its controls kept
e2e/molecule-workbench/description.spec.ts Description placement, unavailable and malformed responses, cancellation on compound change
e2e/molecule-workbench/series.spec.ts The series section on phone and desktop with axe and no sideways page scroll; the Series (CSV) export (desktop)
e2e/molecule-workbench/nearest-walk.spec.ts The walk on phone and desktop: each step's target clear below the bar; the oracle's top three in Similar and Compare; 28 rows and the notice; the file carries the policy, the source and the share-alike line; at 375 px the bar at most 300 px with controls at least 44 px on at most two rows, no sideways scroll, axe; Remove leaves no example record. The bar assertion lives in e2e/support/walk.ts
e2e/objects/*.spec.ts, e2e/smoke/*.spec.ts Send to, object pages, identity across hops (J3), shell accessibility and theme across apps

The browser tests were not run for this page. The Wave 4 record reports the Molecule suite at 10 failures that also fail at the wave's base 83d1e09 (among them workspace.spec.ts:108 and :136), confirmed again at the base for the CTO's merge condition 1. e2e/fragment-search.spec.ts tests the Bench DNA search, not Molecule.

What is not covered: live PubChem and ChEMBL behaviour (tests stub or block them), the request made by name suggestions while a SMILES is typed, the OpenChemLib conformer geometry against any reference, the Bench comparison's domain handling, and performance figures (fingerprint time, conformer time), which are measurements recorded in comments and the README rather than tests.

Known gaps, defects and divergences

Each item was found by reading the code on 10 October 2026; none was reproduced in a browser unless stated. Known issues collects them across the suite.

Defects and risks:

  • A typed or pasted SMILES of 3 to 120 characters, while the suggestion list is open, is sent after a 250 ms pause as q= to /api/search/compounds, in the URL query string, and forwarded to PubChem autocomplete (views/query-bar.tsx:73, src/lib/search/use-pubchem-suggestions.ts). Nothing filters SMILES-like text out. This conflicts with the app's statements that a pasted or drawn structure is not sent to a service or put in a query string. The e2e paste test presses Enter before the debounce fires, so it does not catch this.
  • REL-513: the saved-view reader now admits bounded production ChEMBL and GSRS receipts. Semantic persistence regressions cover exact source/input preservation and malformed-receipt rejection; browser acceptance is recorded separately in the Stage E running record.
  • D-0042's reasoning says Molecule's panels are never shown "for a large peptide reached by name alone", but lookUp() in use-workbench.tsx has no molecule-type check: a peptide name typed into Molecule's own field resolves through PubChem if PubChem returns one compound (semaglutide's structure is within the 2,000-character and 500-atom limits). The decision's rule is enforced only for hand-offs (objects.ts biologic()). Not verified against live PubChem.
  • SCI-255 (fixed 24 October 2026, E2): the Bench comparison judged the rule of 5 for every column without the organic-domain check, took picked QED from the library's stored value beside worker descriptors, showed failed or unscheduled analyses as computing for ever, and exported the same mixture labelled as ChEMBL; comparison-model.ts now applies the scorecard's domain gate and the table and CSV read one snapshot.
  • SCI-256 (fixed 24 October 2026, E3): the walk's example series note held no results while the screen said it was saved; the note now receives the described record before the series reads as saved.
  • REL-572 (fixed 24 October 2026, E4): the series row detail said "RDKit cannot read this SMILES" for withheld, no-SMILES, too-long and still-describing rows; it now gives the table's own reason.
  • SCI-280 (fixed 24 October 2026, the CSO's closing review): the series ZIP's per-row provenance credited every row of an example-sourced series, including an edited example and added rows, to ChEMBL under the library's receipt. SCI-281 (the row hand-off's missing series provenance), SCI-282 (unlabelled current-rule overlays) and SCI-283 ("Theophylline" resolving to a hydrate) are open; SCI-261 (a signed zero) is fixed: values that round to zero show "0.00".
  • MNT-211: molecule-series-chunk 13.4 KB on main; 13.3 after E3 and E4 moved the example series and row detail out; 12.6 KB once the review form moved out (official artifact 12565542221), under its 13.1 ceiling; closed.
  • REL-586 (fixed 24 October 2026): the status bar kept "Series ready: …" after the series was put away; the series section now withdraws its own ready line. REL-585 (the ZIP README's file list), SCI-261 (a signed zero logP) and REL-587 (a reopened series off screen on a phone) are open for the CTO, CSO and CDO.
  • REL-573 (open, recorded): after "Describe as a new series", Back or Forward to an entry naming mine may reopen the saved series over the unsaved repeat (by source reading; MNT-210's trigger).
  • The 3D conformer's fragment choice (OpenChemLib stripSmallFragments) is separate from RDKit's prepareInput(); they could disagree on ties or unusual mixtures.
  • readPubchemResponse() returns an error when PubChem returns more than one row, so otherMatches is always 0 for a found result.
  • The PubChem name lookup bypasses the suite's network layer: no consent prompt, rate limit or backoff (build specification §5.3 asked for consent "always", lanes and backoff; the registry comment says the endpoint's consent will change when a confirmation is added).
  • client.init() is never called. Since Wave 4 the worker compares the running module's rdkit.version() with the library's rdkit field when it loads the library (refusing with incompatible), and policy.test.ts ties RDKIT_VERSION to the vendored version.
  • The audit of 10 October 2026 measured the Molecule definition chunk at 595 KB against a 22.6 KB budget (scripts/size-budgets/molecule-workbench.json); not re-measured here. On the Wave 4 branch the build record gave molecule-definition-chunk 27.1 KB (26.8 at the wave's base); measured against main's head after the merge, commit 1af3986 moved releaseName into its own module so the definition no longer carries the methods copy, and recorded 18.0 KB of 22.6 (the workspace chunk 141.6 of 149.4).
  • "Nearest on the map" excludes only the identical row (nearestInPlane(…, identical) in engine.ts), so other library forms of the compound can appear among the ten nearest map points while they are left out of the similarity ranking.
  • A form found only by InChIKey (a redrawing) is exported with relation same-compound-parent, the same value as a salt's parent (exports.ts); only a whole-SMILES match is same-compound.
  • Carried from Wave 4 (docs/NEXT.md): LibraryDrugActions in the row detail (per-row Pin, Send to and Add to Study were built in E4), and structure thumbnails in the table from 640 px; the walk is link-only until the CMO's landing line.

Stale comments, documents and duplication:

  • objects.ts:55-57 says whether peptides should be sendable by name "is held for the CPO"; D-0042 closed that question.
  • pipeline/molecule-workbench/README.md:13 gives default-example.json as 234 KB, 27 KB gzip; the file is 471,592 bytes, 36,800 gzip (thumbnails in two themes account for about 419 KB).
  • pipeline/molecule-workbench/rdkit_reference.py:19 says the fixture is read by qed.test.ts, which does not exist; build-default-example.mts says chem.test.ts compares the example, but default-example.test.ts does.
  • panels/shape-panel.tsx:11 refers to viewer-3d.tsx and three-dmol.ts:2 to a /tools page; neither exists now (/tools/molecule-workbench redirects).
  • use-workbench.tsx keeps inputRef and a focus/pointer listener that no current view attaches; views/mark-poster.tsx is unused.
  • build-library.mts rounds the stored model (six decimals, explained variance four) while rebuild-map.mts, which produced the shipped file, does not; a rebuild with the first script changes the file's precision.
  • The share link built in definition.ts carries section only for the Bench view; a Scorecard link still omits it, while the address the app writes carries it.
  • The registry's shortcuts list omits v, which definition.ts defines, so the key list is typed in two places.
  • Copy in components rather than content files: "Search molecules" and the PubChem status lines in views/query-bar.tsx, most of the 3D viewer's strings in panels/shape-viewer.tsx and its unavailable line in panels/shape-panel.tsx, and the editor's strings in structure-editor.tsx.
  • The count "2,197" was removed from the workspace copy in Wave 4 but is still typed in the registry entry (content/apps/molecule-workbench.ts: tagline, features and product-page copy), while build specification D68 asks for counts rendered from the library file.

Divergences from the design documents (the specifications describe intent; the code is narrower):

  • Not implemented from the PRD and build specification: projects and collections (A1), the molecule table's actions and batch workflows beyond the Wave 4 series table (A2), MCS-aligned comparison and pairwise matrix (A3), XLSX, JSON schema, SDF of descriptors and print reports (A4), list and compare links (A5), substructure, similarity-threshold and property search with Butina clustering (A6), the PubChem consent step (A15), structure interchange inputs (A16), the shipped-feature flags file content/apps/molecule-workbench.shipped.ts, hashed asset names and paths.generated.ts, the derived per-drug descriptor table, the worker capability probe, analyseMany/neighboursLite, the paper depiction palette and PAINS outline highlights.
  • D-0044 supersedes the build specification's first-visit rule (§319): a bare visit opens the discovery start, not Imatinib. The code follows D-0044.
  • The library's Lipinski N/O rows have no approved-drug band until the library is rebuilt with those counts (audit C2).

Extending it safely

  • Keep chemistry in chem.ts and engine.ts, which the worker, the pipeline and the tests share. A change there invalidates default-example.json: run node pipeline/molecule-workbench/build-default-example.mts, or default-example.test.ts fails.
  • A new descriptor: add it to Descriptors and descriptorsOf(), to the Python values() in rdkit_reference.py, to verify-library.mts, to the fixture (rdkit_reference.py reference), to METHODS with its reference, to descriptorRows() and propertyRows(). Bump DESCRIPTOR_VERSION if an existing field's meaning changes, and regenerate the library and map rather than mixing schemas (audit B6).
  • Do not change QED's own definitions to match the display descriptors; QED.py uses its own acceptor patterns and NumHBD.
  • Changing the map features or the library: rebuild with build-library.mts (new ChEMBL data) or rebuild-map.mts (same structures), then the precomputed example, then rerun the whole-library check and commit the log under docs/science/evidence/.
  • Upgrading RDKit.js: the pin appears in package.json, src/lib/vendor.ts, RDKIT_VERSION in src/components/tools/molecule-workbench/versions.ts, EXPECTED_RDKIT in rdkit_reference.py and fingerprint_policy.py, and the PAINS tag. Regenerate PAINS, the fixture, the library check, the policy fields (recompute-policy.mts, which writes the library's rdkit) and the precomputed example; the library and the engine must agree or similarity is refused. New oracle fixtures go in new files (write-once).
  • Changing the fingerprint policy or the fragment rule: bump FINGERPRINT_POLICY (-v2), rerun fingerprint_policy.py into new fixture files first, then recompute-policy.mts, and retain saved series under their recorded policy; require an explicit new analysis to apply the new policy.
  • Upgrading OpenChemLib: rerun copy-ocl-resources.mts (it expects 22 tables) and update bundledVersions.openchemlib and the credit line; vendor.test.ts fails until they agree.
  • Similarity and series copy belongs in molecule-workbench.similarity.ts and molecule-workbench.series.ts, never in molecule-workbench.workspace.ts, which the definition chunk carries (CPO condition 7).
  • Budgets (scripts/size-budgets/molecule-workbench.json): besides the route and the definition chunk, Wave 4 added molecule-workspace-chunk (marker drybench-island:molecule-workspace, 149.4 KB) and molecule-series-chunk (drybench-island:molecule-series, 13.1 KB, re-based on 13 October 2026 from 12.4 for the CSO's comma rule in parseNumber, REL-49). molecule-shape-viewer-chunk (drybench-island:molecule-shape-viewer, 6.9 KB) was born on 24 October 2026 at 6.0 KB measured plus 15 percent, when the 3D viewer moved behind its loader to recover the workspace chunk (REL-528, REL-531). A new panel goes behind a loader with its own marker and a budget born green.
  • A new worker request: add it to src/components/suite/engines/rdkit/protocol.ts, the worker's handle() and RdkitClient, keeping the crash rule (an exception after start-up closes the worker). Assay shares this engine; see Assay.
  • A new external host: declare it in the registry's dataPolicy.endpoints so the CSP allows it, and prefer a same-origin route that sends only public identifiers, as the description route does. Never put a user's structure in a query string.
  • A new fragment key: add it to moleculeSchema and ADDRESS_KEYS, and to the share link in definition.ts.
  • A new saved-view field: extend MoleculeWorkspaceData and readMoleculeWorkspace() so older saved views still validate; add new origin kinds to its allow-list.
  • Copy belongs in content/apps/molecule-workbench.workspace.ts; product names only in content/app-names.ts. No positive regulatory or validation claims (the FAQ states the app is not validated for GxP use).
  • Before finishing: npm run lint, npm test, npm run build, npx tsc --noEmit and, for anything visible, npm run test:e2e.

Contextual palette handoff (16 October 2026)

A current analysed molecule registers “Send this molecule to Calc” in the command palette. It shares the guided walk's typed moleculePayload path: whole-input formula and formula weight, parent identity and dropped fragments travel together. The receiving calculator retains its existing CIAAW recomputation and sender-weight qualification. No standalone parent weight or physical stock quantity is inferred. Input identity, component lifetime and workspace scope/epoch are checked before and after payload preparation and throughout the optional guarded Send to path. A late result cannot navigate after its molecule or workspace has changed. Existing fragment/private handoff routing and status announcements remain in use.

The handoff also captures the workbench's imperative input-request generation, so starting a new lookup invalidates an older send even before the displayed molecule changes. This action requires the complete compressed compound payload; compression failure cannot degrade into a name-only handoff. Failed walk sends reject to release the walk's busy state; palette failures announce locally.

Contextual sends also check the synchronous pending-input flag and selected SMILES at entry and after every await. A new drawing or lookup therefore cannot send the previous result while its replacement is pending. An older request cannot clear a newer request's pending state. This cancels later work/navigation; it does not roll back an already-started store write.

Public reference workflows (16 October 2026)

Six public reference workflows initialise the existing structure and descriptor engines. Sketcher routes expose the native editor inline; comparisons use the existing library bench. CNS/PROTAC copy states the unimplemented prediction and segmentation limits. See Public reference workflows for the shared route, state, source and export contracts.

Save work and Study retrieval (18 October 2026)

Save work replaces Save to Bench. First save chooses a Study destination or Without a Study; repeat save stays visible beside Work details. A new Study save includes both resumable analysis inputs and the complete molecule's canonical compound as distinct records, atomically organised into the Study. It uses the whole structure's InChIKey, not a parent salt fragment or a comparison molecule. Comparisons remain in the saved payload. A changed primary molecule is included without removing prior Study materials; repeat saves respect a removed unchanged material. Recorded scientific evidence remains separate.

Saving waits for the current analysis and catalogue-backed identity. A failed catalogue load provides Retry; it cannot silently save an empty primary-subject list. Pin is in More. Add molecule to Study is a reference-only action and leaves the default destination unchanged. Persistent membership links and the attachment receipt reveal the exact Study material; View in Study reveals the saved analysis in searchable Study contents. The previous three-material Overview preview is removed.

Published neighbour reference (23 October 2026)

The app remains version 0.2.0; its registry now links the independent ten-query molecule.morgan-tanimoto reference under explicit verification version 0.3.0. pipeline/molecule-workbench/publish.py reproduced the unchanged Python RDKit 2026.03.6 fixture byte for byte before publication. The manifest pins actual input/oracle/library hashes and publication/reproduction dates, without inventing an original generation date. publication.test.ts bridges all ten generated nearest-walk neighbours to the public reference and tests browser query bits, described structures, exclusions and full-precision ranks. This same-release implementation check is not experimental evidence. See Oracles and verification for exact scope, tolerances, commands and source notices.

The landing page’s Medicinal chemistry worked example is a static server projection of that reference: input fragment handling, the library and matching-parent exclusion, then the first three uncurated ranked neighbours. It shows full identifiers and six-decimal Tanimoto scores, with shared-target/activity/safety, library-scope and no-experiment limits visible. It does not run RDKit, load the approved-drug library or add a scientific engine to the landing bundle. Its actual nearest guided-walk action uses the existing helper and needs current affected-walk evidence; the unrelated primer gate is unchanged. The exported note retains ChEMBL 37/EMBL-EBI CC BY-SA 3.0 share-alike and PubChem source/licence receipts. See Registry and content.