Plugins
Mappers
Built-in mapper plugins and their registry.
Follows the pharmaforge.plugins.calculators template: a table of
PluginSpec metadata, availability probing that
never imports a backend, factory helpers, and a PEP 562 __getattr__ so
from rbfenetmap.plugins.mappers import KartografMapper works without importing
kartograf for everyone else.
- rbfenetmap.plugins.mappers.available_mappers()[source]
Return the built-in mappers whose requirements are importable.
Probes with
importlib.util.find_spec(), so nothing is imported.- Return type:
- rbfenetmap.plugins.mappers.create_mapper(name, profile='all', **kwargs)[source]
Instantiate the mapper name.
- Raises:
rbfenetmap.core.exceptions.PluginError – If the mapper is unknown or its backend is not installed.
- Parameters:
- Return type:
- rbfenetmap.plugins.mappers.create_mapper_registry(profile='all')[source]
Return a registry with the mappers of profile registered and activated.
- Parameters:
profile (str)
- Return type:
- rbfenetmap.plugins.mappers.list_active_mappers(profile='all')[source]
Return the names of the mappers in profile that can actually be created.
- rbfenetmap.plugins.mappers.register_mappers(registry, names=None)[source]
Register the named mappers (default: all built-ins) into registry.
- Parameters:
registry (PluginRegistry)
- Return type:
- rbfenetmap.plugins.mappers.require_mappers(names, profile='all')[source]
Raise unless every mapper in names is available.
- Raises:
rbfenetmap.core.exceptions.PluginError – Naming the unavailable mappers and the modules each is missing, so the user learns what to install rather than merely that something is wrong.
- Parameters:
- Return type:
None
MCS-based atom mappers.
Ports BuildEdges._find_mcs and the MCSS / MCSS-E / MCSS-E2 family, with
one substantive change: symmetric substructures are resolved explicitly instead of by
whichever embedding RDKit happens to return first. See MCSSMapper.map_pair().
- class rbfenetmap.plugins.mappers.mcss_mapper.MCSSExtended2Mapper[source]
Bases:
MCSSMapperMCS mapping that demotes pairs differing in element or connectivity.
Reproduces
BuildEdges’MCSS-E2. The default mapper for the CLI: holding a carbon fixed against a nitrogen is legal topologically but rarely what anyone wants from a free energy calculation.
- class rbfenetmap.plugins.mappers.mcss_mapper.MCSSExtendedMapper[source]
Bases:
MCSSMapperMCS mapping that additionally demotes pairs whose connectivity differs.
Reproduces
BuildEdges’MCSS-E.
- class rbfenetmap.plugins.mappers.mcss_mapper.MCSSMapper[source]
Bases:
AbstractMapperMap two ligands by their maximum common substructure.
The base mapper applies no property-based core pruning; the subclasses below enable the degree and element rules that reproduce
MCSS-EandMCSS-E2.- pruning_preset: ClassVar[str | None] = 'mcss'
Overridden by subclasses to enable pruning rules on top of the caller’s options.
- map_pair(source, target, options)[source]
Return the MCS correspondence between source and target.
- Parameters:
source (Ligand)
target (Ligand)
options (MappingOptions) –
timeout, theFindMCSring settings,max_matches, andmatch_selection.
- Return type:
- Raises:
rbfenetmap.core.exceptions.MappingError – If no common substructure exists, or the SMARTS it produces cannot be matched back onto either molecule.
Notes
The correspondence is built by enumerating substructure embeddings of the MCS SMARTS in both molecules and choosing a pairing deliberately, rather than by calling the singular
GetSubstructMatchon each molecule and zipping the results asBuildEdges._find_mcsdoes.That zip is a coin flip for any symmetric substructure. A para-substituted benzene has two embeddings of its ring related by a flip; if RDKit returns different ones for the two molecules, the resulting map pairs atoms across the ring from one another. The mapping is topologically valid, so nothing detects the problem until a geometry check much later – by which point the failure looks like a bad conformer rather than a bad correspondence.
Candidate pairings are ranked by the criterion in
match_selection:"fewest_fragments"(default) prefers the pairing whose soft-core is least fragmented, which directly reduces the work the repair has to do;"best_rmsd"prefers the geometrically closest;"first"restores the old behaviour for comparison.
Geometry-based atom mapping.
A port of amberstudio.worknodes.cartograph, with the two unused parmed.Structure
positionals dropped from the entry point – which is what keeps ParmEd out of this
package’s dependency list.
The approach is geometric rather than topological: shape-align the two molecules, pair atoms by proximity via the Hungarian algorithm, then apply a chain of topology filters that reject pairings no alchemical transformation should make. Where an MCS asks “what substructure do these molecules share”, this asks “which atoms occupy the same space”, which is usually the better question for ligands already posed in a binding site.
- rbfenetmap.plugins.mappers.cartograph_mapper.COMMON_CORE_DISTANCE_THRESHOLD_ANGSTROM = 2.0
Default geometric cutoff, in angstroms, for candidate atom pairings.
- class rbfenetmap.plugins.mappers.cartograph_mapper.CartographMapper[source]
Bases:
AbstractMapperGeometry-based mapper: shape alignment plus Hungarian assignment.
- map_pair(source, target, options)[source]
Return the geometric correspondence between source and target.
- Raises:
rbfenetmap.core.exceptions.MappingError – If shape alignment fails, or nothing survives the topology filters.
- Parameters:
source (Ligand)
target (Ligand)
options (MappingOptions)
- Return type:
- rbfenetmap.plugins.mappers.cartograph_mapper.cartograph_mapping(mol_1, mol_2, *, distance_threshold=2.0)[source]
Return the geometric atom correspondence between two molecules.
- Parameters:
mol_1 (rdkit.Chem.Mol) – Molecules with 3D conformers.
mol_2 (rdkit.Chem.Mol) – Molecules with 3D conformers.
distance_threshold (float, optional) – Candidate pairings further apart than this, after shape alignment, are discarded before the topology filters run.
- Returns:
{index_in_mol_1: index_in_mol_2}.- Return type:
Adapter for the external kartograf atom mapper.
Kept behind an optional dependency. The import happens inside map_pair(), not at
module scope, so rbfenet plugins can list this mapper – and report exactly which
modules are missing – in an environment where kartograf is not installed.
- class rbfenetmap.plugins.mappers.kartograf_mapper.KartografMapper[source]
Bases:
AbstractMapperMap two ligands using kartograf’s geometry-based mapper.
Requires
kartografandgufe.- map_pair(source, target, options)[source]
Return kartograf’s correspondence between source and target.
- Raises:
rbfenetmap.core.exceptions.MappingError – If kartograf is unavailable or produces no mapping.
- Parameters:
source (Ligand)
target (Ligand)
options (MappingOptions)
- Return type:
Index-identity mapper.
Pairs atom i with atom i for as far as both molecules run. Two uses: exercising the downstream pipeline in tests without invoking a real mapping algorithm, and consuming inputs that already carry a correspondence by construction – ligands written out by a tool that guarantees a shared atom ordering.
- class rbfenetmap.plugins.mappers.identity_mapper.IdentityMapper[source]
Bases:
AbstractMapperMap atom i to atom i over the shared index prefix.
Notes
Correct only when the two molecules genuinely share an atom ordering. Nothing here verifies that – it cannot be verified from indices alone – so this mapper trusts the caller. Core pruning still runs, which means an identity mapping across mismatched element ordering will at least be cut back rather than passed through wholesale.
- map_pair(source, target, options)[source]
Return the index-identity correspondence.
- Parameters:
source (Ligand)
target (Ligand)
options (MappingOptions)
- Return type:
Scorers
Built-in scorer plugins and their registry.
- rbfenetmap.plugins.scorers.available_scorers()[source]
Return the built-in scorers whose requirements are importable.
- Return type:
- rbfenetmap.plugins.scorers.create_scorer(name, profile='all', **kwargs)[source]
Instantiate the scorer name.
- rbfenetmap.plugins.scorers.create_scorer_registry(profile='all')[source]
Return a registry with the scorers of profile registered and activated.
- Parameters:
profile (str)
- Return type:
- rbfenetmap.plugins.scorers.list_active_scorers(profile='all')[source]
Return the names of the scorers in profile that can be created.
- rbfenetmap.plugins.scorers.register_scorers(registry, names=None)[source]
Register the named scorers (default: all built-ins) into registry.
- Parameters:
registry (PluginRegistry)
- Return type:
- rbfenetmap.plugins.scorers.require_scorers(names, profile='all')[source]
Raise unless every scorer in names is available.
Weighted-sum edge scorer.
Each descriptor is normalised so that a value of 1.0 means roughly “one typical unit
of badness”, then multiplied by a user-tunable weight and clipped. The normalisation is
what makes the weights interpretable: a weight of 4.0 on charge_delta against
1.0 on softcore_atoms says a unit charge change is about as costly as four
soft-core-sized problems, which is a statement a chemist can argue with.
- rbfenetmap.plugins.scorers.linear_scorer.DEFAULT_SCORE_WEIGHTS: Mapping[str, float] = mappingproxy({'softcore_atoms': 1.0, 'softcore_asymmetry': 0.25, 'heavy_atom_delta': 0.25, 'charge_delta': 4.0, 'ring_delta': 1.0, 'ring_atoms_in_softcore': 0.5, 'mcs_deficit': 2.0, 'core_rmsd': 1.0, 'rotatable_delta': 0.2, 'repair_cost': 0.75, 'logp_delta': 0.1})
Default weights. Overridable per run via
--weightsor--weights-file.
- rbfenetmap.plugins.scorers.linear_scorer.TERM_DEFINITIONS: Mapping[str, tuple[str, float, float]] = mappingproxy({'softcore_atoms': ('n_softcore_max_heavy', 8.0, 8.0), 'softcore_asymmetry': ('softcore_asymmetry', 8.0, 4.0), 'heavy_atom_delta': ('heavy_atom_delta', 8.0, 4.0), 'charge_delta': ('charge_delta', 1.0, 2.0), 'ring_delta': ('ring_delta', 1.0, 3.0), 'ring_atoms_in_softcore': ('n_ring_atoms_in_softcore', 6.0, 4.0), 'mcs_deficit': ('mcs_fraction', 1.0, 1.0), 'core_rmsd': ('core_rmsd', 1.0, 3.0), 'rotatable_delta': ('rotatable_delta', 3.0, 3.0), 'repair_cost': ('n_demoted_atoms', 6.0, 4.0), 'logp_delta': ('logp_delta', 2.0, 3.0)})
term -> (descriptor, divisor, cap). The divisor sets the scale on which one unit of the descriptor equals 1.0; the cap keeps a single pathological descriptor from dominating a sum that is meant to balance several concerns.
- class rbfenetmap.plugins.scorers.linear_scorer.LinearScorer(weights=None)[source]
Bases:
AbstractScorerScore an edge as a weighted sum of normalised descriptors.
- Parameters:
weights (Mapping[str, float], optional) – Overrides merged onto
DEFAULT_SCORE_WEIGHTS.- Raises:
ValueError – If weights names a term that does not exist. Silently ignoring an unknown key would let a typo in
--weights softcore_atom=2look like it took effect while the run used the default, which is the worst possible failure mode for a tuning knob.
Merge weights onto the defaults, rejecting unknown terms.
Multiplicative similarity scorer in the spirit of LOMAP.
Scores an edge as a product of independent penalty factors in (0, 1], then converts
that similarity to a cost with -log. Multiplicative composition behaves differently
from the weighted sum in rbfenetmap.plugins.scorers.linear_scorer: any single
factor near zero drags the whole similarity to zero regardless of how good the rest is.
That is the right shape when the penalties are independent reasons the edge will not
converge, rather than competing preferences to be balanced.
Implemented from the published form; LOMAP itself is not a dependency.
- rbfenetmap.plugins.scorers.lomaplike_scorer.DEFAULT_LOMAP_PARAMETERS: Mapping[str, float] = mappingproxy({'beta': 0.1, 'charge_penalty': 0.1, 'ring_penalty': 0.4, 'ring_atom_penalty': 0.9, 'rmsd_penalty': 0.7})
Tunable factors.
betasets how fast similarity decays with soft-core size; the*_penaltyvalues are the multiplier applied per unit of the corresponding change.
- class rbfenetmap.plugins.scorers.lomaplike_scorer.LomapLikeScorer(parameters=None)[source]
Bases:
AbstractScorerScore an edge by a product of penalty factors.
- Parameters:
parameters (Mapping[str, float], optional) – Overrides merged onto
DEFAULT_LOMAP_PARAMETERS.- Raises:
ValueError – If parameters names an unknown key.
Merge parameters onto the defaults, rejecting unknown keys.
Trivial baseline scorer: cost equals the larger soft-core.
Deliberately the simplest defensible scoring rule. Its purpose is twofold: it is the honest baseline any richer scorer should be shown to beat, and because its costs are whole numbers that a reader can compute by eye, it makes planner tests verifiable by hand – a minimum spanning tree over integer weights has an obvious right answer.
- class rbfenetmap.plugins.scorers.softcore_size_scorer.SoftcoreSizeScorer[source]
Bases:
AbstractScorerCost is the heavy-atom count of the larger soft-core region.
Predicted per-edge standard deviation, in kcal/mol.
Every other scorer in this package returns a cost on an invented scale: the linear
scorer’s totals are weighted, normalised descriptor units, and the only meaningful thing
to do with two of them is compare them. This one returns a physical quantity – the
standard deviation the edge’s free energy estimate is predicted to have – which is what
makes statistical design possible at all. An optimal-design planner needs sigma_ij,
not a ranking; the Fisher information of the network is built from 1 / sigma_ij ** 2
and nothing else.
The functional form is equation 19 of the NetBFE paper: a floor, a term in the transforming (soft-core) heavy-atom count, and a smaller term in the total heavy-atom count.
with \(w = (1.0, 1.0, 0.5)\). Both counts are already computed centrally –
n_softcore_max_heavy is \(\max(h_{ij}, h_{ji})\) by construction, and
\(\max(H_{ij}, H_{ji})\) is the larger of n_heavy_1 and n_heavy_2 – so this
scorer needs no descriptor of its own and, like every scorer here, never sees a molecule.
Why square roots
Sampling error in an alchemical free energy grows roughly with the square root of the
number of degrees of freedom being decoupled, not linearly with it: doubling the soft-core
does not double the noise. The floor w_0 is the irreducible part – an edge that
transforms nothing at all still carries one run’s worth of statistical error – which also
keeps 1 / sigma ** 2 finite for a hypothetical zero-atom transformation, and so keeps
the Fisher information matrix finite.
- rbfenetmap.plugins.scorers.variance_scorer.DEFAULT_VARIANCE_WEIGHTS: Mapping[str, float] = mappingproxy({'intercept': 1.0, 'softcore_heavy': 1.0, 'total_heavy': 0.5})
w0,w1,w2of NetBFE eq. 19, in kcal/mol. A mapping rather than a tuple sorbfenet scorecan display the terms by name and a user can override one of them without restating the others.
- class rbfenetmap.plugins.scorers.variance_scorer.VarianceScorer(weights=None)[source]
Bases:
AbstractScorerPredict an edge’s free energy standard deviation, in kcal/mol.
- Parameters:
weights (Mapping[str, float], optional) – Overrides merged onto
DEFAULT_VARIANCE_WEIGHTS. Keys areintercept,softcore_heavy, andtotal_heavy.- Raises:
ValueError – If weights names a term that does not exist, or if any weight is negative. Unknown terms are refused for the reason
LinearScorerrefuses them: a typo that silently leaves the defaults in place is the worst failure mode a tuning knob can have. Negative weights are refused because they can drive the predicted standard deviation to zero or below, and1 / sigma ** 2then diverges or changes sign – one such edge would make the whole Fisher matrix meaningless.
Notes
Pair this with
--designfor statistical edge selection, and with--design-total-nsfor sample allocation. Both readtotalas a standard deviation in kcal/mol; under any other scorer they still run, but on a scale with no physical meaning.Merge weights onto the defaults, rejecting unknown or negative terms.
- score_edge(descriptors, *, rejections)[source]
Return the predicted standard deviation, in kcal/mol.
- Parameters:
descriptors (Mapping[str, float]) – Needs
n_softcore_max_heavyand both ofn_heavy_1/n_heavy_2. Missing keys read as zero, degrading to the intercept rather than raising – the same tolerance every other scorer here shows.rejections (Sequence[RejectionReason])
- Return type:
Planners
Built-in network planner plugins and their registry.
- rbfenetmap.plugins.planners.available_planners()[source]
Return the built-in planners whose requirements are importable.
- Return type:
- rbfenetmap.plugins.planners.create_planner(name, profile='all', **kwargs)[source]
Instantiate the planner name.
- rbfenetmap.plugins.planners.create_planner_registry(profile='all')[source]
Return a registry with the planners of profile registered and activated.
- Parameters:
profile (str)
- Return type:
- rbfenetmap.plugins.planners.list_active_planners(profile='all')[source]
Return the names of the planners in profile that can be created.
- rbfenetmap.plugins.planners.register_planners(registry, names=None)[source]
Register the named planners (default: all built-ins) into registry.
- Parameters:
registry (PluginRegistry)
- Return type:
- rbfenetmap.plugins.planners.require_planners(names, profile='all')[source]
Raise unless every planner in names is available.
Minimum spanning tree plus redundancy: the default network planner.
Selection proceeds in two stages, and the order is what makes the connectivity guarantee
hold. First a minimum spanning tree, seeded so that forced edges are already in it, which
spans every ligand whenever the feasible candidate graph is connected. Then a purely
additive redundancy pass that raises degrees, closes cycles, and – when max_diameter
is set – buys shortcuts, without ever removing a tree edge.
Because the second stage only adds, connectivity established in the first stage cannot be
lost. That is also why an n_edges smaller than n_ligands - 1 is rejected up front
rather than honoured by trimming: trimming would break the guarantee.
Counterpoised (CBFE) edges
When cbfe_mode is not "off" the guarantee gets stronger rather than weaker: a CBFE
edge exists between every pair of ligands, so the feasible pool can no longer be too sparse
to span. What the mode controls is not whether the stages read CBFE edges but when those
edges enter the graph, at three ordered points in MSTRedundancyPlanner.plan():
forced pairs that only CBFE can supply – before the spanning pass, so the pre-seed loop can find them;
the bridges chosen by
select_cbfe_bridges()– also before the spanning pass, and before the connectivity check, which is what turns a hard disconnection failure into a planned network;the rest of the pool – after the spanning pass, so cycle closure can reach it while the degree-raising pass cannot.
Expressing eligibility as presence rather than as a predicate threaded through three methods is what keeps the stages themselves unchanged. The one place the distinction is read directly is cycle-closure ranking, which prefers an RBFE edge over a CBFE edge that would buy the same coverage.
Clustered planning
cluster_by is likewise a knob on this planner rather than a planner of its own, and for
the same reason: a user who partitions their ligands still wants cycle coverage, degree
targets, and CBFE bridging, and a ClusteredPlanner would have to reimplement all three
to offer any of them.
It acts by removing candidates, at one point, before anything is selected: the
cross-cluster edges are pruned from the graph down to the cluster_bridges most
trustworthy crossings per joined cluster pair, chosen by the same
select_bridges() sweep the CBFE machinery uses. Every stage
downstream then runs unchanged and simply cannot spend on a crossing, which is what turns
n ln n into sum_i n_i ln n_i. The kept crossings are added to the selection
explicitly rather than left to the spanning pass, because with cluster_bridges=2 only
one of the two would survive Kruskal and the second is the entire point – it is what puts
the crossing on a cycle.
Invented vertices
—————–
Some of the ligands handed to MSTRedundancyPlanner.plan() may be ones the pipeline
invented, marked by synthetic. The planner treats
them as ordinary vertices in every stage but two: they are excluded from the
edges_per_ligand target and from the min_cycle_coverage denominator, because the
user asked for redundancy on the compounds whose affinity they care about and an
intermediate is scaffolding. They are emphatically not excluded from the pool, and they
may carry cycles. A network with no synthetic vertices is unaffected in every respect.
- class rbfenetmap.plugins.planners.mst_planner.MSTRedundancyPlanner[source]
Bases:
AbstractNetworkPlannerSelect a spanning network, then add redundancy up to the user’s targets.
- supports_cbfe: ClassVar[bool] = True
Whether this planner knows how to place counterpoised (CBFE) edges. Both of the modes that need planner cooperation –
bridgeandcycles– are expressed as decisions about where an edge goes, which only a planner that reasons about components and cycles can make.allneeds nothing from the planner, because the pipeline hands it a pool that is already entirely CBFE.
- plan(ligands, candidates, options)[source]
Select the network. See the module docstring for the ordering rationale.
- Raises:
rbfenetmap.core.exceptions.NetworkPlanError – If a forced edge is unavailable,
n_edgescannot span the ligands, or the feasible pool is disconnected while connectivity is required.- Parameters:
candidates (Sequence[Transformation])
options (NetworkOptions)
- Return type:
- class rbfenetmap.plugins.planners.mst_planner.RedundantMSTPlanner[source]
Bases:
MSTRedundancyPlannerOverlay
n_redundancyspanning trees, then add the usual redundancy.A distinct topology from MST-plus-greedy-redundancy, and one that is separately benchmarked: Konnektor builds its default network this way with two trees, and the paper that introduced it uses three. Running Kruskal, deleting the edges it chose, and running it again yields a second-cheapest spanning structure that shares no edge with the first, so every ligand has two independent routes into the network rather than a tree plus whatever the greedy degree pass happened to find cheap.
The difference is what fails when an edge fails. Under the greedy pass a ligand’s second edge may well be its first edge’s neighbour; under overlaid trees it is, by construction, part of a structure that spans without the first tree at all.
Everything else – the CBFE ordering, the redundancy passes, the connectivity guarantee – is inherited unchanged. Only
_spanning_edges()differs, and it only ever adds, so the guarantee still holds.- supports_cbfe: ClassVar[bool] = True
Whether this planner knows how to place counterpoised (CBFE) edges. Both of the modes that need planner cooperation –
bridgeandcycles– are expressed as decisions about where an edge goes, which only a planner that reasons about components and cycles can make.allneeds nothing from the planner, because the pipeline hands it a pool that is already entirely CBFE.
Simple network planners: star, explicit, and complete.
These select without optimising. Each is the right answer to a question the MST planner answers differently: a star when every transformation must share a reference ligand, an explicit list when the topology is already decided elsewhere, and the complete graph when the point is to measure every edge rather than to economise.
- class rbfenetmap.plugins.planners.simple_planners.CompletePlanner[source]
Bases:
AbstractNetworkPlannerSelect every feasible candidate.
Maximum redundancy at maximum cost. Useful for small series and for benchmarking a sparser network against the full measurement.
- plan(ligands, candidates, options)[source]
Select all feasible edges, honouring bans and any edge cap.
- Parameters:
candidates (Sequence[Transformation])
options (NetworkOptions)
- Return type:
- class rbfenetmap.plugins.planners.simple_planners.ExplicitPlanner[source]
Bases:
AbstractNetworkPlannerSelect exactly the edges named in
options.explicit_pairs.- plan(ligands, candidates, options)[source]
Select the named edges, failing loudly on any that is unusable.
- Parameters:
candidates (Sequence[Transformation])
options (NetworkOptions)
- Return type:
- class rbfenetmap.plugins.planners.simple_planners.StarPlanner[source]
Bases:
AbstractNetworkPlannerConnect every ligand to a single hub.
The hub defaults to the most central compound in the series. What “central” means is
hub_selection: the ligand with the most feasible partners (the default), or the one with the lowest summed cost to the partners it has.- plan(ligands, candidates, options)[source]
Select the hub’s spokes.
- Parameters:
candidates (Sequence[Transformation])
options (NetworkOptions)
- Return type:
Statistical optimal design: choose edges to minimise a variance criterion.
The default planner asks “what is the cheapest set of edges that connects everything and
closes enough cycles?”. This one asks a different question – “which set of edges, at this
budget, gives the most precise free energies?” – and answers it with the classical theory
of optimal experimental design, using the fact that
rbfenetmap.core.design derives at length: the Fisher information matrix of a network
of relative measurements is its weighted graph Laplacian.
That reframing turns network selection into a subset-selection problem over a matrix criterion:
a_optimalMinimise \(\operatorname{tr} C\), the summed variance of the estimates. The right choice when each ligand’s own number is what matters.
d_optimalMinimise \(\ln \det C\), the volume of the joint confidence ellipsoid. Because the pseudo-determinant of a Laplacian counts weighted spanning trees, a D-optimal design comes out markedly more cyclic than an A-optimal one at the same edge count – Pitman reports 40-80% more cycles – which is why it is the recommendation when a cycle-closure correction will be applied downstream. Otherwise prefer A-optimal.
Both criteria are lowest-is-best, and both read EdgeScore.total as a predicted standard deviation in kcal/mol.
That is what VarianceScorer returns;
under any other scorer the planner still runs, on a scale with no physical meaning.
Why a heuristic, and which one
Choosing the best \(k\)-subset of \(\binom{n}{2}\) candidate edges is combinatorial, and even a 20-ligand series puts it out of reach of enumeration. This ships Xu’s Appendix-H heuristic as the default, in three stages:
the cheapest spanning tree, so the connectivity guarantee is established before anything else competes for the budget;
a candidate pool capped at \(M = 3n\) edges – the spanning tree plus the cheapest remaining candidates;
greedy descent on the chosen criterion within that pool, up to the edge budget.
Published as landing within 1.10 ± 0.03x of the true optimum, and it needs nothing but numpy and networkx. Measured here against exhaustive enumeration on small complete graphs, the worst ratio over 40 randomised instances is 1.03x (A-optimal) and 1.08x (D-optimal).
One deviation, and the reason for it
Appendix H’s first stage is the cheapest 2-edge-connected spanning subgraph, not a
spanning tree. Forcing that turned out to cost more than it buys: choosing a bridge cover by
cost spends part of the budget on edges the criterion would rather have spent elsewhere,
and on the same randomised instances it pushes the D-optimal result out to 1.33x of the
optimum where letting the criterion spend that budget itself stays at 1.08x. The greedy
descent removes the bridges that are worth removing anyway – a bridge has a large effective
resistance, which is exactly what the criterion rewards closing. Any bridge that survives is
recorded on unmet_constraints rather than bought out.
OptimalDesignPlanner also offers Fedorov exchange as an opt-in refinement
(design_refine): repeatedly swap the in-design edge whose removal costs least for the
out-of-design edge whose addition helps most, until no swap improves the criterion. On the
same instances it brings the worst case to 1.005x. It is written here in numpy on purpose –
HiMap’s route to the same answer goes through rpy2==3.4.5 and scikit-learn==0.23.2
and needs an R installation, a dependency footprint out of all proportion to a matrix
criterion over a few hundred edges.
The edge budget
n_edges still caps selection. When it is unset this planner uses Pitman’s floor,
\(k_{\min} = \operatorname{round}(n \ln n)\), rather than the package-wide default of
“as many as redundancy wants” – below that bound precision degrades worse as n grows, so
a design planner that ignored it would be optimising within a budget known to be too small.
This is a property of this planner, not a change to the default n_edges: the mst
planner is untouched and --compat v0.4 is unaffected.
- class rbfenetmap.plugins.planners.optimal_planner.OptimalDesignPlanner[source]
Bases:
AbstractNetworkPlannerSelect edges by minimising an A- or D-optimality criterion.
See the module docstring for the criteria, the heuristic, and the budget rule.
- supports_cbfe: ClassVar[bool] = False
Whether this planner knows how to place counterpoised (CBFE) edges. Both of the modes that need planner cooperation –
bridgeandcycles– are expressed as decisions about where an edge goes, which only a planner that reasons about components and cycles can make.allneeds nothing from the planner, because the pipeline hands it a pool that is already entirely CBFE.
- supports_design: ClassVar[bool] = True
Whether this planner optimises a statistical design criterion.
designnames an objective, not a filter, so there is nothing a planner can do with it halfway: a planner that does not optimise the criterion would ignore the flag entirely, and a knob that is silently ignored is worse than one that is absent.
- plan(ligands, candidates, options)[source]
Select a statistically optimal network.
- Raises:
rbfenetmap.core.exceptions.NetworkPlanError – If
designis"none"– this planner has no criterion to optimise and will not silently pick one – if a forced edge is unavailable, or if the feasible pool is disconnected while connectivity is required.- Parameters:
candidates (Sequence[Transformation])
options (NetworkOptions)
- Return type:
- describe_design(graph, nodes, selected, options)[source]
Return the one-line design summary recorded on every planned network.
Both criteria are reported regardless of which was optimised, because the interesting comparison is almost always between them – and because the number is meaningless without knowing it came from predicted rather than measured variances.
- rbfenetmap.plugins.planners.optimal_planner.minimum_edge_count(n_ligands)[source]
Return Pitman’s edge floor,
round(n ln n), clipped to at leastn - 1.Notes
Pitman, Hahn, Tresadern and Mobley derive \(k_{\min} \approx n \ln n\) as the point below which added ligands make precision worse, not merely no better. At \(n = 40\) that is 148 edges, against the ~40 that
edges_per_ligand=2buys – which is the gap this planner exists to make visible.
Exporters
Built-in exporter plugins and their registry.
Exporters are the package’s hook into other programs: each one adapts a planned network to a downstream consumer without that consumer’s concerns reaching back into the core.
- rbfenetmap.plugins.exporters.available_exporters()[source]
Return the built-in exporters whose requirements are importable.
- Return type:
- rbfenetmap.plugins.exporters.create_exporter(name, profile='all', **kwargs)[source]
Instantiate the exporter name.
- rbfenetmap.plugins.exporters.create_exporter_registry(profile='all')[source]
Return a registry with the exporters of profile registered and activated.
- Parameters:
profile (str)
- Return type:
- rbfenetmap.plugins.exporters.list_active_exporters(profile='all')[source]
Return the names of the exporters in profile that can be created.
- rbfenetmap.plugins.exporters.register_exporters(registry, names=None)[source]
Register the named exporters (default: all built-ins) into registry.
- Parameters:
registry (PluginRegistry)
- Return type:
- rbfenetmap.plugins.exporters.require_exporters(names, profile='all')[source]
Raise unless every exporter in names is available.
Format-neutral exporters: JSON, edge list, and GraphML.
- class rbfenetmap.plugins.exporters.basic_exporters.EdgeListExporter[source]
Bases:
AbstractExporterWrite a plain
source target costedge list.The lowest-common-denominator format, readable by anything including a shell pipeline. Deliberately carries no mapping information – it is for driving a workflow that already knows how to build each edge.
- class rbfenetmap.plugins.exporters.basic_exporters.GraphMLExporter[source]
Bases:
AbstractExporterWrite the selected network as GraphML, for Cytoscape, Gephi, and similar.
- class rbfenetmap.plugins.exporters.basic_exporters.JSONExporter[source]
Bases:
AbstractExporterWrite the full network, including rejected candidates, as JSON.
The package’s own round-trippable format. See
rbfenetmap.io.networkio.
Amber / amberstudio exporter.
Writes an edges.dat list plus one atommap_<src>~<dst>.runconfig YAML per edge, in
the layout amberstudio’s BuildEdges produces and guimapper edits. That file
format is the interoperability contract between this package and the existing tooling:
plan a network here, hand-edit any edge in guimapper, run it in amberstudio.
Mixed RBFE/CBFE networks are written into rbfe/ and cbfe/ subdirectories, because
BuildEdges takes its alchemical_mode per invocation rather than per edge: a
network containing both kinds is two BuildEdges runs, and the export mirrors that
rather than producing a single directory neither run can consume. Each subdirectory also
carries an edges.txt in amberstudio’s own <src>~<dst> form. A CBFE edge needs
nothing beyond that line – amberstudio synthesizes its masks from the edge name, since
there is no mapping to convey – so cbfe/ contains only the edge list.
A network that is entirely RBFE keeps the flat, historical layout, so existing callers see no change.
When design_total_ns is set, each runconfig also carries a sample_allocation block:
the A-optimal share of the total simulation budget for that edge, and a lambda-window count
scaled to it. See AmberExporter._sample_allocation() for why that computation lives
here rather than in the planner.
Structures are written too, into ligands/, and that is not a convenience
—————————————————————————–
edges.dat names residues, and BuildEdges needs a parameterised topology for every
one of them. Before intermediate generation existed, every name in that file was a molecule
the user had supplied and could find on their own disk. It is not any more: an invented
ligand exists only inside the planned network, nobody has ever seen it, and an
edges.dat naming one with no structure beside it is a setup that fails deep inside
someone else’s tooling with an error about a missing residue.
So every ligand is written as ligands/<name>.sdf – the real ones as well, because an
invariant that holds for the whole file (“every name in edges.dat has a structure in
ligands/”) is one a script can check, while “every name except the ones you already
had” is not. Invented ligands are additionally listed in intermediates.txt with their
parents and the generator that proposed them, so a setup script can tell which residues
need parameterising before anything can run.
- class rbfenetmap.plugins.exporters.amber_exporter.AmberExporter[source]
Bases:
AbstractExporterWrite amberstudio-compatible edge and atom-map files.
Requires
pyyaml.- validate(network)[source]
Check every selected edge can produce valid Amber masks.
Called early by
rbfenet plan --validate-exporter amber. Without it, an atom name collision only surfaces after the whole mapping and planning run has completed – which for a large series is many minutes of work discarded over a problem that was knowable from the inputs alone.Also warns about invented ligands, or about generation merely being enabled when the pre-flight network has none yet. It is a warning rather than a refusal because an invented ligand is a correct result that carries an obligation: every one of them is a residue somebody has to parameterise, and the moment to learn that is before the run, not when
BuildEdgesfails on a residue nobody has ever seen.- Raises:
rbfenetmap.core.exceptions.ExporterError – Reporting every offending edge at once, not just the first.
- Parameters:
network (Network)
- Return type:
None
- export(network, destination, **options)[source]
Write the edge lists and one runconfig per RBFE edge into destination.
- Parameters:
network (Network)
destination (pathlib.Path) – Directory, created if absent. A network with counterpoised edges is written into
rbfe/andcbfe/subdirectories of it; an all-RBFE network is written flat, as before.**options –
residue_names– the two residue names, default("SRC", "DST").aggregate– also write a singleatommaps.runconfigkeyed by edge, which guimapper can open as a multi-edge document.write_ligands– writeligands/<name>.sdffor every ligand and, when any were invented,intermediates.txt. DefaultTrue. Turning it off is for a caller who is regenerating only the edge files over an export directory whose structures are already correct; it is not a way to shrink an export that names an invented ligand, which would produce a directory nobody can run.
- Return type:
tuple[pathlib.Path, …]
Self-contained HTML report exporter.
- class rbfenetmap.plugins.exporters.html_exporter.HTMLGalleryExporter[source]
Bases:
AbstractExporterWrite a single self-contained HTML report of the network.
- export(network, destination, **options)[source]
Write the report.
- Parameters:
network (Network)
destination (pathlib.Path) – A file, or a directory in which
network.htmlis written.**options –
title,show_indices,reject_depictions,max_reject_depictions,repair_comparison, andmax_repair_comparisons.
- Return type:
Intermediate generators
Built-in intermediate generators and their registry.
The fifth plugin kind. Same shape as rbfenetmap.plugins.mappers – a table of
PluginSpec metadata, availability probed without
importing anything, and a PEP 562 __getattr__ so an implementation class can be
imported by name without pulling its backend in for everyone else.
- rbfenetmap.plugins.intermediates.available_intermediates()[source]
Return the built-in generators whose requirements are importable.
- Return type:
- rbfenetmap.plugins.intermediates.create_intermediate(name, profile='all', **kwargs)[source]
Instantiate the intermediate generator name.
- Raises:
rbfenetmap.core.exceptions.PluginError – If the generator is unknown or its backend is not installed.
- Parameters:
- Return type:
- rbfenetmap.plugins.intermediates.create_intermediate_registry(profile='all')[source]
Return a registry with the generators of profile registered and activated.
- Parameters:
profile (str)
- Return type:
- rbfenetmap.plugins.intermediates.list_active_intermediates(profile='all')[source]
Return the names of the generators in profile that can actually be created.
- rbfenetmap.plugins.intermediates.register_intermediates(registry, names=None)[source]
Register the named generators (default: all built-ins) into registry.
- Parameters:
registry (PluginRegistry)
- Return type:
- rbfenetmap.plugins.intermediates.require_intermediates(names, profile='all')[source]
Raise unless every generator in names is available.
- Raises:
rbfenetmap.core.exceptions.PluginError – Naming the unavailable generators and the modules each is missing.
- Parameters:
- Return type:
None
One-substituent-at-a-time intermediate generator.
Deliberately the simplest thing that is genuinely an intermediate generator, and it plays
the role 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
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.
- class rbfenetmap.plugins.intermediates.fragment_swap.FragmentSwapGenerator[source]
Bases:
AbstractIntermediateGeneratorPropose 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 –PairMapGenerator, by truncating the position to the shared core – is a different generator.- propose(source, target, options, mapping_options)[source]
Return one hybrid per differing substituent position.
- Parameters:
source (Ligand) – The gap endpoints.
target (Ligand) – The gap endpoints.
options (IntermediateOptions) –
max_moleculescaps 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.
- Return type:
PairMap-style intermediate generator: a searched subnetwork, not a chain.
Re-derived from the method described in
K. Furui, S. Shimizu, Y. Akiyama, S. Kimura, T. Terada and M. Ohue, “PairMap: An Intermediate Scaffold-Based Approach to Improve Alchemical Free Energy Calculations for Complex Perturbations”, J. Chem. Inf. Model. 2025, 65, 705-721, doi:10.1021/acs.jcim.4c01634; reference implementation at https://github.com/ohuelab/PairMap (CC-BY 4.0).
No code was copied or adapted from the reference implementation. The algorithm below was written from the description in the paper and the epic’s specification, against this package’s own R-group decomposition, options object, and plugin contract. The citation is here because the method is theirs and belongs attributed wherever it is used, not because the file carries a CC-BY obligation.
What this generator does
Two ligands that share a core differ at a handful of substituent positions. Every position offers three groups: the source parent’s, the target parent’s, and – when both parents put something there – nothing, which is the shared core itself. An assignment of one group per position is a molecule; the source parent is “source everywhere” and the target parent is “target everywhere”. That state space is the recursive enumeration of operations from both parents toward their MCS, expressed as a product rather than as a recursion, because whole-substituent operations commute and enumerating a commutative recursion is a product.
Two states are linked when a transformation between them is worth running. Its score is
with \(\Delta\) the heavy atoms that disappear plus the heavy atoms that appear – the
LOMAP similarity, which is why the paper’s \(\beta\) and this package’s
beta = 0.1 are the same constant. Links below min_link_score are not links.
A path from source to target is scored by the harmonic mean of its squared link scores divided by its length, which reduces exactly to
so maximising it is a shortest-path problem with edge weight \(s^{-2}\). That identity is the whole reason the paper’s score is shaped that way: “shortest path” and “highest link scores”, the first two of its four subnetwork requirements, are one Dijkstra rather than two competing objectives. Paths of one link are excluded – a one-link path is the direct transformation the pipeline already rejected.
The remaining two requirements shape what is emitted around that path. Every link on it
should sit in a cycle of at most max_cycle edges, because a cycle is what turns a chain
of intermediates into a network with a closure error you can check; and the subnetwork
should carry no more redundancy than that, because every extra vertex is another molecule
somebody has to parameterise. So cycles are closed one uncovered link at a time, cheapest
first, and the search stops the moment every link is covered.
What is covered, and what is not
Covered: the state enumeration, the link score, the path score and its shortest-path
identity, the optimal-path search under max_dist, cycle closure under max_cycle,
the subnetwork extent bound max_subgraph_dist, and the min_link_score cut.
Not covered: the paper’s fine-grained operation set. PairMap enumerates atom-level and ring-level operations – change an element, add or delete one atom, open or close a ring – so it can walk through intermediates that are inside a substituent. This implementation’s operations are whole substituents: put the source’s group here, the target’s group here, or nothing here. That is a strict subset. On a pair differing by several R-groups, which is the common hard case and the one the epic is aimed at, the two enumerations agree on the useful states; on a pair whose difference is a scaffold hop or a change buried in the middle of one substituent, this generator will find the truncation to the shared core and nothing finer, and it will often refuse outright. It says so in its rejection rather than quietly proposing something worse.
Also not covered: the paper’s own scoring of a proposed molecule’s synthetic accessibility
or its similarity to known chemistry. Nothing in this package would read it – the
ProposedLink hint is advisory by contract – and a
number the pipeline cannot act on is a number that lies about its own importance.
- class rbfenetmap.plugins.intermediates.pairmap_generator.PairMapGenerator[source]
Bases:
AbstractIntermediateGeneratorSearch a subnetwork of R-group recombinations between two parents.
Notes
Emits a subnetwork, not a chain: the optimal path plus whatever closes its links into cycles of at most
max_cycle.A-M1-B-M2-Ais the smallest such shape, and it is a genuine consistency check – two independent routes across a gap whose closure error is measurable – rather than redundancy for its own sake.Everything it emits still goes through
build_candidate()like any other edge. The generator’s link score orders what to try; it never becomes a cost, and a molecule whose pose does not survive the geometry gate is dropped by the pipeline regardless of how promising the generator thought it was.- supports_pair(source, target)[source]
Refuse a pair whose parents are not both posed.
The decomposition resolves ring symmetry by in-place RMSD, so a parent without a conformer would have its positions assigned by whichever MCS embedding came back first – a coin flip that produces a plausible molecule with a substituent on the wrong side of the ring.
- describe_parameters()[source]
Return what this generator’s search does, for the run record.
The numeric knobs are not repeated here: they live on
IntermediateOptionsand are serialized with the network, and a second copy that could disagree would be worse than none.
- propose(source, target, options, mapping_options)[source]
Return the subnetwork bridging source to target.
- Parameters:
source (Ligand) – The gap endpoints, co-posed.
target (Ligand) – The gap endpoints, co-posed.
options (IntermediateOptions) –
min_link_score,max_dist,max_cycle,max_subgraph_distandbetasteer the search;max_moleculescaps what it may emit.mapping_options (MappingOptions) – Settings for the MCS that finds the shared core.
- Returns:
With no molecules and a
rejectionstring when no subnetwork was found. The rejections areno_common_core,no_substituent_difference,core_decomposition_incomplete,no_path_within_max_dist,no_molecule_builtandmax_molecules_leaves_no_room.- Return type: