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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions docs/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 - <fields any association declares>` — so it
empties itself when the model catches up, with no code change.

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

Expand Down
14 changes: 9 additions & 5 deletions src/tablassert/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: <letter>, 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.
Expand Down
12 changes: 9 additions & 3 deletions src/tablassert/biolink.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
15 changes: 14 additions & 1 deletion tests/test_biolink.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
65 changes: 64 additions & 1 deletion tests/test_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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: <value>" 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.

Expand Down Expand Up @@ -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"]
8 changes: 5 additions & 3 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})


Expand Down
Loading