From 1121e8232eb9241e7a12de551bd01bd5d85603bf Mon Sep 17 00:00:00 2001 From: SkyeAv Date: Fri, 7 Aug 2026 14:06:13 -0700 Subject: [PATCH 1/3] feat: effect_size / effect_type edge attributes (Biolink PR #1774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the p-value/study-size coercion architecture for the merged-but-unreleased PR #1774 edge attributes (absent from pinned biolink-model 4.4.3, defined locally): - coerce.py: effect_size_target / coerce_effect_size_columns rename effect-size-like columns (effect size, ES, beta, log2FC, odds/hazard/risk ratio, correlation/rho/r-style statistic names, and the OLD relationship_strength name) to canonical effect_size. effect_type_target / coerce_effect_type_columns rename effect-type-like columns, then coerce VALUES into the 25 permissible enum values via a case/separator-insensitive alias table + rapidfuzz fuzzy fallback; values matching nothing drop to null (biolink range is the enum). Biolink class rule applied post-coercion: effect_type is nulled wherever effect_size is null/absent. - biolink.py: EFFECT_TYPE_VALUES (verbatim PR diff, close_mappings ignored) + EffectTypes str-enum via _build_str_enum with the KnowledgeLevels-style TYPE_CHECKING stub; add effect_size / effect_type to TABLASERT_EDGE_EXTRAS so they survive fold_unknown_to_supporting_text and reach final edges; drop the now-superseded relationship_strength from extras (consistent with sample_size fold behavior). - lib.py: both coercions wired into _source_ops after coerce_study_size_columns and before clean_numeric; PHASE_OF "clean"; numeric_columns exact set gains effect_size and drops the superseded sample_size / relationship_strength (coercion renames them before clean_numeric/format_numeric run). - docs + agent prompts: old annotation names moved to supporting_study_size / effect_size / effect_type in advanced-example.md, examples.md, tutorial.md, table.md, tutorial-table.yaml, agent.py INSTRUCTIONS, and optimized_instructions.yaml. - tests: +28 covering rename/best-fuzzy-pick/no-op/value-coercion/class-rule/folding and alias drift guards; existing old-name final-edge assertions moved to the new names (never weakened). Fixture config TEXT keeps old annotation names (coercion renames at build time). Verified: uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run pytest -q — all green (717 passed, 34 skipped = main's skip set); coverage on touched modules at parity or better (coerce.py/biolink.py 100%). --- docs/configuration/advanced-example.md | 19 +- docs/configuration/table.md | 8 +- docs/examples.md | 7 +- docs/examples/tutorial-table.yaml | 2 +- docs/tutorial.md | 6 +- examples/agent/optimized_instructions.yaml | 2 +- src/tablassert/agent.py | 8 +- src/tablassert/biolink.py | 62 +++- src/tablassert/coerce.py | 321 ++++++++++++++++++++- src/tablassert/lib.py | 35 ++- tests/test_biolink.py | 70 ++++- tests/test_lib.py | 307 ++++++++++++++++++-- 12 files changed, 796 insertions(+), 51 deletions(-) diff --git a/docs/configuration/advanced-example.md b/docs/configuration/advanced-example.md index ae91e5e2..70e21ce1 100644 --- a/docs/configuration/advanced-example.md +++ b/docs/configuration/advanced-example.md @@ -52,10 +52,11 @@ template: # Statistical metadata as edge annotations (method: value = constant, # method: column = per-row) annotations: - - {annotation: sample_size, method: value, encoding: 9} + - {annotation: supporting_study_size, method: value, encoding: 9} - {annotation: p_value, method: column, encoding: C} - {annotation: multiple_testing_correction_method, method: value, encoding: Benjamini Hochberg} - - {annotation: relationship_strength, method: column, encoding: B} # Spearman rho + - {annotation: effect_size, method: column, encoding: B} # Spearman rho value + - {annotation: effect_type, method: value, encoding: spearmans_rho} - {annotation: assertion_method, method: value, encoding: Spearman correlation} # Freetext catch-all for context that doesn't map to a structured annotation @@ -97,7 +98,8 @@ whole table or `method: column` to pull per-row notes from the source (see {"id":"CHEBI:41774","name":"13C-tamoxifen","category":["biolink:ChemicalEntity"]} ``` -**Edges:** Allow-listed annotation columns (`sample_size`, `p_value`, `relationship_strength`) stay as +**Edges:** Allow-listed annotation columns (`supporting_study_size`, `p_value`, `effect_size`, +`effect_type`) stay as top-level edge fields (numeric annotations as controlled-notation strings). Any non-Biolink-slot name — here `assertion_method`, `multiple_testing_correction_method`, `miscellaneous_notes` — folds into the edge's `supporting_text` list as `"name: value"` entries (sorted alphabetically), alongside the built-in @@ -109,9 +111,10 @@ edge's `supporting_text` list as `"name: value"` entries (sorted alphabetically) "subject": "NCBITaxon:47715", "predicate": "biolink:correlated_with", "object": "CHEBI:41774", - "sample_size": "9.000", + "supporting_study_size": "9.000", "p_value": "1.0000e-03", - "relationship_strength": "0.8500", + "effect_size": "0.8500", + "effect_type": "spearmans_rho", "supporting_text": [ "assertion_method: Spearman correlation", "extracted_from_row_number: 3", @@ -198,7 +201,7 @@ template: annotations: - {annotation: p_value, method: column, encoding: E} - - {annotation: relationship_strength, method: column, encoding: C} + - {annotation: effect_size, method: column, encoding: C} ``` Each column-mapped node gets its own `prioritize` list to guide disambiguation. `remove` strips each @@ -252,7 +255,7 @@ sections: source: row_slice: [2, auto] annotations: - - {annotation: relationship_strength, method: column, encoding: B} + - {annotation: effect_size, method: column, encoding: B} - statement: object: @@ -261,7 +264,7 @@ sections: source: row_slice: [2, auto] annotations: - - {annotation: relationship_strength, method: column, encoding: C} + - {annotation: effect_size, method: column, encoding: C} # ... (one section per metabolite column; pattern repeats) ``` diff --git a/docs/configuration/table.md b/docs/configuration/table.md index 81b57643..312744d9 100644 --- a/docs/configuration/table.md +++ b/docs/configuration/table.md @@ -410,14 +410,14 @@ Optional edge attributes (statistical metadata, notes, etc.). | Field | Type | Required | Description | |-------|------|----------|-------------| -| `annotation` | String | Yes | Attribute name (e.g., `"p_value"`, `"sample_size"`). Lowercased and trimmed of leading/trailing whitespace at parse time; underscores are preserved (use snake_case). | +| `annotation` | String | Yes | Attribute name (e.g., `"p_value"`, `"effect_size"`). Lowercased and trimmed of leading/trailing whitespace at parse time; underscores are preserved (use snake_case). | | (inherits Encoding) | | | All Encoding fields available (method, encoding, regex, etc.) | **Example:** ```yaml annotations: - - {annotation: p_value, method: column, encoding: C} # Read from column C - - {annotation: sample_size, method: value, encoding: 450} # Literal value for all edges + - {annotation: p_value, method: column, encoding: C} # Read from column C + - {annotation: supporting_study_size, method: value, encoding: 450} # Literal value for all edges - {annotation: multiple_testing_correction_method, method: value, encoding: "Benjamini Hochberg"} # Descriptive name of your choice — folded into `supporting_text` on output. @@ -430,7 +430,7 @@ annotations: Annotation names fall into two groups at build time: -- **Biolink-native slots** — names matching a [Biolink Association](https://biolink.github.io/biolink-model/) slot (e.g. `p_value`, `sample_size`, `knowledge_level`, `primary_knowledge_source`, `adjusted_p_value`, `supporting_text`, `publications`, the qualifier slots like `severity_qualifier` / `disease_context_qualifier`) are written to edges verbatim. +- **Allowed edge fields** — names on the edge allow-list: [Biolink Association](https://biolink.github.io/biolink-model/) slots, qualifier slots, and curated KGX/Tablassert edge fields (e.g. `p_value`, `adjusted_p_value`, `knowledge_level`, `primary_knowledge_source`, `supporting_text`, `publications`, `supporting_study_size`, `effect_size`, `effect_type`, qualifier slots like `severity_qualifier` / `disease_context_qualifier`) are written to edges verbatim. - **Tablassert pipeline fields** — `upstream_resource_ids`, `source_record_urls`. Any other annotation name is treated as **supporting context**. At the end of `compile_graph`, tablassert sweeps the edge columns: for each non-allow-listed name it emits `"name: value"` entries into the edge's `supporting_text` (a `list[str]`), then drops the original column. Behavior worth knowing: diff --git a/docs/examples.md b/docs/examples.md index 5976e69f..1a7848bb 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -127,9 +127,12 @@ template: - annotation: p_value method: column encoding: C - - annotation: relationship_strength + - annotation: effect_size method: column encoding: B + - annotation: effect_type + method: value + encoding: spearmans_rho - annotation: assertion_method method: value encoding: "Spearman correlation" @@ -241,7 +244,7 @@ template: - annotation: p_value method: column encoding: C - - annotation: sample_size + - annotation: supporting_study_size method: column encoding: D ``` diff --git a/docs/examples/tutorial-table.yaml b/docs/examples/tutorial-table.yaml index 0b982507..6387fb39 100644 --- a/docs/examples/tutorial-table.yaml +++ b/docs/examples/tutorial-table.yaml @@ -26,6 +26,6 @@ template: - annotation: p_value method: column encoding: C - - annotation: sample_size + - annotation: supporting_study_size method: column encoding: D diff --git a/docs/tutorial.md b/docs/tutorial.md index 91d2f151..5089673d 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -69,7 +69,7 @@ template: - annotation: p_value method: column encoding: C - - annotation: sample_size + - annotation: supporting_study_size method: column encoding: D ``` @@ -154,8 +154,8 @@ head -n 2 TUTORIAL_KG_1.0.0.edges.ndjson Example output (numeric annotation columns are emitted as controlled-notation strings — p-values in scientific notation): ```json -{"id":"2cfea591-0f8f-33af-a7df-03da531d3359","subject":"HGNC:11998","predicate":"biolink:associated_with","object":"MONDO:0008903","p_value":"1.0000e-03","sample_size":"450"} -{"id":"7b1c9d02-5e8a-4f3b-9c1d-2a6e8f0b4d7c","subject":"HGNC:1100","predicate":"biolink:associated_with","object":"MONDO:0005041","p_value":"1.0000e-04","sample_size":"1200"} +{"id":"2cfea591-0f8f-33af-a7df-03da531d3359","subject":"HGNC:11998","predicate":"biolink:associated_with","object":"MONDO:0008903","p_value":"1.0000e-03","supporting_study_size":"450"} +{"id":"7b1c9d02-5e8a-4f3b-9c1d-2a6e8f0b4d7c","subject":"HGNC:1100","predicate":"biolink:associated_with","object":"MONDO:0005041","p_value":"1.0000e-04","supporting_study_size":"1200"} ``` **RIG file:** diff --git a/examples/agent/optimized_instructions.yaml b/examples/agent/optimized_instructions.yaml index 60072f58..3e92eaa3 100644 --- a/examples/agent/optimized_instructions.yaml +++ b/examples/agent/optimized_instructions.yaml @@ -89,7 +89,7 @@ instructions: "# ROLE + TASK\nYou are an expert knowledge-graph (KG) engineer. Y \ name, or its caption explicitly establishes that entity for the rows. A hard-coded object applied to every\n row must\ \ be justified by the table's actual context.\n3. CAPTURE STATISTICAL ANNOTATIONS: when the table has p_value, q_value,\ \ fold_change, z_score, lfsr, beta,\n standard_error, sample_size, or similar columns, add them as annotations (annotation:\ - \ p_value / q_value /\n relationship_strength / sample_size, method: column, encoding: ). Do NOT silently drop\ + \ p_value / q_value /\n effect_size / supporting_study_size, method: column, encoding: ). Do NOT silently drop\ \ statistical\n columns — they are part of the evidence.\n4. PICK THE RIGHT OBJECT COLUMN: the object column must actually\ \ contain the intended entity. Verify with\n read_table that the column holds the entity type you claim (e.g. a protein-abundance\ \ table with UniProt\n IDs in columns A/B should map those, not a gene-symbol column elsewhere).\n5. prioritize GUIDANCE\ diff --git a/src/tablassert/agent.py b/src/tablassert/agent.py index 556dfefc..a17945dc 100644 --- a/src/tablassert/agent.py +++ b/src/tablassert/agent.py @@ -1926,8 +1926,8 @@ def call(prompt: str) -> str: mappable table/worksheet; each section supplies its OWN `source` (the table's local path + that file's source.url, plus sheet/row_slice/delimiter as needed) and its OWN `statement`. Within each section choose column-letter encodings for entity columns and literal CURIEs for fixed values; -pick a valid biolink predicate; add statistical annotations (p_value / sample_size / -relationship_strength) when that table has those columns. A single-table article is still ONE +pick a valid biolink predicate; add statistical annotations (p_value / supporting_study_size / +effect_size / effect_type) when that table has those columns. A single-table article is still ONE config with ONE section. ## ReAct workflow + planning @@ -1972,7 +1972,9 @@ def call(prompt: str) -> str: provenance: {repo: PMID, publication: "12345678"} annotations: - {annotation: p_value, method: column, encoding: C} - - {annotation: sample_size, method: column, encoding: D} + - {annotation: supporting_study_size, method: column, encoding: D} + - {annotation: effect_size, method: column, encoding: E} + - {annotation: effect_type, method: value, encoding: odds_ratio} # (b) ALAMV6 — an excel organism~chemical correlation table (fixed chemical object) source: {kind: excel, local: ./ALAM.XLSX, sheet: "all correlations", row_slice: [2, auto]} diff --git a/src/tablassert/biolink.py b/src/tablassert/biolink.py index 0fc78a88..1be3dec7 100644 --- a/src/tablassert/biolink.py +++ b/src/tablassert/biolink.py @@ -26,6 +26,9 @@ - ``ALLOWED_EDGE_FIELDS``: the Biolink ``Association`` model fields (walked over the MRO) unioned with the qualifier slot names and a curated set of KGX / Tablassert edge columns that are not Biolink Association fields. +- ``EffectTypes``: the 25 permissible ``effect_type`` values from Biolink PR + #1774 (merged), defined locally because the pinned ``biolink-model`` release + predates the PR; switch to the model's enum once it ships. The enums are built dynamically at runtime from the model. For static type checking, ``TYPE_CHECKING`` stub classes (declaring only the members referenced @@ -50,7 +53,18 @@ if TYPE_CHECKING: from linkml_runtime.utils.schemaview import SchemaView -__all__ = ["ALLOWED_EDGE_FIELDS", "BIOLINK_VERSION", "AgentTypes", "Categories", "EdgeCategories", "KnowledgeLevels", "Predicates", "Qualifiers"] +__all__ = [ + "ALLOWED_EDGE_FIELDS", + "BIOLINK_VERSION", + "EFFECT_TYPE_VALUES", + "AgentTypes", + "Categories", + "EdgeCategories", + "EffectTypes", + "KnowledgeLevels", + "Predicates", + "Qualifiers", +] def _screaming_snake(camel: str) -> str: @@ -217,6 +231,39 @@ def _biolink_enum_values(enum_cls: type[Enum]) -> list[str]: return sorted(str(member.value) for member in enum_cls) +# Permissible ``effect_type`` values, verbatim from the ``EffectTypeEnum`` permitted +# values of Biolink PR #1774 (merged, but not in the pinned biolink-model 4.4.3 +# release; close_mappings intentionally ignored). Kept as a plain tuple so +# ``coerce`` can consume it without touching the enum. +EFFECT_TYPE_VALUES: tuple[str, ...] = ( + "regression_coefficient", + "log2_fold_change", + "wald_ratio", + "inverse_variance_weighted", + "mr_egger", + "weighted_median", + "standardized_mean_difference", + "cohens_d", + "hedges_g", + "glasss_delta", + "strictly_standardized_mean_difference", + "correlation_coefficient", + "pearsons_r", + "spearmans_rho", + "kendalls_tau", + "polychoric_correlation", + "matthews_correlation_coefficient", + "goodman_kruskal_gamma", + "r2_linkage_disequilibrium", + "odds_ratio", + "relative_risk", + "hazard_ratio", + "eta_squared", + "omega_squared", + "root_mean_square_standardized_effect", +) + + # Edge columns Tablassert / KGX emit that are neither Biolink ``Association`` model # fields nor qualifier slot names: synonym carryover from NamedThing, KGX provenance # and denormalized fields, supporting-study evidence slots, and Tablassert pipeline @@ -227,6 +274,11 @@ def _biolink_enum_values(enum_cls: type[Enum]) -> list[str]: TABLASERT_EDGE_EXTRAS: frozenset[str] = frozenset( [ "broad_synonym", + # PR #1774 edge attributes; absent from biolink-model 4.4.3 Association.model_fields, + # so the union keeps them out of fold_unknown_to_supporting_text and they reach the + # final edges. Harmless once a future biolink-model ships them as real fields. + "effect_size", + "effect_type", "equivalent_identifiers", "evidence_direction", "evidence_type", @@ -237,7 +289,6 @@ def _biolink_enum_values(enum_cls: type[Enum]) -> list[str]: "provided_by", "related_synonym", "relation", - "relationship_strength", "source_record_urls", "statistical_significance_qualifier", "supporting_documents", @@ -287,6 +338,10 @@ class AgentTypes(str, Enum): DATA_ANALYSIS_PIPELINE: AgentTypes MANUAL_AGENT: AgentTypes + class EffectTypes(str, Enum): + ODDS_RATIO: EffectTypes + SPEARMANS_RHO: EffectTypes + else: Categories = _build_str_enum("Categories", _entity_category_names()) EdgeCategories = _build_str_enum("EdgeCategories", _association_names()) @@ -294,6 +349,9 @@ class AgentTypes(str, Enum): Qualifiers = _build_str_enum("Qualifiers", _qualifier_values()) KnowledgeLevels = _build_str_enum("KnowledgeLevels", _biolink_enum_values(cast("type[Enum]", _bm.KnowledgeLevelEnum))) AgentTypes = _build_str_enum("AgentTypes", _biolink_enum_values(cast("type[Enum]", _bm.AgentTypeEnum))) + # Defined locally until biolink-model ships PR #1774, then switch to + # _biolink_enum_values(_bm.EffectTypeEnum). + EffectTypes = _build_str_enum("EffectTypes", list(EFFECT_TYPE_VALUES)) _schema_definition: Any = _schema().schema diff --git a/src/tablassert/coerce.py b/src/tablassert/coerce.py index fbac3369..24a5bb73 100644 --- a/src/tablassert/coerce.py +++ b/src/tablassert/coerce.py @@ -1,7 +1,8 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING +from functools import cache, lru_cache +from typing import TYPE_CHECKING, Any from tablassert._lazy import LazyModule @@ -360,3 +361,321 @@ def coerce_study_size_columns(lf: pl.LazyFrame) -> pl.LazyFrame: if chosen == target: return lf return lf.rename({chosen: target}) + + +# --- Effect-type name fragments ---------------------------------------------- +# Labels naming WHICH statistic an effect size is expressed in. Whole-name matches +# cover bare labels ("metric", "effect type"); token matches cover qualified forms +# ("effect size type"). "_" is a word char for \b, so identifier-like names such as +# "metric_value" fall through. +EFFECT_TYPE_EXACT_PATTERN: re.Pattern[str] = re.compile( + rf""" + ^ + (?: + effect {_SEP} type + | effect {_SEP} metric + | statistic(?:al)? {_SEP} type + | metric {_SEP} type + | metric + ) + $ + """, + re.IGNORECASE | re.VERBOSE, +) +EFFECT_TYPE_TOKEN_PATTERN: re.Pattern[str] = re.compile( + rf""" + \b + (?: + effect {_SEP} type + | effect {_SEP} metric + | effect {_SEP} size {_SEP} type + | statistic(?:al)? {_SEP} type + | metric {_SEP} type + ) + \b + """, + re.IGNORECASE | re.VERBOSE, +) + +# --- Effect-size fragments ----------------------------------------------------- +# Numeric effect/statistic column labels. Whole-name matches cover bare statistic +# tokens ("ES", "OR", "HR", "beta", "rho", "r", plus the old ``relationship_strength`` +# name); token matches require a multi-word statistic label, and word-boundary +# anchoring keeps identifier columns out ("correlation_id" fails the trailing \b +# because "_" is a word char). +EFFECT_SIZE_EXACT_PATTERN: re.Pattern[str] = re.compile( + rf""" + ^ + (?: + effect {_SEP} size + | relationship {_SEP} strength + | es + | beta + | beta {_SEP} coefficients? + | log2 {_SEP} fc + | log2 {_SEP} fold {_SEP} change + | odds {_SEP} ratio + | or + | hazard {_SEP} ratio + | hr + | risk {_SEP} ratio + | correlation + | rho + | r + ) + $ + """, + re.IGNORECASE | re.VERBOSE, +) +EFFECT_SIZE_TOKEN_PATTERN: re.Pattern[str] = re.compile( + rf""" + \b + (?: + effect {_SEP} size + | relationship {_SEP} strength + | beta {_SEP} coefficients? + | log2 {_SEP} fc + | log2 {_SEP} fold {_SEP} change + | odds {_SEP} ratio + | hazard {_SEP} ratio + | risk {_SEP} ratio + | correlation {_SEP} coefficients? + | correlation + | spearman(?:s)? {_SEP} rho + | pearson(?:s)? {_SEP} r + | kendall(?:s)? {_SEP} tau + | rho + ) + \b + """, + re.IGNORECASE | re.VERBOSE, +) +# Token matches are \b-anchored like the p-value/study-size patterns: the explicit +# multi-token alternatives span separators ("spearman rho", "spearman_rho"), while +# unknown underscore-glued forms deliberately fall through. + + +def effect_type_target(name: str) -> str | None: + """Map effect-type-like column names to the canonical ``effect_type`` slot. + + Args: + name: Raw source column name. + + Returns: + ``"effect_type"`` when the name looks like an effect-type/metric label + ("effect type", "effect metric", "statistic type", "metric type", bare + "metric"), else ``None``. + """ + if EFFECT_TYPE_EXACT_PATTERN.search(name): + return "effect_type" + if EFFECT_TYPE_TOKEN_PATTERN.search(name): + return "effect_type" + return None + + +def effect_size_target(name: str) -> str | None: + """Map effect-size-like column names to the canonical ``effect_size`` slot. + + Args: + name: Raw source column name. + + Returns: + ``"effect_size"`` when the name matches any of the effect-size + patterns, else ``None``. + + Notes: + Effect-type/metric labels are categorical, not the numeric size, so + they are excluded here and belong to ``effect_type_target`` (mirroring + the significance-flag exclusion in ``pvalue_target``). The old + ``relationship_strength`` name is a candidate, so it is renamed + forward to ``effect_size``. + """ + if effect_type_target(name): + return None + if EFFECT_SIZE_EXACT_PATTERN.search(name): + return "effect_size" + if EFFECT_SIZE_TOKEN_PATTERN.search(name): + return "effect_size" + return None + + +def coerce_effect_size_columns(lf: pl.LazyFrame) -> pl.LazyFrame: + """Rename effect-size-like columns to Biolink KGX-compliant ``effect_size`` (Biolink PR #1774). + + Picks a single best fuzzy match and leaves other candidate columns + untouched. + + Args: + lf: Source LazyFrame. + + Returns: + LazyFrame with the chosen column renamed (no-op if no match). + + Notes: + The old ``relationship_strength`` name is among the candidates, so + existing configs are renamed forward to ``effect_size``. + """ + # Picks a single best fuzzy match and leaves other candidate columns untouched. + from rapidfuzz import fuzz + + names: list[str] = lf.collect_schema().names() + candidates: list[str] = [n for n in names if effect_size_target(n)] + if not candidates: + return lf + + target: str = "effect_size" + reference: str = target.replace("_", " ") + chosen: str = max(candidates, key=lambda c: fuzz.ratio(c, reference)) + if chosen == target: + return lf + return lf.rename({chosen: target}) + + +# --- Effect-type value coercion ------------------------------------------------ +# Raw value aliases -> canonical EffectTypes values (Biolink PR #1774). Keys are +# matched case/separator-insensitively: both sides are lower-cased with every +# separator/apostrophe stripped ("Cohen's d" -> "cohensd"). +_EFFECT_TYPE_ALIASES: tuple[tuple[str, str], ...] = ( + ("Cohen's d", "cohens_d"), + ("odds ratio", "odds_ratio"), + ("OR", "odds_ratio"), + ("hazard ratio", "hazard_ratio"), + ("HR", "hazard_ratio"), + ("risk ratio", "relative_risk"), + ("RR", "relative_risk"), + ("relative risk", "relative_risk"), + ("Spearman", "spearmans_rho"), + ("spearman rho", "spearmans_rho"), + ("spearman's rho", "spearmans_rho"), + ("Pearson", "pearsons_r"), + ("pearson r", "pearsons_r"), + ("Kendall", "kendalls_tau"), + ("log2FC", "log2_fold_change"), + ("log2 fold change", "log2_fold_change"), + ("beta", "regression_coefficient"), + ("regression coefficient", "regression_coefficient"), + ("SMD", "standardized_mean_difference"), + ("eta squared", "eta_squared"), + ("eta2", "eta_squared"), + ("omega squared", "omega_squared"), + ("MCC", "matthews_correlation_coefficient"), + ("matthews", "matthews_correlation_coefficient"), + ("wald", "wald_ratio"), + ("IVW", "inverse_variance_weighted"), + ("MR-Egger", "mr_egger"), + ("weighted median", "weighted_median"), + ("Glass", "glasss_delta"), + ("polychoric", "polychoric_correlation"), + ("Goodman-Kruskal", "goodman_kruskal_gamma"), + ("r2", "r2_linkage_disequilibrium"), + ("LD r2", "r2_linkage_disequilibrium"), + ("Hedges", "hedges_g"), + ("SSMD", "strictly_standardized_mean_difference"), + ("correlation coefficient", "correlation_coefficient"), +) +# Minimum fuzz.ratio for a raw value to count as a fuzzy hit against the canonical +# values; anything lower is dropped to null (the biolink range is the enum). +_EFFECT_TYPE_FUZZY_SCORE: float = 80.0 + + +def _normalize_effect_type(value: str) -> str: + """Lower-case and strip separators/apostrophes for case/separator-insensitive matching.""" + # \u2019 is the right single quote (curly apostrophe) so both apostrophe styles normalize alike. + return re.sub(r"[\s_.\-'\u2019]+", "", value.lower()) + + +@cache +def _effect_type_vocab() -> tuple[dict[str, str], tuple[str, ...]]: + """Normalized alias table and canonical values for the 25 ``EffectTypes`` values (cached). + + Returns: + Tuple of ``(alias table, canonical values)``: normalized-key -> + canonical-value map covering every permissible value plus the common + spellings/abbreviations, and the 25 canonical values verbatim. + """ + from tablassert.biolink import EFFECT_TYPE_VALUES + + table: dict[str, str] = {_normalize_effect_type(value): value for value in EFFECT_TYPE_VALUES} + for raw, canonical in _EFFECT_TYPE_ALIASES: + table[_normalize_effect_type(raw)] = canonical + return table, EFFECT_TYPE_VALUES + + +@lru_cache(maxsize=4096) +def _map_effect_type_value(raw: Any) -> str | None: + """Map one raw ``effect_type`` value to a canonical value, or null when nothing matches. + + Args: + raw: Raw cell value (possibly null). + + Returns: + The canonical ``EffectTypes`` value on an exact/alias hit or a fuzzy + hit scoring at least ``_EFFECT_TYPE_FUZZY_SCORE``; ``None`` otherwise. + """ + if raw is None: + return None + text: str = str(raw).strip() + if not text: + return None + table, values = _effect_type_vocab() + key: str = _normalize_effect_type(text) + hit: str | None = table.get(key) + if hit is not None: + return hit + # Fuzzy fallback against the 25 canonical values only. + from rapidfuzz import fuzz + + best: str = max(values, key=lambda v: fuzz.ratio(key, v)) + if fuzz.ratio(key, best) >= _EFFECT_TYPE_FUZZY_SCORE: + return best + return None + + +def coerce_effect_type_columns(lf: pl.LazyFrame) -> pl.LazyFrame: + """Rename effect-type-like columns to ``effect_type`` and coerce their values (Biolink PR #1774). + + Picks a single best fuzzy column match, then maps every value to one of the + 25 permissible ``EffectTypes`` enum values. + + Args: + lf: Source LazyFrame. + + Returns: + LazyFrame with the chosen column renamed to ``effect_type`` and its + values coerced (no-op if no effect-type-like column is present). + + Notes: + Values are matched case/separator-insensitively against an exact/alias + table first, then by rapidfuzz fallback against the 25 canonical + values. The Biolink range of ``effect_type`` is the enum, so values + matching nothing are dropped to null rather than carried through. + + Warnings: + Biolink class rule (PR #1774): ``effect_type`` may only be populated + when ``effect_size`` is populated. After value coercion, ``effect_type`` + is nulled on every row where ``effect_size`` is null, and nulled + entirely when no ``effect_size`` column is present (the same shape of + class rule ``sig`` documents for the significance qualifier). + """ + # Picks a single best fuzzy match and leaves other candidate columns untouched. + from rapidfuzz import fuzz + + names: list[str] = lf.collect_schema().names() + candidates: list[str] = [n for n in names if effect_type_target(n)] + if not candidates: + return lf + + target: str = "effect_type" + reference: str = target.replace("_", " ") + chosen: str = max(candidates, key=lambda c: fuzz.ratio(c, reference)) + lf = lf.rename({chosen: target}) if chosen != target else lf + + mapped: pl.Expr = pl.col(target).cast(pl.String).map_elements(_map_effect_type_value, return_dtype=pl.String) + lf = lf.with_columns(mapped.alias(target)) + + # Biolink class rule: effect_type may only be populated when effect_size is populated. + if "effect_size" in lf.collect_schema().names(): + populated: pl.Expr = pl.col("effect_size").cast(pl.Float64, strict=False).is_not_null() + guarded: pl.Expr = pl.when(populated).then(pl.col(target)).otherwise(pl.lit(None, dtype=pl.String)) + return lf.with_columns(guarded.alias(target)) + return lf.with_columns(pl.lit(None, dtype=pl.String).alias(target)) diff --git a/src/tablassert/lib.py b/src/tablassert/lib.py index 6a720e86..67f51658 100644 --- a/src/tablassert/lib.py +++ b/src/tablassert/lib.py @@ -13,7 +13,17 @@ from tablassert import rs from tablassert._lazy import LazyModule from tablassert.biolink import ALLOWED_EDGE_FIELDS, Categories, EdgeCategories -from tablassert.coerce import coerce_pvalue_columns, coerce_study_size_columns, pvalue_target, sig, study_size_target +from tablassert.coerce import ( + coerce_effect_size_columns, + coerce_effect_type_columns, + coerce_pvalue_columns, + coerce_study_size_columns, + effect_size_target, + effect_type_target, + pvalue_target, + sig, + study_size_target, +) from tablassert.enums import EncodingMethods, Files, InformationResources, Repositories, Tokens from tablassert.fullmap import ResolveSpec, fullmap_db_path, resolve, resolve_batch from tablassert.log import cat @@ -43,10 +53,14 @@ __all__ = [ "as_list", "clean_values", + "coerce_effect_size_columns", + "coerce_effect_type_columns", "coerce_pvalue_columns", "coerce_study_size_columns", "compile_rig", "curie_prefix", + "effect_size_target", + "effect_type_target", "infores", "normalize_biolink_category", "pvalue_target", @@ -268,8 +282,11 @@ def math_op(lf: pl.LazyFrame, col: str, func: str, args: list[Literal[Tokens.VAL def numeric_columns(names: list[str]) -> list[str]: """Return column names that should be coerced and formatted as numbers. - P-value columns by substring plus the exact ``relationship_strength`` and - study-size fields. + P-value columns by substring plus the exact ``effect_size`` and + study-size fields. The old ``sample_size`` / ``relationship_strength`` + names are absent on purpose: the column coercions rename them to + ``supporting_study_size`` / ``effect_size`` before ``clean_numeric`` / + ``format_numeric`` run. Args: names: Schema column names to filter. @@ -277,15 +294,15 @@ def numeric_columns(names: list[str]) -> list[str]: Returns: Subset of ``names`` destined for numeric coercion/formatting. """ - # P-value columns by substring plus exact strength and study-size fields. - exact: set[str] = {"relationship_strength", "sample_size", "supporting_study_size"} + # P-value columns by substring plus exact effect-size and study-size fields. + exact: set[str] = {"effect_size", "supporting_study_size"} return [c for c in names if ("p_value" in c.lower()) or (c in exact)] def clean_numeric(lf: pl.LazyFrame) -> pl.LazyFrame: """Coerce numeric annotation columns to Float64, dropping non-numeric values to null. - Only touches p-value, relationship-strength and sample-size columns. + Only touches p-value, effect-size and study-size columns. Args: lf: Source LazyFrame. @@ -293,7 +310,7 @@ def clean_numeric(lf: pl.LazyFrame) -> pl.LazyFrame: Returns: LazyFrame with the matched columns cast to Float64 (no-op if none match). """ - # Only touches p-value, relationship-strength and sample-size columns. + # Only touches p-value, effect-size and study-size columns. cols: list[str] = numeric_columns(lf.collect_schema().names()) if not cols: return lf @@ -672,6 +689,8 @@ def _source_ops(self: Self) -> list[Any]: [op for x in self.annotations for op in self.encoding(x, x.annotation.lower())] if self.annotations else None, (coerce_pvalue_columns, ()), (coerce_study_size_columns, ()), + (coerce_effect_size_columns, ()), + (coerce_effect_type_columns, ()), (clean_numeric, ()), # Drop insignificant rows before they ever reach the expensive fullmap resolution below. (sig, ()), @@ -764,6 +783,8 @@ def collect(self: Self, db: Path) -> list[tuple[Callable, tuple[Any]]] | Path: head: "filter", coerce_pvalue_columns: "clean", coerce_study_size_columns: "clean", + coerce_effect_size_columns: "clean", + coerce_effect_type_columns: "clean", clean_numeric: "clean", level_one: "resolve", level_two: "resolve", diff --git a/tests/test_biolink.py b/tests/test_biolink.py index 558cf0e7..2b88cbc8 100644 --- a/tests/test_biolink.py +++ b/tests/test_biolink.py @@ -17,7 +17,18 @@ import biolink_model.datamodel.pydanticmodel_v2 as bm import pytest -from tablassert.biolink import ALLOWED_EDGE_FIELDS, BIOLINK_VERSION, AgentTypes, Categories, EdgeCategories, KnowledgeLevels, Predicates, Qualifiers +from tablassert.biolink import ( + ALLOWED_EDGE_FIELDS, + BIOLINK_VERSION, + EFFECT_TYPE_VALUES, + AgentTypes, + Categories, + EdgeCategories, + EffectTypes, + KnowledgeLevels, + Predicates, + Qualifiers, +) if TYPE_CHECKING: from linkml_runtime.utils.schemaview import SchemaView @@ -64,7 +75,9 @@ def _biolink_association_names() -> set[str]: # --- enum shape --------------------------------------------------------------------------------- -@pytest.mark.parametrize("enum", [Categories, EdgeCategories, Predicates, Qualifiers, KnowledgeLevels, AgentTypes], ids=lambda e: e.__name__) +@pytest.mark.parametrize( + "enum", [Categories, EdgeCategories, Predicates, Qualifiers, KnowledgeLevels, AgentTypes, EffectTypes], ids=lambda e: e.__name__ +) def test_is_str_enum(enum: type) -> None: """Every derived vocabulary is a ``str``/``Enum`` subclass (usable as a Pydantic field type).""" assert issubclass(enum, str) @@ -114,6 +127,14 @@ def test_agent_types_has_manual_agent() -> None: assert AgentTypes.MANUAL_AGENT == "manual_agent" +def test_effect_types_has_spearmans_rho() -> None: + assert EffectTypes.SPEARMANS_RHO == "spearmans_rho" + + +def test_effect_types_has_odds_ratio() -> None: + assert EffectTypes.ODDS_RATIO == "odds_ratio" + + # --- provenance --------------------------------------------------------------------------------- @@ -137,6 +158,45 @@ def test_agent_types_match_biolink() -> None: assert {e.value for e in AgentTypes} == {e.value for e in bm.AgentTypeEnum} +def test_effect_types_match_pr1774() -> None: + """EffectTypes is exactly the 25 permissible ``effect_type`` values from Biolink PR #1774. + + Defined locally because the pinned biolink-model 4.4.3 predates the PR + (close_mappings intentionally ignored); once biolink-model ships the enum, + this becomes a drift guard against ``bm.EffectTypeEnum`` instead. + """ + expected: set[str] = { + "regression_coefficient", + "log2_fold_change", + "wald_ratio", + "inverse_variance_weighted", + "mr_egger", + "weighted_median", + "standardized_mean_difference", + "cohens_d", + "hedges_g", + "glasss_delta", + "strictly_standardized_mean_difference", + "correlation_coefficient", + "pearsons_r", + "spearmans_rho", + "kendalls_tau", + "polychoric_correlation", + "matthews_correlation_coefficient", + "goodman_kruskal_gamma", + "r2_linkage_disequilibrium", + "odds_ratio", + "relative_risk", + "hazard_ratio", + "eta_squared", + "omega_squared", + "root_mean_square_standardized_effect", + } + assert len(expected) == 25 + assert {e.value for e in EffectTypes} == expected + assert set(EFFECT_TYPE_VALUES) == expected + + def test_categories_match_biolink() -> None: """Categories is exactly the set of Biolink entity category names.""" assert {c.value for c in Categories} == _biolink_category_names() @@ -236,6 +296,12 @@ def test_allowed_edge_fields_includes_new_qualifiers() -> None: assert "process_qualifier" in ALLOWED_EDGE_FIELDS +def test_allowed_edge_fields_includes_effect_annotations() -> None: + """PR #1774 ``effect_size`` / ``effect_type`` are allowed edge columns (reach the final edges).""" + assert "effect_size" in ALLOWED_EDGE_FIELDS + assert "effect_type" in ALLOWED_EDGE_FIELDS + + def test_allowed_edge_fields_is_superset_of_qualifiers() -> None: """Every qualifier slot name is an allowed edge column.""" assert {q.value for q in Qualifiers} <= set(ALLOWED_EDGE_FIELDS) diff --git a/tests/test_lib.py b/tests/test_lib.py index bcb7ba92..17fd45c4 100644 --- a/tests/test_lib.py +++ b/tests/test_lib.py @@ -10,7 +10,8 @@ import tablassert.cli as cli import tablassert.lib as lib from tablassert import rs -from tablassert.biolink import ALLOWED_EDGE_FIELDS, Categories +from tablassert.biolink import ALLOWED_EDGE_FIELDS, EFFECT_TYPE_VALUES, Categories +from tablassert.coerce import _EFFECT_TYPE_ALIASES, _map_effect_type_value from tablassert.enums import Repositories from tablassert.fullmap import ResolveSpec from tablassert.ingests import from_yaml @@ -18,11 +19,15 @@ HEAD_ROWS, Tcode, clean_numeric, + coerce_effect_size_columns, + coerce_effect_type_columns, coerce_pvalue_columns, coerce_study_size_columns, drop_not_significant, edge_category, edge_tables, + effect_size_target, + effect_type_target, fold_unknown_to_supporting_text, format_numeric, head, @@ -818,13 +823,15 @@ def test_numeric_columns_matches_p_value_substring() -> None: def test_numeric_columns_matches_exact_names() -> None: - """numeric_columns matches exact relationship strength and study size names.""" - names: list[str] = ["relationship_strength", "sample_size", "supporting_study_size", "cohort"] + """numeric_columns matches exact effect size and study size names.""" + names: list[str] = ["effect_size", "supporting_study_size", "cohort", "sample_size", "relationship_strength"] result: list[str] = numeric_columns(names) - assert "relationship_strength" in result - assert "sample_size" in result + assert "effect_size" in result assert "supporting_study_size" in result assert "cohort" not in result + # Old names are superseded: coercion renames them before clean_numeric/format_numeric run. + assert "sample_size" not in result + assert "relationship_strength" not in result def test_numeric_columns_case_insensitive() -> None: @@ -846,12 +853,10 @@ def test_clean_numeric_parses_numeric_and_scientific() -> None: def test_clean_numeric_nulls_non_numeric() -> None: """clean_numeric drops non numeric entries to null.""" - lf: pl.LazyFrame = pl.DataFrame( - {"p_value": ["1e-8", "N/A", "", "<0.001", "abc"], "relationship_strength": ["0.85", "n/a", "NULL", "x", "y"]} - ).lazy() + lf: pl.LazyFrame = pl.DataFrame({"p_value": ["1e-8", "N/A", "", "<0.001", "abc"], "effect_size": ["0.85", "n/a", "NULL", "x", "y"]}).lazy() result: pl.DataFrame = clean_numeric(lf).collect() assert result["p_value"].to_list() == [1e-8, None, None, None, None] - assert result["relationship_strength"].to_list() == [0.85, None, None, None, None] + assert result["effect_size"].to_list() == [0.85, None, None, None, None] def test_clean_numeric_leaves_non_matching_untouched() -> None: @@ -892,10 +897,10 @@ def test_format_numeric_p_value_scientific() -> None: def test_format_numeric_decimal_general() -> None: - """format_numeric renders relationship strength and study size in decimal general format.""" - lf: pl.LazyFrame = pl.DataFrame({"relationship_strength": ["0.85", "0.42", "0.1234"], "supporting_study_size": ["450", "1200", "7"]}).lazy() + """format_numeric renders effect size and study size in decimal general format.""" + lf: pl.LazyFrame = pl.DataFrame({"effect_size": ["0.85", "0.42", "0.1234"], "supporting_study_size": ["450", "1200", "7"]}).lazy() result: pl.DataFrame = format_numeric(clean_numeric(lf)).collect() - assert result["relationship_strength"].to_list() == ["0.85", "0.42", "0.1234"] + assert result["effect_size"].to_list() == ["0.85", "0.42", "0.1234"] assert result["supporting_study_size"].to_list() == ["450", "1200", "7"] @@ -908,9 +913,9 @@ def test_format_numeric_preserves_nulls() -> None: def test_format_numeric_cleans_float_noise() -> None: """format_numeric cleans floating point noise to four significant figures.""" - lf: pl.LazyFrame = pl.DataFrame({"relationship_strength": ["0.85000000001", "0.41999999999"]}).lazy() + lf: pl.LazyFrame = pl.DataFrame({"effect_size": ["0.85000000001", "0.41999999999"]}).lazy() result: pl.DataFrame = format_numeric(clean_numeric(lf)).collect() - assert result["relationship_strength"].to_list() == ["0.85", "0.42"] + assert result["effect_size"].to_list() == ["0.85", "0.42"] def test_format_numeric_noop_without_numeric_columns() -> None: @@ -923,13 +928,13 @@ def test_format_numeric_noop_without_numeric_columns() -> None: def test_format_numeric_nulls_stripped_from_ndjson_rows() -> None: """cleaned and formatted null numeric values are stripped from NDJSON rows.""" - lf: pl.LazyFrame = pl.DataFrame({"subject": ["BRCA1", "TP53"], "p_value": ["1e-8", "N/A"], "relationship_strength": ["0.85", "0.42"]}).lazy() + lf: pl.LazyFrame = pl.DataFrame({"subject": ["BRCA1", "TP53"], "p_value": ["1e-8", "N/A"], "effect_size": ["0.85", "0.42"]}).lazy() formatted: pl.DataFrame = format_numeric(clean_numeric(lf)).collect() rows: list[dict[str, Any]] = [strip_nulls(r) for r in formatted.iter_rows(named=True)] - assert rows[0] == {"subject": "BRCA1", "p_value": "1.0000e-08", "relationship_strength": "0.85"} + assert rows[0] == {"subject": "BRCA1", "p_value": "1.0000e-08", "effect_size": "0.85"} assert "p_value" not in rows[1] assert rows[1]["subject"] == "TP53" - assert rows[1]["relationship_strength"] == "0.42" + assert rows[1]["effect_size"] == "0.42" def test_compile_graph_emits_ndjson(monkeypatch: Any, tmp_path: Path) -> None: @@ -1543,6 +1548,257 @@ def test_coerce_study_size_columns_noop_when_already_canonical() -> None: assert result["sample_size"].to_list() == [999] +# --- Effect-size / effect-type coercion (Biolink PR #1774) -------------------------------------- + + +def test_effect_size_target_matches_common_spellings() -> None: + """effect_size_target matches common effect size spellings including the old name.""" + names: list[str] = [ + "effect size", + "effectsize", + "effect_size", + "ES", + "es", + "relationship_strength", + "relationship strength", + "beta", + "beta coefficient", + "log2FC", + "log2 fold change", + "odds ratio", + "OR", + "hazard ratio", + "HR", + "risk ratio", + "correlation", + "rho", + "r", + ] + for n in names: + assert effect_size_target(n) == "effect_size", n + + +def test_effect_size_target_matches_qualified_statistic_names() -> None: + """effect_size_target matches qualified numeric-statistic column names.""" + names: list[str] = [ + "spearman rho", + "Spearman's rho", + "spearman_rho", + "pearson_r", + "kendall_tau", + "correlation coefficient", + "adjusted odds ratio", + "effect size estimate", + "log2_FC", + ] + for n in names: + assert effect_size_target(n) == "effect_size", n + + +def test_effect_size_target_excludes_false_positives() -> None: + """effect_size_target excludes identifiers, unrelated columns, and effect-type labels.""" + names: list[str] = [ + "correlation_id", + "subject", + "gene", + "p_value", + "sample_id", + "beta_actin", + "or_value", + "hr_status", + "order", + "effect type", + "effect metric", + "statistic type", + "metric", + ] + for n in names: + assert effect_size_target(n) is None, n + + +def test_effect_type_target_matches_common_spellings() -> None: + """effect_type_target matches effect-type/metric label spellings.""" + names: list[str] = [ + "effect type", + "effect_type", + "effect metric", + "statistic type", + "statistical type", + "metric type", + "metric", + "effect size type", + ] + for n in names: + assert effect_type_target(n) == "effect_type", n + + +def test_effect_type_target_excludes_unrelated_columns() -> None: + """effect_type_target excludes effect-size names and unrelated columns.""" + names: list[str] = ["effect_size", "effect size", "metric_value", "subject", "effect", "correlation"] + for n in names: + assert effect_type_target(n) is None, n + + +def test_coerce_effect_size_columns_renames_relationship_strength() -> None: + """coerce_effect_size_columns renames the old relationship_strength name forward to effect_size.""" + lf: pl.LazyFrame = pl.DataFrame({"relationship_strength": [0.85, 0.42]}).lazy() + result: pl.DataFrame = coerce_effect_size_columns(lf).collect() + assert "effect_size" in result.columns + assert "relationship_strength" not in result.columns + assert result["effect_size"].to_list() == [0.85, 0.42] + + +def test_coerce_effect_size_columns_renames_effect_size_like_column() -> None: + """coerce_effect_size_columns renames an effect-size-like column.""" + lf: pl.LazyFrame = pl.DataFrame({"spearman rho": [0.85]}).lazy() + result: pl.DataFrame = coerce_effect_size_columns(lf).collect() + assert result.columns == ["effect_size"] + assert result["effect_size"].to_list() == [0.85] + + +def test_coerce_effect_size_columns_picks_best_candidate() -> None: + """coerce_effect_size_columns picks the best candidate and leaves others untouched.""" + lf: pl.LazyFrame = pl.DataFrame({"effect size estimate": [0.1], "effect size": [0.85], "beta": [0.3]}).lazy() + result: pl.DataFrame = coerce_effect_size_columns(lf).collect() + assert result["effect_size"].to_list() == [0.85] + assert result["effect size estimate"].to_list() == [0.1] + assert result["beta"].to_list() == [0.3] + + +def test_coerce_effect_size_columns_noop_without_candidates() -> None: + """coerce_effect_size_columns is a noop without effect-size-like columns.""" + lf: pl.LazyFrame = pl.DataFrame({"subject": ["BRCA1"], "cohort": ["adult"]}).lazy() + result: pl.DataFrame = coerce_effect_size_columns(lf).collect() + assert result.columns == ["subject", "cohort"] + + +def test_coerce_effect_size_columns_noop_when_already_canonical() -> None: + """coerce_effect_size_columns is a noop when already canonically named.""" + lf: pl.LazyFrame = pl.DataFrame({"effect_size": [0.85], "beta": [0.3]}).lazy() + result: pl.DataFrame = coerce_effect_size_columns(lf).collect() + assert result.columns == ["effect_size", "beta"] + assert result["effect_size"].to_list() == [0.85] + + +def test_coerce_effect_type_columns_renames_and_maps_alias_values() -> None: + """coerce_effect_type_columns renames the column and maps alias values to canonical enum values.""" + lf: pl.LazyFrame = pl.DataFrame({"effect size": [0.85, 1.2, 0.4], "effect type": ["Spearman", "odds ratio", "beta"]}).lazy() + result: pl.DataFrame = coerce_effect_type_columns(coerce_effect_size_columns(lf)).collect() + assert "effect_type" in result.columns + assert "effect type" not in result.columns + assert result["effect_type"].to_list() == ["spearmans_rho", "odds_ratio", "regression_coefficient"] + + +def test_coerce_effect_type_columns_maps_canonical_and_case_variants() -> None: + """coerce_effect_type_columns passes canonical values through case/separator-insensitively.""" + lf: pl.LazyFrame = pl.DataFrame({"effect_size": [0.5, 0.6, 0.7], "effect_type": ["COHENS_D", "Hedges' g", "eta-squared"]}).lazy() + result: pl.DataFrame = coerce_effect_type_columns(lf).collect() + assert result["effect_type"].to_list() == ["cohens_d", "hedges_g", "eta_squared"] + + +def test_coerce_effect_type_columns_fuzzy_fallback_and_unmatched_null() -> None: + """coerce_effect_type_columns fuzzy-matches close spellings and nulls values matching nothing.""" + lf: pl.LazyFrame = pl.DataFrame( + {"effect_size": [0.5, 0.6, 0.7], "effect_type": ["spearmans", "pearsons", "totally unrelated garbage xyz"]} + ).lazy() + result: pl.DataFrame = coerce_effect_type_columns(lf).collect() + assert result["effect_type"].to_list() == ["spearmans_rho", "pearsons_r", None] + + +def test_map_effect_type_value_direct() -> None: + """_map_effect_type_value handles null input directly (map_elements skips nulls itself).""" + assert _map_effect_type_value(None) is None + assert _map_effect_type_value(" ") is None + assert _map_effect_type_value("Cohen's d") == "cohens_d" + assert _map_effect_type_value("spearmans") == "spearmans_rho" + assert _map_effect_type_value("totally unrelated garbage xyz") is None + + +def test_effect_type_aliases_only_map_to_permissible_values() -> None: + """Drift guard: every alias canonical is a permissible value, and canonical values round-trip.""" + assert {canonical for _, canonical in _EFFECT_TYPE_ALIASES} <= set(EFFECT_TYPE_VALUES) + for value in EFFECT_TYPE_VALUES: + assert _map_effect_type_value(value) == value + + +def test_coerce_effect_type_columns_nulls_blank_and_null_values() -> None: + """coerce_effect_type_columns maps null and blank values to null.""" + lf: pl.LazyFrame = pl.DataFrame({"effect_size": [0.1, 0.2], "effect_type": [None, " "]}).lazy() + result: pl.DataFrame = coerce_effect_type_columns(lf).collect() + assert result["effect_type"].to_list() == [None, None] + + +def test_coerce_effect_type_columns_nulls_where_effect_size_null() -> None: + """effect_type is nulled on rows whose effect_size is null or non-numeric (Biolink class rule).""" + lf: pl.LazyFrame = pl.DataFrame({"effect size": ["0.85", None, "n/a"], "effect type": ["Spearman", "OR", "beta"]}).lazy() + result: pl.DataFrame = coerce_effect_type_columns(coerce_effect_size_columns(lf)).collect() + assert result["effect_size"].to_list() == ["0.85", None, "n/a"] + assert result["effect_type"].to_list() == ["spearmans_rho", None, None] + + +def test_coerce_effect_type_columns_nulls_entirely_without_effect_size() -> None: + """effect_type is nulled entirely when no effect_size column is present (Biolink class rule).""" + lf: pl.LazyFrame = pl.DataFrame({"metric": ["OR", "pearson r"]}).lazy() + result: pl.DataFrame = coerce_effect_type_columns(lf).collect() + assert result.columns == ["effect_type"] + assert result["effect_type"].to_list() == [None, None] + + +def test_coerce_effect_type_columns_picks_best_candidate() -> None: + """coerce_effect_type_columns picks the best candidate column and leaves others untouched.""" + lf: pl.LazyFrame = pl.DataFrame({"effect_size": [0.85, 0.2], "effect size type": ["Spearman", "OR"], "effect type": ["pearson r", "beta"]}).lazy() + result: pl.DataFrame = coerce_effect_type_columns(lf).collect() + assert result["effect_type"].to_list() == ["pearsons_r", "regression_coefficient"] + assert result["effect size type"].to_list() == ["Spearman", "OR"] + + +def test_coerce_effect_type_columns_noop_without_candidates() -> None: + """coerce_effect_type_columns is a noop without effect-type-like columns.""" + lf: pl.LazyFrame = pl.DataFrame({"subject": ["BRCA1"], "effect_size": [0.85]}).lazy() + result: pl.DataFrame = coerce_effect_type_columns(lf).collect() + assert result.columns == ["subject", "effect_size"] + + +def test_coerced_effect_size_alias_survives_unknown_folding() -> None: + """The old relationship_strength name becomes a top-level effect_size field before unknown folding.""" + lf: pl.LazyFrame = pl.DataFrame( + {"subject": ["A"], "object": ["B"], "predicate": ["related_to"], "relationship_strength": ["0.85"], "miscellaneous_notes": ["note"]} + ).lazy() + out: pl.DataFrame = fold_unknown_to_supporting_text(coerce_effect_size_columns(lf)).collect() + assert out["effect_size"].to_list() == ["0.85"] + assert "relationship_strength" not in out.columns + assert out["supporting_text"].to_list() == [["miscellaneous_notes: note"]] + + +def test_unpicked_relationship_strength_folds_into_supporting_text() -> None: + """When a better effect-size candidate wins the fuzzy pick, the superseded old name folds.""" + lf: pl.LazyFrame = pl.DataFrame( + {"subject": ["A"], "object": ["B"], "predicate": ["related_to"], "effect size": ["0.85"], "relationship_strength": ["0.42"]} + ).lazy() + out: pl.DataFrame = fold_unknown_to_supporting_text(coerce_effect_size_columns(lf)).collect() + assert out["effect_size"].to_list() == ["0.85"] + assert "relationship_strength" not in out.columns + assert out["supporting_text"].to_list() == [["relationship_strength: 0.42"]] + + +def test_coerced_effect_type_survives_unknown_folding() -> None: + """Coerced effect_type values stay top-level edge fields after unknown folding.""" + lf: pl.LazyFrame = pl.DataFrame( + { + "subject": ["A"], + "object": ["B"], + "predicate": ["related_to"], + "effect_size": ["0.85"], + "effect type": ["Spearman"], + "miscellaneous_notes": ["note"], + } + ).lazy() + out: pl.DataFrame = fold_unknown_to_supporting_text(coerce_effect_type_columns(lf)).collect() + assert out["effect_size"].to_list() == ["0.85"] + assert out["effect_type"].to_list() == ["spearmans_rho"] + assert out["supporting_text"].to_list() == [["miscellaneous_notes: note"]] + + # tcode coerces P value columns after annotations and before clean_numeric # so downstream numeric_columns/sig/format_numeric see already canonical p_value/adjusted_p_value names def test_tcode_collect_coerces_pvalue_before_clean_numeric(fixtures_path: Path) -> None: @@ -1575,6 +1831,23 @@ def test_tcode_collect_coerces_study_size_before_clean_numeric(fixtures_path: Pa assert coerce_idx < clean_idx +# tcode coerces effect size and effect type columns after annotations and before clean_numeric +# so downstream numeric_columns/format_numeric see already canonical effect_size names +def test_tcode_collect_coerces_effect_columns_before_clean_numeric(fixtures_path: Path) -> None: + data: Any = from_yaml(fixtures_path / "minimal_section.yaml") + store: Path = Path("/tmp/sectionhash.parquet") + tcode_model: Tcode = Tcode.model_validate( # pyright: ignore + {**data, "config": fixtures_path / "minimal_section.yaml", "store": store} + ) + + collected: list[tuple[Any, tuple[Any]]] = tcode_model.collect(Path("/tmp/fullmap.redb")) # pyright: ignore + size_idx: int = next(i for i, op in enumerate(collected) if op[0].__name__ == "coerce_effect_size_columns") + type_idx: int = next(i for i, op in enumerate(collected) if op[0].__name__ == "coerce_effect_type_columns") + clean_idx: int = next(i for i, op in enumerate(collected) if op[0].__name__ == "clean_numeric") + + assert size_idx < type_idx < clean_idx + + def test_coerced_study_size_alias_survives_unknown_folding() -> None: """study size aliases become top-level supporting study size fields before unknown folding.""" lf: pl.LazyFrame = pl.DataFrame( From 48407b05d314e43072b0f133d3346d2b9ac9d5f1 Mon Sep 17 00:00:00 2001 From: SkyeAv Date: Fri, 7 Aug 2026 14:23:26 -0700 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20doc=20notation,=20ordering=20assertion,=20logFC=20a?= =?UTF-8?q?lias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - advanced-example.md edge JSON: use {:.4g}-accurate values ("9", "0.85") - tcode ordering test also asserts study-size coercion precedes effect-size - add limma/edgeR "logFC" alias for log2_fold_change value coercion - clarify ALAMV6 fixture keeps old annotation names as legacy-compat case --- docs/configuration/advanced-example.md | 4 ++-- src/tablassert/coerce.py | 1 + tests/test_agent_derive.py | 3 +++ tests/test_lib.py | 3 ++- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/configuration/advanced-example.md b/docs/configuration/advanced-example.md index 70e21ce1..8b5a9690 100644 --- a/docs/configuration/advanced-example.md +++ b/docs/configuration/advanced-example.md @@ -111,9 +111,9 @@ edge's `supporting_text` list as `"name: value"` entries (sorted alphabetically) "subject": "NCBITaxon:47715", "predicate": "biolink:correlated_with", "object": "CHEBI:41774", - "supporting_study_size": "9.000", + "supporting_study_size": "9", "p_value": "1.0000e-03", - "effect_size": "0.8500", + "effect_size": "0.85", "effect_type": "spearmans_rho", "supporting_text": [ "assertion_method: Spearman correlation", diff --git a/src/tablassert/coerce.py b/src/tablassert/coerce.py index 24a5bb73..3ef1c3cf 100644 --- a/src/tablassert/coerce.py +++ b/src/tablassert/coerce.py @@ -552,6 +552,7 @@ def coerce_effect_size_columns(lf: pl.LazyFrame) -> pl.LazyFrame: ("Kendall", "kendalls_tau"), ("log2FC", "log2_fold_change"), ("log2 fold change", "log2_fold_change"), + ("logFC", "log2_fold_change"), # limma/edgeR spelling; log-fold-change is base-2 by convention ("beta", "regression_coefficient"), ("regression coefficient", "regression_coefficient"), ("SMD", "standardized_mean_difference"), diff --git a/tests/test_agent_derive.py b/tests/test_agent_derive.py index f0b3f7e1..2105cd78 100644 --- a/tests/test_agent_derive.py +++ b/tests/test_agent_derive.py @@ -20,6 +20,9 @@ # The ALAMV6 table config from docs/configuration/advanced-example.md (template shape), # exercising the ``template`` branch of validate_section via to_sections + fastmerge. +# Kept on the OLD annotation names (sample_size / relationship_strength) on purpose: +# build-time coercion renames them to supporting_study_size / effect_size, so this +# doubles as the legacy-config backward-compatibility case. ALAMV6_TEMPLATE: dict[str, Any] = { "template": { "source": { diff --git a/tests/test_lib.py b/tests/test_lib.py index 17fd45c4..aaa4ed57 100644 --- a/tests/test_lib.py +++ b/tests/test_lib.py @@ -1841,11 +1841,12 @@ def test_tcode_collect_coerces_effect_columns_before_clean_numeric(fixtures_path ) collected: list[tuple[Any, tuple[Any]]] = tcode_model.collect(Path("/tmp/fullmap.redb")) # pyright: ignore + study_idx: int = next(i for i, op in enumerate(collected) if op[0].__name__ == "coerce_study_size_columns") size_idx: int = next(i for i, op in enumerate(collected) if op[0].__name__ == "coerce_effect_size_columns") type_idx: int = next(i for i, op in enumerate(collected) if op[0].__name__ == "coerce_effect_type_columns") clean_idx: int = next(i for i, op in enumerate(collected) if op[0].__name__ == "clean_numeric") - assert size_idx < type_idx < clean_idx + assert study_idx < size_idx < type_idx < clean_idx def test_coerced_study_size_alias_survives_unknown_folding() -> None: From 22b6a29704ef5f4db8e917b44b1580215ea8f02d Mon Sep 17 00:00:00 2001 From: SkyeAv Date: Fri, 7 Aug 2026 14:58:08 -0700 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20CodeRabbit=20feedback=20?= =?UTF-8?q?=E2=80=94=20canonical=20precedence=20+=20effect=5Ftype=20contra?= =?UTF-8?q?ct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - all four column coercions keep an existing canonical column instead of fuzzy-picking a higher-scoring spaced alias onto it (polars DuplicateError) - agent.py + optimized_instructions.yaml now encode the full effect_type contract: method: column OR method: value with a fixed valid Biolink effect type, emitted only alongside an effect_size annotation - +4 regression tests (effect_size/effect size, effect_type/effect type, p_value/p value, supporting_study_size/supporting study size) --- examples/agent/optimized_instructions.yaml | 4 ++- src/tablassert/agent.py | 7 ++-- src/tablassert/coerce.py | 12 ++++--- tests/test_lib.py | 37 ++++++++++++++++++++++ 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/examples/agent/optimized_instructions.yaml b/examples/agent/optimized_instructions.yaml index 3e92eaa3..7e1e5b04 100644 --- a/examples/agent/optimized_instructions.yaml +++ b/examples/agent/optimized_instructions.yaml @@ -89,7 +89,9 @@ instructions: "# ROLE + TASK\nYou are an expert knowledge-graph (KG) engineer. Y \ name, or its caption explicitly establishes that entity for the rows. A hard-coded object applied to every\n row must\ \ be justified by the table's actual context.\n3. CAPTURE STATISTICAL ANNOTATIONS: when the table has p_value, q_value,\ \ fold_change, z_score, lfsr, beta,\n standard_error, sample_size, or similar columns, add them as annotations (annotation:\ - \ p_value / q_value /\n effect_size / supporting_study_size, method: column, encoding: ). Do NOT silently drop\ + \ p_value / q_value /\n effect_size / supporting_study_size, method: column, encoding: ). For effect_type, use method:\ + \ column when the table provides it, else method: value with a fixed valid\n Biolink effect type (e.g. spearmans_rho) — emit\ + \ effect_type ONLY alongside an effect_size annotation. Do NOT silently drop\ \ statistical\n columns — they are part of the evidence.\n4. PICK THE RIGHT OBJECT COLUMN: the object column must actually\ \ contain the intended entity. Verify with\n read_table that the column holds the entity type you claim (e.g. a protein-abundance\ \ table with UniProt\n IDs in columns A/B should map those, not a gene-symbol column elsewhere).\n5. prioritize GUIDANCE\ diff --git a/src/tablassert/agent.py b/src/tablassert/agent.py index a17945dc..f06ff2f2 100644 --- a/src/tablassert/agent.py +++ b/src/tablassert/agent.py @@ -1927,8 +1927,11 @@ def call(prompt: str) -> str: file's source.url, plus sheet/row_slice/delimiter as needed) and its OWN `statement`. Within each section choose column-letter encodings for entity columns and literal CURIEs for fixed values; pick a valid biolink predicate; add statistical annotations (p_value / supporting_study_size / -effect_size / effect_type) when that table has those columns. A single-table article is still ONE -config with ONE section. +effect_size / effect_type) when that table has them — method: column for table-provided columns, +method: value for a fixed valid value (e.g. effect_type: spearmans_rho when every row is a +Spearman correlation). Emit effect_type ONLY alongside an effect_size annotation: the pipeline +nulls an effect_type without a numeric effect_size. A single-table article is still ONE config +with ONE section. ## ReAct workflow + planning Reason in an explicit ReAct loop (Thought -> Action -> Observation) and re-plan every few steps: diff --git a/src/tablassert/coerce.py b/src/tablassert/coerce.py index 3ef1c3cf..f0efabdf 100644 --- a/src/tablassert/coerce.py +++ b/src/tablassert/coerce.py @@ -214,7 +214,8 @@ def coerce_pvalue_columns(lf: pl.LazyFrame) -> pl.LazyFrame: renames: dict[str, str] = {} for target, candidates in buckets.items(): reference: str = target.replace("_", " ") - chosen: str = max(candidates, key=lambda c: fuzz.ratio(c, reference)) + # An existing canonical column always wins; fuzzy ranking only picks among aliases. + chosen: str = target if target in candidates else max(candidates, key=lambda c: fuzz.ratio(c, reference)) if chosen != target: renames[chosen] = target @@ -357,7 +358,8 @@ def coerce_study_size_columns(lf: pl.LazyFrame) -> pl.LazyFrame: target: str = "supporting_study_size" reference: str = target.replace("_", " ") - chosen: str = max(candidates, key=lambda c: fuzz.ratio(c, reference)) + # An existing canonical column always wins; fuzzy ranking only picks among aliases. + chosen: str = target if target in candidates else max(candidates, key=lambda c: fuzz.ratio(c, reference)) if chosen == target: return lf return lf.rename({chosen: target}) @@ -525,7 +527,8 @@ def coerce_effect_size_columns(lf: pl.LazyFrame) -> pl.LazyFrame: target: str = "effect_size" reference: str = target.replace("_", " ") - chosen: str = max(candidates, key=lambda c: fuzz.ratio(c, reference)) + # An existing canonical column always wins; fuzzy ranking only picks among aliases. + chosen: str = target if target in candidates else max(candidates, key=lambda c: fuzz.ratio(c, reference)) if chosen == target: return lf return lf.rename({chosen: target}) @@ -668,7 +671,8 @@ class rule ``sig`` documents for the significance qualifier). target: str = "effect_type" reference: str = target.replace("_", " ") - chosen: str = max(candidates, key=lambda c: fuzz.ratio(c, reference)) + # An existing canonical column always wins; fuzzy ranking only picks among aliases. + chosen: str = target if target in candidates else max(candidates, key=lambda c: fuzz.ratio(c, reference)) lf = lf.rename({chosen: target}) if chosen != target else lf mapped: pl.Expr = pl.col(target).cast(pl.String).map_elements(_map_effect_type_value, return_dtype=pl.String) diff --git a/tests/test_lib.py b/tests/test_lib.py index aaa4ed57..40be1d95 100644 --- a/tests/test_lib.py +++ b/tests/test_lib.py @@ -1416,6 +1416,15 @@ def test_coerce_pvalue_columns_noop_when_already_canonical() -> None: assert result["p_value"].to_list() == [0.01] +def test_coerce_pvalue_columns_keeps_existing_canonical_over_alias() -> None: + """An existing canonical column wins over a higher-scoring spaced alias (no duplicate rename).""" + lf: pl.LazyFrame = pl.DataFrame({"p_value": [0.01], "p value": [0.02]}).lazy() + result: pl.DataFrame = coerce_pvalue_columns(lf).collect() + assert result.columns == ["p_value", "p value"] + assert result["p_value"].to_list() == [0.01] + assert result["p value"].to_list() == [0.02] + + def test_study_size_target_matches_common_spellings() -> None: """study_size_target matches common study size spellings.""" names: list[str] = ["n", "N", "sample_size", "sample size", "sample-size", "sample.size", "samplesize", "study size", "cohort size"] @@ -1548,6 +1557,15 @@ def test_coerce_study_size_columns_noop_when_already_canonical() -> None: assert result["sample_size"].to_list() == [999] +def test_coerce_study_size_columns_keeps_existing_canonical_over_alias() -> None: + """An existing canonical column wins over a higher-scoring spaced alias (no duplicate rename).""" + lf: pl.LazyFrame = pl.DataFrame({"supporting_study_size": [1200], "supporting study size": [999]}).lazy() + result: pl.DataFrame = coerce_study_size_columns(lf).collect() + assert result.columns == ["supporting_study_size", "supporting study size"] + assert result["supporting_study_size"].to_list() == [1200] + assert result["supporting study size"].to_list() == [999] + + # --- Effect-size / effect-type coercion (Biolink PR #1774) -------------------------------------- @@ -1680,6 +1698,15 @@ def test_coerce_effect_size_columns_noop_when_already_canonical() -> None: assert result["effect_size"].to_list() == [0.85] +def test_coerce_effect_size_columns_keeps_existing_canonical_over_alias() -> None: + """An existing canonical column wins over a higher-scoring spaced alias (no duplicate rename).""" + lf: pl.LazyFrame = pl.DataFrame({"effect_size": [0.85], "effect size": [0.99]}).lazy() + result: pl.DataFrame = coerce_effect_size_columns(lf).collect() + assert result.columns == ["effect_size", "effect size"] + assert result["effect_size"].to_list() == [0.85] + assert result["effect size"].to_list() == [0.99] + + def test_coerce_effect_type_columns_renames_and_maps_alias_values() -> None: """coerce_effect_type_columns renames the column and maps alias values to canonical enum values.""" lf: pl.LazyFrame = pl.DataFrame({"effect size": [0.85, 1.2, 0.4], "effect type": ["Spearman", "odds ratio", "beta"]}).lazy() @@ -1752,6 +1779,16 @@ def test_coerce_effect_type_columns_picks_best_candidate() -> None: assert result["effect size type"].to_list() == ["Spearman", "OR"] +def test_coerce_effect_type_columns_keeps_existing_canonical_over_alias() -> None: + """An existing canonical column wins over a higher-scoring spaced alias (no duplicate rename).""" + lf: pl.LazyFrame = pl.DataFrame( + {"effect_size": [0.85, 0.2], "effect_type": ["odds_ratio", "cohens_d"], "effect type": ["pearson r", "beta"]} + ).lazy() + result: pl.DataFrame = coerce_effect_type_columns(lf).collect() + assert result["effect_type"].to_list() == ["odds_ratio", "cohens_d"] + assert result["effect type"].to_list() == ["pearson r", "beta"] + + def test_coerce_effect_type_columns_noop_without_candidates() -> None: """coerce_effect_type_columns is a noop without effect-type-like columns.""" lf: pl.LazyFrame = pl.DataFrame({"subject": ["BRCA1"], "effect_size": [0.85]}).lazy()