Assertions framework for BabelTest expectations - #102
Merged
Conversation
Introduce src/babel_validation/assertions: a small framework of handler classes that turn a named BabelTest assertion (HasLabel, SearchByName, ResolvesWith, DoesNotResolveWith, ResolvesWithType, Needed, ...) plus its parameters into a check evaluated against NodeNorm/NameRes, returning a TestResult. assertions/README.md is generated from the handler class attributes by gen_docs.py, and test_assertions_docs.py (marked `unit`, offline) asserts the checked-in README stays in sync. Register the `unit` marker, first used by that test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…path
- ResolvesWithTypeHandler: use node.get('type') or [] instead of node['type']
to match the existing first_type() guard (NodeNorm can return results without
a type key)
- NeededHandler: add test_with_nameres override so Needed issues fail on both
NodeNorm and NameRes paths, not just NodeNorm
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove drug_chemical_conflate='true' from the NodeNorm call used to
canonicalize the expected CURIE; NameRes normalizes without conflation, so
using it caused structural false failures for drug/chemical CURIEs
- Use .get('label', '') instead of ['label'] to avoid KeyError for CURIEs
that NodeNorm resolves without a preferred label
- Regenerate assertions/README.md to reflect the updated parameter description
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ionHandler - AssertionHandler.test_with_nodenorm/test_with_nameres: return iter([]) instead of [] to match the Iterator[TestResult] annotation; simplify docstrings to remove reference to github_issues_test_cases.py (module not yet in this PR) - Remove same stale reference from NodeNormTest.test_param_set and NameResTest.test_param_set docstrings - SearchByNameHandler.DESCRIPTION: "exactly two" not "at least two" to match the len(params) != 2 enforcement in test_param_set - Regenerate assertions/README.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Switch absolute src.babel_validation import to a relative import (..core.testrow) so internal modules aren't coupled to the top-level packaging layout. - Use full MD5 digest for cache filename instead of truncating to 8 chars, eliminating the theoretical hash-collision risk across different sheet IDs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds module docstrings explaining the per-(identifier, params) cache key, the no-auto-eviction policy, and the intended cache-warming pattern: call the batch method once for all identifiers in a task, then use the single-item method per assertion at zero HTTP cost. Adds method docstrings to from_url(), the batch methods (normalize_curies / bulk_lookup), the single-item methods (normalize_curie / lookup), and the cache-clearing methods. Includes a note that lookup() targets a distinct NameRes endpoint from bulk_lookup() and does not delegate to it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
NodeNormService and NameResService Protocols document the public interface callers should type against. When the implementation is later replaced by an external library, any code typed against the Protocol will need no changes. Rename clear_curie() → invalidate_curie() and delete_query() → invalidate_query(). "Invalidate" is standard cache vocabulary and makes the full-eviction-across-all-param-variants semantics clearer than "clear/delete". Also tightens type annotations on normalize_curie / lookup signatures (str, return type) to match the Protocol. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The repo's .gitignore predates the Python rewrite (it only covered Scala/Giter8/IntelliJ artifacts), so __pycache__/ and *.pyc were tracked as untracked noise and easy to commit by accident. Append the standard GitHub Python.gitignore template (bytecode, build/dist, .pytest_cache, .venv/.env, mypy/coverage caches, etc.) plus a .DS_Store entry for macOS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
normalize_curies() built its return value from response.json(), so a CURIE that NodeNorm silently omitted from its response was simply absent from the returned dict rather than present with a None value. A caller iterating results.items() would never see it, and would report success for a CURIE it never actually tested. The warm-cache path happened to add the missing key back, so the hole only opened on a cold cache — which is exactly when a first-time lookup happens. Build the result from *curies* instead. Every requested CURIE is in the cache by that point, so the dict now has exactly one entry per request, in request order. Request ordering also makes downstream "first result that resolved" logic deterministic; previously it depended on the server's JSON ordering on a cold cache and on set iteration order (i.e. per-process string hash randomization) on a warm one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CURIE-format validation and NodeNorm cache warming lived in
NodeNormTest.test_with_nodenorm(), so NameRes assertions got neither:
SearchByName's expected CURIE went to NodeNorm unvalidated, contradicting the
documented "malformed CURIEs are never sent to NodeNorm" invariant, and each
param_set cost its own NodeNorm round-trip instead of one batched call.
Hoist the logic to AssertionHandler.prepare_param_sets() and call it from
both test_with_nodenorm() and test_with_nameres(). SearchByName overrides
curie_params() to params[1:2] — its first param is a free-text search query.
The slice rather than an index keeps a malformed param_set out of validation
so test_param_set() reports the arity problem instead.
prepare_param_sets() also strips surrounding whitespace from every param.
_CURIE_RE is anchored, so a wiki-syntax param with incidental padding
({{BabelTest|Resolves| CHEBI:15365 }}) was reported as malformed; HasLabel
already stripped its label param, so this makes the treatment uniform.
Adds a VALIDATE_CURIES class attribute (default True) for assertions that
need to opt out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Up-front CURIE-format validation applied to DoesNotResolve, which is the one
assertion whose entire purpose is identifiers that don't resolve.
{{BabelTest|DoesNotResolve|not a curie}} failed with "Malformed CURIE(s)"
rather than passing, so the assertion could not express "this junk identifier
must not resolve".
Set VALIDATE_CURIES = False on the handler. A param that isn't a well-formed
CURIE trivially does not resolve, which is the assertion's expected outcome.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
generate_readme() emitted a group header the first time it saw a handler for that service while walking ASSERTION_HANDLERS in insertion order. The registry happens to be grouped today, so the output is correct; register a NodeNormTest after SearchByNameHandler and it renders under "## NameRes Assertions" with no header of its own. test_assertions_docs.py can't catch it either, since it regenerates the same wrong output. Iterate _GROUP_HEADERS and filter handlers per group instead. The README regenerates byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stubs requests.post rather than the service, so the tests run against the real CachedNodeNorm and exercise its bulk-normalization contract instead of restating it in a fake. The fixture DB drops one CURIE from the response entirely, which is what NodeNorm does for some unknown identifiers. Covers the omitted-CURIE contract, ResolvesWith/DoesNotResolveWith handling of an unresolvable CURIE, which CURIE gets blamed when one of three differs, DoesNotResolve accepting a malformed identifier, param stripping, SearchByName rejecting a bad CURIE without calling NodeNorm, and README grouping for a handler registered out of order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts: # src/babel_validation/services/nodenorm.py
"The response is merged with the cached results before returning" described the old implementation, which built its return value from response.json() and then added the cached entries to it. The method now caches the response and assembles the return value from the cache, which is what lets it guarantee an entry per requested CURIE. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (6)
src/babel_validation/assertions/nodenorm.py:205
- No unit test exercises
HasLabelHandler; the exact-match, mismatch, unresolved-node, and missing-label branches are all currently uncovered. Add offline cases for these outcomes using the existing NodeNorm fixture so this registered assertion's contract is verified.
if 'label' not in result['id']:
yield self.failed(
f"CURIE {curie} has no label but expected '{expected_label}' on {nodenorm}"
)
return
src/babel_validation/assertions/nodenorm.py:122
- The public contract omits the implementation's requirement that every CURIE resolve. As written, users can reasonably expect one unresolved CURIE to satisfy “must NOT all resolve to the same result,” but lines 137–144 reject it as a configuration error. Document that all CURIEs must resolve in
DESCRIPTION/PARAMETERS, then regenerate the README.
This issue also appears on line 201 of the same file.
PARAMETERS = "Two or more CURIEs per param_set. They must not all resolve to the same result."
src/babel_validation/assertions/nodenorm.py:5
- This module types every handler against the concrete cache class even though
services/nodenorm.py:28-33explicitly requires callers to depend onNodeNormService. Use that protocol in the import and annotations so alternative service implementations remain type-compatible.
from src.babel_validation.services.nodenorm import CachedNodeNorm
src/babel_validation/assertions/nameres.py:8
- These concrete client imports conflict with the explicit interface convention in
services/nameres.py:34-39andservices/nodenorm.py:28-33. Annotate the handler withNameResServiceandNodeNormServiceinstead, preserving compatibility with drop-in implementations and test doubles.
from src.babel_validation.services.nameres import CachedNameRes
from src.babel_validation.services.nodenorm import CachedNodeNorm
src/babel_validation/assertions/nameres.py:52
- The only
SearchByNametest supplies a malformed CURIE andnameres=None, so it exits during prevalidation and never exercises this lookup or the canonical-ID/top-N matching. Add offline found, not-found, and rank-boundary cases using a fake NameRes client; otherwise the only NameRes assertion's core behavior can regress unnoticed.
results = nameres.lookup(search_query, autocomplete='false', limit=pass_if_found_in_top)
src/babel_validation/assertions/nodenorm.py:252
ResolvesWithTypeHandlerhas no behavioral coverage, including the newly guarded missing/emptytyperesponse. Add offline resolved, wrong-type, missing-type, and unresolved-CURIE cases using the existing NodeNorm fixture.
if not node:
yield self.failed(f"Could not resolve {curie} with NodeNormalization service {nodenorm}")
continue
biolink_types = node.get('type') or []
if expected_biolink_type in biolink_types:
Signatures like `tuple[list[list[str]], dict[int, TestResult]]` said nothing about what the strings were or how the two halves related. Introduce Params (one assertion invocation's parameters) and ParamSets (a list of them) as aliases, and use them throughout the handler modules. The deeper problem was that prepare_param_sets() returned two structures keyed by the same index — the stripped param_sets and a dict of failures — leaving both NodeNormTest and NameResTest to re-zip them by hand with an index lookup. Return a list of PreparedParamSet instead, a frozen two-field record pairing each param_set with the reason it was rejected (None when it wasn't). The callers become a single loop over prepared param_sets with no index bookkeeping. Also splits the per-param_set rejection check out into _rejection(), so prepare_param_sets() reads as strip / reject / warm rather than interleaving all three. No behaviour change; the generated README is byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The package calls this concept a "param set" everywhere that matters — the
generated README, every handler's PARAMETERS string, the error messages, and
test_param_set(), which is the documented extension point for new assertions.
Naming the alias Params while the record pairing it with a failure was called
PreparedParamSet gave the same concept two names depending on which stage of
the pipeline it was in.
Keeps the domain term rather than renaming toward something like ParamsList:
"set" here is the English sense the README already defines ("independent groups
of parameters"), not Python's set type, and renaming only the alias would leave
the type vocabulary at odds with the docs and the method name.
Adds a comment on the alias saying so, since "set" naturally raises the
question of whether order is significant — it is.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Set" invited the reading that these are unordered and deduplicated, which they are not: position carries meaning (ResolvesWithType takes its Biolink type first, HasLabel is [curie, label]) and duplicates are evaluated as given. Nothing outside this repo depends on the vocabulary yet, so fix it before the issue parser in the next part of the stack builds on it. Renames the whole vocabulary rather than just the type alias, since a rename of only the alias would leave signatures at odds with the generated README, the handler PARAMETERS strings, the error messages users read, and the name of the extension point itself: ParamSet -> ParamsList PreparedParamSet -> PreparedParamsList prepare_param_sets-> prepare_params_lists test_param_set -> test_params_list param_set(s) -> params_list(s) (identifiers, messages, prose) Also drops the ParamSets alias in favour of spelling out list[ParamsList]; "ParamsLists" is a worse name than the nesting it hides, and the element type is now self-describing. No behaviour change. The README is regenerated and differs only in wording. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The nodenorm and nameres parameters were entirely untyped, so nothing in a
signature said what they were or what a handler could call on them — the worst
case being AssertionHandler.test_with_nodenorm(), whose one-line docstring
described neither.
Annotate them with the NodeNormService and NameResService Protocols the
services modules already define for exactly this purpose ("type parameters
against this Protocol rather than CachedNodeNorm directly so that a future
drop-in library replacement requires no caller changes"). The concrete
CachedNodeNorm/CachedNameRes annotations in the handler modules move to the
Protocols too, since they were the same instruction ignored a second time.
Documents the parts a new handler author has to know and could not previously
find in the code:
- what label is for, and that it surfaces in failure messages
- what pass_if_found_in_top means, and that it also caps the NameRes request
- why NameRes assertions receive NodeNorm as well (normalizing the expected
CURIE so comparison is by canonical identifier, not exact string)
- that yielding nothing from test_with_* means "not applicable to this service"
- what test_params_list() may assume about its params (non-empty, stripped,
CURIEs validated and pre-warmed) and that it should yield one result per
CURIE rather than one aggregate
- that handlers are shared singletons and must not hold per-evaluation state
Also declares PARAMETERS, WIKI_EXAMPLES and YAML_PARAMS on the base class
alongside NAME and DESCRIPTION. All five are read by gen_docs.py, so they are
part of the contract, but only two were previously visible as such.
Uses the :param: style already used in google_sheet_test_cases.py and conftest.py.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
'unknown type' had the shape of a real Biolink type: current ones are prefixed
("biolink:Gene"), but the older vocabulary NodeNorm used was lowercase prose
("chemical entity"), so a reader scanning a failure message could reasonably
take 'unknown type' for something Babel actually returned.
Use NO TYPE RETURNED instead, named as NodeNormTest.NO_TYPE so a test can
assert on it without restating the literal. Wording covers both branches:
first_type() falls back when the type list is empty *and* when the key is
missing altogether, so "empty type list" would have been wrong in the second
case.
Adds a test, since this branch had no coverage — it checks the placeholder
can't be confused with either type vocabulary rather than just comparing to
the constant, which would pass for any value at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three separate comments said NAME must be lowercase and nothing checked it. The rule is load-bearing: README.md promises users that assertion names are case-insensitive, which only holds if every registry key is already lowercase, so a handler declaring NAME = "Resolves" would become unreachable the moment the issue parser lowercases its input. Building the registry with a dict comprehension had a second silent failure mode — two handlers sharing a NAME would drop one with no error. Replace the comprehension with _register(), which rejects both. Neither can happen except while adding an assertion, so failing at import is the cheapest possible feedback. Tests cover both rejections and assert the real registry satisfies them. Also folds the "adding a new assertion" instructions into one place. There were two: a six-step list in this module's docstring and gen_docs.ADDING_NEW, which renders into assertions/README.md. They had already drifted — the docstring split the attributes across two steps and never mentioned the lowercase rule. README.md keeps the instructions, since it is what someone adding an assertion is already reading; the docstring now points at it and describes the module layout instead, which is what a code reader wants. The README section also gains what a new author previously had to infer from the base classes: what each documentation attribute is for, what test_params_list() may assume about its params, and when to override curie_params(). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gaurav
added a commit
that referenced
this pull request
Aug 26, 2026
) Test cases for Babel currently live in a Google Sheet, which is fine for bulk regression data and poor for anything tied to a specific bug: the sheet has no idea which issue a row came from, so nothing tells us when a fixed issue regresses or when an open one has quietly started working. This PR lets a test case live in the GitHub issue that motivated it, and runs those cases as pytest tests. An issue body carries assertions in either of two syntaxes — a wiki-style `{{BabelTest|Resolves|CHEBI:15365}}` marker, or a fenced YAML block whose top-level key is `babel_tests:` — and the harness turns each issue into one pytest item, with each assertion a subtest. Open issues are expected to fail, so an open issue whose assertions all pass is reported as a strict XPASS: the tool is telling you the issue looks closeable. Closed issues are expected to pass, so one that starts failing is telling you to reopen it. **Stack 3 of 4** splitting #67. #101 and #102 have merged, so this now bases on `main`; #104 is the remaining piece. This leaves #67 open, which stays open for #104. ## What's here **Parsing — `src/babel_validation/sources/github/`.** `GitHubIssuesTestCases` finds assertion blocks in issue bodies and resolves each to a handler from the assertions framework that landed in #102. Issue discovery uses the GitHub search API rather than paginating every issue, and reads `body` and `html_url` straight off the search result, so scanning the five configured repositories costs two search requests per repo and no core requests at all. **Harness — `tests/github_issues/`.** One pytest item per issue, parametrized by issue ID, across the repositories listed under `Repositories` in `targets.ini`'s `[DEFAULT]` section. `--issue` targets a specific issue by `org/repo#N`, `repo#N` or `N`. Issue IDs and hydrated issues are cached and shared across `pytest-xdist` workers behind a `FileLock`. **Input validation.** Issue bodies are untrusted: anyone with a GitHub account can write one, nothing reviews it, and we parse it and turn it into live NodeNorm and NameRes calls. That is tolerable while a human watches the run and can hit Ctrl-C; it is not tolerable for the unattended daily runs this is meant to enable. Two of the gaps were reproducible denial of service rather than theory: - The pattern that finds a `babel_tests` block ended with `\s+.*?\s+` before the closing fence. Those three nested backtracking quantifiers made matching cubic on a body that opens a block and never closes the fence — 6.8s at 4KB, 53s at 8KB, and hours at GitHub's 65536-character body limit. It runs during collection, which `pytest-timeout` does not cover, so a single such issue hung the whole run before any test started. Anchoring on the newline that follows `babel_tests:` in a real fenced block removes the ambiguity: 0.0008s at 65536 characters. - `yaml.safe_load` blocks code execution but still resolves aliases, and PyYAML shares the aliased nodes rather than copying them, so the load looks cheap and the cost lands on whatever formats the result afterwards. A 337-byte body of chained anchors reached 25MB in an error message. Aliases are refused outright, which covers merge keys too. On top of those: caps on body length, assertions, param sets and parameters per issue; per-parameter checks for empty, over-long and non-printable values; duplicate YAML keys rejected, because YAML keeps the last silently and the block a reviewer reads would not be the block that runs; issue text `repr()`'d into log lines so an ANSI escape or bidi override cannot reach an operator's terminal; and `--issue` resolving only within the configured repositories. Structural caps fail the whole issue — the fix is to split it across several issues — while a bad parameter fails only its own param set, so the rest of the issue still runs. The caps sit far above anything the configured repositories currently contain, so they are a no-op for real issues today, and they are documented in the generated `assertions/README.md` where issue authors will meet them. **Caches moved out of the shared temp directory.** Both the issue-ID cache and the Google Sheet CSV cache used fixed names in the world-writable temp directory. The issue cache holds the IDs a later run fetches and executes assertions from, so being able to write it was close to being able to choose what the run tests. They now live in `~/.cache/babel-validation`, created `0700`, overridable with `BABEL_VALIDATION_CACHE_DIR` — and failing to create that directory names the override, since a read-only home on a locked-down runner otherwise raises a `PermissionError` that gives no hint an escape hatch exists. The cache sweep in `pytest_configure` also stopped deleting the Google Sheet `.lock` files. 48b1c44 had already removed that for the GitHub issue lock — a concurrent pytest holding the lock keeps its now-unlinked inode while this run creates a fresh one, so two processes end up inside "the" lock — and the Google Sheet path had the same shape. `unlink_if_exists()` now also refuses any path outside the cache directory, because it deletes whatever it is handed and runs before anything else in the session. **Test layout.** The offline tests live in `tests/github_issues/unit/` — `test_syntax.py` (what the two syntaxes mean), `test_discovery.py` (finding issues, identifying them, resolving an ID) and `test_untrusted_input.py` (the guards on body content). A subdirectory rather than three siblings because of the fixture: `tests/github_issues/conftest.py` defines a session-scoped `github_issues_test_cases` that needs a real `GITHUB_TOKEN`, and these tests want a dummy-token parser instead. A `conftest.py` in the subdirectory scopes that override to exactly the files that want it, where putting it in the parent would replace the real fixture for the live `test_github_issues.py` as well. **Documentation.** `CLAUDE.md` gains an `Untrusted Input` section: which inputs are hostile and which (`targets.ini`) are trusted config, and each failure mode above written as the shape to look for rather than as the fix that was applied — so the next parser added here starts from them. The generated `assertions/README.md` carries the caps, where issue authors meet them. And it now says in as many words that a red `pytest tests/github_issues` is the tool working, not a defect to be fixed by editing the assertions, because the obvious reading of eighteen red tests is otherwise the wrong one. And it warns against writing a complete `{{BabelTest|...}}` marker into an issue: this repository is itself in the scanned `Repositories` list, so an issue that merely *describes* an assertion gets collected and runs it. ## What a run produces `pytest -m unit` is fully offline and needs no token: **97 passed**, in about a second. This is what CI runs, and it is green. `pytest tests/github_issues --target dev` currently reports **18 failing issues**, and that is the tool working rather than a defect in it: - **10 open issues XPASS** — every assertion now passes, so they look closeable. - **7 closed issues have failing assertions** — #406, #552, #584, #711, #714, #723, #906 — so they look like they should be reopened. - **1 issue uses an assertion type we have not written yet**: `ShouldNotHaveSynonym`, in NCATSTranslator/Babel#744. Unknown assertion names fail loudly by design rather than being skipped, so this stays a hard failure until #110 lands. It is unrelated to any NodeNorm behaviour. ## What it deliberately does not do - **No new CLI options for the limits.** They are module constants. `pytest --timeout=N` already exists and is the runtime knob for a slow issue. - **No guards around the NodeNorm/NameRes responses.** Those URLs come from `targets.ini`, which is trusted config, not from issue bodies. - **CI runs `-m unit` only**, so nothing here exercises the live GitHub path on a PR. Enabling it needs `issues: read` and a token; the recipe is in a comment in `tests.yaml`, and #114 covers building a tier of tests that need a token but not a full crawl. Worth knowing that without a token the issue tests **skip rather than fail**, so a run can go green having tested nothing. ## Follow-on work Nothing is blocking this merge. Two pieces are tracked separately: - #110 — implement the `ShouldNotHaveSynonym` assertion type, which is the one live failure above that is about this harness rather than about NodeNorm. - #114 — a tier of GitHub API tests that need a token but not a full issue crawl, so CI can cover the integration itself. - #115 — the per-issue size caps are checked after every `GitHubIssueTest` has been built, so an oversized body still constructs a few thousand objects before being rejected. Bounded by GitHub's own body limit and off the network path, so it matters only if the caps are ever tightened much further. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
src/babel_validation/assertions: the engine that turns a named BabelTest assertion plus its parameters into a check evaluated against NodeNorm/NameRes. Nothing consumes it yet — the GitHub issue parser that produces the parameters arrives later in the stack — so this PR is the engine, its generated documentation, and its offline tests.Stack 2 of 4 splitting #67. Base:
main(#101 is merged).The assertion types
AssertionHandleris the base class;NodeNormTestandNameResTestspecialize it per service. Each concrete handler declares its parameters and yieldsTestResults. Registered:Resolves,DoesNotResolve,ResolvesWith,DoesNotResolveWith,HasLabel,ResolvesWithType,SearchByName, andNeeded(a placeholder that always fails, marking an issue as still needing a real test).Both wiki (
{{BabelTest|Resolves|CHEBI:15365}}) and YAML syntax are supported, and an assertion can carry several independent params lists, each evaluated separately so one bad one doesn't sink the rest.Shared parameter handling
AssertionHandler.prepare_params_lists()strips whitespace from every param, rejects params lists whose CURIEs are malformed, and warms the NodeNorm cache for the survivors in a single batched request. Bothtest_with_nodenorm()andtest_with_nameres()route through it, so the NameRes path gets the same validation and the same one-request warming rather than a NodeNorm round-trip per params list.Two escape hatches keep that uniform treatment from being wrong for particular assertions:
curie_params()narrows which params are CURIEs —HasLabelto the first,ResolvesWithTypeto everything after the Biolink type,SearchByNameto the expected CURIE only (its first param is a free-text query).VALIDATE_CURIES = Falseopts an assertion out of format validation entirely.DoesNotResolvesets it: an identifier that isn't even a well-formed CURIE trivially doesn't resolve, which is exactly what that assertion exists to state, so rejecting it up front would leave the assertion unable to express its own purpose.A NodeNorm bulk-normalization fix
CachedNodeNorm.normalize_curies()built its return value fromresponse.json(), so a CURIE that NodeNorm silently omitted from its response was absent from the returned dict rather than present with aNonevalue. A caller iterating the results would never see it and would report success for a CURIE it never tested. The warm-cache path happened to re-add the missing key, so the hole only opened on a cold lookup.It now builds the result from the requested CURIEs: exactly one entry per request, in request order. The ordering guarantee matters independently — "first CURIE that resolved" logic previously depended on the server's JSON ordering when cold and on set iteration order (per-process string hash randomization) when warm, so which CURIE got blamed in a failure message could vary between runs of the same test.
Types and naming
Parameters are named rather than left as nested
list[str]:ParamsList— one assertion invocation's parameters. Position is significant (ResolvesWithTypetakes its Biolink type first,HasLabelis[curie, label]), which is why this is a list.PreparedParamsList— a frozen(params, failure)record.prepare_params_lists()returns a list of these rather than a(stripped, failures_by_index)tuple, so callers don't re-zip two structures by hand.Service parameters are annotated with the
NodeNormService/NameResServiceProtocols thatservices/already defines for the purpose, rather than the concreteCachedNodeNorm/CachedNameRes, so a future drop-in replacement needs no changes here.Guardrails
_register()replaces the{h.NAME: h for h in [...]}comprehension and raises on aNAMEthat isn't lowercase or one that's already taken. Lowercase is load-bearing — the README promises users that assertion names are matched case-insensitively, which only holds if every registry key is lowercase — and a duplicateNAMEwould otherwise silently drop a handler. Both are mistakes only made while adding an assertion, so they fail loudly at import.NO TYPE RETURNED. The earlier placeholder,unknown type, had the shape of a real type — the older Biolink vocabulary was lowercase prose likechemical entity— so it could be misread as something Babel actually returned.Documentation
gen_docs.pyrendersassertions/README.mdfrom the handler class attributes, grouping handlers by the service they test rather than by their order inASSERTION_HANDLERS— otherwise a handler registered in the wrong place lands under the wrong heading, and the sync test can't catch it because it regenerates the same wrong output.tests/test_environment/test_assertions_docs.pyasserts the checked-in README stays in sync.The "Adding a New Assertion Type" instructions live in that generated README and nowhere else. There had been a second copy in the package docstring and the two had already drifted; the docstring now points at the README and describes the module layout instead.
Tests
tests/test_environment/test_assertions.pystubsrequests.postrather than the service, so handlers run against the realCachedNodeNormand exercise its contract instead of a fake restating it. The fixture DB drops one CURIE from the response entirely, reproducing what NodeNorm does for some unknown identifiers. 13 unit tests, all offline.Also registers the
unitpytest marker, first used by these tests.Outcomes
Notes for review
gh pr diffshows hunks in.gitignore,services/nameres.py,sources/google_sheets/, andtests/conftest.pythat came from #101 and are already onmain— they merge as no-ops. The net change againstmainis theassertions/package, thenormalize_curies()fix, the two test files, and the pytest marker.result: dictis left untyped for NodeNorm response entries. ATypedDictwould be more precise, but the response shape is Babel's to change and a wrong one is worse than an honestdict, so the docstrings say what the dict is instead.Verify
uv run pytest -m unit -q→ 13 assertion tests pass offline.uv run python -m src.babel_validation.assertions.gen_docsreproduces the committed README.🤖 Generated with Claude Code