Skip to content

Fix the background null model: the sampler was discarding the tail it measures - #164

Open
lucapinello wants to merge 25 commits into
mainfrom
fix/2026-08-05-vignette-recapitulation
Open

Fix the background null model: the sampler was discarding the tail it measures#164
lucapinello wants to merge 25 commits into
mainfrom
fix/2026-08-05-vignette-recapitulation

Conversation

@lucapinello

@lucapinello lucapinello commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Nine commits. The headline: the null's ceiling was wrong by construction, not merely noisy — and fixing it needed no GPU.

The defect

percentile = min(rank/denominator, 1.0) clamps once an effect passes the largest sampled background value. That had been patched three times (re-anchoring, union-at-2N, a read-side exceedance ratio). None was the cause.

merge_effect_shards.py called ReservoirSampler.from_flat_samples(*parts) with no capacity, silently inheriting DEFAULT_CAPACITY = 50_000. Every AlphaGenome gene_expression track offers 148,367 effect values, so the union was subsampled 2.97×. A uniform m-of-N subsample keeps the population maximum with probability exactly m/N: 50,000/148,367 = 0.3370, and 33.9% of the 667 RNA rows were measured to have kept theirs.

AlphaGenome effect null tracks max ratio (true/shipped) p99 ratio
gene_expression 667 median 1.332, p90 3.18, worst 8.34 1.006
every other layer 4,501 1.0000 1.0000

The tail was wrong by up to 8.3× while p99 was right to 0.02%. That asymmetry is why this survived every calibration gate for months: reservoir sampling is unbiased for the body, and every gate measures the body.

Neither existing guard could have caught it:

  • No shard exceeded its capacity (~18.5k offered against 20,000), so the builders' capacity was never the issue. The merge is a second, independent site.
  • cdf_grid_violations is fed the offered count while checking geometry set by the retained count, then does if n >= n_points: continue. Offered is always ≥ 10,000, so it skipped every thinned row by construction.

Fixed by exact retention. No GPU — the 8 shards hold every offered value, so the union recovers all 148,367 in 23 seconds. capacity is now keyword-only with no default, making this specific defect a TypeError.

Verified against the raw samples, not against itself

  • all 4,501 never-thinned rows come back bit-identical
  • 0 of 5,168 tracks thinned; shipped maxima now equal the true population maxima
  • release gates hold: CAGE 0.6250, RNA 0.7770, 0% saturated
  • Borzoi/Enformer verified bit-exact and deliberately not rebuilt (their unions are under capacity, so the merge never takes the rng.choice path) — pinned as a test so the omission is a recorded negative result rather than an oversight

One gate I had specified wrongly: "p99 within ±10% for every track". 3 of 667 rows exceed it, 2 above / 1 below — unbiased noise in the old 50,000-sample estimate, not movement in the new one, which is now exact. Body invariance is verified at p50/p90, where 0.0% of rows fall outside 10%. At p99.9 the ratio shifts systematically up (median 1.022): that is the recovered tail mass, i.e. the point.

Two more defects found on the way

Sei was entirely dark. sei.py set assay_type = info.name for its sequence classes; classify_track_layer dispatches on the literal "sequence-class", so it returned "other", whose LAYER_CONFIGS entry is None, and every one of Sei's 40 tracks scored raw_score=None. It had a built, verified, zero-degeneracy null that no query could reach — and its absence from every committed example read as "we didn't include Sei" rather than "Sei can't be scored". One line. All 40 now score.

Anchored positions were being clamped onto the contig margin. usable selects whole chromosomes long enough for the margin, but the tss/junction/gene-body populations were filtered only by chrom in usable, never by the margin interval. 2,515 of 20,083 protein-coding TSS (12.5%) sit within 5 Mb of a contig end and were _clamped onto the boundary coordinate — up to 5 Mb from the TSS they were labelled as being within 1 kb of.

before after
positions exactly on a boundary 12.1–14.6% per stratum 0.0–0.1%
distinct positions (of 6,000) 5,265 6,000
worst duplicate coordinate 64× (chr16:5,000,000)

The mislabelling matters; the duplication matters more. Identical positions give identical effect values, so they pad the sample count without adding information and manufacture tied CDF runs — the degeneracy _rank_with_tie_breaking exists to compensate for, injected by the sampler rather than present in the biology. This affected the shipped backgrounds.

Also in here

  • DHS stratum, additive at scaled N (12,000 → 18,000), every pre-existing stratum keeping its exact absolute count. Justified by tail width and max(union) = max(max_a, max_b), not by calibration — and it does not fix motif-creation saturation: the DHS-anchored null ChromBPNet has always used still pins on rs12740374 CEBPA at 1.11×.
  • A landmine disarmed. The stratum dispatch ended in a bare else drawing a uniformly random position, which doubled as the random handler, the empty-pool fallback and the catch-all for unrecognised names. Adding "dhs" without a branch would have shipped 6,000 uniformly random positions tagged, tallied and stamped as DHS, with entirely plausible-looking numbers. Both samplers now raise; the decisive guard is an annotation round-trip (≥99% of dhs within 150 bp of a Meuleman summit, ccre inside a cCRE, tss_near within 1 kb) — which is what found the clamping bug.
  • effect_exceedance: above the ceiling the percentile is clamped and says nothing, so report the ratio to it. 1.011× / 1.109× / 2.973× / 10.109× now separate four effects that all read 1.000000. Deliberately not an extrapolated percentile — measured, a GPD overshoots the far tail by 3.8× and an exponential undershoots by 0.27×, while the plain empirical max is within 13%.
  • HF_HOME no longer relocatedHF_HUB_CACHE is. HF_HOME is the parent of both the blob store and the huggingface-cli login token, so setting it orphaned the credential and every gated model failed telling users to run a command they had already run. Self-inflicted in the data-dir change; found by a measurement that needed AlphaGenome, not by a test.
  • data dir defaults to the install tree rather than $HOME; LegNet and ChromBPNet list_cell_types fixes (each had hidden cell types that shipped background rows for).

Tests

1,181 fast tests pass. Every new guard was checked to fail on the pre-fix code.

Two tests corrected their own wrong premises while being written: unsigned CDF rows start at the smallest sampled magnitude (8.4e-6), not a structural zero; and the two support ends are asymmetric because searchsorted(side="right") counts values ≤ x, so exactly the maximum pins while exactly the minimum does not.

One was rewritten rather than retuned: a TSS-proximity test asserted an aggregate median, which additive union does not preserve — the TSS counts were untouched but the mixture median rose because DHS is legitimately distal. It now asserts per stratum (every tss_near position within 1 kb: measured 100%, median 413 bp) plus aggregate enrichment (13.6% vs 1.4% uniform).

Not in here

The unified GPU rebuild (effect + summary + perbin for all 8 oracles under one build_id) is planned but not started; it is gated on a sampler rewrite so thinning cannot recur.

Still open, and the same defect in other layers: perbin is thinned 16–43× on five oracles, and summary up to 5.2× on three.

🤖 Generated with Claude Code

lucapinello and others added 9 commits August 5, 2026 12:56
13 of 15 checkable claims hold, 1 is refuted, 1 is unresolved. The script drives the
shipped predict_variant_effect -> build_variant_report path, so a pass is evidence
about chorus rather than about the script.

Worth stating up front: raw_score does not depend on the background at all, so the
rebuild could only move PERCENTILES. Every vignette claim about direction, magnitude
or which-factor-wins was never at risk from it -- and the claim that fails, fails for
an unrelated reason.

VIGNETTE 1 (rs12740374 / SORT1), confirmed:
  * ChromBPNet: IMR-90 +2.022 (pct 0.9994), HepG2 +1.376 (pct 0.9995, HIGHEST),
    GM12878 +0.396, K562 +0.272. The blog's careful hedge -- HepG2 co-highest by
    percentile while IMR-90 has the larger raw effect -- is exactly right. A nice
    demonstration of why per-track normalisation matters: +1.376 is more unusual FOR
    HEPG2 than +2.022 is for IMR-90.
  * C/EBP tops the HepG2 TF panel: CEBPB +3.316, CEBPA +2.945, CEBPG +2.460,
    CEBPD +2.033 -- four of the top five of 539 cell-matched tracks, all gained.
  * H3K27ac +1.251 (blog +1.27), CAGE +1.502 (blog +1.52), accessibility +1.334
    (blog +1.34), LegNet alt>ref at +0.347 (blog +0.30).
  * SORT1 magnitude under-predicted: +0.078 = 1.056-fold against >12-fold measured.

VIGNETTE 1, REFUTED: "the top hit was SORT1". Ranked by best |log2FC| per gene over
the 29 protein-coding genes in the 1 Mb window, SORT1 is FOURTH -- PSRC1 +0.782,
CELSR2 +0.622, MYBPHL +0.243, SORT1 +0.078 -- and the ranking is monotonic in TSS
distance (8.2 kb, 25.4 kb, ..., 123.0 kb). At this locus AlphaGenome's gene-level
readout tracks proximity, not the causal target the 2010 paper established. Not a
regression from the rebuild, since raw_score is background-independent; most likely
the #149 denominator fix, which corrected the RNA numerator by 251-1736x. SORT1 does
still rise in liver tracks at percentile 0.9986 -- it is simply not the largest mover.

VIGNETTE 2 (rs9504151 / CDYL), confirmed: rank 1 of 56 in AlphaGenome (composite
0.9992, effect -1.368 against the blog's 0.995 / -1.363); accessibility -1.368 and
H3K27ac -1.194 both dropping; RNA max |log2FC| 0.011, i.e. no change; and ATF4 second
only to CEBPB in the all-TF panel (-4.169 vs -4.346) with ATF3 close behind, all
negative. The cell-matched TF panel has exactly ONE track (CTCF, -0.011), which is
why the factor had to come from the global panel -- precisely the caveat the blog
states.

VIGNETTE 2, UNRESOLVED: the ChromBPNet rank-1 claim. The sentinel's raw effect
matches the blog exactly at -0.985, but it ranks 2 behind rs386522231. I ranked by
single-track effect percentile while the blog used fine_map_causal_variant's
composite (0.896) -- different statistics, so rank 2 is NOT a refutation. Resolving
it needs a fine_map run with an LDlink token.

Also corrects three API assumptions I made and had to fix by inspection rather than
guesswork: list_cell_types takes no assay argument, score_variant_multilayer's
signature is (variant_result, gene_name=None) with no normalizer, and it returns
{allele: {track_key: {field: value}}} -- plain dicts, not objects. Two earlier
guesses at that shape produced zero rows silently, which is exactly the failure mode
worth avoiding in a verification script.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everything large used to land in $HOME. On this box that was 7.8 GB of per-track
backgrounds under ~/.chorus/backgrounds/ plus 12 GB of model weights under
~/.cache/huggingface/ -- the latter simply because nothing ever set HF_HOME. That is
the wrong filesystem for a shared machine or any box with a home quota, and it is why
/ filled up here earlier in this work.

Now: one resolver in chorus/core/globals.py, one switch, three ways to set it.

  CHORUS_DATA_DIR=/path                        # env var, per-shell, highest priority
  chorus setup --data-dir /path                # at install time
  chorus config data-dir --set /path           # persist for an existing install
  chorus config data-dir                       # show what resolved and WHY
  chorus config data-dir --set P --migrate     # move existing backgrounds

Resolution order: env var > <install>/chorus_data_dir.txt > the installation
directory (the new default) > ~/.chorus, the last only when the install tree is not
writable, which is the normal case for a pip install into system site-packages.
Crashing on the first download would be worse than quietly using a location that
works.

HF_HOME is now pointed at <data_dir>/huggingface, which is what actually moves the
12 GB. It has to happen at import time -- huggingface_hub reads HF_HOME when its
constants module is first imported -- so the call sits at the bottom of globals.py
rather than lazily in an oracle. A pre-existing HF_HOME/HF_HUB_CACHE is left alone,
since someone who already pointed it somewhere meant to.

TWO THINGS DELIBERATELY DO NOT FOLLOW THE DATA DIR, and both are pinned by tests:

  * credentials. The entire point of a data directory is that it can be SHARED
    between users, and a group-readable install tree is the wrong home for a
    personal LDlink or HuggingFace token. Those stay in $HOME.
  * conda environments. A shared data directory must not imply shared conda
    prefixes.

TWO REGRESSIONS I INTRODUCED WHILE BUILDING THIS, both now tests:

  * The legacy fallback was whole-directory. Because ~/.chorus/backgrounds had data,
    the resolver sent annotations, downloads AND genomes to ~/.chorus too -- and
    those three were always in the installation tree, so it relocated data that was
    never misplaced. Legacy compat is now applied PER KIND: only backgrounds, the
    only kind that ever lived under ~/.chorus, get it.
  * A scripted edit with str.replace(..., 1) fixed the first of EIGHT
    Path.home()/".chorus"/"backgrounds" defaults in normalization.py and left seven.
    The headline path moved while most entry points still defaulted into $HOME --
    the worst kind of half-fix, because the obvious check passes. A test now greps
    for the pattern across chorus/.

Backwards compatible: an install whose backgrounds are already in ~/.chorus keeps
using them, with a one-time message, rather than silently re-downloading 7.8 GB and
orphaning the old copy. --migrate moves them deliberately, copy-then-verify-then-
remove rather than rename, because the source may be a symlink across filesystems
where rename fails with EXDEV.

13 new tests, subprocess-based because the resolution is import-time and
re-importing a module whose side effects already ran proves nothing. 1,121 fast tests
pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found while re-verifying a blog vignette. LegNetOracle.list_cell_types() returned
[self.cell_type] -- whichever single line the instance happened to be constructed
with. But LEGNET_AVAILABLE_CELLTYPES already listed three, all three are reachable via
LegNetOracle(cell_type=...), and all three have a row in legnet_pertrack.npz. So two
of three were undiscoverable: an agent asking "what cell types does LegNet cover?"
through the MCP layer was told HepG2 and had no way to learn the others existed. The
authoritative constant existed; the method just was not using it.

The hidden lines are not redundant. Measured at rs12740374:

    LentiMPRA:HepG2   +0.3466     (liver -- the trait-relevant line)
    LentiMPRA:K562    +0.0141
    LentiMPRA:WTC11   -0.0482

That contrast IS liver-specificity evidence for the SORT1 vignette, and hiding two
thirds of the panel hid the comparison. The blog currently cites only the HepG2 number.

Also gitignores the new data directories. Since bulk data now defaults to the
installation directory, backgrounds/ and huggingface/ appear inside the repo on a
default install, along with the chorus_data_dir.txt marker.

REPORTED, NOT ASSERTED: ChromBPNet's list_cell_types() returns 4 while its background
carries 172 cell types. The other 168 are CHIP (TF-binding) lines rather than
accessibility models, so the 4 may be deliberate scoping -- its docstring ("Return
ChromBPNet's cell types") does not say which. Asserting a design decision I have not
confirmed is wrong would make the test a guess, so the test is scoped to LegNet and
the observation goes to the maintainer.

One correction to my own earlier report: I said LegNet "3 tracks (HepG2, K562,
WTC11)" from the background, then separately that list_cell_types() returned
['HepG2']. Both were true; the mismatch between them was the bug, and I did not
connect them until testing whether the other two were reachable at all.

And a self-inflicted mess worth naming: 41 MB landed in a directory literally named
HepG2/ in the repo root during this work, because I called
load_pretrained_model(cell) where the signature is load_pretrained_model(weights=None)
-- so "HepG2" was taken as a download root. Removed. The LegNet vignette number was
re-checked with the correct call and is unchanged at +0.3466, verified rather than
assumed.

1,124 fast tests pass.

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

A hardcoded ["IMR-90", "GM12878", "HepG2", "K562"] sat beside
CHROMBPNET_MODELS_DICT -- the registry the loader actually reads -- which has FIVE
under DNASE. H1 was missing, even though DNASE:H1 ships a background row, so it could
be loaded and scored but never discovered. A hardcoded list next to a registry is a
second source of truth, and this is what that costs.

Now derived from the registry, with an optional assay argument. CHIP is excluded from
the default deliberately: there are 172 CHIP cell types against 5 accessibility lines,
and returning all of them would bury the answer to "which cell types can I profile
accessibility in?". list_cell_types(assay="CHIP") returns them; an unknown assay
raises InvalidAssayError rather than returning an empty list.

I was too cautious about this one turn ago. I found chrombpnet returning 4 of 172 and
called it "possibly deliberate scoping, reported not asserted" -- correct about CHIP,
but I had not checked the accessibility side, where H1 was a plain omission. Splitting
the two by assay makes both answers right.

Also fixes my own test helper, which parsed chrombpnet ids with rsplit(":", 1)[-1].
The format is ATAC:CELL and CHIP:CELL:TF, so the cell is at index 1, NOT last -- the
helper was collecting TF names (ARNT2, ATF2, BACH1) as if they were cell types, which
made its assertion nonsense.

Verified on the way: BPNet/CHIP coverage has NO gap. The registry's 1,259 entries are
only 744 distinct (TF, cell_line) pairs -- the rest are version duplicates -- and all
744 have a background row.

Also verified there is no species mismatch. The 33 ENCODE mouse developmental
ChromBPNet models were removed on 2026-08-01 because the background builder opens
hg38.fa, so their CDFs had been built by pushing human sequence through mouse models.
The BPNet registry is now 1,259/1,259 TAX_ID 9606, and 0 of the 753 shipped rows are
mouse. The root-cause note is worth keeping: "Nothing in the registry recorded an
organism, so there was no field to filter or assert on. That is what let the mismatch
ship." That is #124's thesis.

1,127 fast tests pass.

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

All three rebuilt backgrounds shipped WITHOUT a per-row layer, despite the whole
canonical_layer machinery being built for it. The chain: each shard wrote the field
correctly, union_shards read it and never wrote it to the union, apply_effect_rebuild
had nothing to copy, and the guard test SKIPPED because the field was absent -- so the
suite stayed green.

That is the "guard that protects nothing" failure mode I had already warned about
twice in this cycle, in this cycle's own code. A test that skips on absence verifies
nothing about presence. There is now a separate test that FAILS when a rebuilt
background lacks the field, and it failed on all three before the backfill.

Backfilled from the surviving shards rather than by re-running 4 GPU-hours: the shard
track_ids are compared against the final file's before writing, and every value is
checked against LAYER_CONFIGS. Row counts by layer:

  enformer     5,313  acc 684, tf 2,101, histone 1,890, cage 638
  borzoi       7,611  cage 1,276, acc 906, tf 1,996, histone 1,890, rna 1,543
  alphagenome  5,168  acc 472, cage 558, rna 667, histone 1,116, tf 1,617, splice 738

This was found because the per-layer analysis needed for a ChIP-null question could
not be run at all -- the field the analysis keys on was missing from the artefacts.

1,130 fast tests pass.

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

Two independent fixes, both read-side, neither needing a rebuild.

1. effect_exceedance. effect_percentile is min(rank/denominator, 1.0), so it
   reaches 1.0 the moment an effect reaches the largest of ~10k sampled
   background effects and stays there however much further it goes. At
   rs12740374, CHIP:HepG2:CEBPA:+ scores +1.865 against a null max of 1.682 and
   pins -- indistinguishable from an effect of 17.0. The bound was already in the
   shipped artefacts (first/last entry of the effect_cdfs row), so exposing the
   ratio costs no GPU: 1.011x / 1.109x / 2.973x / 10.109x now separate four
   effects that all read 1.000000.

   Signed rows make both ends live (Sei and LegNet 100%, AlphaGenome 12.9%,
   Borzoi 20.3%), so the ratio is taken against whichever end was crossed --
   defining it against the maximum alone would return None for exactly those
   strongly repressive effects where direction is the finding.

   NOT an extrapolated percentile. A GPD fit to Enformer's TF nulls gives shape
   c = -0.190, a bounded tail whose endpoint (4.245) sits above the empirical max
   (2.956) but below the observed effect (4.372) -- the fitted model calls the
   measurement impossible. Forcing an exponential tail extrapolates monotonically
   but prints a modelling assumption to eight decimals. A ratio to the sample max
   is a fact about the sample.

   Threaded through all 8 emission sites (markdown table, score guide, HTML,
   dataframe/TSV, discovery, causal, batch, multi-oracle) rather than one.

2. HF_HOME no longer relocated -- HF_HUB_CACHE is. HF_HOME is the parent of both
   the blob store (hub/, the 12 GB the redirect exists for) and the credential
   from huggingface-cli login (token). Setting it moved both, so on a machine that
   had already logged in the token stayed at ~/.cache/huggingface/token where
   huggingface_hub no longer looked, and every gated model -- AlphaGenome -- failed
   with "requires HuggingFace authentication ... run 'huggingface-cli login'",
   advice the user had already followed. This was self-inflicted, shipped in the
   data-dir change, and found by a measurement that needed AlphaGenome rather than
   by a test. Its regression test was confirmed to fail on the old code.

Two tests corrected their own wrong premises while being written: unsigned rows
begin at the smallest sampled magnitude (8.4e-6) not a structural zero, and the
two support ends are asymmetric -- searchsorted(side="right") counts samples <=
value, so exactly the maximum pins while exactly the minimum does not.

Recorded, not yet fixed: Sei's 40 tracks all come back layer='other' with
raw_score=None from the shared scorer, and Sei appears in no committed example
output, so its variant path is unexercised.

29 new/updated tests pass; full fast suite next.
…d reach

sei.py set `assay_type = info.name` for its sequence classes (e.g.
"Polycomb-repressed"). classify_track_layer dispatches on the literal string
"sequence-class" (scorers.py:297) and has no branch for a class name, so it
returned "other"; LAYER_CONFIGS.get("other") is None; score_track_effect returned
None; and every one of Sei's 40 tracks scored raw_score=None. Nothing raised.

So Sei had a built, verified, non-degenerate null -- 40 rows, zero degeneracy, a
2.05x p99 tail ratio after the re-anchoring -- that was unreachable from the query
path. It appeared in no committed example output, and that absence read as "we did
not include Sei in the examples" rather than "Sei cannot be scored at all".

Fixed by assigning the literal "sequence-class" and keeping the class name as the
track description rather than dropping it. Measured at rs12740374, all 40 tracks now
score and resolve against the shipped null (top: CTCF-cohesin +5.0116 at pctile
0.9966, Enhancer/Multi-tissue +5.0045 at 0.9958).

The second half of the diagnosis -- that Sei's ids would not resolve through
_match_track_id -- turned out to be WRONG when tested: the shipped ids resolve
exactly. Not "fixed", because nothing was broken.

Guards added, and each was checked to fail on the pre-fix code:
  - every shipped background row resolves, all 8 oracles (also pins the LegNet
    bare-cell-name vs LentiMPRA:CELL bridge)
  - resolution is one-to-one, so two ids can never silently share one null
  - the 40 class names must each classify to "other", so the regression cannot hide
  - sei.py assigns the literal label -- asserted by parsing the AST, not by matching
    source text, since an earlier guard in this repo passed only because the
    replacement it was verifying had introduced the very line it asserted
  - an end-to-end GPU test that runs via `conda run -n chorus-sei`, because
    chorus-sei has no pytest and an in-process test could never have executed

1,165 fast tests pass; 19/19 in the new module including the GPU one.
…ted up to 8.3x

The percentile clamp has been patched three times (re-anchoring, union-at-2N, the
read-side exceedance ratio). None of them was the cause. The cause is that the
sampler was discarding the tail it exists to measure.

merge_effect_shards.py called ReservoirSampler.from_flat_samples(*parts) with no
capacity, inheriting DEFAULT_CAPACITY = 50,000. Every AlphaGenome gene_expression
track offers 148,367 effect values, so the union was subsampled 2.97x -- and a
uniform m-of-N subsample retains the population maximum with probability exactly
m/N. 50,000/148,367 = 0.3370; 33.9% of the 667 RNA rows were measured to have kept
theirs. The arithmetic matches the data to three digits.

Measured, by re-unioning the raw shards (which were still on disk):

  RNA ceiling understated by a median 1.332x, p90 3.18x, worst 8.345x, on 66.1% of
  rows -- while p99 was right to 0.02% and p50/p90 did not move at all.

That asymmetry is why this survived every calibration gate for months: reservoir
sampling is unbiased for the body, and every gate measures the body. The eQTL fixture
sits near grid index 8500-9000, so RNA p50 read 0.778 before and 0.7770 after.

Fixed by exact retention (capacity=None) in the merge. NO GPU: the 8 shards hold
every offered value, so the union recovers all 148,367. 23 seconds.

Verified before applying, against the raw samples rather than against itself:
  - all 4,501 non-RNA rows come back BIT-IDENTICAL (they were never thinned)
  - 0 of 5,168 tracks thinned; retained == offered everywhere
  - RNA ceilings rose, none fell; shipped maxima now equal the true population maxima
  - the 12 release gates hold: CAGE 0.6250, RNA 0.7770, 0% saturated

One gate I had specified wrongly: "p99 within +-10% for every track". 3 of 667 rows
exceed it, split 2 above / 1 below -- unbiased noise in the OLD 50,000-sample
estimate, not movement in the new one, which is now the exact population value. The
body invariance that matters is verified at p50 and p90, where 0.0% of rows fall
outside 10%. At p99.9 the ratio shifts systematically UP (median 1.022, 408 rows up
vs 258 down): that is the recovered tail mass, i.e. the point.

Neither existing guard could have caught this, and both are documented in place
because the instinct is to assume one did:
  - no SHARD exceeded its capacity (~18.5k offered against 20,000), so the builders'
    capacity argument was never the issue; the merge is a second, independent site;
  - cdf_grid_violations is fed the OFFERED count while checking geometry set by the
    RETAINED count, then skips every row with n >= n_points -- offered is always
    >= 10,000, so it skips every thinned row by construction.

capacity is now keyword-only with NO default, so this specific defect is a TypeError.
Borzoi and Enformer are deliberately NOT rebuilt: their unions are under capacity, so
the merge never takes the rng.choice path. Verified bit-exact and pinned as a test,
so the omission is a recorded negative result rather than an oversight.

1,171 fast tests pass; 3 integration gates pass against the real shards.
…ns onto the margin

Two defects, one found while guarding against the other.

1. The stratum dispatch was a landmine. sample_gene_anchored_positions ended its
   elif chain in a bare `else` that drew a uniformly random position, and that `else`
   was simultaneously the handler for "random", the fallback for an empty source
   population, AND the catch-all for any unrecognised name. Adding "dhs": 1/3 to the
   strata dict without a branch would have emitted 6,000 uniformly random positions,
   tagged them "dhs", tallied them as DHS in the build log and stamped them as DHS in
   provenance -- an invisibly wrong reference class in every artefact, with numbers
   that look entirely reasonable because random positions make a plausible null.
   sample_promoter_anchored_positions had the identical hole.

   Both now raise on an unknown stratum and on an empty pool. The decisive guard is
   not the name check though: it is the round-trip, which takes the positions actually
   returned and checks each against the annotation its label names (>=99% of 'dhs'
   within 150 bp of a Meuleman summit, 'ccre' inside a cCRE, 'tss_near' within 1 kb).

2. The round-trip immediately failed on tss_near at 88.8%, which turned out to be a
   REAL and pre-existing defect, not a labelling one. `usable` selects whole
   CHROMOSOMES long enough for the margin, but tss/junctions/bodies were filtered only
   by `chrom in usable` -- never by the margin interval. 2,515 of 20,083 PC TSS
   (12.5%) lie within 5 Mb of a contig end, passed that test, and were then _clamp()ed
   onto the boundary coordinate, up to 5 Mb from the TSS they were labelled as being
   within 1 kb of. The cCRE pool had always been filtered correctly; these three had
   not.

   Measured over 6,000 positions, BEFORE: 12.1% of tss_near, 12.2% of junction, 13.0%
   of tss_far and 14.6% of gene_body landed exactly on a boundary; only 5,265 of 6,000
   positions were distinct; chr16:5,000,000 alone appeared 64 times.
   AFTER: 0.0-0.1% on a boundary, 6,000 of 6,000 distinct, worst duplicate 1.

   The mislabelling matters, but the duplication matters more: identical positions
   give identical effect values, so they pad the sample count without adding
   information and manufacture tied runs in the CDF -- the same degeneracy
   _rank_with_tie_breaking exists to compensate for, injected by the sampler rather
   than present in the biology. This affected the currently shipped backgrounds.

Also: per-stratum pool cursors. `ccre_pool[len(out) % len(pool)]` indexed by the TOTAL
positions emitted, so inserting any stratum silently re-drew the cCRE half. The
promoter sampler already used `pls[i % len(pls)]`; the two disagreed -- #144's shape.

DHS is additive at scaled N (12,000 -> 18,000), every pre-existing stratum keeping its
exact absolute count: tss_near 1,200, tss_far 1,200, junction 1,980, gene_body 720,
random 900, ccre 6,000, dhs 6,000. Re-dividing instead would dilute; measured when the
cCRE half was first tried at fixed N, TF saturation went 25% -> 92%.

Justified by tail width and max(union) = max(max_a, max_b), NOT by calibration, and it
does NOT fix motif-creation saturation -- the DHS-anchored null ChromBPNet has always
used still pins on rs12740374 CEBPA at 1.11x. Recorded as such.

LegNet gets DHS too, which contradicts a committed comment at annotations.py:1288
("not DHS summits ... right family, wrong member"). That objection is correct for a
RE-WEIGHTING and wrong for an additive union at scaled N, since the promoter component
cannot be diluted. The comment is amended in place rather than deleted, because the
measurement behind it stands: DHS summits are 3.6% TSS-proximal with a median distance
of 68.7 kb, i.e. mostly enhancer-distal.

One test rewritten rather than retuned: test_far_more_tss_proximal_than_uniform_random
asserted an aggregate MEDIAN, which additive union does not preserve -- the TSS counts
were untouched but the mixture median rose from ~21 kb to 32.7 kb because DHS is
distal. Now asserts per stratum (every tss_near position within 1 kb: measured 100%,
median 413 bp) plus aggregate enrichment (13.6% vs 1.4% uniform = 10x). Same lesson
already recorded for the random stratum: assert the count, not the share.

1,181 fast tests pass; 3 annotation round-trips pass.
@lucapinello lucapinello changed the title Store downloaded data in the install dir by default; re-verify both blog vignettes Fix the background null model: the sampler was discarding the tail it measures Aug 6, 2026
The existing guard could not, by construction. cdf_grid_violations is handed the
OFFERED count while the row geometry it validates is set by the RETAINED count, and
its first act is `if n >= n_points: continue`. Offered is always >= 10,000 in a real
build, so it skipped every thinned row -- while its docstring promises it "refuses to
write a CDF matrix that could not have been produced by to_cdf_matrix". That promise
is false whenever retained < offered, which is how 667 AlphaGenome RNA rows shipped
with ceilings drawn from a 33.7% subsample.

thinning_violations is a NEW, INDEPENDENT check rather than an extension of that one:
geometry and retention are different questions, and the early-return that makes the
geometry check correct is exactly what makes it blind here.

A row passes if either retention was exact, or an exact top-K tail fills at least
MIN_EXACT_TAIL_SLOTS (200 of 10,000 -- the top 2%, covering p98 upward, which is
where percentiles saturate and where effect_exceedance divides by the maximum).

Wired into build_and_save AND into merge_effect_shards.py, which is the script that
had the defect. Demonstrated end to end: re-running the merge with --capped, which
reproduces the old behaviour, is now refused --

  refusing to write: alphagenome.effect_cdfs row 1018: offered 148367 values but
  retained only 50000 (2.97x thinned) with no exact tail kept. A uniform subsample
  retains the population maximum with probability 0.337, so this row's ceiling is a
  random draw.

-- naming all 667 thinned tracks and stating the survival probability, because the
probability IS the mechanism.

Two things learned from how the previous guards failed:

  - a builder that omits the new sampling= block gets NO protection, so its absence
    is logger.error naming the writer rather than a quiet default-None. "A guard
    nobody wired up" is how both the padded enformer grid and this defect reached
    users past guards that already existed.
  - the merge now writes effect_retained beside effect_counts, so "was this thinned?"
    is answerable from the artefact alone. Only the offered count was ever stored,
    and offered == retained is the fact that matters.

Deliberately NOT in this commit: numpy-backed storage, vectorised Algorithm R, and
the threshold-gated exact tail. All three change which values a reservoir retains, so
they must land WITH the GPU rebuild rather than before it, or the shipped backgrounds
and the code that reproduces them diverge.

6 new tests; the guard was verified to block a thinned write, to accept the identical
matrix when retention was exact, and to log loudly when unwired. 1,187 fast tests pass.
…ruthful ceiling

Exact retention fixed AlphaGenome's effect null because its 148,367-value union fits
in memory. perbin does not: it offers up to 2,176,256 values per track against a
50,000 capacity -- a 43.5x thinning, on five oracles -- and summary is thinned up to
5.2x on three. Those cannot be fixed by keeping everything.

They do not need to be. The body of a reservoir sample is unbiased; only the ceiling
is a draw, retained with probability m/N. So keep the exact top-K and bottom-K beside
the uniform body, and splice the grid row by POPULATION rank.

Verified, on a 300,000-value heavy-tailed stream thinned to 50,000 with tail_k=20,000:

  row max == population max                    exactly (plain thinned row: 0.40x)
  top 667 grid slots == population order stats  max|diff| 0.00e+00
  bottom 667 slots                              max|diff| 0.00e+00
  interior quantiles (0.25-0.9)                 within 5% of truth
  row non-decreasing                            for every seed tried

Both ends, not just the top: 12.9% of AlphaGenome's rows and 20.3% of Borzoi's are
signed, and for those a strongly repressive effect crosses the LOWER bound, which is
also what effect_exceedance divides by. Tracking only the maximum would leave exactly
those rows with an estimated floor.

Spliced by population rank rather than by concatenating tail onto body and re-gridding.
Concatenation would double-count every value present in both and would place the top K
of N at the top K/(len(body)+K) of the grid instead of the top K/N -- i.e. at the wrong
quantiles, which is the same class of error as comparing a statistic computed one way
against a null computed another.

Opt-in, default off, and an unthinned build is BIT-IDENTICAL with or without a tail
(pinned by test). That matters twice: today's builders are unaffected, and every
existing grid-integrity test still describes real behaviour. Where offered == retained
the caller takes the original code path untouched.

Buffered-then-trimmed rather than heap-maintained: a heapq touched once per value is
O(N log K) Python-level operations and the perbin layer offers ~7.1e9 values across the
fleet; np.partition on a bounded buffer is O(K) and fires only when the buffer fills.

Still deferred to land WITH the GPU rebuild, because all three change which values a
reservoir retains: numpy-backed storage, vectorised Algorithm R, and the baseline-pass
sharding needed to rebuild summary/perbin at all.

9 new tests. 1,195 fast tests pass.
…he real lever

The DHS idea was sound a priori -- Meuleman summits concentrate TF footprints, so a
single-base change there should perturb TF and histone tracks more than one in a gene
body, lengthening the tail of the layers that pin most. It does not survive the data,
and it was cheaper to find that out on a 3-minute Sei build than on 63 GPU-hours.

THREE Sei builds, differing only as labelled (new --no-dhs ablation flag), medians
over its 40 tracks:

    A = 12,000 positions, no DHS   (the composition shipped before this)
    B = 18,000 positions, +DHS     (the proposal: purely additive, N grown)
    C = 18,000 positions, no DHS   (same budget, more cCRE + gene instead)

    stat     B/A      C/A      B/C
    p50     0.971    1.035    0.942
    p90     0.937    1.030    0.908
    p99     0.954    1.042    0.913
    p99.9   0.936    0.992    0.924
    max     1.000    1.261    0.821

B/A max is exactly 1.000. Across all 40 tracks not one DHS position produced a larger
effect than the best cCRE- or gene-anchored position already in the set: DHS added
nothing whatsoever to the ceiling, while lowering every quantile. C -- the same 18,000
positions drawn from the populations ALREADY in use -- widened every statistic and
raised the ceiling 26%.

Why the argument failed: the SCREEN cCRE catalogue already carries the
accessibility-and-TF categories DHS was meant to add (CA-TF, CA-CTCF, CA-H3K4me3, TF),
so DHS summits were largely redundant with positions already sampled, while also being
3.6% TSS-proximal at a median 68.7 kb, i.e. skewed distal. Redundant draws dilute a
mixture without extending it.

LegNet was worse, and it also refuted my own justification. I had argued DHS could not
hurt because the union is additive at scaled N, so max(union) = max(max_a, max_b). That
protects the MAXIMUM and nothing else -- a percentile is a quantile of the mixture, so
adding positions with systematically smaller effects lowers the whole upper body and
the same variant then scores HIGHER than it should. Ablation, n=18,000 both:

    track   p50    p90    p99    max
    K562   0.81x  0.85x  0.94x  1.14x
    HepG2  0.91x  0.90x  0.90x  1.01x
    WTC11  0.88x  0.89x  0.95x  1.28x

Every quantile diluted on every track. For a 200 bp promoter MPRA model that trades a
slightly higher ceiling for making the other 99% of the scale wrong. The comment at
annotations.py:1288 ("right family, wrong member") was correct and my reconciliation
was not.

So: DHS removed from both default mixtures, N held at 18,000 with the original
proportions -- which is the change that measured well. The "dhs" sampler branch stays,
because ChromBPNet's and Cherimoya's nulls have always been DHS-anchored and because
deleting it would make this measurement unrepeatable. A test pins that it still works.

Also in here, found while preparing the rebuild:

  - ALL EIGHT builders and five background scripts hardcoded
    os.path.expanduser("~/.chorus/backgrounds"), so a chorus installed with
    CHORUS_DATA_DIR=/data/... still wrote every background it built into the home
    directory the data dir exists to avoid. The guard for this defect scanned only
    chorus/, never scripts/ -- the same half-fix shape it was written for. Guard
    extended; 13 sites fixed.
  - per-layer sampler policy wired into all 8 builders: effect and summary retained
    EXACTLY (measured affordable -- worst layer 52 GB against 1,806 GB available),
    perbin capped with a DERIVED exact tail. A single fixed tail_k=20,000 silently
    gives ChromBPNet only 91 exact grid slots and Cherimoya 183, against a 200 floor;
    derive_tail_k gives 43,526 and 21,763 and exactly 200 each.
  - sampler_preflight() refuses a thinning configuration BEFORE the first forward pass,
    since every input is analytically known.
  - every interim now records *_retained beside *_counts, so "was the tail thinned?" is
    answerable from the artefact.

Measured and NOT done: vectorising Algorithm R. The plan estimated it would save ~11
GPU-hours, derived by dividing a whole baseline pass's wall-clock by its sample count.
Benchmarked directly, the reservoir runs at 2.4M values/s, so it accounts for ~19% of
a baseline pass, and vectorising would save under an hour across the fleet -- while
changing every retained sample and so invalidating reproducibility of any background
not rebuilt. Not worth it.

1,196 fast tests pass.
…running it small

Both surfaced from a 2-arm enformer ablation, which is exactly why it ran at n=6,000
before anything ran at n=18,000 x 8 oracles.

1. Builders OVERWROTE CUDA_VISIBLE_DEVICES with their --gpu default.

   `os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)`, unconditionally. So
   `CUDA_VISIBLE_DEVICES=1 python build_backgrounds_enformer.py` ran on GPU 0 anyway.
   Both ablation arms landed on the same device (identical pci bus id 0000:04:00.0),
   the first grabbed 78,230 MB, and the second could not allocate a cuBLAS handle:

     failed to create cublas handle: the resource allocation failed
     Attempting to perform BLAS operation using StreamExecutor without BLAS support

   Every one of 5,968 positions was dropped. A 63-GPU-hour rebuild sharded across
   devices by env var would have serialised onto GPU 0 the same silent way. Five
   builders had the line; all now let an explicit env var win.

2. A build where EVERY position failed still wrote a well-formed background.

   The per-position try/except is right -- one bad locus must not lose a run -- but
   there was no floor on the total. The failed arm produced an interim with 5,313
   tracks, every row all-zero, every count 0. That file merges cleanly, and
   `_has_samples` then suppresses those tracks at query time, so the symptom is an
   oracle that silently stops ranking anything: the same shape as Sei's 40 dark rows,
   reached by a different route. The build log said so loudly (drop_reasons tallied
   5,968 InternalErrors) and nothing downstream cared.

   yield_violations refuses a background where under half the tracks have any samples,
   wired into build_and_save ahead of the geometry and thinning checks. A genuinely
   partial build (69% of tracks) still passes.

Neither is about the null's statistics; both are about a pipeline that reports success
after total failure. That is the same class as the guard fed the wrong counts and the
guard nobody wired up -- three instances now, in one subsystem.

1,199 fast tests pass.
…er it targeted

The Sei ablation left a real gap -- Sei outputs chromatin-state classes, not TF ChIP
tracks, and tf_binding is the layer that actually saturates, so it could not settle the
question the DHS proposal was actually about.

Enformer, n=6,000 per arm, medians of (+DHS / no-DHS) per layer:

    layer                       n      p90     p99   p99.9     max
    chromatin_accessibility   684    0.942   0.980   0.972   0.947
    histone_marks            1890    0.953   0.917   0.916   0.931
    tf_binding               2101    0.904   0.858   0.888   0.953
    tss_activity              638    0.747   0.822   0.760   0.810

tf_binding is diluted the most of any layer. Only 744 of its 2,101 tracks gained a
higher ceiling; 1,217 lost one, median max 1.0920 -> 1.0419.

So the DHS hypothesis fails hardest exactly where it was aimed, and the earlier removal
stands on evidence from the relevant layer rather than by extrapolation from Sei.
…e path end to end

Everything before this validated a build's *interim*. The merge -- interim to shipped
pertrack.npz -- had never been run with the new plumbing, which is exactly where a
schema break hides. Ran it on Sei: 40 tracks, 2.9 MB, zero all-zero rows, all 40 rows
monotone, loads through PerTrackNormalizer, resolves, and both effect_percentile and
effect_exceedance work on it.

The outcome is what the region-set work predicted. Body unchanged, ceiling up 30%:

    stat    shipped   rebuilt   ratio
    p50      0.0355    0.0360   1.014
    p90      0.2533    0.2546   1.005
    p99      0.9962    0.9807   0.984
    p99.9    2.3943    2.3963   1.001
    max      4.8979    6.3687   1.300

1.300 against the 1.261 the Sei ablation predicted for the same change, from an
independent build. n went 11,934 -> 17,909 positions.

One shared helper, sampling_block(), instead of the same seven lines in seven builders
-- the #125 precedent. Mechanically wiring it introduced three defects that all PARSED
cleanly, which is the point worth recording:

  - cherimoya and epinformerseq referenced a variable (`interim`) that does not exist
    in those scopes: two latent NameErrors that would have fired only at merge time,
    after the GPU hours were spent;
  - cherimoya's edit landed on append_tracks(), which takes no `sampling=` kwarg at
    all -- a TypeError, not a silent wrong answer, but equally fatal at the last step.

Fixed by hand and now checked by AST: every name passed to sampling_block must be
assigned in its enclosing function. 17 call arguments across 8 builders verified.

Retention is now persisted INTO the shipped file (`{layer}_retained`, plus
`{layer}_tail_k` for hybrid layers), not only into the interim. Only the offered count
was ever stored, so "was this track's tail thinned?" was unanswerable from a published
background -- which is how AlphaGenome's 2.97x thinning survived a republish. One int64
per track per layer buys the check.

1,201 fast tests pass.
…g builder envs

The fleet rebuild's first wave surfaced both as failures that reported success.

Six builders' merge steps did `logger.error("Missing interim files"); return`, so the
process exited 0. The driver recorded `rc=0` for two steps that wrote nothing at all --
the same report-success-after-failure shape as the all-zero interim and the guard fed
the wrong counts. Now `raise SystemExit(1)`, 7 sites. No data was written either time,
so nothing to undo; the harm was purely that a driver keying off exit codes believed it.

And the two builds failed for a reason CLAUDE.md could not have told me: cherimoya and
epinformerseq are absent from its env list, and both `import torch` inside the builder
while the base `chorus` env has none. Both run under chorus-borzoi (torch 2.12.1 +
CUDA). Recorded, along with two other gotchas that cost time: epinformerseq's --part
takes `all` where every other builder takes `both`, and only five builders accept
--gpu while legnet and epinformerseq need CUDA_VISIBLE_DEVICES.

Fleet status: AlphaGenome baseline (the 14 h critical path) and Enformer variants have
been running since 13:30 on GPUs 2-3; cherimoya and epinformerseq relaunched correctly
on GPUs 0-1. Staged into /data/chorus_data/rebuild_2026-08-06 so live backgrounds are
untouched until verified.
…part gotchas

cherimoya and epinformerseq are absent from the env list and both import torch inside
the builder, while the base chorus env has none. That cost two failed builds in the
fleet rebuild's first wave. Both run under chorus-borzoi.

Also recorded: only five builders accept --gpu (they used to overwrite
CUDA_VISIBLE_DEVICES with its default of 0, which put two processes on one GPU and made
the second fail every forward pass), and epinformerseq's --part takes 'all' where every
other builder takes 'both'.
… being wrong

Nothing is swapped into place until each rebuilt background is compared against its
backup. The rebuild changes three things at once -- region set 12,000 -> 18,000, the
contig-margin fix, and retention -- so "the numbers moved" is expected and useless as a
check. What must hold is the SHAPE of the move: body nearly unchanged (reservoir
sampling was always unbiased there and the region set only grew), ceiling not falling,
no thinning on an exact layer, and the file still loading through the real query path.
Exits non-zero, so a driver cannot swap on a bad build.

First run refused legnet: ceiling 0.88x. The gate was wrong, not the build.

  - the ceiling is ONE extreme order statistic per track, which is the instability this
    entire rebuild exists to reduce, so gating hard on a median over 3 tracks is
    self-contradictory. LegNet's shipped K562 maximum (1.2696) recurs EXACTLY in an
    independent 18,000-position build -- an attainable extreme a build either samples or
    misses, not a property that moved.
  - "adding positions can only raise the ceiling" is true, but this rebuild also REMOVED
    positions: 12% of anchored positions used to be clamped onto contig-margin
    coordinates, which for a 200 bp promoter model are out-of-distribution windows where
    erratic large effects inflate the null's upper body. Dropping them makes the null
    more correct and narrower at the same time.

So the ceiling gate is now track-count aware (>= 25 tracks to fail, advisory below) and
p99 -- stable, and the statistic that actually governs saturation -- is gated instead.

Verified so far, all passing, and the shape is what the region-set work predicted:

  oracle          p50     p90     p99     max     retention
  sei           1.021   1.014   1.031   1.308   exact, 0 thinned
  epinformerseq 1.034   1.020   1.033   0.932   exact, 0 thinned
  legnet        0.993   0.996   0.930   0.881   exact, 0 thinned (3 tracks; advisory)

Also: epinformerseq's interim writes were missed by the mechanical retention pass
because they use `reservoir.counts.copy()` rather than `.get_counts()`, so its shipped
file carried no *_retained and thinning was unverifiable there. Fixed and rebuilt; it
now passes --strict-retention.

And one more import defect caught before it could be expensive: `sampling_block` was
never imported into epinformerseq's builder, so its merge died with NameError AFTER the
build succeeded. My earlier AST check verified the argument names were in scope but not
the function name. Checked all 8 -- only epinformerseq was affected, and AlphaGenome's
was clean, which is the one that would have failed at the end of a 20-hour job.

1,201 fast tests pass.
…list I got wrong

Cherimoya ran for 75 MINUTES loading nothing. Its per-track loop is
`try: load(); except: logger.warning("Failed to load %s"); continue`, so in an env
without the `cherimoya` package it logged that warning 1,518 times and carried on, then
died at the provenance step on the same missing import. Had that step not happened to
import it, the run would have written an all-zero background.

The tolerance is right -- one missing checkpoint must not lose a run -- but with no
floor it also tolerates loading NOTHING. abort_if_nothing_loads raises once 25 tracks
have been attempted with zero successes: a configuration failure, not bad luck. Wired
into both per-track loaders (cherimoya, chrombpnet, the latter with a differently-named
variable that my first mechanical pass missed). 500 attempts with 1 success still
passes, so the per-track tolerance is preserved.

That is the fifth instance in this subsystem of work reporting progress while failing:
a guard fed the wrong counts, a guard nobody wired up, an all-zero build that wrote a
valid file, a merge that exits 0 having done nothing, and now a loader that warns 1,518
times instead of stopping.

And I had the env story wrong in CLAUDE.md. I wrote that cherimoya and epinformerseq
have "no env of their own" -- `conda env list` shows chorus-cherimoya AND
chorus-epinformerseq, and the `cherimoya` package exists ONLY in the former. Corrected,
with the instruction to check `conda env list` rather than trust that section, since it
has now been wrong twice.

Both oracles rebuilt in their correct envs. epinformerseq's numbers are unchanged from
the substitute env (p50 1.032 vs 1.034, p99 1.038 vs 1.033), so nothing was corrupted;
cherimoya now reports 0 load failures against 1,518.

1,201 fast tests pass.
…ntil exit

During the fleet rebuild this meant a 14-hour AlphaGenome baseline had a 0-byte log
throughout. Failure detection still works via exit code, but progress does not. Use
'conda run --no-capture-output ... python -u' when a build needs watching.
The MCP tool score_ism delegates to saturation_mutagenesis, which produces the
per-position importance profile blog vignette 2 reads as "which bases the oracle
actually looks at". It had zero tests.

Tested against a controllable fake oracle rather than a real one: the interesting
behaviour is geometry (which positions, which bases, in what order), bookkeeping (the
reference base stays zero, importance is the mean disruption with the sign flipped so a
functional base scores positive) and failure handling. None needs a GPU, and on a real
oracle all of it hides behind plausible numbers.

Two limitations pinned as current behaviour rather than quietly changed:

  - A site whose three predictions all raised gets importance exactly 0.0 -- identical
    to a site the oracle genuinely does not care about. Nothing in the returned dict
    distinguishes them, so a logo drawn from a partially-failed sweep shows confident
    zeros where it should show gaps. The warning goes to the log, which a caller
    rendering a figure never sees. The test asserts the indistinguishability directly,
    and says to tighten it if a failed-site field is ever added.

  - `window` is documented "odd recommended" and an even value is off by one:
    half = window // 2 then [pos-half, pos+half] always spans 2*half+1 bases, so
    window=24 returns 25 positions while the returned `window` field still says 24. A
    caller sizing an array from `window` and filling from `scores` is short by one.
    Not fixed here -- changing the geometry would move every committed ISM artefact --
    but now it fails loudly if someone does fix it.

10 tests. That leaves 6 of the previously-untested MCP tools still uncovered
(discover_variant_cell_types, get_genes_in_region, list_genomes, oracle_status,
score_prediction_region, score_variant_effect_at_region).
…d-awareness check

These are the tools an agent calls FIRST when orienting in a locus, so a wrong answer
misdirects everything downstream -- and unlike a scoring bug it produces no implausible
numbers to notice. All three were untested and none needs a GPU.

  list_genomes          asserts a path is reported IFF the genome is downloaded, and
                        that the path exists. Reporting a path for an absent genome
                        sends every caller to a missing file.
  get_genes_in_region   asserts SORT1 is found at its own locus, that every returned
                        gene actually OVERLAPS the requested interval, that the heavy
                        GTF `attributes` blob is dropped, and that an empty region and
                        a nonexistent contig both decline rather than inventing an
                        answer.
  get_gene_tss          asserts STRAND-AWARENESS against the transcript coordinates:
                        SORT1 is on the minus strand, so its TSS must equal
                        transcript_end, not transcript_start.

That last one is the point of the module. Getting strand backwards anchors on 3' ends
where there is no promoter signal -- the same error the region samplers guard against --
and it is invisible in aggregate, because a wrong TSS is still a plausible chr1
coordinate inside the gene. My first draft asserted `"SORT1" in blob or out`, which is
true for almost any response; it now recomputes the expected TSS from each transcript's
own start/end and strand.

Remaining untested: oracle_status, score_prediction_region,
score_variant_effect_at_region -- all three call state.get_oracle() and need a loaded
model, so they belong in an integration module.

1,220 fast tests pass.
…what was refused

CLAUDE.md requires a dated report when an audit uncovers findings. These were living
only in commit messages, which is the wrong place for the reasoning behind a reversed
user decision and for measurements that cost GPU hours to obtain.

Records: the reservoir thinning and why m/N is the mechanism; why no existing guard
could see it; the DHS proposal measured and reversed on three oracles including the
tf_binding layer it targeted; seven further defects, four of which are the same
report-success-after-failure pattern; and three things deliberately NOT done, each with
the measurement that decided it (vectorising Algorithm R saves <1h not 11h; a GPD
overshoots the far tail 3.8x; motif saturation is irreducible with an empirical ceiling).

Also records two places my own reasoning was wrong -- the additive-union argument
protecting only the maximum, and a verifier gate that refused a good build.
…cts still pin?

Distributional ratios say a null got wider. They do not say whether that translates into
variants that can be RANKED, which is the only reason any of this was done. An effect at
or beyond the ceiling reads exactly 1.0 and carries no ordering information beyond the
exceedance ratio, so the fraction of real effects in that state is the target.

Measured on the raw scores already committed in examples/**/example_output.json -- real
predictions at real variants, no GPU needed to re-score. Enformer, whose 18,000-position
effect null finished at 16:29:

  pinned on 168 committed effects:  16 (9.5%)  ->  6 (3.6%)

A 62% reduction, and 10 rows newly resolved with the ceiling movement that did it, e.g.
raw 1.969 against a ceiling that went 1.867 -> 2.969.

Per-layer, medians of per-track ratios (new/old):

  layer                       n     p50     p90     p99   p99.9     max
  chromatin_accessibility   684   1.037   1.042   1.068   0.997   1.131
  histone_marks            1890   1.026   1.045   1.059   0.998   1.041
  tf_binding               2101   1.019   1.039   1.077   1.031   1.174
  tss_activity              638   1.110   1.056   1.051   1.084   1.080

1,621 of 2,101 tf_binding tracks have a higher ceiling against 398 lower. tf_binding is
the layer that saturates, so it is the one this had to move.

Quoted as the MEDIAN OF PER-TRACK RATIOS (1.174), not the ratio of medians (1.637/1.198
= 1.37). Those are different statistics and I conflated them earlier in this cycle on
EPInformer, reporting a regression that did not exist. The per-track ratio is the honest
summary; the ratio of medians flatters the result by a third here.

The verifier now fails an oracle where MORE real effects pin than before, since a wider
null cannot increase pinning -- that would mean the ceiling moved the wrong way for
exactly the tracks that matter. It stays silent for oracles with no committed examples
rather than reporting a clean 0%: one earlier version of this check used `oracle_name`
where the artefacts use `oracle`, matched zero rows, and printed a confident 0% -> 0%.
…composition I changed

Two things, both caught by the verifier on cherimoya -- the first oracle with a perbin
layer, so also the first real test of the exact-tail machinery. That part worked exactly
as designed: 1,518/1,518 tracks thinned, derived tail k=21,763, exactly 200 exact grid
slots.

1. The verifier refused on "track_ids changed order or content". The SET is identical --
   1,518 tracks, 0 lost, 0 gained -- but the rebuild emits them sorted where the shipped
   file was unsorted, moving 1,511 of 1,518 positions.

   Harmless for queries: PerTrackNormalizer._resolve_row looks a track up by id, not by
   row index, so a reordered file answers identically. NOT harmless for splicing rows
   BETWEEN files, which is why apply_effect_rebuild.py rightly refuses on a reorder --
   carrying a per-row array across would misalign every row. So the verifier now
   compares BY ID (a positional comparison would have read a reorder as a catastrophic
   distributional shift) and prints the reorder as a caveat rather than a failure.

2. With the comparison fixed, the effect ratios exposed MY error: p50 0.963, p90 0.962.
   I passed --n-variants 18000 to cherimoya while leaving --n-dhs-variants at its
   default 10,000, shifting its composition from 50:50 random:DHS to 64:36 and diluting
   the null with 8,000 extra uniformly random positions.

   Cherimoya's region set was never supposed to change. It is random ∪ DHS-summit by
   design, assay-appropriate, and its effect layer was never thinned -- its only real
   gain from this rebuild is the perbin tail. The plan said so explicitly and I
   overrode it by reflex, applying the gene-anchored oracles' n=18,000 to an oracle
   that does not use gene anchoring.

   Rebuilt with defaults so the only change is retention. ChromBPNet was launched
   without --n-variants and is unaffected.

The diluted build is kept as cherimoya_pertrack.diluted-composition.npz rather than
deleted, since it is the measurement that shows what a 14-point composition shift costs:
p50 0.963, p90 0.962, p99 0.983, max 1.000.
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