diff --git a/CHANGELOG.md b/CHANGELOG.md index 6353b5b..1cd0d83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this project are documented in this file. +## Unreleased + +### Added +- **`validate-kgx` can repair a graph in place with `--prune` (`-p`).** The flag rewrites the edges NDJSON in place, keeping only the edges that validate strictly: real defects (non-coercible values, extras the model will never declare, rejected predicates), the deliberate pending carryovers (`synonym`/`xref`/`relation`/`provided_by`, ...), and lines that are not JSON at all are all removed, so the final graph validates with zero failures (on a real graph the carryovers are most of the edges, so the flag trades them for compliance). The rewrite is atomic and kept lines stay byte-identical, a clean file is not rewritten at all, a missing edges file is reported without ever being pruned, nodes are never touched, and the exit code goes green as soon as the nodes are clean too. The core lives in the new public `biolink.prune_kgx_edges`, which reuses `validate_kgx`'s own strict bar, so a prune can never disagree with what a plain validation run reports. + ## 19.5.1 - 2026-10-01 ### Changed diff --git a/docs/cli.md b/docs/cli.md index 291348c..a93b3c1 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -400,6 +400,7 @@ tablassert validate-kgx --nodes MY_KG_1.0.0.nodes.ndjson --edges MY_KG_1.0.0.edg | `--nodes`, `-n` | Path | Yes | n/a | Built `*.nodes.ndjson` file to validate | | `--edges`, `-e` | Path | Yes | n/a | Built `*.edges.ndjson` file to validate | | `--limit` | int | No | `20` | Maximum example failures to retain per file | +| `--prune`, `-p` | Flag | No | `False` | Rewrite the edges file in place, keeping only edges that validate strictly (pending carryovers are dropped too); nodes are never touched, and a clean file is not rewritten | Failures are grouped by field and error type, so a systematic modelling problem shows up as one line rather than a million: @@ -429,6 +430,35 @@ edges: 1200000/2000085 valid (800085 failures; 800085 pending biolink-model supp The strict count is what `ok` and the exit code use; the pending count is what [`tablassert agent`](#agent) optimizes against, so a deliberate gap never reads as a modelling error. +### Repairing a graph with `--prune` + +`--prune` (`-p`) repairs an already-emitted graph instead of just scoring it: the edges file is +rewritten in place and every edge that does not validate strictly is removed. That includes real +defects (non-coercible values, extras the model will never declare, rejected predicates), the +deliberate pending carryovers (`synonym`, `xref`, `relation`, `provided_by`, ...), and lines that +are not JSON at all (counted separately as malformed). On a real graph the carryovers are most of +the edges -- a DAKP-style build prunes roughly 800085 of 2000085 -- so `--prune` trades them for a +strictly-compliant graph. Nodes are validated and reported, never pruned: dropping one would +strand its references. + +```bash +tablassert validate-kgx -n MY_KG_1.0.0.nodes.ndjson -e MY_KG_1.0.0.edges.ndjson --prune +``` + +The rewrite is atomic (written to a sibling temp file, then swapped in) and kept lines stay +byte-identical, so a pruned graph differs from the original only by the removed records. A clean +file is not rewritten at all, and a missing edges file is reported without ever being pruned. +After pruning, every kept edge is strictly valid, so the exit code goes green as soon as the +nodes are clean too: + +```text +edges: pruned 12/424159 non-compliant records + 9 p_value: float_parsing + e.g. DISEASE_EDGE_000017: p_value: float_parsing +edges: 424147/424159 valid (0 failures) +KGX output is Biolink-compliant. +``` + Retrieval-source entries are the mirror case. Tablassert emits `resource_id` as each `sources` entry's sole identifier, while the pinned model still requires the inherited `Entity.id` on `RetrievalSource`. The validator supplies that `id` to its own in-memory copy of the record whenever the installed model diff --git a/src/tablassert/biolink.py b/src/tablassert/biolink.py index c1fff6f..f4fc7f1 100644 --- a/src/tablassert/biolink.py +++ b/src/tablassert/biolink.py @@ -46,6 +46,7 @@ import inspect import json +import os import re from collections import Counter from enum import Enum @@ -86,6 +87,7 @@ "legal_predicates", "node_class", "numeric_slot_kind", + "prune_kgx_edges", "resolve_association_class", "resolve_node_category", "resolve_node_class", @@ -944,45 +946,201 @@ def validate_kgx(nodes_path: Path, edges_path: Path, limit: int = 20) -> dict[st """ report: dict[str, Any] = {"biolink_version": BIOLINK_VERSION, "ok": True, "ok_excluding_pending": True} for label, path, edge in (("nodes", nodes_path, False), ("edges", edges_path, True)): - total: int = 0 - valid: int = 0 - valid_excluding_pending: int = 0 - problems: Counter[str] = Counter() - examples: list[dict[str, Any]] = [] - # A missing path must never read as a clean bill of health: counting zero records - # out of zero would otherwise exit 0 on a typo'd filename and hide a broken build. - missing: bool = not path.is_file() - if not missing: - with path.open(encoding="utf-8") as handle: - for line in handle: + section: dict[str, Any] = _validate_file(path, edge=edge, limit=limit) + report[label] = section + if section["missing"] or section["total"] != section["valid"]: + report["ok"] = False + if section["missing"] or section["total"] != section["valid_excluding_pending"]: + report["ok_excluding_pending"] = False + return report + + +def _validate_file(path: Path, *, edge: bool, limit: int) -> dict[str, Any]: + """Validate one KGX NDJSON file record by record (the per-file half of ``validate_kgx``). + + Args: + path: Path to the ``.ndjson`` file; a missing path reports ``missing`` instead of + counting zero records out of zero as a pass. + edge: ``True`` to dispatch on the association family, ``False`` for nodes. + limit: Maximum number of example failures to retain. + + Returns: + The same per-file section ``validate_kgx`` nests under ``"nodes"`` / ``"edges"``. + """ + total: int = 0 + valid: int = 0 + valid_excluding_pending: int = 0 + problems: Counter[str] = Counter() + examples: list[dict[str, Any]] = [] + # A missing path must never read as a clean bill of health: counting zero records + # out of zero would otherwise exit 0 on a typo'd filename and hide a broken build. + missing: bool = not path.is_file() + if not missing: + with path.open(encoding="utf-8") as handle: + for line in handle: + if not line.strip(): + continue + total += 1 + record: dict[str, Any] = json.loads(line) + errors: list[str] = validate_record(record, edge=edge) + if not errors: + valid += 1 + valid_excluding_pending += 1 + continue + if _record_has_only_pending_problems(record, errors): + valid_excluding_pending += 1 + problems.update(errors) + if len(examples) < limit: + examples.append({"id": record.get("id"), "errors": errors}) + return { + "total": total, + "valid": valid, + "valid_excluding_pending": valid_excluding_pending, + "failures": total - valid, + "missing": missing, + "problems": dict(problems.most_common()), + "examples": examples, + } + + +def prune_kgx_edges(edges_path: Path, limit: int = 20) -> dict[str, Any]: + """Drop every edge record that is not strictly KGX/Biolink-compliant, in place. + + A record survives only when it validates against the Biolink Pydantic class named + by its own ``category`` with zero errors -- the same strict bar a plain + ``validate_kgx`` run uses for its ``ok`` verdict, so a prune can never disagree + with what validation reports: + + * a strictly valid record is kept; + * a record whose every failure is a known, intentional model gap (the KGX + denormalized carryovers in :data:`KNOWN_PENDING_EDGE_FIELDS`, or a + :data:`PREDICATE_OVERRIDES` predicate the pinned classes reject) is dropped -- + these carryovers are most of a real graph, so the caller asked for a + strictly-compliant graph over maximal data retention when invoking this; + * every other failing record is a real defect and is dropped; + * a line that is not valid JSON at all is dropped and counted separately in + ``malformed``. + + Nodes are never touched: dropping one would strand references, and cascading that + into the edges is a modelling decision, not a validation repair. + + The rewrite is ATOMIC and lossless for kept records: kept lines are written verbatim + (byte-identical, order preserved, blank lines passed through) into a sibling + ``.{name}.tmp`` and swapped in with :func:`os.replace` only once every line was + written, so a crash never leaves a torn edges file. When nothing is prunable the + destination is not rewritten at all (``rewritten`` is ``False`` and the bytes are + untouched), and a missing file is reported (``missing``) without ever being created + -- a typo'd path must never read as a prune. + + Args: + edges_path: Path to ``_.edges.ndjson``. + limit: Maximum number of example failures to retain per report section. + + Returns: + Mapping with ``before`` (the same per-file section ``validate_kgx`` builds over + the original file), ``after`` (the same shape over the kept records, i.e. the + file's post-prune state, equal to what ``validate_kgx`` reports on the rewritten + file), ``dropped`` (the ``pruned`` records' problem histogram and examples -- + the records that were removed), ``pruned`` / ``kept`` / ``malformed`` / + ``rewritten`` counts, and ``ok`` / ``ok_excluding_pending`` computed over the + post-prune state. For a readable file ``ok`` is therefore always true -- every + kept record is strictly valid -- and ``ok_excluding_pending`` agrees, because + nothing pending survives the prune. + + Raises: + OSError: the destination's directory is unwritable; the error propagates and the + original file keeps its previous content. + """ + total: int = 0 + valid: int = 0 + pending: int = 0 # pending-only failures: counted for the BEFORE report, never kept + pruned: int = 0 + malformed: int = 0 + all_problems: Counter[str] = Counter() + all_examples: list[dict[str, Any]] = [] + pruned_problems: Counter[str] = Counter() + pruned_examples: list[dict[str, Any]] = [] + # A missing path must never read as a clean bill of health: reporting zero records + # out of zero would otherwise exit 0 on a typo'd filename and hide a broken build. + missing: bool = not edges_path.is_file() + if not missing: + tmp: Path = edges_path.with_name(f".{edges_path.name}.tmp") + try: + # newline="" on both ends keeps every kept line byte-identical: the default + # universal-newlines mode would silently rewrite "\r\n" as "\n". + with edges_path.open(encoding="utf-8", newline="") as src, tmp.open("w", encoding="utf-8", newline="") as dst: + for line in src: if not line.strip(): + dst.write(line) # blank lines are not records; pass through verbatim continue total += 1 - record: dict[str, Any] = json.loads(line) - errors: list[str] = validate_record(record, edge=edge) - if not errors: - valid += 1 - valid_excluding_pending += 1 + try: + record: dict[str, Any] = json.loads(line) + except json.JSONDecodeError: + malformed += 1 + pruned += 1 + pruned_problems["record: json_parse_error"] += 1 + if len(pruned_examples) < limit: + pruned_examples.append({"id": None, "errors": ["record: json_parse_error"]}) continue - if _record_has_only_pending_problems(record, errors): - valid_excluding_pending += 1 - problems.update(errors) - if len(examples) < limit: - examples.append({"id": record.get("id"), "errors": errors}) - report[label] = { - "total": total, - "valid": valid, - "valid_excluding_pending": valid_excluding_pending, - "failures": total - valid, - "missing": missing, - "problems": dict(problems.most_common()), - "examples": examples, - } - if missing or total != valid: - report["ok"] = False - if missing or total != valid_excluding_pending: - report["ok_excluding_pending"] = False - return report + errors: list[str] = validate_record(record, edge=True) + all_problems.update(errors) + if len(all_examples) < limit: + all_examples.append({"id": record.get("id"), "errors": errors}) + if errors: + # The before-section keeps validate_kgx's pending score so the + # pre-report matches a plain validation run; the STRICT bar still + # drops the record -- real defects AND deliberate pending gaps. + if _record_has_only_pending_problems(record, errors): + pending += 1 + pruned += 1 + pruned_problems.update(errors) + if len(pruned_examples) < limit: + pruned_examples.append({"id": record.get("id"), "errors": errors}) + continue + valid += 1 + dst.write(line) + # ATOMIC: the swap happens only after every kept line was written, so a crash + # mid-write leaves the original file untouched and the temp never read back. + rewritten: bool = pruned > 0 + if rewritten: + os.replace(tmp, edges_path) + finally: + tmp.unlink(missing_ok=True) # no-op after a successful replace; cleanup after a raise + else: + rewritten = False + kept: int = total - pruned + before: dict[str, Any] = { + "total": total, + "valid": valid, + "valid_excluding_pending": valid + pending, + "failures": total - valid, + "missing": missing, + "problems": dict(all_problems.most_common()), + "examples": all_examples, + } + dropped: dict[str, Any] = {"total": pruned, "problems": dict(pruned_problems.most_common()), "examples": pruned_examples} + after: dict[str, Any] = { + "total": kept, + "valid": valid, + "valid_excluding_pending": valid, # nothing pending survives, so the scores agree + "failures": kept - valid, + "missing": missing, + "problems": {}, + "examples": [], + } + return { + "biolink_version": BIOLINK_VERSION, + "before": before, + "after": after, + "dropped": dropped, + "pruned": pruned, + "kept": kept, + "malformed": malformed, + "rewritten": rewritten, + "ok": not missing and kept == valid, + "ok_excluding_pending": not missing and kept == valid, + } # Node slots Tablassert can always populate from a fullmap hit. A class requiring diff --git a/src/tablassert/cli.py b/src/tablassert/cli.py index edd8e54..24ab16c 100644 --- a/src/tablassert/cli.py +++ b/src/tablassert/cli.py @@ -955,11 +955,29 @@ def validate( run(3, validate_pipeline, configuration_file) +def _print_kgx_section(label: str, section: dict[str, Any], path: Path) -> None: + """Print one ``validate-kgx`` per-file section (shared by the plain and ``--prune`` paths).""" + if section["missing"]: + # Never let a typo'd path read as a pass: 0/0 valid would otherwise exit 0. + print(f"{label}: file not found ({path})", file=sys.stderr) + logger.info(f"validate-kgx {label}: file not found") + return + pending: int = section["valid_excluding_pending"] - section["valid"] + suffix: str = f"; {pending} pending biolink-model support" if pending else "" + print(f"{label}: {section['valid']}/{section['total']} valid ({section['failures']} failures{suffix})", file=sys.stderr) + for problem, count in section["problems"].items(): + print(f" {count:>9} {problem}", file=sys.stderr) + for example in section["examples"][:3]: + print(f" e.g. {example['id']}: {', '.join(example['errors'])}", file=sys.stderr) + logger.info(f"validate-kgx {label}: {section['valid']}/{section['total']} valid") + + @APP.command(name="validate-kgx") def validate_kgx_command( nodes: Annotated[Path, cyclopts.Parameter(name=["--nodes", "-n"])], edges: Annotated[Path, cyclopts.Parameter(name=["--edges", "-e"])], limit: Annotated[int, cyclopts.Parameter(name=["--limit"])] = 20, + prune: Annotated[bool, cyclopts.Parameter(name=["--prune", "-p"])] = False, ) -> None: """Validate built KGX NDJSON against the Biolink Model. @@ -968,33 +986,60 @@ def validate_kgx_command( and reports failures grouped by field and error type. Exits non-zero when any record fails, so a build can be gated in CI. + ``--prune`` rewrites the EDGES file in place, keeping only edges that validate + strictly. Real defects (non-coercible values, extras the model will never declare, + rejected predicates), the deliberate pending carryovers (``synonym``/``xref``/ + ``relation``/``provided_by``, ...), and lines that are not JSON at all are all + removed. On a real graph the carryovers are most of the edges, so ``--prune`` + trades them for a strictly-compliant graph. The rewrite is atomic and kept lines + stay byte-identical; a clean file is not rewritten at all, and a missing edges file + is reported without ever being pruned. Nodes are validated and reported, never + pruned. The exit code stays strict over the post-prune state: after a prune, every + kept edge is strictly valid, so the run is compliant exactly when the nodes are. + Args: nodes: Path to the built nodes NDJSON file (``_.nodes.ndjson``). edges: Path to the built edges NDJSON file (``_.edges.ndjson``). limit: Maximum example failures retained per file for the ``e.g.`` report lines; every record is still validated regardless of this cap. A missing file is reported and fails the run rather than reading as a pass. + prune: Rewrite the edges file in place, removing every edge record that is not + strictly KGX/Biolink-compliant, including the deliberate pending carryovers. + The nodes file is never modified; a clean edges file is left untouched. """ - from tablassert.biolink import validate_kgx - - report: dict[str, Any] = validate_kgx(nodes, edges, limit=limit) - print(f"biolink-model {report['biolink_version']}", file=sys.stderr) - for label in ("nodes", "edges"): - section: dict[str, Any] = report[label] - if section["missing"]: - # Never let a typo'd path read as a pass: 0/0 valid would otherwise exit 0. - print(f"{label}: file not found ({nodes if label == 'nodes' else edges})", file=sys.stderr) - logger.info(f"validate-kgx {label}: file not found") - continue - pending: int = section["valid_excluding_pending"] - section["valid"] - suffix: str = f"; {pending} pending biolink-model support" if pending else "" - print(f"{label}: {section['valid']}/{section['total']} valid ({section['failures']} failures{suffix})", file=sys.stderr) - for problem, count in section["problems"].items(): - print(f" {count:>9} {problem}", file=sys.stderr) - for example in section["examples"][:3]: - print(f" e.g. {example['id']}: {', '.join(example['errors'])}", file=sys.stderr) - logger.info(f"validate-kgx {label}: {section['valid']}/{section['total']} valid") - if not report["ok"]: + from tablassert.biolink import _validate_file, prune_kgx_edges, validate_kgx + + if not prune: + report: dict[str, Any] = validate_kgx(nodes, edges, limit=limit) + print(f"biolink-model {report['biolink_version']}", file=sys.stderr) + _print_kgx_section("nodes", report["nodes"], nodes) + _print_kgx_section("edges", report["edges"], edges) + if not report["ok"]: + print("KGX output is not Biolink-compliant.", file=sys.stderr) + raise SystemExit(1) + print("KGX output is Biolink-compliant.", file=sys.stderr) + return + + # The prune path validates nodes-only itself and drives prune_kgx_edges' own single + # pass for both edges sections, so the edges file is never read and written twice. + nodes_section: dict[str, Any] = _validate_file(nodes, edge=False, limit=limit) + pruned_report: dict[str, Any] = prune_kgx_edges(edges, limit=limit) + print(f"biolink-model {pruned_report['biolink_version']}", file=sys.stderr) + _print_kgx_section("nodes", nodes_section, nodes) + _print_kgx_section("edges", pruned_report["before"], edges) + if pruned_report["before"]["missing"]: + print("KGX output is not Biolink-compliant.", file=sys.stderr) + raise SystemExit(1) + malformed_note: str = f" ({pruned_report['malformed']} malformed lines)" if pruned_report["malformed"] else "" + print(f"edges: pruned {pruned_report['pruned']}/{pruned_report['before']['total']} non-compliant records{malformed_note}", file=sys.stderr) + logger.info(f"validate-kgx edges: pruned {pruned_report['pruned']}/{pruned_report['before']['total']} (rewritten={pruned_report['rewritten']})") + for problem, count in pruned_report["dropped"]["problems"].items(): + print(f" {count:>9} {problem}", file=sys.stderr) + for example in pruned_report["dropped"]["examples"][:3]: + print(f" e.g. {example['id']}: {', '.join(example['errors'])}", file=sys.stderr) + _print_kgx_section("edges", pruned_report["after"], edges) + nodes_ok: bool = not nodes_section["missing"] and nodes_section["total"] == nodes_section["valid"] + if not (nodes_ok and pruned_report["ok"]): print("KGX output is not Biolink-compliant.", file=sys.stderr) raise SystemExit(1) print("KGX output is Biolink-compliant.", file=sys.stderr) diff --git a/tests/test_biolink.py b/tests/test_biolink.py index 9057f3d..6d65a64 100644 --- a/tests/test_biolink.py +++ b/tests/test_biolink.py @@ -42,6 +42,7 @@ is_pending_problem, legal_predicates, numeric_slot_kind, + prune_kgx_edges, resolve_association_class, validate_kgx, validate_record, @@ -701,6 +702,216 @@ def test_validate_kgx_counts_override_predicates_as_pending_not_valid(tmp_path: assert "predicate: enum" in report["edges"]["problems"] +def _prunable_edge_base() -> dict[str, Any]: + """Minimal otherwise-valid DAKP-shaped edge used by the ``prune_kgx_edges`` fixtures.""" + return { + "subject": "HGNC:11998", + "predicate": "biolink:associated_with", + "object": "MONDO:0008903", + "category": ["biolink:GeneToDiseaseAssociation"], + "knowledge_level": "statistical_association", + "agent_type": "data_analysis_pipeline", + } + + +def _write_prune_fixture(tmp_path: Path, records: list[Any]) -> Path: + """Write one record per line (raw strings pass through verbatim, for malformed lines).""" + edges: Path = tmp_path / "PRUNE_KG_1.0.0.edges.ndjson" + edges.write_text("".join(record if isinstance(record, str) else json.dumps(record) + "\n" for record in records), encoding="utf-8") + return edges + + +def test_prune_kgx_edges_keeps_only_strictly_valid_edges(tmp_path: Path) -> None: + """Prune keeps strictly valid edges and drops everything else, pending included. + + Why: ``--prune`` rewrites a built graph in place under validate_kgx's own STRICT + bar, so its keep/drop line must match what a plain validation run would reject: + a record that can never validate (a non-coercible ``p_value``, an extra the model + will never declare) and a deliberate pending carryover (the KGX denormalized + extras) are all non-compliant. The byte-identity of kept lines is pinned too: a + re-serialization pass would silently re-format numbers and rewrite the graph. + """ + edges: Path = _write_prune_fixture( + tmp_path, + [ + # Strictly valid: scientific-notation p_value coerces back to the float slot. + {**_prunable_edge_base(), "id": "e1", "p_value": "1.0000e-03"}, + # Real defect: a non-numeric string can never coerce into p_value's float. + {**_prunable_edge_base(), "id": "e2", "p_value": "not-a-number"}, + # Real defect: extra_forbidden on a field outside KNOWN_PENDING_EDGE_FIELDS. + {**_prunable_edge_base(), "id": "e3", "approval_ids": "011111|022222"}, + # Deliberate pending carryover: still not strictly valid, so it goes too. + {**_prunable_edge_base(), "id": "e4", sorted(KNOWN_PENDING_EDGE_FIELDS)[0]: "carryover"}, + ], + ) + original_lines: list[str] = edges.read_text(encoding="utf-8").splitlines(keepends=True) + + report: dict[str, Any] = prune_kgx_edges(edges) + + assert report["before"]["total"] == 4 + assert report["before"]["valid"] == 1 + assert report["before"]["valid_excluding_pending"] == 2 + assert report["pruned"] == 3 + assert report["kept"] == 1 + assert report["malformed"] == 0 + assert report["rewritten"] is True + assert report["ok_excluding_pending"] is True + assert report["ok"] is True # strict: everything kept validates cleanly + assert report["after"]["total"] == 1 + assert report["after"]["failures"] == 0 + assert {"p_value: float_parsing", "approval_ids: extra_forbidden"} <= set(report["before"]["problems"]) + # The dropped section names exactly what was removed -- the records the CLI reports + # under the pruned line -- defects and pending carryovers alike. + assert report["dropped"]["total"] == 3 + assert {"p_value: float_parsing", "approval_ids: extra_forbidden"} <= set(report["dropped"]["problems"]) + assert {example["id"] for example in report["dropped"]["examples"]} == {"e2", "e3", "e4"} + # The rewritten file holds exactly the kept lines, byte-identical and in order. + assert edges.read_text(encoding="utf-8").splitlines(keepends=True) == [original_lines[0]] + assert not (tmp_path / ".PRUNE_KG_1.0.0.edges.ndjson.tmp").exists() # no torn temp survives + + +def test_prune_kgx_edges_clean_file_is_a_no_op(tmp_path: Path) -> None: + """Pruning an already-compliant file must not rewrite it (bytes stay untouched). + + Why: ``--prune`` runs against build artifacts that may be published; a rewrite that + only round-trips JSON would churn mtimes and (via float re-formatting) bytes, so + the no-op path must leave the file exactly as it was. + """ + edges: Path = _write_prune_fixture(tmp_path, [{**_prunable_edge_base(), "id": "e1"}]) + before: bytes = edges.read_bytes() + + report: dict[str, Any] = prune_kgx_edges(edges) + + assert report["pruned"] == 0 + assert report["rewritten"] is False + assert report["ok"] is True + assert edges.read_bytes() == before + + +def test_prune_kgx_edges_never_treats_a_missing_file_as_a_pass(tmp_path: Path) -> None: + """A typo'd path reports ``missing`` and must not create or pass over a file. + + Why: ``validate_kgx`` already refuses to read a missing file as a clean bill of + health; the pruning path has strictly more power (it deletes records), so it must + refuse even harder instead of silently "pruning" nothing. + """ + edges: Path = tmp_path / "ABSENT_KG_1.0.0.edges.ndjson" + + report: dict[str, Any] = prune_kgx_edges(edges) + + assert report["before"]["missing"] is True + assert report["after"]["missing"] is True + assert report["ok"] is False + assert report["ok_excluding_pending"] is False + assert report["pruned"] == 0 + assert not edges.exists() # the prune never created the file + + +def test_prune_kgx_edges_counts_malformed_lines_as_prunable(tmp_path: Path) -> None: + """A line that is not JSON at all is dropped and counted under ``malformed``. + + Why: a torn build artifact can carry a half-written line; leaving it in place + would keep the final graph unreadable for every downstream KGX consumer, while + silently mixing it into the ordinary failure count would hide the two failure + kinds from each other. + """ + edges: Path = _write_prune_fixture( + tmp_path, [{**_prunable_edge_base(), "id": "e1"}, '{"id": "e2", "subject": "HGNC:11998", "object":\n', {**_prunable_edge_base(), "id": "e3"}] + ) + + report: dict[str, Any] = prune_kgx_edges(edges) + + assert report["malformed"] == 1 + assert report["pruned"] == 1 + assert report["kept"] == 2 + assert report["rewritten"] is True + assert report["after"]["problems"] == {} # the malformed line left no record-level problems behind + assert [json.loads(line)["id"] for line in edges.read_text(encoding="utf-8").splitlines()] == ["e1", "e3"] + + +def test_prune_kgx_edges_drops_pending_gaps_to_a_strict_clean_file(tmp_path: Path) -> None: + """A file holding only deliberate pending gaps prunes to empty and reports ok. + + Why: the strict bar is the whole point of ``--prune`` -- after it runs, the final + graph validates with zero failures, so the pending forgiveness score cannot keep a + record alive. The no-rewrite guarantee only applies when nothing is prunable; here + everything is, so the file becomes empty rather than silently keeping the gaps. + """ + edges: Path = _write_prune_fixture( + tmp_path, + [ + # Constrained Literal class rejecting an override predicate -> pending only. + { + **_prunable_edge_base(), + "id": "e1", + "subject": "CHEBI:1", + "predicate": "biolink:prevents", + "category": ["biolink:ChemicalEntityToBiologicalProcessAssociation"], + } + ], + ) + + report: dict[str, Any] = prune_kgx_edges(edges) + + assert report["pruned"] == 1 + assert report["kept"] == 0 + assert report["rewritten"] is True + assert report["ok"] is True # strict: nothing left to fail + assert report["ok_excluding_pending"] is True + assert edges.read_bytes() == b"" # the pending gap did not survive + + +def test_prune_kgx_edges_after_section_matches_a_plain_revalidation(tmp_path: Path) -> None: + """``after`` must equal what ``validate_kgx`` reports over the rewritten file. + + Why: the CLI prints the post-prune verdict straight from ``after``; if that shape + ever drifted from a real validation pass, ``--prune`` could report compliance the + next plain run would contradict. + """ + nodes: Path = tmp_path / "PRUNE_KG_1.0.0.nodes.ndjson" + nodes.write_text(json.dumps({"id": "HGNC:11998", "name": "TP53", "category": ["biolink:Gene"]}) + "\n", encoding="utf-8") + edges: Path = _write_prune_fixture( + tmp_path, + [ + {**_prunable_edge_base(), "id": "e1", "p_value": "1.0000e-03"}, + {**_prunable_edge_base(), "id": "e2", "p_value": "not-a-number"}, + # Pending-only carryover: not strictly valid, so it is dropped like a defect. + {**_prunable_edge_base(), "id": "e3", sorted(KNOWN_PENDING_EDGE_FIELDS)[0]: "carryover"}, + ], + ) + + report: dict[str, Any] = prune_kgx_edges(edges) + revalidated: dict[str, Any] = validate_kgx(nodes, edges) + + assert report["after"] == revalidated["edges"] + assert report["before"]["total"] == 3 + assert report["before"]["valid"] == 1 + assert report["before"]["valid_excluding_pending"] == 2 + assert revalidated["edges"]["total"] == 1 + assert revalidated["edges"]["failures"] == 0 # only the strictly valid edge survives + assert revalidated["ok"] is True + + +def test_prune_kgx_edges_raises_when_the_destination_cannot_be_written(tmp_path: Path) -> None: + """An unwritable destination raises instead of silently skipping the prune. + + Why: a read-only graph directory makes the rewrite impossible; failing silently + would leave the user believing defective edges were removed. The original file + must keep its previous content and the temp must be cleaned up. + """ + edges: Path = _write_prune_fixture(tmp_path, [{**_prunable_edge_base(), "id": "e1", "p_value": "not-a-number"}]) + before: bytes = edges.read_bytes() + tmp_path.chmod(0o500) # r-x: creating the sibling temp inside fails + try: + with pytest.raises(PermissionError): + prune_kgx_edges(edges) + finally: + tmp_path.chmod(0o700) + + assert edges.read_bytes() == before + assert not (tmp_path / ".PRUNE_KG_1.0.0.edges.ndjson.tmp").exists() + + def test_category_overrides_track_the_installed_model() -> None: """Every override name must still be invisible to the Entity-subclass scan. diff --git a/tests/test_cli_help_friendly.py b/tests/test_cli_help_friendly.py index 7b3dc81..d570ac5 100644 --- a/tests/test_cli_help_friendly.py +++ b/tests/test_cli_help_friendly.py @@ -129,6 +129,21 @@ def test_validate_kgx_help_documents_the_failure_example_cap() -> None: assert ".edges.ndjson" in text +def test_validate_kgx_help_documents_the_prune_flag() -> None: + """validate-kgx renders what --prune rewrites (edges in place; strict bar). + + Why: --prune has destructive power (it rewrites a build artifact in place), so an + agent must be able to learn from --help alone that it drops every non-strictly- + valid edge INCLUDING the deliberate pending carryovers, never touches nodes, and + leaves a clean file untouched -- without reading the source or docs. + """ + text = render_help(["validate-kgx"]) + assert "--prune" in text + assert "pending carryovers" in text + assert "nodes file is never modified" in text + assert "clean edges file is left untouched" in text + + ALL_COMMANDS = ["agent", "build-fullmap", "build-kg", "distill-export", "distill-weigh", "quick-map", "validate", "validate-kgx"] diff --git a/tests/test_cli_validation.py b/tests/test_cli_validation.py index d094db8..0f718fe 100644 --- a/tests/test_cli_validation.py +++ b/tests/test_cli_validation.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from pathlib import Path from typing import Any @@ -131,3 +132,110 @@ def test_validate_command_graph_branch_rejects_invalid_table(tmp_path: Path, fix with pytest.raises(SectionValidationError) as exc_info: validate(graph_file, schema="graph") assert exc_info.value.code == "section-validation-failed" + + +def _kgx_node_row() -> dict[str, Any]: + """Minimal valid node record for ``validate-kgx`` fixtures.""" + return {"id": "HGNC:11998", "name": "TP53", "category": ["biolink:Gene"]} + + +def _kgx_edge_base() -> dict[str, Any]: + """Minimal otherwise-valid edge record for ``validate-kgx`` fixtures.""" + return { + "subject": "HGNC:11998", + "predicate": "biolink:associated_with", + "object": "MONDO:0008903", + "category": ["biolink:GeneToDiseaseAssociation"], + "knowledge_level": "statistical_association", + "agent_type": "data_analysis_pipeline", + } + + +def _write_kgx_pair(tmp_path: Path, node_rows: list[dict[str, Any]], edge_lines: list[Any]) -> tuple[Path, Path]: + """Write a nodes/edges NDJSON pair (raw strings pass through verbatim, for defects).""" + nodes: Path = tmp_path / "PRUNECLI_KG_1.0.0.nodes.ndjson" + nodes.write_text("".join(json.dumps(row) + "\n" for row in node_rows), encoding="utf-8") + edges: Path = tmp_path / "PRUNECLI_KG_1.0.0.edges.ndjson" + edges.write_text("".join(line if isinstance(line, str) else json.dumps(line) + "\n" for line in edge_lines), encoding="utf-8") + return nodes, edges + + +def test_validate_kgx_prune_flag_parses() -> None: + """Guard: ``validate-kgx`` binds ``--prune``/``-p`` through the parser as a bool flag. + + The direct ``validate_kgx_command()`` calls below bypass Cyclopts; this pins the live + CLI contract -- both spellings bind to the ``prune`` parameter and default to False, + so a run without the flag can never rewrite a graph by accident. + """ + + def parse(argv: list[str]) -> dict[str, Any]: + fn, bound, _ = cli.APP.parse_args(argv, exit_on_error=False) + assert fn is cli.validate_kgx_command + return dict(bound.arguments) + + dummy: str = "x.ndjson" + assert parse(["validate-kgx", "-n", dummy, "-e", dummy, "--prune"])["prune"] is True + assert parse(["validate-kgx", "-n", dummy, "-e", dummy, "-p"])["prune"] is True + # cyclopts only binds flags the caller passed; the default stays False at call time + # (pinned by the behavior tests below, which never rewrite a graph by accident). + assert parse(["validate-kgx", "-n", dummy, "-e", dummy]).get("prune", False) is False + + +def test_validate_kgx_prune_removes_defective_edges_and_exits_zero(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Guard: ``--prune`` drops the never-compliant edge from the FINAL graph and exits 0. + + Why: the verification requirement is that after a prune the shipped edges file no + longer contains non-KGX-compliant records -- not merely that a report says so. The + kept edge must survive byte-identically, and the report must name exactly what was + removed and what remains. + """ + nodes, edges = _write_kgx_pair( + tmp_path, [_kgx_node_row()], [{**_kgx_edge_base(), "id": "keep-1"}, {**_kgx_edge_base(), "id": "drop-1", "p_value": "not-a-number"}] + ) + original_kept_line: str = json.dumps({**_kgx_edge_base(), "id": "keep-1"}) + "\n" + + cli.validate_kgx_command(nodes=nodes, edges=edges, limit=20, prune=True) + + stderr: str = capsys.readouterr().err + assert "edges: pruned 1/2 non-compliant records" in stderr + assert "p_value: float_parsing" in stderr + assert "KGX output is Biolink-compliant." in stderr + assert edges.read_text(encoding="utf-8") == original_kept_line # the defective record is GONE + + +def test_validate_kgx_prune_drops_pending_gaps_and_exits_zero(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Guard: ``--prune`` removes pending carryovers too, and the run goes green. + + Why: the strict bar is the point of the flag -- after it runs, the final graph + validates with zero failures, so a run over clean nodes exits 0. The report must + still name the removed records, and a pruned file must be EMPTY here, not quietly + holding the gaps. + """ + nodes, edges = _write_kgx_pair(tmp_path, [_kgx_node_row()], [{**_kgx_edge_base(), "id": "pending-1", "provided_by": "infores:test"}]) + + cli.validate_kgx_command(nodes=nodes, edges=edges, limit=20, prune=True) + + stderr: str = capsys.readouterr().err + assert "edges: pruned 1/1 non-compliant records" in stderr + assert "provided_by: extra_forbidden" in stderr + assert "KGX output is Biolink-compliant." in stderr + assert edges.read_bytes() == b"" # the pending gap did not survive the prune + + +def test_validate_kgx_prune_missing_edges_file_is_not_a_pass(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Guard: pruning a typo'd path reports it and exits non-zero without creating files. + + Why: the prune path has destructive power; a misspelled ``--edges`` must never read + as a successful no-op prune (0/0 valid would otherwise exit 0 and hide the mistake). + """ + nodes, edges = _write_kgx_pair(tmp_path, [_kgx_node_row()], []) + edges.unlink() # keep the nodes file; only the edges path is a typo + + with pytest.raises(SystemExit) as exc_info: + cli.validate_kgx_command(nodes=nodes, edges=edges, limit=20, prune=True) + + assert exc_info.value.code == 1 + stderr: str = capsys.readouterr().err + assert "edges: file not found" in stderr + assert "KGX output is not Biolink-compliant." in stderr + assert not edges.exists() # the prune never created the file diff --git a/tests/test_e2e_smoke.py b/tests/test_e2e_smoke.py index fd7eec6..b3960a7 100644 --- a/tests/test_e2e_smoke.py +++ b/tests/test_e2e_smoke.py @@ -22,7 +22,7 @@ import pytest from tablassert import rs -from tablassert.cli import build_pipeline, validate, validate_pipeline +from tablassert.cli import build_pipeline, validate, validate_kgx_command, validate_pipeline from tablassert.ingests import to_yaml from tablassert.progress import PipelineProgress @@ -431,3 +431,90 @@ def _build(name: str, nullable: bool) -> list[dict[str, Any]]: by_subject: dict[str, dict[str, Any]] = {edge["subject"]: edge for edge in nullable_edges} assert by_subject["CHEBI:1"]["disease_context_qualifier"] == "MONDO:2" assert "disease_context_qualifier" not in by_subject["CHEBI:2"] + + +def test_prune_command_cleans_the_final_built_graph(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, rig_factory: Any) -> None: + """validate-kgx --prune cleans the FINAL graph, not just the report: every injected + non-compliant edge is gone from the rewritten file and the pair then validates + strictly with exit 0. + + Why: the prune command's promise is about the shipped artifact, so the proof must + read the file back after the command runs -- injected defects (non-coercible values, + unforgiven extras), an injected deliberate pending carryover, and a malformed line + all have to be absent, every surviving line has to come from the real build + byte-identically, and a plain ``validate_kgx`` over the pruned pair has to agree + with the command's post-prune verdict. Anything less (report-only validation, or a + prune that silently keeps pending gaps) would let a non-compliant graph ship. + """ + from tablassert.biolink import validate_kgx + + monkeypatch.chdir(tmp_path) + (tmp_path / ".tablassert" / "store").mkdir(parents=True) + fullmap: Path = _build_real_redb(tmp_path / "fullmap") + + data: Path = tmp_path / "data.tsv" + data.write_text("brca1\tmapk1\nbrca1\tmapk1\n") + table: Path = tmp_path / "table.yaml" + to_yaml( + table, + { + "template": { + "source": {"kind": "text", "local": str(data), "url": ["https://example.com/data.tsv"], "delimiter": "\t"}, + "statement": { + "subject": {"method": "column", "encoding": "A"}, + "predicate": "associated_with", + "object": {"method": "column", "encoding": "B"}, + }, + "provenance": {"repo": "PMC", "publication": "PMC0000000"}, + } + }, + ) + graph: Path = tmp_path / "graph.yaml" + to_yaml( + graph, + { + "name": "SMOKE_KG", + "version": "1.0.0", + "tables": [str(table)], + "fullmap": str(fullmap), + "rig": rig_factory(tmp_path, infores_id="infores:smoke-kg", source_info={"description": "e2e prune graph"}), + }, + ) + build_pipeline(graph, PipelineProgress(total_stages=6)) + + nodes: Path = tmp_path / "SMOKE_KG_1.0.0.nodes.ndjson" + edges: Path = tmp_path / "SMOKE_KG_1.0.0.edges.ndjson" + built_lines: list[str] = edges.read_text(encoding="utf-8").splitlines() + base: dict[str, Any] = json.loads(built_lines[0]) + + # The injected tail mirrors how a torn or drifted artifact looks downstream of a build. + injected: list[str] = [ + json.dumps({**base, "id": "INJECT-DEFECT-1", "p_value": "not-a-number"}), + json.dumps({**base, "id": "INJECT-DEFECT-2", "approval_ids": "011111|022222"}), + json.dumps({**base, "id": "INJECT-PENDING-1", "provided_by": "infores:smoke-kg"}), + ] + edges.write_bytes(("\n".join(built_lines + injected) + "\n{ broken json\n").encode("utf-8")) + + try: + validate_kgx_command(nodes=nodes, edges=edges, limit=20, prune=True) + exit_code: int = 0 + except SystemExit as exc: + exit_code = int(exc.code or 0) + + post: dict[str, Any] = validate_kgx(nodes, edges) + + # The command's strict verdict and a plain re-validation agree: fully compliant. + assert exit_code == 0 + assert post["ok"] is True + assert post["edges"]["valid"] == post["edges"]["total"] # every remaining edge is strictly valid + assert post["edges"]["failures"] == 0 + + text: str = edges.read_text(encoding="utf-8") + for record_id in ("INJECT-DEFECT-1", "INJECT-DEFECT-2", "INJECT-PENDING-1"): + assert record_id not in text + assert "{ broken json" not in text + + # Everything that survived came from the real build, byte-identical and in order. + current: list[str] = text.splitlines() + pending_built = iter(built_lines) + assert all(line in pending_built for line in current)