diff --git a/CHANGELOG.md b/CHANGELOG.md index 011be48..ee57d87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/configuration/table.md b/docs/configuration/table.md index 74243e6..a26fe9c 100644 --- a/docs/configuration/table.md +++ b/docs/configuration/table.md @@ -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 diff --git a/src/tablassert/biolink.py b/src/tablassert/biolink.py index 7bef318..0c7d732 100644 --- a/src/tablassert/biolink.py +++ b/src/tablassert/biolink.py @@ -59,6 +59,7 @@ __all__ = [ "ALLOWED_EDGE_FIELDS", "BIOLINK_VERSION", + "CLASS_FIELD_OVERRIDES", "DISABLED_EDGE_FIELDS", "EFFECT_TYPE_VALUES", "ENUM_RANGED_QUALIFIERS", @@ -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 @@ -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: @@ -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]: diff --git a/src/tablassert/lib.py b/src/tablassert/lib.py index 5946546..0cf827b 100644 --- a/src/tablassert/lib.py +++ b/src/tablassert/lib.py @@ -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, @@ -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 @@ -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. @@ -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. diff --git a/tests/test_biolink.py b/tests/test_biolink.py index b4b2dd9..3c91669 100644 --- a/tests/test_biolink.py +++ b/tests/test_biolink.py @@ -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, @@ -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: @@ -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. diff --git a/tests/test_lib.py b/tests/test_lib.py index 73c1bdc..fa0ef0c 100644 --- a/tests/test_lib.py +++ b/tests/test_lib.py @@ -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"])