From ae3b759384a600a26c9fc060a124a5a52cff2857 Mon Sep 17 00:00:00 2001 From: SkyeAv Date: Fri, 21 Aug 2026 10:43:16 -0700 Subject: [PATCH] feat: assert node ids and edge core slots in final graph QC --- CHANGELOG.md | 1 + docs/cli.md | 2 +- src/tablassert/cli.py | 8 +-- src/tablassert/study.py | 54 +++++++++++---- tests/test_study.py | 145 +++++++++++++++++++++++++++++++++++----- 5 files changed, 178 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d21dae2..caa921f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project are documented in this file. ### Added - **The final graph QC now asserts no field in the emitted NDJSON is null or empty, and that every node carries a name.** `build-kg --qc`'s stage-7 study pass (in the spirit of `studyKGtsvs.pl`) gained two assertions. `empty-or-null-values`: any field in the nodes or edges file whose value is JSON `null`, a string that strips to empty, or an empty container — checked recursively, so a null or blank nested inside an `attributes` list counts — fails the build. The check is deliberately stricter than the NDJSON writer's `strip_nulls` (`rust/src/json.rs`), which scrubs dict entries at every depth but passes array scalars (`["x", ""]`) and emptied nested objects (`[{}]`) through verbatim; the study stage now asserts the stronger contract — no null or empty value anywhere — so the first such shape to reach an emitted file fails the build loudly instead of shipping silently. Null-like *strings* (`NA`/`NaN`/`null`/`none`) are also dropped by the writer but are neither null nor empty, and are deliberately not flagged; the `original_*` whitespace exemption does not extend to emptiness. `unnamed-nodes`: a node record whose `name` key is missing, `null`, or strips to empty fails the build — the missing-key case is what pipeline output surfaces, since `strip_nulls` deletes empty and null-like names before the file is written. Both report offenders per field or per node id, capped at 10 examples like the other assertions. +- **The final graph QC now asserts every node has an `id` and every edge has `subject`, `predicate`, and `object`.** Two more stage-7 study assertions join the `unnamed-nodes` check (which already requires a non-empty node `name`). `unidentified-nodes`: a node record whose `id` key is missing, `null`, or strips to empty fails the build; since the id is exactly what is absent, examples key on the node's `name` (or `` when it has none). `incomplete-edges`: an edge record missing any of the three core slots — missing key, `null`, or strips-to-empty — fails, counted per slot (e.g. `predicate (2)`). The writer's `strip_nulls` deletes a null slot outright rather than emitting it, so on pipeline output a hit means the slot was null upstream and the record shipped broken — exactly the condition these assertions exist to catch loudly. Non-string, non-null ids and slots pass, mirroring the name convention (no writer emits them). ## 12.1.0 - 2026-08-18 diff --git a/docs/cli.md b/docs/cli.md index 6644c00..81fa146 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -146,7 +146,7 @@ The positional `GRAPH-CONFIGURATION-FILE` (also `--configuration-file`, `-f`) is | --- | --- | --- | --- | --- | | `GRAPH-CONFIGURATION-FILE` (`--configuration-file`, `-f`) | Path | Yes | — | Graph YAML | | `--release`, `-r` | Flag | No | `False` | Emit a slim, significant-only graph (drops `biolink:not_significant` edges before resolution) | -| `--qc`, `-q` | Flag | No | `False` | Audit resolved mappings (exact → fuzzy → abbreviation → SapBERT) so low-confidence edges are flagged; requires the `[qc]` extra, checked before the build starts. Also runs a final study stage that asserts over the emitted NDJSON — no duplicate node ids, no nodes with no name or an empty name, no undeclared or isolated nodes, no malformed lines, no null or empty values in any field (checked recursively), and no stray whitespace — verbatim `original_*` fields excepted from the whitespace check, since they are faithful source copies — and fails the build (non-zero exit) on any violation | +| `--qc`, `-q` | Flag | No | `False` | Audit resolved mappings (exact → fuzzy → abbreviation → SapBERT) so low-confidence edges are flagged; requires the `[qc]` extra, checked before the build starts. Also runs a final study stage that asserts over the emitted NDJSON — no duplicate node ids, every node has a non-empty `id` and `name`, every edge has a non-empty `subject`, `predicate`, and `object`, no undeclared or isolated nodes, no malformed lines, no null or empty values in any field (checked recursively), and no stray whitespace — verbatim `original_*` fields excepted from the whitespace check, since they are faithful source copies — and fails the build (non-zero exit) on any violation | | `--log`, `-l` | Flag | No | `False` | Enable verbose per-section logging | | `--head`, `-hd` | Flag | No | `False` | Fast output-shape preview: ≤5 random rows/section, cached to `.head.parquet`, never clobbers a full build | | `--threads`, `-t` | int | No | `None` (auto) | Worker threads for the parallel fullmap reads behind entity resolution. Readers fan out across the 16 record-shard files, and values above the (non-empty) shard count further split the busiest shards' term buckets across more concurrent readers of the same shard — redb readers share-lock, so they never contend with each other. Unset keeps the auto behavior: large batches (≥ 1024 terms) fan out, small ones stay serial. Results are identical at any worker count | diff --git a/src/tablassert/cli.py b/src/tablassert/cli.py index 6a962bf..151d607 100644 --- a/src/tablassert/cli.py +++ b/src/tablassert/cli.py @@ -665,10 +665,10 @@ def build_kg( "tablassert[qc]"``); it is checked before the build starts, because the audit stage runs LAST and a missing extra would otherwise surface only after entity resolution has finished. It also runs a final study stage that asserts over the emitted NDJSON - -- no duplicate node ids, no nodes with no name or an empty name, no undeclared or - isolated nodes, no malformed lines, no null or empty values in any field, and no - stray whitespace -- and fails the build (non-zero exit) when any assertion is - violated. + -- no duplicate node ids, every node has a non-empty id and name, every edge has a + non-empty subject, predicate, and object, no undeclared or isolated nodes, no + malformed lines, no null or empty values in any field, and no stray whitespace -- + and fails the build (non-zero exit) when any assertion is violated. """ # A non-positive thread count would only fail deep inside the Rust lookup; fail loud # up front, matching the --gepa-threads pattern. diff --git a/src/tablassert/study.py b/src/tablassert/study.py index 1b7d823..6ad1a5d 100644 --- a/src/tablassert/study.py +++ b/src/tablassert/study.py @@ -17,6 +17,8 @@ "empty-or-null-values": "null or empty values", "duplicate-node-ids": "duplicate node ids", "unnamed-nodes": "nodes with no name or an empty name", + "unidentified-nodes": "nodes with no id or an empty id", + "incomplete-edges": "edges missing subject, predicate, or object", "undeclared-nodes": "nodes referenced by edges but not declared in the nodes file", "isolated-nodes": "declared nodes participating in no edge", } @@ -48,6 +50,8 @@ class _FileScan: whitespace: Counter[str] empty_null: Counter[str] unnamed: Counter[str] + idless: Counter[str] + incomplete: Counter[str] malformed: int missing: bool path: Path @@ -81,15 +85,16 @@ def _scan_ndjson(path: Path, *, edge: bool) -> _FileScan: Args: path: Path to a ``.nodes.ndjson`` or ``.edges.ndjson`` file. - edge: ``True`` to collect referenced ids from ``subject``/``object``; - ``False`` to collect declared node ``id``s and track duplicates and - nodes with no name or an empty name. + edge: ``True`` to collect referenced ids from ``subject``/``object`` and + assert the three core edge slots are present; ``False`` to collect + declared node ``id``s and track duplicates, nodes with no name or + an empty name, and nodes with no id or an empty id. Returns: A :class:`_FileScan`; ``missing`` is set (and nothing else) when the file does not exist, so a typo'd path can never read as a clean pass. """ - scan: _FileScan = _FileScan(set(), Counter(), Counter(), Counter(), Counter(), 0, not path.is_file(), path) + scan: _FileScan = _FileScan(set(), Counter(), Counter(), Counter(), Counter(), Counter(), Counter(), 0, not path.is_file(), path) if scan.missing: return scan with path.open(encoding="utf-8") as handle: @@ -126,6 +131,17 @@ def _scan_ndjson(path: Path, *, edge: bool) -> _FileScan: ident: object = record.get(role) if isinstance(ident, str): scan.ids.add(ident) + # An edge without all three core slots is unusable downstream: KGX + # consumers traverse subject -> predicate -> object. Flag a missing + # key, a null, or a strip-empty string per slot, counted under the + # slot name (e.g. `predicate (2)`) for the examples list. The + # writer's strip_nulls deletes a null slot outright, so on pipeline + # output a hit means the slot was null upstream and the record + # shipped broken -- exactly what this assertion exists to catch. + for slot in ("subject", "predicate", "object"): + slot_value: object = record.get(slot) + if slot_value is None or (isinstance(slot_value, str) and not slot_value.strip()): + scan.incomplete[slot] += 1 else: ident = record.get("id") if isinstance(ident, str): @@ -134,6 +150,15 @@ def _scan_ndjson(path: Path, *, edge: bool) -> _FileScan: if ident in scan.ids: scan.duplicate_ids[ident] += 1 scan.ids.add(ident) + # A node with no id is unusable downstream: the id is the graph's join + # key -- edges reference nodes only through it. Flag a missing key, + # a null, or a strip-empty string, mirroring the name assertion. + # Since the id is exactly what is absent, examples key on the node's + # name (or ``). + node_id: str = ident if isinstance(ident, str) else "" + name: object = record.get("name") + if ident is None or (isinstance(ident, str) and not ident.strip()): + scan.idless[name if isinstance(name, str) and name.strip() else ""] += 1 # A node with no name is unusable downstream: KGX consumers key display # and merging off `name`. Flag a missing key, a null, or a string that # strips to empty. On pipeline output the writer's strip_nulls @@ -143,8 +168,6 @@ def _scan_ndjson(path: Path, *, edge: bool) -> _FileScan: # non-null names pass -- no writer emits them. Offenders are keyed by # node id (or `` when the record has no string id) for the # examples list. - node_id: str = ident if isinstance(ident, str) else "" - name: object = record.get("name") if name is None or (isinstance(name, str) and not name.strip()): scan.unnamed[node_id] += 1 return scan @@ -154,12 +177,13 @@ def study_kgx(nodes_path: Path, edges_path: Path, *, example_limit: int = 10) -> """Assert over the final KGX NDJSON files, in the spirit of studyKGtsvs.pl. Streams both files once each and checks: duplicate node ids, nodes with no - name or an empty name, nodes referenced by edges but never declared - (``undeclared``), declared nodes participating in no edge (``isolated``), - empty/malformed lines, string values carrying leading/trailing whitespace, - and null or empty values in any field (a stronger contract than the writer's - strip_nulls). Every check is an assertion -- the caller decides whether - violations fail the build. + name or an empty name, nodes with no id or an empty id, edges missing any + of ``subject``/``predicate``/``object``, nodes referenced by edges but + never declared (``undeclared``), declared nodes participating in no edge + (``isolated``), empty/malformed lines, string values carrying leading/trailing + whitespace, and null or empty values in any field (a stronger contract than + the writer's strip_nulls). Every check is an assertion -- the caller decides + whether violations fail the build. Args: nodes_path: Path to ``_.nodes.ndjson``. @@ -191,6 +215,12 @@ def study_kgx(nodes_path: Path, edges_path: Path, *, example_limit: int = 10) -> if nodes.unnamed: examples = [ident for ident, _ in nodes.unnamed.most_common(example_limit)] violations.append(StudyViolation("unnamed-nodes", "nodes", sum(nodes.unnamed.values()), examples)) + if nodes.idless: + examples = [name for name, _ in nodes.idless.most_common(example_limit)] + violations.append(StudyViolation("unidentified-nodes", "nodes", sum(nodes.idless.values()), examples)) + if edges.incomplete: + examples = [f"{slot} ({n})" for slot, n in edges.incomplete.most_common(example_limit)] + violations.append(StudyViolation("incomplete-edges", "edges", sum(edges.incomplete.values()), examples)) if not nodes.missing and not edges.missing: undeclared: list[str] = sorted(edges.ids - nodes.ids) if undeclared: diff --git a/tests/test_study.py b/tests/test_study.py index c57322f..40ae30a 100644 --- a/tests/test_study.py +++ b/tests/test_study.py @@ -66,7 +66,12 @@ def test_unnamed_nodes(tmp_path: Path) -> None: ), ) edges: Path = _write_ndjson( - tmp_path / "e.ndjson", _records({"subject": "A:1", "object": "A:5"}, {"subject": "A:2", "object": "A:3"}, {"subject": "A:4", "object": "A:5"}) + tmp_path / "e.ndjson", + _records( + {"subject": "A:1", "predicate": "biolink:related_to", "object": "A:5"}, + {"subject": "A:2", "predicate": "biolink:related_to", "object": "A:3"}, + {"subject": "A:4", "predicate": "biolink:related_to", "object": "A:5"}, + ), ) checks: dict[str, study.StudyViolation] = _checks(study.study_kgx(nodes, edges)) violation: study.StudyViolation = checks["unnamed-nodes"] @@ -76,13 +81,105 @@ def test_unnamed_nodes(tmp_path: Path) -> None: def test_unnamed_node_without_id(tmp_path: Path) -> None: - """A nameless record with no string id is still counted, keyed as ````.""" + """A nameless record with no string id trips both node assertions. + + It has neither id nor name, so it counts under `unnamed-nodes` (keyed + ``) and under `unidentified-nodes` (keyed ``, since the + name is exactly what is absent). + """ nodes: Path = _write_ndjson(tmp_path / "n.ndjson", _records({"name": ""}, {"id": "A:1", "name": "x"})) - edges: Path = _write_ndjson(tmp_path / "e.ndjson", _records({"subject": "A:1", "object": "A:1"})) + edges: Path = _write_ndjson(tmp_path / "e.ndjson", _records({"subject": "A:1", "predicate": "biolink:related_to", "object": "A:1"})) checks: dict[str, study.StudyViolation] = _checks(study.study_kgx(nodes, edges)) - violation: study.StudyViolation = checks["unnamed-nodes"] - assert violation.count == 1 - assert violation.examples == [""] + assert checks["unnamed-nodes"].count == 1 + assert checks["unnamed-nodes"].examples == [""] + assert checks["unidentified-nodes"].count == 1 + assert checks["unidentified-nodes"].examples == [""] + + +def test_unidentified_nodes(tmp_path: Path) -> None: + """A missing, null, or empty node id fails the unidentified assertion, keyed by name.""" + nodes: Path = _write_ndjson( + tmp_path / "n.ndjson", + _records( + {"name": "insulin"}, + {"id": None, "name": "IGF1"}, + {"id": "", "name": "EGFR"}, + {"id": " ", "name": "GAPDH"}, + {"id": "A:1", "name": "valid"}, + ), + ) + # The edge references only the well-formed node, so no other assertion fires. + edges: Path = _write_ndjson(tmp_path / "e.ndjson", _records({"subject": "A:1", "predicate": "biolink:related_to", "object": "A:1"})) + checks: dict[str, study.StudyViolation] = _checks(study.study_kgx(nodes, edges)) + violation: study.StudyViolation = checks["unidentified-nodes"] + assert violation.label == "nodes" + assert violation.count == 4 + assert sorted(violation.examples) == ["EGFR", "GAPDH", "IGF1", "insulin"] + + +def test_unidentified_examples_capped(tmp_path: Path) -> None: + """Unidentified-node examples are capped while the count stays exact.""" + nodes: Path = _write_ndjson(tmp_path / "n.ndjson", _records(*({"name": f"n{i}"} for i in range(30)))) + _, edges = _clean(tmp_path) + checks: dict[str, study.StudyViolation] = _checks(study.study_kgx(nodes, edges, example_limit=10)) + assert checks["unidentified-nodes"].count == 30 + assert len(checks["unidentified-nodes"].examples) == 10 + + +def test_non_string_id_is_not_unidentified(tmp_path: Path) -> None: + """A non-string, non-null id passes the unidentified assertion, like non-string names. + + No writer emits a non-string id; the assertion targets absent ids, not their + JSON types. Note such a node is invisible to every id-based check (duplicate, + undeclared, isolated all collect string ids only) -- which is why a writer + emitting one would be a separate bug to fix, not one to silently accept here. + """ + nodes: Path = _write_ndjson(tmp_path / "n.ndjson", _records({"id": 5, "name": "a"}, {"id": "A:1", "name": "b"})) + edges: Path = _write_ndjson(tmp_path / "e.ndjson", _records({"subject": "A:1", "predicate": "biolink:related_to", "object": "A:1"})) + assert study.study_kgx(nodes, edges) == [] + + +def test_incomplete_edges(tmp_path: Path) -> None: + """An edge missing any of subject/predicate/object fails, counted per slot. + + A missing key, a null, and a strip-empty string all count; examples carry + the slot name and per-slot totals, matching the whitespace example format. + """ + nodes, _ = _clean(tmp_path) + edges: Path = _write_ndjson( + tmp_path / "e.ndjson", + _records( + {"predicate": "biolink:related_to", "object": "HGNC:5"}, + {"subject": "HGNC:5", "predicate": None, "object": "HGNC:6"}, + {"subject": "HGNC:5", "predicate": "biolink:related_to", "object": ""}, + {"subject": "HGNC:5", "predicate": "biolink:related_to", "object": "HGNC:6"}, + ), + ) + checks: dict[str, study.StudyViolation] = _checks(study.study_kgx(nodes, edges)) + violation: study.StudyViolation = checks["incomplete-edges"] + assert violation.label == "edges" + assert violation.count == 3 + assert sorted(violation.examples) == ["object (1)", "predicate (1)", "subject (1)"] + + +def test_non_string_edge_slots_are_not_incomplete(tmp_path: Path) -> None: + """Non-string, non-null edge slots pass the incomplete assertion. + + Mirrors the id/name convention: the assertion targets absent slots, not + their JSON types; no writer emits a non-string slot. + """ + nodes: Path = _write_ndjson(tmp_path / "n.ndjson", _records({"id": "A:1", "name": "a"}, {"id": "A:2", "name": "b"})) + edges: Path = _write_ndjson(tmp_path / "e.ndjson", _records({"subject": "A:1", "predicate": 5, "object": "A:2"})) + checks: dict[str, study.StudyViolation] = _checks(study.study_kgx(nodes, edges)) + assert "incomplete-edges" not in checks + + +def test_incomplete_edges_clean_pass(tmp_path: Path) -> None: + """Edges carrying all three core slots raise no incomplete-edges violation.""" + nodes, _ = _clean(tmp_path) + edges: Path = _write_ndjson(tmp_path / "e.ndjson", _records({"subject": "HGNC:5", "predicate": "biolink:related_to", "object": "HGNC:6"})) + checks: dict[str, study.StudyViolation] = _checks(study.study_kgx(nodes, edges)) + assert "incomplete-edges" not in checks def test_unnamed_examples_capped(tmp_path: Path) -> None: @@ -97,7 +194,7 @@ def test_unnamed_examples_capped(tmp_path: Path) -> None: def test_non_string_name_is_not_unnamed(tmp_path: Path) -> None: """A non-string, non-null name passes; the assertion targets absent/empty names only.""" nodes: Path = _write_ndjson(tmp_path / "n.ndjson", _records({"id": "A:1", "name": 5}, {"id": "A:2", "name": "x"})) - edges: Path = _write_ndjson(tmp_path / "e.ndjson", _records({"subject": "A:1", "object": "A:2"})) + edges: Path = _write_ndjson(tmp_path / "e.ndjson", _records({"subject": "A:1", "predicate": "biolink:related_to", "object": "A:2"})) assert study.study_kgx(nodes, edges) == [] @@ -149,7 +246,13 @@ def test_empty_or_null_writer_pass_through_shapes(tmp_path: Path) -> None: {"id": "A:4", "name": "d", "nested": [[]]}, ), ) - edges: Path = _write_ndjson(tmp_path / "e.ndjson", _records({"subject": "A:1", "object": "A:2"}, {"subject": "A:3", "object": "A:4"})) + edges: Path = _write_ndjson( + tmp_path / "e.ndjson", + _records( + {"subject": "A:1", "predicate": "biolink:related_to", "object": "A:2"}, + {"subject": "A:3", "predicate": "biolink:related_to", "object": "A:4"}, + ), + ) checks: dict[str, study.StudyViolation] = _checks(study.study_kgx(nodes, edges)) violation: study.StudyViolation = checks["empty-or-null-values"] assert violation.count == 4 @@ -178,7 +281,7 @@ def test_original_fields_empty_still_flagged(tmp_path: Path) -> None: nodes: Path = _write_ndjson( tmp_path / "n.ndjson", _records({"id": "A:1", "name": "a", "original_subject": None}, {"id": "A:2", "name": "b", "original_object": ""}) ) - edges: Path = _write_ndjson(tmp_path / "e.ndjson", _records({"subject": "A:1", "object": "A:2"})) + edges: Path = _write_ndjson(tmp_path / "e.ndjson", _records({"subject": "A:1", "predicate": "biolink:related_to", "object": "A:2"})) checks: dict[str, study.StudyViolation] = _checks(study.study_kgx(nodes, edges)) violation: study.StudyViolation = checks["empty-or-null-values"] assert violation.count == 2 @@ -192,14 +295,20 @@ def test_null_like_strings_are_not_empty_or_null(tmp_path: Path) -> None: bad-token sweep already guarantees the spellings never reach the final output. """ nodes: Path = _write_ndjson(tmp_path / "n.ndjson", _records({"id": "A:1", "name": "NA"}, {"id": "A:2", "name": "b", "source": "none"})) - edges: Path = _write_ndjson(tmp_path / "e.ndjson", _records({"subject": "A:1", "object": "A:2"})) + edges: Path = _write_ndjson(tmp_path / "e.ndjson", _records({"subject": "A:1", "predicate": "biolink:related_to", "object": "A:2"})) assert study.study_kgx(nodes, edges) == [] def test_undeclared_nodes(tmp_path: Path) -> None: """Edge subject/object ids missing from the nodes file fail the undeclared assertion.""" nodes: Path = _write_ndjson(tmp_path / "n.ndjson", _records({"id": "HGNC:5", "name": "a"})) - edges: Path = _write_ndjson(tmp_path / "e.ndjson", _records({"subject": "HGNC:5", "object": "HGNC:6"}, {"subject": "HGNC:7", "object": "HGNC:5"})) + edges: Path = _write_ndjson( + tmp_path / "e.ndjson", + _records( + {"subject": "HGNC:5", "predicate": "biolink:related_to", "object": "HGNC:6"}, + {"subject": "HGNC:7", "predicate": "biolink:related_to", "object": "HGNC:5"}, + ), + ) checks: dict[str, study.StudyViolation] = _checks(study.study_kgx(nodes, edges)) assert checks["undeclared-nodes"].count == 2 assert checks["undeclared-nodes"].examples == ["HGNC:6", "HGNC:7"] @@ -210,7 +319,7 @@ def test_isolated_nodes(tmp_path: Path) -> None: nodes: Path = _write_ndjson( tmp_path / "n.ndjson", _records({"id": "HGNC:5", "name": "a"}, {"id": "HGNC:6", "name": "b"}, {"id": "HGNC:7", "name": "c"}) ) - edges: Path = _write_ndjson(tmp_path / "e.ndjson", _records({"subject": "HGNC:5", "object": "HGNC:6"})) + edges: Path = _write_ndjson(tmp_path / "e.ndjson", _records({"subject": "HGNC:5", "predicate": "biolink:related_to", "object": "HGNC:6"})) checks: dict[str, study.StudyViolation] = _checks(study.study_kgx(nodes, edges)) assert checks["isolated-nodes"].count == 1 assert checks["isolated-nodes"].examples == ["HGNC:7"] @@ -219,7 +328,10 @@ def test_isolated_nodes(tmp_path: Path) -> None: def test_malformed_lines(tmp_path: Path) -> None: """Empty lines, invalid JSON, and non-object lines all count as malformed.""" nodes, _ = _clean(tmp_path) - edges: Path = _write_ndjson(tmp_path / "e.ndjson", [json.dumps({"subject": "HGNC:5", "object": "HGNC:6"}), "", "{not json", '["a list"]']) + edges: Path = _write_ndjson( + tmp_path / "e.ndjson", + [json.dumps({"subject": "HGNC:5", "predicate": "biolink:related_to", "object": "HGNC:6"}), "", "{not json", '["a list"]'], + ) checks: dict[str, study.StudyViolation] = _checks(study.study_kgx(nodes, edges)) assert checks["malformed-lines"].label == "edges" assert checks["malformed-lines"].count == 3 @@ -228,7 +340,7 @@ def test_malformed_lines(tmp_path: Path) -> None: def test_whitespace_values(tmp_path: Path) -> None: """Leading/trailing whitespace in string values is counted per field.""" nodes: Path = _write_ndjson(tmp_path / "n.ndjson", _records({"id": "HGNC:5", "name": " padded"}, {"id": "HGNC:6 ", "name": "x"})) - edges: Path = _write_ndjson(tmp_path / "e.ndjson", _records({"subject": "HGNC:5", "object": "HGNC:6"})) + edges: Path = _write_ndjson(tmp_path / "e.ndjson", _records({"subject": "HGNC:5", "predicate": "biolink:related_to", "object": "HGNC:6"})) checks: dict[str, study.StudyViolation] = _checks(study.study_kgx(nodes, edges)) violation: study.StudyViolation = checks["whitespace-values"] assert violation.label == "nodes" @@ -246,7 +358,10 @@ def test_whitespace_allowed_in_original_fields(tmp_path: Path) -> None: nodes: Path = _write_ndjson( tmp_path / "n.ndjson", _records({"id": "HGNC:5", "original_name": " padded source ", "name": " padded"}, {"id": "HGNC:6", "name": "b"}) ) - edges: Path = _write_ndjson(tmp_path / "e.ndjson", _records({"subject": "HGNC:5", "object": "HGNC:6", "original_subject": " raw gene "})) + edges: Path = _write_ndjson( + tmp_path / "e.ndjson", + _records({"subject": "HGNC:5", "predicate": "biolink:related_to", "object": "HGNC:6", "original_subject": " raw gene "}), + ) checks: dict[str, study.StudyViolation] = _checks(study.study_kgx(nodes, edges)) violation: study.StudyViolation = checks["whitespace-values"] # Only the genuinely padded `name` field is flagged; the `original_*` slots are not.