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:

dict[str, PluginSpec]

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:

Any

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:

PluginRegistry

rbfenetmap.plugins.mappers.list_active_mappers(profile='all')[source]

Return the names of the mappers in profile that can actually be created.

Parameters:

profile (str)

Return type:

list[str]

rbfenetmap.plugins.mappers.register_mappers(registry, names=None)[source]

Register the named mappers (default: all built-ins) into registry.

Parameters:
Return type:

PluginRegistry

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: MCSSMapper

MCS mapping that demotes pairs differing in element or connectivity.

Reproduces BuildEdgesMCSS-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.

pruning_preset: ClassVar[str | None] = 'mcss-e2'

Overridden by subclasses to enable pruning rules on top of the caller’s options.

class rbfenetmap.plugins.mappers.mcss_mapper.MCSSExtendedMapper[source]

Bases: MCSSMapper

MCS mapping that additionally demotes pairs whose connectivity differs.

Reproduces BuildEdgesMCSS-E.

pruning_preset: ClassVar[str | None] = 'mcss-e'

Overridden by subclasses to enable pruning rules on top of the caller’s options.

class rbfenetmap.plugins.mappers.mcss_mapper.MCSSMapper[source]

Bases: AbstractMapper

Map 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-E and MCSS-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, the FindMCS ring settings, max_matches, and match_selection.

Return type:

AtomMapping

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 GetSubstructMatch on each molecule and zipping the results as BuildEdges._find_mcs does.

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: AbstractMapper

Geometry-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:
Return type:

AtomMapping

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:

dict[int, int]

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: AbstractMapper

Map two ligands using kartograf’s geometry-based mapper.

Requires kartograf and gufe.

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:
Return type:

AtomMapping

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: AbstractMapper

Map 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:
Return type:

AtomMapping

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:

dict[str, PluginSpec]

rbfenetmap.plugins.scorers.create_scorer(name, profile='all', **kwargs)[source]

Instantiate the scorer name.

Parameters:
Return type:

Any

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:

PluginRegistry

rbfenetmap.plugins.scorers.list_active_scorers(profile='all')[source]

Return the names of the scorers in profile that can be created.

Parameters:

profile (str)

Return type:

list[str]

rbfenetmap.plugins.scorers.register_scorers(registry, names=None)[source]

Register the named scorers (default: all built-ins) into registry.

Parameters:
Return type:

PluginRegistry

rbfenetmap.plugins.scorers.require_scorers(names, profile='all')[source]

Raise unless every scorer in names is available.

Parameters:
Return type:

None

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 --weights or --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: AbstractScorer

Score 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=2 look 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.

describe_weights()[source]

Return the effective weights.

Return type:

Mapping[str, float]

score_edge(descriptors, *, rejections)[source]

Return the weighted cost, or an infeasible score if rejections is non-empty.

Parameters:
Return type:

EdgeScore

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. beta sets how fast similarity decays with soft-core size; the *_penalty values are the multiplier applied per unit of the corresponding change.

class rbfenetmap.plugins.scorers.lomaplike_scorer.LomapLikeScorer(parameters=None)[source]

Bases: AbstractScorer

Score 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.

describe_weights()[source]

Return the effective parameters.

Return type:

Mapping[str, float]

score_edge(descriptors, *, rejections)[source]

Return -log(similarity) as the cost.

Parameters:
Return type:

EdgeScore

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: AbstractScorer

Cost is the heavy-atom count of the larger soft-core region.

score_edge(descriptors, *, rejections)[source]

Return the larger soft-core size as the cost.

Parameters:
Return type:

EdgeScore

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.

\[s_{ij} = w_0 + w_1 \sqrt{\max(h_{ij}, h_{ji})} + w_2 \sqrt{\max(H_{ij}, H_{ji})}\]

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, w2 of NetBFE eq. 19, in kcal/mol. A mapping rather than a tuple so rbfenet score can 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: AbstractScorer

Predict an edge’s free energy standard deviation, in kcal/mol.

Parameters:

weights (Mapping[str, float], optional) – Overrides merged onto DEFAULT_VARIANCE_WEIGHTS. Keys are intercept, softcore_heavy, and total_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 LinearScorer refuses 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, and 1 / sigma ** 2 then diverges or changes sign – one such edge would make the whole Fisher matrix meaningless.

Notes

Pair this with --design for statistical edge selection, and with --design-total-ns for sample allocation. Both read total as 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.

describe_weights()[source]

Return the effective weights.

Return type:

Mapping[str, float]

score_edge(descriptors, *, rejections)[source]

Return the predicted standard deviation, in kcal/mol.

Parameters:
  • descriptors (Mapping[str, float]) – Needs n_softcore_max_heavy and both of n_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:

EdgeScore

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:

dict[str, PluginSpec]

rbfenetmap.plugins.planners.create_planner(name, profile='all', **kwargs)[source]

Instantiate the planner name.

Parameters:
Return type:

Any

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:

PluginRegistry

rbfenetmap.plugins.planners.list_active_planners(profile='all')[source]

Return the names of the planners in profile that can be created.

Parameters:

profile (str)

Return type:

list[str]

rbfenetmap.plugins.planners.register_planners(registry, names=None)[source]

Register the named planners (default: all built-ins) into registry.

Parameters:
Return type:

PluginRegistry

rbfenetmap.plugins.planners.require_planners(names, profile='all')[source]

Raise unless every planner in names is available.

Parameters:
Return type:

None

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():

  1. forced pairs that only CBFE can supply – before the spanning pass, so the pre-seed loop can find them;

  2. 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;

  3. 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: AbstractNetworkPlanner

Select 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 – bridge and cycles – are expressed as decisions about where an edge goes, which only a planner that reasons about components and cycles can make. all needs 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_edges cannot span the ligands, or the feasible pool is disconnected while connectivity is required.

Parameters:
Return type:

Network

class rbfenetmap.plugins.planners.mst_planner.RedundantMSTPlanner[source]

Bases: MSTRedundancyPlanner

Overlay n_redundancy spanning 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 – bridge and cycles – are expressed as decisions about where an edge goes, which only a planner that reasons about components and cycles can make. all needs 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: AbstractNetworkPlanner

Select 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:
Return type:

Network

class rbfenetmap.plugins.planners.simple_planners.ExplicitPlanner[source]

Bases: AbstractNetworkPlanner

Select 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:
Return type:

Network

class rbfenetmap.plugins.planners.simple_planners.StarPlanner[source]

Bases: AbstractNetworkPlanner

Connect 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:
Return type:

Network

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_optimal

Minimise \(\operatorname{tr} C\), the summed variance of the estimates. The right choice when each ligand’s own number is what matters.

d_optimal

Minimise \(\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:

  1. the cheapest spanning tree, so the connectivity guarantee is established before anything else competes for the budget;

  2. a candidate pool capped at \(M = 3n\) edges – the spanning tree plus the cheapest remaining candidates;

  3. 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: AbstractNetworkPlanner

Select 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 – bridge and cycles – are expressed as decisions about where an edge goes, which only a planner that reasons about components and cycles can make. all needs 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. design names 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 design is "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:
Return type:

Network

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.

Parameters:
Return type:

str

rbfenetmap.plugins.planners.optimal_planner.minimum_edge_count(n_ligands)[source]

Return Pitman’s edge floor, round(n ln n), clipped to at least n - 1.

Parameters:

n_ligands (int)

Return type:

int

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=2 buys – 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:

dict[str, PluginSpec]

rbfenetmap.plugins.exporters.create_exporter(name, profile='all', **kwargs)[source]

Instantiate the exporter name.

Parameters:
Return type:

Any

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:

PluginRegistry

rbfenetmap.plugins.exporters.list_active_exporters(profile='all')[source]

Return the names of the exporters in profile that can be created.

Parameters:

profile (str)

Return type:

list[str]

rbfenetmap.plugins.exporters.register_exporters(registry, names=None)[source]

Register the named exporters (default: all built-ins) into registry.

Parameters:
Return type:

PluginRegistry

rbfenetmap.plugins.exporters.require_exporters(names, profile='all')[source]

Raise unless every exporter in names is available.

Parameters:
Return type:

None

Format-neutral exporters: JSON, edge list, and GraphML.

class rbfenetmap.plugins.exporters.basic_exporters.EdgeListExporter[source]

Bases: AbstractExporter

Write a plain source target cost edge 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.

export(network, destination, **options)[source]

Write the edge list.

Parameters:
Return type:

tuple[Path, …]

class rbfenetmap.plugins.exporters.basic_exporters.GraphMLExporter[source]

Bases: AbstractExporter

Write the selected network as GraphML, for Cytoscape, Gephi, and similar.

export(network, destination, **options)[source]

Write the GraphML file.

Parameters:
Return type:

tuple[Path, …]

class rbfenetmap.plugins.exporters.basic_exporters.JSONExporter[source]

Bases: AbstractExporter

Write the full network, including rejected candidates, as JSON.

The package’s own round-trippable format. See rbfenetmap.io.networkio.

export(network, destination, **options)[source]

Write network.json (or destination itself if it names a file).

Parameters:
Return type:

tuple[Path, …]

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: AbstractExporter

Write 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 BuildEdges fails 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/ and cbfe/ subdirectories of it; an all-RBFE network is written flat, as before.

  • **optionsresidue_names – the two residue names, default ("SRC", "DST"). aggregate – also write a single atommaps.runconfig keyed by edge, which guimapper can open as a multi-edge document. write_ligands – write ligands/<name>.sdf for every ligand and, when any were invented, intermediates.txt. Default True. 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: AbstractExporter

Write 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.html is written.

  • **optionstitle, show_indices, reject_depictions, max_reject_depictions, repair_comparison, and max_repair_comparisons.

Return type:

tuple[Path, …]

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:

dict[str, PluginSpec]

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:

Any

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:

PluginRegistry

rbfenetmap.plugins.intermediates.list_active_intermediates(profile='all')[source]

Return the names of the generators in profile that can actually be created.

Parameters:

profile (str)

Return type:

list[str]

rbfenetmap.plugins.intermediates.register_intermediates(registry, names=None)[source]

Register the named generators (default: all built-ins) into registry.

Parameters:
Return type:

PluginRegistry

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: 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 – 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_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.

Return type:

IntermediateProposal

describe_parameters()[source]

Return the generator’s settings. It has none of its own.

Return type:

Mapping[str, object]

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

\[s = \exp(-\beta \, \Delta)\]

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

\[\frac{1}{\sum_i s_i^{-2}}\]

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: AbstractIntermediateGenerator

Search 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-A is 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.

Parameters:
Return type:

bool

describe_parameters()[source]

Return what this generator’s search does, for the run record.

The numeric knobs are not repeated here: they live on IntermediateOptions and are serialized with the network, and a second copy that could disagree would be worse than none.

Return type:

Mapping[str, object]

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_dist and beta steer the search; max_molecules caps what it may emit.

  • mapping_options (MappingOptions) – Settings for the MCS that finds the shared core.

Returns:

With no molecules and a rejection string when no subnetwork was found. The rejections are no_common_core, no_substituent_difference, core_decomposition_incomplete, no_path_within_max_dist, no_molecule_built and max_molecules_leaves_no_room.

Return type:

IntermediateProposal