Skip to content

fix: enformer effect percentiles had an unreachable top 4% (padded CDF grid) - #143

Merged
lucapinello merged 1 commit into
mainfrom
fix/2026-08-02-background-integrity
Aug 2, 2026
Merged

fix: enformer effect percentiles had an unreachable top 4% (padded CDF grid)#143
lucapinello merged 1 commit into
mainfrom
fix/2026-08-02-background-integrity

Conversation

@lucapinello

Copy link
Copy Markdown
Contributor

The defect

enformer_pertrack.npz shipped its effect_cdfs gridded at 9,606 pointsmax(effect_counts) — then padded to 10,000 by repeating each row's maximum 395 times. All 5,313 rows, every one with its first maximum at index 9605.

Because _get_denominator correctly divides by the stored grid width (#119), the padding silently rescaled every enformer effect percentile and made a band of the scale unreachable:

probe quantile percentile shift after repair
p50 +0.0205
p90 +0.0368
p99 and above +0.0393
reachable ceiling 0.9604 → 0.9999

Understated everywhere, worst in the upper tail — which is exactly where a calibration floor reads.

It also contaminates #83. A per-track floor at any q >= 0.96 resolves to the enformer null maximum for all 5,313 tracks, so the q sweep was invalid there. This unblocks it.

Why nothing caught it

  • ReservoirSampler.to_cdf_matrix interpolates short rows onto the full grid and cannot produce that shape — so the shipped file was never reproducible from repo code.
  • build_and_save only resampled when shape[1] > n_points; a narrow matrix was written verbatim with no assert.
  • normalization.py:455 states rows are never padded. The artefact contradicted an invariant its own code documents.

The guard

cdf_grid_violations / expected_first_max_index in background_sampling.py — one implementation shared by the test, the write-time guard in build_and_save, and the repair script. Validated across all eight shipped backgrounds (19,393 short rows): flags every one of enformer's 5,313 effect rows and nothing else.

The derivation is easy to get wrong. source_q = arange(n)/n stops at (n-1)/n, not 1.0, so np.interp clamps beyond it and a short row legitimately ends in a plateau of repeated maxima — 6 slots for AlphaGenome's 1,909 samples, 100 for a 100-sample row. Trailing duplicates are normal and are not the padding signal.

Three exemptions, each with a test and a reason:

  • The plateau check is one-sided. Padding makes the plateau longer (395 vs 2); a hand-built fixture makes it shorter, which is legitimate. Measured excess is exactly 0 for every healthy row and +393 for every enformer row, so one-sided loses no detection power.
  • n >= n_points rows are skipped. AlphaGenome's summary row 2,452 has its top two H3K4me1 windows tied at exactly 2480.0 — real saturation, not corruption.
  • Constant rows are skipped. They carry no grid geometry (a single sample interpolates flat), so both checks would false-positive.

A narrow matrix is logged, not rejected: _get_denominator reads shape[1], so 100-wide stored at 100 is self-consistent — and enformer's defect had shape[1] == n_points, which no width check could have caught.

The repair

scripts/repair_enformer_effect_grid.py recovers the quantile function from the 9,606 stored quantiles and re-expresses it on the 10,000-point grid. Idempotent, backed up, exact max preservation, median-quantile shift 6.4e-07, all 5,313 rows monotone.

Pseudo-samples must span linspace(0,1,n), not arange(n)/n — the latter discards the top sample and loses up to 74% of a row's maximum, precisely the tail being restored.

This is an interpolation of a derived artefact, not a rebuild; the planned enformer rebuild supersedes it. Local cache only — republishing to HuggingFace is a separate authenticated step.

Verification

  • 670 passed, 8 skipped (was 653) on the CI invocation.
  • Integration: 8/8 shipped backgrounds clean; the retained pre-repair backup still trips the guard.
  • Written failing-first: the guard was red on enformer before the repair existed.

🤖 Generated with Claude Code

…F grid)

`enformer_pertrack.npz` shipped its `effect_cdfs` gridded at 9,606 points —
`max(effect_counts)` — then padded to 10,000 by repeating each row's maximum
395 times. All 5,313 rows, every one with its first maximum at index 9605.

Since `_get_denominator` correctly divides by the stored grid *width* (#119),
the padding silently rescaled every enformer effect percentile and made a whole
band of the scale unreachable:

  probe quantile   percentile shift after repair
  p50              +0.0205
  p90              +0.0368
  p99 and above    +0.0393
  reachable ceiling  0.9604 -> 0.9999

Nothing caught it, three ways:

* `ReservoirSampler.to_cdf_matrix` interpolates short rows onto the *full* grid
  and cannot produce that shape, so the file was never reproducible from repo
  code;
* `build_and_save` only resampled when `shape[1] > n_points`, so a narrow matrix
  was written verbatim, with no assert;
* `normalization.py`'s own docstring states rows are never padded. The artefact
  contradicted an invariant its code documents.

It also contaminated #83: a per-track floor at any q >= 0.96 resolves to the
enformer null *maximum* for all 5,313 tracks, so the q sweep was invalid there.

WHAT THIS ADDS

`cdf_grid_violations` / `expected_first_max_index` in background_sampling.py —
one implementation shared by the test, the write-time guard and the repair.
Two checks, each validated against all eight shipped backgrounds (19,393 short
rows): both flag every one of enformer's 5,313 effect rows and nothing else.

The derivation matters and is easy to get wrong. `to_cdf_matrix`'s
`source_q = arange(n)/n` stops at `(n-1)/n`, not 1.0, so `np.interp` clamps
beyond it and a short row legitimately ends in a plateau of repeated maxima —
6 slots for AlphaGenome's 1,909 samples, 100 for a 100-sample row. Trailing
duplicates are therefore normal and are *not* the padding signal. Three edge
cases are exempted for cause, each with a test:

* the plateau check is one-sided. Padding makes the plateau longer (395 vs 2);
  a hand-built fixture makes it shorter, which is legitimate.
* rows with `n >= n_points` are skipped: AlphaGenome's summary row 2,452 has its
  top two H3K4me1 windows tied at exactly 2480.0 — real saturation.
* constant rows carry no grid geometry at all (a single sample interpolates
  flat), so both checks would false-positive on them.

A narrow matrix is *not* rejected, only logged. `_get_denominator` reads
`shape[1]`, so a 100-wide matrix stored at 100 is self-consistent — and
enformer's defect was the opposite shape, `shape[1] == n_points`, which no
width check could have caught.

THE REPAIR

`scripts/repair_enformer_effect_grid.py` recovers the quantile function from the
9,606 stored quantiles and re-expresses it on the 10,000-point grid: idempotent,
backed up, exact max preservation, median-quantile shift 6.4e-07, all 5,313 rows
monotone. Pseudo-samples must span `linspace(0,1,n)` and not `arange(n)/n` —
the latter discards the top sample and loses up to 74% of a row's maximum,
precisely the tail being restored.

This is an interpolation of a derived artefact, not a rebuild; the planned
enformer rebuild supersedes it. Local cache only — republishing to HuggingFace
is a separate authenticated step.

Tests: 670 passed (was 653), 8 skipped. Integration: 8/8 shipped backgrounds
clean, and the retained pre-repair backup still trips the guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lucapinello
lucapinello merged commit b141b56 into main Aug 2, 2026
1 check passed
@lucapinello
lucapinello deleted the fix/2026-08-02-background-integrity branch August 2, 2026 12:38
lucapinello added a commit that referenced this pull request Aug 4, 2026
…, and fix four defects found while verifying (#160)

* fix: the grid guard mistook tied maxima for padding, and a real rebuild caught it

The #143 guard REFUSED to write a fresh Borzoi background. It was right to fail
closed and wrong about the reason.

Borzoi row 3011's top effect value 0.689308 recurs 9 times, because several sampled
variants hit the same clipped ceiling. np.interp holds a tied value from the
q-position of the first tied sample onward, so ties lengthen the trailing run of
maxima exactly as padding does. 10 Borzoi rows and 152 Enformer rows tripped the
one-sided plateau check.

I had already exempted ties for `n >= n_points` — citing AlphaGenome's summary row
2,452, two H3K4me1 windows tied at exactly 2480.0 — and simply never applied the same
reasoning to short rows.

WHY THE FIX IS FILE-LEVEL

Padding shifts EVERY row's maximum to the same index, because the whole matrix was
gridded at one narrower width. Ties shift a handful. So the signal is the MEDIAN of
(expected first max - actual) across rows:

  every legitimate shipped background      0
  a fresh Borzoi / Enformer build          0
  enformer_pertrack.npz before repair    393

Three orders of magnitude between signal and tolerance. cdf_grid_file_violations()
also abstains below 8 rows, because one tied row genuinely is indistinguishable from
one padded row — only unanimity separates them.

The tie-immune per-row check (distinct != count) is kept and is what actually caught
the original defect: 5,313 of 5,313 padded rows flagged, 0 of 30,000+ legitimate ones
across every shipped file AND both fresh rebuilds.

Two of my own test expectations were wrong here too: the tie fixture used
N_POINTS=64 with 5,949 samples, which is the subsample branch rather than the
interpolation branch being tested, and a single-row matrix cannot support a median.

Tests: 38 in this module, 853 overall. Both fresh rebuilds now merge cleanly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat: AlphaGenome's RNA null uses TSS-in-window selection, matching the reference

Aligns gene selection with what AlphaGenome actually does, verified against the
shipped implementation rather than the paper text.

THEIR RULE, from the code

  gene_mask_extractor.py:326  transcripts selected by TSS position
  _PositionExtractor          position >= interval.start and < interval.end (semi-open)
  gene_annotation.py:94       TSS = Start for '+', End for '-'  (strand-aware)
  gene_annotation.py:75       feature='transcript', so PER-TRANSCRIPT
  gene_mask_extractor.py:369  gene mask = OR of exon masks of THOSE transcripts

So a gene whose body overlaps the window but whose TSS lies outside contributes
NOTHING under their rule, where the gene-body-overlap rule chorus used contributes
all of its exons.

HOW MUCH IT ACTUALLY MATTERS — measured before spending 10 GPU-hours

  locus     overlap(PC)  TSS-in-window(PC)  only-overlap  only-TSS
  SORT1              29                 28             1         0
  FTO                 6                  5             1         0
  BCL11A              4                  3             1         0
  TERT               14                 14             0         0
  MYC                 2                  2             0         0

At most ONE gene of ~29 per locus, and overlap is a strict superset. The smoke run
confirms it end to end: 14.5 genes per window under TSS-in-window against 15.6 under
overlap. Small — but Luca asked for strict correctness over cheapness, and the old
rule was a #144-class divergence I introduced myself.

THE GENE-TYPE FILTER IS A DELIBERATE DIVERGENCE, NOT AN OVERSIGHT

AlphaGenome applies NO gene-type filter — TSS-in-window returns 31-61 genes per
locus against 28 protein-coding. chorus's query filters to protein_coding
(variant_report.py:825), so the builder does too: a null over lncRNAs and
pseudogenes would be a different population from the numerator, which is #144 in
the other direction. Exposed as `gene_types=` and documented so it is a recorded
choice rather than an implicit one.

Aggregation and normalization need NO change — they already match exactly after
#149: softplus output space (coverage-like, not log), mean over mask extent, natural
log, +1e-3, no CPM and no per-track scaling. The pseudocount is benign at real
activity levels: RNA p50 activity is 0.208, where a 2x signal change yields 0.688 of
a possible 0.693.

The overlap-rule interims are preserved at
/data/chorus_data/ag_{effect,baseline}_interim.OVERLAP-RULE.npz so the change can be
diffed rather than trusted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Demote the distinct==count grid check to advisory; raise only on first_max

The distinct-vs-count fingerprint I called perfect — 5,313/5,313 with zero
false positives — fired on a healthy row and blocked a 10-hour rebuild merge.

AlphaGenome effect_cdfs row 3966 (CHIP_TF ARID3A) has count=5949 and exactly
5949 distinct values on the 10,000-point grid. It is not padded: 913 of its
samples are exact zeros, so interpolating the remainder happens to land on
5949 distinct values by coincidence. The proof it is interpolated is that its
maximum first appears at index 9998, which is exactly
expected_first_max_index(5949, 10000) — padding would put it at 5948.

So distinct==count is a coincidence-prone fingerprint, not a proof. This
makes the mechanical signal the only raising condition:

    first_max == n - 1  and  n < n_points - 2

That is a direct consequence of how the padding was produced (grid at
`count`, repeat the last column) and cannot be reached by np.interp, whose
source_q stops at (n-1)/n. distinct==count becomes a logger.warning that
says outright it is usually coincidence.

Tally for this guard before the demotion: three false positives against one
true catch (the per-row plateau check on tied maxima — 10 borzoi rows and 152
enformer rows; the file-level median check, removed entirely; and this). A
guard that blocks a rebuild at that rate is worse than no guard.

Verified: still catches the genuinely padded enformer_pertrack.npz.prepad
(row 0, first_max 9605), and all three fresh rebuilds pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Pin what quantile tie-breaking can actually reach, and what it cannot

Two test corrections, both because the test encoded my motivation rather than
the code's behaviour.

test_a_value_inside_the_tie_is_spread_across_it queried 0.0417 — the value I
said AlphaGenome RNA's null tops out at — expecting the flat top to be spread.
It is not, and cannot be: build_and_save stores CDFs as float32, so the stored
ceiling is 0.04170000106 while the float64 query is 0.04169999999. The query
sorts strictly BELOW it, searchsorted returns lo == hi, and no tie is seen.

So tie-breaking does not do what motivated it. Its reachable set is queries
bit-exact against a stored grid value, which in practice means exactly 0.0 —
common, because a variant a track does not respond to gives ref == alt
bit-for-bit. Measured there it works: 39 distinct percentiles across 40
identical tracks, spanning 0.0030-0.0894 of a 0.0900-wide band. The test now
uses that case, and a second test pins the float32 near-miss as a stated
limitation rather than leaving it to be rediscovered.

A value just above a degenerate ceiling still reads 1.0. That needs a wider
null, not a lookup change — which is what the re-anchored region set is for.

The grid-integrity test asserted the substring 'distinct', naming the
fingerprint just demoted to advisory. It now asserts the mechanical signal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Stamp as-built provenance on the three rebuilt backgrounds, and gate determinism end to end

The provenance PR taught build_and_save and append_tracks to carry a file-level
build_config, but none of the three rebuild-set builders passes one, so
alphagenome, borzoi and enformer shipped with only the canonical 8 keys. Only
cherimoya had a build_config, and it predates this work.

build_config is file-level, so it is appended in place rather than by re-running
eleven hours of forward passes. The discipline is that only establishable facts go
in. Three things are deliberately NOT stamped:

  * xla_flags, because reading os.environ here records THIS process's flags, not
    the build's. pin_deterministic_xla_flags logs nothing on its success path, so
    the build logs carry no positive record either.
  * a builder_git_sha set to HEAD, because two commits landed after these builds
    finished. Worse, the AlphaGenome build started at 16:26 with its builder
    changes still uncommitted -- they became c4ded13 at 19:28, three hours into an
    eleven-hour run -- so no single sha describes what ran.
  * wall-clock figures.

Instead the stamp is content-addressed (git hash-object sha1 + mtime + a boolean
for whether the mtime predates the build start) and, decisively, records the
strata the build ITSELF logged. That last field settled a case the mtimes could
not: borzoi and enformer started at 03:08:07, nine minutes BEFORE the commit that
introduced gene-anchored sampling, and their mtimes were later bumped to 03:30:14
by a git operation, so mtime alone reads as changed-after-build-start for both.
Their logs show the gene-anchored strata at 03:08:25 and 03:08:30 -- the code was
running from the first minute, as uncommitted edits. All three oracles logged
identical counts (tss_near 1200, tss_far 1200, junction 1980, gene_body 720,
random 849 of 6000), confirming one shared seeded region set as designed.

Also adds gate_end_to_end_determinism.py. #127 was closed on 9/9 bit-exactness at
the raw predict_sequence level, which does not cover the scorers, the window
geometry, the percentile lookups or the tie-breaking hash layered on top. This
runs the real build_variant_report path twice in two processes on one GPU and
diffs every numeric leaf bitwise -- no tolerance, because #127's symptom was 36
sign flips and a tolerance would hide those.

One fix worth naming: np.savez_compressed appends .npz unless the filename
already ends in it, so a .stamping temp suffix wrote to a different path and the
verify-before-replace read a file that did not exist. The originals were never
touched, but the guard was silently inert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Assert the three release gates, and record what four CAGE reference classes measure

The rebuild plan listed three residuals to caption; two were fixable and one was a
hedge, so all three became work items. This turns them into tests.

Gate 3 is the headline: a full build_variant_report is now bit-exact across two OS
processes on one GPU. 603 numeric fields, 0 differing, 0 sign flips, worst relative
delta exactly 0.0 -- against #127's baseline of 454 differing fields with 36 sign
flips. #127 was closed on 9/9 bit-exactness at the raw predict_sequence level,
which did not cover the scorers, the window geometry, the percentile lookups or the
tie-breaking hash layered on top; this covers all of it.

The calibration band needs a correction on the record. An earlier version of this
assertion used [0.75, 0.95], which I invented before having any measurement, and
CAGE at 0.659 then read as a failure when it is simply where a correctly-widened
null puts strong eQTLs. The band is now [0.55, 0.97], stated as a collapse/
re-saturation detector rather than a target to tune toward.

The audit note records the four CAGE reference classes measured against strong
TSS-proximal liver eQTLs:

  uniform-random positions (shipped before)       p50 0.857
  gene-anchored mixture (shipped now)             p50 0.659
  all annotated TSSs, variant AT the TSS          p50 0.323
  all annotated TSSs, eQTL-matched offsets        p50 0.713

Placing the variant exactly at the annotated TSS puts it at the peak maximum, where
the +1 pseudocount barely damps, so the null's median |effect| (0.036-0.039) comes
out nearly double a validated eQTL's (0.020) and a real eQTL reports BELOW median.
That is the mirror of the failure this work set out to fix. Anchoring on the same
TSSs but drawing the offset from the empirical eQTL tss_distance distribution gives
0.713 -- better than the shipped mixture, with no invented 40/33/12/15 fractions.
So the direction is right; only the exactly-at-the-TSS reading of it is wrong.

Also pins #123's fingerprint across all eight shipped backgrounds: no *_counts
array may be a tight run of consecutive integers (enformer shipped 9600-9606).
Verified clean on all eight.

One thing that did not improve, recorded rather than buried: enformer
chromatin_accessibility at SORT1 went from 4/12 saturated to 6/12. That is not a
new regression -- 0.960 was the padded-grid artefact ceiling (#143), so those rows
were already pinned and the repair only made it visible instead of disguising it as
a plausible 0.96. Enformer's accessibility effect null is genuinely too narrow for
a variant this strong. It clears gate 1 and is the next thing to look at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Delete the duplicate TSV writer that was silently dropping per-gene rows

Found while checking JSON<->TSV parity across all 13 walkthroughs after the
rebuild. Six of them disagreed, and the cause was not the rebuild -- the counts are
identical before and after -- but a second implementation of one routine, which is
the same defect class as everything else in this cycle.

scripts/regenerate_remaining_examples.py carried its own flattener,
_variant_report_tsv_rows, wrong in two ways:

  1. It de-duplicated on (allele, assay_id, layer), a key that omits the region.
     RNA and CAGE emit one row per GENE per track -- same allele, same assay, same
     layer, different gene -- so every gene after the first was discarded:

       validation/TERT_chr5_1295046        99 json ->  18 tsv
       discovery/SORT1_cell_type_screen   347 rows -> 39 tsv
       sequence_engineering/region_swap     32 json ->   4 tsv
       sequence_engineering/integration     55 json ->   3 tsv

     TERT's TSV showed one tss_activity row where there were fifteen, one per
     nearby gene TSS (BRD9, CLPTM1L, LPCAT1, MRPL36, ...). A dropped row leaves no
     gap and no null in a TSV, so nothing looked wrong -- which is why this
     survived however long it has been there.

  2. It wrote region_label into a column named "description", which already means
     the TRACK description in to_dict(). One name for two things across two
     artefacts of the same report, so the two could not even be compared until the
     column was renamed back.

The fix is deletion, not repair: route everything through report.to_dataframe(),
the canonical writer scripts/regenerate_examples.py already used and which needs no
de-dup at all. All 14 walkthrough (json, tsv) pairs now agree exactly.

tests/test_json_tsv_parity.py pins both the counts and the row IDENTITIES -- an
equal count with different rows would be a worse failure than an unequal one.

877 fast tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Correct the AlphaGenome track count in user-facing docs: 5,731 -> 5,168

The docs advertised AlphaGenome as 5,731 tracks in ten places across four files,
including the README's headline sentence. 5,731 is the row count of its metadata
table; 563 of those rows are `padding` placeholders whose only job is to keep
local_index aligned with the model's output array. They carry no assay,
iter_tracks() skips them, and the shipped background has no row for any of them.

The number a user can actually query is 5,168 -- verified both directions: 5,168
metadata tracks have a background row, and 0 do not. So the docs overstated usable
coverage by 563 tracks. Not a large error, but the kind that erodes trust in every
other number printed beside it, and it survived because nothing compared the prose
to the artefact.

CHANGELOG.md:324 claims this was already "disambiguated inline" in an earlier cycle.
It was not, in any of the four live docs.

tests/test_documented_track_counts.py now does the comparison: it reads the shipped
NPZs plus the oracle metadata and fails on any live doc whose per-oracle count
contradicts them. It also pins WHY the two numbers differ (5,168 real + 563 padding
= 5,731 exactly), so the explanatory footnote cannot quietly become false.

Dated files under audits/ and historical CHANGELOG entries are deliberately left
alone -- they record what was true when written, and rewriting them would destroy
the record rather than fix anything. That exclusion is stated in the test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* CHANGELOG: the rebuild, with attribution where it is measurable and none where it is not

Records the gene-anchored rebuild of AlphaGenome, Borzoi and Enformer, the eQTL
calibration table, and the effect on the committed examples: saturated rows fell
from 47 to 16 across the four variant walkthroughs, row counts unchanged at 369.

The plan asked for a table attributing each moved number to the change that moved
it. That is delivered for the four changes measured in isolation -- the RNA
denominator (numerator overstated 251-1736x), the Enformer grid repair (ceiling
0.9605 -> 0.9998), cross-process determinism (603 fields, 0 differing, against 454
differing with 36 sign flips), and the gene-anchored null (the eQTL table).

It is NOT delivered per-change for the per-layer walkthrough diff, and the entry
says so rather than implying otherwise. Splitting that honestly would mean
re-running each rebuild in isolation; splitting it by judgement would mean printing
numbers I cannot support. It ships as a fused diff with its causes listed.

The Enformer chromatin_accessibility regression is in the entry as a Changed item
with its own heading, not buried: 4/12 saturated -> 6/12, because 0.960 was the
artefact ceiling and the repair made existing pinning visible rather than creating
it. Stated as "did not improve, and is not described as fixed".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Blog draft: the rebuild made two of its background claims stale, and one framing wrong

The five corrections the plan listed for this draft were already applied (Enformer
393,216 bp, nine registered oracles with Cherimoya, the 5,731-vs-5,168 and
21,907-vs-40 footnotes, tool count 24). Three things needed changing anyway.

Stale because of my own rebuild: the draft described every effect null as built from
"random genomic positions" with 1,697-1,909 samples per AlphaGenome track and ~9,600
for Enformer/Borzoi. AlphaGenome, Borzoi and Enformer are now gene-anchored, at
5,949-87,781 / 5,949-20,337 / 5,949. Sei, LegNet, ChromBPNet and Cherimoya are
unchanged and still uniformly random, so the sentence has to distinguish them rather
than generalise. Also updated the reader-facing gloss: "relative to random SNPs" is
no longer true for three of eight oracles.

Wrong framing, and it was mine to begin with: "5,168 of the 5,731 have percentile
backgrounds" implies 563 real tracks lack one. They are not tracks -- they are
`padding` placeholders that keep local_index aligned with the model's output array,
carry no assay, and are skipped by iter_tracks(). Every queryable track has a
background row. 5,168 is the number to quote, and the draft now says why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* The bit-exactness gate has to find the alphagenome env, not skip itself away

The new integration gate failed on its first real run: ModuleNotFoundError: jax.
It launched the script with sys.executable, which under the base chorus env has no
JAX -- the oracle envs are deliberately isolated (CLAUDE.md).

Fixed by locating the sibling interpreter (../chorus-alphagenome/bin/python from
sys.prefix) rather than by adding a skip. A gate that skips itself when its
dependency is missing reports green while checking nothing, which is worse than
failing: the whole point of this test is that #127's symptom was invisible. It
still skips if neither the sibling env nor an importable jax exists, but the skip
message names the exact command to run by hand.

Verified through pytest on GPU 0: 1 passed in 215s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Make the stamped provenance load-bearing, and unify a shape three writers disagreed on

build_and_save and append_tracks could write and preserve a file-level build_config,
and the three rebuilt backgrounds carry one -- but a grep for build_config across
chorus/ found only the writer, the append-path preservation, and docstrings. Nothing
read it. Provenance nobody consults is documentation, not an invariant, so it could
not catch the thing it exists to catch.

That thing is #122: AlphaGenome histone CHIP nulls built over 501 bp while the query
summed 2001 bp. Both artefacts were internally consistent, so neither looked wrong;
the defect existed only BETWEEN them, and it shipped across 1,075 of 5,168 tracks.
There is now a read side -- PerTrackNormalizer.provenance() plus a load-time
comparison of the stamped histone/other window_bp against LAYER_CONFIGS -- so that
disagreement is detectable instead of invisible.

It warns rather than raises. Every background built before the provenance work is
unstamped, and an unstamped file makes no claim to contradict; refusing to load one
would break working installs to enforce a metadata convention.

Writing the reader immediately surfaced a third instance of this cycle's recurring
defect. build_and_save wrote build_config as a 0-d array; Cherimoya's builder and
scripts/stamp_background_provenance.py both wrote a 1-element array, which is what
every file on disk actually has. So a reader written against the shipped artefacts
raised IndexError on anything build_and_save produced, and vice versa -- three
producers, two conventions, nothing comparing them. The writer is now consistent at
1-element and _read_build_config accepts both, so old files still load. Three
assertions in test_npz_provenance.py had pinned the 0-d shape; they now parse through
the shared reader and assert content instead.

Also refreshes all six executed notebooks against the rebuilt backgrounds: 6/6
re-executed clean, 0 errors. Run in a mirrored directory tree because cherimoya's
notebook resolves ../../genomes/hg38.fa relative to its own location, so executing a
flat copy under /tmp fails on a path, not on anything real.

895 fast tests pass; 24 integration pass with 2 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
lucapinello added a commit that referenced this pull request Aug 5, 2026
…163)

* Per-layer effect region sets, and exact position-sharding so a build can use 8 GPUs

Two things, both driven by a census rather than by argument.

THE CENSUS. Comparing every committed walkthrough row against its own track's null
maximum on the shipped gene-anchored null:

    oracle        layer                      rows   above null max
    enformer      chromatin_accessibility      12       50.0 %
    alphagenome   histone_marks                10       30.0 %
    enformer      tf_binding                   12       25.0 %
    alphagenome   gene_expression             100        7.0 %
    alphagenome   tss_activity                263        8.0 %
    enformer      tss_activity                 48        0.0 %

The PEAK layers saturate and nothing else does. The cause is structural: most
gene-anchored positions are not inside a peak, and a variant in closed chromatin
cannot move an accessibility or ChIP signal much, so the null's upper tail is too
short. At SORT1, enformer accessibility effects are 1.14-1.45x its null maximum,
which is exactly why they read 1.0000 and stop discriminating.

So --effect-regions {gene-anchored,ccre} is added to all three builders, and
sample_ccre_anchored_positions reuses sample_ccre_positions -- the ENCODE SCREEN
sampler the BASELINE path has always used and the effect path never did. Sharing it
means the two paths cannot drift.

CAGE turned out to need nothing, which is the opposite of what I expected and worth
recording. Sweeping the variant's distance from an annotated TSS gives a monotone
curve in that single parameter (strong-eQTL percentile p50: 0.323 at the TSS itself,
0.411 at +/-500 bp, 0.526 at 1 kb, 0.604 at 2 kb, 0.654 at 5 kb, 0.729 at 10 kb), and
the shipped gene-anchored mixture sits at 0.659 -- it already behaves like a
"+/-5 kb of a TSS" null for CAGE. There is no qualitative gain available, only a
choice of distance scale that no principle fixes.

Also: the "Generated N gene-anchored SNPs" log line now names the region set from
args instead of hardcoding it, because that line IS the provenance
stamp_background_provenance.py reads back. Saying "gene-anchored" while sampling
cCREs would stamp a lie into every rebuilt NPZ. The stamp's regex captures the region
set rather than assuming it, and still parses the existing gene-anchored logs.

EXACT POSITION SHARDING. These three oracles produce every track from ONE forward
pass, so sharding by track -- which is how the chrombpnet builder does it, correctly,
because its tracks are separate model files -- saves no GPU time; each shard would
still run every pass. They have to be sharded by POSITION, and that changes what a
merge is: each shard then holds a partial reservoir for EVERY track.

The tempting merge is to pool the shards' 10,000-point CDF grids. That is an
approximation, decent for equal shards, and this repo has already shipped one
artefact whose approximation looked exact (#143's padded grid). So
ReservoirSampler.to_flat_samples/from_flat_samples serialise raw samples -- ragged
data stored flat with offsets, so the NPZ still loads under allow_pickle=False -- and
the CDF is built once from the union. counts ADD, since shards see disjoint
positions.

tests/test_position_sharding.py pins the property that makes this trustworthy: for
2, 3, 4 and 8-way partitions the merged sampler is bit-identical to one fed the whole
stream, in retained values, in counts, and in the resulting CDF. Also covers eviction
under capacity pressure, empty tracks, disagreeing track counts, and determinism.

912 fast tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* #144 instance FIVE: the discovery path computed a different window than the query

I closed #144 as an umbrella on four instances. A consistency audit found a fifth,
at two sites in chorus/analysis/discovery.py:

    ref_track.score_region(chrom, pos - half, pos + half + 1, cfg.aggregation)

That is the exact pre-#144 arithmetic scorers.py was migrated off. It builds genomic
coordinates and lets score_region floor/ceil-expand them back to bins, giving 4 OR 5
bins for window_bp=501 at Enformer's 128 bp depending on where the variant falls
inside its bin -- against a null built over 3. discover_variant_effects and
discover_cell_types read the SAME per-track backgrounds as the variant report, so
both were ranking a wider statistic against a narrower reference. Worse for
discover_cell_types, which *compares* those effects to order cell types: a span that
varies with sub-bin position can reorder the ranking itself.

It survived because the umbrella fix was applied where the defect was known to be,
and nothing searched for the shape elsewhere. Four instances were enumerated by hand;
a fifth existed. So the guard now does the searching, rather than trusting an
enumeration: any score_region call on the same line as half-width arithmetic fails,
across every module in chorus/. score_region itself is not banned -- it is right for
a genuine genomic interval, which core/base.py's exon summing and core/result.py's
region scoring both want. What is banned is using it to approximate a CENTRED window.

A second, separate defect at the same place: gene_expression has window_bp=None
because its statistic is the mean over a gene's merged exon mask, and the
window_bp-is-None branch fell through to np.mean(track.values) -- the mean over the
ENTIRE prediction, 524 kb for Borzoi and 1 Mb for AlphaGenome, dominated by
intergenic and intronic zeros. That number bears no relation to the exon-mask null it
was then ranked against. Discovery has no gene context to build a mask from, so it
now declines the layer with a logged reason instead of emitting a confidently wrong
percentile; analyze_variant_multilayer, which takes a gene, is the path for RNA.

The guard is deliberately two-sided. A test that only forbids the bad pattern goes
green if someone deletes the scoring altogether, so there is also a positive
assertion that score_centered_window is actually called at both sites, and an
enumeration of which layers legitimately have no window so a new one is noticed
rather than silently falling through to the whole-prediction mean.

1,051 fast tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* README: describe the reference population the shipped backgrounds actually use

Six places said the effect percentile was computed against "~10,000 random SNPs
sampled uniformly across chr1-chr22". That has not been true for AlphaGenome, Borzoi
or Enformer since the gene-anchored rebuild, and it was never true of the sample
size: the shipped counts are 5,949-87,781 per track, not 10,000.

It is also the wrong shape of claim, because the reference population is no longer
one thing. It now varies by oracle AND by layer, so the README carries a table:
gene-anchored for the three rebuilt oracles, cCRE-anchored for their accessibility
rows specifically, uniform-random for the five that were never rebuilt.

The prose explains WHY rather than just listing, since the reasoning is the part a
reader needs to interpret a percentile: a random genomic position carries almost no
CAGE or accessibility signal, so the +1 pseudocount damps its log-ratio toward zero
and the null's body collapses below where real regulatory effects live. It also names
the deliberate 15 % uniform tail -- without near-zero mass, genuinely small effects
would get artificially LOW percentiles, the mirror of the same failure.

And it states the negative result, because a reference class that helped one layer
and not others is the kind of thing that otherwise gets over-generalised later:
accessibility rows come from cCREs because 50 % of Enformer's accessibility rows at
SORT1 exceeded their own null maximum and a cCRE-anchored null takes that to 0 %.
TF binding and histone marks were measured and did NOT improve -- a cCRE is defined
by accessibility, H3K4me3 or CTCF signal, so a randomly chosen one often is not bound
by the particular TF a given ChIP track measures.

The sample-size table is knowingly still stale in this commit; it is regenerated from
the shipped artefacts once the cCRE build lands, so its numbers are final rather than
written twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Migrate the last local ReservoirSampler out of the AlphaGenome builder (#125)

Eight builders, eight copies of this class. Seven had already been migrated to the
shared one; build_backgrounds_alphagenome.py kept its own, 87 lines at line 107.

Keeping it cost real time today. I added to_flat_samples to the SHARED class for
position sharding, launched eight AlphaGenome shards, and every one of them ran for
fifty GPU minutes and then died at the write step with

    AttributeError: 'ReservoirSampler' object has no attribute 'to_flat_samples'

because this file's copy did not have the method. That is the thesis of #125
demonstrated at my own expense, and the reason the fix is deletion rather than
keeping the copy in sync.

Both differences the copy carried are preserved:

  * default capacity 20,000 against the shared 50,000 -- moot, since all three call
    sites pass capacity=args.reservoir_size explicitly;
  * a hand-vectorised add_batch, which the baseline pass's per-variant fan-out
    genuinely needs. That is now the SHARED implementation, so migrating does not
    regress AlphaGenome's throughput, and the plain loop survives as
    _add_batch_reference so the equivalence test still has something to compare to.

The vectorised fast path is only taken while the reservoir still has room -- where
"which samples survive" is not yet a question, so bulk-extending is identical to
appending one at a time. Once full it falls back to per-value Algorithm R, because
there the traversal ORDER decides which samples survive and a different order moves
the CDF with no arithmetic changing.

Verified numerically against the deleted implementation, not just by reading: at
capacities 40, 500 and 20,000, over 30 chunks of 37 values into 3 tracks, both counts
and retained samples are identical. Capacity 40 forces the overflow branch, which is
the only branch where order matters.

1,054 fast tests pass. The 10 failures in the run are tests/test_walkthrough_readmes_
match_artefacts.py correctly flagging stale README prose, fixed in a follow-up once
the rebuild lands and the artefacts are final.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Anchor the effect null on the regions each assay actually measures, in all 8 oracles

Five of the eight oracles already anchored their effect null on peaks; three did not,
even though two of those three already used cCREs for their BASELINE pass. An
asymmetry inside a single oracle is harder to defend than any difference between
oracles, so this closes it.

  chrombpnet   already had it: 10,000 DHS-summit variants + 10,000 uniform
  cherimoya    already had it: snps = random + dhs, explicitly unioned
  alphagenome  gains it: gene-anchored + cCRE union
  enformer     gains it: same
  borzoi       gains it: same
  sei          gains it: same generic union -- its 40 classes span promoters,
               enhancers, TF binding, transcription, heterochromatin and low signal,
               so no single peak type fits, which is when the generic mixture is right
  legnet       gains a PROMOTER-anchored set, not the generic one. It is a 200 bp
               promoter MPRA model with window_bp=None, so the sampled position IS the
               whole thing being modelled. The cCRE catalogue is 62% dELS (1,469,205
               of 2,348,854 distal enhancer-like) against 2% PLS (47,532
               promoter-like), and DHS summits track accessibility rather than
               promoter identity -- either would give a promoter model a null made
               mostly of enhancers. PLS leads at 30%, TSS+/-250bp at 40%, pELS at 15%,
               uniform tail at 15%.
  epinformer   gains its OWN baseline composition for the effect pass, removing the
               intra-oracle asymmetry.

MEASURED, per track, as the ratio of the new null's tail to the old one's. Median
over tracks, and the share of tracks that got wider:

    oracle          tracks    p99    p99.9   wider
    sei                 40   2.05x   1.80x    100%
    epinformerseq       33   1.38x   1.28x     76%
    legnet               3   1.30x   1.17x     67%
    enformer         5,313   1.26x   1.33x     84%
    borzoi           7,611   1.19x   1.19x     82%

All five improve. Sei gains most, which fits: it had a pure uniform-random null and
the widest class space.

TWO CORRECTIONS TO MY OWN METHOD, both worth recording because both produced a wrong
answer first.

(1) I initially reported EPInformer-seq as REGRESSING at 0.89x. That came from
comparing median(new) against median(old) -- the median TRACK of each set, which need
not be the same track. The correct statistic is the median of per-track ratios, which
is 1.38x with 25 of 33 tracks wider. Same family of error as (2).

(2) The metric itself. I had been scoring these changes by "share of rows above the
null MAXIMUM". A maximum is a single extreme order statistic with high sampling
variance: Enformer's shipped tf_binding max is 3.539 while the rebuilt one is 2.956
even though its p99.9 nearly doubled. That metric reads noise in one draw, not tail
width. It is the right thing to REPORT to a user, since it is literally "percentile
pinned at 1.0", but the wrong thing to tune on. Acceptance is now p99/p99.9.

WEAKER JUSTIFICATION, STATED AS SUCH: sei, legnet and epinformerseq have no committed
walkthrough rows and no positive set (no eQTL equivalent for MPRA activity or Sei
sequence classes), so unlike the Enformer accessibility fix -- where saturation was
measured at 50% and dropped to 0% -- these three are justified by tail width and by
matching the assay's biology, not by a calibration check. That distinction belongs in
the CHANGELOG too.

Also fixes an import bug I introduced and nearly shipped: the guard that was supposed
to add the module-level `from chorus.utils.annotations import ...` tested for a string
that a FUNCTION-level import of the same module already satisfied, so nothing was
written and both sei and legnet would have died with NameError at runtime. Caught by
an AST bound-name check rather than by the parse, since a missing import parses fine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Apply the rebuilt effect nulls to 5 backgrounds; generate the README table

apply_effect_rebuild.py swaps a freshly built effect null into a shipped background
and touches nothing else. The rebuild changed only which positions the EFFECT null is
drawn from -- the baseline/summary and per-bin passes are identical -- so re-running
them would burn GPU reproducing the same numbers while risking drift.

It is also the only correct option rather than a shortcut: sei, legnet and
epinformerseq no longer have baseline interims on disk, so `--part merge` is not
available for them at all.

What it refuses to do:
  * proceed when track_ids differ in content OR ORDER. Row i means "the track at
    index i of track_ids", so a reordering silently reassigns every null to the wrong
    track -- and a same-set-different-order case would pass a naive set comparison.
  * proceed when the new matrix fails cdf_grid_violations (#143's guard).
  * overwrite in place. It writes a sibling, verifies the sibling loads and that
    summary_cdfs/perbin_cdfs came through byte-identical, and only then replaces.

Applied to enformer, borzoi, sei, legnet, epinformerseq. Originals backed up to
/data/chorus_data/pre_effect_rebuild/. AlphaGenome follows when its shards land.

Verified after: all 8 shipped backgrounds monotone, zero NaN/inf, provenance
readable. Effect samples/track now 11,913-34,482 for the rebuilt ones.

The provenance stamp records effect_region_set per oracle, and says explicitly that
only the effect rows moved -- otherwise a reader would reasonably assume the activity
percentile was rebuilt too.

Also replaces README's hand-maintained per-oracle background table with a generated
one. It had drifted badly: 10,000 effect / 31,500 activity samples claimed for six of
seven rows against real counts of 5,949-104,033, and Cherimoya -- one of the eight
shipped backgrounds -- missing from the table that enumerates them. Same defect as the
stale walkthrough READMEs: prose about numbers that live in binary artefacts, with
nothing comparing the two.

One fix worth naming: the generator's fallback defaulted to "uniform random" when a
stamp had no effect_region_set, which mislabelled AlphaGenome -- its stamp records the
gene-anchored rule under an older key. It now falls back only to recorded facts and
prints "(unrecorded)" rather than asserting something wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Regenerate every artefact against the rebuilt nulls, and fix the prose that lied

All eight backgrounds changed, so every percentile in every committed example moved.
Regenerated the 13 walkthroughs, the multi-oracle path (chrombpnet + legnet +
alphagenome + consolidate), and corrected 34 stale numbers across 11 READMEs.

Twenty-seven of those were the same row with a slightly moved value and were
substituted mechanically at each site's own decimal precision. Four needed prose
rewrites because the claim, not the number, was wrong:

  * variant_analysis/SORT1_chrombpnet/README.md described an ATAC run at -0.111
    ("moderate closing") and devoted a whole section to explaining why AlphaGenome
    and ChromBPNet DISAGREE at this locus. The committed artefact is DNASE at
    +1.376, and AlphaGenome reports +1.334 -- they agree on direction and within a
    few percent on magnitude. The section explained a contradiction that does not
    exist. Rewritten to report the agreement, which is the actual point of running
    two oracles, and to note that the loaded assay is DNase not ATAC.
  * variant_analysis/README.md claimed the Enformer example's key finding was "very
    strong CTCF binding loss (-0.89)". The artefact contains NO CTCF track at all.
    Replaced with its real maxima: TF +4.372, chromatin +2.247, histone +1.886.
  * variant_analysis/BCL11A_rs1427407/README.md had the SIGN wrong: "-0.113 log2FC"
    against an artefact value of +0.145. An opening reported as a closing.
  * The same file's ChromBPNet entry quoted +0.43 where the artefact says +1.376.

The root cause is structural, not clerical: the regeneration scripts rewrite
example_output.{json,md,tsv} and the HTML, and have never touched a README. So every
correctness fix since #92 left the narrative behind, and nothing compared prose to
data. tests/test_walkthrough_readmes_match_artefacts.py now fails on any signed
decimal in a walkthrough README that its own artefact does not contain.

TWO TESTS OF MINE ALSO NEEDED FIXING, both for asserting a FORM rather than the
invariant, and both broken by a change that preserved the property exactly:

  * test_the_random_stratum_is_present_and_substantial asserted the random stratum's
    FRACTION was >= 0.10. Adding the cCRE stratum took the total from 6,000 positions
    to 12,000 and halved every pre-existing fraction to keep its COUNT identical:
    random went 0.15 -> 0.075, which is 900 positions either way. Now asserts the
    absolute count, which is the property that matters and is robust to N.
  * test_builder_imports_the_shared_classifier asserted an exact import LINE, which
    broke the moment a second name joined the same import. Now parses the AST and
    asserts the name is imported. A guard that fails on formatting trains people to
    edit the guard.

1,107 fast tests pass, zero failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* CHANGELOG + dated audit note for the reference-population work

Records the result, the design, and the three measurements I got wrong before I got
them right -- the mistakes being the more useful half.

The audit note states the guarantee precisely, because it is what makes the design
defensible rather than merely measured: keeping each component at full size makes the
union's maximum exactly max(max_gene, max_cCRE), so the union is provably never worse
than the better component for any layer. The fixed-N mixture had no such property and
indeed came out below BOTH components for Enformer tf_binding, taking its saturation
from 25% of rows to 92%.

It also reconciles the previous cycle's entry rather than leaving two that contradict
each other. That entry said Enformer chromatin_accessibility was "the next thing to
look at" at 6/12 saturated; it is now 0/12, fixed later in the same release. The two
are sequential, not competing, and the text now says so. The older eQTL table is
marked as the post-gene-anchored figures with a pointer to the final ones.

Both the CHANGELOG and the note state, in their own words, that Sei, LegNet and
EPInformer-seq are justified by tail width and assay biology and NOT by a calibration
check -- there is no eQTL equivalent for MPRA activity or Sei sequence classes. That
is weaker ground than the accessibility fix stood on and should not be blurred into
it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Re-execute all six notebooks against the rebuilt backgrounds

6/6 clean, zero error outputs. Run in a mirrored directory tree because
cherimoya_quickstart resolves ../../genomes/hg38.fa relative to its own location,
so executing a flat copy under /tmp fails on a path rather than on anything real.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Refresh the generated background table after AlphaGenome's rebuild landed

AlphaGenome's effect samples/track went 87,781 -> 148,367 and its reference
population is now recorded as gene-anchored+ccre rather than read from the older
effect_region_rule key. Generated, not hand-edited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant