Skip to content
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ build/
venv/
env/

# uv resolver lockfile: local environment setup only, not a project workflow
uv.lock

# IDE
.vscode/
.idea/
Expand Down
372 changes: 359 additions & 13 deletions src/gpcr_tools/aggregator/runner.py

Large diffs are not rendered by default.

72 changes: 62 additions & 10 deletions src/gpcr_tools/aggregator/voting.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
from typing import Any

from gpcr_tools.config import (
APO_SENTINEL,
CASE_FOLD_NAME_FIELDS,
GROUND_TRUTH_PATHS,
LIST_ITEM_KEY_FIELDS,
SITE_REF_UNKNOWN,
SOFT_FIELD_KEYS,
VOTE_NEAR_TIE_MARGIN,
is_empty_key,
Expand All @@ -22,6 +24,33 @@
list_item_identity as _list_item_identity,
)

# The keyless grouping identity of an apo (no-ligand) placeholder, as it appears
# inside a discrepancy path: ``ligands[__keyless__:apo].site_ref``. The closing
# bracket is part of the match so a real component whose normalized name merely
# starts with "apo" (e.g. an "apocynin" ligand) is never mistaken for it.
_APO_PLACEHOLDER_SEGMENT = f"[__keyless__:{APO_SENTINEL}]"


def _site_ref_controversy_is_non_gating(path: str, shipped_value: Any) -> bool:
"""Whether a ``site_ref`` vote controversy should be advisory, not gating.

A binding-site disagreement stops encoding a shipped error in exactly two
cases, and no others:

* the controversy is on the apo (no-ligand) placeholder, whose ``site_ref``
is meaningless and never reaches ligands.csv; or
* the shipped value is literally ``unknown`` -- the record committed to no
site, so there is no wrong site to review.

A near-tie between two REAL sites (orthosteric vs allosteric), or the mere
presence of ``unknown`` among the votes while a real site was shipped, is a
genuine site conflict and keeps gating.
"""
if _APO_PLACEHOLDER_SEGMENT in path:
return True
return str(shipped_value).strip().lower() == SITE_REF_UNKNOWN


# ---------------------------------------------------------------------------
# Utility helpers
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -435,6 +464,14 @@ def find_discrepancies(
# also keeps gating.
if current_key == "role" and path.startswith("ligand_copies["):
record["gating"] = False
# A site_ref controversy is advisory when it is on the apo placeholder
# or the shipped value is 'unknown' (no committed site to be wrong).
# Real site conflicts (a shipped orthosteric/allosteric value) keep
# gating.
if current_key == "site_ref" and _site_ref_controversy_is_non_gating(
path, best_run_data
):
record["gating"] = False
discrepancies.append(record)
else:
margin = _vote_margin(all_votes_data)
Expand All @@ -458,6 +495,14 @@ def find_discrepancies(
# surface it for review without gating accept-all.
if current_key == "role" and path.startswith("ligand_copies["):
record["gating"] = False
# Same site_ref carve-out as the differing-value branch: an apo
# placeholder or a shipped 'unknown' cannot encode a wrong site,
# so a near-tie there is advisory; a near-tie between two real
# sites keeps gating.
if current_key == "site_ref" and _site_ref_controversy_is_non_gating(
path, best_run_data
):
record["gating"] = False
discrepancies.append(record)
return discrepancies

Expand Down Expand Up @@ -527,15 +572,22 @@ def _record(node_path: str, node: Any) -> dict[str, Any]:
confidence = copy_row.get("confidence")
for field in ("site_ref", "role"):
value = copy_row.get(field)
flags.append(
{
"path": f"ligand_copies[{key}].{field}",
"best_run_value": value,
"majority_vote_value": value,
"all_votes": {},
"needs_review": True,
"low_confidence": confidence,
}
)
flag: dict[str, Any] = {
"path": f"ligand_copies[{key}].{field}",
"best_run_value": value,
"majority_vote_value": value,
"all_votes": {},
"needs_review": True,
"low_confidence": confidence,
}
# A per-copy ROLE cannot encode a shipped error: the CSV Role
# column is taken from the compound-level ligand, never a
# ligand_copies row (mirrors the near-tie carve-out in
# find_discrepancies). It stays visible for review but is
# advisory. The per-copy site_ref does drive residue partitioning,
# so it keeps gating here.
if field == "role":
flag["gating"] = False
flags.append(flag)

return flags
143 changes: 143 additions & 0 deletions src/gpcr_tools/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1439,6 +1439,15 @@ def gpcrdb_aux_type_for(comp_id: str | None) -> str:
# and are still surfaced by the SUSPICIOUS_7TM alert).
GPCR_MIN_ANNOTATED_TM: int = 4

# A chain whose every annotated TM helix is resolved counts as COMPLETE even when
# fewer than six TMs were annotated, provided the annotation is substantial (at
# least this many TMs). This covers receptors whose UniProt/RCSB mapping only
# resolved five of the canonical seven helices: with resolved_tms == total_tms
# no mapped TM is unmodeled, so the "incompleteness" is a mapping artifact, not
# missing density. Kept above single-/few-pass partner slugs (total_tms small)
# so those never wave through.
TM_MIN_RECEPTOR_ANNOTATED: int = 5

TM_ENTITY_FEATURE_TYPES: frozenset[str] = frozenset(
{
"TRANSMEMBRANE",
Expand Down Expand Up @@ -1503,6 +1512,132 @@ def gpcrdb_aux_type_for(comp_id: str | None) -> str:
"rarr2",
"a0a",
"mtor",
# Non-receptor chains that carry a GPCRdb entry-name slug but are not 7TM
# receptors: peptide / protein agonists, chemokines, toxins, crystallization
# and expression partners, and signalling-pathway proteins. Without a
# transmembrane span they trip the SUSPICIOUS_7TM tripwire; denylisting their
# slug prefixes keeps them out of the receptor roster. Every prefix below was
# checked against the full GPCRdb real-receptor slug universe (all species)
# and filters zero real receptors. A trailing underscore is the safe form for
# a ligand stem that would otherwise be a prefix of its own receptor family
# (e.g. "npy_" matches the ligand npy_* but not the npy1r_* receptors).
# Adhesion receptors and the V2 receptor slug are deliberately NOT listed:
# their stems cannot be disambiguated from real receptors, so they remain
# covered by the SUSPICIOUS_7TM alert.
# Chemokine ligands
"ccl2",
"ccl5",
"ccl7",
"ccl15",
"ccl19",
"cxcl2",
"cxcl3",
"cxcl5",
"cxcl6",
"cxcl9",
"cxl10",
"cxl11",
"sdf1",
"il8",
"groa",
"x3cl1",
"xcl1",
# Peptide / protein-hormone ligands
"edn1",
"edn3",
"pthy",
"pthr",
"npy_",
"pyy",
"cckn",
"tkn1",
"tknk",
"sms",
"paca",
"gala",
"apel",
"angt",
"ucn1",
"kng1",
"penk",
"pdyn",
"pnoc",
"prrp",
"calca",
"calc_",
"adml",
"adm2",
"secr",
"gast",
"ghrl",
"nmu_",
"nmb_",
"nms",
"npff_",
"vip_",
"crh_",
"gip_",
"grp_",
"insl5",
"moti",
"orex",
"kiss1",
"cort",
"mch_",
"tip39",
"spxn",
"hunin",
"ela",
"slib",
"ndp",
"rspo1",
"rspo2",
"gp15l",
"paho",
"exe3",
"exe4",
# Toxin / venom peptides
"3si1a",
"3sim3",
"3sim7",
"srtx",
"maxa",
"crfa2",
# Non-receptor proteins (fusion tags, signalling-pathway, viral, other)
"thio",
"coli",
"luci",
"spg1",
"spa",
"gpa1",
"mfal1",
"hema",
"env",
"vmi2",
"da2d",
"cd4",
"co3",
"co5",
"neu1",
"neu2",
"neut",
"gnb5",
"rgs7",
"pp2ba",
"vgf",
"ox26",
"dvl2",
# Accession-named non-receptor chains
"b5xgr7",
"b5z8h1",
"f5gzd9",
"f6vl43",
"o87916",
"q5fwy2",
"q70145",
"q7z3y4",
"q92163",
"v9hw68",
)

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1621,6 +1756,14 @@ def gpcrdb_aux_type_for(comp_id: str | None) -> str:
ALERT_PREFIX_ALGO_WARNING: str = "[ALGO WARNING]"
ALERT_PREFIX_API_UNAVAILABLE: str = "[API_UNAVAILABLE]"
ALERT_PREFIX_CHIMERIC_REVIEW: str = "[CHIMERIC G PROTEIN]"
# The alpha5 helix resolves only the coupling FAMILY; members with identical
# alpha5 (e.g. gnai1/gnai2, gnaq/gna11, the transducins) cannot be split from
# structure. When the family is verified and the record is otherwise a native,
# family-consistent G protein this is advisory, not a chimera to resolve.
ALERT_PREFIX_GALPHA_SUBTYPE_UNRESOLVED: str = "[G-ALPHA SUBTYPE UNRESOLVED]"
# The alpha5 family matches but the candidate slugs are non-human orthologs, so
# the species / GPCRdb mapping is unconfirmed -- gating.
ALERT_PREFIX_GALPHA_SPECIES_UNVERIFIED: str = "[G-ALPHA SPECIES UNVERIFIED]"
ALERT_PREFIX_MISSED_POLYMER: str = "[UNANNOTATED CHAIN]"
ALERT_PREFIX_FUSION_NOTE: str = "[CRYSTALLIZATION FUSION]"
ALERT_PREFIX_BINDER_RENAME: str = "[BINDER NAME CORRECTED]"
Expand Down
71 changes: 70 additions & 1 deletion src/gpcr_tools/validator/gating.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

from __future__ import annotations

import re

from gpcr_tools.config import (
ALERT_HALLUCINATION,
ALERT_MISSED_PROTOMER,
Expand Down Expand Up @@ -42,6 +44,43 @@
}
)

# Block key of the per-copy ligand table (one row per modelled copy, each
# recording the binding site that copy occupies).
_LIGAND_COPIES_BLOCK: str = "ligand_copies"

# The compound-level routing anchor a multi-copy ligand alert is built with,
# e.g. "at 'ligands[CLR]':". Matched so the mirror can re-anchor the same text at
# the per-copy table while keeping the component id visible to the curator.
_LIGANDS_ANCHOR_RE = re.compile(r"at '\.?ligands\[(?P<component>[^\]]*)\]'\s*:?\s*")


def _mirror_at_ligand_copies(alert_type: str, message: str | None) -> str:
"""Re-anchor a compound-level ligand alert at the per-copy ligand table.

A multi-copy ligand alert is raised against the compound (``ligands[<comp>]``),
but the compound-level row cannot say which copy sits at which site -- only the
per-copy table can, and that is where a wrong site assignment is corrected.
Rewriting the anchor to ``ligand_copies`` makes the alert reachable from that
table, and the component id is carried over in words so the curator still knows
which compound is meant without re-introducing a compound-level anchor (which
would route the mirror straight back to the compound table).

The ``[TYPE]`` label is preserved exactly once via :func:`ensure_alert_prefix`,
so a back-catalogue message stored without its label still gets one, and a
current message keeps the single label it already carries. A message with no
recognizable compound anchor is simply anchored at the per-copy table as-is.
"""
label = f"[{alert_type}]"
body = ensure_alert_prefix(alert_type, message)[len(label) :].strip()

anchor = f"at '{_LIGAND_COPIES_BLOCK}':"
match = _LIGANDS_ANCHOR_RE.search(body)
if match:
anchor = f"at '{_LIGAND_COPIES_BLOCK}' (component {match.group('component')}):"
body = (body[: match.start()] + body[match.end() :]).strip()

return f"{label} {anchor} {body}".strip()


def oligomer_gating_warnings(oligo: dict | None) -> list[str]:
"""Oligomer findings that should gate review, as curator-facing strings.
Expand All @@ -50,6 +89,11 @@ def oligomer_gating_warnings(oligo: dict | None) -> list[str]:
surfaces under ``receptor_info`` (a corrected chain id, a gating oligomer
alert, a multi-copy ligand, an incomplete 7TM domain). Returns ``[]`` for an
absent or empty oligomer analysis.

A gating multi-copy ligand alert yields TWO strings: the compound-anchored
original plus a copy re-anchored at ``ligand_copies``, because the per-copy
table is the only block whose fields can record which copy sits at which
binding site (see :func:`_mirror_at_ligand_copies`).
"""
if not oligo:
return []
Expand All @@ -67,17 +111,42 @@ def oligomer_gating_warnings(oligo: dict | None) -> list[str]:
for alert in oligo.get("alerts") or []:
atype = alert.get("type") or ""
if atype in _GATING_OLIGOMER_ALERTS:
if atype == ALERT_OLIGOMER_DISAGREEMENT and not alert.get("gating", True):
# An OD alert the aggregator downgraded to advisory: the AI released
# monomer while the classifier counted >=2 same-slug chains, but
# RCSB's own global biological assembly records the receptor as a
# single copy (a Monomer assembly, or an all-single stoichiometry),
# so the released monomer agrees with both the AI and RCSB. Still
# surfaced to the curator via the alert list, but not gating -- the
# same non-gating policy the parallel ASSEMBLY_MISMATCH advisory
# already carries. The flag defaults to True so a back-catalogue OD
# alert recorded before the flag existed still gates rather than
# being silently waved through.
continue
# The "at 'receptor_info'" prefix is a routing anchor so this alert
# buckets under the receptor block during review. ensure_alert_prefix
# keeps the message's own "[TYPE]" label present exactly once --
# current validator messages already carry it (re-prepending would
# duplicate it), while older recorded data needs it added.
message = ensure_alert_prefix(atype, alert.get("message"))
warnings.append(f"OLIGOMER ALERT at 'receptor_info': {message}")
elif atype == ALERT_MULTI_COPY_LIGAND:
elif atype == ALERT_MULTI_COPY_LIGAND and alert.get("gating", True):
# A multi-copy ligand gates only when its copies sit at distinct binding
# sites; the aggregator stamps that decision on the alert's ``gating``
# flag (copies sharing one site are advisory, still surfaced elsewhere).
# The flag defaults to True so a back-catalogue alert recorded before the
# flag existed still gates rather than being silently waved through.
# Already carries its own 'ligands[...]' path, so it buckets with the
# ligand block during review rather than under receptor_info.
warnings.append(alert.get("message") or "")
# ... but the compound-level ligand row has no field for a per-copy
# site: the copies are only separable in the per-copy ligand table, so
# that is the one block where a curator can act on this alert. Emit an
# additional copy of the same finding re-anchored there, so the per-copy
# table is opened for review instead of passing through unseen. Only a
# gating alert is mirrored -- copies that share one binding site need no
# per-copy decision, so mirroring those would be pure prompt noise.
warnings.append(_mirror_at_ligand_copies(atype, alert.get("message")))

if any(c.get("7tm_status") == TM_STATUS_INCOMPLETE for c in oligo.get("all_gpcr_chains") or []):
warnings.append(
Expand Down
Loading