fix(biolink): make KGX output validate against the Biolink Model - #71
Conversation
|
Warning Review limit reached
Next review available in: 5 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (18)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Measured against the 2M-edge corpus in .tablassert/million-kg/ with
biolink-model 4.4.3, zero of 2,000,085 edges and zero of 424,141 nodes
constructed as their own Biolink class. Nothing in the pipeline ever
instantiated Association/NamedThing against an emitted record -- biolink.py
derived the *vocabulary* from the model but never checked the output -- so
this went unnoticed. Both files are now 100% valid.
Retrieval provenance
upstream_resource_ids and source_record_urls have `domain: retrieval source`,
so they were extra_forbidden on every edge, and primary_knowledge_source was
emitted as a list where Biolink wants a scalar. Added retrieval_sources(),
mirroring build_association_knowledge_sources() in translator-ingests: a
sources[] of RetrievalSource carrying the roles, upstream ids and record urls.
Category/predicate contradictions (48.2% of edges)
edge_category() picked a class from (subject role, object role) without
consulting the predicate, so 723,595 edges were GeneToDiseaseAssociation +
gene_associated_with_condition -- a predicate that class forbids.
resolve_association_class() now walks the MRO and gives up specificity only
as far as the predicate requires.
Qualifiers
Values were fullmap-resolved even for enum-ranged slots, yielding
object_direction_qualifier: "UMLS:C0205217" where the vocabulary wants
`increased`. Enum-ranged qualifiers are now passed through unresolved,
validated against their vocabulary at config time and at emit time (a
column-encoded qualifier carries whatever the sheet holds).
prune_to_class() masks per row what the resolved class does not declare and
hands the values to the inlined Study rather than deleting them.
Study
Sheet name, row number and statistics with no Association slot went into
supporting_text as "key: value" strings -- a slot whose Biolink meaning is a
supporting sentence. They now populate has_supporting_studies (Study ->
StudyResult), the COHD/ICEES pattern in translator-ingests.
Allow-list
_association_model_fields() walked only the base Association MRO, so
subclass-only evidence slots (clinical_approval_status, number_of_cases,
FDA_regulatory_approvals) were demoted into supporting_text. It now unions
the whole association family. Slots declared in the LinkML schema but
attached to no Pydantic class are rejected at config time instead of being
emitted unvalidatably.
Nodes
source/source_version were extra_forbidden on every node; they are build
provenance, so provided_by carries the infores, taxon becomes in_taxon +
in_taxon_label, and the version moves to graph level. resolve_node_class()
demotes the 48 categories that cannot be emitted (Publication requires
publication_type, ClinicalAttribute requires has_attribute_type, GenomicEntity
is a mixin that rejects its own name).
Also
- p_value/adjusted_p_value emit as JSON numbers, not "6.5200e-06" strings.
- Annotations gain `delimiter` so multivalued slots (has_evidence,
FDA_regulatory_approvals) emit real arrays instead of joined strings that
consumers iterate character by character.
- strip_nulls no longer deletes 0 and false, which silently dropped a
p_value of 0 and number_of_cases: 0.
- New `tablassert validate-kgx` constructs every record as its own Biolink
class and exits non-zero on failure, gating this in CI.
Nothing is hardcoded to a biolink-model version: UNSATISFIABLE_EDGE_FIELDS is
derived from the installed package. Verified by generating the Pydantic model
from biolink/biolink-model#1770 -- with it applied the six supporting_study_*
slots stop being routed to the StudyResult and are emitted flat on the edge,
with no code change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6KH4PXbz68F3DSFNAFa63
dspy is used only by the GEPA prompt-optimization path (`agent --optimize`); ordinary agent runs never import it. Splitting it into its own extra gives everyone who does not optimize a lighter `[agent]` install, and makes the missing-package error point at the extra that actually ships the package. Runs that use --optimize now need `pip install "tablassert[agent,optimize]"`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6KH4PXbz68F3DSFNAFa63
5f96a4e to
4ec1cd5
Compare
|
@coderabbitai review |
|
Context
Tablassert's
*.{nodes,edges}.ndjsonis meant to be consumed as a KGX passthrough byNCATSTranslator/translator-ingestsand by the local DINGO DAKP ingest. It did not work.Measured against the real 2M-edge corpus in
.tablassert/million-kg/using the installedbiolink-model4.4.3 Pydantic classes:Not a single record validated. This stayed invisible because nothing ever instantiated
Association/NamedThingagainst an emitted record —biolink.pyderived the vocabulary from the model but never checked the output.What was wrong
upstream_resource_idsandsource_record_urlshavedomain: retrieval source, so both wereextra_forbiddenon every edge;primary_knowledge_sourcewas a list where Biolink wants a scalar. There was nosourceslist and noresource_role.GeneToDiseaseAssociation+biolink:gene_associated_with_condition, a predicate that class's enum forbids.edge_category()chose a class from(subject role, object role)and never consulted the predicate.object_direction_qualifier: "UMLS:C0205217"where the vocabulary requires the tokenincreased.species_context_qualifier(1,436,251),anatomical_context_qualifier(383,798), and others.statistical_significance_qualifier(1,191,276),supporting_study_size(1,191,032). They are declared in the LinkML schema but attached to no class.AssociationMRO, so subclass-only evidence slots (clinical_approval_status,number_of_cases,FDA_regulatory_approvals) were demoted intosupporting_text.source/source_versionwereextra_forbiddenon all 424,141 nodes.p_valuewas written as"6.5200e-06"where Biolink types itfloat.supporting_textwas used as an untyped dump ("sheet_name: Table_S7"), though its Biolink meaning is a supporting sentence.strip_nullsdeleted0andfalse, so ap_valueof 0 ornumber_of_cases: 0vanished.What this changes
retrieval_sources()emits asources[]ofRetrievalSource, mirroringbuild_association_knowledge_sources()in translator-ingests;primary_knowledge_sourceis now scalar.resolve_association_class()walks the MRO and gives up specificity only as far as the predicate requires.prune_to_class()masks per row what the resolved class does not declare, and hands those values to the inlined study rather than deleting them.inline_supporting_study()populateshas_supporting_studies(Study→StudyResult), the COHD/ICEES pattern, replacing thesupporting_textkey/value dump.provided_by/in_taxon/in_taxon_label;resolve_node_class()demotes the 48 categories that cannot be emitted (Publicationrequirespublication_type,ClinicalAttributerequireshas_attribute_type,GenomicEntityis a mixin that rejects its own name).tablassert validate-kgxconstructs every record as its own Biolink class, groups failures by field and error type, and exits non-zero — so a 0% pass rate can never ship again. Also asserted in the e2e build test.The second commit is unrelated housekeeping that was already in the working tree:
dspymoves out of[agent]into a new[optimize]extra, since it powers only the GEPA--optimizepath.effect_size/effect_typemain(6f8456d) emitseffect_sizeandeffect_typeas top-level edge fields forbiolink-model#1774. Neither is on
Associationin the pinned 4.4.3, so edges carrying them fail validation withextra_forbidden— the same class of problem this PR fixes, in a feature merged three days ago.I deliberately did not change that behaviour: it is a recently shipped feature and the call is yours. Two options:
effect_size/effect_typereach the final edges;validate-kgxreports those edges as invalid until abiolink-modelrelease ships #1774.supporting_study_*. Drop them fromTABLASERT_EDGE_EXTRASso the existing derivation carries them on the inlinedStudyResultuntil the model ships them, at which point they become flat edge fields automatically. Fully valid today, feature deferred.Say which and I will apply it.
On biolink-model#1770
Nothing is hardcoded to a model version.
UNSATISFIABLE_EDGE_FIELDSis derived from the installed package at import time. Verified by generating the Pydantic model frombiolink/biolink-model#1770(dbd60110f): with it applied, all sixsupporting_study_*slots become realAssociationfields inherited by every subclass, drop out of the unsatisfiable set, and are emitted flat on the edge instead of routed to theStudyResult— with no code change. Notesupporting_study_sizeisOptional[int]there, so it must be a JSON integer.Verification
pytest: 728 passed, 34 skipped (includes main's 32 neweffect_size/effect_typetests).cargo test: 68 + 10 passed.ruff check/ruff format/cargo fmt/cargo clippy: clean.Rebased onto
main, so this contains only the two commits above.🤖 Generated with Claude Code
https://claude.ai/code/session_01V6KH4PXbz68F3DSFNAFa63