Skip to content

Emit the reference DOI on bacdive metabolite-utilization edges - #776

Open
realmarcin wants to merge 2 commits into
masterfrom
bacdive-emit-reference-doi
Open

realmarcin wants to merge 2 commits into
masterfrom
bacdive-emit-reference-doi

Conversation

@realmarcin

Copy link
Copy Markdown
Collaborator

Addresses #770.

BacDive attaches an @ref to every data item and lists the matching citations in each record's Reference section. The transform doesn't read it, so an edge can name the strain record it came from but never the paper. That linkage can't be recovered downstream, because the edge no longer records which data item produced it.

The change

reference_dois() maps a record's @ref ids to publication DOIs; the metabolite-utilization path threads it through. Where a DOI is known the edge carries it alongside the existing provenance:

['infores:bacdive', 'bacdive:1', 'doi:10.1099/ijs.0.02862-0']

Same list-literal convention already used for the strain id, so nothing changes shape.

Two doi/url kinds are skipped deliberately

Catalogue URLs (DSMZ, StrainInfo) aren't publications, and BacDive's own record DOI (10.13145/bacdive…) points back at the record primary_knowledge_source already identifies. Either would attach confident-looking provenance that leads nowhere.

Backward compatible by construction

_StrainProvenanceWriter.writerow takes publication as an optional keyword, so every call site that doesn't pass one emits exactly what it emitted before. That's what makes this landable one section at a time, and there's a test asserting it rather than a claim.

Scope

Metabolite utilization only — the carbon-source relations. The pattern for the rest is identical: capture the item's @ref where the item is read, pass the resolved DOI at the write site. I stopped at one section so the change is reviewable and the convention can be agreed before it's repeated ~8 times.

Testing

  • 51 bacdive tests pass, including 7 new ones covering the mapping, the single-dict form, the skip rules, malformed input, the appended DOI, and unchanged output without a publication.
  • Verified reference_dois() against the real dump: 300/300 sampled records resolve at least one paper DOI.
  • The 3 collection errors elsewhere in tests/ are a missing parameterized dependency and are identical on a clean checkout — not from this change.

Why it matters downstream

In kg-microbe-projects we needed statement-level citations and had to re-parse the raw JSON in parallel, which covers 9 of ~50 referenced sections. In a BacDive-vs-assay comparison there, only 81 of 996 rows could be attributed to a paper — and the one row where two papers disagreed with each other (Alteromonas stellipolaris on NAG: 10.1099/ijs.0.02862-0 says "does not assimilate", 10.1099/ijs.0.63521-0 says "uses as carbon source") turned out to be the most informative row in the analysis. Without DOIs that's indistinguishable from a curation error.

Threading @ref through the remaining sections would make every edge attributable and let that parallel extraction be deleted.

realmarcin and others added 2 commits August 13, 2026 13:43
BacDive attaches an @ref to every data item and lists the matching citations
in each record's Reference section. The transform did not read it, so an
edge could name the strain record it came from but never the paper. That
linkage cannot be recovered downstream, because the edge no longer records
which data item produced it.

Adds reference_dois(), which maps a record's @ref ids to publication DOIs,
and threads it through the metabolite-utilization path. Where a DOI is
known the edge carries it alongside the existing provenance:

  ['infores:bacdive', 'bacdive:1', 'doi:10.1099/ijs.0.02862-0']

Two kinds of doi/url are skipped deliberately. Catalogue URLs (DSMZ,
StrainInfo) are not publications, and BacDive's own record DOI
(10.13145/bacdive...) points back at the record primary_knowledge_source
already identifies. Either would attach confident-looking provenance that
leads nowhere.

_StrainProvenanceWriter.writerow takes publication as an optional keyword,
so every call site that does not pass one emits exactly what it emitted
before. That is what makes this landable one section at a time, and it is
covered by a test rather than asserted.

Scope is metabolite utilization only -- the carbon-source relations. The
pattern is the same for the other sections: capture the item's @ref where
the item is read, pass the resolved DOI at the write site.

Tested: 51 bacdive tests pass, including 7 new ones. The three collection
errors elsewhere in tests/ are a missing 'parameterized' dependency and are
identical on a clean checkout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The preceding commit landed tests/test_bacdive_reference_doi.py without the
code it exercises: a stash/pop cycle during verification left the two source
files unstaged, and the commit only picked up the staged test.

This adds them, so the branch is self-consistent again:

  reference_dois()  maps a record's @ref ids to publication DOIs, skipping
                    catalogue URLs and BacDive's own record DOI
  REFERENCE_KEY     the '@ref' constant, beside the other item keys
  writerow()        takes an optional publication and appends it to the
                    primary_knowledge_source list

Kept as a separate commit rather than amending, since the previous one was
already pushed.

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

Copy link
Copy Markdown
Collaborator Author

Adversarial review

Reviewed on request; not my PR, so nothing pushed. The core idea is right and reference_dois() is careful about what it refuses to call a citation (catalogue URLs, malformed entries). Three blockers, filed separately, and two design questions worth settling before the pattern is repeated eight times for #737.

Quantitative claims were measured by replaying the PR's own reference_dois logic over the real data/raw/bacdive_strains.json (99,392 records, 862,829 metabolite-utilization items with a Chebi-ID). Those are derived counts, not counts of emitted rows — the transform was not run.

Blockers

Design

The coverage number in the PR body is measured on the wrong denominator. "300/300 sampled records resolve at least one paper DOI" is true (98,448/99,392 records, 99.0%), but per edge — which is what is emitted:

items share
resolve to a DOI 201,976 23.4%
ref has no doi/url 608,545 70.5%
ref is a catalogue URL 52,289 6.1%

The 608,545 are not noise — by title they are Automatically annotated from API 50CH acid (303,167), API rID32STR (72,691), API 20NE (62,881) and so on: machine-read test panels. Worth carrying 23.4% in the PR body.

Emitting only the DOI, and not the @ref id, discards more than it keeps. #770 asked for the @ref id per edge and called DOI resolution optional; this PR does only the optional half. Carrying bacdive.ref:N as well costs nothing, raises the attribution ceiling from 23.4% to ~100%, and preserves a signal the transform currently gets wrong — those ~608k API-panel items are stamped observation / manual_agent, and the @ref this PR now reads is exactly what would let that be corrected later.

publications looks like the right slot. The DOI currently lands in primary_knowledge_source, whose Biolink range is a single infores CURIE; fix_list_representations.py:22 then rewrites the cell to infores:bacdive|bacdive:145|doi:10.1099/.... That same module already lists publications at line 34 as a known multivalued edge field. Putting it in Transform.edge_header would make the DOI visible to Biolink-aware consumers, dissolve #794 for free, and give #737 somewhere to put per-reference observations. It does mean a real header change across all 36 write sites — as the PR stands there is no column added and no ragged-row risk.

Tests

The production wiring is entirely untested — verified by mutation. Deleting both hooks (the ref capture at :2819 and the publication= argument at :2891) in a throwaway worktree still gives 49 passed, 2 skipped. All 7 new tests exercise reference_dois and _StrainProvenanceWriter in isolation; nothing touches the metabolite-utilization path, which is why #793 slipped through. tests/resources/bacdive/yaml/{145,2567,10174}.yaml already carry metabolite utilization, so an end-to-end assertion is cheap.

Also: test_non_strain_rows_are_untouched_even_with_a_publication asserts that a supplied DOI is silently discarded when the row has no strain endpoint — a hazard encoded as a requirement. The isolation-source path uses a bare bacdive pks, so threading a DOI there under #737 would be a no-op with no error and no warning.

Minor

  • :3472 hand-builds a list literal with no escaping. Safe today (no DOI in the dump contains a quote or backslash), but one contains spaces — 10.1094/ MPMI -19-0181 — which becomes a CURIE with spaces once piped. str(parts) is safer.
  • :3446 publication: str = None — implicit Optional; want str | None.
  • :3476 writerows takes no publication; 5 of 36 write sites use it, which matters for BacDive per-reference observations disagree: 4,278 strains get contradictory phenotype edges #737.

Verified non-issues

@ref/@id types match (all int, both sides, 383,408 / 862,829); @ref is never a list here; no record has duplicate @id with conflicting doi/url; 0 metabolite-utilization items point at a PMID-only reference (so skipping pubmed loses nothing here — it will matter for #737's other sections); lowercase doi: matches microbedecoder/utils.py:250 and lpsn_api/lpsn_api.py:93; scoping of record_reference_dois is correct; -k bacdive gives 49 passed / 2 skipped on the branch.

Not concluded

Post-merge behaviour of the duplicated rows in #794 (running the merge rewrites tracked artifacts); whether any DOI resolves to a real, correct paper (syntactic shape only).

realmarcin added a commit that referenced this pull request Aug 16, 2026
#737) (#795)

* feat(bacdive): report per-reference phenotype conflicts, split by kind

#737 quoted "4,278 strains get contradictory phenotype edges" from an ad-hoc
measurement that was never persisted. Nothing on disk recorded which strains, or
which references disagreed: the bacdive transform writes only nodes.tsv,
edges.tsv and bacdive_media_links.txt, and the contradictory edges sit in
edges.tsv unmarked — same primary_knowledge_source on both rows, and no slot for
the `@ref` that would tell them apart.

This makes the number reproducible and inspectable, and splits it three ways.

Restricted to #737's eight fields the script reproduces **4,278 exactly**, which
is the check that it is measuring the same thing. Splitting that total:

  specificity     955  values on one METPO subsumption chain
  contradiction  3,342 strains  values that cannot both hold
  unresolved            a value with no METPO term at all

**The specificity share is far smaller than I assumed, and #737's note that
`anaerobe` vs `obligate anaerobe` is "arguably not a conflict at all (2,643 of
4,278)" reads as if subsumption explains most of the problem. It does not.**
All 955 specificity cases are oxygen tolerance, and there they are 36% of
disagreements, 22% of the 4,278 — 854 `aerobe | obligate aerobe` and 101
`anaerobe | obligate anaerobe`. The dominant oxygen-tolerance patterns are
genuine contradictions: `aerobe | facultative anaerobe` (440),
`anaerobe | microaerophile` (299), `facultative anaerobe | microaerophile` (266).
So ~3,342 strains really do need a resolution policy.

Getting that split required resolving through METPO **synonyms**, not labels.
BacDive writes `anaerobe`; METPO's label is `anaerobic` and carries `anaerobe`
as a synonym. A label-only match resolves nothing and reports every
oxygen-tolerance disagreement as a contradiction — my first cut did exactly
that and reported 4,686 strains, all "contradiction".

Third bucket is deliberate. `unresolved` means a value has no METPO term, so the
relation cannot be judged; calling it a contradiction would report a curation gap
as a data conflict. `colony color` is 1,196 rows, every one unresolved, because
colours have no METPO terms.

The script also scans four fields #737 did not — `colony color`, `colony shape`,
`type of spore`, `forms multicellular complex` — adding 1,793 more disagreeing
observations, of which `forms multicellular complex` (551) and `colony shape`
(23) are the ones worth looking at.

Deliberately not a gate: it exits 0 and reports. Wire it into CI once the counts
are expected to be stable.

One design note carried over from reviewing #776: `observations()` handles the
list and single-dict shapes in **one** code path. Handling them per-section is
how #793 happened — that PR captured `@ref` in the list branch and not the dict
branch of a single section, silently unattributing 157 records.

* fix: scope METPO resolution per field, so one axis cannot judge another

From Codex's external review. A single flat alias index resolved every value
through whichever METPO term sorted first, and 67 aliases are claimed by more
than one term. The collisions land exactly on the yes/no fields:

    yes -> METPO:1000702 (motile)     AND  METPO:1000871 (spore forming)
    no  -> METPO:1000703 (non motile) AND  METPO:1000872 (non-spore forming)

So spore formation, and `forms multicellular complex`, were being judged on the
motility terms. It changed no verdict — the pairs are siblings on either axis,
so "contradiction" came out right by luck — but one METPO edit putting two
collision-sharing terms into a subsumption relation would silently downgrade
real contradictions to `specificity`, discarding true statements. That is the
exact failure this script exists to prevent, so luck is not good enough.

`resolve()` now takes the field and intersects the alias's owners with that
field's declared axis. A field with no axis accepts only an unambiguous alias
and otherwise refuses.

This changes one count for the better. `forms multicellular complex` has no
METPO terms at all, so its 551 observations move from `contradiction` to
`unresolved` — honest, since nothing in the ontology can express the concept.
Contradiction strains: 3,342 -> 2,828.

Unchanged: restricted to #737's eight fields the script still reproduces
**4,278** exactly, and the specificity split is still 955.
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.

1 participant