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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

All notable changes to this project are documented in this file.

## Unreleased

### Added
- **`disease_context_qualifier` is granted to `EntityToDiseaseAssociation` / `EntityToPhenotypicFeatureAssociation` edges as a class-scoped policy override.** Biolink declares the slot only on the `ChemicalEntityToDiseaseOrPhenotypicFeatureAssociation` lineage, while `FDA_regulatory_approvals` lives only on the `EntityToDisease` / `EntityToPhenotypicFeature` classes DAKP pins via `category_override` — so a contraindication edge could natively carry one slot or the other, never both. The new `CLASS_FIELD_OVERRIDES` table in `biolink.py` grants the qualifier to the pinned classes: `prune_to_class` keeps the value on those rows instead of nulling and rescuing it, and record validation strips the granted field from its in-memory copy so the deliberate gap is not reported as `extra_forbidden`. The grant is deliberately ahead of the pinned model (pending an upstream Biolink widening); a tripwire test asserts every granted field stays absent from its class, so a future biolink-model release that attaches the slot fails the suite until the stale grant is removed.

## 15.0.0 - 2026-08-24

### Breaking Changes
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration/table.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ Defines subject-predicate-object relationships.
| `predicate` | String | No | Biolink predicate. Defaults to `"related_to"`. |
| `object` | NodeEncoding | Yes | Object entity configuration |
| `qualifiers` | List[Qualifier] | No | Edge qualifiers (context) |
| `category_override` | Map[Categories, EdgeCategories] | No | Pin the association class per resolved object category (bare names, no `biolink:` prefix), replacing the derived (subject, object) pair lookup for those rows. Rows whose object category is absent from the map derive as before. Pinned classes are still reconciled against the section predicate (a class whose `predicate` slot rejects it is walked up the association hierarchy, with a warning at config time). Use it when one section mixes object categories the pair lookup merges — e.g. `Disease` and `PhenotypicFeature` both derive to `ChemicalEntityToDiseaseOrPhenotypicFeatureAssociation`, but only `EntityToDiseaseAssociation` / `EntityToPhenotypicFeatureAssociation` declare slots like `FDA_regulatory_approvals` and `number_of_cases`. |
| `category_override` | Map[Categories, EdgeCategories] | No | Pin the association class per resolved object category (bare names, no `biolink:` prefix), replacing the derived (subject, object) pair lookup for those rows. Rows whose object category is absent from the map derive as before. Pinned classes are still reconciled against the section predicate (a class whose `predicate` slot rejects it is walked up the association hierarchy, with a warning at config time). Use it when one section mixes object categories the pair lookup merges — e.g. `Disease` and `PhenotypicFeature` both derive to `ChemicalEntityToDiseaseOrPhenotypicFeatureAssociation`, but only `EntityToDiseaseAssociation` / `EntityToPhenotypicFeatureAssociation` declare slots like `FDA_regulatory_approvals` and `number_of_cases`. Rows pinned to either of those classes may additionally carry `disease_context_qualifier` under a deliberate Tablassert policy grant, ahead of the pinned Biolink model. |

**Example:**
```yaml
Expand Down
60 changes: 50 additions & 10 deletions src/tablassert/biolink.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
__all__ = [
"ALLOWED_EDGE_FIELDS",
"BIOLINK_VERSION",
"CLASS_FIELD_OVERRIDES",
"DISABLED_EDGE_FIELDS",
"EFFECT_TYPE_VALUES",
"ENUM_RANGED_QUALIFIERS",
Expand Down Expand Up @@ -519,6 +520,32 @@ class EffectTypes(str, Enum):
"""


CLASS_FIELD_OVERRIDES: dict[str, frozenset[str]] = {
"EntityToDiseaseAssociation": frozenset({"disease_context_qualifier"}),
"EntityToPhenotypicFeatureAssociation": frozenset({"disease_context_qualifier"}),
}
"""Per-class grants of edge fields the resolved association class does not declare.

Keys are bare association class names (``association_class(cat).__name__``), values
the slots ``lib.prune_to_class`` keeps on rows resolved to that class even though the
installed model attaches them elsewhere.

The motivating case is a DAKP contraindication edge: ``FDA_regulatory_approvals`` is
declared only on the ``EntityToDisease`` / ``EntityToPhenotypicFeature`` classes the
edge is pinned to, while ``disease_context_qualifier`` is declared only on the
``ChemicalEntityToDiseaseOrPhenotypicFeatureAssociation`` lineage -- so one edge can
natively carry one slot or the other, never both. Tablassert deliberately emits the
qualifier on the pinned classes ahead of the pinned model (pending an upstream Biolink
widening), exactly as :data:`KNOWN_PENDING_EDGE_FIELDS` emits KGX carryovers ahead of
it. ``_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
removed.
"""


ENUM_RANGED_QUALIFIERS: dict[str, frozenset[str]] = {
qualifier.value: choices
for qualifier in Qualifiers
Expand Down Expand Up @@ -647,20 +674,33 @@ def _retrieval_source_id_required() -> bool:


def _validation_record(record: dict[str, Any], *, edge: bool) -> dict[str, Any]:
"""Add only in-memory compatibility aliases needed by the installed Biolink model.

``RetrievalSource`` in the currently pinned model still requires the inherited
``Entity.id`` even though ``resource_id`` is the canonical provenance identifier
emitted by Tablassert. The alias is used solely for Pydantic validation; it never
changes the decoded KGX record or the files written by the pipeline.
"""Apply only in-memory compatibility shims needed by the installed Biolink model.

Two shims, both used solely for Pydantic validation -- neither changes the decoded
KGX record or the files written by the pipeline:

* ``RetrievalSource`` in the currently pinned model still requires the inherited
``Entity.id`` even though ``resource_id`` is the canonical provenance identifier
emitted by Tablassert, so ``id`` is aliased in.
* Fields granted by :data:`CLASS_FIELD_OVERRIDES` are stripped when the record's
own class does not declare them: the grant is a deliberate, class-scoped step
ahead of the pinned model, not a malformed record, so its ``extra_forbidden``
is never reported.
"""
if not edge:
return record
out: dict[str, Any] = record
categories: Any = record.get("category") or []
category: str = categories[0] if isinstance(categories, list) and categories else str(categories or "")
granted: frozenset[str] = CLASS_FIELD_OVERRIDES.get(category.removeprefix("biolink:"), frozenset())
undeclared: frozenset[str] = granted - class_fields(association_class(category)) if granted else frozenset()
if undeclared:
out = {key: value for key, value in out.items() if key not in undeclared}
if not _retrieval_source_id_required():
return record
sources: Any = record.get("sources")
return out
sources: Any = out.get("sources")
if not isinstance(sources, list):
return record
return out
normalized: list[Any] = []
changed: bool = False
for source in sources:
Expand All @@ -669,7 +709,7 @@ def _validation_record(record: dict[str, Any], *, edge: bool) -> dict[str, Any]:
changed = True
else:
normalized.append(source)
return {**record, "sources": normalized} if changed else record
return {**out, "sources": normalized} if changed else out


def validate_record(record: dict[str, Any], *, edge: bool) -> list[str]:
Expand Down
14 changes: 11 additions & 3 deletions src/tablassert/lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from tablassert._lazy import LazyModule
from tablassert.biolink import (
ALLOWED_EDGE_FIELDS,
CLASS_FIELD_OVERRIDES,
DISABLED_EDGE_FIELDS,
ENUM_RANGED_QUALIFIERS,
STUDY_METADATA_FIELDS,
Expand Down Expand Up @@ -284,7 +285,9 @@ def prune_to_class(lf: pl.LazyFrame) -> pl.LazyFrame:
accepts it is a separate question, and getting it wrong is the single largest
source of ``extra_forbidden`` failures for qualifier fields on a class that has
no such slot. Tablassert-disabled fields are removed before this class-specific
masking so they cannot be rescued into study metadata.
masking so they cannot be rescued into study metadata. Slots granted to a class by
:data:`biolink.CLASS_FIELD_OVERRIDES` survive the mask -- the grant is a deliberate
step ahead of the pinned model, not a pruning target.

Categories vary per row within a section, so this masks per row rather than
dropping columns: values are nulled where the row's class rejects them, and the
Expand Down Expand Up @@ -321,7 +324,12 @@ def prune_to_class(lf: pl.LazyFrame) -> pl.LazyFrame:
text: pl.Expr = (
pl.col(col).list.eval(pl.element().cast(pl.String)).list.join(", ") if isinstance(schema[col], pl.List) else pl.col(col).cast(pl.String)
)
accepts: dict[str, bool] = {cat: col in class_fields(association_class(cat)) for cat in categories}
declares: dict[str, bool] = {cat: col in class_fields(association_class(cat)) for cat in categories}
# CLASS_FIELD_OVERRIDES grants a slot to a class that does not declare it
# (deliberately ahead of the pinned model); granted rows keep the value.
accepts: dict[str, bool] = {
cat: declares[cat] or col in CLASS_FIELD_OVERRIDES.get(cat.removeprefix("biolink:"), frozenset()) for cat in categories
}
# A closed-vocabulary slot additionally constrains the *value*. A qualifier
# encoded from a column carries whatever the sheet holds, so the token can only
# be checked here -- config-time validation sees no data.
Expand All @@ -345,7 +353,7 @@ def prune_to_class(lf: pl.LazyFrame) -> pl.LazyFrame:
# them is what produced a spuriously mixed per-row wrap that died in
# strict_cast at collect. Rows whose class rejects the slot are already null and
# stay null. A hypothetically mixed slot keeps its scalar rather than crashing.
declaring: list[str] = [cat for cat in categories if accepts[cat]]
declaring: list[str] = [cat for cat in categories if declares[cat]]
listed: dict[str, bool] = {cat: is_multivalued(association_class(cat), col) for cat in declaring}
if listed and all(listed.values()) and not isinstance(schema[col], pl.List):
# concat_list maps null -> [null]; the when preserves real nulls instead.
Expand Down
33 changes: 33 additions & 0 deletions tests/test_biolink.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from tablassert.biolink import (
ALLOWED_EDGE_FIELDS,
BIOLINK_VERSION,
CLASS_FIELD_OVERRIDES,
DISABLED_EDGE_FIELDS,
EFFECT_TYPE_VALUES,
KNOWN_PENDING_EDGE_FIELDS,
Expand All @@ -34,11 +35,14 @@
KnowledgeLevels,
Predicates,
Qualifiers,
association_class,
class_fields,
is_pending_problem,
legal_predicates,
numeric_slot_kind,
resolve_association_class,
validate_kgx,
validate_record,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -362,6 +366,35 @@ def test_disabled_edge_fields_are_never_emittable() -> None:
assert DISABLED_EDGE_FIELDS.isdisjoint(ALLOWED_EDGE_FIELDS)


def test_class_field_overrides_track_the_installed_model() -> None:
"""Every granted field must still be absent from its class and emittable elsewhere.

Tripwire: the moment a biolink-model release attaches a granted slot to the class,
this fails and the stale grant is removed from ``CLASS_FIELD_OVERRIDES`` (same
philosophy as the ``UNSATISFIABLE_EDGE_FIELDS`` derivation guard). A field the
family allow-list would strip anyway must never be granted.
"""
for class_name, fields in CLASS_FIELD_OVERRIDES.items():
cls: type[Any] = association_class(f"biolink:{class_name}")
assert issubclass(cls, bm.Association), class_name
for field in fields:
assert field not in class_fields(cls), (class_name, field)
assert field in ALLOWED_EDGE_FIELDS, (class_name, field)


def test_validate_record_tolerates_class_field_override_grants() -> None:
"""A granted field on its granted class is not reported; on any other class it is.

The grant is a deliberate, class-scoped step ahead of the pinned model, so its
``extra_forbidden`` must not surface in validation -- while the same slot on an
ungranted class stays a real defect.
"""
record: dict[str, Any] = {"category": ["biolink:EntityToDiseaseAssociation"], "disease_context_qualifier": "MONDO:0005148"}
assert "disease_context_qualifier: extra_forbidden" not in validate_record(record, edge=True)
control: dict[str, Any] = {**record, "category": ["biolink:GeneToDiseaseAssociation"]}
assert "disease_context_qualifier: extra_forbidden" in validate_record(control, edge=True)


def test_allowed_edge_fields_excludes_unattached_qualifiers() -> None:
"""Qualifier slots attached to no Pydantic class are not emittable.

Expand Down
26 changes: 26 additions & 0 deletions tests/test_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -1776,6 +1776,32 @@ def test_prune_to_class_keeps_override_only_slots() -> None:
assert all(any("FDA_regulatory_approvals=" in s for s in v) for v in control[PRUNED_COLUMN].to_list())


def test_prune_to_class_keeps_class_field_override_grants() -> None:
"""A slot granted to a class by CLASS_FIELD_OVERRIDES survives prune_to_class.

``disease_context_qualifier`` is declared only on the
``ChemicalEntityToDiseaseOrPhenotypicFeatureAssociation`` lineage, but the policy
grant keeps it on ``EntityToDiseaseAssociation`` /
``EntityToPhenotypicFeatureAssociation`` rows so a pinned edge can carry it
alongside ``FDA_regulatory_approvals``. Classes without the grant still prune it.
"""
from tablassert.lib import PRUNED_COLUMN, prune_to_class

lf: pl.LazyFrame = pl.LazyFrame(
{
"category": [
["biolink:EntityToDiseaseAssociation"],
["biolink:GeneToDiseaseAssociation"],
["biolink:EntityToPhenotypicFeatureAssociation"],
],
"disease_context_qualifier": ["MONDO:0005148", "MONDO:0005148", "MONDO:0005015"],
}
)
out: pl.DataFrame = prune_to_class(lf).collect()
assert out["disease_context_qualifier"].to_list() == ["MONDO:0005148", None, "MONDO:0005015"]
assert out[PRUNED_COLUMN].to_list() == [[], ["disease_context_qualifier=MONDO:0005148"], []]


def test_parse_edge_name_standard() -> None:
"""parse_edge_name parses standard name."""
assert parse_edge_name("GeneToDiseaseAssociation") == ("Gene", ["Disease"])
Expand Down
Loading