diff --git a/CHANGELOG.md b/CHANGELOG.md index 97b3d7f..1a4a75f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project are documented in this file. ## Unreleased ### Added +- **`approval_ids` is now a curated pass-through edge field.** FDA application numbers declared as an `approval_ids` annotation (the DAKP translator-ingest precedent) now reach the final KGX edges as their own top-level field instead of being folded into `supporting_text`, notwithstanding that no association class declares the slot: the allow-list keeps the column out of the fold sweep, annotation validation emits no `BiolinkRelocationWarning`, and strict KGX validation counts it as *pending* Biolink support exactly like `effect_size` / `effect_type`. The representation follows the ingest: a pipe-joined scalar (e.g. `011111|022222`) is emitted verbatim as a scalar string — Tablassert does not split it into a JSON array. - **`build-kg --release` now drops edges whose `effect_size` is exactly zero.** When an `effect_size` column is present, release-mode builds filter out rows with a non-null zero effect size before fullmap resolution, matching the existing release-mode drop of `biolink:not_significant` edges. Rows with a null effect size are kept. ### Fixed diff --git a/docs/agent.md b/docs/agent.md index 739a643..06da19c 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -214,11 +214,13 @@ cannot drift from the model the build validates against: - any predicate is safe for: Gene~Gene, Gene~Pathway, ChemicalEntity~Disease, … ``` -!!! note "`effect_size` / `effect_type` are exempt" - Tablassert emits both deliberately, pending - [biolink-model#1774](https://github.com/biolink/biolink-model/pull/1774) — 4.4.3 declares neither - on `Association`, so a strict check rejects every edge carrying them. `biolink_valid_pct` exempts - them (and the other curated KGX carryovers) so the agent is scored on **its own** decisions. +!!! note "`effect_size` / `effect_type` / `approval_ids` are exempt" + Tablassert emits `effect_size` / `effect_type` deliberately, pending + [biolink-model#1774](https://github.com/biolink/biolink-model/pull/1774), and emits + `approval_ids` as a translator-ingest pass-through. The installed model declares none of + these on `Association`, so a strict check rejects edges carrying them. `biolink_valid_pct` + exempts them (and the other curated KGX carryovers) so the agent is scored on **its own** + decisions. The exempt set is *derived* — `TABLASERT_EDGE_EXTRAS - ` — so it empties itself when the model catches up, with no code change. diff --git a/docs/cli.md b/docs/cli.md index 86d9103..12e4874 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -253,7 +253,8 @@ a pass. Edges carrying `effect_size` / `effect_type` are reported invalid until a `biolink-model` release ships [#1774](https://github.com/biolink/biolink-model/pull/1774), because 4.4.3 declares neither on -`Association`. Those are counted separately as *pending* rather than treated as defects: +`Association`. The translator-ingest `approval_ids` pass-through is also intentionally absent from +that model. These curated fields are counted separately as *pending* rather than treated as defects: ```text edges: 1200000/2000085 valid (800085 failures; 800085 pending biolink-model support) diff --git a/docs/configuration/table.md b/docs/configuration/table.md index 6f1abf3..17ebff2 100644 --- a/docs/configuration/table.md +++ b/docs/configuration/table.md @@ -470,6 +470,7 @@ annotations: - {annotation: supporting_study_size, method: value, encoding: 450} # Attached to no class -> inlined supporting study (see below) - {annotation: multiple_testing_correction_method, method: value, encoding: "Benjamini Hochberg"} - {annotation: has_evidence, method: column, encoding: E, split_by: "|"} # Multivalued -> a per-row JSON array + - {annotation: approval_ids, method: column, encoding: F} # Curated pass-through -> emitted verbatim as a scalar (e.g. "011111|022222") # Descriptive name of your choice — folded into `supporting_text` on output. - annotation: log2fc_relative_to_vehicle_control @@ -481,7 +482,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`, `primary_knowledge_source`, `supporting_text`, `publications`, `effect_size`, `effect_type`, qualifier slots like `severity_qualifier` / `disease_context_qualifier`) are written to edges verbatim. +- **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`, `primary_knowledge_source`, `supporting_text`, `publications`, `effect_size`, `effect_type`, `approval_ids`, qualifier slots like `severity_qualifier` / `disease_context_qualifier`) are written to edges verbatim. `approval_ids` (FDA application numbers, following the DAKP translator-ingest precedent) is deliberately a **scalar pass-through**: a pipe-joined cell such as `011111|022222` is emitted verbatim as its own top-level edge field — no `split_by`, not a JSON array. - **Unsatisfiable slots** — names the Biolink LinkML schema declares but attaches to **no** Pydantic class: `supporting_study_size`, `sample_size`, `relationship_strength`, `statistical_significance_qualifier`, and the other `supporting_study_*` slots. A record carrying one could never validate, so their values are routed onto the edge's **inlined supporting study** (`has_supporting_studies` → `Study` → `StudyResult`, the COHD/ICEES pattern) rather than emitted as edge fields. Declaring one is legal and loses nothing, but Tablassert emits a `BiolinkRelocationWarning` naming where the value went. This set is derived from the *installed* `biolink-model`, so a slot leaves it automatically once a release attaches it. - **Tablassert pipeline fields** — `upstream_resource_ids`, `source_record_urls`. diff --git a/src/tablassert/agent.py b/src/tablassert/agent.py index 04e7578..fb89110 100644 --- a/src/tablassert/agent.py +++ b/src/tablassert/agent.py @@ -1211,8 +1211,9 @@ def _biolink_report(nodes: Path, edges: Path) -> dict[str, object]: Wraps ``biolink.validate_kgx`` (the same check ``tablassert validate-kgx`` runs) into the flat, JSON-safe keys ``build_and_audit`` returns, plus the ``_notes`` list the caller folds into its own. ``biolink_valid_pct`` excludes the known-pending fields Tablassert - emits on purpose (``effect_size`` / ``effect_type`` pending biolink-model#1774, the KGX - denormalized carryovers) so the scored number reflects the agent's decisions rather than + emits on purpose (``effect_size`` / ``effect_type`` pending biolink-model#1774, + ``approval_ids`` as a translator-ingest pass-through, and the KGX denormalized carryovers) + so the scored number reflects the agent's decisions rather than a deliberate gap; ``biolink_valid_pct_strict`` keeps that gap visible. Never raises: an unreadable or unparseable artifact degrades to ``None`` metrics and a @@ -2196,14 +2197,17 @@ def predicate_cheatsheet(pairs: Sequence[tuple[str, str]] = CHEATSHEET_PAIRS) -> schema but belong to NO class, so their values are rerouted into an inlined StudyResult description rather than emitted on the edge. `q_value`, `fold_change`, `z_score`, `beta` and similar are not association slots at all and are folded into `supporting_text`. Prefer - `p_value`, `adjusted_p_value`, `effect_size`, `effect_type`, `has_evidence`. + `p_value`, `adjusted_p_value`, `effect_size`, `effect_type`, `has_evidence`. For FDA + application numbers, `approval_ids` is a deliberate translator-ingest pass-through: keep + the pipe-joined value as a scalar and do not add `split_by`. - MULTIVALUED slots (`has_evidence` and friends) take a real JSON array, never a joined string: declare the annotation `{method: column, encoding: , split_by: "|"}` so each cell's delimited text splits into its own per-row array. `split_by` is the ONLY multivalued encoding — there is no literal-list method, and a scalar bound for a multivalued slot ships to consumers as one unusable "a|b|c" blob. -- `effect_size` / `effect_type` are deliberate Tablassert extras pending biolink-model#1774 and - are EXEMPT from the validity score: a `biolink_valid_pct` below 1.0 is never caused by them. +- `effect_size` / `effect_type` are deliberate Tablassert extras pending biolink-model#1774; + `approval_ids` is a deliberate translator-ingest pass-through extra. All three are EXEMPT from + the validity score: a `biolink_valid_pct` below 1.0 is never caused by these intentional fields. - QUALIFIERS: enum-ranged qualifiers take a literal TOKEN, never a CURIE (`object_direction_qualifier: increased`, not a UMLS id), and `species_context_qualifier` is disabled — never author it as a qualifier or annotation. diff --git a/src/tablassert/biolink.py b/src/tablassert/biolink.py index f7c08fe..c016069 100644 --- a/src/tablassert/biolink.py +++ b/src/tablassert/biolink.py @@ -424,6 +424,11 @@ def resolve_association_class(category: str, predicate: str) -> type[Any]: # - ``taxon`` -- a node property; no species-context edge is synthesized from it. TABLASERT_EDGE_EXTRAS: frozenset[str] = frozenset( [ + # FDA application numbers from translator-ingests (DAKP precedent): the ingest + # emits them as a pipe-joined scalar (e.g. ``"011111|022222"``) with no + # ``split_by``, so the curated extra carries the scalar to the final edge + # verbatim instead of folding it into ``supporting_text``. + "approval_ids", "broad_synonym", # PR #1774 edge attributes; absent from biolink-model 4.4.3 Association.model_fields, # so the union keeps them out of fold_unknown_to_supporting_text and they reach the @@ -585,9 +590,10 @@ class EffectTypes(str, Enum): """Curated edge extras the installed Biolink Model does not (yet) declare on any association. Tablassert emits these deliberately -- ``effect_size`` / ``effect_type`` pending -``biolink/biolink-model#1774``, plus the KGX denormalized carryovers (``synonym``, -``xref``, ``relation``, ...) -- so a Biolink class rejects them as ``extra_forbidden`` -even though the build is behaving as designed. :func:`is_pending_problem` uses this set +``biolink/biolink-model#1774``, ``approval_ids`` as a translator-ingest pass-through, plus the +KGX denormalized carryovers (``synonym``, ``xref``, ``relation``, ...) -- so a Biolink class +rejects them as ``extra_forbidden`` even though the build is behaving as designed. +:func:`is_pending_problem` uses this set to separate "Tablassert is ahead of the pinned model" from "this record is genuinely malformed", so a validity *score* is not dominated by a known, intentional gap. diff --git a/tests/test_biolink.py b/tests/test_biolink.py index 6452543..7865dce 100644 --- a/tests/test_biolink.py +++ b/tests/test_biolink.py @@ -321,6 +321,18 @@ def test_allowed_edge_fields_includes_effect_annotations() -> None: assert "effect_type" in ALLOWED_EDGE_FIELDS +def test_allowed_edge_fields_includes_approval_ids() -> None: + """``approval_ids`` is an allowed edge column (translator-ingest precedent). + + The DAKP translator-ingest emits FDA application numbers as a pipe-joined scalar + (``"011111|022222"``, no ``split_by``). Folding the column into ``supporting_text`` + would bury structured approval provenance in prose, so the curated extra keeps it a + top-level edge field, preserved verbatim as a scalar string -- never a JSON array. + """ + assert "approval_ids" in TABLASERT_EDGE_EXTRAS + assert "approval_ids" in ALLOWED_EDGE_FIELDS + + def test_allowed_edge_fields_includes_subclass_only_slots() -> None: """Slots declared only by ``Association`` *subclasses* are still allowed columns. @@ -399,13 +411,14 @@ def test_known_pending_fields_are_derived_not_hardcoded() -> None: owned |= set(getattr(cls, "model_fields", {})) assert frozenset(TABLASERT_EDGE_EXTRAS) - owned == KNOWN_PENDING_EDGE_FIELDS # Today's state, asserted so the pending exemption is visibly scoped. - assert {"effect_size", "effect_type"} <= KNOWN_PENDING_EDGE_FIELDS + assert {"approval_ids", "effect_size", "effect_type"} <= KNOWN_PENDING_EDGE_FIELDS assert KNOWN_PENDING_EDGE_FIELDS <= ALLOWED_EDGE_FIELDS def test_is_pending_problem_only_exempts_extra_forbidden_pending_fields() -> None: """The exemption is narrow: a deliberate extra Biolink has not declared, and nothing else.""" assert is_pending_problem("effect_size: extra_forbidden") + assert is_pending_problem("approval_ids: extra_forbidden") # Same field, a REAL failure -> not exempt. assert not is_pending_problem("effect_size: missing") # A genuinely malformed value on a real slot -> never exempt. diff --git a/tests/test_lib.py b/tests/test_lib.py index 147901e..bc4a5d6 100644 --- a/tests/test_lib.py +++ b/tests/test_lib.py @@ -2191,12 +2191,15 @@ def test_fold_unknown_noop_when_all_allowed() -> None: "p_value": [0.01], "disease_context_qualifier": ["MONDO:0005148"], "publications": [["PMID:1"]], + # translator-ingest (DAKP) precedent: a pipe-joined scalar that must reach the + # edge verbatim, not be folded into supporting_text. + "approval_ids": ["011111|022222"], } ).lazy() out: pl.DataFrame = fold_unknown_to_supporting_text(lf).collect() # nothing folded, no supporting_text column created assert "supporting_text" not in out.columns - assert set(out.columns) == {"subject", "object", "predicate", "p_value", "disease_context_qualifier", "publications"} + assert set(out.columns) == {"subject", "object", "predicate", "p_value", "disease_context_qualifier", "publications", "approval_ids"} def test_fold_unknown_single_column() -> None: @@ -2386,6 +2389,50 @@ def test_compile_graph_folds_unknown_annotations_into_supporting_text(monkeypatc assert "PMID:1" in edges +def test_compile_graph_passes_approval_ids_through_verbatim(monkeypatch: Any, tmp_path: Path, rig_factory: Any) -> None: + """``approval_ids`` reaches the final edges as its own top-level scalar field. + + WHY: the DAKP translator-ingest emits FDA application numbers as a pipe-joined scalar + (``"011111|022222"``, no ``split_by``). Folding the column into ``supporting_text`` + would bury structured approval provenance in prose, so the curated edge extra must + carry the value verbatim into the NDJSON -- still a scalar string, never a JSON array. + """ + monkeypatch.chdir(tmp_path) + sub: Path = tmp_path / "sub.parquet" + pl.DataFrame( + { + "subject": ["A"], + "subject_category": ["gene"], + "subject_pre_resolution": ["A"], + "object": ["X"], + "object_category": ["disease"], + "object_pre_resolution": ["X"], + "predicate": ["biolink:related_to"], + "knowledge_level": ["knowledge_assertion"], + "agent_type": ["manual_agent"], + "primary_knowledge_source": ["infores:approval-kg"], + "sources": [ + [ + { + "id": "infores:approval-kg", + "resource_id": "infores:approval-kg", + "resource_role": "primary_knowledge_source", + "source_record_urls": ["https://example.org/approval.tsv"], + } + ] + ], + "approval_ids": ["011111|022222"], + "publications": [["PMID:1"]], + } + ).write_parquet(sub) + lib.compile_graph([sub], "approval", "1.0.0", rig_factory(tmp_path, infores_id="infores:approval-kg")) + record: dict[str, Any] = json.loads((tmp_path / "approval_1.0.0.edges.ndjson").read_text().strip()) + # Own top-level field, pipe-joined scalar preserved verbatim (NOT a one-element list). + assert record["approval_ids"] == "011111|022222" + # And never folded into supporting_text as an "approval_ids: " string. + assert not any(entry.startswith("approval_ids") for entry in record.get("supporting_text", [])) + + def test_compile_subgraph_e2e_value_encoded_nodes(monkeypatch: Any, tmp_path: Path) -> None: """compile_subgraph resolves value-encoded subject/object nodes into parquet output. @@ -2885,3 +2932,19 @@ def test_prune_to_class_mixed_class_qualifier_does_not_crash_and_rescues() -> No out: pl.DataFrame = prune_to_class(lf).collect() assert out["anatomical_context_qualifier"].to_list() == [None, ["UBERON:0001556"]] assert out[PRUNED_COLUMN].to_list() == [["anatomical_context_qualifier=UBERON:0001555"], []] + + +def test_prune_to_class_preserves_classless_approval_ids() -> None: + """A curated edge extra with no association-class owner survives per-row class pruning. + + WHY: ``approval_ids`` is allow-listed so the finalizer can emit DAKP's pipe-joined scalar, + but no Biolink association class declares it. The class-pruning pass must therefore leave it + untouched for the later allow-list/final-output stages rather than nulling or rescuing it. + """ + from tablassert.lib import prune_to_class + + lf: pl.LazyFrame = pl.DataFrame( + {"category": [["biolink:Association"], ["biolink:GeneToDiseaseAssociation"]], "approval_ids": ["011111|022222", "033333"]} + ).lazy() + out: pl.DataFrame = prune_to_class(lf).collect() + assert out["approval_ids"].to_list() == ["011111|022222", "033333"] diff --git a/tests/test_models.py b/tests/test_models.py index e671165..e06f241 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -825,11 +825,13 @@ def test_annotation_warns_when_the_slot_cannot_reach_the_edge() -> None: # Not an association slot at all, and no statistical coercion claims it -> folded into supporting_text. with pytest.warns(BiolinkRelocationWarning, match="folded into `supporting_text`"): Annotation.model_validate({"annotation": "overlap", "method": "column", "encoding": "E"}) - # Real association slots, the deliberate pending extras, and aliases the coercions rename to a - # canonical slot (the pipeline emits those on the edge) are silent. + # Real association slots, the deliberate pending extras (``effect_size`` / ``effect_type`` + # awaiting biolink-model#1774, ``approval_ids`` as the translator-ingest pipe-joined + # scalar pass-through), and aliases the coercions rename to a canonical slot (the + # pipeline emits those on the edge) are silent. with warnings.catch_warnings(): warnings.simplefilter("error", BiolinkRelocationWarning) - for name in ("p_value", "adjusted_p_value", "effect_size", "effect_type", "adjusted p value", "odds ratio", "q_value"): + for name in ("p_value", "adjusted_p_value", "effect_size", "effect_type", "approval_ids", "adjusted p value", "odds ratio", "q_value"): Annotation.model_validate({"annotation": name, "method": "column", "encoding": "C"})