Skip to content

OpenDRIVE validator: mutation-proven static checks with a CLI - #106

Merged
kosuke55 merged 6 commits into
mainfrom
feat/odr-validator
Aug 22, 2026
Merged

OpenDRIVE validator: mutation-proven static checks with a CLI#106
kosuke55 merged 6 commits into
mainfrom
feat/odr-validator

Conversation

@kosuke55

@kosuke55 kosuke55 commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Summary

A strict validation layer for OpenDRIVE files, separate from the tolerant importer. The importer is deliberately forgiving — it will load a truncated document or a junction missing a <connection> without complaint, which is right for editing but useless for QA. validateOpenDrive(xml) runs strict checks and returns findings with severity, a rule id, a location (road / junction / lane / s), and an attribution category: MAP_DEFECT (the file is broken), TOOL_LIMITATION (this toolchain cannot represent it), or INFO.

Checks: XML integrity (truncation, tag balance), reference integrity (dangling road / lane / signal links), junction consistency (connecting roads missing from <connection>, membership mismatches), and geometric continuity (plan-view gaps, link contact gaps, negative widths, length mismatches). An optional esmini road-manager adapter runs when a binary is supplied; the core stays dependency-free and browser-safe.

CLI: npx tsx scripts/odr-validate.mts <file.xodr> [--json out.json] — verdict green / yellow / red, exit code 0 / 1 / 2.

Evidence-first design

The mutation harness was written before any detector: ten defect injections (each verified to have actually applied) must be caught as MAP_DEFECT errors — 10/10 detected. The false-positive gate is treated as equally important: zero MAP_DEFECT errors across all bundled fixtures plus 26 real-world maps (ODR_VALIDATE_CORPUS). Thresholds were calibrated on that corpus rather than assumed — e.g. reference-line gaps at road links subtract the laneOffset difference before judging, and negative-width tolerance sits above float noise from lanes authored to close at exactly zero.

Verification

  • 425 tests passing, tsc --noEmit clean
  • Detection matrix 10/10; false positives 0/32 maps

Adds the strict counterpart to the lenient OpenDRIVE importer: a validator
that reports defects without repairing them.

This commit lands the evidence layer first. Every mutation in the corpus is
a two-part contract (apply + prove it applied); applyMutation rejects a
mutation whose output is byte-identical to its input, so a pattern that
fails to match a document's numeric formatting can never be misread as an
undetected defect. All 10 mutations are shown to apply against the fixture
corpus; the detections they expect are it.todo until each layer lands.

The false-positive gate is asserted from the start: no fixture may produce
a MAP_DEFECT error. An optional wider corpus is read from
ODR_VALIDATE_CORPUS and skipped when unset.
Detects the failure mode the lenient importer cannot see: a document cut
mid-file parses into however many roads survived the cut and reports
nothing. A hand-written tokenizer (the SDK carries no dependencies) matches
open and close tags on a stack while skipping comments, CDATA, processing
instructions and quoted attribute values, so markup-like text inside a
geoReference or a road name is not mistaken for structure.

Integrity findings are fatal: they short-circuit the later layers, because
every dangling reference in a truncated file is an artefact of the missing
bytes rather than a defect of its own.

Detects mutation: truncate -> xml.truncated.
Resolves every cross-reference explicitly instead of best-effort: road
links, junction connection roads, lane links, controller signals and
signal references. The importer drops what it cannot resolve, so a
document can lose whole connections without complaint.

Lane links are resolved against the right target. Within a road a lane's
successor names a lane of the *next* lane section; only the outermost
links cross into the linked road, at the contact point the link declares
(defaulting per ASAM OpenDRIVE 1.8 section 9.2). Links into a junction are
resolved through the junction's laneLinks and are not checkable here.

Two rules are warnings rather than errors, on evidence: a road pointing at
a junction the document does not define is the signature of a legitimately
excerpted map, not a broken pointer. The town04-junction106 fixture is one
such excerpt -- four boundary roads reference junctions left outside the
slice -- and it now lands yellow instead of red. A link to a missing
*road* stays an error, since a road is a leaf rather than a cut point.

Detects mutations: dangling-road-successor -> ref.dangling-road-link,
dangling-connection-incoming -> ref.dangling-connection-road,
drop-lane-link -> ref.dangling-lane-link,
orphan-controller-signal -> ref.dangling-controller-signal.

False-positive gate: 0 MAP_DEFECT errors across 6 fixtures + 26 esmini maps.
A junction is described twice -- by its <connection> records and by the
roads declaring junction="<id>" -- and the two descriptions must agree.
The importer reads one side per question, so deleting a junction's
connections yields a set of unrelated roads and no complaint.

The same broken pair is visible from either side, so exactly one finding is
emitted per road, with the rule chosen by evidence rather than viewpoint:
a road still wired into the junction's roads was a genuine member whose
connection record went missing (junction.connection-missing), while a road
linking to nothing the junction knows about has a spurious membership claim
(junction.road-not-member). Emitting both would double every junction count
in the report.

Direct junctions (<junction type="direct">) have no separate connecting
road, so membership and contact-point rules are skipped for them; the
soderleden fixture pins this.

Detects mutations: drop-junction-connection -> junction.connection-missing,
road-junction-attr-without-membership -> junction.road-not-member.

False-positive gate: 0 MAP_DEFECT errors across 6 fixtures + 26 esmini maps.
…on corpus

Reuses the exporter's evalGeometry (line/arc/spiral/paramPoly3/poly3) so
the validator and exporter agree by construction about what a geometry
record means.

Thresholds were set by measuring the corpora (6 fixtures + 26 esmini maps:
401 geometry joints, 182 roads, 242 road links) rather than by guessing,
and the measurement changed two of them.

Within a road, real maps are near-exact and the defaults have wide margin:
plan-view gaps reach 4.0e-4 m against a 0.02 m threshold, heading steps
2.3e-5 rad against 0.005, and road@length agrees with the plan-view sum to
4e-16 relative against 1 %.

Between roads the picture is different and 0.5 m as an *error* would have
reddened working maps. Two structural causes produce large link gaps: a
road carrying a <laneOffset> has its reference line shifted from its lane
geometry, so roads whose lanes meet keep reference lines exactly the offset
difference apart (fabriksgatan 1.75 m, multi_intersections 3.75 m, both
correct); and some shipped maps carry link records their geometry does not
honour (soderleden road 7 declares a predecessor 66 m from where it
begins, and esmini drives the map anyway by routing through the junction).
The offset difference is subtracted, which removes the first cause exactly,
and the check reports a warning, which keeps the second from failing a map
that works.

Lane widths need a tolerance for an arithmetic rather than structural
reason: merge lanes authored to close at exactly zero land a few ulps below
it. All 8 negative evaluations in the corpora fall between -8.9e-16 and
-1.8e-15 m, so the 1 mm default sits twelve orders of magnitude above the
noise and far below any intended width.

Detects mutations: geometry-gap -> geom.plan-view-gap, length-mismatch ->
geom.road-length-mismatch, negative-width -> geom.negative-lane-width.

Detection matrix now 10/10. False-positive gate: 0 MAP_DEFECT errors across
6 fixtures + 26 esmini maps.
Layer 6 is split so the validator core stays pure: src/validator/
esminiAdapter.ts only turns captured road-manager output into findings (no
fs, no child_process, unit-testable without the binary installed), while
scripts/odr-validate.mts does the spawning. A missing or unrunnable binary
downgrades to an info finding rather than failing the run.

esmini findings are attributed TOOL_LIMITATION: the road manager
disagreeing with a document is evidence about the tool/map pair, not proof
the map is at fault, so it can never redden a report by itself. Repeated
lines collapse with a count, since esmini repeats a complaint per lane and
an unfiltered dump would bury every other layer.

The CLI prints a rule histogram followed by worst-first detail rows with
locations, and writes the full report with --json. Exit codes are the CI
contract: 0 green, 1 yellow, 2 red, and 3 for a usage or IO error so a
broken invocation is never mistaken for a clean map.
@kosuke55 kosuke55 added the run-tests Trigger CI test execution label Aug 21, 2026
@kosuke55
kosuke55 merged commit 593de4c into main Aug 22, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-tests Trigger CI test execution

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant