if-split 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. if_split-0.3.0/CHANGELOG.md +88 -0
  2. {if_split-0.2.0 → if_split-0.3.0}/CLAUDE.md +14 -6
  3. {if_split-0.2.0 → if_split-0.3.0}/PKG-INFO +187 -25
  4. {if_split-0.2.0 → if_split-0.3.0}/PLAN.md +44 -14
  5. {if_split-0.2.0 → if_split-0.3.0}/README.md +186 -24
  6. {if_split-0.2.0 → if_split-0.3.0}/config/default.yaml +23 -5
  7. if_split-0.3.0/config/masterclass.yaml +51 -0
  8. if_split-0.3.0/examples/IF-Split-2026.07.14/README.md +118 -0
  9. if_split-0.3.0/examples/IF-Split-2026.07.14/STATS.txt +28 -0
  10. {if_split-0.2.0/examples/IF-Split-2026.05.31 → if_split-0.3.0/examples/IF-Split-2026.07.14}/config.yaml +8 -4
  11. if_split-0.3.0/examples/IF-Split-2026.07.14/manifest.json +167 -0
  12. {if_split-0.2.0/examples/IF-Split-2026.05.31 → if_split-0.3.0/examples/IF-Split-2026.07.14}/test/metal_test.json +180 -57
  13. {if_split-0.2.0/examples/IF-Split-2026.05.31 → if_split-0.3.0/examples/IF-Split-2026.07.14}/test/nucleic_acid_test.json +9 -2
  14. {if_split-0.2.0/examples/IF-Split-2026.05.31 → if_split-0.3.0/examples/IF-Split-2026.07.14}/test/small_molecule_test.json +164 -521
  15. {if_split-0.2.0/examples/IF-Split-2026.05.31 → if_split-0.3.0/examples/IF-Split-2026.07.14}/test.json +234 -84
  16. {if_split-0.2.0/examples/IF-Split-2026.05.31 → if_split-0.3.0/examples/IF-Split-2026.07.14}/train.json +1536 -265
  17. {if_split-0.2.0/examples/IF-Split-2026.05.31 → if_split-0.3.0/examples/IF-Split-2026.07.14}/val.json +214 -103
  18. {if_split-0.2.0 → if_split-0.3.0}/pyproject.toml +1 -1
  19. if_split-0.3.0/scripts/audit_nico_histag.py +206 -0
  20. if_split-0.3.0/scripts/consume_split.py +157 -0
  21. if_split-0.3.0/scripts/eval_metal_tiering.py +122 -0
  22. if_split-0.3.0/scripts/eval_sm_tiering.py +73 -0
  23. if_split-0.3.0/scripts/eval_structural_clustering.py +52 -0
  24. {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/__init__.py +1 -1
  25. {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/cli.py +204 -52
  26. {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/cluster.py +46 -1
  27. {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/config.py +99 -0
  28. if_split-0.3.0/src/ifsplit/dataset.py +200 -0
  29. {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/enumerate.py +22 -0
  30. {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/hydrate.py +17 -2
  31. {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/ligands.py +165 -31
  32. if_split-0.3.0/src/ifsplit/manifest.py +713 -0
  33. {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/parse.py +62 -5
  34. {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/rcsb.py +16 -1
  35. {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/schema.py +91 -1
  36. {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/split.py +116 -6
  37. {if_split-0.2.0 → if_split-0.3.0}/tests/test_config.py +40 -0
  38. {if_split-0.2.0 → if_split-0.3.0}/tests/test_download.py +45 -0
  39. if_split-0.3.0/tests/test_enumerate_lock.py +298 -0
  40. if_split-0.3.0/tests/test_ligands.py +647 -0
  41. {if_split-0.2.0 → if_split-0.3.0}/tests/test_loader.py +60 -0
  42. {if_split-0.2.0 → if_split-0.3.0}/tests/test_pipeline.py +277 -0
  43. {if_split-0.2.0 → if_split-0.3.0}/tests/test_schema.py +101 -0
  44. {if_split-0.2.0 → if_split-0.3.0}/uv.lock +1 -1
  45. if_split-0.2.0/examples/IF-Split-2026.05.31/README.md +0 -84
  46. if_split-0.2.0/examples/IF-Split-2026.05.31/STATS.txt +0 -20
  47. if_split-0.2.0/examples/IF-Split-2026.05.31/manifest.json +0 -132
  48. if_split-0.2.0/src/ifsplit/dataset.py +0 -112
  49. if_split-0.2.0/src/ifsplit/manifest.py +0 -417
  50. if_split-0.2.0/tests/test_enumerate_lock.py +0 -101
  51. if_split-0.2.0/tests/test_ligands.py +0 -342
  52. {if_split-0.2.0 → if_split-0.3.0}/.github/workflows/ci.yml +0 -0
  53. {if_split-0.2.0 → if_split-0.3.0}/.github/workflows/publish.yml +0 -0
  54. {if_split-0.2.0 → if_split-0.3.0}/.gitignore +0 -0
  55. {if_split-0.2.0 → if_split-0.3.0}/.python-version +0 -0
  56. {if_split-0.2.0 → if_split-0.3.0}/LICENSE +0 -0
  57. {if_split-0.2.0 → if_split-0.3.0}/data/cache/.gitkeep +0 -0
  58. {if_split-0.2.0 → if_split-0.3.0}/data/out/.gitkeep +0 -0
  59. {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/__main__.py +0 -0
  60. {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/download.py +0 -0
  61. {if_split-0.2.0 → if_split-0.3.0}/tests/conftest.py +0 -0
  62. {if_split-0.2.0 → if_split-0.3.0}/tests/test_integration.py +0 -0
@@ -0,0 +1,88 @@
1
+ # Changelog
2
+
3
+ All notable changes to IF-Split are recorded here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project aims to
5
+ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ The **split is always computed from metadata + sequences only** — `build` never
8
+ downloads structure coordinates. That invariant holds across every release below.
9
+
10
+ ## [0.3.0] — 2026-07-14
11
+
12
+ A large release: fold-honest splitting, split-output certification, a two-corpus
13
+ training model, a metadata-only curation overhaul, and offline re-derivability.
14
+
15
+ ### Added
16
+
17
+ - **Fold-level structural leakage control** (opt-in `structural_clustering`:
18
+ `off` | `cath` | `ecod` | `scop2`). Same-fold protein chains are union-merged into
19
+ one leakage-safe component in addition to shared sequence clusters, so a fold cannot
20
+ straddle train/test — using RCSB's precomputed CATH/ECOD/SCOP2 classifications
21
+ (metadata only, no coordinates).
22
+ - **Balance-aware split strategy** (`split_strategy: balanced`). Caps dominant folds
23
+ to train and fills val/test to their *entry* targets from the fold tail, restoring
24
+ ~80/10/10 by entries with thousands of held-out folds. `config/masterclass.yaml`
25
+ ships the fold-honest recipe (`scop2` + `balanced`).
26
+ - **Split-output certification.** The `@2` `dataset.lock` records `split_sha256` (a
27
+ hash of the entry→split partition); `verify` re-derives Stages 3–6 and certifies the
28
+ split *output* reproduced, not just the Stage-1 candidate set.
29
+ - **Two training corpora from one split**: all kept structures as design *backbones*,
30
+ plus a functional-ligand *conditioning-target* corpus (`targets.jsonl`, one row per
31
+ ligand keyed to entry + split + class + tier). `SplitView` exposes both views.
32
+ - **Offline `resplit`** (`if-split resplit --candidates candidates.jsonl --config X`):
33
+ re-derives Stages 3–7 from a cached snapshot with no RCSB — ablate curation /
34
+ clustering / split settings, or tighten a filter, in seconds instead of
35
+ re-enumerating the PDB. The lock records `source` (`build` | `resplit`).
36
+ - **Offline `verify`** (`verify LOCK --candidates candidates.jsonl`): integrity-check
37
+ a distributed dataset with no network; a corrupt candidates file is reported as an
38
+ integrity failure. A `resplit` lock is steered to offline verification.
39
+ - **Per-method resolution caps** (`resolution_max_A_by_method`) and a **cryo-EM
40
+ map-fit floor** (`min_em_backbone_inclusion`, wiring in the previously-unused
41
+ `em_backbone_inclusion` metric). Resolution is now re-derived in Stage 3, so the cut
42
+ is auditable from `candidates.jsonl` and tightenable offline.
43
+ - **Opt-in sequence-usability floor** (`min_modeled_residues`) and an always-on drop of
44
+ empty / all-`X` (poly-UNK) protein chains, which carry no learnable label.
45
+ - RCSB **metal-binding annotations** (GO/InterPro/Pfam) captured to rescue native
46
+ metalloenzymes; `if-split spec` to emit a portable, self-identifying split spec.
47
+
48
+ ### Changed / curation
49
+
50
+ - **Metal tiering**: heavy-atom / lanthanide **phasing derivatives** (Hg/Au/Pt/Pb/Tl/…)
51
+ demoted to `ambiguous` (reported, recoverable) rather than counted as functional
52
+ metal sites; inorganic **Fe-S / metal-oxo / FeMo clusters** (SF4/FES, the OEC) now
53
+ classed `metal`; native Ni/Co (and heavy/lanthanide) sites rescued via annotation,
54
+ affinity, or subject-of-investigation. The lone-Ni/Co His-tag figure was corrected
55
+ (~96% → ~82%).
56
+ - **Glycans** (RCSB CCD `type` = *saccharide*) with no measured affinity are tiered
57
+ `glycan` (decorative / detergent), recoverable via an opt-in tier — not counted as
58
+ small-molecule conditioning targets.
59
+ - **Small molecules**: a measured binding affinity now overrides the additive
60
+ blacklist, so a blacklisted comp that is the real measured ligand stays functional.
61
+ - **Nucleic acids**: `is_nucleic` now recognizes the `NA-hybrid` polymer type; the
62
+ ligand class was renamed `nucleotide` → `nucleic_acid`.
63
+ - The size cap keeps `< 6000` residues correctly (`> max_total_residues`, not `>=`).
64
+ - Adding a resolution-less method (NMR/SAXS) now warns instead of silently returning
65
+ zero entries.
66
+
67
+ ### Fixed
68
+
69
+ - `verify` warns (rather than fails) on a version-only lock mismatch.
70
+ - `fetch` reads split id-lists from the manifest directory, not the current directory.
71
+ - `identity_threshold` is validated against RCSB's precomputed cluster levels
72
+ (30/50/70/90/95/100) so an unsupported level can't silently disable clustering.
73
+ - A bound halide is tiered a counterion, not a functional small molecule.
74
+
75
+ ## [0.2.0] — 2026
76
+
77
+ - Recover non-covalently bound cofactors (FAD/NAD/FMN/NADP, inhibitors) via RCSB's
78
+ `is_subject_of_investigation` flag.
79
+ - Harden Ni/Co metal curation against His-tags absent from the deposited sequence.
80
+ - Shareable split spec (`if-split spec`) and a self-identifying config header.
81
+ - Rename the ligand class `nucleotide` → `nucleic_acid`; PyPI/CI badges + install docs.
82
+
83
+ ## [0.1.0] — 2026
84
+
85
+ - Initial release: a reproducible, date-pinned, ligand-aware train/val/test splitter
86
+ for the PDB. Enumerate → filter → tier ligands → cluster (union-find, leakage-safe)
87
+ → deterministic split → manifest + lock, all from RCSB Search + Data API metadata
88
+ (no coordinates). Optional `fetch` downloads structures for a built split.
@@ -10,7 +10,8 @@ usage. This file is the orientation for working in the repo.
10
10
  **The split is computed from metadata + sequences only — `build` never downloads
11
11
  structure coordinates.** Everything needed (resolution, method, release date,
12
12
  residue counts, per-entity sequences, ligand chem-comp + bound-component signals,
13
- RCSB cluster membership) comes from the RCSB Search + Data APIs. Coordinates
13
+ RCSB sequence-cluster membership, and CATH/ECOD/SCOP2 structural classifications)
14
+ comes from the RCSB Search + Data APIs. Coordinates
14
15
  (mmCIF) are large and only needed downstream, so `fetch` (Stage 2) is optional.
15
16
  Keep it that way: do not add coordinate access to the build path.
16
17
 
@@ -36,6 +37,8 @@ wsl -d ubuntu bash -lc 'cd ~/projects/IF-Split && export PATH="$HOME/.local/bin:
36
37
  uv run ruff check . # lint (must pass)
37
38
  uv run ruff format . # format
38
39
  uv run if-split build --limit 50 --out /tmp/ifs # dev build (small, live RCSB)
40
+ uv run if-split build --config config/masterclass.yaml --out /tmp/mc # fold-honest split (scop2 + balanced)
41
+ uv run if-split resplit --candidates data/out/candidates.jsonl --config X.yaml --out /tmp/rs # re-derive Stages 3-7 offline (no RCSB)
39
42
  ```
40
43
 
41
44
  - `uv sync` sets up the env; `uv sync --extra mlops` adds pyarrow for `fetch`'s
@@ -47,17 +50,22 @@ uv run if-split build --limit 50 --out /tmp/ifs # dev build (small, live RCSB)
47
50
 
48
51
  `enumerate.py`+`rcsb.py` (Stage 1, Search+Data API → candidates.jsonl) →
49
52
  `parse.py` (3, metadata filters) → `ligands.py` (4, confidence tiering) →
50
- `cluster.py` (5, union-find components) `split.py` (6, deterministic hash) →
53
+ `cluster.py` (5, union-find components: sequence + optional fold-level structural
54
+ clustering) → `split.py` (6, split assignment: `hash` | `balanced`) →
51
55
  `manifest.py` (7, lock + manifest + registry, verify/stats) → `dataset.py` (8,
52
- loader). `download.py`+`hydrate.py` are the optional Stage 2 `fetch`.
56
+ loader). `download.py`+`hydrate.py` are the optional Stage 2 `fetch`. `cli.py`'s
57
+ `resplit` re-runs Stages 3-7 from a cached `candidates.jsonl` (no Stage 1) via the
58
+ shared `_run_pipeline`; `verify --candidates` does the same for offline checking.
53
59
 
54
60
  Invariants that must not regress:
55
61
  - **Determinism:** same config → byte-identical `manifest.json` (no wall-clock
56
62
  fields). `test_manifest_is_deterministic` guards this.
57
63
  - **No cross-split leakage:** sequence clusters joined by a shared multi-chain
58
- entry are union-find–merged into one component; a component maps to exactly one
59
- split, so overlap is impossible by construction. `check_no_leakage` is a real
60
- invariant (not a tautology) keep it that way.
64
+ entry (and, with `structural_clustering` on, by a shared fold superfamily) are
65
+ union-find–merged into one component; a component maps to exactly one split, so
66
+ overlap is impossible by construction. This holds for both split strategies
67
+ (`hash` and `balanced`, which only chooses *which* split a whole component lands
68
+ in). `check_no_leakage` is a real invariant (not a tautology) — keep it that way.
61
69
  - **Growth stability:** a cluster/component's split is `hash(salt + canonical_key)`
62
70
  into cumulative fractions, keyed on the global-min member id (not RCSB's volatile
63
71
  integer id). A larger snapshot only *adds* components; `splits.registry.json`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: if-split
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Reproducible, date-pinned, ligand-aware train/val/test splitter for the PDB (LigandMPNN-style).
5
5
  Author: WSobo
6
6
  License: MIT
@@ -43,9 +43,10 @@ records and sequences. Coordinates are an optional, downstream concern.
43
43
  | | |
44
44
  |---|---|
45
45
  | **Fresh** | Builds from the current PDB, not a years-old frozen copy. |
46
- | **Reproducible** | A `dataset.lock` pins the snapshot; `verify` re-derives it and reports any drift. |
46
+ | **Reproducible** | A `dataset.lock` pins the snapshot **and the split output**; `verify` re-derives both and certifies they reproduced byte-for-byte (or reports exactly what drifted). |
47
47
  | **Cheap** | Metadata-only — a split is megabytes of JSON, not a terabyte of mmCIF. |
48
48
  | **Honest about quality** | Every ligand is tiered (`functional` / `ambiguous` / `artifact`) with a reason; nothing is silently dropped. |
49
+ | **Fold-aware** | Controls *structural* leakage, not just sequence: same-fold chains can't straddle train/test — the leak that matters most for structure→sequence models. |
49
50
 
50
51
  ### Two reproducibility guarantees
51
52
 
@@ -60,6 +61,51 @@ records and sequences. Coordinates are an optional, downstream concern.
60
61
  `splits.registry.json` pins prior assignments to make this exact even across
61
62
  re-clustering.
62
63
 
64
+ ### Fold-level leakage control
65
+
66
+ Sequence clustering alone is not enough for inverse folding. A model learns
67
+ **structure → sequence**, so two chains below the 30% identity threshold that
68
+ nonetheless share a **fold** (TIM barrels, Rossmann folds, globins…) leak
69
+ structural information across the split — the model has effectively seen the test
70
+ backbone during training. `structural_clustering` closes this: protein entities
71
+ sharing a structural **(super)family** are union-merged into the same component in
72
+ addition to shared sequence clusters, so a fold cannot straddle train/test.
73
+
74
+ It uses RCSB's precomputed **CATH / ECOD / SCOP2** classifications — still metadata
75
+ only, no coordinates — selectable per build (`off | cath | ecod | scop2`). It is
76
+ **purely additive**: it can only merge components, never split them, and a chain
77
+ with no classification simply contributes no structural edge. `if-split stats`
78
+ reports how many components the structural pass folded together, so the effect is
79
+ always measurable (`scripts/eval_structural_clustering.py` compares the methods).
80
+
81
+ Coverage is partial by nature: CATH ≈ 55%, ECOD ≈ 80%, SCOP2 ≈ 52% of protein
82
+ chains are classified (measured on the full 2026-07-14 snapshot); the rest fall
83
+ back to sequence-only.
84
+
85
+ **Why it needs a balance-aware split.** On its own, fold-merging collapses the
86
+ dominant superfamilies (antibodies, TIM barrels) into mega-components that land
87
+ wholesale in one split, skewing the *entry* balance to ~95/3/2 (the *component*
88
+ split stays ~80/10/10). `split_strategy: "balanced"` fixes this: it **caps the
89
+ dominant folds to train and fills val/test to their entry targets from the tail of
90
+ smaller folds** — restoring ~80/10/10 by entries with thousands of distinct,
91
+ held-out folds per split. It stays leakage-safe (whole components) and
92
+ growth-stable (via the registry), and reports a gap if a method's tail is too thin
93
+ (`cath`/`ecod` starve val; **`scop2` is the sweet spot**). `balanced` also fixes
94
+ the plain sequence-only skew (88/6/6 → 80/10/10) from the antibody mega-cluster.
95
+
96
+ ### The masterclass split
97
+
98
+ ```bash
99
+ uv run if-split build --config config/masterclass.yaml --out data/mc
100
+ ```
101
+
102
+ `config/masterclass.yaml` = default **+ `structural_clustering: scop2` + `split_strategy: balanced`**: a fold-honest ~80/10/10 split whose val/test are thousands of folds held entirely out of train — the truest generalization measure the tool can produce, still from metadata alone.
103
+
104
+ | split | strategy | structural | entry balance | val/test |
105
+ |---|---|---|--:|---|
106
+ | default | hash | off | 88 / 6 / 6 | sequence-clustered |
107
+ | masterclass | balanced | scop2 | 80 / 10 / 10 | thousands of held-out folds |
108
+
63
109
  ---
64
110
 
65
111
  ## Install
@@ -94,9 +140,19 @@ uv run if-split build --limit 50 --out /tmp/ifs
94
140
  # Summarize a build: split sizes, per-class test counts, curation tiers.
95
141
  uv run if-split stats data/out/manifest.json
96
142
 
97
- # Reproduce-check: re-derive from a lock and report drift vs the live PDB.
143
+ # Reproduce-check: re-derive from a lock, report candidate drift vs the live PDB,
144
+ # and (when candidates reproduce) certify the split output matches its hash.
98
145
  uv run if-split verify data/out/dataset.lock
99
146
 
147
+ # Offline verify: integrity-check a distributed candidates.jsonl + lock, no network.
148
+ uv run if-split verify data/out/dataset.lock --candidates data/out/candidates.jsonl
149
+
150
+ # Re-derive the split from a CACHED candidates.jsonl — no RCSB. Ablate curation,
151
+ # clustering, split strategy, or TIGHTEN a filter (e.g. resolution) on a fixed
152
+ # snapshot in ~seconds instead of re-enumerating the whole PDB.
153
+ uv run if-split resplit --candidates data/out/candidates.jsonl \
154
+ --config config/masterclass.yaml --out data/mc
155
+
100
156
  # Growth-stable regeneration: pin prior cluster→split assignments.
101
157
  uv run if-split build --registry data/out/splits.registry.json --out data/out2
102
158
 
@@ -112,7 +168,7 @@ uv run if-split spec data/out/manifest.json --name my-split --out my-split.ifspl
112
168
  | File | Purpose |
113
169
  |---|---|
114
170
  | `candidates.jsonl` | The snapshot definition — one canonical JSON record per entry. Hashed into the lock. |
115
- | `dataset.lock` | Reproduction anchor: embedded config + candidates SHA-256 + entry list. |
171
+ | `dataset.lock` | Reproduction anchor: embedded config + candidates SHA-256 + entry list + a `split` hash of the entry→split partition (so `verify` certifies the split output, not just the inputs). |
116
172
  | `manifest.json` | Human-facing run record: per-split entry lists, ligand classes + tiers, per-class (and ambiguous) counts, drop log, cluster/leakage stats, entry→cluster map. |
117
173
  | `splits.registry.json` | `cluster key → split`, for growth-stable regeneration. |
118
174
 
@@ -176,10 +232,10 @@ A `build` runs eight stages; none touch coordinates.
176
232
  | Stage | Module | What it does |
177
233
  |---|---|---|
178
234
  | 1 — enumerate | `enumerate.py`, `rcsb.py` | RCSB Search → entry IDs; Data API (GraphQL, batched) → sequences, ligands, residue counts, cluster membership → `candidates.jsonl`. |
179
- | 3 — filter | `parse.py` | Drop no-protein / no-sequence / oversized entries (assembly-1 residue count vs `max_total_residues`), plus optional wwPDB validation-report quality caps (clashscore, R-free, Ramachandran/rotamer/RSRZ) — all from metadata. Every drop is logged with its reason. |
235
+ | 3 — filter | `parse.py` | Drop no-protein / no-usable-sequence (empty **or** all-`X` poly-UNK) / too-short (opt-in `min_modeled_residues`) / oversized entries (assembly-1 residue count `> max_total_residues`) / over-resolution (re-derived here so it is auditable; per-method caps via `resolution_max_A_by_method`), plus optional wwPDB validation-report quality caps (clashscore, R-free, Ramachandran/rotamer/RSRZ, cryo-EM map-fit floor) — all from metadata. Every drop is logged with its reason. |
180
236
  | 4 — ligands | `ligands.py` | Tier each non-protein component `functional`/`ambiguous`/`artifact`; derive class labels (metal / small-molecule / nucleic-acid). `nucleic_acid` = a protein↔DNA/RNA *complex* (verified assembly interface), **not** a bound mononucleotide. **Annotate, never drop.** |
181
- | 5 — cluster | `cluster.py` | Group protein entities by RCSB precomputed cluster id at `identity_threshold`; canonical key = smallest member id. |
182
- | 6 — split | `split.py` | Deterministic hash → train/val/test; assert no cluster spans two splits; audit residual secondary-chain overlap. |
237
+ | 5 — cluster | `cluster.py` | Group protein entities by RCSB precomputed cluster id at `identity_threshold`; canonical key = smallest member id. Optionally union same-fold entities (CATH/ECOD/SCOP2) for structural-leakage control. |
238
+ | 6 — split | `split.py` | Assign components → train/val/test (`hash`, or `balanced` for entry-balanced fold-tail val/test); assert no cluster spans two splits; audit residual secondary-chain overlap. |
183
239
  | 7 — manifest | `manifest.py` | Emit lock + manifest + registry (all deterministic, no wall-clock fields). |
184
240
  | 8 — loader | `dataset.py` | Read a manifest into train/val/test views with cluster-balanced sampling. |
185
241
  | 2 — fetch *(opt-in)* | `download.py`, `hydrate.py` | Download mmCIF for a built manifest into a sharded, indexed, ML-ready tree. |
@@ -201,6 +257,7 @@ metrics come straight from the deposited report:
201
257
  | `max_rotamer_outlier_pct` | % sidechain rotamer outliers | X-ray + cryo-EM |
202
258
  | `max_rfree` | R-free (DCC) | X-ray |
203
259
  | `max_rsrz_outlier_pct` | % real-space-R Z-score outliers | X-ray |
260
+ | `min_em_backbone_inclusion` | backbone atom-in-density (a **floor** — higher is better) | cryo-EM |
204
261
 
205
262
  Two rules keep it honest: a cap fires **only when the metric is present**, so a
206
263
  cryo-EM entry is never dropped for a missing R-free; and every cap is **off by
@@ -220,8 +277,8 @@ machine-readable reason, from RCSB metadata signals:
220
277
 
221
278
  | Tier | Meaning | Example reasons |
222
279
  |---|---|---|
223
- | `functional` | Real ligand/site → gets a class label | bound to protein (`nonpolymer_bound_components`) or has measured binding affinity |
224
- | `ambiguous` | Present but uncorroborated → reported, **not** labelled | `metal_unbound`, `ligand_unbound` |
280
+ | `functional` | Real ligand/site → gets a class label | `metal_bound`/`ligand_bound` (contacts protein), `*_affinity` (measured), `*_investigated` (RCSB SOI), `metal_annotated` (protein annotated to bind this metal) |
281
+ | `ambiguous` | Present but uncorroborated → reported, **not** labelled | `metal_unbound`, `ligand_unbound`, `metal_site_nonnative`, `glycan`, `purification_metal_uncorroborated` |
225
282
  | `artifact` | Buffer / counterion / purification tag → excluded from labels | `additive`, `counterion`, `histag_metal` |
226
283
 
227
284
  **Holo gating (metadata-only).** Presence isn't enough. A small molecule or metal
@@ -244,13 +301,23 @@ affinity purification. A poly-His run anywhere — or a short run at a chain
244
301
  terminus (`histag_terminal_min_run`, catching 6×His tags left partial by
245
302
  unmodeled or trimmed residues) — flags the entry's Ni/Co as an `artifact`.
246
303
 
247
- But a real audit showed the deeper issue: **~96% of lone Ni/Co entries have no
248
- His-tag in the deposited sequence at all** (the tag is trimmed from the SEQRES
249
- record, not just unmodeled), so a sequence scan can never see it. So even with no
250
- detectable tag, a *lone* Ni/Co (the entry's only metal) with no measured affinity
251
- is demoted from `functional` to `ambiguous` reported, not labelled. Real metals
252
- (Zn, Mg, Fe, …), and Ni/Co backed by an affinity or sitting alongside a genuine
253
- metal, are untouched. On the full PDB this re-tiers ~2.7% of the metal set.
304
+ But an audit (reproducible via [`scripts/audit_nico_histag.py`](scripts/audit_nico_histag.py))
305
+ showed a subtler issue: **~82% of lone Ni/Co entries carry no detectable His-tag
306
+ in the deposited sequence** IMAC tags are frequently absent from the SEQRES
307
+ record, not just unmodeled, so a sequence scan can't recover them. So even with no
308
+ detectable tag, a *lone* Ni/Co (the entry's only metal) with no corroboration is
309
+ demoted from `functional` to `ambiguous` reported, not labelled.
310
+
311
+ To avoid over-firing on genuine bare-Ni/Co enzymes (urease, cobalt methionine
312
+ aminopeptidase, nitrile hydratase, …), a lone Ni/Co is **rescued to `functional`
313
+ (`metal_annotated`)** when the protein's RCSB GO/InterPro/Pfam annotation says it
314
+ binds that metal. A protein that binds a *different* native metal (Ni/Co as an
315
+ isomorphous substitute — e.g. Co in a Mg enzyme) is reported `metal_site_nonnative`
316
+ so a consumer can choose to keep it; one with no metal annotation at all stays
317
+ `purification_metal_uncorroborated`. All from RCSB's own metadata (no extra
318
+ UniProt call). Real metals (Zn, Mg, Fe, …), and Ni/Co with affinity/SOI or beside
319
+ a genuine metal, are untouched. Rerun [`scripts/eval_metal_tiering.py`](scripts/eval_metal_tiering.py)
320
+ to measure the tier distribution over the whole lone-Ni/Co set.
254
321
 
255
322
  Crucially, **the structure always stays in its split** — a protein with a junk
256
323
  ion is still a good backbone; we just don't label the junk. A consumer wanting
@@ -258,11 +325,29 @@ ion is still a good backbone; we just don't label the junk. A consumer wanting
258
325
  threshold, not the build. The same per-component tier is what a downstream
259
326
  featurizer reads to decide what counts as real ligand context.
260
327
 
328
+ > **Per-instance is a featurizer concern.** These tiers are per *component* — they
329
+ > establish whether a structure contains a real Ni/Co site, not *which* of several
330
+ > same-element ions is it. A deposition can hold both a catalytic Ni and a surface
331
+ > crystallization Ni under one `NI` id, and no metadata separates them (adventitious
332
+ > Ni binds surface His/Asp with the same geometry as a catalytic site). Deciding
333
+ > which individual ion to featurize is left to the coordinate-level featurizer.
334
+
335
+ **Glycans aren't ligand pockets.** A carbohydrate (RCSB CCD type `*saccharide*` —
336
+ NAG/BMA/MAN/…, and sugar-detergents like LMT) is overwhelmingly decorative
337
+ glycosylation or a purification detergent, not a site an inverse-folding model
338
+ conditions on. So a carbohydrate is tiered `glycan` (reported, not a small-molecule
339
+ target) unless it has a *measured binding affinity* — RCSB's `is_subject_of_investigation`
340
+ flag is too noisy for sugars (it flags glycosylation and detergents), so it doesn't
341
+ rescue here. A genuine lectin/glycosidase ligand is recoverable as an opt-in target
342
+ (`include_ambiguous=True`). Real cofactors (ATP, NAD, HEM, FAD) and structural lipids
343
+ (cardiolipin, phosphatidyl-*) are `non-polymer`, not saccharides, so they're untouched.
344
+
261
345
  ### Test-set representation
262
346
 
263
- The split is a pure deterministic hash, so the test set's ligand mix is reported
264
- but not forced by default: `manifest.json` carries per-split, per-class
265
- `functional` counts plus `ambiguous` counts, so under-representation is visible.
347
+ The split is deterministic (a per-component hash, or the `balanced` fold-tail
348
+ fill), so the test set's ligand mix is reported but not forced by default:
349
+ `manifest.json` carries per-split, per-class `functional` counts plus `ambiguous`
350
+ counts, so under-representation is visible.
266
351
  An opt-in `--enforce-minimums` top-up (recruit `functional`-only ligand clusters
267
352
  into test in deterministic order) is scoped for a future release.
268
353
 
@@ -283,6 +368,75 @@ for epoch in range(3):
283
368
  batch_ids = ds.train.sample_by_cluster(seed=epoch)
284
369
  ```
285
370
 
371
+ ### Training strategies for inverse folding
372
+
373
+ An inverse-folding model consumes two different things, with opposite scale/quality
374
+ tradeoffs. IF-Split emits **both from the same leakage-safe split**, so you pick a
375
+ strategy without re-deriving the split:
376
+
377
+ | Corpus | What | Use it for |
378
+ |---|---|---|
379
+ | **Backbones** — every kept structure | `ds.train.backbones` | ProteinMPNN-style, ligand-agnostic. The scale lever — a structure with only junk ions or no ligand is still a good backbone. |
380
+ | **Conditioning targets** — the `functional`-tier ligands | `ds.train.conditioning_targets()` | LigandMPNN-style. One row per `(structure, ligand)`; junk is never a target. The quality lever. |
381
+
382
+ ```python
383
+ ds = load_dataset("data/out/manifest.json")
384
+
385
+ # 1. Backbone-only training (max data): every structure.
386
+ backbones = ds.train.backbones
387
+
388
+ # 2. Ligand-conditioned training: condition on the real ligands only.
389
+ targets = ds.train.conditioning_targets() # metal / small_molecule / nucleic_acid
390
+ metal_targets = ds.train.conditioning_targets(classes=["metal"])
391
+
392
+ # 3. Condition on ALL of a structure's ligands at once (group by entry), or one at a time:
393
+ for entry_id, ligs in ds.train.targets_by_entry().items():
394
+ ctx = [(t.ligand_class, t.comp_id) for t in ligs] # e.g. [("small_molecule","HEM")]
395
+
396
+ # 4. Only structures that actually carry a conditioning target:
397
+ conditioned = ds.train.conditioned_entry_ids() # subset of backbones
398
+
399
+ # 5. Opt in to ambiguous targets — non-native metal pockets (Ni/Co substituting the
400
+ # native metal) and glycans (glycosylation / lectin ligands) — off by default:
401
+ any_site = ds.train.conditioning_targets(include_ambiguous=True)
402
+ ```
403
+
404
+ The full corpus is also written to `targets.jsonl` (one row per target: entry, split,
405
+ cluster, class, comp_id, tier, reason) and mirrored into `index.parquet`'s
406
+ `conditioning_targets` column after `fetch`.
407
+
408
+ #### From a target to its pocket (your featurizer owns this)
409
+
410
+ IF-Split stops at the **labels** — it never parses coordinates. The last mile is
411
+ yours: join a target's `comp_id` to a fetched structure with your own parser and
412
+ pull the ligand atoms + pocket. This is deliberate — featurization is
413
+ model-specific (ligand atoms? SMILES? a pocket mask? all-atom context?), and every
414
+ inverse-folding model already has its own pipeline. `comp_id` is the join key:
415
+
416
+ ```python
417
+ from pathlib import Path
418
+
419
+ import gemmi # or biotite / Biopython — same pattern
420
+ from ifsplit.dataset import load_dataset
421
+ from ifsplit.download import rel_path_for
422
+
423
+ ds = load_dataset("data/out/manifest.json")
424
+ for entry_id, targets in ds.test.targets_by_entry().items():
425
+ path = Path("data/structures") / rel_path_for(entry_id, "test", assembly=True)
426
+ model = gemmi.read_structure(str(path))[0]
427
+ for t in targets:
428
+ # A structure may hold several copies of one ligand (plus adventitious ions).
429
+ # Surface ALL copies; pick the instance to condition on per your model.
430
+ copies = [r for ch in model for r in ch if r.name == t.comp_id]
431
+ # ... extract residues within config.ligand_context_radius_A of each copy ...
432
+ ```
433
+
434
+ When an entry has several functional ligands (e.g. a cofactor *and* an inhibitor,
435
+ or a catalytic metal alongside an adventitious one), that shows up as **multiple
436
+ target rows** — condition on all (group by `entry_id`) or one per example, your
437
+ call. A runnable, copy-and-adapt version with pocket extraction is
438
+ [`scripts/consume_split.py`](scripts/consume_split.py).
439
+
286
440
  ## Sharing a split spec
287
441
 
288
442
  The config **is** the shareable recipe. Everything that affects the split lives in
@@ -307,9 +461,9 @@ spec:
307
461
  ifsplit_spec: ifsplit/config@1 # schema id — the file says what it is
308
462
  name: my-split
309
463
  author: you
310
- created_with: if-split 0.1.0
464
+ created_with: if-split 0.3.0
311
465
  expected_config_hash: 3b63318286fd2ac4994f34d10936be05
312
- snapshot_date: '2026-05-31'
466
+ snapshot_date: '2026-07-14'
313
467
  resolution_max_A: 3.5
314
468
  # ... all output-affecting settings ...
315
469
  ```
@@ -323,7 +477,7 @@ differ only in their labels produce byte-identical outputs.
323
477
  |---|---|--:|
324
478
  | `*.ifsplit.yaml` (or `config.yaml`) | *"How did you make this split?"* — the recipe | ~KB |
325
479
  | `manifest.json` | *"What's in it?"* — counts, provenance, file index | ~KB |
326
- | `dataset.lock` | *"Reproduce the exact bytes"* — pins entry set + candidates SHA | ~MB |
480
+ | `dataset.lock` | *"Reproduce the exact bytes"* — pins entry set + candidates SHA + split-output hash | ~MB |
327
481
 
328
482
  ## Configuration
329
483
 
@@ -336,8 +490,10 @@ doubles as a shareable **split spec** — see [Sharing a split spec](#sharing-a-
336
490
  |---|---|---|
337
491
  | `snapshot_date` | `2026-05-30` | `release_date <= this` — the reproducibility anchor. |
338
492
  | `experimental_methods` | X-ray, EM | Allowed `exptl.method` values. |
339
- | `resolution_max_A` | `3.5` | Resolution cutoff. |
340
- | `max_total_residues` | `5999` | Size cap (LigandMPNN used `< 6000`). |
493
+ | `resolution_max_A` | `3.5` | Resolution cutoff (re-derived in Stage 3, so it is auditable from `candidates.jsonl`). |
494
+ | `resolution_max_A_by_method` | `{}` | Optional per-method resolution overrides, e.g. `{ELECTRON MICROSCOPY: 3.0}` — cryo-EM 3.5 Å ≠ X-ray 3.5 Å. Empty = one cap for all. |
495
+ | `max_total_residues` | `5999` | Max residues **kept** (drop if `> this`) — LigandMPNN kept `< 6000`, i.e. `<= 5999`. |
496
+ | `min_modeled_residues` | `0` | Opt-in floor on modeled (non-`X`) residues in a protein chain. `0` = off; only the always-on empty/all-`X` (poly-UNK) drop applies. `~20` also drops tiny/mostly-unknown chains. |
341
497
  | `excluded_het` | waters + common ions | Extra components forced to `artifact`. |
342
498
  | `use_biological_assembly` | `true` | Count residues from assembly 1, not the deposited asymmetric unit. |
343
499
  | `purification_metals` | `[NI, CO]` | Metals treated as IMAC tags; `[]` disables the heuristic. |
@@ -348,7 +504,7 @@ doubles as a shareable **split spec** — see [Sharing a split spec](#sharing-a-
348
504
  | `clustering_backend` | `precomputed` | `precomputed` (RCSB clusters) or `mmseqs2` (run your own). |
349
505
  | `split_fractions` | 0.80 / 0.10 / 0.10 | train / val / test. |
350
506
  | `split_salt` | `snapsplit-v1` | Bump to intentionally reshuffle the split. |
351
- | `max_clashscore`, `max_rfree`, `max_ramachandran_outlier_pct`, `max_rotamer_outlier_pct`, `max_rsrz_outlier_pct`, `require_validation_report` | off | Optional validation-report quality caps — see [Structure quality](#structure-quality-validation-report). |
507
+ | `max_clashscore`, `max_rfree`, `max_ramachandran_outlier_pct`, `max_rotamer_outlier_pct`, `max_rsrz_outlier_pct`, `min_em_backbone_inclusion`, `require_validation_report` | off | Optional validation-report quality caps — see [Structure quality](#structure-quality-validation-report). |
352
508
  | `ligand_context_radius_A`, `max_ligand_atoms` | `8.0`, `25` | Featurization only (not part of the split). |
353
509
 
354
510
  ## Develop
@@ -379,6 +535,12 @@ data/out/ # generated manifests + lock files
379
535
  tests/
380
536
  ```
381
537
 
538
+ ## Changelog
539
+
540
+ Release history is in [CHANGELOG.md](CHANGELOG.md). The current release is **0.3.0**
541
+ (fold-honest splitting, split-output certification, the two-corpus training model, a
542
+ metadata-only curation overhaul, and offline `resplit` / `verify`).
543
+
382
544
  ## License
383
545
 
384
546
  MIT — see [LICENSE](LICENSE).
@@ -106,8 +106,8 @@ IF-Split/
106
106
  download.py # Stage 2: OPTIONAL on-demand mmCIF fetch (featurization)
107
107
  parse.py # Stage 3: metadata filters + drop log
108
108
  ligands.py # Stage 4: classify non-protein entities from metadata
109
- cluster.py # Stage 5: precomputed RCSB clusters (default) | mmseqs2
110
- split.py # Stage 6: deterministic hash assignment + stratification
109
+ cluster.py # Stage 5: seq clusters (precomputed|mmseqs2) + opt. fold-level structural union
110
+ split.py # Stage 6: split assignment (hash | balanced) + stratification
111
111
  manifest.py # Stage 7: emit manifest + lock file
112
112
  dataset.py # Stage 8: loader / torch Dataset consuming a manifest
113
113
  cli.py # `if-split build`, `if-split verify`, `if-split stats`
@@ -151,7 +151,8 @@ guaranteed to have used identical settings.
151
151
  snapshot_date: "2026-05-30" # release_date <= this. The reproducibility anchor.
152
152
  experimental_methods: ["X-RAY DIFFRACTION", "ELECTRON MICROSCOPY"]
153
153
  resolution_max_A: 3.5
154
- max_total_residues: 5999 # LigandMPNN used "< 6000"
154
+ max_total_residues: 5999 # max residues KEPT (drop if > this); LigandMPNN kept < 6000
155
+ min_modeled_residues: 0 # opt-in floor on modeled (non-X) residues; 0 = off
155
156
  excluded_het: ["HOH", "NA", "CL", "K", "BR"] # waters + common crystallization ions
156
157
  use_biological_assembly: true # biounits, as in LigandMPNN (assembly 1)
157
158
  # purification-artifact curation (Stage 4): His-tag + Ni/Co only -> not a metal site
@@ -215,10 +216,13 @@ re-featurize from the cleaned structures.
215
216
  **Stage 3 — Filter (`parse.py`)**
216
217
 
217
218
  - Operate on the metadata in `candidates.jsonl` (no coordinate parsing). Apply
218
- filters: drop entries with `total_residues >= max_total_residues` (use the
219
- assembly residue count when `use_biological_assembly`), drop entries whose only
220
- non-protein components are in `excluded_het`, drop entries with no protein
221
- polymer entity. Record drop reasons + counts.
219
+ filters: drop entries with `total_residues > max_total_residues` (use the
220
+ assembly residue count when `use_biological_assembly`), over the (per-method)
221
+ resolution cap — re-derived here from `resolution_A` so the cut is auditable and
222
+ tightenable offline drop entries whose only non-protein components are in
223
+ `excluded_het`, drop entries with no protein polymer entity or no usable
224
+ (non-all-`X`) sequence, plus optional validation-report caps (incl. the cryo-EM
225
+ map-fit floor). Record drop reasons + counts.
222
226
  - Sequences come from the Data API canonical one-letter code, which already maps
223
227
  modified residues to canonical parents (e.g. MSE→MET). Coordinate-level
224
228
  re-parsing with `gemmi` belongs to the optional featurization path, not here.
@@ -311,12 +315,34 @@ entry may touch multiple clusters via different chains; assign the entry to the
311
315
  cluster of its longest protein chain (record all) so split assignment is
312
316
  unambiguous.
313
317
 
318
+ **Fold-level structural clustering (`structural_clustering`, opt-in ← *implemented***).
319
+ Sequence clustering misses *structural* redundancy: chains below the identity
320
+ threshold can still share a fold, which a structure→sequence model leaks across
321
+ splits. When set (`cath` | `ecod` | `scop2`), protein entities sharing an RCSB
322
+ structural (super)family are union-merged into the same component **in addition
323
+ to** shared sequence clusters — so a fold cannot straddle train/test. The
324
+ classifications ride in the snapshot as metadata (`rcsb_polymer_instance_annotation`,
325
+ Stage 1; **no coordinates**). CATH keys on the superfamily code (`1.10.490.10`),
326
+ ECOD/SCOP2 on the family name. Purely additive (only merges, never splits);
327
+ coverage is partial (CATH ~55%, ECOD ~81%, SCOP2 ~52% of chains) so unclassified
328
+ chains fall back to sequence-only. See README "Fold-level leakage control".
329
+
314
330
  **Stage 6 — Split assignment (`split.py`) — the reproducibility core**
315
331
 
316
332
  - Assign each cluster (not each entry) to a split by deterministic hash:
317
333
  `bucket = int(blake2b(cluster_repr_id + split_salt)) ...` mapped to cumulative
318
334
  `split_fractions`. Same salt + same cluster IDs → same assignment, forever.
319
335
  (See the Stage 6 note in §1 on making `cluster_repr_id` input-independent.)
336
+ - **Balance-aware strategy (`split_strategy: "balanced"` ← *implemented***).
337
+ Per-component hashing balances *components*, not *entries*; a dominant fold
338
+ (under structural clustering) or the antibody mega-cluster (even sequence-only)
339
+ balloons one split. `balanced` caps dominant folds (> 0.2% of entries) to train
340
+ and fills val/test to their *entry* targets from the tail of smaller folds in
341
+ hash order — leakage-safe (whole components), growth-stable via the registry,
342
+ and it reports a gap rather than forcing a target it can't reach. The
343
+ "masterclass" recipe `structural_clustering: scop2` + `split_strategy: balanced`
344
+ (`config/masterclass.yaml`) yields ~80/10/10 by entries with thousands of folds
345
+ held entirely out of train.
320
346
  - Stratify the test set by ligand class so SM/metal/nucleotide are all
321
347
  represented (LigandMPNN's test sets are deliberately ligand-containing).
322
348
  Implement as: within the test-bucketed clusters, label structures by ligand
@@ -333,13 +359,17 @@ Emit two artifacts in `data/out/`:
333
359
  - `manifest.json` — human-facing: snapshot_date, config hash, tool versions
334
360
  (mmseqs2, gemmi), per-split entry lists, per-structure metadata, ligand-class
335
361
  tags, per-class test counts, drop log.
336
- - `dataset.lock` — reproduction-facing: the two snapshot anchors. (1) every
337
- entry ID + obsolescence status (the candidate set is reproduced from the Data
338
- API by id + `release_date <= snapshot_date`); (2) the **cluster file**: its
339
- SHA-256, RCSB `Last-Modified`, and a stored copy (≈17 MB, the only sizeable
340
- artifact). `if-split verify dataset.lock` re-fetches metadata + cluster file
341
- and confirms the cluster-file hash matches and the entry set is unchanged.
342
- (mmCIF SHA-256s belong to the *optional* featurization fetch, not this lock.)
362
+ - `dataset.lock` — reproduction-facing anchors: (1) the embedded config + the
363
+ **candidate set** every entry ID and the canonical `candidates.jsonl` SHA-256
364
+ (the candidate set is reproduced from the Data API by id + `release_date <=
365
+ snapshot_date`; clustering rides along as per-entity `cluster_ids`, so there is
366
+ no separate cluster file); (2) a **`split` block** hashing the entry→split
367
+ partition (`@2` locks). `if-split verify dataset.lock` re-enumerates and reports
368
+ candidate drift (added/removed/hash); when the candidate set reproduced
369
+ byte-for-byte it recomputes Stages 3-6 and certifies the split-output hash
370
+ matches (registry-free builds), so a curation/split-logic change is caught even
371
+ with identical inputs. (mmCIF SHA-256s belong to the *optional* featurization
372
+ fetch, not this lock.)
343
373
  - Version the dataset as `IF-Split-<snapshot_date>` (e.g. `IF-Split-2026.05.30`).
344
374
 
345
375
  **Stage 8 — Loader (`dataset.py`)**