Porting a Conformance Runner¶
Non-normative guide. The rules themselves live in
docs/conformance-harness.md (track 1: OML/OSD
CLI-wrapper fixtures) and §8.5
(track 2: JSON vectors). If this page and either of those ever disagree, they
win — this page only collects what the five ports (omnist in Python,
omnist-ts, omnist-rs, omnist-go, omnist-j) already learned building
their own runners, so a sixth doesn't have to rediscover it from scratch.
Two tracks, both worth building¶
Track 1 (conformance/fixtures/ in this repo) exercises a real CLI or
direct library calls against small, hand-written fixtures — 19 currently,
plus a 10-case referee self-test. Track 2 (test-suite/) is a larger
JSON-vector suite (python3 tools/check_vectors.py prints the current count)
— dispatched by operation name rather than fixture directory shape. They're
complementary, not redundant: track 1 proves your CLI wrapper (if you have one)
actually works
end to end; track 2 has far denser coverage of individual rules. Build both;
all five existing ports did.
What to build, in order¶
1. A referee. Comparison, using your own implementation's parser and
equality — never another port's. Document comparison needs nothing beyond
your Doc/Node type's own equality, provided it's order-sensitive (order is
data, per §2.3 D-1/D-3).
Schema comparison needs three modes, and which one an operation uses depends on the track. Two ports built one structural referee and reused it for everything; this table exists so a new port does not.
| Mode | What it does | Used by |
|---|---|---|
exact |
Structural. Re-parse both texts with your own OSD parser, then require every record name and every field's label, type and cardinality to match. Declaration order and formatting are not compared. | Track 1 — normalize, prune, extract — and the referee self-test. conformance-harness.md §4, §6. |
canonical |
Byte-exact string comparison. Your implementation's canonical output text against the vector's expected text. No re-parse, no normalization of your own. | Track 2 — every schema-valued expect field except infer's and infer_with_report's (table below). §8.5.3: "compared byte for byte per §3.3/§5.9". |
isomorphic |
Structural up to a renaming of records. | infer and infer_with_report's schema (stated for Track 1 in conformance-harness.md §4; §8.5.3 states no comparison for Track 2's, and the same reasoning applies) — the one case where a structural comparison is the correct one, because §6.10 never normalizes infer's output and its record names are implementation-derived. |
exact and canonical are not interchangeable, in either direction.
Making exact byte-exact fails the referee self-test on purpose-built cases
(01-schema-exact-equal-different-field-order and
07-schema-exact-equal-different-env-declaration-order are meant to compare
equal). Using exact for Track 2 passes vectors a formatting bug should fail:
a structural comparison re-parses both sides, so two texts that differ
byte for byte — wrong indentation, wrong join character, wrong escaping, two
fields swapped — can still parse to the same Schema and report green.
TypeScript and Rust both shipped exactly that
(omnist-ts#150,
omnist-rs#184); changing
canonical OSD output's indentation, or to_osd's join character, left every
canonical-output vector green, which is also how
OSD-15's escaping bug went unseen. Build canonical as its
own comparison next to exact, used only by the Track 2 runner. And do not
overcorrect the other way: infer output is not canonical, so comparing it
byte for byte fails correct implementations over record names §6.10 never
fixed.
If your library doesn't yet expose an isomorphism check, you'll need to add
one — it's a real, narrow addition (see omnist's Schema.isomorphic_to(),
added for exactly this), not a substitute for whatever your library already
uses as its canonical "same schema" comparison.
Prove the referee trustworthy before it judges anything: port the
10-case self-test under conformance/fixtures/_referee-self-test/ and get
it passing first. All five existing ports did this as their literal step
one. The self-test covers exact and isomorphic only: canonical is a string
comparison and has no fixtures there, so prove it with a mutation of your own
instead — change one detail of your canonical output (an indentation width, the
join character, one escape, the order of two fields) and confirm at least one
Track 2 vector goes red. A canonical comparison that survives that mutation is
a structural one under another name.
2. Track 1's fixture runner. Walk conformance/fixtures/'s
per-operation directories, invoke each operation (CLI or direct library
call — see below), compare with the referee, report pass/fail/skip.
3. Track 2's vector runner. Walk test-suite/'s JSON files, dispatch on
each vector's operation field per §8.5.3's
table, compare expect against your result per
§8.5.2's rules
(message text never compared; diagnostics compare as a set of (path,
code), never severity; no partial matching), with the mode each operation's
schema-valued or document-valued field needs, from the table under "Which
comparison each operation needs" below.
Which comparison each operation needs¶
Every row cites the text that requires it; nothing here is a requirement of this page's own. Message text is never compared in either track (§8.5.2 rule 1).
Track 1 — fixtures¶
Source: conformance-harness.md §2 (contract), §3
(fixture shapes), §4 (comparison), §6 (self-test).
| Operation | What is compared | Mode |
|---|---|---|
write |
output OML against expected.oml |
Document equality: read both with your own reader, order-sensitive (§4 compare_document) |
normalize, prune |
output OSD against expected.osd |
exact (§4) |
extract |
on success expected/output.osd; expected/ok.txt either way |
exact (§4) |
infer |
on success expected/output.osd; expected/ok.txt either way |
isomorphic (§4) |
validate |
expected/ok.txt; on failure expected/diagnostics.json |
(path, code) set (§2, the lint findings note) |
materialize |
expected/ok.txt; on success expected/output.oml, on failure expected/diagnostics.json |
Document equality; failure as validate |
is_empty, compatible_with, equivalent |
expected.txt, a boolean |
equality |
lint |
expected.json findings |
code, severity and location exactly (§2) |
Track 2 — JSON vectors¶
Source: §8.5.3 (drivers),
§8.5.2 (diagnostics matching), §8.5.4 (document encoding). For a failing
vector, every operation alike: ok is false and diagnostics is compared as
a set of (path, code) pairs — every expected pair present, none extra
(§8.5.2 rules 2 and 3, E-17), severity never compared. Paths are compared byte
for byte (E-9, E-11), with one exception: an expected path of the string
line:col (E-32) is a placeholder that a parse.codec-syntax vector uses
because a codec's blamed character is implementation-defined (E-31). For that
entry compare the code, and check only that the reported path is a
well-formed text position, ^[1-9][0-9]*:[1-9][0-9]*$; do not compare its
value. Every other path in the suite stays byte for byte. A runner that skips
this step
fails every vector that uses the placeholder (the four formats-*/syntax/
vectors and five in formats-yaml/alias-expansion.json: nine at v0.29.0-beta;
python3 tools/check_vectors.py prints the current count). Rule 4's
code-agnostic mode is the one permitted relaxation of the code, a bring-up aid
whose results are not a conformance claim, and the run MUST say it was
used.
operation |
expect fields on success |
What a runner MUST compare, and how (E-17) |
|---|---|---|
parse |
ok, document; diagnostics on the XML vectors that read a dropped attribute or namespace |
document: Document equality, order-sensitive (§8.5.4, D-1/D-3). If the vector lists diagnostics, they are compared as a (path, code) set and an unlisted one is unexpected (§8.3.8, E-5, E-17 rule 3). |
parse_schema |
ok; schema only when the vector pins round-trip fidelity |
schema present: canonical, byte for byte (§8.5.3, §3.3/§5.9). Absent: acceptance only. |
validate |
ok |
ok; on failure the (path, code) set. |
materialize |
ok, document |
document: Document equality; on failure the (path, code) set. |
write |
ok, text; diagnostics MAY accompany a success (format.temporal-stringified, format.interleaving-lost) |
text: byte for byte, with §8.5.3 E-18's whitespace rule for XML; diagnostics as a (path, code) set. On failure the (path, code) set. The strict key some write vectors carry in input selects strict mode (§8.3.9). The compact key (OML vectors only, E-35) selects OML's compact layout (OML-22): call your OML writer's compact option through the library (not every CLI has a --compact flag, and a runner goes through the library for this key), and do not drop it, or every compact vector fails for the runner's reason. |
compatible_with, equivalent |
result |
the boolean. |
is_empty |
empty |
the boolean. |
normalize, prune |
schema |
canonical (§8.5.3; §9.2: "compared as canonical OSD text byte for byte"). |
extract |
ok, schema |
canonical: extract ends by delegating to normalize (§6.9 step 5), and §9.2 lists extract beside normalize/prune. On failure the (path, code) set (algebra.extract-invalidates-root). |
infer |
ok, schema |
isomorphic (§6.10: output never normalized). On failure the (path, code) set. |
infer_with_report |
ok, schema, fallbacks |
schema: isomorphic. fallbacks: a list of {location, reason}, always present on success and empty when nothing was opened (§8.5.3); compare it, empty included — a runner that ignores it passes an implementation that never reports. reason is one of the two spellings §6.10 fixes, so it is not message text under rule 1. location is RecordName.label (§6.10) and infer's record names are implementation-derived, so whether location is compared exactly or modulo the record renaming is not settled by the text; the vectors' entries look like Root.id. Nor does §8.5.3 say whether the list is compared as a sequence or a set, and no vector holds more than one entry. |
lint |
ok, findings |
ok, and each finding's code, severity and location (§8.5.3); no message. Failure vectors carry ok: false and findings too, not diagnostics. |
schema_from_document, parse_schema_oml |
ok, schema |
canonical (§8.5.3, same comparison as parse_schema). On failure the (path, code) set. |
schema_to_document |
ok, document |
Document equality, order-sensitive (§8.5.3, §8.5.4). |
write_schema_oml |
ok, text |
byte for byte as OML text (§8.5.3, same rule as write). |
Read-side vectors (parse, parse_schema, parse_schema_oml) take their
source as text or as bytes_hex, exactly one (E-27; see the section below).
A trap specific to temporal scalars, found the hard way (omnist-spec#51;
D-5).
A vector's expect.document value field for a date/time/datetime
scalar MUST already be written in your own format's canonical spelling — not
just any string that happens to be semantically equivalent to the source
text. This matters because two equally valid comparison strategies exist and
disagree on what "equivalent" means: an implementation with a native temporal
type (Python's reference omnist, comparing parsed datetime objects) treats
"2024-01-01T10:30+05:30" and "2024-01-01T10:30:00+05:30" as the identical
value, since a missing :SS and an explicit :00 describe the same instant.
An implementation whose Scalar::Datetime (or equivalent) holds the
canonical string the reader produced, with no native temporal type behind
it, compares those two spellings as literally different strings — and if your
reader canonicalizes a missing :SS to :00 on read (a legitimate, common
design choice), only one of the two spellings will ever match. A vector
authored and tested only against a native-temporal-type implementation can
pass there while being subtly wrong for a string-backed one, with nothing
in the vector's own JSON signaling which convention it assumed. If you hit a
single, otherwise-inexplicable failure on an isolated happy-path temporal
vector while everything else in the same batch passes, check the vector's
value field against its own file's other temporal vectors for exactly this
kind of un-canonicalized omission before assuming your reader is wrong — and
file it against this repo if it's a genuine vector defect rather than
reworking your reader to match one outlier vector.
CLI wrapper, or direct library calls?¶
omnist (Python) uses a CLI wrapper for track 1, because a real, already-existing
CLI was the natural thing to reuse. omnist-ts and omnist-rs both chose
direct library/function calls for both tracks instead, since a port that's
primarily a library — not primarily a CLI tool — gains nothing from spawning
a subprocess per fixture. Either is conformant; pick whichever fits your
port's actual shape. If you do wrap a CLI, §2's
table documents Python's real, verified command shapes — treat it as one
worked example of a CLI binding, not a mandate to replicate Python's exact
flag names in your own CLI.
Fixture sourcing: a pinned git submodule¶
All five existing ports vendor this repo the same way: a git submodule
pinned to a tag, never tracking master, so fixture updates are explicit,
reviewable commits rather than silent drift. See any of the five repos'
tools/conformance/README.md for the exact bump procedure — they're
functionally identical.
Reporting: skip is not failure, and every skip needs a reason¶
§8.5.5 (E-20 to E-22) is normative here, not
just a suggestion: your CI MUST fail the build on any nonzero fail
count, and MUST NOT fail merely because skip is nonzero — provided
every skip cites a real reason. Two categories:
- Not yet implemented. Temporary; expected to become a
passonce the work lands. No ledger entry required on its own. - Documented divergence (E-21). Your target language or design genuinely cannot provide something a vector depends on — not a missing feature, a structural limit. This requires a numbered entry in chapter 9's divergence ledger (see §9.4 for the current open entries), and your runner's skip reason MUST cite that entry by number. Don't invent an ungrounded skip reason, and don't silently rewrite a vector to route around a real divergence instead of documenting it. A divergence this narrow — one language, one scalar-kind distinction — is closed once the implementation adds real type support and its entry is removed from the ledger; it doesn't stay listed as historical record.
If you find a genuinely new divergence category building your own runner,
follow the same pattern: file it as a new DIV--numbered entry in this repo
first (with real, source-verified evidence — every existing entry cites a
specific file, function, or confirmed behavior, never "presumably"), then
cite it. §9.2's forbidden-variation rules have a narrow, explicit carve-out
for exactly this case (a missing distinction being skipped) — it does not
cover producing incorrect output, which stays a plain conformance bug
regardless of the reason behind it. That distinction is the whole point: one
accepted accommodation and one real bug can come out of the same underlying
architecture decision, and only the first belongs in the ledger.
Declared-limit keys: keep your allowlist current¶
A vector that pins a safety-limit boundary carries a declared_max_* key in
its input, naming the limit value it was written against. Your runner needs
an allowlist of those keys, checked before the vector runs: if the key is
present and your implementation exposes no way to configure that limit to the
stated value, the vector is a skip. Python's runner spells this as a
_LIMIT_KEYS set in tools/conformance/vector_runner.py; every port's
runner has an equivalent.
A key your allowlist doesn't know about is not skipped — it is run against
your own default, which is the one outcome the mechanism exists to prevent.
E-20a makes this a
MUST: a runner reports a vector carrying a declared_* key it does not
understand as a fail or an E-20 skip, never as a run against its own
default.
test-suite/README.md lists the current keys. Check that list against your
allowlist on every submodule bump, and treat a new key as part of adopting
the rule that introduced it, not as separate work.
As of v0.30.0-beta the newest key is declared_max_input_bytes
(§2.4.2's D-23, the maximum input size in bytes); ten vectors in
document-model/input-size.json carry it. It follows the same rule: allowlist
it, and report an E-20 skip until D-23 is implemented. All five ports implement
it now and their runners honour the key; at v0.30.0-beta none did, and three of
the five runners that did not allowlist it ran the accepted boundary vectors
against their own default and reported green, which is the false pass this
section warns about.
Before it, as of v0.26.0-beta the newest key was declared_max_expanded_slots
(§2.4.1's D-22, the absolute cap on expanded size); ten vectors in
formats-yaml/alias-expansion.json carry it (python3 tools/check_vectors.py
prints the current per-key counts). It follows the same rule as
every other key: allowlist it, and report skip until the cap is implemented.
Before it, as of v0.18.0-beta, the newest was declared_max_alias_expansion
(§2.4.1's D-18). Most vectors in formats-yaml/alias-expansion.json carry one
of the two; seven of its 42 carry neither and run against the implementation's
own defaults. All five existing ports now implement both rules, so this
applies to a new port: until you implement a rule, a runner MUST report each
vector carrying its key as an E-20 skip, citing the tracking issue for your
port's gap, and never as a pass.
Adopting is two steps: (a) implement D-18 and D-22, and (b) add both
declared_max_alias_expansion and declared_max_expanded_slots to your
allowlist.
Doing (a) without (b) buys you a false pass, which is worse than a
failure. Of the vectors in formats-yaml/alias-expansion, every one that
expects rejection (for example nested-anchor-fan-out-exceeds-expansion-limit
and expansion-one-past-declared-limit-fails) fails outright until you
implement the rule — loud, triaged, fixed. The accepted boundary vectors, such
as
expansion-at-declared-limit-succeeds, report green either way. It is
green today because nothing enforces the limit at all, and it stays green
after step (a) because it is run against your implementation's own default
maximum instead of the 3 the vector declares — an E at your default's
boundary is not the boundary the vector was written to pin, so the vector
exercises nothing and a real off-by-one in your threshold sails through it. A
failure gets looked at. A pass does not. Treat step (b) as part of step (a),
never as follow-up work.
Byte inputs: bytes_hex and the one wrong way to run it¶
New in v0.21.0-beta. A read-side vector (parse, parse_schema,
parse_schema_oml) may give its input as bytes_hex — lowercase hex, two
digits per byte — instead of text, per
§8.5.3's E-27.
Exactly one of the two is present. Fourteen vectors use it today, all of them
§2.5's: eight pinning
D-14's rejection of invalid UTF-8 across
the six surfaces, six valid-UTF-8 controls proving the reader still accepts
multi-byte characters.
Your runner does three things with it:
- Decode the hex to bytes. Nothing else — no normalization, no trimming.
- Hand those bytes to your implementation as bytes, through a byte-oriented entry point. Any entry point you provide that reads a file, standard input or a byte stream is one — your CLI counts, and so does a bytes-taking reader. If the only one you have is the CLI, run these vectors through the CLI; the signature of the reader behind it does not matter, because D-14 binds the place the bytes enter. The "write the bytes to a temporary file" route only works if you have a file-reading entry point to point at it, which for some ports is exactly and only the CLI.
- Compare as usual. The expected diagnostic for every D-14 vector is
parse.invalid-encodingat1:1— always1:1, on every surface, wherever in the input the bad byte sits, because nothing decoded and the whole input is what failed.
The wrong way, and it is the tempting one: decode the bytes into your
language's string type with replacement (U+FFFD), with a surrogate-escape
scheme, or with any other lossy recovery, and then run the vector through
your ordinary string-taking reader. That is not the vector's input. For a
D-14 vector it asks your reader a question about a string of replacement
characters — which is valid UTF-8 — and whatever it answers says nothing
about the rule. It can report a pass for the one behaviour D-14 explicitly
forbids.
Which non-result you report matters, and E-21 is narrower than it looks.
If you have simply not taught your runner the bytes_hex field yet, that is
an E-20 "not yet implemented" skip — no ledger citation needed. (Until
you report it as one, a runner that chokes on the unknown field is reporting
a fail, which is fine and loud, but say which it is.) E-21 — the
documented-divergence skip, which needs its own
§9.4 ledger entry — is only
for an implementation with no byte-oriented entry point anywhere: no
bytes-taking reader, no file/stdin/stream entry point, and no CLI. Check your
CLI before you reach for it; none of the five ports qualifies, and all five
report parse.invalid-encoding as of v0.21.0-beta. If
you have a byte-oriented entry point and these vectors fail through it, that
is a plain fail and a real bug, not a skip.
If your language's string type can itself hold ill-formed UTF-8 — Go's
string is a byte sequence, and Go is the worked example — your
string-taking reader is itself a byte-oriented entry point, and §2.5 spells
out what that means concretely: reject any reader input s for which
utf8.ValidString(s) is false. The reverse case is Rust, where &str
carries validity as its own invariant and the reader has nothing left to
check; there the CLI is the entry point that matters. And note what D-14 does
not reach: a lone UTF-16 surrogate in a JavaScript or Java string, or a
surrogate-escape artefact in a Python str, is a property of an
already-decoded string, not of bytes you read, and §2.5 puts both out of
scope. Your writers are a different matter: C-9 (§7.3) makes every writer
refuse such a string, in a value or a label, with write.unsupported-value.
No vector can carry one (an input is UTF-8 text or bytes_hex), so nothing in
your runner will tell you whether you do; it is a unit-test obligation
(DIV-5).
Say which comparison mode produced your numbers¶
§8.5.2 rule 4 permits a runner to compare code-agnostically — ok plus
the set of paths, never code — as a bring-up aid while a port has not yet
adopted §8.3. §8.3 is mandatory
(§8.1), so a
code-agnostic run is never a conformance result; the Python reference's runner
used that mode until v0.21.0-beta. It is also a quiet one: a code-agnostic run passes vectors the implementation does not
actually satisfy. A diagnostic with the right ok and the right position
but the wrong code reports green, and nothing in the run says so.
This is not hypothetical.
The retired DIV-4 (§9.4) had a row of exactly
that shape — OML-25, where the reference's ok and paths match
every affected vector and only the code is wrong — and the reference had
therefore been failing a vector's stated expectation for as long as the
vector existed while its own suite reported clean. §8.5.5 already requires a run to state which mode
produced it; treat that as load-bearing rather than as a header field, and
when you report conformance numbers anywhere else — a README badge, a release
note, an issue — say the mode alongside the count. "338 pass" and "338 pass,
code-agnostic" are different claims.
When you find a real failure¶
Triage before touching anything:
- A genuine bug in your own implementation. Fix it, following your repo's own conventions (coverage gate, changelog, version bump).
- A vector or spec-doc defect. This has happened in both directions during this suite's own construction — a vector that looked like a real bug turned out to be a construction mistake (a mismatched input string that never exercised what its name claimed), and a spec table that looked authoritative turned out to disagree with what every implementation actually did in six of twelve rows. File the issue on this repo, propose the fix, and let it be reviewed rather than assuming your first read is correct — don't silently patch a vector or a spec doc from your own repo.
- A genuinely new divergence. Follow the process above.
Report real, verified pass/fail/skip counts once you have them — don't imply
parity with another port's numbers if your true ceiling differs for
principled, documented reasons. It usually will: the ports' skip counts
differ today (§9.3's "What each port skips" gives the current split, for
instance the limits vectors only a port with runtime-configurable limits can
run), and each count is worth reporting accurately rather than rounding to "the
same as everyone else."