diff --git a/CHANGELOG.md b/CHANGELOG.md index 44142ef..b09fdae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project are documented in this file. ## Unreleased +### Changed +- **`biolink-model` is bumped to 4.4.6 and the grant it absorbed is dropped.** Unlike the 4.4.5 wheel, whose bundled schema still carried the 4.4.4 version string, the 4.4.6 wheel's schema registers as 4.4.6, so `BIOLINK_VERSION` now reports the pinned version accurately. The release also attached `population_context_qualifier` to `EntityToDiseaseAssociation` / `EntityToPhenotypicFeatureAssociation`, so the `CLASS_FIELD_OVERRIDES` tripwire removed that grant from both classes (the removed entries stay in the source as commented examples for future grants); the remaining deliberate grants are `temporal_context_qualifier` on both classes plus `sex_qualifier` on `EntityToDiseaseAssociation`, and `prune_to_class` now keeps `population_context_qualifier` natively on the whole disease/phenotype family, including previously ungranted classes. + ### Added - **`validate-infores` checks every emitted knowledge-source identifier against the Translator registry.** The new command classifies each `infores:` CURIE on the edges (`primary_knowledge_source`, `sources[].resource_id`, `sources[].upstream_resource_ids`), the nodes (`provided_by`), and optionally the RIG document against `biolink/information-resource-registry`'s `infores_catalog.yaml` -- the authoritative catalog translator-ingests' SOP points at but never checks programmatically. The registry ships as a bundled offline snapshot (vendored from upstream commit `a72dbc3b55d5`), `--registry refresh` fetches the live catalog through the bounded-retry network layer, and `--registry off` reports structure only. The default posture is advisory (exit 0 with warnings) because Tablassert legitimately mints graph-local CURIEs; repeatable `--allow-infores` exempts them and `--strict` makes unregistered CURIEs fail in CI. Values without the `infores:` prefix are malformed, not unregistered. This closes the validation gap that only the CURIE prefix was ever checked. diff --git a/docs/configuration/table.md b/docs/configuration/table.md index 2ffd621..927fbed 100644 --- a/docs/configuration/table.md +++ b/docs/configuration/table.md @@ -590,7 +590,7 @@ annotations: Annotation names fall into three groups at build time: -- **Allowed edge fields:** names on the edge allow-list: [Biolink Association](https://biolink.github.io/biolink-model/) slots, qualifier slots, and curated KGX/Tablassert edge fields (e.g. `p_value`, `adjusted_p_value`, `knowledge_level`, `supporting_text`, `publications`, `effect_size`, `effect_type`, `statistical_significance_qualifier`, qualifier slots like `severity_qualifier` / `disease_context_qualifier`) are written to edges verbatim. `effect_size` and `effect_type` became real Association slots in biolink-model 4.4.4 (PR #1774) and emit as real JSON numbers and enum tokens. Annotation names are matched case-insensitively against the allow-list and emitted under the canonical slot spelling. `regulatory_approvals` (multivalued, so declare [`split_by`](#split_by) for pipe-joined cells like `011111|022222`), `disease_context_qualifier`, `anatomical_context_qualifier`, and `frequency_qualifier` are declared natively on `EntityToDiseaseAssociation` / `EntityToPhenotypicFeatureAssociation` by biolink-model 4.4.5, and `sex_qualifier` on the phenotypic-feature class, so those rows keep them without any override. The remaining class-scoped grants cover DAKP's sparse qualifier stack ahead of the model: `population_context_qualifier` and `temporal_context_qualifier` on both classes, plus `sex_qualifier` on `EntityToDiseaseAssociation`; without a grant `prune_to_class` nulls those qualifiers off the pinned rows. `species_context_qualifier` stays disabled, and `temporal_interval_qualifier` / `severity_qualifier` stay unsatisfiable, so none of the three is granted. +- **Allowed edge fields:** names on the edge allow-list: [Biolink Association](https://biolink.github.io/biolink-model/) slots, qualifier slots, and curated KGX/Tablassert edge fields (e.g. `p_value`, `adjusted_p_value`, `knowledge_level`, `supporting_text`, `publications`, `effect_size`, `effect_type`, `statistical_significance_qualifier`, qualifier slots like `severity_qualifier` / `disease_context_qualifier`) are written to edges verbatim. `effect_size` and `effect_type` became real Association slots in biolink-model 4.4.4 (PR #1774) and emit as real JSON numbers and enum tokens. Annotation names are matched case-insensitively against the allow-list and emitted under the canonical slot spelling. `regulatory_approvals` (multivalued, so declare [`split_by`](#split_by) for pipe-joined cells like `011111|022222`), `disease_context_qualifier`, `anatomical_context_qualifier`, and `frequency_qualifier` are declared natively on `EntityToDiseaseAssociation` / `EntityToPhenotypicFeatureAssociation` by biolink-model 4.4.5, and `sex_qualifier` on the phenotypic-feature class; biolink-model 4.4.6 added `population_context_qualifier` to both classes, so those rows keep them without any override. The remaining class-scoped grants cover DAKP's sparse qualifier stack ahead of the model: `temporal_context_qualifier` on both classes, plus `sex_qualifier` on `EntityToDiseaseAssociation`; without a grant `prune_to_class` nulls those qualifiers off the pinned rows. `species_context_qualifier` stays disabled, and `temporal_interval_qualifier` / `severity_qualifier` stay unsatisfiable, so none of the three is granted. - **Study metadata:** `study_size`, `study_cohort`, `study_context`, `study_date_range`, `study_method_description`, and `study_method_types` are current Biolink `Study` node properties ([biolink-model PR #1770](https://github.com/biolink/biolink-model/pull/1770)). They describe the study itself, not an association, so their values are carried on the edge's **inlined supporting Study** (`has_supporting_studies` to `Study`, the COHD/ICEES pattern) rather than emitted as edge fields. Deprecated `supporting_study_*` spellings and `sample_size` are accepted as aliases and renamed onto canonical `study_*` names by coercion. They never appear in final JSON. Declaring any of them is legal and emits a `BiolinkRelocationWarning` naming where the value went. - **Unsatisfiable slots:** names the Biolink LinkML schema declares but attaches to **no** Pydantic class, derived from the installed `biolink-model`. A record carrying one could never validate, so its value is preserved in the inlined supporting study's `StudyResult.description`. A slot leaves the set automatically once a release attaches it. diff --git a/pyproject.toml b/pyproject.toml index 8061f84..d380913 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ classifiers = [ ] requires-python = ">=3.11" dependencies = [ - "biolink-model>=4.4.5", + "biolink-model>=4.4.6", "polars>=2.0.0rc2", "pyarrow>=21.0.0", "rapidfuzz>=3.14.3", diff --git a/src/tablassert/biolink.py b/src/tablassert/biolink.py index f4fc7f1..6fc6a6f 100644 --- a/src/tablassert/biolink.py +++ b/src/tablassert/biolink.py @@ -593,8 +593,12 @@ class EffectTypes(str, Enum): CLASS_FIELD_OVERRIDES: dict[str, frozenset[str]] = { - "EntityToDiseaseAssociation": frozenset({"population_context_qualifier", "sex_qualifier", "temporal_context_qualifier"}), - "EntityToPhenotypicFeatureAssociation": frozenset({"population_context_qualifier", "temporal_context_qualifier"}), + # Absorbed by biolink-model 4.4.6 (now declared natively on both pinned classes, + # removed by the tripwire; kept here commented as the pattern for future grants): + # "EntityToDiseaseAssociation": frozenset({"population_context_qualifier", "sex_qualifier", "temporal_context_qualifier"}), + # "EntityToPhenotypicFeatureAssociation": frozenset({"population_context_qualifier", "temporal_context_qualifier"}), + "EntityToDiseaseAssociation": frozenset({"sex_qualifier", "temporal_context_qualifier"}), + "EntityToPhenotypicFeatureAssociation": frozenset({"temporal_context_qualifier"}), } """Per-class grants of edge fields the resolved association class does not declare. @@ -612,11 +616,12 @@ class EffectTypes(str, Enum): ``anatomical_context_qualifier``, and ``frequency_qualifier`` to the whole disease/phenotype family, renamed ``FDA_regulatory_approvals`` to the canonical ``regulatory_approvals`` on the pinned classes, and added ``sex_qualifier`` to -``EntityToPhenotypicFeatureAssociation``); the tripwire removed those grants the day -the release landed, and the remaining ones stay deliberately ahead of the model -(pending an upstream Biolink widening). ``_validation_record`` strips granted fields -before record validation so the deliberate gap is not reported as -``extra_forbidden``. +``EntityToPhenotypicFeatureAssociation``; biolink-model 4.4.6 then attached +``population_context_qualifier`` to both pinned classes, so the tripwire removed +those grants the day each release landed). The remaining ones stay deliberately +ahead of the model (pending an upstream Biolink widening). +``_validation_record`` strips granted fields before record validation so the +deliberate gap is not reported as ``extra_forbidden``. A tripwire test asserts every granted field is still absent from its class: the moment a biolink-model release attaches the slot, the suite fails and the stale grant is diff --git a/tests/test_biolink.py b/tests/test_biolink.py index 6d65a64..4dd2526 100644 --- a/tests/test_biolink.py +++ b/tests/test_biolink.py @@ -415,18 +415,18 @@ def test_class_field_overrides_track_the_installed_model() -> None: philosophy as the ``UNSATISFIABLE_EDGE_FIELDS`` derivation guard). A field the family allow-list would strip anyway must never be granted. The biolink-model 4.4.5 mixin consolidation (plus the ``regulatory_approvals`` rename and the - phenotypic-feature ``sex_qualifier`` attachment) fired exactly this tripwire for - the original seven-field grant stack; the remaining grants are DAKP's sparse - qualifier slots still absent from the pinned classes - (``population_context_qualifier`` / ``temporal_context_qualifier`` on both, and - ``sex_qualifier`` on the disease side only). Deliberately excluded from the - grants: ``species_context_qualifier`` (disabled) plus - ``temporal_interval_qualifier`` and ``severity_qualifier`` (unsatisfiable -- a - grant could never validate). + phenotypic-feature ``sex_qualifier`` attachment) and the 4.4.6 + ``population_context_qualifier`` attachment to both pinned classes fired exactly + this tripwire for the original eight-field grant stack; the remaining grants are + DAKP's sparse qualifier slots still absent from the pinned classes + (``temporal_context_qualifier`` on both, and ``sex_qualifier`` on the disease + side only). Deliberately excluded from the grants: ``species_context_qualifier`` + (disabled) plus ``temporal_interval_qualifier`` and ``severity_qualifier`` + (unsatisfiable -- a grant could never validate). """ assert { - "EntityToDiseaseAssociation": frozenset({"population_context_qualifier", "sex_qualifier", "temporal_context_qualifier"}), - "EntityToPhenotypicFeatureAssociation": frozenset({"population_context_qualifier", "temporal_context_qualifier"}), + "EntityToDiseaseAssociation": frozenset({"sex_qualifier", "temporal_context_qualifier"}), + "EntityToPhenotypicFeatureAssociation": frozenset({"temporal_context_qualifier"}), } == CLASS_FIELD_OVERRIDES for class_name, fields in CLASS_FIELD_OVERRIDES.items(): cls: type[Any] = association_class(f"biolink:{class_name}") @@ -442,9 +442,10 @@ def test_validate_record_tolerates_class_field_override_grants() -> None: The grant is a deliberate, class-scoped step ahead of the pinned model, so its ``extra_forbidden`` must not surface on either intended target -- while the same field on an ungranted class stays a real defect through the installed-model boundary. - ``population_context_qualifier`` is the probe because it is still granted on both - targets: ``regulatory_approvals`` was absorbed by the model itself in 4.4.5, so a - probe on it would pass natively and exercise nothing. + ``temporal_context_qualifier`` is the probe because it is still granted on both + targets: ``regulatory_approvals`` and ``population_context_qualifier`` were + absorbed by the model itself (in 4.4.5 and 4.4.6), so a probe on either would + pass natively and exercise nothing. """ base: dict[str, Any] = { "id": "e1", @@ -453,13 +454,16 @@ def test_validate_record_tolerates_class_field_override_grants() -> None: "object": "MONDO:0005148", "knowledge_level": "statistical_association", "agent_type": "data_analysis_pipeline", + "temporal_context_qualifier": "TIME:1", + # 4.4.6 attached population_context_qualifier to the whole family: native on + # both granted targets AND the control, so it cannot serve as a rejection probe. "population_context_qualifier": "NCIT:C25667", } for category in ("EntityToDiseaseAssociation", "EntityToPhenotypicFeatureAssociation"): record: dict[str, Any] = {**base, "category": [f"biolink:{category}"]} assert validate_record(record, edge=True) == [] control: dict[str, Any] = {**base, "category": ["biolink:GeneToDiseaseAssociation"]} - assert "population_context_qualifier: extra_forbidden" in validate_record(control, edge=True) + assert "temporal_context_qualifier: extra_forbidden" in validate_record(control, edge=True) unknown: dict[str, Any] = {**base, "category": ["biolink:EntityToDiseaseAssociation"], "not_a_biolink_field": "x"} assert "not_a_biolink_field: extra_forbidden" in validate_record(unknown, edge=True) diff --git a/tests/test_lib.py b/tests/test_lib.py index f59b5d6..24e43d3 100644 --- a/tests/test_lib.py +++ b/tests/test_lib.py @@ -1995,15 +1995,16 @@ def test_prune_to_class_keeps_class_field_override_grants() -> None: DAKP's sparse qualifier stack rides the pinned ``EntityToDiseaseAssociation`` / ``EntityToPhenotypicFeatureAssociation`` rows through ``prune_to_class`` via the - grants: ``population_context_qualifier`` / ``temporal_context_qualifier`` (both - classes) and ``sex_qualifier`` (disease side only) are kept even though the - installed model does not declare them there. ``regulatory_approvals`` needs no - grant anymore -- biolink-model 4.4.5 attached it natively to the two pinned - classes -- but the ungranted control class still prunes it, alongside the - still-granted qualifiers. The qualifiers the 4.4.5 mixin consolidation attached - to the whole disease/phenotype family (``disease_context_qualifier``, - ``anatomical_context_qualifier``, ``frequency_qualifier``) survive on every row - including the control. + grants: ``temporal_context_qualifier`` (both classes) and ``sex_qualifier`` + (disease side only) are kept even though the installed model does not declare + them there. ``regulatory_approvals`` (biolink-model 4.4.5) needs no grant + anymore on the two pinned classes, but the ungranted control class still + prunes it, alongside the still-granted qualifiers; and biolink-model 4.4.6 + attached ``population_context_qualifier`` to the whole disease/phenotype + family, so it survives on every row including the control. The qualifiers the + 4.4.5 mixin consolidation attached to the family (``disease_context_qualifier``, + ``anatomical_context_qualifier``, ``frequency_qualifier``) also survive on + every row. """ from tablassert.lib import PRUNED_COLUMN, prune_to_class @@ -2029,10 +2030,14 @@ def test_prune_to_class_keeps_class_field_override_grants() -> None: assert out["anatomical_context_qualifier"].to_list() == [["UBERON:0001557"]] * 3 assert out["frequency_qualifier"].to_list() == ["HP:0012823"] * 3 assert out["regulatory_approvals"].to_list() == [["FDA:1"], None, ["FDA:3"]] - for col in ("sex_qualifier", "population_context_qualifier", "temporal_context_qualifier"): + # 4.4.6 attached population_context_qualifier to the whole disease/phenotype + # family (not just the two pinned classes): kept natively on the ungranted + # control row too. + assert out["population_context_qualifier"].to_list() == ["NCIT:C25667"] * 3 + for col in ("sex_qualifier", "temporal_context_qualifier"): assert out[col].to_list() == [out[col].to_list()[0], None, out[col].to_list()[2]], col pruned_second: list[str] = out[PRUNED_COLUMN].to_list()[1] - for col in ("regulatory_approvals", "sex_qualifier", "population_context_qualifier", "temporal_context_qualifier"): + for col in ("sex_qualifier", "temporal_context_qualifier"): assert any(entry.startswith(f"{col}=") for entry in pruned_second), col assert out[PRUNED_COLUMN].to_list()[0] == [] assert out[PRUNED_COLUMN].to_list()[2] == [] diff --git a/uv.lock b/uv.lock index e332d62..7db26ca 100644 --- a/uv.lock +++ b/uv.lock @@ -319,15 +319,15 @@ wheels = [ [[package]] name = "biolink-model" -version = "4.4.5" +version = "4.4.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "linkml-runtime" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cc/95/b63c5b6bb70863585314a6ff14df32b91eaba5f38450a1e94228eef971ed/biolink_model-4.4.5.tar.gz", hash = "sha256:68534bbfa19c6ba979088bcb30e32fac081db36418ea574e7fbd4b03ab4febe4", size = 4115710, upload-time = "2026-09-18T15:06:06.971Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/e7/3726cdfd6fe1e56b36166f520627fe5cdf503a891407ddb55e7e90bdb2c2/biolink_model-4.4.6.tar.gz", hash = "sha256:9fec7249cfbc19a6a3a1ddd2757a9f86fd893b50210a47847364255ddd919946", size = 4125333, upload-time = "2026-10-09T23:09:44.525Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/aa/1a95a68693332df121e143ad84ff1f640cbe5717e5c7ec5dfe521d97d001/biolink_model-4.4.5-py3-none-any.whl", hash = "sha256:62fbd39eb81a41fbf7fa150b550115fe5d1b5b076dc908c32486e37bd7ca7251", size = 370799, upload-time = "2026-09-18T15:06:05.22Z" }, + { url = "https://files.pythonhosted.org/packages/de/36/d352b574c274d8098ae6e7d3ddddd24f3f23f68345d659a4e52056e27d38/biolink_model-4.4.6-py3-none-any.whl", hash = "sha256:22359ced4f7f6f8a335000584bbf619d3596ff691b8b0f41aa852360b41cfc3c", size = 371561, upload-time = "2026-10-09T23:09:42.634Z" }, ] [[package]] @@ -3549,7 +3549,7 @@ typecheck = [ [package.metadata] requires-dist = [ { name = "aria2", marker = "extra == 'aria2'", specifier = "==0.0.1b0" }, - { name = "biolink-model", specifier = ">=4.4.5" }, + { name = "biolink-model", specifier = ">=4.4.6" }, { name = "cyclopts", marker = "extra == 'cli'", specifier = ">=1.0.0" }, { name = "datasets", marker = "extra == 'distill'", specifier = ">=3.0.0" }, { name = "dspy", marker = "extra == 'optimize'", specifier = ">=3.2.1" },