Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
# Changelog

## 1.4.4 - 2026-10-01

### Fixed
- Per-run CSV reuse now requires `interfaces.csv.meta.json`, matching scoring options, inputs, software/calibration, and CSV checksum. The default checks file size, mtime, ctime and file identity without reading input payloads; `--cache_validation content` hashes every input for strict validation. Metadata can miss same-size edits on filesystems with coarse or preserved timestamps. Old caches and incomplete scoring runs are recomputed; CSVs and manifests are published with atomic file replacement.
- Native AlphaFold-Multimer rankings with only `order` and `iptm+ptm` are accepted. Individual confidences and missing PAE JSON are recovered from result pickles, including gzip/xz; unavailable individual scores remain missing.
- Parser backend and selected structure are written to `backend` and `structure_file`. Calibration and report labels use the explicit backend, preventing Boltz filenames from selecting an AF2 ladder. Legacy CSV inference is retained.
- Corrupt JSON reports the actual file, PAE dimensions and values are checked before scoring, and unmappable Boltz PAE is rejected instead of trimmed. Failed models log tracebacks and cannot leave partially scored model rows or a reusable completed cache.
- AF2 structure choice is deterministic: exact preferred state, other state, model name, model directory, then ranked output; CIF precedes PDB within each category. `--af2_structure relaxed` is the default; `unrelaxed` reverses the first two choices. Legacy fallback matching respects model-name boundaries and sorts candidates. This can change scores when both coordinate sets exist: the old glob order was unspecified, and the frozen calibration predates per-row structure provenance, so its relaxed/unrelaxed selection cannot be reconstructed reliably. The existing calibration is retained, not represented as a uniformly relaxed or unrelaxed reference.
- PAE PNG requests and rendering failures no longer invalidate CSV scores. Missing, stale or failed plots can be regenerated on a cache hit without rerunning interface scoring or biophysical calculations.
- A missing AF3 full-confidence file now raises a file-not-found error even when the summary contains chain-pair minima. Existing minima-only files retain a distinct insufficient-PAE error; combined exports with actual full PAE remain supported.
- Filtered residues retain a full Bio.PDB hierarchy, so equal residue numbers on different chains have distinct identities. The existing pairwise pDockQ2 formula and coefficients are unchanged, with an IPSAE compatibility regression.
- Per-interface and aggregate ranking tables paginate without dropping rows, with repeated headers and sequential page numbers. Reports show the actual metascore feature count instead of implying that every row has all eleven features.
- Report imports preserve the caller's Matplotlib backend and figures; rendering restores plotting settings even on errors. Invalid `--aggregate_report` usage is rejected before scoring starts.
- CSVs record scoring cutoffs, a content-derived calibration ID, and the contributing metascore features/count. Reports disclose recalculation versus stored scores and flag custom or unknown cutoffs; custom settings retain the existing formulas and reference distributions, with percentiles labelled exploratory. The unsupported interface-area entry was removed from the percentile registry; raw area remains available.

### CSV additions

| Column | Meaning |
| --- | --- |
| `backend` | Prediction source: `af2`, `af3`, or `boltz2`. |
| `structure_file` | Selected structure path relative to the run directory. |
| `contact_thresh` | Residue contact cutoff in Å. |
| `pae_filter` | Maximum mean interface PAE accepted for a CSV row, in Å. |
| `ipsae_pae_cutoff` | PAE cutoff used by ipSAE, in Å. |
| `metascore_calibration` | Content-derived identifier of the frozen calibration. |
| `metascore_calibration_status` | Whether scoring cutoffs match the calibration defaults: `default`, `custom`, or `unknown`. |
| `metascore_features` | Semicolon-separated names of the features contributing to this row's metascore. |
| `metascore_feature_count` | Number of contributing features. |

Custom parsers should accept `parse_run(directory, *, options=ParseOptions())`, ignoring unused options; `cache_options` identifies which parser options affect scores.

## 1.4.3 - 2026-09-28

### Fixed
Expand Down
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,9 +237,8 @@ AlphaJudge writes `interfaces.csv` with one row per interface (and includes the
- **interface_hb, interface_sb, interface_sc, interface_area, interface_solv_en**

Exact header is asserted in tests to be consistent across AF2 and AF3 runs.
When a normal or summary run finds a cached per-run CSV from an older release that lacks current
required fields, AlphaJudge recomputes that run automatically; `--force_recompute` remains
available when values, rather than the schema, must be refreshed.
Cached results are reused when inputs and scoring settings match; use `--force_recompute` to rescore.
See the [changelog](CHANGELOG.md) for release details.

---

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "alphajudge"
version = "1.4.3"
version = "1.4.4"
description = "Evaluate AlphaFold-predicted protein complexes using confidence metrics and interface biophysics."
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.10"
Expand Down
139 changes: 139 additions & 0 deletions src/alphajudge/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""Versioned provenance for per-run CSV reuse.

Input metadata is checked by default; content validation hashes compressed
inputs as stored. Package source (including calibration) and CSV output are
always hashed. Missing/old manifests are cache misses.
"""
from __future__ import annotations

from contextlib import contextmanager
from dataclasses import asdict, dataclass
import hashlib
from importlib.metadata import PackageNotFoundError, version
import json
from pathlib import Path
import platform
import tempfile

from .parsers import ParseOptions

SCHEMA_VERSION = 2
_INPUT_SUFFIXES = (".json", ".json.gz", ".json.xz", ".pkl", ".pkl.gz", ".pkl.xz",
".npz", ".cif", ".pdb")


@dataclass(frozen=True)
class ScoringSettings:
contact_thresh: float = 8.0
pae_filter: float = 100.0
models_to_analyse: str = "best"
ipsae_pae_cutoff: float = 10.0
skip_biophysical_scores: bool = False
parser_options: ParseOptions | None = None


def file_digest(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()


def manifest_path(csv_path: Path) -> Path:
return csv_path.with_name(csv_path.name + ".meta.json")


def file_fingerprint(path: Path) -> dict[str, int]:
"""Cheap change detection, including file replacements.

ctime supplements mtime, but coarse/preserved timestamps can still hide
same-size rewrites. Use content validation when metadata is insufficient.
"""
stat = path.stat()
return {"size": stat.st_size, "mtime_ns": stat.st_mtime_ns, "ctime_ns": stat.st_ctime_ns,
"device": stat.st_dev, "inode": stat.st_ino}


def software_identity() -> dict:
root = Path(__file__).parent
dependencies = {}
for package in ("alphajudge", "numpy", "scipy", "biopython", "matplotlib"):
try:
dependencies[package] = version(package)
except PackageNotFoundError:
dependencies[package] = "uninstalled"
return {
"python": platform.python_version(), "packages": dependencies,
"source": {str(p.relative_to(root)): file_digest(p) for p in sorted(root.rglob("*.py"))},
}


def request_identity(directory: Path, csv_path: Path, *,
settings: ScoringSettings = ScoringSettings(),
cache_validation: str = "stat") -> dict:
if cache_validation not in {"stat", "content"}:
raise ValueError("cache_validation must be 'stat' or 'content'")
inputs = {}
for path in sorted(directory.rglob("*")):
if not path.is_file() or path == csv_path or path.name.endswith(".meta.json"):
continue
if path.name.endswith(_INPUT_SUFFIXES) or path.name.endswith("ranking_scores.csv"):
inputs[str(path.relative_to(directory))] = (
file_digest(path) if cache_validation == "content" else file_fingerprint(path)
)
return {"schema": SCHEMA_VERSION, "directory": str(directory.resolve()),
"cache_validation": cache_validation,
"settings": asdict(settings), "inputs": inputs,
"software": software_identity()}


@contextmanager
def atomic_text(path: Path):
"""Publish a complete file with a same-directory atomic rename."""
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(mode="w", newline="", dir=path.parent,
prefix=f".{path.name}.", suffix=".tmp", delete=False) as handle:
temporary = Path(handle.name)
try:
yield handle
handle.close()
temporary.replace(path)
finally:
temporary.unlink(missing_ok=True)


def write_manifest(csv_path: Path, request: dict, *, csv_sha256: str, complete: bool,
backend: str, models: list[str], structure_files: dict[str, str],
pae_pngs: dict | None = None) -> None:
manifest = {"request": request, "complete": complete,
"csv_sha256": csv_sha256, "backend": backend,
"models": models, "structure_files": structure_files, "pae_pngs": pae_pngs or {}}
_write_manifest_data(csv_path, manifest)


def _write_manifest_data(csv_path: Path, manifest: dict) -> None:
with atomic_text(manifest_path(csv_path)) as handle:
json.dump(manifest, handle, indent=2, sort_keys=True)
handle.write("\n")


def read_manifest(csv_path: Path) -> dict:
return json.loads(manifest_path(csv_path).read_text())


def update_pae_pngs(csv_path: Path, request: dict, pngs: dict) -> None:
"""Update optional plots without certifying new CSV bytes or scoring options."""
manifest = read_manifest(csv_path)
if manifest.get("request") == request:
manifest["pae_pngs"] = pngs
_write_manifest_data(csv_path, manifest)


def matches(csv_path: Path, request: dict) -> bool:
try:
manifest = read_manifest(csv_path)
return (manifest.get("complete") is True and manifest.get("request") == request
and manifest.get("csv_sha256") == file_digest(csv_path))
except (OSError, ValueError, AttributeError):
return False
10 changes: 8 additions & 2 deletions src/alphajudge/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ def main() -> None:
p.add_argument("--pae_filter", type=float, default=100.0)
p.add_argument("--ipsae_pae_cutoff", type=float, default=10.0)
p.add_argument("--models_to_analyse", choices=["best","all"], default="best")
p.add_argument("--af2_structure", choices=["relaxed", "unrelaxed"], default="relaxed",
help="Preferred AF2 coordinates when both states exist (default: relaxed).")
p.add_argument("--cache_validation", choices=["stat", "content"], default="stat",
help="Validate input metadata (fast default) or hash full input content.")
p.add_argument("-r","--recursive", action="store_true", help="Recursively search for runs under given PATHS")
p.add_argument("-o","--summary", help="Write aggregated CSV across runs to this path")
p.add_argument(
Expand Down Expand Up @@ -67,6 +71,8 @@ def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(message)s")
if not args.paths:
p.error("Provide PATHS")
if args.aggregate_report and not args.summary:
p.error("--aggregate_report requires --summary")

write_per_run_report = args.report
if write_per_run_report is None:
Expand All @@ -86,11 +92,11 @@ def main() -> None:
skip_pae_png=args.skip_pae_png,
skip_biophysical_scores=args.skip_biophysical_scores,
write_per_run_report=write_per_run_report,
cache_validation=args.cache_validation,
af2_structure=args.af2_structure,
)

if args.aggregate_report:
if not args.summary:
p.error("--aggregate_report requires --summary")
if summary_path is None:
p.error(
f"--aggregate_report requested but no summary was written to "
Expand Down
28 changes: 14 additions & 14 deletions src/alphajudge/complex.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
from functools import cached_property
from typing import Any

from Bio.PDB import Chain
from Bio.PDB import Chain, Model, Structure

from .confidence import Confidence
from .confidence import Confidence, validate_pae
from .docking_scores import MPDOCKQ
from .geometry import is_pae_token_residue
from .geometry import chain_boundaries, is_pae_token_residue
from .interface import Interface

logger = logging.getLogger(__name__)
Expand All @@ -35,7 +35,13 @@ def __init__(
self.pae_filter = float(pae_filter)
self.ipsae_pae_cutoff = None if ipsae_pae_cutoff is None else float(ipsae_pae_cutoff)

self._res_index_map, self._chain_indices_by_id, self._chains = self._build_maps()
# Retain the Bio.PDB hierarchy so equally numbered residues on distinct
# chains retain distinct identities after filtering.
model = next(structure.get_models())
self._scored_structure = Structure.Structure(structure.id)
scored_model = Model.Model(model.id)
self._scored_structure.add(scored_model)
self._res_index_map, self._chain_indices_by_id, self._chains = self._build_maps(model, scored_model)

self.interfaces: list[Interface] = []
for i in range(len(self._chains)):
Expand All @@ -44,8 +50,7 @@ def __init__(
if iface.num_intf_residues > 0:
self.interfaces.append(iface)

def _build_maps(self) -> tuple[dict[tuple[str, Any], int], dict[str, list[int]], list[Chain.Chain]]:
model = next(self.structure.get_models())
def _build_maps(self, model, scored_model) -> tuple[dict[tuple[str, Any], int], dict[str, list[int]], list[Chain.Chain]]:
chains = list(model.get_chains())

res_index_map: dict[tuple[str, Any], int] = {}
Expand All @@ -59,6 +64,7 @@ def _build_maps(self) -> tuple[dict[tuple[str, Any], int], dict[str, list[int]],
continue

new_chain = Chain.Chain(chain.id)
scored_model.add(new_chain)
for residue in kept:
new_chain.add(residue.copy())
filtered_chains.append(new_chain)
Expand All @@ -70,12 +76,7 @@ def _build_maps(self) -> tuple[dict[tuple[str, Any], int], dict[str, list[int]],
idx += 1
chain_indices_by_id[new_chain.id] = idxs

pae_n = len(self.conf.pae_matrix)
if idx != pae_n:
logger.warning(
f"token residues counted = {idx}, but PAE is {pae_n}x{pae_n}. "
f"Indexing may be misaligned for this structure."
)
validate_pae(self.conf.pae_matrix, idx)

return res_index_map, chain_indices_by_id, filtered_chains

Expand All @@ -86,8 +87,7 @@ def num_chains(self) -> int:
@property
def chain_boundaries(self) -> list[float]:
"""PAE-matrix positions between consecutive chains (heatmap separators)."""
ends = sorted(max(idxs) + 0.5 for idxs in self._chain_indices_by_id.values() if idxs)
return ends[:-1]
return chain_boundaries(self._chain_indices_by_id)

@cached_property
def average_interface_plddt(self) -> float:
Expand Down
11 changes: 11 additions & 0 deletions src/alphajudge/confidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@
IPTM_SCOPE_GLOBAL = "global"


def validate_pae(pae: np.ndarray, residue_count: int, *, source: str = "PAE") -> None:
"""Reject incomplete or invalid residue matrices before interface scoring."""
if pae.shape != (residue_count, residue_count):
raise ValueError(
f"{source}: PAE shape {pae.shape} does not match {residue_count} scored residues "
f"(expected {residue_count}x{residue_count}); cannot align PAE to structure"
)
if not np.all(np.isfinite(pae)) or np.any(pae < 0):
raise ValueError(f"{source}: PAE values must be finite and non-negative")


@dataclass(frozen=True)
class Confidence:
pae_matrix: np.ndarray # Keep as numpy array for memory efficiency (7-10x reduction)
Expand Down
7 changes: 7 additions & 0 deletions src/alphajudge/geometry.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from collections.abc import Mapping, Sequence

from Bio.PDB.Polypeptide import is_aa


Expand All @@ -21,6 +23,11 @@
}


def chain_boundaries(chain_indices: Mapping[str, Sequence[int]]) -> list[float]:
"""PAE-matrix positions between nonempty scored chains."""
return sorted(max(indices) + .5 for indices in chain_indices.values() if indices)[:-1]


def representative_atom(res):
"""
Representative atom for distance / bfactor use.
Expand Down
5 changes: 1 addition & 4 deletions src/alphajudge/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,10 +345,7 @@ def _avg_plddt_union(self) -> float:
def _avg_pae_over_pairs(self) -> float:
vals = []
for i, j in self._pair_indices():
try:
vals.extend((float(self._pae[i, j]), float(self._pae[j, i])))
except IndexError: # PAE smaller than the scored residues
continue
vals.extend((float(self._pae[i, j]), float(self._pae[j, i])))
return sum(vals) / len(vals) if vals else float("nan")

def _frac(self, names: set[str]) -> float:
Expand Down
Loading
Loading