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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions docs/architecture/ADR-0030-dsl-lowering-to-the-ir.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<!--
SPDX-FileCopyrightText: 2026 Robomous
SPDX-License-Identifier: Apache-2.0
-->

# 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(<field-path> == <constant>)`,
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.
18 changes: 17 additions & 1 deletion docs/roadmap/coverage/osc-dsl-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(<field-path> == <constant>)` 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 |
Expand Down Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions frontends/dsl/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
27 changes: 27 additions & 0 deletions frontends/dsl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
66 changes: 66 additions & 0 deletions frontends/dsl/include/scena/dsl/lower.h
Original file line number Diff line number Diff line change
@@ -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 <string>
#include <vector>

#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<std::string> 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
36 changes: 30 additions & 6 deletions frontends/dsl/src/expression.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<TypeId> enums = program_.enums_declaring(member_name);
if (enums.size() != 1) {
Expand Down
Loading
Loading