What the conversion actually does to a VCF, in order, and what it decides on
your behalf. The companion documents are
architecture.md (how the pieces fit),
rml-mappings.md (how to change the mapping), and
vcf-coverage.md (which VCF element ends up where).
--input takes a single file or a directory.
- A file must be named
*.vcfor*.vcf.gz. Nothing else is accepted — not.bcf, not.vcf.bgz, not a bare.gz. Extension, not content, decides. - A directory is enumerated one level deep, sorted, at the moment the run starts. Files that appear later are not picked up. Subdirectories are ignored.
- Each input is processed independently and end to end before the next one begins. A failure is isolated to its input.
The output basename is the filename with .vcf / .vcf.gz removed. Two inputs
that reduce to the same basename would collide, and the pre-flight collision
check rejects the run before Docker starts.
src/vcf_as_tsv.sh reads the VCF (through gzip -dc
when compressed) in one awk pass and writes three tab-separated tables.
| Output | One row per | Columns |
|---|---|---|
<sample>.file_metadata.tsv |
file (exactly one row) | SOURCE_FILE, FILE_FORMAT, FILE_DATE, SOURCE_SOFTWARE, REFERENCE_GENOME, HEADER_COUNT, RECORD_COUNT |
<sample>.header_lines.tsv |
## meta-information line |
SOURCE_FILE, HEADER_INDEX, HEADER_KEY, HEADER_VALUE, RAW_LINE |
<sample>.records.tsv |
VCF data line | SOURCE_FILE, ROW_ID, CHROM, POS, ID, REF, ALT, QUAL, FILTER, INFO, FORMAT, (sample payload) |
Two more paths exist for compatibility, sample_calls.tsv and
sample_format_values.tsv. Under the shipped mapping they are written
header-only — see §5.
What this stage does, precisely:
##lines are split on the first=. Everything after it isHEADER_VALUE;RAW_LINEkeeps the original text minus the leading##. No structural parsing happens here.- Four keys are lifted into
file_metadata.tsvby case-insensitive match:fileformat,filedate,source,reference. HEADER_INDEXcounts##lines and the#CHROMline, 1-based.ROW_IDis a 1-based counter over data lines, stable within one file. It is the join key for every record-level IRI.- Trailing carriage returns are stripped from every field.
- All sample columns are joined into one trailing field separated by single
spaces, and runs of whitespace are collapsed. The column's name is the
whitespace-joined sample IDs from
#CHROM, or the literalSAMPLESwhen the VCF declares none — which is why a mapping cannot reference it by a fixed name.
Limitations of this stage, stated plainly. There is no htslib here. The
parser is regex-and-field-index awk, which means:
- The
#CHROMline is recognised only when it is tab-delimited. A space-delimited header line is matched by no rule, so sample column names are lost and the records header falls back toSAMPLES— silently. - Nothing validates VCF spec conformance. A malformed file produces a malformed graph rather than an error, and the first thing that notices is the validation suite.
- A data line with fewer than 8 columns yields empty strings for the missing fields rather than an error.
- Because sample fields are whitespace-normalized, a value containing a literal space would be corrupted. The VCF specification forbids spaces in data fields, so this is only reachable with an already-invalid file — but it is not detected.
src/run_conversion.sh runs RMLStreamer 2.5.0 over
the TSVs using the mapping at --rules (default
rules/default_rules.ttl), then normalizes the
Flink part files and merges them into one aggregate.
The mapping's five csvw:url values are literal container paths
(/data/tsv/records.tsv and friends) that the wrapper rewrites per input to
/data/tsv/<sample>.records.tsv. That rewrite is why the contract in
rml-mappings.md insists those strings stay verbatim.
--rdf-storage-mode decides how the aggregate is assembled:
| Mode | Behaviour |
|---|---|
plain |
Parts are merged into one uncompressed <sample>.nt |
space-optimized |
Each part is streamed through gzip into one <sample>.nt.gz and the source part is deleted immediately, so a full uncompressed copy never exists |
The output is N-Triples. No named graphs are produced, and no blank nodes:
every class in the vocabulary declares an IRI template, and
preflight_blank_nodes treats a blank node as a validation failure precisely
because it means a term map produced no IRI.
--spark-partitions is a parallelism hint passed through to RMLStreamer. It
does not change the output, only how many parts are produced before the merge.
Three classes of triple cannot come from RML, and are appended to the aggregate
by the host process before compression. See
architecture.md for why.
The rule is: RML carries every field whose datatype is the same for every row;
the wrapper carries everything else — anything that may be the missing token,
and anything that has to be decomposed out of a single source cell.
Four fixed fields and the raw INFO string are always emitted here, because
each may be the VCF missing token and the vocabulary requires that as
"."^^vcfc:Null:
| Field | Emitted as |
|---|---|
ID |
the lexical value, or "."^^vcfc:Null |
ALT |
the lexical value, or "."^^vcfc:Null |
FILTER |
the lexical value, or "."^^vcfc:Null |
INFO (vcfc:infoRaw) |
the lexical value, or "."^^vcfc:Null |
QUAL |
see below |
QUAL has its own table because its shape is a disjunction over the whole VCF
Float lexical space:
| QUAL value | Emitted as |
|---|---|
| a finite number | "<lexical form>"^^xsd:decimal — the source lexical form, so no precision is gained or lost |
INF / INFINITY / NAN, any case |
"<lexical form>"^^vcfc:VCFFloat — these are in the VCF Float lexical space but in no XSD numeric one |
. or empty |
"."^^vcfc:Null |
| anything else | a plain string literal, deliberately kept rather than dropped, and reported by the SHACL layer |
Three of those cells are also decomposed, in both INFO representations, because a single cell carries a list the vocabulary models as resources:
- ID. Each semicolon-separated component becomes a
vcfc:RecordIdentifierat…#record/{ROW_ID}/id/{N}, linked withvcfc:hasIdentifierand carryingidentifierValueandcomponentIndex. The rawvcfc:recordIdstays beside it. - FILTER. The call gets a
vcfc:filterStatusofvcfc:FiltersPassed(PASS),vcfc:FiltersNotApplied(.) orvcfc:FiltersFailed. In the failed case each code also becomes avcfc:FilterCodewithfilterCodeValueandcomponentIndex, joined to its##FILTERdeclaration withvcfc:declaredByFilterso a consumer can read the description without re-reading the header. - FORMAT. The key list becomes ordered
vcfc:FormatKeyresources carryingvcfc:fieldIndexandvcfc:declaredBy, beside the rawvcfc:formatRaw. The IRI shape follows the class's owniriTemplate.
--info-representation structured (the default) additionally emits:
-
The allele layer.
REFand eachALTitem become an orderedvcfc:ReferenceAllele/vcfc:AltAllelewithalleleIndex(0 for REF, then 1..n in source order),alleleValueandalleleKind. The kind is the VCF syntactic category: a base sequence, the overlapping-deletion*, the missing., an angle-bracketed symbolic ID, the unspecified<*>/<NON_REF>, or a breakend. A symbolic allele is joined to its##ALTdeclaration withdeclaredByAltand, when its ID is one of the reserved codes, given avcfc:svType. A breakend is parsed into its orientation, replacement string and mate position. The record is also linked to the contig declaration itsCHROMnames, withvcfc:chromosome. -
Structured INFO. One
vcfc:InfoFieldValueper record and key at…#call/{ROW_ID}/info/{KEY}, linked to the##INFOdeclaration throughvcfc:declaredByand carryingvcfc:fieldIndex, its position in the INFO column. A single-valued field whose declaredTypeisIntegerorFloatalso gets a typedfieldValueInteger/fieldValueDecimal; aFlaggetsvcfc:fieldValueBoolean true. -
Value items. A field whose declared
Numbergives its positions meaning —A,R,LA,LR(one per allele),G,LG(one per genotype),P(one per GT allele) — is decomposed into orderedvcfc:FieldValueItemresources, each joined to the allele it describes withvcfc:forAllele, or carrying its genotype/GT ordinal. Keys that flatten fixed-width tuples (CIPOS,MEINFO, …) record the width withvcfc:tupleArityso a consumer can regroup them. This closes the multi-valued-INFO gap that earlier versions recorded inlimitations.md.The local-allele codes
LA/LR/LGare a special case: they index a sample's allele subset, and an INFO field has no sample. Their items are therefore still materialized and indexed, but carry novcfc:forAllele— see the genotype layer below, where the subset exists. -
The SV carriers.
SVLEN,SVCLAIM,IMPRECISE,NOVEL,END,EVENTwithEVENTTYPE, the confidence intervals (CIPOS/CIENDthrough FALDOInRangePosition, the rest asvcfc:ConfidenceInterval), and theRN/RUS/RUL/RUC/RBtandem-repeat structure.
Those carriers are emitted once per record, after the INFO values, rather than
key by key — several of them need more than one key to be well formed. A
vcfc:VariantEvent needs both EVENT and EVENTTYPE; a reference block needs
both END and POS; a tandem repeat needs RN before its repeat values have a
grouping. Where a record does not supply the whole set, the carrier is not
emitted and the values stay available as ordinary INFO values, rather than
producing a resource that would fail its SHACL shape.
--info-representation raw emits only the opaque vcfc:infoRaw string, and
drops the value items with it.
The allele layer is not tied to the INFO representation. Two layers join to
<record>/allele/<index>: the structured INFO value items, and the expanded
sample layer's per-call vcfc:calledAllele. Either one alone requires the
alleles, so the layer is emitted when --info-representation structured or
--sample-representation expanded is in force, and left out only for
raw + condensed, where nothing references it.
--header-representation structured (the default) types each ## line with its
vocabulary subclass (INFOHeaderLine, ContigHeaderLine, FILTERHeaderLine,
ALTHeaderLine, MetaHeaderLine, SampleHeaderLine, PedigreeHeaderLine,
AssemblyHeaderLine, PedigreeDBHeaderLine, …) and lifts its attributes into
their own properties. The attribute parser respects quoting and backslash
escapes, so a Description containing a comma is handled correctly.
Every structured line also gets one ordered vcfc:HeaderAttribute per source
attribute, carrying attributeKey, attributeValue and a one-based
attributeIndex. This is not optional decoration:
vcfc:StructuredHeaderLineShape requires at least one vcfc:hasAttribute, so a
line typed as one of the structured subclasses without them is non-conformant.
It also keeps implementation-defined attributes queryable, which the dedicated
properties alone cannot do.
An unrecognised ## key is still typed as vcfc:StructuredHeaderLine or
vcfc:UnstructuredHeaderLine according to its value's form, which keeps it
queryable without inventing a subclass the vocabulary does not define. An
unstructured line is only typed when it has a value, because
vcfc:UnstructuredHeaderLineShape requires one.
##fileDate is emitted as xsd:date when its form is recognisable, and
verbatim as a plain literal otherwise — again preserved rather than dropped, and
reported by SHACL.
--header-representation basic keeps only the base header-line triples the RML
mapping emits. It is the fastest and smallest header form, but it does not
produce a SHACL-conformant graph, because the structured lines then carry no
attributes and no subclass.
Exactly one sample emitter runs per conversion, chosen by
--sample-representation. Full treatment in
sample-representation-guide.md; the short
version:
| Mode | Shape | Growth |
|---|---|---|
expanded (default) |
one vcfc:SampleCall per record × sample, one vcfc:FormatFieldValue per FORMAT key, and the parsed genotype layer below |
≈ variants × samples × FORMAT fields |
condensed |
one reusable vcfc:SampleSet, one vcfc:CohortCallMatrix per call, one vcfc:FormatValueVector per FORMAT key holding a tab-separated vcfc:VCFTextVector |
≈ samples + variants × FORMAT fields |
Both modes emit the file-level vcfc:SampleSet and its ordered vcfc:VCFSample
members, and link them from the #CHROM vcfc:ColumnHeaderLine with
vcfc:hasGenotypeColumns. That is one resource per sample column for the whole
file, not per record, so it costs nothing at cohort scale — and it gives the
expanded profile's SampleCall a reusable identity through vcfc:forSample
instead of relying on repeated sampleId literals.
Expanded mode additionally parses the per-sample values into the vocabulary's
genotype layer: GT becomes a vcfc:Genotype with genotypeString, ploidy,
phasingStatus and one vcfc:GenotypeAlleleCall per position (each either
pointing at the allele it called or flagged isNoCall, and each carrying its
own vcfc:phaseIndicator); FT becomes sampleFilter; PS/PSL/PSO/PSQ
become a vcfc:PhaseSet; LAA becomes a vcfc:LocalAlleleSet;
CN/CNQ/CNL/CNP and HAP/AHAP become their copy-number and haplotype
properties; and the pattern-defined M/DPM/ADM keys become
vcfc:BaseModification resources pointing at a ChEBI residue.
Four details of that layer are worth stating explicitly:
- Local-allele fields are resolved through
LAA, not positionally. ANumber=LA/LRvalue list indexes the sample's own local allele subset, not the record's ALT column. WithLAA=2,4the local set is {REF, ALT2, ALT4}, so the three values of anLRfield belong to global alleles 0, 2 and 4 — not 0, 1, 2. Eachvcfc:FieldValueItemis joined withvcfc:forAllelethrough that mapping. A sample that declares noLAAhas no local ordering to resolve against, so its items keepvcfc:valueIndexand get novcfc:forAlleleat all: a wrong link is worse than an absent one, because nothing in the graph would signal it.LAAis read before the per-key loop, since a FORMAT string may legally list it after a field that depends on it. - Phasing is per position, not per genotype.
vcfc:phasingStatusisvcfc:Phasedorvcfc:Unphasedonly when every position agrees. A genotype whose indicators disagree —0|1/2— getsvcfc:MixedPhasing, and the per-callvcfc:phaseIndicatorcarries the precise reading. A single verdict would misdescribe it. - Local-allele position belongs to the membership. Alongside
vcfc:hasLocalAllele, each member of avcfc:LocalAlleleSetgets avcfc:LocalAlleleMembershipwithvcfc:localAlleleandvcfc:localIndex. The ordinal cannot live on the allele resource, which is shared across samples: two samples may give the same ALT different local positions. The vocabulary deprecatesvcfc:localAlleleIndexfor that reason, and the converter no longer emits it. - Base-modification aliases resolve. VCF 4.5 reserves each modification key
in two spellings —
M[0-9]+[ACGTUN]and a named alias such asM5mCforM27551C— across all three families, 30 aliased keys in total. Both spellings build the same carrier against the same ChEBI residue, while the key keeps its written spelling in the resource IRI so the graph traces back to the source column. ANumber=Mfield is decomposed over the bases the genotype's called alleles actually carry, in GT order, since that is the order the specification defines it over.
Condensed mode deliberately stops at the vector. Decomposing GT per sample is
exactly the per-sample materialization the condensed profile exists to avoid.
Every value stays recoverable by decoding a vector against its FORMAT definition
and the matrix SampleSet.
A GT value that is not in the vcfc:GenotypeString lexical space produces no
Genotype resource at all — the value remains present as the FORMAT field's
fieldValue, and inventing a genotype for it would create a resource that fails
vcfc:GenotypeShape.
The file declares which shape it carries via vcfc:representationProfile, so a
consumer can branch on it before querying. The SPARQL SHACL profile enforces
that a graph never mixes the two.
VCF Core ships one SHACL overlay per VCF version (4.1 through 4.5), each scoped
by the file's vcfc:fileFormat, plus an optional vcfc:VCF4xFile subclass that
activates that version's gate. The conversion follows the version of each input.
##fileformat is required to be the first line of a conforming VCF, and
src/vcf_as_tsv.sh already lifts it into
file_metadata.tsv, so detection costs one small read and needs nothing from
you. It happens per input, not per run: a directory may hold files of
different versions and each gets its own mapping and emitter behaviour.
--vcf-version overrides it, for a file whose declaration is missing or wrong:
vcf-rdfizer --mode full -i ./cohort.vcf.gz --vcf-version 4.3 -o ./resultsThe run log states which version was used and how it was chosen — (detected),
(forced), or a warning that the declaration was not recognized.
VCF 4.0 is not recognized. VCF Core claims no 4.0 conformance overlay, so a
4.0 file converts with the newest supported rules — nothing representable is
dropped — but no vcfc:VCF4xFile class is emitted, so the graph never claims a
version gate that cannot be checked. The same applies to a missing or malformed
##fileformat line. All three cases are reported, never assumed silently.
| 4.1 | 4.2 | 4.3 | 4.4 | 4.5 | |
|---|---|---|---|---|---|
Number=R |
— | ✓ | ✓ | ✓ | ✓ |
Number=P (FORMAT) |
— | — | — | ✓ | ✓ |
Number=LA/LR/LG/M (FORMAT) |
— | — | — | — | ✓ |
CIPOS/CIEND |
one pair per record | one pair per record | one pair per record | one pair per ALT | one pair per ALT |
CILEN/CICN |
— | — | — | ✓ | ✓ |
EVENT |
one per record | one per record | one per record | one per ALT | one per ALT |
EVENTTYPE |
— | — | — | ✓ | ✓ |
Local alleles (LAA) |
— | — | — | — | ✓ |
Base modifications (M/DPM/ADM) |
— | — | — | — | ✓ |
| Leading GT phase indicator | — | — | — | ✓ | ✓ |
Three consequences in the emitted graph:
vcfc:FieldValueItemlinkage. Before 4.4 aCIPOSlist is one pair describing the record, so its items get avcfc:valueIndexand avcfc:tupleAritybut novcfc:forAllele— there is no single allele to point at, and each ALT allele of a multi-allelic record shares the interval. From 4.4 the list repeats per ALT and each item joins its own allele.- Tuple items are always materialized. The version overlays count a tuple
key's value items against the ALT count, so
CIPOSand friends are decomposed even though 4.4 and 4.5 declare themNumber=., which on its own would say "no positional meaning". - Families a version does not define are not invented. A
LAAcolumn in a 4.4 file, or anM27551Ccolumn in a 4.3 file, keeps its rawvcfc:fieldValueand gets novcfc:LocalAlleleSetorvcfc:BaseModificationresource.
The version does not affect parsing leniency. A GT with a leading phase
indicator in a 4.1 file is still parsed into a vcfc:Genotype; the version
overlay reports it as non-conformant, which is the right division of labour —
the converter transcribes, the validator judges. The same holds for a Number=R
declaration in a 4.1 file, or a reserved key declared with the wrong arity: the
graph records what the file says, and SHACL says whether the file was right.
sample_calls.tsv and sample_format_values.tsv exist so that a mapping can
express genotypes in RML if it wants to. Under the shipped mapping they stay
header-only and the wrapper streams genotypes directly, because materializing
them means one row per variant × sample (× FORMAT key) — the largest
intermediate the pipeline can produce.
A custom mapping that consumes them in any other way forces full
materialization, and is rejected in --sample-representation condensed,
which would otherwise emit both genotype representations into one graph.
Every IRI is derived from the source filename and the row counter, so a conversion is deterministic and re-running it produces byte-comparable subjects.
| Resource | Template |
|---|---|
VCFFile |
file://{SOURCE_FILE} |
VCFHeader |
file://{SOURCE_FILE}#header |
HeaderLine |
file://{SOURCE_FILE}#header/line/{HEADER_INDEX} |
VCFRecord |
file://{SOURCE_FILE}#record/{ROW_ID} |
VariantCall |
file://{SOURCE_FILE}#call/{ROW_ID} |
ColumnHeaderLine |
file://{SOURCE_FILE}#header/columns |
HeaderAttribute |
file://{SOURCE_FILE}#header/line/{HEADER_INDEX}/attribute/{N} |
SampleDeclaration (##SAMPLE/##PEDIGREE) |
file://{SOURCE_FILE}#header/sample/{ID} |
InfoFieldValue |
file://{SOURCE_FILE}#call/{ROW_ID}/info/{KEY} |
Allele (REF at 0, ALT from 1) |
file://{SOURCE_FILE}#record/{ROW_ID}/allele/{N} |
FieldValueItem |
<parent value IRI>/value/{N} |
VariantEvent |
file://{SOURCE_FILE}#record/{ROW_ID}/event/{EVENT} |
SampleSet (both profiles) |
file://{SOURCE_FILE}#samples |
VCFSample (both profiles) |
file://{SOURCE_FILE}#samples/{SAMPLE} |
SampleCall (expanded) |
file://{SOURCE_FILE}#sample/{ROW_ID}/{SAMPLE} |
FormatFieldValue (expanded) |
file://{SOURCE_FILE}#sample/{ROW_ID}/{SAMPLE}/fmt/{KEY} |
Genotype (expanded) |
file://{SOURCE_FILE}#sample/{ROW_ID}/{SAMPLE}/genotype |
GenotypeAlleleCall (expanded) |
…/genotype/call/{N} |
PhaseSet (expanded) |
file://{SOURCE_FILE}#sample/{ROW_ID}/{SAMPLE}/phaseset |
CohortCallMatrix (condensed) |
file://{SOURCE_FILE}#call/{ROW_ID}/matrix |
FormatValueVector (condensed) |
file://{SOURCE_FILE}#call/{ROW_ID}/matrix/fmt/{KEY} |
These follow the vcfc:iriTemplate patterns the vocabulary recommends for each
class. Where the vocabulary declares no template — the allele-layer, genotype
and SV resources — the IRI is minted beneath the resource it belongs to, so the
whole graph stays derivable from the filename and the row counter alone.
{SOURCE_FILE} is the basename, not a path, so a graph does not encode
where the VCF happened to live. The consequence is that two different VCFs with
the same filename mint the same IRIs; if you convert chr1/data.vcf and
chr2/data.vcf, their graphs will collide when merged. Rename before
converting, or keep the graphs separate.
The vocabulary's vcfc:missingValuePolicy says a missing token should be
"."^^vcfc:Null, and the conversion follows it. preflight_missing_token_conformance
reports a plain "." literal as an anomaly, and --strict-conformance promotes
that from a report to a failure.
Not every "." is a missing value, and the check excludes the three places the
vocabulary requires a bare dot: vcfc:fieldNumber (Number=. is VCF's
variable-cardinality arity), vcfc:genotypeString (a fully missing call is
literally . or ./.), and the vcfc:attributeValue of a header attribute
whose vcfc:attributeKey is Number — the structured-header layer carries each
declaration's attributes verbatim, so Number=. appears there too, and the
shapes require that value to be xsd:string. A bare dot anywhere else, including
on any other header attribute, is still reported.
This used to conflict with the published SHACL shapes, which constrained
vcfc:alt to sh:datatype xsd:string and so rejected every REF-only and
gVCF-style record. The vocabulary fixed that: vcfc:VCFRecordShape now
accepts xsd:string or vcfc:Null for alt, and vcfc:VariantCallShape
accepts the full VCF Float lexical space for qual. Emitting the missing token
as vcfc:Null is now both the policy and the conformant choice.
CHROM, POS and REF are the three fixed fields VCF 4.5 forbids from being
the missing token, which is exactly why they can stay in the RML mapping while
ID, ALT, QUAL and FILTER cannot.
- No normalization. No left-alignment, no trimming, no multi-allelic
splitting.
ALT=A,Tstays one record with onealtliteral. This is deliberate — the graph is a faithful transcription of the file — but it means the graph is not directly joinable with normalized external resources. This is the central problem the data-linking design has to solve. - No reference checking.
REFis not verified against any genome. - No cross-file merging. Each VCF produces its own graph.
Two entries that used to be in this list no longer belong here, because the move to the VCF Core vocabulary added both:
- Structural variants are modelled. Symbolic ALTs, breakends and
*are classified byvcfc:alleleKind, given avcfc:svTypewhere the identifier is a reserved code, and parsed into their components;END,SVLEN,SVCLAIM,EVENT/EVENTTYPE, theCI*intervals and the tandem-repeat keys all have meaning. The raw literals are still there — the structure is added alongside, not instead. - Genotypes are interpreted in the expanded profile:
GTis parsed into ordered allele calls with an explicitvcfc:phasingStatus, so|versus/is modelled rather than only preserved. The condensed profile keeps them lexical by design.
- VCF coverage matrix — element-by-element, with the validation check that covers each
- Sample representations — the genotype shapes in depth
- Custom RML mappings — changing what is emitted
- Limitations — consolidated