Skip to content

Check the middle of gtdb_lineage, not just its head and tail (#454) - #455

Merged
realmarcin merged 3 commits into
mainfrom
gtdb-lineage-consistency-454
Aug 6, 2026
Merged

Check the middle of gtdb_lineage, not just its head and tail (#454)#455
realmarcin merged 3 commits into
mainfrom
gtdb-lineage-consistency-454

Conversation

@realmarcin

Copy link
Copy Markdown
Contributor

Closes #454.

The gap

gtdb_lineage is a denormalised path, and the existing checks only look at its ends — freshness compares gtdb_id, gtdb_taxon and the tail; the prokaryote-only gate (#365) reads the head. A segment corrupted in between passed every gate and the entire test suite:

d__Bacteria;p__Bacteroidota;c__Chlorobiia
d__Archaea;p__Nonsense;c__Chlorobiia        <- indistinguishable

That was tolerable while gtdb_ground.py wrote every lineage from the crosswalk. #450 made seven of them hand-written curator pins, which is when it stopped being theoretical — and the review that found it could only do so by querying the crosswalk, which CI has no checkout of. A crosswalk-based check would skip precisely where it is needed.

Two checks, both offline

Shape, per record — every segment carries a known rank prefix, and ranks get finer left to right. Runs inside validate-strict as gtdb_lineage_malformed. Catches d__Bacteria;c__Chlorobiia;p__Bacteroidota, a missing prefix, an unknown prefix, a repeated rank.

Hierarchy, across records — GTDB is a strict hierarchy, so a taxon has exactly one parent. Reading all 727 blocks as (taxon, path-above-it) pairs, a segment appearing under two different paths is a contradiction between two records, whichever is wrong. This needs the whole corpus at once, so it runs in just validate-gtdb-all and in the test suite rather than per file.

Neither needs the mapping, which is the point: they run wherever CI runs.

What it does and does not catch

The corpus check is a weaker claim than "this lineage matches GTDB" and a far cheaper one. It cannot catch a corruption that is internally consistent — a taxon named in one record only, misspelled throughout. It does catch the case that motivated it: a hand-edited pin drifting from the 720 blocks the tool wrote. The docstring says so plainly rather than implying more.

Measured

727 blocks · 740 distinct GTDB taxa · 0 conflicts, 0 malformed.

14 new tests, including the exact corruption that prompted this, the malformed-input guards (gtdb_lineage has no range in the schema, so a YAML list is schema-valid), and a KB sweep. Verified end to end: validate_strict.py on a record with reversed ranks exits 1 with a gtdb_lineage_malformed row.

just qc green.

🤖 Generated with Claude Code

realmarcin and others added 3 commits August 6, 2026 05:43
The freshness checks compare gtdb_id, gtdb_taxon and the lineage's tail; the
prokaryote-only gate (#365) reads its head. A segment corrupted in between
passed every gate and the whole suite:

    d__Bacteria;p__Bacteroidota;c__Chlorobiia
    d__Archaea;p__Nonsense;c__Chlorobiia        <- indistinguishable

That was tolerable while gtdb_ground.py wrote every lineage from the crosswalk.
#450 made seven of them hand-written curator pins, and the review that caught it
could only do so by asking the crosswalk - which CI has no checkout of, so a
crosswalk-based check would skip exactly where it is needed.

Both new checks are corpus-internal and need no mapping. Ranks must carry a
known prefix and get finer left to right, which is per-record and runs inside
validate-strict as gtdb_lineage_malformed. And because GTDB is a hierarchy, a
taxon must sit under exactly one parent path across every record naming it -
that one needs the whole corpus at once, so it runs in
`just validate-gtdb-all` and in the test suite rather than per file.

The corpus check is a weaker claim than "this lineage matches GTDB" and a much
cheaper one. It cannot catch a corruption that is internally consistent, but it
catches the case that matters: a hand-edited pin drifting from the 720 blocks
the tool wrote.

Measured: 727 blocks, 740 distinct taxa, zero conflicts, zero malformed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shape check accepted a lineage that skips a rank; the corpus check keys a
taxon by the literal path above it, so the same lineage would place its tail
under a different path than the full chain does - and be reported as a hierarchy
conflict naming the record that is correct. Two halves of one module disagreeing
about what a valid lineage is, and the failure lands on the innocent file.

Requiring contiguity, and a start at d__, turns that into a precise per-record
error against the record that actually has the problem. No crosswalk lineage
skips a rank, so this constrains hand-written pins rather than tool output -
which is the case #454 exists for.

Walk interaction participants too. source_taxon/target_taxon share taxon_term's
range, so a block is schema-valid there, and the sibling #365 gate already walks
them; none exist today, so missing them would have been silent.

The KB test counted taxonomy entries, not blocks - 1032 rather than 727 - so if
`_blocks` ever stopped matching, both checks would return [] for every record
and the test would stay green on an empty corpus. It now counts what the checks
actually walk.

Also: my insertion split the #365 comment from the loop it documents, so it read
as documentation for the new check and claimed something false of it; the
docstring said the shape check compares against gtdb_id, which it never reads;
and the CLI and justfile described only the evidence-count half of a script that
can now exit 1 for a lineage conflict, including that a single-file run cannot
find a cross-record one.

The review also cleared the risk I was most unsure of: across all 92,711
crosswalk rows there is no GTDB segment with more than one parent path, and
though 1,237 bare names are reused across ranks (UBA1381 is an order, a family
and a genus), keying on the rank-prefixed segment keeps them distinct. The
corpus check has no latent false positive from name reuse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… promised

The CLI docstring said passing one file checks its shape. It did not - the
script imported only check_corpus - so `just validate-gtdb-all`, the recipe the
docstring points at, still had the failure mode round 1 was meant to remove: a
rank-skipping lineage surfacing only as a hierarchy conflict that names the
record which is correct. The shape check now runs there too, and prints first,
so a curator sees the malformed record before the conflict it causes. Verified:
a skip-rank file alone exits 1, a clean file exits 0.

Also fixed the #365 comment, which I orphaned a second time - merged into the
new paragraph one call site down, so it documented the wrong loop and claimed
something false of it. Each comment now sits with the loop it describes, and the
#454 one names the contiguity and d__-first rules that are its substance.

Round 2 confirmed the riskiest part of round 1: making the shape rule stricter
rejects nothing real. All 727 KB lineages pass, and replicating the tool's own
lineage builder over all 92,711 crosswalk rows at every truncation level gives
47,996 distinct lineages, none of which skips a rank or fails to start at d__.
The tool cannot write data its own validator rejects.

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

Copy link
Copy Markdown
Contributor Author

Review complete — two rounds, merge verdict

Round 1

The two halves of the module disagreed about what a valid lineage is. The shape check accepted a rank-skipping lineage; the corpus check keys a taxon by the literal path above it, so that same lineage put its tail under a different path than the full chain — and got reported as a hierarchy conflict naming the record that was correct. Requiring contiguity and a d__ start turns that into a precise per-record error against the record that actually has the problem.

Also fixed: the KB test counted taxonomy entries (1032) rather than blocks (727), so if _blocks ever stopped matching, both checks would return [] for every record and the test would stay green on an empty corpus; interaction participants were skipped, unlike the sibling #365 gate; an orphaned comment; a docstring claiming a gtdb_id comparison the function never makes.

Round 1 also cleared the risk I was least sure of: across all 92,711 crosswalk rows there is no GTDB segment with more than one parent path, and although 1,237 bare names are reused across ranks (UBA1381 is an order, a family and a genus), keying on the rank-prefixed segment keeps them distinct. No latent false positive from name reuse.

Round 2

The CLI docstring promised the shape check ran there; it didn't — only check_corpus was imported. So just validate-gtdb-all still had exactly the failure mode round 1 removed. Now wired, printing the malformed record before the conflict it causes. And the #365 comment got orphaned a second time by my own edit, documenting the wrong loop.

Round 2 verified the riskiest change — making the rule stricter could have rejected legitimate data. It doesn't: all 727 KB lineages pass, and replicating the tool's own lineage builder over all 92,711 crosswalk rows at every truncation level yields 47,996 distinct lineages, 0 rejected. The tool cannot write data its own validator rejects.

State

727 blocks · 740 distinct taxa · 0 conflicts, 0 malformed · just qc green · 1564 passed, 16 skipped. Verdict: merge.

@realmarcin
realmarcin merged commit d4335af into main Aug 6, 2026
3 checks passed
@realmarcin
realmarcin deleted the gtdb-lineage-consistency-454 branch August 6, 2026 14:08
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.

Nothing validates the middle of gtdb_lineage, now that seven are hand-written

1 participant