From 522d21dbd41e2a6850ded8479430ba80a7ce7472 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 16:10:05 -0400 Subject: [PATCH 01/27] Added data/ as a scratch space. --- .gitignore | 3 +++ data/.gitkeep | 0 2 files changed, 3 insertions(+) create mode 100644 data/.gitkeep diff --git a/.gitignore b/.gitignore index 83972fa..de15926 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Ignore /data/ as a scratch space. +/data/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 From 587e30bacf50eb69a7c7067aeb75b80b1a8a770f Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 16:52:32 -0400 Subject: [PATCH 02/27] Ignore .idea/ (PyCharm project settings) Co-Authored-By: Claude Opus 5 --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index de15926..2bd7f8a 100644 --- a/.gitignore +++ b/.gitignore @@ -189,7 +189,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -# .idea/ +.idea/ # Abstra # Abstra is an AI-powered process automation framework. From 5888c8a4b913046b25b7fb9212d1849146e58c54 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 16:52:42 -0400 Subject: [PATCH 03/27] Set up uv project scaffold Python >=3.12, no dependencies yet. Co-Authored-By: Claude Opus 5 --- .python-version | 1 + pyproject.toml | 17 +++++++++++++++++ src/normalization_dashboard/__init__.py | 2 ++ uv.lock | 8 ++++++++ 4 files changed, 28 insertions(+) create mode 100644 .python-version create mode 100644 pyproject.toml create mode 100644 src/normalization_dashboard/__init__.py create mode 100644 uv.lock diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..32a06a9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "normalization-dashboard" +version = "0.1.0" +description = "A dashboard to track normalization across KGX releases" +readme = "README.md" +authors = [ + { name = "Gaurav Vaidya", email = "gaurav@renci.org" } +] +requires-python = ">=3.12" +dependencies = [] + +[project.scripts] +normalization-dashboard = "normalization_dashboard:main" + +[build-system] +requires = ["uv_build>=0.12.3,<0.13.0"] +build-backend = "uv_build" diff --git a/src/normalization_dashboard/__init__.py b/src/normalization_dashboard/__init__.py new file mode 100644 index 0000000..b905b41 --- /dev/null +++ b/src/normalization_dashboard/__init__.py @@ -0,0 +1,2 @@ +def main() -> None: + print("Hello from normalization-dashboard!") diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..0f77a2f --- /dev/null +++ b/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "normalization-dashboard" +version = "0.1.0" +source = { editable = "." } From 7d43fc15882278eba4fc4edf307cd41de38c81fb Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 16:52:42 -0400 Subject: [PATCH 04/27] Add rclone-based sync for KGX Storage normalization files 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 --- README.md | 42 ++++++++++++++++++++++++++++++- scripts/sync-kgx-normalization.sh | 19 ++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100755 scripts/sync-kgx-normalization.sh diff --git a/README.md b/README.md index 149dc61..839fc6d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,42 @@ # NormalizationDashboard -A dashboard to track normalization across KGX releases + +A dashboard to track normalization across KGX releases. + +The NCATS Translator DINGO pipeline normalizes KGX node CURIEs through +[NodeNorm](https://github.com/biothings/NodeNormalizationAPI), which uses cliques from +[Babel](https://github.com/NCATSTranslator/Babel), and publishes the results to +[KGX Storage](https://kgx-storage.ci.transltr.io/). This project summarizes how that normalization +went: which prefixes normalized to what, and which CURIEs failed to normalize (so they can be +prioritized for a future Babel ingest). + +## Syncing the normalization data + +Each KGX build directory contains `normalization-metadata.json`, `normalization_failures.txt`, and +`normalization_map.json` alongside multi-gigabyte `normalized_nodes.jsonl` / `normalized_edges.jsonl` +files. The sync script mirrors *only* the three normalization files, preserving KGX Storage's folder +layout, and deletes local files that have disappeared upstream. + +```bash +brew install rclone # or apt install rclone +./scripts/sync-kgx-normalization.sh +``` + +That writes ~970 MB into `data/kgx-storage.ci.transltr.io/` (gitignored). Any extra arguments are +passed through to `rclone`: + +```bash +./scripts/sync-kgx-normalization.sh /tmp/kgxtest --dry-run +./scripts/sync-kgx-normalization.sh data/kgx-storage.ci.transltr.io --exclude "**/normalization_map.json" +``` + +Skipping the maps with that last `--exclude` brings the mirror down to ~56 MB, which is enough for +the prefix-summary and normalization-failure reports. + +**Known upstream issue:** ubergraph's 247 MB `normalization_map.json` returns 502 from KGX Storage +and cannot be downloaded, so a full sync exits non-zero. See [CLAUDE.md](CLAUDE.md) for details. + +## Development + +```bash +uv sync +``` diff --git a/scripts/sync-kgx-normalization.sh b/scripts/sync-kgx-normalization.sh new file mode 100755 index 0000000..bf22cc6 --- /dev/null +++ b/scripts/sync-kgx-normalization.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Mirror only the normalization artifacts from KGX Storage, preserving its folder layout. +# Downloads new/changed files and deletes local files that no longer exist upstream. +# +# Usage: ./scripts/sync-kgx-normalization.sh [DEST] [extra rclone args...] +# e.g. ./scripts/sync-kgx-normalization.sh /tmp/kgxtest --dry-run +# ./scripts/sync-kgx-normalization.sh data/kgx-storage.ci.transltr.io --exclude "**/normalization_map.json" +# +# Requires rclone (brew install rclone). The S3 bucket behind KGX Storage is not +# anonymously listable, so we walk the site's HTML listings via rclone's http backend. +set -euo pipefail + +DEST="${1:-data/kgx-storage.ci.transltr.io}" + +rclone sync --http-url https://kgx-storage.ci.transltr.io :http:data/ "$DEST" \ + --include "**/normalization-metadata.json" \ + --include "**/normalization_failures.txt" \ + --include "**/normalization_map.json" \ + --checkers 16 --transfers 8 --progress --stats-one-line "${@:2}" From c8732889a79573cc461454146a3e7e18875fa6fe Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 16:52:42 -0400 Subject: [PATCH 05/27] Add CLAUDE.md with KGX Storage access notes 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 --- CLAUDE.md | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..80cf4e4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,92 @@ +# CLAUDE.md + +## What this is + +A dashboard for tracking how well NCATS Translator KGX ingests normalize against +[Babel](https://github.com/NCATSTranslator/Babel)/[NodeNorm](https://github.com/biothings/NodeNormalizationAPI). +The DINGO team transforms biomedical sources into KGX `_nodes.jsonl`/`_edges.jsonl`, normalizes node +CURIEs through NodeNorm, and publishes the results to KGX Storage. This repo consumes the +normalization side of those outputs. + +Three goals, in order: + +1. Summary of all prefixes normalized across every data source in KGX Storage. +2. Report of CURIEs that could not be normalized — this drives Babel ingest prioritization. +3. Renormalize a build's normalization output against a new Babel release to estimate how much the + new release would change the results. + +Planned shape: a Python CLI produces small static JSON summaries, committed to the repo and rendered +by a plain HTML/JS page on GitHub Pages. Possibly ported to [Dash](https://plotly.com/dash/) later so +it can be combined with other Translator dashboards. + +## KGX Storage + +`https://kgx-storage.ci.transltr.io/` fronts `s3://kgx-translator-ingests`. See `/docs` on that site. + +- **HTTPS is the only public access path.** The bucket is *not* anonymously listable — both + `aws s3 ls --no-sign-request` and the S3 REST API return `AccessDenied`. The site's HTML directory + listings are the sole public index. `rclone`'s `:http:` backend parses them correctly, which is why + the sync script is one `rclone sync` call. +- **File URLs 302-redirect to presigned S3 URLs**, so any client must follow redirects — use + `curl -fL`. Directory URLs need a trailing slash. Appending `?view` to a JSON URL opens an HTML + viewer instead of returning the raw file. +- **Large JSON files 502.** The origin appears to buffer JSON responses in memory: a 48 MB + `normalization_map.json` downloads fine, but ubergraph's 247 MB one + (`data/ubergraph/2026-05-31/transform_e7f773ea/normalization_.../normalization_map.json`) returns + 502 Bad Gateway to every client. This is an upstream bug worth reporting to DINGO; until it's + fixed, a full sync exits non-zero because of that one file. + +### Path layout + +``` +data/{source}/{source_version}/transform_{hash}/normalization_{babel}_{nn}_{code}_{conflated}_{strict}/ + normalization-metadata.json + normalization_failures.txt + normalization_map.json + normalized_nodes.jsonl # huge, never download + normalized_edges.jsonl # huge, never download + merge_{version}/ +data/{source}/latest-build.json # index: which transform/normalization is current +releases/latest-release-summary.json # index: every source's current release +releases/{source}/{release_version}/ +``` + +The normalization directory name encodes Babel version, NodeNorm version, normalization code +version, and the conflation/strict flags. Every directory in the bucket currently uses +`normalization_2025sep1_2.4.1_1.4.0_conflated_strict` — one Babel version so far, but the layout +allows several to coexist and the sync handles that without changes. + +### File semantics + +- `normalization-metadata.json` — pre/post node counts, failure count, edge counts, and + `normalization_by_prefix`: per source prefix `{succeeded, failed, total, success_rate, + normalized_to: {target_prefix: count}}`. This is the input for goal 1. Tiny (~2–7 KB each). +- `normalization_failures.txt` — one unnormalized source CURIE per line. Input for goal 2. Not + present in every build (e.g. ctkp, dakp have none). +- `normalization_map.json` — `{"normalization_map": {"": [""] | null}}`. + Failures are the `null` entries, so `normalization_failures.txt` is a subset of this file's keys. + Kept anyway because it's 16× smaller and directly answers goal 2. The map is what goal 3 needs. + +### Scale (full crawl of `data/`, Aug 2026) + +90 normalization directories; a full listing crawl takes ~40s. + +| file | count | total | +|---|---|---| +| `normalization-metadata.json` | 90 | 0.2 MB | +| `normalization_failures.txt` | 87 | 56 MB | +| `normalization_map.json` | 90 | 914 MB | + +## Local mirror + +`./scripts/sync-kgx-normalization.sh` mirrors those three file types into +`data/kgx-storage.ci.transltr.io/`, named for the host it came from. `/data/` is gitignored as +scratch space. The nodes/edges files are excluded by the `--include` filters, which apply to the +listing walk, so they are never fetched. + +## Conventions + +- Add a dependency only when something actually needs it. The project has none so far. +- The downloader stays `rclone` — it already does listing, filtering, incremental sync, pruning, + retries, and concurrency. +- Data files are never committed; only the small derived JSON summaries will be. From cac7c8d4c1261095b36f82236b6b39b556176250 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 17:12:22 -0400 Subject: [PATCH 06/27] Also mirror latest-build.json 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 --- scripts/sync-kgx-normalization.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/sync-kgx-normalization.sh b/scripts/sync-kgx-normalization.sh index bf22cc6..34952eb 100755 --- a/scripts/sync-kgx-normalization.sh +++ b/scripts/sync-kgx-normalization.sh @@ -16,4 +16,5 @@ rclone sync --http-url https://kgx-storage.ci.transltr.io :http:data/ "$DEST" \ --include "**/normalization-metadata.json" \ --include "**/normalization_failures.txt" \ --include "**/normalization_map.json" \ + --include "**/latest-build.json" \ --checkers 16 --transfers 8 --progress --stats-one-line "${@:2}" From 87bd0520f4941d771f2ca524f44fa676592f628b Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 17:12:22 -0400 Subject: [PATCH 07/27] Add loader for normalization metadata 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 --- src/normalization_dashboard/loader.py | 132 ++++++++++++++++++++++++++ tests/test_loader.py | 55 +++++++++++ 2 files changed, 187 insertions(+) create mode 100644 src/normalization_dashboard/loader.py create mode 100644 tests/test_loader.py diff --git a/src/normalization_dashboard/loader.py b/src/normalization_dashboard/loader.py new file mode 100644 index 0000000..1580f9a --- /dev/null +++ b/src/normalization_dashboard/loader.py @@ -0,0 +1,132 @@ +"""Load normalization metadata out of a local KGX Storage mirror. + +Pure data, no Dash imports, so a notebook or a static-JSON exporter can reuse it. +See CLAUDE.md for the mirror layout and what each file contains. +""" + +import json +from collections import defaultdict +from pathlib import Path + +DEFAULT_MIRROR = Path("data/kgx-storage.ci.transltr.io") + + +def _normalized_to_str(normalized_to): + """'NCBIGene 91678; UniProtKB 82176; PR 79548', biggest target first.""" + return "; ".join( + f"{prefix} {count}" + for prefix, count in sorted(normalized_to.items(), key=lambda kv: -kv[1]) + ) + + +def load_rows(mirror=DEFAULT_MIRROR): + """One row per (build, prefix), with the prefix spelled as the file spells it. + + `prefix_key` is the case-insensitive grouping key: NodeNorm resolves CURIE + prefixes case-insensitively, so ENSEMBL and Ensembl are the same prefix. + """ + mirror = Path(mirror) + rows = [] + for metadata_path in sorted(mirror.glob("*/*/*/*/normalization-metadata.json")): + source, source_version, transform, normalization = metadata_path.relative_to( + mirror + ).parts[:4] + metadata = json.loads(metadata_path.read_text()) + failures_path = metadata_path.parent / "normalization_failures.txt" + for prefix, stats in metadata.get("normalization_by_prefix", {}).items(): + normalized_to = stats.get("normalized_to", {}) + rows.append( + { + "source": source, + "source_version": source_version, + "transform": transform.removeprefix("transform_"), + "normalization": normalization.removeprefix("normalization_"), + "babel_version": metadata.get("babel_version"), + "prefix": prefix, + "prefix_key": prefix.upper(), + "total": stats["total"], + "succeeded": stats["succeeded"], + "failed": stats["failed"], + # Recomputed, not taken from the file: the pipeline truncates + # success_rate (99.556 -> 99.55), which would not agree with the + # rates summarize() derives from pooled counts. + "success_rate": round(100 * stats["succeeded"] / stats["total"], 2) + if stats["total"] + else 0.0, + "normalized_to": normalized_to, + "normalized_to_str": _normalized_to_str(normalized_to), + "failures_path": str(failures_path) + if failures_path.exists() + else None, + } + ) + _mark_latest(rows, mirror) + return rows + + +def _mark_latest(rows, mirror): + """Flag the rows belonging to each source's current build. + + latest-build.json names the current transform; where it is missing (or names a + build we have not mirrored) fall back to the most recently modified build. + """ + builds = defaultdict(set) # source -> {(source_version, transform)} + for row in rows: + builds[row["source"]].add((row["source_version"], row["transform"])) + + latest = {} + for source, source_builds in builds.items(): + current = None + try: + build = json.loads((mirror / source / "latest-build.json").read_text()) + current = (build["source_version"], build["transform_version"]) + except (OSError, KeyError, json.JSONDecodeError): + pass + if current not in source_builds: + current = max( + source_builds, + key=lambda b: (mirror / source / b[0] / f"transform_{b[1]}").stat().st_mtime, + ) + latest[source] = current + + for row in rows: + row["is_latest"] = ( + latest[row["source"]] == (row["source_version"], row["transform"]) + ) + + +def summarize(rows): + """Pool rows by (source, case-insensitive prefix). + + Rates are recomputed from the summed counts rather than averaged, and the + spellings actually seen are reported so a case merge is never silent. + """ + grouped = defaultdict(list) + for row in rows: + grouped[(row["source"], row["prefix_key"])].append(row) + + summary = [] + for (source, prefix_key), group in grouped.items(): + total = sum(row["total"] for row in group) + succeeded = sum(row["succeeded"] for row in group) + normalized_to = defaultdict(int) + for row in group: + for target, count in row["normalized_to"].items(): + normalized_to[target.upper()] += count + summary.append( + { + "source": source, + "source_versions": ", ".join( + sorted({row["source_version"] for row in group}) + ), + "builds": len({(r["source_version"], r["transform"]) for r in group}), + "prefix": prefix_key, + "observed_as": ", ".join(sorted({row["prefix"] for row in group})), + "total": total, + "succeeded": succeeded, + "failed": total - succeeded, + "success_rate": round(100 * succeeded / total, 2) if total else 0.0, + "normalized_to_str": _normalized_to_str(normalized_to), + } + ) + return sorted(summary, key=lambda row: (row["success_rate"], -row["failed"])) diff --git a/tests/test_loader.py b/tests/test_loader.py new file mode 100644 index 0000000..594cc56 --- /dev/null +++ b/tests/test_loader.py @@ -0,0 +1,55 @@ +"""Invariants for the metadata loader. Run: uv run python tests/test_loader.py + +Checks against the real mirror rather than a fixture -- the failure mode worth +catching is a new DINGO build whose shape we did not expect. +""" + +import sys +from collections import Counter +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from normalization_dashboard.loader import DEFAULT_MIRROR, load_rows, summarize + + +def main(): + if not DEFAULT_MIRROR.exists(): + print(f"SKIP: no mirror at {DEFAULT_MIRROR}; run scripts/sync-kgx-normalization.sh") + return 0 + + rows = load_rows() + assert rows, "no rows loaded" + + for row in rows: + where = f"{row['source']} {row['source_version']} {row['prefix']}" + assert row["succeeded"] + row["failed"] == row["total"], where + if row["total"]: + expected = round(100 * row["succeeded"] / row["total"], 2) + assert abs(row["success_rate"] - expected) <= 0.01, f"{where}: {row['success_rate']} != {expected}" + if row["normalized_to"]: + assert sum(row["normalized_to"].values()) == row["succeeded"], where + assert row["failures_path"] is None or Path(row["failures_path"]).exists(), where + + latest = {(row["source"], row["source_version"], row["transform"]) for row in rows if row["is_latest"]} + per_source = Counter(source for source, _, _ in latest) + assert all(count == 1 for count in per_source.values()), f"multiple latest builds: {per_source}" + assert per_source.keys() == {row["source"] for row in rows}, "a source has no latest build" + + summary = summarize(rows) + assert sum(row["total"] for row in summary) == sum(row["total"] for row in rows), "summarize lost rows" + keys = Counter((row["source"], row["prefix"]) for row in summary) + assert all(count == 1 for count in keys.values()), "summarize left duplicate (source, prefix)" + assert all(row["prefix"] == row["prefix"].upper() for row in summary), "summary prefix not case-folded" + + latest_summary = summarize([row for row in rows if row["is_latest"]]) + print( + f"OK: {len(rows)} rows over {len(per_source)} sources, " + f"{len({row['prefix_key'] for row in rows})} case-insensitive prefixes; " + f"{len(summary)} summary rows ({len(latest_summary)} latest-only)" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From d10344238f98fb9dcb89bb537e0c86931c785b9a Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 17:12:22 -0400 Subject: [PATCH 08/27] Add local Dash app: source x prefix normalization ranking 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 --- pyproject.toml | 4 +- src/normalization_dashboard/__init__.py | 5 +- src/normalization_dashboard/app.py | 82 ++++ uv.lock | 562 ++++++++++++++++++++++++ 4 files changed, 650 insertions(+), 3 deletions(-) create mode 100644 src/normalization_dashboard/app.py diff --git a/pyproject.toml b/pyproject.toml index 32a06a9..8f9752b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,9 @@ authors = [ { name = "Gaurav Vaidya", email = "gaurav@renci.org" } ] requires-python = ">=3.12" -dependencies = [] +dependencies = [ + "dash>=4.4.1", +] [project.scripts] normalization-dashboard = "normalization_dashboard:main" diff --git a/src/normalization_dashboard/__init__.py b/src/normalization_dashboard/__init__.py index b905b41..28cb2d2 100644 --- a/src/normalization_dashboard/__init__.py +++ b/src/normalization_dashboard/__init__.py @@ -1,2 +1,3 @@ -def main() -> None: - print("Hello from normalization-dashboard!") +from normalization_dashboard.app import main + +__all__ = ["main"] diff --git a/src/normalization_dashboard/app.py b/src/normalization_dashboard/app.py new file mode 100644 index 0000000..6e4022a --- /dev/null +++ b/src/normalization_dashboard/app.py @@ -0,0 +1,82 @@ +"""Local Dash app: every data source x CURIE prefix, worst normalization first.""" + +from dash import Dash, Input, Output, callback, dash_table, dcc, html + +from normalization_dashboard.loader import load_rows, summarize + +COLUMNS = [ + ("source", "Source"), + ("source_versions", "Version"), + ("prefix", "Prefix"), + ("observed_as", "Observed as"), + ("total", "Total"), + ("succeeded", "Succeeded"), + ("failed", "Failed"), + ("success_rate", "Success %"), + ("normalized_to_str", "Normalized to"), +] + +# Red at 0% shading to green at 100%, so the ranking is scannable without reading numbers. +RATE_SHADING = [ + { + "if": {"column_id": "success_rate", "filter_query": f"{{success_rate}} >= {low} && {{success_rate}} < {low + 20}"}, + "backgroundColor": colour, + } + for low, colour in ((0, "#f8d0d0"), (20, "#fae0cd"), (40, "#fdf3cd"), (60, "#e8f2d5"), (80, "#d5ecd8")) +] + +app = Dash(__name__) +ROWS = load_rows() + + +def _table_data(latest_only): + rows = [row for row in ROWS if row["is_latest"]] if latest_only else ROWS + return summarize(rows) + + +app.layout = html.Div( + style={"maxWidth": "1400px", "margin": "0 auto", "fontFamily": "system-ui, sans-serif"}, + children=[ + html.H1("Normalization by source and prefix"), + html.P( + "Prefixes are pooled case-insensitively, because NodeNorm resolves CURIE " + "prefixes case-insensitively. Sort by Failed to rank by how many CURIEs are " + "actually at stake rather than by percentage.", + ), + dcc.Checklist( + id="latest-only", + options=[{"label": " Latest build per source only", "value": "latest"}], + value=["latest"], + ), + html.Div(id="row-count", style={"margin": "8px 0", "color": "#555"}), + dash_table.DataTable( + id="table", + columns=[{"name": name, "id": key} for key, name in COLUMNS], + sort_action="native", + filter_action="native", + page_size=50, + sort_by=[{"column_id": "success_rate", "direction": "asc"}], + style_cell={"fontFamily": "system-ui, sans-serif", "textAlign": "left", "padding": "4px 8px"}, + style_cell_conditional=[ + {"if": {"column_id": c}, "textAlign": "right"} + for c in ("total", "succeeded", "failed", "success_rate") + ], + style_data_conditional=RATE_SHADING, + style_header={"fontWeight": "600"}, + ), + ], +) + + +@callback( + Output("table", "data"), + Output("row-count", "children"), + Input("latest-only", "value"), +) +def update_table(latest_only): + data = _table_data("latest" in latest_only) + return data, f"{len(data)} rows across {len({row['source'] for row in data})} sources" + + +def main() -> None: + app.run(debug=True) diff --git a/uv.lock b/uv.lock index 0f77a2f..e1ba457 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,569 @@ version = 1 revision = 3 requires-python = ">=3.12" +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/31/4971872b3ed8715346231fb6eb4da8fcba65a4143c189db151ee28a2812b/charset_normalizer-3.5.0.tar.gz", hash = "sha256:49bd5feb59b0bf3cbf6ebcf4352e371c95b9da9bacd4449f8b64d0ad2c10a26e", size = 169295, upload-time = "2026-08-12T14:35:31.624Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/3c/045ea64ea5a550870dd8ab60b2242870328d53f17d2be593b4f9f3121474/charset_normalizer-3.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:98820e1ceb25c6df7a80c4fd8efa59cb121f99bc7c4c1693ad94a2caff5b311d", size = 343861, upload-time = "2026-08-12T14:32:27.254Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1b/7502be709db899d5b4801509829188b3a5a10969411da9c846115a5f1b70/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:608553f476fca509537e804c4a71f5eb166ce63b75141f89c2c686ce1aa36956", size = 237550, upload-time = "2026-08-12T14:32:28.385Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ce/66392661375148d9455c17bee25509a54e28c39969e34befa48ec8777936/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6753de11eef42f1c321b26d682957d92c7f7bbce6530f34bbe0f9291dd37cc6f", size = 229673, upload-time = "2026-08-12T14:32:29.668Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/f58c32a8d4ecf55b82ee61ee9aa6a664d4afcd36c72feb4c926fd6fe9af8/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f76dc0a47f94cb9b69d86f01e477f4b0371ca70208b9ccea7e063c41eed9046", size = 260768, upload-time = "2026-08-12T14:32:30.891Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ea/36c90e59a96386174377e855479ec154221ef001e96637e0b23be92489c4/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c387c6bf91b4774e359a48a179e2872b8e8bf741e4fde06ba8d1665eb9a4760a", size = 257880, upload-time = "2026-08-12T14:32:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/23/35/5b85772eb82528ef22ba29487ad544a7049dfd27f35b1a5a55dbc0843048/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14f6904a3cf870abf044df3a8c4924ac6c8ef77e9896586fd37e73ae96cff2af", size = 247547, upload-time = "2026-08-12T14:32:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/b6/14/ba11a99c2a22ab04c2d5383a700b378cb463a78ab15f36444cabc10cd671/charset_normalizer-3.5.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cce46dd29d73e135e8087b96eb62a4aca6d69391b7f97808c6588ebed3178f3", size = 243326, upload-time = "2026-08-12T14:32:34.794Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4a/cadba3f2400b45aa1d62a4ae0298bf58a3b30b1158baf15c338c7ce5b601/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b476cdb63df22da2b91837593380be3ddbe406f36c506c1c91d80e7196b66288", size = 238820, upload-time = "2026-08-12T14:32:35.984Z" }, + { url = "https://files.pythonhosted.org/packages/47/41/d5188b9342d75b72c2b05d3ee373f01a691397e770f001ee05e3b37925f5/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1f56ce84b317ef2a59d7d3461891c7597c79247d2192bb8114c68a1a1debfcc0", size = 231661, upload-time = "2026-08-12T14:32:37.191Z" }, + { url = "https://files.pythonhosted.org/packages/13/5f/df38fa972c4e945c3d8cee2bc4e610613af522fd359c7dc7a74c419f0278/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9ce0f885239357379d92fd9a5fddbe20f0e30e0527c29ba69f8e99eeb1304a76", size = 261459, upload-time = "2026-08-12T14:32:38.369Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d7/043ff7720067a3beee05523465ac9c1c846c68b7884930dd483f72ee5ab6/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:96ae7ab5d8155fde927aa0864fbc8ba3cc4fde6d41ab0c7cea9d6012b4978603", size = 242300, upload-time = "2026-08-12T14:32:39.505Z" }, + { url = "https://files.pythonhosted.org/packages/19/f6/33980b7b802a048e546a6d9ad2ea783a6cf6b10a86aaccd10db462d8b913/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bf91921009025e96ce57a03ced6d14604fc3baf0530351638e9504a55da6fa3b", size = 259101, upload-time = "2026-08-12T14:32:41.02Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b7/0d19bde844bff9165377c1da9ef3c4792a4c24bd49b5b7094d9e6f6ab58b/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0b2e44e6d42d1a4ff78ccc219a93c5449105d10b16198d1aea581080df8073f9", size = 249246, upload-time = "2026-08-12T14:32:42.47Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cc/2c34fdfaacdf0e96e880ef562cbf80a9b5f8ea97e0dd9e57ba348a9c65cf/charset_normalizer-3.5.0-cp312-cp312-win32.whl", hash = "sha256:deb99535e9bf0bea8e274c6413eb939a21be35a3f492678dba4d5b1f4d70f142", size = 178025, upload-time = "2026-08-12T14:32:43.909Z" }, + { url = "https://files.pythonhosted.org/packages/76/d0/c34dbd1df23bcbdc1b5d2f48256340d72fa747f1eb03924a9d2fa35ed85b/charset_normalizer-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54dd1a66fa4bce0ccaf0db9dde336e49b3eec646dc4c1c0991279369d373a14", size = 200143, upload-time = "2026-08-12T14:32:45.254Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/4e6bf1465d60c3d8f488d5bde140d0cb91cd23ccab0dc2895cc6c6982047/charset_normalizer-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:b8ea208b304587d47931b36481342d20336e0d338ab052f8b4305926482598d6", size = 180046, upload-time = "2026-08-12T14:32:46.545Z" }, + { url = "https://files.pythonhosted.org/packages/1d/be/cc7b7b6fc41984902c0d31b06f5d9297e67705c1dae9352608e5540fad09/charset_normalizer-3.5.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:5c23fa4f6eccdd601949cb00f3988c01d64e671d8faba356397971077022e144", size = 211050, upload-time = "2026-08-12T14:32:47.81Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ae/e3ec8f17313609f43f7b323012fdb1ee37b83432277ca4eceba83e00366c/charset_normalizer-3.5.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:07f6f42b5a6325df35b458004fb5f9f29bf502d89287a33c7cdef3590e31de0f", size = 222768, upload-time = "2026-08-12T14:32:49.027Z" }, + { url = "https://files.pythonhosted.org/packages/c5/50/9f9c0d7ccc1512d49e27a0e7c12c58ec71dfe91698fa4326f058c33e1f1b/charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8efc3f1563ed431882dd0dc0411b5f8ace1b1b89074981deaf6bd8af77dbe1bc", size = 193907, upload-time = "2026-08-12T14:32:50.414Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1d/cfe7b745ef7f4c3b7214581955b5a0869ba2ac551a58fc11036281ae167c/charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:368eb2fc9482158b3a3386e8f01fa61f479c968e9a19ceab8f0188b86b312991", size = 197135, upload-time = "2026-08-12T14:32:51.605Z" }, + { url = "https://files.pythonhosted.org/packages/28/55/30fafdcfca9ba616bc394240545e4cd52f4f66dea43ded81b7d2d5274fde/charset_normalizer-3.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:826a295a039178479a325be1ae60eded1f0b10f7dda749df59e2440de8f61d64", size = 339892, upload-time = "2026-08-12T14:32:52.82Z" }, + { url = "https://files.pythonhosted.org/packages/f4/08/bdca5fc2bdc36ee443673dc7d12b23885a5a7b282bef85a1a4c3b325b40e/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70ff1c16eb0eb5ee6bb12739292347f981a5ba764cc4df1bc2e69b0405d4ac3b", size = 239439, upload-time = "2026-08-12T14:32:54.058Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9e/506c8d7a7722bba7c8cdd78c1b5ef23bda92bfbe0b3e28ea84673d519a0f/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3cfdab178a4add5483e26a9bb1c16d8018ccf39b4be7a3aea6c3979e6828f2ee", size = 227896, upload-time = "2026-08-12T14:32:55.326Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/dd828f2b1d6f4bf10821b9a74d866be05ffcdbfcddfc501d6fe6428762a7/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6083d10a846218502d664375b9448508d9fa580bd834567423156c6abfbe899d", size = 262548, upload-time = "2026-08-12T14:32:56.484Z" }, + { url = "https://files.pythonhosted.org/packages/be/81/196d26f6bd78b93e0d451b69082a71027ceeddd4b0be9170b81bb038f824/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f211c21aa316cb6e2662e54a1194633a79d98a50a876addacfce7ba5b34b09f", size = 259986, upload-time = "2026-08-12T14:32:57.661Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6a/5b964a1eb0f9075ecd45083eeb21aaec215334f98bac3d400302ea73875d/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b08ebf9488c7ff5eff038e48e6ea938178dfd9dcc8598b5ca941e4ae27b20be", size = 249853, upload-time = "2026-08-12T14:32:59.123Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b4/aee3a9d82edd0e931091ef3e9f03e46491ae3590e96e998d0975dadbe17c/charset_normalizer-3.5.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95450fce59f00c6d08eff6572ec2e736e5054c9450253afd5748f8416f2eb9", size = 244217, upload-time = "2026-08-12T14:33:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/22/3e/33f72ca11c1b619b220fd9f35905ebd171cbd0e0470f2357e467b9e861ee/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5780a29823e1d2bec69b7a104ead4195a43f3e97782efaedbf1f79a0157af715", size = 241307, upload-time = "2026-08-12T14:33:01.589Z" }, + { url = "https://files.pythonhosted.org/packages/78/27/6029dccba958621c7f3a65136f87c5512d712aef9e890f09512cc171bd03/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:054420b5db984971d886e5e4e2c37c760ae6682aedbd066687ff0949d9ed5f08", size = 233305, upload-time = "2026-08-12T14:33:02.866Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/691c967be459153fe9faf49bf78bc95639ef8bf6dd008f38cc6389a349eb/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d016dc857136c726958102c3b8a3986acdc65ace6fbf12cfdc09cc4bfa2935b2", size = 263465, upload-time = "2026-08-12T14:33:04.166Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2d/9202221be5c90b2a835924191e362690ed8dc8c7d6606100c2bd03fe0f8c/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ce3d39fb4a9d674e6639dd5d3146b2e273475d2260f10163228d66fc04433d", size = 245060, upload-time = "2026-08-12T14:33:05.325Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9a/298772fd0a0cbccadf36451a1cd7eef4b66a11e99b4a7f6fafc47cc62c75/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5613a3a82c974227bde18f03409e30c467f8065cb56d822e3eb83708a5f223d", size = 261091, upload-time = "2026-08-12T14:33:06.475Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/1a11fe66e555dbe2f5714ade6ba74fa29edc9155d9cf1001d4d6ed096aa7/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fded2e82ff082e5d8e017e2ddcc1411bd8cb83b8585097fc401ef574f756b888", size = 251857, upload-time = "2026-08-12T14:33:07.784Z" }, + { url = "https://files.pythonhosted.org/packages/17/fc/73b817e8af3f1d25ec5cf458d405abba5a144cf9812238a61530f5eac186/charset_normalizer-3.5.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:8f006866047c6ec4b627ec144b1e0bbc7427cb31fd7c08d19897d0ac9032af3d", size = 139745, upload-time = "2026-08-12T14:33:09.123Z" }, + { url = "https://files.pythonhosted.org/packages/b3/fb/ddb66303c86f7dc5043a457dad9fa82b4d6d0cb97094f9bcdde21693fd58/charset_normalizer-3.5.0-cp313-cp313-win32.whl", hash = "sha256:196e270c4e80827b5072eed7d6aa661d133afada94fe366669f9609e718d305e", size = 177217, upload-time = "2026-08-12T14:33:10.305Z" }, + { url = "https://files.pythonhosted.org/packages/fb/88/6018cc8d76ea2b7cb02918f37e23e86c261d1a102713d7e88d2cfb8b211c/charset_normalizer-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:72982d9958a42f8132bf2d6b90214ed66477295ef1188731f98ae3511c6eeb5a", size = 198896, upload-time = "2026-08-12T14:33:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/20/2e/04c0bbfc8d9abf91959f7a3d207d45cbf63a8116984caae2381890019bb5/charset_normalizer-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:0b373bab0b867b68b8eb249da9478cab9181a42993437cd2f5dba5fb0b4fbd1b", size = 179193, upload-time = "2026-08-12T14:33:12.726Z" }, + { url = "https://files.pythonhosted.org/packages/43/14/d098868dac5ff27e0258f548b1c74c6484be528384965d8fcf8fc6a4011d/charset_normalizer-3.5.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d95244906ed69d0f79f190893c65e336c15959003e21449256dc05c001b52ea2", size = 211664, upload-time = "2026-08-12T14:33:14.153Z" }, + { url = "https://files.pythonhosted.org/packages/e7/da/a944b32a46601ae5a4c3499e8d64ecd14fe82313f00da74dcdf00273a0b4/charset_normalizer-3.5.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d788e2ded0c4c47efa4d73cfe59eaf975ee32f425219873d2cb3e3fbaa00f636", size = 224375, upload-time = "2026-08-12T14:33:15.472Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/eabb5996be2f529744755e7b2fc9396eff4a64961f034e7fd49d54b9afb2/charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:f9f91d3e8382900f3a68fa0ce94294479de9cd2de6bc0c70acd0f0dfd511836b", size = 194364, upload-time = "2026-08-12T14:33:16.607Z" }, + { url = "https://files.pythonhosted.org/packages/78/65/4ad3c5be108930310d8003f5602861d5b89f728293b9f09c3a4837f7ba10/charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d54625cbf4e6b60bf0639728cb8b4cb541e340f6d7cafae5806051a40ddf4c45", size = 197643, upload-time = "2026-08-12T14:33:17.88Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8fee3201b98d52289be60a775797d69be05a04fb6cfb48c1587dad33e649/charset_normalizer-3.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1f99a8c3a1da5d955edbad18208b3d627bdd54c48a6e739fa877bdca98c686d6", size = 341384, upload-time = "2026-08-12T14:33:19.239Z" }, + { url = "https://files.pythonhosted.org/packages/a7/dd/9e757101d1f76c35c0643684ba499ac3a181fb2b264c68174bf727d627e8/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf1e75dc07a3850b53d1e5f75e04d3ae12afe56284be7821771eaa2466350c73", size = 241637, upload-time = "2026-08-12T14:33:20.619Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/7857023015400bc4aa0a82fbcca29fa2dc7ec25f971a130764cb2dc7a589/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ac5a9cc079c67d75f4ddf343276031879eadbb333d1bb231cce297b8d7b9aae8", size = 226170, upload-time = "2026-08-12T14:33:21.773Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ae/8b52935b304f7b6bbf33151ed2b75266b09aa4b6f8f04230d948885b2577/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0dfe83c1b4d00abbf433998117a14f56a5c2bc68226c0d331709eed0d1ce539b", size = 265093, upload-time = "2026-08-12T14:33:22.999Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/6e838f6bb059f2c0afc60a4e7f294252f043c254656ad4114c50302cae4d/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e8fa586df2208ef040684751345f10f503834a757c9a74ecd19c1a2f9b1ccd", size = 262789, upload-time = "2026-08-12T14:33:24.214Z" }, + { url = "https://files.pythonhosted.org/packages/c2/08/189b27e51fddc9d6b3695331da0e31792c1d88b953ad854e57f06e9b2cc8/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82cc5835997ec78afe293a192e385099355770a7db94b2fb1239d36b32796f1c", size = 250580, upload-time = "2026-08-12T14:33:25.707Z" }, + { url = "https://files.pythonhosted.org/packages/ac/55/64854e99b25841f83e8e37d9df2f3d1f96f693439f80e5fabd542a7e47ab/charset_normalizer-3.5.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19e52bda45086df8a4be4bb5910af6f5d9d3b538c78712c8ae09ef10b85bf458", size = 245008, upload-time = "2026-08-12T14:33:26.971Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/7720c904fa635d4260b4dced6029cf3d298c57b26741365d5a8d28c54043/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3418edd0ecb72a0a3861cf72f31be0ad9b7fe338ce2b58fb5cc80b9aeb792700", size = 243892, upload-time = "2026-08-12T14:33:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/70/50/7bfcb327631d4870c720872b548745f6ec8baa044d51c21b5d1d32ac4e3a/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:125ee619611019471b177c70bc3e9d4cda9fad7e01d93523501d3b188df0193a", size = 230996, upload-time = "2026-08-12T14:33:29.511Z" }, + { url = "https://files.pythonhosted.org/packages/24/51/40c45d6d940c04005ed721aa54bdebf1ebb2930f8a2ae537e8d60484fb27/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a3ad0e3da22852533858663848608f3f24c0d35e5cde415a4903476f2b4c88ec", size = 265834, upload-time = "2026-08-12T14:33:30.689Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/ef7a227ef89d215b47f9df79c3966610b17faa13bb2f236989207a631622/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4ebebb410bc517e1d284c52a123e82704b21e4e7e26a21ebecf7439d0647b8a3", size = 245544, upload-time = "2026-08-12T14:33:31.859Z" }, + { url = "https://files.pythonhosted.org/packages/37/a9/a4ca9156964ded61c7718eba410ce11be2fd2b263fda4bcf08367b6578cd/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:91f9f7c151e772acebe489eaec96e96a2877202d7dd144e3f96b8676881715a0", size = 264110, upload-time = "2026-08-12T14:33:33.13Z" }, + { url = "https://files.pythonhosted.org/packages/38/6a/838364bb8702229c6e5f8b23f80ff0f052a12dfaf3113a12fd6acbe92a44/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:401ea6e7af9e7852ed818f64714b579c1935482049670847ca3bd7ba45dc63fb", size = 252303, upload-time = "2026-08-12T14:33:34.98Z" }, + { url = "https://files.pythonhosted.org/packages/9c/5f/d88032edce951f499a2321cf7ae0d35a043c74be12bc22d81084cc7afbcc/charset_normalizer-3.5.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f7496aed56b06325a1ad419c5bf23c6dd042558e874f71dd1b958f3e255f3053", size = 139964, upload-time = "2026-08-12T14:33:36.195Z" }, + { url = "https://files.pythonhosted.org/packages/37/ae/1c4a46b6b00d1c34d2ee355ef99ad6173674166800d1af0f05f85028d513/charset_normalizer-3.5.0-cp314-cp314-win32.whl", hash = "sha256:606a86c1c3196f3738de39a67a7490bbd61cb31c0e0436070bd0c6a48170b38e", size = 179790, upload-time = "2026-08-12T14:33:37.356Z" }, + { url = "https://files.pythonhosted.org/packages/01/51/f94dcf34fa8eba48c1fb89b6490a5f1426e19488fe5f38aac6c648c99057/charset_normalizer-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ec6c464cf45867f66a2273e2214d9199a8fbad5cb95ca0fd45f6a2fe1d9d2cf4", size = 203723, upload-time = "2026-08-12T14:33:38.639Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ba/91d386870b5d9e4b0d8c4034f63877cc2e47b99c81ef05f3e6d42bf9a53f/charset_normalizer-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:dc28949de1bb5f7f30a46f15d74ce7ac5aaa63e03c5de04d68f571c7423af834", size = 183423, upload-time = "2026-08-12T14:33:39.899Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c9/534ecb17b7fb95f9052c4a44cf316316a27d4a8f73e8475ff55e778dcdd7/charset_normalizer-3.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:68b7e84ae8239a94f8d2c8f3f3a3a81bcde54805ec8f42a34de927d155688ec6", size = 368967, upload-time = "2026-08-12T14:33:41.093Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/ad7c3242d7fe55cd55126c22c65cb1b49779782cdf8932fd01d12232d86a/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58ca5dc0a0ef99f2801ec0574214c978e9574055bc783830bbb6e7433218609f", size = 239478, upload-time = "2026-08-12T14:33:42.428Z" }, + { url = "https://files.pythonhosted.org/packages/7e/62/77f0b850048e430fc350ec58876b0c020f5c8d0d3956fd1a4d6ae2fa292f/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6c57af4084c10cb3286688d65e4c654190ff5edcbc2411d08cdca0a8a44c59a1", size = 227036, upload-time = "2026-08-12T14:33:43.635Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ba/47d951e1a51dddbaad0a1410baf49fb1d897ceb00281568f1183b79bce9a/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1619a3cc174a7e3963dd34348e6fceb6e50db0ddeb0031bd7c73a58286454fa", size = 260772, upload-time = "2026-08-12T14:33:44.96Z" }, + { url = "https://files.pythonhosted.org/packages/b0/61/8c7ff4c81b2a88271126acf4b83ab3e31f6d63868b0f01d331eaa0f9cb67/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1328cc57dd4372be1265f68232cee890e087416e3e6e93e6ffb32c2bad4d36a4", size = 259273, upload-time = "2026-08-12T14:33:46.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fd/36129689be08dc287b951306946657ff70d76e287dd57018861f86d0e474/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06f4fb62a9139bef056b8b2da6773c94c2f259f90e4b8e53b166f3d0372d7cf6", size = 248086, upload-time = "2026-08-12T14:33:47.54Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f8/bcae67f994c8fd31dda445e5ebf84045823c31443fe46f0e9ee6aca99aa0/charset_normalizer-3.5.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:478650a70a750d75d5add401606c77f77069c32e4ba2c9131dc6cee566962ca0", size = 242671, upload-time = "2026-08-12T14:33:48.746Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/0472cdad1061c2f0e4d3aee29973eb6e81bb8fe256ff2860cf115b15f1c9/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:48920bf6fe83eb2226756ac623fa54940487154eb18f80889d5735cf234965c0", size = 241311, upload-time = "2026-08-12T14:33:50.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/89/04a03de5d27c77c624d9fcf6287073754bd438df1b58cb7d030c57c2824d/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:168a0cb536b5123a77bc42ecf5e0bf6f923d0d9ae43c42a14eb0677c19ac6c19", size = 229898, upload-time = "2026-08-12T14:33:51.523Z" }, + { url = "https://files.pythonhosted.org/packages/42/a2/639c4278adcb7ed1f4db608dd9ac19b6774fa2285a96b1c0bdb9c124ccbd/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f278e131afa96a3622cef9211c406ea2ad1b68eb06f8837cd443684a40e0ae50", size = 262852, upload-time = "2026-08-12T14:33:52.924Z" }, + { url = "https://files.pythonhosted.org/packages/9d/95/02e34c97bedfd0c5574efb9179c850591acc7f967ba039ed8dd29d332b73/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d6100f877d2ed95f0856a3fde25334153add94bf2224c43f45f88e7039262aaa", size = 242913, upload-time = "2026-08-12T14:33:54.18Z" }, + { url = "https://files.pythonhosted.org/packages/a3/64/0946aeab6462dad9f160a50dfb4704d3f58a5ee708f085abc2105fbbff0c/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4c440122e1ea68b1f8b44a631ebf49c39180f6869b1da22d76e8a724208ec6e9", size = 257938, upload-time = "2026-08-12T14:33:55.802Z" }, + { url = "https://files.pythonhosted.org/packages/79/77/36787d41ead124746506a4425c729f4f17c68280af8a6a5baa0a598cae86/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e5f834965c2fe589837bac1002e07e25734ff70381903ccd95b3d649e22bfa40", size = 249467, upload-time = "2026-08-12T14:33:57.081Z" }, + { url = "https://files.pythonhosted.org/packages/65/10/d9f6c5589cd24198d4ce6cd2948191c18e657272f433e5a00d258d9f5c22/charset_normalizer-3.5.0-cp314-cp314t-win32.whl", hash = "sha256:076cf9d3f3c7e410295c09d96355cf3b1bcae74990034d80e4371e20fe1ba4c6", size = 190624, upload-time = "2026-08-12T14:33:58.449Z" }, + { url = "https://files.pythonhosted.org/packages/6c/81/43e0584a802051a22c725795ebe1df78263abc7de858eef6cdc9b36637e9/charset_normalizer-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3288a560dc3114d5d2ebe309b1ef43f8af355eafe25856832415c2a8196c9db3", size = 215902, upload-time = "2026-08-12T14:33:59.753Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/af6a1160fef0eac4510d035241e11eccf78e5350e4cd4de79e79fe02a5e5/charset_normalizer-3.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a284c36b9c6616bf0a8aa4aabba668a0c75ba65ccf40a79868aeaa69ad996897", size = 193452, upload-time = "2026-08-12T14:34:01.017Z" }, + { url = "https://files.pythonhosted.org/packages/42/a4/dee470afb7a55c4f78b6fef37306c51fed17ebf94dbe530798c91d394350/charset_normalizer-3.5.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c38d1e9bc2073b0984d2099ea647fd7f6c0d8f83a1e14e0cd32926f16e4c44ce", size = 341595, upload-time = "2026-08-12T14:34:02.4Z" }, + { url = "https://files.pythonhosted.org/packages/6a/32/9c3126dc429c6d9d7f79c52681a7c4453ed20a26267c9a8275d7ab620aba/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9f45186390aee4d1f26f723c615b67df346766c3b16df000d84d6e374f06757", size = 242177, upload-time = "2026-08-12T14:34:03.741Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9d/5b616a887301ff4cc0916b39ba44257390d3da80deeed6e8b6f2f26b14a8/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f0fde5e5100c735b2274ab898f0742a5dcde492796296cfbe7e0ad6a4cd1a396", size = 236730, upload-time = "2026-08-12T14:34:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/d6d3e70be93ebe5fabef65e4c7ac113e1d1705cbaeb5fb72467e713aca17/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d00e18e7bbf47e332ab63903d18bae31efc701b1d8cca0382b97784a621fc44", size = 265158, upload-time = "2026-08-12T14:34:06.235Z" }, + { url = "https://files.pythonhosted.org/packages/30/e7/3f1fafa87e2643257474f9c4eec609f2193a61d907dce7dd4f3f2390ebd5/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b81980668800dd1c69faad8aea6e85a8cee0e13bcd3bba7671695ff16260293", size = 262931, upload-time = "2026-08-12T14:34:07.511Z" }, + { url = "https://files.pythonhosted.org/packages/0a/df/ebeb224a949d91829e5e114c6b64372a3c792b00762a9e951ce416f3a32d/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb3e0d1345b9c0fe73673ea656375f38a78ec679c2edeae0c24800f04798a85", size = 251388, upload-time = "2026-08-12T14:34:08.949Z" }, + { url = "https://files.pythonhosted.org/packages/a0/64/9a6ce2e7acc5cf1b4636f78f82e89ff581e06a0216a40678b28bd4d832c4/charset_normalizer-3.5.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2401f7671242e921e604f609d429f6b282ea4ca787a6ffd22ed7372011ddb9d1", size = 251821, upload-time = "2026-08-12T14:34:10.138Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b1/6e69b8056f615e5ccff6b91ca16db2d47922251f016821a300c115267fef/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:96720f2aeed3434bc48f4d52fbad64ecc820cfed88915d664780ed9ba09ede78", size = 244507, upload-time = "2026-08-12T14:34:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/0f/34/02c15d6a0aa6b934dcdc136b111da63ae857b9fd51cf5505b0736337c2eb/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:c455829625df983f716cbaecbba77f2d1dc2e0e0ed1638c059cece15a279344b", size = 240951, upload-time = "2026-08-12T14:34:12.991Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/0fe893d3e1c7d111280bd6c4bd4c1e431487a1124a1bcbce78dfeda3a3a8/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:c41b067eddcfa5ee6b1169c287605be7fb6b0ea22bba6474c5bb978a668def4f", size = 266162, upload-time = "2026-08-12T14:34:14.232Z" }, + { url = "https://files.pythonhosted.org/packages/55/ea/eca03527307670f5d102c295671a800c404ca958cf94fefd10fc963a72f0/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:e31786a947b136329bfdc458c82c06d4ec539b4a4436b7da4df4aafc9902ee80", size = 251835, upload-time = "2026-08-12T14:34:15.48Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/fee5633081e595fe9e191df6f215106c791ad596eddf5e41e39b8ea0f2e2/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:c5c6d47a865147e0ae3322ce92e7fb52ba3169d94b447deda56897ea2aa6fac9", size = 264314, upload-time = "2026-08-12T14:34:16.679Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fb/17f47ae6ca35b562fb6e6f4b05f7aec6034217353eb4a23aaa3566dc7340/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c75191e3c8052045179646cb40e280800a4e0bdfda34d9c949c2f268d44e80e4", size = 253194, upload-time = "2026-08-12T14:34:17.975Z" }, + { url = "https://files.pythonhosted.org/packages/59/88/f2b0f7ebb92493e925889ff29239b3b0073ffafd91230dbfc69e5cf9389c/charset_normalizer-3.5.0-cp315-cp315-win32.whl", hash = "sha256:83b62410bd36bb1178a7d563e2ee0cf21eb1c980c912ab99c2c78f06227f1731", size = 179800, upload-time = "2026-08-12T14:34:19.409Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f0/afb5bfdea52fd943b1960403847a276b8e900c6e4cd6a38752321b4eda64/charset_normalizer-3.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:e3b9eaa99a6d8c9ace4cd303915947ef55088d4cd87c6676874f98c5c03aa040", size = 203726, upload-time = "2026-08-12T14:34:20.656Z" }, + { url = "https://files.pythonhosted.org/packages/fc/71/219783eb691aa2ec879c0e521afdfe2b826f9678eed51b9c039d03e0db2b/charset_normalizer-3.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:fec352b793cdc183cc9e7e0b6c10fd7bff38ec54ba44cc43599b9b56f7f3db2e", size = 183428, upload-time = "2026-08-12T14:34:21.975Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d0/14aef3b9f80f2593c039d897e89034635b9eb0eb44b6ce5173bbd79ff338/charset_normalizer-3.5.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c9bde7a960720c8b8e1b5ef7afaa0c9a2f3b55c44abd635b2b29dd066b298e3a", size = 368728, upload-time = "2026-08-12T14:34:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/10/fc/b249466ddbbeffa448b6597631e9091d1f01b5132ff8e7a0e21a6eb72b63/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8cd1283a9fe6c2065c807e9d5da81afe5e1e004caef39adc0d8ae86dd883698", size = 240925, upload-time = "2026-08-12T14:34:24.504Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b9/c17e72aaa1b3e1ca6c184e8025cf138ed492d01a54f85286ff7d31253a4b/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1c010dd86d3f4c4433c9634d33ce8147393b270dfa54f217f965540b8ae8e075", size = 234932, upload-time = "2026-08-12T14:34:25.822Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/f84ef0966bbe216f71029e34e7fa425a16b1682e2a40265e679dedf2b655/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5e68229977b2dea28e7061c0c0630a23f2f9f6e9c6fb38d77d3d6dbfe3768b74", size = 261733, upload-time = "2026-08-12T14:34:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/3b/73/3e887fa0781a395339355ed934ab6561ceb5bb52574160f070224039c630/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a60773eb5fda796e6e6f76b9c152d270fe59f9788a51a6ff8ba44082d8548ae4", size = 258460, upload-time = "2026-08-12T14:34:28.431Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/52ebd9849bf9e35d0b21fff115cb6543162a8e1f2f564e8f87121a336b8c/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9419f44e568f7fafcdc0b3b5c766a2364e705a9b34fb8a56b431e0d1f3f4258", size = 249894, upload-time = "2026-08-12T14:34:29.698Z" }, + { url = "https://files.pythonhosted.org/packages/85/f3/9366492b8a5fe0187de282e001d61345740cf79eb4a5f20181d769be02b5/charset_normalizer-3.5.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75e243abbb528c1a774390ed71e3f868a9f37b1373442e4bbadd401cfc505ff4", size = 249540, upload-time = "2026-08-12T14:34:30.96Z" }, + { url = "https://files.pythonhosted.org/packages/0b/af/28bb5e5dbd3e67cb9196a62781ac2b6d79492f4fc7a069b6ca7d6d6c8d58/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:54c963ce6404e52255b737e8a06d356fc762d59096ae566203a67cf2b7d050f2", size = 242734, upload-time = "2026-08-12T14:34:32.481Z" }, + { url = "https://files.pythonhosted.org/packages/26/d6/7ccfa62b53b40fc06b2d3504825aa400764740bd10cf248fdc4272441b93/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:63ea0cc840c66670183578c2630d138c0e944aeadfc33f25173ee240f5db780d", size = 239580, upload-time = "2026-08-12T14:34:33.916Z" }, + { url = "https://files.pythonhosted.org/packages/0b/82/71c0c9b046697b8da66b3acefa8d5f92d00a9ef433ad7c3522b971d0369a/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6c06875a1d4a7537bef70f659b55c6b55b9a47ec3ba8f2db610350c2d9915e6e", size = 263281, upload-time = "2026-08-12T14:34:35.333Z" }, + { url = "https://files.pythonhosted.org/packages/d6/01/d027583c869f40ba980c1c76994adbd522c360a6327e72beb44d7c267385/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:4253da1b4456b633651a8d59eb1dc7a8a8fa38241014dd7c217b353e547ae394", size = 250027, upload-time = "2026-08-12T14:34:36.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e9/6475d739e0ec8bb1236e06263dc3affaffdf947d8114ad27024932f325da/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:f044cb1cf44012184715f46584658993b5fee9344d71c4b0c455a17a299730c0", size = 257547, upload-time = "2026-08-12T14:34:38.277Z" }, + { url = "https://files.pythonhosted.org/packages/a5/60/d1f502fcaa048a2aca3ab80bfef8407659c131e4f1792fa805fec14b4960/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a17864853f7c518ae7d4b368af98f427f9396805476af40af8698560f09d7d97", size = 251718, upload-time = "2026-08-12T14:34:39.562Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/76343dcf4381a698807ff8a20d89f66bbdd9f6222b0b17740f77ab764335/charset_normalizer-3.5.0-cp315-cp315t-win32.whl", hash = "sha256:9e726478d7a213847860219d74665a6892a643ac93b8f76580f6cf9ed39996b7", size = 190757, upload-time = "2026-08-12T14:34:41.053Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ea/d18147626a1667cc773c42104ab155a4ca5d6d4d174b7a35e01062213ea5/charset_normalizer-3.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:d7229a99120c6c2792d96f4857c2648ce5530e93667a2c2388c5ef69a6b84775", size = 215431, upload-time = "2026-08-12T14:34:42.501Z" }, + { url = "https://files.pythonhosted.org/packages/47/21/4869598aae0872d94faa5933918a4fe37ab2c5af9d095786e241f9506fed/charset_normalizer-3.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:527e28a5e751d9e11369b9c5f9ab35c748eb9c109101920c7deb40d6eadf8d03", size = 193205, upload-time = "2026-08-12T14:34:43.781Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f3/7b523d807cb5e73562ef8acf21d39cdb9d704955327362c781bc3478a73d/charset_normalizer-3.5.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:5a4ee37248dfac25107c758bda99d545ce73e60b44d2dd39e4a2bb9f2831e9f5", size = 330840, upload-time = "2026-08-12T14:34:45.06Z" }, + { url = "https://files.pythonhosted.org/packages/f0/de/fc68978fe78ca97063c96d764e41ff92ca639948f319271e0ff450e577a2/charset_normalizer-3.5.0-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a864bdcacd8bff58bb4845304e031f821a3ec64b2b7259f2d409cd49c9e59ca3", size = 251862, upload-time = "2026-08-12T14:34:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/a9/cb/82b41a0ab7fb1a88065f1d78ad32696ad88ea3fe8e25b8189d08833938de/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84b736e3b391601bc47b86da381c749c0f894e9191aaca9f31f30c2632206df3", size = 239484, upload-time = "2026-08-12T14:34:47.869Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0c/19608b631f4538f908098d4a2d56a8f79a665e27cc58e9d90479761a9227/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6abb1f356fb865baeb6ebc3fadd843e9a96fbf49b9adcca55037f3cceccb7438", size = 230602, upload-time = "2026-08-12T14:34:49.265Z" }, + { url = "https://files.pythonhosted.org/packages/29/db/f648eb30e14eba301aed61e11672156f137905c1bdbb530151abe8065943/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d366548d2ee28a8cfdcc4296363978cc644a728333be9824d2de4652e83df0a", size = 259208, upload-time = "2026-08-12T14:34:50.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e0/ed2c8bdbac484d69614d6993143aeb6cb0f4dd1561c883402517b623c8ef/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d672f329ae504ee240eb39b6effb3318aa8e7e8924c0ce8eee5760b3fad98539", size = 253659, upload-time = "2026-08-12T14:34:52.11Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/b4907cb9ec5b521d9d024ced13611240b86ef065c2eb15b3ad2334dc9940/charset_normalizer-3.5.0-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c54036a518748b6c02e666f6d46c3817561998fb904c3be25b56fb4fe3dc5706", size = 248821, upload-time = "2026-08-12T14:34:53.399Z" }, + { url = "https://files.pythonhosted.org/packages/12/b2/e2d1abcfbc05822f0030869efb4e9f8a3658e13b4821796d4b62da917327/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:22a1889f1c9b752c63c36758a0c2145458e3cadb20fced7a0790002e9dd12b26", size = 240271, upload-time = "2026-08-12T14:34:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f9/4ba127ad610542fa3eabfa41c45bf12d357860a815b3566374ec0188e213/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b7eb3eab5c646d3de7dcb14a7c9caebace5249c5767da39e1761cb1576e521a3", size = 232155, upload-time = "2026-08-12T14:34:56.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/68/40613182366d00bd6dbd5f6c84a926cbd120960e038a8269e9ae7d782762/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2080aa129a28267984cdc902898993d788c995c384e285d0d19199f56760d52e", size = 259674, upload-time = "2026-08-12T14:34:57.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/53/4574a14fa4c9de4a6c9f31725354bfa40b67f653e6d594ce1654f9a41b32/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:fd68c825548a611158230e2f9222e210ceb2e3391995c0aa5865cbdf3ab4bd49", size = 246122, upload-time = "2026-08-12T14:34:59.337Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/bd8030d13d92c058ca7b2b9615bbb3169569e144db64d65c149cd45abf5e/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:d8a9316f4da85e937242642b537c6d55d7e9287dd38e5634732f8233932aff45", size = 255221, upload-time = "2026-08-12T14:35:00.71Z" }, + { url = "https://files.pythonhosted.org/packages/77/9d/10ecd3bcbe2666b3d4d4026c97b48f73990682815db516052a1e8f4a31c5/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d90254c8f609338c53ec180fcd4c4f9c16502e238e3fc88ca7fd4c2f38d445b8", size = 253450, upload-time = "2026-08-12T14:35:02.217Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/d044c4872c0938a84f87b5027a698c0e61bacfc5c3551a4e749ca9b7bc5c/charset_normalizer-3.5.0-cp37-abi3-win32.whl", hash = "sha256:8b3e9e29b8b07cc461b9ce7768db7693a93979d0dadf22046f6f3555ded2f516", size = 173594, upload-time = "2026-08-12T14:35:03.845Z" }, + { url = "https://files.pythonhosted.org/packages/10/6b/6046773901f1944b9a89436351529811ee958afc7b774563be9d74a6f0c3/charset_normalizer-3.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:0c8953d9d1617794cfc40d81179571c9ba3805dd029623a15c93f1fb70e60a74", size = 198959, upload-time = "2026-08-12T14:35:05.187Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/b57708ac92aefc8e8389d51d5178129b81f03196da61ee2c23e687b8178a/charset_normalizer-3.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:562d24ca7797c1af8852994950c2e623a907b201fc4b0ed29e92af173d3828ca", size = 267055, upload-time = "2026-08-12T14:35:06.533Z" }, + { url = "https://files.pythonhosted.org/packages/22/c7/754d09943a616937df61e4ba367c409ded2a987e872972098d51a6fcf73b/charset_normalizer-3.5.0-py3-none-any.whl", hash = "sha256:993dfcbe75a85a3784abb5084f2c41b915767c90546fcc92803cffa28611baea", size = 67943, upload-time = "2026-08-12T14:35:30.363Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + +[[package]] +name = "dash" +version = "4.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "comm" }, + { name = "flask" }, + { name = "importlib-metadata" }, + { name = "janus" }, + { name = "nest-asyncio" }, + { name = "plotly" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "retrying" }, + { name = "setuptools" }, + { name = "typing-extensions" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/67/d5cea6dedfbfde4165436fb58154480ea6358a9e71a739d3366575b5b724/dash-4.4.1.tar.gz", hash = "sha256:9356ca7856bc496c12c5b6de5978aaf783e090d07450ee395ade1c0c2cbdf816", size = 8536482, upload-time = "2026-07-21T18:58:19.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/29/498125538103a11eab9cfbbde41bacf93e14ab3ac92ae9fb4ac0ac4dd61b/dash-4.4.1-py3-none-any.whl", hash = "sha256:72120a91b10ee4d73f9446efd5d6a4ec218086feed7b1b479d2259844d1f658f", size = 8945027, upload-time = "2026-07-21T18:58:11.405Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "janus" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/7f/69884b6618be4baf6ebcacc716ee8680a842428a19f403db6d1c0bb990aa/janus-2.0.0.tar.gz", hash = "sha256:0970f38e0e725400496c834a368a67ee551dc3b5ad0a257e132f5b46f2e77770", size = 22910, upload-time = "2024-12-13T12:59:08.622Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/34/65604740edcb20e1bda6a890348ed7d282e7dd23aa00401cbe36fd0edbd9/janus-2.0.0-py3-none-any.whl", hash = "sha256:7e6449d34eab04cd016befbd7d8c0d8acaaaab67cb59e076a69149f9031745f9", size = 12161, upload-time = "2024-12-13T12:59:06.106Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "narwhals" +version = "2.24.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/1d/58946e5aab18393e793bd4add6985b95d0e01c3a2d832f38f54468b10dcd/narwhals-2.24.0.tar.gz", hash = "sha256:b5c0f684ccd9d7475b564111e319a4964abcf2baf79d3cf6b1003d06ac9b828d", size = 661143, upload-time = "2026-07-13T10:49:19.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl", hash = "sha256:42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489", size = 461030, upload-time = "2026-07-13T10:49:17.571Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + [[package]] name = "normalization-dashboard" version = "0.1.0" source = { editable = "." } +dependencies = [ + { name = "dash" }, +] + +[package.metadata] +requires-dist = [{ name = "dash", specifier = ">=4.4.1" }] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "plotly" +version = "6.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/07/795c79dbce40c39bece88e69d049babbd23ffa95b5d117f248db8ea03abb/plotly-6.9.0.tar.gz", hash = "sha256:967ad33e8c704fed051800d11d985eb206a9c795c14206b30a6f463ed9c67d0d", size = 6919903, upload-time = "2026-07-09T14:55:59.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/18/d8544811ab076f876c4892b3714f5b0dad335e1dc33aef826df431b8325d/plotly-6.9.0-py3-none-any.whl", hash = "sha256:36bebe2f1bb13884774fe61689c329071446f6ce4a8927fb1f0d6fb24f581236", size = 9909646, upload-time = "2026-07-09T14:55:55.421Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "retrying" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/5a/b17e1e257d3e6f2e7758930e1256832c9ddd576f8631781e6a072914befa/retrying-1.4.2.tar.gz", hash = "sha256:d102e75d53d8d30b88562d45361d6c6c934da06fab31bd81c0420acb97a8ba39", size = 11411, upload-time = "2025-08-03T03:35:25.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl", hash = "sha256:bbc004aeb542a74f3569aeddf42a2516efefcdaff90df0eb38fbfbf19f179f59", size = 10859, upload-time = "2025-08-03T03:35:23.829Z" }, +] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] From ff1eb4ecf148f95d6d03bdb37817cd6d398fdae8 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 17:12:22 -0400 Subject: [PATCH 09/27] Document the dashboard, prefix case-folding and the truncated success_rate Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 25 +++++++++++++++++++++++++ README.md | 19 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 80cf4e4..420d2ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,8 @@ allows several to coexist and the sync handles that without changes. - `normalization-metadata.json` — pre/post node counts, failure count, edge counts, and `normalization_by_prefix`: per source prefix `{succeeded, failed, total, success_rate, normalized_to: {target_prefix: count}}`. This is the input for goal 1. Tiny (~2–7 KB each). + Note its `success_rate` is **truncated, not rounded** (99.556 → 99.55; 128 of 587 rows differ), + so `loader.py` recomputes the rate from the counts instead of reading that field. - `normalization_failures.txt` — one unnormalized source CURIE per line. Input for goal 2. Not present in every build (e.g. ctkp, dakp have none). - `normalization_map.json` — `{"normalization_map": {"": [""] | null}}`. @@ -77,6 +79,29 @@ allows several to coexist and the sync handles that without changes. | `normalization_failures.txt` | 87 | 56 MB | | `normalization_map.json` | 90 | 914 MB | +### CURIE prefix case + +NodeNorm resolves CURIE prefixes case-insensitively — `ENSEMBL:ENSG00000139618`, `Ensembl:…` and +`ensembl:…` all return `NCBIGene:675`. So anything that summarizes must pool prefixes +case-insensitively (`loader.py` carries `prefix_key = prefix.upper()`), while individual records +keep the case as observed in the files. In the current mirror this merges 86 observed source +prefixes into 84: `Ensembl`/`ENSEMBL` and `SIGNOR`/`signor`. Both collisions happen to be across +different sources, so per-source rows never show two spellings today — the folding matters for +cross-source rollups. + +## The app + +- `src/normalization_dashboard/loader.py` — no Dash imports, returns plain `list[dict]` so a + notebook, the Dash app and a future static-JSON exporter can all reuse it. `load_rows()` gives one + row per (build, prefix); `summarize()` pools by (source, case-insensitive prefix). +- `src/normalization_dashboard/app.py` — the Dash app. `uv run normalization-dashboard`. +- `tests/test_loader.py` — invariant checks against the real mirror, no framework. + `uv run python tests/test_loader.py`. Skips cleanly if the mirror is not synced. + +Current shape of the data through the loader: 587 raw rows, 31 sources, 84 case-insensitive +prefixes, 242 summary rows (241 for latest builds only — historical builds add almost nothing at the +(source, prefix) level). + ## Local mirror `./scripts/sync-kgx-normalization.sh` mirrors those three file types into diff --git a/README.md b/README.md index 839fc6d..560bac4 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,27 @@ the prefix-summary and normalization-failure reports. **Known upstream issue:** ubergraph's 247 MB `normalization_map.json` returns 502 from KGX Storage and cannot be downloaded, so a full sync exits non-zero. See [CLAUDE.md](CLAUDE.md) for details. +## Running the dashboard + +```bash +uv run normalization-dashboard # http://127.0.0.1:8050 +``` + +A local [Dash](https://plotly.com/dash/) app. The first view is every data source × CURIE prefix, +sorted from the worst normalization rate to the best — the ranking that says which prefixes Babel +should ingest next. Sort by **Failed** instead to rank by how many CURIEs are actually at stake: a +prefix at 0% of 3 CURIEs and one at 0% of 216,000 sort identically by percentage. + +Prefixes are pooled case-insensitively, because NodeNorm resolves CURIE prefixes case-insensitively +(`ENSEMBL:`, `Ensembl:` and `ensembl:` all resolve alike); the spellings actually seen in the files +are shown in the "Observed as" column. + +It runs locally, which keeps individual CURIEs off the public web and leaves the deployment question +(GitHub Pages export, Kubernetes, or folding into another Translator dashboard) open. + ## Development ```bash uv sync +uv run python tests/test_loader.py ``` From 1321b78c4a838259c8c337452f2bbbb424864e7c Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 17:16:41 -0400 Subject: [PATCH 10/27] Note the node files as future sources of labels and Biolink types 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 --- CLAUDE.md | 35 +++++++++++++++++++++++++++++++++++ README.md | 1 + 2 files changed, 36 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 420d2ba..ebafba7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,6 +69,41 @@ allows several to coexist and the sync handles that without changes. Failures are the `null` entries, so `normalization_failures.txt` is a subset of this file's keys. Kept anyway because it's 16× smaller and directly answers goal 2. The map is what goal 3 needs. +### Node files — labels and Biolink types, not mirrored (yet) + +Two files we deliberately do **not** download, recorded here as sources of label/type information +for when a report needs it. Both are large; check the size before pulling any of them. + +**`normalization_{...}/normalized_nodes.jsonl`** — post-normalization. One JSON object per line with +the preferred CURIE, the full Biolink category ancestor list, the label, the clique members, and an +information content score: + +```json +{"id": "CHEBI:165710", "category": ["biolink:SmallMolecule", "...", "biolink:NamedThing"], + "name": "Palmitoleyl linoleate", + "equivalent_identifiers": ["CHEBI:165710", "PUBCHEM.COMPOUND:56935947", "INCHIKEY:NBQ..."], + "information_content": 100.0, "description": "..."} +``` + +This is what would let a summary report say *what* a normalized node is, not just that it +normalized. Ubergraph's alone is 384 MB. + +**`transform_{hash}/{source}_nodes.jsonl`** — the transform output, generated *before* +normalization. This is the one that says what the upstream source knew about a CURIE we could +**not** normalize, which is exactly the missing half of the unnormalized-CURIE report. How much it +knows varies sharply by source, so check before relying on it: + +| source | first line | +|---|---| +| pathbank (305 MB) | `{"id":"SMPDB:SMP0000055","category":["biolink:Pathway"],"name":"Alanine Metabolism","description":"Alanine (L-Alanine) is an α-amino acid…"}` | +| ncbi_gene (74 MB) | `{"id":"NCBIGene:1","category":["biolink:Gene"],"name":"A1BG","description":"alpha-1-B glycoprotein","full_name":…,"taxon":"NCBITaxon:9606","symbol":"A1BG"}` | +| goa (15 MB) | `{"name":"NUDT4B","id":"UniProtKB:A0A024RBG1","category":["biolink:Protein"],"description":"Diphosphoinositol polyphosphate phosphohydrolase NUDT4B","in_taxon":["NCBITaxon:9606"]}` | +| ubergraph (49 MB) | `{"id":"CHEBI:165710","category":["biolink:NamedThing"]}` — **no label, no real type** | + +So for pathbank — currently the worst source, 215,953 unnormalized PathBank/SMPDB CURIEs — the +transform output has names and descriptions and would make a genuinely actionable Babel ingest +report. For ubergraph it would add nothing; those labels live in the ontologies themselves. + ### Scale (full crawl of `data/`, Aug 2026) 90 normalization directories; a full listing crawl takes ~40s. diff --git a/README.md b/README.md index 560bac4..070e222 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ files. The sync script mirrors *only* the three normalization files, preserving layout, and deletes local files that have disappeared upstream. ```bash + brew install rclone # or apt install rclone ./scripts/sync-kgx-normalization.sh ``` From 5bb4ebe4d759add8cf3438d1f934cba66d1033d8 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 17:18:48 -0400 Subject: [PATCH 11/27] Record the total size of normalized_nodes.jsonl across builds 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 --- CLAUDE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index ebafba7..a0e4701 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,8 @@ information content score: ``` This is what would let a summary report say *what* a normalized node is, not just that it -normalized. Ubergraph's alone is 384 MB. +normalized. Ubergraph's alone is 384 MB; **5.7 GB across all 90 builds**, so mirroring these means +restricting to the latest build per source (~31 files) rather than adding them to the sync filters. **`transform_{hash}/{source}_nodes.jsonl`** — the transform output, generated *before* normalization. This is the one that says what the upstream source knew about a CURIE we could From 28c997e5a776be93ec1f53db800e928a980bf115 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 17:19:44 -0400 Subject: [PATCH 12/27] Note that data/ is the scratch space for one-off jobs, not /tmp 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 --- CLAUDE.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a0e4701..b9696ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -141,9 +141,14 @@ prefixes, 242 summary rows (241 for latest builds only — historical builds add ## Local mirror `./scripts/sync-kgx-normalization.sh` mirrors those three file types into -`data/kgx-storage.ci.transltr.io/`, named for the host it came from. `/data/` is gitignored as -scratch space. The nodes/edges files are excluded by the `--include` filters, which apply to the -listing walk, so they are never fetched. +`data/kgx-storage.ci.transltr.io/`, named for the host it came from. Mirrors go in a directory named +after the host they came from, so it stays obvious where a local copy originated. The nodes/edges +files are excluded by the `--include` filters, which apply to the listing walk, so they are never +fetched. + +`/data/` is gitignored, so use it as the scratch space for one-off jobs — intermediate results, +downloaded samples, ad-hoc query output — rather than `/tmp`. It survives reboots and stays next to +the code, so a one-off job can be picked up or re-run later instead of being redone from scratch. ## Conventions From 9db89f7ed6bc11a3fba6ca0c00c82269ae81fbbc Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 17:32:55 -0400 Subject: [PATCH 13/27] Link CURIEs out, and flag the malformed ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../biolink-model-prefix-map.json | 268 ++++++++++++++++++ src/normalization_dashboard/curie.py | 57 ++++ tests/test_curie.py | 47 +++ 3 files changed, 372 insertions(+) create mode 100644 src/normalization_dashboard/biolink-model-prefix-map.json create mode 100644 src/normalization_dashboard/curie.py create mode 100644 tests/test_curie.py diff --git a/src/normalization_dashboard/biolink-model-prefix-map.json b/src/normalization_dashboard/biolink-model-prefix-map.json new file mode 100644 index 0000000..781d845 --- /dev/null +++ b/src/normalization_dashboard/biolink-model-prefix-map.json @@ -0,0 +1,268 @@ +{ + "AGRKB": "https://www.alliancegenome.org/", + "APO": "http://purl.obolibrary.org/obo/APO_", + "AspGD": "http://www.aspergillusgenome.org/cgi-bin/locus.pl?dbid=", + "BFO": "http://purl.obolibrary.org/obo/BFO_", + "BIGG.METABOLITE": "http://identifiers.org/bigg.metabolite/", + "BIGG.REACTION": "http://identifiers.org/bigg.reaction/", + "BIOGRID": "http://identifiers.org/biogrid/", + "BIOSAMPLE": "http://identifiers.org/biosample/", + "BSPO": "http://purl.obolibrary.org/obo/BSPO_", + "BTO": "http://purl.obolibrary.org/obo/BTO_", + "CAID": "http://reg.clinicalgenome.org/redmine/projects/registry/genboree_registry/by_caid?caid=", + "CARO": "http://purl.obolibrary.org/obo/CARO_", + "CAS": "http://identifiers.org/cas/", + "CATH": "http://identifiers.org/cath/", + "CATH.SUPERFAMILY": "http://identifiers.org/cath.superfamily/", + "CDD": "http://identifiers.org/cdd/", + "CHADO": "http://gmod.org/wiki/Chado/", + "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", + "CHEMBL.COMPOUND": "http://identifiers.org/chembl.compound/", + "CHEMBL.MECHANISM": "https://www.ebi.ac.uk/chembl/mechanism/inspect/", + "CHEMBL.TARGET": "http://identifiers.org/chembl.target/", + "CID": "http://pubchem.ncbi.nlm.nih.gov/compound/", + "CIO": "http://purl.obolibrary.org/obo/CIO_", + "CL": "http://purl.obolibrary.org/obo/CL_", + "CLINICALTRIALS": "http://identifiers.org/clinicaltrials/", + "CLINVAR": "http://identifiers.org/clinvar", + "CLO": "http://purl.obolibrary.org/obo/CLO_", + "COAR_RESOURCE": "http://purl.org/coar/resource_type/", + "COG": "https://www.ncbi.nlm.nih.gov/research/cog-project/", + "CPT": "https://www.ama-assn.org/practice-management/cpt/", + "CTD": "http://ctdbase.org/", + "CTD.CHEMICAL": "http://ctdbase.org/detail.go?type=chem&acc=", + "CTD.DISEASE": "http://ctdbase.org/detail.go?type=disease&db=MESH&acc=", + "CTD.GENE": "http://ctdbase.org/detail.go?type=gene&acc=", + "ChemBank": "http://chembank.broadinstitute.org/chemistry/viewMolecule.htm?cbid=", + "ComplexPortal": "https://www.ebi.ac.uk/complexportal/complex/", + "DBSNP": "http://identifiers.org/dbsnp/", + "DDANAT": "http://purl.obolibrary.org/obo/DDANAT_", + "DGIdb": "https://www.dgidb.org/interaction_types", + "DOID": "http://purl.obolibrary.org/obo/DOID_", + "DOID-PROPERTY": "http://purl.obolibrary.org/obo/doid#", + "DRUGBANK": "http://identifiers.org/drugbank/", + "DrugCentral": "http://drugcentral.org/drugcard/", + "EC": "http://www.enzyme-database.org/query.php?ec=", + "ECO": "http://purl.obolibrary.org/obo/ECO_", + "ECTO": "http://purl.obolibrary.org/obo/ECTO_", + "EDAM-DATA": "http://edamontology.org/data_", + "EDAM-FORMAT": "http://edamontology.org/format_", + "EDAM-OPERATION": "http://edamontology.org/operation_", + "EDAM-TOPIC": "http://edamontology.org/topic_", + "EFO": "http://www.ebi.ac.uk/efo/EFO_", + "EGGNOG": "http://identifiers.org/eggnog/", + "EMAPA": "http://purl.obolibrary.org/obo/EMAPA_", + "ENSEMBL": "http://identifiers.org/ensembl/", + "ENVO": "http://purl.obolibrary.org/obo/ENVO_", + "ExO": "http://purl.obolibrary.org/obo/ExO_", + "FAO": "http://purl.obolibrary.org/obo/FAO_", + "FB": "http://identifiers.org/fb/", + "FBbt": "http://purl.obolibrary.org/obo/FBbt_", + "FBcv": "http://purl.obolibrary.org/obo/FBcv_", + "FBdv": "http://purl.obolibrary.org/obo/FBdv_", + "FMA": "http://purl.obolibrary.org/obo/FMA_", + "FOODON": "http://purl.obolibrary.org/obo/FOODON_", + "FYECO": "https://www.pombase.org/term/", + "FYPO": "http://purl.obolibrary.org/obo/FYPO_", + "GENEPIO": "http://purl.obolibrary.org/obo/GENEPIO_", + "GENO": "http://purl.obolibrary.org/obo/GENO_", + "GO": "http://purl.obolibrary.org/obo/GO_", + "GOLD.META": "http://identifiers.org/gold.meta/", + "GOP": "http://purl.obolibrary.org/obo/go#", + "GOREL": "http://purl.obolibrary.org/obo/GOREL_", + "GSID": "https://scholar.google.com/citations?user=", + "GTEx": "https://www.gtexportal.org/home/gene/", + "GTOPDB": "https://www.guidetopharmacology.org/GRAC/LigandDisplayForward?ligandId=", + "HAMAP": "http://identifiers.org/hamap/", + "HANCESTRO": "http://www.ebi.ac.uk/ancestro/ancestro_", + "HCPCS": "http://purl.bioontology.org/ontology/HCPCS/", + "HGNC": "http://identifiers.org/hgnc/", + "HGNC.FAMILY": "http://identifiers.org/hgnc.family/", + "HMDB": "http://identifiers.org/hmdb/", + "HP": "http://purl.obolibrary.org/obo/HP_", + "HsapDv": "http://purl.obolibrary.org/obo/HsapDv_", + "IAO": "http://purl.obolibrary.org/obo/IAO_", + "ICD10": "https://icd.who.int/browse10/2016/en#/", + "ICD9": "http://translator.ncats.nih.gov/ICD9_", + "IDO": "http://purl.obolibrary.org/obo/IDO_", + "INCHI": "http://identifiers.org/inchi/", + "INCHIKEY": "http://identifiers.org/inchikey/", + "INO": "http://purl.obolibrary.org/obo/INO_", + "INTACT": "http://identifiers.org/intact/", + "IUPHAR.FAMILY": "http://identifiers.org/iuphar.family/", + "KEGG": "http://www.kegg.jp/entry/", + "KEGG.BRITE": "https://bioregistry.io/kegg.brite:", + "KEGG.COMPOUND": "http://identifiers.org/kegg.compound/", + "KEGG.DISEASE": "http://identifiers.org/kegg.disease/", + "KEGG.DRUG": "http://identifiers.org/kegg.drug/", + "KEGG.ENVIRON": "http://identifiers.org/kegg.environ/", + "KEGG.GENES": "https://bioregistry.io/kegg.genes:bsu:", + "KEGG.GLYCAN": "http://identifiers.org/kegg.glycan/", + "KEGG.MODULE": "http://identifiers.org/kegg.module/", + "KEGG.ORTHOLOGY": "http://identifiers.org/kegg.orthology/", + "KEGG.PATHWAY": "https://bioregistry.io/kegg.pathway:", + "KEGG.RCLASS": "https://www.genome.jp/dbget-bin/www_bget?rc:", + "KEGG.REACTION": "http://identifiers.org/kegg.reaction/", + "LOINC": "http://loinc.org/rdf/", + "MA": "http://purl.obolibrary.org/obo/MA_", + "MAXO": "http://purl.obolibrary.org/obo/MAXO_", + "MEDDRA": "http://identifiers.org/meddra/", + "MESH": "http://id.nlm.nih.gov/mesh/", + "METANETX.REACTION": "https://www.metanetx.org/equa_info/", + "METPO": "https://w3id.org/metpo/", + "MGI": "http://identifiers.org/mgi/", + "MI": "http://purl.obolibrary.org/obo/MI_", + "MIR": "http://identifiers.org/mir/", + "MOD": "http://purl.obolibrary.org/obo/MOD_", + "MONDO": "http://purl.obolibrary.org/obo/MONDO_", + "MP": "http://purl.obolibrary.org/obo/MP_", + "MPATH": "http://purl.obolibrary.org/obo/MPATH_", + "MSigDB": "https://www.gsea-msigdb.org/gsea/msigdb/", + "MmusDv": "http://purl.obolibrary.org/obo/MMUSDV_", + "NBO": "http://purl.obolibrary.org/obo/NBO_", + "NBO-PROPERTY": "http://purl.obolibrary.org/obo/nbo#", + "NCBIGene": "http://identifiers.org/ncbigene/", + "NCBITaxon": "http://purl.obolibrary.org/obo/NCBITaxon_", + "NCIT": "http://purl.obolibrary.org/obo/NCIT_", + "NCIT-OBO": "http://purl.obolibrary.org/obo/ncit#", + "NDC": "http://identifiers.org/ndc/", + "NDDF": "http://purl.bioontology.org/ontology/NDDF/", + "NLMID": "https://www.ncbi.nlm.nih.gov/nlmcatalog/?term=", + "OBAN": "http://purl.org/oban/", + "OBI": "http://purl.obolibrary.org/obo/OBI_", + "OGMS": "http://purl.obolibrary.org/obo/OGMS_", + "OMIM": "http://purl.obolibrary.org/obo/OMIM_", + "OMIM.PS": "https://www.omim.org/phenotypicSeries/", + "OMIT": "http://purl.obolibrary.org/obo/OMIT_", + "ORCID": "https://orcid.org/", + "PANTHER.FAMILY": "http://www.pantherdb.org/panther/family.do?clsAccession=", + "PANTHER.PATHWAY": "http://identifiers.org/panther.pathway/", + "PATO": "http://purl.obolibrary.org/obo/PATO_", + "PCO": "http://purl.obolibrary.org/obo/PCO_", + "PFAM": "http://identifiers.org/pfam/", + "PHARMGKB.DISEASE": "https://www.pharmgkb.org/disease/", + "PHARMGKB.DRUG": "https://www.pharmgkb.org/chemical/", + "PHARMGKB.GENE": "https://www.pharmgkb.org/gene/", + "PHARMGKB.PATHWAYS": "https://www.pharmgkb.org/pathway/", + "PHARMGKB.VARIANT": "https://www.pharmgkb.org/variant/", + "PHAROS": "http://pharos.nih.gov", + "PIRSF": "http://identifiers.org/pirsf/", + "PMC": "http://europepmc.org/articles/PMC", + "PMCID": "http://www.ncbi.nlm.nih.gov/pmc/", + "PMID": "http://www.ncbi.nlm.nih.gov/pubmed/", + "PO": "http://purl.obolibrary.org/obo/PO_", + "PR": "http://purl.obolibrary.org/obo/PR_", + "PRINTS": "http://identifiers.org/prints/", + "PRODOM": "http://identifiers.org/prodom/", + "PROSITE": "http://identifiers.org/prosite/", + "PUBCHEM.COMPOUND": "http://identifiers.org/pubchem.compound/", + "PUBCHEM.SUBSTANCE": "http://identifiers.org/pubchem.substance/", + "PW": "http://purl.obolibrary.org/obo/PW_", + "PathWhiz": "http://smpdb.ca/pathways/#", + "PomBase": "https://www.pombase.org/gene/", + "REACT": "http://www.reactome.org/PathwayBrowser/#/", + "REPODB": "http://apps.chiragjpgroup.org/repoDB/", + "RFAM": "http://identifiers.org/rfam/", + "RGD": "http://identifiers.org/rgd/", + "RHEA": "http://identifiers.org/rhea/", + "RNACENTRAL": "http://identifiers.org/rnacentral/", + "RO": "http://purl.obolibrary.org/obo/RO_", + "RXCUI": "https://mor.nlm.nih.gov/RxNav/search?searchBy=RXCUI&searchTerm=", + "RXNORM": "http://purl.bioontology.org/ontology/RXNORM/", + "ResearchID": "https://publons.com/researcher/", + "SEED.REACTION": "https://modelseed.org/biochem/reactions/", + "SEMMEDDB": "https://skr3.nlm.nih.gov/SemMedDB", + "SEPIO": "http://purl.obolibrary.org/obo/SEPIO_", + "SGD": "http://identifiers.org/sgd/", + "SIDER.DRUG": "http://identifiers.org/sider.drug/", + "SIO": "http://semanticscience.org/resource/SIO_", + "SMART": "http://identifiers.org/smart/", + "SMPDB": "http://identifiers.org/smpdb/", + "SNOMED": "http://purl.obolibrary.org/obo/SNOMED_", + "SNOMEDCT": "http://snomed.info/id/", + "SO": "http://purl.obolibrary.org/obo/SO_", + "SPDI": "https://api.ncbi.nlm.nih.gov/variation/v0/spdi/", + "STATO": "http://purl.obolibrary.org/obo/STATO_", + "STY": "http://purl.bioontology.org/ontology/STY/", + "SUPFAM": "http://identifiers.org/supfam/", + "ScopusID": "https://www.scopus.com/authid/detail.uri?authorId=", + "TAXRANK": "http://purl.obolibrary.org/obo/TAXRANK_", + "TCDB": "http://identifiers.org/tcdb/", + "TIGRFAM": "http://identifiers.org/tigrfam/", + "TO": "http://purl.obolibrary.org/obo/TO_", + "UBERGRAPH": "http://translator.renci.org/ubergraph-axioms.ofn#", + "UBERON": "http://purl.obolibrary.org/obo/UBERON_", + "UBERON_CORE": "http://purl.obolibrary.org/obo/uberon/core#", + "UBERON_NONAMESPACE": "http://purl.obolibrary.org/obo/core#", + "UMLS": "http://identifiers.org/umls/", + "UMLSSG": "https://lhncbc.nlm.nih.gov/semanticnetwork/download/sg_archive/SemGroups-v04.txt", + "UNII": "http://identifiers.org/unii/", + "UNIMOD": "http://identifiers.org/unimod/", + "UNIPROT.ISOFORM": "http://purl.uniprot.org/isoforms/", + "UO-PROPERTY": "http://purl.obolibrary.org/obo/uo#", + "UPHENO": "http://purl.obolibrary.org/obo/UPHENO_", + "UniProtKB": "http://purl.uniprot.org/uniprot/", + "VANDF": "https://www.nlm.nih.gov/research/umls/sourcereleasedocs/current/VANDF/", + "VMC": "https://github.com/ga4gh/vr-spec/", + "WB": "http://identifiers.org/wb/", + "WBPhenotype": "http://purl.obolibrary.org/obo/WBPhenotype_", + "WBVocab": "http://bio2rdf.org/wormbase_vocabulary", + "WBbt": "http://purl.obolibrary.org/obo/WBbt_", + "WBls": "http://purl.obolibrary.org/obo/WBls_", + "WIKIDATA": "https://www.wikidata.org/entity/", + "WIKIDATA_PROPERTY": "https://www.wikidata.org/prop/", + "WIKIPATHWAYS": "http://identifiers.org/wikipathways/", + "WormBase": "https://www.wormbase.org/get?name=", + "XAO": "http://purl.obolibrary.org/obo/XAO_", + "XCO": "http://purl.obolibrary.org/obo/XCO_", + "XPO": "http://purl.obolibrary.org/obo/XPO_", + "Xenbase": "http://www.xenbase.org/gene/showgene.do?method=display&geneId=", + "ZFA": "http://purl.obolibrary.org/obo/ZFA_", + "ZFIN": "http://identifiers.org/zfin/", + "ZFS": "http://purl.obolibrary.org/obo/ZFS_", + "ZP": "http://purl.obolibrary.org/obo/ZP_", + "apollo": "https://github.com/GMOD/Apollo", + "biolink": "https://w3id.org/biolink/vocab/", + "bioschemas": "https://bioschemas.org/", + "dcat": "http://www.w3.org/ns/dcat#", + "dcid": "https://datacommons.org/browser/", + "dct": "http://purl.org/dc/terms/", + "dctypes": "http://purl.org/dc/dcmitype/", + "dictyBase": "http://dictybase.org/gene/", + "doi": "https://doi.org/", + "fabio": "http://purl.org/spar/fabio/", + "faldo": "http://biohackathon.org/resource/faldo#", + "foaf": "http://xmlns.com/foaf/0.1/", + "foodb.compound": "http://foodb.ca/compounds/", + "foodb.food": "http://foodb.ca/foods/", + "gff3": "https://github.com/The-Sequence-Ontology/Specifications/blob/master/gff3.md#", + "gpi": "https://github.com/geneontology/go-annotation/blob/master/specs/gpad-gpi-2-0.md#", + "gtpo": "https://rdf.guidetopharmacology.org/ns/gtpo#", + "icd11": "http://id.who.int/icd/entity/", + "icd11.foundation": "http://id.who.int/icd/entity/", + "interpro": "https://www.ebi.ac.uk/interpro/entry/", + "isbn": "https://www.isbn-international.org/identifier/", + "isni": "https://isni.org/isni/", + "issn": "https://portal.issn.org/resource/ISSN/", + "linkml": "https://w3id.org/linkml/", + "medgen": "https://www.ncbi.nlm.nih.gov/medgen/", + "metacyc.reaction": "http://identifiers.org/metacyc.reaction:", + "mirbase": "http://identifiers.org/mirbase", + "mmmp.biomaps": "https://bioregistry.io/mmmp.biomaps:", + "ncats.bioplanet": "https://tripod.nih.gov/bioplanet/detail.jsp?pid=", + "ncats.drug": "https://drugs.ncats.io/drug/", + "oboInOwl": "http://www.geneontology.org/formats/oboInOwl#", + "orphanet": "http://www.orpha.net/ORDO/Orphanet_", + "os": "https://github.com/cmungall/owlstar/blob/master/owlstar.ttl", + "owl": "http://www.w3.org/2002/07/owl#", + "pav": "http://purl.org/pav/", + "prov": "http://www.w3.org/ns/prov#", + "qud": "http://qudt.org/1.1/schema/qudt#", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "schema": "http://schema.org/", + "skos": "http://www.w3.org/2004/02/skos/core#", + "uspto-patent": "http://www.uspto.gov/patent/grant/v1/", + "wgs": "http://www.w3.org/2003/01/geo/wgs84_pos", + "xsd": "http://www.w3.org/2001/XMLSchema#" +} diff --git a/src/normalization_dashboard/curie.py b/src/normalization_dashboard/curie.py new file mode 100644 index 0000000..5cbd8a7 --- /dev/null +++ b/src/normalization_dashboard/curie.py @@ -0,0 +1,57 @@ +"""Turn CURIEs into links, and spot the ones that are malformed. + +A CURIE that cannot be normalized is sometimes just missing from Babel, and +sometimes malformed in a way that explains the failure by itself +(`rhea:RHEA:13065`, `CL:0000089 ∩ UBERON:0000473`, `UniProtKB:B3DHD6 Q6XCC7`). +Telling those apart is the point of this module. +""" + +import json +from pathlib import Path + +# Vendored from biolink-model so the app works offline; 266 prefixes, refresh with: +# curl -sL https://raw.githubusercontent.com/biolink/biolink-model/master/src/biolink_model/prefixmaps/biolink-model-prefix-map.json \ +# -o src/normalization_dashboard/biolink-model-prefix-map.json +PREFIX_MAP_PATH = Path(__file__).parent / "biolink-model-prefix-map.json" + +# Prefixes are matched case-insensitively, because NodeNorm resolves them that way. +PREFIX_MAP = { + prefix.upper(): uri_stem + for prefix, uri_stem in json.loads(PREFIX_MAP_PATH.read_text()).items() +} + +# Resolves nearly everything the Biolink map does not, including PathBank. +BIOREGISTRY_URL = "https://bioregistry.io/{}" + + +def malformed(curie): + """Why this is not a well-formed CURIE, or None if it looks fine.""" + if any(character.isspace() for character in curie): + return "contains whitespace" + prefix, separator, local_id = curie.partition(":") + if not separator: + return "no ':' separator" + if not prefix: + return "empty prefix" + if not local_id: + return "empty local identifier" + if ":" in local_id: + return "more than one ':'" + return None + + +def url(curie): + """A URL to look this CURIE up, or None if it is too malformed to try.""" + if malformed(curie): + return None + prefix, _, local_id = curie.partition(":") + uri_stem = PREFIX_MAP.get(prefix.upper()) + return uri_stem + local_id if uri_stem else BIOREGISTRY_URL.format(curie) + + +def as_markdown(curie): + """A table cell for one CURIE: a link, or the reason it is not linkable.""" + link = url(curie) + if link: + return f"[{curie}]({link})" + return f"`{curie}` ⚠ {malformed(curie)}" diff --git a/tests/test_curie.py b/tests/test_curie.py new file mode 100644 index 0000000..0a98ab6 --- /dev/null +++ b/tests/test_curie.py @@ -0,0 +1,47 @@ +"""Checks for CURIE linking and malformed-CURIE detection. + +Run: uv run python tests/test_curie.py + +The malformed examples are real strings taken from normalization_failures.txt. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from normalization_dashboard import curie + + +def main(): + # Well formed, prefix in the Biolink map -> link straight to the source. + assert curie.malformed("MONDO:0001034") is None + assert curie.url("MONDO:0001034") == "http://purl.obolibrary.org/obo/MONDO_0001034" + # Prefix case does not matter, NodeNorm resolves prefixes case-insensitively. + assert curie.url("mondo:0001034") == curie.url("MONDO:0001034") + + # Well formed but not in the Biolink map -> Bioregistry resolves it anyway. + # PathBank is the largest such prefix: 215,953 unnormalized CURIEs. + assert curie.url("PathBank:SMP0000055") == "https://bioregistry.io/PathBank:SMP0000055" + + # Malformed: no link, and a reason that explains the normalization failure. + for value, reason in ( + ("rhea:RHEA:13065", "more than one ':'"), + ("CL:0000089 ∩ UBERON:0000473", "contains whitespace"), + ("UniProtKB:B3DHD6 Q6XCC7", "contains whitespace"), + ("UniProtKB:", "empty local identifier"), + ("MONDO_1034", "no ':' separator"), + (":0001034", "empty prefix"), + ): + assert curie.malformed(value) == reason, f"{value}: {curie.malformed(value)}" + assert curie.url(value) is None, value + assert reason in curie.as_markdown(value) + + assert curie.as_markdown("MONDO:0001034").startswith("[MONDO:0001034](") + + print(f"OK: {len(curie.PREFIX_MAP)} Biolink prefixes loaded") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 031a8162013e579e4f6f9f13d5edaf940ceb1cad Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 17:32:55 -0400 Subject: [PATCH 14/27] Carry build URLs and failures paths onto summary rows 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 --- src/normalization_dashboard/loader.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/normalization_dashboard/loader.py b/src/normalization_dashboard/loader.py index 1580f9a..e9b84f2 100644 --- a/src/normalization_dashboard/loader.py +++ b/src/normalization_dashboard/loader.py @@ -10,6 +10,10 @@ DEFAULT_MIRROR = Path("data/kgx-storage.ci.transltr.io") +# The mirror directory is named after the host, so the remote URL of any mirrored +# file is the host plus its path under the mirror root. +KGX_STORAGE_URL = "https://kgx-storage.ci.transltr.io/data" + def _normalized_to_str(normalized_to): """'NCBIGene 91678; UniProtKB 82176; PR 79548', biggest target first.""" @@ -42,6 +46,9 @@ def load_rows(mirror=DEFAULT_MIRROR): "transform": transform.removeprefix("transform_"), "normalization": normalization.removeprefix("normalization_"), "babel_version": metadata.get("babel_version"), + "build_url": "/".join( + (KGX_STORAGE_URL, source, source_version, transform, normalization, "") + ), "prefix": prefix, "prefix_key": prefix.upper(), "total": stats["total"], @@ -113,11 +120,21 @@ def summarize(rows): for row in group: for target, count in row["normalized_to"].items(): normalized_to[target.upper()] += count + # One link per build the row pools, pointing at the normalization output + # directory these numbers came from. + build_links = { + row["source_version"]: row["build_url"] + for row in sorted(group, key=lambda row: row["source_version"]) + } summary.append( { "source": source, - "source_versions": ", ".join( - sorted({row["source_version"] for row in group}) + "source_versions": ", ".join(build_links), + "source_versions_md": ", ".join( + f"[{version}]({url})" for version, url in build_links.items() + ), + "failures_paths": sorted( + {row["failures_path"] for row in group if row["failures_path"]} ), "builds": len({(r["source_version"], r["transform"]) for r in group}), "prefix": prefix_key, From de2c7fa626a9dafa14ff68f171bd237b50ede908 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 17:32:55 -0400 Subject: [PATCH 15/27] Add CURIE drill-down and rework the table layout 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 --- src/normalization_dashboard/app.py | 146 ++++++++++++++++++++++++----- 1 file changed, 122 insertions(+), 24 deletions(-) diff --git a/src/normalization_dashboard/app.py b/src/normalization_dashboard/app.py index 6e4022a..5bab9e1 100644 --- a/src/normalization_dashboard/app.py +++ b/src/normalization_dashboard/app.py @@ -2,68 +2,103 @@ from dash import Dash, Input, Output, callback, dash_table, dcc, html +from normalization_dashboard import curie from normalization_dashboard.loader import load_rows, summarize +# (key, header, markdown?) -- CURIE Prefix leads, it is the row's subject. COLUMNS = [ - ("source", "Source"), - ("source_versions", "Version"), - ("prefix", "Prefix"), - ("observed_as", "Observed as"), - ("total", "Total"), - ("succeeded", "Succeeded"), - ("failed", "Failed"), - ("success_rate", "Success %"), - ("normalized_to_str", "Normalized to"), + ("prefix", "CURIE Prefix", False), + ("source", "Source", False), + ("source_versions_md", "Version", True), + ("observed_as", "Observed as", False), + ("total", "Total", False), + ("succeeded", "Succeeded", False), + ("failed", "Failed", False), + ("success_rate", "Success %", False), + ("normalized_to_str", "Normalized to", False), ] # Red at 0% shading to green at 100%, so the ranking is scannable without reading numbers. RATE_SHADING = [ { - "if": {"column_id": "success_rate", "filter_query": f"{{success_rate}} >= {low} && {{success_rate}} < {low + 20}"}, + "if": { + "column_id": "success_rate", + "filter_query": f"{{success_rate}} >= {low} && {{success_rate}} < {low + 20}", + }, "backgroundColor": colour, } - for low, colour in ((0, "#f8d0d0"), (20, "#fae0cd"), (40, "#fdf3cd"), (60, "#e8f2d5"), (80, "#d5ecd8")) + for low, colour in ( + (0, "#f8d0d0"), + (20, "#fae0cd"), + (40, "#fdf3cd"), + (60, "#e8f2d5"), + (80, "#d5ecd8"), + ) ] +# The prefix column is effectively the row header, so it reads like one. +PREFIX_STYLE = { + "if": {"column_id": "prefix"}, + "fontWeight": "600", + "fontFamily": "ui-monospace, monospace", + "backgroundColor": "#f4f4f6", +} + +MAX_CURIES_SHOWN = 300 + app = Dash(__name__) ROWS = load_rows() -def _table_data(latest_only): +def table_rows(latest_only, hide_complete): rows = [row for row in ROWS if row["is_latest"]] if latest_only else ROWS - return summarize(rows) + summary = summarize(rows) + if hide_complete: + summary = [row for row in summary if row["success_rate"] < 100] + return summary app.layout = html.Div( style={"maxWidth": "1400px", "margin": "0 auto", "fontFamily": "system-ui, sans-serif"}, children=[ - html.H1("Normalization by source and prefix"), + html.H1("Normalization by source and CURIE prefix"), html.P( "Prefixes are pooled case-insensitively, because NodeNorm resolves CURIE " "prefixes case-insensitively. Sort by Failed to rank by how many CURIEs are " - "actually at stake rather than by percentage.", + "actually at stake rather than by percentage. Click a row to list the CURIEs " + "that failed to normalize." ), dcc.Checklist( id="latest-only", options=[{"label": " Latest build per source only", "value": "latest"}], value=["latest"], ), + dcc.Checklist(id="hide-complete", options=[], value=["hide"]), html.Div(id="row-count", style={"margin": "8px 0", "color": "#555"}), dash_table.DataTable( id="table", - columns=[{"name": name, "id": key} for key, name in COLUMNS], + columns=[ + {"name": name, "id": key, **({"presentation": "markdown"} if md else {})} + for key, name, md in COLUMNS + ], + markdown_options={"link_target": "_blank"}, sort_action="native", filter_action="native", - page_size=50, + page_size=75, sort_by=[{"column_id": "success_rate", "direction": "asc"}], - style_cell={"fontFamily": "system-ui, sans-serif", "textAlign": "left", "padding": "4px 8px"}, + style_cell={ + "fontFamily": "system-ui, sans-serif", + "textAlign": "left", + "padding": "4px 8px", + }, style_cell_conditional=[ - {"if": {"column_id": c}, "textAlign": "right"} - for c in ("total", "succeeded", "failed", "success_rate") + {"if": {"column_id": column}, "textAlign": "right"} + for column in ("total", "succeeded", "failed", "success_rate") ], - style_data_conditional=RATE_SHADING, + style_data_conditional=[PREFIX_STYLE, *RATE_SHADING], style_header={"fontWeight": "600"}, ), + html.Div(id="failures", style={"marginTop": "24px"}), ], ) @@ -71,11 +106,74 @@ def _table_data(latest_only): @callback( Output("table", "data"), Output("row-count", "children"), + Output("hide-complete", "options"), Input("latest-only", "value"), + Input("hide-complete", "value"), +) +def update_table(latest_only, hide_complete): + latest_only = "latest" in latest_only + data = table_rows(latest_only, "hide" in hide_complete) + complete = sum(1 for row in table_rows(latest_only, False) if row["success_rate"] == 100) + return ( + data, + f"{len(data)} rows across {len({row['source'] for row in data})} sources", + [{"label": f" Hide prefixes that fully normalize (n={complete})", "value": "hide"}], + ) + + +@callback( + Output("failures", "children"), + Input("table", "active_cell"), + # The viewport, not `data`: active_cell.row indexes the sorted, filtered page. + Input("table", "derived_viewport_data"), ) -def update_table(latest_only): - data = _table_data("latest" in latest_only) - return data, f"{len(data)} rows across {len({row['source'] for row in data})} sources" +def show_failures(active_cell, data): + """List the unnormalized CURIEs behind the clicked row. + + Read on demand rather than indexed up front: the biggest failures file is + under 7 MB, and preloading all 87 of them would cost 3.2M lines for nothing. + """ + if not active_cell or active_cell["row"] >= len(data): + return None + row = data[active_cell["row"]] + + prefix = row["prefix"] + curies = [] + for path in row["failures_paths"]: + with open(path) as failures: + curies.extend( + line.strip() + for line in failures + if line.strip() and line.split(":")[0].strip().upper() == prefix + ) + curies = sorted(set(curies)) + + if not curies: + return html.P( + f"No unnormalized CURIEs recorded for {prefix} in {row['source']} " + "(some builds ship no normalization_failures.txt).", + style={"color": "#555"}, + ) + + shown = curies[:MAX_CURIES_SHOWN] + malformed = [c for c in shown if curie.malformed(c)] + heading = f"{len(curies):,} unnormalized {prefix} CURIEs in {row['source']}" + if len(shown) < len(curies): + heading += f" (showing the first {len(shown):,})" + return [ + html.H2(heading, style={"fontSize": "1.2em"}), + html.P( + f"{len(malformed)} of these are malformed, which may be the whole explanation.", + style={"color": "#a33"}, + ) + if malformed + else None, + dcc.Markdown( + " · ".join(curie.as_markdown(c) for c in shown), + link_target="_blank", + style={"lineHeight": "2"}, + ), + ] def main() -> None: From e1ab3d6a2b192f965326e0d1ce25ad414ba76b6d Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 17:32:55 -0400 Subject: [PATCH 16/27] Document CURIE linking, the vendored prefix map and the drill-down Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 28 ++++++++++++++++++++++++++-- README.md | 9 ++++++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b9696ba..0fc3ffb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,8 +131,32 @@ cross-source rollups. notebook, the Dash app and a future static-JSON exporter can all reuse it. `load_rows()` gives one row per (build, prefix); `summarize()` pools by (source, case-insensitive prefix). - `src/normalization_dashboard/app.py` — the Dash app. `uv run normalization-dashboard`. -- `tests/test_loader.py` — invariant checks against the real mirror, no framework. - `uv run python tests/test_loader.py`. Skips cleanly if the mirror is not synced. +- `src/normalization_dashboard/curie.py` — CURIE → URL, and malformed-CURIE detection. +- `tests/test_loader.py`, `tests/test_curie.py` — invariant checks against the real mirror, no + framework. `uv run python tests/test_loader.py`. The loader one skips cleanly if the mirror is + not synced. + +### Linking CURIEs + +`biolink-model-prefix-map.json` is **vendored** into the package (266 prefixes, 14 KB) so the app +works offline. Refresh it with: + +```bash +curl -sL https://raw.githubusercontent.com/biolink/biolink-model/master/src/biolink_model/prefixmaps/biolink-model-prefix-map.json \ + -o src/normalization_dashboard/biolink-model-prefix-map.json +``` + +The map covers 75.5% of the 898,042 distinct unnormalized CURIEs. The other 24 prefixes fall back to +`https://bioregistry.io/{curie}`, which resolves them — including PathBank, which is not in the +Biolink prefix map at all and is by itself 215,953 of those CURIEs. The `curies` package is not used: +the Biolink file is a flat prefix → URI-stem dict, so expansion is one dict lookup and a string +concat, and a case-insensitive index is a one-line comprehension. + +Malformed CURIEs get no link and a reason instead, because the malformation is often the whole +explanation for the normalization failure. Real species found in the data: `rhea:RHEA:13065` (double +prefix), `CL:0000089 ∩ UBERON:0000473` (post-composed class expression), `UniProtKB:B3DHD6 Q6XCC7` +(two accessions in one CURIE), `UniProtKB:` (empty local id). Only 24 of 898,042 are malformed at +this syntactic level — rare, but they cluster: intact's 2 RHEA failures are both double-prefixed. Current shape of the data through the loader: 587 raw rows, 31 sources, 84 case-insensitive prefixes, 242 summary rows (241 for latest builds only — historical builds add almost nothing at the diff --git a/README.md b/README.md index 070e222..838d5f8 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,14 @@ prefix at 0% of 3 CURIEs and one at 0% of 216,000 sort identically by percentage Prefixes are pooled case-insensitively, because NodeNorm resolves CURIE prefixes case-insensitively (`ENSEMBL:`, `Ensembl:` and `ensembl:` all resolve alike); the spellings actually seen in the files -are shown in the "Observed as" column. +are shown in the "Observed as" column. Prefixes that fully normalize are hidden by default, and each +version links back to the normalization output directory in KGX Storage that the numbers came from. + +**Click a row** to list the CURIEs that failed to normalize for that source and prefix. Each links +out — via the Biolink prefix map, falling back to [Bioregistry](https://bioregistry.io/) — so you can +check what the identifier actually is. CURIEs that are malformed are flagged with the reason instead +of linked, since that is often the whole explanation for the failure (`rhea:RHEA:13065`, +`CL:0000089 ∩ UBERON:0000473`, `UniProtKB:B3DHD6 Q6XCC7`). It runs locally, which keeps individual CURIEs off the public web and leaves the deployment question (GitHub Pages export, Kubernetes, or folding into another Translator dashboard) open. From 448f1fb9458a3cf7e6a1b03ac965cef70b7c2c16 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 17:37:33 -0400 Subject: [PATCH 17/27] Find the mirror regardless of the working directory 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 --- src/normalization_dashboard/loader.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/normalization_dashboard/loader.py b/src/normalization_dashboard/loader.py index e9b84f2..e058848 100644 --- a/src/normalization_dashboard/loader.py +++ b/src/normalization_dashboard/loader.py @@ -8,7 +8,23 @@ from collections import defaultdict from pathlib import Path -DEFAULT_MIRROR = Path("data/kgx-storage.ci.transltr.io") +MIRROR_NAME = Path("data/kgx-storage.ci.transltr.io") + + +def _default_mirror(): + """Find the mirror whether or not we were started from the repo root. + + An IDE run configuration or a `uv run --project` from elsewhere sets a + different working directory, which used to load zero rows in silence. + """ + from_repo = Path(__file__).parents[2] / MIRROR_NAME # src//loader.py -> repo + return next( + (path for path in (MIRROR_NAME, from_repo) if path.is_dir()), + from_repo if from_repo.parent.parent.is_dir() else MIRROR_NAME, + ) + + +DEFAULT_MIRROR = _default_mirror() # The mirror directory is named after the host, so the remote URL of any mirrored # file is the host plus its path under the mirror root. @@ -30,6 +46,11 @@ def load_rows(mirror=DEFAULT_MIRROR): prefixes case-insensitively, so ENSEMBL and Ensembl are the same prefix. """ mirror = Path(mirror) + if not mirror.is_dir(): + raise FileNotFoundError( + f"No KGX Storage mirror at {mirror.resolve()} -- run " + "./scripts/sync-kgx-normalization.sh from the repository root first." + ) rows = [] for metadata_path in sorted(mirror.glob("*/*/*/*/normalization-metadata.json")): source, source_version, transform, normalization = metadata_path.relative_to( From 6355c6aa8a63a1f94cf2266c210ae1ec41111d87 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 17:58:05 -0400 Subject: [PATCH 18/27] Keep DataTable rows scalar, fixing the invalid-argument error 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 --- src/normalization_dashboard/app.py | 30 +++++++++++++++++++++++---- src/normalization_dashboard/loader.py | 3 --- tests/test_loader.py | 7 +++++++ 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/normalization_dashboard/app.py b/src/normalization_dashboard/app.py index 5bab9e1..48de8dc 100644 --- a/src/normalization_dashboard/app.py +++ b/src/normalization_dashboard/app.py @@ -1,5 +1,7 @@ """Local Dash app: every data source x CURIE prefix, worst normalization first.""" +import os + from dash import Dash, Input, Output, callback, dash_table, dcc, html from normalization_dashboard import curie @@ -121,25 +123,44 @@ def update_table(latest_only, hide_complete): ) +def failures_paths(source, prefix, latest_only): + """The failures files behind one summary row. + + Looked up here rather than carried in the table: DataTable cells may only + hold scalars, and the browser rejects the whole table if a row holds a list. + """ + return sorted( + { + row["failures_path"] + for row in ROWS + if row["source"] == source + and row["prefix_key"] == prefix + and row["failures_path"] + and (row["is_latest"] or not latest_only) + } + ) + + @callback( Output("failures", "children"), Input("table", "active_cell"), # The viewport, not `data`: active_cell.row indexes the sorted, filtered page. Input("table", "derived_viewport_data"), + Input("latest-only", "value"), ) -def show_failures(active_cell, data): +def show_failures(active_cell, data, latest_only): """List the unnormalized CURIEs behind the clicked row. Read on demand rather than indexed up front: the biggest failures file is under 7 MB, and preloading all 87 of them would cost 3.2M lines for nothing. """ - if not active_cell or active_cell["row"] >= len(data): + if not active_cell or not data or active_cell["row"] >= len(data): return None row = data[active_cell["row"]] prefix = row["prefix"] curies = [] - for path in row["failures_paths"]: + for path in failures_paths(row["source"], prefix, "latest" in latest_only): with open(path) as failures: curies.extend( line.strip() @@ -177,4 +198,5 @@ def show_failures(active_cell, data): def main() -> None: - app.run(debug=True) + # PORT= lets a second instance run alongside one already on 8050. + app.run(debug=True, port=int(os.environ.get("PORT", 8050))) diff --git a/src/normalization_dashboard/loader.py b/src/normalization_dashboard/loader.py index e058848..9c7ae23 100644 --- a/src/normalization_dashboard/loader.py +++ b/src/normalization_dashboard/loader.py @@ -154,9 +154,6 @@ def summarize(rows): "source_versions_md": ", ".join( f"[{version}]({url})" for version, url in build_links.items() ), - "failures_paths": sorted( - {row["failures_path"] for row in group if row["failures_path"]} - ), "builds": len({(r["source_version"], r["transform"]) for r in group}), "prefix": prefix_key, "observed_as": ", ".join(sorted({row["prefix"] for row in group})), diff --git a/tests/test_loader.py b/tests/test_loader.py index 594cc56..7461720 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -42,6 +42,13 @@ def main(): assert all(count == 1 for count in keys.values()), "summarize left duplicate (source, prefix)" assert all(row["prefix"] == row["prefix"].upper() for row in summary), "summary prefix not case-folded" + # Summary rows go straight into a DataTable, whose cells may only hold + # scalars -- a list or dict here makes the browser reject the whole table, + # which no server-side check would notice. + for row in summary: + for key, value in row.items(): + assert isinstance(value, (str, int, float, bool)), f"{key} is {type(value).__name__}" + latest_summary = summarize([row for row in rows if row["is_latest"]]) print( f"OK: {len(rows)} rows over {len(per_source)} sources, " From a12b3011959be1738c5e431e34f514abc15ab762 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 18:03:55 -0400 Subject: [PATCH 19/27] Show example failing CURIEs in the table 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 --- README.md | 4 +++ src/normalization_dashboard/app.py | 15 ++++++++++-- src/normalization_dashboard/curie.py | 13 +++++++--- src/normalization_dashboard/loader.py | 35 +++++++++++++++++++++++++-- tests/test_curie.py | 7 ++++-- 5 files changed, 64 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 838d5f8..5327e38 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,10 @@ Prefixes are pooled case-insensitively, because NodeNorm resolves CURIE prefixes are shown in the "Observed as" column. Prefixes that fully normalize are hidden by default, and each version links back to the normalization output directory in KGX Storage that the numbers came from. +Each row carries up to five **Example** CURIEs that actually failed to normalize, spelled and cased +as the source spells them — often the fastest explanation of a 0% row. PathBank's failures turn out +to be `PathBank:Reaction_13124` and `PathBank:Compound_102409`, not pathway identifiers at all. + **Click a row** to list the CURIEs that failed to normalize for that source and prefix. Each links out — via the Biolink prefix map, falling back to [Bioregistry](https://bioregistry.io/) — so you can check what the identifier actually is. CURIEs that are malformed are flagged with the reason instead diff --git a/src/normalization_dashboard/app.py b/src/normalization_dashboard/app.py index 48de8dc..b7f527a 100644 --- a/src/normalization_dashboard/app.py +++ b/src/normalization_dashboard/app.py @@ -5,7 +5,9 @@ from dash import Dash, Input, Output, callback, dash_table, dcc, html from normalization_dashboard import curie -from normalization_dashboard.loader import load_rows, summarize +from normalization_dashboard.loader import load_failure_examples, load_rows, summarize + +EXAMPLE_COUNT = 5 # (key, header, markdown?) -- CURIE Prefix leads, it is the row's subject. COLUMNS = [ @@ -17,6 +19,7 @@ ("succeeded", "Succeeded", False), ("failed", "Failed", False), ("success_rate", "Success %", False), + *[(f"example_{n}", f"Example {n}", True) for n in range(1, EXAMPLE_COUNT + 1)], ("normalized_to_str", "Normalized to", False), ] @@ -50,6 +53,7 @@ app = Dash(__name__) ROWS = load_rows() +EXAMPLES = load_failure_examples(ROWS, limit=EXAMPLE_COUNT) def table_rows(latest_only, hide_complete): @@ -57,6 +61,11 @@ def table_rows(latest_only, hide_complete): summary = summarize(rows) if hide_complete: summary = [row for row in summary if row["success_rate"] < 100] + for row in summary: + examples = EXAMPLES.get((row["source"], row["prefix"]), ()) + for number in range(1, EXAMPLE_COUNT + 1): + example = examples[number - 1] if number <= len(examples) else None + row[f"example_{number}"] = curie.as_markdown(example) if example else "" return summary @@ -84,6 +93,8 @@ def table_rows(latest_only, hide_complete): for key, name, md in COLUMNS ], markdown_options={"link_target": "_blank"}, + # 14 columns: let the table scroll rather than crush every cell. + style_table={"overflowX": "auto"}, sort_action="native", filter_action="native", page_size=75, @@ -190,7 +201,7 @@ def show_failures(active_cell, data, latest_only): if malformed else None, dcc.Markdown( - " · ".join(curie.as_markdown(c) for c in shown), + " · ".join(curie.as_markdown(c, explain=True) for c in shown), link_target="_blank", style={"lineHeight": "2"}, ), diff --git a/src/normalization_dashboard/curie.py b/src/normalization_dashboard/curie.py index 5cbd8a7..dc4d022 100644 --- a/src/normalization_dashboard/curie.py +++ b/src/normalization_dashboard/curie.py @@ -49,9 +49,14 @@ def url(curie): return uri_stem + local_id if uri_stem else BIOREGISTRY_URL.format(curie) -def as_markdown(curie): - """A table cell for one CURIE: a link, or the reason it is not linkable.""" +def as_markdown(curie, explain=False): + """One CURIE, monospaced so its exact characters are visible, linked if it can be. + + Monospacing matters here: it is how `UniProtKB:B3DHD6 Q6XCC7` reads as one + broken string rather than as ordinary prose. `explain` adds why a CURIE is + not linkable, which is too wide for a table cell but wanted in the listing. + """ link = url(curie) if link: - return f"[{curie}]({link})" - return f"`{curie}` ⚠ {malformed(curie)}" + return f"[`{curie}`]({link})" + return f"`{curie}`" + (f" ⚠ {malformed(curie)}" if explain else " ⚠") diff --git a/src/normalization_dashboard/loader.py b/src/normalization_dashboard/loader.py index 9c7ae23..662182c 100644 --- a/src/normalization_dashboard/loader.py +++ b/src/normalization_dashboard/loader.py @@ -32,13 +32,44 @@ def _default_mirror(): def _normalized_to_str(normalized_to): - """'NCBIGene 91678; UniProtKB 82176; PR 79548', biggest target first.""" + """'NCBIGene (91678); UniProtKB (82176)', biggest target first. + + The count is bracketed so a prefix and its count cannot be misread as a + CURIE: 'CHEBI 5' looked like a malformed one. + """ return "; ".join( - f"{prefix} {count}" + f"{prefix} ({count})" for prefix, count in sorted(normalized_to.items(), key=lambda kv: -kv[1]) ) +def load_failure_examples(rows, limit=5): + """A few real unnormalized CURIEs per (source, uppercased prefix). + + One pass per failures file rather than per row -- 87 files, 3.2M lines, under + a second. CURIEs keep the case the file spells them with, which is half the + point: seeing `Ensembl:ENSRNOG…` or `PathBank:Reaction_13124` says more about + why a prefix fails than the folded prefix name does. + """ + paths = {} + for row in rows: + if row["failures_path"]: + paths.setdefault(row["failures_path"], (row["source"], row["is_latest"])) + + examples = defaultdict(list) + # Latest builds first, so examples come from current data where there is any. + for path, (source, _) in sorted(paths.items(), key=lambda item: not item[1][1]): + with open(path) as failures: + for line in failures: + curie = line.strip() + if not curie: + continue + found = examples[(source, curie.split(":")[0].upper())] + if len(found) < limit and curie not in found: + found.append(curie) + return examples + + def load_rows(mirror=DEFAULT_MIRROR): """One row per (build, prefix), with the prefix spelled as the file spells it. diff --git a/tests/test_curie.py b/tests/test_curie.py index 0a98ab6..032e89a 100644 --- a/tests/test_curie.py +++ b/tests/test_curie.py @@ -35,9 +35,12 @@ def main(): ): assert curie.malformed(value) == reason, f"{value}: {curie.malformed(value)}" assert curie.url(value) is None, value - assert reason in curie.as_markdown(value) + assert reason in curie.as_markdown(value, explain=True) + # A table cell gets the warning sign but not the wide explanation. + assert curie.as_markdown(value) == f"`{value}` ⚠" - assert curie.as_markdown("MONDO:0001034").startswith("[MONDO:0001034](") + # Linked, and monospaced so the exact characters of a CURIE are visible. + assert curie.as_markdown("MONDO:0001034").startswith("[`MONDO:0001034`](") print(f"OK: {len(curie.PREFIX_MAP)} Biolink prefixes loaded") return 0 From 64efe53846d4144c157a945bc206bddd74fa51db Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 18:10:25 -0400 Subject: [PATCH 20/27] Sample example CURIEs evenly, and drop the Observed as column 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 --- src/normalization_dashboard/app.py | 4 ++- src/normalization_dashboard/loader.py | 35 +++++++++++++++++++-------- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/normalization_dashboard/app.py b/src/normalization_dashboard/app.py index b7f527a..51d00cd 100644 --- a/src/normalization_dashboard/app.py +++ b/src/normalization_dashboard/app.py @@ -14,7 +14,9 @@ ("prefix", "CURIE Prefix", False), ("source", "Source", False), ("source_versions_md", "Version", True), - ("observed_as", "Observed as", False), + # No "Observed as" column: it equals the prefix in every row today, and the + # Example columns show the original casing anyway. summarize() still carries + # the field for whenever one source does spell a prefix two ways. ("total", "Total", False), ("succeeded", "Succeeded", False), ("failed", "Failed", False), diff --git a/src/normalization_dashboard/loader.py b/src/normalization_dashboard/loader.py index 662182c..68056f3 100644 --- a/src/normalization_dashboard/loader.py +++ b/src/normalization_dashboard/loader.py @@ -43,30 +43,45 @@ def _normalized_to_str(normalized_to): ) +def _spread(values, limit): + """`limit` values spaced evenly across the list, not just its head. + + A prefix's failures are usually grouped in file order, so the first few are + often all alike; a spread shows whether the rest of them differ. + """ + if len(values) <= limit: + return list(values) + step = len(values) / limit + return [values[int(index * step)] for index in range(limit)] + + def load_failure_examples(rows, limit=5): """A few real unnormalized CURIEs per (source, uppercased prefix). One pass per failures file rather than per row -- 87 files, 3.2M lines, under - a second. CURIEs keep the case the file spells them with, which is half the - point: seeing `Ensembl:ENSRNOG…` or `PathBank:Reaction_13124` says more about - why a prefix fails than the folded prefix name does. + a second, holding only one file's CURIEs at a time. CURIEs keep the case the + file spells them with, which is half the point: seeing `Ensembl:ENSRNOG…` or + `PathBank:Reaction_13124` says more about why a prefix fails than the folded + prefix name does. """ paths = {} for row in rows: if row["failures_path"]: paths.setdefault(row["failures_path"], (row["source"], row["is_latest"])) - examples = defaultdict(list) - # Latest builds first, so examples come from current data where there is any. + examples = {} + # Latest builds first, so a source's examples come from its current build. for path, (source, _) in sorted(paths.items(), key=lambda item: not item[1][1]): + by_prefix = defaultdict(list) with open(path) as failures: for line in failures: curie = line.strip() - if not curie: - continue - found = examples[(source, curie.split(":")[0].upper())] - if len(found) < limit and curie not in found: - found.append(curie) + if curie: + by_prefix[curie.split(":")[0].upper()].append(curie) + for prefix, curies in by_prefix.items(): + examples.setdefault( + (source, prefix), _spread(list(dict.fromkeys(curies)), limit) + ) return examples From 4e10bf8ecef70f6db6ebaeff00d382512211526e Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 18:24:23 -0400 Subject: [PATCH 21/27] Let the table use the whole window 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 --- src/normalization_dashboard/app.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/normalization_dashboard/app.py b/src/normalization_dashboard/app.py index 51d00cd..948283a 100644 --- a/src/normalization_dashboard/app.py +++ b/src/normalization_dashboard/app.py @@ -22,7 +22,10 @@ ("failed", "Failed", False), ("success_rate", "Success %", False), *[(f"example_{n}", f"Example {n}", True) for n in range(1, EXAMPLE_COUNT + 1)], - ("normalized_to_str", "Normalized to", False), + # No "Normalized to" column: at 200+ characters for semmeddb it was the one + # value wide enough to force a scrollbar by itself, and where a prefix + # normalizes *to* is a renormalization-diff question, not a today question. + # summarize() still carries normalized_to_str for when that work starts. ] # Red at 0% shading to green at 100%, so the ranking is scannable without reading numbers. @@ -72,14 +75,17 @@ def table_rows(latest_only, hide_complete): app.layout = html.Div( - style={"maxWidth": "1400px", "margin": "0 auto", "fontFamily": "system-ui, sans-serif"}, + # No maxWidth: the table wants every pixel the window has. Prose does not, + # so the paragraph below caps its own line length. + style={"margin": "0 auto", "padding": "0 16px", "fontFamily": "system-ui, sans-serif"}, children=[ html.H1("Normalization by source and CURIE prefix"), html.P( "Prefixes are pooled case-insensitively, because NodeNorm resolves CURIE " "prefixes case-insensitively. Sort by Failed to rank by how many CURIEs are " "actually at stake rather than by percentage. Click a row to list the CURIEs " - "that failed to normalize." + "that failed to normalize.", + style={"maxWidth": "70ch"}, ), dcc.Checklist( id="latest-only", @@ -95,7 +101,7 @@ def table_rows(latest_only, hide_complete): for key, name, md in COLUMNS ], markdown_options={"link_target": "_blank"}, - # 14 columns: let the table scroll rather than crush every cell. + # Safety net for narrow windows: scroll the table, never the page. style_table={"overflowX": "auto"}, sort_action="native", filter_action="native", From 174d8ac7b1701d86385c075a8364ddd891490a30 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 18:29:48 -0400 Subject: [PATCH 22/27] Highlight the whole selected row, and group the CURIE listing 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
    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 --- src/normalization_dashboard/app.py | 88 ++++++++++++++++++++++----- src/normalization_dashboard/curie.py | 37 +++++++++++ src/normalization_dashboard/loader.py | 4 +- tests/test_curie.py | 18 ++++++ tests/test_loader.py | 7 ++- 5 files changed, 137 insertions(+), 17 deletions(-) diff --git a/src/normalization_dashboard/app.py b/src/normalization_dashboard/app.py index 948283a..ccfc19e 100644 --- a/src/normalization_dashboard/app.py +++ b/src/normalization_dashboard/app.py @@ -4,7 +4,7 @@ from dash import Dash, Input, Output, callback, dash_table, dcc, html -from normalization_dashboard import curie +from normalization_dashboard import curie, loader from normalization_dashboard.loader import load_failure_examples, load_rows, summarize EXAMPLE_COUNT = 5 @@ -54,7 +54,13 @@ "backgroundColor": "#f4f4f6", } -MAX_CURIES_SHOWN = 300 +# Clicking anywhere in a row highlights the whole row; the single active cell +# gets no colour of its own, since which cell you happened to hit means nothing. +ACTIVE_CELL_STYLE = {"if": {"state": "active"}, "backgroundColor": "transparent"} +SELECTED_ROW_COLOUR = "#dbe6ff" + +MAX_CURIES_SHOWN = 200 +EXAMPLES_PER_GROUP = 6 app = Dash(__name__) ROWS = load_rows() @@ -116,7 +122,7 @@ def table_rows(latest_only, hide_complete): {"if": {"column_id": column}, "textAlign": "right"} for column in ("total", "succeeded", "failed", "success_rate") ], - style_data_conditional=[PREFIX_STYLE, *RATE_SHADING], + style_data_conditional=[PREFIX_STYLE, *RATE_SHADING, ACTIVE_CELL_STYLE], style_header={"fontWeight": "600"}, ), html.Div(id="failures", style={"marginTop": "24px"}), @@ -160,6 +166,32 @@ def failures_paths(source, prefix, latest_only): ) +@callback( + Output("table", "style_data_conditional"), + Input("table", "active_cell"), + Input("table", "derived_viewport_data"), +) +def highlight_selected_row(active_cell, data): + """Colour the whole clicked row, matched on identity rather than position. + + A row index would follow the viewport rather than the row once the table is + re-sorted or filtered. + """ + styles = [PREFIX_STYLE, *RATE_SHADING, ACTIVE_CELL_STYLE] + if active_cell and data and active_cell["row"] < len(data): + row = data[active_cell["row"]] + styles.append( + { + "if": { + "filter_query": f'{{prefix}} = "{row["prefix"]}" ' + f'&& {{source}} = "{row["source"]}"' + }, + "backgroundColor": SELECTED_ROW_COLOUR, + } + ) + return styles + + @callback( Output("failures", "children"), Input("table", "active_cell"), @@ -195,27 +227,55 @@ def show_failures(active_cell, data, latest_only): style={"color": "#555"}, ) - shown = curies[:MAX_CURIES_SHOWN] - malformed = [c for c in shown if curie.malformed(c)] - heading = f"{len(curies):,} unnormalized {prefix} CURIEs in {row['source']}" - if len(shown) < len(curies): - heading += f" (showing the first {len(shown):,})" + malformed = [c for c in curies if curie.malformed(c)] + groups = curie.group_by_stem(curies) return [ - html.H2(heading, style={"fontSize": "1.2em"}), + html.H2( + f"{len(curies):,} unnormalized {prefix} CURIEs in {row['source']}", + style={"fontSize": "1.2em"}, + ), html.P( - f"{len(malformed)} of these are malformed, which may be the whole explanation.", + f"{len(malformed):,} of these are malformed, which may be the whole explanation.", style={"color": "#a33"}, ) if malformed else None, - dcc.Markdown( - " · ".join(curie.as_markdown(c, explain=True) for c in shown), - link_target="_blank", - style={"lineHeight": "2"}, + html.Ul( + [_group_item(shape, count, members) for shape, count, members in groups] + if groups + else [_curie_item(c) for c in loader.spread(curies, MAX_CURIES_SHOWN)], + style={"lineHeight": "1.8"}, ), ] +def _group_item(shape, count, members): + """One shape of CURIE, with a few members spread across the whole group.""" + return html.Li( + [ + html.Strong(f"{count:,} × {shape}… "), + dcc.Markdown( + " · ".join( + curie.as_markdown(c, explain=True) + for c in loader.spread(members, EXAMPLES_PER_GROUP) + ), + link_target="_blank", + style={"display": "inline"}, + ), + ] + ) + + +def _curie_item(value): + return html.Li( + dcc.Markdown( + curie.as_markdown(value, explain=True), + link_target="_blank", + style={"display": "inline"}, + ) + ) + + def main() -> None: # PORT= lets a second instance run alongside one already on 8050. app.run(debug=True, port=int(os.environ.get("PORT", 8050))) diff --git a/src/normalization_dashboard/curie.py b/src/normalization_dashboard/curie.py index dc4d022..28ecd2d 100644 --- a/src/normalization_dashboard/curie.py +++ b/src/normalization_dashboard/curie.py @@ -7,6 +7,8 @@ """ import json +import re +from collections import Counter from pathlib import Path # Vendored from biolink-model so the app works offline; 266 prefixes, refresh with: @@ -49,6 +51,41 @@ def url(curie): return uri_stem + local_id if uri_stem else BIOREGISTRY_URL.format(curie) +def stem(curie): + """The shape of a CURIE: everything up to its first digit. + + `PathBank:Reaction_13124` -> `PathBank:Reaction_`, `Ensembl:ENSRNOG00000019082` + -> `Ensembl:ENSRNOG`. Grouping on this splits pathbank's failures into + Reaction_, Compound_ and ProteinComplex_, and bgee's into one group per + species, which says far more than a flat list of 215,953 identifiers. + """ + prefix, _, local_id = curie.partition(":") + return prefix + ":" + re.match(r"[^0-9]*", local_id).group(0) + + +# Above this many groups, the stem is not finding real structure -- InChIKeys +# have no digits to split on, so 87 of them make 87 groups of one. +MAX_USEFUL_GROUPS = 12 + + +def group_by_stem(curies): + """[(stem, count, [curies in that group])], biggest group first. + + Returns None when grouping would not be informative, so the caller can fall + back to a plain list. + """ + counts = Counter(stem(curie) for curie in curies) + if len(counts) > MAX_USEFUL_GROUPS: + return None + grouped = {} + for curie in curies: + grouped.setdefault(stem(curie), []).append(curie) + return [ + (shape, counts[shape], grouped[shape]) + for shape, _ in counts.most_common() + ] + + def as_markdown(curie, explain=False): """One CURIE, monospaced so its exact characters are visible, linked if it can be. diff --git a/src/normalization_dashboard/loader.py b/src/normalization_dashboard/loader.py index 68056f3..af38da9 100644 --- a/src/normalization_dashboard/loader.py +++ b/src/normalization_dashboard/loader.py @@ -43,7 +43,7 @@ def _normalized_to_str(normalized_to): ) -def _spread(values, limit): +def spread(values, limit): """`limit` values spaced evenly across the list, not just its head. A prefix's failures are usually grouped in file order, so the first few are @@ -80,7 +80,7 @@ def load_failure_examples(rows, limit=5): by_prefix[curie.split(":")[0].upper()].append(curie) for prefix, curies in by_prefix.items(): examples.setdefault( - (source, prefix), _spread(list(dict.fromkeys(curies)), limit) + (source, prefix), spread(list(dict.fromkeys(curies)), limit) ) return examples diff --git a/tests/test_curie.py b/tests/test_curie.py index 032e89a..321e1f9 100644 --- a/tests/test_curie.py +++ b/tests/test_curie.py @@ -42,6 +42,24 @@ def main(): # Linked, and monospaced so the exact characters of a CURIE are visible. assert curie.as_markdown("MONDO:0001034").startswith("[`MONDO:0001034`](") + # Grouping on CURIE shape: everything up to the first digit. + assert curie.stem("PathBank:Reaction_13124") == "PathBank:Reaction_" + assert curie.stem("Ensembl:ENSRNOG00000019082") == "Ensembl:ENSRNOG" + assert curie.stem("FOODON:02021990") == "FOODON:" + + groups = curie.group_by_stem( + ["PathBank:Reaction_1", "PathBank:Reaction_2", "PathBank:Compound_9"] + ) + assert [(shape, count) for shape, count, _ in groups] == [ + ("PathBank:Reaction_", 2), + ("PathBank:Compound_", 1), + ], groups + + # InChIKeys have no digits to split on, so every one becomes its own group. + # Grouping declines rather than printing a list of groups of one. + inchikeys = [f"INCHIKEY:{chr(65 + n)}FOFVIBWSLOHFR-QDMKHBRRSA-N" for n in range(20)] + assert curie.group_by_stem(inchikeys) is None + print(f"OK: {len(curie.PREFIX_MAP)} Biolink prefixes loaded") return 0 diff --git a/tests/test_loader.py b/tests/test_loader.py index 7461720..b3be695 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -10,10 +10,15 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src")) -from normalization_dashboard.loader import DEFAULT_MIRROR, load_rows, summarize +from normalization_dashboard.loader import DEFAULT_MIRROR, load_rows, spread, summarize def main(): + # Examples are spread across a list, not taken from its head. + assert spread(list(range(10)), 5) == [0, 2, 4, 6, 8] + assert spread(["a", "b"], 5) == ["a", "b"] + assert spread([], 5) == [] + if not DEFAULT_MIRROR.exists(): print(f"SKIP: no mirror at {DEFAULT_MIRROR}; run scripts/sync-kgx-normalization.sh") return 0 From 5c789945ed24195433e3d88bde23b342d4009f80 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 18:36:28 -0400 Subject: [PATCH 23/27] Group unnormalized CURIEs by why they failed 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 --- README.md | 12 +++-- src/normalization_dashboard/app.py | 77 +++++++++++++++++----------- src/normalization_dashboard/curie.py | 28 ++++++++++ tests/test_curie.py | 10 ++++ 4 files changed, 92 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 5327e38..8683eff 100644 --- a/README.md +++ b/README.md @@ -56,11 +56,13 @@ Each row carries up to five **Example** CURIEs that actually failed to normalize as the source spells them — often the fastest explanation of a 0% row. PathBank's failures turn out to be `PathBank:Reaction_13124` and `PathBank:Compound_102409`, not pathway identifiers at all. -**Click a row** to list the CURIEs that failed to normalize for that source and prefix. Each links -out — via the Biolink prefix map, falling back to [Bioregistry](https://bioregistry.io/) — so you can -check what the identifier actually is. CURIEs that are malformed are flagged with the reason instead -of linked, since that is often the whole explanation for the failure (`rhea:RHEA:13065`, -`CL:0000089 ∩ UBERON:0000473`, `UniProtKB:B3DHD6 Q6XCC7`). +**Click a row** to list the CURIEs that failed to normalize for that source and prefix, grouped by +*why* they plausibly failed — malformed first (`rhea:RHEA:13065`, `CL:0000089 ∩ UBERON:0000473`, +`UniProtKB:B3DHD6 Q6XCC7`), then prefixes the Biolink model has never heard of, then the ordinary +"no Babel clique" remainder. Within each reason they are broken down by CURIE shape, which is how +pathbank's 215,953 failures resolve into 175,039 `PathBank:Reaction_…`, 31,182 `PathBank:Compound_…` +and 8,886 `PathBank:ProteinComplex_…`. Every CURIE links out — via the Biolink prefix map, falling +back to [Bioregistry](https://bioregistry.io/) — so you can check what the identifier actually is. It runs locally, which keeps individual CURIEs off the public web and leaves the deployment question (GitHub Pages export, Kubernetes, or folding into another Translator dashboard) open. diff --git a/src/normalization_dashboard/app.py b/src/normalization_dashboard/app.py index ccfc19e..043c426 100644 --- a/src/normalization_dashboard/app.py +++ b/src/normalization_dashboard/app.py @@ -1,6 +1,7 @@ """Local Dash app: every data source x CURIE prefix, worst normalization first.""" import os +from collections import defaultdict from dash import Dash, Input, Output, callback, dash_table, dcc, html @@ -59,7 +60,7 @@ ACTIVE_CELL_STYLE = {"if": {"state": "active"}, "backgroundColor": "transparent"} SELECTED_ROW_COLOUR = "#dbe6ff" -MAX_CURIES_SHOWN = 200 +MAX_CURIES_SHOWN = 30 EXAMPLES_PER_GROUP = 6 app = Dash(__name__) @@ -227,52 +228,68 @@ def show_failures(active_cell, data, latest_only): style={"color": "#555"}, ) - malformed = [c for c in curies if curie.malformed(c)] - groups = curie.group_by_stem(curies) + by_problem = defaultdict(list) + for value in curies: + by_problem[curie.problem(value)].append(value) + ordered = sorted( + by_problem.items(), key=lambda item: (curie.problem_rank(item[0]), -len(item[1])) + ) return [ html.H2( f"{len(curies):,} unnormalized {prefix} CURIEs in {row['source']}", style={"fontSize": "1.2em"}, ), - html.P( - f"{len(malformed):,} of these are malformed, which may be the whole explanation.", - style={"color": "#a33"}, - ) - if malformed - else None, html.Ul( - [_group_item(shape, count, members) for shape, count, members in groups] - if groups - else [_curie_item(c) for c in loader.spread(curies, MAX_CURIES_SHOWN)], + [_problem_item(label, members) for label, members in ordered], style={"lineHeight": "1.8"}, ), ] -def _group_item(shape, count, members): - """One shape of CURIE, with a few members spread across the whole group.""" +def _problem_item(label, members): + """One reason these CURIEs failed, broken down by CURIE shape underneath. + + A single shape is rendered inline: nesting one bullet under another says + nothing. + """ + heading = html.Strong( + f"{len(members):,} — {label}", + style={"color": "#a33"} if label.startswith(curie.MALFORMED) else None, + ) + shapes = curie.group_by_stem(members) + if shapes and len(shapes) > 1: + return html.Li([heading, html.Ul([_shape_item(*shape) for shape in shapes])]) + if shapes: + return html.Li([heading, " ", _examples(members)]) + # No shared shape to group on, so just list them, spread across the whole set. + listed = loader.spread(members, MAX_CURIES_SHOWN) + note = ( + f" (showing {len(listed)} spread across {len(members):,})" + if len(listed) < len(members) + else "" + ) return html.Li( [ - html.Strong(f"{count:,} × {shape}… "), - dcc.Markdown( - " · ".join( - curie.as_markdown(c, explain=True) - for c in loader.spread(members, EXAMPLES_PER_GROUP) - ), - link_target="_blank", - style={"display": "inline"}, - ), + heading, + note, + html.Ul([html.Li(_examples([value], 1)) for value in listed]), ] ) -def _curie_item(value): - return html.Li( - dcc.Markdown( - curie.as_markdown(value, explain=True), - link_target="_blank", - style={"display": "inline"}, - ) +def _shape_item(shape, count, members): + return html.Li([html.Strong(f"{count:,} × {shape}… "), _examples(members)]) + + +def _examples(members, limit=EXAMPLES_PER_GROUP): + """A few members spread across the whole group, linked.""" + return dcc.Markdown( + " · ".join( + curie.as_markdown(value, explain=True) + for value in loader.spread(members, limit) + ), + link_target="_blank", + style={"display": "inline"}, ) diff --git a/src/normalization_dashboard/curie.py b/src/normalization_dashboard/curie.py index 28ecd2d..f6f52d0 100644 --- a/src/normalization_dashboard/curie.py +++ b/src/normalization_dashboard/curie.py @@ -51,6 +51,34 @@ def url(curie): return uri_stem + local_id if uri_stem else BIOREGISTRY_URL.format(curie) +MALFORMED = "malformed" +UNKNOWN_PREFIX = "prefix unknown to the Biolink model" +NO_CLIQUE = "no Babel clique for this identifier" + +# Most actionable first: a malformed CURIE is a transform bug we can point at, an +# unknown prefix is a modelling gap, and the rest is ordinary Babel coverage. +PROBLEM_ORDER = {MALFORMED: 0, UNKNOWN_PREFIX: 1, NO_CLIQUE: 2} + + +def problem(curie): + """Why this CURIE plausibly failed to normalize. + + The prefix check is a signal, not a proven 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 its CURIEs all fail. + """ + reason = malformed(curie) + if reason: + return f"{MALFORMED}: {reason}" + if curie.partition(":")[0].upper() not in PREFIX_MAP: + return UNKNOWN_PREFIX + return NO_CLIQUE + + +def problem_rank(label): + return PROBLEM_ORDER.get(label.split(": ")[0], len(PROBLEM_ORDER)) + + def stem(curie): """The shape of a CURIE: everything up to its first digit. diff --git a/tests/test_curie.py b/tests/test_curie.py index 321e1f9..12487b0 100644 --- a/tests/test_curie.py +++ b/tests/test_curie.py @@ -42,6 +42,16 @@ def main(): # Linked, and monospaced so the exact characters of a CURIE are visible. assert curie.as_markdown("MONDO:0001034").startswith("[`MONDO:0001034`](") + # Grouping by problem, most actionable first. + assert curie.problem("rhea:RHEA:13065") == "malformed: more than one ':'" + assert curie.problem("PathBank:SMP0000055") == curie.UNKNOWN_PREFIX + assert curie.problem("MONDO:0001034") == curie.NO_CLIQUE + assert ( + curie.problem_rank(curie.problem("rhea:RHEA:13065")) + < curie.problem_rank(curie.UNKNOWN_PREFIX) + < curie.problem_rank(curie.NO_CLIQUE) + ) + # Grouping on CURIE shape: everything up to the first digit. assert curie.stem("PathBank:Reaction_13124") == "PathBank:Reaction_" assert curie.stem("Ensembl:ENSRNOG00000019082") == "Ensembl:ENSRNOG" From e99343e7b4ab7ff67183d5095c2829744f952499 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 18:42:30 -0400 Subject: [PATCH 24/27] Widen the intro and put real totals in the summary line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/normalization_dashboard/app.py | 34 ++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/src/normalization_dashboard/app.py b/src/normalization_dashboard/app.py index 043c426..484fc8b 100644 --- a/src/normalization_dashboard/app.py +++ b/src/normalization_dashboard/app.py @@ -82,8 +82,8 @@ def table_rows(latest_only, hide_complete): app.layout = html.Div( - # No maxWidth: the table wants every pixel the window has. Prose does not, - # so the paragraph below caps its own line length. + # No maxWidth anywhere: the table wants every pixel the window has, and the + # intro should line up with it rather than sit in a narrow column. style={"margin": "0 auto", "padding": "0 16px", "fontFamily": "system-ui, sans-serif"}, children=[ html.H1("Normalization by source and CURIE prefix"), @@ -91,8 +91,8 @@ def table_rows(latest_only, hide_complete): "Prefixes are pooled case-insensitively, because NodeNorm resolves CURIE " "prefixes case-insensitively. Sort by Failed to rank by how many CURIEs are " "actually at stake rather than by percentage. Click a row to list the CURIEs " - "that failed to normalize.", - style={"maxWidth": "70ch"}, + "that failed to normalize. CURIEs are counted once per source, so one that " + "appears in three sources counts three times.", ), dcc.Checklist( id="latest-only", @@ -140,15 +140,37 @@ def table_rows(latest_only, hide_complete): ) def update_table(latest_only, hide_complete): latest_only = "latest" in latest_only + everything = table_rows(latest_only, False) data = table_rows(latest_only, "hide" in hide_complete) - complete = sum(1 for row in table_rows(latest_only, False) if row["success_rate"] == 100) + complete = sum(1 for row in everything if row["success_rate"] == 100) return ( data, - f"{len(data)} rows across {len({row['source'] for row in data})} sources", + summary_line(data, everything), [{"label": f" Hide prefixes that fully normalize (n={complete})", "value": "hide"}], ) +def summary_line(shown, everything): + """Totals for the whole build selection, not just the visible rows. + + Hiding the fully-normalizing prefixes must not make the overall score look + worse than it is, so every figure but the row count describes `everything`. + """ + total = sum(row["total"] for row in everything) + succeeded = sum(row["succeeded"] for row in everything) + rows = ( + f"{len(shown):,} rows" + if len(shown) == len(everything) + else f"Showing {len(shown):,} of {len(everything):,} rows" + ) + return ( + f"{rows} — {len({row['source'] for row in everything})} sources, " + f"{len({row['prefix'] for row in everything})} CURIE prefixes, " + f"{succeeded:,} of {total:,} CURIEs normalized " + f"({100 * succeeded / total:.1f}%), {total - succeeded:,} failed" + ) + + def failures_paths(source, prefix, latest_only): """The failures files behind one summary row. From 6bda0347ff1ec23561770f462059ff3589daf270 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 18:46:08 -0400 Subject: [PATCH 25/27] Turn the intro into a scannable list, figures first 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
      : 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 --- src/normalization_dashboard/app.py | 56 ++++++++++++++++++------------ 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/src/normalization_dashboard/app.py b/src/normalization_dashboard/app.py index 484fc8b..0ede615 100644 --- a/src/normalization_dashboard/app.py +++ b/src/normalization_dashboard/app.py @@ -87,20 +87,14 @@ def table_rows(latest_only, hide_complete): style={"margin": "0 auto", "padding": "0 16px", "fontFamily": "system-ui, sans-serif"}, children=[ html.H1("Normalization by source and CURIE prefix"), - html.P( - "Prefixes are pooled case-insensitively, because NodeNorm resolves CURIE " - "prefixes case-insensitively. Sort by Failed to rank by how many CURIEs are " - "actually at stake rather than by percentage. Click a row to list the CURIEs " - "that failed to normalize. CURIEs are counted once per source, so one that " - "appears in three sources counts three times.", - ), + html.Ul(id="summary", style={"lineHeight": "1.7"}), dcc.Checklist( id="latest-only", options=[{"label": " Latest build per source only", "value": "latest"}], value=["latest"], ), dcc.Checklist(id="hide-complete", options=[], value=["hide"]), - html.Div(id="row-count", style={"margin": "8px 0", "color": "#555"}), + html.Div(id="row-count", style={"margin": "8px 0"}), dash_table.DataTable( id="table", columns=[ @@ -131,8 +125,22 @@ def table_rows(latest_only, hide_complete): ) +# Fixed notes, listed after the figures so the numbers come first. +NOTES = [ + "Prefixes are pooled case-insensitively, because NodeNorm resolves CURIE " + "prefixes case-insensitively.", + "Sort by Failed to rank by how many CURIEs are at stake rather than by " + "percentage: 0% of 3 and 0% of 215,953 rank identically by rate.", + "Click a row to list the CURIEs that failed to normalize, grouped by why " + "they plausibly failed.", + "CURIEs are counted once per source, so one appearing in three sources " + "counts three times.", +] + + @callback( Output("table", "data"), + Output("summary", "children"), Output("row-count", "children"), Output("hide-complete", "options"), Input("latest-only", "value"), @@ -145,30 +153,32 @@ def update_table(latest_only, hide_complete): complete = sum(1 for row in everything if row["success_rate"] == 100) return ( data, - summary_line(data, everything), + summary_items(everything), + f"{len(data):,} rows displayed", [{"label": f" Hide prefixes that fully normalize (n={complete})", "value": "hide"}], ) -def summary_line(shown, everything): - """Totals for the whole build selection, not just the visible rows. +def summary_items(everything): + """The headline figures, then the fixed notes, as one scannable list. - Hiding the fully-normalizing prefixes must not make the overall score look - worse than it is, so every figure but the row count describes `everything`. + These describe the whole build selection rather than the visible rows: an + overall score that moved when you hid the fully-normalizing prefixes would + be worse than useless. """ total = sum(row["total"] for row in everything) succeeded = sum(row["succeeded"] for row in everything) - rows = ( - f"{len(shown):,} rows" - if len(shown) == len(everything) - else f"Showing {len(shown):,} of {len(everything):,} rows" - ) - return ( - f"{rows} — {len({row['source'] for row in everything})} sources, " - f"{len({row['prefix'] for row in everything})} CURIE prefixes, " + figures = [ f"{succeeded:,} of {total:,} CURIEs normalized " - f"({100 * succeeded / total:.1f}%), {total - succeeded:,} failed" - ) + f"({100 * succeeded / total:.1f}%)", + f"{total - succeeded:,} CURIEs failed to normalize", + f"{len({row['source'] for row in everything})} sources, " + f"{len({row['prefix'] for row in everything})} CURIE prefixes, " + f"{len(everything):,} source/prefix pairs", + ] + return [html.Li(html.Strong(figure)) for figure in figures] + [ + html.Li(note) for note in NOTES + ] def failures_paths(source, prefix, latest_only): From 112450a99f1a9407429d43cad62dae25d25d1d31 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 18:55:37 -0400 Subject: [PATCH 26/27] Document the load-and-run pipeline in docs/Loading.md 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 --- README.md | 54 ++++++++---------- docs/Loading.md | 148 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 31 deletions(-) create mode 100644 docs/Loading.md diff --git a/README.md b/README.md index 8683eff..a6127fd 100644 --- a/README.md +++ b/README.md @@ -9,38 +9,24 @@ The NCATS Translator DINGO pipeline normalizes KGX node CURIEs through went: which prefixes normalized to what, and which CURIEs failed to normalize (so they can be prioritized for a future Babel ingest). -## Syncing the normalization data - -Each KGX build directory contains `normalization-metadata.json`, `normalization_failures.txt`, and -`normalization_map.json` alongside multi-gigabyte `normalized_nodes.jsonl` / `normalized_edges.jsonl` -files. The sync script mirrors *only* the three normalization files, preserving KGX Storage's folder -layout, and deletes local files that have disappeared upstream. - -```bash - -brew install rclone # or apt install rclone -./scripts/sync-kgx-normalization.sh -``` - -That writes ~970 MB into `data/kgx-storage.ci.transltr.io/` (gitignored). Any extra arguments are -passed through to `rclone`: +## Quick start ```bash -./scripts/sync-kgx-normalization.sh /tmp/kgxtest --dry-run -./scripts/sync-kgx-normalization.sh data/kgx-storage.ci.transltr.io --exclude "**/normalization_map.json" +brew install rclone # or apt install rclone +uv sync +./scripts/sync-kgx-normalization.sh # ~970 MB into data/ (gitignored), a few minutes +uv run normalization-dashboard # http://127.0.0.1:8050 ``` -Skipping the maps with that last `--exclude` brings the mirror down to ~56 MB, which is enough for -the prefix-summary and normalization-failure reports. +The sync mirrors *only* the normalization files from each KGX build — the multi-gigabyte +`normalized_nodes.jsonl` / `normalized_edges.jsonl` files are never fetched — preserving KGX +Storage's folder layout and pruning anything that disappears upstream. -**Known upstream issue:** ubergraph's 247 MB `normalization_map.json` returns 502 from KGX Storage -and cannot be downloaded, so a full sync exits non-zero. See [CLAUDE.md](CLAUDE.md) for details. +**[docs/Loading.md](docs/Loading.md)** has the full runbook: prerequisites, sync variants (including +a ~56 MB mirror that skips the normalization maps), what lands where, how to keep it current, +hosting, and troubleshooting. -## Running the dashboard - -```bash -uv run normalization-dashboard # http://127.0.0.1:8050 -``` +## The dashboard A local [Dash](https://plotly.com/dash/) app. The first view is every data source × CURIE prefix, sorted from the worst normalization rate to the best — the ranking that says which prefixes Babel @@ -48,9 +34,9 @@ should ingest next. Sort by **Failed** instead to rank by how many CURIEs are ac prefix at 0% of 3 CURIEs and one at 0% of 216,000 sort identically by percentage. Prefixes are pooled case-insensitively, because NodeNorm resolves CURIE prefixes case-insensitively -(`ENSEMBL:`, `Ensembl:` and `ensembl:` all resolve alike); the spellings actually seen in the files -are shown in the "Observed as" column. Prefixes that fully normalize are hidden by default, and each -version links back to the normalization output directory in KGX Storage that the numbers came from. +(`ENSEMBL:`, `Ensembl:` and `ensembl:` all resolve alike). Prefixes that fully normalize are hidden +by default, and each version links back to the normalization output directory in KGX Storage that +the numbers came from. Each row carries up to five **Example** CURIEs that actually failed to normalize, spelled and cased as the source spells them — often the fastest explanation of a 0% row. PathBank's failures turn out @@ -65,11 +51,17 @@ and 8,886 `PathBank:ProteinComplex_…`. Every CURIE links out — via the Bioli back to [Bioregistry](https://bioregistry.io/) — so you can check what the identifier actually is. It runs locally, which keeps individual CURIEs off the public web and leaves the deployment question -(GitHub Pages export, Kubernetes, or folding into another Translator dashboard) open. +(GitHub Pages export, Kubernetes, or folding into another Translator dashboard) open. `PORT=8051` +runs a second instance alongside the first. ## Development ```bash uv sync -uv run python tests/test_loader.py +uv run python tests/test_loader.py # invariants against the real mirror +uv run python tests/test_curie.py # CURIE linking, malformed detection, grouping ``` + +`src/normalization_dashboard/loader.py` has no Dash imports and returns plain `list[dict]`, so a +notebook or a future static-JSON exporter can reuse it without touching the app. See +[CLAUDE.md](CLAUDE.md) for how KGX Storage behaves and what each file contains. diff --git a/docs/Loading.md b/docs/Loading.md new file mode 100644 index 0000000..6b9ed19 --- /dev/null +++ b/docs/Loading.md @@ -0,0 +1,148 @@ +# Loading the data and running the dashboard + +Everything needed to get from a fresh checkout to a working dashboard. The short version: + +```bash +brew install rclone # or apt install rclone +uv sync +./scripts/sync-kgx-normalization.sh # ~970 MB, a few minutes +uv run normalization-dashboard # http://127.0.0.1:8050 +``` + +## Prerequisites + +| | | +|---|---| +| [uv](https://docs.astral.sh/uv/) | runs the project; `uv sync` installs Dash | +| [rclone](https://rclone.org/) | the entire downloader — `brew install rclone` / `apt install rclone` | +| disk | ~970 MB for a full mirror, or ~56 MB without the normalization maps | +| network | HTTPS to `kgx-storage.ci.transltr.io`; **no AWS credentials needed** | + +No AWS credentials are needed, and none would help for listing: the `s3://kgx-translator-ingests` +bucket is not anonymously listable, so the site's HTML directory listings are the only public index. +rclone's `:http:` backend walks them. + +## Step 1 — mirror the normalization data + +```bash +./scripts/sync-kgx-normalization.sh +``` + +One `rclone sync` call. It walks every build directory in KGX Storage and downloads only four +kinds of file, preserving the remote folder layout and deleting local files that have disappeared +upstream: + +- `normalization-metadata.json` — per-prefix counts, the input for the whole table +- `normalization_failures.txt` — one unnormalized CURIE per line +- `normalization_map.json` — source CURIE → normalized CURIE, for the future renormalization work +- `latest-build.json` — which build is current for each source + +The multi-gigabyte `normalized_nodes.jsonl` and `normalized_edges.jsonl` files are **never fetched**. +The `--include` filters apply to the listing walk, so only directory HTML is requested for them. + +### Variants + +```bash +# See what would transfer, without downloading +./scripts/sync-kgx-normalization.sh /tmp/kgxtest --dry-run + +# Skip the maps: ~56 MB instead of ~970 MB, enough for everything the dashboard shows today +./scripts/sync-kgx-normalization.sh data/kgx-storage.ci.transltr.io \ + --exclude "**/normalization_map.json" + +# Mirror somewhere else +./scripts/sync-kgx-normalization.sh /some/other/path +``` + +Any argument after the destination is passed straight through to `rclone`. + +### What you end up with + +``` +data/kgx-storage.ci.transltr.io/ +├── {source}/ +│ ├── latest-build.json +│ └── {source_version}/ +│ └── transform_{hash}/ +│ └── normalization_{babel}_{nn}_{code}_{conflated}_{strict}/ +│ ├── normalization-metadata.json +│ ├── normalization_failures.txt +│ └── normalization_map.json +``` + +As of August 2026: 31 sources, 90 build directories, ~970 MB — 298 files, of which 297 arrive (see +the ubergraph 502 under Troubleshooting). The directory is named after the host it came from, and +`/data/` is gitignored, so nothing here is ever committed. + +Re-running the script is incremental: unchanged files are skipped on size and modification time, so +a no-op sync transfers nothing. The listing walk still takes a few minutes, since every directory +must be fetched as HTML. + +## Step 2 — check the mirror + +```bash +uv run python tests/test_loader.py # OK: 587 rows over 31 sources, 84 case-insensitive prefixes… +uv run python tests/test_curie.py # OK: 266 Biolink prefixes loaded +``` + +No framework, no fixtures — these assert invariants against the real mirror (counts reconcile, +rates match the counts, exactly one latest build per source, summary values are all scalars). The +loader check skips cleanly if the mirror has not been synced yet. + +## Step 3 — run the dashboard + +```bash +uv run normalization-dashboard # http://127.0.0.1:8050 +PORT=8051 uv run normalization-dashboard # second instance alongside the first +``` + +Startup reads the 90 metadata files and scans the 87 failures files once to collect example CURIEs — +3.2M lines, under a second. Nothing is cached to disk; restart to pick up a fresh sync. + +The mirror is found relative to the repository even when the app is launched from another working +directory, so an IDE run configuration works without extra setup. If no mirror is found at all, the +app fails at startup naming the sync script rather than rendering an empty table. + +## Keeping it current + +- **New KGX builds**: re-run `./scripts/sync-kgx-normalization.sh`, then restart the app. +- **Biolink prefix map** (vendored so the app works offline, 266 prefixes, 14 KB): + + ```bash + curl -sL https://raw.githubusercontent.com/biolink/biolink-model/master/src/biolink_model/prefixmaps/biolink-model-prefix-map.json \ + -o src/normalization_dashboard/biolink-model-prefix-map.json + ``` + + It decides which CURIEs link directly to their source; the rest fall back to `bioregistry.io`. + +## Hosting + +**There is no static export yet.** The dashboard is a local Dash server, which is deliberate for +now: individual unnormalized CURIEs stay off the public web while we work out what the dashboard +should show, and it keeps the door open to folding this into another Translator dashboard later. + +When we do want it hosted, the shape of the work is already set up for it: + +- `loader.py` has no Dash imports and returns plain `list[dict]`, so a static-JSON exporter can + reuse `load_rows()`, `summarize()` and `load_failure_examples()` without touching the app. +- The prefix summary is genuinely small — 242 source/prefix rows, well under a megabyte as JSON — + so a GitHub Pages page reading a committed JSON file is viable for goal 1. +- The 898,042 distinct unnormalized CURIEs are the part that would need a decision, both for size + and for whether they should be public at all. + +## Troubleshooting + +**`502 Bad Gateway` on ubergraph's `normalization_map.json`, sync exits non-zero.** Expected, and +upstream. That one file is 247 MB and the origin appears to buffer JSON responses in memory; a 48 MB +map from the same source downloads fine. The sync completes 297 of 298 files. Add +`--exclude "**/ubergraph/**/normalization_map.json"` if the non-zero exit is a nuisance. + +**`Port 8050 is in use by another program.`** Another instance is still running. `lsof -ti:8050` +finds it, or use `PORT=8051`. + +**`No KGX Storage mirror at …`** The sync has not been run, or was pointed somewhere else. Run +`./scripts/sync-kgx-normalization.sh` from the repository root. + +**The table is empty, or shows fewer rows than expected.** Check the mirror actually has metadata +files: `find data/kgx-storage.ci.transltr.io -name normalization-metadata.json | wc -l` should +report 90. From f20249242353271678ddabf75b70bed54814dec0 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 14 Aug 2026 18:55:38 -0400 Subject: [PATCH 27/27] Bring CLAUDE.md up to date with what was actually built 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 --- CLAUDE.md | 73 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 61 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0fc3ffb..9c1b6af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,9 +15,13 @@ Three goals, in order: 3. Renormalize a build's normalization output against a new Babel release to estimate how much the new release would change the results. -Planned shape: a Python CLI produces small static JSON summaries, committed to the repo and rendered -by a plain HTML/JS page on GitHub Pages. Possibly ported to [Dash](https://plotly.com/dash/) later so -it can be combined with other Translator dashboards. +Current shape: a local [Dash](https://plotly.com/dash/) app over a local mirror of KGX Storage. +Local is deliberate — individual unnormalized CURIEs stay off the public web while we work out what +the dashboard should show, and Dash is the form that can later fold into other Translator +dashboards. Nothing is hosted yet; see the Hosting section of [docs/Loading.md](docs/Loading.md). + +Goal 1 is built. Goals 2 and 3 are partly served by the click-through CURIE listing, and the +normalization maps are mirrored ready for goal 3. ## KGX Storage @@ -127,15 +131,55 @@ cross-source rollups. ## The app +See [docs/Loading.md](docs/Loading.md) for the runbook. Layout: + - `src/normalization_dashboard/loader.py` — no Dash imports, returns plain `list[dict]` so a notebook, the Dash app and a future static-JSON exporter can all reuse it. `load_rows()` gives one - row per (build, prefix); `summarize()` pools by (source, case-insensitive prefix). -- `src/normalization_dashboard/app.py` — the Dash app. `uv run normalization-dashboard`. -- `src/normalization_dashboard/curie.py` — CURIE → URL, and malformed-CURIE detection. + row per (build, prefix); `summarize()` pools by (source, case-insensitive prefix); + `load_failure_examples()` collects a few real failing CURIEs per (source, prefix); + `spread()` samples evenly across a list rather than taking its head. +- `src/normalization_dashboard/app.py` — the Dash app. `uv run normalization-dashboard`, or + `PORT=8051` for a second instance. +- `src/normalization_dashboard/curie.py` — CURIE → URL, malformed detection, and the `problem()` / + `stem()` grouping used by the click-through listing. - `tests/test_loader.py`, `tests/test_curie.py` — invariant checks against the real mirror, no framework. `uv run python tests/test_loader.py`. The loader one skips cleanly if the mirror is not synced. +### Gotchas worth not rediscovering + +- **DataTable cells may only hold a string, number or boolean.** A list or dict on a row makes the + browser reject the *whole* table with `Invalid argument data[0].x passed into DataTable`. This is + a client-side propType check, so no server-side test and no `curl` of the callback endpoint will + ever see it — `test_loader.py` asserts every `summarize()` value is a scalar for exactly this + reason. More generally: verifying Dash callbacks over HTTP proves the data, never the rendering. +- **`active_cell["row"]` indexes the sorted, filtered viewport**, not the `data` prop. Read + `derived_viewport_data`, or a click after re-sorting silently picks the wrong row. +- **Row identity beats row index** for `style_data_conditional`: the selected-row highlight matches + on `{prefix}` and `{source}` via `filter_query` so it follows the row through a re-sort. +- **The mirror path is resolved relative to the repo**, not the working directory, or an IDE run + configuration loads zero rows in silence. +- **Table width was never a Dash limit.** A page container with `maxWidth` will silently crop the + table; `DataTable` fills whatever its parent gives it. + +### The click-through CURIE listing + +Unnormalized CURIEs are grouped by `curie.problem()`, most actionable first: `malformed: `, +then `prefix unknown to the Biolink model`, then `no Babel clique for this identifier`. The prefix +check is a *signal*, not a proven cause — Babel decides coverage for itself and does not consult the +Biolink prefix map — so the label says what was actually checked. + +Within each problem, CURIEs are grouped by `curie.stem()`, everything up to the first digit. That is +what turns pathbank's 215,953 failures into 175,039 `PathBank:Reaction_…`, 31,182 +`PathBank:Compound_…` and 8,886 `PathBank:ProteinComplex_…`, and splits bgee's ENSEMBL failures by +species. Grouping declines above 12 groups and falls back to a flat list: InChIKeys contain no +digits, so 87 of them would otherwise make 87 groups of one. + +Headline figures in the summary list describe the whole build selection, not the visible rows — an +overall score that moved when you hid the fully-normalizing prefixes would be worse than useless. +CURIEs are counted once per source, so the all-builds total of 3,225,939 failures counts occurrences +(898,042 distinct). + ### Linking CURIEs `biolink-model-prefix-map.json` is **vendored** into the package (266 prefixes, 14 KB) so the app @@ -164,11 +208,11 @@ prefixes, 242 summary rows (241 for latest builds only — historical builds add ## Local mirror -`./scripts/sync-kgx-normalization.sh` mirrors those three file types into -`data/kgx-storage.ci.transltr.io/`, named for the host it came from. Mirrors go in a directory named -after the host they came from, so it stays obvious where a local copy originated. The nodes/edges -files are excluded by the `--include` filters, which apply to the listing walk, so they are never -fetched. +`./scripts/sync-kgx-normalization.sh` mirrors those three file types plus `latest-build.json` into +`data/kgx-storage.ci.transltr.io/` — a directory named after the host they came from, so it stays +obvious where a local copy originated. The nodes/edges files are excluded by the `--include` +filters, which apply to the listing walk, so they are never fetched. Full runbook, variants and +troubleshooting: [docs/Loading.md](docs/Loading.md). `/data/` is gitignored, so use it as the scratch space for one-off jobs — intermediate results, downloaded samples, ad-hoc query output — rather than `/tmp`. It survives reboots and stays next to @@ -176,7 +220,12 @@ the code, so a one-off job can be picked up or re-run later instead of being red ## Conventions -- Add a dependency only when something actually needs it. The project has none so far. +- Add a dependency only when something actually needs it. `dash` is the only one; `curies` was + considered and skipped because the Biolink prefix map is a flat dict. - The downloader stays `rclone` — it already does listing, filtering, incremental sync, pruning, retries, and concurrency. - Data files are never committed; only the small derived JSON summaries will be. +- Derived values are recomputed rather than trusted: `success_rate` comes from the counts, not from + the field in the file. +- PR titles become release notes, so they describe the change and its effect — never "WIP" or + "Initial implementation of X".