Skip to content

fix(igv): bound the JSON features any one track may emit (closes #129) - #132

Merged
lucapinello merged 1 commit into
mainfrom
fix/2026-08-01-igv-feature-budget
Aug 1, 2026
Merged

fix(igv): bound the JSON features any one track may emit (closes #129)#132
lucapinello merged 1 commit into
mainfrom
fix/2026-08-01-igv-feature-budget

Conversation

@lucapinello

Copy link
Copy Markdown
Contributor

Closes #129. rs12740374_SORT1_legnet_report.html regenerated at 131 MB (from 1.29 MB) and the consolidated multi-oracle report at 139 MB — both above GitHub's hard 100 MiB per-file limit, so neither could be committed in any form. That blocked regenerating the multi-oracle example at all.

Root cause: a missing cap, not tiling

_calculate_track_bin_size returned the bare resolution for LegNet, which makes bins_per = max(1, bin_size // resolution) equal 1 inside _downsample_to_features — one JSON feature per input bin, no downsampling whatever. Every other oracle fell through to a 3,000-feature budget.

Features per track, measured before this change:

oracle resolution window features
legnet 1 200,000 200,000 over
legnet 1 1,048,576 1,048,576 over
chrombpnet 1 200,000 10,000 over
sei 1 4,096 4,096 over
chrombpnet 1 2,114 106 ok
alphagenome 1 1,048,576 3,005 ok
enformer 128 114,688 896 ok
borzoi 32 524,288 3,277 ok

Three oracles were unbounded, not just LegNet — LegNet was simply the one that got wide enough to break the limit.

The fix

Keep each oracle's preferred bin size and aggregation, then apply _MAX_FEATURES_PER_TRACK = 4_000 as a floor on the bin width for everyone. Two properties matter:

  • It only binds on wide windows. At ChromBPNet's real 2,114 bp input the budget floor is 1 bp, so its deliberate 20 bp max-pooling survives untouched at 106 features. (That 20 bp exists for a reason — per PR Fix/igv multi resolution normalization #79, mean-pooling a 1 bp profile dilutes sharp peaks below the p95 floor-rescale and leaves the panel 97% empty.)
  • Aggregation is never rewritten. Widening a bin must not silently turn max-pooling into mean-pooling.

After: LegNet 1,048,576 → 3,987 features (263×), ChromBPNet at 2,114 bp unchanged, alphagenome/enformer/borzoi unchanged.

The test

tests/test_igv_feature_budget.py checks the budget two ways — the bin-size arithmetic, and end-to-end through the real _downsample_to_features with skip_zeros=False.

That flag matters. Callers pass skip_zeros=not (floor_ok or signed_track), and LegNet is signed, so zero-skipping was already off and could not have been masking this. Worth stating because it also means this is independent of #126 — fixing the LegNet percentile does not cause the blow-up, and the blow-up is live today.

It also pins ChromBPNet's 20 bp, that max-pooling is preserved, and that no oracle gets a bin narrower than its own resolution — the exact condition that silently floors bins_per to 1.

534 passed, 4 skipped, 0 failed (507 + 27 new).

One thing I found and deliberately did not patch here

scripts/rerender_examples.py points at examples/applications, renamed to examples/walkthroughs in 340f30e — so it dies with FileNotFoundError before doing any work, and has since the rename. It is the only file in the repo still using the old path.

Fixing only the path is actively harmful. I tried it: it rewrote 15 shipped reports from MB-scale down to 0.01–0.02 MB, because VariantReport.from_dict does not carry the per-bin IGV arrays. I reverted all of it. A crash is safer than silent data loss, so the path stays wrong until the script can preserve what it rewrites — filed separately.

This also means the TSV-writing I added to that script in #130 is inert, and the multi-oracle TSV gap is still open.

🤖 Generated with Claude Code

Closes #129. `rs12740374_SORT1_legnet_report.html` regenerated at 131 MB
(from 1.29 MB) and the consolidated multi-oracle report at 139 MB — both
above GitHub's hard 100 MiB per-file limit, so neither could be committed
in any form. That blocked regenerating the multi-oracle example at all.

Root cause is a missing cap, not tiling. `_calculate_track_bin_size`
returned the bare `resolution` for legnet, which makes
`bins_per = max(1, bin_size // resolution)` equal 1 inside
`_downsample_to_features` — one JSON feature per input bin, no
downsampling whatever. Every other oracle fell through to a
3,000-feature budget.

Measured features per track before this change:

  legnet      res=1   window=200,000     ->   200,000   OVER
  legnet      res=1   window=1,048,576   -> 1,048,576   OVER
  chrombpnet  res=1   window=200,000     ->    10,000   OVER
  sei         res=1   window=4,096       ->     4,096   OVER
  chrombpnet  res=1   window=2,114       ->       106
  alphagenome res=1   window=1,048,576   ->     3,005
  enformer    res=128 window=114,688     ->       896
  borzoi      res=32  window=524,288     ->     3,277

So three oracles were unbounded, not just legnet — legnet was simply the
one that got wide enough to break the 100 MiB limit.

The fix keeps each oracle's *preferred* bin size and aggregation, then
applies `_MAX_FEATURES_PER_TRACK = 4_000` as a floor on the bin width for
everyone. Two properties matter:

  - It only binds on wide windows. At ChromBPNet's real 2,114 bp input the
    budget floor is 1 bp, so its deliberate 20 bp max-pooling (PR #79:
    mean-pooling a 1 bp profile dilutes sharp peaks below the p95
    floor-rescale and leaves the panel 97% empty) survives untouched at
    106 features.
  - Aggregation is never rewritten. Widening a bin must not silently turn
    max-pooling into mean-pooling.

After: legnet 1,048,576 -> 3,987 features (263x), chrombpnet at 2,114 bp
unchanged at 106, alphagenome/enformer/borzoi unchanged.

tests/test_igv_feature_budget.py checks the budget two ways — the bin-size
arithmetic, and end-to-end through the real `_downsample_to_features` with
`skip_zeros=False`. That flag matters: callers pass
`skip_zeros=not (floor_ok or signed_track)`, and legnet is signed, so
zero-skipping was already off and could not have been masking this. The
suite also pins ChromBPNet's 20 bp, that max-pooling is preserved, and
that no oracle gets a bin narrower than its own resolution — the exact
condition that silently floors `bins_per` to 1.

534 passed, 4 skipped, 0 failed (507 + 27 new).

Not fixed here: `scripts/rerender_examples.py` is separately broken and I
have filed it rather than patch it in this PR — it points at
`examples/applications`, renamed to `examples/walkthroughs` in 340f30e, so
it dies with FileNotFoundError before doing any work. Fixing only the path
is actively harmful: I tried it, and it rewrote 15 shipped reports from
MB-scale down to 0.01-0.02 MB because `VariantReport.from_dict` does not
carry the per-bin IGV arrays. A crash is safer than silent data loss, so
the path stays wrong until the script can preserve what it rewrites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lucapinello
lucapinello merged commit 3ab1ae7 into main Aug 1, 2026
1 check passed
@lucapinello
lucapinello deleted the fix/2026-08-01-igv-feature-budget branch August 1, 2026 14:27
lucapinello added a commit that referenced this pull request Aug 1, 2026
…ps they found (#134)

There was no validation layer at all. `scripts/` holds 8 background
builders and 5 regenerators and **0** validators; no test read
`examples/`; nothing compared a JSON against the TSV beside it, checked a
file size, or noticed a missing provenance stamp. The audit checklist was
the validation layer, and it ran when a human ran it.

tests/test_committed_examples.py adds six checks, all reading only files
already in the repo — no GPU, no network, no weights, no FASTA:

  - every example_output.json has a TSV beside it
  - every percentile the TSV reports exists in a JSON in that directory
  - percentiles are not saturated at the ceiling
  - every example records when it was generated
  - every example is reachable from a generator, via AST not grep
  - no tracked file under examples/ exceeds 20 MiB
  - plus a source invariant: no generator may write a JSON and no TSV

Writing them first found two more real defects, and corrected me twice.

TWO DEFECTS FIXED

1. `scripts/regenerate_multioracle.py` wrote example_output.json and .md
   but never a TSV, so validation/SORT1_rs12740374_multioracle was the
   only one of 13 directories without one. `rerender_examples.py`
   refreshes a TSV only `if tsv_path.exists()`, so the gap could not heal
   itself — and that script is dead anyway (#133). MultiOracleReport has
   no to_dataframe(), so the TSV is projected from the per-oracle
   VariantReports with an `oracle` column prefixed: same schema as the
   single-oracle TSVs, directly comparable.

2. `regen_discovery` assembled its JSON by hand and so carried no
   analysis_request at all — discovery/SORT1_cell_type_screen was the one
   example of 13 with no `generated_at`, i.e. precisely the artefact a
   staleness check could not evaluate. Now stamped like every other.

TWO PLACES THE MEASUREMENT CORRECTED THE DESIGN

  - A `median|quantile| < 0.999` threshold was the obvious guard for
    staleness and is wrong: SORT1_chrombpnet scores a single track at
    0.9995 off a genuine +1.376 effect, so a median test flags a
    legitimately strong small example. The actual fingerprint of the #119
    denominator bug is saturation at *exactly* 1.0 — measured 76-100% on
    the stale examples versus 0-41% after the refresh. Threshold 0.60,
    ~20 points of margin either side.

  - The TSV check was wrong twice before it was right. First it
    double-counted, because a report stores each score in both
    `all_scores` and `scores_by_layer` (104 values for 52 rows). Then
    equality was still wrong: `_variant_report_tsv_rows` writes a
    deliberate per-layer summary, so region_swap ships 4 TSV rows against
    32 scored tracks and TERT 18 against 83. A subset check catches the
    real drift — SORT1_enformer's TSV described ENCFF571HTM at quantile
    1.0 while the JSON beside it described ENCFF430NNH at 0.9605 — and
    leaves summaries alone.

Reachability is parsed with `ast`, not grepped, for a specific reason: a
commented-out registry entry is still text, so a grep would happily
"find" the three examples that were unreachable for three months. It
vanishes from the AST. Follows the tests/test_cherimoya.py:581 idiom
because regenerate_examples.py calls parse_args() at module scope and
cannot be imported.

ARTEFACTS REGENERATED

The multi-oracle example, now that #131 and #132 have both landed —
regenerating before either would have produced a 131 MB HTML that cannot
be pushed. Measured:

  legnet       131 MB (unpushable) ->  1.78 MB
  chrombpnet      7.41 MB          ->  1.76 MB
  multioracle     9.47 MB          ->  3.95 MB
  alphagenome     2.99 MB          ->  2.99 MB  (budget already bound)

and its legnet_variant_report.json now carries quantile_score 0.9934
where it previously had no such key — #131 verified in the artefact, not
only in a unit test.

Largest tracked file under examples/ is now 14.70 MiB against the 20 MiB
ceiling.

622 passed, 7 skipped, 0 failed.

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

The actual root cause of #129, and it is upstream of the rendering code I
patched in #132.

`_legnet_sliding_prediction` in scripts/regenerate_multioracle.py tiles
LegNet's 200 bp window NON-OVERLAPPING (step = win = 200), producing one
MPRA-activity scalar per window — then expanded each scalar across its
200 bp span into a length-Q array and declared `resolution=1` with the
comment "base-pair resolution, like ChromBPNet". That comment was false.
Over a 1,048,576 bp locus it turned 5,243 real numbers into 1,048,576 IGV
features, 99.5% redundant, which is what made the report 131 MB and
impossible to commit at all (above GitHub's hard 100 MiB).

Now it emits the 5,243 values at `resolution=step`. Lossless — the
information was always 200 bp — and it removes the redundancy at source
instead of relying on a downstream cap to mop it up.

MEASURED, and this is the part that matters: the fix RECOVERS signal the
#132 feature cap was destroying.

                     features  distinct  range
  before (capped)       3,987     2,344  -0.9473 .. +1.1317
  after (true 200bp)    5,243     3,633  -1.1333 .. +1.1317

5,243 == ceil(1,048,576/200), i.e. every genuine window. So max-pooling
into 50 bp bins had been hiding 1,289 distinct values AND clipping the
true minimum (-1.1333 read as -0.9473). File size barely moves: 1.78 ->
1.94 MB. There was no size/resolution trade-off to make here.

WHY THE ELEGANT FIX WOULD HAVE BEEN WRONG

I had proposed replacing the flat 4,000-feature cap with "never bin below
the oracle's native resolution". That rule reads `track.resolution` — the
field this commit shows was fabricated as 1 for exactly the track that
caused the incident. It would have reinstated the 131 MB report verbatim.
The blunt cap worked *because* it ignores that field.

So the cap stays as a backstop against redundancy-by-construction, and
this commit fixes the thing that made it necessary. Note chorus/oracles/
legnet.py:260 was always correct — it reports `resolution=self.bin_size`;
only the regeneration script lied.

ARTEFACT CEILING RAISED 20 -> 50 MiB

The 20 was mine, picked as "headroom over today's largest" and not derived
from any real failure, and it blocked a legitimate 25.70 MB report. Three
measurements say a large report is not itself the problem:

  - these HTMLs gzip to 12-17%, so 25 MB costs ~3.5 MB of history;
  - IGV.js does not render the payload — it indexes inline features into a
    red-black interval tree (`qd`/`to`/`io`), culls to the visible window,
    then summarises to one value per screen pixel (`Pd`, always reached
    because chorus sets windowFunction). Draw cost is bounded by viewport
    width, not feature count. The cost is at load, once;
  - GitHub's 100 MiB is the only hard wall, and 50 MiB is its own advisory
    threshold — the right place for ours.

The ceiling now exists to catch a pathology, not to police size. Losing
resolution to hit a number is the wrong trade, which is precisely the
mistake this commit undoes.

Still open, filed separately: the payload is ~94% scaffolding. Measured on
the 14.70 MiB discovery report, 58 of 60.8 bytes per feature are
`{"chr":...,"start":...,"end":...,"value":` and all 60 wig tracks are
perfect regular grids, so a values-only array plus a (chr,start,step)
header is 12.8x smaller with zero loss. That is #135's proper fix and a
larger change than this one.

626 passed, 8 skipped, 0 failed.

Co-authored-by: Claude Opus 5 <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.

LegNet report HTML is 100x oversized and cannot be committed at all (blocks regenerating the multi-oracle example)

1 participant