File size: 21,282 Bytes
5d3f072
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
"""What two chains actually touch — measured from coordinates, not predicted.

THE DISTINCTION THIS MODULE EXISTS TO ENFORCE
---------------------------------------------
A docking result contains three completely different kinds of claim, and the
product's credibility depends on never letting them blur:

    MEASURED      geometry computed from the coordinates in front of us.
                  Buried surface area, atomic contacts, salt bridges, clashes.
                  Anyone with the same file gets the same answer. That is what
                  this module produces, and all it produces.

    PREDICTED     the docking method's own confidence in the pose (ipTM, pLDDT,
                  a docking score). Reported from the provider, never derived
                  here — the same rule `resolve.py` already applies to
                  AlphaFold's pLDDT.

    INTERPRETED   the sentence Turing writes about it. Downstream, grounded in
                  the first two, and never a substitute for either.

Everything here is the first kind. These numbers describe the pose; they say
nothing about whether the pose is right.

WHAT IS NOT COMPUTED, AND WILL NOT BE
-------------------------------------
Binding affinity. Kd, ΔG, kcal/mol. No protein-protein docking method in this
deployment estimates them, and a plausible-looking energy is the single most
damaging number this feature could display: it reads as a measurement, it is
quotable, and it would be fabricated. `stability.py` already made this call for
ΔΔG and said it in the same words. Interface area is not affinity, a hydrogen
bond count is not affinity, and no weighted sum of them becomes affinity.

HYDROGEN BONDS, HONESTLY
------------------------
Predicted structures and most crystal structures carry no hydrogen atoms, so a
true hydrogen bond — with its donor-H-acceptor angle — cannot be determined
from them. What can be measured is the heavy-atom geometry that a hydrogen bond
requires: a donor nitrogen or oxygen close enough to an acceptor. That is what
is counted here, and it is labelled `putative_hydrogen_bonds` everywhere it
appears, with the criterion attached. It is standard practice; calling the
result "hydrogen bonds" without the qualifier would not be.
"""
from __future__ import annotations

import logging
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Sequence, Tuple

from dee.core.structures import ChainInfo, NumberingMap, ResidueRef

logger = logging.getLogger(__name__)


# ---- criteria, each one a published convention rather than a tuned knob ----

# Any heavy atom of one chain within this of a heavy atom of the other. 5.0 A
# is the usual definition of an interface contact in the protein-protein
# literature and is what PDBePISA-style summaries use.
CONTACT_CUTOFF_A = 5.0

# Heavy-atom donor/acceptor separation consistent with a hydrogen bond. 3.5 A
# is the conventional heavy-atom cutoff used when hydrogens are absent.
HBOND_CUTOFF_A = 3.5

# Barlow & Thornton (1983): a salt bridge is a charged nitrogen and a charged
# oxygen within 4.0 A.
SALT_BRIDGE_CUTOFF_A = 4.0

# Below this, two non-bonded heavy atoms are interpenetrating. Generous on
# purpose: at 2.2 A a contact is unambiguous rather than arguable, so a
# reported clash is a real problem with the pose and not a rounding argument.
CLASH_CUTOFF_A = 2.2

# A residue counts as interface if it buries at least this much area. Small
# enough to catch a genuine rim residue, large enough to exclude a side chain
# that merely points in the right direction.
BURIAL_MIN_A2 = 1.0

# Shrake-Rupley sampling. 100 points is Biopython's default and is plenty for
# an area difference; raising it costs time on a shared CPU worker for digits
# nobody should be reading.
SASA_POINTS = 100

_CHARGED_DONORS = {
    ("ARG", "NE"), ("ARG", "NH1"), ("ARG", "NH2"),
    ("LYS", "NZ"),
    ("HIS", "ND1"), ("HIS", "NE2"),
}
_CHARGED_ACCEPTORS = {
    ("ASP", "OD1"), ("ASP", "OD2"),
    ("GLU", "OE1"), ("GLU", "OE2"),
}

_HYDROPHOBIC = {"ALA", "VAL", "LEU", "ILE", "MET", "PHE", "TRP", "PRO", "CYS",
                "TYR"}


@dataclass(frozen=True)
class Contact:
    """One measured atom-atom contact across the interface."""
    receptor: ResidueRef
    ligand: ResidueRef
    receptor_atom: str
    ligand_atom: str
    distance_a: float
    kind: str            # 'contact' | 'putative_hydrogen_bond' | 'salt_bridge' | 'clash'

    def as_dict(self) -> Dict[str, Any]:
        return {
            "receptor": self.receptor.as_dict(),
            "ligand": self.ligand.as_dict(),
            "receptor_atom": self.receptor_atom,
            "ligand_atom": self.ligand_atom,
            "distance_a": round(self.distance_a, 2),
            "kind": self.kind,
        }


@dataclass
class InterfaceResidue:
    """A residue at the interface, in both numbering systems.

    `user_position` is the 1-based position in the sequence the user gave us,
    or None when the residue has no counterpart there. Mutation names are built
    from this field and never from `seq_id`, because `seq_id` belongs to the
    coordinate file, not to the scientist's protein.
    """
    ref: ResidueRef
    buried_a2: float
    contacts: int
    user_position: Optional[int] = None

    @property
    def mutation_prefix(self) -> Optional[str]:
        """'A147' — the anchor a substitution name is built on.

        None when the residue is unmapped: without a position in the user's own
        numbering there is no mutation to name, and guessing one is the
        failure `structures.py` exists to prevent.
        """
        letter = self.ref.one_letter
        if letter is None or self.user_position is None:
            return None
        return f"{letter}{self.user_position}"

    def as_dict(self) -> Dict[str, Any]:
        out = dict(self.ref.as_dict())
        out.update({
            "buried_a2": round(self.buried_a2, 1),
            "contacts": self.contacts,
            "user_position": self.user_position,
        })
        prefix = self.mutation_prefix
        if prefix:
            out["mutation_prefix"] = prefix
        return out


@dataclass
class InterfaceAnalysis:
    """Measured description of one pose's interface.

    Every number here was computed from coordinates. `criteria` travels with
    the result so a reader can check what "contact" meant, and so two results
    are never compared under different definitions.
    """
    receptor_chain: str
    ligand_chain: str
    buried_area_a2: float
    receptor_buried_a2: float
    ligand_buried_a2: float
    receptor_residues: List[InterfaceResidue] = field(default_factory=list)
    ligand_residues: List[InterfaceResidue] = field(default_factory=list)
    contacts: List[Contact] = field(default_factory=list)
    putative_hydrogen_bonds: int = 0
    salt_bridges: int = 0
    clashes: int = 0
    hydrophobic_contacts: int = 0
    notes: List[str] = field(default_factory=list)

    @property
    def contact_count(self) -> int:
        return len([c for c in self.contacts if c.kind != "clash"])

    def as_dict(self, *, max_contacts: int = 120) -> Dict[str, Any]:
        return {
            "receptor_chain": self.receptor_chain,
            "ligand_chain": self.ligand_chain,
            "measured": {
                "buried_area_a2": round(self.buried_area_a2, 1),
                "receptor_buried_a2": round(self.receptor_buried_a2, 1),
                "ligand_buried_a2": round(self.ligand_buried_a2, 1),
                "interface_residues_receptor": len(self.receptor_residues),
                "interface_residues_ligand": len(self.ligand_residues),
                "atomic_contacts": self.contact_count,
                "putative_hydrogen_bonds": self.putative_hydrogen_bonds,
                "salt_bridges": self.salt_bridges,
                "hydrophobic_contacts": self.hydrophobic_contacts,
                "clashes": self.clashes,
            },
            "criteria": {
                "contact_cutoff_a": CONTACT_CUTOFF_A,
                "putative_hydrogen_bond_cutoff_a": HBOND_CUTOFF_A,
                "salt_bridge_cutoff_a": SALT_BRIDGE_CUTOFF_A,
                "clash_cutoff_a": CLASH_CUTOFF_A,
                "burial_min_a2": BURIAL_MIN_A2,
                "hydrogens": "not modelled; hydrogen bonds are heavy-atom "
                             "geometry only",
                "affinity": "not estimated by this method",
            },
            "receptor_residues": [r.as_dict() for r in self.receptor_residues],
            "ligand_residues": [r.as_dict() for r in self.ligand_residues],
            "contacts": [c.as_dict() for c in self.contacts[:max_contacts]],
            "contacts_truncated": max(0, len(self.contacts) - max_contacts),
            "notes": list(self.notes),
        }

    def summary_line(self) -> str:
        """One sentence of measured fact, safe to quote verbatim."""
        return (
            f"{self.buried_area_a2:.0f} A^2 buried across "
            f"{len(self.receptor_residues)}+{len(self.ligand_residues)} "
            f"residues, {self.contact_count} atomic contacts, "
            f"{self.putative_hydrogen_bonds} putative hydrogen bond(s), "
            f"{self.salt_bridges} salt bridge(s), {self.clashes} clash(es)."
        )


def _is_heavy(atom) -> bool:
    element = (getattr(atom, "element", "") or "").strip().upper()
    if element:
        return element not in ("H", "D")
    return not (atom.get_id() or "").strip().upper().startswith(("H", "D"))


def _residue_key(residue) -> Tuple[Any, ...]:
    return tuple(residue.id)


def analyse_interface(model, receptor_chain: str, ligand_chain: str, *,
                      chains: Optional[Dict[str, ChainInfo]] = None,
                      numbering: Optional[Dict[str, NumberingMap]] = None,
                      ) -> InterfaceAnalysis:
    """Measure the interface between two chains of one Biopython model.

    `model` is a parsed structure's first model holding BOTH chains — i.e. a
    docked complex, not two separate files. `numbering` maps chain id to the
    mapping established by :func:`structures.map_numbering`; when a chain is
    absent from it, that chain's residues are reported without a user position
    and a note says so.
    """
    try:
        from Bio.PDB import NeighborSearch
        from Bio.PDB.SASA import ShrakeRupley
    except Exception as exc:  # noqa: BLE001
        raise RuntimeError(
            "The structural analysis library is not available on this "
            "deployment."
        ) from exc

    try:
        rec = model[receptor_chain]
        lig = model[ligand_chain]
    except KeyError as exc:
        raise ValueError(
            f"The complex does not contain chains {receptor_chain} and "
            f"{ligand_chain}."
        ) from exc

    numbering = numbering or {}
    chains = chains or {}
    notes: List[str] = []

    # ---- buried surface area: SASA of each chain alone, minus in complex ----
    # Computed on copies so the input model is never mutated: the same model is
    # re-measured for every pose and a detached-and-reattached chain would
    # quietly change later answers.
    sasa = ShrakeRupley(n_points=SASA_POINTS)
    rec_alone = _chain_sasa(sasa, rec)
    lig_alone = _chain_sasa(sasa, lig)
    pair_sasa = _pair_sasa(sasa, rec, lig)

    rec_buried_by_residue: Dict[Tuple[Any, ...], float] = {}
    lig_buried_by_residue: Dict[Tuple[Any, ...], float] = {}
    for key, alone in rec_alone.items():
        rec_buried_by_residue[key] = max(
            0.0, alone - pair_sasa.get((receptor_chain, key), alone))
    for key, alone in lig_alone.items():
        lig_buried_by_residue[key] = max(
            0.0, alone - pair_sasa.get((ligand_chain, key), alone))

    receptor_buried = sum(rec_buried_by_residue.values())
    ligand_buried = sum(lig_buried_by_residue.values())

    # ---- contacts ----
    rec_atoms = [a for r in rec for a in r if _is_heavy(a)]
    lig_atoms = [a for r in lig for a in r if _is_heavy(a)]
    if not rec_atoms or not lig_atoms:
        raise ValueError(
            "One of the chains has no heavy atoms, so no interface can be "
            "measured.")

    contacts: List[Contact] = []
    hbonds = salt_bridges = clashes = hydrophobic = 0
    rec_contact_counts: Dict[Tuple[Any, ...], int] = {}
    lig_contact_counts: Dict[Tuple[Any, ...], int] = {}

    search = NeighborSearch(lig_atoms)
    for atom in rec_atoms:
        for partner in search.search(atom.coord, CONTACT_CUTOFF_A):
            distance = float(atom - partner)
            rres, lres = atom.get_parent(), partner.get_parent()
            rkey, lkey = _residue_key(rres), _residue_key(lres)
            rec_contact_counts[rkey] = rec_contact_counts.get(rkey, 0) + 1
            lig_contact_counts[lkey] = lig_contact_counts.get(lkey, 0) + 1

            rname = (rres.get_resname() or "").strip().upper()
            lname = (lres.get_resname() or "").strip().upper()
            raid = (atom.get_id() or "").strip().upper()
            laid = (partner.get_id() or "").strip().upper()
            relem = (getattr(atom, "element", "") or raid[:1]).strip().upper()
            lelem = (getattr(partner, "element", "")
                     or laid[:1]).strip().upper()

            kind = "contact"
            if distance < CLASH_CUTOFF_A:
                kind = "clash"
                clashes += 1
            elif (distance <= SALT_BRIDGE_CUTOFF_A
                  and (((rname, raid) in _CHARGED_DONORS
                        and (lname, laid) in _CHARGED_ACCEPTORS)
                       or ((lname, laid) in _CHARGED_DONORS
                           and (rname, raid) in _CHARGED_ACCEPTORS))):
                kind = "salt_bridge"
                salt_bridges += 1
            elif (distance <= HBOND_CUTOFF_A
                  and relem in ("N", "O") and lelem in ("N", "O")):
                kind = "putative_hydrogen_bond"
                hbonds += 1
            elif (relem == "C" and lelem == "C"
                  and rname in _HYDROPHOBIC and lname in _HYDROPHOBIC):
                hydrophobic += 1

            contacts.append(Contact(
                receptor=_ref_for(rres, receptor_chain),
                ligand=_ref_for(lres, ligand_chain),
                receptor_atom=raid, ligand_atom=laid,
                distance_a=distance, kind=kind,
            ))

    # Shortest contact first: a reader scanning the list should meet the
    # tightest interactions, and clashes, before the incidental ones.
    contacts.sort(key=lambda c: c.distance_a)

    receptor_residues = _interface_residues(
        rec, receptor_chain, rec_buried_by_residue, rec_contact_counts,
        numbering.get(receptor_chain))
    ligand_residues = _interface_residues(
        lig, ligand_chain, lig_buried_by_residue, lig_contact_counts,
        numbering.get(ligand_chain))

    for chain_id, residues in ((receptor_chain, receptor_residues),
                              (ligand_chain, ligand_residues)):
        if chain_id not in numbering and residues:
            notes.append(
                f"Chain {chain_id} residues are given in the structure's own "
                f"numbering; no sequence was supplied to map them onto.")
        elif residues:
            unmapped = [r for r in residues if r.user_position is None]
            if unmapped:
                notes.append(
                    f"{len(unmapped)} interface residue(s) on chain {chain_id} "
                    f"are not present in the sequence supplied for it, so they "
                    f"carry no sequence position.")

    if clashes:
        notes.append(
            f"{clashes} atom pair(s) are closer than {CLASH_CUTOFF_A} A. A "
            f"pose with steric clashes is physically questionable.")

    return InterfaceAnalysis(
        receptor_chain=receptor_chain,
        ligand_chain=ligand_chain,
        buried_area_a2=receptor_buried + ligand_buried,
        receptor_buried_a2=receptor_buried,
        ligand_buried_a2=ligand_buried,
        receptor_residues=receptor_residues,
        ligand_residues=ligand_residues,
        contacts=contacts,
        putative_hydrogen_bonds=hbonds,
        salt_bridges=salt_bridges,
        clashes=clashes,
        hydrophobic_contacts=hydrophobic,
        notes=notes,
    )


def _ref_for(residue, chain_id: str) -> ResidueRef:
    _, seq_id, icode = residue.id
    return ResidueRef(chain=chain_id, seq_id=int(seq_id),
                      icode=str(icode or " "),
                      resname=(residue.get_resname() or "").strip().upper())


def _chain_sasa(sasa, chain) -> Dict[Tuple[Any, ...], float]:
    """Per-residue SASA for one chain in isolation.

    Biopython writes `.sasa` onto the entity it is given, so this runs on a
    detached copy — computing on the live chain would leave stale values behind
    for whatever measures it next.
    """
    import copy

    clone = copy.deepcopy(chain)
    clone.detach_parent()
    sasa.compute(clone, level="R")
    return {_residue_key(r): float(getattr(r, "sasa", 0.0) or 0.0)
            for r in clone}


def _pair_sasa(sasa, rec, lig) -> Dict[Tuple[str, Tuple[Any, ...]], float]:
    """Per-residue SASA for both chains together, keyed by (chain, residue)."""
    import copy

    from Bio.PDB.Model import Model

    holder = Model(0)
    rec_copy, lig_copy = copy.deepcopy(rec), copy.deepcopy(lig)
    rec_copy.detach_parent()
    lig_copy.detach_parent()
    holder.add(rec_copy)
    holder.add(lig_copy)
    sasa.compute(holder, level="R")
    out: Dict[Tuple[str, Tuple[Any, ...]], float] = {}
    for chain in holder:
        for residue in chain:
            out[(chain.id, _residue_key(residue))] = float(
                getattr(residue, "sasa", 0.0) or 0.0)
    return out


def _interface_residues(chain, chain_id: str,
                        buried: Dict[Tuple[Any, ...], float],
                        contact_counts: Dict[Tuple[Any, ...], int],
                        mapping: Optional[NumberingMap],
                        ) -> List[InterfaceResidue]:
    """Residues that bury area or make contact, most buried first."""
    out: List[InterfaceResidue] = []
    for residue in chain:
        key = _residue_key(residue)
        area = buried.get(key, 0.0)
        count = contact_counts.get(key, 0)
        if area < BURIAL_MIN_A2 and count == 0:
            continue
        ref = _ref_for(residue, chain_id)
        out.append(InterfaceResidue(
            ref=ref,
            buried_a2=area,
            contacts=count,
            user_position=(mapping.to_user_position(ref) if mapping else None),
        ))
    out.sort(key=lambda r: (-r.buried_a2, r.ref.seq_id))
    return out


# --------------------------------------------------------------------------
# Ranking
# --------------------------------------------------------------------------


def rank_poses(poses: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
    """Order candidate poses, defensibly, and say what the order rests on.

    The provider's own confidence leads when it reports one. That is deliberate:
    a co-folding model's interface confidence (ipTM) is trained to predict
    whether an interface is real, which is the actual question, and no
    combination of geometry measured here is a better answer to it. Measured
    geometry is the tie-break and the sanity check, not the primary signal.

    A pose with steric clashes is demoted regardless of its score: whatever the
    model believed, atoms do not interpenetrate.

    Each pose is annotated with `rank_basis` naming what decided its place, so
    the ordering is never a black box. Returns a new list; the input is not
    modified.
    """
    ordered: List[Dict[str, Any]] = []
    for pose in poses:
        item = dict(pose)
        measured = (item.get("interface") or {}).get("measured") or {}
        confidence = item.get("confidence") or {}

        primary = None
        basis = []
        for field_name in ("iptm", "protein_iptm", "interface_confidence"):
            value = confidence.get(field_name)
            if isinstance(value, (int, float)):
                primary = float(value)
                basis.append(f"{field_name}={primary:.3f} (reported by the "
                             f"method)")
                break
        if primary is None and isinstance(item.get("score"), (int, float)):
            primary = float(item["score"])
            basis.append(f"score={primary:.3f} (reported by the method)")

        buried = float(measured.get("buried_area_a2") or 0.0)
        clashes = int(measured.get("clashes") or 0)
        if buried:
            basis.append(f"{buried:.0f} A^2 buried (measured)")
        if clashes:
            basis.append(f"{clashes} clash(es) (measured) — demoted")

        item["rank_basis"] = "; ".join(basis) or "no ranking signal available"
        item["_sort"] = (
            1 if clashes else 0,
            -(primary if primary is not None else -1.0),
            -buried,
        )
        ordered.append(item)

    ordered.sort(key=lambda p: p["_sort"])
    for position, item in enumerate(ordered, start=1):
        item.pop("_sort", None)
        item["rank"] = position
    return ordered