Skip to content

Rank normalization quality by source and CURIE prefix, over a local KGX Storage mirror - #1

Draft
gaurav wants to merge 27 commits into
mainfrom
initial-implementation
Draft

Rank normalization quality by source and CURIE prefix, over a local KGX Storage mirror#1
gaurav wants to merge 27 commits into
mainfrom
initial-implementation

Conversation

@gaurav

@gaurav gaurav commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Gets the normalization data onto disk, then makes it answerable: which prefixes, in which sources,
are failing to normalize — and what the failing identifiers actually look like.

Mirroring KGX Storage

scripts/sync-kgx-normalization.sh is one rclone sync call. It mirrors only
normalization-metadata.json, normalization_failures.txt, normalization_map.json and
latest-build.json into data/kgx-storage.ci.transltr.io/, preserving the remote layout and
pruning files that disappear upstream. The multi-gigabyte normalized_nodes.jsonl /
normalized_edges.jsonl files are never fetched — the --include filters apply to the listing
walk, so only directory HTML is requested. A full sync is ~970 MB across 267 files.

rclone's http backend is used because the S3 bucket is not anonymously listable; the site's HTML
listings are the only public index.

The dashboard

A local Dash app (uv run normalization-dashboard, or PORT=8051 … for a second instance): every
data source × CURIE prefix, sorted worst normalization rate first, defaulting to each source's
latest build. Prefixes that fully normalize are hidden by default (n=97 of 241).

  • Failed is a sortable column, because at 0% the percentage says nothing about how many CURIEs
    are at stake — FOODON at 0% of 28,920 and a prefix at 0% of 3 rank identically by rate.
  • Five example CURIEs per row, sampled evenly across that prefix's failures, spelled and cased as
    the source spells them, each linked out. This is usually the fastest explanation of a 0% row.
  • Click a row for the full list of unnormalized CURIEs, with malformed ones flagged.
  • Versions link to the KGX Storage directory the numbers came from.

Running locally keeps individual CURIEs off the public web and leaves deployment (Pages export, k8s,
folding into another Translator dashboard) open. loader.py has no Dash imports, so a notebook or a
static-JSON exporter can reuse it.

What it already shows

  • pathbank, the worst source at 0% of 215,953, is failing on PathBank:Reaction_13124 and
    PathBank:Compound_46510 — internal reaction and compound identifiers, not pathway identifiers.
    "Babel lacks PathBank" is the wrong diagnosis.
  • goa/RNAcentral fails on taxon-suffixed RNAcentral:URS0002337E65_9606.
  • intact/RHEA is 0% of exactly 2 CURIEs, both rhea:RHEA:13065 — double-prefixed, fully
    explained by malformation, no Babel work needed.
  • The rest of the 0% band is whole ontologies: FOODON, ENVO, OBI, ECTO, PATO, ComplexPortal, SO, ECO.

Things found in the data

  • NodeNorm resolves CURIE prefixes case-insensitively (ENSEMBL:, Ensembl:, ensembl: all
    return NCBIGene:675), so summaries pool on the uppercased prefix while records keep the observed
    case. Merges 86 observed prefixes into 84.
  • The pipeline truncates success_rate rather than rounding it (99.556 → 99.55, in 128 of 587
    rows), so the loader recomputes the rate from the counts.
  • PathBank is absent from the Biolink prefix map, along with 23 other prefixes covering 219,847
    unnormalized CURIEs; those fall back to bioregistry.io for linking.

Scale, for the "do we need a database" question

tier rows on disk
normalization-metadata.json 587 (build, prefix) rows; 242 (source, prefix) 0.2 MB
normalization_failures.txt 3.2M lines, 898K distinct CURIEs 56 MB
normalization_map.json tens of millions of pairs 914 MB

No database needed for the prefix summary, or for a drill-down that reads one build's failures file
on demand. Only the renormalization-diff work will want DuckDB or similar.

Known upstream issue

Ubergraph's 247 MB normalization_map.json returns 502 — the origin appears to buffer JSON
responses in memory, and a 48 MB map from the same source downloads fine. A full sync lands 266/267
files and exits non-zero. Worth reporting to DINGO.

Verification

  • uv run python tests/test_loader.py and tests/test_curie.py — invariants against the real
    mirror: counts reconcile, rates match the counts, normalized_to sums to succeeded, exactly one
    latest build per source, summarize() conserves totals and leaves no case-duplicate prefixes, and
    every summary value is a scalar (a list in a row makes the browser reject the whole DataTable, and
    no server-side check sees that).
  • App serves 200 and both callbacks return correct data over HTTP.

🤖 Generated with Claude Code

gaurav and others added 9 commits August 14, 2026 16:10
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Python >=3.12, no dependencies yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mirrors only normalization-metadata.json, normalization_failures.txt and
normalization_map.json (~970 MB across 267 files) into
data/kgx-storage.ci.transltr.io/, preserving the remote layout and pruning
files that disappear upstream. The multi-gigabyte normalized_nodes.jsonl /
normalized_edges.jsonl files are never fetched: the --include filters apply
to the listing walk, so only directory HTML is requested.

rclone's http backend is used because the S3 bucket behind KGX Storage is not
anonymously listable -- the site's HTML listings are the only public index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records what had to be discovered by probing the live server: no anonymous S3
listing, HTML-only index, presigned-URL redirects, the 502 on large JSON files,
the path layout, what each normalization file contains, and how big they all are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
31 files of ~600 B, and it makes "which build is current for this source"
authoritative instead of inferred from directory mtimes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
load_rows() returns one plain dict per (build, prefix) from the local mirror;
summarize() pools by (source, case-insensitive prefix). No Dash imports, so a
notebook or a static-JSON exporter can reuse it.

Prefixes are pooled case-insensitively because NodeNorm resolves CURIE prefixes
case-insensitively -- ENSEMBL:, Ensembl: and ensembl: all return NCBIGene:675.
Individual rows keep the case observed in the files and the summary reports the
spellings it merged, so a fold is never silent.

success_rate is recomputed from the counts rather than read from the file: the
pipeline truncates it (99.556 -> 99.55) in 128 of 587 rows, which would not
agree with rates derived from pooled counts.

tests/test_loader.py checks these invariants against the real mirror.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One sortable, filterable table of every data source x CURIE prefix, sorted worst
normalization rate first, defaulting to each source's latest build (241 rows;
242 with all 90 builds shown). Failed counts are a column so the ranking can be
flipped to "how many CURIEs are at stake", which is the better prioritization
signal at 0%.

Running locally keeps individual CURIEs off the public web and leaves the
deployment question open.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gaurav gaurav changed the title Initial implementation of a NormalizationDashboard Mirror KGX Storage normalization data and rank normalization rates by source and CURIE prefix Aug 14, 2026
gaurav and others added 11 commits August 14, 2026 17:16
normalized_nodes.jsonl carries the label, full Biolink category list and clique
members for every normalized node; the pre-normalization transform output
({source}_nodes.jsonl) carries what the upstream source knew about CURIEs we
could not normalize -- but only for some sources. pathbank, ncbi_gene and goa
have names and descriptions there; ubergraph has only id + biolink:NamedThing.

Recorded rather than downloaded: these run to hundreds of MB per source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5.7 GB over all 90 builds, so mirroring these would mean restricting to the
latest build per source rather than adding them to the sync filters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It is gitignored, survives reboots and sits next to the code, so a one-off job
can be picked up or re-run later instead of being redone from scratch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Expands a CURIE through the Biolink prefix map (vendored, 266 prefixes, so the
app works offline), falling back to bioregistry.io for prefixes the map lacks.
The map covers 75.5% of the 898,042 distinct unnormalized CURIEs; the fallback
covers the rest, including PathBank, which is absent from the Biolink map and is
by itself 215,953 of them.

Malformed CURIEs get the reason instead of a link, because the malformation is
often the whole explanation for the normalization failure. Real species in the
data: rhea:RHEA:13065 (double prefix), CL:0000089 ∩ UBERON:0000473 (post-composed
class expression), UniProtKB:B3DHD6 Q6XCC7 (two accessions), UniProtKB: (empty).

The curies package is not used: the Biolink file is a flat prefix -> URI-stem
dict, so expansion is a dict lookup and a string concat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
build_url points at the normalization output directory in KGX Storage that a
row's numbers came from; the summary renders one markdown link per pooled build.
failures_paths lets the UI find the CURIEs behind a row without re-deriving
paths from the mirror layout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clicking a row lists the CURIEs that failed to normalize for that source and
prefix, each linked out so it can be checked, with malformed ones flagged.
Failures files are read on demand -- the biggest is under 7 MB, and preloading
all 87 would cost 3.2M lines for nothing.

Table changes: CURIE Prefix moves to the leftmost column and is styled as the
row header it effectively is; versions link to their KGX Storage directory;
prefixes that fully normalize are hidden by default, with the hidden count in
the checkbox label; 75 rows per page, enough to show all 67 rows below 80% at
once.

The drill-down reads derived_viewport_data rather than data, since active_cell
indexes the sorted and filtered page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mirror path was relative to the working directory, so starting the app from
anywhere but the repository root -- an IDE run configuration, uv run --project
-- loaded zero rows in silence. Fall back to the repo-relative path, and raise a
FileNotFoundError naming the sync script when no mirror is found at all, instead
of rendering an empty table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DataTable cells may only hold a string, number or boolean, so the list of
failures paths carried on each summary row made the browser reject the whole
table with "Invalid argument `data[0].failures_paths`". The check is client-side,
so no server-side test or HTTP request saw it.

The drill-down now looks its paths up from the loaded rows by (source, prefix)
instead, which also stops sending local file paths to the browser at all.
test_loader.py asserts every summary value is a scalar, so this class of error
fails a self-check rather than only the UI.

Also honours PORT, so a second instance can run alongside one already on 8050.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Up to five real unnormalized CURIEs per row, one per column, monospaced and
linked. They keep the case the source file spells them with, which is much of
the value: PathBank's failures are PathBank:Reaction_13124 and
PathBank:Compound_102409 -- not pathway identifiers at all -- and goa's are
RNAcentral:URS0002337E65_9606, taxon-suffixed.

Collected in one pass per failures file rather than per row: 87 files, 3.2M
lines, 0.8s at startup. Latest builds are scanned first so examples come from
current data.

Also bracket the counts in "Normalized to" (CHEBI (5), not CHEBI 5), which read
as a possibly-malformed CURIE, and let the 14-column table scroll rather than
crushing every cell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first five CURIEs in a failures file are often all alike, since a prefix's
failures are grouped in file order. Spreading the sample across the whole list
shows the variety instead: pathbank now reads Reaction_13124, Compound_46510,
Reaction_8770 rather than three consecutive Reaction ids.

Still one pass per file and 0.8s, holding only one file's CURIEs at a time, and
a source's examples now all come from its latest build rather than whichever
build filled the bucket first.

Observed as leaves the table: it equals the CURIE Prefix in every row today,
because both case collisions are cross-source, and the Example columns show the
original casing anyway. summarize() still carries the field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gaurav gaurav changed the title Mirror KGX Storage normalization data and rank normalization rates by source and CURIE prefix Rank normalization quality by source and CURIE prefix, over a local KGX Storage mirror Aug 14, 2026
gaurav and others added 7 commits August 14, 2026 18:24
The page container capped itself at 1400px, and a DataTable can only fill the
width its parent gives it -- so roughly four columns sat off-screen behind a
scrollbar. This looked like a Dash limitation and nearly cost us a rewrite into
hand-built HTML/JS; it was two lines of our own CSS.

Also drops the "Normalized to" column, which at 200+ characters for semmeddb was
the one value wide enough to force a scrollbar by itself. Where a prefix
normalizes to is a renormalization-diff question rather than a today question;
summarize() still carries normalized_to_str for when that work starts.

Prose keeps a 70ch cap -- wide tables are good, wide paragraphs are not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clicking a cell used to highlight just that cell, which says nothing -- which
column you happened to hit is not information. The whole row now highlights,
matched on (prefix, source) identity rather than row index, so it follows the
row when the table is re-sorted or filtered, and the active cell gets no colour
of its own.

The unnormalized CURIE listing is now a <ul> grouped by CURIE shape -- everything
up to the first digit -- with each group's members spread across the whole group
rather than taken from its head. pathbank's 215,953 failures read as
175,039 Reaction_, 31,182 Compound_, 8,886 ProteinComplex_, 416 Bound_; bgee's
ENSEMBL failures split into one group per species. Grouping declines above 12
groups and falls back to a flat spread list, because InChIKeys have no digits to
split on and would otherwise produce 87 groups of one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The listing grouped by CURIE shape, which described the identifiers without
diagnosing them -- rhea:RHEA: was reported as just another shape rather than as
a double prefix. It now groups by problem, most actionable first: malformed
CURIEs, then prefixes the Biolink model has never heard of, then the ordinary
"no Babel clique" remainder. Shape grouping survives one level down, where it
was doing real work: pathbank's 215,953 unknown-prefix CURIEs still break into
Reaction_, Compound_, ProteinComplex_, Bound_, ElementCollection_, NucleicAcid_.

The prefix check is labelled as a signal rather than a cause -- Babel decides
coverage for itself, not from the Biolink prefix map -- but a prefix the model
has never heard of is rarely a coincidence when all of its CURIEs fail.

When no shape is shared, as for the 87 digit-free InChIKeys, the fallback list
is capped at 30 spread across the set rather than printing every one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The intro paragraph kept a 70ch cap while the table went full width, which just
looked broken. Nothing caps its width now.

The summary line reported only row and source counts. It now carries the totals
worth knowing at a glance:

  Showing 144 of 241 rows — 31 sources, 84 CURIE prefixes,
  2,793,224 of 3,652,613 CURIEs normalized (76.5%), 859,389 failed

Every figure but the row count describes the whole build selection rather than
the visible rows, so hiding the fully-normalizing prefixes cannot make the
overall score look worse than it is. CURIEs are counted once per source, which
the intro now says: the all-builds total of 3,225,939 failures matches the line
count across the mirrored failures files exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The intro was one paragraph of prose and the totals sat in a greyed-out line
above the table, which is the least readable place to put the numbers people
actually want. Both become one <ul>: three bold figure bullets -- CURIEs
normalized and the percentage, CURIEs failed, and the source/prefix/pair counts
-- followed by the fixed notes.

The line above the table now reports only what it can honestly claim, the
number of rows currently displayed, and is no longer greyed out. The figures
still describe the whole build selection, so hiding the fully-normalizing
prefixes cannot move the headline score.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A full runbook from a fresh checkout to a running dashboard: prerequisites, the
sync and its variants, what lands where, how to keep it current, and
troubleshooting for the failure modes we actually hit (the ubergraph 502, a port
already in use, a missing mirror, an empty table). README keeps a quick start and
links to it.

Also states plainly that there is no static export yet and what hosting would
take, rather than leaving the deployment question implied. Drops the README's
reference to the "Observed as" column, which no longer exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It still described Dash as a possible future port and claimed the project had no
dependencies. Now records the current shape, which goals are met, and the
gotchas that cost real time to find:

- DataTable cells hold only scalars, and the check is client-side, so no
  server-side test or curl of the callback endpoint can catch a violation
- active_cell indexes the viewport, not the data prop
- row identity beats row index for conditional styling
- the mirror resolves relative to the repo, not the working directory
- table width was never a Dash limit, just a maxWidth on our own container

Plus how the click-through listing groups CURIEs by problem and by shape, and
why the prefix check is labelled a signal rather than a cause.

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.

1 participant