Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion .github/workflows/publish-python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,24 @@ on:

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
# Pin pip instead of taking whatever released most recently. Every job used to
# run `pip install --upgrade pip`, so each run resolved dependencies with a
# different, unannounced resolver: CI was non-reproducible by construction,
# and a pip release could break the build with no change in this repository.
#
# This does NOT prevent transient index failures, and should not be mistaken
# for a fix for one. A case is on record: the pull_request run for PR #14
# failed with `no matching distributions available for your environment:
# mdurl` while the identical commit had passed 15 minutes earlier on push,
# passed on two other platforms in that same run, and passed again on re-run
# with no code change. mdurl is a pure-Python py3-none-any wheel, so nothing
# about the environment made it uninstallable -- that was the index, not the
# resolver. The right response there is a re-run. The right response to an
# unpinned toolchain is this pin.
#
# Bump deliberately, as its own commit, so a resolver change lands where it
# can be attributed instead of appearing inside an unrelated PR.
PIP_VERSION: "26.2.1"

jobs:
build:
Expand All @@ -36,7 +54,7 @@ jobs:

- name: Build package
run: |
python -m pip install --upgrade pip build twine
python -m pip install "pip==${{ env.PIP_VERSION }}" build twine
rm -rf dist build *.egg-info
python -m build
python -m twine check dist/*
Expand Down
26 changes: 22 additions & 4 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@ on:

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
# Pin pip instead of taking whatever released most recently. Every job used to
# run `pip install --upgrade pip`, so each run resolved dependencies with a
# different, unannounced resolver: CI was non-reproducible by construction,
# and a pip release could break the build with no change in this repository.
#
# This does NOT prevent transient index failures, and should not be mistaken
# for a fix for one. A case is on record: the pull_request run for PR #14
# failed with `no matching distributions available for your environment:
# mdurl` while the identical commit had passed 15 minutes earlier on push,
# passed on two other platforms in that same run, and passed again on re-run
# with no code change. mdurl is a pure-Python py3-none-any wheel, so nothing
# about the environment made it uninstallable -- that was the index, not the
# resolver. The right response there is a re-run. The right response to an
# unpinned toolchain is this pin.
#
# Bump deliberately, as its own commit, so a resolver change lands where it
# can be attributed instead of appearing inside an unrelated PR.
PIP_VERSION: "26.2.1"

jobs:
wrapper-cross-platform:
Expand Down Expand Up @@ -36,7 +54,7 @@ jobs:
# editable pulls exactly what pyproject declares and cannot drift from
# it, while tests still run against the checkout.
run: |
python -m pip install --upgrade pip
python -m pip install "pip==${{ env.PIP_VERSION }}"
python -m pip install -e .

- name: Run cross-platform wrapper tests (no real external tools)
Expand Down Expand Up @@ -68,7 +86,7 @@ jobs:
# editable pulls exactly what pyproject declares and cannot drift from
# it, while tests still run against the checkout.
run: |
python -m pip install --upgrade pip
python -m pip install "pip==${{ env.PIP_VERSION }}"
python -m pip install -e .

- name: Run full unit test suite
Expand All @@ -91,7 +109,7 @@ jobs:

- name: Run coverage
run: |
python -m pip install --upgrade pip coverage
python -m pip install "pip==${{ env.PIP_VERSION }}" coverage
python -m pip install -e .
coverage run -m unittest discover -s test -p "test_*_unit.py"
coverage xml -o coverage.xml
Expand Down Expand Up @@ -127,7 +145,7 @@ jobs:

- name: Build + install wheel
run: |
python -m pip install --upgrade pip build
python -m pip install "pip==${{ env.PIP_VERSION }}" build
python -m build
python -c "import glob,subprocess,sys; wheels=sorted(glob.glob('dist/*.whl')); wheels or (_ for _ in ()).throw(SystemExit('No wheel files found in dist/')); print(f'Installing wheel: {wheels[-1]}'); subprocess.check_call([sys.executable,'-m','pip','install',wheels[-1]])"
python -m vcf_rdfizer --help
22 changes: 21 additions & 1 deletion .github/workflows/validation-mutation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,26 @@ on:
pull_request:
workflow_dispatch:

env:
# Pin pip instead of taking whatever released most recently. Every job used to
# run `pip install --upgrade pip`, so each run resolved dependencies with a
# different, unannounced resolver: CI was non-reproducible by construction,
# and a pip release could break the build with no change in this repository.
#
# This does NOT prevent transient index failures, and should not be mistaken
# for a fix for one. A case is on record: the pull_request run for PR #14
# failed with `no matching distributions available for your environment:
# mdurl` while the identical commit had passed 15 minutes earlier on push,
# passed on two other platforms in that same run, and passed again on re-run
# with no code change. mdurl is a pure-Python py3-none-any wheel, so nothing
# about the environment made it uninstallable -- that was the index, not the
# resolver. The right response there is a re-run. The right response to an
# unpinned toolchain is this pin.
#
# Bump deliberately, as its own commit, so a resolver change lands where it
# can be attributed instead of appearing inside an unrelated PR.
PIP_VERSION: "26.2.1"

jobs:
mutation-score:
name: mutation score (rdflib)
Expand All @@ -26,7 +46,7 @@ jobs:
python-version: "3.11"

- name: Install test dependencies
run: python -m pip install --upgrade pip rdflib
run: python -m pip install "pip==${{ env.PIP_VERSION }}" rdflib

- name: Run the mutation harness
env:
Expand Down
46 changes: 41 additions & 5 deletions src/validation/validation_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -760,8 +760,8 @@ def _count_definitions(predicates: dict[str, int], numbers: list[str]) -> None:


def expected_census(
parser: dict[str, Any], representation: str, *, info_representation: str = "structured",
header_representation: str = "structured",
parser: dict[str, Any], representation: str, *, info_representation: str,
header_representation: str,
) -> dict[str, list[dict[str, Any]]]:
"""Predicate and class counts the graph must contain, and nothing else."""
records = parser["totalRecords"]
Expand Down Expand Up @@ -1188,8 +1188,8 @@ def parse_header_metadata(raw_header: str) -> dict[str, Any]:


def attach_census_expectations(
parser: dict[str, Any], representation: str, *, info_representation: str = "structured",
header_representation: str = "structured",
parser: dict[str, Any], representation: str, *, info_representation: str,
header_representation: str,
) -> dict[str, Any]:
"""Add the expected predicate/class inventory to a parser summary.

Expand All @@ -1206,6 +1206,12 @@ def attach_census_expectations(
else "_condensedFormatValueDigest"
)
parser["q13_format_value_digest"] = parser.get(key, [])
# The INFO value digest hashes the decomposed value items, which only the
# structured representation emits. Under raw INFO the column stays one
# opaque literal, so the query matches nothing and the expectation is
# empty -- not the digest of values the graph was never asked to produce.
if info_representation != "structured":
parser["q12_info_value_digest"] = []
return parser


Expand Down Expand Up @@ -3469,7 +3475,12 @@ def run_validation(args: argparse.Namespace) -> int:
)
parse_seconds = time.monotonic() - oracle_started
census_started = time.monotonic()
parser = attach_census_expectations(parser, args.representation)
parser = attach_census_expectations(
parser,
args.representation,
info_representation=args.info_representation,
header_representation=args.header_representation,
)
oracle_phases["censusSeconds"] = time.monotonic() - census_started
oracle_seconds = {
"parse": parse_seconds,
Expand Down Expand Up @@ -3774,6 +3785,31 @@ def build_arg_parser() -> argparse.ArgumentParser:
help="Override artifact format detection (default: infer from the filename)",
)
parser.add_argument("--representation", choices=("expanded", "condensed"), required=True)
# The oracle's expectation depends on these exactly as much as it does on
# --representation: the allele layer, value items, SV carriers and parsed
# genotype layer are only emitted for structured INFO, and the '##' line
# detail only for structured headers. Without them the oracle expected a
# structured graph for every run and any --info-representation raw
# conversion failed validation with the whole structured layer reported
# missing.
parser.add_argument(
"--info-representation",
choices=("raw", "structured"),
default="structured",
help=(
"How the INFO column was emitted in the graph under test. Must match "
"the conversion; the wrapper passes it automatically."
),
)
parser.add_argument(
"--header-representation",
choices=("basic", "structured"),
default="structured",
help=(
"How the '##' meta-information lines were emitted in the graph under "
"test. Must match the conversion; the wrapper passes it automatically."
),
)
parser.add_argument("--results-dir", type=Path, required=True)
parser.add_argument("--dataset-id", required=True)
parser.add_argument("--filter-oracle", choices=("auto", "bcftools", "cyvcf2"), default="auto")
Expand Down
80 changes: 80 additions & 0 deletions test/test_validation_engines_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,8 @@ def test_validation_command_carries_engine_and_format(self):
vcf_path=vcf_path,
rdf_path=hdt_path,
representation="condensed",
info_representation="structured",
header_representation="structured",
validation_id="cohort",
results_dir=tmp_path / "results",
metrics_dir=tmp_path / "metrics",
Expand All @@ -744,6 +746,82 @@ def test_validation_command_carries_engine_and_format(self):
self.assertEqual(command[command.index("--qlever-memory-gb") + 1], "12")
self.assertIn("--qlever-index-arg", command)

def test_representation_flags_reach_the_validation_runner(self):
"""The oracle's expectation depends on these; the wrapper must forward them.

Regression: the runner defaulted info/header representation to
"structured" and the wrapper never passed either, so every
--info-representation raw conversion validated against a structured
oracle and failed with the whole structured layer reported missing
(allele layer, value items, InfoFieldValue, the INFO digest). The oracle
itself was already correct and unit-tested for raw -- only the wiring
between wrapper and runner was missing, which no test covered.
"""
with tempfile.TemporaryDirectory() as td:
tmp_path = Path(td)
vcf_path = tmp_path / "cohort.vcf"
vcf_path.write_text("##fileformat=VCFv4.2\n#CHROM\tPOS\n", encoding="utf-8")
nt_path = tmp_path / "cohort.nt"
nt_path.write_text("", encoding="utf-8")
commands = []

with mock.patch.object(
vcf_rdfizer, "run", side_effect=lambda cmd, **kw: commands.append(cmd) or 0
):
vcf_rdfizer.run_validation_mode(
vcf_path=vcf_path,
rdf_path=nt_path,
representation="expanded",
info_representation="raw",
header_representation="basic",
validation_id="cohort",
results_dir=tmp_path / "results",
metrics_dir=tmp_path / "metrics",
run_id="RID",
timestamp="TS",
image_ref="example/vcf-rdfizer:latest",
filter_oracle="auto",
wrapper_log_path=tmp_path / "wrapper.log",
)
command = commands[0]
self.assertEqual(command[command.index("--info-representation") + 1], "raw")
self.assertEqual(command[command.index("--header-representation") + 1], "basic")

def test_the_runner_cli_accepts_what_the_wrapper_sends(self):
"""Both ends of the container boundary must agree on the flag names."""
args = V.build_arg_parser().parse_args([
"--vcf", "a.vcf", "--rdf", "a.nt", "--representation", "expanded",
"--info-representation", "raw", "--header-representation", "basic",
"--results-dir", "r", "--dataset-id", "d",
])
self.assertEqual(args.info_representation, "raw")
self.assertEqual(args.header_representation, "basic")

def test_raw_info_expects_no_value_digest(self):
"""q12 hashes decomposed INFO value items, which raw mode never emits.

Regression: with the representation threaded through, q09/q10 came back
clean but q12 still reported all 256 buckets missing, because the digest
is computed from the VCF in parse_vcf and was never gated on how the
INFO column was actually emitted.
"""
from test import validation_fixtures as fixtures
structured = V.attach_census_expectations(
fixtures.parser_summary("expanded"), "expanded",
info_representation="structured", header_representation="structured",
)
self.assertNotEqual(structured["q12_info_value_digest"], [])
raw = V.attach_census_expectations(
fixtures.parser_summary("expanded"), "expanded",
info_representation="raw", header_representation="structured",
)
self.assertEqual(raw["q12_info_value_digest"], [])

def test_census_expectations_refuse_to_guess(self):
"""No silent default: a caller that forgets must fail, not mis-expect."""
with self.assertRaises(TypeError):
V.attach_census_expectations({}, "expanded")

def test_unsupported_artifact_is_rejected_by_the_wrapper(self):
with tempfile.TemporaryDirectory() as td:
tmp_path = Path(td)
Expand All @@ -756,6 +834,8 @@ def test_unsupported_artifact_is_rejected_by_the_wrapper(self):
vcf_path=vcf_path,
rdf_path=bogus,
representation="expanded",
info_representation="structured",
header_representation="structured",
validation_id="cohort",
results_dir=tmp_path / "results",
metrics_dir=tmp_path / "metrics",
Expand Down
4 changes: 4 additions & 0 deletions test/test_vcf_rdfizer_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,8 @@ def fake_run(cmd, cwd=None, env=None):
vcf_path=vcf_path,
rdf_path=rdf_path,
representation="expanded",
info_representation="structured",
header_representation="structured",
validation_id="sample",
results_dir=results_dir,
metrics_dir=metrics_dir,
Expand Down Expand Up @@ -3110,6 +3112,8 @@ def fake_run(cmd, cwd=None, env=None):
vcf_path=vcf_path,
rdf_path=rdf_path,
representation="expanded",
info_representation="structured",
header_representation="structured",
validation_id="sample",
results_dir=results_dir,
metrics_dir=metrics_dir,
Expand Down
10 changes: 10 additions & 0 deletions vcf_rdfizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7578,6 +7578,8 @@ def fail_current(stage: str, message: str):
rdf_path=target["path"],
rdf_format=target["format"],
representation=sample_workflow.representation,
info_representation=info_representation,
header_representation=header_representation,
validation_id=target_id,
results_dir=target_results_dir,
metrics_dir=metrics_dir,
Expand Down Expand Up @@ -8446,6 +8448,8 @@ def run_validation_mode(
vcf_path: Path,
rdf_path: Path,
representation: str,
info_representation: str,
header_representation: str,
validation_id: str,
results_dir: Path,
metrics_dir: Path,
Expand Down Expand Up @@ -8570,6 +8574,10 @@ def run_validation_mode(
*engine_args,
"--representation",
representation,
"--info-representation",
info_representation,
"--header-representation",
header_representation,
"--results-dir",
"/data/validation",
"--dataset-id",
Expand Down Expand Up @@ -9802,6 +9810,8 @@ def execute_mode():
rdf_path=validation_rdf_gzip_path,
rdf_format=validation_rdf_format,
representation=args.sample_representation,
info_representation=args.info_representation,
header_representation=args.header_representation,
validation_id=validation_id,
results_dir=validation_results_dir,
metrics_dir=metrics_dir,
Expand Down
Loading