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
1 change: 1 addition & 0 deletions changes/unreleased/engine-state-advance.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- The in-browser engine (`sysml-engine`) honours `advance` on `ExecuteState` and `ExecuteAction` as `sysml-grpc` does, running a state machine or an action for a simulated duration, through one runtime implementation both share; the homepage hero gains a "Mission phases on the clock" scene that runs `MissionSequence::MissionPhases` on the engine for 400,000 s (or 800,000 s) and animates the trace it records, clock instants included.
101 changes: 89 additions & 12 deletions editors/vscode/src/landing/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ import { connectModule, moduleUrls } from "./connect";
import {
debugSteps,
journey,
runClock,
runJourney,
stateIdOf,
type DebugStep,
type JourneyRun,
type LandingModel,
Expand All @@ -32,8 +34,8 @@ import {
import { carried, obstacles, resettle } from "./carry";
import { download, exportFrame, exportSvg, rasterSize, rasterize } from "./export";
import { quantityText } from "./present";
import { SCENES, STACK_SCENE, chooseScene, type Scene, type SceneDiagram } from "./scene";
import { message, mountShell, type Mounted, type MountOptions } from "./shell";
import { SCENES, STACK_SCENE, chooseScene, type Scene, type SceneClock, type SceneDiagram } from "./scene";
import { message, mountShell, shortName, type Mounted, type MountOptions } from "./shell";
import { mountTable } from "./table";
import { wireTabs } from "./tabs";
import { ZOOM_STEP, pannedView, pinchedView, refittedView, zoomOf, zoomedView, type View } from "./zoom";
Expand Down Expand Up @@ -135,6 +137,10 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou
const queueEl = debugPanel.querySelector<HTMLElement>("[data-osml-debug-queue]")!;
const traceEl = debugPanel.querySelector<HTMLOListElement>("[data-osml-debug-trace]")!;
const journeyProgram = scene.program.kind === "journey" ? scene.program : undefined;
// A clocked run takes no events and makes no free choices: the controls for those are hidden.
for (const control of debugPanel.querySelectorAll<HTMLElement>("[data-osml-debug-events]")) {
control.hidden = journeyProgram === undefined;
}
debugBtn.setAttribute("aria-expanded", "false");
debugBtn.textContent = "Debug the run";
debugPanel.hidden = true;
Expand Down Expand Up @@ -172,6 +178,7 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou
placed.clear();
settle();
},
reprogram: () => forgetRun(),
syncControls: () => syncDebugControls(),
});
const { status, engine, runnable } = shell;
Expand All @@ -196,7 +203,7 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou
let token: SVGCircleElement | undefined;

const partOf = (id: string): LandingPart | undefined => [...model.parts.values()].find((part) => part.id === id);
const idOf = (feature: string): string | undefined => model.parts.get(feature)?.id;
const idOf = (state: string): string | undefined => stateIdOf(model, state);
// projects is the parts drawn at the top level, which are the ones a visitor can move.
const projects = (): LandingPart[] => [...model.parts.values()].filter((part) => part.owner === undefined);

Expand Down Expand Up @@ -375,8 +382,14 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou
}

function decorate(): void {
const stateIds = new Set([...(model.machine?.states.values() ?? [])].map((state) => state.id));
for (const group of content.querySelectorAll<SVGGElement>("g.opensysml-node")) {
const part = partOf(group.dataset.opensysmlId ?? "");
const id = group.dataset.opensysmlId ?? "";
if (stateIds.has(id)) {
group.classList.add("osml-state");
continue;
}
const part = partOf(id);
if (!part) {
continue;
}
Expand Down Expand Up @@ -464,7 +477,11 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou
if (cardFor) {
fillCard(cardFor);
}
// A debugger run belongs to the model it ran; a new model runs afresh.
forgetRun();
}

// forgetRun drops a debugger run that belongs to another model or program; an open panel runs afresh.
function forgetRun(): void {
if (debugPanel.hidden) {
run = undefined;
} else if (rerunning === undefined) {
Expand Down Expand Up @@ -1068,6 +1085,12 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou
if (!current) {
return;
}
const program = shell.program;
if (program.kind === "clock") {
await runClocked(program, current);
ran = true;
return;
}
const picked = seed === undefined || seedPicked;
if (picked) {
seed = randomSeed();
Expand Down Expand Up @@ -1109,9 +1132,53 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou
})();
});

const secondsText = (seconds: number): string => `${seconds.toLocaleString("en-US")} s`;

// runClocked performs the machine on the clock and replays the trace the engine recorded:
// each state lit as its entry record comes, the token on each transition, the status
// line reading the clock from the records.
async function runClocked(program: SceneClock, current: LandingModel): Promise<void> {
const started = performance.now();
const result = await runClock(await engine(), program, current);
const ms = Math.max(1, Math.round(performance.now() - started));
run = result;
runSeed = seed;
steps = debugSteps(result.trace);
fillTrace();
if (result.error !== undefined) {
throw new Error(result.error);
}
const machine = shortName(program.symbol);
const path = result.visited.map(stateLabel).join(" → ");
const ran = `ExecuteState ran ${machine} on ${shortName(program.performer)} for ${secondsText(program.advance)} in ${ms} ms: ${path}`;
status(`${ran}.`);
runBtn.textContent = "▶ Run it again";
for (let index = 0; index < steps.length && !signal.aborted; index += 1) {
const step = steps[index];
const clock = step.record.time === undefined ? "" : `t = ${quantityText(step.record.time)} · `;
status(`${clock}${step.record.text}`);
await showStep(index, !reduceMotion.matches);
await delay(reduceMotion.matches ? 250 : 400);
}
if (signal.aborted) {
return;
}
const last = result.visited[result.visited.length - 1];
const stopped = result.finalTime === undefined ? "" : ` The clock stopped at t = ${secondsText(result.finalTime)}`;
const final = last === undefined ? undefined : model.machine?.states.get(last);
const reached = steps.filter((step) => step.edge?.to === last && step.record.time !== undefined).pop();
const ending =
final?.kind === "final"
? `; the machine completed when a transition reached ${stateLabel(last)}${reached ? ` at t = ${quantityText(reached.record.time!)}` : ""}.`
: last === undefined
? "."
: ` with the machine still in ${stateLabel(last)}.`;
status(`${ran}.${stopped}${ending}`);
}

// ---- debugging the model ----
// Every change re-runs ModelJourney from the start on the engine with all the events
// sent so far; the panel then steps through that run's trace record by record.
// Every change re-runs the program from the start on the engine, a journey with all the
// events sent so far; the panel then steps through that run's trace record by record.

const journeyEvents: readonly string[] = journeyProgram?.events ?? [];
let sent: string[] = [...journeyEvents];
Expand All @@ -1134,10 +1201,11 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou

function recordText(step: DebugStep): string {
const { record } = step;
const at = record.time === undefined ? "" : `t = ${quantityText(record.time)} · `;
if (step.ignored) {
return `${record.text}: ignored, no transition out of ${stateLabel(step.state)} accepts it`;
return `${at}${record.text}: ignored, no transition out of ${stateLabel(step.state)} accepts it`;
}
return record.text;
return `${at}${record.text}`;
}

function fillTrace(): void {
Expand Down Expand Up @@ -1198,7 +1266,9 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou
const position = steps.length === 0 ? "no records" : `record ${cursor + 1} of ${steps.length}`;
const where = step === undefined ? "Not started" : `In ${stateLabel(step.state)}`;
const choice = step?.record.kind === "choice" ? `, chose ${step.record.taken ?? "?"}` : "";
nowEl.textContent = `${where}${choice} · ${position} · ${accepted} of ${sent.length} events taken`;
const clock = step?.record.time === undefined ? "" : ` · t = ${quantityText(step.record.time)}`;
const taken = journeyProgram === undefined ? "" : ` · ${accepted} of ${sent.length} events taken`;
nowEl.textContent = `${where}${choice}${clock} · ${position}${taken}`;
syncDebugControls();
if (!animated || liveEdge === undefined || reduceMotion.matches || !step?.edge) {
dropToken();
Expand Down Expand Up @@ -1229,15 +1299,22 @@ function mount(root: HTMLElement, scene: Scene, form: SceneDiagram, options: Mou
// This run uses the latest model, including one its own read adopted.
rerunStale = false;
const started = performance.now();
run = await runJourney(client, scene, current, sent, seed);
const program = shell.program;
const machine = shortName(program.kind === "rollup" ? scene.rootSymbol : program.symbol);
run = program.kind === "clock"
? await runClock(client, program, current)
: await runJourney(client, scene, current, sent, seed);
runSeed = seed;
steps = debugSteps(run.trace);
const ms = Math.max(1, Math.round(performance.now() - started));
const schedule = seed === undefined ? "the default schedule" : `seed ${seed}`;
const over = program.kind === "clock"
? `on ${shortName(program.performer)} for ${secondsText(program.advance)}`
: `on ${sent.length} events under ${schedule}`;
status(
run.error
? `ExecuteState failed after ${steps.length} trace records: ${run.error}`
: `ExecuteState ran ModelJourney on ${sent.length} events under ${schedule} in ${ms} ms: ${steps.length} trace records.`,
: `ExecuteState ran ${machine} ${over} in ${ms} ms: ${steps.length} trace records.`,
run.error !== undefined,
);
fillTrace();
Expand Down
98 changes: 97 additions & 1 deletion editors/vscode/src/landing/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,20 @@ import { ExecutionError, renderedViewFromJson } from "@openmbee/opensysml/browse
import type { RenderResult } from "../protocol";
import {
EngineInstance,
clockOf,
debugSteps,
landingModel,
journey,
readModel,
renderResult,
runClock,
runJourney,
stateIdOf,
TraceRecord,
journeyOf,
sceneTarget,
} from "./model";
import { STACK_SCENE } from "./scene";
import { MISSION_PHASES_SCENE, STACK_SCENE } from "./scene";

interface LandingFixture {
hash: string;
Expand All @@ -29,6 +32,8 @@ interface LandingFixture {
const fixture = JSON.parse(readFileSync("src/landing/stack.json", "utf8")) as LandingFixture;
const fixtureView = renderedViewFromJson(fixture.render);
const fixtureRender: RenderResult = renderResult(fixtureView);
const phasesFixture = JSON.parse(readFileSync("src/landing/phases.json", "utf8")) as LandingFixture;
const phasesRender: RenderResult = renderResult(renderedViewFromJson(phasesFixture.render));

const PROJECTS = ["flexo", "opensysml", "pilot"];
const COMPONENTS: Record<string, string[]> = {
Expand Down Expand Up @@ -109,8 +114,12 @@ function typedInstance(instance: EngineInstance): Instance {
}

function traceEvent(record: TraceRecord): DocumentEvent {
const time = record.time === undefined
? undefined
: { kind: "quantity", magnitude: { kind: "real", value: Number(record.time.magnitude) }, unit: record.time.unit };
return {
kind: record.kind,
time,
state: record.state ?? "",
fromState: record.from ?? "",
toState: record.to ?? "",
Expand All @@ -130,6 +139,7 @@ interface FakeAnswers {
diagnostics?: unknown[];
statesVisited?: string[];
trace?: TraceRecord[];
finalTime?: number;
executeError?: Error;
/** Features of the root the instantiation could not value, by the error it gives each. */
rootErrors?: Record<string, string>;
Expand Down Expand Up @@ -172,6 +182,7 @@ function fakeConnection(answers: FakeAnswers = {}): { connection: Connection; ca
return Promise.resolve({
statesVisited: answers.statesVisited ?? [],
trace: (answers.trace ?? []).map(traceEvent),
finalTime: answers.finalTime ?? 0,
});
},
};
Expand Down Expand Up @@ -315,6 +326,91 @@ test("runJourney returns the partial trace of a failed run and rethrows other er
await assert.rejects(runJourney(unavailable.connection, STACK_SCENE, model, ["Commit"]), /engine unavailable/);
});

test("landingModel lifts a state view's states out of the machine's box and keys them by name", () => {
const model = landingModel(sceneTarget(MISSION_PHASES_SCENE), phasesFixture.hash, phasesRender, phasesFixture.instances);
assert.ok(model.machine, "a state view gives a machine");
assert.equal(model.machine.symbol, "MissionSequence::MissionPhases");
assert.deepEqual(
[...model.machine.states.keys()],
["prelaunch", "translunarCoast", "lunarOrbit", "onSurface", "transEarthCoast", "recovered", "done"],
);
assert.equal(model.machine.states.get("prelaunch")?.initial, true);
assert.equal(model.machine.states.get("onSurface")?.path, "onSurface");
assert.equal(model.machine.states.get("onSurface")?.initial, false);
assert.equal(model.machine.states.get("done")?.kind, "final");
assert.equal(model.parts.size, 0, "a state is not a part");
assert.equal(model.render.nodes.length, 8, "six states, the start and the final");
assert.ok(model.render.nodes.every((node) => node.parent === undefined), "the states are drawn at the top level");
assert.equal(model.render.edges.length, 7);
assert.deepEqual(
model.render.edges.map((edge) => edge.label),
["", "after 9000 [s]", "after 270000 [s]", "after 100000 [s]", "after 78000 [s]", "after 245000 [s]", ""],
);
assert.equal(model.root.symbol, "MissionSequence::mission", "the root is the object the machine runs on");
assert.equal(model.root.feature, "mission");
assert.equal(stateIdOf(model, "onSurface"), model.machine.states.get("onSurface")?.id);
assert.equal(stateIdOf(model, "nowhere"), undefined);
const stack = landingModel(sceneTarget(STACK_SCENE), fixture.hash, fixtureRender, fixture.instances);
assert.equal(stack.machine, undefined);

// A state nested in a composite stays under it, keyed by its path and, being the only `landing`, by name.
const surface = model.machine.states.get("onSurface")!;
const nested: RenderResult = {
...phasesRender,
nodes: [
...phasesRender.nodes,
{ id: "n9", kind: "state", name: "landing", type: "", detail: "", parent: surface.id },
{ id: "n10", kind: "state", name: "prelaunch", type: "", detail: "", parent: surface.id },
],
edges: [...phasesRender.edges, { from: surface.id, to: "n9", kind: "transition", label: "" }],
};
const composite = landingModel(sceneTarget(MISSION_PHASES_SCENE), phasesFixture.hash, nested, phasesFixture.instances);
assert.equal(composite.render.nodes.find((node) => node.id === "n9")?.parent, surface.id, "a nested state keeps its parent");
assert.equal(composite.render.edges.filter((edge) => edge.to === "n9").length, 1, "the transition into it is kept");
assert.equal(composite.machine?.states.get("onSurface.landing")?.id, "n9");
assert.equal(stateIdOf(composite, "landing"), "n9");
assert.equal(composite.machine?.states.get("onSurface.prelaunch")?.id, "n10");
assert.equal(stateIdOf(composite, "prelaunch"), model.machine.states.get("prelaunch")?.id, "a repeated name stays with the machine's own state");
assert.equal(stateIdOf(stack, "flexo"), stack.parts.get("flexo")?.id, "a journey's states name parts");
assert.throws(
() => landingModel(sceneTarget(MISSION_PHASES_SCENE), phasesFixture.hash, { ...phasesRender, nodes: [] }, phasesFixture.instances),
/draws 0 machines/u,
);
});

test("runClock performs the machine on its object for the duration and keeps each record's instant", async () => {
const model = landingModel(sceneTarget(MISSION_PHASES_SCENE), phasesFixture.hash, phasesRender, phasesFixture.instances);
const trace: TraceRecord[] = [
{ kind: "entry", state: "prelaunch", time: { magnitude: "0", unit: "s" }, text: "enter: prelaunch" },
{
kind: "transition",
from: "prelaunch",
to: "translunarCoast",
event: "time",
time: { magnitude: "9000", unit: "s" },
text: "transition: prelaunch -> translunarCoast (event: time)",
},
];
const { connection, calls } = fakeConnection({ statesVisited: ["prelaunch", "translunarCoast"], finalTime: 400000, trace });
const program = clockOf(MISSION_PHASES_SCENE.program);
const run = await runClock(connection, program, model);
assert.deepEqual(calls[0], {
method: "ExecuteState",
params: [phasesFixture.hash, "MissionSequence::MissionPhases", { performer: "MissionSequence::mission", advance: 400000, trace: true }],
});
assert.deepEqual(run, { visited: ["prelaunch", "translunarCoast"], trace, finalTime: 400000 });
assert.throws(() => clockOf(STACK_SCENE.program), /runs no clock/u);

const failed = fakeConnection({
executeError: new ExecutionError("state machine execution failed: budget", "unspecified", [], trace.map(traceEvent)),
});
assert.deepEqual(await runClock(failed.connection, program, model), {
visited: [],
trace,
error: "state machine execution failed: budget",
});
});

test("debugSteps tracks active state, transitions, accepts, and ignored events", () => {
const trace: TraceRecord[] = [
{ kind: "entry", state: "flexo", text: "entry flexo" },
Expand Down
Loading
Loading