tablassert 13.0.0__tar.gz → 15.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tablassert-13.0.0 → tablassert-15.0.0}/PKG-INFO +1 -1
- {tablassert-13.0.0 → tablassert-15.0.0}/pyproject.toml +1 -1
- {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/agent.py +6 -12
- {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/biolink.py +39 -10
- {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/coerce.py +95 -13
- {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/errors.py +1 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/lib.py +107 -15
- {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/models.py +146 -3
- {tablassert-13.0.0 → tablassert-15.0.0}/LICENSE +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/README.md +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/rust/Cargo.lock +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/rust/Cargo.toml +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/rust/examples/count_tables.rs +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/rust/src/fullmap.rs +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/rust/src/json.rs +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/rust/src/lib.rs +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/rust/src/ndjson.rs +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/rust/src/uuid.rs +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/rust/tests/build_golden.rs +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/rust/tests/common/mod.rs +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/rust/tests/extract_prebuilt.rs +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/__init__.py +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/_lazy.py +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/cli.py +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/enums.py +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/extras.py +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/fullmap.py +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/graph_target.py +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/ingests.py +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/log.py +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/nlp.py +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/progress.py +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/qc.py +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/rig.py +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/rs.pyi +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/study.py +0 -0
- {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "tablassert"
|
|
3
|
-
version = "
|
|
3
|
+
version = "15.0.0"
|
|
4
4
|
description = "Extract knowledge assertions from tabular data into NCATS Translator-compliant KGX NDJSON — declaratively, with entity resolution and quality control built in."
|
|
5
5
|
authors = [
|
|
6
6
|
{ name = "Skye Lane Goetz", email = "sgoetz@isbscience.org" }
|
|
@@ -1299,11 +1299,11 @@ def _biolink_report(nodes: Path, edges: Path) -> dict[str, object]:
|
|
|
1299
1299
|
Wraps ``biolink.validate_kgx`` (the same check ``tablassert validate-kgx`` runs) into the
|
|
1300
1300
|
flat, JSON-safe keys ``build_and_audit`` returns, plus the ``_notes`` list the caller
|
|
1301
1301
|
folds into its own. ``biolink_valid_pct`` excludes the known-pending fields Tablassert
|
|
1302
|
-
emits on purpose (
|
|
1303
|
-
|
|
1304
|
-
``effect_type`` when biolink-model 4.4.4 shipped them as real
|
|
1305
|
-
scored number reflects the agent's decisions rather than a
|
|
1306
|
-
``biolink_valid_pct_strict`` keeps that gap visible.
|
|
1302
|
+
emits on purpose (the KGX denormalized carryovers -- the set is derived, and emptied
|
|
1303
|
+
itself of ``approval_ids`` when the curated pass-through override was removed and of
|
|
1304
|
+
``effect_size`` / ``effect_type`` when biolink-model 4.4.4 shipped them as real
|
|
1305
|
+
Association slots) so the scored number reflects the agent's decisions rather than a
|
|
1306
|
+
deliberate gap; ``biolink_valid_pct_strict`` keeps that gap visible.
|
|
1307
1307
|
|
|
1308
1308
|
Never raises: an unreadable or unparseable artifact degrades to ``None`` metrics and a
|
|
1309
1309
|
note, exactly like the coverage measurement above it.
|
|
@@ -2349,19 +2349,13 @@ qualifier and evidence slot the specific class declared. build_and_audit reports
|
|
|
2349
2349
|
`effect_type`), `q value` / `padj` -> `adjusted_p_value`. Names nothing claims
|
|
2350
2350
|
(`fold_change` alone, `z_score`, `lfsr`, `standard_error`, free-form notes) are folded
|
|
2351
2351
|
into `supporting_text`. Prefer `p_value`, `adjusted_p_value`, `effect_size`,
|
|
2352
|
-
`effect_type`, `has_evidence`.
|
|
2353
|
-
translator-ingest pass-through: keep the pipe-joined value as a scalar and do not add
|
|
2354
|
-
`split_by`.
|
|
2352
|
+
`effect_type`, `has_evidence`.
|
|
2355
2353
|
- MULTIVALUED slots (`has_evidence` and friends) take a real JSON array, never a joined string:
|
|
2356
2354
|
`split_by` is the ONLY multivalued encoding — there is no literal-list method. INSPECT the
|
|
2357
2355
|
column's cells first (read_table shows them); the separator they ACTUALLY use — `|`, `,`, or
|
|
2358
2356
|
`;` — is the one you declare: `{method: column, encoding: <letter>, split_by: "<separator>"}`.
|
|
2359
2357
|
A SINGLE-value cell gets NO `split_by`: its scalar wraps into a one-element array, the correct
|
|
2360
2358
|
shape. Cells that DO join multiple values but OMIT `split_by` ship as one unusable joined blob.
|
|
2361
|
-
- `approval_ids` is a deliberate translator-ingest pass-through extra, EXEMPT from the
|
|
2362
|
-
validity score: a `biolink_valid_pct` below 1.0 is never caused by it. (`effect_size` /
|
|
2363
|
-
`effect_type` were exempt only until biolink-model 4.4.4 shipped them as real Association
|
|
2364
|
-
slots; they now validate like any other slot.)
|
|
2365
2359
|
- QUALIFIERS: enum-ranged qualifiers take a literal TOKEN, never a CURIE
|
|
2366
2360
|
(`object_direction_qualifier: increased`, not a UMLS id), and `species_context_qualifier` is
|
|
2367
2361
|
disabled — never author it as a qualifier or annotation.
|
|
@@ -396,11 +396,6 @@ def resolve_association_class(category: str, predicate: str) -> type[Any]:
|
|
|
396
396
|
# - ``taxon`` -- a node property; no species-context edge is synthesized from it.
|
|
397
397
|
TABLASERT_EDGE_EXTRAS: frozenset[str] = frozenset(
|
|
398
398
|
[
|
|
399
|
-
# FDA application numbers from translator-ingests (DAKP precedent): the ingest
|
|
400
|
-
# emits them as a pipe-joined scalar (e.g. ``"011111|022222"``) with no
|
|
401
|
-
# ``split_by``, so the curated extra carries the scalar to the final edge
|
|
402
|
-
# verbatim instead of folding it into ``supporting_text``.
|
|
403
|
-
"approval_ids",
|
|
404
399
|
"broad_synonym",
|
|
405
400
|
"equivalent_identifiers",
|
|
406
401
|
"evidence_direction",
|
|
@@ -574,9 +569,9 @@ against the resolved class is done by ``lib.prune_to_class()``.
|
|
|
574
569
|
KNOWN_PENDING_EDGE_FIELDS: frozenset[str] = TABLASERT_EDGE_EXTRAS - frozenset(_association_model_fields())
|
|
575
570
|
"""Curated edge extras the installed Biolink Model does not (yet) declare on any association.
|
|
576
571
|
|
|
577
|
-
Tablassert emits these deliberately --
|
|
578
|
-
|
|
579
|
-
|
|
572
|
+
Tablassert emits these deliberately -- the KGX denormalized carryovers (``synonym``,
|
|
573
|
+
``xref``, ``relation``, ...) -- so a Biolink class rejects them as ``extra_forbidden`` even
|
|
574
|
+
though the build is behaving as designed. The
|
|
580
575
|
statistical ``effect_size`` / ``effect_type`` pair is no longer pending since
|
|
581
576
|
biolink-model 4.4.4 shipped PR #1774.
|
|
582
577
|
:func:`is_pending_problem` uses this set
|
|
@@ -643,6 +638,40 @@ def is_multivalued(cls: type[Any], field: str) -> bool:
|
|
|
643
638
|
return any(get_origin(arg) is list for arg in get_args(annotation))
|
|
644
639
|
|
|
645
640
|
|
|
641
|
+
@cache
|
|
642
|
+
def _retrieval_source_id_required() -> bool:
|
|
643
|
+
"""Whether the installed model still requires the inherited source ``id`` field."""
|
|
644
|
+
retrieval_source: Any = getattr(_bm, "RetrievalSource", None)
|
|
645
|
+
id_field: Any = getattr(retrieval_source, "model_fields", {}).get("id")
|
|
646
|
+
return id_field is not None and id_field.is_required()
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _validation_record(record: dict[str, Any], *, edge: bool) -> dict[str, Any]:
|
|
650
|
+
"""Add only in-memory compatibility aliases needed by the installed Biolink model.
|
|
651
|
+
|
|
652
|
+
``RetrievalSource`` in the currently pinned model still requires the inherited
|
|
653
|
+
``Entity.id`` even though ``resource_id`` is the canonical provenance identifier
|
|
654
|
+
emitted by Tablassert. The alias is used solely for Pydantic validation; it never
|
|
655
|
+
changes the decoded KGX record or the files written by the pipeline.
|
|
656
|
+
"""
|
|
657
|
+
if not edge:
|
|
658
|
+
return record
|
|
659
|
+
if not _retrieval_source_id_required():
|
|
660
|
+
return record
|
|
661
|
+
sources: Any = record.get("sources")
|
|
662
|
+
if not isinstance(sources, list):
|
|
663
|
+
return record
|
|
664
|
+
normalized: list[Any] = []
|
|
665
|
+
changed: bool = False
|
|
666
|
+
for source in sources:
|
|
667
|
+
if isinstance(source, dict) and "id" not in source and "resource_id" in source:
|
|
668
|
+
normalized.append({**source, "id": source["resource_id"]})
|
|
669
|
+
changed = True
|
|
670
|
+
else:
|
|
671
|
+
normalized.append(source)
|
|
672
|
+
return {**record, "sources": normalized} if changed else record
|
|
673
|
+
|
|
674
|
+
|
|
646
675
|
def validate_record(record: dict[str, Any], *, edge: bool) -> list[str]:
|
|
647
676
|
"""Validate one KGX record against the Biolink class named by its ``category``.
|
|
648
677
|
|
|
@@ -659,7 +688,7 @@ def validate_record(record: dict[str, Any], *, edge: bool) -> list[str]:
|
|
|
659
688
|
category: str = categories[0] if isinstance(categories, list) and categories else str(categories or "")
|
|
660
689
|
cls: type[Any] = association_class(category) if edge else node_class(category)
|
|
661
690
|
try:
|
|
662
|
-
cls(**record)
|
|
691
|
+
cls(**_validation_record(record, edge=edge))
|
|
663
692
|
except ValidationError as error:
|
|
664
693
|
return [f"{'.'.join(str(part) for part in item['loc']) or '?'}: {item['type']}" for item in error.errors()]
|
|
665
694
|
return []
|
|
@@ -731,7 +760,7 @@ def validate_kgx(nodes_path: Path, edges_path: Path, limit: int = 20) -> dict[st
|
|
|
731
760
|
Two pass rates are reported. ``valid`` is strict and drives ``ok`` (the CLI's
|
|
732
761
|
non-zero exit). ``valid_excluding_pending`` additionally counts records whose *every*
|
|
733
762
|
failure is a :func:`is_pending_problem` -- the score to optimize against, so a
|
|
734
|
-
deliberate gap like the
|
|
763
|
+
deliberate gap like the KGX denormalized carryovers is not mistaken for a
|
|
735
764
|
malformed record. The two converge as future model releases absorb curated extras.
|
|
736
765
|
|
|
737
766
|
Args:
|
|
@@ -48,8 +48,6 @@ def sig(lf: pl.LazyFrame, col: str = "p_value", out: str = "statistical_signific
|
|
|
48
48
|
null p-value → null qualifier. The qualifier may only be set when
|
|
49
49
|
``p_value``/``adjusted_p_value`` is populated (Biolink class rule).
|
|
50
50
|
"""
|
|
51
|
-
from rapidfuzz import fuzz
|
|
52
|
-
|
|
53
51
|
names: list[str] = lf.collect_schema().names()
|
|
54
52
|
# Same rigorous classification as ``coerce_pvalue_columns``: a column is a significance
|
|
55
53
|
# source only when ``pvalue_target`` accepts it — NOT by naive substring — so non-p-value
|
|
@@ -66,11 +64,14 @@ def sig(lf: pl.LazyFrame, col: str = "p_value", out: str = "statistical_signific
|
|
|
66
64
|
# bucket is present. Raw p-value is the canonical significance source; adjusted is the fallback.
|
|
67
65
|
preferred: str = col if col in buckets else next(iter(buckets))
|
|
68
66
|
candidates: list[str] = buckets[preferred]
|
|
69
|
-
|
|
70
|
-
#
|
|
71
|
-
|
|
72
|
-
chosen: str = preferred if preferred in candidates else max(candidates, key=lambda c: fuzz.ratio(c, reference))
|
|
67
|
+
# Same selection rule as ``coerce_pvalue_columns``: canonical wins, then a
|
|
68
|
+
# raw candidate beats a -log10 alias, then fuzzy ranking among what is left.
|
|
69
|
+
chosen: str = _best_candidate(candidates, preferred, preferred)
|
|
73
70
|
expr: pl.Expr = pl.col(chosen).cast(pl.Float64, strict=False)
|
|
71
|
+
# A -log10(p) score column must be un-logged before banding, or the bands
|
|
72
|
+
# invert (a score of 8 means p = 1e-8, not p = 8.0 -> not_significant).
|
|
73
|
+
if is_neglog10_column(chosen):
|
|
74
|
+
expr = _unlog10(expr)
|
|
74
75
|
band: pl.Expr = (
|
|
75
76
|
pl.when(expr.is_null())
|
|
76
77
|
.then(pl.lit(None, dtype=pl.String))
|
|
@@ -173,6 +174,54 @@ SIGNIFICANCE_FLAG_PATTERN: re.Pattern[str] = re.compile(
|
|
|
173
174
|
""",
|
|
174
175
|
re.IGNORECASE | re.VERBOSE,
|
|
175
176
|
)
|
|
177
|
+
# A -log10(p/q) score column: an explicit negation marker (negative / negated /
|
|
178
|
+
# neg / -) before a log/log10 p-or-q token ("negative log p value" — the
|
|
179
|
+
# mokg-v12 HOYER1 spelling, "negative log10 p value", "-log10(p)",
|
|
180
|
+
# "neg log10 q value"). The trailing token must be a *complete* p/q-value
|
|
181
|
+
# token (or a bare delimited P), so the [pq] cannot swallow the first letter
|
|
182
|
+
# of an unrelated word ("negative log protein" stays out). A plain
|
|
183
|
+
# "log10 p value" without a negation marker does NOT match: the sign
|
|
184
|
+
# convention is ambiguous there, so those columns keep riding verbatim rather
|
|
185
|
+
# than being un-logged on a guess.
|
|
186
|
+
NEGLOG10_PVALUE_PATTERN: re.Pattern[str] = re.compile(
|
|
187
|
+
rf"""
|
|
188
|
+
(?<![A-Za-z0-9])
|
|
189
|
+
(?: negative | negated | neg | - )
|
|
190
|
+
[\s_.\-()]*
|
|
191
|
+
log (?: 10 )?
|
|
192
|
+
[\s_.\-()]*
|
|
193
|
+
(?:
|
|
194
|
+
[pq] {_SEP} {_VALUE} {_NUMQUAL} # complete p/q-value token ("log p value", "log q-value", "log pvalue")
|
|
195
|
+
| p (?! [A-Za-z0-9]) # bare P standing alone ("-log10(p)", "-log10 P")
|
|
196
|
+
)
|
|
197
|
+
""",
|
|
198
|
+
re.IGNORECASE | re.VERBOSE,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def is_neglog10_column(name: str) -> bool:
|
|
203
|
+
"""Return True when a column reports a -log10(p/q) score instead of the raw p/q value.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
name: Raw source column name.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
True when the name carries an explicit negation marker before a
|
|
210
|
+
log/log10 p-or-q token, else False. Plain ``log``/``log10`` spellings
|
|
211
|
+
without a marker are deliberately excluded (sign convention
|
|
212
|
+
ambiguous), as is everything without a log token at all.
|
|
213
|
+
"""
|
|
214
|
+
return bool(NEGLOG10_PVALUE_PATTERN.search(name))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _unlog10(expr: pl.Expr) -> pl.Expr:
|
|
218
|
+
"""Convert a -log10 score expression back to its original scale (10**-x).
|
|
219
|
+
|
|
220
|
+
Float64 underflow floors extreme scores at ``0.0`` — indistinguishable
|
|
221
|
+
from ``p ~ 0`` in practice, and the significance band is identical either
|
|
222
|
+
way. Nulls stay null.
|
|
223
|
+
"""
|
|
224
|
+
return pl.lit(10.0) ** (-expr)
|
|
176
225
|
|
|
177
226
|
|
|
178
227
|
def pvalue_target(name: str) -> str | None:
|
|
@@ -215,10 +264,37 @@ def pvalue_target(name: str) -> str | None:
|
|
|
215
264
|
return "adjusted_p_value" if is_adjusted else "p_value"
|
|
216
265
|
|
|
217
266
|
|
|
267
|
+
def _best_candidate(candidates: list[str], target: str, chosen: str) -> str:
|
|
268
|
+
"""Pick the column a numeric p/q-value slot should read from.
|
|
269
|
+
|
|
270
|
+
A raw (non-neglog) candidate is always preferred over a -log10 alias of
|
|
271
|
+
the same statistic: the raw column holds the p/q value itself, while the
|
|
272
|
+
alias needs un-logging. Fuzzy ranking only runs when no raw candidate
|
|
273
|
+
exists (or only neglog candidates do), so a short raw name ("P", "FDR")
|
|
274
|
+
can no longer lose to a long -log10 alias it would then be un-logged over.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
candidates: Column names bucketed onto ``target`` by ``pvalue_target``.
|
|
278
|
+
target: Canonical slot name (``p_value`` / ``adjusted_p_value``).
|
|
279
|
+
chosen: The canonical name when it is itself among the candidates
|
|
280
|
+
(the existing canonical-wins rule), else a sentinel that is not.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
The winning column name.
|
|
284
|
+
"""
|
|
285
|
+
from rapidfuzz import fuzz
|
|
286
|
+
|
|
287
|
+
pool: list[str] = [c for c in candidates if not is_neglog10_column(c)] or candidates
|
|
288
|
+
return chosen if chosen in pool else max(pool, key=lambda c: fuzz.ratio(c, target.replace("_", " ")))
|
|
289
|
+
|
|
290
|
+
|
|
218
291
|
def coerce_pvalue_columns(lf: pl.LazyFrame) -> pl.LazyFrame:
|
|
219
292
|
"""Rename p-value-like columns to Biolink KGX-compliant ``p_value`` / ``adjusted_p_value``.
|
|
220
293
|
|
|
221
294
|
Picks a single best fuzzy match per target when multiple candidates exist.
|
|
295
|
+
A chosen column that reports a -log10(p/q) score (see
|
|
296
|
+
:func:`is_neglog10_column`) is un-logged (``p = 10**-x``) as it is renamed,
|
|
297
|
+
so the slot receives the p-value the model types it as.
|
|
222
298
|
|
|
223
299
|
Args:
|
|
224
300
|
lf: Source LazyFrame.
|
|
@@ -226,9 +302,6 @@ def coerce_pvalue_columns(lf: pl.LazyFrame) -> pl.LazyFrame:
|
|
|
226
302
|
Returns:
|
|
227
303
|
LazyFrame with the chosen columns renamed (no-op if no candidates).
|
|
228
304
|
"""
|
|
229
|
-
# Picks a single best fuzzy match per target when multiple candidates exist.
|
|
230
|
-
from rapidfuzz import fuzz
|
|
231
|
-
|
|
232
305
|
names: list[str] = lf.collect_schema().names()
|
|
233
306
|
buckets: dict[str, list[str]] = {}
|
|
234
307
|
for n in names:
|
|
@@ -237,14 +310,23 @@ def coerce_pvalue_columns(lf: pl.LazyFrame) -> pl.LazyFrame:
|
|
|
237
310
|
buckets.setdefault(target, []).append(n)
|
|
238
311
|
|
|
239
312
|
renames: dict[str, str] = {}
|
|
313
|
+
unlog_targets: list[str] = []
|
|
240
314
|
for target, candidates in buckets.items():
|
|
241
|
-
|
|
242
|
-
#
|
|
243
|
-
chosen: str =
|
|
315
|
+
# An existing canonical column always wins; a raw candidate beats a
|
|
316
|
+
# -log10 alias before fuzzy ranking has any say.
|
|
317
|
+
chosen: str = _best_candidate(candidates, target, target)
|
|
244
318
|
if chosen != target:
|
|
245
319
|
renames[chosen] = target
|
|
320
|
+
# A -log10(p) score must be un-logged when it lands on the numeric slot.
|
|
321
|
+
if is_neglog10_column(chosen):
|
|
322
|
+
unlog_targets.append(target)
|
|
246
323
|
|
|
247
|
-
|
|
324
|
+
if not renames and not unlog_targets:
|
|
325
|
+
return lf
|
|
326
|
+
out: pl.LazyFrame = lf.rename(renames) if renames else lf
|
|
327
|
+
if unlog_targets:
|
|
328
|
+
out = out.with_columns([_unlog10(pl.col(t).cast(pl.Float64, strict=False)).alias(t) for t in unlog_targets])
|
|
329
|
+
return out
|
|
248
330
|
|
|
249
331
|
|
|
250
332
|
# --- Study-size fragments ----------------------------------------------------
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import math
|
|
4
5
|
import operator
|
|
5
6
|
import re
|
|
@@ -37,6 +38,7 @@ from tablassert.coerce import (
|
|
|
37
38
|
coerced_target,
|
|
38
39
|
effect_size_target,
|
|
39
40
|
effect_type_target,
|
|
41
|
+
is_neglog10_column,
|
|
40
42
|
pvalue_target,
|
|
41
43
|
sig,
|
|
42
44
|
study_size_target,
|
|
@@ -44,7 +46,7 @@ from tablassert.coerce import (
|
|
|
44
46
|
from tablassert.enums import EncodingMethods, Files, InformationResources, Repositories, Tokens
|
|
45
47
|
from tablassert.fullmap import ResolveSpec, fullmap_db_path, resolve, resolve_batch
|
|
46
48
|
from tablassert.log import cat
|
|
47
|
-
from tablassert.models import Encoding, NodeEncoding, Qualifier, RIGConfig, Section
|
|
49
|
+
from tablassert.models import EDGE_ID_PLACEHOLDER, Encoding, NodeEncoding, Qualifier, RIGConfig, Section
|
|
48
50
|
from tablassert.nlp import level_one, level_two
|
|
49
51
|
from tablassert.qc import fullmap_audit
|
|
50
52
|
from tablassert.rig import (
|
|
@@ -81,6 +83,7 @@ __all__ = [
|
|
|
81
83
|
"effect_size_target",
|
|
82
84
|
"effect_type_target",
|
|
83
85
|
"infores",
|
|
86
|
+
"is_neglog10_column",
|
|
84
87
|
"normalize_biolink_category",
|
|
85
88
|
"predicate_options",
|
|
86
89
|
"pvalue_target",
|
|
@@ -219,7 +222,7 @@ def predicate_options(subject_category: str, object_category: str) -> frozenset[
|
|
|
219
222
|
return legal_predicates(derived_edge_category(subject_category, object_category))
|
|
220
223
|
|
|
221
224
|
|
|
222
|
-
def edge_category(lf: pl.LazyFrame, predicate: str | None = None) -> pl.LazyFrame:
|
|
225
|
+
def edge_category(lf: pl.LazyFrame, predicate: str | None = None, category_override: dict[str, str] | None = None) -> pl.LazyFrame:
|
|
223
226
|
"""Add the derived ``category`` column using native polars replace operations.
|
|
224
227
|
|
|
225
228
|
The ``(subject role, object role)`` lookup alone routinely produces a category
|
|
@@ -230,10 +233,18 @@ def edge_category(lf: pl.LazyFrame, predicate: str | None = None) -> pl.LazyFram
|
|
|
230
233
|
by :func:`biolink.resolve_association_class`, which walks up the association
|
|
231
234
|
hierarchy only as far as the predicate requires.
|
|
232
235
|
|
|
236
|
+
``category_override`` pins the association class per *object* category (the
|
|
237
|
+
config's ``statement.category_override``): a row whose raw object category --
|
|
238
|
+
before the role rollup that merges e.g. ``Disease`` and ``PhenotypicFeature``
|
|
239
|
+
into one pair key -- appears in the map carries the pinned class instead of the
|
|
240
|
+
pair-derived one. Pinned classes pass through the same predicate reconciliation.
|
|
241
|
+
|
|
233
242
|
Args:
|
|
234
243
|
lf: Source LazyFrame with ``subject category`` and ``object category``
|
|
235
244
|
columns (biolink-prefixed).
|
|
236
245
|
predicate: Section predicate CURIE used to reconcile the derived category.
|
|
246
|
+
category_override: ``{object category (bare name): biolink: CURIE}`` map
|
|
247
|
+
applied per row in place of the pair lookup.
|
|
237
248
|
|
|
238
249
|
Returns:
|
|
239
250
|
LazyFrame with a new list-typed ``category`` column containing the
|
|
@@ -249,12 +260,20 @@ def edge_category(lf: pl.LazyFrame, predicate: str | None = None) -> pl.LazyFram
|
|
|
249
260
|
# small raw-category -> resolved-category remap resolved once at plan time.
|
|
250
261
|
edge_lookup = {k: f"biolink:{resolve_association_class(v, predicate).__name__}" for k, v in edge_lookup.items()}
|
|
251
262
|
default = f"biolink:{resolve_association_class(default, predicate).__name__}"
|
|
263
|
+
if category_override:
|
|
264
|
+
category_override = {k: f"biolink:{resolve_association_class(v, predicate).__name__}" for k, v in category_override.items()}
|
|
252
265
|
names: list[str] = lf.collect_schema().names()
|
|
253
266
|
subject_col: str = "subject_category" if "subject_category" in names else "subject category"
|
|
254
267
|
object_col: str = "object_category" if "object_category" in names else "object category"
|
|
255
268
|
sr: pl.Expr = pl.col(subject_col).str.replace("biolink:", "").replace(cat_role).fill_null("")
|
|
256
269
|
or_: pl.Expr = pl.col(object_col).str.replace("biolink:", "").replace(cat_role).fill_null("")
|
|
257
|
-
|
|
270
|
+
derived: pl.Expr = pl.concat_str([sr, pl.lit("|"), or_]).replace_strict(edge_lookup, default=default)
|
|
271
|
+
if category_override:
|
|
272
|
+
# Key on the RAW object category: the role rollup above collapses exactly the
|
|
273
|
+
# distinction (Disease vs PhenotypicFeature) the override exists to preserve.
|
|
274
|
+
hit: pl.Expr = pl.col(object_col).str.replace("biolink:", "").replace_strict(category_override, default=None, return_dtype=pl.String)
|
|
275
|
+
derived = pl.when(hit.is_not_null()).then(hit).otherwise(derived)
|
|
276
|
+
return lf.with_columns(pl.concat_list(derived).alias("category"))
|
|
258
277
|
|
|
259
278
|
|
|
260
279
|
def prune_to_class(lf: pl.LazyFrame) -> pl.LazyFrame:
|
|
@@ -354,16 +373,12 @@ def value(lf: pl.LazyFrame, col: str, x: object) -> pl.LazyFrame:
|
|
|
354
373
|
def _retrieval_source(resource_id: str, resource_role: str, upstream: list[str] | None = None, urls: list[str] | None = None) -> pl.Expr:
|
|
355
374
|
"""Build one ``RetrievalSource`` struct expression.
|
|
356
375
|
|
|
357
|
-
Every entry declares the same
|
|
376
|
+
Every entry declares the same four fields so that :func:`retrieval_sources` can
|
|
358
377
|
``concat_list`` them into a single ``list[struct]`` column; absent list fields are
|
|
359
378
|
typed nulls, which the Rust null-stripper removes from the emitted JSON.
|
|
360
379
|
"""
|
|
361
380
|
empty: pl.Expr = pl.lit(None, dtype=pl.List(pl.String))
|
|
362
381
|
return pl.struct(
|
|
363
|
-
# `id` duplicates `resource_id`, but RetrievalSource inherits `id` from
|
|
364
|
-
# `entity` and the generated Pydantic classes require it, so omitting it
|
|
365
|
-
# fails KGX validation. Stays until biolink-model #1706/#1731 land.
|
|
366
|
-
pl.lit(resource_id).alias("id"),
|
|
367
382
|
pl.lit(resource_id).alias("resource_id"),
|
|
368
383
|
pl.lit(resource_role).alias("resource_role"),
|
|
369
384
|
(pl.concat_list([pl.lit(x) for x in upstream]) if upstream else empty).alias("upstream_resource_ids"),
|
|
@@ -558,7 +573,12 @@ def inline_supporting_study(lf: pl.LazyFrame, study_id: str, study_name: str | N
|
|
|
558
573
|
|
|
559
574
|
|
|
560
575
|
def retrieval_sources(
|
|
561
|
-
lf: pl.LazyFrame,
|
|
576
|
+
lf: pl.LazyFrame,
|
|
577
|
+
primary: str,
|
|
578
|
+
upstream: list[str],
|
|
579
|
+
urls: list[str],
|
|
580
|
+
upstream_urls: dict[str, list[str]] | None = None,
|
|
581
|
+
explicit: list[dict[str, Any]] | None = None,
|
|
562
582
|
) -> pl.LazyFrame:
|
|
563
583
|
"""Add the Biolink ``sources`` retrieval-provenance column.
|
|
564
584
|
|
|
@@ -576,18 +596,32 @@ def retrieval_sources(
|
|
|
576
596
|
its own ``source_record_urls`` and the primary entry emits none (the primary is
|
|
577
597
|
the transforming resource, not a downloadable record).
|
|
578
598
|
|
|
599
|
+
When ``explicit`` is given, exactly those entries are emitted, in order, and
|
|
600
|
+
``primary``/``upstream``/``urls``/``upstream_urls`` are ignored. Each entry
|
|
601
|
+
template carries ``resource_id``, ``resource_role``, and optional
|
|
602
|
+
``upstream_resource_ids``/``source_record_urls``; ``source_record_urls`` values
|
|
603
|
+
may contain the literal ``{edge_id}`` placeholder, which is NOT resolved here
|
|
604
|
+
(the edge id is only assigned at the final dedup stage) but in a post-dedup
|
|
605
|
+
sweep of the final edges NDJSON.
|
|
606
|
+
|
|
579
607
|
Args:
|
|
580
608
|
lf: Source LazyFrame.
|
|
581
609
|
primary: Infores CURIE of the primary knowledge source.
|
|
582
610
|
upstream: Infores CURIEs of upstream/supporting data sources.
|
|
583
611
|
urls: Source record URLs for the primary entry (ignored when ``upstream_urls`` is set).
|
|
584
612
|
upstream_urls: Optional per-upstream source record URLs keyed by infores CURIE.
|
|
613
|
+
explicit: Optional explicit entry templates emitted verbatim, in order.
|
|
585
614
|
|
|
586
615
|
Returns:
|
|
587
616
|
LazyFrame with a ``sources`` ``list[struct]`` column appended.
|
|
588
617
|
"""
|
|
589
|
-
if
|
|
590
|
-
entries: list[pl.Expr] = [
|
|
618
|
+
if explicit is not None:
|
|
619
|
+
entries: list[pl.Expr] = [
|
|
620
|
+
_retrieval_source(entry["resource_id"], entry["resource_role"], entry.get("upstream_resource_ids"), entry.get("source_record_urls"))
|
|
621
|
+
for entry in explicit
|
|
622
|
+
]
|
|
623
|
+
elif upstream_urls is not None:
|
|
624
|
+
entries = [_retrieval_source(primary, "primary_knowledge_source", upstream)]
|
|
591
625
|
entries.extend(_retrieval_source(x, "supporting_data_source", urls=upstream_urls.get(x)) for x in upstream)
|
|
592
626
|
else:
|
|
593
627
|
entries = [_retrieval_source(primary, "primary_knowledge_source", upstream, urls)]
|
|
@@ -1159,7 +1193,7 @@ class Tcode(Section):
|
|
|
1159
1193
|
[
|
|
1160
1194
|
op
|
|
1161
1195
|
for x in self.annotations
|
|
1162
|
-
for op in [*self.encoding(x, x.annotation
|
|
1196
|
+
for op in [*self.encoding(x, x.annotation), *([(split_list, (x.annotation, x.split_by))] if x.split_by else [])]
|
|
1163
1197
|
]
|
|
1164
1198
|
if self.annotations
|
|
1165
1199
|
else None,
|
|
@@ -1257,6 +1291,12 @@ class Tcode(Section):
|
|
|
1257
1291
|
if override and override.upstream_source_record_urls is not None
|
|
1258
1292
|
else None
|
|
1259
1293
|
)
|
|
1294
|
+
# An explicit `sources` template replaces the derived primary/upstream
|
|
1295
|
+
# emission entirely; the model already forbids combining it with
|
|
1296
|
+
# `upstream_resource_ids`/`upstream_source_record_urls`.
|
|
1297
|
+
explicit_sources: list[dict[str, Any]] | None = (
|
|
1298
|
+
[entry.model_dump(exclude_none=True) for entry in override.sources] if override and override.sources is not None else None
|
|
1299
|
+
)
|
|
1260
1300
|
knowledge_level = override.knowledge_level if override else self.provenance.knowledge_level
|
|
1261
1301
|
agent_type = override.agent_type if override else self.provenance.agent_type
|
|
1262
1302
|
publication_values = override.publications if override else [publication_curie(self.provenance.repo, self.provenance.publication or "")]
|
|
@@ -1273,17 +1313,22 @@ class Tcode(Section):
|
|
|
1273
1313
|
study_name: str | None = sheet if sheet is not None else Path(self.source.local).name
|
|
1274
1314
|
if study_name == study_id:
|
|
1275
1315
|
study_name = None
|
|
1316
|
+
# `use_enum_values` stores the override's enum keys/values as plain strings.
|
|
1317
|
+
category_override: dict[str, str] | None = (
|
|
1318
|
+
{obj: f"biolink:{pinned}" for obj, pinned in self.statement.category_override.items()} if self.statement.category_override else None
|
|
1319
|
+
)
|
|
1276
1320
|
return [
|
|
1277
1321
|
(value, ("predicate", "biolink:" + self.statement.predicate)),
|
|
1278
|
-
(edge_category, ("biolink:" + self.statement.predicate,)),
|
|
1322
|
+
(edge_category, ("biolink:" + self.statement.predicate, category_override)),
|
|
1279
1323
|
(value, ("knowledge_level", knowledge_level)),
|
|
1280
1324
|
(value, ("agent_type", agent_type)),
|
|
1281
1325
|
# Retrieval provenance lives only in the nested `sources` list (Biolink
|
|
1282
1326
|
# RetrievalSource); current translator-ingests emits no flat
|
|
1283
1327
|
# `primary_knowledge_source` scalar, so neither do we. A per-upstream URL
|
|
1284
1328
|
# mapping (override.upstream_source_record_urls) re-homes the record URLs
|
|
1285
|
-
# from the primary entry onto the matching supporting entries
|
|
1286
|
-
|
|
1329
|
+
# from the primary entry onto the matching supporting entries; an explicit
|
|
1330
|
+
# `sources` template (override.sources) replaces the whole derivation.
|
|
1331
|
+
(retrieval_sources, (primary_knowledge_source, upstream_ids, [str(u) for u in self.source.url], upstream_urls, explicit_sources))
|
|
1287
1332
|
if primary_knowledge_source
|
|
1288
1333
|
else None,
|
|
1289
1334
|
(publications, (publication_values,)) if publication_values else None,
|
|
@@ -1524,6 +1569,44 @@ def dedup_stream(p_in: Path, is_edges: bool) -> None:
|
|
|
1524
1569
|
p_in.unlink()
|
|
1525
1570
|
|
|
1526
1571
|
|
|
1572
|
+
def _resolve_edge_id_placeholders(edges_path: Path) -> None:
|
|
1573
|
+
"""Resolve ``{edge_id}`` placeholders in a final edges NDJSON file.
|
|
1574
|
+
|
|
1575
|
+
The edge ``id`` is a deterministic content hash assigned by the Rust deduper
|
|
1576
|
+
after subgraphs are written, so explicit ``override.sources`` record URLs
|
|
1577
|
+
cannot embed it during the polars build: the literal placeholder is what
|
|
1578
|
+
gets hashed, and this post-dedup sweep substitutes each record's own id into
|
|
1579
|
+
every string inside every ``sources[].source_record_urls`` list. The pass is
|
|
1580
|
+
skipped entirely when no line contains the marker (cheap substring precheck,
|
|
1581
|
+
no full parse), leaving the file byte-identical.
|
|
1582
|
+
|
|
1583
|
+
Args:
|
|
1584
|
+
edges_path: Path to the deduplicated ``*.edges.ndjson`` file.
|
|
1585
|
+
|
|
1586
|
+
Returns:
|
|
1587
|
+
``None``; rewrites ``edges_path`` in place via a temp file when any
|
|
1588
|
+
placeholder was resolved.
|
|
1589
|
+
"""
|
|
1590
|
+
tmp_path: Path = edges_path.with_name(edges_path.name + ".placeholder.tmp")
|
|
1591
|
+
resolved: bool = False
|
|
1592
|
+
with edges_path.open("r", encoding="utf-8") as src, tmp_path.open("w", encoding="utf-8") as dst:
|
|
1593
|
+
for line in src:
|
|
1594
|
+
if EDGE_ID_PLACEHOLDER not in line:
|
|
1595
|
+
dst.write(line)
|
|
1596
|
+
continue
|
|
1597
|
+
record: dict[str, Any] = json.loads(line)
|
|
1598
|
+
for source in record.get("sources") or []:
|
|
1599
|
+
urls: list[str] | None = source.get("source_record_urls")
|
|
1600
|
+
if urls:
|
|
1601
|
+
source["source_record_urls"] = [url.replace(EDGE_ID_PLACEHOLDER, record["id"]) for url in urls]
|
|
1602
|
+
dst.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
1603
|
+
resolved = True
|
|
1604
|
+
if resolved:
|
|
1605
|
+
tmp_path.replace(edges_path)
|
|
1606
|
+
else:
|
|
1607
|
+
tmp_path.unlink()
|
|
1608
|
+
|
|
1609
|
+
|
|
1527
1610
|
def fold_unknown_to_supporting_text(lf: pl.LazyFrame) -> pl.LazyFrame:
|
|
1528
1611
|
"""Fold any non-Biolink edge column into ``supporting_text`` as ``col: value`` strings.
|
|
1529
1612
|
|
|
@@ -1635,6 +1718,12 @@ def _write_ndjson(
|
|
|
1635
1718
|
``compile_graph``; each commented phase boundary below is a hook point for
|
|
1636
1719
|
the US-009 ``on_phase`` progress callback.
|
|
1637
1720
|
|
|
1721
|
+
Dedup assigns each edge ``id`` as the deterministic content hash of the
|
|
1722
|
+
PRE-resolution record -- the literal ``{edge_id}`` placeholder in explicit
|
|
1723
|
+
``override.sources`` record URLs is what gets hashed -- and the placeholder
|
|
1724
|
+
sweep that follows substitutes the assigned id into the final edges file
|
|
1725
|
+
only, keeping ids deterministic.
|
|
1726
|
+
|
|
1638
1727
|
Args:
|
|
1639
1728
|
subnodes: Per-section node LazyFrames from ``_collect_subframes``.
|
|
1640
1729
|
subedges: Per-section edge LazyFrames from ``_collect_subframes``.
|
|
@@ -1669,6 +1758,9 @@ def _write_ndjson(
|
|
|
1669
1758
|
if on_phase is not None:
|
|
1670
1759
|
on_phase("dedup")
|
|
1671
1760
|
dedup_stream(edges_tmp, is_edges=True)
|
|
1761
|
+
# The deduper hashes the record WITH the literal `{edge_id}` placeholder still
|
|
1762
|
+
# in place, so edge ids stay deterministic regardless of this resolution pass.
|
|
1763
|
+
_resolve_edge_id_placeholders(edges_tmp.with_suffix(""))
|
|
1672
1764
|
dedup_stream(nodes_tmp, is_edges=False)
|
|
1673
1765
|
|
|
1674
1766
|
# Phase: rig. Summaries come from the FINAL deduplicated KGX files, and the
|
|
@@ -17,9 +17,11 @@ from tablassert.biolink import (
|
|
|
17
17
|
UNSATISFIABLE_EDGE_FIELDS,
|
|
18
18
|
AgentTypes,
|
|
19
19
|
Categories,
|
|
20
|
+
EdgeCategories,
|
|
20
21
|
KnowledgeLevels,
|
|
21
22
|
Predicates,
|
|
22
23
|
Qualifiers,
|
|
24
|
+
resolve_association_class,
|
|
23
25
|
)
|
|
24
26
|
from tablassert.coerce import coerced_target
|
|
25
27
|
from tablassert.enums import (
|
|
@@ -435,6 +437,14 @@ class Statement(TablaBase):
|
|
|
435
437
|
object: NodeEncoding = Field(..., description="Object node encoding and mapping configuration.")
|
|
436
438
|
predicate: Predicates = Field(Predicates.RELATED_TO, description="Predicate connecting subject and object nodes.")
|
|
437
439
|
qualifiers: list[Qualifier] | None = Field(None, description="Optional qualifier nodes attached to the statement.")
|
|
440
|
+
category_override: dict[Categories, EdgeCategories] | None = Field(
|
|
441
|
+
None,
|
|
442
|
+
description=(
|
|
443
|
+
"Optional per-object-category association-class override: maps a resolved object category (bare name, e.g. `Disease`) "
|
|
444
|
+
"to the association category its rows carry (e.g. `EntityToDiseaseAssociation`), used in place of the derived "
|
|
445
|
+
"(subject, object) pair lookup. Rows whose object category is absent from the map derive as before."
|
|
446
|
+
),
|
|
447
|
+
)
|
|
438
448
|
|
|
439
449
|
@model_validator(mode="after")
|
|
440
450
|
def reject_duplicate_qualifiers(self: Self) -> Self:
|
|
@@ -458,6 +468,30 @@ class Statement(TablaBase):
|
|
|
458
468
|
seen.add(key)
|
|
459
469
|
return self
|
|
460
470
|
|
|
471
|
+
@model_validator(mode="after")
|
|
472
|
+
def warn_when_an_override_is_demoted_by_the_predicate(self: Self) -> Self:
|
|
473
|
+
"""Warn when a pinned association class cannot carry the section predicate.
|
|
474
|
+
|
|
475
|
+
A ``category_override`` value still passes through
|
|
476
|
+
:func:`biolink.resolve_association_class` at build time: when the pinned class
|
|
477
|
+
restricts its ``predicate`` slot and the section predicate is not in it, the
|
|
478
|
+
emitted category silently walks up to an ancestor -- taking every subclass-only
|
|
479
|
+
slot the author pinned the class *for* with it. The value is never lost (the
|
|
480
|
+
reconciliation keeps the edge valid), so this is a warning, not an error.
|
|
481
|
+
"""
|
|
482
|
+
for obj_category, pinned in (self.category_override or {}).items():
|
|
483
|
+
# `use_enum_values` stores both sides as plain strings.
|
|
484
|
+
resolved: str = resolve_association_class(f"biolink:{pinned}", f"biolink:{self.predicate}").__name__
|
|
485
|
+
if resolved != pinned:
|
|
486
|
+
warnings.warn(
|
|
487
|
+
f"category_override pins `{pinned}` for `{obj_category}` objects, but that class does not accept "
|
|
488
|
+
f"predicate `{self.predicate}`, so those rows are emitted as `{resolved}` instead -- any slots only "
|
|
489
|
+
f"`{pinned}` declares are pruned off the edge.",
|
|
490
|
+
BiolinkRelocationWarning,
|
|
491
|
+
stacklevel=2,
|
|
492
|
+
)
|
|
493
|
+
return self
|
|
494
|
+
|
|
461
495
|
|
|
462
496
|
def validate_infores_curie(value: str, code: TablassertErrorCodes) -> str:
|
|
463
497
|
"""Validate an ``infores:`` CURIE used for Biolink knowledge-source fields."""
|
|
@@ -466,16 +500,99 @@ def validate_infores_curie(value: str, code: TablassertErrorCodes) -> str:
|
|
|
466
500
|
return value
|
|
467
501
|
|
|
468
502
|
|
|
503
|
+
EDGE_ID_PLACEHOLDER: str = "{edge_id}"
|
|
504
|
+
"""Placeholder for the final edge id inside ``override.sources`` record URLs.
|
|
505
|
+
|
|
506
|
+
The edge ``id`` is a deterministic content hash computed during the final dedup
|
|
507
|
+
stage -- after subgraphs are written -- so a per-edge URL cannot embed it during
|
|
508
|
+
the table build. The literal placeholder is emitted as-is and resolved against
|
|
509
|
+
the deduplicated ``*.edges.ndjson`` in a post-dedup sweep.
|
|
510
|
+
"""
|
|
511
|
+
|
|
512
|
+
RESOURCE_ROLES: tuple[str, ...] = ("primary_knowledge_source", "aggregator_knowledge_source", "supporting_data_source")
|
|
513
|
+
"""Valid Biolink ``ResourceRoleEnum`` values for retrieval ``sources`` entries.
|
|
514
|
+
|
|
515
|
+
Kept as literals instead of importing ``ResourceRoleEnum`` from the generated
|
|
516
|
+
``biolink_model`` Pydantic classes so config validation stays cheap; any other
|
|
517
|
+
value fails KGX validation downstream.
|
|
518
|
+
"""
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
class SourceOverride(TablaBase):
|
|
522
|
+
"""One explicit retrieval-``sources`` entry template for manual provenance.
|
|
523
|
+
|
|
524
|
+
Each entry becomes one Biolink ``RetrievalSource`` struct on the edge's
|
|
525
|
+
``sources`` list, replacing the derived primary/upstream emission entirely.
|
|
526
|
+
"""
|
|
527
|
+
|
|
528
|
+
resource_id: str = Field(description="Infores CURIE of this retrieval source entry.", examples=["infores:my-source"])
|
|
529
|
+
resource_role: str = Field(
|
|
530
|
+
description="Biolink ResourceRoleEnum value for this entry.",
|
|
531
|
+
examples=["primary_knowledge_source", "aggregator_knowledge_source", "supporting_data_source"],
|
|
532
|
+
)
|
|
533
|
+
upstream_resource_ids: list[str] | None = Field(
|
|
534
|
+
None, description="Upstream infores CURIEs carried by this entry.", examples=[["infores:my-upstream"]]
|
|
535
|
+
)
|
|
536
|
+
source_record_urls: list[str] | None = Field(
|
|
537
|
+
None,
|
|
538
|
+
description=f"Source record URLs carried by this entry; `{EDGE_ID_PLACEHOLDER}` is replaced with the final edge id after dedup.",
|
|
539
|
+
examples=[["https://example.org/edge?id={edge_id}"]],
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
@field_validator("resource_id", mode="after")
|
|
543
|
+
@classmethod
|
|
544
|
+
def infores_resource_id(cls, value: str) -> str:
|
|
545
|
+
return validate_infores_curie(value, "override-bad-sources")
|
|
546
|
+
|
|
547
|
+
@field_validator("resource_role", mode="after")
|
|
548
|
+
@classmethod
|
|
549
|
+
def biolink_resource_role(cls, value: str) -> str:
|
|
550
|
+
if value not in RESOURCE_ROLES:
|
|
551
|
+
raise TablassertValidationError(f"`resource_role` must be one of {list(RESOURCE_ROLES)}, got {value!r}.", code="override-bad-sources")
|
|
552
|
+
return value
|
|
553
|
+
|
|
554
|
+
@field_validator("upstream_resource_ids", mode="after")
|
|
555
|
+
@classmethod
|
|
556
|
+
def infores_upstream_resource_ids(cls, values: list[str] | None) -> list[str] | None:
|
|
557
|
+
if values is None:
|
|
558
|
+
return None
|
|
559
|
+
for value in values:
|
|
560
|
+
validate_infores_curie(value, "override-bad-sources")
|
|
561
|
+
return values
|
|
562
|
+
|
|
563
|
+
@field_validator("source_record_urls", mode="after")
|
|
564
|
+
@classmethod
|
|
565
|
+
def url_or_edge_id_template(cls, values: list[str] | None) -> list[str] | None:
|
|
566
|
+
# Typed as plain str (not HttpUrl) so the `{edge_id}` placeholder survives
|
|
567
|
+
# validation; after removing placeholder occurrences the rest must still be
|
|
568
|
+
# an absolute http(s) URL.
|
|
569
|
+
if values is None:
|
|
570
|
+
return None
|
|
571
|
+
for value in values:
|
|
572
|
+
stripped: str = value.replace(EDGE_ID_PLACEHOLDER, "")
|
|
573
|
+
if not stripped.startswith(("https://", "http://")):
|
|
574
|
+
raise TablassertValidationError(
|
|
575
|
+
f"`source_record_urls` entries must be http(s) URLs (optionally containing `{EDGE_ID_PLACEHOLDER}`), got {value!r}.",
|
|
576
|
+
code="override-bad-sources",
|
|
577
|
+
)
|
|
578
|
+
return values
|
|
579
|
+
|
|
580
|
+
|
|
469
581
|
class ManualProvenance(TablaBase):
|
|
470
582
|
"""Manually-specified provenance for non-PMID/PMC source graphs.
|
|
471
583
|
|
|
472
584
|
When present under :class:`Provenance`, these values replace the legacy
|
|
473
585
|
repo/publication-derived provenance while keeping the same KL/AT defaults.
|
|
474
586
|
The primary ``sources`` entry (``resource_role: primary_knowledge_source``)
|
|
475
|
-
|
|
476
|
-
infores CURIEs
|
|
587
|
+
derives from the graph-level ``rig.source_info.infores_id`` unless an
|
|
588
|
+
explicit ``sources`` template is given; manual infores CURIEs otherwise
|
|
589
|
+
belong in ``upstream_resource_ids``.
|
|
477
590
|
"""
|
|
478
591
|
|
|
592
|
+
sources: list[SourceOverride] | None = Field(
|
|
593
|
+
None,
|
|
594
|
+
description="Explicit retrieval-`sources` entry templates replacing the derived primary/upstream emission entirely; mutually exclusive with `upstream_resource_ids` and `upstream_source_record_urls`, which it subsumes.",
|
|
595
|
+
)
|
|
479
596
|
upstream_resource_ids: list[str] = Field(
|
|
480
597
|
default_factory=list,
|
|
481
598
|
description="Manual upstream source infores CURIEs emitted instead of the repo-derived source map; the sanctioned place for manual infores.",
|
|
@@ -515,6 +632,26 @@ class ManualProvenance(TablaBase):
|
|
|
515
632
|
)
|
|
516
633
|
return values
|
|
517
634
|
|
|
635
|
+
@model_validator(mode="after")
|
|
636
|
+
def sources_template_is_coherent(self: Self) -> Self:
|
|
637
|
+
if self.sources is None:
|
|
638
|
+
return self
|
|
639
|
+
if self.upstream_resource_ids or self.upstream_source_record_urls is not None:
|
|
640
|
+
raise TablassertValidationError(
|
|
641
|
+
"`sources` is mutually exclusive with `upstream_resource_ids` and `upstream_source_record_urls`; the explicit template subsumes both.",
|
|
642
|
+
code="override-bad-sources",
|
|
643
|
+
)
|
|
644
|
+
if not self.sources:
|
|
645
|
+
raise TablassertValidationError("`sources` must contain at least one entry when set.", code="override-bad-sources")
|
|
646
|
+
resource_ids: list[str] = [entry.resource_id for entry in self.sources]
|
|
647
|
+
if len(set(resource_ids)) != len(resource_ids):
|
|
648
|
+
raise TablassertValidationError("`sources` entries must have unique `resource_id` values.", code="override-bad-sources")
|
|
649
|
+
if not any(entry.resource_role in ("primary_knowledge_source", "aggregator_knowledge_source") for entry in self.sources):
|
|
650
|
+
raise TablassertValidationError(
|
|
651
|
+
"`sources` must include at least one `primary_knowledge_source` or `aggregator_knowledge_source` entry.", code="override-bad-sources"
|
|
652
|
+
)
|
|
653
|
+
return self
|
|
654
|
+
|
|
518
655
|
@model_validator(mode="after")
|
|
519
656
|
def upstream_urls_match_resource_ids(self: Self) -> Self:
|
|
520
657
|
if self.upstream_source_record_urls is None:
|
|
@@ -571,7 +708,13 @@ class Annotation(Encoding):
|
|
|
571
708
|
@field_validator("annotation", mode="after")
|
|
572
709
|
@classmethod
|
|
573
710
|
def clean_annotation(cls, annotation: str) -> str:
|
|
574
|
-
|
|
711
|
+
cleaned: str = annotation.strip()
|
|
712
|
+
lowered: str = cleaned.lower()
|
|
713
|
+
# Allow-listed slots may carry uppercase (Biolink's ``FDA_regulatory_approvals``):
|
|
714
|
+
# any casing the author declares canonicalizes onto the allow-listed spelling
|
|
715
|
+
# verbatim, so the emitted edge field preserves its case exactly instead of being
|
|
716
|
+
# lowercased into an unknown name.
|
|
717
|
+
return next((field for field in ALLOWED_EDGE_FIELDS if field.lower() == lowered), lowered)
|
|
575
718
|
|
|
576
719
|
@model_validator(mode="after")
|
|
577
720
|
def reject_disabled_annotations(self: Self) -> Self:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|