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
151 changes: 151 additions & 0 deletions docs/architecture/ADR-0031-dsl-composition-operators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<!--
SPDX-FileCopyrightText: 2026 Robomous
SPDX-License-Identifier: Apache-2.0
-->

# ADR-0031 — Lowering the DSL composition operators

- **Status:** Accepted
- **Date:** 2026-08-02
- **Sprint:** p8-s2 (#45)
- **Supersedes:** nothing. Builds on ADR-0030 (lowering to the IR) and on the
storyboard model P1 pinned (ADR-0025, ADR-0026).

## Context

ADR-0030 gave the DSL frontend a storyboard: one Story, one Act, one
ManeuverGroup per phase. It handled the two easy cases — `serial` chained on the
predecessor's completion, `parallel` left the triggers absent — and reported
everything else.

§7.6.2.1 asks for more than that. A composition can nest; it can carry a
`duration`; `one_of` picks one of several alternatives; `wait elapsed(d)`
introduces a phase in which nothing is specified. None of these is a runtime
concept: the storyboard machinery the XML frontend has been driving since P1
already does everything they need. What was missing is the *arithmetic* that
turns a composition into start conditions.

Three questions had no obvious answer:

1. **How does a `duration` become anything the IR can hold?** A storyboard
element has no duration field, and — probed against the engine — neither of
the obvious tricks works. A stop trigger only cuts a phase short: an Act
completes as soon as its groups do, so a step-shaped action ends the phase
immediately whatever the stop trigger says. And a start trigger delayed on
the predecessor's `runningState` never fires, because the predecessor is no
longer running by the time the delayed lookup happens.
2. **Which alternative does `one_of` run?** §7.6.2.1.3 says at least one must
hold and says nothing about which.
3. **What ends a parallel composition?** Its members end at different times,
and the phase after it has to wait for all of them.

## Decision

### 1. Composition decides *when* a phase starts, and nothing else

A composition operator contributes exactly one thing to the IR: the start
trigger on a phase's Event. No new runtime concept, no new IR node, no change to
the scheduler. That is what makes the two frontends share one runtime rather
than two that resemble each other.

### 2. A concrete duration is arithmetic, done at load time

The storyboard starts at t = 0 and every duration that lowers is a constant, so
a phase's absolute start time is the sum of the durations before it. That sum is
computable while lowering, exact, and needs no feedback from the run — which is
also why it is inside the determinism contract rather than at odds with it.

Lowering therefore tracks, for each point in the `do` directive, what load time
knows about it:

- an **absolute time**, when every preceding phase had a concrete duration; and
- a set of **groups that must have completed**, for the phases whose end only
the runtime knows.

A trigger ANDs whichever of the two is known — a `SimulationTimeCondition` plus
one `StoryboardElementStateCondition` per group, in a single ConditionGroup.
A phase whose start is t = 0 with nothing to wait for gets *no* trigger, because
`t >= 0` is a tautology and §7.6.1.1 already says a trigger-less element starts
with its parent.

**A range duration is not a value.** §7.6.2.4 lets `duration` be `[10s..30s]`,
which constrains accepted traces rather than fixing a time; choosing a value
from it needs a solver, and that is post-v0.0.1 (ADR-0004). It is reported and
the phase falls back to completion chaining.

### 3. A parallel join is an AND, which the trigger model already has

A `ConditionGroup` is a conjunction, so "every member has finished" is one group
with one condition per member. Members that end at a known time collapse into a
single `SimulationTimeCondition` at the latest of them; members that end when
their actions do each contribute their group's completion. Mixing the two is
therefore free, and the common case — every member has a duration — reduces to
one arithmetic comparison.

§7.6.2.1.4's default overlap is `start`: members begin together and may end
apart, which is exactly what absent start triggers already give. The other seven
overlap kinds and the `start_to_start`/`end_to_end` offsets are reported and not
realised in v0.0.1.

### 4. The `one_of` alternative is an input, defaulting to the first

§7.6.2.1.3's latitude is real, and an executor has to pick. Picking at random
would put a hidden input into the run, which is the one thing the determinism
contract exists to prevent — and the engine has no seed machinery to make such a
choice reproducible even in principle.

So the choice is an explicit input: `LowerOptions::alternative`, which
`scena-run --select` feeds, naming an alternative by its label. Empty means the
first alternative in declaration order. An alternative that is not there is an
error listing the ones that are.

This is a *lowering-time* decision, not a runtime one: the alternatives that were
not chosen do not reach the IR at all. That keeps the engine free of a concept
the XML side has no counterpart for.

### 5. `wait elapsed(d)` lowers to nothing but the clock

§7.6.2.4.2 introduces a phase of the given length in which nothing is specified.
Nothing is what it produces: no group, no event, no action — it only advances
the running offset the next phase starts from. An IR element that did nothing
for `d` seconds would be a fiction, and the clock is already running.

`wait` for an *event* is a different construct, and §7.6.2.5's events have no
runtime carrier in v0.0.1; it is reported, along with `emit`, `on` and `until`.

## Consequences

- A DSL scenario's phases are sequenced by construction. `scena-run` runs one
and the trace shows each phase taking over at the second the scenario named.
- Composition is a pure frontend concern. Adding an overlap kind, or a future
operator, changes lowering and nothing below it.
- The scenario's own end becomes the storyboard's stop trigger when it is a
constant, so a run's element states finish rather than sit at running.
- **A pre-existing lexer defect surfaced and was fixed here.** §7.2.2.6.7 spells
the range constructor `'[' expression '..' expression ']'`, and
§7.2.1.5.2's `float-literal ::= digit* '.' digit+` makes the leading digits
optional — so `[2..4]` is a race between the `..` operator and the float `.4`,
and the float was winning. The lexer emitted no `..` token at all and the
parser looked for `...`, a spelling the standard does not have. The two agreed
with each other, so every range in the test suite was written the wrong way and
nothing noticed. `duration: [10s..30s]` is what made it visible.

## Alternatives considered

**Give a storyboard element a duration field.** Rejected: it is a DSL concept
with no OpenSCENARIO XML counterpart, so it would be runtime machinery only one
frontend can reach — the opposite of the two-frontends-one-runtime rule.

**Bound a phase with a stop trigger on the Act.** Probed and rejected: a stop
trigger can only cut a phase short. An Act completes as soon as its groups do,
so a step-shaped action ends the phase immediately, and the duration would be
silently ignored for exactly the scenarios that are easiest to write.

**Delay the start trigger on the predecessor's `runningState`.** Probed and
rejected: the predecessor is no longer running when the delayed lookup happens,
so the trigger never fires at all.

**Pick a `one_of` alternative at random from a seeded generator.** Rejected: it
makes the run depend on a seed the scenario does not state, and the engine has
no seed machinery — introducing one for a frontend concept would put randomness
inside the determinism contract for the first time.
9 changes: 8 additions & 1 deletion docs/roadmap/coverage/osc-dsl-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,14 @@ time is.
| §8.8 movement actions → IR actions | §8.8.2–§8.8.4 | In | In | p8-s1 | **Landed** (`dsl_lowering_test.cpp`): `assign_speed`, `change_speed`, `remain_stationary`, `assign_position`, `change_lane`, `change_space_gap`, `change_time_gap`. `dynamic_profile` names the shape and `rate_peak` the magnitude; with no peak rate there is no number to ramp over, so the change is a Step (§7.4.1.2). Every action with no counterpart is reported by name, never silently dropped |
| Generic actions (`move`, `drive`, `walk`) | §8.8.2.3, §8.8.3.1, §8.8.4.1 | In | In | p8-s1, p8-s3 | **Landed**: they carry no target of their own and exist to be shaped by §8.9 modifiers, so on their own they lower to nothing — "keep doing what you are doing" is what the runtime already does |
| Struct-valued action arguments | §7.2.2.6.7 | In | In | p8-s1 | **Landed**: the DSL declares list and range constructors and no struct constructor, so a struct-valued argument names a declaration and the `keep`s on it are the value (ADR-0030 §7). A coordinate nothing constrains is reported, not assumed |
| `do` directive → storyboard | §7.6.2.1 | In | Partial | p8-s1, p8-s2 | **Landed**: one Story, one Act, one ManeuverGroup per phase; `serial` chains a phase on its predecessor reaching completeState (§7.6.2.1.2), `parallel` leaves the triggers absent (§7.6.1.1). `one_of`, nesting, `until`, `wait`, `emit`, `call` and an invocation `duration` are reported and land in p8-s2 |
| `do` directive → storyboard | §7.6.2.1 | In | In | p8-s1, p8-s2 | **Landed** (`dsl_lowering_test.cpp`): one Story, one Act, one ManeuverGroup per phase, nesting to any depth. A composition contributes exactly one thing — the start trigger on a phase's Event — so the runtime is unchanged (ADR-0031) |
| Serial composition | §7.6.2.1.2 | In | In | p8-s2 | **Landed**: a member starts when its predecessor ends. With concrete durations that is an absolute `SimulationTimeCondition` computed at load; otherwise the predecessor group's completeState |
| Parallel composition | §7.6.2.1.4 | In | In (default overlap) | p8-s2 | **Landed**: the default `overlap: start` is what absent start triggers already give, and the join is one ConditionGroup (an AND) over the members' ends. The other seven overlap kinds and `start_to_start`/`end_to_end` are reported, not realised |
| One-of composition | §7.6.2.1.3 | In | In | p8-s2 | **Landed**: the alternative is an *input* (`LowerOptions::alternative`, fed by `scena-run --select`), defaulting to the first in declaration order. Picking at random would put a hidden input in the run, and the engine has no seed machinery (ADR-0031) |
| `duration` on an invocation or composition | §7.6.2.4, §7.6.2.4.1 | In | Concrete only | p8-s2 | **Landed**: a constant duration becomes absolute phase boundaries; a range (`[10s..30s]`) constrains accepted traces rather than fixing a time, so it is reported and needs a solver (ADR-0004) |
| `wait elapsed(<duration>)` | §7.6.2.4.2 | In | In | p8-s2 | **Landed**: a phase in which nothing is specified lowers to nothing — no group, no event — and only advances the offset the next phase starts from. The clock is already running |
| `emit` / `wait @event` / `on` / `until` | §7.6.2.5 | In | Post | p8-s2 | Reported: §7.6.2.5's events are abstract control objects with no runtime carrier in v0.0.1. The trigger system has no event namespace, and inventing one would be runtime machinery only the DSL frontend can reach |
| 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 |
| `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 |
Expand Down
8 changes: 7 additions & 1 deletion docs/user-guide/scena-run.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ scena-run tests/golden/scenarios/gs1-cruise-baseline.xosc \
| `--trace-format <csv\|json>` | override the format inferred from the extension |
| `--map <file.xodr>` | road network, overriding the scenario's `RoadNetwork/LogicFile` |
| `--replay <entity>=<file>` | drive an entity from a recorded trace |
| `--select <alternative>` | choose a `one_of` alternative (DSL; p8-s2) |
| `--select <alternative>` | choose a `one_of` alternative by label (`.osc` only) |
| `--entry <scenario>` | the DSL scenario to run (`.osc` only) |
| `-I`, `--search-path <dir>` | where DSL imports are looked up (repeatable) |
| `--quiet` | do not print diagnostics |
Expand Down Expand Up @@ -52,6 +52,12 @@ error: the file declares more than one scenario; name the entry point (§7.7.2):
`--entry second` (or `--entry demo::second`) names it. `-I` adds a directory to
the import search path, exactly as it does for `scena-check`.

A `one_of` composition offers alternatives, and §7.6.2.1.3 leaves the choice to
the executor. `--select <label>` makes it, and without it the first alternative
in declaration order runs — the choice is never random, because a seed the
scenario does not state would be exactly the hidden input the determinism
contract exists to rule out.

The road network works the same way in both languages: XML names it in
`RoadNetwork/LogicFile`, DSL in §8.5.4's `map_file` — written either as
`map.set_map_file("m.xodr")` or as `keep(my_map.map_file == "m.xodr")`. Both
Expand Down
12 changes: 12 additions & 0 deletions frontends/dsl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,18 @@ expected. It also keeps the dependency list unchanged.
range constructors and no struct constructor, so `assign_position(position:
start)` reaches its numbers through the same `keep`s that make an actor
concrete.
- **Composition decides when a phase starts, and nothing else** (ADR-0031). A
concrete `duration` is arithmetic done at load — the storyboard starts at
t = 0, so a phase's start time is the sum of the durations before it — and a
parallel join is one ConditionGroup, which is already an AND. `one_of` picks
by label from `LowerOptions::alternative`, defaulting to the first: the engine
has no seed machinery, and a hidden input is what determinism forbids.
`wait elapsed(d)` lowers to nothing but the offset.
- **`..` has to beat a float that starts with a dot.** §7.2.2.6.7 spells the
range constructor `[a '..' b]` while §7.2.1.5.2 makes a float's leading digits
optional, so `[2..4]` is a race the operator must win. It did not until p8-s2;
the lexer emitted no `..` and the parser looked for `...`, and because the two
agreed nothing noticed.
- **The map file travels beside the IR.** §8.5.4's `map_file` is the DSL's
`RoadNetwork/LogicFile`, and like it, a road-network path is a host input
rather than kernel state — the engine reaches roads only through
Expand Down
9 changes: 9 additions & 0 deletions frontends/dsl/include/scena/dsl/lower.h
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ struct LowerOptions {
/// nothing; a file with several is reported rather than guessed at, because
/// picking one silently would make the run depend on declaration order.
std::string entry_point;

/// The `one_of` alternative to run, by its label (§7.6.2.1.3).
///
/// The standard says at least one alternative must hold and says nothing
/// about which, so an executor picks. Picking at random would put a hidden
/// input in the run, which is what the determinism contract exists to
/// prevent — so the choice is an explicit input, and empty means the first
/// alternative in declaration order.
std::string alternative;
};

/// What lowering produced: the IR plus the host-side references that are not
Expand Down
8 changes: 7 additions & 1 deletion frontends/dsl/src/lexer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,13 @@ constexpr std::array<std::string_view, 74> kReservedWords{
/// The multi-character operators of Table 5, longest first: §7.2.1.5 says
/// "tokens comprise the longest possible match that forms a legal token".
constexpr std::array<std::string_view, 8> kMultiCharOperators{
"...", "->", "=>", "==", "!=", "<=", ">=", "::",
// `..` is the range constructor's own spelling (§7.2.2.6.7's
// `'[' expression '..' expression ']'`). It has to be matched before a
// float is: without it `[2..4]` lexes as `2`, `.`, `.4` — the second dot is
// swallowed by `float-literal ::= digit* '.' digit+`, whose leading digits
// are optional. Table 5 lists neither this operator nor `::`; the grammar
// productions are what the tokens have to serve.
"..", "->", "=>", "==", "!=", "<=", ">=", "::",
};

/// The single-character operators and delimiters of Table 5.
Expand Down
Loading
Loading