Skip to content

Diff assembly versions instead of assuming a +1 increment (Phase 4, 2 of 3) - #14

Merged
rjchallis merged 7 commits into
genomehubs:mainfrom
fchen13:fix/assembly-version-supersession-diff
Sep 7, 2026
Merged

rjchallis merged 7 commits into
genomehubs:mainfrom
fchen13:fix/assembly-version-supersession-diff

Conversation

@fchen13

@fchen13 fchen13 commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Phase 4 groundwork, part 2 of 3. Stacked on #13 — that PR's three commits appear in this diff until it merges. The two commits that are new here are PR-B: diff assembly versions instead of assuming a +1 increment and Key the historical merge on something every row has.

Where #13 was behaviour-neutral, this one changes what the daily pipeline actually does.

The defect (F5)

identify_newly_superseded assumed the current version had incremented by exactly one since the last run, so for an assembly current at v3 it reported v2 as "missing" — every day, whether or not anything changed. On the real dataset that is roughly 3,694 assemblies reported as missing a version on every run, each one an NCBI fetch that is not needed. The premise of the module is that it makes no fetches, so this defeated its purpose.

It also missed the case it most needed to catch: when a version is genuinely skipped (v3 current yesterday, v6 today), v4 and v5 are real gaps and the arithmetic never looked for them.

The fix

Diff today's current version against the version current at the last run, per base accession:

Case Behaviour
Unchanged Skipped entirely — the overwhelming majority of rows on any given day
+1 The predecessor is superseded, no gap
Skips versions The predecessor is superseded, and the versions in between are candidate gaps
New series Everything below the current version is a candidate gap
Regressed (NCBI withdrew a version) Reported, nothing written — the previous parse holds a row for a version NCBI no longer publishes

A gap is only reported if no phase has already parsed it: the versions already in assembly_historical.tsv are indexed first, so a settled assembly stops being re-reported on every subsequent run. That index is a local read; the module still opens no network connections.

Per your note on the original review — you had spotted that the old code looked at any version > 1 — the gap check now goes through assembly_historical.tsv before anything is handed to the fetch path.

Also here

append_superseded_to_tsv keyed its merge on the assembly ID alone, so two rows without one collapsed onto the empty string and all but the last were dropped when the file was rewritten. Every row Phase 0 and this parser write carries an ID, so nothing is losing data today, but the merge reads whatever is already on disk and is the wrong place to depend on that. It now falls back to the accession, then to a per-row sentinel — two rows with neither cannot be told apart, and keeping both is the safe answer.

Testing

SKIP_PREFECT=true python -m pytest tests/ -q

160 passing on this branch. The diff paths are covered case by case: unchanged single-version, unchanged multi-version (the case the arithmetic got wrong), +1, skipped versions, new series, and regression. A separate test asserts an unchanged input supersedes nothing and reports nothing missing — the assertion that actually captures "no unnecessary fetches", and the one the old code failed.

Exercised end to end against real NCBI data as well: a two-day run over a 207-assembly Malacostraca slice, with the multi-version assemblies rewound a version to make day 1, so every version the gap path goes looking for is one NCBI actually has.

Notes

The Phase 4 validation suite and the staged run follow in a third PR, once this one and #13 land.

Summary by Sourcery

Replace arithmetic version assumptions with historical diffs so daily assembly tracking reports only real supersessions and missing versions while preserving existing data.

New Features:

  • Diff current assembly versions against the previous run to detect superseded records and genuine skipped-version gaps.
  • Track already parsed historical versions to suppress repeated gap reports and surface version regressions.
  • Resolve current TSV paths from configuration and transparently support compressed TSV snapshots.
  • Add shared schema, column-normalization, and historical TSV utilities across pipeline phases.

Bug Fixes:

  • Stop reporting unchanged multi-version assemblies as missing predecessors on every daily run.
  • Detect and report all versions skipped during multi-version jumps and new series.
  • Preserve historical rows when merging records that lack assembly IDs and when rerunning backfills.
  • Prevent gap-fill backfills from truncating or duplicating existing historical records.

Enhancements:

  • Align supersession metadata with the declared historical schema and normalize legacy column aliases.
  • Make summary and milestone generation compatible with configured and gzipped current TSV inputs and absent-value sentinels.

CI:

  • Add a GitHub Actions workflow to run the pytest suite on pull requests and manual dispatches.

Documentation:

  • Update Phase 0 and Phase 1 operational documentation for config-driven paths, gap detection, historical indexing, and non-destructive writes.

Tests:

  • Expand coverage for unchanged assemblies, skipped versions, new series, regressions, historical gap suppression, schema conformance, compressed inputs, TSV merging, and non-destructive backfill writes.

Behaviour-neutral fixes to the column names Phases 0-3 read and write and to
where their files land. Addresses F1, F2, F3, F4, F6 and F9 from the Phase 4
plan; no change to which assemblies are detected or what the pipeline decides.

F1/F2 - Phase 1 read the non-existent `accession` column and wrote snake_case
version metadata, so the Phase 0 -> Phase 1 handoff raised KeyError on the
first real row. Adds get_accession / get_assembly_id / get_version_status to
assembly_versions_utils and routes every Phase 1 TSV read through them; Phase 1
now emits the canonical `versionStatus` / `assemblyID`. The JSONL read is left
alone, where `accession` is the correct NCBI key.

F3 - append_superseded_to_tsv derived its header from one arbitrary row, so
merging Phase 0 and Phase 1 row sets silently dropped columns. merge_fieldnames
now takes the union across all rows, ordered by the YAML header or the order
already on disk.

F4 - Phases 1-3 hardcoded `assembly_current.tsv` while the production config
emits `ncbi_datasets_eukaryota.tsv.gz`, and Phase 1 opened the gzipped
`.previous` snapshot as plain text. resolve_current_tsv_paths derives the name
from config.meta["file_name"], and open_tsv detects gzip by magic bytes (the
snapshot is compressed but does not end `.gz`).

F6 - Phase 0 wrote relative to the YAML's directory. resolve_output_path pins
the output to --work_dir, so the copy-the-YAML workaround is gone from the
READMEs.

F9 - Phase 0 wrote with mode "w". Harmless for a one-time backfill, but the F5
decision makes the gap-fill path re-invoke it daily, where it would truncate
the ~8,500-row file. write_or_append_parsed writes the whole file only when it
does not exist (so the header is produced) and otherwise appends the rows not
already present, deduplicating on genbankAccession via a cheap accession-only
pass.

Also adds `superseded_by`, `superseded_by_version` and `superseded_date` to
assembly_historical.types.yaml so they survive the GenomeHubs write path.

Tests: fixtures rebuilt on the real headers - building them inline with the
wrong keys is why F1-F3 went undetected - plus a schema-conformance test
against the YAML and a non-destructive-write test. 76 -> 109 passing.
parse_ncbi_assemblies has written processedAssemblyInfo.assemblyId (lowercase
d) since 5180090, while assembly_historical.types.yaml declares assemblyID and
Phase 0 writes assemblyID. Nothing is broken today - the two spellings never
meet in one file - but this is the class of defect PR-A exists to remove, and
it bites as soon as anything joins current and historical rows on that key.

get_assembly_id now reads all three spellings. The write side deliberately
stays on assemblyID: it is what the YAML declares, so a row emitted with the
lowercase form would be dropped by the GenomeHubs write path. A test pins that
asymmetry so a future edit cannot quietly flip the write side.

Narrow by inspection - versionStatus and genbankAccession have no case
variants upstream, so assemblyID is the only divergence. Which case is
canonical is a question for Rich; repointing is a one-line change to
COL_ASSEMBLY_ID plus the YAML once he answers.
Two defects in this PR's own surface, found running the phases together.

Phase 2 counted an ebpStandardDate of "None" as an EBP metric while Phase 3
read the same cell as absent. Upstream writes that string wherever a value
was missing when the row was formatted, so on real data the summary and the
milestones would claim different things about the same assembly. Every
phase now reads cells through assembly_versions_utils.cell, and a test
asserts the two predicates agree on "", "None" and a real date.

The gap fill appended under config.headers. That is right until the Phase 1
parser rewrites the file, which widens the header to the union of every
row's columns — a superseded row copied from the current TSV brings columns
the historical config never declared, and appended rows then come out a few
columns short. The pass that collects existing accessions now keeps the
on-disk header too, and appends against that; load_existing_accessions
becomes read_existing_output to match.

Phase 2.2 had no unit tests. It has them now, including the cross-phase
agreement on the sentinel.

The pytest CI job comes with this PR rather than the last of the three:
F1-F3 survived review because nobody re-ran the suite at the right moment,
which is an argument for the job gating the PR that fixes them.
@sourcery-ai

sourcery-ai Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Reviewer's Guide

This PR replaces arithmetic version assumptions with a historical, per-accession diff that avoids false-positive daily gaps, detects genuine skipped versions, preserves superseded and backfilled history safely, and standardizes TSV schema/path handling with CI-backed regression tests.

Sequence diagram for historical assembly version diff

sequenceDiagram
    participant Daily as Daily parser
    participant Current as Current JSONL
    participant Previous as Previous current TSV
    participant Historical as Historical TSV
    participant Backfill as Gap-fill backfill

    Daily->>Current: Read today's accession
    Daily->>Previous: load_previous_parsed_by_base()
    Daily->>Historical: load_versions_by_base()
    alt Version unchanged
        Daily-->>Daily: Skip assembly
    else Version advanced
        Daily->>Daily: build_superseded_row()
        Daily->>Historical: append_superseded_to_tsv()
        alt Candidate gap is not historical
            Daily->>Backfill: Report missing version
        else Candidate gap already parsed
            Daily-->>Daily: Suppress missing report
        end
    else Version regressed
        Daily->>Daily: print_version_regressions()
    end
Loading

Entity relationship diagram for historical assembly version tracking

erDiagram
    ASSEMBLY_SERIES ||--o{ ASSEMBLY_VERSION : contains
    ASSEMBLY_SERIES {
        string base_accession
    }
    ASSEMBLY_VERSION {
        string genbankAccession
        int version
        string versionStatus
        string superseded_by
        int superseded_by_version
    }
Loading

Flow diagram for assembly version gap detection

flowchart TD
    A["Today's current accession"] --> B["parse_accession()"]
    B --> C{Previous version exists?}
    C -->|No| D["Candidate versions below current"]
    C -->|Yes| E["Compare current and previous"]
    E -->|Unchanged| F[Skip]
    E -->|Advanced| G["build_superseded_row()"]
    E -->|Regressed| H["print_version_regressions()"]
    G --> I["Candidate versions between versions"]
    D --> J["build_gap_records()"]
    I --> J
    J --> K{Version in historical TSV?}
    K -->|No| L["Report missing version"]
    K -->|Yes| F
Loading

File-Level Changes

Change Details Files
Diff current assembly versions against the prior run and historical index to identify true supersessions and gaps.
  • Skip unchanged assemblies entirely.
  • Mark the latest previously observed version superseded when the current version advances.
  • Emit one candidate gap record for each skipped or previously unseen version, excluding versions already present historically.
  • Log regressions without modifying historical data.
  • Use canonical and backward-compatible accession/schema accessors, including transparent gzip support and config-driven TSV paths.
flows/parsers/parse_assembly_versions.py
flows/lib/assembly_versions_utils.py
configs/assembly_historical.types.yaml
tests/test_assembly_versions.py
tests/README_phase_1_daily_updates.md
Make historical TSV writes preserve existing data and schema across daily updates and gap backfills.
  • Merge rows using assembly ID, accession, or unique per-row sentinels instead of collapsing missing IDs.
  • Union columns across all rows and honor configured/header order.
  • Append gap-fill records non-destructively while deduplicating existing accessions.
  • Resolve backfill output under the working directory.
flows/parsers/parse_assembly_versions.py
flows/parsers/parse_backfill_historical_versions.py
tests/test_assembly_versions.py
tests/README_phase_0_backfill.md
Centralize TSV discovery, schema normalization, and compressed-file handling for downstream summaries.
  • Replace hardcoded current and historical filenames with config-based resolution or safe discovery.
  • Read plain and gzip-compressed TSVs through shared helpers.
  • Normalize accession, version status, and absent-value handling across summary and milestone generation.
  • Declare supersession metadata in the historical schema.
flows/lib/assembly_versions_utils.py
flows/lib/generate_assembly_summary.py
flows/lib/compute_taxon_milestones.py
configs/assembly_historical.types.yaml
tests/test_assembly_summary.py
Add automated CI execution and comprehensive regression coverage.
  • Run the pytest suite on pull requests and manual dispatch with Python 3.12.
  • Cover unchanged, incrementing, skipped, new-series, regression, historical-filtering, merge-preservation, schema, gzip, and summary cases.
  • Update operational documentation for config-driven paths, gap filling, and non-destructive writes.
.github/workflows/pytest.yml
tests/test_assembly_versions.py
tests/test_assembly_summary.py
tests/README_phase_0_backfill.md
tests/README_phase_1_daily_updates.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot 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.

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="flows/parsers/parse_backfill_historical_versions.py" line_range="259-260" />
<code_context>
+    output_path = config.meta["file_name"]
+    header, existing = read_existing_output(output_path)
+
+    if not existing:
+        write_to_tsv(parsed, config)
+        return len(parsed)
+
</code_context>
<issue_to_address>
**issue (bug_risk):** An existing historical TSV whose rows all lack `genbankAccession` is treated as empty because `read_existing_output` only adds non-empty accessions to `existing`; `write_to_tsv` then rewrites the file and discards those rows instead of preserving them.

**Triggers:** When the historical file contains one or more rows without an accession but does not have any accession-bearing rows.

**Suggested fix:** Track whether the output file contains data separately from the accession set, and preserve rows with no accession when deciding between rewrite and append.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨

read_existing_output only collects non-empty accessions, so a historical
TSV whose rows all lack one came back with an empty set and took the
write_to_tsv branch, which opens the file with mode "w" — the very rows
that made the file non-empty were discarded.

The header is the exact signal instead: it is empty only when the file is
missing or zero-length.  Keying the branch off it sends such a file down
the append path, where its rows survive.  That also makes the header
non-empty on every append, so the config-header fallback is now dead and
goes away.
A Phase 1 row is copied wholesale from the current TSV, whose schema is
upstream's rather than this pipeline's, and append_superseded_to_tsv
unioned the columns across every row.  Every current-only column
therefore landed in assembly_historical.tsv despite the YAML never
declaring it — upstream's assemblyId spelling among them, sitting next to
the assemblyID that build_superseded_row sets, so the file carried two
assembly-ID columns disagreeing about the same assembly.

Three parts:

  - canonicalize_columns folds each alias spelling onto the column the
    YAML declares, and build_superseded_row copies through it.  Reads
    still tolerate every alias; a row about to be written now carries one
    column per field.
  - When headers are supplied, they bound the schema as well as ordering
    it: a row may contribute the declared columns plus whatever is already
    on disk, and nothing else.  Columns already in the file are still
    never dropped, which is what F3 was about.
  - load_historical_headers resolves assembly_historical.types.yaml from
    work_dir, then from the repo's configs/, and both entry points pass
    the result.  The headers parameter existed but nothing ever supplied
    it, so the bound would have applied to nothing.  Every failure to
    resolve degrades to the order already on disk: a missing schema is not
    a reason to fail a daily run.
identify_newly_superseded inferred supersession arithmetically — it looked
for new_version - 1 in the previous parse and reported it missing when
absent. Since the previous parse holds only the current version of each
assembly, that misfired three ways:

1. Steady state. An assembly unchanged at v3 since yesterday made the code
   look for v2, not find it, and report v2 missing. On a quiet day with no
   new versions at all, missing_versions.json would have held roughly 3,694
   entries — every multi-version assembly in the dataset — each of which
   update_assembly_versions would then re-fetch from NCBI.

2. Skipped versions. With v3 current yesterday and v5 today, the code looked
   for v4, reported it missing and moved on. v3 — sitting in the previous
   parse — was never marked superseded, and was lost when
   assembly_current.tsv was overwritten.

3. No historical check. The function never read assembly_historical.tsv, so
   versions the Phase 0 backfill had already parsed were re-reported as
   missing on every run.

Replace the arithmetic with a per-base diff of the version current now
against the version current at the last run: equal versions are skipped,
a higher version supersedes the one it replaced, a lower version is logged
as a regression (NCBI suppression or rollback), and a base not seen before
treats everything below its current version as a gap. Gap candidates are
then filtered against a version index of assembly_historical.tsv, so only
genuinely unparsed versions are reported.

Gaps can span several versions, so build_gap_records emits one
missing-version record per version rather than widening the record — the
downstream shape read by update_assembly_versions is unchanged.

Both reads are local; the step still makes no network calls.

Adds load_versions_by_base to assembly_versions_utils for the historical
index, and 25 tests covering all three failure modes, multi-version gaps,
regressions, and new bases.
append_superseded_to_tsv keyed its merge on the assembly ID alone, so two
rows without one collapsed onto the empty string and all but the last were
dropped when the file was rewritten. Every row Phase 0 and this parser
write carries an ID, so nothing is losing data today, but the merge is the
wrong place to depend on that: it reads whatever is already on disk.

Falls back to the accession, then to a per-row sentinel, since two rows
with neither cannot be told apart and keeping both is the safe answer.
@fchen13
fchen13 force-pushed the fix/assembly-version-supersession-diff branch from 66a68d3 to 3d0540f Compare September 6, 2026 14:18

@rjchallis rjchallis 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.

thanks again @fchen13. Will get this merged as well

@rjchallis
rjchallis merged commit 11e72e2 into genomehubs:main Sep 7, 2026
2 of 4 checks passed
fchen13 added a commit to fchen13/genomehubs-data that referenced this pull request Sep 10, 2026
Steps ran 1, 2, 4, 3. The production taxonomy path is now Step 3 and the
staged run Step 4, which is also the order they happen in.

The file table listed test_assembly_summary.py and pytest.yml without
saying they merged with genomehubs#13, so two of ten rows are not in this diff.
Say up front that Phase 4 shipped as three stacked PRs and mark those
rows. "Unit tests for Phase 2.2, which had none" read as though the test
file had no tests; it meant the summary generator shipped without any.

The sentinel paragraph said Phase 2 and Phase 3 disagreed but never said
how it was settled: Phase 3 was already reading through cell, Phase 2
was routed through the same helper in genomehubs#13 rather than either place
special-casing the string.

PR-A and PR-B merged on 2026-09-07 as genomehubs#13 and genomehubs#14, so the "do not run
against real data until PR-A is merged" blocker is stale. Refer to the
merged PR numbers instead of the pre-submission labels.

Drop the PYTHONUTF8 note. It is a Windows dev-environment workaround,
not something a reviewer needs, and it sat between list items 2 and 3
breaking the numbering. The full explanation is already in
project_doc/Phase_4_plan.md; the runbook keeps a short inline comment.
fchen13 added a commit to fchen13/genomehubs-data that referenced this pull request Sep 11, 2026
validate_no_ncbi_fetches.py claimed something false. The pipeline does
fetch from NCBI -- Phase 0 backfills, Phase 1.2 fills gaps, and
update_ncbi_datasets pulls the bulk JSONL daily, all by design. The
module's first paragraph existed to walk the title back, which is a sign
the title was doing the wrong work.

What it actually checks is that the daily diff stays local:
parse_assembly_versions copies superseded rows from the previous parse
and checks gaps against assembly_historical.tsv, so it should reach the
network for no input at all.

"Local" also covers the failure the old name missed. The step can
reintroduce daily fetching without making a single call itself, by
emitting a bogus missing-versions list that update_assembly_versions
then works through -- which is exactly what the pre-genomehubs#14 diff did to
~3,694 assemblies. The unchanged-input check catches that; a name about
sockets does not describe it.

Renamed the module, its entry point, the import alias and the output
banner. No behaviour change.
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.

2 participants