Skip to content

Add babel-explorer: a CLI for querying Babel cross-references via DuckDB and NodeNorm - #20

Merged
gaurav merged 125 commits into
mainfrom
basic-implementation-in-uv
Sep 1, 2026
Merged

Add babel-explorer: a CLI for querying Babel cross-references via DuckDB and NodeNorm#20
gaurav merged 125 commits into
mainfrom
basic-implementation-in-uv

Conversation

@gaurav

@gaurav gaurav commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

babel-explorer is a CLI for asking Babel why two identifiers are considered the same thing. It reads Babel's intermediate Parquet files through DuckDB and, optionally, enriches the results with labels from NodeNorm. BabelDownloader handles caching and freshness, BabelXRefs handles querying, NodeNorm handles labels, and cli.py wires them together with Click.

Supersedes #1, which GitHub closed and refused to reopen after this branch's history was rewritten.

Closes #12.

What's here

Three commands:

  • xrefs — cross-references for one or more CURIEs. --recurse expands transitively through a single WITH RECURSIVE DuckDB query; --paths shows the shortest paths connecting the given CURIEs; --labels adds NodeNorm labels and Biolink types.
  • ids — identifier records from Identifiers.parquet, with --labels.
  • test-concord — compare a proposed concordance change against NodeNorm's current cliques.

xrefs and ids also take --format json|tsv|csv for machine-readable output. --paths is console-only, and both of its preconditions — a console format, and at least two CURIEs — are checked before anything is downloaded. Getting either wrong otherwise costs a multi-gigabyte Concord.parquet download and a full recursive query before the run is rejected, because --paths implies --recurse.

Failures from the two services this tool talks to are reported as errors rather than tracebacks. MissingBabelFileError explains that a release does not publish the DuckDB files; requests.RequestException reaching the top means NodeNorm, since the downloader handles its own network failures.

Configuring which Babel release to query

A Babel release is addressed as a releases directory plus a version, which is how both the public and internal trees are actually laid out — one subdirectory per release, plus a latest/ symlink:

Variable CLI option Default
BABEL_RELEASES_URL --babel-releases-url https://stars.renci.org/var/babel/
BABEL_VERSION --babel-version latest
--babel-url (overrides both)

Pinning a release is a one-word change rather than a URL edit, which matters because pinning is the honest fix for a NodeNorm version mismatch. Precedence runs flag > environment variable > .env > built-in default, and only public URLs are committed.

--babel-url takes a complete URL for a tree that does not follow that layout. It is command-line only, with no envvar=, deliberately: two variables already feed the composed URL, and a third that silently outranked both would make "which release am I actually querying?" unanswerable from the environment alone. cli() warns if the pre-refactor BABEL_URL is still set, so a stale .env fails loudly instead of silently pointing somewhere else.

compose_babel_url lives in core/downloader.py rather than cli.py because tests/constants.py needs the identical composition and must not import Click to get it.

The committed template is env.default — visible in a plain ls, unlike a dotfile.

Babel version handling

The release is resolved from VERSION.txt, falling back to the final URL path segment — which under this scheme is exactly BABEL_VERSION, so a pinned release still resolves when VERSION.txt is unreachable, while latest yields None as before.

BABEL_LOCAL_DIR holds one release at a time. When it changes, only last_checked is cleared from the .meta sidecars, so the existing ETag path re-checks each file and re-downloads only what actually changed — the Parquet files are never deleted, and an unchanged file costs one HEAD rather than a fresh multi-gigabyte download. The .babel-version marker records the release the server resolved to rather than the one requested, so latest and an equivalent pinned version share a cache instead of thrashing it.

That marker is written after the cache catches up, not when the change is spotted. It claims "the local cache holds this release", which is only true once every cached file has been re-validated against it, so _write_version_marker_if_synced() stamps it once no .meta sidecar is still missing its last_checked. Writing it up front would leave a run interrupted between Concord.parquet and Identifiers.parquet with a marker naming the new release over a half-old cache, and the next run would see it match and skip the refresh entirely. The cost is that a cached file nobody asks for holds the marker back indefinitely, at one HEAD per run; that is the honest answer, since the file really is still from the previous release.

--check-download never suppresses re-checks within a release, not across one. _is_within_freshness() therefore tests for a missing last_checked before the float("inf") shortcut — reversed, never would hand back the previous release's Parquet with no network call at all, under a marker naming the new release, and nothing would ever notice.

--labels refuses to mix a NodeNorm built from one release with cross-references from another, since the result would be silently wrong rather than obviously wrong. --allow-version-mismatch overrides it; pinning BABEL_VERSION fixes it properly. --recurse never consults NodeNorm, so it does not trigger the check.

Partial downloads cannot corrupt the cache

A corrupt Parquet here is permanent: whatever lands on disk gets stamped with the correct remote ETag and then passes every later freshness check. Five routes to that are closed:

  • A .tmp left by a killed process is discarded before each download rather than resumed. Resume is by byte offset, the only way to reach the download at all is that the remote bytes changed, and an orphaned .tmp carries no record of which version its bytes came from. Cleanup catches BaseException, so Ctrl-C leaves nothing resumable behind.
  • In-run resumes send If-Range, so a file rebuilt mid-download restarts instead of splicing two versions together.
  • HTTP 416 counts as "already complete" only once the local size matches the remote Content-Length — 416 is also what a server returns when the file shrank below the resume offset.
  • A stream ending short of Content-Length raises IncompleteDownloadError and is retried, rather than being promoted as complete.
  • A failed HEAD returns "unknown", not "unchanged", and no longer refreshes last_checked. One flaky HEAD could otherwise pin the previous release's Parquet as freshly validated for the whole freshness window.

.tmp files are deleted in two places on purpose; CLAUDE.md records which one is the safety guarantee and which is housekeeping, so neither gets removed later as redundant.

Querying

DuckDB connections are ephemeral and in-memory, but "in-memory" is not "touches no disk": a larger-than-memory query spills, and DuckDB's default temp_directory is .tmp in the current working directory. The recursive expansion materialises the whole Concord relation, so every connection goes through BabelXRefs._connect(), which points temp_directory at <BABEL_LOCAL_DIR>/duckdb-spill/ — the directory the user already chose to hold multi-gigabyte files, rather than wherever they happened to be standing.

What it deliberately does not do

Known limitations at v0.1.0

This ships knowingly non-functional against its own defaults, so people can try the tool now rather than after the Babel side catches up. The CLI says so clearly rather than failing mid-download.

Testing

261 unit tests and 13 NodeNorm integration tests pass. 28 Parquet-dependent integration tests skip without a Babel release publishing the files — that is the expected result, not a broken environment.

Verified end to end against a Translator releases tree: the composed URL resolves 2026jul22, downloads Concord.parquet, writes a correct .babel-version marker, and returns cross-references for MONDO:0004979. The version-marker sequencing was verified separately by simulating a 2025dec112026jul22 change under --check-download never: both Parquet files are re-fetched, and the marker flips only after the second one lands.

Note that not slow is not a promise of "small". Concord.parquet is 4.6 GB in 2026jul22 and its tests are not marked slow, because excluding them would leave the non-slow integration set covering nothing that touches real data. This matters for sizing #18.

Nothing is blocking this merge

Everything outstanding is tracked in #12, #13, #15, #16, #17, #18, #19 and #25. None of it makes what ships here wrong — #16 and #17 constrain what the defaults can do, and both are stated plainly in the README, CHANGELOG.md and the CLI's own error messages.

Review history — three review rounds and a history rewrite. Kept for anyone tracing why a particular line looks the way it does; the durable conclusions are in the code comments, CLAUDE.md and the sections above.

First review round found six defects, each fixed in its own commit with a regression test: --recurse triggered the NodeNorm version check it no longer needed; ids --labels silently dropped the NodeNorm label because Identifiers.parquet's own label column overwrote it; a version change forced a full re-download instead of an ETag re-check; a stale .tmp could splice two releases into one corrupt Parquet; the HTTP 416 fast path persisted the error response's headers as the file's metadata; and the integration skip probe did not normalise a slashless BABEL_URL, so it probed .../latestduckdb/... and silently skipped the entire integration suite.

Second review round found six more, all but one in the downloader's resume path — the five now described under "Partial downloads cannot corrupt the cache", plus record_to_dict dropping Identifiers.parquet's own label column whenever it was empty, because the omit-when-absent rule matched on a label name suffix rather than the three NodeNorm-derived field names. That made label present on some rows of a json/tsv/csv run and missing on others.

The first round's .tmp fix was narrower than it looked: it swept .tmp files only on a Babel version change, which does not cover a content change within one release or a rebuild in place. The second round replaced it with an unconditional discard before every download. Both deletes are kept, for different reasons, which is why CLAUDE.md spells out which is which.

Copilot review found four threads and one suppressed comment, all five real. Two were in the resume path and are folded into the rules above: a retry sent a bare Range whenever no validator was known, which is exactly the case where a splice cannot be detected afterwards; and a 416 whose HEAD carried no Content-Length was read as "already complete" when it is equally the answer for a file that shrank. Two existing tests had encoded the first behaviour by seeding a partial file with no validator — a state get_downloaded_file never produces — and were rewritten to reach the resume the way production does. The others: ten get_curie_xref.cache_clear() calls left over from an lru_cache that no longer exists, raising AttributeError in integration tests that skip and so never ran; BABEL_ALLOW_VERSION_MISMATCH missing from env.default, which survived because the test guarding that rule listed the settings by hand rather than reading them off the CLI; and this PR's own batched NodeNorm lookups still listed as future work.

Third review round found seven, in five commits. Two were the same defect from opposite ends and are the reason the version-marker rules above are stated so explicitly: _is_within_freshness() returned True on float("inf") before looking at last_checked, so the whole cross-release refresh was a no-op under --check-download never; and the marker was stamped before anything had been re-downloaded, so an interrupted refresh looked complete to the next run. Both let Concord.parquet and Identifiers.parquet be read together across two Babel builds, which is precisely what the marker exists to prevent. The other three code findings: xrefs --paths rejected a single CURIE only inside _print_paths, after the 4.6 GB download; NodeNorm's deliberately-uncaught HTTP errors reached the user as a stack trace; and DuckDB spilled into the working directory.

Two were documentation drifting from the code — CLAUDE.md described @functools.lru_cache in three places where none exists (disk caching by ETag, cached_property, and per-instance dicts respectively), and CHANGELOG.md carried a hard-coded test count that was already wrong, in a repo whose own CLAUDE.md says not to record them because they drift silently and then mislead.

History rewrite (2026-09-01). The internal releases URL had been the hardcoded default from the initial commit until it moved into .env, leaving it in 178 of 180 commits across six branches of a public repository. Every commit was rewritten to remove it, verified by four independent checks before anything was pushed, with every changed line confirmed to be that substitution and nothing else. GitHub then refused to reopen the affected PRs, because their original head commits no longer exist — hence #20 here, and #21-#24 replacing #4, #6, #7 and #11. The rewrite does not un-publish anything: GitHub retains pre-rewrite objects reachable by SHA, and the tree the URL pointed at still serves 200 unauthenticated. Both are being handled outside this repo.

Verifying the release caught a stale claim. Concord.parquet is 4.6 GB in 2026jul22, not the ~626 MB a fixture docstring had claimed, so pytest -m "integration and not slow" — documented in two places as avoiding 2 GB+ downloads — would in fact pull 4.6 GB. The docs were corrected rather than the marker, and hard byte figures were dropped from the fixture docstrings for the same reason the repo already gives for test counts: they drift silently and then mislead.

gaurav and others added 30 commits December 2, 2025 15:38
- Add IdentifierRecord dataclass to babel_xrefs.py (resolves TODO)
- Add 89 tests across 3 files: test_downloader (26), test_babel_xrefs (31), test_nodenorm (23)
- Unit tests (71) use mocks and run without network; integration tests (18) use real downloads/APIs
- Add session-scoped fixtures in conftest.py for shared Parquet file downloads
- Parametrize integration tests over tests/data/valid_curies.txt for easy expansion
- Add integration and slow pytest markers to pyproject.toml
- Update CLAUDE.md and README.md with testing documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
- Remove _calculate_md5/_fetch_remote_md5 (too slow on 2.5-3.9 GB files)
- Add sidecar .meta JSON files (ETag, Last-Modified, Content-Length, last_checked)
- Three-tier logic: freshness window → HEAD/ETag check → full re-download
- Add freshness_seconds param to BabelDownloader (default 3h)
- Add --check-download CLI option to xrefs and ids commands (e.g. 3h, never)
- Update tests: replace MD5 test classes with meta/ETag/tier coverage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add pytest-xdist[psutil] and filelock to dev dependencies
- Enable parallel execution by default with addopts = "-n auto"
- Switch DuckDB connections to in-memory mode (duckdb.connect()) to
  eliminate file locking that would deadlock parallel workers
- Make test_data_dir teardown worker-aware (only gw0 cleans up)
- Wrap download fixtures with FileLock to serialize concurrent downloads
- Fix test_babel_xrefs.py: update expand= to recurse= to match renamed param

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The recurse=True path previously issued one DuckDB query per CURIE and
called itself recursively (O(diameter) queries, Python stack growth).
It now delegates to _get_curie_xrefs_recursive, which traverses the
full connected component in a single SQL query using WITH RECURSIVE.

A bidirectional `edges` CTE (subj→obj and obj→subj) collapses the two
traversal directions into one recursive arm; UNION (not UNION ALL)
provides automatic cycle detection. ignore_curies_in_expansion is now
a no-op on the recurse=True path and emits a DeprecationWarning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
gaurav and others added 12 commits August 18, 2026 14:29
BabelDownloader appends a trailing slash to url_base, but the integration
skip probe joined BABEL_URL and the file path directly. A BABEL_URL without
a trailing slash HEADed ".../latestduckdb/Concord.parquet", got a 404, and
silently skipped the whole integration session — indistinguishable from the
expected skip on a public release. Also skip rather than error when the
Babel server is unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sync_cache_version() deleted the .meta sidecars, but get_downloaded_file()
only enters the ETag branch when a sidecar exists — so every release rollover
re-downloaded Concord (~626 MB) and Identifiers (2 GB+) in full even when
byte-identical, the opposite of what the docstring claimed. Clear only
last_checked and keep the ETag, so unchanged files cost one HEAD.

Also delete partial .tmp downloads on a version change: they are resumed by
byte offset with no If-Range validation, so a Ctrl-C (a BaseException, which
the tmp cleanup in get_downloaded_file does not catch) followed by a release
rollover would append the new release's bytes onto a prefix of the old one
and land a corrupt Parquet that passes every later freshness check.

And on HTTP 416, HEAD for the file's real headers instead of returning the
416 response's own — those describe the error body, and persisting them as
the file's metadata poisoned the very cache entry the fast path just
confirmed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five ways the downloader could end up with a corrupt or stale
`duckdb/*.parquet` that then passed every later freshness check, because
`_save_meta` stamps whatever landed on disk with the *correct* remote ETag.
Once that happens the damage is permanent until the Babel release changes.

- A `.tmp` left by a killed process was resumed by byte offset. The only way
  to reach the download at all is that the remote bytes changed, so the
  server appended the new file's tail to the old file's prefix. Leftover
  `.tmp` files are now discarded before a download starts, and cleaned up on
  `BaseException` so a Ctrl-C leaves nothing resumable behind.
- Resumes within a run now send `If-Range` with the validator from the
  response they started writing from, so a file rebuilt mid-download restarts
  (HTTP 200, already handled) instead of splicing.
- HTTP 416 was read as "already complete", but it is also what the server
  returns when the remote file *shrank* below the resume offset. The local
  size is now checked against the remote `Content-Length` first.
- A stream that ended short of `Content-Length` without raising was promoted
  as complete. It now raises `IncompleteDownloadError` and is retried.
- A failed HEAD returned "unchanged", and the caller then refreshed
  `last_checked`. One flaky HEAD could therefore pin the previous release's
  Parquet as freshly validated for the whole freshness window, right after
  `sync_cache_version` cleared `last_checked` for a new release.
  `_etag_matches` becomes `_remote_unchanged` and returns `None` for "could
  not check": the cached file is still used, but nothing is restamped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`record_to_dict` dropped any key ending in `label` whose value was falsy, to
implement the "omit an absent label rather than emit an empty one" convention.
But `extra_fields` is flattened in first, so Identifiers.parquet's own `label`
column — and any other `*_label` column Babel adds — was dropped whenever it
was empty or NULL. `ids --format json` then emitted some records with a
`label` key and some without, and a consumer doing `row["label"]` got a
KeyError.

The rule only ever meant the NodeNorm-derived labels, so name them:
nodenorm_label, subj_label, obj_label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records why a leftover `.tmp` is never resumed across runs, why in-run resumes
are conditional, where the two size checks sit, and why a failed HEAD does not
refresh `last_checked` — so none of it gets "optimised" back into a cross-run
resume or a fail-open freshness stamp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The delete in `get_downloaded_file` is the safety guarantee; the sweep in
`sync_cache_version` is housekeeping for files that are never re-downloaded
and so never reach it. Neither covers the other's case, and dropping the wrong
one reintroduces silent Parquet corruption, so say which is which.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BABEL_URL was one opaque knob that had to carry both "which server" and "which
release". Splitting it matches how both Babel trees are actually laid out -- one
subdirectory per release plus a `latest/` symlink -- and makes pinning a release
a one-word change rather than a URL edit:

    BABEL_RELEASES_URL=https://stars.renci.org/var/babel/   (--babel-releases-url)
    BABEL_VERSION=latest                                    (--babel-version)

The effective Babel URL is the two composed. `--babel-url` still takes a
complete URL and overrides both, for a tree that does not follow that layout,
but it is now **command line only**: with two variables already feeding the
composed URL, a third that silently outranked both would make "which release am
I actually querying?" unanswerable from the environment alone.

The defaults compose byte-identically to the URL they replace, so nothing about
the shipped behaviour changes.

Falling out of this:

- `resolve_babel_version`'s final-path-segment fallback now strips exactly
  BABEL_VERSION, so a pinned release still resolves when VERSION.txt is
  unreachable. `latest` still yields None, as before.
- The skew message can now suggest pinning --babel-version to the release
  NodeNorm was built from, which fixes the mismatch rather than suppressing it.
- `--babel-version` rejects a value containing "://" or "..", since anyone with
  muscle memory from BABEL_URL will eventually paste a whole URL into it.
- `cli()` warns if BABEL_URL is still set, so a stale .env does not silently
  send someone to the wrong release.

`compose_babel_url` lives in core/downloader.py rather than cli.py because
tests/constants.py needs the identical composition and must not import Click to
get it. TestUrlConfiguration now stubs load_dotenv: it runs inside cli(), after
CliRunner(env=...) has cleared a variable, so a real .env would otherwise leak
into the assertions -- and every Translator developer is about to have
BABEL_RELEASES_URL in theirs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`.env.example` is hidden on Linux and reads like a sample rather than the
defaults it actually holds. `env.default` is visible in a plain `ls` and says
what it is. It also gains BABEL_CHECK_DOWNLOAD, which the README documented but
the old template omitted.

The README and CLAUDE.md are updated throughout for BABEL_RELEASES_URL /
BABEL_VERSION, and both now record why --babel-url deliberately has no envvar=
so nobody adds one back. CLAUDE.md also notes that the .babel-version marker
records the release the server *resolved* to rather than the one requested, so
`latest` and an equivalent pinned version share a cache -- that is intended, not
a bug to fix.

The missing-Parquet caveat is no longer addressed only to Translator members: it
now says plainly that the shipped defaults cannot serve data yet, and links the
tracking issue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The repository is MIT licensed and has been since the first commit, but the
built wheel carried no license, no author and no classifiers. Anyone inspecting
the package could not tell what they were allowed to do with it.

CHANGELOG.md records what 0.1.0 contains and, just as importantly, what it
cannot do yet: the shipped defaults cannot query data (#16) and --labels fails
the version check against public NodeNorm (#17).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verifying the release end to end against a current Babel turned up that
Concord.parquet is 4.6 GB in 2026jul22, not the ~626 MB the fixture docstring
claimed. Its tests are not marked `slow`, so `pytest -m "integration and not
slow"` -- documented in both README.md and CLAUDE.md as avoiding 2GB+ downloads
-- would in fact pull 4.6 GB.

Marking those tests slow instead would leave the non-slow integration set
covering nothing that touches real data, so the marker keeps its meaning and the
docs are corrected: `slow` means Identifiers.parquet, and `not slow` is not a
promise of "small". Called out explicitly because it changes the sizing for #18,
where CI would start pulling these files for real.

Hard byte figures are dropped from the fixture docstrings for the same reason
the file already gives for test counts: they drift silently and then mislead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Never commit the Translator-specific URL" has been a documented rule for as
long as the repository has had a config template, and nothing enforced it. The
URL was in fact the hardcoded default from the initial commit until history was
rewritten to remove it, so the rule has already failed once in practice.

TestCommittedConfigTemplate reads env.default and asserts that every host in it
is public, that its defaults still compose to the CLI's default URL, that it
documents exactly the settings the CLI reads, and that it does not resurrect
BABEL_URL. Verified non-vacuous: injecting the internal URL fails it.

Also pins the missing-Parquet error's wording. That message is where most people
first learn the configuration scheme exists; it named BABEL_URL for as long as
that variable did, and nothing would have caught the wording going stale when
the variable was replaced.

.gitignore gains .env.* alongside .env, because a .env.backup or .env.local
holding that URL is precisely what a blanket `git add` would sweep up.
env.default does not match the pattern and stays tracked.

CLAUDE.md records what the history rewrite means for anyone with an older clone
or an old PR link, and points at the test as the enforcement mechanism.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces the initial Babel Explorer CLI for querying Babel Parquet data through DuckDB with optional NodeNorm enrichment.

Changes:

  • Adds xrefs, ids, and test-concord commands with structured output.
  • Implements release-aware downloading, caching, recursive queries, paths, and batched NodeNorm lookups.
  • Adds packaging, CI, documentation, and comprehensive tests.

Reviewed changes

Copilot reviewed 21 out of 26 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
.github/workflows/ci.yml Adds lint and test workflows.
.gitignore Ignores generated data and local configuration.
.python-version Selects Python 3.11.
CHANGELOG.md Documents the initial release.
CLAUDE.md Adds repository development guidance.
FUTURE.md Lists tracked future work.
README.md Documents setup, configuration, and usage.
env.default Provides public endpoint defaults.
pyproject.toml Configures packaging, dependencies, and tooling.
src/babel_explorer/__init__.py Initializes the package.
src/babel_explorer/cli.py Implements Click commands and configuration.
src/babel_explorer/core/__init__.py Initializes the core package.
src/babel_explorer/core/babel_xrefs.py Implements DuckDB querying and graph traversal.
src/babel_explorer/core/downloader.py Implements downloads, caching, and release handling.
src/babel_explorer/core/nodenorm.py Implements NodeNorm lookups and batching.
src/babel_explorer/formatting.py Implements console and structured output.
tests/__init__.py Initializes the test package.
tests/conftest.py Adds shared integration fixtures.
tests/constants.py Defines shared test configuration.
tests/data/valid_curies.txt Supplies integration-test CURIEs.
tests/test_babel_xrefs.py Tests querying and traversal.
tests/test_cli.py Tests CLI behavior and configuration.
tests/test_downloader.py Tests download and cache behavior.
tests/test_formatting.py Tests output formatting.
tests/test_nodenorm.py Tests NodeNorm integration and batching.
uv.lock Locks project dependencies.
Suppressed comments (1)

src/babel_explorer/core/downloader.py:449

  • When the HEAD response omits Content-Length, this condition falls through and treats the temporary file as complete without verifying its size. A 416 alone does not prove completeness, so an unavailable length must also discard the partial file and restart.
                        remote_length = head.headers.get("Content-Length")
                        if (
                            remote_length is not None
                            and int(remote_length) != resume_byte_pos
                        ):

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/babel_explorer/core/downloader.py Outdated
Comment thread src/babel_explorer/core/downloader.py Outdated
Comment thread tests/test_babel_xrefs.py Outdated
Comment thread FUTURE.md Outdated
Comment thread env.default
gaurav and others added 9 commits September 1, 2026 03:33
Two paths let a cache holding the previous release be used against a marker
naming the new one, so Concord.parquet and Identifiers.parquet could be read
together across two Babel builds — the exact failure the version marker exists
to prevent.

_is_within_freshness() returned True on float("inf") before looking at
last_checked, so the clearing sync_cache_version() does was a no-op under
--check-download never: the old release's Parquet came back with no network
call at all. Test the missing last_checked first. "never" means "do not
re-check within a release", not "ignore a release change".

sync_cache_version() also stamped the new release into .babel-version up front,
so a run interrupted between the two Parquet files left a marker claiming a
release the cache only half held; the next run saw it match and skipped the
refresh. Hand the release to _write_version_marker_if_synced() instead, which
writes it only once no .meta sidecar is still missing its last_checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "at least two CURIEs" requirement lived in _print_paths, which runs after
make_downloader(), the multi-gigabyte Concord.parquet download and the full
recursive query — so `xrefs MONDO:0004979 --paths` fetched 4.6 GB and then said
it needed another CURIE. --paths implies --recurse, so there is no cheap version
of that mistake. Check it beside the --format guard, which was already up front.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BabelExplorerGroup.invoke mapped only MissingBabelFileError to a ClickException.
NodeNorm deliberately lets requests.HTTPError and ConnectionError propagate so a
failed lookup is not cached, and nothing caught them, so `xrefs --labels`, `ids
--labels` and `test-concord` against a down or 5xx NodeNorm ended in a Python
stack trace. Catch requests.RequestException alongside it.

The failure lands mid-query rather than at startup because get_babel_version()
swallows its own errors, so the version check passes against an unreachable
NodeNorm. That is still the right trade — a NodeNorm that cannot report a
version should not block a run — but it does mean the error has to be legible.

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

BabelXRefs opened connections with a bare duckdb.connect(), whose default
temp_directory is `.tmp` in the *current working directory*. Nothing is
persisted, but "in-memory" is not "touches no disk": the recursive expansion
materialises the whole Concord relation (4.6 GB in 2026jul22) plus a doubled
edges relation, so a real `--recurse` run dropped gigabytes of spill wherever
the user happened to be standing.

Route every connection through BabelXRefs._connect(), which points
temp_directory at <BABEL_LOCAL_DIR>/duckdb-spill/ — the directory the user
already chose to hold multi-gigabyte Parquet files.

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

CLAUDE.md claimed `@functools.lru_cache` in three places; `grep -rn lru_cache
src/` returns nothing. BabelDownloader caches on disk via ETags and `.meta`
sidecars and memoises only `babel_version` (with `cached_property`); NodeNorm
uses three plain per-instance dicts. A reader chasing LRU eviction semantics
was chasing nothing.

The changelog's "28 of 288 tests" was already wrong — collection reports 294 —
and CLAUDE.md says not to record per-file test counts precisely because they
drift silently and then mislead. The same reasoning applies here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_download_with_retry sent a bare Range whenever a retry found bytes on disk,
adding If-Range only if a validator happened to be known. A server that supplies
neither an ETag nor a Last-Modified therefore got an unconditional resume — and
that is precisely the case where a file rebuilt between attempts splices its tail
onto the old version's prefix undetectably, since what lands is then stamped with
the new validator and passes every later freshness check. Having a validator is
now a precondition for resuming at all; without one the partial file is discarded
and the download restarts.

Two existing tests encoded the old behaviour by seeding a partial file with no
validator, a state get_downloaded_file never produces. They now reach the resume
through a first attempt that ends short, which is how production gets there.

Copilot (suppressed): src/babel_explorer/core/downloader.py — an HTTP 416 whose
HEAD carries no Content-Length was treated as "already complete". 416 is also how
a server answers when the file shrank below the resume offset, and with no remote
length there is nothing to tell the two apart, so it now restarts as well. The
restart cannot loop: the retry has nothing on disk and so sends no Range.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The integration tests cleared the cross-reference cache with
`get_curie_xref.cache_clear()`, left over from when that method was decorated
with functools.lru_cache. It is a plain method over a per-instance dict now, so
all ten calls raised AttributeError — invisible because every one of those tests
skips without a Babel release publishing the Parquet files, which is the case for
every public release.

Add BabelXRefs.clear_xref_cache() and call that instead, with a unit test so the
method's existence is covered by the suite that actually runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BABEL_ALLOW_VERSION_MISMATCH is read from the environment and documented in both
CLAUDE.md and the changelog, but env.default omitted it, so copying the template
did not in fact give you every supported setting.

The omission survived because the test guarding that rule listed the settings by
hand: `test_documents_every_setting_the_cli_reads` asserted against a hard-coded
set, which passes just as happily when a new option is added to the CLI and
forgotten in the template. It now reads the envvar= declarations off the Click
commands, so the next setting cannot go undocumented the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Issue #12 asked for a `normalize_curies(curies)` batch method to collapse N
serial round-trips into one. That shipped in this branch: NodeNorm batches at
100 CURIEs per request, and all three label paths pre-warm through it — `ids`,
`xrefs --labels` and `test-concord`. Listing it as future work contradicted the
code next to it. #13 is untouched and still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gaurav
gaurav merged commit a07d647 into main Sep 1, 2026
3 checks passed
@gaurav
gaurav deleted the basic-implementation-in-uv branch September 1, 2026 13:00
gaurav added a commit that referenced this pull request Sep 1, 2026
…t assumes (#31)

`CLAUDE.md` had grown to 396 lines by accretion — each hard-won rule
from a review round appended wherever it fit. It was accurate about this
repository's internals and silent about the system those internals exist
to read: it never said what Babel is, what a clique or a concord is, or
why NodeNorm's version matters. An agent arriving at the
`Concord.parquet` query code had no model of the data.

This renames the file to `AGENTS.md`, gives it that missing context,
moves the deep caching invariants to `docs/`, and removes what was being
said twice. **Documentation only — no change to `src/`.**

## What's here

**Renamed to `AGENTS.md`**, the filename coding agents other than Claude
Code look for; the guidance was never Claude-specific. `CLAUDE.md`
remains as a four-line pointer, so Claude Code still finds it. Four test
docstrings that referenced the old name are updated. The rename is the
first commit, so the substantive edits that follow read as edits rather
than as a delete plus an add.

**A Domain context section** covering only the terms this codebase
actually uses — clique, preferred identifier, concord, conflation,
Biolink type — and NodeNorm's relationship to a specific Babel release.
It is a summary with links rather than copied upstream prose, so it
cannot drift from upstream unnoticed.

The part that earns the most space is the boundary between concords and
cliques. Babel's own `AGENTS.md` says to answer clique-membership
questions from a finished build *"never from the concords that fed it"*
— and concords are exactly what this tool reads. That is not a defect,
it is the purpose: `xrefs` reports the evidence Babel read, which is
what you want when a merge looks wrong, while NodeNorm reports the
verdict. Written down explicitly so that nobody later "improves" `xrefs`
into a clique oracle.

**The caching invariants moved to
[`docs/Downloading.md`](docs/Downloading.md)** — near-verbatim, because
the wording is hard-won and each rule records a specific failure that
actually happened. `AGENTS.md` keeps a four-sentence summary and a
pointer. That is ~90 lines an agent needs rarely, which had been sitting
above the orientation it needs every session.

**Deduplication.** The DuckDB spill directory was described in three
places, the caching model in three, and the
never-commit-the-internal-URL rule in three. Each is now stated once,
where it belongs. `## Important Notes` is deleted outright — all three
of its bullets restated earlier sections. The command examples now point
at `README.md`, which documents every flag and is the user-facing
reference.

**Four factual corrections:**

- `MissingBabelFileError` is raised on **any** 404, not only for
`duckdb/` paths, as the file claimed in two places. `downloader.py:546`
has no path condition.
- CI budgeting pointed only at #18; #28 covers the same ground more
precisely.
- "recreated as #20-#24" predated #20 merging.
- `README.md`'s configuration table was missing
`BABEL_ALLOW_VERSION_MISMATCH`, which `env.default` has carried since
#27.

## What upstream does not document

Worth recording, because it shaped what could honestly be written.
Neither Babel's `README.md` nor its `releases/ARTIFACTS.md` describes
`Concord.parquet`, `Identifiers.parquet` or `Metadata.parquet`.
`conflate` / `drug_chemical_conflate` and the `/status` `babel_version`
field are absent from NodeNormalization's README.

So the file schema in the new section is what **this repository's code
assumes**, verified against real files — and it is labelled that way
rather than presented as a published contract. Release naming, `latest`
and `VERSION.txt` are undocumented upstream too. Filed upstream as
[NCATSTranslator/Babel#1077](NCATSTranslator/Babel#1077),
which links back to the Domain context section as the shortest statement
of what is missing.

## What it deliberately does not do

- **Document `search-xrefs`** — that is #21, not on `main`.
- **Rewrite the version-skew wording** — #30 will change that behaviour;
editing the prose now means editing it twice.
- **Split Architecture or Testing into `docs/`** — those are context an
agent needs often, and spreading them costs more than the length saves.

## Verification

272 unit tests pass and ruff is clean (no source changes, so both should
be unchanged). Every relative link and intra-file anchor in `AGENTS.md`,
`docs/Downloading.md` and `CLAUDE.md` resolves, no `.py` file still
references `CLAUDE.md`, and the symbols the reworked text names were
re-checked against the code.

Nothing is blocking this merge.

**One thing to judge:** `AGENTS.md` is 370 lines, down from 396, with a
further 84 in `docs/`. Total documentation went *up*, because the Domain
context section came out at 61 lines rather than the ~25 originally
scoped. The extra went on the concord/clique boundary above. If that
reads as too much for an agent file, the obvious trim is `## Testing`
(73 lines, now the largest section).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Batch NodeNorm lookups to reduce N round-trips when --labels is set

2 participants