diff --git a/docs/architecture/ADR-0030-dsl-lowering-to-the-ir.md b/docs/architecture/ADR-0030-dsl-lowering-to-the-ir.md index b6c8a21..da1c4cd 100644 --- a/docs/architecture/ADR-0030-dsl-lowering-to-the-ir.md +++ b/docs/architecture/ADR-0030-dsl-lowering-to-the-ir.md @@ -7,7 +7,7 @@ SPDX-License-Identifier: Apache-2.0 - **Status:** Accepted - **Date:** 2026-08-02 -- **Sprint:** p8-s1 (#44) +- **Sprint:** p8-s1 (#44), extended in the same sprint's second half - **Supersedes:** nothing. Builds on ADR-0028 (symbols and the type model), ADR-0029 (the bundled standard library) and ADR-0010 (the entity taxonomy). @@ -115,10 +115,83 @@ Anything that would need search is a diagnostic, not a silently-defaulted value frontend takes for constructs it does not implement. A scenario that declares no participant is a warning: the file is well-formed, it simply has nothing to run. +### 6. A behavior invocation denotes an IR action; the `do` directive denotes a storyboard + +The runtime the two frontends share already knows what a speed transition is, so +lowering a §8.8 movement action decides only which IR action it *is*. Seven of +them have an unambiguous counterpart and lower: + +| §8.8 action | IR action | +|---|---| +| `assign_speed` | `SpeedAction`, Step | +| `change_speed` | `SpeedAction` with the transition §8.8.2.18 asks for | +| `remain_stationary` | `SpeedAction` to zero, Step | +| `assign_position` | `TeleportAction` to a `WorldPosition` | +| `change_lane` | `LaneChangeAction` with a `RelativeTargetLane` | +| `change_space_gap` | `LongitudinalDistanceAction`, distance | +| `change_time_gap` | `LongitudinalDistanceAction`, time gap | + +`move`, `drive` and `walk` are the generic actions: they carry no target of +their own and exist to be shaped by §8.9 modifiers (p8-s3). On their own they +say "keep doing what you are doing", which the runtime already does, so they +lower to nothing. Every other §8.8 action is reported by name, never silently +dropped. + +Two readings are worth stating because the standard does not state them: + +- **`dynamic_profile` names a shape, `rate_peak` a magnitude, and neither is a + duration.** With no peak rate there is no number to ramp over, so the change + is instantaneous — which is exactly what a Step shape means (§7.4.1.2). + `asap` is a Step from the other direction: §8.7 declares no performance + envelope, so "as soon as possible" is bounded by nothing. +- **`change_lane` needs an explicit `left` or `right`.** §8.8.3.14's `inside`, + `outside` and `same` need road geometry to say which way that is, and an + unstated side would have to be *chosen*. Choosing is what the determinism + contract forbids, so this is a diagnostic rather than a default. + +The `do` directive becomes one Story, one Act, and one ManeuverGroup per phase — +the group is where an actor lives, so a group per phase keeps each invocation's +actor with its actions. `serial` chains a phase on its predecessor reaching +completeState, which is the trigger form §7.6.2.1.2's "starts when its +predecessor ends" already has in the runtime; `parallel` leaves the triggers +absent, so every phase starts with its Act (§7.6.1.1). A phase without a label +is named for its position, because the runtime addresses storyboard elements by +name path. Everything else about composition — `one_of`, nesting, `until`, +`wait`, `emit`, `call`, and an invocation's `duration` — is reported and belongs +to p8-s2. + +### 7. A struct-valued argument names a declaration, and the `keep`s on it are the value + +The DSL has no struct constructor: §7.2.2.6.7 declares list and range +constructors and nothing else. So `assign_position(position: start)` can only +name a declaration, and what makes `start` concrete is the same binding table +decision 3 already uses for an actor's own attributes. Nothing new is invented; +the rule is simply applied one level further out. A coordinate nothing +constrains is reported, not assumed. + +### 8. The map file travels beside the IR, not inside it + +§8.5.4's `map_file` is the DSL's counterpart to `RoadNetwork/LogicFile`, and it +gets the same treatment the XML frontend already gives that: a road-network path +is an input to the *host*, not kernel state, because the engine reaches roads +only through `IRoadQuery` (ADR-0003). `LowerResult` therefore mirrors +`xml::Document` — the IR plus the file reference — rather than growing a path +field on `ir::Scenario`. + +Both spellings the standard prints are read and mean the same thing: +`map.set_map_file("m.xodr")` (Code 61) and `keep(my_map.map_file == "m.xodr")` +(Code 62). The first needs one resolution rule the checker did not have: **a +bare actor type name is a receiver in its own right**. `map` in Code 61 names +the actor *type* — the road network is a singleton no scenario declares a field +for — and without that rule the standard's own concrete-scenario example +(Code 6) is rejected. A declared field of the same name still wins, because the +receiver a reader means by a name is the declaration in front of them. + ## Consequences -- `.osc` produces an `ir::Scenario` the existing engine accepts. Actions, - `set_map_file` and `scena-run`'s `.osc` support are the rest of p8-s1. +- `.osc` produces an `ir::Scenario` the existing engine accepts, and + `scena-run` runs one: the file's extension picks the frontend and everything + past the load sees only the IR. - Lowering is inside the determinism contract, because load time is. It reads ordered containers only, walks fields in declaration order, and does no floating-point arithmetic of its own — the values it copies were folded once, @@ -137,6 +210,14 @@ what executes. fabricates numbers the standard does not state. "Unconstrained" is what §8.7 actually says, and the runtime already spells it as zero. +**Give `drive()` a default action so every scenario runs something.** Rejected: +the generic actions mean "unconstrained", and inventing a speed for them would +put a number in the trace that the scenario never stated. + +**Put the road-network path in `ir::Scenario`.** Rejected: it would make the +kernel carry a host-side file reference, which is the coupling the gateway +exists to prevent, and the XML frontend already states the opposite rule. + **Solve constraints during lowering.** Rejected by ADR-0004: constraint solving is post-v0.0.1, and half-solving would produce scenarios whose behaviour depends on the solver's search order — the opposite of the determinism promise. diff --git a/docs/roadmap/coverage/osc-dsl-coverage.md b/docs/roadmap/coverage/osc-dsl-coverage.md index 6778be2..95c9ca8 100644 --- a/docs/roadmap/coverage/osc-dsl-coverage.md +++ b/docs/roadmap/coverage/osc-dsl-coverage.md @@ -50,8 +50,13 @@ time is. | Concrete value binding | §7.3.11 | In | In | p8-s1 | **Landed**: `keep( == )` in either operand order, where the constant folds without a solver — that is what §6.3.1.2.1's "attribute-level concrete" means. §7.3.8.2 conditional inheritance is read the same way. Anything needing search is diagnosed, never approximated (ADR-0004) | | Physical values in the IR | §7.3.4 | In | In | p8-s1 | **Landed**: values arrive already folded to their base unit, so lowering never converts and never re-applies the standard's printed factors a second time (ADR-0029) | | Performance limits | §8.7 | n/a | Excl | p8-s1 | §8.7 declares no performance limits at all — the domain model has no counterpart to XML's `Performance`. The IR's zeros are the faithful lowering: the runtime reads a non-positive limit as unconstrained. No numbers are invented | -| §8.8 movement actions → IR actions | §8.8.2–§8.8.4 | In | In | p8-s1 | Planned (p8-s1 follow-up): the subset the action table below marks In | -| `set_map_file` → road backend | §8.12.2 | In | In | p8-s1 | Planned (p8-s1 follow-up) | +| §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 | +| `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 | ## Language core (§7.2, §7.3) diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index 80c0add..7465094 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -56,8 +56,8 @@ The user guide grows sprint by sprint along the reach position), the traffic-signal conditions, the scalar-velocity model, the observation-sampling contract, and the host interface (C++/C/Python). - [`scena-run`](scena-run.md) — the headless CLI: options and exit codes, the - round-trip-exact trace format, replaying a host-controlled entity, and the - golden suite harness. + round-trip-exact trace format, replaying a host-controlled entity, running a + `.osc` file through the DSL frontend, and the golden suite harness. - [`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. diff --git a/docs/user-guide/scena-run.md b/docs/user-guide/scena-run.md index b072838..0b01613 100644 --- a/docs/user-guide/scena-run.md +++ b/docs/user-guide/scena-run.md @@ -23,9 +23,40 @@ scena-run tests/golden/scenarios/gs1-cruise-baseline.xosc \ | `--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) | +| `--entry ` | the DSL scenario to run (`.osc` only) | +| `-I`, `--search-path ` | where DSL imports are looked up (repeatable) | | `--quiet` | do not print diagnostics | | `-h`, `--help` | usage | +## Two frontends, one runtime + +The file's extension picks the frontend: `.osc` is compiled by the +OpenSCENARIO DSL frontend, anything else by the OpenSCENARIO XML frontend. +Everything past the load — the engine, the gateway, the trace — sees only the +Scenario IR, so the options, the exit codes and the trace format are the same +either way. + +```sh +scena-run cruise.osc --dt 0.01 --duration 12 --trace out/cruise.csv +``` + +A DSL file may declare several scenarios, and §7.7.2 leaves it to the +implementation which one runs. Scena runs the only one when there is only one +and reports the choice when there is not, because guessing would make the run +depend on declaration order: + +``` +error: the file declares more than one scenario; name the entry point (§7.7.2): demo::first demo::second +``` + +`--entry second` (or `--entry demo::second`) names it. `-I` adds a directory to +the import search path, exactly as it does for `scena-check`. + +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 +are resolved relative to the scenario file, and `--map` overrides either. + Only whole steps run: `--duration 1 --dt 0.3` runs three steps, not three and a third. A variable last step would make the tail of every trace depend on how the duration happens to divide. diff --git a/frontends/dsl/README.md b/frontends/dsl/README.md index 17bdcaf..47f52ae 100644 --- a/frontends/dsl/README.md +++ b/frontends/dsl/README.md @@ -242,3 +242,20 @@ expected. It also keeps the dependency list unchanged. evaluation searches the use list after the current namespace (§7.7.4.2). Before p8-s1 it searched only the current namespace, which silently made every enum-valued `keep` look like one that needs a solver. +- **A movement action denotes an IR action, and the `do` directive a + storyboard.** Seven §8.8 actions have an unambiguous counterpart and lower; + the generic `move`/`drive`/`walk` carry no target of their own and lower to + nothing, because they exist to be shaped by §8.9 modifiers (p8-s3). Anything + with no counterpart is reported by name — never silently dropped. `serial` + chains a phase on its predecessor reaching completeState, `parallel` leaves + the triggers absent; the rest of composition is p8-s2. +- **A struct-valued argument names a declaration.** §7.2.2.6.7 declares list and + range constructors and no struct constructor, so `assign_position(position: + start)` reaches its numbers through the same `keep`s that make an actor + concrete. +- **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 + `IRoadQuery`. `map.set_map_file("m.xodr")` needs one resolution rule the + checker did not have: a bare **actor type name** is a receiver in its own + right, because the map is a singleton no scenario declares a field for. diff --git a/frontends/dsl/include/scena/dsl/lower.h b/frontends/dsl/include/scena/dsl/lower.h index 59ca66c..275935b 100644 --- a/frontends/dsl/include/scena/dsl/lower.h +++ b/frontends/dsl/include/scena/dsl/lower.h @@ -40,6 +40,23 @@ struct LowerOptions { std::string entry_point; }; +/// What lowering produced: the IR plus the host-side references that are not +/// kernel state. +/// +/// The shape mirrors `xml::Document` deliberately. A road-network file is an +/// input to the *host*, not to the engine — the engine reaches roads only +/// through `IRoadQuery` (ADR-0003) — so it travels beside the IR rather than +/// inside it, exactly as `xml::RoadNetwork` does. +struct LowerResult { + ir::Scenario scenario; + /// §8.5.4's `map_file`, verbatim and unresolved: the string the scenario + /// wrote, whether through `map.set_map_file("m.xodr")` (Code 61) or through + /// a `keep` on a declared `map` field (Code 62). Interpreting it relative to + /// the scenario file is the host's decision, the same rule the XML side + /// states for `LogicFile`. + std::string map_file; +}; + /// The scenarios a root file offers as entry points, in declaration order. /// /// What a CLI prints when the choice is ambiguous, and what an editor would @@ -61,6 +78,6 @@ struct LowerOptions { /// /// Returns Status::Ok when nothing was reported as an error. [[nodiscard]] Status lower(const Program& program, const LoadResult& loaded, - const LowerOptions& options, ir::Scenario& out, DiagnosticSink& sink); + const LowerOptions& options, LowerResult& out, DiagnosticSink& sink); } // namespace scena::dsl diff --git a/frontends/dsl/src/lower.cpp b/frontends/dsl/src/lower.cpp index d2ae302..59eef89 100644 --- a/frontends/dsl/src/lower.cpp +++ b/frontends/dsl/src/lower.cpp @@ -25,16 +25,25 @@ #include "scena/dsl/lower.h" #include +#include #include +#include #include #include #include #include "scena/dsl/ast.h" #include "scena/dsl/expression.h" +#include "scena/ir/action.h" #include "scena/ir/bounding_box.h" +#include "scena/ir/condition.h" +#include "scena/ir/dynamics.h" #include "scena/ir/entity.h" #include "scena/ir/entity_types.h" +#include "scena/ir/evaluation_context.h" +#include "scena/ir/position.h" +#include "scena/ir/storyboard.h" +#include "scena/ir/trigger.h" namespace scena::dsl { namespace { @@ -47,6 +56,7 @@ struct LibraryTypes { TypeId vehicle = kInvalidType; TypeId person = kInvalidType; TypeId stationary_object = kInvalidType; + TypeId map = kInvalidType; }; [[nodiscard]] TypeId find_type(const Program& program, const std::string& name) { @@ -60,6 +70,7 @@ struct LibraryTypes { types.vehicle = find_type(program, "std::vehicle"); types.person = find_type(program, "std::person"); types.stationary_object = find_type(program, "std::stationary_object"); + types.map = find_type(program, "std::map"); return types; } @@ -242,6 +253,131 @@ std::optional vehicle_category_of(const std::string& name) return found->second; } +// --- §8.8 movement actions ------------------------------------------------- + +/// One argument of a behavior invocation, taken as far as lowering can take it: +/// a folded constant, or the path of the field it names. +/// +/// The two cases are different in kind, not in degree. `target: 20mps` is a +/// value; `reference: lead` and `position: start_point` name a *declaration*, +/// and what makes them concrete is the equality constraints the scenario put on +/// it — the same binding table §7.3.11 already gives the entities (ADR-0030). +struct ArgumentValue { + bool folded = false; + Value value; + std::vector path; + SourceRange range; +}; + +using ArgumentMap = std::map; + +/// Binds an invocation's arguments to the invoked behavior's parameter names. +/// +/// Positional arguments bind in declaration order and named ones by name +/// (§7.2.2.5.2). A name the behavior does not declare is left out: the resolver +/// has already reported it, and lowering does not repeat a diagnostic. +ArgumentMap bind_arguments(const Program& program, TypeId behavior, + const std::vector& arguments, + const ExpressionContext& context) { + ArgumentMap bound; + const std::vector& order = program.types[behavior].field_order; + std::size_t positional = 0; + for (const Argument& argument : arguments) { + if (argument.value == nullptr) { + continue; + } + std::string name = argument.name; + if (name.empty()) { + if (positional >= order.size()) { + continue; + } + name = order[positional++]; + } + ArgumentValue value; + value.range = argument.range; + value.folded = evaluate_constant(program, *argument.value, context, value.value); + if (!value.folded) { + (void)field_path(*argument.value, value.path); + } + bound.insert_or_assign(std::move(name), std::move(value)); + } + return bound; +} + +/// The folded number an argument carries, if it carries one. +std::optional argument_number(const ArgumentMap& arguments, const char* name) { + const auto found = arguments.find(name); + if (found == arguments.end() || !found->second.folded || !found->second.value.is_numeric()) { + return std::nullopt; + } + return found->second.value.as_double(); +} + +/// The enum member an argument names, or empty. +std::string argument_enum(const Program& program, const ArgumentMap& arguments, const char* name) { + const auto found = arguments.find(name); + if (found == arguments.end() || !found->second.folded || + found->second.value.kind != Value::Kind::Enum || found->second.value.type == kInvalidType) { + return {}; + } + for (const EnumMemberInfo& member : program.types[found->second.value.type].enum_members) { + if (member.value == found->second.value.enum_value) { + return member.name; + } + } + return {}; +} + +/// The single field name an argument refers to, or empty when it is not a plain +/// reference to a declaration of the scenario. +std::string argument_reference(const ArgumentMap& arguments, const char* name) { + const auto found = arguments.find(name); + if (found == arguments.end() || found->second.folded || found->second.path.size() != 1) { + return {}; + } + return found->second.path.front(); +} + +/// The behavior an invocation names, found the way §7.3.12.2 scopes it: through +/// the receiver's inheritance chain, one exact lookup per supertype. +TypeId lookup_behavior_on(const Program& program, TypeId receiver, const std::string& name) { + for (TypeId current = receiver; current != kInvalidType; + current = program.types[current].base) { + const auto found = program.types_by_name.find(program.types[current].name + "." + name); + if (found != program.types_by_name.end() && + program.types[found->second].kind == TypeKind::Action) { + return found->second; + } + } + return kInvalidType; +} + +/// The transition a §8.8 `rate_profile`/`rate_peak` pair asks for. +/// +/// §8.8.2.18's `dynamic_profile` names the *shape* of the change and +/// `rate_peak` its magnitude. Neither carries a duration, so with no peak rate +/// there is no number to ramp over and the change is instantaneous — which is +/// what a Step shape means (§7.4.1.2). `asap` is a Step from the other +/// direction: §8.7 declares no performance envelope at all (ADR-0030), so "as +/// soon as possible" is bounded by nothing. +ir::TransitionDynamics rate_dynamics(const std::string& profile, + const std::optional& peak) { + ir::TransitionDynamics dynamics; + if (profile == "asap" || !peak.has_value() || *peak <= 0.0) { + dynamics.shape = ir::DynamicsShape::Step; + dynamics.dimension = ir::DynamicsDimension::Time; + dynamics.value = 0.0; + return dynamics; + } + // `smooth` is the profile whose gradient vanishes at both ends, which is + // the Cubic shape (§DynamicsShape); `constant` and an unstated profile are + // both a constant rate, which is Linear. + dynamics.shape = profile == "smooth" ? ir::DynamicsShape::Cubic : ir::DynamicsShape::Linear; + dynamics.dimension = ir::DynamicsDimension::Rate; + dynamics.value = *peak; + return dynamics; +} + /// The `use` list the file gives `name_space` (§7.7.4). std::vector uses_of(const File& file, const std::string& name_space) { for (const Declaration& declaration : file.declarations) { @@ -253,6 +389,344 @@ std::vector uses_of(const File& file, const std::string& name_space return {}; } +// --- the `do` directive ---------------------------------------------------- + +/// Everything the behavior half of lowering reads. +struct Behaviors { + const Program& program; + const LibraryTypes& library; + const std::string& file; + const ExpressionContext& context; + const std::vector& bindings; + std::set entities; + DiagnosticSink& sink; + + void warn(const SourceRange& at, std::string message) { + report(sink, Severity::Warning, Status::UnsupportedFeature, file, at, std::move(message)); + } + void error(const SourceRange& at, std::string message) { + report(sink, Severity::Error, Status::SemanticError, file, at, std::move(message)); + } + + /// The world position a `position_3d`-typed declaration is constrained to. + /// + /// The DSL has no struct constructor — §7.2.2.6.7 declares list and range + /// constructors and nothing else — so a struct-valued argument can only + /// name a declaration, and the values come from the `keep`s on it. That is + /// the same route ADR-0030 already takes for an actor's own attributes. + ir::WorldPosition world_position(const std::string& field, const SourceRange& at) { + ir::WorldPosition position; + bind_number(bindings, field, {"x"}, position.x); + bind_number(bindings, field, {"y"}, position.y); + bind_number(bindings, field, {"z"}, position.z); + if (binding_for(bindings, field, {"x"}) == nullptr && + binding_for(bindings, field, {"y"}) == nullptr && + binding_for(bindings, field, {"z"}) == nullptr) { + warn(at, "no constraint fixes any coordinate of '" + field + + "', so it lowers as the world origin (§7.3.11)"); + } + return position; + } + + /// The entity an argument refers to, reported when it names anything else. + std::string entity_argument(const ArgumentMap& arguments, const char* name, + const SourceRange& at, const std::string& action) { + const std::string reference = argument_reference(arguments, name); + if (reference.empty() || entities.count(reference) == 0) { + error(at, "'" + action + "' needs '" + name + + "' to name a §8.7 participant of this scenario (§8.8.3)"); + return {}; + } + return reference; + } + + /// One §8.8 invocation as the IR actions it denotes, or none. + std::vector> + actions_of(const DoMember& member, const std::string& actor, TypeId actor_type) { + std::vector> actions; + const TypeId behavior = lookup_behavior_on(program, actor_type, member.name); + if (behavior == kInvalidType) { + // A scenario invocation, a user-declared behavior, or something the + // resolver already complained about. Composition of scenarios is + // p8-s2 (#45), so this stays a note rather than an error. + warn(member.range, "'" + member.name + + "' is not a §8.8 movement action, so it contributes no IR " + "action (scenario invocation is p8-s2, #45)"); + return actions; + } + const std::string name = program.types[behavior].simple_name; + const ArgumentMap arguments = bind_arguments(program, behavior, member.arguments, context); + + if (name == "movable_object.assign_speed") { + // §8.8.2.6: the actor's speed *is* the value from this point on, so + // it is a Step transition (§7.4.1.2) — the same reading the XML + // frontend gives an init SpeedAction. + const std::optional speed = argument_number(arguments, "speed"); + if (!speed.has_value()) { + error(member.range, "'assign_speed' needs a concrete 'speed' (§8.8.2.6)"); + return actions; + } + actions.push_back(std::make_shared(actor, *speed)); + return actions; + } + if (name == "movable_object.change_speed") { + const std::optional target = argument_number(arguments, "target"); + if (!target.has_value()) { + error(member.range, "'change_speed' needs a concrete 'target' (§8.8.2.12)"); + return actions; + } + actions.push_back(std::make_shared( + actor, *target, + rate_dynamics(argument_enum(program, arguments, "rate_profile"), + argument_number(arguments, "rate_peak")))); + return actions; + } + if (name == "movable_object.remain_stationary") { + // §8.8.2.10 is "the actor does not move": speed zero, immediately. + actions.push_back(std::make_shared(actor, 0.0)); + return actions; + } + if (name == "movable_object.assign_position") { + if (arguments.count("route_point") != 0 || arguments.count("odr_point") != 0) { + warn(member.range, "'assign_position' by route or OpenDRIVE point is not lowered " + "yet (§8.8.2.4); use 'position'"); + return actions; + } + const std::string field = argument_reference(arguments, "position"); + if (field.empty()) { + error(member.range, "'assign_position' needs 'position' to name a 'position_3d' " + "declaration (§8.8.2.4)"); + return actions; + } + actions.push_back(std::make_shared( + actor, ir::Position{world_position(field, member.range)})); + return actions; + } + if (name == "vehicle.change_lane") { + const std::string side = argument_enum(program, arguments, "side"); + if (side != "left" && side != "right") { + // §8.8.3.14's `inside`/`outside`/`same` need the road geometry + // to say which way that is, and an unstated side would have to + // be chosen — a choice the determinism contract does not allow + // lowering to make. + error(member.range, + "'change_lane' needs an explicit 'side' of left or right (§8.8.3.3); '" + + (side.empty() ? std::string("none given") : side) + "' is not lowered"); + return actions; + } + const std::optional count = argument_number(arguments, "num_of_lanes"); + ir::RelativeTargetLane target; + // The reference defaults to the actor itself (§8.8.3.3's + // `Default=it.actor`), and positive counts go left (§7.4.1.4). + target.entity_ref = argument_reference(arguments, "reference"); + if (target.entity_ref.empty() || entities.count(target.entity_ref) == 0) { + target.entity_ref = actor; + } + const int lanes = count.has_value() ? static_cast(*count) : 1; + target.value = side == "left" ? lanes : -lanes; + actions.push_back(std::make_shared( + actor, target, + rate_dynamics(argument_enum(program, arguments, "rate_profile"), + argument_number(arguments, "rate_peak")))); + return actions; + } + if (name == "vehicle.change_space_gap" || name == "vehicle.change_time_gap") { + const bool space = name == "vehicle.change_space_gap"; + const std::optional target = argument_number(arguments, "target"); + if (!target.has_value()) { + error(member.range, "'" + member.name + "' needs a concrete 'target' (§8.8.3." + + (space ? "6" : "4") + ")"); + return actions; + } + const std::string reference = + entity_argument(arguments, "reference", member.range, member.name); + if (reference.empty()) { + return actions; + } + // §8.8.3.15's `ahead`/`behind` say which side of the reference the + // actor ends up on, which is exactly §LongitudinalDisplacement; the + // four lateral members have no longitudinal reading. + const std::string direction = argument_enum(program, arguments, "direction"); + ir::LongitudinalDisplacement displacement = + ir::LongitudinalDisplacement::TrailingReferencedEntity; + if (direction == "ahead") { + displacement = ir::LongitudinalDisplacement::LeadingReferencedEntity; + } else if (!direction.empty() && direction != "behind") { + warn(member.range, "gap direction '" + direction + + "' is lateral and is not lowered (§8.8.3.15); the " + "longitudinal gap is kept behind the reference"); + } + // A "gap" in §8.8.3 is the clear space between the two objects, so + // it is measured between bounding boxes, not reference points + // (§6.4.7). `change_*` reaches the gap and ends; `keep_*` is the + // continuous form and is not lowered yet. + actions.push_back(std::make_shared( + actor, reference, space ? target : std::nullopt, space ? std::nullopt : target, + /*freespace=*/true, /*continuous=*/false, ir::CoordinateSystem::Entity, + displacement)); + return actions; + } + if (name == "movable_object.move" || name == "vehicle.drive" || name == "person.walk") { + // §8.8.2.3/§8.8.3.1/§8.8.4.1 are the generic actions: they carry no + // target of their own and exist to be shaped by §8.9 modifiers, + // which are p8-s3 (#46). On their own they say "keep doing what you + // are doing", which the runtime already does, so they lower to + // nothing and say so only when they carry modifiers that would + // have changed that. + return actions; + } + warn(member.range, "'" + name + + "' has no runtime counterpart in v0.0.1 (§8.8); see the " + "DSL coverage matrix"); + return actions; + } + + /// The actor a `do` member is invoked on, and its type. + bool actor_of(const DoMember& member, std::string& actor, TypeId& actor_type) { + std::vector path; + if (member.actor == nullptr || !field_path(*member.actor, path) || path.size() != 1) { + error(member.range, + "a movement action is invoked on one participant of this scenario (§7.2.2.4.7)"); + return false; + } + actor = path.front(); + if (entities.count(actor) == 0) { + error(member.range, "'" + actor + "' is not a §8.7 participant of this scenario"); + return false; + } + const auto field = program.types[context.self].fields.find(actor); + if (field == program.types[context.self].fields.end()) { + return false; + } + actor_type = field->second.type; + return true; + } + + /// Reports the parts of an invocation that p8-s2 and p8-s3 will lower. + void report_deferred(const DoMember& member) { + for (const Argument& argument : member.arguments) { + if (argument.name == "duration") { + warn(argument.range, + "an invocation 'duration' bounds the phase and is lowered in p8-s2 (#45), " + "§7.6.2.5.3"); + } + } + for (const ModifierApplication& modifier : member.with.modifiers) { + warn(modifier.range, + "movement modifier '" + modifier.name + "' is lowered in p8-s3 (#46), §8.9"); + } + for (const EventSpec& until : member.with.until) { + (void)until; + warn(member.range, "'until' ends the invocation at an event and is lowered in p8-s2 " + "(#45), §7.6.2.5.4"); + } + } + + /// A name for the phase that is unique among its siblings, because the + /// runtime addresses storyboard elements by name path. + std::string phase_name(const DoMember& member, std::size_t index, + std::set& taken) { + std::string name = + member.label.empty() ? "phase_" + std::to_string(index + 1) : member.label; + std::string candidate = name; + for (std::size_t suffix = 2; taken.count(candidate) != 0; ++suffix) { + candidate = name + "_" + std::to_string(suffix); + } + taken.insert(candidate); + return candidate; + } + + /// The `do` directive as a storyboard. + /// + /// One Story, one Act, and one ManeuverGroup per phase — the group is where + /// the actor lives, so a phase per group is what keeps each invocation's + /// actor with its actions. `serial` chains a phase's Event on the previous + /// group reaching completeState, which is the trigger form §7.6.2.1.2's + /// "starts when its predecessor ends" already has in the runtime; + /// `parallel` leaves the triggers absent, so every phase starts with the + /// Act (§7.6.1.1). Everything else about composition is p8-s2 (#45). + void build(const DoMember& root, const std::string& story_name, ir::Storyboard& out) { + std::vector phases; + bool serial = true; + if (root.kind == DoMemberKind::Composition) { + if (root.composition == CompositionOperator::OneOf) { + warn(root.range, "'one_of' selects one alternative and is lowered in p8-s2 (#45), " + "§7.6.2.1.3"); + return; + } + serial = root.composition == CompositionOperator::Serial; + for (const Argument& argument : root.composition_arguments) { + warn(argument.range, + "composition argument '" + + (argument.name.empty() ? std::string("(positional)") : argument.name) + + "' is lowered in p8-s2 (#45), §7.6.2.1"); + } + for (const DoMemberPtr& member : root.members) { + if (member != nullptr) { + phases.push_back(member.get()); + } + } + } else { + phases.push_back(&root); + } + + ir::Act act; + act.name = "act"; + std::set taken; + std::string previous; + for (std::size_t index = 0; index < phases.size(); ++index) { + const DoMember& member = *phases[index]; + if (member.kind != DoMemberKind::Invocation) { + warn(member.range, + "only a behavior invocation is lowered in p8-s1; nested composition, 'wait', " + "'emit' and 'call' are p8-s2 (#45), §7.6.2.5"); + continue; + } + std::string actor; + TypeId actor_type = kInvalidType; + if (!actor_of(member, actor, actor_type)) { + continue; + } + report_deferred(member); + std::vector> actions = + actions_of(member, actor, actor_type); + if (actions.empty()) { + continue; // nothing to run; whoever decided that has said why + } + + const std::string name = phase_name(member, index, taken); + ir::Event event; + event.name = name; + event.actions = std::move(actions); + if (serial && !previous.empty()) { + event.start_trigger = + ir::make_trigger(std::make_shared( + ir::StoryboardElementType::ManeuverGroup, previous, + ir::StoryboardElementState::CompleteState)); + } + + ir::Maneuver maneuver; + maneuver.name = name; + maneuver.events.push_back(std::move(event)); + + ir::ManeuverGroup group; + group.name = name; + group.actors.push_back(actor); + group.maneuvers.push_back(std::move(maneuver)); + act.groups.push_back(std::move(group)); + previous = name; + } + + if (act.groups.empty()) { + return; // an empty Act would be an init-time validation error + } + ir::Story story; + story.name = story_name; + story.acts.push_back(std::move(act)); + out.stories.push_back(std::move(story)); + } +}; + } // namespace std::vector entry_points(const Program& program, const LoadResult& loaded) { @@ -282,7 +756,8 @@ std::vector entry_points(const Program& program, const LoadResult& } Status lower(const Program& program, const LoadResult& loaded, const LowerOptions& options, - ir::Scenario& out, DiagnosticSink& sink) { + LowerResult& result, DiagnosticSink& sink) { + ir::Scenario& out = result.scenario; const File* root = loaded.root(); if (root == nullptr) { // Nothing parsed. Host misuse: lowering is only defined for a program @@ -445,6 +920,51 @@ Status lower(const Program& program, const LoadResult& loaded, const LowerOption "'" + entry + "' declares no §8.7 participant, so the lowered scenario is empty"); } + // §8.5.4's map_file, in either spelling the standard prints: the + // `map.set_map_file("m.xodr")` modifier of Code 61, or a `keep` on a + // declared `map` field (Code 62). Both are the same statement about which + // road network the scenario means, and neither is kernel state — the file + // reference goes to the host, exactly as `LogicFile` does on the XML side. + for (const Binding& binding : bindings) { + if (binding.path.size() != 2 || binding.path[1] != "map_file" || + binding.value.kind != Value::Kind::String) { + continue; + } + const auto field = scenario.fields.find(binding.path.front()); + if (field != scenario.fields.end() && library.map != kInvalidType && + program.is_derived_from(field->second.type, library.map)) { + result.map_file = binding.value.text; + } + } + + Behaviors behaviors{program, library, root->path, context, bindings, {}, sink}; + for (const ir::Entity& entity : out.entities) { + behaviors.entities.insert(entity.id); + } + + for (const StructuredDecl* declaration : scenario.declarations) { + if (declaration == nullptr) { + continue; + } + for (const Member& member : declaration->members) { + const TypeId set_map_file = find_type(program, "std::map.set_map_file"); + if (member.kind == Member::Kind::ModifierApplication && + member.modifier.name == "set_map_file" && set_map_file != kInvalidType) { + const ArgumentMap arguments = + bind_arguments(program, set_map_file, member.modifier.arguments, context); + const auto file = arguments.find("file"); + if (file != arguments.end() && file->second.folded && + file->second.value.kind == Value::Kind::String) { + result.map_file = file->second.value.text; + } + continue; + } + if (member.kind == Member::Kind::Behavior && member.behavior != nullptr) { + behaviors.build(*member.behavior, scenario.simple_name, out.storyboard); + } + } + } + return sink.has_errors() ? Status::ValidationError : Status::Ok; } diff --git a/frontends/dsl/src/resolve.cpp b/frontends/dsl/src/resolve.cpp index 20331e1..335bd7f 100644 --- a/frontends/dsl/src/resolve.cpp +++ b/frontends/dsl/src/resolve.cpp @@ -1111,18 +1111,36 @@ void Resolver::check_modifier_application(const ModifierApplication& application // expression names the receiver; omitting it means the receiver is the one // the application site already implies — the enclosing declaration or the // actor of the invocation the `with:` block belongs to. + const Scope scope{context.name_space, context.uses, context.file}; + TypeId receiver = implicit_receiver; if (application.actor != nullptr) { - // May grow Program::types (a list or range constructor interns a type), - // so nothing may hold a TypeInfo& across it. - receiver = check_expression(*application.actor, context); - if (receiver == kInvalidType) { - return; // the actor expression already reported why + // §8.5.4 writes the map file as `map.set_map_file("m.xodr")` (Code 61, + // and Code 6's concrete scenario), where `map` names the actor *type*: + // the road network is a singleton no scenario declares a field for. A + // bare name that resolves to an actor type is therefore a receiver in + // its own right — but only when no field of that name is in scope, + // because a declared field is what the reader means by the name. + TypeId named = kInvalidType; + if (application.actor->kind == ExprKind::Name && + out_.find_field(context.self, application.actor->text) == nullptr) { + named = lookup_declared(application.actor->text, scope); + if (named != kInvalidType && out_.types[named].kind != TypeKind::Actor) { + named = kInvalidType; // only an actor has modifiers to apply + } + } + if (named != kInvalidType) { + receiver = named; + } else { + // May grow Program::types (a list or range constructor interns a + // type), so nothing may hold a TypeInfo& across it. + receiver = check_expression(*application.actor, context); + if (receiver == kInvalidType) { + return; // the actor expression already reported why + } } } - const Scope scope{context.name_space, context.uses, context.file}; - // An unassociated or scenario-associated modifier is named plainly // (§7.3.12.4.2's first example); an actor-associated one lives in the actor // scope and is only reachable through the receiver. A written actor diff --git a/frontends/dsl/tests/dsl_lowering_test.cpp b/frontends/dsl/tests/dsl_lowering_test.cpp index 57ffbab..fd22ef5 100644 --- a/frontends/dsl/tests/dsl_lowering_test.cpp +++ b/frontends/dsl/tests/dsl_lowering_test.cpp @@ -34,7 +34,9 @@ #include "scena/dsl/load.h" #include "scena/dsl/lower.h" #include "scena/dsl/types.h" +#include "scena/ir/action.h" #include "scena/ir/entity.h" +#include "scena/ir/position.h" #include "scena/ir/scenario.h" #include "scena/status.h" @@ -55,7 +57,7 @@ struct Lowered { Program program; DiagnosticSink check_sink; DiagnosticSink sink; - scena::ir::Scenario scenario; + scena::dsl::LowerResult lowered; Status check_status = Status::Ok; Status status = Status::Ok; }; @@ -70,7 +72,7 @@ void run(std::string_view source, Lowered& out, const std::string& entry_point = } LowerOptions options; options.entry_point = entry_point; - out.status = scena::dsl::lower(out.program, out.loaded, options, out.scenario, out.sink); + out.status = scena::dsl::lower(out.program, out.loaded, options, out.lowered, out.sink); } /// The header every scenario below shares: the standard library, and a @@ -101,8 +103,8 @@ TEST(DslLoweringTest, TheOnlyScenarioIsTheEntryPointWithoutBeingNamed) { run(std::string(kPrelude).append("scenario overtake:\n ego: vehicle\n"), result); ASSERT_EQ(result.check_status, Status::Ok) << first_message(result.check_sink); ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); - EXPECT_EQ(result.scenario.name, "overtake"); - EXPECT_EQ(result.scenario.entities.size(), 1U); + EXPECT_EQ(result.lowered.scenario.name, "overtake"); + EXPECT_EQ(result.lowered.scenario.entities.size(), 1U); } TEST(DslLoweringTest, MoreThanOneScenarioMustBeChosenBetween) { @@ -129,14 +131,14 @@ TEST(DslLoweringTest, AnEntryPointIsNamedQualifiedOrAsWritten) { Lowered qualified; run(source, qualified, "demo::top.second"); ASSERT_EQ(qualified.status, Status::Ok) << first_message(qualified.sink); - EXPECT_EQ(qualified.scenario.entities.size(), 2U); + EXPECT_EQ(qualified.lowered.scenario.entities.size(), 2U); // A file with one namespace makes the prefix pure ceremony, so the name as // written is accepted too. Lowered written; run(source, written, "top.second"); ASSERT_EQ(written.status, Status::Ok) << first_message(written.sink); - EXPECT_EQ(written.scenario.entities.size(), 2U); + EXPECT_EQ(written.lowered.scenario.entities.size(), 2U); } TEST(DslLoweringTest, AnEntryPointThatIsNotThereIsReported) { @@ -174,15 +176,15 @@ TEST(DslLoweringTest, EveryPhysicalObjectFieldBecomesAnEntity) { result); ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); // Three participants, in declaration order; `lap_count` is not one. - ASSERT_EQ(result.scenario.entities.size(), 3U); - EXPECT_EQ(result.scenario.entities[0].id, "ego"); - EXPECT_EQ(result.scenario.entities[1].id, "walker"); - EXPECT_EQ(result.scenario.entities[2].id, "cone"); - EXPECT_EQ(scena::ir::object_type_of(result.scenario.entities[0]), + ASSERT_EQ(result.lowered.scenario.entities.size(), 3U); + EXPECT_EQ(result.lowered.scenario.entities[0].id, "ego"); + EXPECT_EQ(result.lowered.scenario.entities[1].id, "walker"); + EXPECT_EQ(result.lowered.scenario.entities[2].id, "cone"); + EXPECT_EQ(scena::ir::object_type_of(result.lowered.scenario.entities[0]), scena::ir::ObjectType::Vehicle); - EXPECT_EQ(scena::ir::object_type_of(result.scenario.entities[1]), + EXPECT_EQ(scena::ir::object_type_of(result.lowered.scenario.entities[1]), scena::ir::ObjectType::Pedestrian); - EXPECT_EQ(scena::ir::object_type_of(result.scenario.entities[2]), + EXPECT_EQ(scena::ir::object_type_of(result.lowered.scenario.entities[2]), scena::ir::ObjectType::MiscObject); } @@ -193,9 +195,9 @@ TEST(DslLoweringTest, AParticipantWithNoTaxonomyCounterpartStaysUnclassified) { Lowered result; run(std::string(kPrelude).append("scenario safari:\n deer: animal\n"), result); ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); - ASSERT_EQ(result.scenario.entities.size(), 1U); - EXPECT_FALSE(result.scenario.entities.front().object.has_value()); - EXPECT_EQ(result.scenario.entities.front().control_mode, + ASSERT_EQ(result.lowered.scenario.entities.size(), 1U); + EXPECT_FALSE(result.lowered.scenario.entities.front().object.has_value()); + EXPECT_EQ(result.lowered.scenario.entities.front().control_mode, scena::ir::ControlMode::EngineControlled); } @@ -205,8 +207,8 @@ TEST(DslLoweringTest, ADerivedActorIsStillTheParticipantItInheritsFrom) { "scenario drive:\n ego: car\n"), result); ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); - ASSERT_EQ(result.scenario.entities.size(), 1U); - EXPECT_EQ(scena::ir::object_type_of(result.scenario.entities.front()), + ASSERT_EQ(result.lowered.scenario.entities.size(), 1U); + EXPECT_EQ(scena::ir::object_type_of(result.lowered.scenario.entities.front()), scena::ir::ObjectType::Vehicle); } @@ -221,7 +223,7 @@ TEST(DslLoweringTest, AnEqualityKeepFixesTheGeometry) { " keep(ego.bounding_box.height == 1.5m)\n"), result); ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); - const scena::ir::Entity* ego = entity(result.scenario, "ego"); + const scena::ir::Entity* ego = entity(result.lowered.scenario, "ego"); ASSERT_NE(ego, nullptr); const std::optional box = scena::ir::bounding_box_of(*ego); ASSERT_TRUE(box.has_value()); @@ -239,7 +241,7 @@ TEST(DslLoweringTest, AKeepIsReadFromEitherSide) { " keep(4.5m == ego.bounding_box.length)\n"), result); ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); - const scena::ir::Entity* ego = entity(result.scenario, "ego"); + const scena::ir::Entity* ego = entity(result.lowered.scenario, "ego"); ASSERT_NE(ego, nullptr); EXPECT_DOUBLE_EQ(scena::ir::bounding_box_of(*ego)->length, 4.5); } @@ -255,7 +257,8 @@ TEST(DslLoweringTest, APhysicalValueArrivesInItsBaseUnit) { " keep(ego.bounding_box.length == 450cm)\n"), result); ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); - EXPECT_DOUBLE_EQ(scena::ir::bounding_box_of(*entity(result.scenario, "ego"))->length, 4.5); + EXPECT_DOUBLE_EQ(scena::ir::bounding_box_of(*entity(result.lowered.scenario, "ego"))->length, + 4.5); } TEST(DslLoweringTest, AKeepFixesTheVehicleCategory) { @@ -266,7 +269,7 @@ TEST(DslLoweringTest, AKeepFixesTheVehicleCategory) { result); ASSERT_EQ(result.check_status, Status::Ok) << first_message(result.check_sink); ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); - const scena::ir::Entity* bus = entity(result.scenario, "b"); + const scena::ir::Entity* bus = entity(result.lowered.scenario, "b"); ASSERT_NE(bus, nullptr); ASSERT_TRUE(bus->object.has_value()); EXPECT_EQ(std::get(*bus->object).category, scena::ir::VehicleCategory::Bus); @@ -282,7 +285,7 @@ TEST(DslLoweringTest, ConditionalInheritanceFixesTheCategoryToo) { "scenario haul:\n t: lorry\n"), result); ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); - const scena::ir::Entity* lorry = entity(result.scenario, "t"); + const scena::ir::Entity* lorry = entity(result.lowered.scenario, "t"); ASSERT_NE(lorry, nullptr); ASSERT_TRUE(lorry->object.has_value()); EXPECT_EQ(std::get(*lorry->object).category, @@ -295,9 +298,10 @@ TEST(DslLoweringTest, AnUnfixedCategoryKeepsTheIrDefault) { Lowered result; run(std::string(kPrelude).append("scenario drive:\n ego: vehicle\n"), result); ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); - ASSERT_TRUE(entity(result.scenario, "ego")->object.has_value()); - EXPECT_EQ(std::get(*entity(result.scenario, "ego")->object).category, - scena::ir::VehicleCategory::Car); + ASSERT_TRUE(entity(result.lowered.scenario, "ego")->object.has_value()); + EXPECT_EQ( + std::get(*entity(result.lowered.scenario, "ego")->object).category, + scena::ir::VehicleCategory::Car); } TEST(DslLoweringTest, PerformanceLimitsHaveNoDslSourceAndStayUnconstrained) { @@ -308,7 +312,7 @@ TEST(DslLoweringTest, PerformanceLimitsHaveNoDslSourceAndStayUnconstrained) { run(std::string(kPrelude).append("scenario drive:\n ego: vehicle\n"), result); ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); const scena::ir::Performance* performance = - scena::ir::performance_of(*entity(result.scenario, "ego")); + scena::ir::performance_of(*entity(result.lowered.scenario, "ego")); ASSERT_NE(performance, nullptr); EXPECT_DOUBLE_EQ(performance->max_speed, 0.0); } @@ -321,7 +325,382 @@ TEST(DslLoweringTest, AScenarioWithNoParticipantsSaysSo) { EXPECT_EQ(result.status, Status::Ok); ASSERT_EQ(result.sink.diagnostics().size(), 1U); EXPECT_EQ(result.sink.diagnostics().front().severity, Severity::Warning); - EXPECT_TRUE(result.scenario.entities.empty()); + EXPECT_TRUE(result.lowered.scenario.entities.empty()); +} + +/// The events of the lowered storyboard, flattened in document order. One +/// ManeuverGroup per phase is the shape lowering builds, so this reads back as +/// "the phases, in order". +struct Phase { + std::string name; + std::string actor; + bool has_start_trigger = false; + const scena::ir::Event* event = nullptr; +}; + +std::vector phases(const scena::ir::Scenario& scenario) { + std::vector out; + for (const scena::ir::Story& story : scenario.storyboard.stories) { + for (const scena::ir::Act& act : story.acts) { + for (const scena::ir::ManeuverGroup& group : act.groups) { + for (const scena::ir::Maneuver& maneuver : group.maneuvers) { + for (const scena::ir::Event& event : maneuver.events) { + out.push_back(Phase{group.name, + group.actors.empty() ? std::string() : group.actors[0], + event.start_trigger.has_value(), &event}); + } + } + } + } + } + return out; +} + +/// The single action of a phase, as `T`, or nullptr. +template const T* only_action(const Phase& phase) { + if (phase.event == nullptr || phase.event->actions.size() != 1) { + return nullptr; + } + return dynamic_cast(phase.event->actions.front().get()); +} + +bool sink_says(const DiagnosticSink& sink, std::string_view fragment) { + for (const scena::Diagnostic& diagnostic : sink.diagnostics()) { + if (diagnostic.message.find(fragment) != std::string::npos) { + return true; + } + } + return false; +} + +// --- §8.8 movement actions ------------------------------------------------- + +TEST(DslLoweringTest, AnAssignSpeedIsAStepSpeedChange) { + // §8.8.2.6: the actor's speed *is* the value from that point on, which is + // what a Step transition means (§7.4.1.2). + Lowered result; + run(std::string(kPrelude).append("scenario go:\n" + " ego: vehicle\n" + " do launch: ego.assign_speed(speed: 10mps)\n"), + result); + ASSERT_EQ(result.check_status, Status::Ok) << first_message(result.check_sink); + ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); + const std::vector lowered = phases(result.lowered.scenario); + ASSERT_EQ(lowered.size(), 1U); + EXPECT_EQ(lowered[0].name, "launch"); + EXPECT_EQ(lowered[0].actor, "ego"); + const scena::ir::SpeedAction* action = only_action(lowered[0]); + ASSERT_NE(action, nullptr); + EXPECT_EQ(action->entity_id(), "ego"); + EXPECT_EQ(action->target_speed(), 10.0); + EXPECT_EQ(action->dynamics().shape, scena::ir::DynamicsShape::Step); +} + +TEST(DslLoweringTest, AChangeSpeedTakesItsShapeFromTheRateProfile) { + // §8.8.2.18's `smooth` is the profile whose gradient vanishes at both ends, + // and `rate_peak` is the magnitude — together, a rate-dimensioned Cubic. + Lowered result; + run(std::string(kPrelude).append( + "scenario go:\n" + " ego: vehicle\n" + " do ramp: ego.change_speed(target: 25mps, rate_profile: dynamic_profile!smooth, " + "rate_peak: 2.0)\n"), + result); + ASSERT_EQ(result.check_status, Status::Ok) << first_message(result.check_sink); + ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); + const std::vector lowered = phases(result.lowered.scenario); + ASSERT_EQ(lowered.size(), 1U); + const scena::ir::SpeedAction* action = only_action(lowered[0]); + ASSERT_NE(action, nullptr); + EXPECT_EQ(action->target_speed(), 25.0); + EXPECT_EQ(action->dynamics().shape, scena::ir::DynamicsShape::Cubic); + EXPECT_EQ(action->dynamics().dimension, scena::ir::DynamicsDimension::Rate); + EXPECT_EQ(action->dynamics().value, 2.0); +} + +TEST(DslLoweringTest, AChangeSpeedWithoutAPeakRateIsInstantaneous) { + // Neither `dynamic_profile` nor §8.7 supplies a duration, so with no peak + // rate there is no number to ramp over. Step is the honest reading. + Lowered result; + run(std::string(kPrelude).append("scenario go:\n" + " ego: vehicle\n" + " do ramp: ego.change_speed(target: 25mps)\n"), + result); + ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); + const std::vector lowered = phases(result.lowered.scenario); + ASSERT_EQ(lowered.size(), 1U); + const scena::ir::SpeedAction* action = only_action(lowered[0]); + ASSERT_NE(action, nullptr); + EXPECT_EQ(action->dynamics().shape, scena::ir::DynamicsShape::Step); +} + +TEST(DslLoweringTest, RemainStationaryIsSpeedZero) { + Lowered result; + run(std::string(kPrelude).append("scenario go:\n" + " ego: vehicle\n" + " do hold: ego.remain_stationary()\n"), + result); + ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); + const std::vector lowered = phases(result.lowered.scenario); + ASSERT_EQ(lowered.size(), 1U); + const scena::ir::SpeedAction* action = only_action(lowered[0]); + ASSERT_NE(action, nullptr); + EXPECT_EQ(action->target_speed(), 0.0); +} + +TEST(DslLoweringTest, AnAssignPositionReadsItsCoordinatesFromTheConstraints) { + // The DSL has no struct constructor (§7.2.2.6.7 declares list and range + // constructors and nothing else), so a struct-valued argument names a + // declaration and the `keep`s on it are where the numbers are. + Lowered result; + run(std::string(kPrelude).append("scenario go:\n" + " ego: vehicle\n" + " start: position_3d\n" + " keep(start.x == 12m)\n" + " keep(start.y == 300cm)\n" + " do place: ego.assign_position(position: start)\n"), + result); + ASSERT_EQ(result.check_status, Status::Ok) << first_message(result.check_sink); + ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); + const std::vector lowered = phases(result.lowered.scenario); + ASSERT_EQ(lowered.size(), 1U); + const scena::ir::TeleportAction* action = only_action(lowered[0]); + ASSERT_NE(action, nullptr); + const scena::ir::WorldPosition* world = + std::get_if(&action->position()); + ASSERT_NE(world, nullptr); + EXPECT_EQ(world->x, 12.0); + // Folded to metres once, during checking; lowering never converts again. + EXPECT_EQ(world->y, 3.0); + EXPECT_EQ(world->z, 0.0); +} + +TEST(DslLoweringTest, APositionNothingConstrainsIsReportedNotAssumed) { + Lowered result; + run(std::string(kPrelude).append("scenario go:\n" + " ego: vehicle\n" + " start: position_3d\n" + " do place: ego.assign_position(position: start)\n"), + result); + ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); + EXPECT_TRUE(sink_says(result.sink, "world origin")); +} + +TEST(DslLoweringTest, AChangeLaneNeedsAnExplicitSide) { + // §8.8.3.14's `inside`/`outside`/`same` need the road geometry to say which + // way that is, and an unstated side would have to be chosen — a choice the + // determinism contract does not let lowering make. + Lowered result; + run(std::string(kPrelude).append( + "scenario go:\n" + " ego: vehicle\n" + " do swerve: ego.change_lane(side: lane_change_side!same)\n"), + result); + ASSERT_EQ(result.check_status, Status::Ok) << first_message(result.check_sink); + EXPECT_EQ(result.status, Status::ValidationError); + EXPECT_TRUE(sink_says(result.sink, "explicit 'side'")); +} + +TEST(DslLoweringTest, AChangeLaneLowersToARelativeLaneTarget) { + Lowered result; + run(std::string(kPrelude).append( + "scenario go:\n" + " ego: vehicle\n" + " do swerve: ego.change_lane(side: lane_change_side!left, num_of_lanes: 2, " + "rate_peak: 3.0)\n"), + result); + ASSERT_EQ(result.check_status, Status::Ok) << first_message(result.check_sink); + ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); + const std::vector lowered = phases(result.lowered.scenario); + ASSERT_EQ(lowered.size(), 1U); + const scena::ir::LaneChangeAction* action = + only_action(lowered[0]); + ASSERT_NE(action, nullptr); + ASSERT_TRUE(action->is_relative()); + // Positive counts go left (§7.4.1.4), and the reference defaults to the + // actor itself (§8.8.3.3's `Default=it.actor`). + EXPECT_EQ(action->relative_target()->value, 2); + EXPECT_EQ(action->relative_target()->entity_ref, "ego"); +} + +TEST(DslLoweringTest, AGapActionKeepsTheDistanceToItsReference) { + Lowered result; + run(std::string(kPrelude).append( + "scenario go:\n" + " ego: vehicle\n" + " lead: vehicle\n" + " do gap: ego.change_space_gap(target: 20m, direction: gap_direction!behind, " + "reference: lead)\n"), + result); + ASSERT_EQ(result.check_status, Status::Ok) << first_message(result.check_sink); + ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); + const std::vector lowered = phases(result.lowered.scenario); + ASSERT_EQ(lowered.size(), 1U); + const scena::ir::LongitudinalDistanceAction* action = + only_action(lowered[0]); + ASSERT_NE(action, nullptr); + EXPECT_EQ(action->entity_ref(), "lead"); + ASSERT_TRUE(action->distance().has_value()); + EXPECT_EQ(*action->distance(), 20.0); + EXPECT_FALSE(action->time_gap().has_value()); + EXPECT_EQ(action->displacement(), + scena::ir::LongitudinalDisplacement::TrailingReferencedEntity); +} + +TEST(DslLoweringTest, AGapActionNeedsItsReferenceToBeAParticipant) { + Lowered result; + run(std::string(kPrelude).append( + "scenario go:\n" + " ego: vehicle\n" + " do gap: ego.change_time_gap(target: 2s, reference: ego.bounding_box)\n"), + result); + ASSERT_EQ(result.check_status, Status::Ok) << first_message(result.check_sink); + EXPECT_EQ(result.status, Status::ValidationError); + EXPECT_TRUE(sink_says(result.sink, "participant of this scenario")); +} + +TEST(DslLoweringTest, TheGenericDriveContributesNoActionOfItsOwn) { + // §8.8.3.1's `drive` carries no target: it exists to be shaped by §8.9 + // modifiers (p8-s3), and on its own says "keep doing what you are doing". + Lowered result; + run(std::string(kPrelude).append("scenario go:\n" + " ego: vehicle\n" + " do cruise: ego.drive()\n"), + result); + ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); + EXPECT_TRUE(result.lowered.scenario.storyboard.stories.empty()); +} + +TEST(DslLoweringTest, AMovementActionWithNoCounterpartIsReportedNotDropped) { + Lowered result; + run(std::string(kPrelude).append("scenario go:\n" + " ego: vehicle\n" + " do hold: ego.keep_speed()\n"), + result); + ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); + EXPECT_TRUE(sink_says(result.sink, "no runtime counterpart")); +} + +// --- the `do` directive ----------------------------------------------------- + +TEST(DslLoweringTest, SerialPhasesChainOnTheirPredecessor) { + // §7.6.2.1.2: a member starts when its predecessor ends. In the runtime that + // is a start trigger on the previous element reaching completeState. + Lowered result; + run(std::string(kPrelude).append("scenario go:\n" + " ego: vehicle\n" + " do serial:\n" + " launch: ego.assign_speed(speed: 10mps)\n" + " stop: ego.remain_stationary()\n"), + result); + ASSERT_EQ(result.check_status, Status::Ok) << first_message(result.check_sink); + ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); + const std::vector lowered = phases(result.lowered.scenario); + ASSERT_EQ(lowered.size(), 2U); + EXPECT_EQ(lowered[0].name, "launch"); + EXPECT_FALSE(lowered[0].has_start_trigger); + EXPECT_EQ(lowered[1].name, "stop"); + EXPECT_TRUE(lowered[1].has_start_trigger); +} + +TEST(DslLoweringTest, ParallelPhasesStartWithTheirAct) { + Lowered result; + run(std::string(kPrelude).append("scenario go:\n" + " ego: vehicle\n" + " other: vehicle\n" + " do parallel:\n" + " a: ego.assign_speed(speed: 10mps)\n" + " b: other.assign_speed(speed: 12mps)\n"), + result); + ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); + const std::vector lowered = phases(result.lowered.scenario); + ASSERT_EQ(lowered.size(), 2U); + EXPECT_FALSE(lowered[0].has_start_trigger); + EXPECT_FALSE(lowered[1].has_start_trigger); + EXPECT_EQ(lowered[0].actor, "ego"); + EXPECT_EQ(lowered[1].actor, "other"); +} + +TEST(DslLoweringTest, AnUnlabelledPhaseGetsItsPositionAsAName) { + // The runtime addresses storyboard elements by name path, so a phase needs + // a name whether or not the author wrote a label. + Lowered result; + run(std::string(kPrelude).append("scenario go:\n" + " ego: vehicle\n" + " do serial:\n" + " ego.assign_speed(speed: 10mps)\n" + " ego.remain_stationary()\n"), + result); + ASSERT_EQ(result.check_status, Status::Ok) << first_message(result.check_sink); + ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); + const std::vector lowered = phases(result.lowered.scenario); + ASSERT_EQ(lowered.size(), 2U); + EXPECT_EQ(lowered[0].name, "phase_1"); + EXPECT_EQ(lowered[1].name, "phase_2"); +} + +TEST(DslLoweringTest, OneOfIsReportedAsBelongingToTheNextSprint) { + Lowered result; + run(std::string(kPrelude).append("scenario go:\n" + " ego: vehicle\n" + " do one_of:\n" + " a: ego.assign_speed(speed: 10mps)\n" + " b: ego.assign_speed(speed: 20mps)\n"), + result); + ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); + EXPECT_TRUE(sink_says(result.sink, "one_of")); + EXPECT_TRUE(result.lowered.scenario.storyboard.stories.empty()); +} + +TEST(DslLoweringTest, ModifiersAndInvocationDurationsAreReportedAsDeferred) { + // Both are real constructs of the invocation, and both belong to a later + // sprint. Reporting beats silently running a scenario that says more than + // the engine was told. + Lowered result; + run(std::string(kPrelude).append("scenario go:\n" + " ego: vehicle\n" + " do phase: ego.drive(duration: 5s) with:\n" + " speed(speed: 30kph)\n"), + result); + ASSERT_EQ(result.check_status, Status::Ok) << first_message(result.check_sink); + ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); + EXPECT_TRUE(sink_says(result.sink, "p8-s2 (#45)")); + EXPECT_TRUE(sink_says(result.sink, "p8-s3 (#46)")); +} + +// --- §8.5.4 the map file ------------------------------------------------------ + +TEST(DslLoweringTest, TheMapFileComesFromSetMapFile) { + // Code 61's spelling, where `map` names the actor type: the road network is + // a singleton no scenario declares a field for. + Lowered result; + run(std::string(kPrelude).append("scenario go:\n" + " map.set_map_file(\"flat.xodr\")\n" + " ego: vehicle\n"), + result); + ASSERT_EQ(result.check_status, Status::Ok) << first_message(result.check_sink); + ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); + EXPECT_EQ(result.lowered.map_file, "flat.xodr"); +} + +TEST(DslLoweringTest, TheMapFileCanAlsoComeFromAKeep) { + // Code 62's spelling. The two say the same thing, so they lower the same. + Lowered result; + run(std::string(kPrelude).append("scenario go:\n" + " my_map: map\n" + " ego: vehicle\n" + " keep(my_map.map_file == \"junction.xodr\")\n"), + result); + ASSERT_EQ(result.check_status, Status::Ok) << first_message(result.check_sink); + ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); + EXPECT_EQ(result.lowered.map_file, "junction.xodr"); +} + +TEST(DslLoweringTest, AScenarioThatNamesNoMapLeavesTheReferenceEmpty) { + Lowered result; + run(std::string(kPrelude).append("scenario go:\n ego: vehicle\n"), result); + ASSERT_EQ(result.status, Status::Ok) << first_message(result.sink); + EXPECT_TRUE(result.lowered.map_file.empty()); } // --- determinism ------------------------------------------------------------ @@ -340,10 +719,10 @@ TEST(DslLoweringTest, LoweringTheSameSourceTwiceGivesTheSameIr) { run(source, second); ASSERT_EQ(first.status, Status::Ok) << first_message(first.sink); ASSERT_EQ(second.status, Status::Ok); - ASSERT_EQ(first.scenario.entities.size(), second.scenario.entities.size()); - for (std::size_t index = 0; index < first.scenario.entities.size(); ++index) { - const scena::ir::Entity& left = first.scenario.entities[index]; - const scena::ir::Entity& right = second.scenario.entities[index]; + ASSERT_EQ(first.lowered.scenario.entities.size(), second.lowered.scenario.entities.size()); + for (std::size_t index = 0; index < first.lowered.scenario.entities.size(); ++index) { + const scena::ir::Entity& left = first.lowered.scenario.entities[index]; + const scena::ir::Entity& right = second.lowered.scenario.entities[index]; EXPECT_EQ(left.id, right.id); EXPECT_EQ(scena::ir::object_type_of(left), scena::ir::object_type_of(right)); const std::optional left_box = scena::ir::bounding_box_of(left); diff --git a/frontends/dsl/tests/dsl_types_test.cpp b/frontends/dsl/tests/dsl_types_test.cpp index cb6ef81..462a9b8 100644 --- a/frontends/dsl/tests/dsl_types_test.cpp +++ b/frontends/dsl/tests/dsl_types_test.cpp @@ -715,6 +715,40 @@ TEST(DslTypesTest, ANameThatIsNotAModifierSaysWhatItIs) { EXPECT_TRUE(mentions(errors, "not a modifier")); } +TEST(DslTypesTest, AnActorTypeNameIsAReceiverInItsOwnRight) { + // §8.5.4 writes the map file as `map.set_map_file("m.xodr")` (Code 61), + // where `map` names the actor *type*: a singleton the scenario never + // declares a field for. Without this the standard's own spelling of a + // concrete scenario is rejected. + const Program program = resolve_ok("actor road_map:\n file: string\n" + "modifier road_map.set_file:\n name: string\n" + "scenario demo:\n" + " road_map.set_file(\"m.xodr\")\n"); + EXPECT_NE(program.find("::road_map.set_file"), nullptr); +} + +TEST(DslTypesTest, ADeclaredFieldWinsOverAnActorTypeOfTheSameName) { + // The receiver a reader means by a name is the declaration in front of + // them, so a field shadows the type it is named after. + const Program program = resolve_ok("actor thing:\n def go() is undefined\n" + "modifier thing.tweak:\n v: int\n" + "scenario demo:\n" + " thing: thing\n" + " thing.tweak(1)\n"); + EXPECT_NE(program.find("::thing.tweak"), nullptr); +} + +TEST(DslTypesTest, ATypeNameThatIsNotAnActorIsStillJustAnExpression) { + // Only an actor has modifiers, so a struct name in receiver position gets + // the ordinary "that is a type, not a value" answer rather than a special + // case that would hide the mistake. + const std::vector errors = resolve_errors("struct box:\n v: int\n" + "modifier tweak:\n v: int\n" + "scenario demo:\n" + " box.tweak(1)\n"); + EXPECT_TRUE(mentions(errors, "is a type, not a value")); +} + TEST(DslTypesTest, AScenarioAssociatedModifierIsRejectedElsewhere) { const std::vector errors = resolve_errors("scenario drive\n" "modifier follow of drive\n" diff --git a/python/tests/test_parity.py b/python/tests/test_parity.py index a80eb24..2ed6de1 100644 --- a/python/tests/test_parity.py +++ b/python/tests/test_parity.py @@ -90,6 +90,8 @@ def test_the_audit_notices_an_unbound_method(tmp_path) -> None: sandbox / "frontends/xml/include/scena/xml/loader.h") shutil.copy(REPO / "frontends/dsl/include/scena/dsl/load.h", sandbox / "frontends/dsl/include/scena/dsl/load.h") + shutil.copy(REPO / "frontends/dsl/include/scena/dsl/lower.h", + sandbox / "frontends/dsl/include/scena/dsl/lower.h") header = (REPO / "core" / "include" / "scena" / "engine.h").read_text(encoding="utf-8") doctored = header.replace( diff --git a/scripts/parity_audit.py b/scripts/parity_audit.py index be246c1..67fffe5 100644 --- a/scripts/parity_audit.py +++ b/scripts/parity_audit.py @@ -58,6 +58,7 @@ BINDINGS = REPO / "python" / "src" / "bindings.cpp" XML_LOADER_HEADER = REPO / "frontends" / "xml" / "include" / "scena" / "xml" / "loader.h" DSL_LOAD_HEADER = REPO / "frontends" / "dsl" / "include" / "scena" / "dsl" / "load.h" +DSL_LOWER_HEADER = REPO / "frontends" / "dsl" / "include" / "scena" / "dsl" / "lower.h" #: C++ Engine methods that deliberately do not reach one or both bindings, with #: the reason. Keeping the reason here rather than in a comment is what lets the @@ -106,9 +107,9 @@ #: The frontend headers whose namespace-scope entry points are audited, and the #: namespace each one lives in (used only to name the row). -FRONTEND_HEADERS: dict[str, Path] = { - "xml": XML_LOADER_HEADER, - "dsl": DSL_LOAD_HEADER, +FRONTEND_HEADERS: dict[str, tuple[Path, ...]] = { + "xml": (XML_LOADER_HEADER,), + "dsl": (DSL_LOAD_HEADER, DSL_LOWER_HEADER), } #: `::` -> the C entry point that implements it. The names @@ -139,6 +140,11 @@ # a host actually wants. "dsl::load_file": "the lower half of check_file; its result is an AST no binding can carry", "dsl::load_source": "the lower half of check_source; same reason", + # Lowering produces an ir::Scenario, which no binding carries today: the C + # ABI and Python reach a scenario through the loaders, which build and + # initialize an engine in one call. The DSL execution surface follows the + # same shape in p8-s4 (#47), where the two frontends are compared directly. + "dsl::lower": "the IR it produces has no binding carrier yet; p8-s4 (#47)", } @@ -211,8 +217,13 @@ def main() -> int: ) ) - for name_space, header in FRONTEND_HEADERS.items(): - for function in frontend_entry_points(header.read_text(encoding="utf-8")): + for name_space, headers in FRONTEND_HEADERS.items(): + functions: list[str] = [] + for header in headers: + for function in frontend_entry_points(header.read_text(encoding="utf-8")): + if function not in functions: + functions.append(function) + for function in functions: qualified = f"{name_space}::{function}" c_symbol = FRONTEND_C_ALIASES.get(qualified, "") py_name = FRONTEND_PY_ALIASES.get(qualified, "") diff --git a/tools/scena-run/CMakeLists.txt b/tools/scena-run/CMakeLists.txt index e64196d..4fa7703 100644 --- a/tools/scena-run/CMakeLists.txt +++ b/tools/scena-run/CMakeLists.txt @@ -11,7 +11,10 @@ add_executable(scena-run src/main.cpp) # LogicFile gets a real road network, which is what the lane-relative and # routing golden scenarios need. The engine still reaches it only through # gateway::IRoadQuery (ADR-0003). +# Both frontends, one runtime: the scenario's extension picks which one +# compiles the file, and everything past the load sees only the Scenario IR. target_link_libraries(scena-run PRIVATE scena::core scena::frontend-xml + scena::frontend-dsl scena::roads-opendrive) target_compile_features(scena-run PRIVATE cxx_std_20) set_target_properties(scena-run PROPERTIES CXX_EXTENSIONS OFF) diff --git a/tools/scena-run/src/main.cpp b/tools/scena-run/src/main.cpp index bc310a7..31f9cb1 100644 --- a/tools/scena-run/src/main.cpp +++ b/tools/scena-run/src/main.cpp @@ -41,6 +41,9 @@ #include #include "scena/diagnostic.h" +#include "scena/dsl/load.h" +#include "scena/dsl/lower.h" +#include "scena/dsl/types.h" #include "scena/engine.h" #include "scena/gateway/simulator_gateway.h" #include "scena/ir/entity.h" @@ -71,6 +74,11 @@ struct Options { /// entity id -> replay file, from --replay =. std::map replay; std::string select; ///< --select , the one_of alternative (p8-s2) + /// --entry , the DSL entry point (§7.7.2). Empty means the only + /// scenario the file declares; a file with several is reported, not guessed. + std::string entry; + /// -I/--search-path, where a DSL module reference is looked up (§7.7.5.1.2). + std::vector search_paths; /// --map , overriding the scenario's own RoadNetwork/LogicFile. std::filesystem::path map; bool quiet = false; @@ -81,7 +89,7 @@ void print_usage() { usage: scena-run [options] - an OpenSCENARIO XML file (.xosc) + an OpenSCENARIO XML file (.xosc) or DSL file (.osc) options: --dt fixed step size (default 0.01) @@ -91,6 +99,8 @@ usage: scena-run [options] --map road network, overriding the scenario's LogicFile --replay = drive a host-controlled entity from a trace file --select choose a one_of alternative by name (DSL, p8-s2) + --entry the DSL scenario to run (.osc only) + -I, --search-path where DSL imports are looked up (repeatable) --quiet do not print diagnostics to stderr -h, --help this text @@ -356,6 +366,20 @@ std::optional parse_options(int argc, char** argv, int& exit_code) { return std::nullopt; } options.map = std::filesystem::path(std::string(text)); + } else if (arg == "--entry") { + const std::string_view text = value_of(i, arg); + if (text.empty()) { + exit_code = kUsage; + return std::nullopt; + } + options.entry = std::string(text); + } else if (arg == "-I" || arg == "--search-path") { + const std::string_view text = value_of(i, arg); + if (text.empty()) { + exit_code = kUsage; + return std::nullopt; + } + options.search_paths.emplace_back(std::string(text)); } else if (arg == "--select") { const std::string_view text = value_of(i, arg); if (text.empty()) { @@ -423,13 +447,43 @@ int main(int argc, char** argv) { } } + // Two frontends, one runtime: the extension picks which one compiles the + // file, and everything past this point sees only the Scenario IR. The + // road-network reference travels beside it in both cases, because a file + // path is a host input rather than kernel state (ADR-0003). scena::DiagnosticSink load_sink; - scena::xml::Document document; - const scena::Status loaded = scena::xml::load_file(options.scenario, document, load_sink); - print_diagnostics(load_sink.diagnostics(), options.quiet); - if (loaded != scena::Status::Ok) { - std::cerr << "scena-run: could not load " << options.scenario.string() << '\n'; - return kLoadFailed; + scena::ir::Scenario scenario; + std::string road_network; + if (options.scenario.extension() == ".osc") { + scena::dsl::LoadOptions load_options; + load_options.search_paths = options.search_paths; + scena::dsl::LoadResult files; + scena::dsl::Program program; + scena::Status loaded = + scena::dsl::check_file(options.scenario, load_options, files, program, load_sink); + if (loaded == scena::Status::Ok) { + scena::dsl::LowerOptions lower_options; + lower_options.entry_point = options.entry; + scena::dsl::LowerResult lowered; + loaded = scena::dsl::lower(program, files, lower_options, lowered, load_sink); + scenario = std::move(lowered.scenario); + road_network = std::move(lowered.map_file); + } + print_diagnostics(load_sink.diagnostics(), options.quiet); + if (loaded != scena::Status::Ok) { + std::cerr << "scena-run: could not load " << options.scenario.string() << '\n'; + return kLoadFailed; + } + } else { + scena::xml::Document document; + const scena::Status loaded = scena::xml::load_file(options.scenario, document, load_sink); + print_diagnostics(load_sink.diagnostics(), options.quiet); + if (loaded != scena::Status::Ok) { + std::cerr << "scena-run: could not load " << options.scenario.string() << '\n'; + return kLoadFailed; + } + scenario = std::move(document.scenario); + road_network = document.road_network.logic_file; } // --replay declares that the host will drive an entity, which is the host's @@ -440,7 +494,7 @@ int main(int argc, char** argv) { for (const auto& [entity, path] : options.replay) { (void)path; bool found = false; - for (scena::ir::Entity& declared : document.scenario.entities) { + for (scena::ir::Entity& declared : scenario.entities) { if (declared.id == entity) { declared.control_mode = scena::ir::ControlMode::HostControlled; found = true; @@ -460,8 +514,8 @@ int main(int argc, char** argv) { // points a scenario at a different network without editing it. Without // either, the engine runs road-free and the flat-world model applies. std::filesystem::path map = options.map; - if (map.empty() && !document.road_network.logic_file.empty()) { - map = std::filesystem::path(document.road_network.logic_file); + if (map.empty() && !road_network.empty()) { + map = std::filesystem::path(road_network); if (map.is_relative()) { map = options.scenario.parent_path() / map; } @@ -490,7 +544,7 @@ int main(int argc, char** argv) { } scena::Engine engine(&gateway); - const scena::Status initialized = engine.init(std::move(document.scenario)); + const scena::Status initialized = engine.init(std::move(scenario)); print_diagnostics(engine.diagnostics(), options.quiet); if (initialized != scena::Status::Ok) { std::cerr << "scena-run: could not initialize the scenario\n"; diff --git a/tools/scena-run/tests/scena_run_test.cpp b/tools/scena-run/tests/scena_run_test.cpp index e9f17ce..6646af1 100644 --- a/tools/scena-run/tests/scena_run_test.cpp +++ b/tools/scena-run/tests/scena_run_test.cpp @@ -309,6 +309,108 @@ TEST(ScenaRunTest, SelectIsAcceptedAndReportedAsNotYetActive) { EXPECT_NE(output.find("--select has no effect yet"), std::string::npos); } +// --- the DSL frontend (p8-s1, #44) ----------------------------------------- + +/// Writes a DSL scenario to a temporary file and returns its path. The source +/// lives here rather than in a fixture directory because what these tests pin +/// is the *CLI*, and a scenario the reader can see beside the assertion is +/// worth more than one they have to go and find. +fs::path dsl_file(const std::string& name, const std::string& source) { + const fs::path path = temp_file(name); + std::ofstream(path) << source; + return path; +} + +constexpr const char* kCruiseOsc = R"(import osc.standard.all + +namespace demo use std, stdtypes + +scenario cruise: + ego: vehicle + start: position_3d + keep(start.x == 5m) + do serial: + place: ego.assign_position(position: start) + launch: ego.assign_speed(speed: 10mps) +)"; + +TEST(ScenaRunTest, ADslScenarioRunsEndToEnd) { + // The p8-s1 exit criterion, driven the way a user would drive it: a .osc + // file in, a trace out, through the same runtime the XML frontend feeds. + const fs::path scenario = dsl_file("scena_run_cruise.osc", kCruiseOsc); + const fs::path trace = temp_file("scena_run_cruise.csv"); + std::string output; + ASSERT_EQ(run("\"" + scenario.string() + "\" --dt 0.1 --duration 1 --trace \"" + + trace.string() + "\"", + output), + 0) + << output; + const std::vector lines = read_lines(trace); + ASSERT_EQ(lines.size(), 11U); // header + 10 steps + EXPECT_EQ(lines[0], "t,entity,x,y,z,heading,speed"); + // Teleported to x = 5 m, then driving at 10 m/s: after one step, 6 m. + EXPECT_EQ(lines[1], "0.1,ego,6,0,0,0,10"); + EXPECT_EQ(lines.back(), "1,ego,15,0,0,0,10"); +} + +TEST(ScenaRunTest, ADslScenarioWithSeveralEntryPointsNeedsOneNamed) { + // §7.7.2 leaves the choice to the implementation, and guessing would make + // the run depend on declaration order. + const fs::path scenario = dsl_file("scena_run_two.osc", + R"(import osc.standard.all + +namespace demo use std, stdtypes + +scenario first: + ego: vehicle + do launch: ego.assign_speed(speed: 10mps) + +scenario second: + ego: vehicle + do launch: ego.assign_speed(speed: 20mps) +)"); + std::string output; + EXPECT_EQ(run("\"" + scenario.string() + "\" --dt 0.1 --duration 0.2", output), 3); + EXPECT_NE(output.find("more than one scenario"), std::string::npos) << output; + + const fs::path trace = temp_file("scena_run_two.csv"); + ASSERT_EQ(run("\"" + scenario.string() + + "\" --dt 0.1 --duration 0.2 --entry second --trace \"" + trace.string() + + "\"", + output), + 0) + << output; + const std::vector lines = read_lines(trace); + ASSERT_GT(lines.size(), 1U); + EXPECT_NE(lines[1].find(",20"), std::string::npos) << lines[1]; +} + +TEST(ScenaRunTest, AMalformedDslScenarioIsALoadErrorNotACrash) { + const fs::path scenario = dsl_file("scena_run_broken.osc", "scenario :\n ???\n"); + std::string output; + EXPECT_EQ(run("\"" + scenario.string() + "\"", output), 3); +} + +TEST(ScenaRunTest, ADslScenarioNamesItsOwnRoadNetwork) { + // §8.5.4's map_file plays the part RoadNetwork/LogicFile plays on the XML + // side, including being resolved relative to the scenario file. + const fs::path scenario = dsl_file("scena_run_map.osc", + R"(import osc.standard.all + +namespace demo use std, stdtypes + +scenario mapped: + map.set_map_file("no_such_map.xodr") + ego: vehicle + do launch: ego.assign_speed(speed: 10mps) +)"); + std::string output; + // The map does not exist, which is exactly what makes the reference + // observable: the run stops with the map exit code rather than ignoring it. + EXPECT_EQ(run("\"" + scenario.string() + "\" --dt 0.1 --duration 0.2", output), 7); + EXPECT_NE(output.find("no_such_map.xodr"), std::string::npos) << output; +} + TEST(ScenaRunTest, QuietSuppressesDiagnosticsButNotFailures) { std::string output; EXPECT_EQ(run("no/such/scenario.xosc --quiet", output), 3);