COTTAS-on-Parquet v1 (factoidal)#

Status: Draft v1, 2026-04-25. Scope: the on-disk container layout that factoidal currently reads when given a .cottas file, written by version 1.1.0 of the upstream pycottas tool. v1 fixes that contract so future upstream changes do not silently break our F*-extracted readers.

Note on identity. "COTTAS-on-Parquet v1 (factoidal)" is a factoidal-side stability label. It is not the same thing as pycottas's own internal format-version string (pycottas does not currently expose one as a Parquet KV-metadata key — see §10). v1 therefore describes a shape of file, not a producer-stamp. A file is "v1-shaped" if it satisfies §2–§7 below.

This document is a contract a third-party producer or reader can implement against without consulting pycottas internals. Where a claim is grounded in F* code, the line numbers in formal/fstar/Parquet.Footer.fst are cited so future readers can audit it directly.

Companion design notes:

1. Format identity#

A v1 file is an Apache Parquet file whose schema, column ordering, encodings, compression, and term-token convention all match §2–§7 of this document. factoidal v1 readers MUST refuse a file that is not v1-shaped. v1 is the only format factoidal reads today.

This spec applies equally to files factoidal might write in the future. At present (2026-04-25) factoidal does not have an F*-extracted COTTAS writer; production of v1 files is delegated to pycottas via tools/corpus_pipeline.py (see materialize_nq_cottas_corpus at line 1015 — it shells out to pycottas.rdf2cottas(input_nq, output_cottas, index="spog", disk=...)). Building an F*-only writer is tracked separately and is out of scope for v1; if added, it MUST emit files satisfying this spec.

2. Container#

3. Column schema#

Exactly four required columns, in this order. Column names are the single ASCII letters shown.

Idx Name Parquet physical type Logical / converted type Encoding(s) observed Notes
0 s BYTE_ARRAY STRING (UTF-8) DELTA_LENGTH_BYTE_ARRAY subject token (§4)
1 p BYTE_ARRAY STRING (UTF-8) DELTA_LENGTH_BYTE_ARRAY predicate token (§4)
2 o BYTE_ARRAY STRING (UTF-8) DELTA_LENGTH_BYTE_ARRAY object token (§4)
3 g BYTE_ARRAY STRING (UTF-8) DELTA_LENGTH_BYTE_ARRAY graph token (§4, §6)

Schema requirements for a v1-shaped file:

Allowed encodings#

Parquet permits a column chunk to use multiple encodings across pages. v1 producers SHOULD use the encodings shown above. v1 readers MUST support at least:

The combined per-column dispatcher probe_parquet_column_decode_all tries DELTA_LENGTH_BYTE_ARRAY first and falls back to RLE_DICTIONARY. Empirical note: the reference parliament fixture at tmp/ukparliament/.../v1/data.cottas (3,143,406 rows, 26 row groups, 122,880 row-group default) uses DELTA_LENGTH_BYTE_ARRAY for all four columns. Some upstream DuckDB / pycottas configurations emit RLE_DICTIONARY on the high-cardinality columns (p, g); both shapes are v1-conformant.

File-level KV metadata#

The following Parquet file-level metadata key is recognised in v1:

Key Value example Meaning
index spog Logical key permutation supplied by the producer to indicate the row sort order. v1 readers MAY use it as a hint; readers MUST NOT rely on it for correctness (a row-group-bound producer may sort within row groups but not globally).

All other key-value metadata (e.g. DuckDB's duckdb_schema group wrapper) MUST be ignored by v1 readers.

4. Term encoding (the value bytes in s, p, o)#

The cell value of s, p, o is a UTF-8 string carrying an N-Quads token for one RDF term. The token grammar is a subset of N-Quads (W3C Recommendation 25 February 2014, §4) restricted to the form needed to round-trip a single term:

iri      ::= '<' IRI-as-bytes '>'
bnode    ::= '_:' [A-Za-z0-9_]+
literal  ::= '"' lex '"'
           | '"' lex '"' '@' lang
           | '"' lex '"' '^^' iri
lex      ::= UCHAR | ECHAR | <any byte except '"' or '\\'>

where:

Per-column term mapping#

Column Allowed token shapes
s iri or bnode
p iri only
o iri, bnode, or literal
g iri (i.e. <...>) or the literal ASCII string DEFAULT (see §6)

Subject blank nodes carry the bare _:label; F* reads back the label by stripping the _: prefix (see cottas_runtime.sh:147–171). Object blank nodes follow the same shape.

A literal without datatype or language tag is the bare "lex" form. Per RDF 1.1, such a literal has datatype xsd:string; v1 readers MUST treat it as such on read (the factoidal OCaml glue does this at cottas_runtime.sh:122–128).

A language-tagged literal "lex"@en has datatype rdf:langString per RDF 1.1; v1 readers MUST set the datatype slot accordingly (cottas_runtime.sh:129–134).

Most format-fragile claim#

The literal-token grammar is the most format-fragile part of v1. N-Quads §7.4 permits \\u/\\U escapes inside lex; the factoidal OCaml glue currently performs a naive unescape (\\X → X for any single byte X — see unescape_literal at cottas_runtime.sh:90–103) which round-trips correctly only for the simple \\\\, \\" escapes, NOT for \\n, \\t, \\uXXXX, \\UXXXXXXXX. If a pycottas writer emits, say, \\u00E9 for é, today's reader will read back the eight-byte ASCII é, not é. The parliament fixture happens not to exercise this (its literal samples are ASCII or pre-decoded UTF-8), so the gap has not manifested. v1 nominally requires full N-Quads §7.4 unescaping; factoidal's reader does not yet meet that bar — see "known reader gaps" in §10.

5. Token examples (parliament fixture)#

s : <http://albertowenmp.org/>
p : <https://id.parliament.uk/schema/personWebLinkHasPerson>
o : <https://id.parliament.uk/krGtkX0n>
g : DEFAULT
s : _:115161184861629af182dafdbb0eaf23
p : <https://id.parliament.uk/schema/personHasGender>
o : <https://id.parliament.uk/...>
g : DEFAULT
o : "2018-04-15+00:00"^^<http://www.w3.org/2001/XMLSchema#date>
o : "Well-known text"
o : "2"^^<http://www.w3.org/2001/XMLSchema#integer>

(Source: tmp/ukparliament/CorpusCOTTAS/ukparliament/v1/data.cottas, 3,143,406 rows, inspected 2026-04-25.)

6. Default-graph sentinel#

A row that belongs to the unnamed (default) graph MUST have the literal ASCII string DEFAULT (seven bytes, no quotes, no <>, no language tag) in column 3 (g). It is not the empty string, not a NULL, and not an IRI.

This is a true sentinel: since DEFAULT is not a valid IRI token (no surrounding <...>), a v1 reader can disambiguate "default-graph row" from "named-graph row whose IRI happens to be DEFAULT" purely by leading character (< ⇒ named, otherwise ⇒ default). v1 explicitly forbids any other sentinel string.

The factoidal OCaml glue parses this at cottas_runtime.sh:167–171:

let parse_graph s =
  if s = "DEFAULT" then Some None
  else match parse_iri_token s with
    | Some iri -> Some (Some iri)
    | None -> None

Some None here means "row exists, graph is default"; Some (Some iri) means "row exists, graph is the given IRI". Anything else is a malformed v1 file.

7. Multi-row-group semantics#

A v1 file MAY have any positive number of row groups (the parliament fixture has 26). Within each row group, the four columns have the same num_values equal to that row group's num_rows; a row is the i-th cell of column 0 + the i-th cell of column 1 + … + the i-th cell of column 3 within the same row group. No row group sees a partial quad.

The logical row sequence is the concatenation, in row-group-index order, of each row group's per-column streams. v1 readers MUST iterate every row group; reading only row group 0 is a v1 conformance failure.

8. Reading model in F*#

The verified read path is rooted at:

9. Conformance checklist#

A producer claims v1 conformance iff it emits a file satisfying all of the following:

A reader claims v1 conformance iff it can read every such file and return the original quad sequence (modulo blank-node label scope — labels are file-local, not globally fresh).

10. What is NOT in v1#

The following were considered and deliberately excluded:

Known reader gaps#

The factoidal v1 reader at this commit (2026-04-25) has the following honest gaps relative to this spec; they are acknowledged deltas, not relaxations of the spec:

These gaps live in the reader, not the format. Files written by pycottas are v1-shaped; factoidal's reader is partially v1.

11. Compatibility with pycottas#

pycottas version v1-compatible? Notes
1.1.0 yes (last tested) Parliament fixture 2026-04-25; column order s,p,o,g; encoding DLBA across all 4; sentinel DEFAULT; KV-metadata {index: spog}.
< 1.1.0 unknown Not retested under v1 framing; column ordering and encoding choice may differ.
> 1.1.0 unknown Forward-compatibility depends on pycottas's release notes. If pycottas adds e.g. additional columns or changes the default-graph sentinel, the resulting file is not v1; factoidal readers MUST reject it.

If pycottas drifts, the migration path is:

  1. Update factoidal's tools/corpus_pipeline.py to pin a known-good pycottas version (it currently does not pin).
  2. If the new pycottas writes a different shape, define v2 in a new file (docs/cottas-format-v2.md); leave this v1 spec untouched. v1 files keep working with v1 readers.

12. Forward-compatibility commitment#


Last-revised: 2026-04-25 (initial draft).