Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/configuration/table.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 12 additions & 7 deletions src/tablassert/biolink.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
32 changes: 18 additions & 14 deletions tests/test_biolink.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand All @@ -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",
Expand All @@ -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)

Expand Down
27 changes: 16 additions & 11 deletions tests/test_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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] == []
Expand Down
8 changes: 4 additions & 4 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading