Summary
A catch-all namespace in prefix_maps permanently shadows the more specific mappings in the fallback maps, so OBO ontologies without their own entry in the JSON-LD context contract to OBO:DDPHENO_0000001 instead of DDPHENO:0000001.
from kgx.prefix_manager import PrefixManager
pm = PrefixManager()
pm.contract("http://purl.obolibrary.org/obo/DDPHENO_0000001")
# kgx 2.7.0: 'OBO:DDPHENO_0000001'
# expected: 'DDPHENO:0000001'
Affects at least DDPHENO, FBbt, WBbt, EMAPA, ZFA, XAO, CHR, ZFS, OBA, FBdv — 165 OBO idspaces in total (see blast radius below). HP, MONDO, GO, RO, BFO, CHEBI, UBERON and everything else with its own context entry are unaffected.
Cause
kgx/utils/kgx_utils.py::contract() consults the default maps only when prefix_maps returns zero matches:
curie_list = contract_uri(uri, prefix_maps)
if len(curie_list) == 0:
if fallback:
curie_list = contract_uri(uri, default_curie_maps)
The context carries OBO -> http://purl.obolibrary.org/obo/, a catch-all that matches every OBO IRI. So curie_list is never empty for an OBO IRI, the fallback never runs, and obo_context's per-ontology mappings (DDPHENO -> …/obo/DDPHENO_, FBbt -> …/obo/FBbt_, …) are unreachable.
prefixcommons.contract_uri already resolves this ambiguity correctly when both mappings are in scope — it prefers the shortest CURIE, so DDPHENO:0000001 beats OBO:DDPHENO_0000001. The two tiers just never meet:
>>> contract_uri(uri, [biolink_context]) # ['OBO:DDPHENO_0000001']
>>> contract_uri(uri, [monarch_context, obo_context]) # ['DDPHENO:0000001']
The same shadowing applies to the MONARCH -> https://monarchinitiative.org/ catch-all, which hides BNODE, ISBN, ISBN-10, ISBN-13 and OMIA-breed.
Why this surfaced in 2.7.0
Not a new defect — 2.6.0 was accidentally immune. TsvSource.set_prefix_map was destructive, and the transformer calls source.set_prefix_map({}) when the caller supplies no map, so the map was wiped down to 5 entries:
kgx 2.6.0, after ObographSource.set_prefix_map({}):
prefix_map size: 5 ['', 'MONARCH', 'MONARCH_NODE', 'biolink', 'owlstar']
http://purl.obolibrary.org/obo/DDPHENO_0000001 -> DDPHENO:0000001
With nothing to match, every OBO IRI fell through to the fallback and got the right CURIE. #548 made set_prefix_map additive — correctly, since the wipe was also breaking identifiers.org/hgnc/ and friends — which restored the 631-entry map and with it the catch-all that had been sitting dormant.
Downstream, this untyped ~52k nodes in kg-phenio: its transform looks node categories up by CURIE prefix, and OBO isn't an ontology, so DDPHENO/FBbt/EMAPA/WBbt/ZFA/XAO/CHR/ZFS terms have shipped as biolink:NamedThing since the 2026-06-03 release.
Proposed fix
Consult both tiers and keep the most specific match — the candidate that consumed the longest IRI prefix — with prefix_maps winning ties so a caller-supplied map stays canonical:
if prefix_maps:
curie_list = contract_uri(uri, prefix_maps)
if fallback:
fallback_list = contract_uri(uri, default_curie_maps)
if fallback_list:
curie_list = _most_specific(uri, curie_list, fallback_list)
if curie_list:
curie = curie_list[0]
where _most_specific compares the length of the local part (shorter local part == longer IRI prefix consumed == more specific mapping). No OBO-specific special-casing, and fallback=False is untouched.
Alternatives considered:
- Drop the
OBO key from the loaded context. One line, fixes the eight ontologies that matter to phenio, and OBO: survives for both expansion and for contracting IRIs nothing more specific matches (OBO:fbbt#has_function_in), because monarch_context re-supplies the catch-all in the fallback tier. But it hardcodes a special case and leaves the identical MONARCH bug in place.
- Bump the pinned context.
config.yml still points at biolink-model/2.2.5/context.jsonld (628 entries, 2021). The current biolink-model context has 1182 entries, no OBO catch-all at all, and explicit FBbt/EMAPA/WBbt/ZFA/XAO/ZFS/FBdv/WBls mappings — it fixes this at the source and looks overdue for a tool emitting biolink 4.x-shaped data. Much larger blast radius; probably its own PR.
Blast radius
Probed every namespace in all three contexts (488 URIs), patched vs unpatched PrefixManager.contract:
| change |
n |
OBO: catch-all → specific idspace |
165 |
MONARCH: catch-all → specific (BNODE, ISBN, ISBN-10, ISBN-13, OMIA-breed) |
5 |
| other |
1 (see below) |
Everything that resolves correctly today — HP:, MONDO:, RO:, BFO:, HGNC: via identifiers.org, all biolink: vocab terms — is unchanged.
Tests
Have a branch with the fix plus 20 test cases across tests/unit/test_kgx_utils.py (catch-all vs fallback, prefix_maps wins ties, fallback=False unchanged) and tests/unit/test_prefix_manager.py (the eight regressed idspaces, four controls, one relation IRI that must keep OBO:).
- 11 of the new cases fail on
master, all pass with the fix.
- Full unit suite with the fix: 393 passed, 19 skipped, 1 failed — the failure is
test_sink/test_jsonl_sink.py::test_write_jsonl2, which fails identically on unpatched master.
Happy to open the PR.
Related: contraction is not deterministic across runs
Found while measuring the above, independent of this bug and present in 2.6.0 too. prefixcommons.contract_uri collects candidates in a set and filters to the shortest; when two candidates tie on length, contract() takes [0] of an arbitrarily ordered set:
PYTHONHASHSEED=1 https://www.wikidata.org/wiki/Property:P31 -> WIKIDATA:Property:P31
PYTHONHASHSEED=2 https://www.wikidata.org/wiki/Property:P31 -> WIKIDATA_PROPERTY:P31
Same input, same version, different output between runs. Happy to file separately if you'd prefer it tracked on its own.
Summary
A catch-all namespace in
prefix_mapspermanently shadows the more specific mappings in the fallback maps, so OBO ontologies without their own entry in the JSON-LD context contract toOBO:DDPHENO_0000001instead ofDDPHENO:0000001.Affects at least DDPHENO, FBbt, WBbt, EMAPA, ZFA, XAO, CHR, ZFS, OBA, FBdv — 165 OBO idspaces in total (see blast radius below). HP, MONDO, GO, RO, BFO, CHEBI, UBERON and everything else with its own context entry are unaffected.
Cause
kgx/utils/kgx_utils.py::contract()consults the default maps only whenprefix_mapsreturns zero matches:The context carries
OBO -> http://purl.obolibrary.org/obo/, a catch-all that matches every OBO IRI. Socurie_listis never empty for an OBO IRI, the fallback never runs, andobo_context's per-ontology mappings (DDPHENO -> …/obo/DDPHENO_,FBbt -> …/obo/FBbt_, …) are unreachable.prefixcommons.contract_urialready resolves this ambiguity correctly when both mappings are in scope — it prefers the shortest CURIE, soDDPHENO:0000001beatsOBO:DDPHENO_0000001. The two tiers just never meet:The same shadowing applies to the
MONARCH -> https://monarchinitiative.org/catch-all, which hidesBNODE,ISBN,ISBN-10,ISBN-13andOMIA-breed.Why this surfaced in 2.7.0
Not a new defect — 2.6.0 was accidentally immune.
TsvSource.set_prefix_mapwas destructive, and the transformer callssource.set_prefix_map({})when the caller supplies no map, so the map was wiped down to 5 entries:With nothing to match, every OBO IRI fell through to the fallback and got the right CURIE. #548 made
set_prefix_mapadditive — correctly, since the wipe was also breakingidentifiers.org/hgnc/and friends — which restored the 631-entry map and with it the catch-all that had been sitting dormant.Downstream, this untyped ~52k nodes in kg-phenio: its transform looks node categories up by CURIE prefix, and
OBOisn't an ontology, so DDPHENO/FBbt/EMAPA/WBbt/ZFA/XAO/CHR/ZFS terms have shipped asbiolink:NamedThingsince the 2026-06-03 release.Proposed fix
Consult both tiers and keep the most specific match — the candidate that consumed the longest IRI prefix — with
prefix_mapswinning ties so a caller-supplied map stays canonical:where
_most_specificcompares the length of the local part (shorter local part == longer IRI prefix consumed == more specific mapping). No OBO-specific special-casing, andfallback=Falseis untouched.Alternatives considered:
OBOkey from the loaded context. One line, fixes the eight ontologies that matter to phenio, andOBO:survives for both expansion and for contracting IRIs nothing more specific matches (OBO:fbbt#has_function_in), becausemonarch_contextre-supplies the catch-all in the fallback tier. But it hardcodes a special case and leaves the identicalMONARCHbug in place.config.ymlstill points atbiolink-model/2.2.5/context.jsonld(628 entries, 2021). The current biolink-model context has 1182 entries, noOBOcatch-all at all, and explicitFBbt/EMAPA/WBbt/ZFA/XAO/ZFS/FBdv/WBlsmappings — it fixes this at the source and looks overdue for a tool emitting biolink 4.x-shaped data. Much larger blast radius; probably its own PR.Blast radius
Probed every namespace in all three contexts (488 URIs), patched vs unpatched
PrefixManager.contract:OBO:catch-all → specific idspaceMONARCH:catch-all → specific (BNODE,ISBN,ISBN-10,ISBN-13,OMIA-breed)Everything that resolves correctly today —
HP:,MONDO:,RO:,BFO:,HGNC:viaidentifiers.org, allbiolink:vocab terms — is unchanged.Tests
Have a branch with the fix plus 20 test cases across
tests/unit/test_kgx_utils.py(catch-all vs fallback,prefix_mapswins ties,fallback=Falseunchanged) andtests/unit/test_prefix_manager.py(the eight regressed idspaces, four controls, one relation IRI that must keepOBO:).master, all pass with the fix.test_sink/test_jsonl_sink.py::test_write_jsonl2, which fails identically on unpatchedmaster.Happy to open the PR.
Related: contraction is not deterministic across runs
Found while measuring the above, independent of this bug and present in 2.6.0 too.
prefixcommons.contract_uricollects candidates in asetand filters to the shortest; when two candidates tie on length,contract()takes[0]of an arbitrarily ordered set:Same input, same version, different output between runs. Happy to file separately if you'd prefer it tracked on its own.