diff --git a/docs/architecture/ADR-0030-dsl-lowering-to-the-ir.md b/docs/architecture/ADR-0030-dsl-lowering-to-the-ir.md new file mode 100644 index 0000000..b6c8a21 --- /dev/null +++ b/docs/architecture/ADR-0030-dsl-lowering-to-the-ir.md @@ -0,0 +1,142 @@ + + +# ADR-0030 — Lowering a checked DSL program to the Scenario IR + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Sprint:** p8-s1 (#44) +- **Supersedes:** nothing. Builds on ADR-0028 (symbols and the type model), + ADR-0029 (the bundled standard library) and ADR-0010 (the entity taxonomy). + +## Context + +P7 ended with a frontend that *checks* OpenSCENARIO DSL: it parses, resolves, +types and validates a program against the whole §8 standard library. Nothing +runs. P8 connects that to the runtime the XML frontend has been feeding since +P4, and the connection point is the Scenario IR — the architecture's rule that +both frontends compile into one IR and that runtime semantics live in the +runtime, never in a frontend. + +Lowering therefore decides one thing only: **which DSL construct denotes which +IR construct**. Where it cannot decide, it reports. + +Three questions had no obvious answer: + +1. **Which scenario runs?** §7.7.2 says outright that entry-point selection "is + defined by implementation", and that this is deliberate: "This allows + implementations to experiment with the best ways to select this entry point." +2. **What is a participant?** The DSL has no `Entities` section. A scenario + declares fields, some of which happen to be actors. +3. **Where do concrete values come from?** The IR wants numbers. §7.3.11 + constraints can say far more than "this field is 4.5 m", and solving them in + general needs a constraint solver, which ADR-0004 places after v0.0.1. + +## Decision + +### 1. The entry point is named, and a single scenario names itself + +`LowerOptions::entry_point` takes a scenario by qualified name (`demo::overtake`) +or by the name as written (`overtake`) — a file with one namespace makes the +prefix pure ceremony. + +When it is empty: + +- a root file declaring exactly **one** scenario uses that one; +- a root file declaring **several** is an error that lists them. + +Guessing among several would make the run depend on declaration order, which is +the kind of hidden input the determinism contract exists to eliminate. +`entry_points()` returns the same list a CLI would print, in declaration order: +that is what the file *offers*, and a reader matches it against the file in +front of them rather than against an alphabetized list. + +Only the **root** file's scenarios are offered. An imported file contributes +types, not entry points. + +### 2. A participant is a field whose type derives from `std::physical_object` + +§8.7 roots its actor hierarchy at `physical_object`, so that is the test. +Every such field of the entry scenario becomes one `ir::Entity`, in declaration +order, with the field name as both id and name. + +Classification follows the same hierarchy onto ADR-0010's taxonomy: + +| DSL actor | IR object | +|---|---| +| derives from `std::vehicle` | `ir::Vehicle` | +| derives from `std::person` | `ir::Pedestrian` | +| derives from `std::stationary_object` | `ir::MiscObject` | +| anything else deriving from `physical_object` | unclassified | + +The last row matters. §8.7.10's `animal` is a sibling actor, not a pedestrian +category, and XML has nowhere to put it; an entity with an identity and a +control mode is all the runtime needs of it, and a wrong classification would be +worse than none. + +Every lowered participant is `EngineControlled`: the DSL has no way to say +otherwise, and the host reassigns ownership through the engine API (ADR-0003). + +### 3. Concrete values come from equality constraints and nowhere else + +Lowering reads exactly one constraint shape: `keep( == )`, +in either operand order, where the constant side folds without a solver. That is +what "attribute-level concrete" means (§6.3.1.2.1) and it is the only shape +whose meaning is unambiguous without search. + +Two consequences worth stating: + +- **Lowering never converts.** §7.3.4 folding already happened during checking, + so a physical value arrives in its base unit. In particular lowering must not + re-apply the standard's printed conversion factors — ADR-0029 carries them + verbatim once, at fold time, and once is the whole point. +- **What is not fixed keeps the IR's own default.** A vehicle whose category no + constraint fixes is whatever `ir::Vehicle` defaults to, not a guess made here. + +§7.3.8.2's conditional inheritance (`inherits vehicle(vehicle_category == car)`) +fixes a value on the *type* rather than in the scenario, and is read the same +way — it is the spelling §8.7's own examples use. + +### 4. §8.7 has no performance limits, and lowering does not invent any + +The DSL domain model has no counterpart to XML's `Performance` element: §8.7 +declares no maximum speed, acceleration or deceleration anywhere. The IR's zeros +are the faithful lowering rather than a gap, because the runtime already reads a +non-positive limit as "unconstrained" (`actor_max_speed` in `engine.cpp`). A DSL +vehicle is therefore unlimited until the scenario says otherwise, and no numbers +are fabricated. + +### 5. A remnant is reported, never approximated + +Anything that would need search is a diagnostic, not a silently-defaulted value +— the same stance ADR-0004 takes for the checker and the same one the XML +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. + +## 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. +- 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, + during checking, by the same detmath-constrained path. +- A future frontend (or a future entry-point mechanism the standard may + introduce) changes §7.7.2's rule here without touching the runtime, which is + the point of putting the decision in the frontend. + +## Alternatives considered + +**Pick the first scenario when several are declared.** Rejected: it makes the +run depend on declaration order, and reordering a file would silently change +what executes. + +**Give DSL vehicles a documented default performance profile.** Rejected: it +fabricates numbers the standard does not state. "Unconstrained" is what §8.7 +actually says, and the runtime already spells it as zero. + +**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 e199b26..6778be2 100644 --- a/docs/roadmap/coverage/osc-dsl-coverage.md +++ b/docs/roadmap/coverage/osc-dsl-coverage.md @@ -37,6 +37,22 @@ construct one. | C ABI — `scn_check_dsl_file` / `scn_check_dsl_string` | In | p7-s5 | Opaque `scn_dsl_check` handle carrying the diagnostics and the two counts; a failing check still produces one, because that is the case whose findings you want (`c_consumer.c`) | | Python — `scena.check_dsl_file` / `check_dsl_string` | In | p7-s5 | Returns a `DslCheck` — status, diagnostics, `type_count`, `file_count` (`test_dsl_check.py`, `python/examples/check_dsl.py`) | +## Lowering to the IR (P8) + +The DSL and the XML frontend compile into one Scenario IR, so nothing in this +table decides runtime semantics — only which DSL construct denotes which IR +construct (ADR-0030). Lowering is inside the determinism contract, because load +time is. + +| Feature | Section | Check | Exec | Sprint(s) | Notes | +|---|---|---|---|---|---| +| Actor field → IR entity | §8.7 | In | In | p8-s1 | **Landed** (`dsl_lowering_test.cpp`): every entry-scenario field whose type derives from `std::physical_object` becomes one entity, in declaration order, engine-controlled. `vehicle`/`person`/`stationary_object` classify onto the p2-s1 taxonomy; anything else deriving from `physical_object` (§8.7.10's `animal`) stays an unclassified participant rather than being misfiled | +| 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) | + ## Language core (§7.2, §7.3) | Feature | Section | Check | Exec | Sprint(s) | Notes | @@ -70,7 +86,7 @@ construct one. | Namespaces + `::`, export rules | §7.7.4 | In | n/a | p7-s3 | **Landed**: `namespace ... use`, explicit `ns::name`, export lists and wildcards, the current namespace shadowing the use list (§7.7.4.2), ambiguity across two used namespaces reported, `std`-prefixed namespaces warned, each file starting in the null namespace | | Import (URI + identifier forms; `osc.standard.all/types/domain`, legacy `osc.standard`) | §7.7.5 | In | n/a | p7-s2, p7-s5 | **Landed** (`dsl_import_test.cpp`): both reference forms resolved, `file` URIs (`file:///p`, `file:/p`, bare) with relative references anchored to the referencing file, module references mapped `a.b.c` → `a/b/c.osc` over configured search paths, import-once by canonical path so a diamond declares once and a cycle terminates (§7.7.5.1), referenced files ordered before the referencing file, `osc`-prefixed references reserved (§7.7.5.1.2) | | Standard-library access (built-in definitions; auto-use) | §7.7.5.2 | In | n/a | p7-s5 | **Landed** (`dsl_import_test.cpp`, `dsl_stdlib_test.cpp`): the types sub-module is provided as built-in definitions, the route §7.7.5.2 permits, with `stdtypes` auto-used in the null namespace (§7.7.5.2.3) so a physical literal types before any import; all four module references accepted; a namespace statement restores the ordinary §7.7.4 use-list rules (ADR-0029) | -| Scenario entry-point selection | §7.7.2 | n/a | In | p8-s1 | Implementation-defined per spec: qualified name via API/CLI | +| Scenario entry-point selection | §7.7.2 | n/a | In | p8-s1 | **Landed** (`dsl_lowering_test.cpp`): §7.7.2 leaves the choice to the implementation. `LowerOptions::entry_point` takes a scenario by qualified name or as written; empty means the root file's only scenario, and a file declaring several is an error that lists them rather than guessing (ADR-0030). `entry_points()` returns the same list in declaration order | ## Expressions (§7.4) diff --git a/frontends/dsl/CMakeLists.txt b/frontends/dsl/CMakeLists.txt index 1d983b8..82bdff6 100644 --- a/frontends/dsl/CMakeLists.txt +++ b/frontends/dsl/CMakeLists.txt @@ -14,6 +14,7 @@ add_library(scena-frontend-dsl STATIC src/types.cpp src/stdlib.cpp src/load.cpp + src/lower.cpp ) add_library(scena::frontend-dsl ALIAS scena-frontend-dsl) diff --git a/frontends/dsl/README.md b/frontends/dsl/README.md index 30372b0..17bdcaf 100644 --- a/frontends/dsl/README.md +++ b/frontends/dsl/README.md @@ -53,6 +53,9 @@ expected. It also keeps the dependency list unchanged. | `src/stdlib.cpp` | its source, as chunked raw literals | | `tests/dsl_import_test.cpp` | §7.7.5 imports, search paths, diagnostics carrying their file | | `tests/dsl_stdlib_test.cpp` | the §8 library, pinned declaration by declaration | +| `include/scena/dsl/lower.h` | `lower()` / `entry_points()` — checked program to Scenario IR | +| `src/lower.cpp` | the §7.7.2 entry point, §8.7 actors as entities, §7.3.11 concrete values | +| `tests/dsl_lowering_test.cpp` | the DSL→IR mapping, and that lowering is deterministic | ## Lexing notes @@ -215,3 +218,27 @@ expected. It also keeps the dependency list unchanged. an engine's diagnostic list. `scripts/parity_audit.py` now audits these entry points alongside the `Engine` methods, so a frontend function added to one surface and forgotten in the others is a CI failure. + +## Lowering notes (ADR-0030) + +- **Lowering decides denotation, not semantics.** Both frontends compile into + one IR; runtime behaviour lives in the runtime. If the DSL side needs + behaviour the XML side would also need, it belongs in `core/`. +- **The entry point is named, and a lone scenario names itself.** §7.7.2 leaves + the choice to the implementation. A file declaring several scenarios is an + error listing them, because picking one would make the run depend on + declaration order. +- **A participant is a field deriving from `std::physical_object`.** §8.7 roots + its hierarchy there, so that is the test; `animal` has no taxonomy + counterpart and stays an unclassified participant rather than being misfiled + as a pedestrian. +- **Concrete means `keep(field == constant)`.** Either operand order, constant + side folding without a solver. Everything else is diagnosed (ADR-0004). + Values arrive already folded to base units, so lowering never converts — + re-applying §8.14.1.3's printed factors a second time is exactly the bug + ADR-0029 exists to prevent. +- **An enum literal resolves through the use list.** `vehicle_category!bus` + written in `namespace demo use std` names a type in `std`, so constant + 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. diff --git a/frontends/dsl/include/scena/dsl/lower.h b/frontends/dsl/include/scena/dsl/lower.h new file mode 100644 index 0000000..59ca66c --- /dev/null +++ b/frontends/dsl/include/scena/dsl/lower.h @@ -0,0 +1,66 @@ +/* + * Copyright 2026 Robomous + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include "scena/diagnostic.h" +#include "scena/dsl/load.h" +#include "scena/dsl/types.h" +#include "scena/ir/scenario.h" +#include "scena/status.h" + +namespace scena::dsl { + +/// How a checked DSL program becomes a Scenario IR (ADR-0030). +struct LowerOptions { + /// The scenario to instantiate, by qualified name (`demo::overtake`) or by + /// the name as written (`overtake`). + /// + /// §7.7.2 leaves entry-point selection entirely to the implementation. + /// Empty means "the only top-level scenario the root file declares" — a + /// file with exactly one is the common case and naming it again adds + /// nothing; a file with several is reported rather than guessed at, because + /// picking one silently would make the run depend on declaration order. + std::string entry_point; +}; + +/// The 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 +/// offer. Qualified names, so each one can be passed back as +/// `LowerOptions::entry_point` unambiguously. +[[nodiscard]] std::vector entry_points(const Program& program, + const LoadResult& loaded); + +/// Lowers a checked program to the Scenario IR. +/// +/// Only **attribute-level concrete** scenarios lower (§6.3.1.2.1): every value +/// the IR needs must be fixed by an equality constraint or a field default. +/// Anything that would need search is reported as an +/// `UnsupportedFeature` warning and left at the IR's default — reporting beats +/// approximating where determinism is at stake (ADR-0004). +/// +/// `program` must have been checked without errors; lowering an unchecked +/// program is host misuse, not a content defect, and is rejected as such. +/// +/// 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); + +} // namespace scena::dsl diff --git a/frontends/dsl/src/expression.cpp b/frontends/dsl/src/expression.cpp index dddd2c9..b128641 100644 --- a/frontends/dsl/src/expression.cpp +++ b/frontends/dsl/src/expression.cpp @@ -707,11 +707,40 @@ class Evaluator { return id < program_.types.size() ? program_.types[id].kind : TypeKind::Struct; } bool evaluate_binary(const Expr& expression, Value& out); + [[nodiscard]] TypeId lookup_enum(const std::string& written) const; const Program& program_; const ExpressionContext& context_; }; +/// Resolves the enum name in `enum-name '!' member` (§7.3.3). +/// +/// §7.7.4.2's rules, as far as a constant context needs them: an explicitly +/// qualified name resolves in exactly one place, the current namespace shadows +/// the use list, and the use list is searched last. Searching the use list is +/// the whole point — a scenario in `demo use std` writes +/// `vehicle_category!bus`, and without it that literal is not constant, which +/// silently turns every enum-valued `keep` into one that "needs a solver". +TypeId Evaluator::lookup_enum(const std::string& written) const { + if (written.find("::") != std::string::npos) { + const auto found = program_.types_by_name.find(written); + return found == program_.types_by_name.end() ? kInvalidType : found->second; + } + const auto local = program_.types_by_name.find( + (context_.name_space.empty() ? "::" : context_.name_space + "::") + written); + if (local != program_.types_by_name.end()) { + return local->second; + } + // The use list, in the order the namespace statement gives it. + for (const std::string& used : context_.uses) { + const auto found = program_.types_by_name.find(used + "::" + written); + if (found != program_.types_by_name.end()) { + return found->second; + } + } + return kInvalidType; +} + bool Evaluator::evaluate_binary(const Expr& expression, Value& out) { Value left; Value right; @@ -909,12 +938,7 @@ bool Evaluator::evaluate(const Expr& expression, Value& out) { const std::string& member_name = expression.text; TypeId id = kInvalidType; if (!enum_name.empty()) { - const auto found = program_.types_by_name.find( - enum_name.find("::") != std::string::npos - ? enum_name - : (context_.name_space.empty() ? "::" : context_.name_space + "::") + - enum_name); - id = found == program_.types_by_name.end() ? kInvalidType : found->second; + id = lookup_enum(enum_name); } else { const std::vector enums = program_.enums_declaring(member_name); if (enums.size() != 1) { diff --git a/frontends/dsl/src/lower.cpp b/frontends/dsl/src/lower.cpp new file mode 100644 index 0000000..d2ae302 --- /dev/null +++ b/frontends/dsl/src/lower.cpp @@ -0,0 +1,451 @@ +/* + * Copyright 2026 Robomous + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// +// Lowering a checked DSL program to the Scenario IR (p8-s1, ADR-0030). +// +// The DSL and the XML frontend compile into the same IR, so nothing here +// decides runtime semantics: it decides which DSL construct denotes which IR +// construct, and reports whatever it cannot make concrete. +// + +#include "scena/dsl/lower.h" + +#include +#include +#include +#include +#include + +#include "scena/dsl/ast.h" +#include "scena/dsl/expression.h" +#include "scena/ir/bounding_box.h" +#include "scena/ir/entity.h" +#include "scena/ir/entity_types.h" + +namespace scena::dsl { +namespace { + +/// The standard-library types the mapping keys off. Looked up once, by +/// qualified name, so a scenario that never imports the library simply finds +/// none of them and lowers no entities. +struct LibraryTypes { + TypeId physical_object = kInvalidType; + TypeId vehicle = kInvalidType; + TypeId person = kInvalidType; + TypeId stationary_object = kInvalidType; +}; + +[[nodiscard]] TypeId find_type(const Program& program, const std::string& name) { + const auto found = program.types_by_name.find(name); + return found == program.types_by_name.end() ? kInvalidType : found->second; +} + +[[nodiscard]] LibraryTypes library_types(const Program& program) { + LibraryTypes types; + types.physical_object = find_type(program, "std::physical_object"); + types.vehicle = find_type(program, "std::vehicle"); + types.person = find_type(program, "std::person"); + types.stationary_object = find_type(program, "std::stationary_object"); + return types; +} + +void report(DiagnosticSink& sink, Severity severity, Status code, const std::string& file, + const SourceRange& at, std::string message) { + Diagnostic diagnostic; + diagnostic.severity = severity; + diagnostic.code = code; + diagnostic.message = std::move(message); + diagnostic.location.file = file; + diagnostic.location.line = at.line; + diagnostic.location.column = at.column; + // The DSL standard defines no `asam.net:` rule identifiers, so the citation + // stays in the message and rule_id stays empty. + sink.report(std::move(diagnostic)); +} + +/// One `keep( == )` the entry scenario fixes. +/// +/// §7.3.11 lets a constraint say far more than this. Everything else needs a +/// solver, which is post-v0.0.1 (ADR-0004), so lowering reads exactly the shape +/// that resolves to a value without one. +struct Binding { + /// The field path as written, e.g. `ego.bounding_box.length`. + std::vector path; + Value value; + SourceRange range; +}; + +/// Reads `a.b.c` out of an expression, or returns false for anything else. +bool field_path(const Expr& expression, std::vector& out) { + if (expression.kind == ExprKind::Name) { + out.push_back(expression.text); + return true; + } + if (expression.kind == ExprKind::FieldAccess && expression.operands.size() == 1 && + expression.operands.front() != nullptr) { + if (!field_path(*expression.operands.front(), out)) { + return false; + } + out.push_back(expression.text); + return true; + } + return false; +} + +/// Collects the equality bindings of a scenario, in declaration order. +/// +/// Both operand orders are read: `keep(x == 3)` and `keep(3 == x)` say the same +/// thing, and the standard prints both. +void collect_bindings(const Program& program, const StructuredDecl& decl, + const ExpressionContext& context, std::vector& out) { + for (const Member& member : decl.members) { + if (member.kind != Member::Kind::Constraint || member.constraint.expression == nullptr) { + continue; + } + const Expr& expression = *member.constraint.expression; + if (expression.kind != ExprKind::Binary || expression.text != "==" || + expression.operands.size() != 2) { + continue; + } + for (int side = 0; side < 2; ++side) { + const ExprPtr& name = expression.operands[static_cast(side)]; + const ExprPtr& value = expression.operands[static_cast(1 - side)]; + if (name == nullptr || value == nullptr) { + continue; + } + Binding binding; + if (!field_path(*name, binding.path)) { + continue; + } + if (!evaluate_constant(program, *value, context, binding.value)) { + continue; // needs search; the caller reports the remnant + } + binding.range = member.constraint.range; + out.push_back(std::move(binding)); + break; + } + } +} + +/// The binding for `.....`, or nullptr. +const Value* binding_for(const std::vector& bindings, const std::string& entity, + std::initializer_list tail) { + for (const Binding& binding : bindings) { + if (binding.path.size() != tail.size() + 1 || binding.path.front() != entity) { + continue; + } + std::size_t index = 1; + bool matched = true; + for (const char* step : tail) { + if (binding.path[index++] != step) { + matched = false; + break; + } + } + if (matched) { + return &binding.value; + } + } + return nullptr; +} + +/// Writes a bound number into `out`, if there is one. Physical quantities have +/// already been folded to their base unit (§7.3.4), which is the unit the IR +/// stores — so lowering never converts, and never re-applies the standard's +/// printed factors (ADR-0029). +void bind_number(const std::vector& bindings, const std::string& entity, + std::initializer_list tail, double& out) { + const Value* value = binding_for(bindings, entity, tail); + if (value != nullptr && value->is_numeric()) { + out = value->as_double(); + } +} + +/// The enum member a binding names, or empty. +std::string bound_enum(const Program& program, const std::vector& bindings, + const std::string& entity, std::initializer_list tail) { + const Value* value = binding_for(bindings, entity, tail); + if (value == nullptr || value->kind != Value::Kind::Enum || value->type == kInvalidType) { + return {}; + } + for (const EnumMemberInfo& member : program.types[value->type].enum_members) { + if (member.value == value->enum_value) { + return member.name; + } + } + return {}; +} + +ir::BoundingBox lower_bounding_box(const std::vector& bindings, const std::string& id) { + ir::BoundingBox box; + bind_number(bindings, id, {"bounding_box", "length"}, box.length); + bind_number(bindings, id, {"bounding_box", "width"}, box.width); + bind_number(bindings, id, {"bounding_box", "height"}, box.height); + bind_number(bindings, id, {"bounding_box", "center_x"}, box.center_x); + bind_number(bindings, id, {"bounding_box", "center_y"}, box.center_y); + bind_number(bindings, id, {"bounding_box", "center_z"}, box.center_z); + return box; +} + +/// §8.7.16's `vehicle_category` mapped onto the XML-side taxonomy (p2-s1). +/// +/// The two standards enumerate the same twenty things under slightly different +/// spellings, so this is a spelling bridge and nothing more — no category is +/// invented and none is dropped. `truck` and `vru_vehicle` are §8.7.16's +/// backward-compatibility aliases, equal in value to `heavy_truck` and +/// `micro_mobility_device`, so they never appear here: a lookup by value finds +/// the replacement first. XML's `Truck` and `Motorbike` are the mirror case — +/// deprecated 1.3 spellings with no DSL counterpart at all. +/// +/// A category nothing fixes is left at the IR's own default rather than guessed +/// at, which is why this returns an optional. +std::optional vehicle_category_of(const std::string& name) { + static const std::map kCategories{ + {"aircraft", ir::VehicleCategory::Aircraft}, + {"bicycle", ir::VehicleCategory::Bicycle}, + {"bus", ir::VehicleCategory::Bus}, + {"car", ir::VehicleCategory::Car}, + {"heavy_truck", ir::VehicleCategory::HeavyTruck}, + {"land_vehicle", ir::VehicleCategory::LandVehicle}, + {"micro_mobility_device", ir::VehicleCategory::MicromobilityDevice}, + {"motorcycle", ir::VehicleCategory::Motorcycle}, + {"other", ir::VehicleCategory::Other}, + {"semi_tractor", ir::VehicleCategory::Semitractor}, + {"semi_trailer", ir::VehicleCategory::Semitrailer}, + {"stand_up_scooter", ir::VehicleCategory::StandupScooter}, + {"trailer", ir::VehicleCategory::Trailer}, + {"train", ir::VehicleCategory::Train}, + {"tram", ir::VehicleCategory::Tram}, + {"van", ir::VehicleCategory::Van}, + {"watercraft", ir::VehicleCategory::Watercraft}, + {"wheelchair", ir::VehicleCategory::Wheelchair}, + {"work_machine", ir::VehicleCategory::WorkMachine}, + }; + const auto found = kCategories.find(name); + if (found == kCategories.end()) { + return std::nullopt; + } + return found->second; +} + +/// 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) { + if (declaration.kind == Declaration::Kind::Namespace && + declaration.name_space.name == name_space) { + return declaration.name_space.uses; + } + } + return {}; +} + +} // namespace + +std::vector entry_points(const Program& program, const LoadResult& loaded) { + std::vector names; + const File* root = loaded.root(); + if (root == nullptr) { + return names; + } + // Declaration order, not name order: this is what a file *offers*, and a + // reader matches it against the file they are looking at. + std::string name_space; + for (const Declaration& declaration : root->declarations) { + if (declaration.kind == Declaration::Kind::Namespace) { + name_space = declaration.name_space.name; + continue; + } + if (declaration.kind != Declaration::Kind::Structured || + declaration.structured.kind != StructuredKind::Scenario) { + continue; + } + const std::string qualified = name_space + "::" + declaration.structured.name; + if (program.types_by_name.count(qualified) != 0) { + names.push_back(qualified); + } + } + return names; +} + +Status lower(const Program& program, const LoadResult& loaded, const LowerOptions& options, + ir::Scenario& out, DiagnosticSink& sink) { + const File* root = loaded.root(); + if (root == nullptr) { + // Nothing parsed. Host misuse: lowering is only defined for a program + // that checked. + return Status::InvalidArgument; + } + + const std::vector available = entry_points(program, loaded); + std::string entry; + if (options.entry_point.empty()) { + if (available.size() == 1) { + entry = available.front(); + } else { + std::string message = + available.empty() + ? "the file declares no scenario to run (§7.7.2)" + : "the file declares more than one scenario; name the entry point (§7.7.2):"; + for (const std::string& name : available) { + message += " " + name; + } + report(sink, Severity::Error, Status::SemanticError, root->path, SourceRange{}, + std::move(message)); + return Status::SemanticError; + } + } else { + for (const std::string& name : available) { + // Accept both the qualified name and the name as written, since a + // file with one namespace makes the prefix pure ceremony. + const std::size_t separator = name.rfind("::"); + const std::string simple = + separator == std::string::npos ? name : name.substr(separator + 2); + if (name == options.entry_point || simple == options.entry_point) { + entry = name; + break; + } + } + if (entry.empty()) { + std::string message = + "'" + options.entry_point + "' is not a scenario this file declares (§7.7.2);"; + message += available.empty() ? " it declares none" : " it declares:"; + for (const std::string& name : available) { + message += " " + name; + } + report(sink, Severity::Error, Status::SemanticError, root->path, SourceRange{}, + std::move(message)); + return Status::SemanticError; + } + } + + const TypeId scenario_id = program.types_by_name.at(entry); + const TypeInfo& scenario = program.types[scenario_id]; + out.name = scenario.simple_name; + + ExpressionContext context; + context.self = scenario_id; + context.name_space = scenario.name_space; + context.file = root->path; + // The use list is not kept on a resolved type — it belongs to the scope the + // declaration was written in — so it is read back off the file. Without it + // an enum literal like `vehicle_category!bus` cannot name its enum, and + // every enum-valued constraint would silently fail to bind. + context.uses = uses_of(*root, scenario.name_space); + + std::vector bindings; + for (const StructuredDecl* declaration : scenario.declarations) { + if (declaration != nullptr) { + collect_bindings(program, *declaration, context, bindings); + } + } + + const LibraryTypes library = library_types(program); + for (const std::string& field_name : scenario.field_order) { + const auto found = scenario.fields.find(field_name); + if (found == scenario.fields.end()) { + continue; + } + const FieldInfo& field = found->second; + if (library.physical_object == kInvalidType || + !program.is_derived_from(field.type, library.physical_object)) { + continue; // not a participant; §8.7's root is what makes one + } + + ir::Entity entity; + entity.id = field.name; + entity.name = field.name; + // Every lowered participant is engine-controlled: the DSL has no way to + // say otherwise, and the host reassigns ownership through the engine + // API (ADR-0003). + entity.control_mode = ir::ControlMode::EngineControlled; + + if (library.vehicle != kInvalidType && + program.is_derived_from(field.type, library.vehicle)) { + ir::Vehicle vehicle; + vehicle.bounding_box = lower_bounding_box(bindings, field.name); + const std::string category = + bound_enum(program, bindings, field.name, {"vehicle_category"}); + if (const std::optional mapped = vehicle_category_of(category); + mapped.has_value()) { + vehicle.category = *mapped; + } else { + // A conditional `inherits vehicle(vehicle_category == car)` + // (§7.3.8.2) fixes it on the type rather than in the scenario. + Value value; + for (TypeId current = field.type; current != kInvalidType; + current = program.types[current].base) { + const TypeInfo& type = program.types[current]; + if (type.constraint_field == "vehicle_category" && + type.constraint_value != nullptr && + evaluate_constant(program, *type.constraint_value, context, value) && + value.kind == Value::Kind::Enum && value.type != kInvalidType) { + for (const EnumMemberInfo& member : + program.types[value.type].enum_members) { + if (member.value != value.enum_value) { + continue; + } + if (const std::optional inherited = + vehicle_category_of(member.name); + inherited.has_value()) { + vehicle.category = *inherited; + } + break; + } + break; + } + } + } + // §8.7 declares no performance limits at all — the DSL domain + // model has no counterpart to XML's Performance element. The IR's + // zeros are the faithful lowering, not a gap: the runtime already + // reads a non-positive limit as "unconstrained" + // (`actor_max_speed`), so a DSL vehicle is simply unlimited until + // the scenario says otherwise. + entity.object = std::move(vehicle); + } else if (library.person != kInvalidType && + program.is_derived_from(field.type, library.person)) { + ir::Pedestrian pedestrian; + pedestrian.bounding_box = lower_bounding_box(bindings, field.name); + // §8.7.9 declares `person` with no members of its own, so there is + // no category to read; the IR's default stands. `animal` is a + // sibling actor rather than a person category, and lowers as an + // unclassified participant until the taxonomy has somewhere to put + // it. + entity.object = std::move(pedestrian); + } else if (library.stationary_object != kInvalidType && + program.is_derived_from(field.type, library.stationary_object)) { + ir::MiscObject object; + object.bounding_box = lower_bounding_box(bindings, field.name); + entity.object = std::move(object); + } + // Anything else deriving from physical_object stays an unclassified + // participant: it has an identity and a control mode, which is all the + // runtime needs of it. + + out.entities.push_back(std::move(entity)); + } + + // A scenario that says nothing about who is in it has nothing to run. + if (out.entities.empty()) { + report(sink, Severity::Warning, Status::UnsupportedFeature, root->path, SourceRange{}, + "'" + entry + "' declares no §8.7 participant, so the lowered scenario is empty"); + } + + return sink.has_errors() ? Status::ValidationError : Status::Ok; +} + +} // namespace scena::dsl diff --git a/frontends/dsl/tests/CMakeLists.txt b/frontends/dsl/tests/CMakeLists.txt index 4d0410f..1d2689a 100644 --- a/frontends/dsl/tests/CMakeLists.txt +++ b/frontends/dsl/tests/CMakeLists.txt @@ -20,3 +20,4 @@ scn_add_dsl_test(dsl_expression_test) scn_add_dsl_test(dsl_constraint_test) scn_add_dsl_test(dsl_import_test) scn_add_dsl_test(dsl_stdlib_test) +scn_add_dsl_test(dsl_lowering_test) diff --git a/frontends/dsl/tests/dsl_expression_test.cpp b/frontends/dsl/tests/dsl_expression_test.cpp index d11d3ef..4ea89f3 100644 --- a/frontends/dsl/tests/dsl_expression_test.cpp +++ b/frontends/dsl/tests/dsl_expression_test.cpp @@ -30,6 +30,7 @@ #include "scena/diagnostic.h" #include "scena/dsl/expression.h" +#include "scena/dsl/load.h" #include "scena/dsl/parser.h" #include "scena/dsl/resolve.h" #include "scena/dsl/stdlib.h" @@ -44,6 +45,8 @@ using scena::Status; using scena::dsl::ExpressionContext; using scena::dsl::File; using scena::dsl::kInvalidType; +using scena::dsl::LoadOptions; +using scena::dsl::LoadResult; using scena::dsl::Program; using scena::dsl::TypeId; using scena::dsl::Value; @@ -589,6 +592,50 @@ TEST(DslExpressionTest, AFieldReadIsNotConstant) { value)); } +TEST(DslExpressionTest, AQualifiedEnumLiteralResolvesThroughTheUseList) { + // §7.3.3's `enum-name '!' member` names a type, and §7.7.4.2 says a type + // name resolves through the use list after the current namespace. Constant + // evaluation searched only the current namespace until p8-s1, which + // silently made every enum-valued `keep` written in a namespace that *uses* + // the enum's look like one that needs a solver. + DiagnosticSink sink; + LoadResult loaded; + Program program; + ASSERT_EQ(scena::dsl::check_source("namespace lights\n" + "enum colour: [red, amber, green]\n" + "export *\n" + "namespace demo use lights\n" + "struct signal:\n c: colour = colour!amber\n", + "", LoadOptions{}, loaded, program, sink), + Status::Ok) + << (sink.diagnostics().empty() ? std::string() : sink.diagnostics().front().message); + + const auto signal = program.types_by_name.find("demo::signal"); + ASSERT_NE(signal, program.types_by_name.end()); + const scena::dsl::FieldInfo* field = program.find_field(signal->second, "c"); + ASSERT_NE(field, nullptr); + ASSERT_NE(field->declaration, nullptr); + ASSERT_NE(field->declaration->default_value, nullptr); + + ExpressionContext context; + context.name_space = "demo"; + context.uses = {"lights"}; + Value value; + ASSERT_TRUE( + scena::dsl::evaluate_constant(program, *field->declaration->default_value, context, value)); + EXPECT_EQ(value.kind, Value::Kind::Enum); + EXPECT_EQ(value.enum_value, 1U); + EXPECT_EQ(program.types[value.type].name, "lights::colour"); + + // Without the use list there is nothing to find, which is the behaviour an + // explicitly qualified name must not depend on. + ExpressionContext bare; + bare.name_space = "demo"; + Value unused; + EXPECT_FALSE( + scena::dsl::evaluate_constant(program, *field->declaration->default_value, bare, unused)); +} + TEST(DslExpressionTest, TypingIsDeterministicAcrossRepeats) { Program program = host_program(" i: int\n f: float\n v: speed\n"); for (int repeat = 0; repeat < 3; ++repeat) { diff --git a/frontends/dsl/tests/dsl_lowering_test.cpp b/frontends/dsl/tests/dsl_lowering_test.cpp new file mode 100644 index 0000000..57ffbab --- /dev/null +++ b/frontends/dsl/tests/dsl_lowering_test.cpp @@ -0,0 +1,360 @@ +/* + * Copyright 2026 Robomous + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// +// Lowering a checked DSL program to the Scenario IR (p8-s1, #44, ADR-0030). +// +// The scenarios here are hand-authored from the specification's own shapes. +// What each test pins is the *mapping* — which DSL construct denotes which IR +// construct — not runtime behaviour, which is the same runtime the XML frontend +// already feeds. +// + +#include +#include +#include +#include + +#include + +#include "scena/diagnostic.h" +#include "scena/dsl/load.h" +#include "scena/dsl/lower.h" +#include "scena/dsl/types.h" +#include "scena/ir/entity.h" +#include "scena/ir/scenario.h" +#include "scena/status.h" + +namespace { + +using scena::DiagnosticSink; +using scena::Severity; +using scena::Status; +using scena::dsl::LoadOptions; +using scena::dsl::LoadResult; +using scena::dsl::LowerOptions; +using scena::dsl::Program; + +/// A checked program plus what lowering made of it, kept together because the +/// LoadResult owns the ASTs the Program points into. +struct Lowered { + LoadResult loaded; + Program program; + DiagnosticSink check_sink; + DiagnosticSink sink; + scena::ir::Scenario scenario; + Status check_status = Status::Ok; + Status status = Status::Ok; +}; + +/// Checks `source`, then lowers it. Both statuses are recorded rather than +/// asserted, so a test can pin either half. +void run(std::string_view source, Lowered& out, const std::string& entry_point = {}) { + out.check_status = scena::dsl::check_source(source, "", LoadOptions{}, out.loaded, + out.program, out.check_sink); + if (out.check_status != Status::Ok) { + return; + } + LowerOptions options; + options.entry_point = entry_point; + out.status = scena::dsl::lower(out.program, out.loaded, options, out.scenario, out.sink); +} + +/// The header every scenario below shares: the standard library, and a +/// namespace that uses it. +constexpr std::string_view kPrelude = "import osc.standard.all\n" + "namespace demo use std, stdtypes\n"; + +[[nodiscard]] const scena::ir::Entity* entity(const scena::ir::Scenario& scenario, + std::string_view id) { + for (const scena::ir::Entity& candidate : scenario.entities) { + if (candidate.id == id) { + return &candidate; + } + } + return nullptr; +} + +std::string first_message(const DiagnosticSink& sink) { + return sink.diagnostics().empty() ? std::string() : sink.diagnostics().front().message; +} + +// --- entry point (§7.7.2) --------------------------------------------------- + +TEST(DslLoweringTest, TheOnlyScenarioIsTheEntryPointWithoutBeingNamed) { + // §7.7.2 leaves entry-point selection to the implementation. One scenario + // in the file is the common case, and naming it again would add nothing. + Lowered result; + 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); +} + +TEST(DslLoweringTest, MoreThanOneScenarioMustBeChosenBetween) { + // Picking one silently would make the run depend on declaration order. + Lowered result; + run(std::string(kPrelude).append("actor top\n" + "scenario top.first:\n ego: vehicle\n" + "scenario top.second:\n ego: vehicle\n"), + result); + ASSERT_EQ(result.check_status, Status::Ok) << first_message(result.check_sink); + EXPECT_EQ(result.status, Status::SemanticError); + EXPECT_NE(first_message(result.sink).find("§7.7.2"), std::string::npos); + // The message lists what it could have run, so the fix is copy-pasteable. + EXPECT_NE(first_message(result.sink).find("demo::top.first"), std::string::npos); + EXPECT_NE(first_message(result.sink).find("demo::top.second"), std::string::npos); +} + +TEST(DslLoweringTest, AnEntryPointIsNamedQualifiedOrAsWritten) { + const std::string source = std::string(kPrelude).append("actor top\n" + "scenario top.first:\n a: vehicle\n" + "scenario top.second:\n" + " b: vehicle\n" + " c: vehicle\n"); + 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); + + // 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); +} + +TEST(DslLoweringTest, AnEntryPointThatIsNotThereIsReported) { + Lowered result; + run(std::string(kPrelude).append("scenario overtake:\n ego: vehicle\n"), result, "nowhere"); + EXPECT_EQ(result.status, Status::SemanticError); + EXPECT_NE(first_message(result.sink).find("'nowhere'"), std::string::npos); + EXPECT_NE(first_message(result.sink).find("demo::overtake"), std::string::npos); +} + +TEST(DslLoweringTest, TheEntryPointsAreListedInDeclarationOrder) { + // Declaration order, not name order: this is what the file *offers*, and a + // reader matches it against the file in front of them. + Lowered result; + run(std::string(kPrelude).append("actor top\n" + "scenario top.zulu:\n a: vehicle\n" + "scenario top.alpha:\n b: vehicle\n"), + result, "top.zulu"); + ASSERT_EQ(result.check_status, Status::Ok) << first_message(result.check_sink); + const std::vector names = scena::dsl::entry_points(result.program, result.loaded); + ASSERT_EQ(names.size(), 2U); + EXPECT_EQ(names[0], "demo::top.zulu"); + EXPECT_EQ(names[1], "demo::top.alpha"); +} + +// --- actors become entities (§8.7 → p2-s1 taxonomy) ------------------------- + +TEST(DslLoweringTest, EveryPhysicalObjectFieldBecomesAnEntity) { + Lowered result; + run(std::string(kPrelude).append("scenario mixed:\n" + " ego: vehicle\n" + " walker: person\n" + " cone: stationary_object\n" + " lap_count: int\n"), + 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]), + scena::ir::ObjectType::Vehicle); + EXPECT_EQ(scena::ir::object_type_of(result.scenario.entities[1]), + scena::ir::ObjectType::Pedestrian); + EXPECT_EQ(scena::ir::object_type_of(result.scenario.entities[2]), + scena::ir::ObjectType::MiscObject); +} + +TEST(DslLoweringTest, AParticipantWithNoTaxonomyCounterpartStaysUnclassified) { + // §8.7.10's `animal` is a sibling actor, not a pedestrian category. An + // entity with an identity and a control mode is all the runtime needs; a + // wrong classification would be worse than none. + 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, + scena::ir::ControlMode::EngineControlled); +} + +TEST(DslLoweringTest, ADerivedActorIsStillTheParticipantItInheritsFrom) { + Lowered result; + run(std::string(kPrelude).append("actor car inherits vehicle(vehicle_category == car)\n" + "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()), + scena::ir::ObjectType::Vehicle); +} + +// --- concrete values (§7.3.11) ---------------------------------------------- + +TEST(DslLoweringTest, AnEqualityKeepFixesTheGeometry) { + Lowered result; + run(std::string(kPrelude).append("scenario overtake:\n" + " ego: vehicle\n" + " keep(ego.bounding_box.length == 4.5m)\n" + " keep(ego.bounding_box.width == 2.0m)\n" + " 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"); + ASSERT_NE(ego, nullptr); + const std::optional box = scena::ir::bounding_box_of(*ego); + ASSERT_TRUE(box.has_value()); + EXPECT_DOUBLE_EQ(box->length, 4.5); + EXPECT_DOUBLE_EQ(box->width, 2.0); + EXPECT_DOUBLE_EQ(box->height, 1.5); +} + +TEST(DslLoweringTest, AKeepIsReadFromEitherSide) { + // `keep(4.5m == x)` says exactly what `keep(x == 4.5m)` says, and the + // standard prints both orders. + Lowered result; + run(std::string(kPrelude).append("scenario overtake:\n" + " ego: vehicle\n" + " 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"); + ASSERT_NE(ego, nullptr); + EXPECT_DOUBLE_EQ(scena::ir::bounding_box_of(*ego)->length, 4.5); +} + +TEST(DslLoweringTest, APhysicalValueArrivesInItsBaseUnit) { + // §7.3.4 folding happens during checking, so lowering never converts — and + // therefore never re-applies the standard's printed factors (ADR-0029). + // 450cm is 4.5m exactly; the kph factor is the case that would show a + // second conversion, and it is deliberately not one. + Lowered result; + run(std::string(kPrelude).append("scenario overtake:\n" + " ego: vehicle\n" + " 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); +} + +TEST(DslLoweringTest, AKeepFixesTheVehicleCategory) { + Lowered result; + run(std::string(kPrelude).append("scenario ride:\n" + " b: vehicle\n" + " keep(b.vehicle_category == vehicle_category!bus)\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 scena::ir::Entity* bus = entity(result.scenario, "b"); + ASSERT_NE(bus, nullptr); + ASSERT_TRUE(bus->object.has_value()); + EXPECT_EQ(std::get(*bus->object).category, scena::ir::VehicleCategory::Bus); +} + +TEST(DslLoweringTest, ConditionalInheritanceFixesTheCategoryToo) { + // §7.3.8.2's `inherits vehicle(vehicle_category == heavy_truck)` fixes the + // category on the type rather than in the scenario — the spelling §8.7's + // own examples use. + Lowered result; + run(std::string(kPrelude).append( + "actor lorry inherits vehicle(vehicle_category == heavy_truck)\n" + "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"); + ASSERT_NE(lorry, nullptr); + ASSERT_TRUE(lorry->object.has_value()); + EXPECT_EQ(std::get(*lorry->object).category, + scena::ir::VehicleCategory::HeavyTruck); +} + +TEST(DslLoweringTest, AnUnfixedCategoryKeepsTheIrDefault) { + // Nothing is guessed at: a category the scenario does not fix is the IR's + // own default, not an invention of the lowering. + 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); +} + +TEST(DslLoweringTest, PerformanceLimitsHaveNoDslSourceAndStayUnconstrained) { + // §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". + Lowered result; + 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")); + ASSERT_NE(performance, nullptr); + EXPECT_DOUBLE_EQ(performance->max_speed, 0.0); +} + +TEST(DslLoweringTest, AScenarioWithNoParticipantsSaysSo) { + Lowered result; + run(std::string(kPrelude).append("scenario empty:\n laps: int\n"), result); + // A warning, not an error: the file is well-formed, it simply has nothing + // to run. + 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()); +} + +// --- determinism ------------------------------------------------------------ + +TEST(DslLoweringTest, LoweringTheSameSourceTwiceGivesTheSameIr) { + // Load time is inside the bit-identity contract, and lowering is load time. + const std::string source = + std::string(kPrelude).append("scenario overtake:\n" + " ego: vehicle\n" + " walker: person\n" + " keep(ego.bounding_box.length == 4.5m)\n" + " keep(ego.vehicle_category == vehicle_category!van)\n"); + Lowered first; + Lowered second; + run(source, first); + 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]; + 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); + const std::optional right_box = scena::ir::bounding_box_of(right); + ASSERT_EQ(left_box.has_value(), right_box.has_value()); + if (left_box.has_value()) { + // Bit-identical, not merely close: this is the contract. + EXPECT_EQ(left_box->length, right_box->length); + EXPECT_EQ(left_box->width, right_box->width); + } + } +} + +} // namespace