Files
Jungfraujoch/image_analysis/structure_refinement/MolecularReplacement.h
T
leonarski_fandClaude Opus 5.5 78c2a69267 Merge broker-rugnux-live into rc176: rugnux in jfjoch_broker as a live thread during a DECTRIS rotation collection (off by default), the rugnux library without file I/O (results in ProcessResult::outputs, the CLI writes them through JFJochRugnuxOutput), END carrying the merged table, map coefficients and placed model
Branch reviewed at e16db07ff (review: owner's decisions accepted - _maps.mtz instead of .ccp4 in the
viewer, -o "" computes everything incl. MR/validation, Ctrl+C/error paths may leave fewer files, the
writer links gemmi/zlib) and merged at 55184eb09 with its review fixes (END size guard, frame drop as
in Lite, outputs released before re-runs, pool priority, docs, frontend gating).

Conflicts: rugnux/Rugnux.h (kept rc176's host_memory_budget_bytes, output_prefix removed as on the
branch), rugnux/RugnuxPipeline.h (MtzTable future + rc176's GPUMerge pointer), MolecularReplacement.h/.cpp
(rc176's GPU cost model, packed transform and split functions with the branch's RugnuxModel signature;
search_model with rc176's other_budgets, result.model_name). tests/RugnuxLargeTest.cpp: rc176's
host-budget test writes its files through WriteRugnuxOutputs now that the library writes none.
Generated code (broker/gen, frontend client, Python client docs, Redoc) regenerated from the merged
jfjoch_api.yaml with update_version.sh (version unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
2026-10-17 21:01:38 +02:00

462 lines
30 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
// Molecular replacement (rugnux --mr, experimental): a global search for a model's orientation and
// position in the cell, so that a model with no placement of its own - an AlphaFold prediction, or an
// experimental structure from another crystal - can be scored against the data, and the space groups the
// data cannot tell apart can be scored against it.
//
// - The model: a prediction with residues of pLDDT < 70 removed and B-factors from pLDDT; an experimental
// structure as its polymer, B-factors kept. Hydrogens dropped either way.
// - Rotation: a uniform sampling of the point group's rotation ASU, each orientation scored by the
// correlation of the observed E^2 with the model's own symmetry-summed |F|^2 - read off the
// model's molecular transform, computed once by FFT and interpolated per orientation.
// - Translation: the full-symmetry translation function on one FFT per orientation and space group.
// - Further copies, one at a time, as many as the cell can hold: the translation function again with
// the copies already placed as a known partial structure, each new copy refined against the others.
// - Rescoring: the best placements through the rigid body, bulk solvent, R-free, a packing check
// against the symmetry mates and a null of the same model in random orientations.
//
// The rotation and translation functions, the rigid body and the structure factors the R-factors are
// taken on run on the GPU where there is one, and on the CPU otherwise; the same functions either way.
#include <complex>
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include "gemmi/model.hpp"
#include "gemmi/recgrid.hpp"
#include "gemmi/symmetry.hpp"
#include "gemmi/unitcell.hpp"
#include "../../common/Reflection.h" // MergedReflection
#include "../../common/UnitCell.h"
#include "RugnuxModel.h"
class Logger;
// The model as the search uses it, centroid at the origin. A prediction (AlphaFold, ModelCIF) trimmed to the
// residues it is confident of, B-factors from pLDDT; an experimental structure as it is, its polymer only.
struct SearchModel {
gemmi::Structure st;
bool predicted = true; // a prediction, its B column a pLDDT; an experimental structure otherwise
int residues_read = 0;
int residues_kept = 0;
double mass_da = 0; // of the residues kept, 110 Da each
double radius_A = 0; // the farthest atom from the centroid
double error_A = 0; // rms of the coordinate errors the pLDDTs predict, over the atoms kept (fixed for an
// experimental structure)
std::vector<int> residue_index; // of each residue kept, in model order: its position in the prediction
std::string description; // "the whole model", or which domain
std::string failure_reason;
};
// A prediction where the file says so - ModelCIF's _ma_qa_metric - or where it has no cell and its B column
// holds one value per residue, from 0 to 100; an experimental structure otherwise.
SearchModel PrepareSearchModel(const RugnuxModel &model);
// The rigid domains of a prediction from its predicted aligned error (PAE, the AlphaFold DB's matrix
// "predicted_aligned_error" over the residues of the whole prediction): the kept residues clustered by average
// linkage on the symmetrised PAE until every two clusters are a mean PAE_DOMAIN_A apart; clusters of at least
// DOMAIN_MIN_RESIDUES, largest first, each a list of positions in the prediction. Empty where the matrix does
// not match the model.
std::vector<std::vector<int>> PaeDomains(const std::vector<std::vector<double>> &pae, const SearchModel &model);
// The search model of one domain: the residues of `model` at those positions, recentred.
SearchModel DomainModel(const SearchModel &model, const std::vector<int> &residues);
// Rotations covering SO(3) with neighbours about `step_deg` apart: the identity, then shells of
// rotation angle kappa = step, 2 step, ... 180 deg, each with its axes on a golden-spiral lattice of
// the sphere as dense as 2 sin(kappa/2) - the distance a change of axis moves a rotation - needs.
std::vector<gemmi::Mat33> UniformRotations(double step_deg);
// The rotations of the space group's point group, in Cartesian coordinates of `cell`.
std::vector<gemmi::Mat33> CartesianPointGroup(const gemmi::SpaceGroup &sg, const gemmi::UnitCell &cell);
// The rotations of `all` in the rotation ASU of `point_group`: the crystal puts a copy of the model
// in every orientation S R, so only one rotation of each class {S R} is worth scoring - here the one
// closest to the identity (largest trace), the classes' Dirichlet region about it.
std::vector<gemmi::Mat33> RotationAsu(const std::vector<gemmi::Mat33> &all,
const std::vector<gemmi::Mat33> &point_group);
// The angle in degrees between a and b, the nearest over the classes {S b}.
double RotationDistanceDeg(const gemmi::Mat33 &a, const gemmi::Mat33 &b,
const std::vector<gemmi::Mat33> &point_group);
// The structure factors of one copy of the model, at its centroid, at any reciprocal vector.
class MolecularTransform {
public:
// `gpu_budget`: what its FFT may take on the GPU; on the CPU where that is too little.
MolecularTransform(const SearchModel &model, double d_min, size_t nthreads, size_t gpu_budget = 0);
// F at the Cartesian reciprocal vector s (1/A), interpolated trilinearly.
std::complex<double> At(const gemmi::Vec3 &s) const;
// The grid At() interpolates (half-l, XYZ), its box edge in A and the blur it is unblurred by.
const gemmi::FPhiGrid<float> &Grid() const { return grid_; }
double Box() const { return box_; }
double Blur() const { return blur_; }
double DMin() const { return d_min_; }
private:
gemmi::FPhiGrid<float> grid_; // half-l, on a P1 box `box_` A wide
double box_ = 0;
double blur_ = 0;
double d_min_ = 0;
};
// The observed reflections the search scores against: E^2 in resolution bins, with each unique
// reflection's multiplicity on the whole sphere.
struct SearchReflections {
std::vector<gemmi::Miller> hkl;
std::vector<double> e2;
std::vector<double> multiplicity;
std::vector<int> bin;
int bins = 0;
};
SearchReflections MakeSearchReflections(const std::vector<MergedReflection> &merged, const gemmi::UnitCell &cell,
const gemmi::SpaceGroup &sg, double d_min, double d_max);
// Copy j of the model contributes |F_model(s)|^2 at s = h R_j, the reflection's index under the j-th
// operator of the point group as a Cartesian reciprocal vector: refl.hkl.size() x |point group|, index-major.
std::vector<gemmi::Vec3> CopyVectors(const SearchReflections &refl, const gemmi::UnitCell &cell,
const gemmi::SpaceGroup &sg);
// The rotation function at each of `rotations`: the correlation of E^2 - 1 with the bin-normalised
// sum over the point group's copies of |F_model|^2.
std::vector<double> RotationFunction(const MolecularTransform &ft, const SearchReflections &refl,
const gemmi::UnitCell &cell, const gemmi::SpaceGroup &sg,
const std::vector<gemmi::Mat33> &rotations, size_t nthreads);
// What the translation function of one space group needs that does not depend on the orientation:
// for every reflection not absent in the group (a "term") and every operator j, s = h R_j as a
// Cartesian vector and exp(2 pi i h.t_j); the term's weight - its multiplicity times E^2 - 1 - and bin;
// and for every term and every ordered pair of operators (j, k), j != k, where the coefficient goes:
// the index h R_j - h R_k on the function's grid, or u = -1 where its l is negative (only l >= 0 is kept).
struct TranslationSetup {
int nu = 0, nv = 0, nw = 0; // the function's grid
int ops = 0;
std::vector<gemmi::Vec3> s; // terms x ops
std::vector<std::complex<double>> phase; // terms x ops
std::vector<double> weight; // terms
std::vector<double> e2, multiplicity; // terms
std::vector<int> bin; // terms
int bins = 0;
std::vector<int> pair_j, pair_k; // the ordered pairs
std::vector<int> target_u, target_v, target_l; // terms x pairs
// With a known partial structure (MakeTranslationSetup's `known_part`): where the cross terms
// conj(F_known) g_j go, at h R_j - or, as their conjugate F_known conj(g_j), at -h R_j where that
// has l >= 0; both where l = 0. One entry per coefficient: its term, operator, index and whether it
// is the conjugate.
std::vector<int> cross_term, cross_op, cross_u, cross_v, cross_l;
std::vector<char> cross_conj;
size_t Terms() const { return weight.size(); }
};
// `known_part`: the grid is sized for the cross terms with a known partial structure as well, which
// reach out to h R_j where the copies' own terms reach h R_j - h R_k (in a polar group no further than
// the plane the polar axis is free in).
TranslationSetup MakeTranslationSetup(const SearchReflections &refl, const gemmi::UnitCell &cell,
const gemmi::SpaceGroup &sg, bool known_part = false);
// F of copies of the model already placed - copy c turned by rotations[c] about its centroid, the
// centroid at fractional positions[c] - at each term of `setup`, summed over the copies and their
// symmetry mates, in the units the translation function's g_j are in.
std::vector<std::complex<double>> KnownStructureFactors(const MolecularTransform &ft, const TranslationSetup &setup,
const gemmi::UnitCell &cell,
const std::vector<gemmi::Mat33> &rotations,
const std::vector<gemmi::Fractional> &positions,
size_t nthreads = 1);
// The translation function of the model in orientation `rotation`: its highest point as a Z-score over
// every point of the function, and where it is (the fractional position of the model's centroid).
struct TranslationPeak {
double z = 0;
gemmi::Fractional t;
};
// With `known` (KnownStructureFactors on a setup made with known_part), the function of one more copy
// beside the known ones: the overlap of E^2 - 1 with |F_known + F(t)|^2, whose cross term
// 2 Re(conj(F_known) F(t)) is a Fourier series in t too and goes on the same grid.
TranslationPeak TranslationFunction(const MolecularTransform &ft, const TranslationSetup &setup,
const gemmi::Mat33 &rotation,
const std::vector<std::complex<double>> *known = nullptr);
// The same at each of `rotations`, `nthreads` of them at once - fewer where their buffers do not all fit the host's
// worker pool (HostWorkerPool) - the grid's gather worked out once for all.
std::vector<TranslationPeak> TranslationFunction(const MolecularTransform &ft, const TranslationSetup &setup,
const std::vector<gemmi::Mat33> &rotations,
const std::vector<std::complex<double>> *known, size_t nthreads);
// --- The likelihood a placement is judged by ---
// The terms it is summed over: the reflections of a SearchReflections not absent in a group, each with
// its E_obs, whether it is centric, its epsilon and 1/d^2, and for every operator j of the group h R_j (as
// a Cartesian vector and as indices) and exp(2 pi i h.t_j), as in TranslationSetup.
struct LikelihoodSetup {
int ops = 0;
std::vector<gemmi::Vec3> s; // terms x ops
std::vector<gemmi::Miller> k; // terms x ops
std::vector<std::complex<double>> phase; // terms x ops
std::vector<double> eo, epsilon, s2; // terms
std::vector<char> centric; // terms
std::vector<int> bin; // terms
int bins = 0;
int max_k[3] = {0, 0, 0}; // the largest |index| of h R_j along each axis
size_t Terms() const { return eo.size(); }
};
LikelihoodSetup MakeLikelihoodSetup(const SearchReflections &refl, const gemmi::UnitCell &cell,
const gemmi::SpaceGroup &sg);
// g_j = G_j(h) exp(2 pi i h.t_j) of one copy in orientation `rotation`, terms x ops: the copy with its
// centroid at t adds sum_j g_j exp(2 pi i (h R_j).t) to F(h). G_j(h) = F1(h R_j), F1 the transform of
// the copy in `cell` with its centroid at the origin - one FFT of the cell's grid to `d_min`, read off at
// the lattice points themselves, so exact at any resolution. Where `ft` is given and reaches `d_min`, read
// off it instead, interpolated as the rotation and translation functions read it.
std::vector<std::complex<double>> CopyFactors(const SearchModel &model, const LikelihoodSetup &setup,
const gemmi::UnitCell &cell, const gemmi::Mat33 &rotation,
double d_min, size_t nthreads, const MolecularTransform *ft = nullptr);
// `f` += that copy's F with its centroid at the fractional position t.
void AddCopy(const LikelihoodSetup &setup, const std::vector<std::complex<double>> &g, const gemmi::Fractional &t,
std::vector<std::complex<double>> &f);
// The log-likelihood gain of the model structure factors `f` (one per term) over a random structure,
// with sigma_A = scale exp(-2 pi^2 error^2 / (3 d^2)): E_calc are |f| normalised in the resolution bins.
double LogLikelihoodGain(const LikelihoodSetup &setup, const std::vector<std::complex<double>> &f, double error_A,
double scale);
// The same with the scale refined to its maximum (returned in `scale`).
double RefinedLogLikelihoodGain(const LikelihoodSetup &setup, const std::vector<std::complex<double>> &f,
double error_A, double *scale = nullptr);
// How the likelihood of one more copy in orientation `rotation` at `t`, beside the `known` structure factors
// (zero for none), stands out over the same copy in the same orientation at random positions: the gain
// with the scale refined at t, and its Z-score over the gains at those positions with that scale held.
struct LikelihoodPeak {
double llg = 0; // of all the copies, the known and this one
double gain = 0; // over the known ones alone, at their own refined scale
double z = 0;
double scale = 0;
};
// `g`: CopyFactors of the copy in that orientation; `known_llg`: RefinedLogLikelihoodGain of `known` alone.
LikelihoodPeak LikelihoodTranslationZ(const LikelihoodSetup &setup, const std::vector<std::complex<double>> &known,
double known_llg, const std::vector<std::complex<double>> &g,
const gemmi::Fractional &t, double error_A, size_t nthreads);
// The gain alone: of the known structure factors and the copy over the known ones (`llg`: of all of them).
double LikelihoodGain(const LikelihoodSetup &setup, const std::vector<std::complex<double>> &known, double known_llg,
const std::vector<std::complex<double>> &g, const gemmi::Fractional &t, double error_A,
double *llg = nullptr, double *scale = nullptr);
// The model placed: rotated about its centroid by `rotation`, the centroid at `t`, in `cell` and `sg`.
gemmi::Structure PlaceSearchModel(const SearchModel &model, const gemmi::Mat33 &rotation, const gemmi::Fractional &t,
const gemmi::UnitCell &cell, const gemmi::SpaceGroup &sg);
// The fraction of the C-alpha atoms within 3 A of a C-alpha of another copy: a symmetry mate, or one of
// the other `copies` the structure's chains are split into evenly (in order), or their mates.
double ClashFraction(const gemmi::Structure &placed, size_t copies = 1);
// Copies of the model as one structure: their chains in order, renamed so that no two share a name.
gemmi::Structure AssembleCopies(const std::vector<gemmi::Structure> &copies);
// The copies of an assembly (AssembleCopies), its chains split evenly in order.
std::vector<gemmi::Structure> SplitCopies(const gemmi::Structure &assembly, int copies);
// The setting of a subgroup's merge (cell `h`, group `h_sg`) in the cell `g` of a group `g_sg`: `basis` has h's
// axes as columns, lattice vectors of g (with g's centring) in g's fractional coordinates, right-handed and
// matching h's lengths and angles to 1%; `origin` is where g's origin lies in h's fractional coordinates, so
// that the operators of g with h's rotations are h's own: x_h = basis^-1 x_g + origin. Empty where there is no
// such setting.
struct SubgroupSetting {
gemmi::Mat33 basis;
gemmi::Vec3 origin;
};
std::optional<SubgroupSetting> SubgroupBasis(const gemmi::UnitCell &g, const gemmi::SpaceGroup &g_sg,
const gemmi::UnitCell &h, const gemmi::SpaceGroup &h_sg);
// The `copies` of a placement in group `g_sg` and every symmetry image of them that the subgroup `h_sg` does
// not make itself, as independent copies in the subgroup's cell `h`, in its setting (SubgroupBasis); their
// number in `copies_out`.
gemmi::Structure ExpandIntoSubgroup(const gemmi::Structure &placed, int copies, const gemmi::SpaceGroup &g_sg,
const SubgroupSetting &setting, const gemmi::UnitCell &h,
const gemmi::SpaceGroup &h_sg, int *copies_out);
// The probabilities of 1, 2, ... copies of a model of `mass_da` in the asymmetric unit of a cell of
// `asu_volume` A^3, from the distribution of the Matthews coefficient over protein crystals; one entry
// per copy number whose Vm is at least 1.5 A^3/Da.
std::vector<double> MatthewsProbabilities(double asu_volume, double mass_da);
// The space groups with the rotations and the centring of `sg`, in its setting: those merged
// intensities cannot tell apart, apart from systematic absences. `sg` itself first.
std::vector<gemmi::SpaceGroup> SameRotationGroups(const gemmi::SpaceGroup &sg);
// Translational non-crystallographic symmetry: copies in the same orientation, a translation apart, make
// the native Patterson peak off its origin where no lattice vector is. The highest peak of the Patterson
// of E^2 (5-10 A), at least 15 A from the origin and from every centring vector, as a fraction of the
// origin's height, and where it is; tNCS where that is a fifth or more.
struct TranslationalNcs {
bool present = false;
double height = 0; // over the origin's
gemmi::Fractional vector;
};
TranslationalNcs DetectTranslationalNcs(const std::vector<MergedReflection> &merged, const gemmi::UnitCell &cell,
const gemmi::SpaceGroup &sg);
// Whether groups `a` and `b` (the same rotations) differ only in screw translations that the tNCS vector
// `v` makes up: then copies a translation v apart put one group's operators onto the other's, and neither
// is told from the other by a search that has not placed all of them.
bool TncsRelatesGroups(const gemmi::SpaceGroup &a, const gemmi::SpaceGroup &b, const gemmi::Fractional &v);
// One merge the model is searched against: the answer's, or the twin hypothesis' in a subgroup.
struct MRMerge {
std::string name; // "MERGE" or "TWIN_SUBGROUP_MERGE"
const std::vector<MergedReflection> *merged = nullptr;
UnitCell cell;
gemmi::SpaceGroup space_group; // the group it was merged in
std::optional<gemmi::Op> twin_law; // the twin hypothesis' merge: the law it carries
};
// One space group's best placement: its first copy and every further one the search added.
struct MRCandidate {
int merge = 0; // index into the merges searched
gemmi::SpaceGroup space_group;
double vm = 0; // Matthews coefficient of one copy
int copies_likely = 1; // the most probable copy number, by Matthews
int copies_max = 1; // the most the search tried (Matthews probability >= 1%)
int copies = 0; // placed
double rf_z = 0; // of the first copy's orientation, over the rotation function
double tf_z = 0; // the highest of the copies'
std::vector<double> copy_tf_z; // each copy's, the later ones beside the earlier
// The likelihood (LikelihoodTranslationZ), after the rigid body, taken only for a copy whose translation
// function does not stand out (NaN otherwise): of all the copies with the last such one, and the highest
// of their Z-scores, and each copy's.
double llg = NAN;
double llg_z = NAN;
std::vector<double> copy_llg_z;
double clash_fraction = 0; // of all the copies
double r_work = NAN, r_free = NAN; // after the rigid body, to d_min_score
double k_sol = NAN, b_sol = NAN; // the bulk solvent R was computed with
double null_sigma = NAN; // R-work above its random-orientation null, in sd
// On the twin hypothesis' merge, against the data as twinned by its law (the twin comparison).
double r_work_twin = NAN, r_free_twin = NAN, twin_fraction = NAN;
std::string verdict; // PLACED, or why not
std::shared_ptr<gemmi::Structure> placed; // every copy
};
struct MolecularReplacementResult {
bool ok = false;
std::string failure_reason;
std::string model_name; // RugnuxModel::name
int residues_read = 0, residues_kept = 0;
double mass_da = 0;
std::string search_model; // the whole model, or the PAE domain the decision was made with
bool predicted_model = true; // SearchModel::predicted
int pae_domains = 0; // the rigid domains of the prediction's PAE; 0 without a PAE file
double d_min_search = 0, d_max_search = 0;
double d_min_score = 0; // the limit R-work / R-free are computed to
std::vector<MRCandidate> candidates;
// The decision: the candidate the model chooses, or -1 where it chooses none, and in words why.
int chosen = -1;
std::string decision;
bool gpu_search = false; // the rotation and translation functions ran on the GPU
TranslationalNcs tncs; // of the answer's merge
// An ENANTIOMORPH decision is taken into the answer's label only as model validation takes a model's
// enantiomorph (ValidateAgainstModel): the placed model has to fit against its null and the anomalous
// map at its atoms must not be inverted. Filled by the caller, which runs that validation.
struct HandCheck {
bool performed = false;
bool adopted = false;
bool model_fits = false;
double fit_sigma = NAN;
bool anomalous_inverted = false;
int anomalous_pairs = 0;
double anomalous_scatterer_mean_sigma = NAN;
std::string deepest_site;
double deepest_sigma = NAN;
double seconds = 0;
} hand_check;
// Seconds per step.
double t_model = 0, t_transform = 0, t_rotation = 0, t_translation = 0, t_rescore = 0, t_null = 0;
double t_domains = 0; // the PAE domains' own searches, wall clock (they run side by side)
};
// What a molecular-replacement search puts on its GPU, for one merge: the molecular transform the rotation and
// translation functions read, the rigid body's engines and the score engines, and what the functions' working
// buffers may take beside them. Bytes of one of each; 0 where that part cannot run on the GPU.
struct MolecularReplacementGPUNeed {
size_t budget = 0; // ModelGPUBudget (ModelValidation.h), or a PAE domain's share of it
// The same for every other card, in the order the rigid body's engines count on to them from this one
// (RigidBodyGPUPool::Reserve, OtherCardsTotalMemory).
std::vector<size_t> other_budgets;
size_t transform = 0;
size_t rigid_body = 0;
size_t rigid_body_engines = 0; // how many rigid-body fits can run at once
size_t structure_factors = 0; // the largest score engine of the merge's placements
// The rotation function's transform reads, for the cost of the transform's layout: orientations x reflections x
// symmetry operators of the merge's search.
double rotation_interpolations = 0;
};
// What a search puts on one card.
struct MolecularReplacementGPUCard {
bool transform = false; // a copy of the transform: the card scores its share of every function
size_t rigid_body_engines = 0;
size_t room = 0; // for the functions' working buffers (and on this card, the score engines)
};
struct MolecularReplacementGPUPlan {
bool search = false; // the transform held on the GPU, the functions run there
size_t rigid_body_engines = 0; // 0: the rigid body on the CPU; on every card together
bool structure_factors = false; // the scores on the GPU
size_t room = 0; // for the score engines and the functions' working buffers
size_t bytes = 0; // the transform, the rigid-body engines and one score engine
// This card and each card of need.other_budgets, in that order.
std::vector<MolecularReplacementGPUCard> cards;
};
// The one rule for what a search puts on its GPU, decided before anything is reserved, from the budget model
// validation keeps to (80% of the card's TOTAL memory less what is resident). The transform and the rigid
// body's engines are held for the whole merge, so it is their sum, with the largest score engine, that is held
// against the budget. Each part takes one where it still fits, in the order of what it saves: the transform
// (the rotation and translation functions), the rigid body, the scores; a part that does not fit runs on the
// CPU. More rigid-body engines only within half of the budget, as model validation adds them. What is left of
// the budget beside the transform and the engines is the room the score engines - kept for as long as they fit,
// the least recently used given up first - and the functions' working buffers share, each waiting for the other
// to be done rather than spilling past it. This card is planned as on a machine with it alone; each other card
// then takes, within half of its own budget, a copy of the transform - the rotation and translation functions'
// orientations are split over the cards that hold one, each scored as on one card. The rigid-body engines past this
// card's first go one card after the other in turn, from the next card and this one last, each within half of its
// budget, so that the fits run on every card even where this one alone has room for them all. How many cards there
// are changes only where the work runs, never a number.
MolecularReplacementGPUPlan PlanMolecularReplacementGPU(const MolecularReplacementGPUNeed &need);
// The expected seconds of the GPU work the transform's layout changes, for `plan` made from `need`: the rotation
// function's need.rotation_interpolations, split over the cards with a copy of the transform, plus the rigid-body
// fits - need.rigid_body_engines at once, each costing as its engine's bytes - shared among the plan's engines. The
// rest of the search is the same in either layout. Constants measured on Tesla T4 cards; infinite where the search
// is not on the GPU, and without the rigid body where it is on the CPU.
double MolecularReplacementGPUSeconds(const MolecularReplacementGPUNeed &need, const MolecularReplacementGPUPlan &plan,
bool packed);
// Whether the molecular transform goes on the GPU packed (MolecularReplacementGPU), `packed` bytes in place of
// the plain one's need.transform, on this card and in every copy on the other cards: where the plan with it keeps
// the search, the scores and the rigid body on the GPU as the plain plan does, the budget holds both grids while
// the packed one is made from the plain one, and its expected seconds (MolecularReplacementGPUSeconds) are fewer -
// the faster rotation function against what the copies and engines it costs on the cards take. Decided on sizes and
// counts alone, never on free memory.
bool PackMolecularTransformGPU(const MolecularReplacementGPUNeed &need, size_t packed);
// Search `merges` (the answer's first) for the predicted `model`, in every space group with
// the rotations of each merge's group - one copy, then further copies one at a time while each new one
// stands out, packs and lowers R-free - and decide. A candidate is PLACED where a copy's translation
// function stands out (TF Z >= 8), the copies pack, one copy fits the cell, and its R-work beats its own
// null. The model chooses among the answer merge's groups where one placed candidate leads
// every other by an R-free margin; it chooses the twin hypothesis' merge only where none of the
// answer's groups can hold the model at all (Matthews, packing) and a group of the subgroup can.
// `gpu_resident_bytes`: what is held on the card meanwhile by what came before (a merge engine still at work).
MolecularReplacementResult RunMolecularReplacement(const RugnuxModel &model, const std::vector<MRMerge> &merges,
Logger &logger, size_t nthreads, size_t gpu_resident_bytes = 0);
// The hand check of an ENANTIOMORPH decision, filled into result.hand_check: taken into the answer by
// --model's rule for a model's enantiomorph - the model has to fit against its own null, which the choice
// already required of it, and the anomalous map at its atoms (`merged`, the answer's merge in
// `data_space_group`) must not come out inverted, read by model validation's own code on the placement
// and the bulk solvent MR scored. Where it is not taken, the decision becomes ENANTIOMORPH_VETOED.
void CheckEnantiomorphHand(MolecularReplacementResult &result, const std::vector<MergedReflection> &merged,
const gemmi::SpaceGroup &data_space_group, Logger &logger, size_t nthreads,
size_t gpu_resident_bytes = 0);