Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/common-estepper-store-from-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sourceacademy/common-e-stepper": patch
---

The `frames` and `heap` of a step need not repeat the frames and objects that became unreachable in earlier steps: a host works out the whole store from the step and the ones before it (py-slang#611)
5 changes: 5 additions & 0 deletions .changeset/estepper-store-from-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sourceacademy/web-e-stepper": patch
---

e-stepper: the store at a step is worked out from that step and the ones before it, so that an evaluator need not repeat unreachable frames and objects in every step (py-slang#611)
12 changes: 10 additions & 2 deletions src/common/e-stepper/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,17 @@ export interface EStepperLookup {
* store it refers to, in the state *before* the step's reduction, as in the stepper.
*/
export interface EStepperStep extends SerializedStepperStep {
/** All frames created so far, in creation order (the global frame first). */
/**
* The frames created so far that are reachable, in creation order (the global frame first), and
* those that become unreachable (`isGarbage`) in this step. What is unreachable stays so, so an
* evaluator need not repeat a frame in the steps after the one in which it becomes unreachable
* (all of them in every step would make the steps of a long run grow with the square of their
* number): a host works out the store at a step from that step and the ones before it — the
* latest version of each frame, those the step does not have being unreachable. An evaluator
* that does repeat them gives the same store.
*/
frames: EStepperFrame[];
/** All heap objects created so far, in creation order. */
/** The heap objects created so far that are reachable, and those that become unreachable in this step; as `frames`. */
heap: EStepperHeapObject[];
/** The frame the step's redex is evaluated in (the innermost enclosing `EnvBlock`'s frame). */
activeFrameId: string;
Expand Down
20 changes: 13 additions & 7 deletions src/web/e-stepper/src/EStepperView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { injectStepperStyles } from "../../stepper/src/styles";
import { frameColor } from "./colors";
import EnvDiagram from "./EnvDiagram";
import ProgramArrows, { ENV_ATTRIBUTE, REF_ATTRIBUTE } from "./ProgramArrows";
import { type Store, storeAt } from "./store";
import { injectEStepperStyles } from "./styles";

const DEFAULT_PROGRAM_HEIGHT = 240;
Expand Down Expand Up @@ -279,6 +280,11 @@ export default function EStepperView({ steps, profile, error, cseDiagram, layout
const hasRun = lastStep > 0;
const stepIndex = hasRun ? Math.min(stepValue, lastStep) - 1 : 0;
const step = hasRun ? steps[stepIndex] : undefined;
// (the frames and objects that became unreachable in earlier steps are in those steps)
const store = useMemo<Store>(
() => (hasRun ? storeAt(steps, stepIndex) : { frames: [], heap: [] }),
[hasRun, steps, stepIndex],
);
// The arrangement of the panes: side by side ("wide", the default) or one above the other
// ("vertical"), as the user chooses, whatever the tab's width.
const [vertical, setVertical] = useRemembered(layout, "vertical", false);
Expand Down Expand Up @@ -306,14 +312,14 @@ export default function EStepperView({ steps, profile, error, cseDiagram, layout

// Frame colours by id, for the program's environment brackets (the diagram uses the same order).
const frameColors = useMemo(
() => Object.fromEntries((step?.frames ?? []).map((f, i) => [f.id, frameColor(i)])),
[step],
() => Object.fromEntries(store.frames.map((f, i) => [f.id, frameColor(i)])),
[store],
);
const colorOf = (frameId: string) => frameColors[frameId] ?? frameColor(1);
const objectOf = useMemo(() => {
const objects = new Map<string, EStepperHeapObject>((step?.heap ?? []).map(o => [o.id, o]));
const objects = new Map<string, EStepperHeapObject>(store.heap.map(o => [o.id, o]));
return (id: string) => objects.get(id);
}, [step]);
}, [store]);

const nodeRenderers: NodeRenderers = {
EnvBlock: (node: StepperNode, renderChild) => {
Expand Down Expand Up @@ -532,7 +538,7 @@ export default function EStepperView({ steps, profile, error, cseDiagram, layout
text="Clear Unreachable Objects"
disabled={
clearUnreachable ||
!(step?.frames.some(f => f.isGarbage) || step?.heap.some(o => o.isGarbage))
!(store.frames.some(f => f.isGarbage) || store.heap.some(o => o.isGarbage))
}
onClick={() => setClearDead(true)}
/>
Expand Down Expand Up @@ -593,8 +599,8 @@ export default function EStepperView({ steps, profile, error, cseDiagram, layout
}) as React.ReactNode)
) : diagramSize.width > 0 && diagramSize.height > 0 ? (
<EnvDiagram
frames={step!.frames}
heap={step!.heap}
frames={store.frames}
heap={store.heap}
activeFrameId={step!.activeFrameId}
lookups={step!.lookups ?? []}
hovered={hovered}
Expand Down
25 changes: 25 additions & 0 deletions src/web/e-stepper/src/__tests__/EStepperView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,31 @@ describe("EStepperView", () => {
expect(clear.props.disabled).toBe(false);
});

test("frames that became unreachable in an earlier step are drawn, as unreachable", () => {
// The evaluator puts an unreachable frame in the step where it becomes so, and in no step after.
const later: EStepperStep = {
...fixture[2],
frames: fixture[2].frames.filter(f => !f.isGarbage),
heap: fixture[2].heap.filter(o => !o.isGarbage),
};
const view = render({ steps: [fixture[2], later], profile });
const diagram = () => view.root.find(n => n.props.onAnchors && n.props.frames);
const unreachable = (frames: { id: string; isGarbage: boolean }[]) =>
frames.filter(f => f.isGarbage).map(f => f.id);
expect(unreachable(diagram().props.frames)).toEqual(["E2"]);
act(() =>
view.root
.find(n => hasClass(n, "sa-e-stepper"))
.props.onKeyDown({ key: "e", preventDefault: () => {} }),
);
expect(later.frames.map(f => f.id)).not.toContain("E2");
expect(unreachable(diagram().props.frames)).toEqual(["E2"]);
const clear = view.root
.findAllByType(Button)
.find(b => b.props.text === "Clear Unreachable Objects")!;
expect(clear.props.disabled).toBe(false);
});

test("Clear Unreachable Objects clears them from the diagram until the step changes", () => {
const view = render({ steps: fixture, profile });
const diagram = () => view.root.find(n => n.props.onAnchors && n.props.frames);
Expand Down
93 changes: 93 additions & 0 deletions src/web/e-stepper/src/__tests__/store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type {
EStepperFrame,
EStepperHeapObject,
EStepperStep,
} from "@sourceacademy/common-e-stepper";
import { describe, expect, test } from "vitest";

import { storeAt } from "../store";

const frame = (id: string, isGarbage = false, value = "1"): EStepperFrame => ({
id,
name: id === "Global" ? "global" : "f",
parentId: id === "Global" ? null : "Global",
bindings: [{ name: "x", value: { kind: "primitive", display: value, label: "int" } }],
isGarbage,
});
const list = (id: string, isGarbage = false): EStepperHeapObject => ({
kind: "list",
id,
elements: [],
isGarbage,
});
const step = (frames: EStepperFrame[], heap: EStepperHeapObject[] = []): EStepperStep => ({
ast: { type: "Program", nodeId: "p", body: [] },
frames,
heap,
activeFrameId: "Global",
});
const ids = (items: { id: string; isGarbage: boolean }[]) =>
items.map(i => `${i.id}${i.isGarbage ? "!" : ""}`);

// E1 is live in step 0, becomes unreachable in step 1, E2 in step 2; the steps after the one in
// which an item becomes unreachable do not have it.
const steps = [
step([frame("Global"), frame("E1")], [list("#1")]),
step([frame("Global"), frame("E1", true), frame("E2")], [list("#1", true), list("#2")]),
step([frame("Global"), frame("E2", true), frame("E3")], [list("#2", true)]),
step([frame("Global"), frame("E3")], []),
];

describe("storeAt", () => {
test("the frames and objects of the earlier steps that the step does not have are unreachable", () => {
expect(ids(storeAt(steps, 0).frames)).toEqual(["Global", "E1"]);
expect(ids(storeAt(steps, 1).frames)).toEqual(["Global", "E1!", "E2"]);
expect(ids(storeAt(steps, 2).frames)).toEqual(["Global", "E1!", "E2!", "E3"]);
expect(ids(storeAt(steps, 3).frames)).toEqual(["Global", "E1!", "E2!", "E3"]);
expect(ids(storeAt(steps, 3).heap)).toEqual(["#1!", "#2!"]);
});

test("moving back and forth gives the same store", () => {
const at = (i: number) => JSON.stringify(storeAt(steps, i));
const forward = [0, 1, 2, 3].map(at);
expect([3, 2, 1, 0].map(at).reverse()).toEqual(forward);
expect([2, 0, 3, 1].map(i => at(i))).toEqual([2, 0, 3, 1].map(i => forward[i]));
});

test("the latest version of a frame is used", () => {
const changed = [
step([frame("Global"), frame("E1", false, "1")]),
step([frame("Global"), frame("E1", true, "2")]),
step([frame("Global")]),
];
const e1 = storeAt(changed, 2).frames.find(f => f.id === "E1")!;
expect(e1.isGarbage).toBe(true);
expect(e1.bindings[0].value).toEqual({ kind: "primitive", display: "2", label: "int" });
});

test("an evaluator that repeats the unreachable ones in every step gives the same store", () => {
const repeated = [
step([frame("Global"), frame("E1")], [list("#1")]),
step([frame("Global"), frame("E1", true), frame("E2")], [list("#1", true), list("#2")]),
step(
[frame("Global"), frame("E1", true), frame("E2", true), frame("E3")],
[list("#1", true), list("#2", true)],
),
step(
[frame("Global"), frame("E1", true), frame("E2", true), frame("E3")],
[list("#1", true), list("#2", true)],
),
];
for (let i = 0; i < steps.length; i++) {
expect(storeAt(repeated, i)).toEqual(storeAt(steps, i));
}
});

test("frames are in creation order, the global frame first", () => {
const shuffled = [
step([frame("E10"), frame("E2"), frame("Global")], [list("#10"), list("#9")]),
];
expect(ids(storeAt(shuffled, 0).frames)).toEqual(["Global", "E2", "E10"]);
expect(ids(storeAt(shuffled, 0).heap)).toEqual(["#9", "#10"]);
});
});
61 changes: 61 additions & 0 deletions src/web/e-stepper/src/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type {
EStepperFrame,
EStepperHeapObject,
EStepperStep,
} from "@sourceacademy/common-e-stepper";

export interface Store {
frames: EStepperFrame[];
heap: EStepperHeapObject[];
}

/**
* What the steps up to one have made, kept while the user moves over a run: a slider moving on by
* one step adds the frames of that step only.
*/
interface Seen {
next: number;
frames: Map<string, EStepperFrame>;
heap: Map<string, EStepperHeapObject>;
}
const seen = new WeakMap<EStepperStep[], Seen>();

const order = (id: string): number => (id === "Global" ? -1 : Number(id.replace(/\D/g, "")));

/**
* The whole store at `steps[index]`. What is unreachable stays so, so the evaluator puts a frame or
* an object that has become unreachable in the step where it does, and in none of the steps after
* it (all of them in every step would make the steps of a long run grow with the square of their
* number: py-slang#611). The store at a step is the latest version of every frame and object in
* that step and the ones before it; those the step itself does not have are unreachable. (An
* evaluator that repeats them in every step gives the same store.) Frames are in creation order,
* the global frame first, and objects in the order of their labels.
*/
export function storeAt(steps: EStepperStep[], index: number): Store {
let state = seen.get(steps);
if (!state || state.next > index + 1) {
state = { next: 0, frames: new Map(), heap: new Map() };
seen.set(steps, state);
}
for (; state.next <= index; state.next++) {
for (const f of steps[state.next].frames) state.frames.set(f.id, f);
for (const o of steps[state.next].heap) state.heap.set(o.id, o);
}
const step = steps[index];
const here = {
frames: new Set(step.frames.map(f => f.id)),
heap: new Set(step.heap.map(o => o.id)),
};
const unreachable = <T extends { id: string; isGarbage: boolean }>(
item: T,
ids: Set<string>,
): T => (ids.has(item.id) || item.isGarbage ? item : { ...item, isGarbage: true });
return {
frames: [...state.frames.values()]
.map(f => unreachable(f, here.frames))
.sort((a, b) => order(a.id) - order(b.id)),
heap: [...state.heap.values()]
.map(o => unreachable(o, here.heap))
.sort((a, b) => order(a.id) - order(b.id)),
};
}
Loading