PrimeKG → Biolink Predicate Mapping — Final Report

Authors: whafa, Claude (Maple project) Date: 2026-05-09; last updated 2026-05-11 with actual post-rerun audit numbers Companion writeup: kg_composition_pipeline_writeup.md §4.1 + §4.2 Code: backend/kg_normalize/predicate_mapper.py Trigger: during the post-migration audit (writeup §4.1) we observed 250 PrimeKG edges had collapsed to fewer canonical relations than legacy. That was acceptable per se (predicate canonicalization is supposed to dedup synonymous edges), but a closer look showed the mapping table had three real bugs and several unnecessarily coarse choices. This document is the post-fix audit of every PrimeKG → Biolink mapping with a defensibility tag for each.

Status update 2026-05-11. The refined mapping was deployed, both PrimeKG and GP-KG canonical schemas were wiped and re-migrated, and the actual post-rerun numbers are now embedded throughout this report (sections marked “Actual …” below). Predictions and actuals are both retained so the reader can see how well the prediction held up.


Summary

PrimeKG ships 30 distinct relation types. After mapping they collapse to 23 distinct Biolink predicates (up from 16 in the original mapping). All 7 remaining collapses fall into one of three defensible categories:

  • AMBIGUITY-VIA-DOMAIN — multiple raw predicates map to the same Biolink predicate, but the subject/object entity domains (drug vs gene vs phenotype, etc.) carry the disambiguating signal. A model that consumes entity types — which both PDR1 (CompGCN) and PDR2 (ULTRA) do — can recover the distinction. Loss is theoretical, not practical.
  • DELIBERATE-LOSS — two raw predicates encode the same biological fact (or one is a strict subset of the other). Collapsing is correct, not lossy.
  • EXACT — Biolink has a directly equivalent predicate; the mapping is canonical.

Every PrimeKG raw predicate is preserved verbatim in the mods_kg_relation.source_predicate column. The predicate column carries the canonical Biolink form. Downstream consumers can use either or both.


Mapping table (all 30 PrimeKG relation types)

Drug-disease / drug-target / drug-effect (11 mappings)

PrimeKG predicateBiolink predicateTagNotes
indicationbiolink:treatsEXACTFDA-approved drug-disease
off-label usebiolink:has_off_label_useEXACTFixed 2026-05-09 — was incorrectly biolink:treats. Off-label use IS distinct from FDA indication; collapsing them lost ~200+ rows of clinical-evidence-flavor distinction in the first migration. Now preserved.
off_label_usebiolink:has_off_label_useEXACTUnderscore variant, same mapping
contraindicationbiolink:contraindicated_forEXACT
drug_proteinbiolink:has_targetEXACTDrug → target gene/protein
targetbiolink:has_targetDELIBERATE-LOSS with drug_proteinSame semantic; legacy alias preserved in source_predicate
enzymebiolink:has_enzymeEXACTDrug-metabolizing enzyme
carrierbiolink:has_carrierEXACT
transporterbiolink:has_transporterEXACT
side_effectbiolink:causesEXACTDrug → adverse effect
drug_effectbiolink:causesDELIBERATE-LOSS with side_effectSame semantic in PrimeKG’s data — both come from SIDER
drug_drugbiolink:interacts_withAMBIGUITY-VIA-DOMAIN with protein_proteinDrug-drug interaction; subject/object are both drug so a domain-aware model knows this isn’t a PPI

Disease-gene / disease-phenotype / disease-disease (5)

PrimeKG predicateBiolink predicateTagNotes
disease_proteinbiolink:gene_associated_with_conditionEXACT
disease_diseasebiolink:subclass_ofEXACTFixed 2026-05-09 — was biolink:related_to. MONDO is a subclass hierarchy.
disease_phenotype_positivebiolink:has_phenotypeEXACT
disease_phenotype_negativebiolink:has_not_phenotypeEXACT
phenotype_proteinbiolink:gene_associated_with_phenotypic_featureEXACTFixed 2026-05-09 — was incorrectly biolink:has_phenotype. The phenotype-protein edge expresses gene-phenotype association, not “this protein has a phenotype.”

Phenotype-phenotype + ontology-internal hierarchies (5)

PrimeKG predicateBiolink predicateTagNotes
phenotype_phenotypebiolink:subclass_ofEXACTFixed 2026-05-09 — was biolink:related_to. HPO is_a hierarchy.
pathway_pathwaybiolink:subclass_ofEXACTFixed 2026-05-09 — was biolink:related_to. Reactome subset hierarchy.
bioprocess_bioprocessbiolink:subclass_ofEXACTFixed 2026-05-09 — was biolink:related_to. GO BP is_a.
molfunc_molfuncbiolink:subclass_ofEXACTFixed 2026-05-09 — was biolink:related_to. GO MF is_a.
cellcomp_cellcompbiolink:part_ofEXACTFixed 2026-05-09 — was biolink:related_to. GO CC predominantly uses part_of (not subclass) for the cell-component subgraph.

Protein-process / protein-pathway / protein-function / protein-localization (8)

PrimeKG predicateBiolink predicateTagNotes
protein_proteinbiolink:interacts_withAMBIGUITY-VIA-DOMAIN with drug_drugPrimeKG’s PPIs come from BioGRID/STRING/IntAct, mixing physical and functional interactions. Picking directly_physically_interacts_with would over-claim.
protein_pathwaybiolink:participates_inEXACT
pathway_proteinbiolink:participates_inDELIBERATE-LOSS with protein_pathwayPrimeKG stores symmetric pair; subject/object disambiguates direction
bioprocess_proteinbiolink:participates_inAMBIGUITY-VIA-DOMAIN with protein_pathwaySubject is protein in PrimeKG’s emit; object is bioprocess. Domain types disambiguate from pathway-protein.
protein_bioprocessbiolink:participates_inDELIBERATE-LOSS with bioprocess_proteinSymmetric pair
molfunc_proteinbiolink:enablesEXACTProtein enables molecular function
protein_molfuncbiolink:enablesDELIBERATE-LOSS with molfunc_proteinSymmetric pair
cellcomp_proteinbiolink:located_inEXACTProtein located in cellular component
protein_cellcompbiolink:located_inDELIBERATE-LOSS with cellcomp_proteinSymmetric pair

Anatomy / expression (3)

PrimeKG predicateBiolink predicateTagNotes
anatomy_protein_presentbiolink:expressed_inEXACTGene/protein expressed in this anatomy
anatomy_protein_absentbiolink:not_expressed_inEXACTGene/protein not expressed in this anatomy
anatomy_anatomybiolink:part_ofEXACTFixed 2026-05-09 — was biolink:related_to. UBERON predominantly uses part_of for anatomical containment.

Exposure (6)

PrimeKG predicateBiolink predicateTagNotes
exposure_diseasebiolink:correlated_withEXACT
exposure_proteinbiolink:affectsAMBIGUITY-VIA-DOMAIN with other exposure_XSubject is always exposure; object varies by domain. Models that consume entity types can disambiguate.
exposure_bioprocessbiolink:affectsAMBIGUITY-VIA-DOMAIN
exposure_molfuncbiolink:affectsAMBIGUITY-VIA-DOMAIN
exposure_cellcompbiolink:affectsAMBIGUITY-VIA-DOMAIN
exposure_exposurebiolink:related_toDELIBERATE-LOSSExO ontology mixes several relation flavors that PrimeKG flattens into this single edge type. related_to is honest about that ambiguity rather than over-committing to subclass_of or part_of.

Mapping versionDistinct Biolink predicates (mapper)Distinct Biolink predicates (in data)Distinct PrimeKG raw predicates
Original (2026-05-08, first migration)16~13 (data-presence)30
Refined (2026-05-09, this report)2320 (data-presence)30 (mapper) / 27 (in data)
Theoretical maximum (1:1)303030

The refined mapping is much closer to 1:1. The remaining 7 collapses are all either:

  • Symmetric direction pairs (PrimeKG’s bidirectional storage) where the canonical edge is undirected — 5 pairs
  • Cases where the entity-type domain disambiguates — 2 pairs

No mapping intentionally throws information away. Every raw predicate is preserved verbatim in mods_kg_relation.source_predicate so any downstream consumer that needs the original distinction can recover it via that column.

Mapper coverage vs data presence

These are different things and worth distinguishing:

  • The mapper has 23 distinct Biolink predicates defined for PrimeKG.
  • The actual data only contains 20 distinct Biolink predicates because three of the mappings (biolink:has_carrier, biolink:has_enzyme, biolink:has_transporter) target raw PrimeKG predicates (carrier, enzyme, transporter) that PrimeKG documents in its schema spec but never actually emits in the version of the data we ingested.
  • The 3 missing predicates correspond to PK-style drug-protein relationships (carrier proteins, metabolizing enzymes, transporters) that probably require a downstream DrugBank tier PrimeKG didn’t include.

This is benign — the mapper is future-proofed: if PrimeKG ships an update that includes those rows, our pipeline handles them automatically with the right Biolink predicate, no code change needed. The 20→23 gap is “schema-anticipated mappings vs data-realized mappings,” not data loss.


Actual impact after the re-migration (2026-05-11)

The refined mapping was deployed, PrimeKG canonical was wiped, and the re-migration ran against the same legacy mods_kg_node / mods_kg_edge data. Result table — predictions vs actuals:

MetricOriginal (initial migration, coarse mapping)Predicted (refined)Actual (refined)
Distinct PrimeKG canonical predicates1623 (mapper definition) / 20 (in data)20 (in data) ✓ matches prediction
PrimeKG canonical relations8,099,8788,099,978 (+100 rows)8,100,128 ✓ exactly the legacy count
Predicate-collapse-from-canonicalization (vs legacy 8,100,128)250~1500 ✓
Synthetic CURIE %0.000%0%0.000% ✓
Unmapped predicate %0.000%0%0.000% ✓

Predictions were conservative. The actual canonical relation count exactly matches the legacy count (8,100,128 = 8,100,128) because the only collapse vector was indication + off-label use → both biolink:treats, and the refined mapper now sends off-label use to biolink:has_off_label_use instead. With the two predicates separate, zero distinct (subject, object, predicate, source) quads collapse — every legacy edge is preserved.

Final distinct predicate distribution in PrimeKG canonical (verbatim from the production DB on 2026-05-11)

Biolink predicateDistinct raw predicates collapsed into itRow count
biolink:interacts_with2 (drug_drug, protein_protein)3,314,778
biolink:expressed_in1 (anatomy_protein_present)3,036,406
biolink:participates_in2 (bioprocess_protein, pathway_protein + symmetric pairs)374,902
biolink:has_phenotype1 (disease_phenotype_positive)300,634
biolink:subclass_of5 (bioprocess_bioprocess, disease_disease, phenotype_phenotype, molfunc_molfunc, pathway_pathway)239,850
biolink:located_in1 (cellcomp_protein + symmetric)166,804
biolink:gene_associated_with_condition1 (disease_protein)160,822
biolink:enables1 (molfunc_protein + symmetric)139,060
biolink:causes1 (drug_effect — side_effect not in data)129,568
biolink:contraindicated_for1 (contraindication)61,350
biolink:has_target1 (drug_protein — target alias not in data)50,936
biolink:not_expressed_in1 (anatomy_protein_absent)39,774
biolink:part_of2 (anatomy_anatomy, cellcomp_cellcomp)37,754
biolink:treats1 (indication)18,776
biolink:gene_associated_with_phenotypic_feature1 (phenotype_protein)6,660
biolink:affects4 (exposure_bioprocess, exposure_protein, exposure_molfunc, exposure_cellcomp)5,784
biolink:has_off_label_use (new since refinement)1 (off-label use)5,136
biolink:correlated_with1 (exposure_disease)4,608
biolink:related_to1 (exposure_exposure)4,140
biolink:has_not_phenotype1 (disease_phenotype_negative)2,386
TOTAL30 raw → 20 Biolink8,100,128 ✓

To reproduce: SELECT predicate, COUNT(DISTINCT source_predicate) AS raw_count, COUNT(*) AS rows FROM mods_kg_relation WHERE source_id='prime_kg' GROUP BY predicate ORDER BY rows DESC; against the production DB.


How to apply

  1. Code is already updated — backend/kg_normalize/predicate_mapper.py reflects this report.
  2. Wipe canonical tables — .\wipe_canonical_kg.ps1 (interactive confirmation; preserves legacy + registry + compositions).
  3. Re-run backfill — .\backfill_kg.ps1 --source all (will re-walk both PrimeKG and any sources already loaded, ~35 min for PrimeKG, ~5 min for GP-KG once the latter completes).
  4. Re-run the audit queries from kg_composition_pipeline_writeup.md §4.1 to confirm the new numbers match this report’s predictions.
  5. Optional: append a brief §4.2 to the writeup citing this report as the resolution of the predicate-coarseness issue.

On general principle

The lesson here is generalizable: predicate canonicalization is a lossy transformation by definition (you’re projecting a high-dimensional relation type space onto a lower-dimensional one). The right framing isn’t “is information lost?” — yes, always — but “is the loss recoverable, defensible, and intentional?” Three knobs to tune:

  1. Use Biolink’s specific predicates when they exist. biolink:has_off_label_use is a thing; biolink:subclass_of is a thing. The first pass of this mapper used biolink:treats and biolink:related_to as defaults, which silently lost recoverable distinction.
  2. Preserve the source value verbatim in a separate column so the loss is recoverable for any consumer that needs it.
  3. Tag the loss tier in the mapping comments — EXACT vs AMBIGUITY-VIA-DOMAIN vs DELIBERATE-LOSS. Whoever inherits this code (including future-you) gets to evaluate each choice on its own terms rather than re-deriving them from scratch.

This applies to any other source we add (CIViC, OncoKB, PharmGKB, RTX-KG2). The connector tests should include an “all source predicates have a defensible mapping with a tag” check as part of D-phase acceptance.



GP-KG mapping (added 2026-05-11)

The first GP-KG migration audit surfaced two real bugs that we fixed in the same session as the PrimeKG predicate refinement: an 87% synthetic CURIE rate (the hint code didn’t recognize GP-KG’s external_id formats) and a 98.76% unmapped predicate rate (the mapper had been written against the published GP-KG schema names, not the names PrimeKG’s actual data uses). Both fixed; mappings here.

GP-KG external_id patterns (now handled by _hint_from_legacy_node)

GP-KG dialectExampleCanonical CURIE
HGNC symbol (gene)BRAF, TNFSF13BHGNC.SYMBOL:BRAF
PubChem CID with 100M offsetCID100002907PUBCHEM.COMPOUND:2907 (offset stripped)
UMLS CUI (any case)C3150896, c0009402UMLS:C3150896 (uppercased)
HPO with underscoreHP_0001128HP:0001128
MGI MP with underscoreMP_0004829MP:0004829
GO with underscoreGO_0004366GO:0004366
UBERON with underscoreUBERON_0006618UBERON:0006618
Already-CURIE formsMONDO:0005148unchanged

GP-KG predicate mappings (9 raw types in data)

GP-KG predicateBiolink predicateTagRationale
gene_uao_phenotypebiolink:expressed_inEXACTGene → UBERON tissue (uao = “Uberon Anatomy Of”). 539,845 rows.
gene_go_phenotypebiolink:related_toDELIBERATE-LOSSGene → GO term, but GO mixes BP/MF/CC subgraphs and PrimeKG’s data doesn’t include the GO subgraph metadata. Without that we can’t choose between participates_in (BP), enables (MF), and located_in (CC). 204,862 rows.
gene_mp_phenotypebiolink:gene_associated_with_phenotypic_featureEXACTGene → MGI mammalian phenotype. 187,304 rows.
drug_hp_phenotypebiolink:has_phenotypeAMBIGUITY-VIA-DOMAINDrug → HPO phenotype. Could be efficacy or adverse event; subject-domain (drug) signals “drug induces phenotype.” 175,713 rows.
drug_mp_phenotypebiolink:has_phenotypeAMBIGUITY-VIA-DOMAINDrug → MP phenotype. 36,422 rows.
disease_hp_phenotypebiolink:has_phenotypeEXACTDisease → HPO phenotype. The canonical disease-phenotype relation. 87,154 rows.
treatbiolink:treatsEXACTDrug → disease. 2,764 rows.
targetbiolink:has_targetEXACTDrug → gene. 5,280 rows.
associatebiolink:gene_associated_with_conditionEXACTDisease → gene. 7,382 rows.

Naming note: GP-KG (and the KG-Predict paper) describe the schema as treat / target / associate / relate / bind / express / downregulate / upregulate / interact (9 short names). The actual data ships with gene_uao_phenotype etc. (verbose <subject>_<vocabulary>_phenotype patterns) for 6 of those 9. We keep both forms in the mapper as future-proof aliases — if a GP-KG-derivative source ships the short names, they map identically.

Actual GP-KG audit numbers (2026-05-11)

MetricBefore fixPredicted (after fix)Actual (after fix)
Synthetic CURIE rate87.26%~10-25%0.013% (8 of 61,146)
Unmapped predicate rate98.76%0%0.000% ✓
Distinct Biolink predicates377 ✓
Canonical entities (vs 61,146 legacy)61,14661,14661,146 ✓ (no dedup; GP-KG is internally unique)
Canonical relations (vs 1,246,726 legacy)1,246,7261,246,7261,246,726 ✓ (0 edges skipped)
Cross-source-shared entities with PrimeKGn/aunknown2,188 entities canonical-merged across sources

Predictions were way too conservative on synthetic CURIE rate. Actual is 0.013% vs predicted 10-25%. The hint code fixes (CID-with-100M-offset stripping, HGNC.SYMBOL: namespace, underscore-CURIE normalization, lowercase-UMLS uppercasing) resolved nearly every external_id pattern in the data.

The 8 GP-KG entities that DID land as synthetic (full audit)

gp_kg:C2           disease  "C2"
gp_kg:C3           disease  "C3"
gp_kg:C5           disease  "C5"
gp_kg:C6           disease  "C6"
gp_kg:C7           disease  "C7"
gp_kg:C9           disease  "C9"
gp_kg:NS3/4A       gene     "NS3/4A"
gp_kg:HIV-1 PROTEASE  gene  "HIV-1 PROTEASE"

To reproduce: SELECT e.curie, e.domain, e.name FROM mods_kg_entity e JOIN mods_kg_entity_source es ON es.entity_id=e.id WHERE es.source_id='gp_kg' AND e.is_synthetic_curie; against the production DB.

All 8 are defensible non-fixes:

  • C2 / C3 / C5 / C6 / C7 / C9 are 2-character strings that look like UMLS CUIs but are way too short (real CUIs are C + 7 digits). They’re almost certainly complement proteins (complement C2/C3/C5 etc.) miscategorized as entity_type='disease' in GP-KG’s source data. The synthetic CURIE is correct because there is no canonical “C3-the-disease” in any controlled vocabulary; it’s a gene symbol stored in the wrong row.
  • NS3/4A is HCV non-structural protein 3/4A. Contains /, fails the HGNC.SYMBOL alphanumeric check. Viral genes aren’t in HGNC anyway.
  • HIV-1 PROTEASE is HIV protease. Space-containing, not in HGNC.

These are GP-KG data-quality artifacts (viral and complement entries mis-typed), not gaps in our hint code.

Final GP-KG predicate distribution (verbatim from the production DB on 2026-05-11)

Biolink predicateRaw GP-KG predicate(s)Row count
biolink:expressed_ingene_uao_phenotype (gene → UBERON anatomy)539,845
biolink:related_togene_go_phenotype (gene → GO; subgraph-ambiguous)204,862
biolink:gene_associated_with_phenotypic_featuregene_mp_phenotype (gene → MGI phenotype)187,304
biolink:has_phenotypedrug_hp_phenotype + drug_mp_phenotype + disease_hp_phenotype (3 raw → 1 Biolink, subject-domain disambiguates)175,713 + 36,422 + 87,154 = 299,289
biolink:gene_associated_with_conditionassociate (disease → gene)7,382
biolink:has_targettarget (drug → gene)5,280
biolink:treatstreat (drug → disease)2,764
TOTAL9 raw → 7 Biolink1,246,726 ✓

To reproduce: SELECT predicate, source_predicate, COUNT(*) FROM mods_kg_relation WHERE source_id='gp_kg' GROUP BY 1,2 ORDER BY 1, 3 DESC;


(Adding this section to address a recurring question: if Biolink has hundreds of predicates, why do we keep collapsing source-native predicates into smaller numbers of canonical Biolink predicates?)

Biolink IS big at the predicate level — it ships hundreds of slot definitions. But the appearance of “Biolink being too small to cover what we have” comes from three real architectural choices Biolink makes that differ from how individual KGs structure their data:

When a source like CIViC says “PREDICTS_SENSITIVITY_TO drug X for cancer type Y at evidence level 3A”, the source compresses everything into one relation type per (predicate, qualifier-combination). Biolink instead models this as ONE predicate (biolink:predicts_sensitivity_to) PLUS separate qualified_predicate, subject_aspect_qualifier, evidence_level slots that carry the extra structure.

Our current schema (mods_kg_relation) has columns for subject_id, object_id, predicate, source_predicate, source_id, weight, evidence, metadata. There’s no qualifier column. So when a source represents an evidence-level distinction as a separate relation type (e.g., PrimeKG’s indication vs off-label use), and Biolink would naturally model both as biolink:treats with different qualifier values, our schema makes us choose:

  • Map both to biolink:treats → loss (this was the original PrimeKG bug we just fixed)
  • Use different Biolink predicates if available (biolink:treats vs biolink:has_off_label_use) → preserves the distinction at the predicate level (current refined approach)
  • Add qualifier columns to our schema → cleanest, but bigger refactor; deferred

So Biolink IS big enough to express everything, but our schema doesn’t yet model the qualifier dimension where Biolink puts ~30% of the information.

Take drug_drug (drug-drug interaction) and protein_protein (protein-protein interaction) — semantically very different! Biolink maps both to biolink:interacts_with (the same predicate) because the subject and object entity types already convey the distinction. A (drug, biolink:interacts_with, drug) row means DDI; a (protein, biolink:interacts_with, protein) row means PPI.

This is a design choice: Biolink avoids predicate redundancy when entity types convey the information. A downstream model (CompGCN, ULTRA, etc.) typically embeds entity types alongside predicates, so the distinction is recoverable.

You can see this in our refined PrimeKG mapping: protein_protein and drug_drug both → biolink:interacts_with (3,314,778 rows total in PrimeKG canonical, of which 2,672,628 are drug-drug and 642,150 are protein-protein — recoverable via entity-type filter).

3. Some source predicates are inherently multi-semantic

PrimeKG’s gene_go_phenotype covers gene→GO-term relationships, but GO has three subgraphs (Biological Process, Molecular Function, Cellular Component) with semantically distinct relationships:

  • Gene → BP term should be biolink:participates_in
  • Gene → MF term should be biolink:enables
  • Gene → CC term should be biolink:located_in

PrimeKG flattens all three into one relation type without exposing the GO subgraph metadata. We have no honest way to pick the right Biolink predicate per row, so we map to biolink:related_to (the parent predicate). This is one of two DELIBERATE-LOSS entries in our PrimeKG mapping table. 204,862 rows in GP-KG and similar in PrimeKG sit at this related_to placeholder until/unless we re-process those edges with the GO subgraph membership lookup.

Summary

Apparent “Biolink too small” caseActual reasonRecoverable?
indication + off-label use both → biolink:treats (the original bug)Biolink has both biolink:treats AND biolink:has_off_label_use; we just hadn’t used the latter✅ Fixed in the refined mapper
protein_protein + drug_drug → biolink:interacts_withBiolink design: entity types carry the distinction✅ Via subject/object domain filter
250 PrimeKG edges previously dedupedWas caused by the first issue, not Biolink limitations✅ Resolved (post-refinement: 0 dedups)
gene_go_phenotype → biolink:related_toSource flattens BP/MF/CC subgraphs; no honest way to disambiguate without subgraph metadata⏳ Recoverable via GO ontology lookup (future enhancement)
Evidence-level qualifiers (CIViC Level A/B/C, OncoKB Level 1/2/3A)Biolink models these as qualifiers, not as predicate variants⏳ Our schema needs qualifier columns to use Biolink’s idiomatic representation

So: Biolink is comprehensive at the predicate-AND-qualifier level. Our schema currently captures only the predicate dimension. Apparent collapses are typically Biolink working as designed (entity-type disambiguation) or our schema lagging (missing qualifier columns). In every case, the raw source_predicate column preserves the source’s distinction verbatim — no information is irretrievably lost.


Cross-references