Source code for rbfenetmap.plugins.intermediates.fragment_swap

"""One-substituent-at-a-time intermediate generator.

Deliberately the simplest thing that is genuinely an intermediate generator, and it plays
the role :class:`~rbfenetmap.plugins.mappers.identity_mapper.IdentityMapper` plays for
mappers: it exercises the whole seam -- proposal, atom map, posing, naming, provenance --
without any chemistry that can surprise a reviewer.

The idea
--------

Take the common core of the two ligands. Where they differ, they differ at a handful of
substituent positions. If they differ at only one, there is nothing to invent: any hybrid
*is* one of the parents. If they differ at two or more, then for each differing position
there is a molecule that is the source with exactly that one substituent replaced by the
target's -- a molecule strictly closer to the target than the source is, and strictly
closer to the source than the target is. That is the entire algorithm.

What it deliberately does not do
--------------------------------

No scaffold hops, no ring transformations, no linker growth, no search over combinations
of positions. A real generator (PairMap) chooses which of the many possible hybrids are
worth the compute; this one enumerates the single-swap ones in a fixed order and stops at
the budget. Its value is that its output is obvious by inspection, so a failure anywhere
downstream is unambiguously downstream.

Bookkeeping
-----------

The decomposition and the molecule construction both live in
:mod:`rbfenetmap.plugins.intermediates._rgroups`, shared with the PairMap generator. They
were factored out rather than copied: the decomposition decides which atoms count as "the
same position" on the two parents, and two copies that drifted would mean the two
generators disagreeing about what a molecule *is* while both looking correct in isolation.

The molecule is built by combining both parents, adding the one new bond, and deleting
what is not wanted -- which means every surviving atom's origin is known exactly. That is
what lets the generator hand over a complete ``parent_atom_map`` and spare the poser a
substructure search whose symmetry it would have to resolve by guessing.
"""

from __future__ import annotations

from typing import ClassVar, Mapping

from rbfenetmap.core.intermediates import (
    IntermediateOptions,
    IntermediateProposal,
    ProposedLink,
    ProposedMolecule,
    intermediate_name,
)
from rbfenetmap.core.meta.intermediates import AbstractIntermediateGenerator
from rbfenetmap.core.models import Ligand
from rbfenetmap.core.options import MappingOptions
from rbfenetmap.plugins.intermediates._rgroups import assemble, differing_positions, shared_core

__all__ = ("FragmentSwapGenerator",)


[docs] class FragmentSwapGenerator(AbstractIntermediateGenerator): """Propose the hybrids that swap one substituent at a time. Notes ----- Rejects with ``"single_substituent_difference"`` when the parents differ at only one position. That is not a limitation to be worked around: with one difference, the only hybrids are the parents themselves, so there is genuinely no intermediate to invent and a generator that returned one would be returning a duplicate ligand. A generator that can do something useful there -- :class:`~rbfenetmap.plugins.intermediates.pairmap_generator.PairMapGenerator`, by truncating the position to the shared core -- is a different generator. """ name: ClassVar[str] = "fragment-swap"
[docs] def propose( self, source: Ligand, target: Ligand, options: IntermediateOptions, mapping_options: MappingOptions ) -> IntermediateProposal: """Return one hybrid per differing substituent position. Parameters ---------- source, target : Ligand The gap endpoints. options : IntermediateOptions ``max_molecules`` caps how many hybrids are returned. The subnetwork knobs are not consulted: this generator emits a fan of independent two-link paths, not a searched subnetwork. mapping_options : MappingOptions Settings for the MCS that finds the shared core. Returns ------- IntermediateProposal """ trace: list[str] = [] core = shared_core(source, target, mapping_options, trace) if core is None: return IntermediateProposal( source=source.name, target=target.name, generator=self.name, rejection="no_common_core", trace=tuple(trace), ) positions = differing_positions(source, target, core) trace.append(f"{len(positions)} differing substituent position(s) on a {len(core)}-atom core") if len(positions) < 2: return IntermediateProposal( source=source.name, target=target.name, generator=self.name, rejection="single_substituent_difference", trace=tuple(trace), ) molecules: list[ProposedMolecule] = [] links: list[ProposedLink] = [] for index, position in enumerate(positions): if len(molecules) >= options.max_molecules: trace.append(f"stopped at max_molecules={options.max_molecules}") break choices = ["target" if other == index else "source" for other in range(len(positions))] built = assemble(source, target, positions, choices) if built is None: trace.append(f"position {position.source_anchor}->{position.target_anchor}: could not be built") continue mol, atom_map = built proposed = ProposedMolecule( mol=mol, parents=(source.name, target.name), parent_atom_map=atom_map, detail={"swapped_position": position.source_anchor}, ) molecules.append(proposed) invented = intermediate_name(proposed.parents, proposed.mol) links.append(ProposedLink(source=source.name, target=invented)) links.append(ProposedLink(source=invented, target=target.name)) trace.append(f"position {position.source_anchor}->{position.target_anchor}: proposed {invented}") if not molecules: return IntermediateProposal( source=source.name, target=target.name, generator=self.name, rejection="no_valid_hybrid", trace=tuple(trace), ) return IntermediateProposal( source=source.name, target=target.name, generator=self.name, molecules=tuple(molecules), links=tuple(links), trace=tuple(trace), )
[docs] def describe_parameters(self) -> Mapping[str, object]: """Return the generator's settings. It has none of its own.""" return {"swaps_per_molecule": 1}