diff --git a/gui/src/client/src/components/MenuContent.tsx b/gui/src/client/src/components/MenuContent.tsx
index af55d2e..725483e 100644
--- a/gui/src/client/src/components/MenuContent.tsx
+++ b/gui/src/client/src/components/MenuContent.tsx
@@ -10,6 +10,7 @@ import BarChartIcon from "@mui/icons-material/BarChart";
import HomeRoundedIcon from "@mui/icons-material/HomeRounded";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import RuleIcon from "@mui/icons-material/Rule";
+import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
import { useNavigate, useLocation } from "react-router-dom";
const mainListItems = [
@@ -33,6 +34,11 @@ const mainListItems = [
icon: ,
to: `/dashboard/enrichment`
},
+ {
+ text: "Generate",
+ icon: ,
+ to: `/dashboard/generate`
+ },
{
text: "Rules",
icon: ,
diff --git a/gui/src/client/src/components/SmilesDrawerContainer.js b/gui/src/client/src/components/SmilesDrawerContainer.js
index 251a72f..6a0700e 100644
--- a/gui/src/client/src/components/SmilesDrawerContainer.js
+++ b/gui/src/client/src/components/SmilesDrawerContainer.js
@@ -5,9 +5,14 @@ import { useColorScheme } from '@mui/material/styles';
class CustomSvgDrawer extends SmilesDrawer.SvgDrawer {
- constructor(options, showIsotopes = false) {
+ constructor(options, showIsotopes = false, orientationTags = null) {
super(options);
this.showIsotopes = showIsotopes;
+ // { startTags, endTags }: isotope tags (see retromol.chem.tagging) of the
+ // first and last blocks in a generated sequence -- when given, the drawing
+ // is mirrored horizontally (if needed) so the start block ends up on the
+ // left, matching reading order of the primary sequence shown alongside it.
+ this.orientationTags = orientationTags;
const themeOverrides = {
light: {
@@ -86,8 +91,35 @@ class CustomSvgDrawer extends SmilesDrawer.SvgDrawer {
atom.bracket = null;
}
+ orientSequenceLeftToRight(startTags, endTags) {
+ const graph = this.preprocessor?.graph;
+ if (!graph || !startTags?.length || !endTags?.length) return;
+
+ const vertices = graph.vertices.filter((v) => v.position);
+ if (!vertices.length) return;
+
+ const avgX = (tags) => {
+ const matched = vertices.filter((v) => v.value?.bracket && tags.includes(v.value.bracket.isotope));
+ if (!matched.length) return null;
+ return matched.reduce((sum, v) => sum + v.position.x, 0) / matched.length;
+ };
+
+ const startX = avgX(startTags);
+ const endX = avgX(endTags);
+ if (startX === null || endX === null || startX <= endX) return;
+
+ const xs = vertices.map((v) => v.position.x);
+ const centerX = (Math.min(...xs) + Math.max(...xs)) / 2;
+ for (const vertex of vertices) {
+ vertex.position.x = 2 * centerX - vertex.position.x;
+ }
+ }
+
drawAtomHighlights(highlights) {
this.prepareTinAsRightWildcard('Sn');
+ if (this.orientationTags) {
+ this.orientSequenceLeftToRight(this.orientationTags.startTags, this.orientationTags.endTags);
+ }
let preprocessor = this.preprocessor;
let graph = preprocessor.graph;
@@ -177,12 +209,17 @@ class CustomSvgDrawer extends SmilesDrawer.SvgDrawer {
* @property {string} [themeOverride] force “light” or “dark” drawing theme
* @property {boolean} [showIsotopes] draw isotope numbers (e.g. R-group
* tags like [1*]/[2*]) instead of hiding them
+ * @property {{startTags: number[], endTags: number[]}} [orientationTags]
+ * isotope tags of the first/last blocks in a
+ * generated sequence -- mirrors the drawing
+ * horizontally (if needed) so the start block
+ * ends up on the left
*/
/**
* @param {Props} props
*/
-const SmilesDrawerContainer = ({ identifier, smiles, size, highlightAtoms = [], themeOverride = '', showIsotopes = false }) => {
+const SmilesDrawerContainer = ({ identifier, smiles, size, highlightAtoms = [], themeOverride = '', showIsotopes = false, orientationTags = null }) => {
const { mode, systemMode } = useColorScheme();
const [error, setError] = useState(null);
@@ -198,7 +235,15 @@ const SmilesDrawerContainer = ({ identifier, smiles, size, highlightAtoms = [],
// A fresh drawer instance per draw call, since it isn't safe to reuse
// once it holds a graph for a previous (possibly differently-sized) SMILES.
- let drawer = new CustomSvgDrawer({ width: size, height: size }, showIsotopes);
+ //
+ // padding is bumped above the library default (10) because that default
+ // only leaves room for atom labels -- it doesn't account for the extra
+ // radius a highlight circle (customDrawAtomHighlight, r = bondLength / 3)
+ // draws around a highlighted atom, so a highlighted atom sitting at the
+ // very edge of the layout (as the first/last block often does once
+ // orientationTags pins it there) gets its highlight clipped by the SVG's
+ // own viewBox.
+ let drawer = new CustomSvgDrawer({ width: size, height: size, padding: 24 }, showIsotopes, orientationTags);
try {
SmilesDrawer.parse(
@@ -221,7 +266,7 @@ const SmilesDrawerContainer = ({ identifier, smiles, size, highlightAtoms = [],
console.error('SmilesDrawerContainer: unexpected error', err);
setError('Could not render this structure.');
}
- }, [identifier, smiles, highlightAtoms, size, themeOverride, showIsotopes, mode, systemMode]);
+ }, [identifier, smiles, highlightAtoms, size, themeOverride, showIsotopes, orientationTags, mode, systemMode]);
if (error) {
return (
diff --git a/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx b/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx
index f743b47..ca4c0cc 100644
--- a/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx
+++ b/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx
@@ -143,7 +143,7 @@ export function usePrimarySequenceEditor(
export type PrimarySequenceEditorState = ReturnType;
-function PrimarySequenceChips({
+export function PrimarySequenceChips({
sequence,
selectedTags,
onToggleMotif,
diff --git a/gui/src/client/src/components/workspace/Workspace.tsx b/gui/src/client/src/components/workspace/Workspace.tsx
index 489087b..feecb4b 100644
--- a/gui/src/client/src/components/workspace/Workspace.tsx
+++ b/gui/src/client/src/components/workspace/Workspace.tsx
@@ -15,6 +15,7 @@ import { WorkspaceHome } from "./WorkspaceHome";
import { WorkspaceUpload } from "./WorkspaceUpload";
import { WorkspaceDiscovery } from "./WorkspaceDiscovery";
import { WorkspaceRules } from "./WorkspaceRules";
+import { WorkspaceGenerate } from "./WorkspaceGenerate";
// import { WorkspaceEnrichment } from "./tabs/enrichment/WorkspaceEnrichment";
export const Workspace: React.FC = () => {
@@ -173,6 +174,7 @@ export const Workspace: React.FC = () => {
} />
{/*} />*/}
Analysis currently available. Check back later.} />
+ } />
} />
diff --git a/gui/src/client/src/components/workspace/WorkspaceGenerate.tsx b/gui/src/client/src/components/workspace/WorkspaceGenerate.tsx
new file mode 100644
index 0000000..f76dd25
--- /dev/null
+++ b/gui/src/client/src/components/workspace/WorkspaceGenerate.tsx
@@ -0,0 +1,194 @@
+import React from "react";
+import Box from "@mui/material/Box";
+import Card from "@mui/material/Card";
+import CardContent from "@mui/material/CardContent";
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import Alert from "@mui/material/Alert";
+import Button from "@mui/material/Button";
+import CircularProgress from "@mui/material/CircularProgress";
+import ContentCopyIcon from "@mui/icons-material/ContentCopy";
+import { generateBackbone } from "../../features/reconstruction/api";
+import type { GeneratedBackbone } from "../../features/reconstruction/types";
+import { useNotifications } from "../NotificationProvider";
+import { ErrorBoundary } from "../ErrorBoundary";
+import { ExportImageButton } from "../ExportImageButton";
+import SmilesDrawerContainer from "../SmilesDrawerContainer.js";
+import { DrawingAttribution } from "../DrawingAttribution";
+import { SequenceEditor, type SequenceBlock } from "./SequenceEditor";
+import { PrimarySequenceChips } from "./PrimarySequenceEditor";
+
+type HighlightAtom = [number, string];
+
+export const WorkspaceGenerate: React.FC = () => {
+ const { pushNotification } = useNotifications();
+
+ const [blocks, setBlocks] = React.useState([]);
+ const [generating, setGenerating] = React.useState(false);
+ const [result, setResult] = React.useState(null);
+ const [error, setError] = React.useState(null);
+ const [selectedTags, setSelectedTags] = React.useState([]);
+ const diagramRef = React.useRef(null);
+
+ const canGenerate = blocks.length > 0 && !generating;
+
+ const handleGenerate = async () => {
+ if (!canGenerate) return;
+ setGenerating(true);
+ setError(null);
+ setSelectedTags([]);
+
+ try {
+ const reconstruction = await generateBackbone(blocks.map((b) => b.name));
+ setResult(reconstruction);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ setResult(null);
+ setError(msg);
+ pushNotification(`Failed to generate backbone: ${msg}`, "error");
+ } finally {
+ setGenerating(false);
+ }
+ };
+
+ const handleToggleMotif = (tags: number[]) => {
+ setSelectedTags((prev) => {
+ const allSelected = tags.every((tag) => prev.includes(tag));
+ if (allSelected) return prev.filter((tag) => !tags.includes(tag));
+ return Array.from(new Set([...prev, ...tags]));
+ });
+ };
+
+ const handleCopySmiles = async () => {
+ if (!result?.backboneSmiles) return;
+ try {
+ await navigator.clipboard.writeText(result.backboneSmiles);
+ pushNotification("Copied SMILES to clipboard", "success");
+ } catch {
+ pushNotification("Failed to copy SMILES", "error");
+ }
+ };
+
+ // Memoized so it's a stable reference across re-renders that don't touch
+ // selectedTags -- otherwise SmilesDrawerContainer redraws on every unrelated
+ // parent re-render (same reasoning as DialogViewItem's highlightAtoms).
+ const highlightAtoms = React.useMemo(
+ () => selectedTags.map((tag) => [tag, "#027bf3"]),
+ [selectedTags]
+ );
+
+ const orientationTags = React.useMemo(() => {
+ if (!result || result.primary_sequence.length < 2) return undefined;
+ const [, startTags] = result.primary_sequence[0];
+ const [, endTags] = result.primary_sequence[result.primary_sequence.length - 1];
+ if (!startTags.length || !endTags.length) return undefined;
+ return { startTags, endTags };
+ }, [result]);
+
+ return (
+
+
+
+
+ Build a primary sequence
+
+
+ Add building blocks in biosynthetic order, then generate the linear backbone
+ structure RetroMol's fusion chemistry would assemble from them.
+
+
+ {blocks.length === 0 ? (
+
+ No blocks yet.
+
+ ) : null}
+
+
+
+
+
+ {generating && }
+
+
+
+
+ {(result || error) && (
+
+
+
+ Generated backbone
+
+
+ {error && {error}}
+
+ {result && (
+
+ {result.backbone_warning && (
+
+ {result.backbone_warning}
+
+ )}
+
+ {result.tagged_backbone_smiles ? (
+ <>
+
+
+ b.name).join("-").replace(/[^a-z0-9]+/gi, "-")}`}
+ label="Download the diagram and sequence below as a PNG"
+ />
+ {result.backboneSmiles && (
+ }
+ onClick={handleCopySmiles}
+ >
+ Copy SMILES
+
+ )}
+
+
+
+
+ Could not render this structure.
+
+ }
+ >
+
+
+
+
+
+
+
+ >
+ ) : (
+
+
+
+ )}
+
+ )}
+
+
+ )}
+
+ );
+};
diff --git a/gui/src/client/src/features/reconstruction/api.ts b/gui/src/client/src/features/reconstruction/api.ts
index 1ceae8e..7f963db 100644
--- a/gui/src/client/src/features/reconstruction/api.ts
+++ b/gui/src/client/src/features/reconstruction/api.ts
@@ -1,7 +1,15 @@
import { postJson } from "../http";
import { saveSession } from "../session/api";
import type { Session } from "../session/types";
-import { ReconstructCompoundRespSchema, Reconstruction, PrimarySequenceItem } from "./types";
+import { ReconstructCompoundRespSchema, GenerateBackboneRespSchema, GeneratedBackbone, Reconstruction, PrimarySequenceItem } from "./types";
+
+// Generates a linear backbone from a hand-typed primary sequence (block names,
+// e.g. from a from-scratch SequenceEditor) rather than an already-parsed
+// compound -- see routes/rules.py's generate_backbone / reconstruct_named_sequence.
+export async function generateBackbone(sequence: string[], signal?: AbortSignal): Promise {
+ const data = await postJson("/api/generateBackbone", { sequence }, GenerateBackboneRespSchema, signal);
+ return data.data;
+}
export interface ReconstructCompoundResult {
reconstructions: Reconstruction[];
diff --git a/gui/src/client/src/features/reconstruction/types.ts b/gui/src/client/src/features/reconstruction/types.ts
index 90b2019..559b026 100644
--- a/gui/src/client/src/features/reconstruction/types.ts
+++ b/gui/src/client/src/features/reconstruction/types.ts
@@ -27,6 +27,19 @@ export const ReconstructionSchema = z.object({
});
export type Reconstruction = z.output;
+export const GeneratedBackboneSchema = ReconstructionSchema.extend({
+ // Same structure as tagged_backbone_smiles but with RetroMol's internal atom
+ // tags stripped -- what a "copy SMILES" affordance should hand the user,
+ // rather than leaking isotope tags into a SMILES they'd paste elsewhere. Null
+ // under the same conditions tagged_backbone_smiles is.
+ backboneSmiles: z.string().nullable().default(null),
+});
+export type GeneratedBackbone = z.output;
+
+export const GenerateBackboneRespSchema = z.object({
+ data: GeneratedBackboneSchema,
+});
+
export const ReconstructCompoundRespSchema = z.object({
ok: z.boolean().optional(),
status: z.string().optional(),
diff --git a/gui/src/server/app.py b/gui/src/server/app.py
index 6bf9b23..10bb52c 100644
--- a/gui/src/server/app.py
+++ b/gui/src/server/app.py
@@ -39,7 +39,7 @@
get_discovery_context,
)
from routes.rate_limit import limiter, RATE_LIMIT_REJECTIONS
-from routes.rules import blp_rule_set
+from routes.rules import blp_rule_set, blp_generate_backbone
# Initialize the Flask app
@@ -254,6 +254,7 @@ def ready() -> tuple[dict[str, str], int]:
app.register_blueprint(blp_submit_discovery_query)
app.register_blueprint(blp_get_discovery_query_result)
app.register_blueprint(blp_rule_set)
+app.register_blueprint(blp_generate_backbone)
# The two rate-limit tiers on top of the app-wide default (see routes/rate_limit.py)
# are applied as @limiter.limit(...) decorators directly on each route function, in
diff --git a/gui/src/server/routes/rules.py b/gui/src/server/routes/rules.py
index 6917d86..b08bc68 100644
--- a/gui/src/server/routes/rules.py
+++ b/gui/src/server/routes/rules.py
@@ -6,9 +6,14 @@
from flask import Blueprint, Response, jsonify, request
from rdkit.Chem.Draw import rdMolDraw2D
+from retromol.chem.mol import smiles_to_mol, mol_to_smiles
from retromol.model.rules import ReactionRule, RuleSet
+from retromol_synthesis.reconstruction import BackboneReconstructionError, reconstruct_named_sequence
+
+from routes.rate_limit import limiter
blp_rule_set = Blueprint("rule_set", __name__)
+blp_generate_backbone = Blueprint("generate_backbone", __name__)
_rule_set: RuleSet | None = None
_reaction_rules_by_id: dict[str, ReactionRule] | None = None
@@ -156,3 +161,46 @@ def reaction_scheme_svg(rule_id: str) -> tuple[Response, int]:
svg = _get_reaction_svg(rule, theme)
return jsonify({"svg": svg, "rdkitVersion": rdkit.__version__}), 200
+
+
+MAX_GENERATE_BACKBONE_BLOCKS = 100
+
+
+@blp_generate_backbone.post("/api/generateBackbone")
+@limiter.limit("60 per minute")
+def generate_backbone() -> tuple[Response, int]:
+ """
+ Generate a linear backbone structure from a hand-typed primary sequence (a list
+ of matching-rule names, e.g. from a `SequenceEditor` built from scratch), using
+ the same fusion chemistry as a parsed compound's "View item" reconstruction.
+
+ Alongside the Reconstruction's own `tagged_backbone_smiles` (isotope-tagged, for
+ atom highlighting), the response also includes `backboneSmiles` -- the same
+ structure with tags stripped, for a "copy SMILES" affordance that shouldn't leak
+ RetroMol's internal atom-tagging into a SMILES the user pastes elsewhere.
+
+ :return: a tuple containing the generated Reconstruction (or an error) and an HTTP status code
+ """
+ payload = request.get_json(force=True) or {}
+ sequence = payload.get("sequence")
+
+ if not isinstance(sequence, list) or not all(isinstance(name, str) for name in sequence):
+ return jsonify({"error": "'sequence' must be a list of strings"}), 400
+ if not sequence:
+ return jsonify({"error": "'sequence' must not be empty"}), 400
+ if len(sequence) > MAX_GENERATE_BACKBONE_BLOCKS:
+ return jsonify({"error": f"'sequence' must have at most {MAX_GENERATE_BACKBONE_BLOCKS} blocks"}), 400
+
+ try:
+ reconstruction = reconstruct_named_sequence(_get_rule_set(), sequence)
+ except BackboneReconstructionError as e:
+ return jsonify({"error": str(e)}), 400
+
+ data = reconstruction.to_dict()
+ data["backboneSmiles"] = (
+ mol_to_smiles(smiles_to_mol(reconstruction.tagged_backbone_smiles), include_tags=False)
+ if reconstruction.tagged_backbone_smiles
+ else None
+ )
+
+ return jsonify({"data": data}), 200
diff --git a/src/retromol/data/mxn.yml b/src/retromol/data/mxn.yml
index 3f67042..275bc22 100644
--- a/src/retromol/data/mxn.yml
+++ b/src/retromol/data/mxn.yml
@@ -425,7 +425,7 @@
pseudonyms: ["PK_C", "PK"]
- name: C^Z1
smiles: 'O=C(/C=C\SO)O'
- display_smiles: 'O=C(/C=C\[1*)S[2*]'
+ display_smiles: 'O=C(/C=C\[1*])S[2*]'
stereochemistry: true
pseudonyms: ["PK_C", "PK"]
- name: C1
diff --git a/src/retromol_synthesis/reconstruction.py b/src/retromol_synthesis/reconstruction.py
index 506d519..c913788 100644
--- a/src/retromol_synthesis/reconstruction.py
+++ b/src/retromol_synthesis/reconstruction.py
@@ -15,6 +15,7 @@
from retromol.chem.stereo import BondStereoRecord
from retromol.model.readout import LinearReadout
from retromol.model.result import Result
+from retromol.model.rules import RuleSet
logger = logging.getLogger(__name__)
@@ -204,10 +205,10 @@ class BackboneReconstructionError(RuntimeError):
rxn_pk_double = smarts_to_reaction(r"OS[C:1]=[C:2][C:3](=[O:4])[OH:5]>>[PbH][C:1]=[C:2][C:3](=[O:4])[O:5]-[SnH]")
rxn_fuse_starter_pk = smarts_to_reaction(r"[*:1]C(=O)O[SnH].[PbH][C:2]~[C:3][C:4](=[O:5])[O:6][SnH]>>[*:1][C:2]~[C:3][C:4](=[O:5])[O:6][SnH]")
-rxn_fuse_starter_aa_alpha = smarts_to_reaction(r"[*:1][C:2](=[O:3])O[SnH].[N:4][C:5]C(=O)[OH]>>[*:1][C:2](=[O:3])[N:4][C:5]C(=O)[OH]")
+rxn_fuse_starter_aa_alpha = smarts_to_reaction(r"[*:1][C:2](=[O:3])O[SnH].[N:4][C:5][C:6](=[O:7])[OH:8]>>[*:1][C:2](=[O:3])[N:4][C:5][C:6](=[O:7])[OH:8]")
rxn_fuse_pk_pk = smarts_to_reaction(r"[*:1][C:2]~[C:3]C(=O)O[SnH].[PbH][C:4]~[C:5][C:6](=[O:7])[O:8][SnH]>>[*:1][C:2]~[C:3][C:4]~[C:5][C:6](=[O:7])[O:8][SnH]")
rxn_fuse_aa_alpha_pk = smarts_to_reaction(r"[N:1][C:2]C(=O)[OH].[PbH][C:3]~[C:4][C:5](=[O:6])[O:7][SnH:8]>>[N:1][C:2][C:3]~[C:4][C:5](=[O:6])[O:7][SnH:8]")
-rxn_fuse_pk_aa_alpha = smarts_to_reaction(r"[*:1][C:2]~[C:3][C:4](=[O:5])O[SnH].[N:6][C:7]C(=O)[OH]>>[*:1][C:2]~[C:3][C:4](=[O:5])[N:6][C:7]C(=O)[OH]")
+rxn_fuse_pk_aa_alpha = smarts_to_reaction(r"[*:1][C:2]~[C:3][C:4](=[O:5])O[SnH].[N:6][C:7][C:8](=[O:9])[OH:10]>>[*:1][C:2]~[C:3][C:4](=[O:5])[N:6][C:7][C:8](=[O:9])[OH:10]")
rxn_fuse_aa_alpha_aa_alpha = smarts_to_reaction(r"[N:1][C:2][C:8](=[O:9])[OH].[N:3][C:4]-,=[C:5](=[O:6])[OH:7]>>[N:1][C:2][C:8](=[O:9])[N:3][C:4]-,=[C:5](=[O:6])[OH:7]")
@@ -581,3 +582,107 @@ def reconstruct_linear_readout(result: Result) -> list[Reconstruction]:
)
return reconstructions
+
+
+def reconstruct_named_sequence(rule_set: RuleSet, names: list[str]) -> Reconstruction:
+ """
+ Build a linear backbone reconstruction from a hand-typed list of matching-rule
+ names, rather than from an already-parsed compound's `Result`.
+
+ Applies the same eligibility/orientation/fusion logic as one path's worth of
+ `reconstruct_linear_readout`, but starting from each rule's own canonical
+ SMILES instead of a tagged mol pulled out of a parsed structure. Each block is
+ still given its own globally-unique isotope tags (see `tag_mol`) before fusion,
+ purely so `_reconstruct_backbone`'s E/Z restoration -- which is keyed by atom
+ tag pairs -- can tell one block's double bond from another's; two untagged
+ (isotope 0) double bonds anywhere in the sequence would otherwise collide on
+ the same registry key and silently lose or mix up their stereo.
+
+ :param rule_set: RuleSet to resolve `names` against (see `RuleSet.load_default`).
+ :param names: Ordered building-block names, e.g. as typed into a `SequenceEditor`.
+ :return: The reconstructed candidate. `tagged_backbone_smiles` is None (with
+ `backbone_warning` set) if the fusion chemistry couldn't combine this
+ particular sequence.
+ :raises BackboneReconstructionError: If `names` is empty, or contains a name
+ this rule set has no matching rule for.
+ """
+ if not names:
+ raise BackboneReconstructionError("No building blocks given.")
+
+ name_to_rule = {rule.name: rule for rule in rule_set.matching_rules}
+
+ building_blocks: list[str] = []
+ primary_sequence: list[tuple[str, set[int]]] = []
+ eligible: list[bool] = []
+ next_tag = 0
+
+ for name in names:
+ rule = name_to_rule.get(name)
+ if rule is None:
+ raise BackboneReconstructionError(f"Unknown building block name: {name!r}.")
+
+ mol = smiles_to_mol(rule.smiles)
+ eligible.append(any(mol.HasSubstructMatch(pattern) for pattern in eligible_patterns))
+
+ for atom in mol.GetAtoms():
+ next_tag += 1
+ atom.SetIsotope(next_tag)
+
+ primary_sequence.append((rule.name, get_tags_mol(mol)))
+ building_blocks.append(mol_to_smiles(mol, include_tags=True))
+
+ # Mirrors reconstruct_linear_readout's per-path orientation logic: a valid
+ # arrangement has every item eligible, or every item but the first/last
+ # eligible (that one is the non-eligible starter). Anything else -- a
+ # non-eligible item stuck in the middle, or more than one non-eligible item --
+ # isn't a biosynthetic order the fusion chemistry can ever assemble, no matter
+ # which reactions it has; report that plainly rather than attempting fusion and
+ # surfacing BACKBONE_WARNING's "chemistry doesn't cover this" framing, which
+ # would misattribute a structurally invalid sequence to a chemistry gap.
+ non_eligible_idxs = [i for i, e in enumerate(eligible) if not e]
+ valid_arrangement = not non_eligible_idxs or (
+ len(non_eligible_idxs) == 1 and non_eligible_idxs[0] in (0, len(eligible) - 1)
+ )
+
+ if not valid_arrangement:
+ offending = ", ".join(f"'{names[i]}'" for i in non_eligible_idxs)
+ return Reconstruction(
+ tagged_input_smiles="",
+ tagged_backbone_smiles=None,
+ primary_sequence=primary_sequence,
+ backbone_warning=(
+ f"{offending} {'is' if len(non_eligible_idxs) == 1 else 'are'} not a polyketide- or "
+ "amino-acid-type building block that RetroMol's fusion chemistry can extend a chain "
+ "through, so it can only appear as the very first or very last block (the starter "
+ "unit). Move it to one end, or remove it, to generate a backbone."
+ ),
+ )
+
+ starter: str | None
+ if not non_eligible_idxs:
+ starter = None
+ elif non_eligible_idxs[0] == 0:
+ starter = building_blocks[0]
+ building_blocks = building_blocks[1:]
+ else:
+ # The non-eligible starter is at the end -- flip so it's first.
+ building_blocks = list(reversed(building_blocks))
+ primary_sequence = list(reversed(primary_sequence))
+ starter = building_blocks[0]
+ building_blocks = building_blocks[1:]
+
+ tagged_backbone_smiles: str | None = None
+ backbone_warning: str | None = None
+ try:
+ backbone_mol = _reconstruct_backbone(starter, building_blocks)
+ tagged_backbone_smiles = mol_to_smiles(backbone_mol, include_tags=True)
+ except Exception:
+ logger.warning("reconstruct_named_sequence: backbone reconstruction failed", exc_info=True)
+ backbone_warning = BACKBONE_WARNING
+
+ return Reconstruction(
+ tagged_input_smiles="",
+ tagged_backbone_smiles=tagged_backbone_smiles,
+ primary_sequence=primary_sequence,
+ backbone_warning=backbone_warning,
+ )