Skip to content

feat(dsl): composition operators, durations and one_of (p8-s2) - #109

Merged
JArmandoAnaya merged 2 commits into
mainfrom
feat/p8-s2-composition
Aug 2, 2026
Merged

feat(dsl): composition operators, durations and one_of (p8-s2)#109
JArmandoAnaya merged 2 commits into
mainfrom
feat/p8-s2-composition

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

A composition contributes exactly one thing to the IR: the start trigger on
a phase's Event. No new runtime concept, no new IR node, no scheduler change —
which is what keeps the two frontends sharing one runtime rather than two that
resemble each other. ADR-0031 records it.

A concrete duration is arithmetic, done at load time

The storyboard starts at t = 0 and every duration that lowers is a constant, so
a phase's absolute start time is the sum of the durations before it. Lowering
tracks, for each point in the do directive, what load time knows:

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

A trigger ANDs whichever of the two it has. A phase starting at t = 0 with
nothing to wait for gets no trigger, because t >= 0 is a tautology and
§7.6.1.1 already says a trigger-less element starts with its parent.

Two obvious alternatives were probed against the engine and both fail, which
is why the arithmetic route is the one taken:

  • A stop trigger only cuts a phase short. An Act completes as soon as its
    groups do, so a step-shaped action ends the phase immediately whatever the
    stop trigger says — the duration would be silently ignored for exactly the
    scenarios easiest to write.
  • A start trigger delayed on the predecessor's runningState never fires.
    The predecessor is no longer running by the time the delayed lookup happens.

A parallel join is an AND the trigger model already has

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

§7.6.2.1.4's default overlap: start is exactly what absent start triggers give.
The other seven overlap kinds and start_to_start/end_to_end are reported.

one_of picks by label, defaulting to the first

§7.6.2.1.3's latitude is real and an executor has to pick. Picking at random
would put a hidden input in the run — and the engine has no seed machinery at
all
, so such a choice could not even be made reproducible. The alternative is
therefore an input: LowerOptions::alternative, fed by scena-run --select.
Alternatives not chosen never reach the IR, so the engine stays free of a
concept the XML side has no counterpart for.

wait elapsed(d) lowers to nothing but the clock

§7.6.2.4.2 introduces a phase in which nothing is specified, so nothing is what
it produces — no group, no event, no action. It advances the offset the next
phase starts from, and the clock is already running.

A range duration (§7.6.2.4) constrains accepted traces rather than fixing a
time; choosing from it needs a solver (ADR-0004), so it is reported. §7.6.2.5's
emit, wait @event, on and until are reported too: those events are
abstract control objects with no runtime carrier in v0.0.1.

A pre-existing lexer defect this sprint surfaced

§7.2.2.6.7 spells the range constructor '[' expression '..' expression ']',
and §7.2.1.5.2's float-literal ::= digit* '.' digit+ makes the leading digits
optional — so [2..4] is a race between the .. operator and the float .4,
and the float was winning. The lexer emitted no .. token at all, and the
parser looked for ..., a spelling the standard does not have.

Because the two agreed with each other, every range in the test suite was
written the wrong way and nothing noticed. duration: [10s..30s] is what made
it visible. Fixed in both, with the affected test expectations corrected to the
standard's spelling and the race pinned in dsl_lexer_test.cpp.

Tests and docs

  • dsl_lowering_test.cpp 37 → 45: absolute start times from durations,
    completion chaining without them, a nested parallel inside a serial, an
    AND-shaped join, wait elapsed, one_of default and named, an alternative
    that is not there, a range duration, and a non-default overlap.
  • scena_run_test.cpp +2: --select choosing an alternative end to end, and a
    serial scenario whose phases take over at the second it named.
  • dsl_lexer_test.cpp +2: the ..-versus-.4 race, in both plain and physical
    literals.
  • ADR-0031; coverage-matrix rows for §7.6.2.1–§7.6.2.5 and §7.2.2.6.7; the
    scena-run user guide and the DSL frontend README.

1378 gtest (+12), 194 pytest.

Closes #45

A composition contributes exactly one thing to the IR: the start trigger on a
phase's Event. No new runtime concept, no new IR node, no scheduler change —
which is what keeps the two frontends sharing one runtime rather than two that
resemble each other (ADR-0031).

A concrete `duration` is arithmetic done at load time. The storyboard starts at
t = 0 and every duration that lowers is a constant, so a phase's absolute start
time is the sum of the ones before it. Lowering tracks, for each point in the
`do` directive, an absolute time when one is known and the set of groups that
must have completed otherwise; a trigger ANDs whichever of the two it has. Two
obvious alternatives were probed against the engine and both fail: a stop
trigger only cuts a phase short, and a start trigger delayed on the
predecessor's runningState never fires at all.

A parallel join is one ConditionGroup, which is already an AND, so members that
end at a known time and members that end when their actions do mix for free.
The default `overlap: start` is what absent start triggers already give; the
other seven kinds are reported.

`one_of` picks by label from LowerOptions::alternative, fed by
`scena-run --select`, defaulting to the first alternative in declaration order.
The engine has no seed machinery, and a seed the scenario does not state would
be exactly the hidden input determinism rules out.

`wait elapsed(d)` lowers to nothing but the offset the next phase starts from.
A range duration, the non-default overlaps, and §7.6.2.5's event directives are
reported rather than approximated.

Fixes a pre-existing lexer defect the sprint surfaced: §7.2.2.6.7 spells the
range constructor `[a '..' b]` while §7.2.1.5.2 makes a float's leading digits
optional, so `[2..4]` is a race the operator must win. It did not — the lexer
emitted no `..` at all and the parser looked for `...`. The two agreed with each
other, so every range in the suite was written the wrong way and nothing
noticed until `duration: [10s..30s]`.

1378 gtest (+12), 194 pytest, ADR-0031.
@JArmandoAnaya
JArmandoAnaya merged commit e3eb4c5 into main Aug 2, 2026
12 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the feat/p8-s2-composition branch August 2, 2026 14:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[p8-s2] Composition operators: serial, parallel, one_of

1 participant