Skip to content

Commit 01ac6e3

Browse files
authored
Merge pull request #22 from ecrum19/fix/missing-token-header-number
Stop the missing-token check reporting a header's own Number=.
2 parents a072b95 + f43ec15 commit 01ac6e3

4 files changed

Lines changed: 165 additions & 2 deletions

File tree

‎docs/conversion.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,15 @@ The vocabulary's `vcfc:missingValuePolicy` says a missing token should be
436436
reports a plain `"."` literal as an anomaly, and `--strict-conformance` promotes
437437
that from a report to a failure.
438438

439+
Not every `"."` is a missing value, and the check excludes the three places the
440+
vocabulary requires a bare dot: `vcfc:fieldNumber` (`Number=.` is VCF's
441+
variable-cardinality arity), `vcfc:genotypeString` (a fully missing call is
442+
literally `.` or `./.`), and the `vcfc:attributeValue` of a header attribute
443+
whose `vcfc:attributeKey` is `Number` — the structured-header layer carries each
444+
declaration's attributes verbatim, so `Number=.` appears there too, and the
445+
shapes require that value to be `xsd:string`. A bare dot anywhere else, including
446+
on any other header attribute, is still reported.
447+
439448
This used to conflict with the published SHACL shapes, which constrained
440449
`vcfc:alt` to `sh:datatype xsd:string` and so rejected every REF-only and
441450
gVCF-style record. **The vocabulary fixed that**: `vcfc:VCFRecordShape` now

‎src/validation/queries/common/preflight_missing_token_conformance.rq‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,22 @@ PREFIX vcfc: <https://w3id.org/vcf-core/vocab#>
1616
# header as a conformance failure. On the 100,000-record HG005 benchmark cell
1717
# it returned 20 such rows, and all of them were declarations the vocabulary
1818
# requires to look exactly that way.
19+
#
20+
# The same token appears once more, in the structured-header attribute layer:
21+
# every declaration's attributes are also carried verbatim as
22+
# vcfc:HeaderAttribute resources, so Number=. becomes an attributeValue of "."
23+
# on the attribute whose attributeKey is "Number". The shapes require that
24+
# value to be exactly xsd:string, so it can never be vcfc:Null. With only the
25+
# two predicates above excluded, the HG005 cell still returned ten rows -- one
26+
# per Number=. INFO declaration in its header. Only the Number attribute is
27+
# excluded; a bare "." on any other attribute is still reported.
1928
SELECT ?s ?p ?o (DATATYPE(?o) AS ?datatype)
2029
WHERE {
2130
?s ?p ?o .
2231
FILTER(ISLITERAL(?o) && STR(?o) = ".")
2332
FILTER(DATATYPE(?o) != vcfc:Null)
2433
FILTER(?p NOT IN (vcfc:fieldNumber, vcfc:genotypeString))
34+
OPTIONAL { ?s vcfc:attributeKey ?attributeKey }
35+
FILTER(?p != vcfc:attributeValue || !BOUND(?attributeKey) || STR(?attributeKey) != "Number")
2536
}
2637
LIMIT 100
Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
PREFIX vcfc: <https://w3id.org/vcf-core/vocab#>
22

33
# Exact number of missing tokens serialized as a plain "." literal.
4-
# The predicate exclusions must match preflight_missing_token_conformance.rq
5-
# exactly, or the count and its sample describe different populations.
4+
# The exclusions -- the two predicates and the header Number attribute -- must
5+
# match preflight_missing_token_conformance.rq exactly, or the count and its
6+
# sample describe different populations.
67
SELECT (COUNT(*) AS ?anomalyCount)
78
WHERE {
89
?s ?p ?o .
910
FILTER(ISLITERAL(?o) && STR(?o) = ".")
1011
FILTER(DATATYPE(?o) != vcfc:Null)
1112
FILTER(?p NOT IN (vcfc:fieldNumber, vcfc:genotypeString))
13+
OPTIONAL { ?s vcfc:attributeKey ?attributeKey }
14+
FILTER(?p != vcfc:attributeValue || !BOUND(?attributeKey) || STR(?attributeKey) != "Number")
1215
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""The missing-token check must not report a header's own Number=. declaration.
2+
3+
``preflight_missing_token_conformance`` looks for bare "." literals that should
4+
have been typed ``vcfc:Null``. It already excluded ``vcfc:fieldNumber`` and
5+
``vcfc:genotypeString``, where a dot is conformant. The same token also reaches
6+
the graph a third way: the structured-header attribute layer carries every
7+
declaration's attributes verbatim, so ``Number=.`` becomes a
8+
``vcfc:attributeValue`` of "." on the attribute whose key is "Number". The
9+
shapes require that value to be exactly ``xsd:string``, so it can never be
10+
``vcfc:Null``.
11+
12+
On the v3.1.0 benchmark the narrowed check still returned ten rows on the
13+
100,000-record HG005 cell -- one per ``Number=.`` INFO declaration in its
14+
header. These tests run both forms of the query against a graph produced by the
15+
real header emitter, so the exclusion is checked end to end rather than by
16+
string inspection alone.
17+
"""
18+
19+
import tempfile
20+
import unittest
21+
from pathlib import Path
22+
23+
# The behavioural tests need rdflib to evaluate SPARQL; the rest of the suite
24+
# runs without it, so they skip rather than erroring discovery -- the same
25+
# pattern test_linking_unit.py and test_validation_mutation_unit.py use.
26+
try:
27+
import rdflib
28+
except ModuleNotFoundError: # pragma: no cover - exercised only without rdflib
29+
rdflib = None
30+
31+
import vcf_rdfizer
32+
from test.helpers import VerboseTestCase
33+
34+
VCFC = "https://w3id.org/vcf-core/vocab#"
35+
QUERY_DIR = (
36+
Path(vcf_rdfizer.__file__).resolve().parent
37+
/ "src" / "validation" / "queries" / "common"
38+
)
39+
SAMPLE_QUERY = QUERY_DIR / "preflight_missing_token_conformance.rq"
40+
COUNT_QUERY = QUERY_DIR / "preflight_missing_token_conformance_count.rq"
41+
42+
HEADERS_TSV = (
43+
"SOURCE_FILE\tHEADER_INDEX\tHEADER_KEY\tHEADER_VALUE\tRAW_LINE\n"
44+
"s.vcf\t1\tfileformat\tVCFv4.2\tx\n"
45+
"s.vcf\t2\tINFO\t<ID=platformnames,Number=.,Type=String,Description=\"Platforms\">\tx\n"
46+
"s.vcf\t3\tINFO\t<ID=callsets,Number=1,Type=Integer,Description=\"Call sets\">\tx\n"
47+
"s.vcf\t4\tFORMAT\t<ID=AD,Number=.,Type=Integer,Description=\"Allelic depths\">\tx\n"
48+
)
49+
50+
51+
def header_graph(tmp_path: Path) -> "rdflib.Graph":
52+
"""Emit the structured header layer exactly as a conversion would."""
53+
headers_tsv = tmp_path / "s.header_lines.tsv"
54+
headers_tsv.write_text(HEADERS_TSV, encoding="utf-8")
55+
rdf_path = tmp_path / "s.nt"
56+
rdf_path.write_text("", encoding="utf-8")
57+
vcf_rdfizer.append_header_representation_rdf(headers_tsv, rdf_path)
58+
graph = rdflib.Graph()
59+
graph.parse(rdf_path, format="nt")
60+
return graph
61+
62+
63+
def sample_rows(graph) -> list:
64+
return list(graph.query(SAMPLE_QUERY.read_text(encoding="utf-8")))
65+
66+
67+
def anomaly_count(graph) -> int:
68+
rows = list(graph.query(COUNT_QUERY.read_text(encoding="utf-8")))
69+
return int(rows[0][0])
70+
71+
72+
@unittest.skipIf(rdflib is None, "rdflib is required to evaluate the queries")
73+
class HeaderNumberAttributeTests(VerboseTestCase):
74+
"""The header's Number=. is a declaration, not a missing value."""
75+
76+
def test_the_emitter_really_writes_the_dot_this_test_is_about(self):
77+
"""Guard the premise: without it the other tests would pass vacuously."""
78+
with tempfile.TemporaryDirectory() as td:
79+
graph = header_graph(Path(td))
80+
number_values = {
81+
str(value)
82+
for attribute, _, key in graph.triples(
83+
(None, rdflib.URIRef(VCFC + "attributeKey"), None))
84+
if str(key) == "Number"
85+
for value in graph.objects(attribute, rdflib.URIRef(VCFC + "attributeValue"))
86+
}
87+
self.assertIn(".", number_values)
88+
89+
def test_number_dot_declarations_are_not_reported(self):
90+
with tempfile.TemporaryDirectory() as td:
91+
graph = header_graph(Path(td))
92+
self.assertEqual(sample_rows(graph), [])
93+
self.assertEqual(anomaly_count(graph), 0)
94+
95+
def test_a_bare_dot_elsewhere_is_still_reported(self):
96+
"""The exclusion is the Number attribute only, not the whole layer."""
97+
with tempfile.TemporaryDirectory() as td:
98+
graph = header_graph(Path(td))
99+
# A genuinely untyped missing value on a record field ...
100+
graph.add((
101+
rdflib.URIRef("file://s.vcf#record/1"),
102+
rdflib.URIRef(VCFC + "recordId"),
103+
rdflib.Literal("."),
104+
))
105+
# ... and a dot on a header attribute that is not Number.
106+
attribute = rdflib.URIRef("file://s.vcf#header/line/3/attribute/9")
107+
graph.add((attribute, rdflib.URIRef(VCFC + "attributeKey"), rdflib.Literal("Source")))
108+
graph.add((attribute, rdflib.URIRef(VCFC + "attributeValue"), rdflib.Literal(".")))
109+
110+
reported = {(str(row[0]), str(row[1])) for row in sample_rows(graph)}
111+
self.assertEqual(reported, {
112+
("file://s.vcf#record/1", VCFC + "recordId"),
113+
(str(attribute), VCFC + "attributeValue"),
114+
})
115+
self.assertEqual(anomaly_count(graph), 2)
116+
117+
118+
class ExclusionTextTests(VerboseTestCase):
119+
"""Static guarantees that hold without rdflib."""
120+
121+
def test_both_queries_exclude_only_the_number_attribute(self):
122+
for path in (SAMPLE_QUERY, COUNT_QUERY):
123+
text = path.read_text(encoding="utf-8")
124+
self.assertIn('STR(?attributeKey) != "Number"', text, path.name)
125+
self.assertIn("OPTIONAL { ?s vcfc:attributeKey ?attributeKey }", text, path.name)
126+
127+
def test_the_bundled_shapes_still_require_attribute_values_as_strings(self):
128+
"""If a shape ever allowed vcfc:Null here, the exclusion must be revisited."""
129+
shapes = (
130+
Path(vcf_rdfizer.__file__).resolve().parent
131+
/ "vcf_rdfizer_data" / "shacl" / "vcf-core-vocabulary.shacl.ttl"
132+
).read_text(encoding="utf-8")
133+
self.assertIn(
134+
"sh:path vcfc:attributeValue ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:string",
135+
shapes,
136+
)
137+
138+
139+
if __name__ == "__main__":
140+
unittest.main()

0 commit comments

Comments
 (0)