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.
Files changed (37) hide show
  1. {tablassert-13.0.0 → tablassert-15.0.0}/PKG-INFO +1 -1
  2. {tablassert-13.0.0 → tablassert-15.0.0}/pyproject.toml +1 -1
  3. {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/agent.py +6 -12
  4. {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/biolink.py +39 -10
  5. {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/coerce.py +95 -13
  6. {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/errors.py +1 -0
  7. {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/lib.py +107 -15
  8. {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/models.py +146 -3
  9. {tablassert-13.0.0 → tablassert-15.0.0}/LICENSE +0 -0
  10. {tablassert-13.0.0 → tablassert-15.0.0}/README.md +0 -0
  11. {tablassert-13.0.0 → tablassert-15.0.0}/rust/Cargo.lock +0 -0
  12. {tablassert-13.0.0 → tablassert-15.0.0}/rust/Cargo.toml +0 -0
  13. {tablassert-13.0.0 → tablassert-15.0.0}/rust/examples/count_tables.rs +0 -0
  14. {tablassert-13.0.0 → tablassert-15.0.0}/rust/src/fullmap.rs +0 -0
  15. {tablassert-13.0.0 → tablassert-15.0.0}/rust/src/json.rs +0 -0
  16. {tablassert-13.0.0 → tablassert-15.0.0}/rust/src/lib.rs +0 -0
  17. {tablassert-13.0.0 → tablassert-15.0.0}/rust/src/ndjson.rs +0 -0
  18. {tablassert-13.0.0 → tablassert-15.0.0}/rust/src/uuid.rs +0 -0
  19. {tablassert-13.0.0 → tablassert-15.0.0}/rust/tests/build_golden.rs +0 -0
  20. {tablassert-13.0.0 → tablassert-15.0.0}/rust/tests/common/mod.rs +0 -0
  21. {tablassert-13.0.0 → tablassert-15.0.0}/rust/tests/extract_prebuilt.rs +0 -0
  22. {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/__init__.py +0 -0
  23. {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/_lazy.py +0 -0
  24. {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/cli.py +0 -0
  25. {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/enums.py +0 -0
  26. {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/extras.py +0 -0
  27. {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/fullmap.py +0 -0
  28. {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/graph_target.py +0 -0
  29. {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/ingests.py +0 -0
  30. {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/log.py +0 -0
  31. {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/nlp.py +0 -0
  32. {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/progress.py +0 -0
  33. {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/qc.py +0 -0
  34. {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/rig.py +0 -0
  35. {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/rs.pyi +0 -0
  36. {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/study.py +0 -0
  37. {tablassert-13.0.0 → tablassert-15.0.0}/src/tablassert/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tablassert
3
- Version: 13.0.0
3
+ Version: 15.0.0
4
4
  Classifier: License :: OSI Approved :: Apache Software License
5
5
  Classifier: Development Status :: 5 - Production/Stable
6
6
  Classifier: Intended Audience :: Science/Research
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tablassert"
3
- version = "13.0.0"
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 (``approval_ids`` as a translator-ingest pass-through and the KGX
1303
- denormalized carryovers -- the set is derived, and emptied itself of ``effect_size`` /
1304
- ``effect_type`` when biolink-model 4.4.4 shipped them as real Association slots) so the
1305
- scored number reflects the agent's decisions rather than a deliberate gap;
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`. For FDA application numbers, `approval_ids` is a deliberate
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 -- ``approval_ids`` as a translator-ingest pass-through,
578
- plus the KGX denormalized carryovers (``synonym``, ``xref``, ``relation``, ...) -- so a Biolink
579
- class rejects them as ``extra_forbidden`` even though the build is behaving as designed. The
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 curated ``approval_ids`` pass-through is not mistaken for a
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
- reference: str = preferred.replace("_", " ")
70
- # An existing canonical column always wins; fuzzy ranking only picks among aliases
71
- # (same rule as ``coerce_pvalue_columns``).
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
- reference: str = target.replace("_", " ")
242
- # An existing canonical column always wins; fuzzy ranking only picks among aliases.
243
- chosen: str = target if target in candidates else max(candidates, key=lambda c: fuzz.ratio(c, reference))
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
- return lf.rename(renames) if renames else lf
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 ----------------------------------------------------
@@ -22,6 +22,7 @@ TablassertErrorCodes = Literal[
22
22
  "encoding-bad-remove-entry",
23
23
  "graph-bad-infores",
24
24
  "override-bad-publication",
25
+ "override-bad-sources",
25
26
  "override-bad-upstream-infores",
26
27
  "override-bad-upstream-urls",
27
28
  "provenance-bad-pmc-id",
@@ -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
- return lf.with_columns(pl.concat_list(pl.concat_str([sr, pl.lit("|"), or_]).replace_strict(edge_lookup, default=default)).alias("category"))
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 five fields so that :func:`retrieval_sources` can
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, primary: str, upstream: list[str], urls: list[str], upstream_urls: dict[str, list[str]] | None = None
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 upstream_urls is not None:
590
- entries: list[pl.Expr] = [_retrieval_source(primary, "primary_knowledge_source", upstream)]
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.lower()), *([(split_list, (x.annotation.lower(), x.split_by))] if x.split_by else [])]
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
- (retrieval_sources, (primary_knowledge_source, upstream_ids, [str(u) for u in self.source.url], upstream_urls))
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
- always derives from the graph-level ``rig.source_info.infores_id``; manual
476
- infores CURIEs belong in ``upstream_resource_ids``.
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
- return annotation.lower().strip()
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