OpenDRIVE validator: mutation-proven static checks with a CLI - #106
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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), orINFO.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_DEFECTerrors — 10/10 detected. The false-positive gate is treated as equally important: zeroMAP_DEFECTerrors 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 thelaneOffsetdifference before judging, and negative-width tolerance sits above float noise from lanes authored to close at exactly zero.Verification
tsc --noEmitclean