diff --git a/docs/architecture/ADR-0031-dsl-composition-operators.md b/docs/architecture/ADR-0031-dsl-composition-operators.md new file mode 100644 index 0000000..0849ee3 --- /dev/null +++ b/docs/architecture/ADR-0031-dsl-composition-operators.md @@ -0,0 +1,151 @@ + + +# 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. diff --git a/docs/roadmap/coverage/osc-dsl-coverage.md b/docs/roadmap/coverage/osc-dsl-coverage.md index 95c9ca8..6caad84 100644 --- a/docs/roadmap/coverage/osc-dsl-coverage.md +++ b/docs/roadmap/coverage/osc-dsl-coverage.md @@ -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()` | §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 | diff --git a/docs/user-guide/scena-run.md b/docs/user-guide/scena-run.md index 0b01613..4ecbbbf 100644 --- a/docs/user-guide/scena-run.md +++ b/docs/user-guide/scena-run.md @@ -22,7 +22,7 @@ scena-run tests/golden/scenarios/gs1-cruise-baseline.xosc \ | `--trace-format ` | override the format inferred from the extension | | `--map ` | road network, overriding the scenario's `RoadNetwork/LogicFile` | | `--replay =` | drive an entity from a recorded trace | -| `--select ` | choose a `one_of` alternative (DSL; p8-s2) | +| `--select ` | choose a `one_of` alternative by label (`.osc` only) | | `--entry ` | the DSL scenario to run (`.osc` only) | | `-I`, `--search-path ` | where DSL imports are looked up (repeatable) | | `--quiet` | do not print diagnostics | @@ -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