From b55d4f86efba18ebd46947d53e78428cce900bce Mon Sep 17 00:00:00 2001 From: henz Date: Mon, 19 Oct 2026 19:30:28 +0800 Subject: [PATCH] e-stepper: the store at a step is worked out from that step and the ones before it An evaluator can then put a frame or an object that has become unreachable in the step where it does, and in none after it, instead of repeating all of them in every step (py-slang#611: the steps of a loop that makes a frame in each iteration grew with the square of their number). An evaluator that does repeat them gives the same store. Co-Authored-By: Claude Sonnet 5.5 --- .../common-estepper-store-from-history.md | 5 + .changeset/estepper-store-from-history.md | 5 + src/common/e-stepper/src/index.ts | 12 ++- src/web/e-stepper/src/EStepperView.tsx | 20 ++-- .../src/__tests__/EStepperView.test.tsx | 25 +++++ src/web/e-stepper/src/__tests__/store.test.ts | 93 +++++++++++++++++++ src/web/e-stepper/src/store.ts | 61 ++++++++++++ 7 files changed, 212 insertions(+), 9 deletions(-) create mode 100644 .changeset/common-estepper-store-from-history.md create mode 100644 .changeset/estepper-store-from-history.md create mode 100644 src/web/e-stepper/src/__tests__/store.test.ts create mode 100644 src/web/e-stepper/src/store.ts diff --git a/.changeset/common-estepper-store-from-history.md b/.changeset/common-estepper-store-from-history.md new file mode 100644 index 0000000..3f4c4c9 --- /dev/null +++ b/.changeset/common-estepper-store-from-history.md @@ -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) diff --git a/.changeset/estepper-store-from-history.md b/.changeset/estepper-store-from-history.md new file mode 100644 index 0000000..637b211 --- /dev/null +++ b/.changeset/estepper-store-from-history.md @@ -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) diff --git a/src/common/e-stepper/src/index.ts b/src/common/e-stepper/src/index.ts index 46f389e..4cdca37 100644 --- a/src/common/e-stepper/src/index.ts +++ b/src/common/e-stepper/src/index.ts @@ -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; diff --git a/src/web/e-stepper/src/EStepperView.tsx b/src/web/e-stepper/src/EStepperView.tsx index fd0b228..711e039 100644 --- a/src/web/e-stepper/src/EStepperView.tsx +++ b/src/web/e-stepper/src/EStepperView.tsx @@ -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; @@ -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( + () => (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); @@ -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((step?.heap ?? []).map(o => [o.id, o])); + const objects = new Map(store.heap.map(o => [o.id, o])); return (id: string) => objects.get(id); - }, [step]); + }, [store]); const nodeRenderers: NodeRenderers = { EnvBlock: (node: StepperNode, renderChild) => { @@ -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)} /> @@ -593,8 +599,8 @@ export default function EStepperView({ steps, profile, error, cseDiagram, layout }) as React.ReactNode) ) : diagramSize.width > 0 && diagramSize.height > 0 ? ( { 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); diff --git a/src/web/e-stepper/src/__tests__/store.test.ts b/src/web/e-stepper/src/__tests__/store.test.ts new file mode 100644 index 0000000..925481b --- /dev/null +++ b/src/web/e-stepper/src/__tests__/store.test.ts @@ -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"]); + }); +}); diff --git a/src/web/e-stepper/src/store.ts b/src/web/e-stepper/src/store.ts new file mode 100644 index 0000000..1ac7e9b --- /dev/null +++ b/src/web/e-stepper/src/store.ts @@ -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; + heap: Map; +} +const seen = new WeakMap(); + +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 = ( + item: T, + ids: Set, + ): 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)), + }; +}