Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 25 additions & 43 deletions database/scripts/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from retromol.model.result import Result
from retromol.model.rules import MatchingRule, RuleSet
from retromol_database.duckdb import FINGERPRINT_SIZE
from retromol_fingerprint.fingerprint import Fingerprinter, Vocabulary
from retromol_fingerprint.fingerprint import TOKEN_LINK, Fingerprinter, Vocabulary

# Silences RDKit's kekulization/valence/etc. warnings in *this* (single) process --
# every pipeline script imports common, so this alone covers create_db.py,
Expand Down Expand Up @@ -69,7 +69,16 @@ def build_fingerprint_context(ruleset: RuleSet) -> tuple[dict[str, MatchingRule]


def per_monomer_tokens(name: str, name_to_rule: dict[str, MatchingRule]) -> list[str]:
"""Fingerprinting token list for one compound primary-sequence block (mirrors discovery.py's `_per_monomer_tokens`)."""
"""Fingerprinting token list for one compound primary sequence block (mirrors discovery.py's `_per_monomer_tokens`).

TOKEN_LINK is not a building block -- it just joins two merged paths -- and is
filtered out by callers before fingerprinting (see load_compounds.py), so it's
never actually looked up here. Handled explicitly anyway so a stray call can't
silently fall through to the empty-tokens/TOKEN_UNK path instead.
"""
if name == TOKEN_LINK:
return [TOKEN_LINK]

if name in PK_GROUP_TOKENS:
return [name, "PK"]

Expand Down Expand Up @@ -103,53 +112,26 @@ def npatlas_url(npaid: str | None) -> str | None:
return NPATLAS_URL_TEMPLATE.format(npaid=npaid)


def primary_sequences_from_result(result: Result, min_length: int = 2) -> tuple[list[list[str]], list[str]]:
def primary_sequence_from_result(result: Result) -> list[str]:
"""
Every candidate primary sequence for a parsed compound, read directly off
`result.linear_readout.paths` -- no backbone reconstruction involved, that's a
The single primary sequence for a parsed compound, read directly off
`result.linear_readout` -- no backbone reconstruction involved, that's a
display-only concern this pipeline has no use for. `result.linear_readout` is
already computed by retromol.pipelines.parsing.run_retromol (it's just a field
on Result), each path is one candidate ordering of monomers through the
molecule, and each becomes its own db entry (see load_compounds.py). An
unidentified node is named "X", the same convention used everywhere else in
RetroMol.

`result.linear_readout` also includes single-node paths for tailoring events
that don't connect to any chain -- most commonly glycosylation (AssemblyGraph
only keeps C-C/C-N bonds as "connections", so a sugar attached via a glycosidic
C-O-C linkage always ends up disconnected from the main chain) and methylation.
These aren't a "sequence" in any useful sense, so they're excluded from the
returned primary_sequences by the `min_length` floor -- but they're still real,
identified content RetroMol found on this molecule, so their names are returned
separately as `extra_tokens`, meant to be folded into the fingerprint alongside
each primary_sequence (see per_monomer_tokens) without being displayed as part
of it. Dropping them from the fingerprint entirely -- the previous behavior --
made two compounds differing only in glycosylation pattern (a common source of
real biosynthetic diversity, e.g. erythromycin vs. megalomicin) invisible to
that difference.

`extra_tokens` is NOT deduplicated: Fingerprinter.encode has no final
normalization step, so each occurrence adds its own weight -- a molecule with
two glycosylation events should end up with roughly twice the glycosylation
weight of one with a single event, not the same weight collapsed to "some
glycosylation happened". Deduping here would throw that count signal away.
on Result); every path through the molecule -- including single-node paths for
tailoring events that don't connect to any chain, e.g. glycosylation/methylation
(AssemblyGraph only keeps C-C/C-N bonds as "connections", so a sugar attached via
a glycosidic C-O-C linkage always ends up disconnected from the main chain) --
is merged into one sequence (longest first, ties broken lexicographically, joined
by TOKEN_LINK -- see `LinearReadout.primary_sequence`), and that single sequence
becomes this compound's one db entry (see load_compounds.py). Nothing found in
the assembly graph is dropped. An unidentified node is named "X", the same
convention used everywhere else in RetroMol.

:param result: a parsed RetroMol Result
:param min_length: drop paths shorter than this from primary_sequences (default 2)
:return: (primary_sequences, extra_tokens) -- one extra_tokens entry per
qualifying tailoring-event path, duplicates and all
:return: the single primary sequence
"""
primary_sequences: list[list[str]] = []
extra_tokens: list[str] = []

for path in result.linear_readout.paths:
names = [node.identity.matched_rule.name if node.is_identified else "X" for node in path]
if len(path) >= min_length:
primary_sequences.append(names)
else:
extra_tokens.extend(n for n in names if n != "X")

return primary_sequences, extra_tokens
return result.linear_readout.primary_sequence()


def _init_worker_quiet(ruleset: RuleSet) -> None:
Expand Down
38 changes: 16 additions & 22 deletions database/scripts/load_compounds.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
"""Steps 4 & 6: turn parsed compound results into database entries.
For each RetroMol Result, every candidate primary sequence -- one per path in
result.linear_readout.paths, read directly off the Result (see
common.primary_sequences_from_result) -- becomes its own "compound" entry.
Ambiguous parses intentionally produce multiple queryable entries, the same
convention the webapp uses for an uploaded compound with more than one candidate
reading. `raw` is always the original input SMILES, the same for every entry a
given compound produces.
A compound's tailoring events (glycosylation, methylation -- anything that shows up
as its own disconnected single-node path, see common.primary_sequences_from_result)
aren't part of any displayed primary_sequence, but their tokens still get folded
into every one of that compound's fingerprints, so two compounds that only differ
by e.g. an attached sugar aren't fingerprint-identical.
For each RetroMol Result, every path found in result.linear_readout -- including a
compound's tailoring events (glycosylation, methylation -- anything that shows up as
its own disconnected single-node path) -- is merged into the one primary sequence
stored for that compound: longest path first, ties broken lexicographically, joined
by TOKEN_LINK (see common.primary_sequence_from_result). One compound -> one db
entry, always. `raw` is the original input SMILES.
"""

import argparse
Expand All @@ -30,10 +23,11 @@
mibig_url,
npatlas_url,
per_monomer_tokens,
primary_sequences_from_result,
primary_sequence_from_result,
)
from retromol.model.result import Result
from retromol_database.duckdb import RetroMolDuckDB
from retromol_fingerprint.fingerprint import TOKEN_LINK

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -96,15 +90,15 @@ def run(

name = name or result.submission.name or result.submission.inchikey

sequences, extra_tokens = primary_sequences_from_result(result)
extra_tokens_encoded = [per_monomer_tokens(n, name_to_rule) for n in extra_tokens]
names = primary_sequence_from_result(result)

for names in sequences:
if not names:
skipped += 1
continue

tokens = [per_monomer_tokens(n, name_to_rule) for n in names] + extra_tokens_encoded
if not names:
skipped += 1
else:
# TOKEN_LINK only marks where two merged paths join -- it isn't
# a building block, so it's excluded from the fingerprint (an
# empty token list would otherwise silently add TOKEN_UNK mass).
tokens = [per_monomer_tokens(n, name_to_rule) for n in names if n != TOKEN_LINK]
fp = fingerprinter.encode(tokens)

db.add_entry(
Expand Down
16 changes: 15 additions & 1 deletion gui/src/client/src/components/MotifHoverCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import { useQuery } from "@tanstack/react-query";
import { fetchMotifStructures } from "../features/motifs/api";
import { MotifName } from "./MotifName";
Expand All @@ -23,7 +24,11 @@ function MotifHoverContent({ name, hint }: { name: string; hint?: string }) {
gcTime: Infinity,
});

const smiles = structuresQuery.data?.[name];
const smiles = structuresQuery.data?.structures[name];
// Some names (e.g. "glycosylation") don't identify one specific rule -- they're
// shared by many rules with different structures, so whichever one `smiles`
// shows was picked arbitrarily (see /api/motifStructures's ambiguousNames).
const isAmbiguous = structuresQuery.data?.ambiguousNames.includes(name) ?? false;

// Must be unique per *mounted instance*, not derived from `name` alone --
// SmilesDrawerContainer draws into a DOM node it looks up by this id, and the
Expand Down Expand Up @@ -62,6 +67,15 @@ function MotifHoverContent({ name, hint }: { name: string; hint?: string }) {
)}
</Box>
)}
{isAmbiguous && (
<Box sx={{ display: "flex", alignItems: "flex-start", gap: 0.5 }}>
<WarningAmberIcon color="warning" sx={{ fontSize: "0.9rem", mt: "1px", flexShrink: 0 }} />
<Typography variant="caption" color="text.secondary" sx={{ textAlign: "left" }}>
Multiple structures share this name: the one shown is picked arbitrarily,
not necessarily the one that actually matched here.
</Typography>
</Box>
)}
<Typography variant="caption" fontWeight={600}>
<MotifName name={name} />
</Typography>
Expand Down
12 changes: 12 additions & 0 deletions gui/src/client/src/components/MotifName.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import React from "react";
import Box from "@mui/material/Box";
import { isLinkToken } from "../features/reconstruction/types";

// Renders a monomer name like "A2^R" or "C^E2", superscripting the trailing stereo marker.
// The link token (joining two merged primary sequence paths, see LINK_TOKEN) isn't a real
// building block -- render it as a muted glyph instead of a name, everywhere a monomer
// name would otherwise be rendered (sequence chips, alignment grid cells, ...).
export function MotifName({ name }: { name: string }) {
if (isLinkToken(name)) {
return (
<Box component="span" sx={{ color: "text.disabled", fontWeight: 400 }} title="Joins two merged primary sequence paths">
&#8942;
</Box>
);
}

const parts = name.split(/(\^[SREZ])/g);

return (
Expand Down
14 changes: 13 additions & 1 deletion gui/src/client/src/components/workspace/DialogViewItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { ErrorBoundary } from "../ErrorBoundary";
import { ExportImageButton } from "../ExportImageButton";
import SmilesDrawerContainer from "../SmilesDrawerContainer.js";
import { DrawingAttribution } from "../DrawingAttribution";
import { PrimarySequenceRows, usePrimarySequenceEditor } from "./PrimarySequenceEditor";
import { PrimarySequenceOverview, PrimarySequenceRows, usePrimarySequenceEditor } from "./PrimarySequenceEditor";
import { ClusterReadoutRows } from "./ClusterReadoutRows";

type HighlightAtom = [number, string];
Expand Down Expand Up @@ -126,6 +126,7 @@ export const DialogViewItem: React.FC<DialogViewItemProps> = ({
});

const data = reconstructionQuery.data?.reconstructions ?? null;
const dbMatchingSequences = reconstructionQuery.data?.dbMatchingSequences ?? [];
const loading = reconstructionQuery.isLoading;
const error = reconstructionQuery.error
? (reconstructionQuery.error as Error).message || "Unknown error"
Expand Down Expand Up @@ -269,6 +270,17 @@ export const DialogViewItem: React.FC<DialogViewItemProps> = ({
</Tooltip>
</Stack>
)}

{dbMatchingSequences.length > 0 && (
<Stack spacing={1}>
<PrimarySequenceOverview
data={dbMatchingSequences}
selectedTags={selectedTags}
onToggleMotif={handleToggleMotif}
/>
</Stack>
)}

<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<ExportImageButton
targetRef={diagramRef}
Expand Down
99 changes: 97 additions & 2 deletions gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import React from "react";
import Box from "@mui/material/Box";
import Chip from "@mui/material/Chip";
import CircularProgress from "@mui/material/CircularProgress";
import Stack from "@mui/material/Stack";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import { Session, SessionItem } from "../../features/session/types";
import type { PrimarySequenceItem, Reconstruction } from "../../features/reconstruction/types";
import { splitOnLinkToken, type PrimarySequenceItem, type Reconstruction } from "../../features/reconstruction/types";
import { saveEditedPrimarySequences, revertEditedPrimarySequence } from "../../features/reconstruction/api";
import { useNotifications } from "../NotificationProvider";
import { MotifHoverCard } from "../MotifHoverCard";
Expand Down Expand Up @@ -245,6 +246,100 @@ export function PrimarySequenceChips({
);
}

// The single, canonical primary sequence for a compound -- the same merged
// sequence (nothing filtered out, tailoring events included) the database
// actually stores and matches against, see features/reconstruction/types.ts's
// splitOnLinkToken / LINK_TOKEN. `data` is a dbMatchingSequences array (length 0
// or 1). When the sequence has more than one biosynthetic chain (a branched/
// disconnected assembly, or a main chain plus tailoring events like
// glycosylation), each chain is also broken out into its own row below the full
// sequence -- so those chains show up everywhere the primary sequence does, not
// just in the Discovery query picker.
//
// Read-only by default; pass onToggleMotif to wire up click-to-highlight against
// a molecule view (same convention as PrimarySequenceChips), and renderAction to
// add a per-row action (e.g. Discovery's "Use this" button) without this
// component needing to know what that action does.
export function PrimarySequenceOverview({
data,
selectedTags = [],
onToggleMotif,
renderAction,
}: {
data: Reconstruction[];
selectedTags?: number[];
onToggleMotif?: (tags: number[]) => void;
renderAction?: (sequence: PrimarySequenceItem[]) => React.ReactNode;
}) {
return (
<>
{data.map((reconstruction, idx) => {
const subsequences = splitOnLinkToken(reconstruction.primary_sequence);
return (
<Box key={idx}>
{/* pb reserves the same space PrimarySequenceChips' own mb below cancels out of
the row's reported height (see that mb's comment) -- without this, the row
as a whole under-reports its true rendered height by that same amount, and
whatever comes right after (a chain row, or anything a caller of this
component places after it) paints over the scrollbar instead of below it. */}
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, minWidth: 0, pb: 2.5 }}>
<Tooltip title={reconstruction.backbone_warning ?? ""} arrow>
<Chip
label="primary sequence"
size="small"
color="info"
variant="outlined"
sx={{ fontSize: "0.7rem", flexShrink: 0, cursor: "help" }}
/>
</Tooltip>
{/* mb cancels out PrimarySequenceChips' own reserved scrollbar space (see
horizontalScrollSx's pb) so it doesn't throw off alignItems: "center"
against the label chip -- without it, the chip row reads as sitting
above center, offset by however much space is reserved below it. */}
<Box sx={{ flex: 1, minWidth: 0, mb: -1.5 }}>
<PrimarySequenceChips
sequence={reconstruction.primary_sequence}
selectedTags={selectedTags}
onToggleMotif={onToggleMotif}
/>
</Box>
{renderAction && <Box sx={{ flexShrink: 0 }}>{renderAction(reconstruction.primary_sequence)}</Box>}
</Box>

{subsequences.length > 1 && (
<Stack spacing={0.75} sx={{ pl: 3 }}>
{subsequences.map((subsequence, subIdx) => (
<Box key={subIdx} sx={{ display: "flex", alignItems: "center", gap: 1.5, minWidth: 0, pb: 1.5 }}>
<Tooltip
title="One biosynthetic chain from the primary sequence above, split out at its link token(s). Query with just this chain from the Discovery tab to search without the rest of the molecule."
arrow
>
<Chip
label={`chain ${subIdx + 1}`}
size="small"
variant="outlined"
sx={{ fontSize: "0.65rem", flexShrink: 0, cursor: "help" }}
/>
</Tooltip>
<Box sx={{ flex: 1, minWidth: 0, mb: -1.5 }}>
<PrimarySequenceChips
sequence={subsequence}
selectedTags={selectedTags}
onToggleMotif={onToggleMotif}
/>
</Box>
{renderAction && <Box sx={{ flexShrink: 0 }}>{renderAction(subsequence)}</Box>}
</Box>
))}
</Stack>
)}
</Box>
);
})}
</>
);
}

// Renders one row per reconstruction: a label (+ "Edited" chip), and either the
// read-only chip row or the live SequenceEditor + per-row revert control.
export function PrimarySequenceRows({
Expand Down Expand Up @@ -317,7 +412,7 @@ export function PrimarySequenceRows({
minWidth: 0,
}}
>
{ordered ? `primary sequence ${idx + 1}` : "parsed motifs (unordered)"}
{ordered ? `reconstructed backbone ${idx + 1}` : "parsed motifs (unordered)"}
</Typography>

{override && !editing && (
Expand Down
Loading
Loading