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
24 changes: 24 additions & 0 deletions docs/configuration/table.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ Defines how to extract and resolve entities.
|-------|------|----------|-------------|
| `method` | String | No | `"value"` (literal) or `"column"` (source column letters). Defaults to `"value"`. |
| `encoding` | String\|Int\|Float | Yes | Literal value or source column letters, depending on `method` |
| `no_map` | Bool | No | Skip fullmap entity resolution: the encoded value is used verbatim as the node CURIE, for data that already carries its own CURIEs. No NLP normalization, taxon constraint, or category lookup is applied; blank cells are dropped like unresolved nodes and a non-blank non-CURIE cell fails the build. Combines with nothing resolution-only (`prioritize`, `avoid`, `exclude_prefixes`, `exclude_regex`, non-default `taxon` are rejected as dead config). See [No-map mode](#no-map-mode). |
| `taxon` | PositiveInt | No | NCBI Taxon ID for filtering (e.g., `9606` for human) |
| `prioritize` | List[String] | No | Preferred Biolink categories (must be valid `Categories` enum values such as `Gene`, `Protein`) |
| `avoid` | List[String] | No | Excluded Biolink categories (must be valid `Categories` enum values). Setting `avoid` makes the column an allow-list by complement: categories the `Categories` enum cannot name (fullmap-emitted Biolink mixins; see `biolink.CATEGORY_OVERRIDES`) are dropped as well, so the guard has no silent hole |
Expand Down Expand Up @@ -258,6 +259,29 @@ subject:
encoding: A # Read from column A
```

#### No-map mode

**`no_map: true`** - Use the data's own CURIEs as-is

Most sources name entities in plain text and need entity resolution against the fullmap to turn a
mention like `BRCA1` into a CURIE. When the table already carries CURIEs (your own identifiers or
an upstream resource's), `no_map: true` skips that lookup entirely: the encoded value becomes the
node id verbatim, no NLP normalization or taxon/category filtering is applied, and the node is
emitted with the generic `biolink:NamedThing` category (so its edges derive `biolink:Association`).

```yaml
subject:
method: column
encoding: A
no_map: true # Column A already holds CURIEs like "CHEBI:41774"
```

Blank cells are dropped like unresolved nodes (an edge needs a node id), and a non-blank cell that
is not CURIE-shaped fails the build with `no-map-curie-violation`. `prefix`/`suffix` still apply,
so raw identifiers without a namespace can form CURIEs (`prefix: "CHEBI:"`). Resolution filters
(`taxon`, `prioritize`, `avoid`, `exclude_prefixes`, `exclude_regex`) are rejected alongside
`no_map` as dead config: with no lookup, nothing reads them.

At runtime those letters are converted internally to Polars column names such as `column_1`, but those internal names are not valid configuration values.

#### `split_by`
Expand Down
3 changes: 3 additions & 0 deletions src/tablassert/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ def redact_secrets(text: str, secrets: Sequence[str] = ()) -> str:
"provenance-missing-publication",
"provenance-publication-and-override",
"encoding-list-method-removed",
"no-map-dead-config",
"no-map-bad-curie",
"no-map-curie-violation",
"annotation-split-by-requires-column",
"annotation-split-by-empty",
"field-disabled",
Expand Down
95 changes: 88 additions & 7 deletions src/tablassert/lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
from tablassert.errors import TablassertError
from tablassert.fullmap import ResolveSpec, fullmap_db_path, resolve, resolve_batch
from tablassert.log import cat
from tablassert.models import EDGE_ID_PLACEHOLDER, Encoding, NodeEncoding, Qualifier, RIGConfig, Section
from tablassert.models import EDGE_ID_PLACEHOLDER, Encoding, NodeEncoding, Qualifier, RIGConfig, Section, is_curie
from tablassert.nlp import level_one, level_two
from tablassert.qc import fullmap_audit
from tablassert.rig import (
Expand Down Expand Up @@ -1215,6 +1215,65 @@ def qualifier_vocabulary_audit(lf: pl.LazyFrame, col: str, vocabulary: Iterable[
)


def curie_audit(lf: pl.LazyFrame, col: str, section: str, config_file: str) -> pl.LazyFrame:
"""Finalize a ``no_map`` node column: blank rows drop, non-CURIE values fail, category is synthesized.

Columns configured with ``no_map: true`` bypass entity resolution, so nothing else
(a) drops rows whose cell is blank (a missing node id can never form an edge, exactly
like an unresolved subject/object), (b) rejects cells that are not CURIE-shaped -- the
encoded value becomes the KGX node id verbatim, so garbage would ship as an identifier --
or (c) produces the ``<col>_category`` column downstream stages require: without a
fullmap lookup there is no role to derive, so the column is pinned to
``biolink:NamedThing`` and edge derivation degrades to ``biolink:Association`` exactly
like the documented no-role-lookup fallback in :func:`edge_category`.

Args:
lf: Source LazyFrame.
col: The no_map node column (``subject``/``object`` or a CURIE-ranged qualifier key).
section: Short section hash (for log context).
config_file: Originating config file (for log context).

Returns:
LazyFrame with blank/null-like rows dropped and ``<col>_category`` added.

Notes:
A non-blank value that is not CURIE-shaped raises a coded error naming the
offending values (mirroring ``qualifier_vocabulary_audit``); blank and null-like
cells (``""``, ``"NA"``, ...) are dropped with a loud per-column count instead,
mirroring how an unresolvable subject/object row is dropped, never emitted.
"""
values: pl.Series = lf.select(pl.col(col)).collect().get_column(col)
if values.dtype.base_type() in (pl.List, pl.Array):
values = values.explode()
blank_mask: pl.Series = values.is_null() | values.str.strip_chars().str.to_lowercase().is_in(list(_NULL_LIKE_TEXT))
# Judge CURIE shape on the distinct non-blank values only: the audit runs once per
# section, and map_elements is a per-element Python call that must not scale with
# row count when one bad cell repeats a million times.
distinct: pl.Series = values.filter(~blank_mask).unique()
offending: pl.Series = distinct.filter(~distinct.str.strip_chars().map_elements(is_curie, return_dtype=pl.Boolean)).sort()
if offending.len() > 0:
shown: list[str] = offending.head(10).to_list()
more: str = "..." if offending.len() > len(shown) else ""
raise TablassertError(
f"{col} is configured with `no_map: true` so its values are used verbatim as node CURIEs, but "
f"{offending.len()} non-blank value(s) in the built column are not CURIE-shaped: "
f"{', '.join(repr(x) for x in shown)}{more}. Fix the source table or the encoding "
"(prefix/suffix can form a CURIE from raw cells).",
code="no-map-curie-violation",
)
dropped: int = int(blank_mask.sum())
if dropped:
logger.info(
"no_map column {col} in {config} ({hash}) dropped {n} blank row(s): a missing CURIE cannot form a node",
col=col,
config=config_file,
hash=section,
n=dropped,
)
present: pl.Expr = pl.col(col).is_not_null() & ~pl.col(col).str.strip_chars().str.to_lowercase().is_in(list(_NULL_LIKE_TEXT))
return lf.filter(present).with_columns(pl.lit("biolink:NamedThing").alias(f"{col}_category"))


def to_store(lf: pl.LazyFrame, p: Path, config_name: str) -> Path:
"""Collect and write a section LazyFrame to a parquet file.

Expand Down Expand Up @@ -1369,16 +1428,25 @@ def _node_ops(self: Self, db: Path) -> list[Any]:
enabled.
"""
# Subject/object/qualifiers share one resolve_batch call instead of one per column.
# Enum-ranged qualifiers are excluded from resolution: their range is a closed
# Biolink vocabulary, so sending them through the fullmap would turn the required
# token `increased` into the CURIE `UMLS:C0205217`, which the slot rejects.
# Two kinds of columns never reach it: enum-ranged qualifiers, whose range is a
# closed Biolink vocabulary (sending them through the fullmap would turn the
# required token `increased` into the CURIE `UMLS:C0205217`, which the slot
# rejects), and no_map nodes, whose values are already CURIEs used verbatim.
qualifiers: list[Qualifier] = self.statement.qualifiers or []
subj_obj: list[tuple[NodeEncoding, str]] = [(self.statement.subject, "subject"), (self.statement.object, "object")]
node_columns: list[tuple[NodeEncoding, str]] = [
(self.statement.subject, "subject"),
(self.statement.object, "object"),
*[(x, col) for x, col in subj_obj if not x.no_map],
*[(x, x.qualifier) for x in qualifiers if x.resolved],
]
literals: list[Qualifier] = [x for x in qualifiers if not x.resolved]
# no_map subject/object plus CURIE-ranged qualifiers that opted out (a no_map
# enum-ranged qualifier is rejected at config time). Their encoded values become
# node ids verbatim: encode only, then curie_audit drops blank rows, fails on
# non-CURIE cells, and synthesizes the category column edge derivation needs.
verbatim: list[tuple[NodeEncoding, str]] = [
*[(x, col) for x, col in subj_obj if x.no_map],
*[(x, x.qualifier) for x in qualifiers if not x.resolved and x.no_map],
]
literals: list[Qualifier] = [x for x in qualifiers if not x.resolved and not x.no_map]
# A nullable qualifier keeps its edge when the cell is blank or unresolvable (the
# column stays null and the null-stripper omits the key); subject/object and
# strict qualifiers drop the row as before.
Expand All @@ -1401,6 +1469,18 @@ def _node_ops(self: Self, db: Path) -> list[Any]:
# ranged column ends with a vocabulary audit: nothing else validates its cell
# values against the closed Biolink range before KGX emission.
[[*self.encoding(x, x.qualifier), (qualifier_vocabulary_audit, (str(x.qualifier), sorted(x.vocabulary or ())))] for x in literals],
# no_map columns are encoded exactly like enum-ranged literals (no NLP
# normalization -- it exists only to feed entity resolution these columns
# never undergo), then finalized by curie_audit: blank rows dropped,
# non-CURIE cells failed loudly, and the pinned `biolink:NamedThing`
# category added for downstream edge derivation. The `<col>_pre_resolution`
# snapshot rides along because `_collect_subframes` discovers node columns
# through it at graph-compile time; without it a verbatim subject/object
# would never be split out of the edges into nodes.
[
[*self.encoding(x, col), (column, (f"{col}_pre_resolution", col)), (curie_audit, (col, self.store.stem, self.config.name))]
for x, col in verbatim
],
# Raw ``column_<n>`` columns are dead weight once the encodings above have
# copied them into named slots; trim before resolution so the frame that
# resolve_batch materializes and joins stays narrow.
Expand Down Expand Up @@ -1558,6 +1638,7 @@ def collect(self: Self, db: Path) -> list[tuple[Callable, tuple[Any]]] | Path:
fullmap_audit: "qc",
column: "encode",
qualifier_vocabulary_audit: "encode",
curie_audit: "encode",
edge_category: "edge",
publications: "provenance",
retrieval_sources: "provenance",
Expand Down
113 changes: 110 additions & 3 deletions src/tablassert/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,16 @@ def polars_compatible_replacement(cls, remove: list[int | float | str] | None) -
)


#: CURIE shape enforced on verbatim (``no_map``) node values: non-empty ``PREFIX:local-id``.
#: Shared by config-time literal validation (models) and build-time cell audits (lib).
CURIE_PATTERN: re.Pattern[str] = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]*:[A-Za-z0-9_.\-/]+$")


def is_curie(value: object) -> bool:
"""Whether ``value`` is a CURIE-shaped string (``PREFIX:local-id``, both halves non-empty)."""
return isinstance(value, str) and CURIE_PATTERN.match(value.strip()) is not None


class NodeEncoding(Encoding):
taxon: PositiveInt | None = Field(
9606,
Expand All @@ -312,6 +322,87 @@ class NodeEncoding(Encoding):
examples=[["^OMIM:\\d+$"]],
)

no_map: bool = Field(
False,
description=(
"When True, skip fullmap entity resolution: the encoded value is used verbatim as the node CURIE, "
"for data that already carries its own CURIEs. No NLP normalization, taxon constraint, or category "
"lookup is applied; blank cells are dropped like unresolved nodes and non-CURIE cells fail the build."
),
examples=[True],
)

@model_validator(mode="before")
@classmethod
def no_map_rejects_explicit_taxon(cls, data: Any) -> Any:
"""Reject ``no_map`` with an explicit non-default ``taxon`` (dead resolution config).

``taxon`` defaults to 9606 and only steers fullmap ranking, so an explicit
non-default taxon alongside ``no_map: true`` is config that can never take
effect. The default itself stays indistinguishable from an explicit 9606 and
is therefore allowed. Checked ``before`` precisely so "explicitly set" is
detectable from the raw input dict.
"""
if isinstance(data, dict) and data.get("no_map") and "taxon" in data and data["taxon"] not in (None, 9606):
raise TablassertValidationError(
f"`no_map: true` skips entity resolution, so `taxon: {data['taxon']}` can never take effect; remove the taxon constraint.",
code="no-map-dead-config",
)
return data

@model_validator(mode="after")
def no_map_rejects_resolution_knobs(self: Self) -> Self:
"""Reject ``no_map`` combined with resolution-only knobs (dead config).

``prioritize``, ``avoid``, ``exclude_prefixes``, and ``exclude_regex`` only
steer fullmap ranking and filtering; with ``no_map: true`` no lookup happens,
so these fields mislead the reader. Fail loudly at config time, like the
``nullable``-on-literal check on :class:`Qualifier`.
"""
if not self.no_map:
return self
dead: list[str] = [
name
for name, value in (
("prioritize", self.prioritize),
("avoid", self.avoid),
("exclude_prefixes", self.exclude_prefixes),
("exclude_regex", self.exclude_regex),
)
if value
]
if dead:
raise TablassertValidationError(
f"`no_map: true` skips entity resolution, so {', '.join(f'`{name}`' for name in dead)} can never take effect; remove them.",
code="no-map-dead-config",
)
return self

@model_validator(mode="after")
def no_map_literal_must_be_curie(self: Self) -> Self:
"""Validate that a ``no_map`` literal encodes a CURIE at config time.

A ``method: value`` node with ``no_map: true`` becomes the node id verbatim,
so the literal (after ``prefix``/``suffix`` are applied, matching the build's
``encoding()``) must already be CURIE-shaped; otherwise every row would be
dropped at build time. ``method: column`` cells are audited per row at build
time instead.
"""
if not self.no_map or self.method != EncodingMethods.VALUE:
return self
# Enum-ranged qualifier literals are closed-vocabulary tokens (audited against
# the vocabulary, and `no_map` on them is rejected as dead config), not CURIEs.
if getattr(self, "vocabulary", None) is not None:
return self
literal: str = f"{self.prefix or ''}{self.encoding}{self.suffix or ''}".strip()
if not is_curie(literal):
raise TablassertValidationError(
f"`no_map: true` uses the encoded value verbatim as the node CURIE, but the literal {literal!r} is not CURIE-shaped "
"(expected `PREFIX:local-id`). Fix the literal, or add `prefix`/`suffix` to form a CURIE.",
code="no-map-bad-curie",
)
return self

@field_validator("exclude_regex", mode="after")
@classmethod
def polars_compatible_exclude_regex(cls, exclude_regex: list[str] | None) -> list[str] | None:
Expand Down Expand Up @@ -359,10 +450,26 @@ def vocabulary(self: Self) -> frozenset[str] | None:
def resolved(self: Self) -> bool:
"""Whether this qualifier's values go through fullmap entity resolution.

Only CURIE-ranged qualifiers are resolved. Enum-ranged ones carry a literal
token from a closed Biolink vocabulary and must be passed through verbatim.
Enum-ranged qualifiers carry a literal token from a closed Biolink vocabulary
and must be passed through verbatim. CURIE-ranged qualifiers are resolved
unless they opt out with ``no_map: true`` (their values are already CURIEs).
"""
return self.vocabulary is None and not self.no_map

@model_validator(mode="after")
def reject_no_map_on_enum_ranged(self: Self) -> Self:
"""Reject ``no_map: true`` on enum-ranged qualifiers as dead config.

Enum-ranged qualifiers already pass their literal tokens through verbatim, so
``no_map`` would be a no-op that misleads the reader; fail loudly at config
time, like the ``nullable``-on-literal check below.
"""
return self.vocabulary is None
if self.no_map and self.vocabulary is not None:
raise TablassertValidationError(
f"{self.qualifier} has a closed Biolink vocabulary and already passes values through verbatim, so `no_map` is redundant; remove it.",
code="no-map-dead-config",
)
return self

@model_validator(mode="after")
def reject_disabled_qualifiers(self: Self) -> Self:
Expand Down
3 changes: 3 additions & 0 deletions src/tablassert/runcache.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,9 @@ def prefix_digest(ops: list[tuple[Callable, tuple[Any, ...]]]) -> str:
lib.split_list: 1,
# Vocabulary audit: one eager distinct-value pass over a single enum column.
lib.qualifier_vocabulary_audit: 1,
# CURIE audit: one eager pass over a single no_map column (distinct-value check,
# blank-row filter, category column synthesis).
lib.curie_audit: 1,
# Clean: coerce_columns applies five coercion rules (schema resolve + renames + value
# coercion) in one pass, so it outprices a single-expression clean op.
lib.coerce_columns: 3,
Expand Down
Loading
Loading