Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/roadmap/coverage/osc-dsl-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ time is.
| Range constructor `[a..b]` | §7.2.2.6.7 | In | In | p7-s1, p8-s2 | **Fixed in p8-s2** (`dsl_lexer_test.cpp`): `..` has to beat `float-literal ::= digit* '.' digit+`, whose leading digits are optional, or `[2..4]` lexes as `2`, `.`, `.4`. The lexer emitted no `..` at all and the parser looked for `...`; they agreed with each other, so nothing noticed until a duration range was written |
| `set_map_file` → road backend | §8.5.4, §8.12.2 | In | In | p8-s1 | **Landed** (`dsl_lowering_test.cpp`, `scena_run_test.cpp`): both spellings the standard prints — `map.set_map_file("m.xodr")` (Code 61) and `keep(my_map.map_file == "m.xodr")` (Code 62). The reference travels beside the IR, as `RoadNetwork/LogicFile` does, because a road-network path is a host input not kernel state (ADR-0003) |
| Actor type name as a modifier receiver | §7.3.12.4.1, §8.5.4 | In | n/a | p8-s1 | **Landed** (`dsl_types_test.cpp`): `map` in Code 61 names the actor *type* — the road network is a singleton no scenario declares a field for — so a bare actor type name is a receiver in its own right. A declared field of the same name still wins |
| XML↔DSL trace parity | — | n/a | In | p8-s4 | **Landed**: `scripts/golden.py compare-pair` runs a declared pair — one scenario authored in both languages — and asserts the traces are equal byte for byte. Part of `check-all`, so CI verifies it on three platforms |
| DSL golden scenarios | — | n/a | In | p8-s4 | **Landed**: GS-12 (`gs12-dsl-cruise.osc`, with its XML twin) and GS-13 (`gs13-dsl-alternatives.osc` — a parallel nested in a serial, a placement modifier, a relative-speed modifier and a `one_of` chosen by input) |
| `scena-run` runs `.osc` | — | n/a | In | p8-s1 | **Landed** (`scena_run_test.cpp`): the extension picks the frontend, `--entry` names the §7.7.2 entry point, `-I` adds an import search path. Same options, exit codes and trace format as XML |

## Language core (§7.2, §7.3)
Expand Down
64 changes: 40 additions & 24 deletions docs/roadmap/golden-scenarios.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,33 +259,49 @@ a cross vehicle flows during the orthogonal phase.
- **Pass:** bit-identical trace; ego standstill while red; ego crosses only
during green window; phase timings match declared program.

### GS-12 — DSL cut-in (parity pair with GS-2)
### GS-12 — DSL cruise, with an XML twin (parity pair) — **landed**

The GS-2 scenario expressed in OpenSCENARIO DSL 2.x as a concrete scenario
(`osc.vehicle` actors, `do serial`/`parallel` composition, movement actions
with speed/position/lane modifiers), compiled through the DSL frontend to
the same Scenario IR.
`gs12-dsl-cruise.osc`: a concrete DSL scenario — one `vehicle` actor, a
`do serial` of two `drive()` phases with concrete durations, speed modifiers
anchored at `start` and at `end` — compiled through the DSL frontend into the
same Scenario IR the XML frontend produces. `gs12-xml-cruise.xosc` says the same
thing in the other language.

- **Exercises:** P7 (parse, type-check against the standard library), P8
(lowering, modifiers, composition), plus everything GS-2 touches.
- **Pass:** DSL trace is **bit-identical to the GS-2 XML trace** — the
two-frontends-one-runtime claim, verified literally.
- **Role:** retires the execution-parity risk (R5); the release headline
demo.

### GS-13 — DSL composition showcase

A DSL-only scenario: `do serial` of a `parallel` phase (two vehicles adjust
speeds concurrently) and a `one_of` phase executed with the documented
deterministic selection input (host-selected alternative, default first);
an `event`/`wait` dependency gates the final phase; `until` bounds a
sub-composition.

- **Exercises:** P7 (full syntax incl. events), P8 (serial/parallel/one_of
semantics per DSL §7.6, modifier timing), P6.
- **Pass:** bit-identical trace per selected alternative (run twice with
alternative 0 and 1, each against its own reference); phase boundaries at
declared times; `wait` releases exactly on the emitted event.
(entry point, actor lowering, serial composition, durations, `at` anchoring).
- **Pass:** the two traces are **equal byte for byte**
(`golden.py compare-pair gs12`, part of `check-all` on all three platforms) —
the two-frontends-one-runtime claim, verified literally.
- **Role:** retires the execution-parity risk (R5); the release headline demo.

> **Why the pair is GS-1's shape and not GS-2's.** The plan was a DSL twin of
> the GS-2 cut-in. A byte-identical pair needs both files to denote the *same IR
> actions*, and GS-2's lane change does not survive that: §8.9's `lateral`
> modifier lowers to a `LaneOffsetAction` while GS-2's XML uses a
> `LaneChangeAction`, whose transition time is derived from `maxLateralAcc`
> rather than from a duration (ADR-0016). The two are different actions with
> different timing laws, so a pair built on them would compare two things that
> were never claimed to be equal. GS-1's longitudinal shape is expressible
> identically in both languages, so it is what the parity claim is made on;
> GS-13 covers the DSL-only constructs that have no XML counterpart at all.

### GS-13 — DSL composition showcase — **landed**

`gs13-dsl-alternatives.osc`: DSL-only constructs, with no XML counterpart. A
`do serial` whose first phase is a `parallel` of two vehicles adjusting speed
concurrently — one of them placed by a `position(ahead_of:)` modifier — followed
by a `one_of` phase run with the documented deterministic selection input
(`--select`, default the first alternative). The chosen alternative uses a
relative-speed modifier (`slower_than`), the other an absolute target.

- **Exercises:** P7 (full syntax), P8 (serial/parallel/one_of per §7.6.2.1,
modifier timing, relative targets), P6 (the CLI's selection input).
- **Pass:** bit-identical trace for the default alternative; phase boundaries at
the declared times; `--select brake` changes what runs and nothing else.
- **Not covered, and deliberately:** `event`/`wait`/`until` gating. §7.6.2.5's
events are abstract control objects with no runtime carrier in v0.0.1, so they
are reported rather than executed (ADR-0031); a golden scenario asserting them
would be asserting a diagnostic, not a behaviour.

### GS-14 — Determinism soak

Expand Down
3 changes: 3 additions & 0 deletions docs/user-guide/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ The user guide grows sprint by sprint along the
- [`scena-run`](scena-run.md) — the headless CLI: options and exit codes, the
round-trip-exact trace format, replaying a host-controlled entity, running a
`.osc` file through the DSL frontend, and the golden suite harness.
- [One scenario, two languages](one-scenario-two-languages.md) — the XML/DSL
parity claim as a test, how each language reaches the same IR, and every place
the two standards genuinely differ.
- [`scena-check`](scena-check.md) — the OpenSCENARIO DSL checker: options and
exit codes, the diagnostic format, resolving imports and search paths,
checking from C and Python, and what "checked clean" does and does not cover.
Expand Down
120 changes: 120 additions & 0 deletions docs/user-guide/one-scenario-two-languages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# One scenario, two languages

Scena executes ASAM OpenSCENARIO XML and ASAM OpenSCENARIO DSL. They are not
two engines that behave similarly: both frontends compile into the same
Scenario IR, and one runtime executes it. This page is where that claim is
spelled out — and where the places the two languages genuinely differ are
written down rather than glossed over.

## The claim, as a test

`tests/golden/scenarios/gs12-dsl-cruise.osc` and `gs12-xml-cruise.xosc` say the
same thing in the two languages:

```
# OpenSCENARIO DSL
do serial:
cruise: ego.drive(duration: 5s) with:
speed(speed: 10mps, at: at!start)
accelerate: ego.drive(duration: 4s) with:
speed(speed: 20mps, at: at!end)
```

```xml
<!-- OpenSCENARIO XML -->
<Event name="cruise">…step to 10 m/s…</Event>
<Event name="accelerate">…linear over 4 s to 20 m/s, from t >= 5…</Event>
```

Run both and the traces are equal **byte for byte** — not close, equal:

```sh
python scripts/golden.py compare-pair gs12
ok gs12 pair (gs12-dsl-cruise.osc == gs12-xml-cruise.xosc, byte for byte)
```

The pair is part of `check-all`, so CI asserts it on macOS, Linux and Windows
on every push. If a change made one frontend drift from the other, this is what
would say so.

## How the two get there

| | OpenSCENARIO XML | OpenSCENARIO DSL |
|---|---|---|
| Who runs | the `Storyboard`'s stories and acts | the entry scenario's `do` directive (§7.7.2) |
| Who is in it | `Entities`/`ScenarioObject` | fields whose type derives from `std::physical_object` |
| Concrete values | attributes | `keep(field == constant)` (§7.3.11) |
| Sequencing | start triggers you write | `serial`/`parallel`/`one_of` lowered to start triggers |
| What acts | actions in events | actions, plus §8.9 modifiers on generic actions |
| Road network | `RoadNetwork/LogicFile` | §8.5.4's `map_file` |

ADR-0030, ADR-0031 and ADR-0032 record the mapping decisions. The short version:
a DSL construct only ever decides *which IR construct it denotes*. Nothing in
the runtime knows the DSL exists.

## Where the two languages genuinely differ

These are not implementation gaps. They are places where the standards
themselves do not line up, and Scena had to choose. Each choice is recorded
here, in the coverage matrix, and in the ADR that made it.

**The DSL declares no performance limits.** §8.7's actor hierarchy has no
counterpart to XML's `Performance` element — no maximum speed, acceleration or
deceleration anywhere. A DSL vehicle is therefore unconstrained, which the IR
already spells as zero (the runtime reads a non-positive limit as "no limit").
No numbers are invented. It does mean an XML scenario and its DSL twin will
diverge if the XML one relies on a `Performance` clamp — which is why
`gs12-xml-cruise.xosc` sets its limits to zero.

**`animal` has no taxonomy counterpart.** §8.7.10 declares `animal` as a sibling
of `vehicle` and `person`. XML has nowhere to put it, so a DSL `animal` lowers
as an unclassified participant: it has an identity and a control mode, which is
all the runtime needs of it. A wrong classification would be worse than none.

**Entry-point selection is implementation-defined.** §7.7.2 says so outright. A
file with one scenario runs it; a file with several is an error listing them,
because guessing would make the run depend on declaration order. `--entry`
names one.

**`one_of` has no XML counterpart at all.** §7.6.2.1.3 says at least one
alternative must hold and says nothing about which. Scena makes the choice an
*input* (`--select`, defaulting to the first alternative) rather than a random
draw, because a seed the scenario does not state is exactly the hidden input the
determinism contract exists to rule out. Alternatives that are not chosen never
reach the IR.

**Three §8.8/§8.9 names collide in the standard.** `change_speed`, `keep_speed`
and `change_lane` are declared both as actions on an actor (§8.8) and as
modifiers for the same actor (§8.9). A qualified behavior name identifies
exactly one declaration, so the language cannot hold both; the three modifiers
take §7.3.12.3's *unassociated* form, the only spelling that exists and does not
collide.

**Two more §8.15 contradictions**, resolved by asking which surface the rest of
the chapter corroborates: §8.15.4.2.1 prints `extend traffic_light:` for a
*group*'s `state_equal` while its heading, prose and Table 319 all say group —
the printed code loses. Table 337 names `set_group_bulb_state`'s first parameter
`traffic_light` while its description says "group" — the parameter table wins,
because that is what a conforming scenario is written against.

**An overloaded enum literal is not yet resolved by context** — a known gap,
tracked as issue #110. §7.3.3 says such a literal "will depend on the type
requirements of the place it is used in"; Scena reports the ambiguity first. In
practice this means `at: start` is rejected and `at: at!start` is accepted,
because §8.9.19's `at` and §8.12's `route_overlap_kind` both declare `start`.
Nothing is blocked, but a scenario copied out of the specification may need the
literal qualified.

## What the DSL does not execute yet

Checked but not executed in v0.0.1, always with a structured diagnostic naming
the section: logical-scenario *selection* (ranges, distributions), constraints
that would need a solver, coverage collection, `emit`/`wait @event`/`on`/`until`
(§7.6.2.5's events have no runtime carrier), `along`/`along_trajectory` (a
concrete route comes only from §8.12.2's map methods, which the standard says
may be external), and the non-default parallel overlaps. The DSL coverage matrix
lists every one with its reason.

Nothing in that list is approximated. Where a value cannot be made concrete
without search, Scena reports it — reporting beats approximating when
determinism is the promise (ADR-0004).
141 changes: 141 additions & 0 deletions scripts/golden.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,11 +239,73 @@ class Scenario:
Checkpoint(t=19.0, entity="ego", field="speed", value=0.0),
),
),
# --- OpenSCENARIO DSL (p8-s4) -------------------------------------------
# Written in the other language, executed by the same runtime. GS-12 has an
# XML twin (gs12-xml-cruise.xosc) whose trace must be byte-identical; that
# pair is what turns "two frontends, one runtime" from a claim into a test.
Scenario(
name="gs12",
file="gs12-dsl-cruise.osc",
duration=12.0,
exercises="DSL frontend end to end: entry point, actor lowering, serial "
"composition with concrete durations, speed modifiers anchored at start and end",
checkpoints=(
# Phase 1 holds the speed the `at: start` modifier fixed.
Checkpoint(t=4.0, entity="ego", field="speed", value=10.0),
# Phase 2 ramps to 20 over its 4 s duration, and the scenario ends
# when its `do` directive does (t = 9), freezing the last value.
Checkpoint(t=11.0, entity="ego", field="speed", value=20.0, tolerance=0.03),
# Straight line throughout: nothing lateral was ever asked for.
Checkpoint(t=11.0, entity="ego", field="y", value=0.0),
),
),
Scenario(
name="gs13",
file="gs13-dsl-alternatives.osc",
duration=8.0,
exercises="DSL composition with no XML counterpart: a parallel nested in a serial, "
"a placement modifier, a relative-speed modifier, and a one_of alternative "
"chosen by input",
checkpoints=(
# The placement modifier put lead 30 m ahead of ego at t = 0.
Checkpoint(t=0.01, entity="lead", field="x", value=30.12),
Checkpoint(t=1.0, entity="ego", field="speed", value=10.0),
Checkpoint(t=1.0, entity="lead", field="speed", value=12.0),
# The default alternative is `follow`: 4 m/s slower than lead, from
# the moment the parallel phase ends at t = 2.
Checkpoint(t=5.0, entity="ego", field="speed", value=8.0),
Checkpoint(t=5.0, entity="lead", field="speed", value=12.0),
),
),
)

BY_NAME = {scenario.name: scenario for scenario in SUITE}


@dataclass(frozen=True)
class Pair:
"""One scenario written in both languages, with the same trace expected.

The architecture's central claim is that the two frontends compile into one
IR and that the runtime is shared. A pair is that claim as a test: the same
scenario, authored once in OpenSCENARIO XML and once in OpenSCENARIO DSL,
must produce traces that are equal byte for byte — not close, equal.
"""

name: str
dsl: str
xml: str
duration: float
dt: float = 0.01


#: The declared pairs. `dsl` is a suite member, so its trace is already
#: bit-identical across platforms; the twin is run alongside and compared.
PAIRS: tuple[Pair, ...] = (
Pair(name="gs12", dsl="gs12-dsl-cruise.osc", xml="gs12-xml-cruise.xosc", duration=12.0),
)


def platform_key(explicit: str | None = None) -> str:
"""The reference-trace directory. "reference" — shared — unless overridden."""
return explicit or "reference"
Expand Down Expand Up @@ -400,6 +462,69 @@ def cmd_record(args: argparse.Namespace) -> int:
return 0


def run_file(path: Path, duration: float, dt: float, out: Path) -> Path:
"""Runs one scenario file — either language — and returns its trace."""
out.mkdir(parents=True, exist_ok=True)
trace = out / f"{path.stem}.csv"
result = subprocess.run(
[
str(scena_run_binary()),
str(path),
"--dt",
repr(dt),
"--duration",
repr(duration),
"--trace",
str(trace),
"--quiet",
],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise SystemExit(f"{path.name}: scena-run exited {result.returncode}\n{result.stderr}")
return trace


def check_pair(pair: Pair, out_dir: Path) -> list[str]:
"""Both languages, one runtime: the traces must be equal byte for byte."""
left = run_file(SCENARIOS / pair.dsl, pair.duration, pair.dt, out_dir)
right = run_file(SCENARIOS / pair.xml, pair.duration, pair.dt, out_dir)
left_bytes = left.read_bytes()
right_bytes = right.read_bytes()
if left_bytes == right_bytes:
return []
if len(left_bytes) != len(right_bytes):
return [
f"{pair.name}: {pair.dsl} is {len(left_bytes)} bytes, "
f"{pair.xml} is {len(right_bytes)}"
]
for index, (a, b) in enumerate(zip(left_bytes, right_bytes)):
if a != b:
line = left_bytes[:index].count(b"\n") + 1
return [
f"{pair.name}: the two languages diverge at line {line}\n"
f" {pair.dsl}: {left_bytes.splitlines()[line - 1].decode()}\n"
f" {pair.xml}: {right_bytes.splitlines()[line - 1].decode()}"
]
return []


def cmd_compare_pair(args: argparse.Namespace) -> int:
pairs = [p for p in PAIRS if not args.pair or p.name == args.pair]
out_dir = REPO / "build" / "golden-out"
failures: list[str] = []
for pair in pairs:
pair_failures = check_pair(pair, out_dir)
failures.extend(pair_failures)
for failure in pair_failures:
print(f"FAIL {failure}", file=sys.stderr)
if not pair_failures:
print(f"ok {pair.name} pair ({pair.dsl} == {pair.xml}, byte for byte)")
return 1 if failures else 0


def cmd_check_all(args: argparse.Namespace) -> int:
platform = platform_key(args.platform)
out_dir = REPO / "build" / "golden-out"
Expand All @@ -415,6 +540,16 @@ def cmd_check_all(args: argparse.Namespace) -> int:
print(f" {failure}", file=sys.stderr)
else:
print(f"ok {scenario.name} ({len(scenario.checkpoints)} checkpoint(s))")

for pair in PAIRS:
pair_failures = check_pair(pair, out_dir)
if pair_failures:
failures.extend(pair_failures)
print(f"FAIL {pair.name} pair", file=sys.stderr)
for failure in pair_failures:
print(f" {failure}", file=sys.stderr)
else:
print(f"ok {pair.name} pair ({pair.dsl} == {pair.xml}, byte for byte)")
print(f"\n{len(SUITE)} scenarios, {len(failures)} failure(s)", file=sys.stderr)
return 1 if failures else 0

Expand Down Expand Up @@ -442,6 +577,12 @@ def main() -> int:
record_parser.add_argument("--platform", default=None)
record_parser.set_defaults(func=cmd_record)

pair_parser = sub.add_parser(
"compare-pair", help="assert a declared XML/DSL pair traces identically"
)
pair_parser.add_argument("pair", nargs="?", default="")
pair_parser.set_defaults(func=cmd_compare_pair)

check_parser = sub.add_parser("check-all", help="run and verify the whole suite")
check_parser.add_argument("--platform", default=None)
check_parser.set_defaults(func=cmd_check_all)
Expand Down
Loading
Loading