aboutsummaryrefslogtreecommitdiffstats
path: root/prototypes/workbench/results
diff options
context:
space:
mode:
authoreunhhu <[email protected]>2026-09-24 17:14:54 +0100
committereunhhu <[email protected]>2026-09-24 17:14:54 +0100
commitb1ce1927022d374198441afafeb27199fc8a052a (patch)
tree6021f175f4c67ed03b44240eaa2f90d846f12f6f /prototypes/workbench/results
parentd34f9426d5317a1948f12f229439946728bc342a (diff)
downloadpure-b1ce1927022d374198441afafeb27199fc8a052a.tar.gz
pure-b1ce1927022d374198441afafeb27199fc8a052a.zip
Establish native micro runtime checkpoint
Diffstat (limited to 'prototypes/workbench/results')
-rw-r--r--prototypes/workbench/results/2026-09-22-evaluation/browser.json231
-rw-r--r--prototypes/workbench/results/2026-09-22-evaluation/comparison.json309
-rw-r--r--prototypes/workbench/results/2026-09-22-evaluation/sources/baseline.test.mjs78
-rw-r--r--prototypes/workbench/results/2026-09-22-evaluation/sources/cli.mjs69
-rw-r--r--prototypes/workbench/results/2026-09-22-evaluation/sources/cli.test.mjs83
-rw-r--r--prototypes/workbench/results/2026-09-22-evaluation/sources/compiler.test.mjs240
-rw-r--r--prototypes/workbench/results/2026-09-22-evaluation/sources/core.zig813
-rw-r--r--prototypes/workbench/results/2026-09-22-evaluation/sources/evaluation-check.mjs76
-rw-r--r--prototypes/workbench/results/2026-09-22-evaluation/sources/evaluation.test.mjs133
-rw-r--r--prototypes/workbench/results/2026-09-22-evaluation/sources/evaluation.zig156
-rw-r--r--prototypes/workbench/results/2026-09-22-evaluation/sources/examples/charge.pure34
-rw-r--r--prototypes/workbench/results/2026-09-22-evaluation/sources/examples/counter.pure8
-rw-r--r--prototypes/workbench/results/2026-09-22-evaluation/sources/examples/toggle.pure17
-rw-r--r--prototypes/workbench/results/2026-09-22-evaluation/sources/manage.py87
-rw-r--r--prototypes/workbench/results/2026-09-22-evaluation/sources/manifest.json21
-rw-r--r--prototypes/workbench/results/2026-09-22-evaluation/sources/qa-evaluation.js86
-rw-r--r--prototypes/workbench/results/2026-09-22-evaluation/sources/runtime.test.mjs197
-rw-r--r--prototypes/workbench/results/2026-09-22-evaluation/sources/web/app.js427
-rw-r--r--prototypes/workbench/results/2026-09-22-evaluation/sources/web/baseline.mjs68
-rw-r--r--prototypes/workbench/results/2026-09-22-evaluation/sources/web/compiler.mjs303
-rwxr-xr-xprototypes/workbench/results/2026-09-22-evaluation/sources/web/core.wasmbin0 -> 53125 bytes
-rw-r--r--prototypes/workbench/results/2026-09-22-evaluation/sources/web/runtime.mjs197
-rw-r--r--prototypes/workbench/results/2026-09-22-evaluation/verification.json68
-rw-r--r--prototypes/workbench/results/2026-09-22-linux-aarch64/authored.pngbin0 -> 176600 bytes
-rw-r--r--prototypes/workbench/results/2026-09-22-linux-aarch64/blank.pngbin0 -> 148863 bytes
-rw-r--r--prototypes/workbench/results/2026-09-22-linux-aarch64/browser-checks.json159
-rw-r--r--prototypes/workbench/results/2026-09-22-linux-aarch64/comparison.json298
-rw-r--r--prototypes/workbench/results/2026-09-22-linux-aarch64/exported-draft.json5
-rw-r--r--prototypes/workbench/results/2026-09-22-linux-aarch64/mobile.pngbin0 -> 151662 bytes
-rw-r--r--prototypes/workbench/results/2026-09-22-linux-aarch64/verification.json239
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/README.md21
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/authoring-browser.json128
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/build.json23
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/layout-browser.json97
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/manifest.json21
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/pure-layout-desktop.pngbin0 -> 206894 bytes
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/pure-layout-mobile.pngbin0 -> 162877 bytes
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/pure-layout-saved.json96
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/qa-browser.js228
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/sources/CONTRACT.md371
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/sources/cli.mjs93
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/sources/cli.test.mjs121
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/sources/core.zig1136
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/sources/evaluation.zig164
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/sources/examples/layout.pure92
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/sources/project.test.mjs79
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/sources/qa-layout.js103
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/sources/web/app.js403
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/sources/web/core.wasmbin0 -> 56203 bytes
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/sources/web/index.html94
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/sources/web/project.mjs45
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/sources/web/runtime.mjs324
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/sources/web/scene-input.mjs83
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/sources/workspace.test.mjs89
-rw-r--r--prototypes/workbench/results/2026-09-23-layout/verification.json56
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/README.md36
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/application-build.json40
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/auxiliary-sources.json28
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/browser-build.json24
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/browser-tests.log95
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/default-linkage.json12
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/native.json159
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sanitized.json159
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/evaluate.c53
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/flow.h25
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/program.h44
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/result.h8
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/runtime.c273
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/runtime.h88
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/store-port.c44
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/store-port.h6
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/store.c258
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/store.h44
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/baseline.test.mjs78
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/cli.mjs127
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/cli.test.mjs121
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/compare.mjs231
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/compiler.test.mjs240
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/core.zig999
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/engine.zig601
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/evaluation-check.mjs76
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/evaluation.test.mjs133
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/evaluation.zig164
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/charge.pure34
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/counter.pure8
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/direct.pure47
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/document.pure6
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/layout.pure92
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/routed-layout.pure70
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/shared-layout.pure75
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/toggle.pure17
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/manage.py127
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native.zig127
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/app.c208
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/app.h27
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/check.mjs129
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/main.c135
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/plan.c38
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/plan.h36
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/prepare.mjs35
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/scalar.h46
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/test.c120
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/pointer.test.mjs118
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/presentation.test.mjs132
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/project.test.mjs79
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/remote.mjs89
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/routing.test.mjs114
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/rules.test.mjs94
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/runtime.test.mjs197
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/value-input.test.mjs50
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/baseline.mjs68
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/compiler.mjs496
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/core.wasmbin0 -> 61065 bytes
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/project.mjs47
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/runtime.mjs350
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/workspace-check.mjs62
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/workspace.test.mjs91
-rw-r--r--prototypes/workbench/results/2026-09-23-native-instances/verification.json39
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/README.md51
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/authoring-result.log13
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/authoring-run.log237
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/authoring.json130
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/before/compiler.mjs447
-rwxr-xr-xprototypes/workbench/results/2026-09-23-pointer-routing/before/core.wasmbin0 -> 58440 bytes
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/before/core.zig1230
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/before/runtime.mjs328
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/browser-initial-failure.json32
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/browser-result.log12
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/browser-run.log172
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/browser.json113
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/build.json23
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/legacy-browser.json106
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/legacy-result.log12
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/legacy-run.log146
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/manifest.json47
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/node-tests.log91
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/pure-routed-layout-desktop.pngbin0 -> 211960 bytes
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/pure-routed-layout-mobile.pngbin0 -> 165704 bytes
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/pure-routed-layout-saved.json108
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/qa-authoring.js228
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/qa-legacy-pointer.js138
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/representation.json73
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/sources/CONTRACT.md496
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/sources/cli.mjs98
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/sources/core.zig1398
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/sources/evaluation.zig164
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/sources/examples/routed-layout.pure70
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/sources/presentation.test.mjs132
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/sources/qa-routed-input.js164
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/sources/routing.test.mjs114
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/app.js403
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/compiler.mjs484
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/core.wasmbin0 -> 60774 bytes
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/index.html94
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/output.mjs103
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/runtime.mjs339
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/scene-input.mjs84
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/sources/workspace.test.mjs91
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/touch-evidence.json246
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/touch-evidence.log12
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/touch-reproduction.js21
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/touch-reproduction.log28
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/touch-run.log29
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer-routing/verification.json13
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/README.md23
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/after/CONTRACT.md308
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/after/cli.mjs74
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/after/cli.test.mjs97
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/after/core.zig1027
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/after/evaluation.zig164
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/after/examples/direct.pure48
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/after/pointer.test.mjs110
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/after/qa-pointer.js120
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/after/web/app.js378
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/after/web/compiler.mjs326
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/after/web/core.wasmbin0 -> 54511 bytes
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/after/web/index.html93
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/after/web/output.mjs95
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/after/web/runtime.mjs298
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/after/web/scene-input.mjs68
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/after/web/style.css10
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/authoring-browser.json128
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/before/cli.mjs69
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/before/core.zig942
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/before/evaluation.zig164
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/before/manifest.json12
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/before/web/app.js368
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/before/web/compiler.mjs303
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/before/web/core.wasmbin0 -> 53952 bytes
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/before/web/index.html93
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/before/web/output.mjs92
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/before/web/runtime.mjs267
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/before/web/style.css8
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/build.json23
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/manifest.json23
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/pointer-browser.json32
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/pure-pointer-desktop.pngbin0 -> 197508 bytes
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/pure-pointer-mobile.pngbin0 -> 159460 bytes
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/qa-browser.js228
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/qa-pointer.js120
-rw-r--r--prototypes/workbench/results/2026-09-23-pointer/verification.json50
-rw-r--r--prototypes/workbench/results/2026-09-23-presentation/before/core.zig942
-rw-r--r--prototypes/workbench/results/2026-09-23-presentation/before/evaluation.zig164
-rw-r--r--prototypes/workbench/results/2026-09-23-presentation/before/manifest.json10
-rw-r--r--prototypes/workbench/results/2026-09-23-presentation/before/web/app.js427
-rw-r--r--prototypes/workbench/results/2026-09-23-presentation/before/web/compiler.mjs303
-rw-r--r--prototypes/workbench/results/2026-09-23-presentation/before/web/core.wasmbin0 -> 53952 bytes
-rw-r--r--prototypes/workbench/results/2026-09-23-presentation/before/web/index.html93
-rw-r--r--prototypes/workbench/results/2026-09-23-presentation/before/web/runtime.mjs205
-rw-r--r--prototypes/workbench/results/2026-09-23-presentation/before/web/style.css8
-rw-r--r--prototypes/workbench/results/2026-09-23-presentation/browser-authoring.json131
-rw-r--r--prototypes/workbench/results/2026-09-23-presentation/browser-limit.json242
-rw-r--r--prototypes/workbench/results/2026-09-23-presentation/browser.json1536
-rw-r--r--prototypes/workbench/results/2026-09-23-presentation/verification.json58
-rw-r--r--prototypes/workbench/results/2026-09-23-refresh/before/core.zig942
-rw-r--r--prototypes/workbench/results/2026-09-23-refresh/before/evaluation.zig164
-rw-r--r--prototypes/workbench/results/2026-09-23-refresh/before/manifest.json14
-rw-r--r--prototypes/workbench/results/2026-09-23-refresh/before/presentation.test.mjs123
-rw-r--r--prototypes/workbench/results/2026-09-23-refresh/before/qa-presentation.js141
-rw-r--r--prototypes/workbench/results/2026-09-23-refresh/before/qa-presentation.py54
-rw-r--r--prototypes/workbench/results/2026-09-23-refresh/before/web/app.js349
-rw-r--r--prototypes/workbench/results/2026-09-23-refresh/before/web/compiler.mjs303
-rw-r--r--prototypes/workbench/results/2026-09-23-refresh/before/web/core.wasmbin0 -> 53952 bytes
-rw-r--r--prototypes/workbench/results/2026-09-23-refresh/before/web/index.html93
-rw-r--r--prototypes/workbench/results/2026-09-23-refresh/before/web/output.mjs92
-rw-r--r--prototypes/workbench/results/2026-09-23-refresh/before/web/runtime.mjs267
-rw-r--r--prototypes/workbench/results/2026-09-23-refresh/before/web/style.css8
-rw-r--r--prototypes/workbench/results/2026-09-23-refresh/browser-authoring.json131
-rw-r--r--prototypes/workbench/results/2026-09-23-refresh/browser-limit.json242
-rw-r--r--prototypes/workbench/results/2026-09-23-refresh/browser.json1487
-rw-r--r--prototypes/workbench/results/2026-09-23-refresh/profile-before.json34
-rw-r--r--prototypes/workbench/results/2026-09-23-refresh/verification.json41
-rw-r--r--prototypes/workbench/results/2026-09-23-release/README.md19
-rw-r--r--prototypes/workbench/results/2026-09-23-release/after-browser.json62
-rw-r--r--prototypes/workbench/results/2026-09-23-release/after/cli.test.mjs97
-rw-r--r--prototypes/workbench/results/2026-09-23-release/after/examples/direct.pure47
-rw-r--r--prototypes/workbench/results/2026-09-23-release/after/pointer.test.mjs118
-rw-r--r--prototypes/workbench/results/2026-09-23-release/after/qa-pointer.js122
-rw-r--r--prototypes/workbench/results/2026-09-23-release/after/qa-release.js59
-rw-r--r--prototypes/workbench/results/2026-09-23-release/after/web/examples/direct.pure47
-rw-r--r--prototypes/workbench/results/2026-09-23-release/after/web/scene-input.mjs83
-rw-r--r--prototypes/workbench/results/2026-09-23-release/before-browser.json21
-rw-r--r--prototypes/workbench/results/2026-09-23-release/manifest.json6
-rw-r--r--prototypes/workbench/results/2026-09-23-release/pointer-browser.json33
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/README.md24
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/authoring-browser.json128
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/before/core.zig1136
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/before/examples/layout.pure92
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/before/web/compiler.mjs326
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/before/web/core.wasmbin0 -> 56203 bytes
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/before/web/runtime.mjs324
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/build.json23
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/manifest.json21
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/pure-shared-layout-desktop.pngbin0 -> 228262 bytes
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/pure-shared-layout-mobile.pngbin0 -> 167873 bytes
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/pure-shared-layout-saved.json108
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/qa-browser.js228
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/representation.json403
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/shared-browser.json106
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/sources/CONTRACT.md437
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/sources/core.zig1230
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/sources/evaluation.zig164
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/sources/examples/shared-layout.pure75
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/sources/qa-shared-rules.js137
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/sources/rules.test.mjs94
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/sources/runtime.test.mjs197
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/sources/web/app.js403
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/sources/web/compiler.mjs447
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/sources/web/core.wasmbin0 -> 58440 bytes
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/sources/web/index.html94
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/sources/web/project.mjs47
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/sources/web/runtime.mjs328
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/sources/web/scene-input.mjs83
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/sources/workspace.test.mjs89
-rw-r--r--prototypes/workbench/results/2026-09-23-shared-rules/verification.json62
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/README.md47
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/authoring-result.log13
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/authoring-run.log237
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/authoring.json130
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/before/compiler.mjs484
-rwxr-xr-xprototypes/workbench/results/2026-09-23-web-layout/before/core.wasmbin0 -> 60774 bytes
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/before/core.zig1398
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/before/runtime.mjs339
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/browser-result.log12
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/browser-run.log111
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/browser.json89
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/build.json23
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/compiler-check.log21
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/guards-result.log9
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/guards-run.log38
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/guards.json10
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/manifest.json45
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/native-build.log39
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/node-tests.log95
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/pure-document-desktop.pngbin0 -> 184975 bytes
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/pure-document-mobile.pngbin0 -> 106808 bytes
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/pure-document-saved.txt3
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/qa-authoring.js229
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/qa-value-control.js12
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/sources/CONTRACT.md568
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/sources/cli.mjs103
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/sources/core.zig1443
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/sources/evaluation.zig164
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/sources/examples/document.pure6
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/sources/qa-document-guards.js30
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/sources/qa-document.js103
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/sources/value-input.test.mjs50
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/sources/web/app.js411
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/sources/web/compiler.mjs496
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/sources/web/core.wasmbin0 -> 61072 bytes
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/sources/web/document.css3
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/sources/web/document.html32
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/sources/web/document.mjs132
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/sources/web/index.html95
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/sources/web/runtime.mjs350
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/sources/web/split-input.mjs57
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/value-control.json7
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/value-control.log18
-rw-r--r--prototypes/workbench/results/2026-09-23-web-layout/verification.json14
-rw-r--r--prototypes/workbench/results/2026-09-23-workspace/browser.json240
-rw-r--r--prototypes/workbench/results/2026-09-23-workspace/comparison.json142
-rw-r--r--prototypes/workbench/results/2026-09-23-workspace/verification.json75
-rw-r--r--prototypes/workbench/results/2026-09-24-compact-runtime/app-memory-three-way-final.json160
-rw-r--r--prototypes/workbench/results/2026-09-24-fixed-capacity/fixed-cli-build.json48
-rw-r--r--prototypes/workbench/results/2026-09-24-fixed-capacity/fixed-parity-final.json209
-rw-r--r--prototypes/workbench/results/2026-09-24-fixed-capacity/fixed-peer-build.json52
-rw-r--r--prototypes/workbench/results/2026-09-24-fixed-capacity/general-parity-final.json209
-rw-r--r--prototypes/workbench/results/2026-09-24-fixed-capacity/instance-memory.json65
-rw-r--r--prototypes/workbench/results/2026-09-24-fixed-capacity/peer-check.json1
-rw-r--r--prototypes/workbench/results/2026-09-24-fixed-capacity/size.txt6
-rw-r--r--prototypes/workbench/results/2026-09-24-fixed-capacity/stripped.txt3
-rw-r--r--prototypes/workbench/results/2026-09-24-idle-work-binding/app-memory.json108
-rw-r--r--prototypes/workbench/results/2026-09-24-idle-work-binding/native-sanitized.json1
-rw-r--r--prototypes/workbench/results/2026-09-24-idle-work-binding/native-unit.json1
-rw-r--r--prototypes/workbench/results/2026-09-24-idle-work-binding/scalar-peer.json1
-rw-r--r--prototypes/workbench/results/2026-09-24-idle-work-binding/stack-baseline.su9
-rw-r--r--prototypes/workbench/results/2026-09-24-idle-work-binding/stack-elastic.su9
-rw-r--r--prototypes/workbench/results/2026-09-24-native-desktop/README.md38
-rw-r--r--prototypes/workbench/results/2026-09-24-native-desktop/authored-after.pure72
-rw-r--r--prototypes/workbench/results/2026-09-24-native-desktop/capture-x11.c43
-rw-r--r--prototypes/workbench/results/2026-09-24-native-desktop/core-budget.json53
-rw-r--r--prototypes/workbench/results/2026-09-24-native-desktop/drag-and-save.pngbin0 -> 134122 bytes
-rw-r--r--prototypes/workbench/results/2026-09-24-native-desktop/manifest.json141
-rw-r--r--prototypes/workbench/results/2026-09-24-native-desktop/native-parity-sanitized.json159
-rw-r--r--prototypes/workbench/results/2026-09-24-native-desktop/native-parity.json159
-rw-r--r--prototypes/workbench/results/2026-09-24-native-desktop/numeric-input.pngbin0 -> 45958 bytes
-rw-r--r--prototypes/workbench/results/2026-09-24-native-desktop/recovered-edit.pngbin0 -> 139858 bytes
-rw-r--r--prototypes/workbench/results/2026-09-24-native-desktop/rejected-edit.pngbin0 -> 142502 bytes
-rw-r--r--prototypes/workbench/results/2026-09-24-native-desktop/reopened-state.pngbin0 -> 134717 bytes
-rw-r--r--prototypes/workbench/results/2026-09-24-native-desktop/resized-input.pngbin0 -> 108314 bytes
-rw-r--r--prototypes/workbench/results/2026-09-24-native-desktop/resized.pngbin0 -> 108735 bytes
-rw-r--r--prototypes/workbench/results/2026-09-24-native-desktop/sources/manage.py137
-rw-r--r--prototypes/workbench/results/2026-09-24-native-desktop/sources/native/app.c239
-rw-r--r--prototypes/workbench/results/2026-09-24-native-desktop/sources/native/app.h32
-rw-r--r--prototypes/workbench/results/2026-09-24-native-desktop/sources/native/check.mjs132
-rw-r--r--prototypes/workbench/results/2026-09-24-native-desktop/sources/native/desktop.c503
-rw-r--r--prototypes/workbench/results/2026-09-24-native-desktop/sources/native/test.c151
-rw-r--r--prototypes/workbench/results/2026-09-24-secure-peer/build.json48
-rw-r--r--prototypes/workbench/results/2026-09-24-secure-peer/peer-check.json1
-rw-r--r--prototypes/workbench/results/2026-09-24-secure-peer/size.txt4
-rw-r--r--prototypes/workbench/results/2026-09-24-shared-program/cli-build.json49
-rw-r--r--prototypes/workbench/results/2026-09-24-shared-program/dependencies.txt6
-rw-r--r--prototypes/workbench/results/2026-09-24-shared-program/desktop-build.json84
-rw-r--r--prototypes/workbench/results/2026-09-24-shared-program/fixed-parity.json205
-rw-r--r--prototypes/workbench/results/2026-09-24-shared-program/general-parity.json209
-rw-r--r--prototypes/workbench/results/2026-09-24-shared-program/instance-memory.json71
-rw-r--r--prototypes/workbench/results/2026-09-24-shared-program/peer-build.json53
-rw-r--r--prototypes/workbench/results/2026-09-24-shared-program/peer-check.json1
-rw-r--r--prototypes/workbench/results/2026-09-24-shared-program/size.txt5
-rw-r--r--prototypes/workbench/results/2026-09-24-shared-program/stripped.txt2
-rw-r--r--prototypes/workbench/results/2026-09-24-shared-program/symbols.txt4
371 files changed, 57849 insertions, 0 deletions
diff --git a/prototypes/workbench/results/2026-09-22-evaluation/browser.json b/prototypes/workbench/results/2026-09-22-evaluation/browser.json
new file mode 100644
index 0000000..c07fdbe
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-evaluation/browser.json
@@ -0,0 +1,231 @@
+{
+ "utc": "2026-09-22T14:04:57.852Z",
+ "checks": [
+ "authored browser input commits fractional state",
+ "bounded evaluation renders the exact authored expression",
+ "code replacement preserves state and changes binding result",
+ "browser Wasm reuses completed bindings",
+ "discarding records preserves the browser snapshot",
+ "browser API publishes no partial scene after budget exhaustion",
+ "raising the budget recovers the browser snapshot",
+ "configured UI keeps committed state while withholding incomplete display",
+ "UI distinguishes display limit from event rollback",
+ "normal configuration reloads to a clean blank work",
+ "no uncaught browser errors"
+ ],
+ "api": {
+ "before": {
+ "states": {
+ "x": 3
+ },
+ "views": [
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "",
+ "x": 40,
+ "y": 60,
+ "value": 10
+ }
+ ],
+ "tick": 0,
+ "revision": 1,
+ "displayError": null,
+ "lastError": 0,
+ "errorMessage": "",
+ "errorLine": null,
+ "lastOps": 0,
+ "successfulEvents": 0,
+ "held": [],
+ "lastEvent": null,
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 20272,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 252,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ }
+ },
+ "warm": {
+ "cacheEntries": 32,
+ "historyEntries": 8,
+ "workLimit": 2048,
+ "work": 6,
+ "instructions": 0,
+ "cacheHits": 3,
+ "reservedBytes": 1200
+ },
+ "forgotten": {
+ "states": {
+ "x": 3
+ },
+ "views": [
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "",
+ "x": 40,
+ "y": 60,
+ "value": 10
+ }
+ ],
+ "tick": 0,
+ "revision": 1,
+ "displayError": null,
+ "lastError": 0,
+ "errorMessage": "",
+ "errorLine": null,
+ "lastOps": 0,
+ "successfulEvents": 0,
+ "held": [],
+ "lastEvent": null,
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 20272,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 252,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ }
+ },
+ "limited": {
+ "states": {
+ "x": 3
+ },
+ "views": [],
+ "tick": 0,
+ "revision": 1,
+ "displayError": {
+ "code": 101,
+ "line": 3,
+ "message": "표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요."
+ },
+ "lastError": 101,
+ "errorMessage": "표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.",
+ "errorLine": 3,
+ "lastOps": 0,
+ "successfulEvents": 0,
+ "held": [],
+ "lastEvent": null,
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 20272,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 252,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ }
+ },
+ "limitStats": {
+ "cacheEntries": 32,
+ "historyEntries": 8,
+ "workLimit": 1,
+ "work": 1,
+ "instructions": 0,
+ "cacheHits": 0,
+ "reservedBytes": 1200
+ },
+ "recovered": {
+ "states": {
+ "x": 3
+ },
+ "views": [
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "",
+ "x": 40,
+ "y": 60,
+ "value": 10
+ }
+ ],
+ "tick": 0,
+ "revision": 1,
+ "displayError": null,
+ "lastError": 0,
+ "errorMessage": "",
+ "errorLine": null,
+ "lastOps": 0,
+ "successfulEvents": 0,
+ "held": [],
+ "lastEvent": null,
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 20272,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 252,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ }
+ }
+ },
+ "limitedUi": {
+ "states": {
+ "x": 3.25
+ },
+ "views": [],
+ "tick": 0,
+ "revision": 1,
+ "displayError": {
+ "code": 101,
+ "line": 3,
+ "message": "표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요."
+ },
+ "lastError": 101,
+ "errorMessage": "표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.",
+ "errorLine": 3,
+ "lastOps": 0,
+ "successfulEvents": 2,
+ "held": [],
+ "lastEvent": {
+ "name": "next",
+ "phase": "up",
+ "source": "pointer:1",
+ "status": 0,
+ "line": null,
+ "ops": 0
+ },
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 20272,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 252,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ },
+ "ready": true,
+ "source": "state x = 3\ninput next = KeyN\ntext label(40, 60, (x + 1) * (x + 2) / 2)\non next.down { x = x + 0.25 }\n",
+ "appliedSource": "state x = 3\ninput next = KeyN\ntext label(40, 60, (x + 1) * (x + 2) / 2)\non next.down { x = x + 0.25 }\n",
+ "dirty": false,
+ "running": false,
+ "droppedTicks": 0,
+ "diagnostic": "코드를 적용했습니다. 같은 이름의 상태는 유지됩니다."
+ },
+ "message": "표시 오류 · 3행 · 표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요. — 이번 장면을 표시하지 않았습니다. 상태 변경은 유지됩니다.",
+ "errors": [],
+ "uiLimitSetup": "Test-only module response overrides default workLimit to 1; production files are unchanged.",
+ "screenshot": "output/playwright/pure-evaluation-limit.png",
+ "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36",
+ "qaSourceSha256": "262d97f0aa409eb68bdfe8aa3e82ad037e6b10681f8b5025e59d148ee49564e1"
+}
diff --git a/prototypes/workbench/results/2026-09-22-evaluation/comparison.json b/prototypes/workbench/results/2026-09-22-evaluation/comparison.json
new file mode 100644
index 0000000..bf4a5cf
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-evaluation/comparison.json
@@ -0,0 +1,309 @@
+{
+ "purpose": "Choose whether to enable binding reuse by default",
+ "utc": "2026-09-22T13:56:13.104Z",
+ "host": {
+ "platform": "linux",
+ "release": "6.18.39+rpt-rpi-2712",
+ "arch": "arm64",
+ "cpu": "Cortex-A76",
+ "node": "v24.20.0",
+ "affinityControlled": false
+ },
+ "sourceSha256": {
+ "web/core.wasm": "b79eecefba5e7da0f5dd945fa1df196ee627803243a430517f5c49c3a5e54ce1",
+ "core.zig": "dfb9fd7f3862806c2bd88ce1c668b795d9df196e1f2e50cceff1b5c4809273df",
+ "evaluation.zig": "2192e563385105f130d373b2e8724ea234c51e2909c7dbfc38e72acc3b70b2ed",
+ "web/runtime.mjs": "d9981282612cb06b50d24bf8919e829dce202f72b59dab607c8684bc0f297939",
+ "web/compiler.mjs": "a34f3beb069c811a9bd5be73dde9341907ba77756066030b8b95fc6d66a5cc02",
+ "evaluation-check.mjs": "11492759b5eff878fb8e43cad69422f9c34da6a85b5d8808478cc1845d737d4f",
+ "evaluation.test.mjs": "d3026ab5c6bffce56c0bde3155d7dddd6fa442a5688656ee710626c762258ff1",
+ "examples/counter.pure": "53cb3a93ff0f26a3d208a5876bfff9ecb1c39e9a01acc101566727733ba166a8",
+ "examples/toggle.pure": "3de97fab9d316c17ba6a0c82a16cccd3baafc6816b879bf9b0a174210c8a1915",
+ "examples/charge.pure": "8d69d9fd51d44ab754d560eb7ff5102ea8a34540753cd7170b3bd800e4294f56"
+ },
+ "cases": [
+ {
+ "name": "counter",
+ "checkedSnapshots": 720,
+ "traceSha256": "5b6a375413a890f53aad8542121341bd4d460d8f7614bde17b359692b7723f04",
+ "totals": {
+ "plain": {
+ "work": 14400,
+ "instructions": 9360,
+ "cacheHits": 0
+ },
+ "cached": {
+ "work": 10251,
+ "instructions": 93,
+ "cacheHits": 5013
+ }
+ },
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 20272,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 284,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ },
+ "evaluation": {
+ "cacheEntries": 32,
+ "historyEntries": 8,
+ "workLimit": 2048,
+ "work": 14,
+ "instructions": 0,
+ "cacheHits": 7,
+ "reservedBytes": 1200
+ },
+ "samples": [
+ {
+ "trial": 0,
+ "key": "plain",
+ "elapsedMs": 4.274374999999964
+ },
+ {
+ "trial": 0,
+ "key": "cached",
+ "elapsedMs": 3.667929999999842
+ },
+ {
+ "trial": 1,
+ "key": "cached",
+ "elapsedMs": 2.7680219999999736
+ },
+ {
+ "trial": 1,
+ "key": "plain",
+ "elapsedMs": 2.9006329999999707
+ },
+ {
+ "trial": 2,
+ "key": "plain",
+ "elapsedMs": 3.10952199999997
+ },
+ {
+ "trial": 2,
+ "key": "cached",
+ "elapsedMs": 2.6932429999999385
+ },
+ {
+ "trial": 3,
+ "key": "cached",
+ "elapsedMs": 2.495447000000013
+ },
+ {
+ "trial": 3,
+ "key": "plain",
+ "elapsedMs": 3.31631799999991
+ },
+ {
+ "trial": 4,
+ "key": "plain",
+ "elapsedMs": 3.110725000000002
+ },
+ {
+ "trial": 4,
+ "key": "cached",
+ "elapsedMs": 2.476169999999911
+ }
+ ],
+ "medianMs": {
+ "plain": 3.110725000000002,
+ "cached": 2.6932429999999385
+ },
+ "cachedOverPlain": 0.8657927010584145
+ },
+ {
+ "name": "toggle",
+ "checkedSnapshots": 720,
+ "traceSha256": "5b6a375413a890f53aad8542121341bd4d460d8f7614bde17b359692b7723f04",
+ "totals": {
+ "plain": {
+ "work": 17280,
+ "instructions": 10080,
+ "cacheHits": 0
+ },
+ "cached": {
+ "work": 14692,
+ "instructions": 54,
+ "cacheHits": 7170
+ }
+ },
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 20272,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 456,
+ "activeStateBytes": 16,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ },
+ "evaluation": {
+ "cacheEntries": 32,
+ "historyEntries": 8,
+ "workLimit": 2048,
+ "work": 20,
+ "instructions": 0,
+ "cacheHits": 10,
+ "reservedBytes": 1200
+ },
+ "samples": [
+ {
+ "trial": 0,
+ "key": "plain",
+ "elapsedMs": 3.189095999999836
+ },
+ {
+ "trial": 0,
+ "key": "cached",
+ "elapsedMs": 2.4810959999999795
+ },
+ {
+ "trial": 1,
+ "key": "cached",
+ "elapsedMs": 2.673020999999835
+ },
+ {
+ "trial": 1,
+ "key": "plain",
+ "elapsedMs": 3.064632999999958
+ },
+ {
+ "trial": 2,
+ "key": "plain",
+ "elapsedMs": 3.1030960000000505
+ },
+ {
+ "trial": 2,
+ "key": "cached",
+ "elapsedMs": 2.6690209999999297
+ },
+ {
+ "trial": 3,
+ "key": "cached",
+ "elapsedMs": 2.628114000000096
+ },
+ {
+ "trial": 3,
+ "key": "plain",
+ "elapsedMs": 2.907020999999986
+ },
+ {
+ "trial": 4,
+ "key": "plain",
+ "elapsedMs": 3.06628099999989
+ },
+ {
+ "trial": 4,
+ "key": "cached",
+ "elapsedMs": 2.6631320000001324
+ }
+ ],
+ "medianMs": {
+ "plain": 3.06628099999989,
+ "cached": 2.6631320000001324
+ },
+ "cachedOverPlain": 0.8685218347569019
+ },
+ {
+ "name": "charge",
+ "checkedSnapshots": 720,
+ "traceSha256": "5b6a375413a890f53aad8542121341bd4d460d8f7614bde17b359692b7723f04",
+ "totals": {
+ "plain": {
+ "work": 21600,
+ "instructions": 11520,
+ "cacheHits": 0
+ },
+ "cached": {
+ "work": 26909,
+ "instructions": 566,
+ "cacheHits": 9676
+ }
+ },
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 20272,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 1092,
+ "activeStateBytes": 32,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ },
+ "evaluation": {
+ "cacheEntries": 32,
+ "historyEntries": 8,
+ "workLimit": 2048,
+ "work": 28,
+ "instructions": 0,
+ "cacheHits": 14,
+ "reservedBytes": 1200
+ },
+ "samples": [
+ {
+ "trial": 0,
+ "key": "plain",
+ "elapsedMs": 4.6710420000001704
+ },
+ {
+ "trial": 0,
+ "key": "cached",
+ "elapsedMs": 4.135338000000047
+ },
+ {
+ "trial": 1,
+ "key": "cached",
+ "elapsedMs": 4.094503999999915
+ },
+ {
+ "trial": 1,
+ "key": "plain",
+ "elapsedMs": 4.58693100000005
+ },
+ {
+ "trial": 2,
+ "key": "plain",
+ "elapsedMs": 4.541652999999997
+ },
+ {
+ "trial": 2,
+ "key": "cached",
+ "elapsedMs": 4.269986000000017
+ },
+ {
+ "trial": 3,
+ "key": "cached",
+ "elapsedMs": 4.093262999999979
+ },
+ {
+ "trial": 3,
+ "key": "plain",
+ "elapsedMs": 4.703355999999985
+ },
+ {
+ "trial": 4,
+ "key": "plain",
+ "elapsedMs": 4.554652999999917
+ },
+ {
+ "trial": 4,
+ "key": "cached",
+ "elapsedMs": 4.1000779999999395
+ }
+ ],
+ "medianMs": {
+ "plain": 4.58693100000005,
+ "cached": 4.1000779999999395
+ },
+ "cachedOverPlain": 0.8938608407233279
+ }
+ ],
+ "timingScope": "Event dispatch plus snapshot construction. Excludes loading, compilation, DOM, equality checks and statistics reads. Five batch samples are not latency percentiles."
+}
diff --git a/prototypes/workbench/results/2026-09-22-evaluation/sources/baseline.test.mjs b/prototypes/workbench/results/2026-09-22-evaluation/sources/baseline.test.mjs
new file mode 100644
index 0000000..c9ef2de
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-evaluation/sources/baseline.test.mjs
@@ -0,0 +1,78 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createChargeModel } from './web/baseline.mjs';
+
+test('reference begins with the authored states and matching scene', () => {
+ const model = createChargeModel();
+ assert.deepEqual(model.snapshot().states, { holding: 0, charge: 0, y: 240, velocity: 0 });
+ assert.deepEqual(model.snapshot().held, []);
+ assert.equal(model.snapshot().tick, 0);
+ assert.equal(model.snapshot().views.find(view => view.id === 'floor').y, 261);
+ assert.equal(model.snapshot().views.find(view => view.id === 'actor').radius, 20);
+});
+
+test('press, hold, release, and frame order produce exact expected values', () => {
+ const model = createChargeModel();
+ model.down(); model.frame(15);
+ let expectedCharge = 0;
+ for (let frame = 0; frame < 15; frame++) expectedCharge = Math.min(Math.max(expectedCharge + 1 / 60, 0), 1);
+ assert.deepEqual(model.snapshot().states, { holding: 1, charge: expectedCharge, y: 240, velocity: 0 });
+ model.up();
+ const initialVelocity = -(160 + expectedCharge * 300);
+ assert.deepEqual(model.snapshot().states, { holding: 0, charge: 0, y: 240, velocity: initialVelocity });
+ model.frame();
+ const nextVelocity = initialVelocity + 600 * (1 / 60);
+ assert.deepEqual(model.snapshot().states, {
+ holding: 0, charge: 0, y: 240 + nextVelocity * (1 / 60), velocity: nextVelocity,
+ });
+});
+
+test('charge clamps at one and explicit floor restores rest', () => {
+ const model = createChargeModel();
+ model.down(); model.frame(120);
+ assert.equal(model.snapshot().states.charge, 1);
+ model.up(); assert.equal(model.snapshot().states.velocity, -460);
+ model.frame(); assert.equal(model.snapshot().states.y, 232.5);
+ model.frame(120);
+ assert.deepEqual(model.snapshot().states, { holding: 0, charge: 0, y: 240, velocity: 0 });
+});
+
+test('repeat and concurrent physical sources produce only logical edges', () => {
+ const model = createChargeModel();
+ model.down('jump', 'keyboard'); model.frame(6);
+ const charge = model.snapshot().states.charge;
+ model.down('jump', 'keyboard'); model.down('jump', 'pointer');
+ assert.equal(model.snapshot().states.charge, charge);
+ model.up('jump', 'keyboard');
+ assert.equal(model.snapshot().states.holding, 1);
+ model.up('jump', 'unknown'); assert.equal(model.snapshot().states.holding, 1);
+ model.up('jump', 'pointer'); assert.equal(model.snapshot().states.velocity, -(160 + charge * 300));
+ const released = model.snapshot(); model.up('jump', 'pointer');
+ assert.deepEqual(model.snapshot(), released);
+});
+
+test('airborne presses cannot charge or cause another jump', () => {
+ const model = createChargeModel(); model.down(); model.up(); model.frame();
+ const airborne = model.snapshot().states;
+ model.down(); model.up();
+ assert.deepEqual(model.snapshot().states, airborne);
+});
+
+test('releaseAll emits release and reset clears latches without a jump', () => {
+ const model = createChargeModel();
+ model.down('jump', 'keyboard'); model.down('jump', 'pointer'); model.frame(10);
+ model.releaseAll(); assert.equal(model.snapshot().states.holding, 0);
+ assert.ok(model.snapshot().states.velocity < 0); assert.deepEqual(model.snapshot().held, []);
+ model.reset(); assert.deepEqual(model.snapshot().states, { holding: 0, charge: 0, y: 240, velocity: 0 });
+ assert.equal(model.snapshot().tick, 0);
+ model.down(); model.reset(); model.up(); assert.equal(model.snapshot().states.velocity, 0);
+});
+
+test('unknown inputs and invalid frame counts reject without mutation', () => {
+ const model = createChargeModel(), initial = model.snapshot();
+ for (const method of ['down', 'up']) assert.throws(() => model[method]('other'), /Unknown input/);
+ for (const count of [-1, .5, 601, Infinity, NaN, '1']) assert.throws(() => model.frame(count), /integer/);
+ model.frame(0); assert.deepEqual(model.snapshot(), initial);
+ const snapshot = model.snapshot(); snapshot.states.y = -999;
+ assert.equal(model.snapshot().states.y, 240);
+});
diff --git a/prototypes/workbench/results/2026-09-22-evaluation/sources/cli.mjs b/prototypes/workbench/results/2026-09-22-evaluation/sources/cli.mjs
new file mode 100644
index 0000000..aafcd51
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-evaluation/sources/cli.mjs
@@ -0,0 +1,69 @@
+#!/usr/bin/env node
+import { readFile, writeFile } from 'node:fs/promises';
+import { createInterface } from 'node:readline';
+import { createRuntime } from './web/runtime.mjs';
+
+const help = `Pure authoring workbench · 같은 정의와 Zig 실행기
+ load PATH .pure 파일을 적용 (같은 이름의 상태 보존)
+ down NAME 정의한 입력 누르기
+ up NAME 정의한 입력 놓기
+ frame [1..600] 시간 진행, 1프레임 = 1/60초
+ show 상태와 도형 출력
+ limits C H W 표시 캐시(0..32)·변경 기록(0..8)·작업량(0..4294967295) 설정
+ forget [PART] 표시 기록 삭제: all, values, history (상태는 유지)
+ costs 마지막 표시 계산의 작업량·명령 수·캐시 적중 출력
+ reset 현재 정의의 초기 상태로
+ save PATH 적용한 정의를 저장
+ help / quit
+브라우저와 별도 인스턴스입니다. 임의 JavaScript는 실행하지 않습니다.`;
+
+async function main() {
+ const args = process.argv.slice(2);
+ if (args.includes('--help')) { console.log(help); return; }
+ if (args.length > 1) throw new Error('사용법: node cli.mjs [project.pure]');
+ const runtime = await createRuntime(await readFile(new URL('./web/core.wasm', import.meta.url)));
+ runtime.apply(args[0] ? await readFile(args[0], 'utf8') : '');
+ const terminal = createInterface({ input: process.stdin, output: process.stdout, terminal: Boolean(process.stdin.isTTY) });
+ if (process.stdin.isTTY) console.log(help);
+ terminal.setPrompt('pure> ');
+ if (process.stdin.isTTY) terminal.prompt();
+ for await (const line of terminal) {
+ try {
+ const match = /^(\S+)(?:\s+(.+))?$/.exec(line.trim());
+ const name = match?.[1] || 'show';
+ const arg = match?.[2];
+ if (name === 'quit') { terminal.close(); break; }
+ if (name === 'help') console.log(help);
+ else if (name === 'costs' && !arg) console.log(JSON.stringify({ evaluation: runtime.evaluationStats() }));
+ else {
+ if (name === 'load' && arg) runtime.apply(await readFile(arg, 'utf8'));
+ else if (name === 'save' && arg) await writeFile(arg, runtime.exportProject().source, { flag: 'wx' });
+ else if (name === 'down' && arg) runtime.down(arg, 'terminal');
+ else if (name === 'up' && arg) runtime.up(arg, 'terminal');
+ else if (name === 'frame') {
+ if (arg !== undefined && !/^\d+$/.test(arg)) throw new Error('frame 1..600 정수를 입력하세요.');
+ const count = arg === undefined ? 1 : Number(arg);
+ if (count < 1 || count > 600) throw new Error('frame 1..600 정수를 입력하세요.');
+ runtime.frame(count);
+ } else if (name === 'reset' && !arg) runtime.reset();
+ else if (name === 'limits' && arg) {
+ const parts = arg.split(/\s+/);
+ if (parts.length !== 3 || parts.some(part => !/^\d+$/.test(part)))
+ throw new Error('limits C H W 정수 세 개를 입력하세요.');
+ const [cacheEntries, historyEntries, workLimit] = parts.map(Number);
+ runtime.configureEvaluation({ cacheEntries, historyEntries, workLimit });
+ } else if (name === 'forget') runtime.forgetEvaluation(arg || 'all');
+ else if (name !== 'show' || arg) throw new Error('명령 형식을 확인하세요. help로 안내를 볼 수 있습니다.');
+ const result = runtime.snapshot();
+ console.log(JSON.stringify(result));
+ if (result.lastError && !process.stdin.isTTY) process.exitCode = 1;
+ }
+ } catch (error) {
+ console.error(`${error.line ? `${error.line}:${error.column || 1} ` : ''}${error.message}`);
+ if (!process.stdin.isTTY) process.exitCode = 1;
+ }
+ if (process.stdin.isTTY) terminal.prompt();
+ }
+}
+
+main().catch((error) => { console.error(error.message); process.exitCode = 1; });
diff --git a/prototypes/workbench/results/2026-09-22-evaluation/sources/cli.test.mjs b/prototypes/workbench/results/2026-09-22-evaluation/sources/cli.test.mjs
new file mode 100644
index 0000000..ef5c535
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-evaluation/sources/cli.test.mjs
@@ -0,0 +1,83 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createRuntime } from './web/runtime.mjs';
+
+const root = fileURLToPath(new URL('.', import.meta.url));
+function run(input, file) {
+ const result = spawnSync(process.execPath, [join(root, 'cli.mjs'), ...(file ? [file] : [])], {
+ input, encoding: 'utf8', cwd: root,
+ });
+ if (result.error) throw result.error;
+ return { ...result, snapshots: result.stdout.split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line)) };
+}
+
+test('terminal starts empty and produces the same charge trace as the shared runtime', async () => {
+ assert.deepEqual(run('show\nquit\n').snapshots[0].states, {});
+ const file = join(root, 'examples/charge.pure');
+ const result = run('down jump\nframe 30\nup jump\nframe 1\nshow\nquit\n', file);
+ assert.equal(result.status, 0, result.stderr);
+ const runtime = await createRuntime(await readFile(join(root, 'web/core.wasm')));
+ runtime.apply(await readFile(file, 'utf8'));
+ runtime.down('jump', 'terminal'); runtime.frame(30); runtime.up('jump', 'terminal'); runtime.frame();
+ // JSON has no undefined-valued optional fields; compare the same wire form.
+ assert.deepEqual(result.snapshots.at(-1), JSON.parse(JSON.stringify(runtime.snapshot())));
+ assert(result.snapshots.at(-1).states.y < 240);
+});
+
+test('invalid terminal commands do not alter state and fail piped execution', () => {
+ const result = run('show\nframe 0\nframe -1\nframe 1.5\nframe 1e2\nframe 601\nshow extra\ndown unknown\nshow\nquit\n', join(root, 'examples/counter.pure'));
+ assert.equal(result.status, 1);
+ assert.deepEqual(result.snapshots[0], result.snapshots[1]);
+ assert.equal(result.stderr.trim().split('\n').length, 7);
+});
+
+test('file load preserves by name, rejected load preserves active work, and save never overwrites', async t => {
+ const directory = await mkdtemp(join(tmpdir(), 'pure-author-cli-'));
+ t.after(() => rm(directory, { recursive: true, force: true }));
+ const first = join(directory, 'first.pure'), second = join(directory, 'second with space.pure');
+ const invalid = join(directory, 'invalid.pure'), saved = join(directory, 'saved.pure');
+ await writeFile(first, 'state n = 7');
+ await writeFile(second, 'state n = 100\nstate added = 9');
+ await writeFile(invalid, 'state n = broken');
+ const result = run(`load ${second}\nload ${invalid}\nshow\nsave ${saved}\nreset\nsave ${saved}\nquit\n`, first);
+ assert.equal(result.status, 1);
+ assert.deepEqual(result.snapshots[0].states, { n: 7, added: 9 });
+ assert.deepEqual(result.snapshots[1], result.snapshots[0]);
+ assert.deepEqual(result.snapshots.at(-1).states, { n: 100, added: 9 });
+ assert.equal(await readFile(saved, 'utf8'), await readFile(second, 'utf8'));
+ assert.match(result.stderr, /EEXIST/);
+});
+
+test('terminal exposes arithmetic rollback and retains failure through matching release', async t => {
+ const directory = await mkdtemp(join(tmpdir(), 'pure-author-error-'));
+ t.after(() => rm(directory, { recursive: true, force: true }));
+ const file = join(directory, 'error.pure');
+ await writeFile(file, 'state n = 7\ninput tap = Space\non tap.down { n = 1 / 0 }');
+ const result = run('down tap\nup tap\nshow\nreset\nquit\n', file);
+ assert.equal(result.status, 1);
+ for (const snapshot of result.snapshots.slice(0, 3)) {
+ assert.equal(snapshot.states.n, 7); assert.equal(snapshot.lastError, 4);
+ assert.equal(snapshot.errorLine, 3);
+ }
+ assert.equal(result.snapshots[3].lastError, 0);
+});
+
+test('terminal configures and discards evaluation records and recovers from a display limit', () => {
+ const result = run('limits 32 8 2048\nshow\ncosts\nforget all\nlimits 32 8 0\nlimits 32 8 2048\nshow\nquit\n', join(root, 'examples/counter.pure'));
+ assert.equal(result.status, 1); // The explicit zero-budget display failed.
+ assert.equal(result.stderr, '');
+ const [configured, warm, costs, forgotten, limited, recovered, final] = result.snapshots;
+ assert.deepEqual(warm.views, configured.views);
+ assert(costs.evaluation.cacheHits > 0);
+ assert.deepEqual(forgotten.states, configured.states);
+ assert.deepEqual(limited.views, []);
+ assert.equal(limited.lastError, 101);
+ assert.deepEqual(recovered, final);
+ assert.deepEqual(recovered.views, configured.views);
+ assert.deepEqual(recovered.states, configured.states);
+});
diff --git a/prototypes/workbench/results/2026-09-22-evaluation/sources/compiler.test.mjs b/prototypes/workbench/results/2026-09-22-evaluation/sources/compiler.test.mjs
new file mode 100644
index 0000000..89b8c6c
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-evaluation/sources/compiler.test.mjs
@@ -0,0 +1,240 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { compile, LIMITS } from './web/compiler.mjs';
+
+function diagnostic(source, pattern, line, column) {
+ assert.throws(() => compile(source), error => {
+ assert.match(error.message, pattern);
+ assert.ok(Number.isInteger(error.line) && error.line > 0);
+ assert.ok(Number.isInteger(error.column) && error.column > 0);
+ if (line !== undefined) assert.equal(error.line, line);
+ if (column !== undefined) assert.equal(error.column, column);
+ return true;
+ });
+}
+
+// A small independent evaluator checks compiler lowering. The product executes
+// these instructions in Zig; the VM has its own native and integration checks.
+function evaluate(program, start, length, values, dt = 0) {
+ const stack = [];
+ for (let pc = start; pc < start + length; pc++) {
+ const instruction = program.instructions[pc];
+ const { op, arg, value } = instruction;
+ if (op === 0) stack.push(value);
+ else if (op === 1) stack.push(values[arg]);
+ else if (op === 2) stack.push(dt);
+ else if (op === 7) stack.push(-stack.pop());
+ else if (op === 16) stack.push(Number(!stack.pop()));
+ else if (op === 19) {
+ const high = stack.pop(), low = stack.pop(), x = stack.pop();
+ assert.ok(low <= high); stack.push(Math.min(Math.max(x, low), high));
+ } else if (op === 20) values[arg] = stack.pop();
+ else if (op === 21) { if (!stack.pop()) pc = arg - 1; }
+ else {
+ const right = stack.pop(), left = stack.pop();
+ const operations = {
+ 3: () => left + right, 4: () => left - right, 5: () => left * right,
+ 6: () => left / right, 8: () => Number(left < right),
+ 9: () => Number(left <= right), 10: () => Number(left > right),
+ 11: () => Number(left >= right), 12: () => Number(left === right),
+ 13: () => Number(left !== right), 14: () => Number(Boolean(left) && Boolean(right)),
+ 15: () => Number(Boolean(left) || Boolean(right)),
+ 17: () => Math.min(left, right), 18: () => Math.max(left, right),
+ };
+ assert.ok(operations[op], `Unknown opcode ${op}`);
+ stack.push(operations[op]());
+ }
+ assert.ok(stack.every(Number.isFinite));
+ }
+ return stack;
+}
+
+function dispatch(program, event, values, dt = 0) {
+ for (const handler of program.handlers.filter(handler => handler.event === event)) {
+ assert.deepEqual(evaluate(program, handler.start, handler.length, values, dt), []);
+ }
+}
+
+test('a blank document is an empty program with no implied behavior', () => {
+ const result = compile('');
+ for (const field of ['states', 'inputs', 'views', 'instructions', 'handlers']) assert.deepEqual(result[field], []);
+ assert.equal(result.metrics.sourceBytes, 0);
+ assert.equal(result.metrics.maxStack, 0);
+ assert.deepEqual(compile('# comment only\n;').instructions, []);
+});
+
+test('forward references and separate state/input namespaces preserve declaration IDs', () => {
+ const program = compile('on tap.down { second = tap + first }\ntext label(0,0,second)\nstate first = -2\nstate tap = +3\ninput tap = Space\nstate second = .5e2');
+ assert.deepEqual(program.states.map(state => state.name), ['first', 'tap', 'second']);
+ assert.equal(program.handlers[0].event, 1);
+ const values = program.states.map(state => state.initial);
+ dispatch(program, 1, values);
+ assert.deepEqual(values, [-2, 3, 1]);
+ const binding = program.views[0].bindings.value;
+ assert.deepEqual(evaluate(program, binding.start, binding.length, values), [1]);
+});
+
+test('ordered assignments, repeated handlers, and condition evaluation once', () => {
+ const program = compile(`state x = 1
+input tap = Enter
+on tap.down {
+ if x == 1 {
+ x = 0
+ x = x + 5
+ }
+}
+on tap.down { x = x * 2 }
+on tap.up { x = x - 1 }`);
+ const values = [1];
+ dispatch(program, 1, values);
+ assert.equal(values[0], 10);
+ dispatch(program, 2, values);
+ assert.equal(values[0], 9);
+ const branch = program.instructions.find(instruction => instruction.op === 21);
+ assert.equal(branch.arg, program.handlers[0].start + program.handlers[0].length);
+});
+
+test('nested branches use absolute forward targets and skip correct bodies', () => {
+ const program = compile(`state x = 0
+text label(0, 0, x)
+on frame {
+ if x > 0 {
+ x = 8
+ if x == 8 { x = 9 }
+ }
+ x = x + 1
+}`);
+ const values = [0];
+ dispatch(program, 0, values, 1 / 60); assert.equal(values[0], 1);
+ dispatch(program, 0, values, 1 / 60); assert.equal(values[0], 10);
+ const handler = program.handlers[0];
+ program.instructions.forEach((instruction, pc) => {
+ if (instruction.op === 21) assert.ok(instruction.arg > pc && instruction.arg <= handler.start + handler.length);
+ });
+});
+
+test('operator precedence, associativity, unary and builtin function lowering', () => {
+ const expressions = new Map([
+ ['2 + 3 * 4', 14], ['8 - 3 - 2', 3], ['12 / 3 / 2', 2],
+ ['-2 * -3', 6], ['!0 + !4', 1], ['1 || 0 && 0', 1],
+ ['1 < 2 && 2 <= 2 && 4 > 3 && 4 >= 4', 1],
+ ['3 == 3 && 3 != 4', 1], ['clamp(max(3, 9) - min(4, 7), 0, 4)', 4],
+ ]);
+ for (const [source, expected] of expressions) {
+ const program = compile(`state x = 0\non frame { x = ${source} }`);
+ const values = [0]; dispatch(program, 0, values, 1 / 60);
+ assert.equal(values[0], expected, source);
+ }
+ const eager = compile('state x = 0\non frame { x = 1 || x / 0 }');
+ assert.ok(eager.instructions.some(instruction => instruction.op === 6));
+ assert.ok(!eager.instructions.some(instruction => instruction.op === 21));
+});
+
+test('multiline parentheses, scientific literals, comments, and string escapes', () => {
+ const program = compile(`state x = 1e-2 # comment
+box tile(
+ (x +
+ 2), 0, max(
+ 1, 2), 4,
+ "#AABBCC")
+text label(0, 0, x, "# count: \\\"value\\\" \\n")
+on frame { x = x + dt }`);
+ assert.equal(program.views[0].color, '#AABBCC');
+ assert.equal(program.views[1].prefix, '# count: "value" \n');
+ const values = [.01]; dispatch(program, 0, values, 1 / 60);
+ assert.equal(values[0], .01 + 1 / 60);
+ const binding = program.views[0].bindings.x;
+ assert.deepEqual(evaluate(program, binding.start, binding.length, values), [values[0] + 2]);
+});
+
+test('all instruction fields are numeric and carry original source line', () => {
+ const program = compile('state x = 0\non frame {\n x = x + dt\n}');
+ for (const instruction of program.instructions) {
+ assert.deepEqual(Object.keys(instruction), ['op', 'arg', 'value', 'line']);
+ assert.ok(Object.values(instruction).every(Number.isFinite));
+ assert.equal(instruction.line, 3);
+ }
+});
+
+test('contextual declaration words remain valid names outside reserved builtins', () => {
+ const program = compile('state if = 1\nstate state = 2\non frame { if = if + state }');
+ const values = [1, 2]; dispatch(program, 0, values, 1 / 60);
+ assert.deepEqual(values, [3, 2]);
+});
+
+test('unknown and reserved symbols have useful one-based locations', () => {
+ diagnostic('state x = 0\non frame {\n x = missing\n}', /Unknown state missing/, 3, 7);
+ diagnostic('on missing.down {}', /Unknown input missing/, 1, 4);
+ diagnostic('on frame { absent = 1 }', /Unknown state absent/, 1, 12);
+ for (const name of ['frame', 'dt', 'min', 'max', 'clamp']) {
+ diagnostic(`state ${name} = 0`, /Reserved name/, 1, 7);
+ diagnostic(`input ${name} = Space`, /Reserved name/);
+ }
+ diagnostic('text clock(0, 0, dt)', /dt is only available in handlers/);
+});
+
+test('duplicates, unsupported keys, initializers, and invalid syntax are rejected', () => {
+ const invalid = [
+ ['state x=0\nstate x=1', /Duplicate state/],
+ ['input tap=Space\ninput tap=Enter', /Duplicate input/],
+ ['input tap=Space\ninput other=Space', /Duplicate keyboard/],
+ ['box a(0,0,1,1,"#000000")\ncircle a(0,0,1,"#000000")', /Duplicate view/],
+ ['input tap=Escape', /Unsupported keyboard/],
+ ['state x=1+2', /Expected newline/],
+ ['state x=1e999', /finite/],
+ ['state x=NaN', /numeric state initializer/],
+ ['on tap.click {}', /Expected down or up/],
+ ['on frame.down {}', /Expected {/],
+ ['state x=0\non frame { x = min(1) }', /min expects 2/],
+ ['state x=0\non frame { x = clamp(1,2) }', /clamp expects 3/],
+ ['state x=0\non frame { x = 1 +\n2 }', /Expected a number/],
+ ['text a(0,0,1,"broken)', /Unterminated string/],
+ ['text a(0,0,1,"bad\\q")', /Unsupported string escape/],
+ ['box a(0,0,1,1,"blue")', /Color must use/],
+ ['state x=0 @', /Unexpected character/],
+ ['on frame {', /assignment, if, or closing brace/],
+ ['state x = 0 state y = 1', /Expected newline/],
+ ];
+ for (const [source, pattern] of invalid) diagnostic(source, pattern);
+});
+
+test('document, declaration, instruction, and nesting limits reject overflow', () => {
+ diagnostic('#' + '한'.repeat(11000), /32768 UTF-8 bytes/);
+ diagnostic(Array.from({ length: 33 }, (_, i) => `state s${i}=0`).join('\n'), /32 states/);
+ diagnostic(Array.from({ length: 9 }, (_, i) => `input i${i}=Digit${i}`).join('\n'), /8 inputs/);
+ diagnostic(Array.from({ length: 25 }, (_, i) => `text t${i}(0,0,0)`).join('\n'), /24 views/);
+ diagnostic('on frame {}\n'.repeat(65), /64 handlers/);
+ diagnostic(`state x=0\non frame {\n${'x=1\n'.repeat(257)}}`, /512 instructions/);
+ assert.equal(compile(`state x=0\non frame {\n${'x=1\n'.repeat(256)}}`).instructions.length, LIMITS.instructions);
+ diagnostic(`state x=0\non frame { x=${'('.repeat(65)}1${')'.repeat(65)} }`, /nesting exceeds 64/);
+ diagnostic(`on frame { ${'if 1 { '.repeat(65)}${'}\n'.repeat(66)}`, /nesting exceeds 64/);
+ // Right-nested clamp's two waiting operands exceed the stack before its depth limit.
+ let deep = '0';
+ for (let i = 0; i < 33; i++) deep = `clamp(0,0,${deep})`;
+ diagnostic(`text stack(0,0,${deep})`, /stack exceeds 64/);
+});
+
+test('every supported input maps to a bounded ordered event ID', () => {
+ const keys = ['Space', 'Enter', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'KeyZ', 'Digit9'];
+ const program = compile(keys.map((key, i) => `input i${i}=${key}\non i${i}.down {}\non i${i}.up {}`).join('\n'));
+ assert.deepEqual(program.handlers.map(handler => handler.event), Array.from({ length: 16 }, (_, i) => i + 1));
+});
+
+test('optional examples compile and charge behavior is entirely authored', async () => {
+ for (const name of ['counter', 'charge', 'toggle']) {
+ const program = compile(await readFile(new URL(`./examples/${name}.pure`, import.meta.url), 'utf8'));
+ assert.ok(program.instructions.length <= LIMITS.instructions);
+ assert.ok(program.views.length > 0);
+ if (name === 'charge') {
+ const values = program.states.map(state => state.initial);
+ dispatch(program, 1, values);
+ for (let frame = 0; frame < 90; frame++) dispatch(program, 0, values, 1 / 60);
+ assert.equal(values[0], 1); assert.equal(values[1], 1); assert.equal(values[2], 240);
+ dispatch(program, 2, values); assert.deepEqual(values, [0, 0, 240, -460]);
+ dispatch(program, 0, values, 1 / 60); assert.ok(values[2] < 240);
+ for (let frame = 0; frame < 120; frame++) dispatch(program, 0, values, 1 / 60);
+ assert.equal(values[2], 240); assert.equal(values[3], 0);
+ }
+ }
+});
diff --git a/prototypes/workbench/results/2026-09-22-evaluation/sources/core.zig b/prototypes/workbench/results/2026-09-22-evaluation/sources/core.zig
new file mode 100644
index 0000000..e2c3028
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-evaluation/sources/core.zig
@@ -0,0 +1,813 @@
+//! Bounded scalar interaction VM experiment. No application behavior is built in.
+//! Same source for native tests and freestanding Wasm; no allocation or imports.
+//! Error ABI: 0 ok, 1 capacity, 2 invalid, 3 stack, 4 division by zero,
+//! 5 non-finite, 6 bad branch, 7 bad state, 8 bad frame, 9 bad event.
+//! Counters wrap modulo 2^32. Failed loads preserve the active program and all
+//! observable state/diagnostics. Failed events preserve values and counters but
+//! report their error and attempted instruction count. Binding evaluation is pure.
+
+const std = @import("std");
+const Evaluation = @import("evaluation.zig").Evaluation;
+
+const max_states = 32;
+const max_inputs = 8;
+const max_instructions = 512;
+const max_handlers = 64;
+const max_stack = 64;
+const no_pc = std.math.maxInt(u32);
+const frame_dt: f64 = 1.0 / 60.0;
+
+const Code = enum(u32) {
+ ok = 0,
+ capacity = 1,
+ invalid = 2,
+ stack = 3,
+ division_by_zero = 4,
+ non_finite = 5,
+ bad_branch = 6,
+ bad_state = 7,
+ bad_frame = 8,
+ bad_event = 9,
+ work_limit = 10,
+};
+
+const Instruction = struct { value: f64 = 0, op: u32 = 0, arg: u32 = 0 };
+const Handler = struct { event: u32 = 0, start: u32 = 0, length: u32 = 0 };
+const Program = struct {
+ instructions: [max_instructions]Instruction = @splat(.{}),
+ handlers: [max_handlers]Handler = @splat(.{}),
+ initials: [max_states]f64 = @splat(0),
+ old_ids: [max_states]i32 = @splat(-1),
+ declared: [max_states]bool = @splat(false),
+ state_count: u32 = 0,
+ input_count: u32 = 0,
+ instruction_count: u32 = 0,
+ handler_count: u32 = 0,
+};
+const Engine = struct {
+ active: Program = .{},
+ staging: Program = .{},
+ values: [max_states]f64 = @splat(0),
+ staging_open: bool = false,
+ staging_error: Code = .ok,
+ tick: u32 = 0,
+ last_error: Code = .ok,
+ error_pc: u32 = no_pc,
+ last_ops: u32 = 0,
+ revision: u32 = 0,
+ successful_events: u32 = 0,
+ evaluation: Evaluation = .{},
+};
+var engine: Engine = .{};
+
+fn public(code: Code) u32 {
+ return @intFromEnum(code);
+}
+
+fn stagingFail(code: Code) u32 {
+ if (engine.staging_error == .ok) engine.staging_error = code;
+ return public(code);
+}
+
+export fn reset_all() void {
+ engine = .{};
+}
+
+export fn begin_program(state_count: u32, input_count: u32) u32 {
+ engine.staging_open = false;
+ engine.staging_error = .ok;
+ if (state_count > max_states or input_count > max_inputs) return stagingFail(.capacity);
+ engine.staging = .{ .state_count = state_count, .input_count = input_count };
+ engine.staging_open = true;
+ return 0;
+}
+
+export fn put_state(id: u32, initial: f64, old_id: i32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ if (id >= engine.staging.state_count or old_id < -1) return stagingFail(.bad_state);
+ if (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count) return stagingFail(.bad_state);
+ if (engine.staging.declared[id]) return stagingFail(.bad_state);
+ if (!std.math.isFinite(initial)) return stagingFail(.non_finite);
+ engine.staging.initials[id] = initial;
+ engine.staging.old_ids[id] = old_id;
+ engine.staging.declared[id] = true;
+ return 0;
+}
+
+export fn put_instruction(op: u32, arg: u32, value: f64) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.staging;
+ if (p.instruction_count == max_instructions) return stagingFail(.capacity);
+ if (op > 21) return stagingFail(.invalid);
+ if (!std.math.isFinite(value)) return stagingFail(.non_finite);
+ p.instructions[p.instruction_count] = .{ .op = op, .arg = arg, .value = value };
+ p.instruction_count += 1;
+ return 0;
+}
+
+export fn put_handler(event: u32, start: u32, length: u32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.staging;
+ if (p.handler_count == max_handlers) return stagingFail(.capacity);
+ if (event > p.input_count * 2) return stagingFail(.bad_event);
+ // Handlers may be appended before their instructions, so range validation
+ // is deferred until commit. Arithmetic remains guarded there.
+ p.handlers[p.handler_count] = .{ .event = event, .start = start, .length = length };
+ p.handler_count += 1;
+ return 0;
+}
+
+fn validate(p: *const Program) Code {
+ for (0..p.state_count) |id| {
+ if (!p.declared[id]) return .bad_state;
+ if (!std.math.isFinite(p.initials[id])) return .non_finite;
+ const old_id = p.old_ids[id];
+ if (old_id < -1 or (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count)) return .bad_state;
+ }
+ for (p.handlers[0..p.handler_count], 0..) |h, i| {
+ if (h.event > 2 * p.input_count) return .bad_event;
+ if (h.start > p.instruction_count or h.length > p.instruction_count - h.start) return .invalid;
+ if (h.length == 0) continue;
+ for (p.handlers[0..i]) |other| {
+ if (other.length != 0 and h.start < other.start + other.length and other.start < h.start + h.length) return .invalid;
+ }
+ }
+ for (p.instructions[0..p.instruction_count], 0..) |inst, pc| {
+ if (inst.op > 21) return .invalid;
+ if (!std.math.isFinite(inst.value)) return .non_finite;
+ if ((inst.op == 1 or inst.op == 20) and inst.arg >= p.state_count) return .bad_state;
+ if (inst.op == 20 or inst.op == 21) {
+ var owner: ?Handler = null;
+ for (p.handlers[0..p.handler_count]) |h| {
+ if (pc >= h.start and pc < h.start + h.length) {
+ owner = h;
+ break;
+ }
+ }
+ const h = owner orelse return .invalid;
+ if (inst.op == 21 and (inst.arg <= pc or inst.arg > h.start + h.length)) return .bad_branch;
+ }
+ }
+ return .ok;
+}
+
+export fn commit_program() u32 {
+ if (!engine.staging_open) return public(.invalid);
+ if (engine.staging_error != .ok) return public(engine.staging_error);
+ const code = validate(&engine.staging);
+ if (code != .ok) return stagingFail(code);
+ var next: [max_states]f64 = @splat(0);
+ for (0..engine.staging.state_count) |i| {
+ const old_id = engine.staging.old_ids[i];
+ next[i] = if (old_id < 0) engine.staging.initials[i] else engine.values[@intCast(old_id)];
+ }
+ engine.active = engine.staging;
+ engine.values = next;
+ engine.evaluation.forget(3);
+ engine.staging_open = false;
+ engine.revision +%= 1;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ return 0;
+}
+
+export fn reset_state() void {
+ engine.values = engine.active.initials;
+ engine.evaluation.forget(3);
+ engine.tick = 0;
+ engine.successful_events = 0;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+}
+
+const Execution = struct {
+ code: Code = .ok,
+ pc: u32 = no_pc,
+ ops: u32 = 0,
+ value: f64 = 0,
+ dependencies: u32 = 0,
+};
+
+fn executionFail(result: Execution, code: Code, pc: u32) Execution {
+ var failed = result;
+ failed.code = code;
+ failed.pc = pc;
+ return failed;
+}
+
+fn execute(values: *[max_states]f64, start: u32, length: u32, dt: f64, pure: bool, budget: ?*Evaluation) Execution {
+ const p = &engine.active;
+ var result: Execution = .{};
+ if (start > p.instruction_count or length > p.instruction_count - start) return executionFail(result, .invalid, no_pc);
+ const end = start + length;
+ var stack: [max_stack]f64 = undefined;
+ var sp: usize = 0;
+ var pc = start;
+ while (pc < end) {
+ if (budget) |b| {
+ if (!b.spend()) return executionFail(result, .work_limit, pc);
+ b.instructions += 1;
+ }
+ const inst = p.instructions[pc];
+ result.ops += 1;
+ if (pure and (inst.op == 2 or inst.op == 20 or inst.op == 21)) return executionFail(result, .invalid, pc);
+ const need: usize = switch (inst.op) {
+ 0...2 => 0,
+ 7, 16, 20, 21 => 1,
+ 3...6, 8...15, 17, 18 => 2,
+ 19 => 3,
+ else => return executionFail(result, .invalid, pc),
+ };
+ if (sp < need) return executionFail(result, .stack, pc);
+ if (inst.op <= 2) {
+ if (sp == max_stack) return executionFail(result, .stack, pc);
+ const value = switch (inst.op) {
+ 0 => inst.value,
+ 1 => blk: {
+ if (inst.arg >= p.state_count) return executionFail(result, .bad_state, pc);
+ if (budget != null and budget.?.cache_count != 0)
+ result.dependencies |= @as(u32, 1) << @as(u5, @intCast(inst.arg));
+ break :blk values[inst.arg];
+ },
+ 2 => dt,
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ stack[sp] = value;
+ sp += 1;
+ } else if (inst.op == 20) {
+ if (inst.arg >= p.state_count) return executionFail(result, .bad_state, pc);
+ sp -= 1;
+ values[inst.arg] = stack[sp];
+ } else if (inst.op == 21) {
+ if (inst.arg <= pc or inst.arg > end) return executionFail(result, .bad_branch, pc);
+ sp -= 1;
+ if (stack[sp] == 0) {
+ pc = inst.arg;
+ continue;
+ }
+ } else {
+ const a = stack[sp - need];
+ const b = if (need >= 2) stack[sp - need + 1] else 0;
+ const value: f64 = switch (inst.op) {
+ 3 => a + b,
+ 4 => a - b,
+ 5 => a * b,
+ 6 => blk: {
+ if (b == 0) return executionFail(result, .division_by_zero, pc);
+ break :blk a / b;
+ },
+ 7 => -a,
+ 8 => @floatFromInt(@intFromBool(a < b)),
+ 9 => @floatFromInt(@intFromBool(a <= b)),
+ 10 => @floatFromInt(@intFromBool(a > b)),
+ 11 => @floatFromInt(@intFromBool(a >= b)),
+ 12 => @floatFromInt(@intFromBool(a == b)),
+ 13 => @floatFromInt(@intFromBool(a != b)),
+ 14 => @floatFromInt(@intFromBool(a != 0 and b != 0)),
+ 15 => @floatFromInt(@intFromBool(a != 0 or b != 0)),
+ 16 => @floatFromInt(@intFromBool(a == 0)),
+ 17 => @min(a, b),
+ 18 => @max(a, b),
+ 19 => blk: {
+ const hi = stack[sp - 1];
+ if (b > hi) return executionFail(result, .invalid, pc);
+ break :blk @max(b, @min(a, hi));
+ },
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ sp = sp - need + 1;
+ stack[sp - 1] = value;
+ }
+ pc += 1;
+ }
+ if (sp != @as(usize, if (pure) 1 else 0)) return executionFail(result, .stack, end);
+ if (pure) result.value = stack[0];
+ return result;
+}
+
+export fn dispatch(event: u32, dt: f64) u32 {
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ if (event > engine.active.input_count * 2) {
+ engine.last_error = .bad_event;
+ return public(.bad_event);
+ }
+ if (!std.math.isFinite(dt)) {
+ engine.last_error = .non_finite;
+ return public(.non_finite);
+ }
+ if ((event == 0 and dt != frame_dt) or (event != 0 and dt != 0)) {
+ engine.last_error = .bad_frame;
+ return public(.bad_frame);
+ }
+ var pending = engine.values;
+ for (engine.active.handlers[0..engine.active.handler_count]) |h| {
+ if (h.event != event) continue;
+ const result = execute(&pending, h.start, h.length, dt, false, null);
+ engine.last_ops += result.ops;
+ if (result.code != .ok) {
+ engine.last_error = result.code;
+ engine.error_pc = result.pc;
+ return public(result.code);
+ }
+ }
+ if (engine.evaluation.cache_count != 0) {
+ var changed: u32 = 0;
+ for (0..engine.active.state_count) |i| {
+ // Signed zero is observable, so compare representation, not ==.
+ if (@as(u64, @bitCast(pending[i])) != @as(u64, @bitCast(engine.values[i])))
+ changed |= @as(u32, 1) << @as(u5, @intCast(i));
+ }
+ engine.evaluation.changed(changed);
+ }
+ engine.values = pending;
+ if (event == 0) engine.tick +%= 1;
+ engine.successful_events +%= 1;
+ return 0;
+}
+
+export fn eval_at(start: u32, length: u32) f64 {
+ // A local copy makes the no-side-effects boundary structural as well as
+ // checked by the pure instruction filter. Diagnostics are never touched.
+ var values = engine.values;
+ const result = execute(&values, start, length, 0, true, null);
+ return if (result.code == .ok) result.value else std.math.nan(f64);
+}
+
+export fn configure_evaluation(cache: u32, history: u32) u32 {
+ return if (engine.evaluation.configure(cache, history)) 0 else public(.capacity);
+}
+
+export fn forget_evaluation(flags: u32) void {
+ engine.evaluation.forget(flags);
+}
+
+export fn begin_evaluation(limit: u32) void {
+ engine.evaluation.begin(limit);
+}
+
+export fn eval_bounded(start: u32, length: u32) f64 {
+ const e = &engine.evaluation;
+ e.status = 0;
+ // Charge per request as well as per instruction and validation probe.
+ if (!e.spend()) return std.math.nan(f64);
+ if (e.lookup(start, length)) |value| return value;
+ if (e.status != 0) return std.math.nan(f64);
+ var values = engine.values;
+ const result = execute(&values, start, length, 0, true, e);
+ e.status = public(result.code);
+ if (result.code != .ok) return std.math.nan(f64);
+ e.remember(start, length, result.value, result.dependencies);
+ return result.value;
+}
+
+export fn evaluation_info(id: u32) u32 {
+ const e = &engine.evaluation;
+ return switch (id) {
+ 0 => e.status,
+ 1 => e.work,
+ 2 => e.instructions,
+ 3 => e.hits,
+ 4 => e.cache_count,
+ 5 => e.history_count,
+ 6 => @sizeOf(Evaluation),
+ else => 0,
+ };
+}
+
+export fn get_state(id: u32) f64 {
+ return if (id < engine.active.state_count) engine.values[id] else std.math.nan(f64);
+}
+
+export fn info(id: u32) u32 {
+ return switch (id) {
+ 0 => engine.tick,
+ 1 => public(engine.last_error),
+ 2 => engine.error_pc,
+ 3 => engine.last_ops,
+ 4 => engine.revision,
+ 5 => engine.active.state_count,
+ 6 => engine.active.instruction_count,
+ 7 => engine.active.handler_count,
+ 8 => engine.active.input_count,
+ 9 => engine.successful_events,
+ else => 0,
+ };
+}
+
+export fn layout(id: u32) u32 {
+ return switch (id) {
+ 0 => @sizeOf(Engine),
+ 1 => @sizeOf(Program),
+ 2 => @sizeOf(Instruction),
+ 3 => @sizeOf(Handler),
+ 4 => engine.active.instruction_count * @sizeOf(Instruction) + engine.active.handler_count * @sizeOf(Handler),
+ 5 => engine.active.state_count * @sizeOf(f64),
+ 6 => @sizeOf(Program),
+ 7 => max_stack * @sizeOf(f64),
+ else => 0,
+ };
+}
+
+// Test helpers describe programs directly; the product compiler is separately
+// tested against this ABI. These tests exercise semantics and failure boundaries.
+fn ok(code: u32) !void {
+ try std.testing.expectEqual(@as(u32, 0), code);
+}
+
+fn emit(op: u32, arg: u32, value: f64) !void {
+ try ok(put_instruction(op, arg, value));
+}
+
+fn counterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try ok(commit_program());
+}
+
+test "blank program, no hidden application behavior, and reset" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 1), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(3));
+ try std.testing.expectEqual(@as(u32, 0), info(5));
+ try std.testing.expect(std.math.isNan(get_state(0)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 0)));
+ reset_state();
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 1), info(4));
+}
+
+test "arbitrary input counter and unused events" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(dispatch(2, 0));
+ try ok(dispatch(0, frame_dt));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(3));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(6));
+}
+
+test "charge and release behavior exists only in the authored instruction program" {
+ reset_all();
+ try ok(begin_program(3, 1)); // held, charge, released_value
+ for (0..3) |i| try ok(put_state(@intCast(i), 0, -1));
+ try emit(0, 0, 1);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 2));
+ try emit(1, 1, 0);
+ try emit(1, 0, 0);
+ try emit(2, 0, 0);
+ try emit(5, 0, 0);
+ try emit(3, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(0, 2, 6));
+ try emit(1, 1, 0);
+ try emit(20, 2, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(2, 8, 6));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ try ok(dispatch(1, 0));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try ok(dispatch(2, 0));
+ try std.testing.expectApproxEqAbs(@as(f64, 0.5), get_state(2), 1e-12);
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+}
+
+test "forward condition executes once and assignments and handlers are ordered" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 1, -1));
+ try ok(put_state(1, 0, -1));
+ try emit(1, 0, 0);
+ try emit(21, 6, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 7);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 0, 6));
+ try emit(1, 1, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 6, 4));
+ try ok(commit_program());
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 14), get_state(1));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 28), get_state(1));
+ try std.testing.expectEqual(@as(u32, 6), info(3));
+}
+
+test "branch to program end is valid" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, 2, 0);
+ try ok(put_handler(0, 0, 2));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+}
+
+test "one event rolls back writes from all handlers on arithmetic failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 8, -1));
+ try emit(0, 0, 99);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 2));
+ try emit(0, 0, 1);
+ try emit(0, 0, 0);
+ try emit(6, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 2, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 8), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(2));
+ try std.testing.expectEqual(@as(u32, 5), info(3));
+}
+
+test "nonfinite arithmetic and invalid clamp fail atomically" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, -1));
+ try emit(0, 0, 1e308);
+ try emit(0, 0, 1e308);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, 0));
+ try emit(0, 0, 1);
+ try emit(0, 0, 10);
+ try emit(0, 0, 2);
+ try emit(19, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 5));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.invalid), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+}
+
+test "stack underflow overflow and leftover values reject the event" {
+ for (0..3) |mode| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ if (mode == 0) {
+ try emit(3, 0, 0);
+ } else {
+ for (0..@as(usize, if (mode == 1) 65 else 1)) |_| try emit(0, 0, 7);
+ }
+ try ok(put_handler(0, 0, engine.staging.instruction_count));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.stack), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ }
+}
+
+test "failed candidates leave active values program revision and diagnostics unchanged" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ const old_revision = info(4);
+ const old_ops = info(3);
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(1, 5, 0);
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try std.testing.expectEqual(old_revision, info(4));
+ try std.testing.expectEqual(old_ops, info(3));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.invalid), put_instruction(100, 0, 0));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+}
+
+test "state preservation mapping reads active values at commit and reset uses new initials" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(2, 0));
+ try ok(put_state(0, 42, -1)); // new symbol first
+ try ok(put_state(1, 100, 0)); // old symbol moves from id 0 to 1
+ try ok(dispatch(1, 0)); // changed after staging began
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 42), get_state(0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 100), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(4));
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try std.testing.expect(std.math.isNan(get_state(0)));
+}
+
+test "capacity boundaries and sticky load errors" {
+ reset_all();
+ try std.testing.expectEqual(public(.capacity), begin_program(33, 0));
+ try std.testing.expectEqual(public(.capacity), begin_program(0, 9));
+ try ok(begin_program(32, 8));
+ for (0..32) |i| try ok(put_state(@intCast(i), @floatFromInt(i), -1));
+ for (0..512) |_| try emit(0, 0, 0);
+ for (0..64) |_| try ok(put_handler(16, 0, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 512), info(6));
+ try std.testing.expectEqual(@as(u32, 64), info(7));
+ try std.testing.expectEqual(@as(f64, 31), get_state(31));
+ try ok(begin_program(0, 0));
+ for (0..512) |_| try emit(0, 0, 0);
+ try std.testing.expectEqual(public(.capacity), put_instruction(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+ try ok(begin_program(0, 0));
+ for (0..64) |_| try ok(put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+}
+
+test "invalid declaration values state mappings event ids and handler ranges" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 0, 0));
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.non_finite), put_state(0, std.math.inf(f64), -1));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 1, -1));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.non_finite), put_instruction(0, 0, std.math.nan(f64)));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.bad_event), put_handler(1, 0, 0));
+ try ok(begin_program(0, 0));
+ try ok(put_handler(0, no_pc, 5));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 1);
+ try ok(put_handler(0, 0, 1));
+ try ok(put_handler(0, 0, 1));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "backward self out-of-handler branches and stores without handlers are rejected" {
+ for ([_]u32{ 0, 1, 3 }) |target| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, target, 0);
+ try emit(0, 0, 7);
+ try ok(put_handler(0, 0, 2));
+ try std.testing.expectEqual(public(.bad_branch), commit_program());
+ }
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try emit(0, 0, 5);
+ try emit(20, 0, 0);
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "event inputs and dt enforce deterministic frame contract" {
+ reset_all();
+ try counterProgram();
+ try std.testing.expectEqual(public(.bad_event), dispatch(3, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch(no_pc, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(1, frame_dt));
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, std.math.nan(f64)));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(u32, 0), info(1));
+ try std.testing.expectEqual(no_pc, info(2));
+}
+
+test "binding evaluation is pure on both success and failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 4, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try emit(2, 0, 0);
+ try emit(0, 0, 0);
+ try emit(21, 7, 0);
+ try ok(put_handler(0, 3, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ const before = engine;
+ try std.testing.expectEqual(@as(f64, 12), eval_at(0, 3));
+ try std.testing.expect(std.math.isNan(eval_at(0, 4))); // attempted store
+ try std.testing.expect(std.math.isNan(eval_at(4, 1))); // dt forbidden in views
+ try std.testing.expect(std.math.isNan(eval_at(5, 2))); // branch forbidden
+ try std.testing.expect(std.math.isNan(eval_at(no_pc, 7)));
+ try std.testing.expect(std.math.isNan(eval_at(0, no_pc)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 2))); // two results
+ try std.testing.expectEqualDeep(before, engine);
+}
+
+test "arithmetic comparison logical min max and clamp expression semantics" {
+ const cases = [_]struct { op: u32, a: f64, b: f64, want: f64 }{
+ .{ .op = 3, .a = 7, .b = 2, .want = 9 },
+ .{ .op = 4, .a = 7, .b = 2, .want = 5 },
+ .{ .op = 5, .a = 7, .b = 2, .want = 14 },
+ .{ .op = 6, .a = 7, .b = 2, .want = 3.5 },
+ .{ .op = 8, .a = 2, .b = 7, .want = 1 },
+ .{ .op = 9, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 10, .a = 2, .b = 7, .want = 0 },
+ .{ .op = 11, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 12, .a = 7, .b = 2, .want = 0 },
+ .{ .op = 13, .a = 7, .b = 2, .want = 1 },
+ .{ .op = 14, .a = -7, .b = 2, .want = 1 },
+ .{ .op = 15, .a = 0, .b = 2, .want = 1 },
+ .{ .op = 17, .a = 7, .b = 2, .want = 2 },
+ .{ .op = 18, .a = 7, .b = 2, .want = 7 },
+ };
+ for (cases) |case| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, case.a);
+ try emit(0, 0, case.b);
+ try emit(case.op, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(case.want, eval_at(0, 3));
+ }
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, -8);
+ try emit(7, 0, 0);
+ try emit(0, 0, 0);
+ try emit(0, 0, 5);
+ try emit(19, 0, 0);
+ try emit(16, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 5), eval_at(0, 5));
+ try std.testing.expectEqual(@as(f64, 0), eval_at(0, 6));
+}
+
+test "diagnostic counters wrap without affecting scalar values" {
+ reset_all();
+ try counterProgram();
+ engine.tick = no_pc;
+ engine.successful_events = no_pc;
+ engine.revision = no_pc;
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 99, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 0), info(4));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+}
+
+test "memory accounting separates reserved storage used IR and stack capacity" {
+ reset_all();
+ try std.testing.expectEqual(@as(u32, 0), layout(4));
+ try std.testing.expectEqual(@as(u32, 0), layout(5));
+ try counterProgram();
+ try std.testing.expectEqual(@as(u32, @sizeOf(Engine)), layout(0));
+ try std.testing.expectEqual(@as(u32, 16), layout(2));
+ try std.testing.expectEqual(@as(u32, 12), layout(3));
+ try std.testing.expectEqual(@as(u32, 76), layout(4));
+ try std.testing.expectEqual(@as(u32, 8), layout(5));
+ try std.testing.expectEqual(layout(1), layout(6));
+ try std.testing.expect(layout(0) >= layout(1) * 2);
+ try std.testing.expectEqual(@as(u32, 512), layout(7));
+ try std.testing.expect(layout(0) + 16 * 1024 < 64 * 1024);
+}
diff --git a/prototypes/workbench/results/2026-09-22-evaluation/sources/evaluation-check.mjs b/prototypes/workbench/results/2026-09-22-evaluation/sources/evaluation-check.mjs
new file mode 100644
index 0000000..bb3d616
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-evaluation/sources/evaluation-check.mjs
@@ -0,0 +1,76 @@
+// A bounded adoption check on existing authored files, not a platform benchmark.
+import assert from 'node:assert/strict';
+import { readFile, writeFile } from 'node:fs/promises';
+import { createHash } from 'node:crypto';
+import { platform, release, arch, cpus } from 'node:os';
+import { performance } from 'node:perf_hooks';
+import { createRuntime } from './web/runtime.mjs';
+
+const args = process.argv.slice(2);
+if (args.length !== 2 || args[0] !== '--output') throw new Error('Use --output NEW.json');
+const root = new URL('.', import.meta.url);
+const wasm = await readFile(new URL('web/core.wasm', root));
+const hash = bytes => createHash('sha256').update(bytes).digest('hex');
+const evidence = { purpose: 'Choose whether to enable binding reuse by default',
+ utc: new Date().toISOString(), host: { platform: platform(), release: release(),
+ arch: arch(), cpu: cpus()[0]?.model, node: process.version, affinityControlled: false },
+ sourceSha256: {}, cases: [],
+ timingScope: 'Event dispatch plus snapshot construction. Excludes loading, compilation, DOM, equality checks and statistics reads. Five batch samples are not latency percentiles.',
+};
+for (const path of ['web/core.wasm', 'core.zig', 'evaluation.zig', 'web/runtime.mjs',
+ 'web/compiler.mjs', 'evaluation-check.mjs', 'evaluation.test.mjs'])
+ evidence.sourceSha256[path] = hash(await readFile(new URL(path, root)));
+
+for (const name of ['counter', 'toggle', 'charge']) {
+ const path = `examples/${name}.pure`;
+ const source = await readFile(new URL(path, root), 'utf8');
+ evidence.sourceSha256[path] = hash(source);
+ const plain = await createRuntime(wasm), cached = await createRuntime(wasm, {cacheEntries:32});
+ const program = plain.apply(source); cached.apply(source);
+ const input = program.inputs[0]?.name;
+ const trace = Array.from({length:100}, () => 'show');
+ for (let i = 0; i < 600; i++) {
+ if (input && i % 60 === 0) trace.push('down');
+ if (input && i % 60 === 15) trace.push('up');
+ trace.push('frame');
+ }
+ function action(runtime, kind) {
+ if (kind === 'frame') assert.equal(runtime.frame(), 0);
+ else if (kind !== 'show') assert.equal(runtime[kind](input, 'replay'), 0);
+ return runtime.snapshot();
+ }
+ const totals = {plain:{work:0,instructions:0,cacheHits:0},cached:{work:0,instructions:0,cacheHits:0}};
+ for (const kind of trace) {
+ const left = action(plain, kind), right = action(cached, kind);
+ assert.equal(left.lastError, 0); assert.deepEqual(right, left);
+ for (const [key, runtime] of Object.entries({plain,cached})) {
+ const stats = runtime.evaluationStats();
+ for (const field of Object.keys(totals[key])) totals[key][field] += stats[field];
+ }
+ }
+ function replay(runtime) {
+ runtime.reset();
+ for (const kind of trace) {
+ if (kind === 'frame') runtime.frame();
+ else if (kind !== 'show') runtime[kind](input, 'replay');
+ runtime.snapshot();
+ }
+ }
+ for (let warm = 0; warm < 3; warm++) { replay(plain); replay(cached); }
+ const samples = [];
+ for (let trial = 0; trial < 5; trial++) {
+ for (const key of trial % 2 ? ['cached','plain'] : ['plain','cached']) {
+ const runtime = key === 'plain' ? plain : cached;
+ const start = performance.now(); replay(runtime);
+ samples.push({trial,key,elapsedMs:performance.now()-start});
+ }
+ assert.deepEqual(cached.snapshot(), plain.snapshot());
+ }
+ const median = key => samples.filter(s=>s.key===key).map(s=>s.elapsedMs).sort((a,b)=>a-b)[2];
+ evidence.cases.push({name,checkedSnapshots:trace.length, traceSha256:hash(JSON.stringify(trace)),
+ totals, memory:cached.snapshot().memory, evaluation:cached.evaluationStats(), samples,
+ medianMs:{plain:median('plain'),cached:median('cached')}, cachedOverPlain:median('cached')/median('plain')});
+}
+await writeFile(args[1], JSON.stringify(evidence,null,2)+'\n', {flag:'wx'});
+for (const c of evidence.cases) console.log(JSON.stringify({name:c.name,checkedSnapshots:c.checkedSnapshots,
+ totals:c.totals,medianMs:c.medianMs,cachedOverPlain:c.cachedOverPlain}));
diff --git a/prototypes/workbench/results/2026-09-22-evaluation/sources/evaluation.test.mjs b/prototypes/workbench/results/2026-09-22-evaluation/sources/evaluation.test.mjs
new file mode 100644
index 0000000..21a9590
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-evaluation/sources/evaluation.test.mjs
@@ -0,0 +1,133 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { createRuntime } from './web/runtime.mjs';
+
+const wasm = await readFile(new URL('./web/core.wasm', import.meta.url));
+const make = (options = {}) => createRuntime(wasm, options);
+const source = `state x = 3
+state spare = 0
+input next = KeyN
+input other = KeyO
+input fail = KeyF
+text value(0, 0, (x + 1) * (x + 2) / 2)
+on next.down { x = x + 0.25 }
+on other.down { spare = spare + 1 }
+on fail.down { x = 900; spare = 1 / 0 }
+`;
+
+test('cache reuse, unrelated changes and discarded records preserve authored values', async () => {
+ const cached = await make({ cacheEntries: 32 }), plain = await make();
+ cached.apply(source); plain.apply(source);
+ assert.deepEqual(cached.snapshot(), plain.snapshot());
+ assert.deepEqual(cached.snapshot(), plain.snapshot());
+ assert(cached.evaluationStats().cacheHits > 0);
+ assert(cached.evaluationStats().instructions < plain.evaluationStats().instructions);
+ for (let i = 0; i < 100; i++) {
+ const name = i % 3 ? 'other' : 'next';
+ for (const runtime of [cached, plain]) { runtime.down(name); runtime.up(name); }
+ if (i % 7 === 0) cached.forgetEvaluation('history');
+ if (i % 11 === 0) cached.forgetEvaluation('values');
+ if (i % 13 === 0) cached.forgetEvaluation('all');
+ assert.deepEqual(cached.snapshot(), plain.snapshot());
+ }
+ // Omit observations long enough to overwrite every change record.
+ for (let i = 0; i < 20; i++) for (const runtime of [cached, plain]) {
+ runtime.down('next'); runtime.up('next');
+ }
+ assert.deepEqual(cached.snapshot(), plain.snapshot());
+});
+
+test('committed changes only: failed events and failed edits preserve reusable output', async () => {
+ const cached = await make({ cacheEntries: 32 }); cached.apply(source);
+ const before = cached.snapshot();
+ cached.down('fail'); cached.up('fail');
+ const failed = cached.snapshot();
+ assert.deepEqual(failed.states, before.states);
+ assert.deepEqual(failed.views, before.views);
+ assert.equal(failed.lastError, 4);
+ assert.equal(cached.evaluationStats().instructions, 0);
+ assert.throws(() => cached.apply('state x = missing'));
+ assert.deepEqual(cached.snapshot(), failed);
+ assert.equal(cached.evaluationStats().instructions, 0);
+});
+
+test('program edits, state reordering and reset cannot reuse old definitions', async () => {
+ const cached = await make({ cacheEntries: 32 }), plain = await make();
+ for (const r of [cached, plain]) { r.apply(source); r.down('next'); r.up('next'); r.snapshot(); }
+ const changed = source.replace('state x = 3\nstate spare = 0', 'state spare = 9\nstate x = 300')
+ .replace('(x + 1) * (x + 2) / 2', '(x + 10) * (x + 20) / 2');
+ for (const r of [cached, plain]) r.apply(changed);
+ assert.deepEqual(cached.snapshot(), plain.snapshot());
+ for (const r of [cached, plain]) r.reset();
+ assert.deepEqual(cached.snapshot(), plain.snapshot());
+ assert.equal(cached.snapshot().states.x, 300);
+});
+
+test('finite float semantics include signed zero, high state bit and arithmetic errors', async () => {
+ const cached = await make({ cacheEntries: 32 }), plain = await make();
+ const program = `${Array.from({ length: 32 }, (_, i) => `state s${i} = 0`).join('\n')}
+input flip = KeyF
+text sign(0, 0, s31)
+text error(0, 0, 1 / s31)
+on flip.down { s31 = -s31 }
+on flip.up { s31 = 2 }
+`;
+ for (const r of [cached, plain]) r.apply(program);
+ assert.deepEqual(cached.snapshot(), plain.snapshot());
+ for (const r of [cached, plain]) r.down('flip');
+ assert.deepEqual(cached.snapshot(), plain.snapshot());
+ assert(Object.is(cached.snapshot().views[0].value, -0));
+ assert.equal(cached.snapshot().displayError.code, 100);
+ for (const r of [cached, plain]) r.up('flip');
+ assert.deepEqual(cached.snapshot(), plain.snapshot());
+ assert.equal(cached.snapshot().displayError, null);
+});
+
+test('one budget covers the whole scene; failures publish no partial scene or state rollback', async () => {
+ const r = await make({ cacheEntries: 32 }); r.apply(source);
+ r.down('next'); r.up('next');
+ const good = r.snapshot();
+ r.configureEvaluation({ workLimit: 5 });
+ const limited = r.snapshot();
+ assert.equal(limited.lastError, 101);
+ assert.deepEqual(limited.views, []);
+ assert.deepEqual(limited.states, good.states);
+ assert.equal(limited.successfulEvents, good.successfulEvents);
+ assert(r.evaluationStats().work <= 5);
+ r.down('fail'); r.up('fail');
+ assert.equal(r.snapshot().lastError, 4);
+ assert.equal(r.snapshot().displayError.code, 101);
+ r.configureEvaluation({ workLimit: 2048 });
+ assert.deepEqual(r.snapshot().views, good.views);
+ assert.equal(r.snapshot().lastError, 4);
+});
+
+test('small caches, no history and every small work limit give exact results or explicit failure', async () => {
+ const plain = await make(); plain.apply(source);
+ for (const cacheEntries of [0, 1, 32]) for (const historyEntries of [0, 1, 8]) {
+ const r = await make({ cacheEntries, historyEntries }); r.apply(source);
+ for (let workLimit = 0; workLimit < 100; workLimit++) {
+ r.configureEvaluation({ workLimit });
+ const actual = r.snapshot();
+ assert(r.evaluationStats().work <= workLimit);
+ if (actual.lastError) {
+ assert.equal(actual.lastError, 101);
+ assert.deepEqual(actual.views, []);
+ } else assert.deepEqual(actual, plain.snapshot());
+ }
+ }
+});
+
+test('invalid settings and forget names preserve policy and state', async () => {
+ const r = await make({ cacheEntries: 32 }); r.apply(source);
+ const before = r.snapshot(), policy = r.evaluationStats();
+ for (const option of [{cacheEntries:33}, {historyEntries:9}, {workLimit:-1},
+ {workLimit:0x100000000}, {workLimit:NaN}, {cacheEntries:0.5}]) {
+ assert.throws(() => r.configureEvaluation(option));
+ assert.deepEqual(r.evaluationStats(), policy);
+ }
+ for (const name of ['unknown', '__proto__', 'constructor']) assert.throws(() => r.forgetEvaluation(name));
+ assert.deepEqual(r.snapshot(), before);
+ assert.equal(r.snapshot().memory.linearBytes, 65536);
+});
diff --git a/prototypes/workbench/results/2026-09-22-evaluation/sources/evaluation.zig b/prototypes/workbench/results/2026-09-22-evaluation/sources/evaluation.zig
new file mode 100644
index 0000000..5473d59
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-evaluation/sources/evaluation.zig
@@ -0,0 +1,156 @@
+//! Disposable binding results and change history. No event state lives here.
+const std = @import("std");
+
+const Memo = struct {
+ value: f64 = 0,
+ revision: u64 = 0,
+ dependencies: u32 = 0,
+ start: u32 = 0,
+ length: u32 = 0,
+};
+const Change = struct { revision: u64 = 0, mask: u32 = 0 };
+
+pub const Evaluation = struct {
+ pub const max_cache = 32;
+ pub const max_history = 8;
+ memo: [max_cache]Memo = @splat(.{}),
+ history: [max_history]Change = @splat(.{}),
+ revision: u64 = 1,
+ floor: u64 = 1,
+ cache_count: u32 = 0,
+ history_count: u32 = 8,
+ next_change: u32 = 0,
+ limit: u32 = 0,
+ work: u32 = 0,
+ instructions: u32 = 0,
+ hits: u32 = 0,
+ status: u32 = 0,
+
+ pub fn configure(self: *Evaluation, cache: u32, history: u32) bool {
+ if (cache > max_cache or history > max_history) return false;
+ self.cache_count = cache;
+ self.history_count = history;
+ self.forget(3);
+ return true;
+ }
+
+ pub fn forget(self: *Evaluation, flags: u32) void {
+ if (flags & 1 != 0) self.memo = @splat(.{});
+ if (flags & 2 != 0) {
+ self.history = @splat(.{});
+ self.floor = self.revision;
+ self.next_change = 0;
+ }
+ }
+
+ pub fn changed(self: *Evaluation, mask: u32) void {
+ if (mask == 0 or self.cache_count == 0) return;
+ if (self.revision == std.math.maxInt(u64)) {
+ self.revision = 1;
+ self.forget(3);
+ }
+ self.revision += 1;
+ if (self.history_count == 0) {
+ self.floor = self.revision;
+ return;
+ }
+ const change = &self.history[self.next_change];
+ self.floor = @max(self.floor, change.revision);
+ change.* = .{ .revision = self.revision, .mask = mask };
+ self.next_change = (self.next_change + 1) % self.history_count;
+ }
+
+ pub fn begin(self: *Evaluation, limit: u32) void {
+ self.limit = limit;
+ self.work = 0;
+ self.instructions = 0;
+ self.hits = 0;
+ self.status = 0;
+ }
+
+ pub fn spend(self: *Evaluation) bool {
+ if (self.work == self.limit) {
+ self.status = 10;
+ return false;
+ }
+ self.work += 1;
+ return true;
+ }
+
+ fn slot(self: *const Evaluation, start: u32, length: u32) u32 {
+ // One candidate, including on misses; no scan proportional to cache size.
+ return (start ^ (start >> 5) ^ (length *% 0x9e3779b9)) % self.cache_count;
+ }
+
+ pub fn lookup(self: *Evaluation, start: u32, length: u32) ?f64 {
+ if (self.cache_count == 0) return null;
+ if (!self.spend()) return null;
+ const memo = &self.memo[self.slot(start, length)];
+ if (memo.revision == 0 or memo.start != start or memo.length != length) return null;
+ // Successful pure constants need no input-change evidence. A program
+ // replacement still clears them, just like every other binding memo.
+ if (memo.dependencies != 0 and memo.revision < self.floor) return null;
+ if (memo.dependencies != 0 and memo.revision != self.revision) {
+ for (self.history[0..self.history_count]) |change| {
+ if (!self.spend()) return null;
+ if (change.revision > memo.revision and change.mask & memo.dependencies != 0) return null;
+ }
+ }
+ memo.revision = self.revision;
+ self.hits += 1;
+ return memo.value;
+ }
+
+ pub fn remember(self: *Evaluation, start: u32, length: u32, value: f64, dependencies: u32) void {
+ if (self.cache_count == 0) return;
+ self.memo[self.slot(start, length)] = .{ .start = start, .length = length,
+ .value = value, .dependencies = dependencies, .revision = self.revision };
+ }
+};
+
+test "record loss cannot authorize an old memo; unrelated changes can" {
+ var e: Evaluation = .{};
+ try std.testing.expect(e.configure(1, 1));
+ e.begin(100);
+ e.remember(0, 3, 12, 1);
+ e.changed(2);
+ try std.testing.expectEqual(@as(?f64, 12), e.lookup(0, 3));
+ e.changed(2);
+ e.changed(2); // The certificate now predates the surviving history.
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(0, 3));
+ e.remember(0, 3, 12, 1);
+ e.changed(1);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(0, 3));
+ e.remember(0, 3, 20, 1);
+ e.changed(2);
+ e.forget(2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(0, 3));
+ e.remember(0, 3, 20, 1);
+ e.revision = std.math.maxInt(u64);
+ e.changed(2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(0, 3));
+}
+
+test "budget, collisions, invalid settings, and disabled history are conservative" {
+ var e: Evaluation = .{};
+ try std.testing.expect(e.configure(1, 0));
+ e.begin(100);
+ e.remember(0, 3, 12, 1);
+ e.remember(1, 3, 15, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(0, 3));
+ try std.testing.expectEqual(@as(?f64, 15), e.lookup(1, 3));
+ e.changed(0x80000000);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(1, 3));
+ try std.testing.expect(!e.configure(33, 0));
+ try std.testing.expectEqual(@as(u32, 1), e.cache_count);
+ e.begin(0);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(1, 3));
+ try std.testing.expectEqual(@as(u32, 10), e.status);
+ try std.testing.expectEqual(@as(u32, 0), e.work);
+ e.begin(100);
+ e.remember(2, 1, 99, 0);
+ e.changed(1);
+ e.forget(2);
+ try std.testing.expectEqual(@as(?f64, 99), e.lookup(2, 1));
+ try std.testing.expectEqual(@as(u32, 1), e.work);
+}
diff --git a/prototypes/workbench/results/2026-09-22-evaluation/sources/examples/charge.pure b/prototypes/workbench/results/2026-09-22-evaluation/sources/examples/charge.pure
new file mode 100644
index 0000000..eac55c4
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-evaluation/sources/examples/charge.pure
@@ -0,0 +1,34 @@
+# All holding, charging, gravity, jumping, and floor behavior is authored here.
+state holding = 0
+state charge = 0
+state y = 240
+state velocity = 0
+input jump = Space
+box floor(40, 261, 560, 4, "#356548")
+circle actor(180, y, 20, "#c78638")
+box meter(80, 80, charge * 200, 12, "#c78638")
+text amount(80, 60, charge, "Charge: ")
+on jump.down {
+ if y >= 240 {
+ holding = 1
+ charge = 0
+ }
+}
+on jump.up {
+ if holding {
+ velocity = -(160 + charge * 300)
+ holding = 0
+ charge = 0
+ }
+}
+on frame {
+ if holding {
+ charge = clamp(charge + dt, 0, 1)
+ }
+ velocity = velocity + 600 * dt
+ y = y + velocity * dt
+ if y >= 240 {
+ y = 240
+ velocity = 0
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-22-evaluation/sources/examples/counter.pure b/prototypes/workbench/results/2026-09-22-evaluation/sources/examples/counter.pure
new file mode 100644
index 0000000..ca5f18e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-evaluation/sources/examples/counter.pure
@@ -0,0 +1,8 @@
+# Space or the tap control increments a state that is displayed and drawn.
+state count = 0
+input tap = Space
+text label(80, 90, count, "Count: ")
+box tile(80, 150, 40 + min(count, 20) * 8, 60, "#356548")
+on tap.down {
+ count = count + 1
+}
diff --git a/prototypes/workbench/results/2026-09-22-evaluation/sources/examples/toggle.pure b/prototypes/workbench/results/2026-09-22-evaluation/sources/examples/toggle.pure
new file mode 100644
index 0000000..a29f38f
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-evaluation/sources/examples/toggle.pure
@@ -0,0 +1,17 @@
+# Space toggles the circle. Right arrow moves the tile.
+state visible = 1
+state position = 120
+input toggle = Space
+input move = ArrowRight
+circle marker(position, 140, visible * 24, "#c78638")
+box tile(position - 24, 190, 48, 48, "#356548")
+text value(80, 80, visible, "Visible: ")
+on toggle.down {
+ visible = !visible
+}
+on move.down {
+ position = position + 40
+ if position > 560 {
+ position = 80
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-22-evaluation/sources/manage.py b/prototypes/workbench/results/2026-09-22-evaluation/sources/manage.py
new file mode 100644
index 0000000..c643997
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-evaluation/sources/manage.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+"""Build the authoring VM or serve its local browser adapter."""
+import argparse
+from datetime import datetime, timezone
+from functools import partial
+import hashlib
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+import json
+from pathlib import Path
+import shutil
+import subprocess
+
+ROOT = Path(__file__).resolve().parent
+
+
+def sync_examples():
+ target = ROOT / 'web/examples'
+ target.mkdir(exist_ok=True)
+ for source in (ROOT / 'examples').glob('*.pure'):
+ shutil.copyfile(source, target / source.name)
+
+
+def build(zig):
+ compiler = shutil.which(zig)
+ if not compiler:
+ raise SystemExit('Zig 0.15.2가 필요합니다. --zig /path/to/zig로 지정할 수 있습니다.')
+ version = subprocess.check_output([compiler, 'version'], text=True).strip()
+ if version != '0.15.2':
+ raise SystemExit(f'이 실험은 Zig 0.15.2로 고정했습니다. 현재 {version}')
+ folder = ROOT / 'build'
+ folder.mkdir(exist_ok=True)
+ cache = ['--cache-dir', str(folder / 'cache'), '--global-cache-dir', str(folder / 'global-cache')]
+ source = ROOT / 'core.zig'
+ result = subprocess.run([compiler, 'test', str(source), *cache], capture_output=True, text=True)
+ print(result.stdout + result.stderr, end='', flush=True)
+ result.check_returncode()
+ flags = ['-target', 'wasm32-freestanding', '-O', 'ReleaseSafe', '-fstrip', '-fno-entry',
+ '-rdynamic', '--export-memory', '--stack', '16384',
+ '--initial-memory=65536', '--max-memory=65536']
+ artifact = ROOT / 'web/core.wasm'
+ subprocess.run([compiler, 'build-exe', str(source), *flags, f'-femit-bin={artifact}', *cache], check=True)
+ sync_examples()
+ sha = lambda path: hashlib.sha256(path.read_bytes()).hexdigest()
+ (folder / 'build.json').write_text(json.dumps({
+ 'builtUtc': datetime.now(timezone.utc).isoformat(), 'zigVersion': version,
+ 'sourceSha256': sha(source), 'wasmSha256': sha(artifact),
+ 'evaluationSourceSha256': sha(ROOT / 'evaluation.zig'),
+ 'wasmFileBytes': artifact.stat().st_size, 'flags': flags,
+ 'nativeTests': result.stdout + result.stderr,
+ }, indent=2) + '\n')
+ print(f'Built {artifact} ({artifact.stat().st_size:,} bytes)')
+
+
+class Handler(SimpleHTTPRequestHandler):
+ extensions_map = {**SimpleHTTPRequestHandler.extensions_map, '.wasm': 'application/wasm', '.mjs': 'text/javascript', '.pure': 'text/plain'}
+
+ def end_headers(self):
+ self.send_header('Cache-Control', 'no-store')
+ super().end_headers()
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ sub = parser.add_subparsers(dest='action', required=True)
+ b = sub.add_parser('build')
+ b.add_argument('--zig', default='zig')
+ s = sub.add_parser('serve')
+ s.add_argument('--port', type=int, default=8766)
+ args = parser.parse_args()
+ if args.action == 'build':
+ build(args.zig)
+ return
+ if not 1 <= args.port <= 65535:
+ parser.error('port must be 1..65535')
+ if not (ROOT / 'web/core.wasm').is_file():
+ parser.error('먼저 manage.py build를 실행하세요.')
+ sync_examples()
+ with ThreadingHTTPServer(('127.0.0.1', args.port), partial(Handler, directory=str(ROOT / 'web'))) as server:
+ print(f'Pure workbench: http://127.0.0.1:{args.port}', flush=True)
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+
+
+if __name__ == '__main__':
+ main()
diff --git a/prototypes/workbench/results/2026-09-22-evaluation/sources/manifest.json b/prototypes/workbench/results/2026-09-22-evaluation/sources/manifest.json
new file mode 100644
index 0000000..7c5e3dc
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-evaluation/sources/manifest.json
@@ -0,0 +1,21 @@
+{
+ "baseline.test.mjs": "3ea469a29d301b95701bc6f6ffafa2471ac0f334c5bd462c0cbc7c320bdc0764",
+ "cli.mjs": "2e0627ef84a45c8a9e91fb28c0010673212d459d0b0d09a5342856a049060030",
+ "cli.test.mjs": "655c6d80b51af818fa7cdeb27cb5462962a0e1aef70f609ef5e2ddc85f4a6bc7",
+ "compiler.test.mjs": "6aa442d9d1eeb69453082f1780ff4597e46b1fc09d8ba3cf955545bc94d81431",
+ "core.zig": "dfb9fd7f3862806c2bd88ce1c668b795d9df196e1f2e50cceff1b5c4809273df",
+ "evaluation-check.mjs": "11492759b5eff878fb8e43cad69422f9c34da6a85b5d8808478cc1845d737d4f",
+ "evaluation.test.mjs": "d3026ab5c6bffce56c0bde3155d7dddd6fa442a5688656ee710626c762258ff1",
+ "evaluation.zig": "2192e563385105f130d373b2e8724ea234c51e2909c7dbfc38e72acc3b70b2ed",
+ "examples/charge.pure": "8d69d9fd51d44ab754d560eb7ff5102ea8a34540753cd7170b3bd800e4294f56",
+ "examples/counter.pure": "53cb3a93ff0f26a3d208a5876bfff9ecb1c39e9a01acc101566727733ba166a8",
+ "examples/toggle.pure": "3de97fab9d316c17ba6a0c82a16cccd3baafc6816b879bf9b0a174210c8a1915",
+ "manage.py": "e04199a0b5addc73bea7748420eee21ccf0aaa1a5b8c83082e5588187d6ed669",
+ "qa-evaluation.js": "262d97f0aa409eb68bdfe8aa3e82ad037e6b10681f8b5025e59d148ee49564e1",
+ "runtime.test.mjs": "5fe66115aab0c178a283dcb592376a2e7209308368004865e6c0a1cc58fa7f62",
+ "web/app.js": "6d63f3bfdfe212146894dbb1772509eb4ecc2f3be3b106675f0fc10cdeb5bfd3",
+ "web/baseline.mjs": "dcd567e5568d3c798d6b48e555e6d54773963c70d23594c175706b061f8b043f",
+ "web/compiler.mjs": "a34f3beb069c811a9bd5be73dde9341907ba77756066030b8b95fc6d66a5cc02",
+ "web/core.wasm": "b79eecefba5e7da0f5dd945fa1df196ee627803243a430517f5c49c3a5e54ce1",
+ "web/runtime.mjs": "d9981282612cb06b50d24bf8919e829dce202f72b59dab607c8684bc0f297939"
+}
diff --git a/prototypes/workbench/results/2026-09-22-evaluation/sources/qa-evaluation.js b/prototypes/workbench/results/2026-09-22-evaluation/sources/qa-evaluation.js
new file mode 100644
index 0000000..eb19e15
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-evaluation/sources/qa-evaluation.js
@@ -0,0 +1,86 @@
+// Run with Playwright CLI run-code on the local workbench. The final UI case
+// overrides only createRuntime's default options in the served module so that
+// a deliberately tiny budget can exercise the normally unreachable limit UI.
+async (page) => {
+ const checks = [], errors = [];
+ const onError = error => errors.push(error.message);
+ const onDialog = dialog => dialog.accept();
+ page.on('pageerror', onError);
+ page.on('dialog', onDialog);
+ const check = (condition, name) => {
+ if (!condition) throw new Error(name);
+ checks.push(name);
+ };
+ const source = 'state x = 3\ninput next = KeyN\ntext label(40, 60, (x + 1) * (x + 2) / 2)\non next.down { x = x + 0.25 }\n';
+ const state = () => page.evaluate(() => window.pureWorkbench.snapshot());
+ const apply = async source => {
+ await page.locator('#source').fill(source);
+ await page.locator('#apply').click();
+ };
+ try {
+ await page.reload();
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ await apply(source);
+ await page.locator('[data-input="next"]').click();
+ check((await state()).states.x === 3.25, 'authored browser input commits fractional state');
+ check((await state()).views[0].value === 11.15625, 'bounded evaluation renders the exact authored expression');
+ await apply(source.replace('(x + 1)', '(x + 10)'));
+ check((await state()).states.x === 3.25 && (await state()).views[0].value === 34.78125,
+ 'code replacement preserves state and changes binding result');
+
+ const api = await page.evaluate(async source => {
+ const { createRuntime } = await import('./runtime.mjs');
+ const runtime = await createRuntime(undefined, {cacheEntries:32});
+ runtime.apply(source); runtime.snapshot();
+ const before = runtime.snapshot(), warm = runtime.evaluationStats();
+ runtime.forgetEvaluation('all');
+ const forgotten = runtime.snapshot();
+ runtime.configureEvaluation({workLimit:1});
+ const limited = runtime.snapshot(), limitStats = runtime.evaluationStats();
+ runtime.configureEvaluation({workLimit:2048});
+ return {before,warm,forgotten,limited,limitStats,recovered:runtime.snapshot()};
+ }, source);
+ check(api.warm.cacheHits > 0, 'browser Wasm reuses completed bindings');
+ check(JSON.stringify(api.before) === JSON.stringify(api.forgotten), 'discarding records preserves the browser snapshot');
+ check(api.limited.lastError === 101 && api.limited.views.length === 0 && api.limitStats.work <= 1,
+ 'browser API publishes no partial scene after budget exhaustion');
+ check(JSON.stringify(api.before) === JSON.stringify(api.recovered), 'raising the budget recovers the browser snapshot');
+
+ await page.route('**/runtime.mjs', async route => {
+ const response = await route.fetch();
+ const body = await response.text();
+ if (!body.includes('evaluationOptions = {}')) throw new Error('configuration setup no longer matches');
+ await route.fulfill({response, body:body.replace('evaluationOptions = {}', 'evaluationOptions = { workLimit: 1 }')});
+ });
+ await page.reload();
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ await apply(source);
+ await page.locator('[data-input="next"]').click();
+ const limitedUi = await state();
+ const message = await page.locator('#runtime-error').textContent();
+ check(limitedUi.states.x === 3.25 && limitedUi.views.length === 0,
+ 'configured UI keeps committed state while withholding incomplete display');
+ check(await page.locator('#runtime-error').isVisible() && message.includes('표시 오류') &&
+ message.includes('상태 변경은 유지'), 'UI distinguishes display limit from event rollback');
+ await page.screenshot({path:'output/playwright/pure-evaluation-limit.png',fullPage:true});
+ await page.unroute('**/runtime.mjs');
+ await page.reload();
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ check((await state()).views.length === 0 && (await state()).lastError === 0, 'normal configuration reloads to a clean blank work');
+ check(errors.length === 0, 'no uncaught browser errors');
+ const evidence = {utc:new Date().toISOString(),checks,api,limitedUi,message,errors,
+ uiLimitSetup:'Test-only module response overrides default workLimit to 1; production files are unchanged.',
+ screenshot:'output/playwright/pure-evaluation-limit.png',
+ browser:await page.evaluate(()=>navigator.userAgent)};
+ await page.evaluate(value=>{window.__pureEvaluationEvidence=value;},evidence);
+ return evidence;
+ } catch (error) {
+ const evidence = {failed:true,message:error.message,checks,errors};
+ await page.evaluate(value=>{window.__pureEvaluationEvidence=value;},evidence);
+ return evidence;
+ } finally {
+ await page.unroute('**/runtime.mjs');
+ page.off('pageerror',onError);
+ page.off('dialog',onDialog);
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-22-evaluation/sources/runtime.test.mjs b/prototypes/workbench/results/2026-09-22-evaluation/sources/runtime.test.mjs
new file mode 100644
index 0000000..f6e71c6
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-evaluation/sources/runtime.test.mjs
@@ -0,0 +1,197 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { createRuntime } from './web/runtime.mjs';
+
+const wasm = await readFile(new URL('./web/core.wasm', import.meta.url));
+const fresh = () => createRuntime(wasm);
+const countSource = `state total = 3
+input add = KeyA
+box bar(20, 80, total * 10, 30, "#abcdef")
+text label(20, 60, total, "Total: ")
+on add.down { total = total + 2 }
+on add.up { total = total + 1 }
+`;
+
+test('blank startup and applied empty source contain no application objects or behavior', async () => {
+ const runtime = await fresh();
+ assert.equal(runtime.currentProgram, null);
+ assert.deepEqual(runtime.snapshot().states, {});
+ assert.deepEqual(runtime.snapshot().views, []);
+ runtime.apply('');
+ runtime.frame(60);
+ assert.deepEqual(runtime.snapshot().states, {});
+ assert.deepEqual(runtime.snapshot().views, []);
+ assert.equal(runtime.snapshot().tick, 60);
+ assert.throws(() => runtime.down('jump'), /jump/);
+});
+
+test('author can add unrelated states inputs views and behavior entirely through source', async () => {
+ const runtime = await fresh();
+ runtime.apply(countSource);
+ runtime.down('add'); runtime.up('add');
+ assert.equal(runtime.snapshot().states.total, 6);
+ const added = `${countSource}\nstate radius = 12
+input inflate = KeyI
+circle bubble(240, 160, radius, "#123456")
+on inflate.down { radius = radius + total }
+on frame { radius = radius + 60 * dt }
+`;
+ runtime.apply(added);
+ runtime.down('inflate'); runtime.up('inflate'); runtime.frame(2);
+ assert.deepEqual(runtime.snapshot().states, { total: 6, radius: 20 });
+ assert.equal(runtime.snapshot().views.find(view => view.id === 'bubble').radius, 20);
+ assert.equal(runtime.snapshot().views.length, 3);
+});
+
+test('keyboard and pointer sources aggregate first down and last up; repeats are ignored', async () => {
+ const runtime = await fresh(); runtime.apply(countSource);
+ runtime.down('add', 'keyboard'); runtime.down('add', 'keyboard'); runtime.down('add', 'pointer');
+ assert.equal(runtime.snapshot().states.total, 5);
+ runtime.up('add', 'unknown'); runtime.up('add', 'keyboard');
+ assert.equal(runtime.snapshot().states.total, 5);
+ assert.deepEqual(runtime.snapshot().held, ['add']);
+ runtime.releaseAll(); runtime.up('add', 'pointer');
+ assert.equal(runtime.snapshot().states.total, 6);
+ assert.deepEqual(runtime.snapshot().held, []);
+ assert.equal(runtime.snapshot().successfulEvents, 2);
+});
+
+test('held input rejects apply without changing applied source, metadata, or values', async () => {
+ const runtime = await fresh(); runtime.apply(countSource); runtime.down('add');
+ const before = runtime.snapshot();
+ assert.throws(() => runtime.apply('state changed = 9'), /놓은 뒤/);
+ assert.deepEqual(runtime.snapshot(), before);
+ assert.equal(runtime.exportProject().source, countSource);
+ runtime.up('add'); runtime.apply('state changed = 9');
+ assert.deepEqual(runtime.snapshot().states, { changed: 9 });
+});
+
+test('invalid syntax, unknown references and capacity overflow preserve the previous program', async () => {
+ const runtime = await fresh(); runtime.apply(countSource); runtime.down('add'); runtime.up('add');
+ const before = runtime.snapshot();
+ for (const source of ['state broken =', 'state n = 0\non frame { n = missing }',
+ Array.from({ length: 33 }, (_, i) => `state v${i} = ${i}`).join('\n'), '#'.repeat(32769)]) {
+ assert.throws(() => runtime.apply(source));
+ assert.deepEqual(runtime.snapshot(), before);
+ assert.equal(runtime.exportProject().source, countSource);
+ }
+});
+
+test('state preservation follows names across reorder/add/remove; reset uses new initializers', async () => {
+ const runtime = await fresh();
+ runtime.apply('state x = 11\nstate y = 22');
+ runtime.apply('state z = 30\nstate y = 2\nstate x = 1');
+ assert.deepEqual(runtime.snapshot().states, { z: 30, y: 22, x: 11 });
+ runtime.apply('state y = 200\nstate renamed = 9');
+ assert.deepEqual(runtime.snapshot().states, { y: 22, renamed: 9 });
+ runtime.reset();
+ assert.deepEqual(runtime.snapshot().states, { y: 200, renamed: 9 });
+ assert.equal(runtime.snapshot().revision, 3);
+});
+
+test('callers cannot mutate active metadata and corrupt name-based state preservation', async () => {
+ const runtime = await fresh(); const program = runtime.apply('state x = 11\nstate y = 22');
+ assert.throws(() => program.states.reverse(), TypeError);
+ assert.throws(() => { runtime.currentProgram.states[0].name = 'y'; }, TypeError);
+ runtime.apply('state x = 0\nstate y = 0');
+ assert.deepEqual(runtime.snapshot().states, { x: 11, y: 22 });
+});
+
+test('conditions execute once, and assignments and multiple handlers follow source order', async () => {
+ const runtime = await fresh();
+ runtime.apply(`state x = 0
+state seen = 0
+on frame {
+ if x == 0 {
+ x = 1
+ seen = x + 1
+ }
+ x = x + 3
+}
+on frame { seen = seen + x }
+`);
+ runtime.frame();
+ assert.deepEqual(runtime.snapshot().states, { x: 4, seen: 6 });
+ runtime.frame();
+ assert.deepEqual(runtime.snapshot().states, { x: 7, seen: 13 });
+});
+
+test('a failed event rolls back all handlers and keeps its source diagnostic through release', async () => {
+ const runtime = await fresh();
+ const source = `state x = 7
+state y = 9
+input breakit = Space
+on breakit.down { x = 99 }
+on breakit.down {
+ y = 2
+ x = 1 / 0
+}
+`;
+ runtime.apply(source);
+ assert.equal(runtime.down('breakit'), 4);
+ assert.deepEqual(runtime.snapshot().states, { x: 7, y: 9 });
+ assert.equal(runtime.snapshot().successfulEvents, 0);
+ assert.equal(runtime.snapshot().errorLine, 7);
+ runtime.up('breakit'); runtime.frame();
+ assert.equal(runtime.snapshot().lastEvent.status, 0);
+ assert.equal(runtime.snapshot().lastFailure.phase, 'down');
+ assert.equal(runtime.snapshot().lastError, 4);
+ assert.equal(runtime.snapshot().errorLine, 7);
+ assert.throws(() => runtime.apply('bad'));
+ assert.equal(runtime.snapshot().lastError, 4);
+ runtime.apply(source.replace('1 / 0', '10'));
+ assert.equal(runtime.snapshot().lastError, 0);
+ runtime.down('breakit'); runtime.up('breakit');
+ assert.deepEqual(runtime.snapshot().states, { x: 10, y: 2 });
+});
+
+test('reset clears held inputs and latched errors; invalid frame counts have no effects', async () => {
+ const runtime = await fresh();
+ runtime.apply('state x = 5\ninput tap = Space\non tap.down { x = 1 / 0 }');
+ runtime.down('tap'); runtime.reset();
+ const before = runtime.snapshot();
+ assert.equal(before.lastError, 0); assert.equal(before.lastFailure, null);
+ assert.deepEqual(before.held, []);
+ for (const count of [-1, 601, 0.5, NaN, Infinity]) assert.throws(() => runtime.frame(count));
+ assert.deepEqual(runtime.snapshot(), before);
+});
+
+test('render evaluation is pure and invalid binding is distinct from an event rollback', async () => {
+ const runtime = await fresh(); runtime.apply(countSource); runtime.down('add'); runtime.up('add');
+ const before = runtime.snapshot();
+ for (let i = 0; i < 100; i++) assert.deepEqual(runtime.snapshot(), before);
+ runtime.apply('state n = 2\ncircle bad(20, 20, -n, "#123456")\ntext fine(0, 0, n)');
+ const result = runtime.snapshot();
+ assert.equal(result.lastError, 100); assert.equal(result.errorLine, 2);
+ assert.deepEqual(result.states, { n: 2 }); assert.equal(result.views.length, 1);
+ assert.equal(result.lastFailure, null);
+ assert.deepEqual(runtime.snapshot(), result);
+});
+
+test('independent adapters replay the same author program without sharing live state', async () => {
+ const left = await fresh(), right = await fresh();
+ left.apply(countSource); right.apply(countSource);
+ for (const runtime of [left, right]) {
+ runtime.down('add', 'keyboard'); runtime.frame(3); runtime.up('add', 'keyboard');
+ }
+ assert.deepEqual(left.snapshot(), right.snapshot());
+ left.down('add');
+ assert.equal(right.snapshot().states.total, 6);
+ assert.equal(left.snapshot().states.total, 8);
+});
+
+test('Wasm has no imports and reports fixed memory and exact active IR/state storage', async () => {
+ const module = await WebAssembly.compile(wasm);
+ assert.deepEqual(WebAssembly.Module.imports(module), []);
+ const runtime = await fresh(); const program = runtime.apply(countSource);
+ const before = runtime.snapshot().memory;
+ assert.equal(before.linearBytes, 65536);
+ assert.equal(before.instructionBytes, 16); assert.equal(before.handlerBytes, 12);
+ assert.equal(before.activeIRBytes, program.instructions.length * 16 + program.handlers.length * 12);
+ assert.equal(before.activeStateBytes, program.states.length * 8);
+ assert(before.stagingReservedBytes < before.reservedEngineBytes);
+ assert(before.reservedEngineBytes < before.linearBytes);
+ for (let i = 0; i < 20; i++) runtime.frame(600);
+ assert.deepEqual(runtime.snapshot().memory, before);
+});
diff --git a/prototypes/workbench/results/2026-09-22-evaluation/sources/web/app.js b/prototypes/workbench/results/2026-09-22-evaluation/sources/web/app.js
new file mode 100644
index 0000000..c931b1a
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-evaluation/sources/web/app.js
@@ -0,0 +1,427 @@
+import { createRuntime } from './runtime.mjs';
+
+const $ = id => document.getElementById(id);
+const source = $('source');
+const MAX_SOURCE_BYTES = 32768;
+const encoder = new TextEncoder();
+const NS = 'http://www.w3.org/2000/svg';
+const snippets = {
+ state: 'state count = 0\nstate x = 80\n',
+ input: 'input tap = Space\non tap.down {\n count = count + 1\n}\n',
+ view: 'box tile(x, 180, 40, 40, "#7ed9ae")\ntext label(40, 60, count, "Count: ")\n',
+ frame: 'on frame {\n x = x + 30 * dt\n if x > 600 { x = 0 }\n}\n',
+};
+let runtime = null;
+let appliedSource = '';
+let running = false;
+let previousTime = null;
+let accumulator = 0;
+let droppedTicks = 0;
+let diagnosticLocation = null;
+const keyboardSources = new Map();
+const pointerSources = new Map();
+const inputButtons = new Map();
+
+const bytes = text => encoder.encode(text).byteLength;
+const dirty = () => source.value !== appliedSource;
+const number = value => Number.isInteger(value) ? String(value) : Number(value.toPrecision(7)).toString();
+const formatBytes = value => `${Number(value).toLocaleString('en-US')} B`;
+
+function setDiagnostic(message, kind = '', location = null) {
+ $('diagnostic-text').textContent = message;
+ $('diagnostic').className = `diagnostic ${kind}`;
+ diagnosticLocation = location;
+ $('error-location').hidden = !location;
+ source.setAttribute('aria-invalid', kind === 'error' && location ? 'true' : 'false');
+}
+
+function cursorPosition() {
+ const before = source.value.slice(0, source.selectionStart);
+ const lines = before.split('\n');
+ $('cursor-position').textContent = `${lines.length}행 · ${lines.at(-1).length + 1}열`;
+}
+
+function draftChanged() {
+ const count = source.value.split('\n').length;
+ $('line-numbers').textContent = Array.from({ length: count }, (_, i) => i + 1).join('\n');
+ $('line-numbers').scrollTop = source.scrollTop;
+ $('source-size').textContent = `${bytes(source.value).toLocaleString('en-US')} / 32,768 bytes`;
+ $('source-size').style.color = bytes(source.value) > MAX_SOURCE_BYTES ? 'var(--danger)' : '';
+ $('draft-status').textContent = dirty() ? '미적용 변경' : runtime?.currentProgram ? '적용됨' : '빈 작업물';
+ $('draft-status').classList.toggle('dirty', dirty());
+ cursorPosition();
+}
+
+function pause() {
+ running = false;
+ previousTime = null;
+ accumulator = 0;
+ $('play').textContent = '▶ 실행';
+ $('play').setAttribute('aria-pressed', 'false');
+}
+
+function safeAction(action) {
+ if (!runtime) return;
+ try { action(); }
+ catch (error) { pause(); setDiagnostic(error.message, 'error', error.line ? { line: error.line, column: error.column || 1 } : null); }
+ refresh();
+}
+
+function releaseEverything() {
+ keyboardSources.clear();
+ pointerSources.clear();
+ if (runtime) safeAction(() => runtime.releaseAll());
+}
+
+function loseFocus() {
+ pause();
+ releaseEverything();
+ refresh();
+}
+
+function svgElement(type, attrs, text) {
+ const node = document.createElementNS(NS, type);
+ for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, String(value));
+ if (text !== undefined) node.textContent = text;
+ return node;
+}
+
+function renderScene(views) {
+ const elements = [];
+ for (const view of views) {
+ let element;
+ const attrs = { 'data-view-id': view.id };
+ if (view.type === 'box') element = svgElement('rect', { ...attrs, x: view.x, y: view.y, width: Math.max(0, view.width), height: Math.max(0, view.height), fill: view.color });
+ else if (view.type === 'circle') element = svgElement('circle', { ...attrs, cx: view.x, cy: view.y, r: Math.max(0, view.radius), fill: view.color });
+ else if (view.type === 'text') element = svgElement('text', { ...attrs, x: view.x, y: view.y, fill: '#e7ece6', 'font-family': 'ui-monospace, monospace', 'font-size': 20 }, `${view.prefix || ''}${number(view.value)}`);
+ if (element) elements.push(element);
+ }
+ $('scene-content').replaceChildren(...elements);
+ $('empty-scene').hidden = elements.length !== 0;
+ $('scene-description').textContent = elements.length ? `사용자가 만든 장면. 도형과 글자 ${elements.length}개.` : '사용자가 코드로 만든 장면. 현재 비어 있습니다.';
+}
+
+function renderStates(states) {
+ const children = [];
+ for (const [name, value] of Object.entries(states)) {
+ const holder = document.createElement('dl');
+ holder.className = 'state-value';
+ const label = document.createElement('dt');
+ label.textContent = name;
+ const amount = document.createElement('dd');
+ amount.textContent = number(value);
+ amount.dataset.state = name;
+ holder.append(label, amount);
+ children.push(holder);
+ }
+ if (!children.length) {
+ const message = document.createElement('p');
+ message.className = 'empty-note';
+ message.textContent = '선언한 상태의 값이 표시됩니다.';
+ children.push(message);
+ }
+ $('state-values').replaceChildren(...children);
+}
+
+function renderCosts(snapshot) {
+ const memory = snapshot.memory || {};
+ const metrics = runtime.currentProgram?.metrics || {};
+ const rows = [
+ ['Wasm 선형 메모리', memory.linearBytes],
+ ['코어 예약 공간', memory.reservedEngineBytes],
+ ['사용 중인 명령·핸들러', memory.activeIRBytes],
+ ['현재 상태 값', memory.activeStateBytes],
+ ['새 코드 준비 공간 (예약에 포함)', memory.stagingReservedBytes],
+ ['평가 스택 용량', memory.evalStackBytes],
+ ];
+ const children = [];
+ for (const [label, amount] of rows) {
+ if (!Number.isFinite(amount)) continue;
+ const dt = document.createElement('dt'); dt.textContent = label;
+ const dd = document.createElement('dd'); dd.textContent = formatBytes(amount);
+ children.push(dt, dd);
+ }
+ const dt = document.createElement('dt'); dt.textContent = '마지막 이벤트 실행 명령';
+ const dd = document.createElement('dd'); dd.textContent = `${snapshot.lastOps || 0}개`;
+ children.push(dt, dd);
+ const sourceBytes = metrics.sourceBytes;
+ if (Number.isFinite(sourceBytes)) {
+ const a = document.createElement('dt'); a.textContent = '적용된 소스 크기';
+ const b = document.createElement('dd'); b.textContent = formatBytes(sourceBytes);
+ children.push(a, b);
+ }
+ $('cost-values').replaceChildren(...children);
+ $('dropped-ticks').textContent = `시간 지연으로 건너뛴 프레임: ${droppedTicks}`;
+}
+
+function refresh() {
+ if (!runtime) return;
+ const snapshot = runtime.snapshot();
+ if (snapshot.lastError) pause();
+ renderScene(snapshot.views || []);
+ renderStates(snapshot.states || {});
+ const held = new Set((snapshot.held || []).map(input => typeof input === 'string' ? input : input.name));
+ for (const [name, button] of inputButtons) {
+ button.classList.toggle('held', held.has(name));
+ button.setAttribute('aria-pressed', String(held.has(name)));
+ }
+ $('scene-status').textContent = runtime.currentProgram ? (running ? '실행 중' : '정지') : '적용 전';
+ $('clock-status').textContent = `${snapshot.tick || 0} frame · ${running ? '60 Hz' : '정지'}`;
+ $('revision').textContent = `revision ${snapshot.revision || 0}`;
+ for (const id of ['play', 'step', 'reset']) $(id).disabled = !runtime.currentProgram;
+ const event = snapshot.lastEvent;
+ $('last-event').textContent = event ? `${event.name}${event.phase && event.phase !== 'frame' ? `.${event.phase}` : ''} · ${event.status ? '실패' : '완료'}${event.line ? ` · ${event.line}행` : ''}` : '아직 없음';
+ $('runtime-error').hidden = !snapshot.lastError;
+ const displayError = snapshot.lastError === 100 || snapshot.lastError === 101;
+ $('runtime-error').textContent = snapshot.lastError ? `${displayError ? '표시 오류' : '마지막 실패'} · ${snapshot.errorLine ? `${snapshot.errorLine}행 · ` : ''}${snapshot.errorMessage || `실행 오류 ${snapshot.lastError}`}${snapshot.lastError === 101 ? ' — 이번 장면을 표시하지 않았습니다. 상태 변경은 유지됩니다.' : displayError ? ' — 해당 도형을 표시할 수 없습니다.' : ' — 실패한 이벤트의 상태 변경은 취소됐습니다. 코드를 적용하거나 초깃값으로 되돌리면 이 기록을 지웁니다.'}` : '';
+ if (!displayError && snapshot.displayError?.code === 101)
+ $('runtime-error').textContent += ` · ${snapshot.displayError.message}`;
+ renderCosts(snapshot);
+ draftChanged();
+}
+
+function endPointer(event) {
+ const entry = pointerSources.get(event.pointerId);
+ if (!entry) return;
+ event.preventDefault();
+ pointerSources.delete(event.pointerId);
+ safeAction(() => runtime.up(entry.name, entry.source));
+}
+
+function rebuildInputControls() {
+ inputButtons.clear();
+ const children = [];
+ for (const input of runtime.currentProgram?.inputs || []) {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'input-button';
+ button.dataset.input = input.name;
+ button.setAttribute('aria-pressed', 'false');
+ const name = document.createElement('span'); name.textContent = input.name;
+ const key = document.createElement('kbd'); key.textContent = input.key;
+ button.append(name, key);
+ button.addEventListener('pointerdown', event => {
+ if (event.button !== 0) return;
+ event.preventDefault();
+ const identity = `pointer:${event.pointerId}`;
+ pointerSources.set(event.pointerId, { name: input.name, source: identity });
+ button.setPointerCapture(event.pointerId);
+ safeAction(() => runtime.down(input.name, identity));
+ });
+ button.addEventListener('pointerup', endPointer);
+ button.addEventListener('pointercancel', endPointer);
+ button.addEventListener('lostpointercapture', endPointer);
+ // Keyboard activation and assistive technology clicks are complete taps.
+ // Pointer clicks already delivered down/up via the handlers above.
+ button.addEventListener('click', event => {
+ if (event.detail !== 0) return;
+ safeAction(() => { runtime.down(input.name, 'activation'); runtime.up(input.name, 'activation'); });
+ });
+ inputButtons.set(input.name, button);
+ children.push(button);
+ }
+ if (!children.length) {
+ const message = document.createElement('p');
+ message.className = 'empty-note';
+ message.textContent = '입력을 선언하면 여기에 버튼이 생깁니다.';
+ children.push(message);
+ }
+ $('input-controls').replaceChildren(...children);
+}
+
+function isEditing(target) {
+ return target instanceof Element && Boolean(target.closest('textarea,input,select,button,[contenteditable="true"],[role="textbox"]'));
+}
+
+$('scene').addEventListener('pointerdown', () => $('scene').focus());
+
+document.addEventListener('keydown', event => {
+ if (!runtime || event.repeat || event.isComposing || event.ctrlKey || event.metaKey || event.altKey || isEditing(event.target)) return;
+ const input = runtime.currentProgram?.inputs.find(item => item.key === event.code);
+ if (!input) return;
+ event.preventDefault();
+ if (keyboardSources.has(event.code)) return;
+ keyboardSources.set(event.code, input.name);
+ safeAction(() => runtime.down(input.name, `keyboard:${event.code}`));
+});
+
+document.addEventListener('keyup', event => {
+ const name = keyboardSources.get(event.code);
+ if (!name) return;
+ event.preventDefault();
+ keyboardSources.delete(event.code);
+ safeAction(() => runtime.up(name, `keyboard:${event.code}`));
+});
+
+source.addEventListener('input', () => {
+ draftChanged();
+ if (diagnosticLocation) setDiagnostic('코드를 수정했습니다. 적용해서 다시 확인하세요.');
+});
+source.addEventListener('scroll', () => { $('line-numbers').scrollTop = source.scrollTop; });
+for (const name of ['click', 'keyup', 'select']) source.addEventListener(name, cursorPosition);
+source.addEventListener('keydown', event => {
+ if (event.key === 'Tab' && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
+ event.preventDefault();
+ source.setRangeText(' ', source.selectionStart, source.selectionEnd, 'end');
+ draftChanged();
+ }
+});
+
+$('apply').addEventListener('click', () => {
+ if (!runtime) return;
+ try {
+ runtime.apply(source.value);
+ appliedSource = source.value;
+ rebuildInputControls();
+ setDiagnostic('코드를 적용했습니다. 같은 이름의 상태는 유지됩니다.', 'success');
+ } catch (error) {
+ const location = error.line ? { line: error.line, column: error.column || 1 } : null;
+ setDiagnostic(`${location ? `${location.line}행 ${location.column}열 · ` : ''}${error.message} · 기존 장면은 그대로 유지됩니다.`, 'error', location);
+ }
+ refresh();
+});
+
+$('error-location').addEventListener('click', () => {
+ if (!diagnosticLocation) return;
+ const lines = source.value.split('\n');
+ const lineIndex = Math.min(lines.length - 1, diagnosticLocation.line - 1);
+ const start = lines.slice(0, lineIndex).reduce((sum, line) => sum + line.length + 1, 0);
+ const at = start + Math.min(lines[lineIndex].length, diagnosticLocation.column - 1);
+ source.focus();
+ source.setSelectionRange(at, Math.min(at + 1, source.value.length));
+ const lineHeight = parseFloat(getComputedStyle(source).lineHeight);
+ source.scrollTop = Math.max(0, lineIndex * lineHeight - source.clientHeight / 3);
+ $('line-numbers').scrollTop = source.scrollTop;
+ cursorPosition();
+});
+
+$('play').addEventListener('click', () => {
+ if (running) pause();
+ else { running = true; previousTime = null; accumulator = 0; $('play').textContent = 'Ⅱ 정지'; $('play').setAttribute('aria-pressed', 'true'); }
+ refresh();
+});
+$('step').addEventListener('click', () => { pause(); safeAction(() => runtime.frame()); });
+$('reset').addEventListener('click', () => {
+ pause(); releaseEverything(); droppedTicks = 0;
+ safeAction(() => runtime.reset());
+ setDiagnostic('적용된 코드의 초깃값으로 되돌렸습니다. 편집 중인 코드는 유지됩니다.');
+});
+
+function replaceDraft(text, message) {
+ source.value = text;
+ source.scrollTop = 0;
+ source.setSelectionRange(0, 0);
+ setDiagnostic(message);
+ draftChanged();
+}
+
+function mayReplaceDraft() {
+ return !source.value || window.confirm('현재 편집 중인 코드를 바꿀까요? 저장하지 않은 내용은 사라집니다. 실행 장면은 적용 전까지 유지됩니다.');
+}
+
+$('new').addEventListener('click', () => {
+ if (mayReplaceDraft()) replaceDraft('', '편집기만 비웠습니다. 빈 코드를 적용하면 장면도 비워집니다.');
+});
+
+for (const button of document.querySelectorAll('.insert-snippet')) {
+ button.addEventListener('click', () => {
+ const at = source.selectionStart;
+ const prefix = at > 0 && source.value[at - 1] !== '\n' ? '\n' : '';
+ source.setRangeText(prefix + snippets[button.dataset.snippet], source.selectionStart, source.selectionEnd, 'end');
+ source.focus();
+ draftChanged();
+ setDiagnostic('조각을 삽입했습니다. 이름과 동작을 원하는 대로 바꾸고 적용하세요.');
+ });
+}
+
+$('load-example').addEventListener('click', async () => {
+ if (!mayReplaceDraft()) return;
+ const selected = $('example').value;
+ const before = source.value;
+ const button = $('load-example');
+ button.disabled = true;
+ try {
+ const response = await fetch(`./examples/${selected}.pure`);
+ if (!response.ok) throw new Error(`예제를 불러오지 못했습니다 (${response.status}).`);
+ const text = await response.text();
+ if (bytes(text) > MAX_SOURCE_BYTES) throw new Error('예제가 코드 크기 제한을 넘었습니다.');
+ if (source.value !== before) throw new Error('불러오는 동안 코드가 바뀌었습니다. 변경 내용을 지키기 위해 불러오기를 취소했습니다.');
+ replaceDraft(text, '예제를 편집기에 불러왔습니다. 아직 적용하지 않았습니다.');
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+ finally { button.disabled = false; }
+});
+
+function download(filename, content, mime) {
+ const url = URL.createObjectURL(new Blob([content], { type: mime }));
+ const link = document.createElement('a');
+ link.href = url; link.download = filename;
+ document.body.append(link); link.click(); link.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ setDiagnostic('편집 중인 코드를 파일로 저장했습니다. 적용되지 않은 변경도 포함합니다.');
+}
+$('download').addEventListener('click', () => download('my-work.pure', source.value, 'text/plain;charset=utf-8'));
+$('download-project').addEventListener('click', () => download('my-work.pure.json', JSON.stringify({ format: 'pure-workbench', version: 1, source: source.value }, null, 2) + '\n', 'application/json'));
+$('upload').addEventListener('click', () => $('file-input').click());
+$('file-input').addEventListener('change', async event => {
+ const file = event.target.files[0];
+ event.target.value = '';
+ if (!file) return;
+ try {
+ if (file.size > MAX_SOURCE_BYTES * 7 + 1024) throw new Error('파일이 너무 큽니다. 코드 제한은 32 KiB입니다.');
+ const before = source.value;
+ let content = await file.text();
+ if (file.name.toLowerCase().endsWith('.json')) {
+ let project;
+ try { project = JSON.parse(content); } catch { throw new Error('올바른 JSON 파일이 아닙니다.'); }
+ if (!project || typeof project !== 'object' || Array.isArray(project) || project.format !== 'pure-workbench' || project.version !== 1 || typeof project.source !== 'string') throw new Error('Pure 작업물 형식이 아닙니다. format, version, source를 확인하세요.');
+ content = project.source;
+ }
+ if (bytes(content) > MAX_SOURCE_BYTES) throw new Error('코드가 32 KiB 제한을 넘었습니다.');
+ if (source.value !== before) throw new Error('파일을 읽는 동안 코드가 바뀌었습니다. 불러오기를 다시 시도하세요.');
+ if (mayReplaceDraft()) replaceDraft(content, `${file.name}을 편집기에 불러왔습니다. 아직 적용하지 않았습니다.`);
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+});
+
+window.addEventListener('blur', loseFocus);
+document.addEventListener('visibilitychange', () => { if (document.hidden) loseFocus(); });
+window.addEventListener('beforeunload', event => {
+ // Applying is not persistence: even applied code can be lost when leaving.
+ if (source.value || appliedSource) { event.preventDefault(); event.returnValue = ''; }
+});
+
+function animate(time) {
+ if (running && runtime) {
+ if (previousTime !== null) {
+ accumulator += Math.max(0, time - previousTime);
+ const wanted = Math.floor(accumulator / (1000 / 60));
+ if (wanted > 0) {
+ const steps = Math.min(6, wanted);
+ accumulator -= wanted * (1000 / 60);
+ droppedTicks += wanted - steps;
+ safeAction(() => runtime.frame(steps));
+ }
+ }
+ previousTime = running ? time : null;
+ }
+ requestAnimationFrame(animate);
+}
+
+Object.defineProperty(window, 'pureWorkbench', {
+ configurable: false,
+ writable: false,
+ value: Object.freeze({ snapshot: () => structuredClone({ ...(runtime?.snapshot() || {}), ready: Boolean(runtime), source: source.value, appliedSource, dirty: dirty(), running, droppedTicks, diagnostic: $('diagnostic-text').textContent }) }),
+});
+
+draftChanged();
+requestAnimationFrame(animate);
+try {
+ runtime = await createRuntime();
+ $('ready-status').textContent = '실행기 준비됨';
+ $('ready-dot').classList.add('ready');
+ $('apply').disabled = false;
+ refresh();
+} catch (error) {
+ $('ready-status').textContent = '실행기 준비 실패';
+ $('ready-dot').classList.add('failed');
+ setDiagnostic(`실행기를 열지 못했습니다. ${error.message}`, 'error');
+}
diff --git a/prototypes/workbench/results/2026-09-22-evaluation/sources/web/baseline.mjs b/prototypes/workbench/results/2026-09-22-evaluation/sources/web/baseline.mjs
new file mode 100644
index 0000000..3873e31
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-evaluation/sources/web/baseline.mjs
@@ -0,0 +1,68 @@
+// Handwritten JavaScript reference for examples/charge.pure.
+// This fixed app has no source compiler, live apply, or state-name migration.
+// Its scene and behavior must be changed by editing this JavaScript file.
+export function createChargeModel() {
+ const held = new Set();
+ let holding = 0, charge = 0, y = 240, velocity = 0, tick = 0;
+ const checkInput = name => {
+ if (name !== 'jump') throw new Error(`Unknown input: ${name}`);
+ };
+
+ const model = {
+ down(name = 'jump', source = 'external') {
+ checkInput(name);
+ const wasDown = held.size > 0;
+ held.add(source);
+ if (!wasDown && y >= 240) {
+ holding = 1;
+ charge = 0;
+ }
+ return 0;
+ },
+ up(name = 'jump', source = 'external') {
+ checkInput(name);
+ if (!held.delete(source) || held.size) return 0;
+ if (holding) {
+ velocity = -(160 + charge * 300);
+ holding = 0;
+ charge = 0;
+ }
+ return 0;
+ },
+ frame(count = 1) {
+ if (!Number.isInteger(count) || count < 0 || count > 600) {
+ throw new Error('Frame count must be an integer from 0 through 600.');
+ }
+ const dt = 1 / 60;
+ for (let frame = 0; frame < count; frame++) {
+ if (holding) charge = Math.min(Math.max(charge + dt, 0), 1);
+ velocity = velocity + 600 * dt;
+ y = y + velocity * dt;
+ if (y >= 240) { y = 240; velocity = 0; }
+ tick++;
+ }
+ return 0;
+ },
+ releaseAll() {
+ for (const source of [...held]) model.up('jump', source);
+ return 0;
+ },
+ reset() {
+ held.clear();
+ holding = 0; charge = 0; y = 240; velocity = 0; tick = 0;
+ },
+ snapshot() {
+ return {
+ states: { holding, charge, y, velocity }, tick,
+ held: held.size ? ['jump'] : [],
+ views: [
+ { id: 'floor', type: 'box', x: 40, y: 261, width: 560, height: 4, color: '#356548' },
+ { id: 'actor', type: 'circle', x: 180, y, radius: 20, color: '#c78638' },
+ { id: 'meter', type: 'box', x: 80, y: 80, width: charge * 200, height: 12, color: '#c78638' },
+ { id: 'amount', type: 'text', x: 80, y: 60, value: charge, prefix: 'Charge: ' },
+ ],
+ };
+ },
+ };
+ return Object.freeze(model);
+}
diff --git a/prototypes/workbench/results/2026-09-22-evaluation/sources/web/compiler.mjs b/prototypes/workbench/results/2026-09-22-evaluation/sources/web/compiler.mjs
new file mode 100644
index 0000000..016ea9e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-evaluation/sources/web/compiler.mjs
@@ -0,0 +1,303 @@
+// Pure's experimental scalar interaction language. No host code is evaluated.
+const LIMITS = Object.freeze({ sourceBytes: 32768, states: 32, inputs: 8,
+ views: 24, handlers: 64, instructions: 512, depth: 64, stack: 64 });
+const RESERVED = new Set(['frame', 'dt', 'min', 'max', 'clamp']);
+const BINARY = new Map([
+ ['||', [1, 15]], ['&&', [2, 14]], ['==', [3, 12]], ['!=', [3, 13]],
+ ['<', [4, 8]], ['<=', [4, 9]], ['>', [4, 10]], ['>=', [4, 11]],
+ ['+', [5, 3]], ['-', [5, 4]], ['*', [6, 5]], ['/', [6, 6]],
+]);
+const FUNCTIONS = { min: [2, 17], max: [2, 18], clamp: [3, 19] };
+const VIEW_FIELDS = { box: ['x', 'y', 'width', 'height'],
+ circle: ['x', 'y', 'radius'], text: ['x', 'y', 'value'] };
+
+function fail(token, message) {
+ const error = new Error(`${message} (${token.line}:${token.column})`);
+ error.line = token.line;
+ error.column = token.column;
+ throw error;
+}
+
+function lex(source) {
+ const tokens = [];
+ let offset = 0, line = 1, column = 1;
+ const advance = () => {
+ const char = source[offset++];
+ if (char === '\n') { line++; column = 1; } else column++;
+ return char;
+ };
+ while (offset < source.length) {
+ const char = source[offset];
+ if (char === ' ' || char === '\t' || char === '\r') { advance(); continue; }
+ if (char === '#') { while (offset < source.length && source[offset] !== '\n') advance(); continue; }
+ const token = { line, column };
+ if (char === '\n') { advance(); tokens.push({ ...token, kind: 'nl' }); continue; }
+ if (char === '"') {
+ advance();
+ let value = '', closed = false;
+ while (offset < source.length) {
+ const next = advance();
+ if (next === '"') { closed = true; break; }
+ if (next === '\n' || next === '\r') fail(token, 'Strings must fit on one line');
+ if (next === '\\') {
+ const escaped = advance();
+ const escapes = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' };
+ if (!Object.hasOwn(escapes, escaped)) fail(token, 'Unsupported string escape');
+ value += escapes[escaped];
+ } else value += next;
+ }
+ if (!closed) fail(token, 'Unterminated string');
+ tokens.push({ ...token, kind: 'string', value }); continue;
+ }
+ const remaining = source.slice(offset);
+ const number = remaining.match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/);
+ if (number) {
+ const value = Number(number[0]);
+ if (!Number.isFinite(value)) fail(token, 'Number must be finite');
+ for (let i = 0; i < number[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'number', value }); continue;
+ }
+ const name = remaining.match(/^[A-Za-z_][A-Za-z_0-9]*/);
+ if (name) {
+ for (let i = 0; i < name[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'name', value: name[0] }); continue;
+ }
+ const pair = source.slice(offset, offset + 2);
+ if (['<=', '>=', '==', '!=', '&&', '||'].includes(pair)) {
+ advance(); advance(); tokens.push({ ...token, kind: pair }); continue;
+ }
+ if ('=+-*/!<>(){},.;'.includes(char)) {
+ advance(); tokens.push({ ...token, kind: char }); continue;
+ }
+ fail(token, `Unexpected character ${JSON.stringify(char)}`);
+ }
+ tokens.push({ kind: 'eof', line, column });
+ return tokens;
+}
+
+class Parser {
+ constructor(tokens) { this.tokens = tokens; this.position = 0; this.depth = 0; }
+ peek() { return this.tokens[this.position]; }
+ take() { return this.tokens[this.position++]; }
+ is(kind, value) { return this.peek().kind === kind && (value === undefined || this.peek().value === value); }
+ expect(kind, description = kind) {
+ if (!this.is(kind)) fail(this.peek(), `Expected ${description}`);
+ return this.take();
+ }
+ newlines() { while (this.is('nl')) this.take(); }
+ separators() { while (this.is('nl') || this.is(';')) this.take(); }
+ end() {
+ if (!['nl', ';', '}', 'eof'].includes(this.peek().kind)) fail(this.peek(), 'Expected newline or semicolon');
+ this.separators();
+ }
+ node(token, properties, children = []) {
+ const depth = 1 + Math.max(0, ...children.map(child => child.depth));
+ if (depth > LIMITS.depth) fail(token, 'Expression nesting exceeds 64');
+ return { token, depth, ...properties };
+ }
+ expression(minimum = 0, multiline = false) {
+ if (++this.depth > LIMITS.depth) fail(this.peek(), 'Expression nesting exceeds 64');
+ if (multiline) this.newlines();
+ const token = this.take();
+ let left;
+ if (token.kind === 'number') left = this.node(token, { kind: 'constant', value: token.value });
+ else if (token.kind === '-' || token.kind === '!') {
+ const child = this.expression(7, multiline);
+ left = this.node(token, { kind: 'unary', op: token.kind === '-' ? 7 : 16, child }, [child]);
+ } else if (token.kind === '(') {
+ left = this.expression(0, true); this.newlines(); this.expect(')');
+ } else if (token.kind === 'name') {
+ if (Object.hasOwn(FUNCTIONS, token.value) && this.is('(')) {
+ this.take();
+ const args = [];
+ this.newlines();
+ if (!this.is(')')) {
+ for (;;) {
+ args.push(this.expression(0, true)); this.newlines();
+ if (!this.is(',')) break;
+ this.take();
+ }
+ }
+ this.expect(')');
+ if (args.length !== FUNCTIONS[token.value][0]) fail(token, `${token.value} expects ${FUNCTIONS[token.value][0]} arguments`);
+ left = this.node(token, { kind: 'call', op: FUNCTIONS[token.value][1], args }, args);
+ } else left = this.node(token, { kind: 'reference', name: token.value });
+ } else fail(token, 'Expected a number, state, or expression');
+ for (;;) {
+ if (multiline) this.newlines();
+ const entry = BINARY.get(this.peek().kind);
+ if (!entry || entry[0] < minimum) break;
+ const operator = this.take();
+ const right = this.expression(entry[0] + 1, multiline);
+ left = this.node(operator, { kind: 'binary', op: entry[1], left, right }, [left, right]);
+ }
+ this.depth--;
+ return left;
+ }
+ block(depth = 0) {
+ if (depth >= LIMITS.depth) fail(this.peek(), 'Handler nesting exceeds 64');
+ this.newlines(); this.expect('{'); this.separators();
+ const statements = [];
+ while (!this.is('}')) {
+ const token = this.expect('name', 'assignment, if, or closing brace');
+ if (token.value === 'if' && !this.is('=')) {
+ const condition = this.expression();
+ const body = this.block(depth + 1);
+ statements.push({ kind: 'if', token, condition, body });
+ } else {
+ this.expect('=');
+ statements.push({ kind: 'assign', token, name: token.value, expression: this.expression() });
+ }
+ this.end();
+ }
+ this.take();
+ return statements;
+ }
+ parse() {
+ const declarations = [];
+ this.separators();
+ while (!this.is('eof')) {
+ const token = this.expect('name', 'declaration');
+ const kind = token.value;
+ if (kind === 'state') {
+ const name = this.expect('name', 'state name'); this.expect('=');
+ let sign = 1;
+ if (this.is('-') || this.is('+')) sign = this.take().kind === '-' ? -1 : 1;
+ const value = this.expect('number', 'numeric state initializer');
+ declarations.push({ kind, token, name, initial: sign * value.value });
+ } else if (kind === 'input') {
+ const name = this.expect('name', 'input name'); this.expect('=');
+ const key = this.expect('name', 'keyboard code');
+ if (!/^(?:Space|Enter|Arrow(?:Left|Right|Up|Down)|Key[A-Z]|Digit[0-9])$/.test(key.value)) fail(key, 'Unsupported keyboard code');
+ declarations.push({ kind, token, name, key });
+ } else if (Object.hasOwn(VIEW_FIELDS, kind)) {
+ const name = this.expect('name', 'view ID'); this.expect('(');
+ const expressions = [];
+ for (let i = 0; i < VIEW_FIELDS[kind].length; i++) {
+ if (i) { this.newlines(); this.expect(','); }
+ expressions.push(this.expression(0, true));
+ }
+ this.newlines();
+ let color, prefix = '';
+ if (kind !== 'text') {
+ this.expect(','); this.newlines();
+ const value = this.expect('string', 'literal hex color');
+ if (!/^#[0-9a-fA-F]{6}$/.test(value.value)) fail(value, 'Color must use six hexadecimal digits, such as "#356548"');
+ color = value.value;
+ } else if (this.is(',')) { this.take(); this.newlines(); prefix = this.expect('string', 'literal text prefix').value; }
+ this.newlines(); this.expect(')');
+ declarations.push({ kind: 'view', type: kind, token, name, expressions, color, prefix });
+ } else if (kind === 'on') {
+ const event = this.expect('name', 'input name or frame');
+ let edge;
+ if (event.value !== 'frame') {
+ this.expect('.'); edge = this.expect('name', 'down or up');
+ if (edge.value !== 'down' && edge.value !== 'up') fail(edge, 'Expected down or up');
+ }
+ declarations.push({ kind: 'handler', token, event, edge, body: this.block() });
+ } else fail(token, `Unknown declaration ${kind}`);
+ this.end();
+ }
+ return declarations;
+ }
+}
+
+export function compile(source) {
+ if (typeof source !== 'string') fail({ line: 1, column: 1 }, 'Source must be a string');
+ const sourceBytes = new TextEncoder().encode(source).length;
+ if (sourceBytes > LIMITS.sourceBytes) fail({ line: 1, column: 1 }, 'Source exceeds 32768 UTF-8 bytes');
+ const declarations = new Parser(lex(source)).parse();
+ const program = { states: [], inputs: [], instructions: [], handlers: [], views: [], metrics: {} };
+ const states = new Map(), inputs = new Map(), views = new Set(), keys = new Set();
+ let handlerCount = 0;
+ for (const declaration of declarations) {
+ const { kind, name, token } = declaration;
+ if (name && RESERVED.has(name.value)) fail(name, `Reserved name ${name.value}`);
+ if (kind === 'state' || kind === 'input') {
+ const symbols = kind === 'state' ? states : inputs;
+ if (symbols.has(name.value)) fail(name, `Duplicate ${kind} ${name.value}`);
+ if (symbols.size >= LIMITS[`${kind}s`]) fail(name, `At most ${LIMITS[`${kind}s`]} ${kind}s are allowed`);
+ symbols.set(name.value, symbols.size);
+ if (kind === 'state') program.states.push({ name: name.value, initial: declaration.initial, line: token.line });
+ else {
+ if (keys.has(declaration.key.value)) fail(declaration.key, `Duplicate keyboard code ${declaration.key.value}`);
+ keys.add(declaration.key.value);
+ program.inputs.push({ name: name.value, key: declaration.key.value, line: token.line });
+ }
+ } else if (kind === 'view') {
+ if (views.has(name.value)) fail(name, `Duplicate view ID ${name.value}`);
+ if (views.size >= LIMITS.views) fail(name, 'At most 24 views are allowed');
+ views.add(name.value);
+ } else if (++handlerCount > LIMITS.handlers) fail(token, 'At most 64 handlers are allowed');
+ }
+ let stack = 0, maxStack = 0;
+ const emit = (op, token, arg = 0, value = 0, delta = 0) => {
+ if (program.instructions.length >= LIMITS.instructions) fail(token, 'At most 512 instructions are allowed');
+ stack += delta;
+ if (stack < 0 || stack > LIMITS.stack) fail(token, 'Expression stack exceeds 64');
+ maxStack = Math.max(maxStack, stack);
+ program.instructions.push({ op, arg, value, line: token.line });
+ };
+ const expression = (node, binding = false) => {
+ const { token } = node;
+ if (node.kind === 'constant') emit(0, token, 0, node.value, 1);
+ else if (node.kind === 'reference') {
+ if (node.name === 'dt') {
+ if (binding) fail(token, 'dt is only available in handlers');
+ emit(2, token, 0, 0, 1);
+ } else {
+ if (!states.has(node.name)) fail(token, `Unknown state ${node.name}`);
+ emit(1, token, states.get(node.name), 0, 1);
+ }
+ } else if (node.kind === 'unary') { expression(node.child, binding); emit(node.op, token); }
+ else if (node.kind === 'binary') {
+ expression(node.left, binding); expression(node.right, binding); emit(node.op, token, 0, 0, -1);
+ } else {
+ for (const arg of node.args) expression(arg, binding);
+ emit(node.op, token, 0, 0, 1 - node.args.length);
+ }
+ };
+ const statements = body => {
+ for (const statement of body) {
+ if (statement.kind === 'assign') {
+ if (!states.has(statement.name)) fail(statement.token, `Unknown state ${statement.name}`);
+ expression(statement.expression);
+ emit(20, statement.token, states.get(statement.name), 0, -1);
+ } else {
+ expression(statement.condition);
+ const branch = program.instructions.length;
+ emit(21, statement.token, 0, 0, -1);
+ statements(statement.body);
+ program.instructions[branch].arg = program.instructions.length;
+ }
+ }
+ };
+ for (const declaration of declarations) {
+ if (declaration.kind === 'handler') {
+ let event = 0;
+ if (declaration.event.value !== 'frame') {
+ if (!inputs.has(declaration.event.value)) fail(declaration.event, `Unknown input ${declaration.event.value}`);
+ event = 1 + inputs.get(declaration.event.value) * 2 + (declaration.edge.value === 'up' ? 1 : 0);
+ }
+ const start = program.instructions.length;
+ stack = 0; statements(declaration.body);
+ program.handlers.push({ event, start, length: program.instructions.length - start, line: declaration.token.line });
+ } else if (declaration.kind === 'view') {
+ const view = { id: declaration.name.value, type: declaration.type, line: declaration.token.line, bindings: {} };
+ if (view.type === 'text') view.prefix = declaration.prefix;
+ else view.color = declaration.color;
+ declaration.expressions.forEach((node, index) => {
+ const start = program.instructions.length;
+ stack = 0; expression(node, true);
+ view.bindings[VIEW_FIELDS[view.type][index]] = { start, length: program.instructions.length - start };
+ });
+ program.views.push(view);
+ }
+ }
+ program.metrics = { sourceBytes, sourceLines: source === '' ? 0 : source.split('\n').length,
+ states: states.size, inputs: inputs.size, views: views.size,
+ handlers: program.handlers.length, instructions: program.instructions.length, maxStack };
+ return program;
+}
+
+export { LIMITS };
diff --git a/prototypes/workbench/results/2026-09-22-evaluation/sources/web/core.wasm b/prototypes/workbench/results/2026-09-22-evaluation/sources/web/core.wasm
new file mode 100755
index 0000000..69c75ec
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-evaluation/sources/web/core.wasm
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-22-evaluation/sources/web/runtime.mjs b/prototypes/workbench/results/2026-09-22-evaluation/sources/web/runtime.mjs
new file mode 100644
index 0000000..47560c6
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-evaluation/sources/web/runtime.mjs
@@ -0,0 +1,197 @@
+import { compile } from './compiler.mjs';
+
+const messages = [
+ '', '실행기의 용량 한도를 넘었습니다.', '실행 정의가 유효하지 않습니다.',
+ '계산 스택의 범위를 벗어났습니다.', '0으로 나눌 수 없습니다.',
+ '계산 결과가 유한한 수의 범위를 벗어났습니다.', '조건 분기 범위가 유효하지 않습니다.',
+ '존재하지 않는 상태를 참조했습니다.', '시간 간격이 유효하지 않습니다.',
+ '정의되지 않은 입력 사건입니다.',
+];
+const sourceLimit = 32768;
+
+function problem(message, line = null, column = null) {
+ return Object.assign(new Error(message), { line, column });
+}
+
+function freezeProgram(value) {
+ if (value && typeof value === 'object') {
+ for (const child of Object.values(value)) freezeProgram(child);
+ Object.freeze(value);
+ }
+ return value;
+}
+
+export async function createRuntime(wasmBytes, evaluationOptions = {}) {
+ if (wasmBytes === undefined) {
+ const response = await fetch(new URL('./core.wasm', import.meta.url), { cache: 'no-store' });
+ if (!response.ok) throw new Error(`실행기를 불러오지 못했습니다 (HTTP ${response.status}).`);
+ wasmBytes = await response.arrayBuffer();
+ }
+ const module = await WebAssembly.compile(wasmBytes);
+ if (WebAssembly.Module.imports(module).length) throw new Error('이 실행기는 호스트 함수 없이 동작해야 합니다.');
+ const { exports: core } = await WebAssembly.instantiate(module, {});
+ if (typeof core.eval_bounded !== 'function') throw new Error('실행기 버전이 맞지 않습니다. 새로고침하거나 core.wasm을 다시 빌드하세요.');
+ core.reset_all();
+ let program = null;
+ let appliedSource = '';
+ let lastEvent = null;
+ // Keep a failed event visible through its release/focus-loss cleanup.
+ // The active program can clear this diagnostic by apply or explicit reset.
+ let lastFailure = null;
+ const held = new Map();
+ let evaluationPolicy = { cacheEntries: 0, historyEntries: 8, workLimit: 2048 };
+
+ function checked(code, line = null) {
+ if (code !== 0) throw problem(messages[code] || `실행기 오류 ${code}`, line);
+ }
+ function inputIndex(name) {
+ const index = program?.inputs.findIndex((input) => input.name === name) ?? -1;
+ if (index < 0) throw new Error(`정의되지 않은 입력: ${name}`);
+ return index;
+ }
+ function dispatch(event, name, phase, source) {
+ const status = core.dispatch(event, event === 0 ? 1 / 60 : 0);
+ const pc = core.info(2) >>> 0;
+ lastEvent = { name, phase, source, status,
+ line: program?.instructions[pc]?.line ?? null, ops: core.info(3) >>> 0 };
+ if (status) lastFailure = { ...lastEvent };
+ return status;
+ }
+
+ const api = {
+ get currentProgram() { return program; },
+ configureEvaluation({ cacheEntries = evaluationPolicy.cacheEntries,
+ historyEntries = evaluationPolicy.historyEntries, workLimit = evaluationPolicy.workLimit } = {}) {
+ for (const [name, value, maximum] of [['cacheEntries', cacheEntries, 32],
+ ['historyEntries', historyEntries, 8], ['workLimit', workLimit, 0xffffffff]]) {
+ if (!Number.isInteger(value) || value < 0 || value > maximum)
+ throw new Error(`${name}: 0~${maximum} 사이의 정수여야 합니다.`);
+ }
+ checked(core.configure_evaluation(cacheEntries, historyEntries));
+ evaluationPolicy = { cacheEntries, historyEntries, workLimit };
+ return { ...evaluationPolicy };
+ },
+ forgetEvaluation(part = 'all') {
+ const flags = part === 'all' ? 3 : part === 'values' ? 1 : part === 'history' ? 2 : undefined;
+ if (flags === undefined) throw new Error('삭제할 기록은 values, history, all 중 하나여야 합니다.');
+ core.forget_evaluation(flags);
+ },
+ evaluationStats() {
+ return { ...evaluationPolicy, work: core.evaluation_info(1),
+ instructions: core.evaluation_info(2), cacheHits: core.evaluation_info(3),
+ reservedBytes: core.evaluation_info(6) };
+ },
+ apply(source) {
+ if (typeof source !== 'string' || new TextEncoder().encode(source).length > sourceLimit) {
+ throw problem('정의는 UTF-8 기준 32,768바이트 이하의 텍스트여야 합니다.');
+ }
+ if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 적용하세요. 현재 작업은 그대로 유지합니다.');
+ const next = freezeProgram(compile(source));
+ const oldIds = new Map(program?.states.map((state, index) => [state.name, index]) ?? []);
+ checked(core.begin_program(next.states.length, next.inputs.length));
+ for (let index = 0; index < next.states.length; index++) {
+ const state = next.states[index];
+ checked(core.put_state(index, state.initial, oldIds.get(state.name) ?? -1), state.line);
+ }
+ for (const op of next.instructions) checked(core.put_instruction(op.op, op.arg, op.value), op.line);
+ for (const handler of next.handlers) checked(core.put_handler(handler.event, handler.start, handler.length), handler.line);
+ checked(core.commit_program());
+ program = next;
+ appliedSource = source;
+ lastEvent = null;
+ lastFailure = null;
+ return next;
+ },
+ reset() {
+ held.clear();
+ core.reset_state();
+ lastEvent = null;
+ lastFailure = null;
+ },
+ frame(count = 1) {
+ if (!Number.isInteger(count) || count < 0 || count > 600) throw new Error('프레임 수는 0~600 사이의 정수여야 합니다.');
+ if (!program) return 0;
+ for (let i = 0; i < count; i++) {
+ const code = dispatch(0, 'frame', 'frame', 'clock');
+ if (code !== 0) return code;
+ }
+ return 0;
+ },
+ down(name, source = 'external') {
+ const index = inputIndex(name);
+ const sources = held.get(name) || new Set();
+ if (sources.has(source)) return 0;
+ const wasDown = sources.size > 0;
+ sources.add(source);
+ held.set(name, sources);
+ return wasDown ? 0 : dispatch(1 + index * 2, name, 'down', source);
+ },
+ up(name, source = 'external') {
+ const index = inputIndex(name);
+ const sources = held.get(name);
+ if (!sources?.delete(source)) return 0;
+ if (sources.size) return 0;
+ held.delete(name);
+ return dispatch(2 + index * 2, name, 'up', source);
+ },
+ releaseAll() {
+ let firstError = 0;
+ for (const [name, sources] of [...held]) {
+ for (const source of [...sources]) {
+ const status = api.up(name, source);
+ if (!firstError) firstError = status;
+ }
+ }
+ return firstError;
+ },
+ exportProject() {
+ return { format: 'pure-workbench', version: 1, source: appliedSource };
+ },
+ snapshot() {
+ core.begin_evaluation(evaluationPolicy.workLimit);
+ const states = Object.fromEntries((program?.states ?? []).map((state, index) => [state.name, core.get_state(index)]));
+ const views = [];
+ let viewError = null;
+ viewLoop: for (const view of program?.views ?? []) {
+ const rendered = { id: view.id, type: view.type, color: view.color, prefix: view.prefix };
+ let valid = true;
+ for (const [name, binding] of Object.entries(view.bindings)) {
+ const value = core.eval_bounded(binding.start, binding.length);
+ if (core.evaluation_info(0) === 10) {
+ // A snapshot must not mix complete views with budget-truncated ones.
+ views.length = 0;
+ viewError = { code: 101, line: view.line,
+ message: '표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.' };
+ break viewLoop;
+ }
+ if (!Number.isFinite(value) || (['width', 'height', 'radius'].includes(name) && value < 0)) {
+ viewError ??= { line: view.line, message: `${view.id}.${name}: 표시할 수 없는 값입니다. 0 나눗셈이나 음수 크기를 확인하세요.` };
+ valid = false;
+ }
+ rendered[name] = value;
+ }
+ if (valid) views.push(rendered);
+ }
+ const executionError = lastFailure?.status || 0;
+ return {
+ states, views, tick: core.info(0) >>> 0, revision: core.info(4) >>> 0,
+ displayError: viewError ? { code: viewError.code || 100, ...viewError } : null,
+ lastError: executionError || (viewError ? viewError.code || 100 : 0),
+ errorMessage: executionError ? messages[executionError] || `실행 오류 ${executionError}` : viewError?.message || '',
+ errorLine: executionError ? lastFailure.line : viewError?.line ?? null,
+ lastOps: core.info(3) >>> 0, successfulEvents: core.info(9) >>> 0,
+ held: [...held.keys()], lastEvent: lastEvent ? { ...lastEvent } : null,
+ lastFailure: lastFailure ? { ...lastFailure } : null,
+ memory: {
+ linearBytes: core.memory.buffer.byteLength,
+ reservedEngineBytes: core.layout(0), programReservedBytes: core.layout(1),
+ instructionBytes: core.layout(2), handlerBytes: core.layout(3),
+ activeIRBytes: core.layout(4), activeStateBytes: core.layout(5),
+ stagingReservedBytes: core.layout(6), evalStackBytes: core.layout(7),
+ },
+ };
+ },
+ };
+ api.configureEvaluation(evaluationOptions);
+ return Object.freeze(api);
+}
diff --git a/prototypes/workbench/results/2026-09-22-evaluation/verification.json b/prototypes/workbench/results/2026-09-22-evaluation/verification.json
new file mode 100644
index 0000000..070da3d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-evaluation/verification.json
@@ -0,0 +1,68 @@
+{
+ "utc": "2026-09-22T13:59:18.937195+00:00",
+ "build": {
+ "builtUtc": "2026-09-22T13:55:49.821902+00:00",
+ "zigVersion": "0.15.2",
+ "sourceSha256": "dfb9fd7f3862806c2bd88ce1c668b795d9df196e1f2e50cceff1b5c4809273df",
+ "wasmSha256": "b79eecefba5e7da0f5dd945fa1df196ee627803243a430517f5c49c3a5e54ce1",
+ "evaluationSourceSha256": "2192e563385105f130d373b2e8724ea234c51e2909c7dbfc38e72acc3b70b2ed",
+ "wasmFileBytes": 53125,
+ "flags": [
+ "-target",
+ "wasm32-freestanding",
+ "-O",
+ "ReleaseSafe",
+ "-fstrip",
+ "-fno-entry",
+ "-rdynamic",
+ "--export-memory",
+ "--stack",
+ "16384",
+ "--initial-memory=65536",
+ "--max-memory=65536"
+ ],
+ "nativeTests": "1/20 core.test.blank program, no hidden application behavior, and reset...OK\n2/20 core.test.arbitrary input counter and unused events...OK\n3/20 core.test.charge and release behavior exists only in the authored instruction program...OK\n4/20 core.test.forward condition executes once and assignments and handlers are ordered...OK\n5/20 core.test.branch to program end is valid...OK\n6/20 core.test.one event rolls back writes from all handlers on arithmetic failure...OK\n7/20 core.test.nonfinite arithmetic and invalid clamp fail atomically...OK\n8/20 core.test.stack underflow overflow and leftover values reject the event...OK\n9/20 core.test.failed candidates leave active values program revision and diagnostics unchanged...OK\n10/20 core.test.state preservation mapping reads active values at commit and reset uses new initials...OK\n11/20 core.test.capacity boundaries and sticky load errors...OK\n12/20 core.test.invalid declaration values state mappings event ids and handler ranges...OK\n13/20 core.test.backward self out-of-handler branches and stores without handlers are rejected...OK\n14/20 core.test.event inputs and dt enforce deterministic frame contract...OK\n15/20 core.test.binding evaluation is pure on both success and failure...OK\n16/20 core.test.arithmetic comparison logical min max and clamp expression semantics...OK\n17/20 core.test.diagnostic counters wrap without affecting scalar values...OK\n18/20 core.test.memory accounting separates reserved storage used IR and stack capacity...OK\n19/20 evaluation.test.record loss cannot authorize an old memo; unrelated changes can...OK\n20/20 evaluation.test.budget, collisions, invalid settings, and disabled history are conservative...OK\nAll 20 tests passed.\n"
+ },
+ "javascript": [
+ {
+ "file": "compiler.test.mjs",
+ "tests": 13,
+ "output": "✔ a blank document is an empty program with no implied behavior (4.767599ms)\n✔ forward references and separate state/input namespaces preserve declaration IDs (2.310706ms)\n✔ ordered assignments, repeated handlers, and condition evaluation once (1.271946ms)\n✔ nested branches use absolute forward targets and skip correct bodies (0.732297ms)\n✔ operator precedence, associativity, unary and builtin function lowering (2.140688ms)\n✔ multiline parentheses, scientific literals, comments, and string escapes (0.846335ms)\n✔ all instruction fields are numeric and carry original source line (1.755169ms)\n✔ contextual declaration words remain valid names outside reserved builtins (0.726316ms)\n✔ unknown and reserved symbols have useful one-based locations (1.823799ms)\n✔ duplicates, unsupported keys, initializers, and invalid syntax are rejected (1.598724ms)\n✔ document, declaration, instruction, and nesting limits reject overflow (7.090898ms)\n✔ every supported input maps to a bounded ordered event ID (0.835279ms)\n✔ optional examples compile and charge behavior is entirely authored (12.957276ms)\nℹ tests 13\nℹ suites 0\nℹ pass 13\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 48.386655\n",
+ "sha256": "6aa442d9d1eeb69453082f1780ff4597e46b1fc09d8ba3cf955545bc94d81431"
+ },
+ {
+ "file": "runtime.test.mjs",
+ "tests": 13,
+ "output": "✔ blank startup and applied empty source contain no application objects or behavior (11.310311ms)\n✔ author can add unrelated states inputs views and behavior entirely through source (6.741194ms)\n✔ keyboard and pointer sources aggregate first down and last up; repeats are ignored (3.252523ms)\n✔ held input rejects apply without changing applied source, metadata, or values (2.028058ms)\n✔ invalid syntax, unknown references and capacity overflow preserve the previous program (2.550577ms)\n✔ state preservation follows names across reorder/add/remove; reset uses new initializers (1.55202ms)\n✔ callers cannot mutate active metadata and corrupt name-based state preservation (1.051539ms)\n✔ conditions execute once, and assignments and multiple handlers follow source order (1.328224ms)\n✔ a failed event rolls back all handlers and keeps its source diagnostic through release (2.714633ms)\n✔ reset clears held inputs and latched errors; invalid frame counts have no effects (1.79928ms)\n✔ render evaluation is pure and invalid binding is distinct from an event rollback (4.471172ms)\n✔ independent adapters replay the same author program without sharing live state (1.705539ms)\n✔ Wasm has no imports and reports fixed memory and exact active IR/state storage (7.750899ms)\nℹ tests 13\nℹ suites 0\nℹ pass 13\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 64.820621\n",
+ "sha256": "5fe66115aab0c178a283dcb592376a2e7209308368004865e6c0a1cc58fa7f62"
+ },
+ {
+ "file": "baseline.test.mjs",
+ "tests": 7,
+ "output": "✔ reference begins with the authored states and matching scene (2.672337ms)\n✔ press, hold, release, and frame order produce exact expected values (0.589537ms)\n✔ charge clamps at one and explicit floor restores rest (1.429872ms)\n✔ repeat and concurrent physical sources produce only logical edges (0.524167ms)\n✔ airborne presses cannot charge or cause another jump (0.285575ms)\n✔ releaseAll emits release and reset clears latches without a jump (0.576501ms)\n✔ unknown inputs and invalid frame counts reject without mutation (0.846019ms)\nℹ tests 7\nℹ suites 0\nℹ pass 7\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 17.303448\n",
+ "sha256": "3ea469a29d301b95701bc6f6ffafa2471ac0f334c5bd462c0cbc7c320bdc0764"
+ },
+ {
+ "file": "cli.test.mjs",
+ "tests": 5,
+ "output": "✔ terminal starts empty and produces the same charge trace as the shared runtime (145.440706ms)\n✔ invalid terminal commands do not alter state and fail piped execution (55.457108ms)\n✔ file load preserves by name, rejected load preserves active work, and save never overwrites (61.176894ms)\n✔ terminal exposes arithmetic rollback and retains failure through matching release (55.231312ms)\n✔ terminal configures and discards evaluation records and recovers from a display limit (53.719162ms)\nℹ tests 5\nℹ suites 0\nℹ pass 5\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 380.392621\n",
+ "sha256": "655c6d80b51af818fa7cdeb27cb5462962a0e1aef70f609ef5e2ddc85f4a6bc7"
+ },
+ {
+ "file": "evaluation.test.mjs",
+ "tests": 7,
+ "output": "✔ cache reuse, unrelated changes and discarded records preserve authored values (18.441968ms)\n✔ committed changes only: failed events and failed edits preserve reusable output (2.9613ms)\n✔ program edits, state reordering and reset cannot reuse old definitions (3.086097ms)\n✔ finite float semantics include signed zero, high state bit and arithmetic errors (2.875652ms)\n✔ one budget covers the whole scene; failures publish no partial scene or state rollback (1.415632ms)\n✔ small caches, no history and every small work limit give exact results or explicit failure (28.082684ms)\n✔ invalid settings and forget names preserve policy and state (1.779484ms)\nℹ tests 7\nℹ suites 0\nℹ pass 7\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 69.418978\n",
+ "sha256": "d3026ab5c6bffce56c0bde3155d7dddd6fa442a5688656ee710626c762258ff1"
+ }
+ ],
+ "javascriptTestCount": 45,
+ "sourceSha256": {
+ "core.zig": "dfb9fd7f3862806c2bd88ce1c668b795d9df196e1f2e50cceff1b5c4809273df",
+ "evaluation.zig": "2192e563385105f130d373b2e8724ea234c51e2909c7dbfc38e72acc3b70b2ed",
+ "web/core.wasm": "b79eecefba5e7da0f5dd945fa1df196ee627803243a430517f5c49c3a5e54ce1",
+ "web/runtime.mjs": "d9981282612cb06b50d24bf8919e829dce202f72b59dab607c8684bc0f297939",
+ "web/app.js": "6d63f3bfdfe212146894dbb1772509eb4ecc2f3be3b106675f0fc10cdeb5bfd3",
+ "cli.mjs": "2e0627ef84a45c8a9e91fb28c0010673212d459d0b0d09a5342856a049060030",
+ "manage.py": "e04199a0b5addc73bea7748420eee21ccf0aaa1a5b8c83082e5588187d6ed669"
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-22-linux-aarch64/authored.png b/prototypes/workbench/results/2026-09-22-linux-aarch64/authored.png
new file mode 100644
index 0000000..9acff70
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-linux-aarch64/authored.png
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-22-linux-aarch64/blank.png b/prototypes/workbench/results/2026-09-22-linux-aarch64/blank.png
new file mode 100644
index 0000000..8b06a0a
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-linux-aarch64/blank.png
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-22-linux-aarch64/browser-checks.json b/prototypes/workbench/results/2026-09-22-linux-aarch64/browser-checks.json
new file mode 100644
index 0000000..04d7b88
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-linux-aarch64/browser-checks.json
@@ -0,0 +1,159 @@
+{
+ "recordedUtc": "2026-09-22T10:57:50.563Z",
+ "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36",
+ "checks": [
+ "initial editor has literally empty source",
+ "initial scene has no built-in entities, states, inputs, or running clock",
+ "controls require an authored program",
+ "authoring a new program is a draft until Apply",
+ "new custom program constructs states and multiple views from source",
+ "input buttons are generated from the authored declarations",
+ "view bindings render the authored state value",
+ "Space and arrows in the editor never reach authored inputs",
+ "pointer down dispatches once while the clock is paused",
+ "pointer up releases without double dispatch from click",
+ "keyboard repeat is ignored",
+ "keyboard activation on a generated button is one complete tap",
+ "assistive click activation is one complete tap",
+ "keyboard and pointer sources share one logical input",
+ "releasing one source does not release the other source",
+ "last source releases the logical input",
+ "authored down/frame behavior uses the declared dt without built-in motion",
+ "keyup releases even after focus moves into the editor",
+ "focus loss sends release and pauses the clock",
+ "Play advances and Pause freezes the clock",
+ "invalid code preserves the previous executable program and states",
+ "compile error exposes a source-line diagnostic",
+ "diagnostic location moves the editor caret to the problem",
+ "apply preserves matching state names and initializes newly authored states",
+ "new input, entity, binding, and behavior work with source changes alone",
+ "changed behavior takes effect without resetting existing values",
+ "explicit Reset restores active initializers and keeps the draft",
+ "Apply is refused while an input is held",
+ "clear editor is explicit and does not clear the running scene before Apply",
+ "downloaded project restores the exact un-applied draft without execution",
+ "invalid JSON project schema cannot replace the draft",
+ "failed assistive down event rolls back and its error survives the following successful up",
+ "latched runtime failure is visible and labeled separately from the latest input",
+ "explicit Reset clears the failure record",
+ "applying empty source removes all authored states, inputs, and scene contents",
+ "explicit example loading only fills the editor and never auto-applies",
+ "authored scene executes in the actual fixed 64 KiB Wasm memory",
+ "cost inspector shows measured Wasm memory without claiming browser heap totals",
+ "390px mobile viewport has no horizontal overflow",
+ "generated mobile input buttons remain usable",
+ "ordinary-JavaScript comparison and equivalent Pure example are linked and served",
+ "missing Wasm shows an actionable load failure and disables execution controls",
+ "fresh load always returns to a blank authoring environment",
+ "no uncaught page errors during the browser scenarios"
+ ],
+ "authoredSource": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "authoredSnapshot": {
+ "states": {
+ "x": 114,
+ "moving": 0,
+ "count": 2
+ },
+ "views": [
+ {
+ "id": "marker",
+ "type": "box",
+ "color": "#7ed9ae",
+ "x": 114,
+ "y": 170,
+ "width": 48,
+ "height": 48
+ },
+ {
+ "id": "reading",
+ "type": "text",
+ "prefix": "Signals: ",
+ "x": 40,
+ "y": 70,
+ "value": 2
+ }
+ ],
+ "tick": 12,
+ "revision": 5,
+ "lastError": 0,
+ "errorMessage": "",
+ "errorLine": null,
+ "lastOps": 2,
+ "successfulEvents": 18,
+ "held": [],
+ "lastEvent": {
+ "name": "advance",
+ "phase": "up",
+ "source": "keyboard:ArrowRight",
+ "status": 0,
+ "line": null,
+ "ops": 2
+ },
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19072,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 464,
+ "activeStateBytes": 24,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ },
+ "ready": true,
+ "source": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "appliedSource": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "dirty": false,
+ "running": false,
+ "droppedTicks": 0,
+ "diagnostic": "코드를 적용했습니다. 같은 이름의 상태는 유지됩니다."
+ },
+ "mobile": {
+ "viewport": 390,
+ "content": 390
+ },
+ "downloaded": {
+ "filename": "my-work.pure.json",
+ "path": "exported-draft.json",
+ "expectedSource": "# A new authored interaction, not a bundled example.\nstate x = 500\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 5 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\nstate bonus = 7\ninput reverse = KeyA\ncircle beacon(300, 190, 15 + bonus, \"#c9f08b\")\non reverse.down { bonus = bonus + 2 }\n# This un-applied draft must survive Reset.\n"
+ },
+ "errors": [],
+ "screenshots": [
+ "blank.png",
+ "authored.png",
+ "mobile.png"
+ ],
+ "checkCount": 44,
+ "artifactSha256": {
+ "authored.png": "bb599b72b8e061bc28101086d664f4e288a014e737db37e958ce4902d0efa23d",
+ "exported-draft.json": "60f35c25dc371bce5667a779d49860d056b7e9ce5884c9755379ed6e526e1710",
+ "blank.png": "967727bf28b71d34f32cb6913fd31f84275eee5909a01bb188b32a041e9a8ca4",
+ "mobile.png": "b6d352f6e8260006a5220aebf9b3c9bc93dcb2a68a7db7124cf3a6f08adb2a4d"
+ },
+ "sourceSha256": {
+ "qa-browser.js": "0d4c155b3575481c60f175b71acdc332f1c0d8489297794deca6f55999ead1b8",
+ "web/index.html": "4fff6eb67d8cff098302465449251d17f0efb39d9144e87d15fefcc152e5d97e",
+ "web/app.js": "d364d777d704e871d90ed03cd2cd74a7e80d895480668b15d7f64c8946ac1e2a",
+ "web/style.css": "43301298028c5bc431eecd5ff30d7362fae83cce1fd0fdd7e8bea858100c22b9",
+ "web/runtime.mjs": "7819737fa37f058e9134f2cc64c5dc2497cece9b364d5409f98e59ac979fa698",
+ "web/compiler.mjs": "a34f3beb069c811a9bd5be73dde9341907ba77756066030b8b95fc6d66a5cc02",
+ "web/core.wasm": "3e2184d515407fcbd422becb56e5dca93fd40feb07475165a0cdbd187e33a0c8",
+ "web/baseline.html": "325e21c004882d83c626db4ca9a305b7aa72518794514d7205f6120e4d736173"
+ },
+ "visualReview": {
+ "reviewed": [
+ "blank.png",
+ "authored.png",
+ "mobile.png"
+ ],
+ "findings": "All three screenshots inspected. Editor and scene are empty on start; user-defined source and generated controls are visible; mobile page has no horizontal overflow. Placeholder directional copy corrected from right to below; final blank screenshot recaptured and checked after this text-only correction."
+ },
+ "limits": [
+ "Automated authoring behavior checks, not a human developer-experience evaluation.",
+ "Generated custom scenario is not a bundled example; it was authored by the test through the editor.",
+ "Expected network failure was intentionally induced for the missing-Wasm test. No uncaught page errors occurred.",
+ "Native dialog events can make Playwright CLI yield before the scenario finishes; final report was retrieved from window.__pureWorkbenchQAEvidence.",
+ "Browser user-agent architecture is recorded as reported by Chromium; it is not a claim about CPU or macOS/M3 performance."
+ ]
+}
diff --git a/prototypes/workbench/results/2026-09-22-linux-aarch64/comparison.json b/prototypes/workbench/results/2026-09-22-linux-aarch64/comparison.json
new file mode 100644
index 0000000..0427838
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-linux-aarch64/comparison.json
@@ -0,0 +1,298 @@
+{
+ "experiment": "Pure Workbench charge / handwritten JavaScript reference",
+ "environment": {
+ "startedAt": "2026-09-22T10:54:56.773Z",
+ "completedAt": "2026-09-22T10:54:57.201Z",
+ "node": "v24.20.0",
+ "v8": "13.6.233.17-node.53",
+ "platform": "linux",
+ "osRelease": "6.18.39+rpt-rpi-2712",
+ "architecture": "arm64",
+ "cpuModel": "Cortex-A76",
+ "logicalCPUs": 4,
+ "availableParallelism": 4,
+ "affinityControlled": false,
+ "powerControlled": false,
+ "command": "node /home/eunhhu/work/pure/prototypes/workbench/compare.mjs --output prototypes/workbench/results/2026-09-22-linux-aarch64/comparison.json"
+ },
+ "artifacts": {
+ "charge": {
+ "bytes": 674,
+ "sha256": "8d69d9fd51d44ab754d560eb7ff5102ea8a34540753cd7170b3bd800e4294f56"
+ },
+ "baseline": {
+ "bytes": 2251,
+ "sha256": "dcd567e5568d3c798d6b48e555e6d54773963c70d23594c175706b061f8b043f"
+ },
+ "runtime": {
+ "bytes": 7219,
+ "sha256": "7819737fa37f058e9134f2cc64c5dc2497cece9b364d5409f98e59ac979fa698"
+ },
+ "compiler": {
+ "bytes": 14327,
+ "sha256": "a34f3beb069c811a9bd5be73dde9341907ba77756066030b8b95fc6d66a5cc02"
+ },
+ "wasm": {
+ "bytes": 48182,
+ "sha256": "3e2184d515407fcbd422becb56e5dca93fd40feb07475165a0cdbd187e33a0c8"
+ },
+ "harness": {
+ "bytes": 11869,
+ "sha256": "f304cd9191fdf737777a8286e223ee32a483a64ba6cfd80fcf9d4c4a5b1bb6d8"
+ }
+ },
+ "correctness": {
+ "passed": true,
+ "checkedSnapshots": 6664,
+ "checkedNumbers": 126616,
+ "maxAbsoluteDifference": 0,
+ "absoluteTolerance": 1e-11,
+ "relativeTolerance": 1e-12,
+ "checkedFields": [
+ "states",
+ "all view fields",
+ "tick",
+ "held input names"
+ ],
+ "traceSha256": "db8110b5aa105921dea390950fed8bcb5ff9a3dc8929f8d368bfd24453b5001f",
+ "counts": {
+ "hostActions": 6663,
+ "frames": 6457,
+ "logicalDown": 52,
+ "logicalUp": 51,
+ "resets": 9,
+ "releaseAllCalls": 10,
+ "downCalls": 118,
+ "upCalls": 69,
+ "dispatchedEvents": 6560
+ }
+ },
+ "sources": {
+ "chargePure": {
+ "bytes": 674,
+ "physicalLines": 34,
+ "nonblankLines": 34,
+ "sha256": "8d69d9fd51d44ab754d560eb7ff5102ea8a34540753cd7170b3bd800e4294f56"
+ },
+ "fixedJavaScriptModel": {
+ "bytes": 2251,
+ "physicalLines": 68,
+ "nonblankLines": 67,
+ "sha256": "dcd567e5568d3c798d6b48e555e6d54773963c70d23594c175706b061f8b043f"
+ },
+ "note": "Raw file size and line counts only; excludes HTML/host/compiler and does not establish authoring effort or DX quality."
+ },
+ "compilerMetrics": {
+ "sourceBytes": 674,
+ "sourceLines": 35,
+ "states": 4,
+ "inputs": 1,
+ "views": 4,
+ "handlers": 3,
+ "instructions": 66,
+ "maxStack": 3
+ },
+ "memory": {
+ "pure": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19072,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 1092,
+ "activeStateBytes": 32,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ },
+ "note": "Wasm linear capacity and core layout counters; not process RSS or browser memory. No equivalent JS heap measurement collected. Do not add overlapping counters."
+ },
+ "timing": {
+ "seed": 20260922,
+ "warmupTracesPerImplementation": 5,
+ "trialsPerImplementation": 7,
+ "targetTrialMs": 12,
+ "calibration": {
+ "pureHostWasm": {
+ "elapsedMs": 10.597304000000008,
+ "traces": 3,
+ "selectedRepetitions": 4
+ },
+ "handwrittenJavaScript": {
+ "elapsedMs": 1.182612000000006,
+ "traces": 3,
+ "selectedRepetitions": 31
+ }
+ },
+ "traceCounts": {
+ "hostActions": 6663,
+ "frames": 6457,
+ "logicalDown": 52,
+ "logicalUp": 51,
+ "resets": 9,
+ "releaseAllCalls": 10,
+ "downCalls": 118,
+ "upCalls": 69,
+ "dispatchedEvents": 6560
+ },
+ "order": "Alternated each trial; Pure first on odd trials, JavaScript first on even trials.",
+ "included": "Host method dispatch, source-latch aggregation, state logic, and resets in the fixed trace. Pure additionally maintains generic VM/event diagnostics.",
+ "excluded": "Compilation, loading, snapshots, binding evaluation, DOM rendering, scene editing, and live apply.",
+ "normalization": "nsPerHostAction divides total time by calls including repeats, releaseAll and reset; nsPerDispatchedEvent also amortizes that host overhead.",
+ "limitations": "Single-machine batch throughput; no CPU pinning or energy/thermal control. Seven trial averages are not event latency percentiles. Fixed JS has no live program replacement, general compiler, or state migration; not feature-equivalent whole-product timing.",
+ "samples": [
+ {
+ "trial": 1,
+ "position": 1,
+ "implementation": "pureHostWasm",
+ "repetitions": 4,
+ "elapsedMs": 13.928176000000008,
+ "nsPerTrace": 3482044.000000002,
+ "nsPerHostAction": 522.5940267146933,
+ "nsPerDispatchedEvent": 530.7993902439027
+ },
+ {
+ "trial": 1,
+ "position": 2,
+ "implementation": "handwrittenJavaScript",
+ "repetitions": 31,
+ "elapsedMs": 11.025396,
+ "nsPerTrace": 355657.93548387097,
+ "nsPerHostAction": 53.37804824911766,
+ "nsPerDispatchedEvent": 54.2161487018096
+ },
+ {
+ "trial": 2,
+ "position": 1,
+ "implementation": "handwrittenJavaScript",
+ "repetitions": 31,
+ "elapsedMs": 13.416638000000034,
+ "nsPerTrace": 432794.7741935495,
+ "nsPerHostAction": 64.95494134677315,
+ "nsPerDispatchedEvent": 65.9748131392606
+ },
+ {
+ "trial": 2,
+ "position": 2,
+ "implementation": "pureHostWasm",
+ "repetitions": 4,
+ "elapsedMs": 19.406993999999997,
+ "nsPerTrace": 4851748.499999999,
+ "nsPerHostAction": 728.1627645204861,
+ "nsPerDispatchedEvent": 739.5958079268291
+ },
+ {
+ "trial": 3,
+ "position": 1,
+ "implementation": "pureHostWasm",
+ "repetitions": 4,
+ "elapsedMs": 19.585902000000033,
+ "nsPerTrace": 4896475.500000008,
+ "nsPerHostAction": 734.8755065285919,
+ "nsPerDispatchedEvent": 746.413948170733
+ },
+ {
+ "trial": 3,
+ "position": 2,
+ "implementation": "handwrittenJavaScript",
+ "repetitions": 31,
+ "elapsedMs": 15.407935000000009,
+ "nsPerTrace": 497030.1612903229,
+ "nsPerHostAction": 74.5955517470093,
+ "nsPerDispatchedEvent": 75.76679287962239
+ },
+ {
+ "trial": 4,
+ "position": 1,
+ "implementation": "handwrittenJavaScript",
+ "repetitions": 31,
+ "elapsedMs": 15.146732000000043,
+ "nsPerTrace": 488604.2580645175,
+ "nsPerHostAction": 73.3309707435866,
+ "nsPerDispatchedEvent": 74.48235641227402
+ },
+ {
+ "trial": 4,
+ "position": 2,
+ "implementation": "pureHostWasm",
+ "repetitions": 4,
+ "elapsedMs": 19.476624000000015,
+ "nsPerTrace": 4869156.000000004,
+ "nsPerHostAction": 730.7753264295368,
+ "nsPerDispatchedEvent": 742.249390243903
+ },
+ {
+ "trial": 5,
+ "position": 1,
+ "implementation": "pureHostWasm",
+ "repetitions": 4,
+ "elapsedMs": 15.168768999999998,
+ "nsPerTrace": 3792192.2499999995,
+ "nsPerHostAction": 569.1418655260393,
+ "nsPerDispatchedEvent": 578.0780868902439
+ },
+ {
+ "trial": 5,
+ "position": 2,
+ "implementation": "handwrittenJavaScript",
+ "repetitions": 31,
+ "elapsedMs": 10.88178499999998,
+ "nsPerTrace": 351025.3225806445,
+ "nsPerHostAction": 52.68277391274869,
+ "nsPerDispatchedEvent": 53.5099577104641
+ },
+ {
+ "trial": 6,
+ "position": 1,
+ "implementation": "handwrittenJavaScript",
+ "repetitions": 31,
+ "elapsedMs": 10.942656,
+ "nsPerTrace": 352988.9032258064,
+ "nsPerHostAction": 52.97747309407271,
+ "nsPerDispatchedEvent": 53.80928402832415
+ },
+ {
+ "trial": 6,
+ "position": 2,
+ "implementation": "pureHostWasm",
+ "repetitions": 4,
+ "elapsedMs": 14.048269000000005,
+ "nsPerTrace": 3512067.2500000014,
+ "nsPerHostAction": 527.0999924958729,
+ "nsPerDispatchedEvent": 535.3761051829271
+ },
+ {
+ "trial": 7,
+ "position": 1,
+ "implementation": "pureHostWasm",
+ "repetitions": 4,
+ "elapsedMs": 13.74212,
+ "nsPerTrace": 3435530,
+ "nsPerHostAction": 515.6130871979589,
+ "nsPerDispatchedEvent": 523.7088414634146
+ },
+ {
+ "trial": 7,
+ "position": 2,
+ "implementation": "handwrittenJavaScript",
+ "repetitions": 31,
+ "elapsedMs": 10.938469999999995,
+ "nsPerTrace": 352853.87096774176,
+ "nsPerHostAction": 52.957207109071256,
+ "nsPerDispatchedEvent": 53.78869984264356
+ }
+ ],
+ "summary": {
+ "pureHostWasm": {
+ "medianNsPerHostAction": 569.1418655260393,
+ "minNsPerHostAction": 515.6130871979589,
+ "maxNsPerHostAction": 734.8755065285919
+ },
+ "handwrittenJavaScript": {
+ "medianNsPerHostAction": 53.37804824911766,
+ "minNsPerHostAction": 52.68277391274869,
+ "maxNsPerHostAction": 74.5955517470093
+ },
+ "pureOverJavaScriptMedianRatio": 10.66247051353076
+ }
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-22-linux-aarch64/exported-draft.json b/prototypes/workbench/results/2026-09-22-linux-aarch64/exported-draft.json
new file mode 100644
index 0000000..c5acd9b
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-linux-aarch64/exported-draft.json
@@ -0,0 +1,5 @@
+{
+ "format": "pure-workbench",
+ "version": 1,
+ "source": "# A new authored interaction, not a bundled example.\nstate x = 500\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 5 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\nstate bonus = 7\ninput reverse = KeyA\ncircle beacon(300, 190, 15 + bonus, \"#c9f08b\")\non reverse.down { bonus = bonus + 2 }\n# This un-applied draft must survive Reset.\n"
+}
diff --git a/prototypes/workbench/results/2026-09-22-linux-aarch64/mobile.png b/prototypes/workbench/results/2026-09-22-linux-aarch64/mobile.png
new file mode 100644
index 0000000..3bb653d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-linux-aarch64/mobile.png
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-22-linux-aarch64/verification.json b/prototypes/workbench/results/2026-09-22-linux-aarch64/verification.json
new file mode 100644
index 0000000..6fffd98
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-22-linux-aarch64/verification.json
@@ -0,0 +1,239 @@
+{
+ "recordedUtc": "2026-09-22T11:02:59.248Z",
+ "scope": "Build record, test outcomes observed in this task, and direct runtime operation-count samples. Browser and timing evidence are separate files.",
+ "build": {
+ "builtUtc": "2026-09-22T10:49:43.509286+00:00",
+ "zigVersion": "0.15.2",
+ "sourceSha256": "147f0fb73edfb021be48961541ca4a2e110efb0fbc2ff6e777528b43ea06fbdd",
+ "wasmSha256": "3e2184d515407fcbd422becb56e5dca93fd40feb07475165a0cdbd187e33a0c8",
+ "wasmFileBytes": 48182,
+ "flags": [
+ "-target",
+ "wasm32-freestanding",
+ "-O",
+ "ReleaseSafe",
+ "-fstrip",
+ "-fno-entry",
+ "-rdynamic",
+ "--export-memory",
+ "--stack",
+ "16384",
+ "--initial-memory=65536",
+ "--max-memory=65536"
+ ],
+ "nativeTests": "1/18 core.test.blank program, no hidden application behavior, and reset...OK\n2/18 core.test.arbitrary input counter and unused events...OK\n3/18 core.test.charge and release behavior exists only in the authored instruction program...OK\n4/18 core.test.forward condition executes once and assignments and handlers are ordered...OK\n5/18 core.test.branch to program end is valid...OK\n6/18 core.test.one event rolls back writes from all handlers on arithmetic failure...OK\n7/18 core.test.nonfinite arithmetic and invalid clamp fail atomically...OK\n8/18 core.test.stack underflow overflow and leftover values reject the event...OK\n9/18 core.test.failed candidates leave active values program revision and diagnostics unchanged...OK\n10/18 core.test.state preservation mapping reads active values at commit and reset uses new initials...OK\n11/18 core.test.capacity boundaries and sticky load errors...OK\n12/18 core.test.invalid declaration values state mappings event ids and handler ranges...OK\n13/18 core.test.backward self out-of-handler branches and stores without handlers are rejected...OK\n14/18 core.test.event inputs and dt enforce deterministic frame contract...OK\n15/18 core.test.binding evaluation is pure on both success and failure...OK\n16/18 core.test.arithmetic comparison logical min max and clamp expression semantics...OK\n17/18 core.test.diagnostic counters wrap without affecting scalar values...OK\n18/18 core.test.memory accounting separates reserved storage used IR and stack capacity...OK\nAll 18 tests passed.\n"
+ },
+ "tests": [
+ {
+ "command": "node prototypes/workbench/compiler.test.mjs",
+ "passed": 13,
+ "failed": 0
+ },
+ {
+ "command": "node prototypes/workbench/runtime.test.mjs",
+ "passed": 13,
+ "failed": 0
+ },
+ {
+ "command": "node prototypes/workbench/baseline.test.mjs",
+ "passed": 7,
+ "failed": 0
+ },
+ {
+ "command": "node prototypes/workbench/cli.test.mjs",
+ "passed": 4,
+ "failed": 0
+ }
+ ],
+ "artifacts": {
+ "core.zig": {
+ "bytes": 28150,
+ "sha256": "147f0fb73edfb021be48961541ca4a2e110efb0fbc2ff6e777528b43ea06fbdd"
+ },
+ "web/core.wasm": {
+ "bytes": 48182,
+ "sha256": "3e2184d515407fcbd422becb56e5dca93fd40feb07475165a0cdbd187e33a0c8"
+ },
+ "web/compiler.mjs": {
+ "bytes": 14327,
+ "sha256": "a34f3beb069c811a9bd5be73dde9341907ba77756066030b8b95fc6d66a5cc02"
+ },
+ "web/runtime.mjs": {
+ "bytes": 7219,
+ "sha256": "7819737fa37f058e9134f2cc64c5dc2497cece9b364d5409f98e59ac979fa698"
+ },
+ "web/baseline.mjs": {
+ "bytes": 2251,
+ "sha256": "dcd567e5568d3c798d6b48e555e6d54773963c70d23594c175706b061f8b043f"
+ },
+ "cli.mjs": {
+ "bytes": 2995,
+ "sha256": "9785d754c07991a004a1ec02e2ceeebf972d80fd142c933ecf82654a7dd01dbd"
+ },
+ "manage.py": {
+ "bytes": 3442,
+ "sha256": "bb18c531e00595555b7864f6fe0a24c1049854997b6dfe4966365d41d89ce8a8"
+ },
+ "compare.mjs": {
+ "bytes": 11869,
+ "sha256": "f304cd9191fdf737777a8286e223ee32a483a64ba6cfd80fcf9d4c4a5b1bb6d8"
+ },
+ "compiler.test.mjs": {
+ "bytes": 11198,
+ "sha256": "6aa442d9d1eeb69453082f1780ff4597e46b1fc09d8ba3cf955545bc94d81431"
+ },
+ "runtime.test.mjs": {
+ "bytes": 8833,
+ "sha256": "5fe66115aab0c178a283dcb592376a2e7209308368004865e6c0a1cc58fa7f62"
+ },
+ "baseline.test.mjs": {
+ "bytes": 3983,
+ "sha256": "3ea469a29d301b95701bc6f6ffafa2471ac0f334c5bd462c0cbc7c320bdc0764"
+ },
+ "cli.test.mjs": {
+ "bytes": 3746,
+ "sha256": "b3a1730850aa041ec97a67831e85563e07266f37ca76e029d42969915c60fae3"
+ }
+ },
+ "program": {
+ "metrics": {
+ "sourceBytes": 674,
+ "sourceLines": 35,
+ "states": 4,
+ "inputs": 1,
+ "views": 4,
+ "handlers": 3,
+ "instructions": 66,
+ "maxStack": 3
+ },
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19072,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 1092,
+ "activeStateBytes": 32,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ }
+ },
+ "operationSamples": {
+ "note": "lastOps counts interpreted instructions in the last dispatched event only; excludes compiler, host bookkeeping, projection evaluation, Wasm-engine instructions and CPU instructions.",
+ "trace": [
+ {
+ "action": "apply",
+ "states": {
+ "holding": 0,
+ "charge": 0,
+ "y": 240,
+ "velocity": 0
+ },
+ "tick": 0,
+ "lastOps": 0,
+ "lastEvent": null,
+ "lastError": 0
+ },
+ {
+ "action": "one grounded frame",
+ "states": {
+ "holding": 0,
+ "charge": 0,
+ "y": 240,
+ "velocity": 0
+ },
+ "tick": 1,
+ "lastOps": 22,
+ "lastEvent": {
+ "name": "frame",
+ "phase": "frame",
+ "source": "clock",
+ "status": 0,
+ "line": null,
+ "ops": 22
+ },
+ "lastError": 0
+ },
+ {
+ "action": "down grounded",
+ "states": {
+ "holding": 1,
+ "charge": 0,
+ "y": 240,
+ "velocity": 0
+ },
+ "tick": 1,
+ "lastOps": 8,
+ "lastEvent": {
+ "name": "jump",
+ "phase": "down",
+ "source": "external",
+ "status": 0,
+ "line": null,
+ "ops": 8
+ },
+ "lastError": 0
+ },
+ {
+ "action": "one charging frame",
+ "states": {
+ "holding": 1,
+ "charge": 0.016666666666666666,
+ "y": 240,
+ "velocity": 0
+ },
+ "tick": 2,
+ "lastOps": 29,
+ "lastEvent": {
+ "name": "frame",
+ "phase": "frame",
+ "source": "clock",
+ "status": 0,
+ "line": null,
+ "ops": 29
+ },
+ "lastError": 0
+ },
+ {
+ "action": "release after one charging frame",
+ "states": {
+ "holding": 0,
+ "charge": 0,
+ "y": 240,
+ "velocity": -165
+ },
+ "tick": 2,
+ "lastOps": 13,
+ "lastEvent": {
+ "name": "jump",
+ "phase": "up",
+ "source": "external",
+ "status": 0,
+ "line": null,
+ "ops": 13
+ },
+ "lastError": 0
+ },
+ {
+ "action": "one airborne frame",
+ "states": {
+ "holding": 0,
+ "charge": 0,
+ "y": 237.41666666666666,
+ "velocity": -155
+ },
+ "tick": 3,
+ "lastOps": 18,
+ "lastEvent": {
+ "name": "frame",
+ "phase": "frame",
+ "source": "clock",
+ "status": 0,
+ "line": null,
+ "ops": 18
+ },
+ "lastError": 0
+ }
+ ]
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-23-layout/README.md b/prototypes/workbench/results/2026-09-23-layout/README.md
new file mode 100644
index 0000000..5eac784
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/README.md
@@ -0,0 +1,21 @@
+# Layout persistence — 2026-09-23
+
+Functional validation, not throughput/latency benchmarking.
+
+- build.json: matching core/Wasm hashes, 29 passing native tests.
+- verification.json: 68 passing Node tests.
+- layout-browser.json: 17 browser checks, actual download and fresh-page restore,
+ independent editing, malformed documents and asynchronous file-read races.
+- authoring-browser.json: 44 passing legacy authoring checks.
+- pure-layout-saved.json: actual downloaded document, including unfinished draft.
+- pure-layout-desktop.png / pure-layout-mobile.png: browser captures.
+- sources/ and manifest.json: exact implementation, contracts and source hashes.
+
+Browser: Linux Chromium via Playwright CLI. qa-layout.js uses real mouse input,
+file download/upload and explicit delayed File.text injection for race cases.
+The legacy qa-browser.js changes only output prefixes to preserve prior artifacts.
+No claim of embedded Codex browser or physical touchscreen verification is made.
+
+Reproduce: build with Zig 0.15.2 using manage.py build; run Node *.test.mjs with
+child-process support. Serve manage.py serve --port 8766 and execute qa-layout.js
+with Playwright CLI run-code against a fresh blank workbench page.
diff --git a/prototypes/workbench/results/2026-09-23-layout/authoring-browser.json b/prototypes/workbench/results/2026-09-23-layout/authoring-browser.json
new file mode 100644
index 0000000..1d7f1b0
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/authoring-browser.json
@@ -0,0 +1,128 @@
+{
+ "recordedUtc": "2026-09-23T06:47:19.951Z",
+ "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36",
+ "checks": [
+ "initial editor has literally empty source",
+ "initial scene has no built-in entities, states, inputs, or running clock",
+ "controls require an authored program",
+ "authoring a new program is a draft until Apply",
+ "new custom program constructs states and multiple views from source",
+ "input buttons are generated from the authored declarations",
+ "view bindings render the authored state value",
+ "Space and arrows in the editor never reach authored inputs",
+ "pointer down dispatches once while the clock is paused",
+ "pointer up releases without double dispatch from click",
+ "keyboard repeat is ignored",
+ "keyboard activation on a generated button is one complete tap",
+ "assistive click activation is one complete tap",
+ "keyboard and pointer sources share one logical input",
+ "releasing one source does not release the other source",
+ "last source releases the logical input",
+ "authored down/frame behavior uses the declared dt without built-in motion",
+ "keyup releases even after focus moves into the editor",
+ "focus loss sends release and pauses the clock",
+ "Play advances and Pause freezes the clock",
+ "invalid code preserves the previous executable program and states",
+ "compile error exposes a source-line diagnostic",
+ "diagnostic location moves the editor caret to the problem",
+ "apply preserves matching state names and initializes newly authored states",
+ "new input, entity, binding, and behavior work with source changes alone",
+ "changed behavior takes effect without resetting existing values",
+ "explicit Reset restores active initializers and keeps the draft",
+ "Apply is refused while an input is held",
+ "clear editor is explicit and does not clear the running scene before Apply",
+ "downloaded project restores the exact un-applied draft without execution",
+ "invalid JSON project schema cannot replace the draft",
+ "failed assistive down event rolls back and its error survives the following successful up",
+ "latched runtime failure is visible and labeled separately from the latest input",
+ "explicit Reset clears the failure record",
+ "applying empty source removes all authored states, inputs, and scene contents",
+ "explicit example loading only fills the editor and never auto-applies",
+ "authored scene executes in the actual fixed 64 KiB Wasm memory",
+ "cost inspector shows measured Wasm memory without claiming browser heap totals",
+ "390px mobile viewport has no horizontal overflow",
+ "generated mobile input buttons remain usable",
+ "ordinary-JavaScript comparison and equivalent Pure example are linked and served",
+ "missing Wasm shows an actionable load failure and disables execution controls",
+ "fresh load always returns to a blank authoring environment",
+ "no uncaught page errors during the browser scenarios"
+ ],
+ "authoredSource": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "authoredSnapshot": {
+ "states": {
+ "x": 114,
+ "moving": 0,
+ "count": 2
+ },
+ "views": [
+ {
+ "id": "marker",
+ "type": "box",
+ "color": "#7ed9ae",
+ "x": 114,
+ "y": 170,
+ "width": 48,
+ "height": 48
+ },
+ {
+ "id": "reading",
+ "type": "text",
+ "prefix": "Signals: ",
+ "x": 40,
+ "y": 70,
+ "value": 2
+ }
+ ],
+ "tick": 12,
+ "revision": 5,
+ "displayError": null,
+ "lastError": 0,
+ "errorMessage": "",
+ "errorLine": null,
+ "lastOps": 2,
+ "successfulEvents": 18,
+ "held": [],
+ "lastEvent": {
+ "name": "advance",
+ "phase": "up",
+ "source": "keyboard:ArrowRight",
+ "status": 0,
+ "line": null,
+ "ops": 2
+ },
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19392,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 464,
+ "activeStateBytes": 24,
+ "stagingReservedBytes": 9656,
+ "evalStackBytes": 512
+ },
+ "ready": true,
+ "source": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "appliedSource": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "dirty": false,
+ "running": false,
+ "droppedTicks": 0,
+ "diagnostic": "코드를 적용했습니다. 같은 이름의 상태는 유지됩니다."
+ },
+ "mobile": {
+ "viewport": 390,
+ "content": 390
+ },
+ "downloaded": {
+ "filename": "my-work.pure.json",
+ "path": "output/playwright/pure-layout-workbench-draft.json",
+ "expectedSource": "# A new authored interaction, not a bundled example.\nstate x = 500\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 5 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\nstate bonus = 7\ninput reverse = KeyA\ncircle beacon(300, 190, 15 + bonus, \"#c9f08b\")\non reverse.down { bonus = bonus + 2 }\n# This un-applied draft must survive Reset.\n"
+ },
+ "errors": [],
+ "screenshots": [
+ "output/playwright/pure-layout-workbench-blank.png",
+ "output/playwright/pure-layout-workbench-authored.png",
+ "output/playwright/pure-layout-workbench-mobile.png"
+ ]
+}
diff --git a/prototypes/workbench/results/2026-09-23-layout/build.json b/prototypes/workbench/results/2026-09-23-layout/build.json
new file mode 100644
index 0000000..5b5302d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/build.json
@@ -0,0 +1,23 @@
+{
+ "builtUtc": "2026-09-23T06:47:08.570632+00:00",
+ "zigVersion": "0.15.2",
+ "sourceSha256": "0e6d98258d2e92547dc6cf726a0cfb309ce61881647d98b20c784e2ac2025465",
+ "wasmSha256": "eb81bdd6c97a3cc34634d23fba58819345a71208adf05a70b563b98f11f57745",
+ "evaluationSourceSha256": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "wasmFileBytes": 56203,
+ "flags": [
+ "-target",
+ "wasm32-freestanding",
+ "-O",
+ "ReleaseSafe",
+ "-fstrip",
+ "-fno-entry",
+ "-rdynamic",
+ "--export-memory",
+ "--stack",
+ "16384",
+ "--initial-memory=65536",
+ "--max-memory=65536"
+ ],
+ "nativeTests": "1/29 core.test.blank program, no hidden application behavior, and reset...OK\n2/29 core.test.arbitrary input counter and unused events...OK\n3/29 core.test.charge and release behavior exists only in the authored instruction program...OK\n4/29 core.test.forward condition executes once and assignments and handlers are ordered...OK\n5/29 core.test.branch to program end is valid...OK\n6/29 core.test.one event rolls back writes from all handlers on arithmetic failure...OK\n7/29 core.test.nonfinite arithmetic and invalid clamp fail atomically...OK\n8/29 core.test.stack underflow overflow and leftover values reject the event...OK\n9/29 core.test.failed candidates leave active values program revision and diagnostics unchanged...OK\n10/29 core.test.state preservation mapping reads active values at commit and reset uses new initials...OK\n11/29 core.test.capacity boundaries and sticky load errors...OK\n12/29 core.test.invalid declaration values state mappings event ids and handler ranges...OK\n13/29 core.test.backward self out-of-handler branches and stores without handlers are rejected...OK\n14/29 core.test.event inputs and dt enforce deterministic frame contract...OK\n15/29 core.test.binding evaluation is pure on both success and failure...OK\n16/29 core.test.arithmetic comparison logical min max and clamp expression semantics...OK\n17/29 core.test.diagnostic counters wrap without affecting scalar values...OK\n18/29 core.test.memory accounting separates reserved storage used IR and stack capacity...OK\n19/29 core.test.point coordinates are ephemeral event operands and unavailable to pure evaluation...OK\n20/29 core.test.point event ranges reject holes and invalid payloads without committing...OK\n21/29 core.test.point event failure rolls back writes from earlier matching handlers...OK\n22/29 core.test.loading shares storage with records while old events and bindings remain executable...OK\n23/29 core.test.failed and abandoned candidates restore empty reuse storage without changing active work...OK\n24/29 core.test.full pending program survives repeated evaluation configuration record loss and reset...OK\n25/29 core.test.restored definition and values commit together without changing reset initializers...OK\n26/29 core.test.missing duplicate nonfinite or invalid restored candidates leave active work untouched...OK\n27/29 core.test.empty and full restored programs validate all values and discard old display records...OK\n28/29 evaluation.test.record loss cannot authorize an old memo; unrelated changes can...OK\n29/29 evaluation.test.budget, collisions, invalid settings, and disabled history are conservative...OK\nAll 29 tests passed.\n"
+}
diff --git a/prototypes/workbench/results/2026-09-23-layout/layout-browser.json b/prototypes/workbench/results/2026-09-23-layout/layout-browser.json
new file mode 100644
index 0000000..a0452e8
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/layout-browser.json
@@ -0,0 +1,97 @@
+{
+ "checks": [
+ "placement save requires an applied definition",
+ "three independently authored targets move and selection directs keyboard adjustment",
+ "placement download is a separate document file",
+ "saving neither applies nor discards the unfinished draft",
+ "reset returns to authored initial positions",
+ "downloaded file restores all states and visible positions",
+ "the draft remains separate from the restored executable definition",
+ "restored work starts stopped with no replayed inputs or clock",
+ "restored selection and bindings remain editable",
+ "a fresh browser page reconstructs the saved work without old runtime state",
+ "invalid saved state cannot partially replace code, draft or scene",
+ "a delayed file read cannot overwrite a newer draft",
+ "a delayed restore cannot overwrite placement changes made while reading",
+ "saving while time advances requests a stopped document",
+ "saving with a held input is refused",
+ "extra persistence control fits a mobile viewport",
+ "no uncaught page errors"
+ ],
+ "errors": [],
+ "saved": "output/playwright/pure-layout-saved.json",
+ "expected": {
+ "states": {
+ "selected": 3,
+ "x1": 150.00000659728084,
+ "y1": 169.9999983506798,
+ "ox1": 9.999986805438311,
+ "oy1": 10.00000164932024,
+ "sx1": 80,
+ "sy1": 120,
+ "held1": 0,
+ "x2": 350.00000659728073,
+ "y2": 209.9999983506798,
+ "ox2": 9.999980208157467,
+ "oy2": 10.00000329864045,
+ "sx2": 280,
+ "sy2": 160,
+ "held2": 0,
+ "x3": 509.999947221753,
+ "y3": 260.0000016493202,
+ "ox3": 10.000013194561802,
+ "oy3": 9.999998350679817,
+ "sx3": 450,
+ "sy3": 220,
+ "held3": 0
+ },
+ "views": [
+ {
+ "id": "board",
+ "type": "box",
+ "color": "#233a31",
+ "x": 20,
+ "y": 90,
+ "width": 600,
+ "height": 240
+ },
+ {
+ "id": "help",
+ "type": "text",
+ "prefix": "선택한 도형: ",
+ "x": 25,
+ "y": 40,
+ "value": 3
+ },
+ {
+ "id": "tile1",
+ "type": "box",
+ "color": "#c9f08b",
+ "x": 150.00000659728084,
+ "y": 169.9999983506798,
+ "width": 56,
+ "height": 44
+ },
+ {
+ "id": "tile2",
+ "type": "box",
+ "color": "#74c4d4",
+ "x": 350.00000659728073,
+ "y": 209.9999983506798,
+ "width": 56,
+ "height": 44
+ },
+ {
+ "id": "tile3",
+ "type": "box",
+ "color": "#dfb184",
+ "x": 509.999947221753,
+ "y": 260.0000016493202,
+ "width": 56,
+ "height": 44
+ }
+ ]
+ },
+ "source": "# 세 도형을 배치하고 배치 저장으로 결과를 보관하세요.\n# 마지막으로 잡은 도형은 방향키로 10씩 이동합니다.\nstate selected = 0\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\nstate x1 = 80\nstate y1 = 120\nstate ox1 = 0\nstate oy1 = 0\nstate sx1 = 80\nstate sy1 = 120\nstate held1 = 0\ninput drag1 = pointer(tile1)\nstate x2 = 280\nstate y2 = 160\nstate ox2 = 0\nstate oy2 = 0\nstate sx2 = 280\nstate sy2 = 160\nstate held2 = 0\ninput drag2 = pointer(tile2)\nstate x3 = 450\nstate y3 = 220\nstate ox3 = 0\nstate oy3 = 0\nstate sx3 = 450\nstate sy3 = 220\nstate held3 = 0\ninput drag3 = pointer(tile3)\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox tile1(x1,y1,56,44,\"#c9f08b\")\nbox tile2(x2,y2,56,44,\"#74c4d4\")\nbox tile3(x3,y3,56,44,\"#dfb184\")\non drag1.down {\n selected = 1\n sx1 = x1; sy1 = y1\n ox1 = event_x - x1; oy1 = event_y - y1\n held1 = 1\n}\non drag1.move {\n x1 = clamp(event_x - ox1, 20, 564)\n y1 = clamp(event_y - oy1, 90, 286)\n}\non drag1.up { held1 = 0 }\non drag1.cancel { x1 = sx1; y1 = sy1; held1 = 0 }\non drag2.down {\n selected = 2\n sx2 = x2; sy2 = y2\n ox2 = event_x - x2; oy2 = event_y - y2\n held2 = 1\n}\non drag2.move {\n x2 = clamp(event_x - ox2, 20, 564)\n y2 = clamp(event_y - oy2, 90, 286)\n}\non drag2.up { held2 = 0 }\non drag2.cancel { x2 = sx2; y2 = sy2; held2 = 0 }\non drag3.down {\n selected = 3\n sx3 = x3; sy3 = y3\n ox3 = event_x - x3; oy3 = event_y - y3\n held3 = 1\n}\non drag3.move {\n x3 = clamp(event_x - ox3, 20, 564)\n y3 = clamp(event_y - oy3, 90, 286)\n}\non drag3.up { held3 = 0 }\non drag3.cancel { x3 = sx3; y3 = sy3; held3 = 0 }\non left.down {\n if selected == 1 && held1 == 0 { x1 = clamp(x1 + -10, 20, 564) }\n if selected == 2 && held2 == 0 { x2 = clamp(x2 + -10, 20, 564) }\n if selected == 3 && held3 == 0 { x3 = clamp(x3 + -10, 20, 564) }\n}\non right.down {\n if selected == 1 && held1 == 0 { x1 = clamp(x1 + 10, 20, 564) }\n if selected == 2 && held2 == 0 { x2 = clamp(x2 + 10, 20, 564) }\n if selected == 3 && held3 == 0 { x3 = clamp(x3 + 10, 20, 564) }\n}\non up.down {\n if selected == 1 && held1 == 0 { y1 = clamp(y1 + -10, 90, 286) }\n if selected == 2 && held2 == 0 { y2 = clamp(y2 + -10, 90, 286) }\n if selected == 3 && held3 == 0 { y3 = clamp(y3 + -10, 90, 286) }\n}\non down.down {\n if selected == 1 && held1 == 0 { y1 = clamp(y1 + 10, 90, 286) }\n if selected == 2 && held2 == 0 { y2 = clamp(y2 + 10, 90, 286) }\n if selected == 3 && held3 == 0 { y3 = clamp(y3 + 10, 90, 286) }\n}\n",
+ "draft": "# 세 도형을 배치하고 배치 저장으로 결과를 보관하세요.\n# 마지막으로 잡은 도형은 방향키로 10씩 이동합니다.\nstate selected = 0\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\nstate x1 = 80\nstate y1 = 120\nstate ox1 = 0\nstate oy1 = 0\nstate sx1 = 80\nstate sy1 = 120\nstate held1 = 0\ninput drag1 = pointer(tile1)\nstate x2 = 280\nstate y2 = 160\nstate ox2 = 0\nstate oy2 = 0\nstate sx2 = 280\nstate sy2 = 160\nstate held2 = 0\ninput drag2 = pointer(tile2)\nstate x3 = 450\nstate y3 = 220\nstate ox3 = 0\nstate oy3 = 0\nstate sx3 = 450\nstate sy3 = 220\nstate held3 = 0\ninput drag3 = pointer(tile3)\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox tile1(x1,y1,56,44,\"#c9f08b\")\nbox tile2(x2,y2,56,44,\"#74c4d4\")\nbox tile3(x3,y3,56,44,\"#dfb184\")\non drag1.down {\n selected = 1\n sx1 = x1; sy1 = y1\n ox1 = event_x - x1; oy1 = event_y - y1\n held1 = 1\n}\non drag1.move {\n x1 = clamp(event_x - ox1, 20, 564)\n y1 = clamp(event_y - oy1, 90, 286)\n}\non drag1.up { held1 = 0 }\non drag1.cancel { x1 = sx1; y1 = sy1; held1 = 0 }\non drag2.down {\n selected = 2\n sx2 = x2; sy2 = y2\n ox2 = event_x - x2; oy2 = event_y - y2\n held2 = 1\n}\non drag2.move {\n x2 = clamp(event_x - ox2, 20, 564)\n y2 = clamp(event_y - oy2, 90, 286)\n}\non drag2.up { held2 = 0 }\non drag2.cancel { x2 = sx2; y2 = sy2; held2 = 0 }\non drag3.down {\n selected = 3\n sx3 = x3; sy3 = y3\n ox3 = event_x - x3; oy3 = event_y - y3\n held3 = 1\n}\non drag3.move {\n x3 = clamp(event_x - ox3, 20, 564)\n y3 = clamp(event_y - oy3, 90, 286)\n}\non drag3.up { held3 = 0 }\non drag3.cancel { x3 = sx3; y3 = sy3; held3 = 0 }\non left.down {\n if selected == 1 && held1 == 0 { x1 = clamp(x1 + -10, 20, 564) }\n if selected == 2 && held2 == 0 { x2 = clamp(x2 + -10, 20, 564) }\n if selected == 3 && held3 == 0 { x3 = clamp(x3 + -10, 20, 564) }\n}\non right.down {\n if selected == 1 && held1 == 0 { x1 = clamp(x1 + 10, 20, 564) }\n if selected == 2 && held2 == 0 { x2 = clamp(x2 + 10, 20, 564) }\n if selected == 3 && held3 == 0 { x3 = clamp(x3 + 10, 20, 564) }\n}\non up.down {\n if selected == 1 && held1 == 0 { y1 = clamp(y1 + -10, 90, 286) }\n if selected == 2 && held2 == 0 { y2 = clamp(y2 + -10, 90, 286) }\n if selected == 3 && held3 == 0 { y3 = clamp(y3 + -10, 90, 286) }\n}\non down.down {\n if selected == 1 && held1 == 0 { y1 = clamp(y1 + 10, 90, 286) }\n if selected == 2 && held2 == 0 { y2 = clamp(y2 + 10, 90, 286) }\n if selected == 3 && held3 == 0 { y3 = clamp(y3 + 10, 90, 286) }\n}\n\nstate unfinished ="
+}
diff --git a/prototypes/workbench/results/2026-09-23-layout/manifest.json b/prototypes/workbench/results/2026-09-23-layout/manifest.json
new file mode 100644
index 0000000..f90f5f6
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/manifest.json
@@ -0,0 +1,21 @@
+{
+ "recordedUtc": "2026-09-23T06:49:39.960675+00:00",
+ "purpose": "Functional persistence validation; no performance benchmark",
+ "sha256": {
+ "core.zig": "0e6d98258d2e92547dc6cf726a0cfb309ce61881647d98b20c784e2ac2025465",
+ "evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "web/runtime.mjs": "265cc84b4d2f1f8be05c95d5775a58b64de27cf8c285b7533321c1b93fed0013",
+ "web/project.mjs": "fcd40c0010ba4ad4d47ef96e0aada7b959e0c1d67fab8ee142f24191a9d64466",
+ "web/app.js": "d5e062f9eab74f7e2bd6ab9f44e1217b12f2c17537054f5f7e4ec55b96801a09",
+ "web/index.html": "c433f12a58e669e5ea80681abcf987ff9b5dc5ab58910c39a1ccfdb743d98254",
+ "web/scene-input.mjs": "a7de0d0c26328fa6885b1d1633783f1550acfa4a2233db604b0ce4646e633a25",
+ "web/core.wasm": "eb81bdd6c97a3cc34634d23fba58819345a71208adf05a70b563b98f11f57745",
+ "examples/layout.pure": "cebd9c3485a2be4ec16ae665c1ed4ee200d51fc56ad0151306c8e9c31138f346",
+ "cli.mjs": "3114b6aafcd9af661b9fbfefaac549c191503964f0306a3072389baad40486b0",
+ "project.test.mjs": "7efd09c75b89af384e4020e8fc59df42932897c26c0a0cb6cb7f960684393568",
+ "cli.test.mjs": "7e4688824e26dc7e56396f9d4d62726de0ccb1490e1a388bb64690ae2aae52f4",
+ "workspace.test.mjs": "9ebe7675967ad9d070902fd7f182abf1a76fd66bc9aa07f513e45d0299115543",
+ "qa-layout.js": "d54272e1ef45fd077b1e81dbcfe54f87151f75eb499c94358811fcec79d1672e",
+ "CONTRACT.md": "f996e56575bdd137a34173976c64cf0e9f548d72ea8643a8f1c8e7db73392c61"
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-23-layout/pure-layout-desktop.png b/prototypes/workbench/results/2026-09-23-layout/pure-layout-desktop.png
new file mode 100644
index 0000000..ad70562
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/pure-layout-desktop.png
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-23-layout/pure-layout-mobile.png b/prototypes/workbench/results/2026-09-23-layout/pure-layout-mobile.png
new file mode 100644
index 0000000..8bde7c4
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/pure-layout-mobile.png
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-23-layout/pure-layout-saved.json b/prototypes/workbench/results/2026-09-23-layout/pure-layout-saved.json
new file mode 100644
index 0000000..ab358a7
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/pure-layout-saved.json
@@ -0,0 +1,96 @@
+{
+ "format": "pure-workbench",
+ "version": 2,
+ "source": "# 세 도형을 배치하고 배치 저장으로 결과를 보관하세요.\n# 마지막으로 잡은 도형은 방향키로 10씩 이동합니다.\nstate selected = 0\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\nstate x1 = 80\nstate y1 = 120\nstate ox1 = 0\nstate oy1 = 0\nstate sx1 = 80\nstate sy1 = 120\nstate held1 = 0\ninput drag1 = pointer(tile1)\nstate x2 = 280\nstate y2 = 160\nstate ox2 = 0\nstate oy2 = 0\nstate sx2 = 280\nstate sy2 = 160\nstate held2 = 0\ninput drag2 = pointer(tile2)\nstate x3 = 450\nstate y3 = 220\nstate ox3 = 0\nstate oy3 = 0\nstate sx3 = 450\nstate sy3 = 220\nstate held3 = 0\ninput drag3 = pointer(tile3)\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox tile1(x1,y1,56,44,\"#c9f08b\")\nbox tile2(x2,y2,56,44,\"#74c4d4\")\nbox tile3(x3,y3,56,44,\"#dfb184\")\non drag1.down {\n selected = 1\n sx1 = x1; sy1 = y1\n ox1 = event_x - x1; oy1 = event_y - y1\n held1 = 1\n}\non drag1.move {\n x1 = clamp(event_x - ox1, 20, 564)\n y1 = clamp(event_y - oy1, 90, 286)\n}\non drag1.up { held1 = 0 }\non drag1.cancel { x1 = sx1; y1 = sy1; held1 = 0 }\non drag2.down {\n selected = 2\n sx2 = x2; sy2 = y2\n ox2 = event_x - x2; oy2 = event_y - y2\n held2 = 1\n}\non drag2.move {\n x2 = clamp(event_x - ox2, 20, 564)\n y2 = clamp(event_y - oy2, 90, 286)\n}\non drag2.up { held2 = 0 }\non drag2.cancel { x2 = sx2; y2 = sy2; held2 = 0 }\non drag3.down {\n selected = 3\n sx3 = x3; sy3 = y3\n ox3 = event_x - x3; oy3 = event_y - y3\n held3 = 1\n}\non drag3.move {\n x3 = clamp(event_x - ox3, 20, 564)\n y3 = clamp(event_y - oy3, 90, 286)\n}\non drag3.up { held3 = 0 }\non drag3.cancel { x3 = sx3; y3 = sy3; held3 = 0 }\non left.down {\n if selected == 1 && held1 == 0 { x1 = clamp(x1 + -10, 20, 564) }\n if selected == 2 && held2 == 0 { x2 = clamp(x2 + -10, 20, 564) }\n if selected == 3 && held3 == 0 { x3 = clamp(x3 + -10, 20, 564) }\n}\non right.down {\n if selected == 1 && held1 == 0 { x1 = clamp(x1 + 10, 20, 564) }\n if selected == 2 && held2 == 0 { x2 = clamp(x2 + 10, 20, 564) }\n if selected == 3 && held3 == 0 { x3 = clamp(x3 + 10, 20, 564) }\n}\non up.down {\n if selected == 1 && held1 == 0 { y1 = clamp(y1 + -10, 90, 286) }\n if selected == 2 && held2 == 0 { y2 = clamp(y2 + -10, 90, 286) }\n if selected == 3 && held3 == 0 { y3 = clamp(y3 + -10, 90, 286) }\n}\non down.down {\n if selected == 1 && held1 == 0 { y1 = clamp(y1 + 10, 90, 286) }\n if selected == 2 && held2 == 0 { y2 = clamp(y2 + 10, 90, 286) }\n if selected == 3 && held3 == 0 { y3 = clamp(y3 + 10, 90, 286) }\n}\n",
+ "draft": "# 세 도형을 배치하고 배치 저장으로 결과를 보관하세요.\n# 마지막으로 잡은 도형은 방향키로 10씩 이동합니다.\nstate selected = 0\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\nstate x1 = 80\nstate y1 = 120\nstate ox1 = 0\nstate oy1 = 0\nstate sx1 = 80\nstate sy1 = 120\nstate held1 = 0\ninput drag1 = pointer(tile1)\nstate x2 = 280\nstate y2 = 160\nstate ox2 = 0\nstate oy2 = 0\nstate sx2 = 280\nstate sy2 = 160\nstate held2 = 0\ninput drag2 = pointer(tile2)\nstate x3 = 450\nstate y3 = 220\nstate ox3 = 0\nstate oy3 = 0\nstate sx3 = 450\nstate sy3 = 220\nstate held3 = 0\ninput drag3 = pointer(tile3)\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox tile1(x1,y1,56,44,\"#c9f08b\")\nbox tile2(x2,y2,56,44,\"#74c4d4\")\nbox tile3(x3,y3,56,44,\"#dfb184\")\non drag1.down {\n selected = 1\n sx1 = x1; sy1 = y1\n ox1 = event_x - x1; oy1 = event_y - y1\n held1 = 1\n}\non drag1.move {\n x1 = clamp(event_x - ox1, 20, 564)\n y1 = clamp(event_y - oy1, 90, 286)\n}\non drag1.up { held1 = 0 }\non drag1.cancel { x1 = sx1; y1 = sy1; held1 = 0 }\non drag2.down {\n selected = 2\n sx2 = x2; sy2 = y2\n ox2 = event_x - x2; oy2 = event_y - y2\n held2 = 1\n}\non drag2.move {\n x2 = clamp(event_x - ox2, 20, 564)\n y2 = clamp(event_y - oy2, 90, 286)\n}\non drag2.up { held2 = 0 }\non drag2.cancel { x2 = sx2; y2 = sy2; held2 = 0 }\non drag3.down {\n selected = 3\n sx3 = x3; sy3 = y3\n ox3 = event_x - x3; oy3 = event_y - y3\n held3 = 1\n}\non drag3.move {\n x3 = clamp(event_x - ox3, 20, 564)\n y3 = clamp(event_y - oy3, 90, 286)\n}\non drag3.up { held3 = 0 }\non drag3.cancel { x3 = sx3; y3 = sy3; held3 = 0 }\non left.down {\n if selected == 1 && held1 == 0 { x1 = clamp(x1 + -10, 20, 564) }\n if selected == 2 && held2 == 0 { x2 = clamp(x2 + -10, 20, 564) }\n if selected == 3 && held3 == 0 { x3 = clamp(x3 + -10, 20, 564) }\n}\non right.down {\n if selected == 1 && held1 == 0 { x1 = clamp(x1 + 10, 20, 564) }\n if selected == 2 && held2 == 0 { x2 = clamp(x2 + 10, 20, 564) }\n if selected == 3 && held3 == 0 { x3 = clamp(x3 + 10, 20, 564) }\n}\non up.down {\n if selected == 1 && held1 == 0 { y1 = clamp(y1 + -10, 90, 286) }\n if selected == 2 && held2 == 0 { y2 = clamp(y2 + -10, 90, 286) }\n if selected == 3 && held3 == 0 { y3 = clamp(y3 + -10, 90, 286) }\n}\non down.down {\n if selected == 1 && held1 == 0 { y1 = clamp(y1 + 10, 90, 286) }\n if selected == 2 && held2 == 0 { y2 = clamp(y2 + 10, 90, 286) }\n if selected == 3 && held3 == 0 { y3 = clamp(y3 + 10, 90, 286) }\n}\n\nstate unfinished =",
+ "states": [
+ {
+ "name": "selected",
+ "value": 3
+ },
+ {
+ "name": "x1",
+ "value": 150.00000659728084
+ },
+ {
+ "name": "y1",
+ "value": 169.9999983506798
+ },
+ {
+ "name": "ox1",
+ "value": 9.999986805438311
+ },
+ {
+ "name": "oy1",
+ "value": 10.00000164932024
+ },
+ {
+ "name": "sx1",
+ "value": 80
+ },
+ {
+ "name": "sy1",
+ "value": 120
+ },
+ {
+ "name": "held1",
+ "value": 0
+ },
+ {
+ "name": "x2",
+ "value": 350.00000659728073
+ },
+ {
+ "name": "y2",
+ "value": 209.9999983506798
+ },
+ {
+ "name": "ox2",
+ "value": 9.999980208157467
+ },
+ {
+ "name": "oy2",
+ "value": 10.00000329864045
+ },
+ {
+ "name": "sx2",
+ "value": 280
+ },
+ {
+ "name": "sy2",
+ "value": 160
+ },
+ {
+ "name": "held2",
+ "value": 0
+ },
+ {
+ "name": "x3",
+ "value": 509.999947221753
+ },
+ {
+ "name": "y3",
+ "value": 260.0000016493202
+ },
+ {
+ "name": "ox3",
+ "value": 10.000013194561802
+ },
+ {
+ "name": "oy3",
+ "value": 9.999998350679817
+ },
+ {
+ "name": "sx3",
+ "value": 450
+ },
+ {
+ "name": "sy3",
+ "value": 220
+ },
+ {
+ "name": "held3",
+ "value": 0
+ }
+ ]
+}
diff --git a/prototypes/workbench/results/2026-09-23-layout/qa-browser.js b/prototypes/workbench/results/2026-09-23-layout/qa-browser.js
new file mode 100644
index 0000000..34f9ca2
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/qa-browser.js
@@ -0,0 +1,228 @@
+// Run this expression with Playwright CLI run-code after opening the local host.
+// All behavior is authored through the editor; snapshot() only observes results.
+async (page) => {
+ const checks = [];
+ const errors = [];
+ const onError = error => errors.push(error.message);
+ const acceptDialog = dialog => dialog.accept();
+ const check = (condition, label) => {
+ if (!condition) throw new Error(`Check ${checks.length + 1} failed: ${label}`);
+ checks.push(label);
+ };
+ const state = () => page.evaluate(() => window.pureWorkbench.snapshot());
+ const apply = async text => { await page.locator('#source').fill(text); await page.locator('#apply').click(); };
+ const authorSource = [
+ '# A new authored interaction, not a bundled example.',
+ 'state x = 90', 'state moving = 0', 'state count = 0',
+ 'input advance = ArrowRight', 'input pulse = Space',
+ 'box marker(x, 170, 48, 48, "#7ed9ae")',
+ 'text reading(40, 70, count, "Signals: ")',
+ 'on advance.down { moving = 1 }', 'on advance.up { moving = 0 }',
+ 'on pulse.down { count = count + 1 }',
+ 'on frame { x = clamp(x + moving * 120 * dt, 0, 580) }', '',
+ ].join('\n');
+ let lastAuthored = null;
+ let mobile = null;
+ let downloaded = null;
+ const shots = ['output/playwright/pure-layout-workbench-blank.png', 'output/playwright/pure-layout-workbench-authored.png', 'output/playwright/pure-layout-workbench-mobile.png'];
+ page.on('pageerror', onError);
+ page.on('dialog', acceptDialog);
+ try {
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.reload();
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ let s = await state();
+ check(s.source === '' && s.appliedSource === '' && !s.dirty, 'initial editor has literally empty source');
+ check(s.views.length === 0 && Object.keys(s.states).length === 0 && s.held.length === 0 && !s.running, 'initial scene has no built-in entities, states, inputs, or running clock');
+ check(await page.locator('#input-controls button').count() === 0 && await page.locator('#play').isDisabled(), 'controls require an authored program');
+ await page.screenshot({ path: shots[0], fullPage: true });
+
+ await page.locator('#source').fill(authorSource);
+ s = await state();
+ check(s.dirty && s.views.length === 0 && s.appliedSource === '', 'authoring a new program is a draft until Apply');
+ await page.locator('#apply').click();
+ s = await state();
+ check(!s.dirty && s.appliedSource === authorSource && s.views.length === 2 && s.states.x === 90 && s.states.count === 0, 'new custom program constructs states and multiple views from source');
+ check(await page.locator('#input-controls button').count() === 2 && await page.locator('[data-input="advance"]').count() === 1 && await page.locator('[data-input="pulse"]').count() === 1, 'input buttons are generated from the authored declarations');
+ check(await page.locator('#scene-content [data-view-id="marker"]').getAttribute('x') === '90', 'view bindings render the authored state value');
+
+ await page.locator('#source').focus();
+ await page.keyboard.press('End');
+ await page.keyboard.press('Space');
+ await page.keyboard.press('ArrowRight');
+ check((await state()).states.count === 0 && (await state()).states.moving === 0, 'Space and arrows in the editor never reach authored inputs');
+ await page.locator('#source').fill(authorSource);
+
+ const pulse = page.locator('[data-input="pulse"]');
+ let box = await pulse.boundingBox();
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ s = await state();
+ check(s.states.count === 1 && s.held.includes('pulse') && !s.running && s.tick === 0, 'pointer down dispatches once while the clock is paused');
+ await page.mouse.up();
+ check((await state()).held.length === 0 && (await state()).states.count === 1, 'pointer up releases without double dispatch from click');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('Space');
+ await page.keyboard.down('Space');
+ await page.keyboard.up('Space');
+ check((await state()).states.count === 2, 'keyboard repeat is ignored');
+ await pulse.focus();
+ await page.keyboard.press('Enter');
+ check((await state()).states.count === 3 && (await state()).held.length === 0, 'keyboard activation on a generated button is one complete tap');
+ await pulse.dispatchEvent('click', { detail: 0 });
+ check((await state()).states.count === 4 && (await state()).held.length === 0, 'assistive click activation is one complete tap');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('Space');
+ box = await pulse.boundingBox();
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ check((await state()).states.count === 5 && (await state()).held.includes('pulse'), 'keyboard and pointer sources share one logical input');
+ await page.mouse.up();
+ check((await state()).held.includes('pulse'), 'releasing one source does not release the other source');
+ await page.keyboard.up('Space');
+ check((await state()).held.length === 0, 'last source releases the logical input');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ await page.locator('#step').click();
+ await page.locator('#step').click();
+ s = await state();
+ check(s.states.x === 94 && s.states.moving === 1 && s.tick === 2, 'authored down/frame behavior uses the declared dt without built-in motion');
+ await page.locator('#source').focus();
+ await page.keyboard.up('ArrowRight');
+ check((await state()).states.moving === 0 && (await state()).held.length === 0, 'keyup releases even after focus moves into the editor');
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ await page.evaluate(() => window.dispatchEvent(new Event('blur')));
+ await page.keyboard.up('ArrowRight');
+ check((await state()).states.moving === 0 && !(await state()).running && (await state()).held.length === 0, 'focus loss sends release and pauses the clock');
+
+ await page.locator('#play').click();
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().tick >= 5);
+ await page.locator('#play').click();
+ const paused = await state();
+ await page.waitForTimeout(100);
+ check(!paused.running && paused.tick === (await state()).tick && paused.tick >= 5, 'Play advances and Pause freezes the clock');
+
+ const beforeFailure = await state();
+ await apply(authorSource + 'state broken =\n');
+ s = await state();
+ check(s.appliedSource === authorSource && s.revision === beforeFailure.revision && JSON.stringify(s.states) === JSON.stringify(beforeFailure.states), 'invalid code preserves the previous executable program and states');
+ check(await page.locator('#error-location').isVisible() && /행/.test(s.diagnostic) && await page.locator('#source').getAttribute('aria-invalid') === 'true', 'compile error exposes a source-line diagnostic');
+ await page.locator('#error-location').click();
+ check(await page.locator('#source').evaluate(el => el.selectionStart >= el.value.indexOf('state broken')), 'diagnostic location moves the editor caret to the problem');
+
+ const expanded = authorSource.replace('state x = 90', 'state x = 500').replace('count = count + 1', 'count = count + 5') + [
+ 'state bonus = 7', 'input reverse = KeyA',
+ 'circle beacon(300, 190, 15 + bonus, "#c9f08b")',
+ 'on reverse.down { bonus = bonus + 2 }', '',
+ ].join('\n');
+ await apply(expanded);
+ s = await state();
+ check(s.states.x === beforeFailure.states.x && s.states.count === 5 && s.states.bonus === 7 && s.views.length === 3, 'apply preserves matching state names and initializes newly authored states');
+ await page.locator('[data-input="reverse"]').click();
+ check((await state()).states.bonus === 9 && await page.locator('#scene-content [data-view-id="beacon"]').getAttribute('r') === '24', 'new input, entity, binding, and behavior work with source changes alone');
+ await page.locator('[data-input="pulse"]').click();
+ check((await state()).states.count === 10, 'changed behavior takes effect without resetting existing values');
+
+ const withDraft = expanded + '# This un-applied draft must survive Reset.\n';
+ await page.locator('#source').fill(withDraft);
+ await page.locator('#reset').click();
+ s = await state();
+ check(s.states.x === 500 && s.states.count === 0 && s.states.bonus === 7 && s.tick === 0 && s.source === withDraft && s.dirty, 'explicit Reset restores active initializers and keeps the draft');
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ const heldRevision = (await state()).revision;
+ await page.locator('#apply').click();
+ check((await state()).revision === heldRevision && /놓은/.test((await state()).diagnostic), 'Apply is refused while an input is held');
+ await page.keyboard.up('ArrowRight');
+
+ const downloadPromise = page.waitForEvent('download');
+ await page.locator('#download-project').click();
+ const projectDownload = await downloadPromise;
+ const downloadPath = 'output/playwright/pure-layout-workbench-draft.json';
+ await projectDownload.saveAs(downloadPath);
+ downloaded = { filename: projectDownload.suggestedFilename(), path: downloadPath, expectedSource: withDraft };
+ await page.locator('#new').click();
+ s = await state();
+ check(s.source === '' && s.views.length === 3 && s.appliedSource === expanded, 'clear editor is explicit and does not clear the running scene before Apply');
+ await page.locator('#file-input').setInputFiles(downloadPath);
+ await page.waitForFunction(expected => window.pureWorkbench.snapshot().source === expected, withDraft);
+ check((await state()).source === withDraft && (await state()).appliedSource === expanded && (await state()).dirty, 'downloaded project restores the exact un-applied draft without execution');
+ await page.locator('#file-input').evaluate(input => {
+ const transfer = new DataTransfer();
+ transfer.items.add(new File(['{"format":"other","version":1,"source":"state no = 1"}'], 'invalid.json', { type: 'application/json' }));
+ input.files = transfer.files;
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ });
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().diagnostic.includes('형식'));
+ check((await state()).source === withDraft && (await state()).appliedSource === expanded, 'invalid JSON project schema cannot replace the draft');
+
+ await apply('state n = 1\ninput fail = Space\ntext label(40, 60, n)\non fail.down { n = n / 0 }\n');
+ await page.locator('[data-input="fail"]').dispatchEvent('click', { detail: 0 });
+ s = await state();
+ check(s.states.n === 1 && s.lastError === 4 && s.held.length === 0 && s.lastEvent.phase === 'up', 'failed assistive down event rolls back and its error survives the following successful up');
+ check(await page.locator('#runtime-error').isVisible() && (await page.locator('#runtime-error').textContent()).includes('마지막 실패'), 'latched runtime failure is visible and labeled separately from the latest input');
+ await page.locator('#reset').click();
+ check((await state()).lastError === 0 && await page.locator('#runtime-error').isHidden(), 'explicit Reset clears the failure record');
+
+ await page.locator('#new').click();
+ await page.locator('#apply').click();
+ s = await state();
+ check(s.source === '' && s.appliedSource === '' && s.views.length === 0 && Object.keys(s.states).length === 0 && await page.locator('#input-controls button').count() === 0, 'applying empty source removes all authored states, inputs, and scene contents');
+ await page.locator('#example').selectOption('counter');
+ await page.locator('#load-example').click();
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().source.includes('state count'));
+ check((await state()).views.length === 0 && (await state()).appliedSource === '' && (await state()).dirty, 'explicit example loading only fills the editor and never auto-applies');
+
+ await apply(authorSource);
+ await page.locator('[data-input="pulse"]').click();
+ await page.locator('[data-input="pulse"]').click();
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ for (let i = 0; i < 12; i++) await page.locator('#step').click();
+ await page.keyboard.up('ArrowRight');
+ lastAuthored = await state();
+ check(lastAuthored.states.x === 114 && lastAuthored.states.count === 2 && lastAuthored.memory.linearBytes === 65536, 'authored scene executes in the actual fixed 64 KiB Wasm memory');
+ await page.locator('.cost-details summary').click();
+ check((await page.locator('#cost-values').textContent()).includes('65,536 B'), 'cost inspector shows measured Wasm memory without claiming browser heap totals');
+ await page.locator('.cost-details summary').click();
+ await page.screenshot({ path: shots[1], fullPage: true });
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ mobile = await page.evaluate(() => ({ viewport: innerWidth, content: document.documentElement.scrollWidth }));
+ check(mobile.content <= mobile.viewport, '390px mobile viewport has no horizontal overflow');
+ await page.locator('[data-input="pulse"]').click();
+ check((await state()).states.count === 3, 'generated mobile input buttons remain usable');
+ await page.screenshot({ path: shots[2], fullPage: true });
+ const comparison = await page.request.get('http://127.0.0.1:8766/baseline.html');
+ const equivalent = await page.request.get('http://127.0.0.1:8766/examples/charge.pure');
+ check(comparison.ok() && equivalent.ok() && await page.locator('a[href="./baseline.html"]').count() === 1, 'ordinary-JavaScript comparison and equivalent Pure example are linked and served');
+
+ await page.route('**/core.wasm', route => route.abort());
+ await page.reload();
+ await page.waitForFunction(() => document.getElementById('ready-status').textContent.includes('실패'));
+ check(await page.locator('#apply').isDisabled() && await page.locator('#play').isDisabled() && await page.locator('#step').isDisabled() && /실행기를 열지/.test(await page.locator('#diagnostic').textContent()), 'missing Wasm shows an actionable load failure and disables execution controls');
+ await page.unroute('**/core.wasm');
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.reload();
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ check((await state()).source === '' && (await state()).views.length === 0, 'fresh load always returns to a blank authoring environment');
+ check(errors.length === 0, 'no uncaught page errors during the browser scenarios');
+ const evidence = { recordedUtc: new Date().toISOString(), browser: await page.evaluate(() => navigator.userAgent),
+ checks, authoredSource: authorSource, authoredSnapshot: lastAuthored, mobile, downloaded, errors, screenshots: shots };
+ // CLI can yield early when native dialogs/download events occur. Retain the
+ // observation report so a follow-up CLI call can retrieve the complete run.
+ await page.evaluate(value => { window.__pureWorkbenchQAEvidence = value; }, evidence);
+ return evidence;
+ } catch (error) {
+ const evidence = { failed: true, error: error.message, stack: error.stack, checks, errors };
+ await page.evaluate(value => { window.__pureWorkbenchQAEvidence = value; }, evidence);
+ return evidence;
+ } finally {
+ page.off('pageerror', onError);
+ page.off('dialog', acceptDialog);
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-23-layout/sources/CONTRACT.md b/prototypes/workbench/results/2026-09-23-layout/sources/CONTRACT.md
new file mode 100644
index 0000000..5c2bfae
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/sources/CONTRACT.md
@@ -0,0 +1,371 @@
+# Workbench experiment contract
+
+An author starts with empty source. There is no built-in player, physics, jump,
+score, or timer behavior. All behavior is declared by the author. This is a
+bounded scalar interaction language experiment, not a general language claim.
+
+## Source language
+
+```text
+state count = 0
+input tap = Space
+box tile(80, 150, 60, 60, "#356548")
+circle dot(200, 180, 20, "#c78638")
+text label(80, 100, count, "Count: ")
+on tap.down {
+ count = count + 1
+}
+on frame {
+ if count > 20 {
+ count = 0
+ }
+}
+```
+
+Declarations and statements end at a newline or semicolon; `#` comments outside
+strings. ASCII identifiers, finite numeric literals, state initializers must be
+numeric constants (optional unary sign). Input key codes accepted:
+Space, Enter, ArrowLeft/Right/Up/Down, KeyA..KeyZ, Digit0..Digit9. Input names
+must be unique, key codes unique, names cannot shadow `frame`, `dt`, `min`, `max`,
+`clamp`, `event_x`, `event_y`. State and input names use separate namespaces, entity IDs unique.
+
+Expressions: parentheses, state references, dt (seconds on frame, zero on input),
+unary - and !, + - * /, < <= > >= == !=, && ||, min(a,b), max(a,b), clamp(x,lo,hi).
+Logical operators evaluate both sides. Division by zero/non-finite results fail
+the whole event without committing state changes. No loops, recursion or eval.
+An if without else executes its condition once; assignment statements are ordered.
+Multiple handlers for the same event execute in source order within one atomic event.
+`dt` is allowed in handlers, not view bindings. A frame always uses 1/60 seconds.
+
+box: x,y,width,height expressions + literal hex color; circle: x,y,radius + color;
+text: x,y,value expression + optional literal prefix. Values are world units in a
+640x360 view. No implicit clipping/collision/physics. Render expressions are pure.
+Unknown identifiers and invalid syntax include 1-based line+column diagnostics.
+
+Initial scene is empty. Optional examples are explicit insertions, never auto-run.
+Successful apply preserves scalar states by exact name; new names use initializers,
+removed names disappear. Apply/reset are separate. Failed parse/load changes nothing.
+Apply is refused while any input is held; release first. This avoids inventing
+release events across changed mappings. Focus loss sends .up to ordinary buttons
+and .cancel to pointer inputs.
+
+## Compiler interface (web/compiler.mjs, browser and Node)
+
+`compile(source)` returns:
+```
+{
+ states:[{name,initial,line}], inputs:[{name,key,line} | {name,target,line}],
+ instructions:[{op,arg,value,line}], handlers:[{event,start,length,line}],
+ views:[{id,type,line,color?,prefix?,bindings:{x:{start,length},...}}],
+ metrics:{...}
+}
+```
+Bindings: box x/y/width/height; circle x/y/radius; text x/y/value.
+Compiler throws an Error with .line and .column. Empty source is a valid empty program.
+Handlers and binding expressions all occupy instructions (maximum 512 total), but
+only handlers execute on events; bindings are evaluated on demand by VM eval_at.
+Limits: 32 states, 8 inputs, 24 views, 64 handlers, 512 instructions, stack 64,
+source 32768 UTF-8 bytes. State IDs follow declaration order. Input event IDs:
+frame=0; input i down=1+2*i, up=2+2*i, move=17+i, cancel=25+i.
+Move/cancel handlers require a pointer input in source. States may be referenced before declaration;
+symbols must be resolved after gathering top-level declarations. `metrics` can
+record source bytes/lines and instruction/handler/view/state counts.
+
+## VM opcodes
+
+| op | operation |
+|---:|---|
+|0| push constant `value` |
+|1| push state `arg` |
+|2| push dt |
+|3..6| add, subtract, multiply, divide |
+|7| negate |
+|8..13| less, less-equal, greater, greater-equal, equal, not-equal |
+|14..16| AND, OR, NOT (nonzero true) |
+|17..19| min, max, clamp (push x,lo,hi; invalid lo>hi fails) |
+|20| store state `arg` (pop) |
+|21| jump-if-zero to absolute instruction index `arg` (pop), forward only |
+|22,23| push current pointer event x,y; fail without point context |
+
+Rules start with an empty stack and must end with empty stack. Bindings must finish
+with exactly one value and cannot STORE, branch, or read dt/event coordinates. Branch targets may be handler end.
+
+## Zig exported ABI
+
+`reset_all()` clears programs and state. `begin_program(state_count,input_count)`
+starts staging, returns0 success; `put_state(id,initial,old_id)` old_id=-1 means init;
+`put_instruction(op,arg,value)` append; `put_handler(event,start,length)` append;
+`commit_program()` all0 success otherwise error code, atomic; candidate is separate
+from the active program, but shares its workspace with optional evaluation records.
+Preserved IDs read active values only at successful commit. Validate limits,
+indices, finite values and forward branch ranges; runtime stack errors are checked.
+`reset_state()` restores active initializers, resets tick/event diagnostics.
+`dispatch(event,dt)` accepts0..16 and finite dt; event0 dt must equal1/60, input dt0;
+returns0 success, other code failure with no state mutation. Tick increments for
+successful frame; one event runs all matching handlers in source order.
+`dispatch_point(event,x,y)` accepts existing input down/up IDs plus move/cancel IDs
+within input_count, never frame0. Coordinates must be finite; dt is implicitly0.
+The core only knows input count, not host device kinds. The compiler and host enforce
+pointer ownership and device mapping. Calling legacy dispatch on a coordinate-reading
+handler fails with bad_event9. Point operands are ephemeral execution context, not
+extra persistent state slots. Both dispatch paths use the same atomic commit/history.
+The host requires this new export; older Wasm must be rebuilt.
+`eval_at(start,length)` returns f64 or NaN on invalid/pure-expression failure;
+must not mutate state or event diagnostics. `get_state(id)` returns f64 orNaN.
+`info(id)` u32: 0 tick,1 last_error,2 error_pc (0xffffffff none),3 last_executed_ops,
+4 revision,5 state_count,6 instruction_count,7 handler_count,8 input_count,
+9 successful_event_count. `layout(id)` u32: 0 Engine reserved @sizeOf,
+1 Program reserved @sizeOf,2 Instruction @sizeOf,3 Handler @sizeOf,
+4 active used IR bytes (instructions+handlers),5 active state-value bytes,
+6 staging reserved bytes (subset of Engine),7 evaluation stack capacity bytes.
+`error_message` belongs to host map; core error codes documented in core.zig.
+No allocation or imported runtime in Wasm. Fixed64KiB linear memory target with
+16KiB stack; increase only if actual linker evidence requires and document it.
+
+## Host interface (web/runtime.mjs)
+
+`await createRuntime(wasmBytes?, evaluationOptions?)` fetches core.wasm in browser if omitted.
+Returns object with apply(source), reset(), frame(count=1), down(name,source),
+up(name,source), point(name,phase,x,y,source), releaseAll(), snapshot(), exportProject(), currentProgram.
+Multiple sources per input share one logical held state; down emits only first
+source, up only last. Source strings identify keyboard vs onscreen pointer.
+`apply` returns compiled program or throws with diagnostic. It must fully validate
+in host and stage+commit in core. It preserves by name. `reset` keeps active program.
+`snapshot` returns {states:{name:value}, views:[{id,type,x,y,width?,height?,radius?,
+value?,prefix?,color?}], tick,revision,lastError,errorLine,lastOps,memory:{...},held:[]}
+`currentProgram` is the latest successfully applied compiled program or null.
+The program and all nested metadata are frozen so readers cannot corrupt state
+name mappings or binding ranges. `lastFailure` records the last failed event;
+its `lastError`/line remain visible through successful release or focus cleanup
+until successful apply or explicit reset. `lastEvent` still describes the latest
+event. A view evaluation failure uses host error100 and does not roll back events.
+UI draft and applied code are distinct. Browser/CLI share compiler/runtime/wasm;
+instances are separate, not a live collaborative session. No arbitrary JS evaluation.
+
+## Bounded display evaluation (2026-09-22)
+
+The existing event and float semantics above are unchanged. The forgetful C
+prototype's contract is adapted into `evaluation.zig`; its uint64 DAG is not
+used as a substitute for this VM's float instructions.
+
+Each snapshot starts one display budget, shared by all bindings. Default options
+are `{cacheEntries:0, historyEntries:8, workLimit:2048}`. A unit is a binding
+request, a candidate-cache probe, a change-record probe, or an executed VM
+instruction. State extraction, host object creation, event dispatch and DOM work
+are outside this budget. It is not a time limit or a bound on whole-app work.
+For the current compiler limits, 96 bindings * 10 units plus 512 instructions
+is at most 1,472 units; the default admits even an uncached valid scene with
+cache lookup/validation enabled. Malformed bindings may fail sooner.
+
+`configureEvaluation({cacheEntries?,historyEntries?,workLimit?})` accepts integers
+0..32, 0..8, and 0..4294967295 respectively. Missing fields retain prior settings.
+Valid configuration clears reuse records; invalid configuration changes nothing.
+`forgetEvaluation('values'|'history'|'all')` drops optional records without
+changing authored state, event diagnostics or program identity.
+`evaluationStats()` returns the options plus work, instructions, cacheHits for
+the last snapshot, and reservedBytes for the complete fixed evaluation structure.
+Statistics are separate from snapshots so repeated observations remain equivalent.
+
+Memo entries contain the value, instruction range, input dependency bit mask
+and validated revision. Direct mapping uses one candidate probe, with collisions
+causing recomputation. Only successful computations are cached. Constant results
+need no input-change history. All other reuse requires a complete retained
+history since validation and no intersection with changed states. Dependencies
+are collected from actual VM LOADs, not trusted compiler-supplied annotations.
+History loss causes recomputation. All history and memo storage is fixed size;
+setting capacities to zero does not return reserved linear memory.
+
+Only bitwise-different committed states advance change history (including signed
+zero). Failed events and net-zero updates do not. Successful apply and reset
+discard all reuse records. Parse/host validation failure before staging preserves
+records. Beginning core staging discards them, including if that load is later
+rejected or aborted. Epoch wrap also clears records. No inputs or effectful event
+handlers are replayed to rebuild a binding.
+
+Budget exhaustion returns an empty views array for the entire snapshot, with
+displayError `{code:101,line,message}`. Successfully committed event state remains.
+There is no automatic retry or silent return of an old scene. Raising the budget
+allows a fresh attempt. Arithmetic/size errors retain the earlier per-view error100
+behavior. `displayError` is null on display success; a latched execution error
+still takes precedence in lastError/errorMessage, while displayError independently
+reports display failure. Partial successful binding memos may survive a failed
+snapshot but no partial scene is published.
+
+New core exports: `configure_evaluation(cache,history)` returns 0 or capacity1;
+`forget_evaluation(flags)` bits 1=values, 2=history; `begin_evaluation(limit)`;
+`eval_bounded(start,length)` returns f64 or NaN and consumes that shared budget.
+`evaluation_info(id)` returns 0 status (10=work limit), 1 work, 2 instructions,
+3 cache hits, 4 cache capacity, 5 history capacity, 6 reserved bytes. These
+diagnostics do not replace event info. The unbounded pure `eval_at` remains for
+reference checks and is not the host snapshot path.
+
+## Shared load/reuse workspace (2026-09-23)
+
+The pending definition/restore values and evaluation Tables occupy one untagged union. The active
+Program, current state and evaluation control fields remain separate. All core
+calls are single-threaded and synchronous; no callback can reenter a phase change.
+No pointer to the reused tables is retained between calls.
+
+`begin_program` switches the workspace to staging and drops optional records.
+Until commit/abort, old events and bindings still run against active. Binding
+evaluation bypasses reuse and recomputes within its budget; cache configuration,
+forget and reset must not write the staging payload. Old-id state preservation
+reads the current values at successful commit, including events/reset during load.
+
+Successful commit publishes the new Program and then initializes empty Tables in
+the shared workspace. `abort_program()` discards a pending definition and restores
+empty Tables without changing active program/state/event diagnostics. Inactive
+abort is a no-op. A failed commit leaves staging open for explicit abort or the
+next begin. An invalid begin closes any previous pending load. Host apply aborts
+after a core loading failure. Lost reuse may make a previously warm tiny budget
+insufficient; neither a preserved cache nor completion at that budget is promised.
+
+`evaluation_info` additionally reports 7 dedicated control bytes, 8 shared Tables
+bytes, 9 reuse available (false during load), and 10 union safety-tag overhead.
+The host exposes these as dedicatedBytes, sharedBytes, reuseAvailable and
+workspaceTagBytes. reservedBytes is control + Tables capacity, not incremental
+Engine storage. The Tables capacity overlaps stagingReservedBytes and must not
+be added to it. Before document restoration was added, the Wasm control was 48 B, shared Tables
+1,152 B, union overhead 8 B. That Engine was 19,128 B, only 56 B larger than the
+19,072 B pre-evaluation core; linear memory remains 65,536 B. No OS pages are
+returned and this is not a measurement of browser/process memory.
+
+## Direct output publication (2026-09-23)
+
+`present(target)` evaluates the same bindings, in the same order and shared
+budget as `snapshot()`, then calls a synchronous trusted output adapter:
+
+- `begin(program)`: the frozen current definition, or null at startup.
+- `state(index, value)`: each current authored state, without a host state map.
+- `view(index, valid)`: every declared view's publication status.
+- `value(viewIndex, bindingName, value)`: each binding of a valid view only.
+- `end(visibleCount)`: number of valid views.
+
+It returns the snapshot's diagnostics, held inputs, event information and memory
+report, without `states` or `views`. Evaluation finishes before the first adapter
+callback. On budget error101 every view is invalid and no binding values are
+published; committed state values are still published. Error100 invalidates only
+the affected views, as before. No stale value is silently substituted.
+
+Runtime mutations, `snapshot()` and nested `present()` are rejected during
+callbacks. Read-only program/export/statistics access remains available. Adapters
+must be synchronous and must not throw. If one does throw, the exception
+propagates and the runtime unlocks in a finally block; arbitrary external adapter
+effects cannot be rolled back. A later call republishes every value. This is not
+a transaction across arbitrary output devices or an untrusted plugin boundary.
+
+Evaluation uses a private reusable Float64Array(96) and Uint8Array(24), allocated
+on first snapshot/presentation. Their payload totals 792 host bytes, outside the
+64 KiB Wasm memory. Typed-array object overhead, binding-entry arrays built at
+apply, and DOM destination references are additional host memory. The numeric
+buffer is a publication barrier, not another authoritative state or public
+mutable scene. `snapshot()` remains an independent object representation for
+CLI/export/debugging, built only when explicitly requested.
+
+The browser calls `present()` for normal refreshes. Its output adapter binds DOM
+destinations once per successful definition change and compares values with the
+existing DOM before writing. Input refreshes reuse scene, state and cost elements.
+Invalid views remain allocated but are hidden using SVG `display="none"`; recovery
+reveals them. A definition change can replace all scene/state destinations. This
+does not skip unchanged binding evaluation, eliminate all host allocations, or
+provide measured process-memory/paint-latency savings.
+
+## Authored direct manipulation (2026-09-23)
+
+`input drag = pointer(tile)` binds a logical input to a declared view, including a
+forward declaration. One input per target; it shares the existing 8-input limit
+with keyboard inputs. `on drag.down/move/up/cancel` handlers may read `event_x`
+and `event_y` in the same 640x360 world coordinates as output. They are unavailable
+in keyboard/frame handlers and display bindings; copy to authored state to retain
+them. There is no built-in drag constraint, snap, grab offset, or gesture rollback.
+The direct.pure example authors these using ordinary assignments and expressions.
+
+Host `point` requires a pointer input, valid phase, finite coordinates and nonempty
+source string. Each logical input has one owning source; different targets may
+have independent active contacts. Same-owner repeated down is a no-op; another
+owner's down throws. Move/up/cancel without that owner are no-ops. Down establishes
+ownership before dispatch; up/cancel release it before dispatch, even if authored
+arithmetic fails. An event failure rolls back that event's state writes, not the
+whole gesture. `releaseAll` cancels owned pointers at their last delivered position,
+then releases ordinary buttons. `reset` directly clears ownership and resets state.
+Last point events include x,y. A failed cancel can leave authored state unchanged,
+but cannot strand host ownership; the failure remains visible until reset/apply.
+
+Browser hit testing selects the declared target on primary-button pointerdown.
+The stable SVG root captures that pointer, including movement/release outside the
+scene. Client coordinates are transformed through the inverse SVG screen matrix.
+Pointerup exposes its own final coordinates to authored handlers. The direct.pure
+example updates position only on move and ends dragging on up, preserving the
+last displayed position even if a host repeats stale coordinates on release. Termination is observed in the window capture phase, including releases outside
+the SVG. A primary mouseup can finish an owned mouse gesture if pointerup was
+not delivered; it cannot finish a touch gesture. Duplicate termination is ignored.
+Only capture loss of the SVG root ends its owner, not a bubbled descendant event.
+Pointercancel, lostpointercapture and
+focus loss use the last delivered position and dispatch cancel, never fake up.
+Missing/non-invertible coordinate transforms cancel an active gesture. A second
+contact on the same target is ignored; contacts on different targets are independent.
+Touch scenes disable native panning on the SVG. Mouse/touch share this adapter;
+pen-specific pressure, tilt and button meanings are not implemented or validated.
+
+Every delivered pointermove executes synchronously; this layer adds no event queue,
+coalescing, frame gating or newest-only drop policy. This is not a guarantee that
+browsers expose every hardware sample. Keyboard commands and pointer coordinates
+enter the same authored state/commit/output path. CLI `point NAME PHASE X Y` uses
+that host API and core with source `terminal`; it does not replay browser hit testing.
+
+
+## Placement documents (2026-09-23)
+
+Code-only version1 JSON and .pure files keep their earlier behavior. Version2
+stores a restorable authored document, not a suspended execution image:
+
+```json
+{"format":"pure-workbench","version":2,"source":"state x=80","draft":"state x=80",
+ "states":[{"name":"x","value":350}]}
+```
+
+`source` is the applied definition; optional `draft` is separate unapplied editor
+text (default source). Each is at most 32768 UTF-8 bytes. Draft syntax may be
+unfinished. State records must contain exactly one finite value for every declared
+state name, with no extra, missing or duplicate names. Record order is irrelevant.
+JSON numbers preserve finite f64 values, except negative zero which is encoded as
+the exact string `"-0"`. No other numeric string is accepted. Input latches, running
+clock, diagnostics, caches, DOM, compiled IR and external resources are not saved.
+The serialized input file limit is 401408 bytes, including escaping/metadata.
+
+`exportDocument(draft?)` returns an independent version2 object. It refuses an
+unapplied runtime, held input or latched execution failure. It reads core state,
+without constructing an output snapshot. `restoreDocument(document)` validates
+and compiles before staging, refuses held input, then commits the definition and
+all saved values together. It returns `{source,draft}`. Failed validation leaves
+active program/values/diagnostics intact. A failed core load is aborted; optional
+reuse records may be lost as with ordinary apply. Successful restore discards old
+reuse, clears diagnostics, resets tick/event count and starts with no held inputs.
+The original source initializers remain intact: explicit reset restores those,
+not saved values. Existing `apply` continues to preserve current values by name.
+Both new document APIs reject reentry during output publication.
+
+Core exports `put_restored_value(id,value)` and `commit_restored_program()` extend
+the existing begin/put/commit loader. Every declared state needs exactly one finite
+restored value; duplicate/out-of-range/nonfinite input poisons staging. Restored
+commit checks completeness and program validity before touching active state.
+Ordinary commit rejects staged restored values. Pending values live alongside the
+pending Program in the same union as optional evaluation Tables; active Program
+layout does not grow. `layout(6)` now includes Pending, rather than Program only.
+The current Wasm Pending is 9656 B (264 B added for 32 values, mask and alignment),
+Engine is 19392 B; linear memory remains fixed at 65536 B. These are build-layout
+facts, not a browser RAM or performance improvement claim. `evaluation_info(10)`
+continues to report only union tag overhead, excluding restoration payload.
+
+Browser **배치 저장** stores applied source, all current values and editor draft.
+It requires a stopped clock. **파일 열기** restores version2 after replacement
+confirmation when existing work is present; it starts stopped. Version1/plain
+source still only replaces the editor draft. Async reading checks draft generation,
+program identity and successful event count so intervening edits/inputs cannot be
+overwritten by a stale read. Invalid files do not alter the working document.
+**.pure 저장 / 코드 JSON 저장** remain draft-only exports.
+
+CLI `save-project PATH` writes version2 with exclusive creation; `restore PATH`
+restores it. Loading a .json path (including at startup) distinguishes versions.
+A browser draft is retained through CLI save-project, but never applied implicitly.
+Existing `save PATH` remains applied-source-only. No filesystem overwrite is added.
diff --git a/prototypes/workbench/results/2026-09-23-layout/sources/cli.mjs b/prototypes/workbench/results/2026-09-23-layout/sources/cli.mjs
new file mode 100644
index 0000000..78e0b49
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/sources/cli.mjs
@@ -0,0 +1,93 @@
+#!/usr/bin/env node
+import { readFile, writeFile } from 'node:fs/promises';
+import { createInterface } from 'node:readline';
+import { createRuntime } from './web/runtime.mjs';
+import { parseProject } from './web/project.mjs';
+
+const help = `Pure authoring workbench · 같은 정의와 Zig 실행기
+ load PATH .pure 파일을 적용 (같은 이름의 상태 보존)
+ down NAME 정의한 입력 누르기
+ up NAME 정의한 입력 놓기
+ point NAME PHASE X Y 좌표 입력 (down / move / up / cancel)
+ frame [1..600] 시간 진행, 1프레임 = 1/60초
+ show 상태와 도형 출력
+ limits C H W 표시 캐시(0..32)·변경 기록(0..8)·작업량(0..4294967295) 설정
+ forget [PART] 표시 기록 삭제: all, values, history (상태는 유지)
+ costs 마지막 표시 계산의 작업량·명령 수·캐시 적중 출력
+ reset 현재 정의의 초기 상태로
+ save PATH 적용한 정의를 저장
+ save-project PATH 적용 코드와 현재 배치를 JSON으로 저장
+ restore PATH 저장한 배치를 복원 (초안이 있으면 파일에 보존)
+ help / quit
+브라우저와 별도 인스턴스입니다. 임의 JavaScript는 실행하지 않습니다.`;
+
+async function main() {
+ const args = process.argv.slice(2);
+ if (args.includes('--help')) { console.log(help); return; }
+ if (args.length > 1) throw new Error('사용법: node cli.mjs [project.pure]');
+ const runtime = await createRuntime(await readFile(new URL('./web/core.wasm', import.meta.url)));
+ let savedDraft = null;
+ async function loadFile(path, restoreOnly = false) {
+ const content = await readFile(path, 'utf8');
+ if (restoreOnly || path.toLowerCase().endsWith('.json')) {
+ const project = parseProject(content);
+ if (project.version === 2) {
+ savedDraft = runtime.restoreDocument(project).draft;
+ return;
+ }
+ if (restoreOnly) throw new Error('현재 배치가 포함된 version 2 파일이 필요합니다.');
+ runtime.apply(project.source);
+ } else runtime.apply(content);
+ savedDraft = null;
+ }
+ if (args[0]) await loadFile(args[0]); else runtime.apply('');
+ const terminal = createInterface({ input: process.stdin, output: process.stdout, terminal: Boolean(process.stdin.isTTY) });
+ if (process.stdin.isTTY) console.log(help);
+ terminal.setPrompt('pure> ');
+ if (process.stdin.isTTY) terminal.prompt();
+ for await (const line of terminal) {
+ try {
+ const match = /^(\S+)(?:\s+(.+))?$/.exec(line.trim());
+ const name = match?.[1] || 'show';
+ const arg = match?.[2];
+ if (name === 'quit') { terminal.close(); break; }
+ if (name === 'help') console.log(help);
+ else if (name === 'costs' && !arg) console.log(JSON.stringify({ evaluation: runtime.evaluationStats() }));
+ else {
+ if (name === 'load' && arg) await loadFile(arg);
+ else if (name === 'restore' && arg) await loadFile(arg, true);
+ else if (name === 'save-project' && arg) await writeFile(arg, JSON.stringify(runtime.exportDocument(savedDraft ?? undefined), null, 2) + '\n', {flag:'wx'});
+ else if (name === 'save' && arg) await writeFile(arg, runtime.exportProject().source, { flag: 'wx' });
+ else if (name === 'down' && arg) runtime.down(arg, 'terminal');
+ else if (name === 'up' && arg) runtime.up(arg, 'terminal');
+ else if (name === 'point' && arg) {
+ const parts = arg.split(/\s+/);
+ if (parts.length !== 4) throw new Error('point NAME PHASE X Y 형식을 사용하세요.');
+ runtime.point(parts[0], parts[1], Number(parts[2]), Number(parts[3]), 'terminal');
+ } else if (name === 'frame') {
+ if (arg !== undefined && !/^\d+$/.test(arg)) throw new Error('frame 1..600 정수를 입력하세요.');
+ const count = arg === undefined ? 1 : Number(arg);
+ if (count < 1 || count > 600) throw new Error('frame 1..600 정수를 입력하세요.');
+ runtime.frame(count);
+ } else if (name === 'reset' && !arg) runtime.reset();
+ else if (name === 'limits' && arg) {
+ const parts = arg.split(/\s+/);
+ if (parts.length !== 3 || parts.some(part => !/^\d+$/.test(part)))
+ throw new Error('limits C H W 정수 세 개를 입력하세요.');
+ const [cacheEntries, historyEntries, workLimit] = parts.map(Number);
+ runtime.configureEvaluation({ cacheEntries, historyEntries, workLimit });
+ } else if (name === 'forget') runtime.forgetEvaluation(arg || 'all');
+ else if (name !== 'show' || arg) throw new Error('명령 형식을 확인하세요. help로 안내를 볼 수 있습니다.');
+ const result = runtime.snapshot();
+ console.log(JSON.stringify(result));
+ if (result.lastError && !process.stdin.isTTY) process.exitCode = 1;
+ }
+ } catch (error) {
+ console.error(`${error.line ? `${error.line}:${error.column || 1} ` : ''}${error.message}`);
+ if (!process.stdin.isTTY) process.exitCode = 1;
+ }
+ if (process.stdin.isTTY) terminal.prompt();
+ }
+}
+
+main().catch((error) => { console.error(error.message); process.exitCode = 1; });
diff --git a/prototypes/workbench/results/2026-09-23-layout/sources/cli.test.mjs b/prototypes/workbench/results/2026-09-23-layout/sources/cli.test.mjs
new file mode 100644
index 0000000..f355d2d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/sources/cli.test.mjs
@@ -0,0 +1,121 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createRuntime } from './web/runtime.mjs';
+
+const root = fileURLToPath(new URL('.', import.meta.url));
+function run(input, file) {
+ const result = spawnSync(process.execPath, [join(root, 'cli.mjs'), ...(file ? [file] : [])], {
+ input, encoding: 'utf8', cwd: root,
+ });
+ if (result.error) throw result.error;
+ return { ...result, snapshots: result.stdout.split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line)) };
+}
+
+test('terminal starts empty and produces the same charge trace as the shared runtime', async () => {
+ assert.deepEqual(run('show\nquit\n').snapshots[0].states, {});
+ const file = join(root, 'examples/charge.pure');
+ const result = run('down jump\nframe 30\nup jump\nframe 1\nshow\nquit\n', file);
+ assert.equal(result.status, 0, result.stderr);
+ const runtime = await createRuntime(await readFile(join(root, 'web/core.wasm')));
+ runtime.apply(await readFile(file, 'utf8'));
+ runtime.down('jump', 'terminal'); runtime.frame(30); runtime.up('jump', 'terminal'); runtime.frame();
+ // JSON has no undefined-valued optional fields; compare the same wire form.
+ assert.deepEqual(result.snapshots.at(-1), JSON.parse(JSON.stringify(runtime.snapshot())));
+ assert(result.snapshots.at(-1).states.y < 240);
+});
+
+test('invalid terminal commands do not alter state and fail piped execution', () => {
+ const result = run('show\nframe 0\nframe -1\nframe 1.5\nframe 1e2\nframe 601\nshow extra\ndown unknown\nshow\nquit\n', join(root, 'examples/counter.pure'));
+ assert.equal(result.status, 1);
+ assert.deepEqual(result.snapshots[0], result.snapshots[1]);
+ assert.equal(result.stderr.trim().split('\n').length, 7);
+});
+
+test('terminal replays authored pointer movement and cancellation through the shared runtime', async () => {
+ const file = join(root, 'examples/direct.pure');
+ const result = run('point drag down 90 170\npoint drag move 360 220\npoint drag up 410 240\npoint drag down 410 240\npoint drag move 500 260\npoint drag cancel 500 260\nshow\nquit\n', file);
+ assert.equal(result.status, 0, result.stderr);
+ const runtime = await createRuntime(await readFile(join(root, 'web/core.wasm')));
+ runtime.apply(await readFile(file, 'utf8'));
+ for (const [phase, x, y] of [['down',90,170],['move',360,220],['up',410,240],['down',410,240],['move',500,260],['cancel',500,260]])
+ runtime.point('drag', phase, x, y, 'terminal');
+ assert.deepEqual(result.snapshots.at(-1), JSON.parse(JSON.stringify(runtime.snapshot())));
+ assert.equal(result.snapshots.at(-1).states.x, 350);
+ assert.equal(result.snapshots.at(-1).states.y, 210);
+ assert.deepEqual(result.snapshots.at(-1).held, []);
+});
+
+test('file load preserves by name, rejected load preserves active work, and save never overwrites', async t => {
+ const directory = await mkdtemp(join(tmpdir(), 'pure-author-cli-'));
+ t.after(() => rm(directory, { recursive: true, force: true }));
+ const first = join(directory, 'first.pure'), second = join(directory, 'second with space.pure');
+ const invalid = join(directory, 'invalid.pure'), saved = join(directory, 'saved.pure');
+ await writeFile(first, 'state n = 7');
+ await writeFile(second, 'state n = 100\nstate added = 9');
+ await writeFile(invalid, 'state n = broken');
+ const result = run(`load ${second}\nload ${invalid}\nshow\nsave ${saved}\nreset\nsave ${saved}\nquit\n`, first);
+ assert.equal(result.status, 1);
+ assert.deepEqual(result.snapshots[0].states, { n: 7, added: 9 });
+ assert.deepEqual(result.snapshots[1], result.snapshots[0]);
+ assert.deepEqual(result.snapshots.at(-1).states, { n: 100, added: 9 });
+ assert.equal(await readFile(saved, 'utf8'), await readFile(second, 'utf8'));
+ assert.match(result.stderr, /EEXIST/);
+});
+
+test('terminal exposes arithmetic rollback and retains failure through matching release', async t => {
+ const directory = await mkdtemp(join(tmpdir(), 'pure-author-error-'));
+ t.after(() => rm(directory, { recursive: true, force: true }));
+ const file = join(directory, 'error.pure');
+ await writeFile(file, 'state n = 7\ninput tap = Space\non tap.down { n = 1 / 0 }');
+ const result = run('down tap\nup tap\nshow\nreset\nquit\n', file);
+ assert.equal(result.status, 1);
+ for (const snapshot of result.snapshots.slice(0, 3)) {
+ assert.equal(snapshot.states.n, 7); assert.equal(snapshot.lastError, 4);
+ assert.equal(snapshot.errorLine, 3);
+ }
+ assert.equal(result.snapshots[3].lastError, 0);
+});
+
+test('terminal configures and discards evaluation records and recovers from a display limit', () => {
+ const result = run('limits 32 8 2048\nshow\ncosts\nforget all\nlimits 32 8 0\nlimits 32 8 2048\nshow\nquit\n', join(root, 'examples/counter.pure'));
+ assert.equal(result.status, 1); // The explicit zero-budget display failed.
+ assert.equal(result.stderr, '');
+ const [configured, warm, costs, forgotten, limited, recovered, final] = result.snapshots;
+ assert.deepEqual(warm.views, configured.views);
+ assert(costs.evaluation.cacheHits > 0);
+ assert.deepEqual(forgotten.states, configured.states);
+ assert.deepEqual(limited.views, []);
+ assert.equal(limited.lastError, 101);
+ assert.deepEqual(recovered, final);
+ assert.deepEqual(recovered.views, configured.views);
+ assert.deepEqual(recovered.states, configured.states);
+});
+
+test('terminal saves and restores placement independently of reset and preserves a browser draft', async t => {
+ const directory = await mkdtemp(join(tmpdir(), 'pure-layout-cli-'));
+ t.after(() => rm(directory, {recursive:true,force:true}));
+ const saved=join(directory,'layout.json'), copied=join(directory,'copy.json');
+ const first=run(`point drag down 90 170\npoint drag move 360 220\npoint drag up 90 170\nsave-project ${saved}\nreset\nrestore ${saved}\nshow\nquit\n`,join(root,'examples/direct.pure'));
+ assert.equal(first.status,0,first.stderr);assert.equal(first.snapshots.at(-1).states.x,350);
+ const document=JSON.parse(await readFile(saved,'utf8'));document.draft='state incomplete =';
+ await writeFile(saved,JSON.stringify(document));
+ const second=run(`save-project ${copied}\nreset\nshow\nquit\n`,saved);
+ assert.equal(second.status,0,second.stderr);assert.equal(second.snapshots.at(-1).states.x,80);
+ const copy=JSON.parse(await readFile(copied,'utf8'));assert.equal(copy.draft,document.draft);assert.deepEqual(copy.states,document.states);
+ const rejected=run(`save-project ${saved}\nquit\n`,saved);
+ assert.equal(rejected.status,1);assert.match(rejected.stderr,/EEXIST/);
+});
+
+test('invalid restoration leaves terminal code and state unchanged', async t => {
+ const directory=await mkdtemp(join(tmpdir(),'pure-invalid-layout-'));
+ t.after(()=>rm(directory,{recursive:true,force:true}));
+ const file=join(directory,'bad.json');
+ await writeFile(file,JSON.stringify({format:'pure-workbench',version:2,source:'state x=1',states:[]}));
+ const result=run(`show\nrestore ${file}\nshow\nquit\n`,join(root,'examples/counter.pure'));
+ assert.equal(result.status,1);assert.deepEqual(result.snapshots[0],result.snapshots[1]);
+});
diff --git a/prototypes/workbench/results/2026-09-23-layout/sources/core.zig b/prototypes/workbench/results/2026-09-23-layout/sources/core.zig
new file mode 100644
index 0000000..1154297
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/sources/core.zig
@@ -0,0 +1,1136 @@
+//! Bounded scalar interaction VM experiment. No application behavior is built in.
+//! Same source for native tests and freestanding Wasm; no allocation or imports.
+//! Error ABI: 0 ok, 1 capacity, 2 invalid, 3 stack, 4 division by zero,
+//! 5 non-finite, 6 bad branch, 7 bad state, 8 bad frame, 9 bad event.
+//! Counters wrap modulo 2^32. Failed loads preserve the active program, authored
+//! state and event diagnostics; beginning a load discards optional reuse records.
+//! Failed events preserve values and counters but
+//! report their error and attempted instruction count. Binding evaluation is pure.
+
+const std = @import("std");
+const Evaluation = @import("evaluation.zig").Evaluation;
+const Tables = @import("evaluation.zig").Tables;
+
+const max_states = 32;
+const max_inputs = 8;
+const max_instructions = 512;
+const max_handlers = 64;
+const max_stack = 64;
+const no_pc = std.math.maxInt(u32);
+const frame_dt: f64 = 1.0 / 60.0;
+
+const Code = enum(u32) {
+ ok = 0,
+ capacity = 1,
+ invalid = 2,
+ stack = 3,
+ division_by_zero = 4,
+ non_finite = 5,
+ bad_branch = 6,
+ bad_state = 7,
+ bad_frame = 8,
+ bad_event = 9,
+ work_limit = 10,
+};
+
+const Instruction = struct { value: f64 = 0, op: u32 = 0, arg: u32 = 0 };
+const Handler = struct { event: u32 = 0, start: u32 = 0, length: u32 = 0 };
+const Program = struct {
+ instructions: [max_instructions]Instruction = @splat(.{}),
+ handlers: [max_handlers]Handler = @splat(.{}),
+ initials: [max_states]f64 = @splat(0),
+ old_ids: [max_states]i32 = @splat(-1),
+ declared: [max_states]bool = @splat(false),
+ state_count: u32 = 0,
+ input_count: u32 = 0,
+ instruction_count: u32 = 0,
+ handler_count: u32 = 0,
+};
+// Loading a definition/optional restored values and result reuse are mutually exclusive phases. Current execution
+// always reads active, and can recompute bindings while this workspace loads.
+const Pending = struct {
+ program: Program = .{},
+ values: [max_states]f64 = @splat(0),
+ value_mask: u32 = 0,
+};
+const Workspace = union { staging: Pending, reuse: Tables };
+const Engine = struct {
+ active: Program = .{},
+ workspace: Workspace = .{ .reuse = .{} },
+ values: [max_states]f64 = @splat(0),
+ staging_open: bool = false,
+ staging_error: Code = .ok,
+ tick: u32 = 0,
+ last_error: Code = .ok,
+ error_pc: u32 = no_pc,
+ last_ops: u32 = 0,
+ revision: u32 = 0,
+ successful_events: u32 = 0,
+ evaluation: Evaluation = .{},
+};
+var engine: Engine = .{};
+
+fn reuseTables() ?*Tables {
+ return if (engine.staging_open) null else &engine.workspace.reuse;
+}
+
+fn finishStaging() void {
+ engine.workspace = .{ .reuse = .{} };
+ engine.staging_open = false;
+ engine.evaluation.forget(null, 3);
+}
+
+fn public(code: Code) u32 {
+ return @intFromEnum(code);
+}
+
+fn stagingFail(code: Code) u32 {
+ if (engine.staging_error == .ok) engine.staging_error = code;
+ return public(code);
+}
+
+export fn reset_all() void {
+ engine = .{};
+}
+
+export fn begin_program(state_count: u32, input_count: u32) u32 {
+ engine.staging_error = .ok;
+ if (state_count > max_states or input_count > max_inputs) {
+ if (engine.staging_open) finishStaging();
+ return stagingFail(.capacity);
+ }
+ engine.workspace = .{ .staging = .{ .program = .{ .state_count = state_count, .input_count = input_count } } };
+ engine.staging_open = true;
+ engine.evaluation.forget(null, 3);
+ return 0;
+}
+
+export fn abort_program() void {
+ if (engine.staging_open) finishStaging();
+}
+
+export fn put_state(id: u32, initial: f64, old_id: i32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ if (id >= engine.workspace.staging.program.state_count or old_id < -1) return stagingFail(.bad_state);
+ if (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count) return stagingFail(.bad_state);
+ if (engine.workspace.staging.program.declared[id]) return stagingFail(.bad_state);
+ if (!std.math.isFinite(initial)) return stagingFail(.non_finite);
+ engine.workspace.staging.program.initials[id] = initial;
+ engine.workspace.staging.program.old_ids[id] = old_id;
+ engine.workspace.staging.program.declared[id] = true;
+ return 0;
+}
+
+export fn put_instruction(op: u32, arg: u32, value: f64) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (p.instruction_count == max_instructions) return stagingFail(.capacity);
+ if (op > 23) return stagingFail(.invalid);
+ if (!std.math.isFinite(value)) return stagingFail(.non_finite);
+ p.instructions[p.instruction_count] = .{ .op = op, .arg = arg, .value = value };
+ p.instruction_count += 1;
+ return 0;
+}
+
+fn validEvent(event: u32, inputs: u32) bool {
+ return event <= inputs * 2 or
+ (event >= 17 and event - 17 < inputs) or
+ (event >= 25 and event - 25 < inputs);
+}
+
+export fn put_handler(event: u32, start: u32, length: u32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (p.handler_count == max_handlers) return stagingFail(.capacity);
+ if (!validEvent(event, p.input_count)) return stagingFail(.bad_event);
+ // Handlers may be appended before their instructions, so range validation
+ // is deferred until commit. Arithmetic remains guarded there.
+ p.handlers[p.handler_count] = .{ .event = event, .start = start, .length = length };
+ p.handler_count += 1;
+ return 0;
+}
+
+fn validate(p: *const Program) Code {
+ for (0..p.state_count) |id| {
+ if (!p.declared[id]) return .bad_state;
+ if (!std.math.isFinite(p.initials[id])) return .non_finite;
+ const old_id = p.old_ids[id];
+ if (old_id < -1 or (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count)) return .bad_state;
+ }
+ for (p.handlers[0..p.handler_count], 0..) |h, i| {
+ if (!validEvent(h.event, p.input_count)) return .bad_event;
+ if (h.start > p.instruction_count or h.length > p.instruction_count - h.start) return .invalid;
+ if (h.length == 0) continue;
+ for (p.handlers[0..i]) |other| {
+ if (other.length != 0 and h.start < other.start + other.length and other.start < h.start + h.length) return .invalid;
+ }
+ }
+ for (p.instructions[0..p.instruction_count], 0..) |inst, pc| {
+ if (inst.op > 23) return .invalid;
+ if (!std.math.isFinite(inst.value)) return .non_finite;
+ if ((inst.op == 1 or inst.op == 20) and inst.arg >= p.state_count) return .bad_state;
+ if (inst.op >= 20) {
+ var owner: ?Handler = null;
+ for (p.handlers[0..p.handler_count]) |h| {
+ if (pc >= h.start and pc < h.start + h.length) {
+ owner = h;
+ break;
+ }
+ }
+ const h = owner orelse return .invalid;
+ if (inst.op == 21 and (inst.arg <= pc or inst.arg > h.start + h.length)) return .bad_branch;
+ }
+ }
+ return .ok;
+}
+
+export fn put_restored_value(id: u32, value: f64) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const pending = &engine.workspace.staging;
+ if (id >= pending.program.state_count) return stagingFail(.bad_state);
+ const bit = @as(u32, 1) << @as(u5, @intCast(id));
+ if (pending.value_mask & bit != 0) return stagingFail(.bad_state);
+ if (!std.math.isFinite(value)) return stagingFail(.non_finite);
+ pending.values[id] = value;
+ pending.value_mask |= bit;
+ return 0;
+}
+
+export fn commit_program() u32 {
+ return commit(false);
+}
+
+export fn commit_restored_program() u32 {
+ return commit(true);
+}
+
+fn commit(restoring: bool) u32 {
+ if (!engine.staging_open) return public(.invalid);
+ if (engine.staging_error != .ok) return public(engine.staging_error);
+ const count = engine.workspace.staging.program.state_count;
+ const expected: u32 = if (count == 32) 0xffffffff else (@as(u32, 1) << @as(u5, @intCast(count))) - 1;
+ if (engine.workspace.staging.value_mask != (if (restoring) expected else @as(u32, 0)))
+ return stagingFail(.bad_state);
+ const code = validate(&engine.workspace.staging.program);
+ if (code != .ok) return stagingFail(code);
+ var next: [max_states]f64 = @splat(0);
+ for (0..engine.workspace.staging.program.state_count) |i| {
+ const old_id = engine.workspace.staging.program.old_ids[i];
+ next[i] = if (restoring) engine.workspace.staging.values[i] else if (old_id < 0) engine.workspace.staging.program.initials[i] else engine.values[@intCast(old_id)];
+ }
+ engine.active = engine.workspace.staging.program;
+ engine.values = next;
+ finishStaging();
+ if (restoring) {
+ engine.tick = 0;
+ engine.successful_events = 0;
+ }
+ engine.revision +%= 1;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ return 0;
+}
+
+export fn reset_state() void {
+ engine.values = engine.active.initials;
+ engine.evaluation.forget(reuseTables(), 3);
+ engine.tick = 0;
+ engine.successful_events = 0;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+}
+
+const Execution = struct {
+ code: Code = .ok,
+ pc: u32 = no_pc,
+ ops: u32 = 0,
+ value: f64 = 0,
+ dependencies: u32 = 0,
+};
+
+fn executionFail(result: Execution, code: Code, pc: u32) Execution {
+ var failed = result;
+ failed.code = code;
+ failed.pc = pc;
+ return failed;
+}
+
+const EventContext = struct { dt: f64 = 0, x: f64 = 0, y: f64 = 0, point: bool = false };
+
+fn execute(values: *[max_states]f64, start: u32, length: u32, context: EventContext, pure: bool, budget: ?*Evaluation) Execution {
+ const p = &engine.active;
+ var result: Execution = .{};
+ if (start > p.instruction_count or length > p.instruction_count - start) return executionFail(result, .invalid, no_pc);
+ const end = start + length;
+ var stack: [max_stack]f64 = undefined;
+ var sp: usize = 0;
+ var pc = start;
+ while (pc < end) {
+ if (budget) |b| {
+ if (!b.spend()) return executionFail(result, .work_limit, pc);
+ b.instructions += 1;
+ }
+ const inst = p.instructions[pc];
+ result.ops += 1;
+ if (pure and (inst.op == 2 or inst.op >= 20)) return executionFail(result, .invalid, pc);
+ const need: usize = switch (inst.op) {
+ 0...2, 22, 23 => 0,
+ 7, 16, 20, 21 => 1,
+ 3...6, 8...15, 17, 18 => 2,
+ 19 => 3,
+ else => return executionFail(result, .invalid, pc),
+ };
+ if (sp < need) return executionFail(result, .stack, pc);
+ if (inst.op <= 2 or inst.op == 22 or inst.op == 23) {
+ if (sp == max_stack) return executionFail(result, .stack, pc);
+ const value = switch (inst.op) {
+ 0 => inst.value,
+ 1 => blk: {
+ if (inst.arg >= p.state_count) return executionFail(result, .bad_state, pc);
+ if (budget != null and budget.?.cache_count != 0)
+ result.dependencies |= @as(u32, 1) << @as(u5, @intCast(inst.arg));
+ break :blk values[inst.arg];
+ },
+ 2 => context.dt,
+ 22, 23 => if (!context.point) return executionFail(result, .bad_event, pc) else if (inst.op == 22) context.x else context.y,
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ stack[sp] = value;
+ sp += 1;
+ } else if (inst.op == 20) {
+ if (inst.arg >= p.state_count) return executionFail(result, .bad_state, pc);
+ sp -= 1;
+ values[inst.arg] = stack[sp];
+ } else if (inst.op == 21) {
+ if (inst.arg <= pc or inst.arg > end) return executionFail(result, .bad_branch, pc);
+ sp -= 1;
+ if (stack[sp] == 0) {
+ pc = inst.arg;
+ continue;
+ }
+ } else {
+ const a = stack[sp - need];
+ const b = if (need >= 2) stack[sp - need + 1] else 0;
+ const value: f64 = switch (inst.op) {
+ 3 => a + b,
+ 4 => a - b,
+ 5 => a * b,
+ 6 => blk: {
+ if (b == 0) return executionFail(result, .division_by_zero, pc);
+ break :blk a / b;
+ },
+ 7 => -a,
+ 8 => @floatFromInt(@intFromBool(a < b)),
+ 9 => @floatFromInt(@intFromBool(a <= b)),
+ 10 => @floatFromInt(@intFromBool(a > b)),
+ 11 => @floatFromInt(@intFromBool(a >= b)),
+ 12 => @floatFromInt(@intFromBool(a == b)),
+ 13 => @floatFromInt(@intFromBool(a != b)),
+ 14 => @floatFromInt(@intFromBool(a != 0 and b != 0)),
+ 15 => @floatFromInt(@intFromBool(a != 0 or b != 0)),
+ 16 => @floatFromInt(@intFromBool(a == 0)),
+ 17 => @min(a, b),
+ 18 => @max(a, b),
+ 19 => blk: {
+ const hi = stack[sp - 1];
+ if (b > hi) return executionFail(result, .invalid, pc);
+ break :blk @max(b, @min(a, hi));
+ },
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ sp = sp - need + 1;
+ stack[sp - 1] = value;
+ }
+ pc += 1;
+ }
+ if (sp != @as(usize, if (pure) 1 else 0)) return executionFail(result, .stack, end);
+ if (pure) result.value = stack[0];
+ return result;
+}
+
+export fn dispatch(event: u32, dt: f64) u32 {
+ return dispatchContext(event, .{ .dt = dt });
+}
+
+export fn dispatch_point(event: u32, x: f64, y: f64) u32 {
+ return dispatchContext(event, .{ .x = x, .y = y, .point = true });
+}
+
+fn dispatchContext(event: u32, context: EventContext) u32 {
+ const dt = context.dt;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ if (!validEvent(event, engine.active.input_count) or
+ (context.point and event == 0) or (!context.point and event > engine.active.input_count * 2)) {
+ engine.last_error = .bad_event;
+ return public(.bad_event);
+ }
+ if (!std.math.isFinite(dt) or !std.math.isFinite(context.x) or !std.math.isFinite(context.y)) {
+ engine.last_error = .non_finite;
+ return public(.non_finite);
+ }
+ if ((event == 0 and dt != frame_dt) or (event != 0 and dt != 0)) {
+ engine.last_error = .bad_frame;
+ return public(.bad_frame);
+ }
+ var pending = engine.values;
+ for (engine.active.handlers[0..engine.active.handler_count]) |h| {
+ if (h.event != event) continue;
+ const result = execute(&pending, h.start, h.length, context, false, null);
+ engine.last_ops += result.ops;
+ if (result.code != .ok) {
+ engine.last_error = result.code;
+ engine.error_pc = result.pc;
+ return public(result.code);
+ }
+ }
+ if (engine.evaluation.cache_count != 0 and !engine.staging_open) {
+ var changed: u32 = 0;
+ for (0..engine.active.state_count) |i| {
+ // Signed zero is observable, so compare representation, not ==.
+ if (@as(u64, @bitCast(pending[i])) != @as(u64, @bitCast(engine.values[i])))
+ changed |= @as(u32, 1) << @as(u5, @intCast(i));
+ }
+ engine.evaluation.changed(reuseTables(), changed);
+ }
+ engine.values = pending;
+ if (event == 0) engine.tick +%= 1;
+ engine.successful_events +%= 1;
+ return 0;
+}
+
+export fn eval_at(start: u32, length: u32) f64 {
+ // A local copy makes the no-side-effects boundary structural as well as
+ // checked by the pure instruction filter. Diagnostics are never touched.
+ var values = engine.values;
+ const result = execute(&values, start, length, .{}, true, null);
+ return if (result.code == .ok) result.value else std.math.nan(f64);
+}
+
+export fn configure_evaluation(cache: u32, history: u32) u32 {
+ return if (engine.evaluation.configure(reuseTables(), cache, history)) 0 else public(.capacity);
+}
+
+export fn forget_evaluation(flags: u32) void {
+ engine.evaluation.forget(reuseTables(), flags);
+}
+
+export fn begin_evaluation(limit: u32) void {
+ engine.evaluation.begin(limit);
+}
+
+export fn eval_bounded(start: u32, length: u32) f64 {
+ const e = &engine.evaluation;
+ e.status = 0;
+ // Charge per request as well as per instruction and validation probe.
+ if (!e.spend()) return std.math.nan(f64);
+ if (e.lookup(reuseTables(), start, length)) |value| return value;
+ if (e.status != 0) return std.math.nan(f64);
+ var values = engine.values;
+ const result = execute(&values, start, length, .{}, true, e);
+ e.status = public(result.code);
+ if (result.code != .ok) return std.math.nan(f64);
+ e.remember(reuseTables(), start, length, result.value, result.dependencies);
+ return result.value;
+}
+
+export fn evaluation_info(id: u32) u32 {
+ const e = &engine.evaluation;
+ return switch (id) {
+ 0 => e.status,
+ 1 => e.work,
+ 2 => e.instructions,
+ 3 => e.hits,
+ 4 => e.cache_count,
+ 5 => e.history_count,
+ 6 => @sizeOf(Evaluation) + @sizeOf(Tables),
+ 7 => @sizeOf(Evaluation),
+ 8 => @sizeOf(Tables),
+ 9 => @intFromBool(!engine.staging_open),
+ 10 => @sizeOf(Workspace) - @sizeOf(Pending),
+ else => 0,
+ };
+}
+
+export fn get_state(id: u32) f64 {
+ return if (id < engine.active.state_count) engine.values[id] else std.math.nan(f64);
+}
+
+export fn info(id: u32) u32 {
+ return switch (id) {
+ 0 => engine.tick,
+ 1 => public(engine.last_error),
+ 2 => engine.error_pc,
+ 3 => engine.last_ops,
+ 4 => engine.revision,
+ 5 => engine.active.state_count,
+ 6 => engine.active.instruction_count,
+ 7 => engine.active.handler_count,
+ 8 => engine.active.input_count,
+ 9 => engine.successful_events,
+ else => 0,
+ };
+}
+
+export fn layout(id: u32) u32 {
+ return switch (id) {
+ 0 => @sizeOf(Engine),
+ 1 => @sizeOf(Program),
+ 2 => @sizeOf(Instruction),
+ 3 => @sizeOf(Handler),
+ 4 => engine.active.instruction_count * @sizeOf(Instruction) + engine.active.handler_count * @sizeOf(Handler),
+ 5 => engine.active.state_count * @sizeOf(f64),
+ 6 => @sizeOf(Pending),
+ 7 => max_stack * @sizeOf(f64),
+ else => 0,
+ };
+}
+
+// Test helpers describe programs directly; the product compiler is separately
+// tested against this ABI. These tests exercise semantics and failure boundaries.
+fn ok(code: u32) !void {
+ try std.testing.expectEqual(@as(u32, 0), code);
+}
+
+fn emit(op: u32, arg: u32, value: f64) !void {
+ try ok(put_instruction(op, arg, value));
+}
+
+fn counterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try ok(commit_program());
+}
+
+test "blank program, no hidden application behavior, and reset" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 1), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(3));
+ try std.testing.expectEqual(@as(u32, 0), info(5));
+ try std.testing.expect(std.math.isNan(get_state(0)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 0)));
+ reset_state();
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 1), info(4));
+}
+
+test "arbitrary input counter and unused events" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(dispatch(2, 0));
+ try ok(dispatch(0, frame_dt));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(3));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(6));
+}
+
+test "charge and release behavior exists only in the authored instruction program" {
+ reset_all();
+ try ok(begin_program(3, 1)); // held, charge, released_value
+ for (0..3) |i| try ok(put_state(@intCast(i), 0, -1));
+ try emit(0, 0, 1);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 2));
+ try emit(1, 1, 0);
+ try emit(1, 0, 0);
+ try emit(2, 0, 0);
+ try emit(5, 0, 0);
+ try emit(3, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(0, 2, 6));
+ try emit(1, 1, 0);
+ try emit(20, 2, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(2, 8, 6));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ try ok(dispatch(1, 0));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try ok(dispatch(2, 0));
+ try std.testing.expectApproxEqAbs(@as(f64, 0.5), get_state(2), 1e-12);
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+}
+
+test "forward condition executes once and assignments and handlers are ordered" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 1, -1));
+ try ok(put_state(1, 0, -1));
+ try emit(1, 0, 0);
+ try emit(21, 6, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 7);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 0, 6));
+ try emit(1, 1, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 6, 4));
+ try ok(commit_program());
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 14), get_state(1));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 28), get_state(1));
+ try std.testing.expectEqual(@as(u32, 6), info(3));
+}
+
+test "branch to program end is valid" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, 2, 0);
+ try ok(put_handler(0, 0, 2));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+}
+
+test "one event rolls back writes from all handlers on arithmetic failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 8, -1));
+ try emit(0, 0, 99);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 2));
+ try emit(0, 0, 1);
+ try emit(0, 0, 0);
+ try emit(6, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 2, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 8), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(2));
+ try std.testing.expectEqual(@as(u32, 5), info(3));
+}
+
+test "nonfinite arithmetic and invalid clamp fail atomically" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, -1));
+ try emit(0, 0, 1e308);
+ try emit(0, 0, 1e308);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, 0));
+ try emit(0, 0, 1);
+ try emit(0, 0, 10);
+ try emit(0, 0, 2);
+ try emit(19, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 5));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.invalid), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+}
+
+test "stack underflow overflow and leftover values reject the event" {
+ for (0..3) |mode| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ if (mode == 0) {
+ try emit(3, 0, 0);
+ } else {
+ for (0..@as(usize, if (mode == 1) 65 else 1)) |_| try emit(0, 0, 7);
+ }
+ try ok(put_handler(0, 0, engine.workspace.staging.program.instruction_count));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.stack), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ }
+}
+
+test "failed candidates leave active values program revision and diagnostics unchanged" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ const old_revision = info(4);
+ const old_ops = info(3);
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(1, 5, 0);
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try std.testing.expectEqual(old_revision, info(4));
+ try std.testing.expectEqual(old_ops, info(3));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.invalid), put_instruction(100, 0, 0));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+}
+
+test "state preservation mapping reads active values at commit and reset uses new initials" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(2, 0));
+ try ok(put_state(0, 42, -1)); // new symbol first
+ try ok(put_state(1, 100, 0)); // old symbol moves from id 0 to 1
+ try ok(dispatch(1, 0)); // changed after staging began
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 42), get_state(0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 100), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(4));
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try std.testing.expect(std.math.isNan(get_state(0)));
+}
+
+test "capacity boundaries and sticky load errors" {
+ reset_all();
+ try std.testing.expectEqual(public(.capacity), begin_program(33, 0));
+ try std.testing.expectEqual(public(.capacity), begin_program(0, 9));
+ try ok(begin_program(32, 8));
+ for (0..32) |i| try ok(put_state(@intCast(i), @floatFromInt(i), -1));
+ for (0..512) |_| try emit(0, 0, 0);
+ for (0..64) |_| try ok(put_handler(16, 0, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 512), info(6));
+ try std.testing.expectEqual(@as(u32, 64), info(7));
+ try std.testing.expectEqual(@as(f64, 31), get_state(31));
+ try ok(begin_program(0, 0));
+ for (0..512) |_| try emit(0, 0, 0);
+ try std.testing.expectEqual(public(.capacity), put_instruction(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+ try ok(begin_program(0, 0));
+ for (0..64) |_| try ok(put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+}
+
+test "invalid declaration values state mappings event ids and handler ranges" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 0, 0));
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.non_finite), put_state(0, std.math.inf(f64), -1));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 1, -1));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.non_finite), put_instruction(0, 0, std.math.nan(f64)));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.bad_event), put_handler(1, 0, 0));
+ try ok(begin_program(0, 0));
+ try ok(put_handler(0, no_pc, 5));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 1);
+ try ok(put_handler(0, 0, 1));
+ try ok(put_handler(0, 0, 1));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "backward self out-of-handler branches and stores without handlers are rejected" {
+ for ([_]u32{ 0, 1, 3 }) |target| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, target, 0);
+ try emit(0, 0, 7);
+ try ok(put_handler(0, 0, 2));
+ try std.testing.expectEqual(public(.bad_branch), commit_program());
+ }
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try emit(0, 0, 5);
+ try emit(20, 0, 0);
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "event inputs and dt enforce deterministic frame contract" {
+ reset_all();
+ try counterProgram();
+ try std.testing.expectEqual(public(.bad_event), dispatch(3, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch(no_pc, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(1, frame_dt));
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, std.math.nan(f64)));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(u32, 0), info(1));
+ try std.testing.expectEqual(no_pc, info(2));
+}
+
+test "binding evaluation is pure on both success and failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 4, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try emit(2, 0, 0);
+ try emit(0, 0, 0);
+ try emit(21, 7, 0);
+ try ok(put_handler(0, 3, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ const before = engine;
+ try std.testing.expectEqual(@as(f64, 12), eval_at(0, 3));
+ try std.testing.expect(std.math.isNan(eval_at(0, 4))); // attempted store
+ try std.testing.expect(std.math.isNan(eval_at(4, 1))); // dt forbidden in views
+ try std.testing.expect(std.math.isNan(eval_at(5, 2))); // branch forbidden
+ try std.testing.expect(std.math.isNan(eval_at(no_pc, 7)));
+ try std.testing.expect(std.math.isNan(eval_at(0, no_pc)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 2))); // two results
+ // Compare only the live member of the untagged workspace, never padding
+ // or the inactive member's indeterminate storage.
+ try std.testing.expectEqual(before.staging_open, engine.staging_open);
+ inline for (std.meta.fields(Engine)) |field| {
+ if (comptime std.mem.eql(u8, field.name, "workspace")) {
+ if (before.staging_open) {
+ try std.testing.expectEqualDeep(before.workspace.staging, engine.workspace.staging);
+ } else {
+ try std.testing.expectEqualDeep(before.workspace.reuse, engine.workspace.reuse);
+ }
+ } else try std.testing.expectEqualDeep(@field(before, field.name), @field(engine, field.name));
+ }
+}
+
+test "arithmetic comparison logical min max and clamp expression semantics" {
+ const cases = [_]struct { op: u32, a: f64, b: f64, want: f64 }{
+ .{ .op = 3, .a = 7, .b = 2, .want = 9 },
+ .{ .op = 4, .a = 7, .b = 2, .want = 5 },
+ .{ .op = 5, .a = 7, .b = 2, .want = 14 },
+ .{ .op = 6, .a = 7, .b = 2, .want = 3.5 },
+ .{ .op = 8, .a = 2, .b = 7, .want = 1 },
+ .{ .op = 9, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 10, .a = 2, .b = 7, .want = 0 },
+ .{ .op = 11, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 12, .a = 7, .b = 2, .want = 0 },
+ .{ .op = 13, .a = 7, .b = 2, .want = 1 },
+ .{ .op = 14, .a = -7, .b = 2, .want = 1 },
+ .{ .op = 15, .a = 0, .b = 2, .want = 1 },
+ .{ .op = 17, .a = 7, .b = 2, .want = 2 },
+ .{ .op = 18, .a = 7, .b = 2, .want = 7 },
+ };
+ for (cases) |case| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, case.a);
+ try emit(0, 0, case.b);
+ try emit(case.op, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(case.want, eval_at(0, 3));
+ }
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, -8);
+ try emit(7, 0, 0);
+ try emit(0, 0, 0);
+ try emit(0, 0, 5);
+ try emit(19, 0, 0);
+ try emit(16, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 5), eval_at(0, 5));
+ try std.testing.expectEqual(@as(f64, 0), eval_at(0, 6));
+}
+
+test "diagnostic counters wrap without affecting scalar values" {
+ reset_all();
+ try counterProgram();
+ engine.tick = no_pc;
+ engine.successful_events = no_pc;
+ engine.revision = no_pc;
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 99, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 0), info(4));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+}
+
+test "memory accounting separates reserved storage used IR and stack capacity" {
+ reset_all();
+ try std.testing.expectEqual(@as(u32, 0), layout(4));
+ try std.testing.expectEqual(@as(u32, 0), layout(5));
+ try counterProgram();
+ try std.testing.expectEqual(@as(u32, @sizeOf(Engine)), layout(0));
+ try std.testing.expectEqual(@as(u32, 16), layout(2));
+ try std.testing.expectEqual(@as(u32, 12), layout(3));
+ try std.testing.expectEqual(@as(u32, 76), layout(4));
+ try std.testing.expectEqual(@as(u32, 8), layout(5));
+ try std.testing.expectEqual(@as(u32, @sizeOf(Pending)), layout(6));
+ try std.testing.expect(layout(6) >= layout(1) + max_states * @sizeOf(f64));
+ try std.testing.expect(layout(0) >= layout(1) * 2);
+ try std.testing.expectEqual(@as(u32, 512), layout(7));
+ try std.testing.expect(layout(0) + 16 * 1024 < 64 * 1024);
+}
+
+fn displayCounterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try emit(1, 0, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try ok(configure_evaluation(32, 8));
+}
+
+fn boundedEquals(want: f64, start: u32, length: u32) !void {
+ begin_evaluation(100);
+ try std.testing.expectEqual(want, eval_bounded(start, length));
+ try ok(evaluation_info(0));
+}
+
+test "point coordinates are ephemeral event operands and unavailable to pure evaluation" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 0, -1));
+ try ok(put_state(1, 0, -1));
+ const events = [_]u32{ 1, 17, 2, 25 };
+ for (events, 0..) |event, i| {
+ try emit(22, 0, 0);
+ try emit(20, 0, 0);
+ try emit(23, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(event, @intCast(i * 4), 4));
+ }
+ try ok(commit_program());
+ for (events, 0..) |event, i| {
+ const x: f64 = @floatFromInt(i + 10);
+ try ok(dispatch_point(event, x, -x));
+ try std.testing.expectEqual(x, get_state(0));
+ try std.testing.expectEqual(-x, get_state(1));
+ }
+ try std.testing.expectEqual(public(.bad_event), dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 13), get_state(0));
+ const diagnostics = [_]u32{ info(1), info(2), info(3), info(9) };
+ try std.testing.expect(std.math.isNan(eval_at(0, 1)));
+ try std.testing.expectEqualSlices(u32, &diagnostics, &.{ info(1), info(2), info(3), info(9) });
+ try std.testing.expectEqual(@as(u32, 4), info(9));
+}
+
+test "point event ranges reject holes and invalid payloads without committing" {
+ reset_all();
+ try counterProgram();
+ for ([_]u32{ 0, 3, 16, 18, 24, 26, 32, 33, no_pc }) |event|
+ try std.testing.expectEqual(public(.bad_event), dispatch_point(event, 1, 2));
+ try std.testing.expectEqual(public(.non_finite), dispatch_point(1, std.math.nan(f64), 0));
+ try std.testing.expectEqual(public(.non_finite), dispatch_point(1, 0, std.math.inf(f64)));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try ok(begin_program(0, 8));
+ try ok(put_handler(24, 0, 0));
+ try ok(put_handler(32, 0, 0));
+ try ok(commit_program());
+ try ok(dispatch_point(24, 1, 2));
+ try ok(dispatch_point(32, 1, 2));
+ try std.testing.expectEqual(public(.bad_event), dispatch(24, 0));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+}
+
+test "point event failure rolls back writes from earlier matching handlers" {
+ reset_all();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 7, -1));
+ try emit(22, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(17, 0, 2));
+ try emit(23, 0, 0);
+ try emit(0, 0, 0);
+ try emit(6, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(17, 2, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch_point(17, 400, 200));
+ try std.testing.expectEqual(@as(f64, 7), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(2));
+}
+
+test "loading shares storage with records while old events and bindings remain executable" {
+ reset_all();
+ try displayCounterProgram();
+ try boundedEquals(20, 4, 3);
+ try boundedEquals(20, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(9));
+ try emit(1, 0, 0);
+ try ok(dispatch(1, 0)); // Active state advances after staging begins.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(3));
+ try ok(configure_evaluation(32, 1));
+ forget_evaluation(3); // Must not overwrite the pending program.
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(33, 0, 3);
+ try boundedEquals(33, 0, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+}
+
+test "failed and abandoned candidates restore empty reuse storage without changing active work" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(20, 0, 0); // Store without a handler fails validation.
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try boundedEquals(20, 4, 3);
+ try ok(dispatch(1, 0));
+ abort_program();
+ try boundedEquals(22, 4, 3);
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ abort_program(); // Inactive abort is a no-op, including for warmed records.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try std.testing.expectEqual(public(.capacity), begin_program(max_states + 1, 0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+}
+
+test "full pending program survives repeated evaluation configuration record loss and reset" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ for (0..max_instructions) |i| {
+ try emit(0, 0, @floatFromInt(i));
+ try ok(configure_evaluation(32, @intCast(i % 9)));
+ forget_evaluation(3);
+ try boundedEquals(22, 4, 3);
+ }
+ reset_state(); // The pending definition and old-id mapping survive reset.
+ try boundedEquals(20, 4, 3);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, max_instructions), info(6));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try boundedEquals(511, 511, 1);
+ // Safe Zig builds may reserve a hidden union tag in addition to the payload.
+ try std.testing.expect(@sizeOf(Workspace) >= @sizeOf(Pending));
+ try std.testing.expect(@sizeOf(Workspace) <= @sizeOf(Pending) + @alignOf(Pending));
+ try std.testing.expectEqual(@as(u32, @sizeOf(Tables)), evaluation_info(8));
+}
+
+test "restored definition and values commit together without changing reset initializers" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(dispatch(0, frame_dt));
+ const revision = info(4);
+ try ok(begin_program(2, 0));
+ try ok(put_state(0, 100, -1));
+ try ok(put_state(1, 200, -1));
+ try ok(put_restored_value(1, -0.0));
+ try ok(put_restored_value(0, 37));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try ok(dispatch(1, 0)); // Old execution remains independent of pending values.
+ try ok(commit_restored_program());
+ try std.testing.expectEqual(@as(f64, 37), get_state(0));
+ try std.testing.expectEqual(@as(u64, @bitCast(@as(f64, -0.0))), @as(u64, @bitCast(get_state(1))));
+ try std.testing.expectEqual(revision + 1, info(4));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 100), get_state(0));
+ try std.testing.expectEqual(@as(f64, 200), get_state(1));
+}
+
+test "missing duplicate nonfinite or invalid restored candidates leave active work untouched" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ const revision = info(4);
+ for (0..4) |mode| {
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 999, -1));
+ if (mode == 1) {
+ try ok(put_restored_value(0, 30));
+ try std.testing.expectEqual(public(.bad_state), put_restored_value(0, 40));
+ } else if (mode == 2) {
+ try std.testing.expectEqual(public(.non_finite), put_restored_value(0, std.math.inf(f64)));
+ } else if (mode == 3) {
+ try ok(put_restored_value(0, 30));
+ try emit(20, 0, 0); // Invalid store outside a handler.
+ }
+ try std.testing.expect(commit_restored_program() != 0);
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try std.testing.expectEqual(revision, info(4));
+ try std.testing.expectEqual(@as(u32, 1), info(9));
+ abort_program();
+ }
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 100, -1));
+ try ok(put_restored_value(0, 42));
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ abort_program();
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+}
+
+test "empty and full restored programs validate all values and discard old display records" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(configure_evaluation(32, 8));
+ try boundedEquals(20, 4, 3);
+ try ok(begin_program(32, 0));
+ for (0..32) |i| {
+ try ok(put_state(@intCast(i), 0, -1));
+ try ok(put_restored_value(@intCast(i), @floatFromInt(i + 1)));
+ }
+ try emit(1, 31, 0);
+ try ok(commit_restored_program());
+ try boundedEquals(32, 0, 1);
+ try ok(begin_program(0, 0));
+ try ok(commit_restored_program());
+ try std.testing.expectEqual(@as(u32, 0), info(5));
+ try std.testing.expect(std.math.isNan(get_state(0)));
+}
diff --git a/prototypes/workbench/results/2026-09-23-layout/sources/evaluation.zig b/prototypes/workbench/results/2026-09-23-layout/sources/evaluation.zig
new file mode 100644
index 0000000..56081d9
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/sources/evaluation.zig
@@ -0,0 +1,164 @@
+//! Disposable binding results and change history. No event state lives here.
+const std = @import("std");
+
+const Memo = struct {
+ value: f64 = 0,
+ revision: u64 = 0,
+ dependencies: u32 = 0,
+ start: u32 = 0,
+ length: u32 = 0,
+};
+const Change = struct { revision: u64 = 0, mask: u32 = 0 };
+
+pub const Tables = struct {
+ memo: [32]Memo = @splat(.{}),
+ history: [8]Change = @splat(.{}),
+};
+
+pub const Evaluation = struct {
+ pub const max_cache = 32;
+ pub const max_history = 8;
+ revision: u64 = 1,
+ floor: u64 = 1,
+ cache_count: u32 = 0,
+ history_count: u32 = 8,
+ next_change: u32 = 0,
+ limit: u32 = 0,
+ work: u32 = 0,
+ instructions: u32 = 0,
+ hits: u32 = 0,
+ status: u32 = 0,
+
+ pub fn configure(self: *Evaluation, tables: ?*Tables, cache: u32, history: u32) bool {
+ if (cache > max_cache or history > max_history) return false;
+ self.cache_count = cache;
+ self.history_count = history;
+ self.forget(tables, 3);
+ return true;
+ }
+
+ pub fn forget(self: *Evaluation, tables: ?*Tables, flags: u32) void {
+ if (flags & 1 != 0) if (tables) |t| { t.memo = @splat(.{}); };
+ if (flags & 2 != 0) {
+ if (tables) |t| t.history = @splat(.{});
+ self.floor = self.revision;
+ self.next_change = 0;
+ }
+ }
+
+ pub fn changed(self: *Evaluation, tables: ?*Tables, mask: u32) void {
+ const t = tables orelse return;
+ if (mask == 0 or self.cache_count == 0) return;
+ if (self.revision == std.math.maxInt(u64)) {
+ self.revision = 1;
+ self.forget(t, 3);
+ }
+ self.revision += 1;
+ if (self.history_count == 0) {
+ self.floor = self.revision;
+ return;
+ }
+ const change = &t.history[self.next_change];
+ self.floor = @max(self.floor, change.revision);
+ change.* = .{ .revision = self.revision, .mask = mask };
+ self.next_change = (self.next_change + 1) % self.history_count;
+ }
+
+ pub fn begin(self: *Evaluation, limit: u32) void {
+ self.limit = limit;
+ self.work = 0;
+ self.instructions = 0;
+ self.hits = 0;
+ self.status = 0;
+ }
+
+ pub fn spend(self: *Evaluation) bool {
+ if (self.work == self.limit) {
+ self.status = 10;
+ return false;
+ }
+ self.work += 1;
+ return true;
+ }
+
+ fn slot(self: *const Evaluation, start: u32, length: u32) u32 {
+ // One candidate, including on misses; no scan proportional to cache size.
+ return (start ^ (start >> 5) ^ (length *% 0x9e3779b9)) % self.cache_count;
+ }
+
+ pub fn lookup(self: *Evaluation, tables: ?*Tables, start: u32, length: u32) ?f64 {
+ const t = tables orelse return null;
+ if (self.cache_count == 0) return null;
+ if (!self.spend()) return null;
+ const memo = &t.memo[self.slot(start, length)];
+ if (memo.revision == 0 or memo.start != start or memo.length != length) return null;
+ // Successful pure constants need no input-change evidence. A program
+ // replacement still clears them, just like every other binding memo.
+ if (memo.dependencies != 0 and memo.revision < self.floor) return null;
+ if (memo.dependencies != 0 and memo.revision != self.revision) {
+ for (t.history[0..self.history_count]) |change| {
+ if (!self.spend()) return null;
+ if (change.revision > memo.revision and change.mask & memo.dependencies != 0) return null;
+ }
+ }
+ memo.revision = self.revision;
+ self.hits += 1;
+ return memo.value;
+ }
+
+ pub fn remember(self: *Evaluation, tables: ?*Tables, start: u32, length: u32, value: f64, dependencies: u32) void {
+ const t = tables orelse return;
+ if (self.cache_count == 0) return;
+ t.memo[self.slot(start, length)] = .{ .start = start, .length = length,
+ .value = value, .dependencies = dependencies, .revision = self.revision };
+ }
+};
+
+test "record loss cannot authorize an old memo; unrelated changes can" {
+ var tables: Tables = .{};
+ var e: Evaluation = .{};
+ try std.testing.expect(e.configure(&tables, 1, 1));
+ e.begin(100);
+ e.remember(&tables, 0, 3, 12, 1);
+ e.changed(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, 12), e.lookup(&tables, 0, 3));
+ e.changed(&tables, 2);
+ e.changed(&tables, 2); // The certificate now predates the surviving history.
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 12, 1);
+ e.changed(&tables, 1);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 20, 1);
+ e.changed(&tables, 2);
+ e.forget(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 20, 1);
+ e.revision = std.math.maxInt(u64);
+ e.changed(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+}
+
+test "budget, collisions, invalid settings, and disabled history are conservative" {
+ var tables: Tables = .{};
+ var e: Evaluation = .{};
+ try std.testing.expect(e.configure(&tables, 1, 0));
+ e.begin(100);
+ e.remember(&tables, 0, 3, 12, 1);
+ e.remember(&tables, 1, 3, 15, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ try std.testing.expectEqual(@as(?f64, 15), e.lookup(&tables, 1, 3));
+ e.changed(&tables, 0x80000000);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 1, 3));
+ try std.testing.expect(!e.configure(&tables, 33, 0));
+ try std.testing.expectEqual(@as(u32, 1), e.cache_count);
+ e.begin(0);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 1, 3));
+ try std.testing.expectEqual(@as(u32, 10), e.status);
+ try std.testing.expectEqual(@as(u32, 0), e.work);
+ e.begin(100);
+ e.remember(&tables, 2, 1, 99, 0);
+ e.changed(&tables, 1);
+ e.forget(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, 99), e.lookup(&tables, 2, 1));
+ try std.testing.expectEqual(@as(u32, 1), e.work);
+}
diff --git a/prototypes/workbench/results/2026-09-23-layout/sources/examples/layout.pure b/prototypes/workbench/results/2026-09-23-layout/sources/examples/layout.pure
new file mode 100644
index 0000000..41cf149
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/sources/examples/layout.pure
@@ -0,0 +1,92 @@
+# 세 도형을 배치하고 배치 저장으로 결과를 보관하세요.
+# 마지막으로 잡은 도형은 방향키로 10씩 이동합니다.
+state selected = 0
+input left = ArrowLeft
+input right = ArrowRight
+input up = ArrowUp
+input down = ArrowDown
+state x1 = 80
+state y1 = 120
+state ox1 = 0
+state oy1 = 0
+state sx1 = 80
+state sy1 = 120
+state held1 = 0
+input drag1 = pointer(tile1)
+state x2 = 280
+state y2 = 160
+state ox2 = 0
+state oy2 = 0
+state sx2 = 280
+state sy2 = 160
+state held2 = 0
+input drag2 = pointer(tile2)
+state x3 = 450
+state y3 = 220
+state ox3 = 0
+state oy3 = 0
+state sx3 = 450
+state sy3 = 220
+state held3 = 0
+input drag3 = pointer(tile3)
+box board(20,90,600,240,"#233a31")
+text help(25,40,selected,"선택한 도형: ")
+box tile1(x1,y1,56,44,"#c9f08b")
+box tile2(x2,y2,56,44,"#74c4d4")
+box tile3(x3,y3,56,44,"#dfb184")
+on drag1.down {
+ selected = 1
+ sx1 = x1; sy1 = y1
+ ox1 = event_x - x1; oy1 = event_y - y1
+ held1 = 1
+}
+on drag1.move {
+ x1 = clamp(event_x - ox1, 20, 564)
+ y1 = clamp(event_y - oy1, 90, 286)
+}
+on drag1.up { held1 = 0 }
+on drag1.cancel { x1 = sx1; y1 = sy1; held1 = 0 }
+on drag2.down {
+ selected = 2
+ sx2 = x2; sy2 = y2
+ ox2 = event_x - x2; oy2 = event_y - y2
+ held2 = 1
+}
+on drag2.move {
+ x2 = clamp(event_x - ox2, 20, 564)
+ y2 = clamp(event_y - oy2, 90, 286)
+}
+on drag2.up { held2 = 0 }
+on drag2.cancel { x2 = sx2; y2 = sy2; held2 = 0 }
+on drag3.down {
+ selected = 3
+ sx3 = x3; sy3 = y3
+ ox3 = event_x - x3; oy3 = event_y - y3
+ held3 = 1
+}
+on drag3.move {
+ x3 = clamp(event_x - ox3, 20, 564)
+ y3 = clamp(event_y - oy3, 90, 286)
+}
+on drag3.up { held3 = 0 }
+on drag3.cancel { x3 = sx3; y3 = sy3; held3 = 0 }
+on left.down {
+ if selected == 1 && held1 == 0 { x1 = clamp(x1 + -10, 20, 564) }
+ if selected == 2 && held2 == 0 { x2 = clamp(x2 + -10, 20, 564) }
+ if selected == 3 && held3 == 0 { x3 = clamp(x3 + -10, 20, 564) }
+}
+on right.down {
+ if selected == 1 && held1 == 0 { x1 = clamp(x1 + 10, 20, 564) }
+ if selected == 2 && held2 == 0 { x2 = clamp(x2 + 10, 20, 564) }
+ if selected == 3 && held3 == 0 { x3 = clamp(x3 + 10, 20, 564) }
+}
+on up.down {
+ if selected == 1 && held1 == 0 { y1 = clamp(y1 + -10, 90, 286) }
+ if selected == 2 && held2 == 0 { y2 = clamp(y2 + -10, 90, 286) }
+ if selected == 3 && held3 == 0 { y3 = clamp(y3 + -10, 90, 286) }
+}
+on down.down {
+ if selected == 1 && held1 == 0 { y1 = clamp(y1 + 10, 90, 286) }
+ if selected == 2 && held2 == 0 { y2 = clamp(y2 + 10, 90, 286) }
+ if selected == 3 && held3 == 0 { y3 = clamp(y3 + 10, 90, 286) }
+}
diff --git a/prototypes/workbench/results/2026-09-23-layout/sources/project.test.mjs b/prototypes/workbench/results/2026-09-23-layout/sources/project.test.mjs
new file mode 100644
index 0000000..ad8a48d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/sources/project.test.mjs
@@ -0,0 +1,79 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {readFile} from 'node:fs/promises';
+import {createRuntime} from './web/runtime.mjs';
+import {parseProject, MAX_PROJECT_BYTES} from './web/project.mjs';
+const wasm = await readFile(new URL('./web/core.wasm',import.meta.url));
+const layout = await readFile(new URL('./examples/layout.pure',import.meta.url),'utf8');
+const fresh = () => createRuntime(wasm,{cacheEntries:32});
+const roundTrip = value => parseProject(JSON.stringify(value));
+
+test('multiple authored placements and a separate invalid draft survive a fresh runtime restore',async()=>{
+ const a=await fresh(); a.apply(layout);
+ for(const [name,x,y,nx,ny] of [['drag1',90,130,180,200],['drag2',290,170,370,210],['drag3',460,230,520,280]]) {
+ a.point(name,'down',x,y);a.point(name,'move',nx,ny);a.point(name,'up',x,y);
+ }
+ a.down('left');a.up('left');
+ const before=a.snapshot(), document=roundTrip(a.exportDocument('state unfinished ='));
+ assert.equal(document.version,2);assert.equal(document.source,layout);
+ const b=await fresh(); b.apply('state old=99'); b.frame(5);
+ const restored=b.restoreDocument(document), after=b.snapshot();
+ assert.equal(restored.draft,'state unfinished =');assert.equal(restored.source,layout);
+ assert.deepEqual(after.states,before.states);assert.deepEqual(after.views,before.views);
+ assert.equal(after.tick,0);assert.equal(after.successfulEvents,0);assert.equal(after.lastEvent,null);assert.deepEqual(after.held,[]);
+ b.down('right');b.up('right');assert.equal(b.snapshot().states.x3,before.states.x3+10);
+ b.reset();assert.equal(b.snapshot().states.x1,80);assert.equal(b.snapshot().states.x2,280);assert.equal(b.snapshot().states.x3,450);
+});
+
+test('malformed files reject before changing active code, values, diagnostics or warm display records',async()=>{
+ const r=await fresh();r.apply('state x=5\ntext t(0,0,x)');r.snapshot();
+ const good=r.exportDocument(), before=r.snapshot(), active=r.currentProgram;
+ const bad=[null,{}, {...good,version:99},{...good,draft:null},{...good,source:'state x='},{...good,states:[]},
+ {...good,states:[{name:'other',value:2}]},{...good,states:[{name:'x',value:null}]},
+ {...good,states:[{name:'x',value:'2'}]},{...good,states:[{name:'x',value:Infinity}]},
+ {...good,states:[{name:'x',value:NaN}]},{...good,draft:'x'.repeat(32769)},
+ {...good,source:'state x=0\nstate y=0',states:[{name:'x',value:1},{name:'x',value:2}]}];
+ for(const doc of bad){assert.throws(()=>r.restoreDocument(doc));assert.equal(r.currentProgram,active);assert.deepEqual(r.snapshot(),before);}
+ assert(r.evaluationStats().cacheHits>0);
+ assert.throws(()=>parseProject('{'));assert.throws(()=>parseProject(' '.repeat(MAX_PROJECT_BYTES+1)));
+});
+
+test('restoration matches names rather than ordering and never reuses stale cached values',async()=>{
+ const r=await fresh();r.apply('state x=1\nstate y=2\ntext t(0,0,x+y)');r.snapshot();r.snapshot();
+ const doc=r.exportDocument();doc.states=[{name:'y',value:20},{name:'x',value:10}];
+ r.restoreDocument(roundTrip(doc));assert.deepEqual(r.snapshot().states,{x:10,y:20});assert.equal(r.snapshot().views[0].value,30);
+ r.apply('state y=200\nstate x=100');assert.deepEqual(r.snapshot().states,{y:20,x:10});
+ r.reset();assert.deepEqual(r.snapshot().states,{y:200,x:100});
+});
+
+test('finite float values including negative zero round trip without changing authored reset values',async()=>{
+ const r=await fresh();r.apply('state z=-0\nstate tiny=5e-324\nstate large=1.7976931348623157e308\nstate constructor=1');
+ const doc=roundTrip(r.exportDocument());assert.equal(doc.states[0].value,'-0');
+ r.restoreDocument(doc);const s=r.snapshot().states;
+ assert(Object.is(s.z,-0));assert.equal(s.tiny,Number.MIN_VALUE);assert.equal(s.large,Number.MAX_VALUE);assert.equal(s.constructor,1);
+});
+
+test('held and failed interactions cannot be saved and restoration cannot steal an active input',async()=>{
+ const r=await fresh();r.apply(layout);const saved=r.exportDocument();
+ r.point('drag1','down',90,130);const before=r.snapshot();
+ assert.throws(()=>r.exportDocument(),/조작/);assert.throws(()=>r.restoreDocument(saved),/놓은 뒤/);assert.deepEqual(r.snapshot(),before);
+ r.releaseAll();r.apply('state x=0\ninput fail=Space\non fail.down { x=1/0 }');
+ r.down('fail');r.up('fail');assert.throws(()=>r.exportDocument(),/오류/);
+ r.restoreDocument(saved);assert.equal(r.snapshot().lastError,0);
+});
+
+test('empty and full documents restore without imposing a new source size or state count',async()=>{
+ const r=await fresh();r.apply('');r.restoreDocument(roundTrip(r.exportDocument()));assert.deepEqual(r.snapshot().states,{});
+ r.apply(Array.from({length:32},(_,i)=>`state s${i}=${i}`).join('\n'));const doc=r.exportDocument();
+ for(const s of doc.states) s.value+=100;
+ r.restoreDocument(roundTrip(doc));assert.equal(r.snapshot().states.s31,131);
+ assert.equal(parseProject(JSON.stringify({format:'pure-workbench',version:1,source:'state unfinished ='})).version,1);
+ assert.throws(()=>r.restoreDocument({format:'pure-workbench',version:1,source:''}));
+});
+
+test('document APIs respect the output callback boundary and export independent values',async()=>{
+ const r=await fresh();r.apply('state x=1');const doc=r.exportDocument();doc.states[0].value=500;
+ assert.equal(r.snapshot().states.x,1);
+ r.present({begin(){assert.throws(()=>r.restoreDocument(doc),/출력 전달/);assert.throws(()=>r.exportDocument(),/출력 전달/);},state(){},view(){},value(){},end(){}});
+ assert.equal(r.snapshot().states.x,1);
+});
diff --git a/prototypes/workbench/results/2026-09-23-layout/sources/qa-layout.js b/prototypes/workbench/results/2026-09-23-layout/sources/qa-layout.js
new file mode 100644
index 0000000..95500ce
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/sources/qa-layout.js
@@ -0,0 +1,103 @@
+// Functional end-to-end persistence checks. Run with Playwright CLI run-code.
+async page => {
+ const checks=[],errors=[];
+ const check=(ok,label)=>{if(!ok)throw new Error(label);checks.push(label);};
+ const state=()=>page.evaluate(()=>window.pureWorkbench.snapshot());
+ const onDialog=dialog=>dialog.accept(), onError=error=>errors.push(error.message);
+ page.on('dialog',onDialog);page.on('pageerror',onError);
+ const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
+ const client=(x,y)=>page.evaluate(({x,y})=>{const p=new DOMPoint(x,y).matrixTransform(document.querySelector('#scene').getScreenCTM());return{x:p.x,y:p.y};},{x,y});
+ const move=async(x,y)=>{const p=await client(x,y);await page.mouse.move(p.x,p.y);};
+ const drag=async(x,y,nx,ny)=>{await page.locator('#scene').scrollIntoViewIfNeeded();await move(x,y);await page.mouse.down();await move(nx,ny);await page.mouse.up();};
+ const upload=async(text,name='layout.json')=>{
+ await page.locator('#file-input').evaluate((input,{text,name})=>{
+ const transfer=new DataTransfer();transfer.items.add(new File([text],name,{type:'application/json'}));
+ input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
+ },{text,name});
+ };
+ try {
+ await page.setViewportSize({width:1440,height:1000});
+ await page.waitForFunction(()=>window.pureWorkbench?.snapshot().ready);
+ check(await page.locator('#download-layout').isDisabled(),'placement save requires an applied definition');
+ await page.locator('#example').selectOption('layout');await page.locator('#load-example').click();await page.locator('#apply').click();
+ const source=await page.locator('#source').inputValue();
+ await drag(90,130,160,180);await drag(290,170,360,220);await drag(460,230,530,270);
+ await page.keyboard.press('ArrowLeft');
+ let s=await state();
+ check(Math.abs(s.states.x1-150)<0.1&&Math.abs(s.states.x2-350)<0.1&&Math.abs(s.states.x3-510)<0.1,'three independently authored targets move and selection directs keyboard adjustment');
+ const expected={states:s.states,views:s.views};
+ const draft=source+'\nstate unfinished =';
+ await page.locator('#source').fill(draft);
+ const pending=page.waitForEvent('download');await page.locator('#download-layout').click();
+ const download=await pending;const saved='output/playwright/pure-layout-saved.json';await download.saveAs(saved);
+ check(download.suggestedFilename()==='my-layout.pure.json','placement download is a separate document file');
+ check((await state()).source===draft&&(await state()).appliedSource===source,'saving neither applies nor discards the unfinished draft');
+ await page.locator('#reset').click();
+ check((await state()).states.x1===80,'reset returns to authored initial positions');
+ await page.locator('#file-input').setInputFiles(saved);
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
+ s=await state();
+ check(same(s.states,expected.states)&&same(s.views,expected.views),'downloaded file restores all states and visible positions');
+ check(s.source===draft&&s.appliedSource===source&&s.dirty,'the draft remains separate from the restored executable definition');
+ check(!s.running&&!s.held.length&&s.tick===0&&s.successfulEvents===0,'restored work starts stopped with no replayed inputs or clock');
+ await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');
+ check(Math.abs((await state()).states.x3-(expected.states.x3+10))<0.1,'restored selection and bindings remain editable');
+ // Use a fresh page/runtime, not just preservation inside the original instance.
+ const fresh=await page.context().newPage();await fresh.goto(page.url());
+ await fresh.waitForFunction(()=>window.pureWorkbench?.snapshot().ready);
+ await fresh.locator('#file-input').setInputFiles(saved);
+ await fresh.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
+ const reopened=await fresh.evaluate(()=>window.pureWorkbench.snapshot());
+ check(same(reopened.states,expected.states)&&same(reopened.views,expected.views)&&reopened.source===draft,'a fresh browser page reconstructs the saved work without old runtime state');
+ await fresh.close();await page.bringToFront();
+
+ const before=await state();
+ await upload(JSON.stringify({format:'pure-workbench',version:2,source:'state changed=99',draft:'replacement',states:[]}));
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('상태 목록'));
+ s=await state();
+ check(same(s.states,before.states)&&s.source===before.source&&s.appliedSource===before.appliedSource&&s.revision===before.revision,'invalid saved state cannot partially replace code, draft or scene');
+ // Delay File.text, then edit before it completes. Restoring must not overwrite the edit.
+ await page.locator('#file-input').evaluate(input=>{
+ const document={format:'pure-workbench',version:2,source:'state other=4',draft:'state other=4',states:[{name:'other',value:55}]};
+ const transfer=new DataTransfer(),file=new File([JSON.stringify(document)],'delayed.json',{type:'application/json'});
+ const text=JSON.stringify(document);file.text=()=>new Promise(resolve=>{window.__finishLayoutRead=()=>resolve(text);});
+ transfer.items.add(file);input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
+ });
+ await page.locator('#source').fill(draft+'\n# edit during file read');
+ await page.evaluate(()=>window.__finishLayoutRead());
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('작업이 바뀌'));
+ s=await state();check(s.source.endsWith('# edit during file read')&&same(s.states,before.states),'a delayed file read cannot overwrite a newer draft');
+ // Repeat with an input event while file content is pending.
+ await page.locator('#file-input').evaluate(input=>{
+ const text=JSON.stringify({format:'pure-workbench',version:2,source:'state other=4',states:[{name:'other',value:55}]});
+ const file=new File([text],'delayed.json',{type:'application/json'}),transfer=new DataTransfer();
+ file.text=()=>new Promise(resolve=>{window.__finishLayoutRead=()=>resolve(text);});
+ transfer.items.add(file);input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
+ });
+ await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');const changed=await state();
+ await page.evaluate(()=>window.__finishLayoutRead());
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('작업이 바뀌'));
+ check(same((await state()).states,changed.states),'a delayed restore cannot overwrite placement changes made while reading');
+ // UI-only run state is deliberately not persisted.
+ await page.locator('#play').click();await page.locator('#download-layout').click();
+ check((await state()).diagnostic.includes('정지한 뒤'),'saving while time advances requests a stopped document');
+ await page.locator('#play').click();
+ // Refuse while a logical input remains held, with no lost source or partial save.
+ await page.locator('#scene').focus();await page.keyboard.down('ArrowRight');
+ await page.locator('#download-layout').click();
+ check((await state()).diagnostic.includes('조작을 끝낸 뒤'),'saving with a held input is refused');
+ await page.keyboard.up('ArrowRight');
+ await page.locator('#file-input').setInputFiles(saved);
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
+ await page.screenshot({path:'output/playwright/pure-layout-desktop.png',fullPage:true});
+ await page.setViewportSize({width:390,height:844});
+ check(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'extra persistence control fits a mobile viewport');
+ await page.screenshot({path:'output/playwright/pure-layout-mobile.png',fullPage:true});
+ check(errors.length===0,'no uncaught page errors');
+ const result={checks,errors,saved,expected,source,draft};
+ await page.evaluate(result=>{window.__pureLayoutEvidence=result;},result);
+ return {checks:checks.length,errors};
+ } catch(error) {
+ await page.evaluate(result=>{window.__pureLayoutEvidence=result;},{failed:String(error),checks,errors});throw error;
+ } finally {page.off('dialog',onDialog);page.off('pageerror',onError);}
+}
diff --git a/prototypes/workbench/results/2026-09-23-layout/sources/web/app.js b/prototypes/workbench/results/2026-09-23-layout/sources/web/app.js
new file mode 100644
index 0000000..e163ebc
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/sources/web/app.js
@@ -0,0 +1,403 @@
+import { createRuntime } from './runtime.mjs';
+import { createOutput } from './output.mjs';
+import { bindSceneInput } from './scene-input.mjs';
+import { parseProject, MAX_PROJECT_BYTES } from './project.mjs';
+
+const $ = id => document.getElementById(id);
+const source = $('source');
+const MAX_SOURCE_BYTES = 32768;
+const encoder = new TextEncoder();
+const output = createOutput(document);
+const snippets = {
+ state: 'state count = 0\nstate x = 80\n',
+ input: 'input tap = Space\non tap.down {\n count = count + 1\n}\n',
+ view: 'box tile(x, 180, 40, 40, "#7ed9ae")\ntext label(40, 60, count, "Count: ")\n',
+ frame: 'on frame {\n x = x + 30 * dt\n if x > 600 { x = 0 }\n}\n',
+};
+let runtime = null;
+let appliedSource = '';
+let running = false;
+let previousTime = null;
+let accumulator = 0;
+let droppedTicks = 0;
+let diagnosticLocation = null;
+let draftGeneration = 0;
+const keyboardSources = new Map();
+const pointerSources = new Map();
+const inputButtons = new Map();
+const sceneInput = bindSceneInput($('scene'), () => runtime, safeAction);
+
+const bytes = text => encoder.encode(text).byteLength;
+const dirty = () => source.value !== appliedSource;
+// DOM is the published value. Avoid rewriting unchanged diagnostics/controls.
+function text(id, value) {
+ const node = $(id);
+ if (node.textContent !== value) node.textContent = value;
+}
+function pressed(node, value) {
+ const next = String(value);
+ if (node.getAttribute('aria-pressed') !== next) node.setAttribute('aria-pressed', next);
+}
+function draftStatus() {
+ const changed = dirty();
+ text('draft-status', changed ? '미적용 변경' : runtime?.currentProgram ? '적용됨' : '빈 작업물');
+ $('draft-status').classList.toggle('dirty', changed);
+}
+
+function setDiagnostic(message, kind = '', location = null) {
+ $('diagnostic-text').textContent = message;
+ $('diagnostic').className = `diagnostic ${kind}`;
+ diagnosticLocation = location;
+ $('error-location').hidden = !location;
+ source.setAttribute('aria-invalid', kind === 'error' && location ? 'true' : 'false');
+}
+
+function cursorPosition() {
+ const before = source.value.slice(0, source.selectionStart);
+ const lines = before.split('\n');
+ text('cursor-position', `${lines.length}행 · ${lines.at(-1).length + 1}열`);
+}
+
+function draftChanged() {
+ draftGeneration++;
+ // Read scroll position before writes; this path runs only for source edits.
+ const scrollTop = source.scrollTop;
+ const count = source.value.split('\n').length;
+ const size = bytes(source.value);
+ text('line-numbers', Array.from({ length: count }, (_, i) => i + 1).join('\n'));
+ $('line-numbers').scrollTop = scrollTop;
+ text('source-size', `${size.toLocaleString('en-US')} / 32,768 bytes`);
+ $('source-size').style.color = size > MAX_SOURCE_BYTES ? 'var(--danger)' : '';
+ draftStatus();
+ cursorPosition();
+}
+
+function pause() {
+ running = false;
+ previousTime = null;
+ accumulator = 0;
+ text('play', '▶ 실행');
+ pressed($('play'), false);
+}
+
+function safeAction(action) {
+ if (!runtime) return;
+ try { action(); }
+ catch (error) { pause(); setDiagnostic(error.message, 'error', error.line ? { line: error.line, column: error.column || 1 } : null); }
+ refresh();
+}
+
+function releaseEverything() {
+ keyboardSources.clear();
+ pointerSources.clear();
+ sceneInput.releaseCaptures();
+ if (runtime) safeAction(() => runtime.releaseAll());
+}
+
+function loseFocus() {
+ pause();
+ releaseEverything();
+ refresh();
+}
+
+function refresh() {
+ if (!runtime) return;
+ const snapshot = runtime.present(output);
+ if (snapshot.lastError) pause();
+ const held = new Set((snapshot.held || []).map(input => typeof input === 'string' ? input : input.name));
+ for (const [name, button] of inputButtons) {
+ button.classList.toggle('held', held.has(name));
+ pressed(button, held.has(name));
+ }
+ text('scene-status', runtime.currentProgram ? (running ? '실행 중' : '정지') : '적용 전');
+ text('clock-status', `${snapshot.tick || 0} frame · ${running ? '60 Hz' : '정지'}`);
+ text('revision', `revision ${snapshot.revision || 0}`);
+ for (const id of ['play', 'step', 'reset', 'download-layout']) {
+ if ($(id).disabled !== !runtime.currentProgram) $(id).disabled = !runtime.currentProgram;
+ }
+ const event = snapshot.lastEvent;
+ text('last-event', event ? `${event.name}${event.phase && event.phase !== 'frame' ? `.${event.phase}` : ''} · ${event.status ? '실패' : '완료'}${event.line ? ` · ${event.line}행` : ''}` : '아직 없음');
+ if ($('runtime-error').hidden !== !snapshot.lastError) $('runtime-error').hidden = !snapshot.lastError;
+ const displayError = snapshot.lastError === 100 || snapshot.lastError === 101;
+ let message = snapshot.lastError ? `${displayError ? '표시 오류' : '마지막 실패'} · ${snapshot.errorLine ? `${snapshot.errorLine}행 · ` : ''}${snapshot.errorMessage || `실행 오류 ${snapshot.lastError}`}${snapshot.lastError === 101 ? ' — 이번 장면을 표시하지 않았습니다. 상태 변경은 유지됩니다.' : displayError ? ' — 해당 도형을 표시할 수 없습니다.' : ' — 실패한 이벤트의 상태 변경은 취소됐습니다. 코드를 적용하거나 초깃값으로 되돌리면 이 기록을 지웁니다.'}` : '';
+ if (!displayError && snapshot.displayError?.code === 101)
+ message += ` · ${snapshot.displayError.message}`;
+ text('runtime-error', message);
+ output.costs(snapshot, runtime.currentProgram?.metrics, droppedTicks);
+}
+
+function endPointer(event) {
+ const entry = pointerSources.get(event.pointerId);
+ if (!entry) return;
+ event.preventDefault();
+ pointerSources.delete(event.pointerId);
+ safeAction(() => runtime.up(entry.name, entry.source));
+}
+
+function rebuildInputControls() {
+ inputButtons.clear();
+ const children = [];
+ for (const input of runtime.currentProgram?.inputs || []) {
+ if (input.target) {
+ const note = document.createElement('p');
+ note.className = 'empty-note';
+ note.textContent = `${input.name} · 장면의 ${input.target}을 잡아 조작하세요.`;
+ children.push(note);
+ continue;
+ }
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'input-button';
+ button.dataset.input = input.name;
+ button.setAttribute('aria-pressed', 'false');
+ const name = document.createElement('span'); name.textContent = input.name;
+ const key = document.createElement('kbd'); key.textContent = input.key;
+ button.append(name, key);
+ button.addEventListener('pointerdown', event => {
+ if (event.button !== 0) return;
+ event.preventDefault();
+ const identity = `pointer:${event.pointerId}`;
+ pointerSources.set(event.pointerId, { name: input.name, source: identity });
+ button.setPointerCapture(event.pointerId);
+ safeAction(() => runtime.down(input.name, identity));
+ });
+ button.addEventListener('pointerup', endPointer);
+ button.addEventListener('pointercancel', endPointer);
+ button.addEventListener('lostpointercapture', endPointer);
+ // Keyboard activation and assistive technology clicks are complete taps.
+ // Pointer clicks already delivered down/up via the handlers above.
+ button.addEventListener('click', event => {
+ if (event.detail !== 0) return;
+ safeAction(() => { runtime.down(input.name, 'activation'); runtime.up(input.name, 'activation'); });
+ });
+ inputButtons.set(input.name, button);
+ children.push(button);
+ }
+ if (!children.length) {
+ const message = document.createElement('p');
+ message.className = 'empty-note';
+ message.textContent = '입력을 선언하면 여기에 버튼이 생깁니다.';
+ children.push(message);
+ }
+ $('input-controls').replaceChildren(...children);
+}
+
+function isEditing(target) {
+ return target instanceof Element && Boolean(target.closest('textarea,input,select,button,[contenteditable="true"],[role="textbox"]'));
+}
+
+$('scene').addEventListener('pointerdown', () => $('scene').focus());
+
+document.addEventListener('keydown', event => {
+ if (!runtime || event.repeat || event.isComposing || event.ctrlKey || event.metaKey || event.altKey || isEditing(event.target)) return;
+ const input = runtime.currentProgram?.inputs.find(item => item.key === event.code);
+ if (!input) return;
+ event.preventDefault();
+ if (keyboardSources.has(event.code)) return;
+ keyboardSources.set(event.code, input.name);
+ safeAction(() => runtime.down(input.name, `keyboard:${event.code}`));
+});
+
+document.addEventListener('keyup', event => {
+ const name = keyboardSources.get(event.code);
+ if (!name) return;
+ event.preventDefault();
+ keyboardSources.delete(event.code);
+ safeAction(() => runtime.up(name, `keyboard:${event.code}`));
+});
+
+source.addEventListener('input', () => {
+ draftChanged();
+ if (diagnosticLocation) setDiagnostic('코드를 수정했습니다. 적용해서 다시 확인하세요.');
+});
+source.addEventListener('scroll', () => { $('line-numbers').scrollTop = source.scrollTop; });
+for (const name of ['click', 'keyup', 'select']) source.addEventListener(name, cursorPosition);
+source.addEventListener('keydown', event => {
+ if (event.key === 'Tab' && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
+ event.preventDefault();
+ source.setRangeText(' ', source.selectionStart, source.selectionEnd, 'end');
+ draftChanged();
+ }
+});
+
+$('apply').addEventListener('click', () => {
+ if (!runtime) return;
+ try {
+ runtime.apply(source.value);
+ appliedSource = source.value;
+ rebuildInputControls();
+ setDiagnostic('코드를 적용했습니다. 같은 이름의 상태는 유지됩니다.', 'success');
+ } catch (error) {
+ const location = error.line ? { line: error.line, column: error.column || 1 } : null;
+ setDiagnostic(`${location ? `${location.line}행 ${location.column}열 · ` : ''}${error.message} · 기존 장면은 그대로 유지됩니다.`, 'error', location);
+ }
+ draftStatus();
+ refresh();
+});
+
+$('error-location').addEventListener('click', () => {
+ if (!diagnosticLocation) return;
+ const lines = source.value.split('\n');
+ const lineIndex = Math.min(lines.length - 1, diagnosticLocation.line - 1);
+ const start = lines.slice(0, lineIndex).reduce((sum, line) => sum + line.length + 1, 0);
+ const at = start + Math.min(lines[lineIndex].length, diagnosticLocation.column - 1);
+ source.focus();
+ source.setSelectionRange(at, Math.min(at + 1, source.value.length));
+ const lineHeight = parseFloat(getComputedStyle(source).lineHeight);
+ source.scrollTop = Math.max(0, lineIndex * lineHeight - source.clientHeight / 3);
+ $('line-numbers').scrollTop = source.scrollTop;
+ cursorPosition();
+});
+
+$('play').addEventListener('click', () => {
+ if (running) pause();
+ else { running = true; previousTime = null; accumulator = 0; $('play').textContent = 'Ⅱ 정지'; $('play').setAttribute('aria-pressed', 'true'); }
+ refresh();
+});
+$('step').addEventListener('click', () => { pause(); safeAction(() => runtime.frame()); });
+$('reset').addEventListener('click', () => {
+ pause(); releaseEverything(); droppedTicks = 0;
+ safeAction(() => runtime.reset());
+ setDiagnostic('적용된 코드의 초깃값으로 되돌렸습니다. 편집 중인 코드는 유지됩니다.');
+});
+
+function replaceDraft(text, message) {
+ source.value = text;
+ source.scrollTop = 0;
+ source.setSelectionRange(0, 0);
+ setDiagnostic(message);
+ draftChanged();
+}
+
+function mayReplaceDraft() {
+ return !source.value || window.confirm('현재 편집 중인 코드를 바꿀까요? 저장하지 않은 내용은 사라집니다. 실행 장면은 적용 전까지 유지됩니다.');
+}
+
+$('new').addEventListener('click', () => {
+ if (mayReplaceDraft()) replaceDraft('', '편집기만 비웠습니다. 빈 코드를 적용하면 장면도 비워집니다.');
+});
+
+for (const button of document.querySelectorAll('.insert-snippet')) {
+ button.addEventListener('click', () => {
+ const at = source.selectionStart;
+ const prefix = at > 0 && source.value[at - 1] !== '\n' ? '\n' : '';
+ source.setRangeText(prefix + snippets[button.dataset.snippet], source.selectionStart, source.selectionEnd, 'end');
+ source.focus();
+ draftChanged();
+ setDiagnostic('조각을 삽입했습니다. 이름과 동작을 원하는 대로 바꾸고 적용하세요.');
+ });
+}
+
+$('load-example').addEventListener('click', async () => {
+ if (!mayReplaceDraft()) return;
+ const selected = $('example').value;
+ const before = source.value;
+ const button = $('load-example');
+ button.disabled = true;
+ try {
+ const response = await fetch(`./examples/${selected}.pure`);
+ if (!response.ok) throw new Error(`예제를 불러오지 못했습니다 (${response.status}).`);
+ const text = await response.text();
+ if (bytes(text) > MAX_SOURCE_BYTES) throw new Error('예제가 코드 크기 제한을 넘었습니다.');
+ if (source.value !== before) throw new Error('불러오는 동안 코드가 바뀌었습니다. 변경 내용을 지키기 위해 불러오기를 취소했습니다.');
+ replaceDraft(text, '예제를 편집기에 불러왔습니다. 아직 적용하지 않았습니다.');
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+ finally { button.disabled = false; }
+});
+
+function download(filename, content, mime, message = '편집 중인 코드를 파일로 저장했습니다. 적용되지 않은 변경도 포함합니다.') {
+ const url = URL.createObjectURL(new Blob([content], { type: mime }));
+ const link = document.createElement('a');
+ link.href = url; link.download = filename;
+ document.body.append(link); link.click(); link.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ setDiagnostic(message);
+}
+$('download').addEventListener('click', () => download('my-work.pure', source.value, 'text/plain;charset=utf-8'));
+$('download-project').addEventListener('click', () => download('my-work.pure.json', JSON.stringify({ format: 'pure-workbench', version: 1, source: source.value }, null, 2) + '\n', 'application/json'));
+$('download-layout').addEventListener('click', () => {
+ try {
+ if (running) throw new Error('시간 진행을 정지한 뒤 배치를 저장하세요.');
+ const project = runtime.exportDocument(source.value);
+ download('my-layout.pure.json', JSON.stringify(project, null, 2) + '\n', 'application/json',
+ '적용된 코드·현재 배치·미적용 초안을 함께 저장했습니다.');
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+});
+$('upload').addEventListener('click', () => $('file-input').click());
+$('file-input').addEventListener('change', async event => {
+ const file = event.target.files[0];
+ event.target.value = '';
+ if (!file) return;
+ try {
+ if (file.size > MAX_PROJECT_BYTES) throw new Error('파일이 너무 큽니다. 코드 제한은 32 KiB입니다.');
+ const before = draftGeneration;
+ const active = runtime?.currentProgram;
+ const events = runtime?.snapshot().successfulEvents;
+ let content = await file.text();
+ if (file.name.toLowerCase().endsWith('.json')) {
+ const project = parseProject(content);
+ if (project.version === 2) {
+ if (!runtime) throw new Error('실행기가 준비되지 않았습니다.');
+ if (draftGeneration !== before || runtime.currentProgram !== active || runtime.snapshot().successfulEvents !== events)
+ throw new Error('파일을 읽는 동안 작업이 바뀌었습니다. 정지한 뒤 다시 불러오세요.');
+ if (running) throw new Error('시간 진행을 정지한 뒤 배치를 복원하세요.');
+ if ((source.value || appliedSource) && !window.confirm('현재 코드와 배치를 파일의 내용으로 복원할까요? 저장하지 않은 작업은 사라집니다.')) return;
+ const restored = runtime.restoreDocument(project);
+ pause(); droppedTicks = 0;
+ appliedSource = restored.source;
+ rebuildInputControls();
+ replaceDraft(restored.draft, '배치를 복원했습니다. 미적용 초안도 보존했고, 시간은 정지 상태입니다.');
+ refresh();
+ return;
+ }
+ content = project.source;
+ }
+ if (bytes(content) > MAX_SOURCE_BYTES) throw new Error('코드가 32 KiB 제한을 넘었습니다.');
+ if (draftGeneration !== before) throw new Error('파일을 읽는 동안 코드가 바뀌었습니다. 불러오기를 다시 시도하세요.');
+ if (mayReplaceDraft()) replaceDraft(content, `${file.name}을 편집기에 불러왔습니다. 아직 적용하지 않았습니다.`);
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+});
+
+window.addEventListener('blur', loseFocus);
+document.addEventListener('visibilitychange', () => { if (document.hidden) loseFocus(); });
+window.addEventListener('beforeunload', event => {
+ // Applying is not persistence: even applied code can be lost when leaving.
+ if (source.value || appliedSource) { event.preventDefault(); event.returnValue = ''; }
+});
+
+function animate(time) {
+ if (running && runtime) {
+ if (previousTime !== null) {
+ accumulator += Math.max(0, time - previousTime);
+ const wanted = Math.floor(accumulator / (1000 / 60));
+ if (wanted > 0) {
+ const steps = Math.min(6, wanted);
+ accumulator -= wanted * (1000 / 60);
+ droppedTicks += wanted - steps;
+ safeAction(() => runtime.frame(steps));
+ }
+ }
+ previousTime = running ? time : null;
+ }
+ requestAnimationFrame(animate);
+}
+
+Object.defineProperty(window, 'pureWorkbench', {
+ configurable: false,
+ writable: false,
+ value: Object.freeze({ snapshot: () => structuredClone({ ...(runtime?.snapshot() || {}), ready: Boolean(runtime), source: source.value, appliedSource, dirty: dirty(), running, droppedTicks, diagnostic: $('diagnostic-text').textContent }) }),
+});
+
+draftChanged();
+requestAnimationFrame(animate);
+try {
+ runtime = await createRuntime();
+ $('ready-status').textContent = '실행기 준비됨';
+ $('ready-dot').classList.add('ready');
+ $('apply').disabled = false;
+ refresh();
+} catch (error) {
+ $('ready-status').textContent = '실행기 준비 실패';
+ $('ready-dot').classList.add('failed');
+ setDiagnostic(`실행기를 열지 못했습니다. ${error.message}`, 'error');
+}
diff --git a/prototypes/workbench/results/2026-09-23-layout/sources/web/core.wasm b/prototypes/workbench/results/2026-09-23-layout/sources/web/core.wasm
new file mode 100644
index 0000000..f657ed7
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/sources/web/core.wasm
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-23-layout/sources/web/index.html b/prototypes/workbench/results/2026-09-23-layout/sources/web/index.html
new file mode 100644
index 0000000..bb42768
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/sources/web/index.html
@@ -0,0 +1,94 @@
+<!doctype html>
+<html lang="ko">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="color-scheme" content="dark">
+ <title>Pure · 빈 작업물에서 시작하기</title>
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23151918'/%3E%3Ctext x='9' y='23' fill='%23c9f08b' font-size='24' font-family='sans-serif'%3Ep%3C/text%3E%3C/svg%3E">
+ <link rel="stylesheet" href="./style.css">
+</head>
+<body>
+ <header class="masthead">
+ <a class="wordmark" href="./" aria-label="Pure 작업 공간">pure<span> / workbench</span></a>
+ <span class="experiment">제작 경험 실험 004</span>
+ <div class="connection"><span id="ready-dot" class="dot"></span><span id="ready-status" role="status">실행기 준비 중</span></div>
+ </header>
+
+ <main>
+ <section class="intro" aria-labelledby="title">
+ <div><p class="eyebrow">빈 코드 · 직접 만든 동작</p><h1 id="title">무엇을 움직이게 할까요?</h1></div>
+ <p>상태를 만들고, 입력에 동작을 연결하세요.<br>코드를 <strong>적용</strong>하면 장면이 바뀝니다. 같은 이름의 상태는 이어집니다.</p>
+ </section>
+
+ <div class="workspace">
+ <section class="editor-panel panel" aria-labelledby="editor-title">
+ <div class="panel-heading"><h2 id="editor-title"><span class="section-index">01</span> 코드</h2><span id="draft-status" class="badge">빈 작업물</span></div>
+ <div class="file-tools" aria-label="파일 관리">
+ <button id="new" class="quiet" type="button">편집기 비우기</button>
+ <button id="upload" class="quiet" type="button">파일 열기</button>
+ <button id="download" class="quiet" type="button">.pure 저장</button>
+ <button id="download-project" class="quiet" type="button">코드 JSON 저장</button>
+ <button id="download-layout" class="quiet" type="button" disabled>배치 저장</button>
+ <input id="file-input" type="file" accept=".pure,.txt,.json,text/plain,application/json" hidden>
+ </div>
+ <label class="sr-only" for="source">Pure 코드 편집기</label>
+ <div class="code-editor">
+ <div id="line-numbers" class="line-numbers" aria-hidden="true">1</div>
+ <textarea id="source" spellcheck="false" autocapitalize="off" autocomplete="off" autocorrect="off" aria-describedby="editor-hint diagnostic" placeholder="여기서 시작하세요.&#10;&#10;아래 ‘언어 안내’에서 작은 조각을 가져올 수 있습니다."></textarea>
+ </div>
+ <div class="editor-footer"><span id="source-size">0 / 32,768 bytes</span><span id="cursor-position">1행 · 1열</span></div>
+ <div class="apply-row"><button id="apply" class="primary" type="button" disabled>코드 적용 <span aria-hidden="true">↗</span></button><p id="editor-hint">편집만으로 실행 중인 코드는 바뀌지 않습니다.</p></div>
+ <div id="diagnostic" class="diagnostic" role="status" aria-live="polite"><span id="diagnostic-text">아직 작성한 코드가 없습니다.</span><button id="error-location" type="button" class="quiet" hidden>문제 위치로</button></div>
+ </section>
+
+ <div class="right-column">
+ <section class="scene-panel panel" aria-labelledby="scene-title">
+ <div class="panel-heading"><h2 id="scene-title"><span class="section-index">02</span> 실행 장면</h2><span id="scene-status" class="badge">적용 전</span></div>
+ <div class="stage-wrap">
+ <svg id="scene" viewBox="0 0 640 360" role="img" tabindex="0" aria-labelledby="scene-description"><title id="scene-description">사용자가 코드로 만든 장면. 현재 비어 있습니다.</title><g id="scene-content"></g></svg>
+ <div id="empty-scene" class="empty-scene"><span class="empty-cross" aria-hidden="true">+</span><strong>아직 아무것도 없습니다</strong><span>도형이나 글자를 선언하고 코드를 적용하세요.</span></div>
+ <span class="stage-dimensions" aria-hidden="true">640 × 360</span>
+ </div>
+ <div class="transport"><button id="play" class="secondary" type="button" disabled>▶ 실행</button><button id="step" type="button" disabled>한 프레임</button><button id="reset" type="button" disabled>초깃값으로</button><span id="clock-status">0 frame · 정지</span></div>
+ <p class="control-note">정지하면 시간만 멈춥니다. 아래 입력은 정지 중에도 작동합니다.</p>
+ <div class="input-heading"><h3>내가 선언한 입력</h3><span>눌렀을 때 .down / 놓았을 때 .up</span></div>
+ <div id="input-controls" class="input-controls"><p class="empty-note">입력을 선언하면 여기에 버튼이 생깁니다.</p></div>
+ <p class="keyboard-note">장면을 클릭하면 키보드로 입력할 수 있습니다. 코드나 폼을 편집하는 동안에는 전달되지 않습니다.</p>
+ </section>
+
+ <section class="inspect-panel panel" aria-labelledby="inspect-title">
+ <div class="panel-heading"><h2 id="inspect-title"><span class="section-index">03</span> 현재 상태</h2><span id="revision" class="mono subdued">revision 0</span></div>
+ <div id="state-values" class="state-values"><p class="empty-note">선언한 상태의 값이 표시됩니다.</p></div>
+ <div class="event-line"><span class="subdued">마지막 입력</span><span id="last-event" class="mono">아직 없음</span></div>
+ <div id="runtime-error" class="runtime-error" role="status" hidden></div>
+ <details class="cost-details"><summary>실행 비용과 범위</summary><dl id="cost-values" class="cost-values"></dl><p>코어가 보고한 WebAssembly 메모리입니다. 브라우저·편집기·렌더러의 메모리와 프로세스 전체 사용량은 포함하지 않습니다. 예약 공간 안에 사용 중인 코드·상태·스테이징 공간이 포함되므로 합산하지 않습니다.</p><p id="dropped-ticks">시간 지연으로 건너뛴 프레임: 0</p></details>
+ </section>
+ </div>
+ </div>
+
+ <section class="reference panel" aria-labelledby="reference-title">
+ <div class="panel-heading"><h2 id="reference-title">언어 안내</h2><span class="subdued">조각을 조합해 자신의 동작을 만드세요.</span></div>
+ <div class="reference-grid">
+ <article><h3>1. 기억할 값</h3><p>이름은 영문으로, 처음 값은 숫자로 정합니다.</p><pre><code>state count = 0
+state x = 80</code></pre><button class="insert-snippet quiet" data-snippet="state" type="button">상태 조각 삽입</button></article>
+ <article><h3>2. 입력과 반응</h3><p>버튼 이름과 키를 선언하고 .down / .up에 연결합니다.</p><pre><code>input tap = Space
+on tap.down {
+ count = count + 1
+}</code></pre><button class="insert-snippet quiet" data-snippet="input" type="button">입력 조각 삽입</button></article>
+ <article><h3>3. 보일 것</h3><p>좌표와 크기를 상태에 연결할 수 있습니다.</p><pre><code>box tile(x, 180, 40, 40, "#7ed9ae")
+text label(40, 60, count, "Count: ")</code></pre><button class="insert-snippet quiet" data-snippet="view" type="button">장면 조각 삽입</button></article>
+ <article><h3>4. 시간에 따른 변화</h3><p>실행하면 매 프레임 반응합니다. dt는 1/60초입니다.</p><pre><code>on frame {
+ x = x + 30 * dt
+ if x > 600 { x = 0 }
+}</code></pre><button class="insert-snippet quiet" data-snippet="frame" type="button">시간 조각 삽입</button></article>
+ </div>
+ <details class="syntax-details"><summary>표현식 · 입력 키 · 제한 보기</summary><div class="syntax-columns"><div><p><strong>표현식</strong><br><code>+ - * /</code>, <code>&lt; &lt;= &gt; &gt;= == !=</code>, <code>&amp;&amp; || !</code>, 괄호, <code>min(a,b)</code>, <code>max(a,b)</code>, <code>clamp(x,lo,hi)</code>를 사용합니다. 논리 연산은 양쪽을 모두 계산합니다.</p><p><strong>도형</strong><br><code>box id(x,y,width,height,"#rrggbb")</code><br><code>circle id(x,y,radius,"#rrggbb")</code><br><code>text id(x,y,value,"접두사")</code><br>글자의 value는 숫자 표현식입니다. 문자 접두사는 생략할 수 있습니다.</p></div><div><p><strong>입력</strong><br><code>Space</code>, <code>Enter</code>, <code>ArrowLeft/Right/Up/Down</code>, <code>KeyA</code>~<code>KeyZ</code>, <code>Digit0</code>~<code>Digit9</code>. 같은 키는 하나의 입력에 연결합니다.</p><p><strong>직접 조작</strong><br><code>input drag = pointer(tile)</code>로 도형에 입력을 연결합니다. <code>on drag.down / .move / .up / .cancel</code>에서 <code>event_x</code>, <code>event_y</code>는 장면 좌표입니다. 이동 방식과 취소 시 동작도 코드로 정합니다. 초점이나 포인터 캡처를 잃으면 <code>.cancel</code>이 전달됩니다.</p><p><strong>이 실험의 범위</strong><br>상태 32개 · 입력 8개 · 도형/글자 24개 · 반응 64개 · 컴파일된 명령 512개 · 코드 32 KiB. 반복문, 함수 선언, 자동 충돌·물리는 없습니다. 줄바꿈 또는 세미콜론으로 구분하고 <code>#</code>으로 주석을 씁니다.</p><p>0으로 나누기 등 계산 실패 시 해당 이벤트의 상태 변경 전체를 취소합니다. 입력을 누르고 있을 때는 새 코드를 적용할 수 없습니다.</p></div></div></details>
+ <div class="example-row"><div><label for="example">완성된 예제 살펴보기</label><p>선택한 코드를 편집기에 불러옵니다. 자동으로 적용되지 않습니다.</p></div><select id="example"><option value="counter">카운터</option><option value="charge">누르는 동안 충전</option><option value="toggle">입력으로 전환</option><option value="direct">직접 잡아 이동</option><option value="layout">세 도형 배치</option></select><button id="load-example" type="button">예제 불러오기</button></div>
+ <div class="baseline-row"><a href="./baseline.html" target="_blank" rel="noopener">같은 동작을 일반 JavaScript로 만든 비교 앱 ↗</a><span>충전 예제와 비교할 수 있습니다. 새 탭에서 열립니다.</span></div>
+ </section>
+ <footer class="page-footer"><span>Pure · authoring experiment</span><p>원하는 동작을 만들어보고, <strong>하려던 일 / 실제로 일어난 일 / 기대했던 일</strong>을 남겨주세요.</p></footer>
+ </main>
+ <script type="module" src="./app.js"></script>
+</body>
+</html>
diff --git a/prototypes/workbench/results/2026-09-23-layout/sources/web/project.mjs b/prototypes/workbench/results/2026-09-23-layout/sources/web/project.mjs
new file mode 100644
index 0000000..0df2711
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/sources/web/project.mjs
@@ -0,0 +1,45 @@
+import { compile } from './compiler.mjs';
+
+export const MAX_PROJECT_BYTES = 2 * 32768 * 6 + 8192;
+const encoder = new TextEncoder();
+function sourceText(value, label) {
+ if (typeof value !== 'string' || encoder.encode(value).length > 32768)
+ throw new Error(`${label}: UTF-8 32 KiB 이하의 코드가 필요합니다.`);
+ return value;
+}
+
+// No runtime mutation. Validate every saved value before opening core staging.
+export function validateDocument(document) {
+ if (!document || typeof document !== 'object' || Array.isArray(document) ||
+ document.format !== 'pure-workbench' || document.version !== 2)
+ throw new Error('배치 파일 형식이 맞지 않습니다. pure-workbench version 2가 필요합니다.');
+ const source = sourceText(document.source, '적용된 코드');
+ const draft = sourceText(Object.hasOwn(document, 'draft') ? document.draft : source, '편집 중인 코드');
+ const program = compile(source);
+ if (!Array.isArray(document.states) || document.states.length !== program.states.length)
+ throw new Error('저장된 상태 목록이 적용된 코드와 일치하지 않습니다.');
+ const values = new Map();
+ for (const entry of document.states) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry) || typeof entry.name !== 'string' || values.has(entry.name))
+ throw new Error('상태 이름이 잘못됐거나 중복됐습니다.');
+ const value = entry.value === '-0' ? -0 : entry.value;
+ if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error('상태 값은 유한한 숫자여야 합니다.');
+ values.set(entry.name, value);
+ }
+ for (const state of program.states)
+ if (!values.has(state.name)) throw new Error(`저장된 상태가 없습니다: ${state.name}`);
+ return { source, draft, program, values: program.states.map(state => values.get(state.name)) };
+}
+
+export function parseProject(text) {
+ if (typeof text !== 'string' || encoder.encode(text).length > MAX_PROJECT_BYTES)
+ throw new Error('작업 파일이 너무 큽니다.');
+ let project;
+ try { project = JSON.parse(text); } catch { throw new Error('올바른 JSON 파일이 아닙니다.'); }
+ if (!project || typeof project !== 'object' || Array.isArray(project) || project.format !== 'pure-workbench')
+ throw new Error('Pure 작업물 형식이 아닙니다.');
+ if (project.version === 1) sourceText(project.source, '코드');
+ else if (project.version === 2) validateDocument(project);
+ else throw new Error('지원하지 않는 작업 파일 버전입니다.');
+ return project;
+}
diff --git a/prototypes/workbench/results/2026-09-23-layout/sources/web/runtime.mjs b/prototypes/workbench/results/2026-09-23-layout/sources/web/runtime.mjs
new file mode 100644
index 0000000..8a9fdce
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/sources/web/runtime.mjs
@@ -0,0 +1,324 @@
+import { compile } from './compiler.mjs';
+import { validateDocument } from './project.mjs';
+
+const messages = [
+ '', '실행기의 용량 한도를 넘었습니다.', '실행 정의가 유효하지 않습니다.',
+ '계산 스택의 범위를 벗어났습니다.', '0으로 나눌 수 없습니다.',
+ '계산 결과가 유한한 수의 범위를 벗어났습니다.', '조건 분기 범위가 유효하지 않습니다.',
+ '존재하지 않는 상태를 참조했습니다.', '시간 간격이 유효하지 않습니다.',
+ '정의되지 않은 입력 사건입니다.',
+];
+const sourceLimit = 32768;
+
+function problem(message, line = null, column = null) {
+ return Object.assign(new Error(message), { line, column });
+}
+
+function freezeProgram(value) {
+ if (value && typeof value === 'object') {
+ for (const child of Object.values(value)) freezeProgram(child);
+ Object.freeze(value);
+ }
+ return value;
+}
+
+export async function createRuntime(wasmBytes, evaluationOptions = {}) {
+ if (wasmBytes === undefined) {
+ const response = await fetch(new URL('./core.wasm', import.meta.url), { cache: 'no-store' });
+ if (!response.ok) throw new Error(`실행기를 불러오지 못했습니다 (HTTP ${response.status}).`);
+ wasmBytes = await response.arrayBuffer();
+ }
+ const module = await WebAssembly.compile(wasmBytes);
+ if (WebAssembly.Module.imports(module).length) throw new Error('이 실행기는 호스트 함수 없이 동작해야 합니다.');
+ const { exports: core } = await WebAssembly.instantiate(module, {});
+ if (typeof core.eval_bounded !== 'function' || typeof core.abort_program !== 'function' || typeof core.dispatch_point !== 'function' || typeof core.put_restored_value !== 'function' || typeof core.commit_restored_program !== 'function')
+ throw new Error('실행기 버전이 맞지 않습니다. 새로고침하거나 core.wasm을 다시 빌드하세요.');
+ core.reset_all();
+ let program = null;
+ let appliedSource = '';
+ let lastEvent = null;
+ // Keep a failed event visible through its release/focus-loss cleanup.
+ // The active program can clear this diagnostic by apply or explicit reset.
+ let lastFailure = null;
+ const held = new Map();
+ const points = new Map();
+ let presenting = false;
+ let bindings = [];
+ // One bounded publication buffer, never exposed to callers. No state mirror.
+ let displayValues = null, displayValid = null;
+ function available() {
+ if (presenting) throw new Error('출력 전달 중에는 실행기를 다시 호출할 수 없습니다.');
+ }
+ let evaluationPolicy = { cacheEntries: 0, historyEntries: 8, workLimit: 2048 };
+
+ function checked(code, line = null) {
+ if (code !== 0) throw problem(messages[code] || `실행기 오류 ${code}`, line);
+ }
+ function inputIndex(name) {
+ const index = program?.inputs.findIndex((input) => input.name === name) ?? -1;
+ if (index < 0) throw new Error(`정의되지 않은 입력: ${name}`);
+ return index;
+ }
+ function dispatch(event, name, phase, source, point = null) {
+ const status = point ? core.dispatch_point(event, point.x, point.y) : core.dispatch(event, event === 0 ? 1 / 60 : 0);
+ const pc = core.info(2) >>> 0;
+ lastEvent = { name, phase, source, status,
+ line: program?.instructions[pc]?.line ?? null, ops: core.info(3) >>> 0 };
+ if (point) { lastEvent.x = point.x; lastEvent.y = point.y; }
+ if (status) lastFailure = { ...lastEvent };
+ return status;
+ }
+
+ function evaluateDisplay() {
+ displayValues ??= new Float64Array(96);
+ displayValid ??= new Uint8Array(24);
+ core.begin_evaluation(evaluationPolicy.workLimit);
+ let error = null, slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ displayValid[i] = 1;
+ for (const [name, binding] of bindings[i]) {
+ const value = core.eval_bounded(binding.start, binding.length);
+ if (core.evaluation_info(0) === 10) {
+ displayValid.fill(0);
+ return { code: 101, line: view.line,
+ message: '표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.' };
+ }
+ if (!Number.isFinite(value) || ((name === 'width' || name === 'height' || name === 'radius') && value < 0)) {
+ error ??= { line: view.line, message: `${view.id}.${name}: 표시할 수 없는 값입니다. 0 나눗셈이나 음수 크기를 확인하세요.` };
+ displayValid[i] = 0;
+ }
+ displayValues[slot++] = value;
+ }
+ }
+ return error;
+ }
+
+ function status(viewError) {
+ const executionError = lastFailure?.status || 0;
+ return {
+ tick: core.info(0) >>> 0, revision: core.info(4) >>> 0,
+ displayError: viewError ? { code: viewError.code || 100, ...viewError } : null,
+ lastError: executionError || (viewError ? viewError.code || 100 : 0),
+ errorMessage: executionError ? messages[executionError] || `실행 오류 ${executionError}` : viewError?.message || '',
+ errorLine: executionError ? lastFailure.line : viewError?.line ?? null,
+ lastOps: core.info(3) >>> 0, successfulEvents: core.info(9) >>> 0,
+ held: [...held.keys()], lastEvent: lastEvent ? { ...lastEvent } : null,
+ lastFailure: lastFailure ? { ...lastFailure } : null,
+ memory: {
+ linearBytes: core.memory.buffer.byteLength,
+ reservedEngineBytes: core.layout(0), programReservedBytes: core.layout(1),
+ instructionBytes: core.layout(2), handlerBytes: core.layout(3),
+ activeIRBytes: core.layout(4), activeStateBytes: core.layout(5),
+ stagingReservedBytes: core.layout(6), evalStackBytes: core.layout(7),
+ },
+ };
+ }
+
+ function load(next, source, restored = null) {
+ const oldIds = new Map(restored ? [] : program?.states.map((state, index) => [state.name, index]) ?? []);
+ checked(core.begin_program(next.states.length, next.inputs.length));
+ try {
+ for (let index = 0; index < next.states.length; index++) {
+ const state = next.states[index];
+ checked(core.put_state(index, state.initial, oldIds.get(state.name) ?? -1), state.line);
+ if (restored) checked(core.put_restored_value(index, restored[index]), state.line);
+ }
+ for (const op of next.instructions) checked(core.put_instruction(op.op, op.arg, op.value), op.line);
+ for (const handler of next.handlers) checked(core.put_handler(handler.event, handler.start, handler.length), handler.line);
+ checked(restored ? core.commit_restored_program() : core.commit_program());
+ } catch (error) {
+ core.abort_program();
+ throw error;
+ }
+ program = next;
+ bindings = next.views.map(view => Object.entries(view.bindings));
+ appliedSource = source;
+ lastEvent = null;
+ lastFailure = null;
+ }
+
+ const api = {
+ get currentProgram() { return program; },
+ configureEvaluation({ cacheEntries = evaluationPolicy.cacheEntries,
+ historyEntries = evaluationPolicy.historyEntries, workLimit = evaluationPolicy.workLimit } = {}) {
+ available();
+ for (const [name, value, maximum] of [['cacheEntries', cacheEntries, 32],
+ ['historyEntries', historyEntries, 8], ['workLimit', workLimit, 0xffffffff]]) {
+ if (!Number.isInteger(value) || value < 0 || value > maximum)
+ throw new Error(`${name}: 0~${maximum} 사이의 정수여야 합니다.`);
+ }
+ checked(core.configure_evaluation(cacheEntries, historyEntries));
+ evaluationPolicy = { cacheEntries, historyEntries, workLimit };
+ return { ...evaluationPolicy };
+ },
+ forgetEvaluation(part = 'all') {
+ available();
+ const flags = part === 'all' ? 3 : part === 'values' ? 1 : part === 'history' ? 2 : undefined;
+ if (flags === undefined) throw new Error('삭제할 기록은 values, history, all 중 하나여야 합니다.');
+ core.forget_evaluation(flags);
+ },
+ evaluationStats() {
+ return { ...evaluationPolicy, work: core.evaluation_info(1),
+ instructions: core.evaluation_info(2), cacheHits: core.evaluation_info(3),
+ reservedBytes: core.evaluation_info(6), dedicatedBytes: core.evaluation_info(7),
+ sharedBytes: core.evaluation_info(8), reuseAvailable: Boolean(core.evaluation_info(9)),
+ workspaceTagBytes: core.evaluation_info(10) };
+ },
+ apply(source) {
+ available();
+ if (typeof source !== 'string' || new TextEncoder().encode(source).length > sourceLimit) {
+ throw problem('정의는 UTF-8 기준 32,768바이트 이하의 텍스트여야 합니다.');
+ }
+ if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 적용하세요. 현재 작업은 그대로 유지합니다.');
+ const next = freezeProgram(compile(source));
+ load(next, source);
+ return next;
+ },
+ restoreDocument(document) {
+ available();
+ if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 복원하세요.');
+ const next = validateDocument(document);
+ load(freezeProgram(next.program), next.source, next.values);
+ return { source: next.source, draft: next.draft };
+ },
+ exportDocument(draft = appliedSource) {
+ available();
+ if (!program) throw problem('먼저 코드를 적용하세요.');
+ if (held.size) throw problem('조작을 끝낸 뒤 배치를 저장하세요.');
+ if (lastFailure) throw problem('실행 오류를 해결한 뒤 배치를 저장하세요.');
+ if (typeof draft !== 'string' || new TextEncoder().encode(draft).length > sourceLimit)
+ throw problem('편집 중인 코드는 UTF-8 32 KiB 이하여야 합니다.');
+ return { format: 'pure-workbench', version: 2, source: appliedSource, draft,
+ states: program.states.map((state, i) => {
+ const value = core.get_state(i);
+ return { name: state.name, value: Object.is(value, -0) ? '-0' : value };
+ }) };
+ },
+ reset() {
+ available();
+ held.clear();
+ points.clear();
+ core.reset_state();
+ lastEvent = null;
+ lastFailure = null;
+ },
+ frame(count = 1) {
+ available();
+ if (!Number.isInteger(count) || count < 0 || count > 600) throw new Error('프레임 수는 0~600 사이의 정수여야 합니다.');
+ if (!program) return 0;
+ for (let i = 0; i < count; i++) {
+ const code = dispatch(0, 'frame', 'frame', 'clock');
+ if (code !== 0) return code;
+ }
+ return 0;
+ },
+ down(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (program.inputs[index].target) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.');
+ const sources = held.get(name) || new Set();
+ if (sources.has(source)) return 0;
+ const wasDown = sources.size > 0;
+ sources.add(source);
+ held.set(name, sources);
+ return wasDown ? 0 : dispatch(1 + index * 2, name, 'down', source);
+ },
+ up(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (program.inputs[index].target) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.');
+ const sources = held.get(name);
+ if (!sources?.delete(source)) return 0;
+ if (sources.size) return 0;
+ held.delete(name);
+ return dispatch(2 + index * 2, name, 'up', source);
+ },
+ point(name, phase, x, y, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (!program.inputs[index].target) throw new Error('포인터에 연결된 입력이 아닙니다.');
+ if (!['down', 'move', 'up', 'cancel'].includes(phase)) throw new Error('포인터 단계는 down, move, up, cancel 중 하나여야 합니다.');
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error('포인터 좌표는 유한한 숫자여야 합니다.');
+ if (typeof source !== 'string' || source.length === 0) throw new Error('포인터 출처 이름이 필요합니다.');
+ let point = points.get(name);
+ if (phase === 'down') {
+ if (point?.source === source) return 0;
+ if (point) throw new Error('이 입력은 다른 포인터가 사용 중입니다.');
+ point = {source, x, y};
+ points.set(name, point);
+ held.set(name, new Set([source]));
+ } else {
+ if (!point || point.source !== source) return 0;
+ point.x = x; point.y = y;
+ if (phase === 'up' || phase === 'cancel') { points.delete(name); held.delete(name); }
+ }
+ const event = phase === 'move' ? 17 + index : phase === 'cancel' ? 25 + index : 1 + index * 2 + (phase === 'up' ? 1 : 0);
+ return dispatch(event, name, phase, source, point);
+ },
+ releaseAll() {
+ available();
+ let firstError = 0;
+ for (const [name, point] of [...points]) {
+ const status = api.point(name, 'cancel', point.x, point.y, point.source);
+ if (!firstError) firstError = status;
+ }
+ for (const [name, sources] of [...held]) {
+ for (const source of [...sources]) {
+ const status = api.up(name, source);
+ if (!firstError) firstError = status;
+ }
+ }
+ return firstError;
+ },
+ exportProject() {
+ return { format: 'pure-workbench', version: 1, source: appliedSource };
+ },
+ // Synchronous trusted output adapter. Callbacks run only after the whole
+ // display has been evaluated. Runtime mutation/reentrant reads are refused.
+ // Exceptions from an adapter propagate; the next call republishes all values.
+ present(target) {
+ available();
+ presenting = true;
+ try {
+ const { begin, state, view, value, end } = target;
+ for (const callback of [begin, state, view, value, end])
+ if (typeof callback !== 'function') throw new TypeError('출력 어댑터 함수가 필요합니다.');
+ const error = evaluateDisplay();
+ begin.call(target, program);
+ for (let i = 0; i < (program?.states.length ?? 0); i++) state.call(target, i, core.get_state(i));
+ let slot = 0, visible = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const valid = Boolean(displayValid[i]);
+ view.call(target, i, valid);
+ for (const [name] of bindings[i]) {
+ if (valid) value.call(target, i, name, displayValues[slot]);
+ slot++;
+ }
+ if (valid) visible++;
+ }
+ end.call(target, visible);
+ return status(error);
+ } finally { presenting = false; }
+ },
+ snapshot() {
+ available();
+ const error = evaluateDisplay();
+ const states = Object.fromEntries((program?.states ?? []).map((state, index) => [state.name, core.get_state(index)]));
+ const views = [];
+ let slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ const rendered = displayValid[i] ? { id: view.id, type: view.type, color: view.color, prefix: view.prefix } : null;
+ for (const [name] of bindings[i]) {
+ if (rendered) rendered[name] = displayValues[slot];
+ slot++;
+ }
+ if (rendered) views.push(rendered);
+ }
+ return { states, views, ...status(error) };
+ },
+ };
+ api.configureEvaluation(evaluationOptions);
+ return Object.freeze(api);
+}
diff --git a/prototypes/workbench/results/2026-09-23-layout/sources/web/scene-input.mjs b/prototypes/workbench/results/2026-09-23-layout/sources/web/scene-input.mjs
new file mode 100644
index 0000000..0f7cbef
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/sources/web/scene-input.mjs
@@ -0,0 +1,83 @@
+// This adapter owns browser capture only. Authored state changes stay in the VM.
+export function bindSceneInput(scene, runtime, act) {
+ const captures = new Map();
+ const events = scene.ownerDocument.defaultView;
+ const coordinates = event => {
+ const matrix = scene.getScreenCTM();
+ if (!matrix) return null;
+ try {
+ const p = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse());
+ return Number.isFinite(p.x) && Number.isFinite(p.y) ? p : null;
+ } catch { return null; }
+ };
+ const release = id => {
+ if (scene.hasPointerCapture(id)) scene.releasePointerCapture(id);
+ };
+ function finish(event, phase, pointerId = event.pointerId) {
+ const entry = captures.get(pointerId);
+ if (!entry) return;
+ event.preventDefault();
+ // Capture loss/cancel may not carry a meaningful position. Use the last
+ // delivered coordinates. Up must use its own final position.
+ const p = phase === 'up' ? coordinates(event) : null;
+ if (p) { entry.x = p.x; entry.y = p.y; }
+ if (phase === 'up' && !p) phase = 'cancel';
+ captures.delete(pointerId);
+ release(pointerId);
+ act(() => runtime().point(entry.name, phase, entry.x, entry.y, entry.source));
+ }
+ scene.addEventListener('pointerdown', event => {
+ if (event.button !== 0 || !runtime() || captures.has(event.pointerId)) return;
+ const target = event.target.closest?.('[data-point-input]');
+ if (!target || !scene.contains(target)) return;
+ const name = target.getAttribute('data-point-input');
+ // A logical input has one coordinate owner. A second contact on it must not
+ // steal the gesture; other authored targets can be manipulated independently.
+ if ([...captures.values()].some(entry => entry.name === name)) return;
+ const p = coordinates(event);
+ if (!p) return;
+ event.preventDefault(); scene.focus();
+ act(() => {
+ scene.setPointerCapture(event.pointerId);
+ const entry = {name, source:`scene:${event.pointerId}`, pointerType:event.pointerType, x:p.x, y:p.y};
+ captures.set(event.pointerId, entry);
+ try { runtime().point(name, 'down', p.x, p.y, entry.source); }
+ catch (error) { captures.delete(event.pointerId); release(event.pointerId); throw error; }
+ });
+ });
+ scene.addEventListener('pointermove', event => {
+ const entry = captures.get(event.pointerId);
+ if (!entry) return;
+ const p = coordinates(event);
+ if (!p) { finish(event, 'cancel'); return; }
+ event.preventDefault();
+ entry.x = p.x; entry.y = p.y;
+ act(() => runtime().point(entry.name, 'move', p.x, p.y, entry.source));
+ });
+ // A host may deliver release outside the SVG even while it owns capture.
+ // Observe termination before target handlers or capture cleanup can cancel it.
+ events.addEventListener('pointerup', event => finish(event, 'up'), true);
+ events.addEventListener('pointercancel', event => finish(event, 'cancel'), true);
+ events.addEventListener('mouseup', event => {
+ if (event.button !== 0) return;
+ // Mouse-only fallback when the host delivers mouseup without pointerup.
+ // A normal pointerup already removed the owner; touch compatibility events
+ // must never finish a different contact.
+ for (const [id, entry] of captures) {
+ if (entry.pointerType === 'mouse') finish(event, 'up', id);
+ }
+ }, true);
+ scene.addEventListener('lostpointercapture', event => {
+ // Descendant capture transitions bubble here but do not release our owner.
+ if (event.target === scene) finish(event, 'cancel');
+ });
+ return Object.freeze({
+ releaseCaptures() {
+ const ids = [...captures.keys()];
+ captures.clear();
+ for (const id of ids) release(id);
+ // The caller subsequently invokes runtime.releaseAll(), which dispatches
+ // each pending cancel once. Clearing first makes lostcapture harmless.
+ },
+ });
+}
diff --git a/prototypes/workbench/results/2026-09-23-layout/sources/workspace.test.mjs b/prototypes/workbench/results/2026-09-23-layout/sources/workspace.test.mjs
new file mode 100644
index 0000000..285080b
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/sources/workspace.test.mjs
@@ -0,0 +1,89 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { compile } from './web/compiler.mjs';
+import { createRuntime } from './web/runtime.mjs';
+
+const wasm = await readFile(new URL('./web/core.wasm', import.meta.url));
+const source = factor => `state x = 10
+input next = KeyN
+text label(0, 0, x * ${factor})
+on next.down { x = x + 0.25 }
+`;
+
+function begin(core, program, preserve) {
+ assert.equal(core.begin_program(program.states.length, program.inputs.length), 0);
+ program.states.forEach((state, i) => assert.equal(core.put_state(i, state.initial, preserve ? i : -1), 0));
+}
+
+function finish(core, program, during = () => {}) {
+ for (const op of program.instructions) {
+ assert.equal(core.put_instruction(op.op, op.arg, op.value), 0);
+ during();
+ }
+ for (const handler of program.handlers)
+ assert.equal(core.put_handler(handler.event, handler.start, handler.length), 0);
+ assert.equal(core.commit_program(), 0);
+}
+
+function observe(core, binding) {
+ core.begin_evaluation(100);
+ const value = core.eval_bounded(binding.start, binding.length);
+ assert.equal(core.evaluation_info(0), 0);
+ return value;
+}
+
+test('Wasm executes old events and output during staged replacement without corrupting either phase', async () => {
+ const {instance:{exports:core}} = await WebAssembly.instantiate(wasm);
+ core.reset_all();
+ const old = compile(source(2)), next = compile(source(3));
+ begin(core, old, false); finish(core, old);
+ assert.equal(core.configure_evaluation(32, 8), 0);
+ const binding = old.views[0].bindings.value;
+ assert.equal(observe(core, binding), 20);
+ assert.equal(observe(core, binding), 20);
+ assert.equal(core.evaluation_info(3), 1);
+ begin(core, next, true);
+ let value = 10;
+ finish(core, next, () => {
+ assert.equal(core.evaluation_info(9), 0);
+ assert.equal(core.dispatch(1, 0), 0); value += 0.25;
+ assert.equal(core.configure_evaluation(32, 8), 0);
+ core.forget_evaluation(3);
+ assert.equal(observe(core, binding), value * 2);
+ assert.equal(core.evaluation_info(3), 0);
+ });
+ assert.equal(core.evaluation_info(9), 1);
+ assert.equal(core.get_state(0), value);
+ const replaced = next.views[0].bindings.value;
+ assert.equal(observe(core, replaced), value * 3);
+ assert.equal(observe(core, replaced), value * 3);
+ assert.equal(core.evaluation_info(3), 1);
+
+ const revision = core.info(4);
+ begin(core, old, true);
+ assert.equal(core.put_instruction(0, 0, Infinity), 5);
+ assert.equal(core.commit_program(), 5);
+ assert.equal(observe(core, replaced), value * 3);
+ core.abort_program();
+ assert.equal(core.info(4), revision);
+ assert.equal(core.get_state(0), value);
+ assert.equal(core.evaluation_info(9), 1);
+ assert.equal(observe(core, replaced), value * 3);
+ assert.equal(core.memory.buffer.byteLength, 65536);
+});
+
+test('workspace accounting reports shared payload and safety tag without double-counting', async () => {
+ const runtime = await createRuntime(wasm, {cacheEntries:32});
+ runtime.apply(source(2)); runtime.snapshot();
+ const stats = runtime.evaluationStats(), memory = runtime.snapshot().memory;
+ assert.equal(stats.dedicatedBytes, 48);
+ assert.equal(stats.sharedBytes, 1152);
+ assert.equal(stats.workspaceTagBytes, 8);
+ assert.equal(stats.reservedBytes, stats.dedicatedBytes + stats.sharedBytes);
+ const restoreStorage = memory.stagingReservedBytes - memory.programReservedBytes;
+ assert.equal(restoreStorage, 264); // 32 values + mask/alignment, only in pending storage.
+ assert.equal(memory.reservedEngineBytes, 19072 + stats.dedicatedBytes + stats.workspaceTagBytes + restoreStorage);
+ assert(stats.sharedBytes < memory.stagingReservedBytes);
+ assert.equal(stats.reuseAvailable, true);
+});
diff --git a/prototypes/workbench/results/2026-09-23-layout/verification.json b/prototypes/workbench/results/2026-09-23-layout/verification.json
new file mode 100644
index 0000000..a021b9a
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-layout/verification.json
@@ -0,0 +1,56 @@
+[
+ {
+ "file": "prototypes/workbench/baseline.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 reference begins with the authored states and matching scene (2.390107ms)\n\u2714 press, hold, release, and frame order produce exact expected values (0.529425ms)\n\u2714 charge clamps at one and explicit floor restores rest (1.33572ms)\n\u2714 repeat and concurrent physical sources produce only logical edges (0.417351ms)\n\u2714 airborne presses cannot charge or cause another jump (0.245111ms)\n\u2714 releaseAll emits release and reset clears latches without a jump (0.408129ms)\n\u2714 unknown inputs and invalid frame counts reject without mutation (0.728128ms)\n\u2139 tests 7\n\u2139 suites 0\n\u2139 pass 7\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 87.76119\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/cli.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 terminal starts empty and produces the same charge trace as the shared runtime (147.899406ms)\n\u2714 invalid terminal commands do not alter state and fail piped execution (51.409953ms)\n\u2714 terminal replays authored pointer movement and cancellation through the shared runtime (58.411904ms)\n\u2714 file load preserves by name, rejected load preserves active work, and save never overwrites (54.468837ms)\n\u2714 terminal exposes arithmetic rollback and retains failure through matching release (53.599468ms)\n\u2714 terminal configures and discards evaluation records and recovers from a display limit (50.104196ms)\n\u2714 terminal saves and restores placement independently of reset and preserves a browser draft (166.566375ms)\n\u2714 invalid restoration leaves terminal code and state unchanged (54.509559ms)\n\u2139 tests 8\n\u2139 suites 0\n\u2139 pass 8\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 711.531406\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/compiler.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 a blank document is an empty program with no implied behavior (4.889714ms)\n\u2714 forward references and separate state/input namespaces preserve declaration IDs (1.963422ms)\n\u2714 ordered assignments, repeated handlers, and condition evaluation once (0.880387ms)\n\u2714 nested branches use absolute forward targets and skip correct bodies (0.584591ms)\n\u2714 operator precedence, associativity, unary and builtin function lowering (1.469961ms)\n\u2714 multiline parentheses, scientific literals, comments, and string escapes (0.657092ms)\n\u2714 all instruction fields are numeric and carry original source line (1.405072ms)\n\u2714 contextual declaration words remain valid names outside reserved builtins (0.478295ms)\n\u2714 unknown and reserved symbols have useful one-based locations (1.539498ms)\n\u2714 duplicates, unsupported keys, initializers, and invalid syntax are rejected (1.273683ms)\n\u2714 document, declaration, instruction, and nesting limits reject overflow (6.691063ms)\n\u2714 every supported input maps to a bounded ordered event ID (0.683203ms)\n\u2714 optional examples compile and charge behavior is entirely authored (19.247691ms)\n\u2139 tests 13\n\u2139 suites 0\n\u2139 pass 13\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 112.656204\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/evaluation.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 cache reuse, unrelated changes and discarded records preserve authored values (19.031877ms)\n\u2714 committed changes only: failed events and failed edits preserve reusable output (2.886995ms)\n\u2714 program edits, state reordering and reset cannot reuse old definitions (3.726605ms)\n\u2714 finite float semantics include signed zero, high state bit and arithmetic errors (4.550215ms)\n\u2714 one budget covers the whole scene; failures publish no partial scene or state rollback (1.971312ms)\n\u2714 small caches, no history and every small work limit give exact results or explicit failure (26.454123ms)\n\u2714 invalid settings and forget names preserve policy and state (2.972995ms)\n\u2139 tests 7\n\u2139 suites 0\n\u2139 pass 7\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 136.239388\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/pointer.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 pointer declarations resolve forward targets and reject ambiguous or contextless inputs (9.252003ms)\n\u2714 authored drag keeps grab offset and commits the displayed position even if release coordinates differ (10.117335ms)\n\u2714 ownership, duplicate downs, stale moves, cancellation and focus cleanup cannot leave held input (4.090548ms)\n\u2714 invalid coordinates and modes do not mutate a held gesture; arithmetic failure rolls back one event (3.814938ms)\n\u2714 independent targets share the event pipeline and publication remains non-reentrant (2.022608ms)\n\u2714 failing down, up and cancel never prevent terminal ownership cleanup (2.742218ms)\n\u2139 tests 6\n\u2139 suites 0\n\u2139 pass 6\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 105.219661\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/presentation.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 direct publication matches archived snapshots and work counts through authored changes (50.493751ms)\n\u2714 whole-scene budget failure never publishes partial bindings and recovers exactly (8.002728ms)\n\u2714 latched event rollback, nonfinite bindings, reset and signed zero keep their semantics (3.308476ms)\n\u2714 publication rejects reentrant runtime changes and reads before they affect the scene (3.977975ms)\n\u2714 invalid or throwing output adapters do not strand the runtime or alter authored state (1.996738ms)\n\u2139 tests 5\n\u2139 suites 0\n\u2139 pass 5\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 147.24287\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/project.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 multiple authored placements and a separate invalid draft survive a fresh runtime restore (31.329222ms)\n\u2714 malformed files reject before changing active code, values, diagnostics or warm display records (4.769531ms)\n\u2714 restoration matches names rather than ordering and never reuses stale cached values (2.511923ms)\n\u2714 finite float values including negative zero round trip without changing authored reset values (2.978108ms)\n\u2714 held and failed interactions cannot be saved and restoration cannot steal an active input (5.746993ms)\n\u2714 empty and full documents restore without imposing a new source size or state count (5.467475ms)\n\u2714 document APIs respect the output callback boundary and export independent values (4.112736ms)\n\u2139 tests 7\n\u2139 suites 0\n\u2139 pass 7\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 150.397748\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/runtime.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 blank startup and applied empty source contain no application objects or behavior (12.431739ms)\n\u2714 author can add unrelated states inputs views and behavior entirely through source (5.39964ms)\n\u2714 keyboard and pointer sources aggregate first down and last up; repeats are ignored (2.080515ms)\n\u2714 held input rejects apply without changing applied source, metadata, or values (2.656921ms)\n\u2714 invalid syntax, unknown references and capacity overflow preserve the previous program (2.90668ms)\n\u2714 state preservation follows names across reorder/add/remove; reset uses new initializers (1.696997ms)\n\u2714 callers cannot mutate active metadata and corrupt name-based state preservation (1.356442ms)\n\u2714 conditions execute once, and assignments and multiple handlers follow source order (1.563127ms)\n\u2714 a failed event rolls back all handlers and keeps its source diagnostic through release (1.71833ms)\n\u2714 reset clears held inputs and latched errors; invalid frame counts have no effects (1.910294ms)\n\u2714 render evaluation is pure and invalid binding is distinct from an event rollback (5.831157ms)\n\u2714 independent adapters replay the same author program without sharing live state (2.742643ms)\n\u2714 Wasm has no imports and reports fixed memory and exact active IR/state storage (7.504414ms)\n\u2139 tests 13\n\u2139 suites 0\n\u2139 pass 13\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 126.79257\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/workspace.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 Wasm executes old events and output during staged replacement without corrupting either phase (7.93719ms)\n\u2714 workspace accounting reports shared payload and safety tag without double-counting (3.192402ms)\n\u2139 tests 2\n\u2139 suites 0\n\u2139 pass 2\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 86.075656\n",
+ "stderr": ""
+ }
+]
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/README.md b/prototypes/workbench/results/2026-09-23-native-instances/README.md
new file mode 100644
index 0000000..f829fce
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/README.md
@@ -0,0 +1,36 @@
+# 네이티브 인스턴스와 기존 작성 동작의 연결
+
+2026-09-23, Linux AArch64 / Zig 0.15.2 / GCC 14.2.0.
+
+- `native.json`, `sanitized.json`: 작성물 9개, 입력 156단계의 native/Wasm 상태·표시 비교,
+ 새 프로세스 복원, 동일 프로세스 인스턴스 3개의 독립성·충돌·실패 검사와 소스 해시.
+- `browser-tests.log`: 기존 브라우저/Wasm 및 Node CLI 검사 87개.
+- `browser-build.json`: Zig 의미 검사 37개와 Wasm 빌드 기록.
+- `application-build.json`: 사용자가 실행할 수 있는 네이티브 라우팅 예제 빌드 기록.
+- `verification.json`: 결과의 소스·실행물 확인, 선택 모듈과 전체 실행물 비용.
+- `default-linkage.json`: 초기 기본 panic/compiler-rt 설정의 링크 전 아카이브 기록. 최종 앱 크기와 비교하는 수치가 아니다.
+- `sources/`: 결과에 기록한 구현과 재현에 필요한 작성물·검사 소스.
+- `auxiliary-sources.json`: 재현용 작성물·기존 검사 소스의 별도 해시 목록.
+
+저장소 루트에서 실행한 빌드:
+
+```sh
+python3 prototypes/workbench/manage.py build --zig /path/to/zig-0.15.2
+python3 prototypes/workbench/manage.py native prototypes/workbench/examples/routed-layout.pure --zig /path/to/zig-0.15.2
+```
+
+그 뒤 `prototypes/workbench`에서:
+
+```sh
+node --test --test-reporter=spec *.test.mjs
+node native/check.mjs --output results/2026-09-23-native-instances/native.json
+node native/check.mjs --sanitize --output results/2026-09-23-native-instances/sanitized.json
+```
+
+기존 결과는 덮어쓰지 않는다. 재현할 때 `--output`을 생략하거나 새 경로를 사용한다.
+동결 소스의 `sources`를 저장소 루트로 삼아 같은 명령을 실행할 수 있다. Node·C17 컴파일러·
+Zig 0.15.2 및 로컬 자식 프로세스 실행이 필요하다. 비용 확인에는 GNU ar/nm/size/strip/readelf를 사용했다.
+
+sanitizer는 C 호스트에 적용됐다. Zig 모듈은 두 빌드 모두 ReleaseSafe다. 네이티브 창·macOS·
+스레드 간 동시 실행·처리량·RSS 검증 자료는 아니다.
+[구현과 남은 범위](../../../../docs/research/024-native-app-instances.md).
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/application-build.json b/prototypes/workbench/results/2026-09-23-native-instances/application-build.json
new file mode 100644
index 0000000..ff35c7a
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/application-build.json
@@ -0,0 +1,40 @@
+{
+ "source": "/home/eunhhu/work/pure/prototypes/workbench/examples/routed-layout.pure",
+ "sourceSha256": "656e81577c250df704ba744b1ea6ec9c987ab3b8ee90c758062ac4beaf195650",
+ "zigVersion": "0.15.2",
+ "zigFlags": [
+ "-O",
+ "ReleaseSafe",
+ "-fno-compiler-rt"
+ ],
+ "cFlags": [
+ "-std=c17",
+ "-O2",
+ "-g",
+ "-Wall",
+ "-Wextra",
+ "-Wpedantic",
+ "-Werror"
+ ],
+ "binary": "/home/eunhhu/work/pure/prototypes/workbench/build/native/pure-native",
+ "binarySha256": "6d587d072b6cfc93777fece74b99cde1ea5ff581b3276a6de545568bbcd5c69c",
+ "librarySha256": "b45932025312c12eea6cd1083c43ed57f3cf3dbbb752c47adba634af8e21c5c2",
+ "preparedSha256": "710503e7ffadf02b309c24a761caade4c5a093750d6801956cc698bfc263fc09",
+ "implementationSha256": {
+ "prototypes/workbench/engine.zig": "555b9ff3143888921d90a475bcb7957c18dbf7101ee9e3d2853c01a562cbf3f5",
+ "prototypes/workbench/evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "prototypes/workbench/native.zig": "fe6c445cdfb5a4bc27ea9f540f3880c45b65c33098eec761d4398d9a7fc5c9a6",
+ "prototypes/workbench/native/prepare.mjs": "3989d4e545255fccf9d846afb79246e874f4cefb03eecef9ee1e2b1ce8fd257e",
+ "prototypes/workbench/native/scalar.h": "b84a796bde2f45d59505cc0addb5f5afede0a9c4dc58d40a38dd37a8ac68d1fd",
+ "prototypes/workbench/native/plan.h": "2e9bd7e3c2ecc52f79c8938d09d2a696174c44a245e4bf52b70be2ed02cbbb57",
+ "prototypes/workbench/native/app.h": "0ea9c9dc18e3d9c34763198c6efbaa3219c6f91764b24f00591d958ca0678ce2",
+ "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1",
+ "prototypes/workbench/native/main.c": "dd28168741e79ffaa73cf74e60b974a1ca84b0473a43aafe089019f61e732aab",
+ "prototypes/workbench/native/app.c": "a8c0547ca789f498e453f8b8b982d1e06a874f34e72d905d86923d0e573eff66",
+ "prototypes/workbench/native/plan.c": "f0bdf8466097cfad54c197120aace8acd5f430170931d575e26807b760ce2173",
+ "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2",
+ "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04",
+ "prototypes/connections/store.c": "44702e9414c26d1ee336b2e36e6713560013c2c8d0091a8900042e2468f88015",
+ "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f"
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/auxiliary-sources.json b/prototypes/workbench/results/2026-09-23-native-instances/auxiliary-sources.json
new file mode 100644
index 0000000..911a77d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/auxiliary-sources.json
@@ -0,0 +1,28 @@
+{
+ "prototypes/workbench/baseline.test.mjs": "3ea469a29d301b95701bc6f6ffafa2471ac0f334c5bd462c0cbc7c320bdc0764",
+ "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da",
+ "prototypes/workbench/cli.test.mjs": "7e4688824e26dc7e56396f9d4d62726de0ccb1490e1a388bb64690ae2aae52f4",
+ "prototypes/workbench/compare.mjs": "f304cd9191fdf737777a8286e223ee32a483a64ba6cfd80fcf9d4c4a5b1bb6d8",
+ "prototypes/workbench/compiler.test.mjs": "6aa442d9d1eeb69453082f1780ff4597e46b1fc09d8ba3cf955545bc94d81431",
+ "prototypes/workbench/evaluation-check.mjs": "11492759b5eff878fb8e43cad69422f9c34da6a85b5d8808478cc1845d737d4f",
+ "prototypes/workbench/evaluation.test.mjs": "d3026ab5c6bffce56c0bde3155d7dddd6fa442a5688656ee710626c762258ff1",
+ "prototypes/workbench/examples/charge.pure": "8d69d9fd51d44ab754d560eb7ff5102ea8a34540753cd7170b3bd800e4294f56",
+ "prototypes/workbench/examples/counter.pure": "53cb3a93ff0f26a3d208a5876bfff9ecb1c39e9a01acc101566727733ba166a8",
+ "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a",
+ "prototypes/workbench/examples/document.pure": "057d2e665d72dd3fce7ac59d232a65e9e0e1a6ca1f8c1e1b36ae766e480367d1",
+ "prototypes/workbench/examples/layout.pure": "cebd9c3485a2be4ec16ae665c1ed4ee200d51fc56ad0151306c8e9c31138f346",
+ "prototypes/workbench/examples/routed-layout.pure": "656e81577c250df704ba744b1ea6ec9c987ab3b8ee90c758062ac4beaf195650",
+ "prototypes/workbench/examples/shared-layout.pure": "be8296244b768f9adda99e7afc89249f34853fa27124a277aecb0841eb8176eb",
+ "prototypes/workbench/examples/toggle.pure": "3de97fab9d316c17ba6a0c82a16cccd3baafc6816b879bf9b0a174210c8a1915",
+ "prototypes/workbench/pointer.test.mjs": "5c5c94933b9b3d33ee29c0861e302209bef4dbb71d353424d29091f97f9d1c5a",
+ "prototypes/workbench/presentation.test.mjs": "1596733634b31125d81a324ca633c51b90ce848486f7909ecc0619f949ead4d1",
+ "prototypes/workbench/project.test.mjs": "7efd09c75b89af384e4020e8fc59df42932897c26c0a0cb6cb7f960684393568",
+ "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f",
+ "prototypes/workbench/routing.test.mjs": "37f6705a496599778eed306e554147b3d9e408447043f4e75abb7225e548999a",
+ "prototypes/workbench/rules.test.mjs": "caab0cb2618a39c7dcd547b402561d8f9edb11acc7cf389ae16761b70464db7b",
+ "prototypes/workbench/runtime.test.mjs": "1b9bdbae7379fcdd106cebf1acc6454839fd0e989d7ce31bfd4924fb8501cb8a",
+ "prototypes/workbench/value-input.test.mjs": "8b08813ed64949cb0d26b9b4e02a5fbf28e84dcccf8641c0ec7c8b6e686c1267",
+ "prototypes/workbench/web/baseline.mjs": "dcd567e5568d3c798d6b48e555e6d54773963c70d23594c175706b061f8b043f",
+ "prototypes/workbench/workspace-check.mjs": "a841167a2be597a56c1adc1cf31e4b1e8841579a42d853e260059531009015f0",
+ "prototypes/workbench/workspace.test.mjs": "053c3883d69a528c244d5298bd67b95642a5d6e8ceceea3d189dce6c9747f6ac"
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/browser-build.json b/prototypes/workbench/results/2026-09-23-native-instances/browser-build.json
new file mode 100644
index 0000000..c5243c7
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/browser-build.json
@@ -0,0 +1,24 @@
+{
+ "builtUtc": "2026-09-23T15:43:54.625372+00:00",
+ "zigVersion": "0.15.2",
+ "sourceSha256": "ea237848df0dbd2ce620c2874e64bc5ecbe569bf77f3737e9a4fe1e64fea40cc",
+ "wasmSha256": "8fe2f501cb9639fea0817aa57e0b7292ca6779cda4ac156b7c6b3df31d7921e9",
+ "evaluationSourceSha256": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "engineSourceSha256": "555b9ff3143888921d90a475bcb7957c18dbf7101ee9e3d2853c01a562cbf3f5",
+ "wasmFileBytes": 61065,
+ "flags": [
+ "-target",
+ "wasm32-freestanding",
+ "-O",
+ "ReleaseSafe",
+ "-fstrip",
+ "-fno-entry",
+ "-rdynamic",
+ "--export-memory",
+ "--stack",
+ "16384",
+ "--initial-memory=65536",
+ "--max-memory=65536"
+ ],
+ "nativeTests": "1/37 core.test.blank program, no hidden application behavior, and reset...OK\n2/37 core.test.arbitrary input counter and unused events...OK\n3/37 core.test.charge and release behavior exists only in the authored instruction program...OK\n4/37 core.test.forward condition executes once and assignments and handlers are ordered...OK\n5/37 core.test.branch to program end is valid...OK\n6/37 core.test.one event rolls back writes from all handlers on arithmetic failure...OK\n7/37 core.test.nonfinite arithmetic and invalid clamp fail atomically...OK\n8/37 core.test.stack underflow overflow and leftover values reject the event...OK\n9/37 core.test.failed candidates leave active values program revision and diagnostics unchanged...OK\n10/37 core.test.state preservation mapping reads active values at commit and reset uses new initials...OK\n11/37 core.test.capacity boundaries and sticky load errors...OK\n12/37 core.test.invalid declaration values state mappings event ids and handler ranges...OK\n13/37 core.test.backward self out-of-handler branches and stores without handlers are rejected...OK\n14/37 core.test.event inputs and dt enforce deterministic frame contract...OK\n15/37 core.test.binding evaluation is pure on both success and failure...OK\n16/37 core.test.arithmetic comparison logical min max and clamp expression semantics...OK\n17/37 core.test.diagnostic counters wrap without affecting scalar values...OK\n18/37 core.test.memory accounting separates reserved storage used IR and stack capacity...OK\n19/37 core.test.point coordinates are ephemeral event operands and unavailable to pure evaluation...OK\n20/37 core.test.point event ranges reject holes and invalid payloads without committing...OK\n21/37 core.test.point event failure rolls back writes from earlier matching handlers...OK\n22/37 core.test.loading shares storage with records while old events and bindings remain executable...OK\n23/37 core.test.failed and abandoned candidates restore empty reuse storage without changing active work...OK\n24/37 core.test.full pending program survives repeated evaluation configuration record loss and reset...OK\n25/37 core.test.restored definition and values commit together without changing reset initializers...OK\n26/37 core.test.missing duplicate nonfinite or invalid restored candidates leave active work untouched...OK\n27/37 core.test.empty and full restored programs validate all values and discard old display records...OK\n28/37 core.test.shared handler bodies access their own bound state slots in source order...OK\n29/37 core.test.failure in a later shared binding rolls back all earlier bindings...OK\n30/37 core.test.shared bindings reject missing arguments partial overlaps and invalid slot mappings...OK\n31/37 core.test.one routed handler changes only its event target and rejects missing or foreign routes...OK\n32/37 core.test.routes reject invalid mappings and bindings without replacing active work...OK\n33/37 core.test.routed event rollback includes globals and earlier handlers and survives staged route replacement...OK\n34/37 core.test.scalar inputs carry a finite ephemeral value without adding state or pointer ownership...OK\n35/37 core.test.scalar input failure rolls back earlier writes and old scalar events survive staging...OK\n36/37 evaluation.test.record loss cannot authorize an old memo; unrelated changes can...OK\n37/37 evaluation.test.budget, collisions, invalid settings, and disabled history are conservative...OK\nAll 37 tests passed.\n"
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/browser-tests.log b/prototypes/workbench/results/2026-09-23-native-instances/browser-tests.log
new file mode 100644
index 0000000..cdfd51b
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/browser-tests.log
@@ -0,0 +1,95 @@
+✔ reference begins with the authored states and matching scene (2.666509ms)
+✔ press, hold, release, and frame order produce exact expected values (0.626349ms)
+✔ charge clamps at one and explicit floor restores rest (2.167844ms)
+✔ repeat and concurrent physical sources produce only logical edges (0.54998ms)
+✔ airborne presses cannot charge or cause another jump (0.305665ms)
+✔ releaseAll emits release and reset clears latches without a jump (0.460091ms)
+✔ unknown inputs and invalid frame counts reject without mutation (0.806034ms)
+✔ terminal starts empty and produces the same charge trace as the shared runtime (302.941322ms)
+✔ invalid terminal commands do not alter state and fail piped execution (115.357513ms)
+✔ terminal replays authored pointer movement and cancellation through the shared runtime (133.075486ms)
+✔ file load preserves by name, rejected load preserves active work, and save never overwrites (142.619156ms)
+✔ terminal exposes arithmetic rollback and retains failure through matching release (128.85302ms)
+✔ terminal configures and discards evaluation records and recovers from a display limit (114.771589ms)
+✔ terminal saves and restores placement independently of reset and preserves a browser draft (316.213387ms)
+✔ invalid restoration leaves terminal code and state unchanged (85.826729ms)
+✔ a blank document is an empty program with no implied behavior (10.648277ms)
+✔ forward references and separate state/input namespaces preserve declaration IDs (3.800505ms)
+✔ ordered assignments, repeated handlers, and condition evaluation once (1.596902ms)
+✔ nested branches use absolute forward targets and skip correct bodies (0.981996ms)
+✔ operator precedence, associativity, unary and builtin function lowering (2.365177ms)
+✔ multiline parentheses, scientific literals, comments, and string escapes (3.012359ms)
+✔ all instruction fields are numeric and carry original source line (0.752497ms)
+✔ contextual declaration words remain valid names outside reserved builtins (1.527846ms)
+✔ unknown and reserved symbols have useful one-based locations (3.273044ms)
+✔ duplicates, unsupported keys, initializers, and invalid syntax are rejected (2.754824ms)
+✔ document, declaration, instruction, and nesting limits reject overflow (13.724877ms)
+✔ every supported input maps to a bounded ordered event ID (0.875534ms)
+✔ optional examples compile and charge behavior is entirely authored (40.07306ms)
+✔ cache reuse, unrelated changes and discarded records preserve authored values (42.423015ms)
+✔ committed changes only: failed events and failed edits preserve reusable output (6.538365ms)
+✔ program edits, state reordering and reset cannot reuse old definitions (10.174723ms)
+✔ finite float semantics include signed zero, high state bit and arithmetic errors (7.63651ms)
+✔ one budget covers the whole scene; failures publish no partial scene or state rollback (3.713079ms)
+✔ small caches, no history and every small work limit give exact results or explicit failure (44.796673ms)
+✔ invalid settings and forget names preserve policy and state (8.188563ms)
+✔ pointer declarations resolve forward targets and reject ambiguous or contextless inputs (24.927503ms)
+✔ authored drag keeps grab offset and commits the displayed position even if release coordinates differ (34.138248ms)
+✔ ownership, duplicate downs, stale moves, cancellation and focus cleanup cannot leave held input (5.890461ms)
+✔ invalid coordinates and modes do not mutate a held gesture; arithmetic failure rolls back one event (4.376762ms)
+✔ independent targets share the event pipeline and publication remains non-reentrant (3.597617ms)
+✔ failing down, up and cancel never prevent terminal ownership cleanup (4.033986ms)
+✔ direct publication matches archived snapshots and work counts through authored changes (100.465381ms)
+✔ whole-scene budget failure never publishes partial bindings and recovers exactly (15.101038ms)
+✔ latched event rollback, nonfinite bindings, reset and signed zero keep their semantics (7.581584ms)
+✔ publication rejects reentrant runtime changes and reads before they affect the scene (3.836042ms)
+✔ invalid or throwing output adapters do not strand the runtime or alter authored state (3.621838ms)
+✔ multiple authored placements and a separate invalid draft survive a fresh runtime restore (46.870351ms)
+✔ malformed files reject before changing active code, values, diagnostics or warm display records (12.714658ms)
+✔ restoration matches names rather than ordering and never reuses stale cached values (6.399829ms)
+✔ finite float values including negative zero round trip without changing authored reset values (3.312525ms)
+✔ held and failed interactions cannot be saved and restoration cannot steal an active input (13.389711ms)
+✔ empty and full documents restore without imposing a new source size or state count (11.491329ms)
+✔ document APIs respect the output callback boundary and export independent values (3.581821ms)
+✔ one input and one handler serve up to 24 static targets without instruction or handler expansion (37.094442ms)
+✔ routes validate forward references, exact types, unique views/items and current input target (6.652994ms)
+✔ grouped layout matches explicit bindings through concurrent capture, partial cancel, keyboard and restore (26.433091ms)
+✔ capture remains bound to its target and releaseAll cancels each target exactly once (3.905616ms)
+✔ reorder and remove/readd rebuild routes by names without persisting physical indices (8.318582ms)
+✔ routed writes invalidate cached displays and failed handlers rollback without losing another target owner (4.411855ms)
+✔ CLI sends grouped pointer events and rejects an unknown target (229.852547ms)
+✔ a route from a different input group cannot be delivered through the named group (3.462247ms)
+✔ binding more objects shares one VM body instead of expanding its instructions (9.239485ms)
+✔ records and rules resolve forward names and reject invalid types fields and bindings (6.422163ms)
+✔ shared layout matches the existing authored layout across independent and simultaneous gestures (27.66003ms)
+✔ field names retain identity across schema and instance reorder, removal and restoration (4.160744ms)
+✔ bindings share one event transaction including global effects and multiple uses of one item (5.169389ms)
+✔ bound writes invalidate affected display caches and failed edits preserve active bindings (8.081342ms)
+✔ generated qualified names cannot make a valid exported document unreadable by its own loader (11.070108ms)
+✔ blank startup and applied empty source contain no application objects or behavior (14.430152ms)
+✔ author can add unrelated states inputs views and behavior entirely through source (13.048972ms)
+✔ keyboard and pointer sources aggregate first down and last up; repeats are ignored (3.87206ms)
+✔ held input rejects apply without changing applied source, metadata, or values (3.873542ms)
+✔ invalid syntax, unknown references and capacity overflow preserve the previous program (6.140793ms)
+✔ state preservation follows names across reorder/add/remove; reset uses new initializers (3.107655ms)
+✔ callers cannot mutate active metadata and corrupt name-based state preservation (4.544114ms)
+✔ conditions execute once, and assignments and multiple handlers follow source order (3.42334ms)
+✔ a failed event rolls back all handlers and keeps its source diagnostic through release (3.54745ms)
+✔ reset clears held inputs and latched errors; invalid frame counts have no effects (4.804816ms)
+✔ render evaluation is pure and invalid binding is distinct from an event rollback (12.577752ms)
+✔ independent adapters replay the same author program without sharing live state (4.865927ms)
+✔ Wasm has no imports and reports fixed memory and exact active IR/state storage (17.508086ms)
+✔ numeric controls use explicit change context and cannot read pointer or frame payloads (7.839268ms)
+✔ numeric input handles scalar rules, rollback, cache invalidation and signed zero (21.075072ms)
+✔ the document policy receives range, keyboard and pointer requests without layouts or text in VM state (6.701791ms)
+✔ CLI supplies the same numeric control value and rejects nonfinite input (199.975377ms)
+✔ Wasm executes old events and output during staged replacement without corrupting either phase (11.735569ms)
+✔ workspace accounting reports shared payload and safety tag without double-counting (5.410517ms)
+ℹ tests 87
+ℹ suites 0
+ℹ pass 87
+ℹ fail 0
+ℹ cancelled 0
+ℹ skipped 0
+ℹ todo 0
+ℹ duration_ms 1433.326385
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/default-linkage.json b/prototypes/workbench/results/2026-09-23-native-instances/default-linkage.json
new file mode 100644
index 0000000..da899ef
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/default-linkage.json
@@ -0,0 +1,12 @@
+{
+ "flags": [
+ "-O",
+ "ReleaseSafe",
+ "-fcompiler-rt"
+ ],
+ "source": "//! Native C ABI. The caller supplies aligned instance storage; no global VM,\n//! allocator, OS call or browser dependency is part of this selected module.\nconst Engine = @import(\"engine.zig\").Engine;\n\nexport fn pure_scalar_bytes() usize {\n return @sizeOf(Engine);\n}\nexport fn pure_scalar_alignment() usize {\n return @alignOf(Engine);\n}\n\nexport fn pure_scalar_initialize(memory: ?*anyopaque, size: usize) ?*Engine {\n const pointer = memory orelse return null;\n if (size < @sizeOf(Engine) or @intFromPtr(pointer) % @alignOf(Engine) != 0) return null;\n const instance: *Engine = @ptrCast(@alignCast(pointer));\n instance.* = .{};\n return instance;\n}\n\nexport fn pure_scalar_reset_all(instance: *Engine) void {\n instance.reset_all();\n}\n\nexport fn pure_scalar_begin_program(instance: *Engine, state_count: u32, input_count: u32) u32 {\n return instance.begin_program(state_count, input_count);\n}\n\nexport fn pure_scalar_abort_program(instance: *Engine) void {\n instance.abort_program();\n}\n\nexport fn pure_scalar_put_state(instance: *Engine, id: u32, initial: f64, old_id: i32) u32 {\n return instance.put_state(id, initial, old_id);\n}\n\nexport fn pure_scalar_put_instruction(instance: *Engine, op: u32, arg: u32, value: f64) u32 {\n return instance.put_instruction(op, arg, value);\n}\n\nexport fn pure_scalar_put_handler(instance: *Engine, event: u32, start: u32, length: u32) u32 {\n return instance.put_handler(event, start, length);\n}\n\nexport fn pure_scalar_bind_handler(instance: *Engine, handler: u32, base: u32, count: u32) u32 {\n return instance.bind_handler(handler, base, count);\n}\n\nexport fn pure_scalar_put_pointer_target(instance: *Engine, input: u32, base: u32, count: u32) u32 {\n return instance.put_pointer_target(input, base, count);\n}\n\nexport fn pure_scalar_bind_target_handler(instance: *Engine, handler: u32, count: u32) u32 {\n return instance.bind_target_handler(handler, count);\n}\n\nexport fn pure_scalar_put_restored_value(instance: *Engine, id: u32, value: f64) u32 {\n return instance.put_restored_value(id, value);\n}\n\nexport fn pure_scalar_commit_program(instance: *Engine) u32 {\n return instance.commit_program();\n}\n\nexport fn pure_scalar_commit_restored_program(instance: *Engine) u32 {\n return instance.commit_restored_program();\n}\n\nexport fn pure_scalar_reset_state(instance: *Engine) void {\n instance.reset_state();\n}\n\nexport fn pure_scalar_dispatch(instance: *Engine, event: u32, dt: f64) u32 {\n return instance.dispatch(event, dt);\n}\n\nexport fn pure_scalar_dispatch_point(instance: *Engine, event: u32, x: f64, y: f64) u32 {\n return instance.dispatch_point(event, x, y);\n}\n\nexport fn pure_scalar_dispatch_target(instance: *Engine, event: u32, target: u32, x: f64, y: f64) u32 {\n return instance.dispatch_target(event, target, x, y);\n}\n\nexport fn pure_scalar_dispatch_value(instance: *Engine, event: u32, value: f64) u32 {\n return instance.dispatch_value(event, value);\n}\n\nexport fn pure_scalar_eval_at(instance: *Engine, start: u32, length: u32) f64 {\n return instance.eval_at(start, length);\n}\n\nexport fn pure_scalar_configure_evaluation(instance: *Engine, cache: u32, history: u32) u32 {\n return instance.configure_evaluation(cache, history);\n}\n\nexport fn pure_scalar_forget_evaluation(instance: *Engine, flags: u32) void {\n instance.forget_evaluation(flags);\n}\n\nexport fn pure_scalar_begin_evaluation(instance: *Engine, limit: u32) void {\n instance.begin_evaluation(limit);\n}\n\nexport fn pure_scalar_eval_bounded(instance: *Engine, start: u32, length: u32) f64 {\n return instance.eval_bounded(start, length);\n}\n\nexport fn pure_scalar_evaluation_info(instance: *Engine, id: u32) u32 {\n return instance.evaluation_info(id);\n}\n\nexport fn pure_scalar_get_state(instance: *Engine, id: u32) f64 {\n return instance.get_state(id);\n}\n\nexport fn pure_scalar_info(instance: *Engine, id: u32) u32 {\n return instance.info(id);\n}\n\nexport fn pure_scalar_layout(instance: *Engine, id: u32) u32 {\n return instance.layout(id);\n}\n",
+ "archive_sha256": "544095c23edc9d9e2fd6418bcd4bc02d5ad81921fa943a31e1635eb4e323d2a7",
+ "size": " text\t data\t bss\t dec\t hex\tfilename\n 240820\t 80\t 149\t 241049\t 3ad99\tprototypes/workbench/build/cache/tmp/b77f55ad39a6ffce/libpure-scalar_zcu.o (ex prototypes/workbench/build/libpure-scalar.a)\n 154614\t 8\t 4096\t 158718\t 26bfe\tprototypes/workbench/build/global-cache/o/0fe48ea8b47e9365f14b0a3f1025eb57/compiler_rt.o (ex prototypes/workbench/build/libpure-scalar.a)\n",
+ "undefined": "\nprototypes/workbench/build/cache/tmp/b77f55ad39a6ffce/libpure-scalar_zcu.o:\n w _DYNAMIC\n U getauxval\n U memcpy\n U memmove\n U memset\n\nprototypes/workbench/build/global-cache/o/0fe48ea8b47e9365f14b0a3f1025eb57/compiler_rt.o:\n",
+ "scope": "Archive section totals before linking; not the deployed file size or runtime memory."
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/native.json b/prototypes/workbench/results/2026-09-23-native-instances/native.json
new file mode 100644
index 0000000..2a79084
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/native.json
@@ -0,0 +1,159 @@
+{
+ "kind": "native-instance-and-authored-behavior-correctness-not-performance",
+ "sanitized_c_host": false,
+ "zig_mode": "ReleaseSafe; Zig object is not ASan-instrumented",
+ "compiler": "cc (Debian 14.2.0-19) 14.2.0",
+ "cases": [
+ {
+ "name": "counter",
+ "commands": 5,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# Space or the tap control increments a state that is displayed and drawn.\nstate count = 0\ninput tap = Space\ntext label(80, 90, count, \"Count: \")\nbox tile(80, 150, 40 + min(count, 20) * 8, 60, \"#356548\")\non tap.down {\n count = count + 1\n}\n",
+ "source_sha256": "53cb3a93ff0f26a3d208a5876bfff9ecb1c39e9a01acc101566727733ba166a8",
+ "prepared_sha256": "eada952dee9afd7071370215364b2a6717a28f03e0513cdd0cae4695939ec52b",
+ "native_binary_sha256": "df9f3e007d2813c94bbbd4d08cfae06571e7d8b190b6212735b32884c07d24a6",
+ "native_binary_bytes": 213768
+ },
+ {
+ "name": "toggle",
+ "commands": 28,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# Space toggles the circle. Right arrow moves the tile.\nstate visible = 1\nstate position = 120\ninput toggle = Space\ninput move = ArrowRight\ncircle marker(position, 140, visible * 24, \"#c78638\")\nbox tile(position - 24, 190, 48, 48, \"#356548\")\ntext value(80, 80, visible, \"Visible: \")\non toggle.down {\n visible = !visible\n}\non move.down {\n position = position + 40\n if position > 560 {\n position = 80\n }\n}\n",
+ "source_sha256": "3de97fab9d316c17ba6a0c82a16cccd3baafc6816b879bf9b0a174210c8a1915",
+ "prepared_sha256": "fc96ee38b256117a7bfe2859b7126e22262c19e96047a8d01fbe30d4d1a28764",
+ "native_binary_sha256": "d446eed2ec2e8939da30c3cb0b6c968e811f725081f2b08beeb555b98ee83652",
+ "native_binary_bytes": 213768
+ },
+ {
+ "name": "charge",
+ "commands": 104,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# All holding, charging, gravity, jumping, and floor behavior is authored here.\nstate holding = 0\nstate charge = 0\nstate y = 240\nstate velocity = 0\ninput jump = Space\nbox floor(40, 261, 560, 4, \"#356548\")\ncircle actor(180, y, 20, \"#c78638\")\nbox meter(80, 80, charge * 200, 12, \"#c78638\")\ntext amount(80, 60, charge, \"Charge: \")\non jump.down {\n if y >= 240 {\n holding = 1\n charge = 0\n }\n}\non jump.up {\n if holding {\n velocity = -(160 + charge * 300)\n holding = 0\n charge = 0\n }\n}\non frame {\n if holding {\n charge = clamp(charge + dt, 0, 1)\n }\n velocity = velocity + 600 * dt\n y = y + velocity * dt\n if y >= 240 {\n y = 240\n velocity = 0\n }\n}\n",
+ "source_sha256": "8d69d9fd51d44ab754d560eb7ff5102ea8a34540753cd7170b3bd800e4294f56",
+ "prepared_sha256": "7ba5fa094b0bc772202bb44a62b3c8377c4f3924db246ec429c2faee4948b09f",
+ "native_binary_sha256": "15505a1f178a40d010eb97d30c236fdcde3909282de2436aa4015b1b1f6b108d",
+ "native_binary_bytes": 213768
+ },
+ {
+ "name": "direct",
+ "commands": 3,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# 도형을 잡은 위치를 유지해 이동합니다. 취소하면 잡기 전 위치로 돌아갑니다.\nstate x = 80\nstate y = 160\nstate grab_x = 0\nstate grab_y = 0\nstate start_x = 80\nstate start_y = 160\nstate dragging = 0\n\ninput drag = pointer(tile)\ninput left = ArrowLeft\ninput right = ArrowRight\n\nbox left_area(20, 100, 280, 220, \"#233a31\")\nbox right_area(340, 100, 280, 220, \"#343b25\")\nbox tile(x, y, 56, 44, \"#c9f08b\")\ntext horizontal(30, 45, x, \"x: \")\ntext vertical(180, 45, y, \"y: \")\ntext region(350, 70, x >= 320, \"오른쪽 영역: \")\ntext active(30, 75, dragging, \"잡고 있음: \")\n\non drag.down {\n start_x = x\n start_y = y\n grab_x = event_x - x\n grab_y = event_y - y\n dragging = 1\n}\non drag.move {\n x = clamp(event_x - grab_x, 20, 564)\n y = clamp(event_y - grab_y, 100, 276)\n}\non drag.up {\n # 놓기는 마지막으로 표시한 위치를 확정합니다.\n dragging = 0\n}\non drag.cancel {\n x = start_x\n y = start_y\n dragging = 0\n}\non left.down {\n if dragging == 0 { x = max(20, x - 10) }\n}\non right.down {\n if dragging == 0 { x = min(564, x + 10) }\n}\n",
+ "source_sha256": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a",
+ "prepared_sha256": "27f0aa76eb3e12e289bcd6edf6c2ae72892a2dfe1ff094b619671f0583e2c8d3",
+ "native_binary_sha256": "3cd4df84f16a734758041c6dbfb46faffcf5836a3415e80bfbec5091a09e18d1",
+ "native_binary_bytes": 213768
+ },
+ {
+ "name": "routed",
+ "commands": 8,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# 하나의 포인터 입력과 네 반응을 모든 도형이 공유합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag = pointer {\n green_box: green\n blue_box: blue\n amber_box: amber\n}\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag.down use grab(drag.target)\non drag.move use move(drag.target)\non drag.up use drop(drag.target)\non drag.cancel use cancel(drag.target)\n\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n",
+ "source_sha256": "656e81577c250df704ba744b1ea6ec9c987ab3b8ee90c758062ac4beaf195650",
+ "prepared_sha256": "710503e7ffadf02b309c24a761caade4c5a093750d6801956cc698bfc263fc09",
+ "native_binary_sha256": "a7331278432f8c2f827ddac036dc8dab4299bffdd2db902f14420b1cf7c31435",
+ "native_binary_bytes": 213768
+ },
+ {
+ "name": "document",
+ "commands": 5,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# 웹 컨트롤이 요청한 분할 비율을 받습니다. 레이아웃과 문서는 브라우저가 소유합니다.\nstate ratio = 50\ninput split = value\ninput reset = KeyR\non split.change { ratio = clamp(event_value, 20, 80) }\non reset.down { ratio = 50 }\n",
+ "source_sha256": "057d2e665d72dd3fce7ac59d232a65e9e0e1a6ca1f8c1e1b36ae766e480367d1",
+ "prepared_sha256": "c74315c1b0067e167a4678b30505d1770b0753b601e8acee27f6d598ac7b9ffb",
+ "native_binary_sha256": "555d4f31801ac70ef93fa84c73da7a2409a60bc8743251abd7969e0a7c5c7730",
+ "native_binary_bytes": 213768
+ },
+ {
+ "name": "atomic-error",
+ "commands": 2,
+ "failed_events": 1,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "state x=7\nstate y=2\ninput n=value\non n.change {x=event_value; y=1/event_value}\ntext v(1,2,y,\"value: \")",
+ "source_sha256": "61a5478e7147ef281d617017910af36ad1bae3dabc97c03c8569c5d8b0e5fe19",
+ "prepared_sha256": "6a415d33780227158ebeae2a75c7520bf13bad3c0a6fef7ee82f5c323be226f0",
+ "native_binary_sha256": "c115e7e8ddbe0d622374203599042255560dcbfb5f2e31c378b5d441c23c0ce6",
+ "native_binary_bytes": 213776
+ },
+ {
+ "name": "blank",
+ "commands": 0,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "",
+ "source_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "prepared_sha256": "1c8f5ade56ee8ec931a62fd1f68ef3635b17297d3a54b20c954860c4a43b6155",
+ "native_binary_sha256": "023396eb5190c11ed724712ff5bcafc34ad09e4c43509e7e8445f998cfb57ce1",
+ "native_binary_bytes": 213744
+ },
+ {
+ "name": "literal-boundary",
+ "commands": 1,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "state x=-0\ninput n=value\non n.change {x=event_value}\ntext v(1,2,x,\"한글 \\\" \\\\ \\n\")",
+ "source_sha256": "7f480fa50bbddaf4e3a42bbd2132701e2091ef12b499e30c1bdda1dcd947dcc1",
+ "prepared_sha256": "e9a8b1542ecf6b008ad30188cb9e95d29638fab1c38d331c00bf706c40a06d3c",
+ "native_binary_sha256": "c972b112a6ea9b22921253f2ecc07df2b57f423022171f891ab4a0654e82d76e",
+ "native_binary_bytes": 213784
+ }
+ ],
+ "units": {
+ "native_instances": 3,
+ "checks": "independent-state-staging-inputs-errors-budget-snapshot-conflict-restore",
+ "scalar_instance_bytes": 20064,
+ "scalar_alignment": 8,
+ "app_controller_bytes": 928,
+ "action_bytes": 32,
+ "snapshot_bytes": 244,
+ "all_work_drained": true
+ },
+ "source_sha256": {
+ "prototypes/workbench/core.zig": "ea237848df0dbd2ce620c2874e64bc5ecbe569bf77f3737e9a4fe1e64fea40cc",
+ "prototypes/workbench/engine.zig": "555b9ff3143888921d90a475bcb7957c18dbf7101ee9e3d2853c01a562cbf3f5",
+ "prototypes/workbench/evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "prototypes/workbench/native.zig": "fe6c445cdfb5a4bc27ea9f540f3880c45b65c33098eec761d4398d9a7fc5c9a6",
+ "prototypes/workbench/manage.py": "02a6566306248cd4da952673d891bf73e6e8aea583b5dee8a20b96d28c61e025",
+ "prototypes/workbench/native/scalar.h": "b84a796bde2f45d59505cc0addb5f5afede0a9c4dc58d40a38dd37a8ac68d1fd",
+ "prototypes/workbench/native/plan.h": "2e9bd7e3c2ecc52f79c8938d09d2a696174c44a245e4bf52b70be2ed02cbbb57",
+ "prototypes/workbench/native/plan.c": "f0bdf8466097cfad54c197120aace8acd5f430170931d575e26807b760ce2173",
+ "prototypes/workbench/native/app.h": "0ea9c9dc18e3d9c34763198c6efbaa3219c6f91764b24f00591d958ca0678ce2",
+ "prototypes/workbench/native/app.c": "a8c0547ca789f498e453f8b8b982d1e06a874f34e72d905d86923d0e573eff66",
+ "prototypes/workbench/native/main.c": "dd28168741e79ffaa73cf74e60b974a1ca84b0473a43aafe089019f61e732aab",
+ "prototypes/workbench/native/prepare.mjs": "3989d4e545255fccf9d846afb79246e874f4cefb03eecef9ee1e2b1ce8fd257e",
+ "prototypes/workbench/native/test.c": "485b97a31f7b0b2fd16000a4c09e3eebfc67045724520d7ba48ffdcb105a8536",
+ "prototypes/workbench/native/check.mjs": "79a1131cedfae67b30122818a29e596c5f5f0eda79c18e771566dc122621006e",
+ "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1",
+ "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54",
+ "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35",
+ "prototypes/workbench/web/core.wasm": "8fe2f501cb9639fea0817aa57e0b7292ca6779cda4ac156b7c6b3df31d7921e9",
+ "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2",
+ "prototypes/connections/runtime.h": "ff03c0be92dcc482247a58a0be54d2dc5c270325b51ceeed37afc2ffa5480b1f",
+ "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04",
+ "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc",
+ "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421",
+ "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d",
+ "prototypes/connections/store.c": "44702e9414c26d1ee336b2e36e6713560013c2c8d0091a8900042e2468f88015",
+ "prototypes/connections/store.h": "e3cf5f169be02e40f1274105046f29a63a46cd153beaf204d33d6100b9c869b8",
+ "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f",
+ "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6"
+ },
+ "scalar_archive_sha256": "b45932025312c12eea6cd1083c43ed57f3cf3dbbb752c47adba634af8e21c5c2",
+ "scalar_archive_size": " text\t data\t bss\t dec\t hex\tfilename\n 33020\t 0\t 0\t 33020\t 80fc\tprototypes/workbench/build/cache/tmp/3cadbbd6974d5d26/libpure-scalar_zcu.o (ex /home/eunhhu/work/pure/prototypes/workbench/build/native/libpure-scalar.a)\n",
+ "scalar_archive_undefined": "\nprototypes/workbench/build/cache/tmp/3cadbbd6974d5d26/libpure-scalar_zcu.o:\n U memcpy\n U memset\n"
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sanitized.json b/prototypes/workbench/results/2026-09-23-native-instances/sanitized.json
new file mode 100644
index 0000000..b8acea5
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sanitized.json
@@ -0,0 +1,159 @@
+{
+ "kind": "native-instance-and-authored-behavior-correctness-not-performance",
+ "sanitized_c_host": true,
+ "zig_mode": "ReleaseSafe; Zig object is not ASan-instrumented",
+ "compiler": "cc (Debian 14.2.0-19) 14.2.0",
+ "cases": [
+ {
+ "name": "counter",
+ "commands": 5,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# Space or the tap control increments a state that is displayed and drawn.\nstate count = 0\ninput tap = Space\ntext label(80, 90, count, \"Count: \")\nbox tile(80, 150, 40 + min(count, 20) * 8, 60, \"#356548\")\non tap.down {\n count = count + 1\n}\n",
+ "source_sha256": "53cb3a93ff0f26a3d208a5876bfff9ecb1c39e9a01acc101566727733ba166a8",
+ "prepared_sha256": "eada952dee9afd7071370215364b2a6717a28f03e0513cdd0cae4695939ec52b",
+ "native_binary_sha256": "7c46b5e4d087aa82625b1f55ce934ae8fcb146b1d01e58926c31ff395d826827",
+ "native_binary_bytes": 591912
+ },
+ {
+ "name": "toggle",
+ "commands": 28,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# Space toggles the circle. Right arrow moves the tile.\nstate visible = 1\nstate position = 120\ninput toggle = Space\ninput move = ArrowRight\ncircle marker(position, 140, visible * 24, \"#c78638\")\nbox tile(position - 24, 190, 48, 48, \"#356548\")\ntext value(80, 80, visible, \"Visible: \")\non toggle.down {\n visible = !visible\n}\non move.down {\n position = position + 40\n if position > 560 {\n position = 80\n }\n}\n",
+ "source_sha256": "3de97fab9d316c17ba6a0c82a16cccd3baafc6816b879bf9b0a174210c8a1915",
+ "prepared_sha256": "fc96ee38b256117a7bfe2859b7126e22262c19e96047a8d01fbe30d4d1a28764",
+ "native_binary_sha256": "e104a514b32150dccc3acd5754efee82220042a55429a5b66dc413e0c24b24b5",
+ "native_binary_bytes": 592296
+ },
+ {
+ "name": "charge",
+ "commands": 104,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# All holding, charging, gravity, jumping, and floor behavior is authored here.\nstate holding = 0\nstate charge = 0\nstate y = 240\nstate velocity = 0\ninput jump = Space\nbox floor(40, 261, 560, 4, \"#356548\")\ncircle actor(180, y, 20, \"#c78638\")\nbox meter(80, 80, charge * 200, 12, \"#c78638\")\ntext amount(80, 60, charge, \"Charge: \")\non jump.down {\n if y >= 240 {\n holding = 1\n charge = 0\n }\n}\non jump.up {\n if holding {\n velocity = -(160 + charge * 300)\n holding = 0\n charge = 0\n }\n}\non frame {\n if holding {\n charge = clamp(charge + dt, 0, 1)\n }\n velocity = velocity + 600 * dt\n y = y + velocity * dt\n if y >= 240 {\n y = 240\n velocity = 0\n }\n}\n",
+ "source_sha256": "8d69d9fd51d44ab754d560eb7ff5102ea8a34540753cd7170b3bd800e4294f56",
+ "prepared_sha256": "7ba5fa094b0bc772202bb44a62b3c8377c4f3924db246ec429c2faee4948b09f",
+ "native_binary_sha256": "298493b0178531dda9f6b3eed9174cae5a5f1cef44bbe90edb6b232e4b8b92df",
+ "native_binary_bytes": 592360
+ },
+ {
+ "name": "direct",
+ "commands": 3,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# 도형을 잡은 위치를 유지해 이동합니다. 취소하면 잡기 전 위치로 돌아갑니다.\nstate x = 80\nstate y = 160\nstate grab_x = 0\nstate grab_y = 0\nstate start_x = 80\nstate start_y = 160\nstate dragging = 0\n\ninput drag = pointer(tile)\ninput left = ArrowLeft\ninput right = ArrowRight\n\nbox left_area(20, 100, 280, 220, \"#233a31\")\nbox right_area(340, 100, 280, 220, \"#343b25\")\nbox tile(x, y, 56, 44, \"#c9f08b\")\ntext horizontal(30, 45, x, \"x: \")\ntext vertical(180, 45, y, \"y: \")\ntext region(350, 70, x >= 320, \"오른쪽 영역: \")\ntext active(30, 75, dragging, \"잡고 있음: \")\n\non drag.down {\n start_x = x\n start_y = y\n grab_x = event_x - x\n grab_y = event_y - y\n dragging = 1\n}\non drag.move {\n x = clamp(event_x - grab_x, 20, 564)\n y = clamp(event_y - grab_y, 100, 276)\n}\non drag.up {\n # 놓기는 마지막으로 표시한 위치를 확정합니다.\n dragging = 0\n}\non drag.cancel {\n x = start_x\n y = start_y\n dragging = 0\n}\non left.down {\n if dragging == 0 { x = max(20, x - 10) }\n}\non right.down {\n if dragging == 0 { x = min(564, x + 10) }\n}\n",
+ "source_sha256": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a",
+ "prepared_sha256": "27f0aa76eb3e12e289bcd6edf6c2ae72892a2dfe1ff094b619671f0583e2c8d3",
+ "native_binary_sha256": "cda176e405e42290f3d8e4e220c42d382271235ba4ded1c814e9d9aac6b24bff",
+ "native_binary_bytes": 593128
+ },
+ {
+ "name": "routed",
+ "commands": 8,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# 하나의 포인터 입력과 네 반응을 모든 도형이 공유합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag = pointer {\n green_box: green\n blue_box: blue\n amber_box: amber\n}\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag.down use grab(drag.target)\non drag.move use move(drag.target)\non drag.up use drop(drag.target)\non drag.cancel use cancel(drag.target)\n\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n",
+ "source_sha256": "656e81577c250df704ba744b1ea6ec9c987ab3b8ee90c758062ac4beaf195650",
+ "prepared_sha256": "710503e7ffadf02b309c24a761caade4c5a093750d6801956cc698bfc263fc09",
+ "native_binary_sha256": "e589c682ddb0bbaa7847ffa8964895dd5dca28bcb2e4356d206052d4d975fdd4",
+ "native_binary_bytes": 594280
+ },
+ {
+ "name": "document",
+ "commands": 5,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# 웹 컨트롤이 요청한 분할 비율을 받습니다. 레이아웃과 문서는 브라우저가 소유합니다.\nstate ratio = 50\ninput split = value\ninput reset = KeyR\non split.change { ratio = clamp(event_value, 20, 80) }\non reset.down { ratio = 50 }\n",
+ "source_sha256": "057d2e665d72dd3fce7ac59d232a65e9e0e1a6ca1f8c1e1b36ae766e480367d1",
+ "prepared_sha256": "c74315c1b0067e167a4678b30505d1770b0753b601e8acee27f6d598ac7b9ffb",
+ "native_binary_sha256": "5aee2ce71e864103c989ec32e14fc5dc6f6db1e43c9d55f8ee55592f2aa784b1",
+ "native_binary_bytes": 591592
+ },
+ {
+ "name": "atomic-error",
+ "commands": 2,
+ "failed_events": 1,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "state x=7\nstate y=2\ninput n=value\non n.change {x=event_value; y=1/event_value}\ntext v(1,2,y,\"value: \")",
+ "source_sha256": "61a5478e7147ef281d617017910af36ad1bae3dabc97c03c8569c5d8b0e5fe19",
+ "prepared_sha256": "6a415d33780227158ebeae2a75c7520bf13bad3c0a6fef7ee82f5c323be226f0",
+ "native_binary_sha256": "4e6a1f92924add1fb99b5f1db8ca9f49917dac36498a198ebcb73075df92e7bf",
+ "native_binary_bytes": 591728
+ },
+ {
+ "name": "blank",
+ "commands": 0,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "",
+ "source_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "prepared_sha256": "1c8f5ade56ee8ec931a62fd1f68ef3635b17297d3a54b20c954860c4a43b6155",
+ "native_binary_sha256": "e0a01e7ba494e4840216bc0801a0db8f4bed7d25503f5117f1a4c134ed6c5251",
+ "native_binary_bytes": 591264
+ },
+ {
+ "name": "literal-boundary",
+ "commands": 1,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "state x=-0\ninput n=value\non n.change {x=event_value}\ntext v(1,2,x,\"한글 \\\" \\\\ \\n\")",
+ "source_sha256": "7f480fa50bbddaf4e3a42bbd2132701e2091ef12b499e30c1bdda1dcd947dcc1",
+ "prepared_sha256": "e9a8b1542ecf6b008ad30188cb9e95d29638fab1c38d331c00bf706c40a06d3c",
+ "native_binary_sha256": "ff39ffa2950c2135f3054aa9592c4ce46242c92e3f76f39cadc8d22134278cf7",
+ "native_binary_bytes": 591672
+ }
+ ],
+ "units": {
+ "native_instances": 3,
+ "checks": "independent-state-staging-inputs-errors-budget-snapshot-conflict-restore",
+ "scalar_instance_bytes": 20064,
+ "scalar_alignment": 8,
+ "app_controller_bytes": 928,
+ "action_bytes": 32,
+ "snapshot_bytes": 244,
+ "all_work_drained": true
+ },
+ "source_sha256": {
+ "prototypes/workbench/core.zig": "ea237848df0dbd2ce620c2874e64bc5ecbe569bf77f3737e9a4fe1e64fea40cc",
+ "prototypes/workbench/engine.zig": "555b9ff3143888921d90a475bcb7957c18dbf7101ee9e3d2853c01a562cbf3f5",
+ "prototypes/workbench/evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "prototypes/workbench/native.zig": "fe6c445cdfb5a4bc27ea9f540f3880c45b65c33098eec761d4398d9a7fc5c9a6",
+ "prototypes/workbench/manage.py": "02a6566306248cd4da952673d891bf73e6e8aea583b5dee8a20b96d28c61e025",
+ "prototypes/workbench/native/scalar.h": "b84a796bde2f45d59505cc0addb5f5afede0a9c4dc58d40a38dd37a8ac68d1fd",
+ "prototypes/workbench/native/plan.h": "2e9bd7e3c2ecc52f79c8938d09d2a696174c44a245e4bf52b70be2ed02cbbb57",
+ "prototypes/workbench/native/plan.c": "f0bdf8466097cfad54c197120aace8acd5f430170931d575e26807b760ce2173",
+ "prototypes/workbench/native/app.h": "0ea9c9dc18e3d9c34763198c6efbaa3219c6f91764b24f00591d958ca0678ce2",
+ "prototypes/workbench/native/app.c": "a8c0547ca789f498e453f8b8b982d1e06a874f34e72d905d86923d0e573eff66",
+ "prototypes/workbench/native/main.c": "dd28168741e79ffaa73cf74e60b974a1ca84b0473a43aafe089019f61e732aab",
+ "prototypes/workbench/native/prepare.mjs": "3989d4e545255fccf9d846afb79246e874f4cefb03eecef9ee1e2b1ce8fd257e",
+ "prototypes/workbench/native/test.c": "485b97a31f7b0b2fd16000a4c09e3eebfc67045724520d7ba48ffdcb105a8536",
+ "prototypes/workbench/native/check.mjs": "79a1131cedfae67b30122818a29e596c5f5f0eda79c18e771566dc122621006e",
+ "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1",
+ "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54",
+ "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35",
+ "prototypes/workbench/web/core.wasm": "8fe2f501cb9639fea0817aa57e0b7292ca6779cda4ac156b7c6b3df31d7921e9",
+ "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2",
+ "prototypes/connections/runtime.h": "ff03c0be92dcc482247a58a0be54d2dc5c270325b51ceeed37afc2ffa5480b1f",
+ "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04",
+ "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc",
+ "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421",
+ "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d",
+ "prototypes/connections/store.c": "44702e9414c26d1ee336b2e36e6713560013c2c8d0091a8900042e2468f88015",
+ "prototypes/connections/store.h": "e3cf5f169be02e40f1274105046f29a63a46cd153beaf204d33d6100b9c869b8",
+ "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f",
+ "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6"
+ },
+ "scalar_archive_sha256": "b45932025312c12eea6cd1083c43ed57f3cf3dbbb752c47adba634af8e21c5c2",
+ "scalar_archive_size": " text\t data\t bss\t dec\t hex\tfilename\n 33020\t 0\t 0\t 33020\t 80fc\tprototypes/workbench/build/cache/tmp/3cadbbd6974d5d26/libpure-scalar_zcu.o (ex /home/eunhhu/work/pure/prototypes/workbench/build/native/libpure-scalar.a)\n",
+ "scalar_archive_undefined": "\nprototypes/workbench/build/cache/tmp/3cadbbd6974d5d26/libpure-scalar_zcu.o:\n U memcpy\n U memset\n"
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/evaluate.c b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/evaluate.c
new file mode 100644
index 0000000..88ffae7
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/evaluate.c
@@ -0,0 +1,53 @@
+#include "program.h"
+#include <stdint.h>
+
+bool pure_region_span(PureRegion region, PureSpan source, PureSpan *out) {
+ *out = (PureSpan){0};
+ if (region.offset > source.size) return false;
+ size_t length = region.to_end ? source.size - region.offset : region.length;
+ if (length > source.size - region.offset) return false;
+ *out = (PureSpan){length ? source.data + region.offset : NULL, length};
+ return true;
+}
+
+static bool index_read(PureIndex index, PureSpan source, size_t *out) {
+ if (!index.from_u32le) { *out = index.value; return true; }
+ if (index.value > source.size || 4 > source.size - index.value) return false;
+ const unsigned char *bytes = source.data + index.value;
+ *out = (size_t)bytes[0] | (size_t)bytes[1] << 8 | (size_t)bytes[2] << 16 | (size_t)bytes[3] << 24;
+ return true;
+}
+
+bool pure_program_splice(const PureProgram *p, PureSpan source, size_t *offset, size_t *remove) {
+ return p->splice && index_read(p->splice_offset, source, offset) &&
+ index_read(p->splice_remove, source, remove);
+}
+
+static bool resolve(PureRegion region, size_t step, PureSpan source,
+ const PureSpan *values, size_t count, PureSpan *out) {
+ if (!step) return pure_region_span(region, source, out);
+ if (step > count) return false;
+ *out = values[step - 1];
+ return true;
+}
+
+bool pure_program_input(const PureProgram *p, PureSpan source, PureSpan *write, PureSpan *reply) {
+ *write = (PureSpan){0};
+ *reply = (PureSpan){0};
+ if (source.size < p->input_required || p->input_steps > PURE_DYNAMIC_LIMIT) return false;
+ PureSpan values[PURE_DYNAMIC_LIMIT];
+ for (size_t i = 0; i < p->input_steps; ++i) {
+ const PureRangeStep *step = &p->steps[i];
+ PureSpan parent;
+ size_t offset, length;
+ if (!resolve(step->source, step->source_step, source, values, i, &parent) ||
+ !index_read(step->offset, source, &offset) || !index_read(step->length, source, &length) ||
+ !pure_region_span((PureRegion){.offset = offset, .length = length}, parent, &values[i])) return false;
+ }
+ PureSpan stored, returned = {0};
+ if (!resolve(p->write, p->write_step, source, values, p->input_steps, &stored) ||
+ (p->reply_input && !resolve(p->reply, p->reply_step, source, values, p->input_steps, &returned))) return false;
+ *write = stored;
+ *reply = returned;
+ return true;
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/flow.h b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/flow.h
new file mode 100644
index 0000000..6e27c58
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/flow.h
@@ -0,0 +1,25 @@
+#ifndef PURE_FLOW_H
+#define PURE_FLOW_H
+#include <stdbool.h>
+#include <stddef.h>
+
+typedef struct { const unsigned char *data; size_t size; } PureSpan;
+typedef void (*PureRelease)(void *owner);
+
+/* A borrowed body stays alive until every byte is consumed or the transfer is
+ * aborted. A successful send() consumes bytes; peer receipt is a separate fact.
+ * Single owner thread; callbacks must not reenter or reuse this transfer. */
+typedef struct {
+ PureSpan spans[2];
+ size_t part, offset;
+ void *owner;
+ PureRelease release;
+ bool active;
+} PureTransfer;
+
+void pure_transfer_init(PureTransfer *, PureSpan header, PureSpan body,
+ void *owner, PureRelease);
+PureSpan pure_transfer_next(const PureTransfer *, size_t limit);
+bool pure_transfer_consume(PureTransfer *, size_t bytes);
+void pure_transfer_abort(PureTransfer *);
+#endif
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/program.h b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/program.h
new file mode 100644
index 0000000..c102eb1
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/program.h
@@ -0,0 +1,44 @@
+#ifndef PURE_PROGRAM_H
+#define PURE_PROGRAM_H
+#include "flow.h"
+
+#define PURE_PROGRAM_LIMIT 16384u
+#define PURE_DYNAMIC_LIMIT 8u
+typedef struct {
+ size_t offset, length;
+ bool to_end;
+} PureRegion;
+
+typedef struct { size_t value; bool from_u32le; } PureIndex;
+typedef struct {
+ PureRegion source;
+ size_t source_step; /* 0: source region; otherwise a preceding step + 1. */
+ PureIndex offset, length;
+} PureRangeStep;
+
+/* Prepared connections, not an instruction stream. All view definitions are
+ * checked at preparation; their root-length requirements survive elimination.
+ * Input views are evaluated before the write effect. A read still opens and
+ * validates the object even if its response has no body. */
+typedef struct {
+ PureRegion read, write, reply;
+ size_t read_required, input_required;
+ size_t view_definitions, range_definitions;
+ size_t write_step, reply_step, input_steps;
+ PureRangeStep steps[PURE_DYNAMIC_LIMIT];
+ bool reply_input;
+ bool splice;
+ PureIndex splice_offset, splice_remove;
+} PureProgram;
+
+bool pure_program_parse(const char *source, size_t length, PureProgram *out,
+ char *error, size_t error_size);
+bool pure_program_load(const char *path, PureProgram *out, char *error, size_t error_size);
+bool pure_region_span(PureRegion region, PureSpan source, PureSpan *out);
+/* Validates all input views, including unused dynamic views, before any effect.
+ * Returns borrowed selections; their lifetime remains that of source. */
+bool pure_program_input(const PureProgram *, PureSpan source, PureSpan *write, PureSpan *reply);
+/* Byte positions in the stored value. Bounds against the current version are
+ * checked by the conditional storage effect under the writer lock. */
+bool pure_program_splice(const PureProgram *, PureSpan source, size_t *offset, size_t *remove);
+#endif
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/result.h b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/result.h
new file mode 100644
index 0000000..8abb0f2
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/result.h
@@ -0,0 +1,8 @@
+#ifndef PURE_RESULT_H
+#define PURE_RESULT_H
+typedef enum {
+ PURE_OK, PURE_NOT_FOUND, PURE_CONFLICT, PURE_BUSY, PURE_BUDGET,
+ PURE_LIMIT, PURE_STORE_ERROR, PURE_UNCERTAIN,
+ PURE_INVALID, PURE_DENIED, PURE_CLOSED, PURE_CANCELLED, PURE_ALLOCATION
+} PureResult;
+#endif
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/runtime.c b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/runtime.c
new file mode 100644
index 0000000..aa4ed05
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/runtime.c
@@ -0,0 +1,273 @@
+#include "runtime.h"
+#include <string.h>
+
+#define NONE SIZE_MAX
+enum { FREE, RECEIVING, READY, RUNNING, DONE };
+
+static PureWork *lookup(PureTicket ticket, bool require_held) {
+ PureRuntime *r = ticket.runtime;
+ if (!r || ticket.slot >= r->capacity || !ticket.generation) return NULL;
+ PureWork *w = &r->work[ticket.slot];
+ if (w->state == FREE || w->generation != ticket.generation ||
+ (require_held && !w->held)) return NULL;
+ return w;
+}
+
+static bool valid_key(const char *key) {
+ if (!key || !*key) return false;
+ size_t n = 0;
+ for (; n <= 64 && key[n]; ++n) {
+ unsigned char c = (unsigned char)key[n];
+ if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
+ (c >= '0' && c <= '9') || c == '-' || c == '_')) return false;
+ }
+ return n <= 64;
+}
+
+static void dispose(PureResource *resource) {
+ PureResource old = *resource;
+ *resource = (PureResource){0};
+ if (old.release) old.release(old.allocation, old.allocation_size);
+}
+
+static void payload_release(PureRuntime *r, PureWork *w);
+
+/* Iteratively retire a chain of forwarded values. An arbitrarily long chain
+ * must not turn completion into recursive stack growth. */
+static void retire(PureRuntime *r, PureWork *w) {
+ while (w && !w->held && !w->pins) {
+ PureTicket parent = w->parent;
+ bool owned = w->charged || w->resource.release || parent.runtime;
+ dispose(&w->resource);
+ r->retained -= w->charged;
+ if (owned) ++r->releases;
+ size_t slot = (size_t)(w - r->work);
+ *w = (PureWork){.next = r->free_head};
+ r->free_head = slot;
+ --r->active;
+ if (!parent.runtime) break;
+ r = parent.runtime;
+ w = lookup(parent, false);
+ /* Pins can only be created and removed by the owner-thread core. */
+ if (!w || !w->pins) break;
+ --w->pins;
+ }
+}
+
+static void payload_release(PureRuntime *r, PureWork *w) {
+ PureTicket parent = w->parent;
+ bool owned = w->charged || w->resource.release || parent.runtime;
+ dispose(&w->resource);
+ r->retained -= w->charged;
+ w->charged = 0;
+ w->parent = (PureTicket){0};
+ if (owned) ++r->releases;
+ if (parent.runtime) {
+ PureWork *source = lookup(parent, false);
+ if (source && source->pins) { --source->pins; retire(parent.runtime, source); }
+ }
+}
+
+bool pure_runtime_init(PureRuntime *r, PureWork *work, size_t capacity,
+ const PureProgram *program, PureHost host, size_t budget) {
+ if (!r || !work || !capacity || !program) return false;
+ *r = (PureRuntime){.program = program, .host = host, .work = work,
+ .capacity = capacity, .free_head = 0, .ready_head = NONE, .ready_tail = NONE,
+ .budget = budget, .accepting = true};
+ for (size_t i = 0; i < capacity; ++i)
+ work[i] = (PureWork){.next = i + 1 < capacity ? i + 1 : NONE};
+ return true;
+}
+
+bool pure_runtime_restart(PureRuntime *r, const PureProgram *p, PureHost host, size_t budget) {
+ if (!r || !p || r->running || r->active) return false;
+ r->program = p;
+ r->host = host;
+ r->budget = budget;
+ r->accepting = true;
+ return true;
+}
+
+void pure_runtime_stop(PureRuntime *r) { if (r && !r->running) r->accepting = false; }
+
+static PureResult admission(PureRuntime *r, PureInput input, const char *key,
+ int64_t expected, size_t bytes) {
+ if (!r || r->running || !valid_key(key) ||
+ (input != PURE_INPUT_READ && input != PURE_INPUT_WRITE) ||
+ (input == PURE_INPUT_READ ? bytes != 0 : expected < 0)) return PURE_INVALID;
+ if (!r->accepting) return PURE_CLOSED;
+ if (input == PURE_INPUT_READ ? !r->host.read :
+ (r->program->splice ? !r->host.splice : !r->host.write)) return PURE_DENIED;
+ if (r->free_head == NONE) return PURE_BUSY;
+ if (r->serial == UINT64_MAX) return PURE_LIMIT;
+ if (bytes > r->budget - r->retained) { ++r->budget_rejections; return PURE_BUDGET; }
+ return PURE_OK;
+}
+
+static PureTicket occupy(PureRuntime *r, PureInput input, const char *key,
+ int64_t expected, size_t bytes, PureResource resource) {
+ size_t slot = r->free_head;
+ PureWork *w = &r->work[slot];
+ r->free_head = w->next;
+ *w = (PureWork){.generation = ++r->serial, .next = NONE, .charged = bytes,
+ .state = RECEIVING, .held = true, .input = input, .expected = expected,
+ .resource = resource};
+ memcpy(w->key, key, strlen(key) + 1);
+ ++r->active;
+ ++r->accepted;
+ r->retained += bytes;
+ if (r->active > r->peak_active) r->peak_active = r->active;
+ if (r->retained > r->peak_retained) r->peak_retained = r->retained;
+ return (PureTicket){r, slot, w->generation};
+}
+
+bool pure_runtime_ready(PureTicket ticket) {
+ PureWork *w = lookup(ticket, true);
+ if (!w || ticket.runtime->running || w->state != RECEIVING) return false;
+ PureRuntime *r = ticket.runtime;
+ w->state = READY;
+ if (r->ready_tail == NONE) r->ready_head = ticket.slot;
+ else r->work[r->ready_tail].next = ticket.slot;
+ r->ready_tail = ticket.slot;
+ return true;
+}
+
+PureResult pure_runtime_accept(PureRuntime *r, PureInput input, const char *key,
+ int64_t expected, size_t size, PureTicket *out) {
+ if (!out) return PURE_INVALID;
+ PureResult status = admission(r, input, key, expected, size);
+ if (status != PURE_OK) return status;
+ PureResource resource = {0};
+ if (size) {
+ if (!r->host.allocate) return PURE_ALLOCATION;
+ r->running = true;
+ bool ok = r->host.allocate(r->host.context, size, &resource);
+ r->running = false;
+ if (!ok || !resource.body.data || resource.body.size != size || !resource.release) {
+ dispose(&resource);
+ return PURE_ALLOCATION;
+ }
+ }
+ *out = occupy(r, input, key, expected, size, resource);
+ if (input == PURE_INPUT_READ) pure_runtime_ready(*out);
+ return PURE_OK;
+}
+
+unsigned char *pure_runtime_ingress(PureTicket ticket, size_t *size) {
+ PureWork *w = lookup(ticket, true);
+ if (size) *size = 0;
+ if (!w || ticket.runtime->running || w->state != RECEIVING ||
+ w->input != PURE_INPUT_WRITE || w->parent.runtime) return NULL;
+ if (size) *size = w->resource.body.size;
+ return (unsigned char *)w->resource.body.data;
+}
+
+bool pure_runtime_step(PureRuntime *r) {
+ if (!r || r->running || r->ready_head == NONE) return false;
+ size_t slot = r->ready_head;
+ PureWork *w = &r->work[slot];
+ r->ready_head = w->next;
+ if (r->ready_head == NONE) r->ready_tail = NONE;
+ w->next = NONE;
+ w->state = RUNNING;
+ r->running = true;
+ PureOutcome outcome = {0};
+ if (w->input == PURE_INPUT_WRITE) {
+ PureSpan value;
+ size_t offset = 0, remove = 0;
+ if (!pure_program_input(r->program, w->resource.body, &value, &outcome.body))
+ outcome.status = PURE_LIMIT;
+ else if (r->program->splice) {
+ if (!pure_program_splice(r->program, w->resource.body, &offset, &remove))
+ outcome.status = PURE_LIMIT;
+ else outcome.status = r->host.splice(r->host.context, w->key, w->expected,
+ offset, remove, value, &outcome.revision);
+ }
+ else outcome.status = r->host.write(r->host.context, w->key, w->expected,
+ value, &outcome.revision);
+ if (outcome.status == PURE_OK) ++r->commits;
+ else if (outcome.status == PURE_CONFLICT) ++r->conflicts;
+ } else {
+ outcome.status = r->host.read(r->host.context, w->key, r->program->read,
+ r->program->read_required, r->budget - r->retained, &w->resource, &outcome.revision);
+ if (outcome.status == PURE_OK) {
+ if (w->resource.body.size > r->budget - r->retained) outcome.status = PURE_BUDGET;
+ else if (w->resource.body.size && (!w->resource.body.data || !w->resource.release))
+ outcome.status = PURE_STORE_ERROR;
+ else {
+ w->charged = w->resource.body.size;
+ r->retained += w->charged;
+ if (r->retained > r->peak_retained) r->peak_retained = r->retained;
+ outcome.body = w->resource.body;
+ ++r->gets;
+ }
+ }
+ if (outcome.status == PURE_BUDGET) ++r->budget_rejections;
+ }
+ if (outcome.status != PURE_OK || !outcome.body.size) {
+ outcome.body = (PureSpan){0};
+ payload_release(r, w);
+ }
+ r->borrowed_output_bytes += outcome.body.size;
+ w->outcome = outcome;
+ w->state = DONE;
+ r->running = false;
+ return true;
+}
+
+bool pure_runtime_result(PureTicket ticket, PureOutcome *out) {
+ PureWork *w = lookup(ticket, true);
+ if (!out || !w || ticket.runtime->running || w->state != DONE) return false;
+ *out = w->outcome;
+ return true;
+}
+
+PureResult pure_runtime_forward(PureTicket source, PureRuntime *r,
+ const char *key, int64_t expected, PureTicket *out) {
+ PureWork *parent = lookup(source, true);
+ if (!out || !parent || source.runtime->running || parent->state != DONE ||
+ parent->outcome.status != PURE_OK) return PURE_INVALID;
+ PureResult status = admission(r, PURE_INPUT_WRITE, key, expected, parent->outcome.body.size);
+ if (status != PURE_OK) return status;
+ if (parent->pins == SIZE_MAX) return PURE_LIMIT;
+ PureTicket ticket = occupy(r, PURE_INPUT_WRITE, key, expected, parent->outcome.body.size,
+ (PureResource){.body = parent->outcome.body});
+ ++parent->pins;
+ r->work[ticket.slot].parent = source;
+ pure_runtime_ready(ticket);
+ *out = ticket;
+ return PURE_OK;
+}
+
+static void unlink_ready(PureRuntime *r, size_t slot) {
+ size_t previous = NONE;
+ for (size_t at = r->ready_head; at != NONE; at = r->work[at].next) {
+ if (at == slot) {
+ if (previous == NONE) r->ready_head = r->work[at].next;
+ else r->work[previous].next = r->work[at].next;
+ if (r->ready_tail == at) r->ready_tail = previous;
+ r->work[at].next = NONE;
+ return;
+ }
+ previous = at;
+ }
+}
+
+bool pure_runtime_cancel(PureTicket ticket) {
+ PureWork *w = lookup(ticket, true);
+ if (!w || ticket.runtime->running || (w->state != RECEIVING && w->state != READY)) return false;
+ if (w->state == READY) unlink_ready(ticket.runtime, ticket.slot);
+ payload_release(ticket.runtime, w);
+ w->outcome = (PureOutcome){.status = PURE_CANCELLED};
+ w->state = DONE;
+ return true;
+}
+
+bool pure_runtime_release(PureTicket ticket) {
+ PureWork *w = lookup(ticket, true);
+ if (!w || ticket.runtime->running) return false;
+ if (w->state == READY) unlink_ready(ticket.runtime, ticket.slot);
+ w->held = false;
+ retire(ticket.runtime, w);
+ return true;
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/runtime.h b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/runtime.h
new file mode 100644
index 0000000..1bff082
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/runtime.h
@@ -0,0 +1,88 @@
+#ifndef PURE_RUNTIME_H
+#define PURE_RUNTIME_H
+#include "program.h"
+#include "result.h"
+#include <stdint.h>
+
+/* First execution profile: authored selection -> read/conditional write ->
+ * reply. No socket, file descriptor, allocator, parser or OS type in the core.
+ * All control calls and connected domains share one owner thread. Host effects
+ * are synchronous in this profile and must not reenter the runtime. */
+typedef struct PureRuntime PureRuntime;
+typedef struct PureWork PureWork;
+typedef struct { PureRuntime *runtime; size_t slot; uint64_t generation; } PureTicket;
+typedef enum { PURE_INPUT_READ, PURE_INPUT_WRITE } PureInput;
+
+/* The host owns this resource until it successfully returns it to the core.
+ * release must end all access and free/unmap allocation exactly once. */
+typedef struct {
+ PureSpan body;
+ void *allocation;
+ size_t allocation_size;
+ void (*release)(void *allocation, size_t size);
+} PureResource;
+typedef struct {
+ void *context;
+ bool (*allocate)(void *, size_t, PureResource *);
+ PureResult (*read)(void *, const char *, PureRegion, size_t minimum,
+ size_t available, PureResource *, int64_t *revision);
+ PureResult (*write)(void *, const char *, int64_t expected,
+ PureSpan, int64_t *revision);
+ PureResult (*splice)(void *, const char *, int64_t expected, size_t offset,
+ size_t remove, PureSpan insert, int64_t *revision);
+} PureHost;
+typedef struct { PureResult status; int64_t revision; PureSpan body; } PureOutcome;
+
+/* Caller-provided work slots: no background thread or per-domain heap. These
+ * records are implementation storage, not an ABI or a wire representation. */
+struct PureWork {
+ uint64_t generation;
+ size_t next, charged, pins;
+ unsigned state;
+ bool held;
+ PureInput input;
+ int64_t expected;
+ char key[65];
+ PureResource resource;
+ PureTicket parent;
+ PureOutcome outcome;
+};
+struct PureRuntime {
+ const PureProgram *program;
+ PureHost host;
+ PureWork *work;
+ size_t capacity, free_head, ready_head, ready_tail;
+ size_t budget, retained, peak_retained, active, peak_active;
+ uint64_t serial, accepted, commits, gets, conflicts, budget_rejections;
+ uint64_t releases, borrowed_output_bytes;
+ bool accepting, running;
+};
+
+/* Initialize fresh storage once. Use restart, not init, to reuse a domain;
+ * the monotonic ticket sequence then survives restart. program is immutable
+ * and remains alive until all work has drained. */
+bool pure_runtime_init(PureRuntime *, PureWork *, size_t capacity,
+ const PureProgram *, PureHost, size_t budget);
+bool pure_runtime_restart(PureRuntime *, const PureProgram *, PureHost, size_t budget);
+void pure_runtime_stop(PureRuntime *); /* stop admission, allow queued work to drain */
+
+/* READ queues immediately. WRITE reserves its slot, budget and host allocation
+ * before returning. Fill ingress then publish once with ready. A partial input
+ * released without ready never executes. On error, out is unchanged. */
+PureResult pure_runtime_accept(PureRuntime *, PureInput, const char *key,
+ int64_t expected, size_t input_size, PureTicket *out);
+unsigned char *pure_runtime_ingress(PureTicket, size_t *size);
+bool pure_runtime_ready(PureTicket);
+bool pure_runtime_step(PureRuntime *); /* execute at most one ready job */
+bool pure_runtime_result(PureTicket, PureOutcome *out);
+
+/* Pin a completed reply and queue it as another domain's WRITE input, with no
+ * allocation, serialization or payload copy. Source may be released as soon
+ * as this succeeds; the core retains its backing until the consumer ends.
+ * Each domain charges its own retained input bytes as admission credits.
+ * Credits across domains are NOT distinct physical memory and must not be summed. */
+PureResult pure_runtime_forward(PureTicket source, PureRuntime *destination,
+ const char *key, int64_t expected, PureTicket *out);
+bool pure_runtime_cancel(PureTicket); /* only before execution; keeps a completion */
+bool pure_runtime_release(PureTicket); /* invalidates caller's ticket, preserves pins */
+#endif
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/store-port.c b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/store-port.c
new file mode 100644
index 0000000..9860308
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/store-port.c
@@ -0,0 +1,44 @@
+#include "store-port.h"
+#include "store.h"
+#include <stdlib.h>
+#include <sys/mman.h>
+
+static void free_memory(void *data, size_t size) { (void)size; free(data); }
+static void unmap_memory(void *data, size_t size) { if (size) munmap(data, size); }
+
+static bool allocate(void *context, size_t size, PureResource *out) {
+ (void)context;
+ void *data = malloc(size);
+ if (!data) return false;
+ *out = (PureResource){.body = {data, size}, .allocation = data,
+ .allocation_size = size, .release = free_memory};
+ return true;
+}
+
+static PureResult read_value(void *context, const char *key, PureRegion region,
+ size_t minimum, size_t available, PureResource *out, int64_t *revision) {
+ PureStoredView view = {0};
+ PureResult result = pure_store_select(context, key, region.offset, region.length,
+ region.to_end, minimum, available, &view);
+ if (result == PURE_OK) {
+ *out = (PureResource){.body = view.body, .allocation = view.mapping,
+ .allocation_size = view.mapped_size, .release = view.mapped_size ? unmap_memory : NULL};
+ *revision = view.revision;
+ }
+ return result;
+}
+
+static PureResult write_value(void *context, const char *key, int64_t expected,
+ PureSpan body, int64_t *revision) {
+ return pure_store_put(context, key, expected, body, revision);
+}
+
+static PureResult splice_value(void *context, const char *key, int64_t expected,
+ size_t offset, size_t remove, PureSpan insert, int64_t *revision) {
+ return pure_store_splice(context, key, expected, offset, remove, insert, revision);
+}
+
+PureHost pure_store_host(const char *path) {
+ return (PureHost){.context = (void *)path, .allocate = allocate,
+ .read = read_value, .write = write_value, .splice = splice_value};
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/store-port.h b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/store-port.h
new file mode 100644
index 0000000..4c0d49d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/store-port.h
@@ -0,0 +1,6 @@
+#ifndef PURE_STORE_PORT_H
+#define PURE_STORE_PORT_H
+#include "runtime.h"
+/* The path remains alive for the domain's complete lifetime, including pins. */
+PureHost pure_store_host(const char *path);
+#endif
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/store.c b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/store.c
new file mode 100644
index 0000000..4c8bdc4
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/store.c
@@ -0,0 +1,258 @@
+#define _POSIX_C_SOURCE 200809L
+#include "store.h"
+#include <errno.h>
+#include <fcntl.h>
+#include <inttypes.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/mman.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+/* Immutable extent envelope: marker, little-endian u64 revision and payload
+ * length, then unchanged caller bytes. Single-threaded control API; cooperating
+ * processes serialize publication using .writer.lock. */
+enum { HEADER_BYTES = 24 };
+static const unsigned char magic[8] = {'P','U','R','E','B','0','0','1'};
+static uint64_t temporary_sequence;
+
+static bool fault(PureStoreStage stage) {
+#ifdef PURE_STORE_TEST
+ if (pure_store_test_fault(stage)) { errno = EIO; return true; }
+#else
+ (void)stage;
+#endif
+ return false;
+}
+
+static bool file_name(const char *key, char name[72]) {
+ if (!key) return false;
+ size_t length = strlen(key);
+ if (!length || length > 64) return false;
+ for (size_t i = 0; i < length; ++i) {
+ char b = key[i];
+ if (!((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') ||
+ (b >= '0' && b <= '9') || b == '-' || b == '_')) return false;
+ }
+ snprintf(name, 72, "obj-%s", key);
+ return true;
+}
+
+static uint64_t decode64(const unsigned char *bytes) {
+ uint64_t value = 0;
+ for (unsigned i = 0; i < 8; ++i) value |= (uint64_t)bytes[i] << (8 * i);
+ return value;
+}
+
+static void encode64(unsigned char *bytes, uint64_t value) {
+ for (unsigned i = 0; i < 8; ++i) bytes[i] = (unsigned char)(value >> (8 * i));
+}
+
+static bool sync_fd(int fd) {
+ int rc;
+ do { rc = fsync(fd); } while (rc && errno == EINTR);
+ return rc == 0;
+}
+
+static bool write_bytes(int fd, const unsigned char *bytes, size_t length) {
+ while (length) {
+ ssize_t n = write(fd, bytes, length);
+ if (n < 0 && errno == EINTR) continue;
+ if (n <= 0) return false;
+ bytes += (size_t)n;
+ length -= (size_t)n;
+ }
+ return true;
+}
+
+static bool read_header(int fd, int64_t *revision, size_t *length) {
+ struct stat info;
+ if (fstat(fd, &info) || !S_ISREG(info.st_mode)) return false;
+ unsigned char header[HEADER_BYTES];
+ size_t used = 0;
+ while (used < sizeof(header)) {
+ ssize_t n = pread(fd, header + used, sizeof(header) - used, (off_t)used);
+ if (n < 0 && errno == EINTR) continue;
+ if (n <= 0) return false;
+ used += (size_t)n;
+ }
+ uint64_t version = decode64(header + 8), size = decode64(header + 16);
+ if (memcmp(header, magic, sizeof(magic)) || !version || version > INT64_MAX ||
+ size > PURE_OBJECT_LIMIT || info.st_size != (off_t)(HEADER_BYTES + size)) return false;
+ *revision = (int64_t)version;
+ *length = (size_t)size;
+ return true;
+}
+
+PureResult pure_store_init(const char *path) {
+ if (!path || !*path) return PURE_STORE_ERROR;
+ bool created = mkdir(path, 0700) == 0;
+ if (!created && errno != EEXIST) return PURE_STORE_ERROR;
+ int directory = open(path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
+ if (directory < 0) return PURE_STORE_ERROR; /* Never reinterpret an old DB file. */
+ int lock = openat(directory, ".writer.lock", O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC, 0600);
+ struct stat info;
+ bool ok = lock >= 0 && fstat(lock, &info) == 0 && S_ISREG(info.st_mode) && sync_fd(lock);
+ if (lock >= 0) close(lock);
+ if (ok) ok = sync_fd(directory);
+ close(directory);
+ if (!ok) return PURE_STORE_ERROR;
+ if (created) {
+ char *parent = strdup(path);
+ if (!parent) return PURE_STORE_ERROR;
+ size_t length = strlen(parent);
+ while (length > 1 && parent[length - 1] == '/') parent[--length] = 0;
+ char *slash = strrchr(parent, '/');
+ if (!slash) strcpy(parent, ".");
+ else if (slash == parent) parent[1] = 0;
+ else *slash = 0;
+ int fd = open(parent, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+ free(parent);
+ ok = fd >= 0 && sync_fd(fd);
+ if (fd >= 0) close(fd);
+ }
+ return ok ? PURE_OK : PURE_STORE_ERROR;
+}
+
+void pure_store_release(PureStoredView *view) {
+ if (view->mapped_size) munmap(view->mapping, view->mapped_size);
+ *view = (PureStoredView){0};
+}
+
+PureResult pure_store_select(const char *path, const char *key, size_t offset,
+ size_t length, bool whole, size_t minimum,
+ size_t available, PureStoredView *out) {
+ *out = (PureStoredView){0};
+ char name[72];
+ if (!file_name(key, name)) return PURE_STORE_ERROR;
+ int directory = open(path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
+ if (directory < 0) return PURE_STORE_ERROR;
+ int fd = openat(directory, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
+ int cause = errno;
+ close(directory);
+ if (fd < 0) return cause == ENOENT ? PURE_NOT_FOUND : PURE_STORE_ERROR;
+ int64_t revision;
+ size_t size;
+ PureResult result = PURE_STORE_ERROR;
+ if (!read_header(fd, &revision, &size)) goto finish;
+ if (size < minimum) { result = PURE_LIMIT; goto finish; }
+ if (offset > size) { result = PURE_LIMIT; goto finish; }
+ if (whole) length = size - offset;
+ if (offset > size || length > size - offset) { result = PURE_LIMIT; goto finish; }
+ if (length > available) { result = PURE_BUDGET; goto finish; }
+ if (length) {
+ long page = sysconf(_SC_PAGESIZE);
+ if (page <= 0) goto finish;
+ size_t start = HEADER_BYTES + offset;
+ size_t aligned = start - start % (size_t)page;
+ size_t prefix = start - aligned;
+ size_t mapped_size = prefix + length;
+ void *mapping = mmap(NULL, mapped_size, PROT_READ, MAP_PRIVATE, fd, (off_t)aligned);
+ if (mapping == MAP_FAILED) goto finish;
+ out->mapping = mapping;
+ out->mapped_size = mapped_size;
+ out->body = (PureSpan){(const unsigned char *)mapping + prefix, length};
+ }
+ out->revision = revision;
+ out->object_size = size;
+ result = PURE_OK;
+finish:
+ close(fd); /* The mapping retains this immutable file version after rename. */
+ return result;
+}
+
+PureResult pure_store_get(const char *path, const char *key, size_t available, PureStoredView *out) {
+ return pure_store_select(path, key, 0, 0, true, 0, available, out);
+}
+
+PureResult pure_store_get_range(const char *path, const char *key, size_t offset,
+ size_t length, size_t available, PureStoredView *out) {
+ return pure_store_select(path, key, offset, length, false, 0, available, out);
+}
+
+static bool copy_extent(int source, int destination, size_t offset, size_t length) {
+ unsigned char scratch[4096];
+ while (length) {
+ size_t wanted = length < sizeof(scratch) ? length : sizeof(scratch);
+ ssize_t n = pread(source, scratch, wanted, (off_t)(HEADER_BYTES + offset));
+ if (n < 0 && errno == EINTR) continue;
+ if (n <= 0 || !write_bytes(destination, scratch, (size_t)n)) return false;
+ offset += (size_t)n;
+ length -= (size_t)n;
+ }
+ return true;
+}
+
+static PureResult mutate(const char *path, const char *key, int64_t expected,
+ PureSpan body, bool splice, size_t offset, size_t remove, int64_t *revision) {
+ *revision = 0;
+ char name[72], temporary[96] = "";
+ if (!file_name(key, name) || (body.size && !body.data)) return PURE_STORE_ERROR;
+ if (body.size > PURE_OBJECT_LIMIT || expected < 0) return PURE_LIMIT;
+ int directory = open(path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
+ if (directory < 0) return PURE_STORE_ERROR;
+ int lock = openat(directory, ".writer.lock", O_RDWR | O_NOFOLLOW | O_CLOEXEC);
+ int fd = -1, source = -1;
+ size_t old_size = 0, result_size = body.size;
+ PureResult result = PURE_STORE_ERROR;
+ struct stat info;
+ if (lock < 0 || fstat(lock, &info) || !S_ISREG(info.st_mode)) goto finish;
+ struct flock hold = {.l_type = F_WRLCK, .l_whence = SEEK_SET, .l_start = 0, .l_len = 0};
+ if (fcntl(lock, F_SETLK, &hold)) {
+ if (errno == EACCES || errno == EAGAIN) result = PURE_BUSY;
+ goto finish;
+ }
+ source = openat(directory, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
+ if (source >= 0) {
+ if (!read_header(source, revision, &old_size)) goto finish;
+ } else if (errno != ENOENT) goto finish;
+ if (*revision != expected) { result = PURE_CONFLICT; goto finish; }
+ if (*revision == INT64_MAX) { result = PURE_LIMIT; goto finish; }
+ if (splice) {
+ if (offset > old_size || remove > old_size - offset ||
+ body.size > PURE_OBJECT_LIMIT - (old_size - remove)) { result = PURE_LIMIT; goto finish; }
+ result_size = old_size - remove + body.size;
+ }
+ for (unsigned attempt = 0; attempt < 64; ++attempt) {
+ if (temporary_sequence == UINT64_MAX) goto finish;
+ snprintf(temporary, sizeof(temporary), ".pending-%ld-%" PRIu64, (long)getpid(), ++temporary_sequence);
+ fd = openat(directory, temporary, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600);
+ if (fd >= 0) break;
+ temporary[0] = 0; /* Never remove an existing file on a name collision. */
+ if (errno != EEXIST) goto finish;
+ }
+ if (fd < 0) goto finish;
+ unsigned char header[HEADER_BYTES];
+ memcpy(header, magic, sizeof(magic));
+ encode64(header + 8, (uint64_t)*revision + 1);
+ encode64(header + 16, result_size);
+ if (!write_bytes(fd, header, sizeof(header)) || fault(PURE_AFTER_HEADER)) goto finish;
+ if (splice && !copy_extent(source, fd, 0, offset)) goto finish;
+ if (!write_bytes(fd, body.data, body.size)) goto finish;
+ if (splice && !copy_extent(source, fd, offset + remove, old_size - offset - remove)) goto finish;
+ if (fault(PURE_BEFORE_FILE_SYNC) || !sync_fd(fd)) goto finish;
+ int close_result = close(fd);
+ fd = -1;
+ if (close_result || fault(PURE_BEFORE_RENAME) || renameat(directory, temporary, directory, name)) goto finish;
+ temporary[0] = 0; /* Published: a later sync failure does not roll it back. */
+ ++*revision;
+ result = fault(PURE_BEFORE_DIR_SYNC) || !sync_fd(directory) ? PURE_UNCERTAIN : PURE_OK;
+finish:
+ if (fd >= 0) close(fd);
+ if (source >= 0) close(source);
+ if (*temporary) unlinkat(directory, temporary, 0);
+ if (lock >= 0) close(lock);
+ close(directory);
+ return result;
+}
+
+PureResult pure_store_put(const char *path, const char *key, int64_t expected,
+ PureSpan body, int64_t *revision) {
+ return mutate(path, key, expected, body, false, 0, 0, revision);
+}
+
+PureResult pure_store_splice(const char *path, const char *key, int64_t expected,
+ size_t offset, size_t remove, PureSpan insert, int64_t *revision) {
+ return mutate(path, key, expected, insert, true, offset, remove, revision);
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/store.h b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/store.h
new file mode 100644
index 0000000..99a3ef5
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/store.h
@@ -0,0 +1,44 @@
+#ifndef PURE_STORE_H
+#define PURE_STORE_H
+#include "flow.h"
+#include "result.h"
+#include <stdint.h>
+
+#define PURE_OBJECT_LIMIT ((2u * 6u + 32u) * 32768u + 8192u)
+typedef struct {
+ void *mapping;
+ size_t mapped_size, object_size;
+ PureSpan body;
+ int64_t revision;
+} PureStoredView;
+
+PureResult pure_store_init(const char *path);
+PureResult pure_store_get(const char *path, const char *key, size_t available,
+ PureStoredView *out);
+PureResult pure_store_get_range(const char *path, const char *key, size_t offset,
+ size_t length, size_t available, PureStoredView *out);
+/* Inspect once: guards and mapping refer to the same immutable file version.
+ * minimum preserves bounds checks from eliminated intermediate views.
+ * whole selects the suffix starting at offset; otherwise length is exact. */
+PureResult pure_store_select(const char *path, const char *key, size_t offset,
+ size_t length, bool whole, size_t minimum,
+ size_t available, PureStoredView *out);
+void pure_store_release(PureStoredView *);
+PureResult pure_store_put(const char *path, const char *key, int64_t expected,
+ PureSpan body, int64_t *revision);
+/* Atomic byte replacement: retain [0,offset), insert bytes, retain suffix after
+ * remove bytes. Check the expected revision and bounds under the same writer
+ * lock. expected=0 permits creating an empty base (offset=remove=0).
+ * Streams retained bytes through bounded scratch space; never assembles a
+ * whole new object in application memory. Still rewrites the immutable file. */
+PureResult pure_store_splice(const char *path, const char *key, int64_t expected,
+ size_t offset, size_t remove, PureSpan insert, int64_t *revision);
+
+/* Fault hooks exist only in the separately built unit-test binary. */
+typedef enum {
+ PURE_AFTER_HEADER, PURE_BEFORE_FILE_SYNC, PURE_BEFORE_RENAME, PURE_BEFORE_DIR_SYNC
+} PureStoreStage;
+#ifdef PURE_STORE_TEST
+int pure_store_test_fault(PureStoreStage stage);
+#endif
+#endif
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/baseline.test.mjs b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/baseline.test.mjs
new file mode 100644
index 0000000..c9ef2de
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/baseline.test.mjs
@@ -0,0 +1,78 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createChargeModel } from './web/baseline.mjs';
+
+test('reference begins with the authored states and matching scene', () => {
+ const model = createChargeModel();
+ assert.deepEqual(model.snapshot().states, { holding: 0, charge: 0, y: 240, velocity: 0 });
+ assert.deepEqual(model.snapshot().held, []);
+ assert.equal(model.snapshot().tick, 0);
+ assert.equal(model.snapshot().views.find(view => view.id === 'floor').y, 261);
+ assert.equal(model.snapshot().views.find(view => view.id === 'actor').radius, 20);
+});
+
+test('press, hold, release, and frame order produce exact expected values', () => {
+ const model = createChargeModel();
+ model.down(); model.frame(15);
+ let expectedCharge = 0;
+ for (let frame = 0; frame < 15; frame++) expectedCharge = Math.min(Math.max(expectedCharge + 1 / 60, 0), 1);
+ assert.deepEqual(model.snapshot().states, { holding: 1, charge: expectedCharge, y: 240, velocity: 0 });
+ model.up();
+ const initialVelocity = -(160 + expectedCharge * 300);
+ assert.deepEqual(model.snapshot().states, { holding: 0, charge: 0, y: 240, velocity: initialVelocity });
+ model.frame();
+ const nextVelocity = initialVelocity + 600 * (1 / 60);
+ assert.deepEqual(model.snapshot().states, {
+ holding: 0, charge: 0, y: 240 + nextVelocity * (1 / 60), velocity: nextVelocity,
+ });
+});
+
+test('charge clamps at one and explicit floor restores rest', () => {
+ const model = createChargeModel();
+ model.down(); model.frame(120);
+ assert.equal(model.snapshot().states.charge, 1);
+ model.up(); assert.equal(model.snapshot().states.velocity, -460);
+ model.frame(); assert.equal(model.snapshot().states.y, 232.5);
+ model.frame(120);
+ assert.deepEqual(model.snapshot().states, { holding: 0, charge: 0, y: 240, velocity: 0 });
+});
+
+test('repeat and concurrent physical sources produce only logical edges', () => {
+ const model = createChargeModel();
+ model.down('jump', 'keyboard'); model.frame(6);
+ const charge = model.snapshot().states.charge;
+ model.down('jump', 'keyboard'); model.down('jump', 'pointer');
+ assert.equal(model.snapshot().states.charge, charge);
+ model.up('jump', 'keyboard');
+ assert.equal(model.snapshot().states.holding, 1);
+ model.up('jump', 'unknown'); assert.equal(model.snapshot().states.holding, 1);
+ model.up('jump', 'pointer'); assert.equal(model.snapshot().states.velocity, -(160 + charge * 300));
+ const released = model.snapshot(); model.up('jump', 'pointer');
+ assert.deepEqual(model.snapshot(), released);
+});
+
+test('airborne presses cannot charge or cause another jump', () => {
+ const model = createChargeModel(); model.down(); model.up(); model.frame();
+ const airborne = model.snapshot().states;
+ model.down(); model.up();
+ assert.deepEqual(model.snapshot().states, airborne);
+});
+
+test('releaseAll emits release and reset clears latches without a jump', () => {
+ const model = createChargeModel();
+ model.down('jump', 'keyboard'); model.down('jump', 'pointer'); model.frame(10);
+ model.releaseAll(); assert.equal(model.snapshot().states.holding, 0);
+ assert.ok(model.snapshot().states.velocity < 0); assert.deepEqual(model.snapshot().held, []);
+ model.reset(); assert.deepEqual(model.snapshot().states, { holding: 0, charge: 0, y: 240, velocity: 0 });
+ assert.equal(model.snapshot().tick, 0);
+ model.down(); model.reset(); model.up(); assert.equal(model.snapshot().states.velocity, 0);
+});
+
+test('unknown inputs and invalid frame counts reject without mutation', () => {
+ const model = createChargeModel(), initial = model.snapshot();
+ for (const method of ['down', 'up']) assert.throws(() => model[method]('other'), /Unknown input/);
+ for (const count of [-1, .5, 601, Infinity, NaN, '1']) assert.throws(() => model.frame(count), /integer/);
+ model.frame(0); assert.deepEqual(model.snapshot(), initial);
+ const snapshot = model.snapshot(); snapshot.states.y = -999;
+ assert.equal(model.snapshot().states.y, 240);
+});
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/cli.mjs b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/cli.mjs
new file mode 100644
index 0000000..b67f4d3
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/cli.mjs
@@ -0,0 +1,127 @@
+#!/usr/bin/env node
+import { readFile, writeFile } from 'node:fs/promises';
+import { createInterface } from 'node:readline';
+import { createRuntime } from './web/runtime.mjs';
+import { parseProject } from './web/project.mjs';
+import { remoteTarget, pushProject, pullProject } from './remote.mjs';
+
+const help = `Pure authoring workbench · 같은 정의와 Zig 실행기
+ load PATH .pure 파일을 적용 (같은 이름의 상태 보존)
+ down NAME 정의한 입력 누르기
+ up NAME 정의한 입력 놓기
+ point NAME PHASE X Y 좌표 입력 (down / move / up / cancel)
+ point-target NAME VIEW PHASE X Y 그룹의 대상에 좌표 입력
+ change NAME VALUE 숫자 입력 전달
+ frame [1..600] 시간 진행, 1프레임 = 1/60초
+ show 상태와 도형 출력
+ limits C H W 표시 캐시(0..32)·변경 기록(0..8)·작업량(0..4294967295) 설정
+ forget [PART] 표시 기록 삭제: all, values, history (상태는 유지)
+ costs 마지막 표시 계산의 작업량·명령 수·캐시 적중 출력
+ reset 현재 정의의 초기 상태로
+ save PATH 적용한 정의를 저장
+ save-project PATH 적용 코드와 현재 배치를 JSON으로 저장
+ restore PATH 저장한 배치를 복원 (초안이 있으면 파일에 보존)
+ connect URL KEY 로컬 네이티브 저장 서버의 작업물에 연결
+ push [REVISION] 현재 배치를 저장 (첫 생성은 push 0, 충돌 시 기존 값 유지)
+ pull 원격 배치를 검증·복원하고 revision 기억
+ help / quit
+브라우저와 별도 인스턴스입니다. 임의 JavaScript는 실행하지 않습니다.`;
+
+async function main() {
+ const args = process.argv.slice(2);
+ if (args.includes('--help')) { console.log(help); return; }
+ if (args.length > 1) throw new Error('사용법: node cli.mjs [project.pure]');
+ const runtime = await createRuntime(await readFile(new URL('./web/core.wasm', import.meta.url)));
+ let savedDraft = null;
+ let remote = null, remoteRevision = null;
+ async function loadFile(path, restoreOnly = false) {
+ const content = await readFile(path, 'utf8');
+ if (restoreOnly || path.toLowerCase().endsWith('.json')) {
+ const project = parseProject(content);
+ if (project.version === 2) {
+ savedDraft = runtime.restoreDocument(project).draft;
+ return;
+ }
+ if (restoreOnly) throw new Error('현재 배치가 포함된 version 2 파일이 필요합니다.');
+ runtime.apply(project.source);
+ } else runtime.apply(content);
+ savedDraft = null;
+ }
+ if (args[0]) await loadFile(args[0]); else runtime.apply('');
+ const terminal = createInterface({ input: process.stdin, output: process.stdout, terminal: Boolean(process.stdin.isTTY) });
+ if (process.stdin.isTTY) console.log(help);
+ terminal.setPrompt('pure> ');
+ if (process.stdin.isTTY) terminal.prompt();
+ for await (const line of terminal) {
+ try {
+ const match = /^(\S+)(?:\s+(.+))?$/.exec(line.trim());
+ const name = match?.[1] || 'show';
+ const arg = match?.[2];
+ if (name === 'quit') { terminal.close(); break; }
+ if (name === 'help') console.log(help);
+ else if (name === 'costs' && !arg) console.log(JSON.stringify({ evaluation: runtime.evaluationStats() }));
+ else {
+ if (name === 'load' && arg) await loadFile(arg);
+ else if (name === 'restore' && arg) await loadFile(arg, true);
+ else if (name === 'connect' && arg) {
+ const parts = arg.split(/\s+/);
+ if (parts.length !== 2) throw new Error('connect URL KEY 형식을 사용하세요.');
+ remote = remoteTarget(parts[0], parts[1]);
+ remoteRevision = null;
+ console.log(JSON.stringify({ remote: remote.href }));
+ } else if (name === 'push') {
+ if (!remote) throw new Error('먼저 connect URL KEY로 연결하세요.');
+ const expected = arg ?? remoteRevision;
+ if (expected === null) throw new Error('먼저 pull로 버전을 읽거나 push 0으로 새 작업물을 만드세요.');
+ remoteRevision = await pushProject(remote, expected, runtime.exportDocument(savedDraft ?? undefined));
+ console.log(JSON.stringify({ remote: remote.href, revision: remoteRevision, stored: true }));
+ } else if (name === 'pull' && !arg) {
+ if (!remote) throw new Error('먼저 connect URL KEY로 연결하세요.');
+ const loaded = await pullProject(remote);
+ savedDraft = runtime.restoreDocument(loaded.project).draft;
+ remoteRevision = loaded.revision;
+ console.log(JSON.stringify({ remote: remote.href, revision: remoteRevision, restored: true }));
+ }
+ else if (name === 'save-project' && arg) await writeFile(arg, JSON.stringify(runtime.exportDocument(savedDraft ?? undefined), null, 2) + '\n', {flag:'wx'});
+ else if (name === 'save' && arg) await writeFile(arg, runtime.exportProject().source, { flag: 'wx' });
+ else if (name === 'down' && arg) runtime.down(arg, 'terminal');
+ else if (name === 'up' && arg) runtime.up(arg, 'terminal');
+ else if (name === 'point' && arg) {
+ const parts = arg.split(/\s+/);
+ if (parts.length !== 4) throw new Error('point NAME PHASE X Y 형식을 사용하세요.');
+ runtime.point(parts[0], parts[1], Number(parts[2]), Number(parts[3]), 'terminal');
+ } else if (name === 'point-target' && arg) {
+ const parts = arg.split(/\s+/);
+ if (parts.length !== 5) throw new Error('point-target NAME VIEW PHASE X Y 형식을 사용하세요.');
+ runtime.point(parts[0], parts[2], Number(parts[3]), Number(parts[4]), 'terminal', parts[1]);
+ } else if (name === 'change' && arg) {
+ const parts = arg.split(/\s+/);
+ if (parts.length !== 2) throw new Error('change NAME VALUE 형식을 사용하세요.');
+ runtime.change(parts[0], Number(parts[1]), 'terminal');
+ } else if (name === 'frame') {
+ if (arg !== undefined && !/^\d+$/.test(arg)) throw new Error('frame 1..600 정수를 입력하세요.');
+ const count = arg === undefined ? 1 : Number(arg);
+ if (count < 1 || count > 600) throw new Error('frame 1..600 정수를 입력하세요.');
+ runtime.frame(count);
+ } else if (name === 'reset' && !arg) runtime.reset();
+ else if (name === 'limits' && arg) {
+ const parts = arg.split(/\s+/);
+ if (parts.length !== 3 || parts.some(part => !/^\d+$/.test(part)))
+ throw new Error('limits C H W 정수 세 개를 입력하세요.');
+ const [cacheEntries, historyEntries, workLimit] = parts.map(Number);
+ runtime.configureEvaluation({ cacheEntries, historyEntries, workLimit });
+ } else if (name === 'forget') runtime.forgetEvaluation(arg || 'all');
+ else if (name !== 'show' || arg) throw new Error('명령 형식을 확인하세요. help로 안내를 볼 수 있습니다.');
+ const result = runtime.snapshot();
+ console.log(JSON.stringify(result));
+ if (result.lastError && !process.stdin.isTTY) process.exitCode = 1;
+ }
+ } catch (error) {
+ console.error(`${error.line ? `${error.line}:${error.column || 1} ` : ''}${error.message}`);
+ if (!process.stdin.isTTY) process.exitCode = 1;
+ }
+ if (process.stdin.isTTY) terminal.prompt();
+ }
+}
+
+main().catch((error) => { console.error(error.message); process.exitCode = 1; });
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/cli.test.mjs b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/cli.test.mjs
new file mode 100644
index 0000000..f355d2d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/cli.test.mjs
@@ -0,0 +1,121 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createRuntime } from './web/runtime.mjs';
+
+const root = fileURLToPath(new URL('.', import.meta.url));
+function run(input, file) {
+ const result = spawnSync(process.execPath, [join(root, 'cli.mjs'), ...(file ? [file] : [])], {
+ input, encoding: 'utf8', cwd: root,
+ });
+ if (result.error) throw result.error;
+ return { ...result, snapshots: result.stdout.split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line)) };
+}
+
+test('terminal starts empty and produces the same charge trace as the shared runtime', async () => {
+ assert.deepEqual(run('show\nquit\n').snapshots[0].states, {});
+ const file = join(root, 'examples/charge.pure');
+ const result = run('down jump\nframe 30\nup jump\nframe 1\nshow\nquit\n', file);
+ assert.equal(result.status, 0, result.stderr);
+ const runtime = await createRuntime(await readFile(join(root, 'web/core.wasm')));
+ runtime.apply(await readFile(file, 'utf8'));
+ runtime.down('jump', 'terminal'); runtime.frame(30); runtime.up('jump', 'terminal'); runtime.frame();
+ // JSON has no undefined-valued optional fields; compare the same wire form.
+ assert.deepEqual(result.snapshots.at(-1), JSON.parse(JSON.stringify(runtime.snapshot())));
+ assert(result.snapshots.at(-1).states.y < 240);
+});
+
+test('invalid terminal commands do not alter state and fail piped execution', () => {
+ const result = run('show\nframe 0\nframe -1\nframe 1.5\nframe 1e2\nframe 601\nshow extra\ndown unknown\nshow\nquit\n', join(root, 'examples/counter.pure'));
+ assert.equal(result.status, 1);
+ assert.deepEqual(result.snapshots[0], result.snapshots[1]);
+ assert.equal(result.stderr.trim().split('\n').length, 7);
+});
+
+test('terminal replays authored pointer movement and cancellation through the shared runtime', async () => {
+ const file = join(root, 'examples/direct.pure');
+ const result = run('point drag down 90 170\npoint drag move 360 220\npoint drag up 410 240\npoint drag down 410 240\npoint drag move 500 260\npoint drag cancel 500 260\nshow\nquit\n', file);
+ assert.equal(result.status, 0, result.stderr);
+ const runtime = await createRuntime(await readFile(join(root, 'web/core.wasm')));
+ runtime.apply(await readFile(file, 'utf8'));
+ for (const [phase, x, y] of [['down',90,170],['move',360,220],['up',410,240],['down',410,240],['move',500,260],['cancel',500,260]])
+ runtime.point('drag', phase, x, y, 'terminal');
+ assert.deepEqual(result.snapshots.at(-1), JSON.parse(JSON.stringify(runtime.snapshot())));
+ assert.equal(result.snapshots.at(-1).states.x, 350);
+ assert.equal(result.snapshots.at(-1).states.y, 210);
+ assert.deepEqual(result.snapshots.at(-1).held, []);
+});
+
+test('file load preserves by name, rejected load preserves active work, and save never overwrites', async t => {
+ const directory = await mkdtemp(join(tmpdir(), 'pure-author-cli-'));
+ t.after(() => rm(directory, { recursive: true, force: true }));
+ const first = join(directory, 'first.pure'), second = join(directory, 'second with space.pure');
+ const invalid = join(directory, 'invalid.pure'), saved = join(directory, 'saved.pure');
+ await writeFile(first, 'state n = 7');
+ await writeFile(second, 'state n = 100\nstate added = 9');
+ await writeFile(invalid, 'state n = broken');
+ const result = run(`load ${second}\nload ${invalid}\nshow\nsave ${saved}\nreset\nsave ${saved}\nquit\n`, first);
+ assert.equal(result.status, 1);
+ assert.deepEqual(result.snapshots[0].states, { n: 7, added: 9 });
+ assert.deepEqual(result.snapshots[1], result.snapshots[0]);
+ assert.deepEqual(result.snapshots.at(-1).states, { n: 100, added: 9 });
+ assert.equal(await readFile(saved, 'utf8'), await readFile(second, 'utf8'));
+ assert.match(result.stderr, /EEXIST/);
+});
+
+test('terminal exposes arithmetic rollback and retains failure through matching release', async t => {
+ const directory = await mkdtemp(join(tmpdir(), 'pure-author-error-'));
+ t.after(() => rm(directory, { recursive: true, force: true }));
+ const file = join(directory, 'error.pure');
+ await writeFile(file, 'state n = 7\ninput tap = Space\non tap.down { n = 1 / 0 }');
+ const result = run('down tap\nup tap\nshow\nreset\nquit\n', file);
+ assert.equal(result.status, 1);
+ for (const snapshot of result.snapshots.slice(0, 3)) {
+ assert.equal(snapshot.states.n, 7); assert.equal(snapshot.lastError, 4);
+ assert.equal(snapshot.errorLine, 3);
+ }
+ assert.equal(result.snapshots[3].lastError, 0);
+});
+
+test('terminal configures and discards evaluation records and recovers from a display limit', () => {
+ const result = run('limits 32 8 2048\nshow\ncosts\nforget all\nlimits 32 8 0\nlimits 32 8 2048\nshow\nquit\n', join(root, 'examples/counter.pure'));
+ assert.equal(result.status, 1); // The explicit zero-budget display failed.
+ assert.equal(result.stderr, '');
+ const [configured, warm, costs, forgotten, limited, recovered, final] = result.snapshots;
+ assert.deepEqual(warm.views, configured.views);
+ assert(costs.evaluation.cacheHits > 0);
+ assert.deepEqual(forgotten.states, configured.states);
+ assert.deepEqual(limited.views, []);
+ assert.equal(limited.lastError, 101);
+ assert.deepEqual(recovered, final);
+ assert.deepEqual(recovered.views, configured.views);
+ assert.deepEqual(recovered.states, configured.states);
+});
+
+test('terminal saves and restores placement independently of reset and preserves a browser draft', async t => {
+ const directory = await mkdtemp(join(tmpdir(), 'pure-layout-cli-'));
+ t.after(() => rm(directory, {recursive:true,force:true}));
+ const saved=join(directory,'layout.json'), copied=join(directory,'copy.json');
+ const first=run(`point drag down 90 170\npoint drag move 360 220\npoint drag up 90 170\nsave-project ${saved}\nreset\nrestore ${saved}\nshow\nquit\n`,join(root,'examples/direct.pure'));
+ assert.equal(first.status,0,first.stderr);assert.equal(first.snapshots.at(-1).states.x,350);
+ const document=JSON.parse(await readFile(saved,'utf8'));document.draft='state incomplete =';
+ await writeFile(saved,JSON.stringify(document));
+ const second=run(`save-project ${copied}\nreset\nshow\nquit\n`,saved);
+ assert.equal(second.status,0,second.stderr);assert.equal(second.snapshots.at(-1).states.x,80);
+ const copy=JSON.parse(await readFile(copied,'utf8'));assert.equal(copy.draft,document.draft);assert.deepEqual(copy.states,document.states);
+ const rejected=run(`save-project ${saved}\nquit\n`,saved);
+ assert.equal(rejected.status,1);assert.match(rejected.stderr,/EEXIST/);
+});
+
+test('invalid restoration leaves terminal code and state unchanged', async t => {
+ const directory=await mkdtemp(join(tmpdir(),'pure-invalid-layout-'));
+ t.after(()=>rm(directory,{recursive:true,force:true}));
+ const file=join(directory,'bad.json');
+ await writeFile(file,JSON.stringify({format:'pure-workbench',version:2,source:'state x=1',states:[]}));
+ const result=run(`show\nrestore ${file}\nshow\nquit\n`,join(root,'examples/counter.pure'));
+ assert.equal(result.status,1);assert.deepEqual(result.snapshots[0],result.snapshots[1]);
+});
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/compare.mjs b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/compare.mjs
new file mode 100644
index 0000000..ceadb0c
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/compare.mjs
@@ -0,0 +1,231 @@
+// Reproduce the fixed charge workload in the Wasm-host runtime and handwritten JS.
+// This compares full host calls, not VM instructions or feature-equivalent products.
+import { readFile, writeFile } from 'node:fs/promises';
+import { createHash } from 'node:crypto';
+import { cpus, platform, release, arch, availableParallelism } from 'node:os';
+import { performance } from 'node:perf_hooks';
+import { fileURLToPath } from 'node:url';
+import { createRuntime } from './web/runtime.mjs';
+import { createChargeModel } from './web/baseline.mjs';
+
+const args = process.argv.slice(2);
+if (args.length && !(args.length === 2 && args[0] === '--output')) {
+ throw new Error('Usage: node compare.mjs [--output report.json]');
+}
+const startedAt = new Date().toISOString();
+const seed = 20260922;
+const trials = 7;
+const warmupTraces = 5;
+const targetTrialMs = 12;
+const absoluteTolerance = 1e-11;
+const relativeTolerance = 1e-12;
+const files = {
+ charge: new URL('./examples/charge.pure', import.meta.url),
+ baseline: new URL('./web/baseline.mjs', import.meta.url),
+ runtime: new URL('./web/runtime.mjs', import.meta.url),
+ compiler: new URL('./web/compiler.mjs', import.meta.url),
+ wasm: new URL('./web/core.wasm', import.meta.url),
+ harness: new URL('./compare.mjs', import.meta.url),
+};
+const contents = Object.fromEntries(await Promise.all(
+ Object.entries(files).map(async ([name, path]) => [name, await readFile(path)]),
+));
+const hash = bytes => createHash('sha256').update(bytes).digest('hex');
+const sourceInfo = name => {
+ const text = contents[name].toString('utf8');
+ return {
+ bytes: contents[name].length,
+ physicalLines: text === '' ? 0 : text.split('\n').length - Number(text.endsWith('\n')),
+ nonblankLines: text.split('\n').filter(line => line.trim().length).length,
+ sha256: hash(contents[name]),
+ };
+};
+
+function makeTrace() {
+ const trace = [];
+ const action = (method, source = undefined) => trace.push({ method, ...(source === undefined ? {} : { source }) });
+ const frames = count => {
+ // Individual calls let correctness checks inspect every frame, not just a batch end.
+ for (let i = 0; i < count; i++) action('frame');
+ };
+ action('reset'); action('up', 'unknown');
+ action('down', 'keyboard'); action('down', 'keyboard'); frames(1);
+ action('down', 'pointer:1'); frames(59); action('up', 'keyboard'); frames(10);
+ action('up', 'pointer:1'); frames(6);
+ action('down', 'keyboard'); frames(2); action('up', 'keyboard'); frames(120);
+ action('down', 'pointer:1'); frames(600); action('releaseAll'); frames(120);
+ action('down', 'keyboard'); action('reset'); action('up', 'keyboard'); frames(1);
+ let random = seed >>> 0;
+ const next = limit => {
+ random = (Math.imul(random, 1664525) + 1013904223) >>> 0;
+ return random % limit;
+ };
+ for (let cycle = 0; cycle < 48; cycle++) {
+ const source = next(2) ? 'keyboard' : 'pointer:1';
+ action('down', source); frames(next(91)); action('down', source);
+ if (cycle % 3 === 0) action('down', 'pointer:2');
+ frames(next(11)); action('up', source);
+ if (cycle % 3 === 0) { frames(next(17)); action('up', 'pointer:2'); }
+ if (cycle % 7 === 0) action('reset');
+ else if (cycle % 5 === 0) action('releaseAll');
+ frames(next(121));
+ }
+ action('releaseAll'); frames(180);
+ return trace;
+}
+
+const trace = makeTrace();
+function traceCounts() {
+ const counts = { hostActions: trace.length, frames: 0, logicalDown: 0,
+ logicalUp: 0, resets: 0, releaseAllCalls: 0, downCalls: 0, upCalls: 0 };
+ const held = new Set();
+ for (const action of trace) {
+ if (action.method === 'frame') counts.frames++;
+ else if (action.method === 'reset') { counts.resets++; held.clear(); }
+ else if (action.method === 'releaseAll') {
+ counts.releaseAllCalls++;
+ if (held.size) counts.logicalUp++;
+ held.clear();
+ } else if (action.method === 'down') {
+ counts.downCalls++;
+ if (!held.size) counts.logicalDown++;
+ held.add(action.source);
+ } else {
+ counts.upCalls++;
+ if (held.delete(action.source) && !held.size) counts.logicalUp++;
+ }
+ }
+ counts.dispatchedEvents = counts.frames + counts.logicalDown + counts.logicalUp;
+ return counts;
+}
+const counts = traceCounts();
+const pure = await createRuntime(contents.wasm);
+const program = pure.apply(contents.charge.toString('utf8'));
+const baseline = createChargeModel();
+function perform(model, action) {
+ switch (action.method) {
+ case 'frame': return model.frame(1);
+ case 'down': return model.down('jump', action.source);
+ case 'up': return model.up('jump', action.source);
+ case 'reset': model.reset(); return 0;
+ case 'releaseAll': return model.releaseAll();
+ default: throw new Error(`Unknown trace action ${action.method}`);
+ }
+}
+
+let checkedNumbers = 0, maxAbsoluteDifference = 0;
+function compare(actual, expected, path) {
+ if (typeof expected === 'number') {
+ if (!Number.isFinite(actual) || !Number.isFinite(expected)) throw new Error(`${path}: non-finite value`);
+ const difference = Math.abs(actual - expected);
+ maxAbsoluteDifference = Math.max(maxAbsoluteDifference, difference);
+ checkedNumbers++;
+ if (difference > absoluteTolerance + relativeTolerance * Math.max(Math.abs(actual), Math.abs(expected))) {
+ throw new Error(`${path}: ${actual} != ${expected}`);
+ }
+ } else if (Array.isArray(expected)) {
+ if (!Array.isArray(actual) || actual.length !== expected.length) throw new Error(`${path}: array shape mismatch`);
+ expected.forEach((item, index) => compare(actual[index], item, `${path}[${index}]`));
+ } else if (expected && typeof expected === 'object') {
+ if (!actual || typeof actual !== 'object') throw new Error(`${path}: object missing`);
+ const actualKeys = Object.keys(actual).filter(key => actual[key] !== undefined).sort();
+ const expectedKeys = Object.keys(expected).filter(key => expected[key] !== undefined).sort();
+ if (JSON.stringify(actualKeys) !== JSON.stringify(expectedKeys)) throw new Error(`${path}: object keys mismatch`);
+ for (const key of expectedKeys) compare(actual[key], expected[key], `${path}.${key}`);
+ } else if (actual !== expected) throw new Error(`${path}: ${JSON.stringify(actual)} != ${JSON.stringify(expected)}`);
+}
+function checkSnapshots(index) {
+ const actual = pure.snapshot(), expected = baseline.snapshot();
+ if (actual.lastError) throw new Error(`Action ${index}: ${actual.errorMessage}`);
+ compare(actual.states, expected.states, `action[${index}].states`);
+ compare(actual.views, expected.views, `action[${index}].views`);
+ compare(actual.tick, expected.tick, `action[${index}].tick`);
+ compare(actual.held, expected.held, `action[${index}].held`);
+}
+checkSnapshots(-1);
+for (let index = 0; index < trace.length; index++) {
+ for (const model of [pure, baseline]) {
+ const code = perform(model, trace[index]);
+ if (code !== 0) throw new Error(`Action ${index}: execution status ${code}`);
+ }
+ checkSnapshots(index);
+}
+const correctness = { passed: true, checkedSnapshots: trace.length + 1,
+ checkedNumbers, maxAbsoluteDifference, absoluteTolerance, relativeTolerance,
+ checkedFields: ['states', 'all view fields', 'tick', 'held input names'],
+ traceSha256: hash(JSON.stringify(trace)), counts };
+const memory = pure.snapshot().memory;
+
+// Snapshot construction, binding evaluation, compilation, Wasm loading, and DOM
+// rendering are deliberately outside these timed loops. Host dispatch is inside.
+function run(model, repetitions) {
+ for (let repeat = 0; repeat < repetitions; repeat++) {
+ for (const action of trace) {
+ const code = perform(model, action);
+ if (code !== 0) throw new Error(`Timing run failed with execution status ${code}`);
+ }
+ }
+}
+const models = { pureHostWasm: pure, handwrittenJavaScript: baseline };
+for (const model of Object.values(models)) run(model, warmupTraces);
+const repetitions = {};
+const calibration = {};
+for (const [name, model] of Object.entries(models)) {
+ const start = performance.now(); run(model, 3);
+ const elapsedMs = performance.now() - start;
+ repetitions[name] = Math.max(1, Math.min(2048, Math.ceil(targetTrialMs / Math.max(elapsedMs / 3, .001))));
+ calibration[name] = { elapsedMs, traces: 3, selectedRepetitions: repetitions[name] };
+}
+const samples = [];
+for (let trial = 0; trial < trials; trial++) {
+ const order = trial % 2 === 0 ? Object.keys(models) : Object.keys(models).reverse();
+ for (const [position, name] of order.entries()) {
+ const start = performance.now(); run(models[name], repetitions[name]);
+ const elapsedMs = performance.now() - start;
+ const nsPerTrace = elapsedMs * 1e6 / repetitions[name];
+ samples.push({ trial: trial + 1, position: position + 1, implementation: name,
+ repetitions: repetitions[name], elapsedMs, nsPerTrace,
+ nsPerHostAction: nsPerTrace / counts.hostActions,
+ nsPerDispatchedEvent: nsPerTrace / counts.dispatchedEvents });
+ }
+}
+// Each trace begins with reset. Both timing implementations must still agree.
+checkSnapshots('after timing');
+const summary = {};
+for (const name of Object.keys(models)) {
+ const values = samples.filter(sample => sample.implementation === name)
+ .map(sample => sample.nsPerHostAction).sort((a, b) => a - b);
+ summary[name] = { medianNsPerHostAction: values[Math.floor(values.length / 2)],
+ minNsPerHostAction: values[0], maxNsPerHostAction: values.at(-1) };
+}
+summary.pureOverJavaScriptMedianRatio = summary.pureHostWasm.medianNsPerHostAction
+ / summary.handwrittenJavaScript.medianNsPerHostAction;
+
+const report = {
+ experiment: 'Pure Workbench charge / handwritten JavaScript reference',
+ environment: { startedAt, completedAt: new Date().toISOString(),
+ node: process.version, v8: process.versions.v8, platform: platform(), osRelease: release(),
+ architecture: arch(), cpuModel: cpus()[0]?.model ?? 'unknown',
+ logicalCPUs: cpus().length, availableParallelism: availableParallelism(),
+ affinityControlled: false, powerControlled: false,
+ command: `node ${fileURLToPath(import.meta.url)}${args.length ? ` --output ${args[1]}` : ''}` },
+ artifacts: Object.fromEntries(Object.keys(files).map(name => [name,
+ { bytes: contents[name].length, sha256: hash(contents[name]) }])),
+ correctness,
+ sources: { chargePure: sourceInfo('charge'), fixedJavaScriptModel: sourceInfo('baseline'),
+ note: 'Raw file size and line counts only; excludes HTML/host/compiler and does not establish authoring effort or DX quality.' },
+ compilerMetrics: program.metrics,
+ memory: { pure: memory,
+ note: 'Wasm linear capacity and core layout counters; not process RSS or browser memory. No equivalent JS heap measurement collected. Do not add overlapping counters.' },
+ timing: { seed, warmupTracesPerImplementation: warmupTraces,
+ trialsPerImplementation: trials, targetTrialMs, calibration, traceCounts: counts,
+ order: 'Alternated each trial; Pure first on odd trials, JavaScript first on even trials.',
+ included: 'Host method dispatch, source-latch aggregation, state logic, and resets in the fixed trace. Pure additionally maintains generic VM/event diagnostics.',
+ excluded: 'Compilation, loading, snapshots, binding evaluation, DOM rendering, scene editing, and live apply.',
+ normalization: 'nsPerHostAction divides total time by calls including repeats, releaseAll and reset; nsPerDispatchedEvent also amortizes that host overhead.',
+ limitations: 'Single-machine batch throughput; no CPU pinning or energy/thermal control. Seven trial averages are not event latency percentiles. Fixed JS has no live program replacement, general compiler, or state migration; not feature-equivalent whole-product timing.',
+ samples, summary },
+};
+const json = JSON.stringify(report, null, 2) + '\n';
+if (args.length) await writeFile(args[1], json, { flag: 'wx' });
+process.stdout.write(json);
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/compiler.test.mjs b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/compiler.test.mjs
new file mode 100644
index 0000000..89b8c6c
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/compiler.test.mjs
@@ -0,0 +1,240 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { compile, LIMITS } from './web/compiler.mjs';
+
+function diagnostic(source, pattern, line, column) {
+ assert.throws(() => compile(source), error => {
+ assert.match(error.message, pattern);
+ assert.ok(Number.isInteger(error.line) && error.line > 0);
+ assert.ok(Number.isInteger(error.column) && error.column > 0);
+ if (line !== undefined) assert.equal(error.line, line);
+ if (column !== undefined) assert.equal(error.column, column);
+ return true;
+ });
+}
+
+// A small independent evaluator checks compiler lowering. The product executes
+// these instructions in Zig; the VM has its own native and integration checks.
+function evaluate(program, start, length, values, dt = 0) {
+ const stack = [];
+ for (let pc = start; pc < start + length; pc++) {
+ const instruction = program.instructions[pc];
+ const { op, arg, value } = instruction;
+ if (op === 0) stack.push(value);
+ else if (op === 1) stack.push(values[arg]);
+ else if (op === 2) stack.push(dt);
+ else if (op === 7) stack.push(-stack.pop());
+ else if (op === 16) stack.push(Number(!stack.pop()));
+ else if (op === 19) {
+ const high = stack.pop(), low = stack.pop(), x = stack.pop();
+ assert.ok(low <= high); stack.push(Math.min(Math.max(x, low), high));
+ } else if (op === 20) values[arg] = stack.pop();
+ else if (op === 21) { if (!stack.pop()) pc = arg - 1; }
+ else {
+ const right = stack.pop(), left = stack.pop();
+ const operations = {
+ 3: () => left + right, 4: () => left - right, 5: () => left * right,
+ 6: () => left / right, 8: () => Number(left < right),
+ 9: () => Number(left <= right), 10: () => Number(left > right),
+ 11: () => Number(left >= right), 12: () => Number(left === right),
+ 13: () => Number(left !== right), 14: () => Number(Boolean(left) && Boolean(right)),
+ 15: () => Number(Boolean(left) || Boolean(right)),
+ 17: () => Math.min(left, right), 18: () => Math.max(left, right),
+ };
+ assert.ok(operations[op], `Unknown opcode ${op}`);
+ stack.push(operations[op]());
+ }
+ assert.ok(stack.every(Number.isFinite));
+ }
+ return stack;
+}
+
+function dispatch(program, event, values, dt = 0) {
+ for (const handler of program.handlers.filter(handler => handler.event === event)) {
+ assert.deepEqual(evaluate(program, handler.start, handler.length, values, dt), []);
+ }
+}
+
+test('a blank document is an empty program with no implied behavior', () => {
+ const result = compile('');
+ for (const field of ['states', 'inputs', 'views', 'instructions', 'handlers']) assert.deepEqual(result[field], []);
+ assert.equal(result.metrics.sourceBytes, 0);
+ assert.equal(result.metrics.maxStack, 0);
+ assert.deepEqual(compile('# comment only\n;').instructions, []);
+});
+
+test('forward references and separate state/input namespaces preserve declaration IDs', () => {
+ const program = compile('on tap.down { second = tap + first }\ntext label(0,0,second)\nstate first = -2\nstate tap = +3\ninput tap = Space\nstate second = .5e2');
+ assert.deepEqual(program.states.map(state => state.name), ['first', 'tap', 'second']);
+ assert.equal(program.handlers[0].event, 1);
+ const values = program.states.map(state => state.initial);
+ dispatch(program, 1, values);
+ assert.deepEqual(values, [-2, 3, 1]);
+ const binding = program.views[0].bindings.value;
+ assert.deepEqual(evaluate(program, binding.start, binding.length, values), [1]);
+});
+
+test('ordered assignments, repeated handlers, and condition evaluation once', () => {
+ const program = compile(`state x = 1
+input tap = Enter
+on tap.down {
+ if x == 1 {
+ x = 0
+ x = x + 5
+ }
+}
+on tap.down { x = x * 2 }
+on tap.up { x = x - 1 }`);
+ const values = [1];
+ dispatch(program, 1, values);
+ assert.equal(values[0], 10);
+ dispatch(program, 2, values);
+ assert.equal(values[0], 9);
+ const branch = program.instructions.find(instruction => instruction.op === 21);
+ assert.equal(branch.arg, program.handlers[0].start + program.handlers[0].length);
+});
+
+test('nested branches use absolute forward targets and skip correct bodies', () => {
+ const program = compile(`state x = 0
+text label(0, 0, x)
+on frame {
+ if x > 0 {
+ x = 8
+ if x == 8 { x = 9 }
+ }
+ x = x + 1
+}`);
+ const values = [0];
+ dispatch(program, 0, values, 1 / 60); assert.equal(values[0], 1);
+ dispatch(program, 0, values, 1 / 60); assert.equal(values[0], 10);
+ const handler = program.handlers[0];
+ program.instructions.forEach((instruction, pc) => {
+ if (instruction.op === 21) assert.ok(instruction.arg > pc && instruction.arg <= handler.start + handler.length);
+ });
+});
+
+test('operator precedence, associativity, unary and builtin function lowering', () => {
+ const expressions = new Map([
+ ['2 + 3 * 4', 14], ['8 - 3 - 2', 3], ['12 / 3 / 2', 2],
+ ['-2 * -3', 6], ['!0 + !4', 1], ['1 || 0 && 0', 1],
+ ['1 < 2 && 2 <= 2 && 4 > 3 && 4 >= 4', 1],
+ ['3 == 3 && 3 != 4', 1], ['clamp(max(3, 9) - min(4, 7), 0, 4)', 4],
+ ]);
+ for (const [source, expected] of expressions) {
+ const program = compile(`state x = 0\non frame { x = ${source} }`);
+ const values = [0]; dispatch(program, 0, values, 1 / 60);
+ assert.equal(values[0], expected, source);
+ }
+ const eager = compile('state x = 0\non frame { x = 1 || x / 0 }');
+ assert.ok(eager.instructions.some(instruction => instruction.op === 6));
+ assert.ok(!eager.instructions.some(instruction => instruction.op === 21));
+});
+
+test('multiline parentheses, scientific literals, comments, and string escapes', () => {
+ const program = compile(`state x = 1e-2 # comment
+box tile(
+ (x +
+ 2), 0, max(
+ 1, 2), 4,
+ "#AABBCC")
+text label(0, 0, x, "# count: \\\"value\\\" \\n")
+on frame { x = x + dt }`);
+ assert.equal(program.views[0].color, '#AABBCC');
+ assert.equal(program.views[1].prefix, '# count: "value" \n');
+ const values = [.01]; dispatch(program, 0, values, 1 / 60);
+ assert.equal(values[0], .01 + 1 / 60);
+ const binding = program.views[0].bindings.x;
+ assert.deepEqual(evaluate(program, binding.start, binding.length, values), [values[0] + 2]);
+});
+
+test('all instruction fields are numeric and carry original source line', () => {
+ const program = compile('state x = 0\non frame {\n x = x + dt\n}');
+ for (const instruction of program.instructions) {
+ assert.deepEqual(Object.keys(instruction), ['op', 'arg', 'value', 'line']);
+ assert.ok(Object.values(instruction).every(Number.isFinite));
+ assert.equal(instruction.line, 3);
+ }
+});
+
+test('contextual declaration words remain valid names outside reserved builtins', () => {
+ const program = compile('state if = 1\nstate state = 2\non frame { if = if + state }');
+ const values = [1, 2]; dispatch(program, 0, values, 1 / 60);
+ assert.deepEqual(values, [3, 2]);
+});
+
+test('unknown and reserved symbols have useful one-based locations', () => {
+ diagnostic('state x = 0\non frame {\n x = missing\n}', /Unknown state missing/, 3, 7);
+ diagnostic('on missing.down {}', /Unknown input missing/, 1, 4);
+ diagnostic('on frame { absent = 1 }', /Unknown state absent/, 1, 12);
+ for (const name of ['frame', 'dt', 'min', 'max', 'clamp']) {
+ diagnostic(`state ${name} = 0`, /Reserved name/, 1, 7);
+ diagnostic(`input ${name} = Space`, /Reserved name/);
+ }
+ diagnostic('text clock(0, 0, dt)', /dt is only available in handlers/);
+});
+
+test('duplicates, unsupported keys, initializers, and invalid syntax are rejected', () => {
+ const invalid = [
+ ['state x=0\nstate x=1', /Duplicate state/],
+ ['input tap=Space\ninput tap=Enter', /Duplicate input/],
+ ['input tap=Space\ninput other=Space', /Duplicate keyboard/],
+ ['box a(0,0,1,1,"#000000")\ncircle a(0,0,1,"#000000")', /Duplicate view/],
+ ['input tap=Escape', /Unsupported keyboard/],
+ ['state x=1+2', /Expected newline/],
+ ['state x=1e999', /finite/],
+ ['state x=NaN', /numeric state initializer/],
+ ['on tap.click {}', /Expected down or up/],
+ ['on frame.down {}', /Expected {/],
+ ['state x=0\non frame { x = min(1) }', /min expects 2/],
+ ['state x=0\non frame { x = clamp(1,2) }', /clamp expects 3/],
+ ['state x=0\non frame { x = 1 +\n2 }', /Expected a number/],
+ ['text a(0,0,1,"broken)', /Unterminated string/],
+ ['text a(0,0,1,"bad\\q")', /Unsupported string escape/],
+ ['box a(0,0,1,1,"blue")', /Color must use/],
+ ['state x=0 @', /Unexpected character/],
+ ['on frame {', /assignment, if, or closing brace/],
+ ['state x = 0 state y = 1', /Expected newline/],
+ ];
+ for (const [source, pattern] of invalid) diagnostic(source, pattern);
+});
+
+test('document, declaration, instruction, and nesting limits reject overflow', () => {
+ diagnostic('#' + '한'.repeat(11000), /32768 UTF-8 bytes/);
+ diagnostic(Array.from({ length: 33 }, (_, i) => `state s${i}=0`).join('\n'), /32 states/);
+ diagnostic(Array.from({ length: 9 }, (_, i) => `input i${i}=Digit${i}`).join('\n'), /8 inputs/);
+ diagnostic(Array.from({ length: 25 }, (_, i) => `text t${i}(0,0,0)`).join('\n'), /24 views/);
+ diagnostic('on frame {}\n'.repeat(65), /64 handlers/);
+ diagnostic(`state x=0\non frame {\n${'x=1\n'.repeat(257)}}`, /512 instructions/);
+ assert.equal(compile(`state x=0\non frame {\n${'x=1\n'.repeat(256)}}`).instructions.length, LIMITS.instructions);
+ diagnostic(`state x=0\non frame { x=${'('.repeat(65)}1${')'.repeat(65)} }`, /nesting exceeds 64/);
+ diagnostic(`on frame { ${'if 1 { '.repeat(65)}${'}\n'.repeat(66)}`, /nesting exceeds 64/);
+ // Right-nested clamp's two waiting operands exceed the stack before its depth limit.
+ let deep = '0';
+ for (let i = 0; i < 33; i++) deep = `clamp(0,0,${deep})`;
+ diagnostic(`text stack(0,0,${deep})`, /stack exceeds 64/);
+});
+
+test('every supported input maps to a bounded ordered event ID', () => {
+ const keys = ['Space', 'Enter', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'KeyZ', 'Digit9'];
+ const program = compile(keys.map((key, i) => `input i${i}=${key}\non i${i}.down {}\non i${i}.up {}`).join('\n'));
+ assert.deepEqual(program.handlers.map(handler => handler.event), Array.from({ length: 16 }, (_, i) => i + 1));
+});
+
+test('optional examples compile and charge behavior is entirely authored', async () => {
+ for (const name of ['counter', 'charge', 'toggle']) {
+ const program = compile(await readFile(new URL(`./examples/${name}.pure`, import.meta.url), 'utf8'));
+ assert.ok(program.instructions.length <= LIMITS.instructions);
+ assert.ok(program.views.length > 0);
+ if (name === 'charge') {
+ const values = program.states.map(state => state.initial);
+ dispatch(program, 1, values);
+ for (let frame = 0; frame < 90; frame++) dispatch(program, 0, values, 1 / 60);
+ assert.equal(values[0], 1); assert.equal(values[1], 1); assert.equal(values[2], 240);
+ dispatch(program, 2, values); assert.deepEqual(values, [0, 0, 240, -460]);
+ dispatch(program, 0, values, 1 / 60); assert.ok(values[2] < 240);
+ for (let frame = 0; frame < 120; frame++) dispatch(program, 0, values, 1 / 60);
+ assert.equal(values[2], 240); assert.equal(values[3], 0);
+ }
+ }
+});
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/core.zig b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/core.zig
new file mode 100644
index 0000000..9b5e213
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/core.zig
@@ -0,0 +1,999 @@
+//! Browser ABI and regression tests for the explicit-instance scalar engine.
+const std = @import("std");
+const impl = @import("engine.zig");
+const Engine = impl.Engine;
+const Code = impl.Code;
+const max_states = impl.max_states;
+const max_instructions = impl.max_instructions;
+const max_stack = impl.max_stack;
+const no_pc = impl.no_pc;
+const frame_dt = impl.frame_dt;
+const Pending = impl.Pending;
+const Workspace = impl.Workspace;
+const Tables = impl.Tables;
+
+var engine: Engine = .{};
+
+fn public(code: Code) u32 {
+ return @intFromEnum(code);
+}
+
+export fn reset_all() void {
+ engine.reset_all();
+}
+
+export fn begin_program(state_count: u32, input_count: u32) u32 {
+ return engine.begin_program(state_count, input_count);
+}
+
+export fn abort_program() void {
+ engine.abort_program();
+}
+
+export fn put_state(id: u32, initial: f64, old_id: i32) u32 {
+ return engine.put_state(id, initial, old_id);
+}
+
+export fn put_instruction(op: u32, arg: u32, value: f64) u32 {
+ return engine.put_instruction(op, arg, value);
+}
+
+export fn put_handler(event: u32, start: u32, length: u32) u32 {
+ return engine.put_handler(event, start, length);
+}
+
+export fn bind_handler(handler: u32, base: u32, count: u32) u32 {
+ return engine.bind_handler(handler, base, count);
+}
+
+export fn put_pointer_target(input: u32, base: u32, count: u32) u32 {
+ return engine.put_pointer_target(input, base, count);
+}
+
+export fn bind_target_handler(handler: u32, count: u32) u32 {
+ return engine.bind_target_handler(handler, count);
+}
+
+export fn put_restored_value(id: u32, value: f64) u32 {
+ return engine.put_restored_value(id, value);
+}
+
+export fn commit_program() u32 {
+ return engine.commit_program();
+}
+
+export fn commit_restored_program() u32 {
+ return engine.commit_restored_program();
+}
+
+export fn reset_state() void {
+ engine.reset_state();
+}
+
+export fn dispatch(event: u32, dt: f64) u32 {
+ return engine.dispatch(event, dt);
+}
+
+export fn dispatch_point(event: u32, x: f64, y: f64) u32 {
+ return engine.dispatch_point(event, x, y);
+}
+
+export fn dispatch_target(event: u32, target: u32, x: f64, y: f64) u32 {
+ return engine.dispatch_target(event, target, x, y);
+}
+
+export fn dispatch_value(event: u32, value: f64) u32 {
+ return engine.dispatch_value(event, value);
+}
+
+export fn eval_at(start: u32, length: u32) f64 {
+ return engine.eval_at(start, length);
+}
+
+export fn configure_evaluation(cache: u32, history: u32) u32 {
+ return engine.configure_evaluation(cache, history);
+}
+
+export fn forget_evaluation(flags: u32) void {
+ engine.forget_evaluation(flags);
+}
+
+export fn begin_evaluation(limit: u32) void {
+ engine.begin_evaluation(limit);
+}
+
+export fn eval_bounded(start: u32, length: u32) f64 {
+ return engine.eval_bounded(start, length);
+}
+
+export fn evaluation_info(id: u32) u32 {
+ return engine.evaluation_info(id);
+}
+
+export fn get_state(id: u32) f64 {
+ return engine.get_state(id);
+}
+
+export fn info(id: u32) u32 {
+ return engine.info(id);
+}
+
+export fn layout(id: u32) u32 {
+ return engine.layout(id);
+}
+
+// Test helpers describe programs directly; the product compiler is separately
+// tested against this ABI. These tests exercise semantics and failure boundaries.
+fn ok(code: u32) !void {
+ try std.testing.expectEqual(@as(u32, 0), code);
+}
+
+fn emit(op: u32, arg: u32, value: f64) !void {
+ try ok(put_instruction(op, arg, value));
+}
+
+fn counterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try ok(commit_program());
+}
+
+test "blank program, no hidden application behavior, and reset" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 1), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(3));
+ try std.testing.expectEqual(@as(u32, 0), info(5));
+ try std.testing.expect(std.math.isNan(get_state(0)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 0)));
+ reset_state();
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 1), info(4));
+}
+
+test "arbitrary input counter and unused events" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(dispatch(2, 0));
+ try ok(dispatch(0, frame_dt));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(3));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(6));
+}
+
+test "charge and release behavior exists only in the authored instruction program" {
+ reset_all();
+ try ok(begin_program(3, 1)); // held, charge, released_value
+ for (0..3) |i| try ok(put_state(@intCast(i), 0, -1));
+ try emit(0, 0, 1);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 2));
+ try emit(1, 1, 0);
+ try emit(1, 0, 0);
+ try emit(2, 0, 0);
+ try emit(5, 0, 0);
+ try emit(3, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(0, 2, 6));
+ try emit(1, 1, 0);
+ try emit(20, 2, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(2, 8, 6));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ try ok(dispatch(1, 0));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try ok(dispatch(2, 0));
+ try std.testing.expectApproxEqAbs(@as(f64, 0.5), get_state(2), 1e-12);
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+}
+
+test "forward condition executes once and assignments and handlers are ordered" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 1, -1));
+ try ok(put_state(1, 0, -1));
+ try emit(1, 0, 0);
+ try emit(21, 6, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 7);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 0, 6));
+ try emit(1, 1, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 6, 4));
+ try ok(commit_program());
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 14), get_state(1));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 28), get_state(1));
+ try std.testing.expectEqual(@as(u32, 6), info(3));
+}
+
+test "branch to program end is valid" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, 2, 0);
+ try ok(put_handler(0, 0, 2));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+}
+
+test "one event rolls back writes from all handlers on arithmetic failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 8, -1));
+ try emit(0, 0, 99);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 2));
+ try emit(0, 0, 1);
+ try emit(0, 0, 0);
+ try emit(6, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 2, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 8), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(2));
+ try std.testing.expectEqual(@as(u32, 5), info(3));
+}
+
+test "nonfinite arithmetic and invalid clamp fail atomically" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, -1));
+ try emit(0, 0, 1e308);
+ try emit(0, 0, 1e308);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, 0));
+ try emit(0, 0, 1);
+ try emit(0, 0, 10);
+ try emit(0, 0, 2);
+ try emit(19, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 5));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.invalid), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+}
+
+test "stack underflow overflow and leftover values reject the event" {
+ for (0..3) |mode| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ if (mode == 0) {
+ try emit(3, 0, 0);
+ } else {
+ for (0..@as(usize, if (mode == 1) 65 else 1)) |_| try emit(0, 0, 7);
+ }
+ try ok(put_handler(0, 0, engine.workspace.staging.program.instruction_count));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.stack), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ }
+}
+
+test "failed candidates leave active values program revision and diagnostics unchanged" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ const old_revision = info(4);
+ const old_ops = info(3);
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(1, 5, 0);
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try std.testing.expectEqual(old_revision, info(4));
+ try std.testing.expectEqual(old_ops, info(3));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.invalid), put_instruction(100, 0, 0));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+}
+
+test "state preservation mapping reads active values at commit and reset uses new initials" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(2, 0));
+ try ok(put_state(0, 42, -1)); // new symbol first
+ try ok(put_state(1, 100, 0)); // old symbol moves from id 0 to 1
+ try ok(dispatch(1, 0)); // changed after staging began
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 42), get_state(0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 100), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(4));
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try std.testing.expect(std.math.isNan(get_state(0)));
+}
+
+test "capacity boundaries and sticky load errors" {
+ reset_all();
+ try std.testing.expectEqual(public(.capacity), begin_program(33, 0));
+ try std.testing.expectEqual(public(.capacity), begin_program(0, 9));
+ try ok(begin_program(32, 8));
+ for (0..32) |i| try ok(put_state(@intCast(i), @floatFromInt(i), -1));
+ for (0..512) |_| try emit(0, 0, 0);
+ for (0..64) |_| try ok(put_handler(16, 0, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 512), info(6));
+ try std.testing.expectEqual(@as(u32, 64), info(7));
+ try std.testing.expectEqual(@as(f64, 31), get_state(31));
+ try ok(begin_program(0, 0));
+ for (0..512) |_| try emit(0, 0, 0);
+ try std.testing.expectEqual(public(.capacity), put_instruction(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+ try ok(begin_program(0, 0));
+ for (0..64) |_| try ok(put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+}
+
+test "invalid declaration values state mappings event ids and handler ranges" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 0, 0));
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.non_finite), put_state(0, std.math.inf(f64), -1));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 1, -1));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.non_finite), put_instruction(0, 0, std.math.nan(f64)));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.bad_event), put_handler(1, 0, 0));
+ try ok(begin_program(0, 0));
+ try ok(put_handler(0, no_pc, 5));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 1);
+ try ok(put_handler(0, 0, 1));
+ try ok(put_handler(0, 0, 1));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "backward self out-of-handler branches and stores without handlers are rejected" {
+ for ([_]u32{ 0, 1, 3 }) |target| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, target, 0);
+ try emit(0, 0, 7);
+ try ok(put_handler(0, 0, 2));
+ try std.testing.expectEqual(public(.bad_branch), commit_program());
+ }
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try emit(0, 0, 5);
+ try emit(20, 0, 0);
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "event inputs and dt enforce deterministic frame contract" {
+ reset_all();
+ try counterProgram();
+ try std.testing.expectEqual(public(.bad_event), dispatch(3, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch(no_pc, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(1, frame_dt));
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, std.math.nan(f64)));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(u32, 0), info(1));
+ try std.testing.expectEqual(no_pc, info(2));
+}
+
+test "binding evaluation is pure on both success and failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 4, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try emit(2, 0, 0);
+ try emit(0, 0, 0);
+ try emit(21, 7, 0);
+ try ok(put_handler(0, 3, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ const before = engine;
+ try std.testing.expectEqual(@as(f64, 12), eval_at(0, 3));
+ try std.testing.expect(std.math.isNan(eval_at(0, 4))); // attempted store
+ try std.testing.expect(std.math.isNan(eval_at(4, 1))); // dt forbidden in views
+ try std.testing.expect(std.math.isNan(eval_at(5, 2))); // branch forbidden
+ try std.testing.expect(std.math.isNan(eval_at(no_pc, 7)));
+ try std.testing.expect(std.math.isNan(eval_at(0, no_pc)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 2))); // two results
+ // Compare only the live member of the untagged workspace, never padding
+ // or the inactive member's indeterminate storage.
+ try std.testing.expectEqual(before.staging_open, engine.staging_open);
+ inline for (std.meta.fields(Engine)) |field| {
+ if (comptime std.mem.eql(u8, field.name, "workspace")) {
+ if (before.staging_open) {
+ try std.testing.expectEqualDeep(before.workspace.staging, engine.workspace.staging);
+ } else {
+ try std.testing.expectEqualDeep(before.workspace.reuse, engine.workspace.reuse);
+ }
+ } else try std.testing.expectEqualDeep(@field(before, field.name), @field(engine, field.name));
+ }
+}
+
+test "arithmetic comparison logical min max and clamp expression semantics" {
+ const cases = [_]struct { op: u32, a: f64, b: f64, want: f64 }{
+ .{ .op = 3, .a = 7, .b = 2, .want = 9 },
+ .{ .op = 4, .a = 7, .b = 2, .want = 5 },
+ .{ .op = 5, .a = 7, .b = 2, .want = 14 },
+ .{ .op = 6, .a = 7, .b = 2, .want = 3.5 },
+ .{ .op = 8, .a = 2, .b = 7, .want = 1 },
+ .{ .op = 9, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 10, .a = 2, .b = 7, .want = 0 },
+ .{ .op = 11, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 12, .a = 7, .b = 2, .want = 0 },
+ .{ .op = 13, .a = 7, .b = 2, .want = 1 },
+ .{ .op = 14, .a = -7, .b = 2, .want = 1 },
+ .{ .op = 15, .a = 0, .b = 2, .want = 1 },
+ .{ .op = 17, .a = 7, .b = 2, .want = 2 },
+ .{ .op = 18, .a = 7, .b = 2, .want = 7 },
+ };
+ for (cases) |case| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, case.a);
+ try emit(0, 0, case.b);
+ try emit(case.op, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(case.want, eval_at(0, 3));
+ }
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, -8);
+ try emit(7, 0, 0);
+ try emit(0, 0, 0);
+ try emit(0, 0, 5);
+ try emit(19, 0, 0);
+ try emit(16, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 5), eval_at(0, 5));
+ try std.testing.expectEqual(@as(f64, 0), eval_at(0, 6));
+}
+
+test "diagnostic counters wrap without affecting scalar values" {
+ reset_all();
+ try counterProgram();
+ engine.tick = no_pc;
+ engine.successful_events = no_pc;
+ engine.revision = no_pc;
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 99, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 0), info(4));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+}
+
+test "memory accounting separates reserved storage used IR and stack capacity" {
+ reset_all();
+ try std.testing.expectEqual(@as(u32, 0), layout(4));
+ try std.testing.expectEqual(@as(u32, 0), layout(5));
+ try counterProgram();
+ try std.testing.expectEqual(@as(u32, @sizeOf(Engine)), layout(0));
+ try std.testing.expectEqual(@as(u32, 16), layout(2));
+ try std.testing.expectEqual(@as(u32, 16), layout(3));
+ try std.testing.expectEqual(@as(u32, 80), layout(4));
+ try std.testing.expectEqual(@as(u32, 8), layout(5));
+ try std.testing.expectEqual(@as(u32, @sizeOf(Pending)), layout(6));
+ try std.testing.expect(layout(6) >= layout(1) + max_states * @sizeOf(f64));
+ try std.testing.expect(layout(0) >= layout(1) * 2);
+ try std.testing.expectEqual(@as(u32, 512), layout(7));
+ try std.testing.expect(layout(0) + 16 * 1024 < 64 * 1024);
+}
+
+fn displayCounterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try emit(1, 0, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try ok(configure_evaluation(32, 8));
+}
+
+fn boundedEquals(want: f64, start: u32, length: u32) !void {
+ begin_evaluation(100);
+ try std.testing.expectEqual(want, eval_bounded(start, length));
+ try ok(evaluation_info(0));
+}
+
+test "point coordinates are ephemeral event operands and unavailable to pure evaluation" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 0, -1));
+ try ok(put_state(1, 0, -1));
+ const events = [_]u32{ 1, 17, 2, 25 };
+ for (events, 0..) |event, i| {
+ try emit(22, 0, 0);
+ try emit(20, 0, 0);
+ try emit(23, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(event, @intCast(i * 4), 4));
+ }
+ try ok(commit_program());
+ for (events, 0..) |event, i| {
+ const x: f64 = @floatFromInt(i + 10);
+ try ok(dispatch_point(event, x, -x));
+ try std.testing.expectEqual(x, get_state(0));
+ try std.testing.expectEqual(-x, get_state(1));
+ }
+ try std.testing.expectEqual(public(.bad_event), dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 13), get_state(0));
+ const diagnostics = [_]u32{ info(1), info(2), info(3), info(9) };
+ try std.testing.expect(std.math.isNan(eval_at(0, 1)));
+ try std.testing.expectEqualSlices(u32, &diagnostics, &.{ info(1), info(2), info(3), info(9) });
+ try std.testing.expectEqual(@as(u32, 4), info(9));
+}
+
+test "point event ranges reject holes and invalid payloads without committing" {
+ reset_all();
+ try counterProgram();
+ for ([_]u32{ 0, 3, 16, 18, 24, 26, 32, 33, no_pc }) |event|
+ try std.testing.expectEqual(public(.bad_event), dispatch_point(event, 1, 2));
+ try std.testing.expectEqual(public(.non_finite), dispatch_point(1, std.math.nan(f64), 0));
+ try std.testing.expectEqual(public(.non_finite), dispatch_point(1, 0, std.math.inf(f64)));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try ok(begin_program(0, 8));
+ try ok(put_handler(24, 0, 0));
+ try ok(put_handler(32, 0, 0));
+ try ok(commit_program());
+ try ok(dispatch_point(24, 1, 2));
+ try ok(dispatch_point(32, 1, 2));
+ try std.testing.expectEqual(public(.bad_event), dispatch(24, 0));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+}
+
+test "point event failure rolls back writes from earlier matching handlers" {
+ reset_all();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 7, -1));
+ try emit(22, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(17, 0, 2));
+ try emit(23, 0, 0);
+ try emit(0, 0, 0);
+ try emit(6, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(17, 2, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch_point(17, 400, 200));
+ try std.testing.expectEqual(@as(f64, 7), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(2));
+}
+
+test "loading shares storage with records while old events and bindings remain executable" {
+ reset_all();
+ try displayCounterProgram();
+ try boundedEquals(20, 4, 3);
+ try boundedEquals(20, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(9));
+ try emit(1, 0, 0);
+ try ok(dispatch(1, 0)); // Active state advances after staging begins.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(3));
+ try ok(configure_evaluation(32, 1));
+ forget_evaluation(3); // Must not overwrite the pending program.
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(33, 0, 3);
+ try boundedEquals(33, 0, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+}
+
+test "failed and abandoned candidates restore empty reuse storage without changing active work" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(20, 0, 0); // Store without a handler fails validation.
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try boundedEquals(20, 4, 3);
+ try ok(dispatch(1, 0));
+ abort_program();
+ try boundedEquals(22, 4, 3);
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ abort_program(); // Inactive abort is a no-op, including for warmed records.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try std.testing.expectEqual(public(.capacity), begin_program(max_states + 1, 0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+}
+
+test "full pending program survives repeated evaluation configuration record loss and reset" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ for (0..max_instructions) |i| {
+ try emit(0, 0, @floatFromInt(i));
+ try ok(configure_evaluation(32, @intCast(i % 9)));
+ forget_evaluation(3);
+ try boundedEquals(22, 4, 3);
+ }
+ reset_state(); // The pending definition and old-id mapping survive reset.
+ try boundedEquals(20, 4, 3);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, max_instructions), info(6));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try boundedEquals(511, 511, 1);
+ // Safe Zig builds may reserve a hidden union tag in addition to the payload.
+ try std.testing.expect(@sizeOf(Workspace) >= @sizeOf(Pending));
+ try std.testing.expect(@sizeOf(Workspace) <= @sizeOf(Pending) + @alignOf(Pending));
+ try std.testing.expectEqual(@as(u32, @sizeOf(Tables)), evaluation_info(8));
+}
+
+test "restored definition and values commit together without changing reset initializers" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(dispatch(0, frame_dt));
+ const revision = info(4);
+ try ok(begin_program(2, 0));
+ try ok(put_state(0, 100, -1));
+ try ok(put_state(1, 200, -1));
+ try ok(put_restored_value(1, -0.0));
+ try ok(put_restored_value(0, 37));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try ok(dispatch(1, 0)); // Old execution remains independent of pending values.
+ try ok(commit_restored_program());
+ try std.testing.expectEqual(@as(f64, 37), get_state(0));
+ try std.testing.expectEqual(@as(u64, @bitCast(@as(f64, -0.0))), @as(u64, @bitCast(get_state(1))));
+ try std.testing.expectEqual(revision + 1, info(4));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 100), get_state(0));
+ try std.testing.expectEqual(@as(f64, 200), get_state(1));
+}
+
+test "missing duplicate nonfinite or invalid restored candidates leave active work untouched" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ const revision = info(4);
+ for (0..4) |mode| {
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 999, -1));
+ if (mode == 1) {
+ try ok(put_restored_value(0, 30));
+ try std.testing.expectEqual(public(.bad_state), put_restored_value(0, 40));
+ } else if (mode == 2) {
+ try std.testing.expectEqual(public(.non_finite), put_restored_value(0, std.math.inf(f64)));
+ } else if (mode == 3) {
+ try ok(put_restored_value(0, 30));
+ try emit(20, 0, 0); // Invalid store outside a handler.
+ }
+ try std.testing.expect(commit_restored_program() != 0);
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try std.testing.expectEqual(revision, info(4));
+ try std.testing.expectEqual(@as(u32, 1), info(9));
+ abort_program();
+ }
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 100, -1));
+ try ok(put_restored_value(0, 42));
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ abort_program();
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+}
+
+test "empty and full restored programs validate all values and discard old display records" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(configure_evaluation(32, 8));
+ try boundedEquals(20, 4, 3);
+ try ok(begin_program(32, 0));
+ for (0..32) |i| {
+ try ok(put_state(@intCast(i), 0, -1));
+ try ok(put_restored_value(@intCast(i), @floatFromInt(i + 1)));
+ }
+ try emit(1, 31, 0);
+ try ok(commit_restored_program());
+ try boundedEquals(32, 0, 1);
+ try ok(begin_program(0, 0));
+ try ok(commit_restored_program());
+ try std.testing.expectEqual(@as(u32, 0), info(5));
+ try std.testing.expect(std.math.isNan(get_state(0)));
+}
+
+test "shared handler bodies access their own bound state slots in source order" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 10, -1));
+ try ok(put_state(1, 20, -1));
+ try emit(24, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(25, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try ok(bind_handler(0, 0, 1));
+ try ok(put_handler(1, 0, 4));
+ try ok(bind_handler(1, 1, 1));
+ try ok(put_handler(1, 0, 4));
+ try ok(bind_handler(2, 0, 1));
+ try ok(commit_program());
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try std.testing.expectEqual(@as(f64, 21), get_state(1));
+ try std.testing.expectEqual(@as(u32, 4), info(6)); // Three bindings, one body.
+ try std.testing.expectEqual(@as(u32, 12), info(3));
+ const diagnostics = [_]u32{ info(1), info(2), info(3), info(9) };
+ try std.testing.expect(std.math.isNan(eval_at(0, 1)));
+ try std.testing.expectEqualSlices(u32, &diagnostics, &.{ info(1), info(2), info(3), info(9) });
+}
+
+test "failure in a later shared binding rolls back all earlier bindings" {
+ reset_all();
+ try ok(begin_program(4, 1));
+ for ([_]f64{ 8, 2, 12, 0 }, 0..) |value, i| try ok(put_state(@intCast(i), value, -1));
+ try emit(24, 0, 0);
+ try emit(24, 1, 0);
+ try emit(6, 0, 0);
+ try emit(25, 0, 0);
+ for (0..2) |i| {
+ try ok(put_handler(1, 0, 4));
+ try ok(bind_handler(@intCast(i), @intCast(i * 2), 2));
+ }
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 8), get_state(0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(2));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+}
+
+test "shared bindings reject missing arguments partial overlaps and invalid slot mappings" {
+ reset_all();
+ try counterProgram();
+ const revision = info(4);
+ for (0..5) |mode| {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 9, -1));
+ try emit(24, 0, 0);
+ try emit(25, 0, 0);
+ try ok(put_handler(1, 0, 2));
+ switch (mode) {
+ 0 => {}, // Parameter op without a binding.
+ 1 => {
+ try ok(bind_handler(0, 0, 1));
+ try std.testing.expectEqual(public(.invalid), bind_handler(0, 0, 1));
+ },
+ 2 => {
+ try std.testing.expectEqual(public(.bad_state), bind_handler(0, 0, 2));
+ },
+ 3 => {
+ try ok(bind_handler(0, 0, 1));
+ try ok(put_handler(1, 1, 1));
+ try ok(bind_handler(1, 0, 1));
+ },
+ else => {
+ try ok(bind_handler(0, 0, 1));
+ try ok(put_handler(1, 0, 2)); // Another owner missing its mapping.
+ },
+ }
+ try std.testing.expect(commit_program() != 0);
+ abort_program();
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(revision, info(4));
+ }
+}
+
+test "one routed handler changes only its event target and rejects missing or foreign routes" {
+ reset_all();
+ try ok(begin_program(2, 2));
+ try ok(put_state(0, 10, -1));
+ try ok(put_state(1, 20, -1));
+ try ok(put_pointer_target(0, 0, 1));
+ try ok(put_pointer_target(0, 1, 1));
+ try emit(22, 0, 0);
+ try emit(25, 0, 0);
+ try ok(put_handler(17, 0, 2));
+ try ok(bind_target_handler(0, 1));
+ try ok(commit_program());
+ try ok(dispatch_target(17, 1, 77, 0));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(f64, 77), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(3));
+ try std.testing.expectEqual(public(.bad_event), dispatch_point(17, 99, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch(1, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch_target(17, 2, 99, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch_target(17, no_pc, 99, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch_target(18, 0, 99, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch_target(0, 0, 99, 0));
+ try std.testing.expectEqual(public(.non_finite), dispatch_target(17, 0, std.math.inf(f64), 0));
+ try std.testing.expectEqual(@as(u32, 1), info(9));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(f64, 77), get_state(1));
+ try std.testing.expectEqual(@as(u32, 3), layout(8));
+ try std.testing.expectEqual(@as(u32, 2), layout(9));
+ try std.testing.expectEqual(@as(u32, 54), layout(4));
+}
+
+test "routes reject invalid mappings and bindings without replacing active work" {
+ for (0..7) |mode| {
+ reset_all();
+ try counterProgram();
+ try ok(begin_program(2, 2));
+ try ok(put_state(0, 0, -1));
+ try ok(put_state(1, 0, -1));
+ switch (mode) {
+ 0 => try std.testing.expectEqual(public(.bad_state), put_pointer_target(0, 1, 2)),
+ 1 => try std.testing.expectEqual(public(.bad_event), put_pointer_target(2, 0, 1)),
+ 2 => {
+ try ok(put_pointer_target(0, 0, 2));
+ try std.testing.expectEqual(public(.invalid), put_pointer_target(1, 1, 1));
+ },
+ else => {
+ if (mode != 3) try ok(put_pointer_target(0, 0, 1));
+ try emit(0, 0, 2);
+ try emit(25, 0, 0);
+ try ok(put_handler(if (mode == 4) 0 else 1, 0, 2));
+ try ok(bind_target_handler(0, if (mode == 5) 2 else 1));
+ if (mode == 6) try std.testing.expectEqual(public(.invalid), bind_target_handler(0, 1));
+ },
+ }
+ try std.testing.expect(commit_program() != 0);
+ // Active program still executes while the failed candidate occupies staging.
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ abort_program();
+ try std.testing.expectEqual(@as(u32, 0), layout(9));
+ }
+ reset_all();
+ try ok(begin_program(32, 1));
+ for (0..32) |i| try ok(put_state(@intCast(i), 0, -1));
+ for (0..24) |i| try ok(put_pointer_target(0, @intCast(i), 1));
+ try std.testing.expectEqual(public(.capacity), put_pointer_target(0, 24, 1));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+}
+
+test "routed event rollback includes globals and earlier handlers and survives staged route replacement" {
+ reset_all();
+ try ok(begin_program(3, 1));
+ for (0..3) |i| try ok(put_state(@intCast(i), @floatFromInt(i), -1));
+ try ok(put_pointer_target(0, 1, 1));
+ try ok(put_pointer_target(0, 2, 1));
+ try emit(22, 0, 0);
+ try emit(25, 0, 0);
+ try ok(put_handler(17, 0, 2));
+ try ok(bind_target_handler(0, 1));
+ try emit(0, 0, 99);
+ try emit(20, 0, 0);
+ try emit(0, 0, 1);
+ try emit(23, 0, 0);
+ try emit(6, 0, 0);
+ try emit(25, 0, 0);
+ try ok(put_handler(17, 2, 6));
+ try ok(bind_target_handler(1, 1));
+ try ok(commit_program());
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 0, -1));
+ try ok(put_pointer_target(0, 0, 1));
+ try std.testing.expectEqual(public(.division_by_zero), dispatch_target(17, 1, 88, 0));
+ try std.testing.expectEqual(@as(f64, 0), get_state(0));
+ try std.testing.expectEqual(@as(f64, 1), get_state(1));
+ try std.testing.expectEqual(@as(f64, 2), get_state(2));
+ abort_program();
+ try ok(dispatch_target(17, 1, 88, 2));
+ try std.testing.expectEqual(@as(f64, 99), get_state(0));
+ try std.testing.expectEqual(@as(f64, 1), get_state(1));
+ try std.testing.expectEqual(@as(f64, 0.5), get_state(2));
+}
+
+test "scalar inputs carry a finite ephemeral value without adding state or pointer ownership" {
+ reset_all();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 50, -1));
+ try emit(26, 0, 0);
+ try emit(0, 0, 20);
+ try emit(0, 0, 80);
+ try emit(19, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(33, 0, 5));
+ try ok(commit_program());
+ try ok(dispatch_value(33, 200));
+ try std.testing.expectEqual(@as(f64, 80), get_state(0));
+ try ok(dispatch_value(33, -200));
+ try std.testing.expectEqual(@as(f64, 20), get_state(0));
+ try std.testing.expectEqual(@as(u32, 1), info(5));
+ try std.testing.expect(std.math.isNan(eval_at(0, 1)));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+ for ([_]u32{ 0, 1, 17, 25, 34, no_pc }) |event|
+ try std.testing.expectEqual(public(.bad_event), dispatch_value(event, 60));
+ try std.testing.expectEqual(public(.bad_event), dispatch(33, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch_point(33, 60, 0));
+ try std.testing.expectEqual(public(.non_finite), dispatch_value(33, std.math.nan(f64)));
+ try std.testing.expectEqual(@as(f64, 20), get_state(0));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+}
+
+test "scalar input failure rolls back earlier writes and old scalar events survive staging" {
+ reset_all();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(26, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(33, 0, 2));
+ try emit(0, 0, 1);
+ try emit(26, 0, 0);
+ try emit(6, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(33, 2, 4));
+ try ok(commit_program());
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.division_by_zero), dispatch_value(33, 0));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try ok(dispatch_value(33, 2));
+ try std.testing.expectEqual(@as(f64, 0.5), get_state(0));
+ abort_program();
+ try std.testing.expectEqual(@as(u32, 1), info(9));
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/engine.zig b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/engine.zig
new file mode 100644
index 0000000..5410a7c
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/engine.zig
@@ -0,0 +1,601 @@
+//! Bounded scalar interaction VM experiment. No application behavior is built in.
+//! Same source for native tests and freestanding Wasm; no allocation or imports.
+//! Error ABI: 0 ok, 1 capacity, 2 invalid, 3 stack, 4 division by zero,
+//! 5 non-finite, 6 bad branch, 7 bad state, 8 bad frame, 9 bad event.
+//! Counters wrap modulo 2^32. Failed loads preserve the active program, authored
+//! state and event diagnostics; beginning a load discards optional reuse records.
+//! Failed events preserve values and counters but
+//! report their error and attempted instruction count. Binding evaluation is pure.
+
+pub const std = @import("std");
+pub const Evaluation = @import("evaluation.zig").Evaluation;
+pub const Tables = @import("evaluation.zig").Tables;
+
+pub const max_states = 32;
+pub const max_inputs = 8;
+pub const max_instructions = 512;
+pub const max_handlers = 64;
+pub const max_stack = 64;
+pub const max_pointer_targets = 24;
+pub const event_target = 255;
+pub const no_pc = std.math.maxInt(u32);
+pub const frame_dt: f64 = 1.0 / 60.0;
+
+pub const Code = enum(u32) {
+ ok = 0,
+ capacity = 1,
+ invalid = 2,
+ stack = 3,
+ division_by_zero = 4,
+ non_finite = 5,
+ bad_branch = 6,
+ bad_state = 7,
+ bad_frame = 8,
+ bad_event = 9,
+ work_limit = 10,
+};
+
+pub const Instruction = struct { value: f64 = 0, op: u32 = 0, arg: u32 = 0 };
+pub const Handler = struct {
+ event: u32 = 0,
+ start: u32 = 0,
+ length: u32 = 0,
+ parameter_base: u8 = 0,
+ parameter_count: u8 = 0,
+};
+pub const PointerTarget = struct { input: u8, base: u8, count: u8 };
+pub const Program = struct {
+ pointer_targets: [max_pointer_targets]PointerTarget = undefined,
+ pointer_target_count: u8 = 0,
+ routed_inputs: u8 = 0,
+ instructions: [max_instructions]Instruction = @splat(.{}),
+ handlers: [max_handlers]Handler = @splat(.{}),
+ initials: [max_states]f64 = @splat(0),
+ old_ids: [max_states]i32 = @splat(-1),
+ declared: [max_states]bool = @splat(false),
+ state_count: u32 = 0,
+ input_count: u32 = 0,
+ instruction_count: u32 = 0,
+ handler_count: u32 = 0,
+};
+// Loading a definition/optional restored values and result reuse are mutually exclusive phases. Current execution
+// always reads active, and can recompute bindings while this workspace loads.
+pub const Pending = struct {
+ program: Program = .{},
+ values: [max_states]f64 = @splat(0),
+ value_mask: u32 = 0,
+};
+pub const Workspace = union { staging: Pending, reuse: Tables };
+pub const Engine = struct {
+ active: Program = .{},
+ workspace: Workspace = .{ .reuse = .{} },
+ values: [max_states]f64 = @splat(0),
+ staging_open: bool = false,
+ staging_error: Code = .ok,
+ tick: u32 = 0,
+ last_error: Code = .ok,
+ error_pc: u32 = no_pc,
+ last_ops: u32 = 0,
+ revision: u32 = 0,
+ successful_events: u32 = 0,
+ evaluation: Evaluation = .{},
+
+ pub fn reuseTables(self: *Engine) ?*Tables {
+ return if (self.staging_open) null else &self.workspace.reuse;
+ }
+
+ pub fn finishStaging(self: *Engine) void {
+ self.workspace = .{ .reuse = .{} };
+ self.staging_open = false;
+ self.evaluation.forget(null, 3);
+ }
+
+ pub fn public(_: *Engine, code: Code) u32 {
+ return @intFromEnum(code);
+ }
+
+ pub fn stagingFail(self: *Engine, code: Code) u32 {
+ if (self.staging_error == .ok) self.staging_error = code;
+ return self.public(code);
+ }
+
+ pub fn reset_all(self: *Engine) void {
+ self.* = .{};
+ }
+
+ pub fn begin_program(self: *Engine, state_count: u32, input_count: u32) u32 {
+ self.staging_error = .ok;
+ if (state_count > max_states or input_count > max_inputs) {
+ if (self.staging_open) self.finishStaging();
+ return self.stagingFail(.capacity);
+ }
+ self.workspace = .{ .staging = .{ .program = .{ .state_count = state_count, .input_count = input_count } } };
+ self.staging_open = true;
+ self.evaluation.forget(null, 3);
+ return 0;
+ }
+
+ pub fn abort_program(self: *Engine) void {
+ if (self.staging_open) self.finishStaging();
+ }
+
+ pub fn put_state(self: *Engine, id: u32, initial: f64, old_id: i32) u32 {
+ if (!self.staging_open) return self.stagingFail(.invalid);
+ if (id >= self.workspace.staging.program.state_count or old_id < -1) return self.stagingFail(.bad_state);
+ if (old_id >= 0 and @as(u32, @intCast(old_id)) >= self.active.state_count) return self.stagingFail(.bad_state);
+ if (self.workspace.staging.program.declared[id]) return self.stagingFail(.bad_state);
+ if (!std.math.isFinite(initial)) return self.stagingFail(.non_finite);
+ self.workspace.staging.program.initials[id] = initial;
+ self.workspace.staging.program.old_ids[id] = old_id;
+ self.workspace.staging.program.declared[id] = true;
+ return 0;
+ }
+
+ pub fn put_instruction(self: *Engine, op: u32, arg: u32, value: f64) u32 {
+ if (!self.staging_open) return self.stagingFail(.invalid);
+ const p = &self.workspace.staging.program;
+ if (p.instruction_count == max_instructions) return self.stagingFail(.capacity);
+ if (op > 26) return self.stagingFail(.invalid);
+ if (!std.math.isFinite(value)) return self.stagingFail(.non_finite);
+ p.instructions[p.instruction_count] = .{ .op = op, .arg = arg, .value = value };
+ p.instruction_count += 1;
+ return 0;
+ }
+
+ pub fn validEvent(_: *Engine, event: u32, inputs: u32) bool {
+ return event <= inputs * 2 or
+ (event >= 17 and event - 17 < inputs) or
+ (event >= 25 and event - 25 < inputs) or
+ (event >= 33 and event - 33 < inputs);
+ }
+
+ pub fn put_handler(self: *Engine, event: u32, start: u32, length: u32) u32 {
+ if (!self.staging_open) return self.stagingFail(.invalid);
+ const p = &self.workspace.staging.program;
+ if (p.handler_count == max_handlers) return self.stagingFail(.capacity);
+ if (!self.validEvent(event, p.input_count)) return self.stagingFail(.bad_event);
+ // Handlers may be appended before their instructions, so range validation
+ // is deferred until commit. Arithmetic remains guarded there.
+ p.handlers[p.handler_count] = .{ .event = event, .start = start, .length = length };
+ p.handler_count += 1;
+ return 0;
+ }
+
+ pub fn bind_handler(self: *Engine, handler: u32, base: u32, count: u32) u32 {
+ if (!self.staging_open) return self.stagingFail(.invalid);
+ const p = &self.workspace.staging.program;
+ if (handler >= p.handler_count or base >= p.state_count) return self.stagingFail(.bad_state);
+ if (count == 0 or count > 8 or count > p.state_count - base) return self.stagingFail(.bad_state);
+ const h = &p.handlers[handler];
+ if (h.parameter_count != 0) return self.stagingFail(.invalid);
+ h.parameter_base = @intCast(base);
+ h.parameter_count = @intCast(count);
+ return 0;
+ }
+
+ // A route is a static program-local index, never a persistent object identity.
+ pub fn put_pointer_target(self: *Engine, input: u32, base: u32, count: u32) u32 {
+ if (!self.staging_open) return self.stagingFail(.invalid);
+ const p = &self.workspace.staging.program;
+ if (p.pointer_target_count == max_pointer_targets) return self.stagingFail(.capacity);
+ if (input >= p.input_count) return self.stagingFail(.bad_event);
+ if (base >= p.state_count or count == 0 or count > 8 or count > p.state_count - base) return self.stagingFail(.bad_state);
+ for (p.pointer_targets[0..p.pointer_target_count]) |t| {
+ if (base < @as(u32, t.base) + t.count and t.base < base + count) return self.stagingFail(.invalid);
+ if (t.input == input and t.count != count) return self.stagingFail(.bad_state);
+ }
+ p.pointer_targets[p.pointer_target_count] = .{ .input = @intCast(input), .base = @intCast(base), .count = @intCast(count) };
+ p.pointer_target_count += 1;
+ p.routed_inputs |= @as(u8, 1) << @as(u3, @intCast(input));
+ return 0;
+ }
+
+ pub fn bind_target_handler(self: *Engine, handler: u32, count: u32) u32 {
+ if (!self.staging_open) return self.stagingFail(.invalid);
+ const p = &self.workspace.staging.program;
+ if (handler >= p.handler_count or count == 0 or count > 8) return self.stagingFail(.bad_state);
+ const h = &p.handlers[handler];
+ if (h.parameter_count != 0) return self.stagingFail(.invalid);
+ h.parameter_base = event_target;
+ h.parameter_count = @intCast(count);
+ return 0;
+ }
+
+ pub fn eventInput(_: *Engine, event: u32) u32 {
+ return if (event >= 33) event - 33 else if (event >= 25) event - 25 else if (event >= 17) event - 17 else (event - 1) / 2;
+ }
+
+ pub fn validate(self: *Engine, p: *const Program) Code {
+ for (0..p.state_count) |id| {
+ if (!p.declared[id]) return .bad_state;
+ if (!std.math.isFinite(p.initials[id])) return .non_finite;
+ const old_id = p.old_ids[id];
+ if (old_id < -1 or (old_id >= 0 and @as(u32, @intCast(old_id)) >= self.active.state_count)) return .bad_state;
+ }
+ for (p.handlers[0..p.handler_count], 0..) |h, i| {
+ if (!self.validEvent(h.event, p.input_count)) return .bad_event;
+ if (h.start > p.instruction_count or h.length > p.instruction_count - h.start) return .invalid;
+ if (h.parameter_base == event_target) {
+ if (h.event == 0 or h.event >= 33 or h.parameter_count == 0 or h.parameter_count > 8) return .bad_event;
+ var found = false;
+ for (p.pointer_targets[0..p.pointer_target_count]) |t| {
+ if (t.input != self.eventInput(h.event)) continue;
+ if (t.count != h.parameter_count) return .bad_state;
+ found = true;
+ }
+ if (!found) return .bad_event;
+ } else if (h.parameter_count > 8 or @as(u32, h.parameter_base) + h.parameter_count > p.state_count) return .bad_state;
+ if (h.length == 0) continue;
+ for (p.handlers[0..i]) |other| {
+ if (other.length != 0 and h.start < other.start + other.length and other.start < h.start + h.length) {
+ // Bound rules may share exactly one body, never a partial range.
+ if (h.parameter_count == 0 or h.parameter_count != other.parameter_count or
+ h.start != other.start or h.length != other.length) return .invalid;
+ }
+ }
+ }
+ for (p.instructions[0..p.instruction_count], 0..) |inst, pc| {
+ if (inst.op > 26) return .invalid;
+ if (!std.math.isFinite(inst.value)) return .non_finite;
+ if ((inst.op == 1 or inst.op == 20) and inst.arg >= p.state_count) return .bad_state;
+ if (inst.op >= 20) {
+ var owner: ?Handler = null;
+ for (p.handlers[0..p.handler_count]) |h| {
+ if (pc >= h.start and pc < h.start + h.length) {
+ owner = h;
+ if (inst.op == 21 and (inst.arg <= pc or inst.arg > h.start + h.length)) return .bad_branch;
+ if ((inst.op == 24 or inst.op == 25) and inst.arg >= h.parameter_count) return .bad_state;
+ }
+ }
+ if (owner == null) return .invalid;
+ }
+ }
+ return .ok;
+ }
+
+ pub fn put_restored_value(self: *Engine, id: u32, value: f64) u32 {
+ if (!self.staging_open) return self.stagingFail(.invalid);
+ const pending = &self.workspace.staging;
+ if (id >= pending.program.state_count) return self.stagingFail(.bad_state);
+ const bit = @as(u32, 1) << @as(u5, @intCast(id));
+ if (pending.value_mask & bit != 0) return self.stagingFail(.bad_state);
+ if (!std.math.isFinite(value)) return self.stagingFail(.non_finite);
+ pending.values[id] = value;
+ pending.value_mask |= bit;
+ return 0;
+ }
+
+ pub fn commit_program(self: *Engine) u32 {
+ return self.commit(false);
+ }
+
+ pub fn commit_restored_program(self: *Engine) u32 {
+ return self.commit(true);
+ }
+
+ pub fn commit(self: *Engine, restoring: bool) u32 {
+ if (!self.staging_open) return self.public(.invalid);
+ if (self.staging_error != .ok) return self.public(self.staging_error);
+ const count = self.workspace.staging.program.state_count;
+ const expected: u32 = if (count == 32) 0xffffffff else (@as(u32, 1) << @as(u5, @intCast(count))) - 1;
+ if (self.workspace.staging.value_mask != (if (restoring) expected else @as(u32, 0)))
+ return self.stagingFail(.bad_state);
+ const code = self.validate(&self.workspace.staging.program);
+ if (code != .ok) return self.stagingFail(code);
+ var next: [max_states]f64 = @splat(0);
+ for (0..self.workspace.staging.program.state_count) |i| {
+ const old_id = self.workspace.staging.program.old_ids[i];
+ next[i] = if (restoring) self.workspace.staging.values[i] else if (old_id < 0) self.workspace.staging.program.initials[i] else self.values[@intCast(old_id)];
+ }
+ self.active = self.workspace.staging.program;
+ self.values = next;
+ self.finishStaging();
+ if (restoring) {
+ self.tick = 0;
+ self.successful_events = 0;
+ }
+ self.revision +%= 1;
+ self.last_error = .ok;
+ self.error_pc = no_pc;
+ self.last_ops = 0;
+ return 0;
+ }
+
+ pub fn reset_state(self: *Engine) void {
+ self.values = self.active.initials;
+ self.evaluation.forget(self.reuseTables(), 3);
+ self.tick = 0;
+ self.successful_events = 0;
+ self.last_error = .ok;
+ self.error_pc = no_pc;
+ self.last_ops = 0;
+ }
+
+ const Execution = struct {
+ code: Code = .ok,
+ pc: u32 = no_pc,
+ ops: u32 = 0,
+ value: f64 = 0,
+ dependencies: u32 = 0,
+ };
+
+ pub fn executionFail(_: *Engine, result: Execution, code: Code, pc: u32) Execution {
+ var failed = result;
+ failed.code = code;
+ failed.pc = pc;
+ return failed;
+ }
+
+ const EventContext = struct { dt: f64 = 0, x: f64 = 0, y: f64 = 0, parameter_base: u32 = 0, parameter_count: u32 = 0, point: bool = false, scalar: bool = false, target: ?u32 = null };
+
+ pub fn execute(self: *Engine, values: *[max_states]f64, start: u32, length: u32, context: EventContext, pure: bool, budget: ?*Evaluation) Execution {
+ const p = &self.active;
+ var result: Execution = .{};
+ if (start > p.instruction_count or length > p.instruction_count - start) return self.executionFail(result, .invalid, no_pc);
+ const end = start + length;
+ var stack: [max_stack]f64 = undefined;
+ var sp: usize = 0;
+ var pc = start;
+ while (pc < end) {
+ if (budget) |b| {
+ if (!b.spend()) return self.executionFail(result, .work_limit, pc);
+ b.instructions += 1;
+ }
+ const inst = p.instructions[pc];
+ result.ops += 1;
+ if (pure and (inst.op == 2 or inst.op >= 20)) return self.executionFail(result, .invalid, pc);
+ const need: usize = switch (inst.op) {
+ 0...2, 22, 23, 24, 26 => 0,
+ 7, 16, 20, 21, 25 => 1,
+ 3...6, 8...15, 17, 18 => 2,
+ 19 => 3,
+ else => return self.executionFail(result, .invalid, pc),
+ };
+ if (sp < need) return self.executionFail(result, .stack, pc);
+ if (inst.op <= 2 or inst.op == 22 or inst.op == 23 or inst.op == 24 or inst.op == 26) {
+ if (sp == max_stack) return self.executionFail(result, .stack, pc);
+ const value = switch (inst.op) {
+ 0 => inst.value,
+ 1 => blk: {
+ if (inst.arg >= p.state_count) return self.executionFail(result, .bad_state, pc);
+ if (budget != null and budget.?.cache_count != 0)
+ result.dependencies |= @as(u32, 1) << @as(u5, @intCast(inst.arg));
+ break :blk values[inst.arg];
+ },
+ 24 => blk: {
+ if (inst.arg >= context.parameter_count) return self.executionFail(result, .bad_state, pc);
+ const state = context.parameter_base + inst.arg;
+ if (state >= p.state_count) return self.executionFail(result, .bad_state, pc);
+ break :blk values[state];
+ },
+ 26 => if (context.scalar) context.x else return self.executionFail(result, .bad_event, pc),
+ 2 => context.dt,
+ 22, 23 => if (!context.point) return self.executionFail(result, .bad_event, pc) else if (inst.op == 22) context.x else context.y,
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return self.executionFail(result, .non_finite, pc);
+ stack[sp] = value;
+ sp += 1;
+ } else if (inst.op == 20 or inst.op == 25) {
+ if (inst.op == 25 and inst.arg >= context.parameter_count) return self.executionFail(result, .bad_state, pc);
+ const state = if (inst.op == 25) context.parameter_base + inst.arg else inst.arg;
+ if (state >= p.state_count) return self.executionFail(result, .bad_state, pc);
+ sp -= 1;
+ values[state] = stack[sp];
+ } else if (inst.op == 21) {
+ if (inst.arg <= pc or inst.arg > end) return self.executionFail(result, .bad_branch, pc);
+ sp -= 1;
+ if (stack[sp] == 0) {
+ pc = inst.arg;
+ continue;
+ }
+ } else {
+ const a = stack[sp - need];
+ const b = if (need >= 2) stack[sp - need + 1] else 0;
+ const value: f64 = switch (inst.op) {
+ 3 => a + b,
+ 4 => a - b,
+ 5 => a * b,
+ 6 => blk: {
+ if (b == 0) return self.executionFail(result, .division_by_zero, pc);
+ break :blk a / b;
+ },
+ 7 => -a,
+ 8 => @floatFromInt(@intFromBool(a < b)),
+ 9 => @floatFromInt(@intFromBool(a <= b)),
+ 10 => @floatFromInt(@intFromBool(a > b)),
+ 11 => @floatFromInt(@intFromBool(a >= b)),
+ 12 => @floatFromInt(@intFromBool(a == b)),
+ 13 => @floatFromInt(@intFromBool(a != b)),
+ 14 => @floatFromInt(@intFromBool(a != 0 and b != 0)),
+ 15 => @floatFromInt(@intFromBool(a != 0 or b != 0)),
+ 16 => @floatFromInt(@intFromBool(a == 0)),
+ 17 => @min(a, b),
+ 18 => @max(a, b),
+ 19 => blk: {
+ const hi = stack[sp - 1];
+ if (b > hi) return self.executionFail(result, .invalid, pc);
+ break :blk @max(b, @min(a, hi));
+ },
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return self.executionFail(result, .non_finite, pc);
+ sp = sp - need + 1;
+ stack[sp - 1] = value;
+ }
+ pc += 1;
+ }
+ if (sp != @as(usize, if (pure) 1 else 0)) return self.executionFail(result, .stack, end);
+ if (pure) result.value = stack[0];
+ return result;
+ }
+
+ pub fn dispatch(self: *Engine, event: u32, dt: f64) u32 {
+ return self.dispatchContext(event, .{ .dt = dt });
+ }
+
+ pub fn dispatch_point(self: *Engine, event: u32, x: f64, y: f64) u32 {
+ return self.dispatchContext(event, .{ .x = x, .y = y, .point = true });
+ }
+
+ pub fn dispatch_target(self: *Engine, event: u32, target: u32, x: f64, y: f64) u32 {
+ return self.dispatchContext(event, .{ .x = x, .y = y, .point = true, .target = target });
+ }
+
+ pub fn dispatch_value(self: *Engine, event: u32, value: f64) u32 {
+ return self.dispatchContext(event, .{ .x = value, .scalar = true });
+ }
+
+ pub fn dispatchContext(self: *Engine, event: u32, context: EventContext) u32 {
+ const dt = context.dt;
+ self.last_error = .ok;
+ self.error_pc = no_pc;
+ self.last_ops = 0;
+ if (!self.validEvent(event, self.active.input_count) or
+ ((context.point or context.scalar) and event == 0) or
+ (context.scalar != (event >= 33)) or
+ (!context.point and !context.scalar and event > self.active.input_count * 2))
+ {
+ self.last_error = .bad_event;
+ return self.public(.bad_event);
+ }
+ if (!std.math.isFinite(dt) or !std.math.isFinite(context.x) or !std.math.isFinite(context.y)) {
+ self.last_error = .non_finite;
+ return self.public(.non_finite);
+ }
+ if ((event == 0 and dt != frame_dt) or (event != 0 and dt != 0)) {
+ self.last_error = .bad_frame;
+ return self.public(.bad_frame);
+ }
+ var route: ?PointerTarget = null;
+ const routed = event != 0 and self.active.routed_inputs & (@as(u8, 1) << @as(u3, @intCast(self.eventInput(event)))) != 0;
+ if (context.target) |target| {
+ if (!routed or target >= self.active.pointer_target_count or
+ self.active.pointer_targets[target].input != self.eventInput(event))
+ {
+ self.last_error = .bad_event;
+ return self.public(.bad_event);
+ }
+ route = self.active.pointer_targets[target];
+ } else if (routed) {
+ self.last_error = .bad_event;
+ return self.public(.bad_event);
+ }
+ var pending = self.values;
+ for (self.active.handlers[0..self.active.handler_count]) |h| {
+ if (h.event != event) continue;
+ var bound = context;
+ bound.parameter_base = if (h.parameter_base == event_target) route.?.base else h.parameter_base;
+ bound.parameter_count = h.parameter_count;
+ const result = self.execute(&pending, h.start, h.length, bound, false, null);
+ self.last_ops += result.ops;
+ if (result.code != .ok) {
+ self.last_error = result.code;
+ self.error_pc = result.pc;
+ return self.public(result.code);
+ }
+ }
+ if (self.evaluation.cache_count != 0 and !self.staging_open) {
+ var changed: u32 = 0;
+ for (0..self.active.state_count) |i| {
+ // Signed zero is observable, so compare representation, not ==.
+ if (@as(u64, @bitCast(pending[i])) != @as(u64, @bitCast(self.values[i])))
+ changed |= @as(u32, 1) << @as(u5, @intCast(i));
+ }
+ self.evaluation.changed(self.reuseTables(), changed);
+ }
+ self.values = pending;
+ if (event == 0) self.tick +%= 1;
+ self.successful_events +%= 1;
+ return 0;
+ }
+
+ pub fn eval_at(self: *Engine, start: u32, length: u32) f64 {
+ // A local copy makes the no-side-effects boundary structural as well as
+ // checked by the pure instruction filter. Diagnostics are never touched.
+ var values = self.values;
+ const result = self.execute(&values, start, length, .{}, true, null);
+ return if (result.code == .ok) result.value else std.math.nan(f64);
+ }
+
+ pub fn configure_evaluation(self: *Engine, cache: u32, history: u32) u32 {
+ return if (self.evaluation.configure(self.reuseTables(), cache, history)) 0 else self.public(.capacity);
+ }
+
+ pub fn forget_evaluation(self: *Engine, flags: u32) void {
+ self.evaluation.forget(self.reuseTables(), flags);
+ }
+
+ pub fn begin_evaluation(self: *Engine, limit: u32) void {
+ self.evaluation.begin(limit);
+ }
+
+ pub fn eval_bounded(self: *Engine, start: u32, length: u32) f64 {
+ const e = &self.evaluation;
+ e.status = 0;
+ // Charge per request as well as per instruction and validation probe.
+ if (!e.spend()) return std.math.nan(f64);
+ if (e.lookup(self.reuseTables(), start, length)) |value| return value;
+ if (e.status != 0) return std.math.nan(f64);
+ var values = self.values;
+ const result = self.execute(&values, start, length, .{}, true, e);
+ e.status = self.public(result.code);
+ if (result.code != .ok) return std.math.nan(f64);
+ e.remember(self.reuseTables(), start, length, result.value, result.dependencies);
+ return result.value;
+ }
+
+ pub fn evaluation_info(self: *Engine, id: u32) u32 {
+ const e = &self.evaluation;
+ return switch (id) {
+ 0 => e.status,
+ 1 => e.work,
+ 2 => e.instructions,
+ 3 => e.hits,
+ 4 => e.cache_count,
+ 5 => e.history_count,
+ 6 => @sizeOf(Evaluation) + @sizeOf(Tables),
+ 7 => @sizeOf(Evaluation),
+ 8 => @sizeOf(Tables),
+ 9 => @intFromBool(!self.staging_open),
+ 10 => @sizeOf(Workspace) - @sizeOf(Pending),
+ else => 0,
+ };
+ }
+
+ pub fn get_state(self: *Engine, id: u32) f64 {
+ return if (id < self.active.state_count) self.values[id] else std.math.nan(f64);
+ }
+
+ pub fn info(self: *Engine, id: u32) u32 {
+ return switch (id) {
+ 0 => self.tick,
+ 1 => self.public(self.last_error),
+ 2 => self.error_pc,
+ 3 => self.last_ops,
+ 4 => self.revision,
+ 5 => self.active.state_count,
+ 6 => self.active.instruction_count,
+ 7 => self.active.handler_count,
+ 8 => self.active.input_count,
+ 9 => self.successful_events,
+ else => 0,
+ };
+ }
+
+ pub fn layout(self: *Engine, id: u32) u32 {
+ return switch (id) {
+ 0 => @sizeOf(Engine),
+ 1 => @sizeOf(Program),
+ 2 => @sizeOf(Instruction),
+ 3 => @sizeOf(Handler),
+ 4 => self.active.instruction_count * @sizeOf(Instruction) + self.active.handler_count * @sizeOf(Handler) + @as(u32, self.active.pointer_target_count) * @sizeOf(PointerTarget),
+ 5 => self.active.state_count * @sizeOf(f64),
+ 6 => @sizeOf(Pending),
+ 7 => max_stack * @sizeOf(f64),
+ 8 => @sizeOf(PointerTarget),
+ 9 => self.active.pointer_target_count,
+ else => 0,
+ };
+ }
+};
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/evaluation-check.mjs b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/evaluation-check.mjs
new file mode 100644
index 0000000..bb3d616
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/evaluation-check.mjs
@@ -0,0 +1,76 @@
+// A bounded adoption check on existing authored files, not a platform benchmark.
+import assert from 'node:assert/strict';
+import { readFile, writeFile } from 'node:fs/promises';
+import { createHash } from 'node:crypto';
+import { platform, release, arch, cpus } from 'node:os';
+import { performance } from 'node:perf_hooks';
+import { createRuntime } from './web/runtime.mjs';
+
+const args = process.argv.slice(2);
+if (args.length !== 2 || args[0] !== '--output') throw new Error('Use --output NEW.json');
+const root = new URL('.', import.meta.url);
+const wasm = await readFile(new URL('web/core.wasm', root));
+const hash = bytes => createHash('sha256').update(bytes).digest('hex');
+const evidence = { purpose: 'Choose whether to enable binding reuse by default',
+ utc: new Date().toISOString(), host: { platform: platform(), release: release(),
+ arch: arch(), cpu: cpus()[0]?.model, node: process.version, affinityControlled: false },
+ sourceSha256: {}, cases: [],
+ timingScope: 'Event dispatch plus snapshot construction. Excludes loading, compilation, DOM, equality checks and statistics reads. Five batch samples are not latency percentiles.',
+};
+for (const path of ['web/core.wasm', 'core.zig', 'evaluation.zig', 'web/runtime.mjs',
+ 'web/compiler.mjs', 'evaluation-check.mjs', 'evaluation.test.mjs'])
+ evidence.sourceSha256[path] = hash(await readFile(new URL(path, root)));
+
+for (const name of ['counter', 'toggle', 'charge']) {
+ const path = `examples/${name}.pure`;
+ const source = await readFile(new URL(path, root), 'utf8');
+ evidence.sourceSha256[path] = hash(source);
+ const plain = await createRuntime(wasm), cached = await createRuntime(wasm, {cacheEntries:32});
+ const program = plain.apply(source); cached.apply(source);
+ const input = program.inputs[0]?.name;
+ const trace = Array.from({length:100}, () => 'show');
+ for (let i = 0; i < 600; i++) {
+ if (input && i % 60 === 0) trace.push('down');
+ if (input && i % 60 === 15) trace.push('up');
+ trace.push('frame');
+ }
+ function action(runtime, kind) {
+ if (kind === 'frame') assert.equal(runtime.frame(), 0);
+ else if (kind !== 'show') assert.equal(runtime[kind](input, 'replay'), 0);
+ return runtime.snapshot();
+ }
+ const totals = {plain:{work:0,instructions:0,cacheHits:0},cached:{work:0,instructions:0,cacheHits:0}};
+ for (const kind of trace) {
+ const left = action(plain, kind), right = action(cached, kind);
+ assert.equal(left.lastError, 0); assert.deepEqual(right, left);
+ for (const [key, runtime] of Object.entries({plain,cached})) {
+ const stats = runtime.evaluationStats();
+ for (const field of Object.keys(totals[key])) totals[key][field] += stats[field];
+ }
+ }
+ function replay(runtime) {
+ runtime.reset();
+ for (const kind of trace) {
+ if (kind === 'frame') runtime.frame();
+ else if (kind !== 'show') runtime[kind](input, 'replay');
+ runtime.snapshot();
+ }
+ }
+ for (let warm = 0; warm < 3; warm++) { replay(plain); replay(cached); }
+ const samples = [];
+ for (let trial = 0; trial < 5; trial++) {
+ for (const key of trial % 2 ? ['cached','plain'] : ['plain','cached']) {
+ const runtime = key === 'plain' ? plain : cached;
+ const start = performance.now(); replay(runtime);
+ samples.push({trial,key,elapsedMs:performance.now()-start});
+ }
+ assert.deepEqual(cached.snapshot(), plain.snapshot());
+ }
+ const median = key => samples.filter(s=>s.key===key).map(s=>s.elapsedMs).sort((a,b)=>a-b)[2];
+ evidence.cases.push({name,checkedSnapshots:trace.length, traceSha256:hash(JSON.stringify(trace)),
+ totals, memory:cached.snapshot().memory, evaluation:cached.evaluationStats(), samples,
+ medianMs:{plain:median('plain'),cached:median('cached')}, cachedOverPlain:median('cached')/median('plain')});
+}
+await writeFile(args[1], JSON.stringify(evidence,null,2)+'\n', {flag:'wx'});
+for (const c of evidence.cases) console.log(JSON.stringify({name:c.name,checkedSnapshots:c.checkedSnapshots,
+ totals:c.totals,medianMs:c.medianMs,cachedOverPlain:c.cachedOverPlain}));
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/evaluation.test.mjs b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/evaluation.test.mjs
new file mode 100644
index 0000000..21a9590
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/evaluation.test.mjs
@@ -0,0 +1,133 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { createRuntime } from './web/runtime.mjs';
+
+const wasm = await readFile(new URL('./web/core.wasm', import.meta.url));
+const make = (options = {}) => createRuntime(wasm, options);
+const source = `state x = 3
+state spare = 0
+input next = KeyN
+input other = KeyO
+input fail = KeyF
+text value(0, 0, (x + 1) * (x + 2) / 2)
+on next.down { x = x + 0.25 }
+on other.down { spare = spare + 1 }
+on fail.down { x = 900; spare = 1 / 0 }
+`;
+
+test('cache reuse, unrelated changes and discarded records preserve authored values', async () => {
+ const cached = await make({ cacheEntries: 32 }), plain = await make();
+ cached.apply(source); plain.apply(source);
+ assert.deepEqual(cached.snapshot(), plain.snapshot());
+ assert.deepEqual(cached.snapshot(), plain.snapshot());
+ assert(cached.evaluationStats().cacheHits > 0);
+ assert(cached.evaluationStats().instructions < plain.evaluationStats().instructions);
+ for (let i = 0; i < 100; i++) {
+ const name = i % 3 ? 'other' : 'next';
+ for (const runtime of [cached, plain]) { runtime.down(name); runtime.up(name); }
+ if (i % 7 === 0) cached.forgetEvaluation('history');
+ if (i % 11 === 0) cached.forgetEvaluation('values');
+ if (i % 13 === 0) cached.forgetEvaluation('all');
+ assert.deepEqual(cached.snapshot(), plain.snapshot());
+ }
+ // Omit observations long enough to overwrite every change record.
+ for (let i = 0; i < 20; i++) for (const runtime of [cached, plain]) {
+ runtime.down('next'); runtime.up('next');
+ }
+ assert.deepEqual(cached.snapshot(), plain.snapshot());
+});
+
+test('committed changes only: failed events and failed edits preserve reusable output', async () => {
+ const cached = await make({ cacheEntries: 32 }); cached.apply(source);
+ const before = cached.snapshot();
+ cached.down('fail'); cached.up('fail');
+ const failed = cached.snapshot();
+ assert.deepEqual(failed.states, before.states);
+ assert.deepEqual(failed.views, before.views);
+ assert.equal(failed.lastError, 4);
+ assert.equal(cached.evaluationStats().instructions, 0);
+ assert.throws(() => cached.apply('state x = missing'));
+ assert.deepEqual(cached.snapshot(), failed);
+ assert.equal(cached.evaluationStats().instructions, 0);
+});
+
+test('program edits, state reordering and reset cannot reuse old definitions', async () => {
+ const cached = await make({ cacheEntries: 32 }), plain = await make();
+ for (const r of [cached, plain]) { r.apply(source); r.down('next'); r.up('next'); r.snapshot(); }
+ const changed = source.replace('state x = 3\nstate spare = 0', 'state spare = 9\nstate x = 300')
+ .replace('(x + 1) * (x + 2) / 2', '(x + 10) * (x + 20) / 2');
+ for (const r of [cached, plain]) r.apply(changed);
+ assert.deepEqual(cached.snapshot(), plain.snapshot());
+ for (const r of [cached, plain]) r.reset();
+ assert.deepEqual(cached.snapshot(), plain.snapshot());
+ assert.equal(cached.snapshot().states.x, 300);
+});
+
+test('finite float semantics include signed zero, high state bit and arithmetic errors', async () => {
+ const cached = await make({ cacheEntries: 32 }), plain = await make();
+ const program = `${Array.from({ length: 32 }, (_, i) => `state s${i} = 0`).join('\n')}
+input flip = KeyF
+text sign(0, 0, s31)
+text error(0, 0, 1 / s31)
+on flip.down { s31 = -s31 }
+on flip.up { s31 = 2 }
+`;
+ for (const r of [cached, plain]) r.apply(program);
+ assert.deepEqual(cached.snapshot(), plain.snapshot());
+ for (const r of [cached, plain]) r.down('flip');
+ assert.deepEqual(cached.snapshot(), plain.snapshot());
+ assert(Object.is(cached.snapshot().views[0].value, -0));
+ assert.equal(cached.snapshot().displayError.code, 100);
+ for (const r of [cached, plain]) r.up('flip');
+ assert.deepEqual(cached.snapshot(), plain.snapshot());
+ assert.equal(cached.snapshot().displayError, null);
+});
+
+test('one budget covers the whole scene; failures publish no partial scene or state rollback', async () => {
+ const r = await make({ cacheEntries: 32 }); r.apply(source);
+ r.down('next'); r.up('next');
+ const good = r.snapshot();
+ r.configureEvaluation({ workLimit: 5 });
+ const limited = r.snapshot();
+ assert.equal(limited.lastError, 101);
+ assert.deepEqual(limited.views, []);
+ assert.deepEqual(limited.states, good.states);
+ assert.equal(limited.successfulEvents, good.successfulEvents);
+ assert(r.evaluationStats().work <= 5);
+ r.down('fail'); r.up('fail');
+ assert.equal(r.snapshot().lastError, 4);
+ assert.equal(r.snapshot().displayError.code, 101);
+ r.configureEvaluation({ workLimit: 2048 });
+ assert.deepEqual(r.snapshot().views, good.views);
+ assert.equal(r.snapshot().lastError, 4);
+});
+
+test('small caches, no history and every small work limit give exact results or explicit failure', async () => {
+ const plain = await make(); plain.apply(source);
+ for (const cacheEntries of [0, 1, 32]) for (const historyEntries of [0, 1, 8]) {
+ const r = await make({ cacheEntries, historyEntries }); r.apply(source);
+ for (let workLimit = 0; workLimit < 100; workLimit++) {
+ r.configureEvaluation({ workLimit });
+ const actual = r.snapshot();
+ assert(r.evaluationStats().work <= workLimit);
+ if (actual.lastError) {
+ assert.equal(actual.lastError, 101);
+ assert.deepEqual(actual.views, []);
+ } else assert.deepEqual(actual, plain.snapshot());
+ }
+ }
+});
+
+test('invalid settings and forget names preserve policy and state', async () => {
+ const r = await make({ cacheEntries: 32 }); r.apply(source);
+ const before = r.snapshot(), policy = r.evaluationStats();
+ for (const option of [{cacheEntries:33}, {historyEntries:9}, {workLimit:-1},
+ {workLimit:0x100000000}, {workLimit:NaN}, {cacheEntries:0.5}]) {
+ assert.throws(() => r.configureEvaluation(option));
+ assert.deepEqual(r.evaluationStats(), policy);
+ }
+ for (const name of ['unknown', '__proto__', 'constructor']) assert.throws(() => r.forgetEvaluation(name));
+ assert.deepEqual(r.snapshot(), before);
+ assert.equal(r.snapshot().memory.linearBytes, 65536);
+});
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/evaluation.zig b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/evaluation.zig
new file mode 100644
index 0000000..56081d9
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/evaluation.zig
@@ -0,0 +1,164 @@
+//! Disposable binding results and change history. No event state lives here.
+const std = @import("std");
+
+const Memo = struct {
+ value: f64 = 0,
+ revision: u64 = 0,
+ dependencies: u32 = 0,
+ start: u32 = 0,
+ length: u32 = 0,
+};
+const Change = struct { revision: u64 = 0, mask: u32 = 0 };
+
+pub const Tables = struct {
+ memo: [32]Memo = @splat(.{}),
+ history: [8]Change = @splat(.{}),
+};
+
+pub const Evaluation = struct {
+ pub const max_cache = 32;
+ pub const max_history = 8;
+ revision: u64 = 1,
+ floor: u64 = 1,
+ cache_count: u32 = 0,
+ history_count: u32 = 8,
+ next_change: u32 = 0,
+ limit: u32 = 0,
+ work: u32 = 0,
+ instructions: u32 = 0,
+ hits: u32 = 0,
+ status: u32 = 0,
+
+ pub fn configure(self: *Evaluation, tables: ?*Tables, cache: u32, history: u32) bool {
+ if (cache > max_cache or history > max_history) return false;
+ self.cache_count = cache;
+ self.history_count = history;
+ self.forget(tables, 3);
+ return true;
+ }
+
+ pub fn forget(self: *Evaluation, tables: ?*Tables, flags: u32) void {
+ if (flags & 1 != 0) if (tables) |t| { t.memo = @splat(.{}); };
+ if (flags & 2 != 0) {
+ if (tables) |t| t.history = @splat(.{});
+ self.floor = self.revision;
+ self.next_change = 0;
+ }
+ }
+
+ pub fn changed(self: *Evaluation, tables: ?*Tables, mask: u32) void {
+ const t = tables orelse return;
+ if (mask == 0 or self.cache_count == 0) return;
+ if (self.revision == std.math.maxInt(u64)) {
+ self.revision = 1;
+ self.forget(t, 3);
+ }
+ self.revision += 1;
+ if (self.history_count == 0) {
+ self.floor = self.revision;
+ return;
+ }
+ const change = &t.history[self.next_change];
+ self.floor = @max(self.floor, change.revision);
+ change.* = .{ .revision = self.revision, .mask = mask };
+ self.next_change = (self.next_change + 1) % self.history_count;
+ }
+
+ pub fn begin(self: *Evaluation, limit: u32) void {
+ self.limit = limit;
+ self.work = 0;
+ self.instructions = 0;
+ self.hits = 0;
+ self.status = 0;
+ }
+
+ pub fn spend(self: *Evaluation) bool {
+ if (self.work == self.limit) {
+ self.status = 10;
+ return false;
+ }
+ self.work += 1;
+ return true;
+ }
+
+ fn slot(self: *const Evaluation, start: u32, length: u32) u32 {
+ // One candidate, including on misses; no scan proportional to cache size.
+ return (start ^ (start >> 5) ^ (length *% 0x9e3779b9)) % self.cache_count;
+ }
+
+ pub fn lookup(self: *Evaluation, tables: ?*Tables, start: u32, length: u32) ?f64 {
+ const t = tables orelse return null;
+ if (self.cache_count == 0) return null;
+ if (!self.spend()) return null;
+ const memo = &t.memo[self.slot(start, length)];
+ if (memo.revision == 0 or memo.start != start or memo.length != length) return null;
+ // Successful pure constants need no input-change evidence. A program
+ // replacement still clears them, just like every other binding memo.
+ if (memo.dependencies != 0 and memo.revision < self.floor) return null;
+ if (memo.dependencies != 0 and memo.revision != self.revision) {
+ for (t.history[0..self.history_count]) |change| {
+ if (!self.spend()) return null;
+ if (change.revision > memo.revision and change.mask & memo.dependencies != 0) return null;
+ }
+ }
+ memo.revision = self.revision;
+ self.hits += 1;
+ return memo.value;
+ }
+
+ pub fn remember(self: *Evaluation, tables: ?*Tables, start: u32, length: u32, value: f64, dependencies: u32) void {
+ const t = tables orelse return;
+ if (self.cache_count == 0) return;
+ t.memo[self.slot(start, length)] = .{ .start = start, .length = length,
+ .value = value, .dependencies = dependencies, .revision = self.revision };
+ }
+};
+
+test "record loss cannot authorize an old memo; unrelated changes can" {
+ var tables: Tables = .{};
+ var e: Evaluation = .{};
+ try std.testing.expect(e.configure(&tables, 1, 1));
+ e.begin(100);
+ e.remember(&tables, 0, 3, 12, 1);
+ e.changed(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, 12), e.lookup(&tables, 0, 3));
+ e.changed(&tables, 2);
+ e.changed(&tables, 2); // The certificate now predates the surviving history.
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 12, 1);
+ e.changed(&tables, 1);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 20, 1);
+ e.changed(&tables, 2);
+ e.forget(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 20, 1);
+ e.revision = std.math.maxInt(u64);
+ e.changed(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+}
+
+test "budget, collisions, invalid settings, and disabled history are conservative" {
+ var tables: Tables = .{};
+ var e: Evaluation = .{};
+ try std.testing.expect(e.configure(&tables, 1, 0));
+ e.begin(100);
+ e.remember(&tables, 0, 3, 12, 1);
+ e.remember(&tables, 1, 3, 15, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ try std.testing.expectEqual(@as(?f64, 15), e.lookup(&tables, 1, 3));
+ e.changed(&tables, 0x80000000);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 1, 3));
+ try std.testing.expect(!e.configure(&tables, 33, 0));
+ try std.testing.expectEqual(@as(u32, 1), e.cache_count);
+ e.begin(0);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 1, 3));
+ try std.testing.expectEqual(@as(u32, 10), e.status);
+ try std.testing.expectEqual(@as(u32, 0), e.work);
+ e.begin(100);
+ e.remember(&tables, 2, 1, 99, 0);
+ e.changed(&tables, 1);
+ e.forget(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, 99), e.lookup(&tables, 2, 1));
+ try std.testing.expectEqual(@as(u32, 1), e.work);
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/charge.pure b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/charge.pure
new file mode 100644
index 0000000..eac55c4
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/charge.pure
@@ -0,0 +1,34 @@
+# All holding, charging, gravity, jumping, and floor behavior is authored here.
+state holding = 0
+state charge = 0
+state y = 240
+state velocity = 0
+input jump = Space
+box floor(40, 261, 560, 4, "#356548")
+circle actor(180, y, 20, "#c78638")
+box meter(80, 80, charge * 200, 12, "#c78638")
+text amount(80, 60, charge, "Charge: ")
+on jump.down {
+ if y >= 240 {
+ holding = 1
+ charge = 0
+ }
+}
+on jump.up {
+ if holding {
+ velocity = -(160 + charge * 300)
+ holding = 0
+ charge = 0
+ }
+}
+on frame {
+ if holding {
+ charge = clamp(charge + dt, 0, 1)
+ }
+ velocity = velocity + 600 * dt
+ y = y + velocity * dt
+ if y >= 240 {
+ y = 240
+ velocity = 0
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/counter.pure b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/counter.pure
new file mode 100644
index 0000000..ca5f18e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/counter.pure
@@ -0,0 +1,8 @@
+# Space or the tap control increments a state that is displayed and drawn.
+state count = 0
+input tap = Space
+text label(80, 90, count, "Count: ")
+box tile(80, 150, 40 + min(count, 20) * 8, 60, "#356548")
+on tap.down {
+ count = count + 1
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/direct.pure b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/direct.pure
new file mode 100644
index 0000000..b29721c
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/direct.pure
@@ -0,0 +1,47 @@
+# 도형을 잡은 위치를 유지해 이동합니다. 취소하면 잡기 전 위치로 돌아갑니다.
+state x = 80
+state y = 160
+state grab_x = 0
+state grab_y = 0
+state start_x = 80
+state start_y = 160
+state dragging = 0
+
+input drag = pointer(tile)
+input left = ArrowLeft
+input right = ArrowRight
+
+box left_area(20, 100, 280, 220, "#233a31")
+box right_area(340, 100, 280, 220, "#343b25")
+box tile(x, y, 56, 44, "#c9f08b")
+text horizontal(30, 45, x, "x: ")
+text vertical(180, 45, y, "y: ")
+text region(350, 70, x >= 320, "오른쪽 영역: ")
+text active(30, 75, dragging, "잡고 있음: ")
+
+on drag.down {
+ start_x = x
+ start_y = y
+ grab_x = event_x - x
+ grab_y = event_y - y
+ dragging = 1
+}
+on drag.move {
+ x = clamp(event_x - grab_x, 20, 564)
+ y = clamp(event_y - grab_y, 100, 276)
+}
+on drag.up {
+ # 놓기는 마지막으로 표시한 위치를 확정합니다.
+ dragging = 0
+}
+on drag.cancel {
+ x = start_x
+ y = start_y
+ dragging = 0
+}
+on left.down {
+ if dragging == 0 { x = max(20, x - 10) }
+}
+on right.down {
+ if dragging == 0 { x = min(564, x + 10) }
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/document.pure b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/document.pure
new file mode 100644
index 0000000..6d78f8c
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/document.pure
@@ -0,0 +1,6 @@
+# 웹 컨트롤이 요청한 분할 비율을 받습니다. 레이아웃과 문서는 브라우저가 소유합니다.
+state ratio = 50
+input split = value
+input reset = KeyR
+on split.change { ratio = clamp(event_value, 20, 80) }
+on reset.down { ratio = 50 }
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/layout.pure b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/layout.pure
new file mode 100644
index 0000000..41cf149
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/layout.pure
@@ -0,0 +1,92 @@
+# 세 도형을 배치하고 배치 저장으로 결과를 보관하세요.
+# 마지막으로 잡은 도형은 방향키로 10씩 이동합니다.
+state selected = 0
+input left = ArrowLeft
+input right = ArrowRight
+input up = ArrowUp
+input down = ArrowDown
+state x1 = 80
+state y1 = 120
+state ox1 = 0
+state oy1 = 0
+state sx1 = 80
+state sy1 = 120
+state held1 = 0
+input drag1 = pointer(tile1)
+state x2 = 280
+state y2 = 160
+state ox2 = 0
+state oy2 = 0
+state sx2 = 280
+state sy2 = 160
+state held2 = 0
+input drag2 = pointer(tile2)
+state x3 = 450
+state y3 = 220
+state ox3 = 0
+state oy3 = 0
+state sx3 = 450
+state sy3 = 220
+state held3 = 0
+input drag3 = pointer(tile3)
+box board(20,90,600,240,"#233a31")
+text help(25,40,selected,"선택한 도형: ")
+box tile1(x1,y1,56,44,"#c9f08b")
+box tile2(x2,y2,56,44,"#74c4d4")
+box tile3(x3,y3,56,44,"#dfb184")
+on drag1.down {
+ selected = 1
+ sx1 = x1; sy1 = y1
+ ox1 = event_x - x1; oy1 = event_y - y1
+ held1 = 1
+}
+on drag1.move {
+ x1 = clamp(event_x - ox1, 20, 564)
+ y1 = clamp(event_y - oy1, 90, 286)
+}
+on drag1.up { held1 = 0 }
+on drag1.cancel { x1 = sx1; y1 = sy1; held1 = 0 }
+on drag2.down {
+ selected = 2
+ sx2 = x2; sy2 = y2
+ ox2 = event_x - x2; oy2 = event_y - y2
+ held2 = 1
+}
+on drag2.move {
+ x2 = clamp(event_x - ox2, 20, 564)
+ y2 = clamp(event_y - oy2, 90, 286)
+}
+on drag2.up { held2 = 0 }
+on drag2.cancel { x2 = sx2; y2 = sy2; held2 = 0 }
+on drag3.down {
+ selected = 3
+ sx3 = x3; sy3 = y3
+ ox3 = event_x - x3; oy3 = event_y - y3
+ held3 = 1
+}
+on drag3.move {
+ x3 = clamp(event_x - ox3, 20, 564)
+ y3 = clamp(event_y - oy3, 90, 286)
+}
+on drag3.up { held3 = 0 }
+on drag3.cancel { x3 = sx3; y3 = sy3; held3 = 0 }
+on left.down {
+ if selected == 1 && held1 == 0 { x1 = clamp(x1 + -10, 20, 564) }
+ if selected == 2 && held2 == 0 { x2 = clamp(x2 + -10, 20, 564) }
+ if selected == 3 && held3 == 0 { x3 = clamp(x3 + -10, 20, 564) }
+}
+on right.down {
+ if selected == 1 && held1 == 0 { x1 = clamp(x1 + 10, 20, 564) }
+ if selected == 2 && held2 == 0 { x2 = clamp(x2 + 10, 20, 564) }
+ if selected == 3 && held3 == 0 { x3 = clamp(x3 + 10, 20, 564) }
+}
+on up.down {
+ if selected == 1 && held1 == 0 { y1 = clamp(y1 + -10, 90, 286) }
+ if selected == 2 && held2 == 0 { y2 = clamp(y2 + -10, 90, 286) }
+ if selected == 3 && held3 == 0 { y3 = clamp(y3 + -10, 90, 286) }
+}
+on down.down {
+ if selected == 1 && held1 == 0 { y1 = clamp(y1 + 10, 90, 286) }
+ if selected == 2 && held2 == 0 { y2 = clamp(y2 + 10, 90, 286) }
+ if selected == 3 && held3 == 0 { y3 = clamp(y3 + 10, 90, 286) }
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/routed-layout.pure b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/routed-layout.pure
new file mode 100644
index 0000000..8933c70
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/routed-layout.pure
@@ -0,0 +1,70 @@
+# 하나의 포인터 입력과 네 반응을 모든 도형이 공유합니다.
+state selected = 0
+record Tile {
+ x = 0; y = 0
+ ox = 0; oy = 0
+ sx = 0; sy = 0
+ held = 0; id = 0
+}
+item green = Tile(x=80, y=120, sx=80, sy=120, id=1)
+item blue = Tile(x=280, y=160, sx=280, sy=160, id=2)
+item amber = Tile(x=450, y=220, sx=450, sy=220, id=3)
+input left = ArrowLeft
+input right = ArrowRight
+input up = ArrowUp
+input down = ArrowDown
+input drag = pointer {
+ green_box: green
+ blue_box: blue
+ amber_box: amber
+}
+box board(20,90,600,240,"#233a31")
+text help(25,40,selected,"선택한 도형: ")
+box green_box(green.x,green.y,56,44,"#c9f08b")
+box blue_box(blue.x,blue.y,56,44,"#74c4d4")
+box amber_box(amber.x,amber.y,56,44,"#dfb184")
+
+rule grab(t: Tile) {
+ selected = t.id
+ t.sx = t.x; t.sy = t.y
+ t.ox = event_x - t.x; t.oy = event_y - t.y
+ t.held = 1
+}
+rule move(t: Tile) {
+ t.x = clamp(event_x - t.ox, 20, 564)
+ t.y = clamp(event_y - t.oy, 90, 286)
+}
+rule drop(t: Tile) { t.held = 0 }
+rule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }
+rule go_left(t: Tile) {
+ if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }
+}
+rule go_right(t: Tile) {
+ if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }
+}
+rule go_up(t: Tile) {
+ if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }
+}
+rule go_down(t: Tile) {
+ if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }
+}
+
+on drag.down use grab(drag.target)
+on drag.move use move(drag.target)
+on drag.up use drop(drag.target)
+on drag.cancel use cancel(drag.target)
+
+on left.down use go_left(green)
+on right.down use go_right(green)
+on up.down use go_up(green)
+on down.down use go_down(green)
+
+on left.down use go_left(blue)
+on right.down use go_right(blue)
+on up.down use go_up(blue)
+on down.down use go_down(blue)
+
+on left.down use go_left(amber)
+on right.down use go_right(amber)
+on up.down use go_up(amber)
+on down.down use go_down(amber)
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/shared-layout.pure b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/shared-layout.pure
new file mode 100644
index 0000000..38c001d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/shared-layout.pure
@@ -0,0 +1,75 @@
+# 대상별 값은 item에, 공통 반응은 rule에 한 번만 작성합니다.
+state selected = 0
+record Tile {
+ x = 0; y = 0
+ ox = 0; oy = 0
+ sx = 0; sy = 0
+ held = 0; id = 0
+}
+item green = Tile(x=80, y=120, sx=80, sy=120, id=1)
+item blue = Tile(x=280, y=160, sx=280, sy=160, id=2)
+item amber = Tile(x=450, y=220, sx=450, sy=220, id=3)
+input left = ArrowLeft
+input right = ArrowRight
+input up = ArrowUp
+input down = ArrowDown
+input drag_green = pointer(green_box)
+input drag_blue = pointer(blue_box)
+input drag_amber = pointer(amber_box)
+box board(20,90,600,240,"#233a31")
+text help(25,40,selected,"선택한 도형: ")
+box green_box(green.x,green.y,56,44,"#c9f08b")
+box blue_box(blue.x,blue.y,56,44,"#74c4d4")
+box amber_box(amber.x,amber.y,56,44,"#dfb184")
+
+rule grab(t: Tile) {
+ selected = t.id
+ t.sx = t.x; t.sy = t.y
+ t.ox = event_x - t.x; t.oy = event_y - t.y
+ t.held = 1
+}
+rule move(t: Tile) {
+ t.x = clamp(event_x - t.ox, 20, 564)
+ t.y = clamp(event_y - t.oy, 90, 286)
+}
+rule drop(t: Tile) { t.held = 0 }
+rule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }
+rule go_left(t: Tile) {
+ if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }
+}
+rule go_right(t: Tile) {
+ if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }
+}
+rule go_up(t: Tile) {
+ if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }
+}
+rule go_down(t: Tile) {
+ if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }
+}
+
+on drag_green.down use grab(green)
+on drag_green.move use move(green)
+on drag_green.up use drop(green)
+on drag_green.cancel use cancel(green)
+on left.down use go_left(green)
+on right.down use go_right(green)
+on up.down use go_up(green)
+on down.down use go_down(green)
+
+on drag_blue.down use grab(blue)
+on drag_blue.move use move(blue)
+on drag_blue.up use drop(blue)
+on drag_blue.cancel use cancel(blue)
+on left.down use go_left(blue)
+on right.down use go_right(blue)
+on up.down use go_up(blue)
+on down.down use go_down(blue)
+
+on drag_amber.down use grab(amber)
+on drag_amber.move use move(amber)
+on drag_amber.up use drop(amber)
+on drag_amber.cancel use cancel(amber)
+on left.down use go_left(amber)
+on right.down use go_right(amber)
+on up.down use go_up(amber)
+on down.down use go_down(amber)
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/toggle.pure b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/toggle.pure
new file mode 100644
index 0000000..a29f38f
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/toggle.pure
@@ -0,0 +1,17 @@
+# Space toggles the circle. Right arrow moves the tile.
+state visible = 1
+state position = 120
+input toggle = Space
+input move = ArrowRight
+circle marker(position, 140, visible * 24, "#c78638")
+box tile(position - 24, 190, 48, 48, "#356548")
+text value(80, 80, visible, "Visible: ")
+on toggle.down {
+ visible = !visible
+}
+on move.down {
+ position = position + 40
+ if position > 560 {
+ position = 80
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/manage.py b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/manage.py
new file mode 100644
index 0000000..ef89d42
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/manage.py
@@ -0,0 +1,127 @@
+#!/usr/bin/env python3
+"""Build the authoring VM or serve its local browser adapter."""
+import argparse
+from datetime import datetime, timezone
+from functools import partial
+import hashlib
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+import json
+from pathlib import Path
+import shutil
+import subprocess
+
+ROOT = Path(__file__).resolve().parent
+
+
+def sync_examples():
+ target = ROOT / 'web/examples'
+ target.mkdir(exist_ok=True)
+ for source in (ROOT / 'examples').glob('*.pure'):
+ shutil.copyfile(source, target / source.name)
+
+
+def build(zig):
+ compiler = shutil.which(zig)
+ if not compiler:
+ raise SystemExit('Zig 0.15.2가 필요합니다. --zig /path/to/zig로 지정할 수 있습니다.')
+ version = subprocess.check_output([compiler, 'version'], text=True).strip()
+ if version != '0.15.2':
+ raise SystemExit(f'이 실험은 Zig 0.15.2로 고정했습니다. 현재 {version}')
+ folder = ROOT / 'build'
+ folder.mkdir(exist_ok=True)
+ cache = ['--cache-dir', str(folder / 'cache'), '--global-cache-dir', str(folder / 'global-cache')]
+ source = ROOT / 'core.zig'
+ result = subprocess.run([compiler, 'test', str(source), *cache], capture_output=True, text=True)
+ print(result.stdout + result.stderr, end='', flush=True)
+ result.check_returncode()
+ flags = ['-target', 'wasm32-freestanding', '-O', 'ReleaseSafe', '-fstrip', '-fno-entry',
+ '-rdynamic', '--export-memory', '--stack', '16384',
+ '--initial-memory=65536', '--max-memory=65536']
+ artifact = ROOT / 'web/core.wasm'
+ subprocess.run([compiler, 'build-exe', str(source), *flags, f'-femit-bin={artifact}', *cache], check=True)
+ sync_examples()
+ sha = lambda path: hashlib.sha256(path.read_bytes()).hexdigest()
+ (folder / 'build.json').write_text(json.dumps({
+ 'builtUtc': datetime.now(timezone.utc).isoformat(), 'zigVersion': version,
+ 'sourceSha256': sha(source), 'wasmSha256': sha(artifact),
+ 'evaluationSourceSha256': sha(ROOT / 'evaluation.zig'),
+ 'engineSourceSha256': sha(ROOT / 'engine.zig'),
+ 'wasmFileBytes': artifact.stat().st_size, 'flags': flags,
+ 'nativeTests': result.stdout + result.stderr,
+ }, indent=2) + '\n')
+ print(f'Built {artifact} ({artifact.stat().st_size:,} bytes)')
+
+
+def native(zig, source, output, cc):
+ compiler = shutil.which(zig)
+ if not compiler or subprocess.check_output([compiler, 'version'], text=True).strip() != '0.15.2':
+ raise SystemExit('Zig 0.15.2가 필요합니다. --zig /path/to/zig로 지정하세요.')
+ folder = ROOT / 'build/native'
+ folder.mkdir(parents=True, exist_ok=True)
+ cache = ['--cache-dir', str(ROOT / 'build/cache'), '--global-cache-dir', str(ROOT / 'build/global-cache')]
+ library = folder / 'libpure-scalar.a'
+ subprocess.run([compiler, 'build-lib', str(ROOT / 'native.zig'), '-O', 'ReleaseSafe',
+ '-fno-compiler-rt', f'-femit-bin={library}', *cache], check=True)
+ prepared = folder / 'application.c'
+ subprocess.run(['node', str(ROOT / 'native/prepare.mjs'), str(source.resolve()), str(prepared)], check=True)
+ result = output.resolve() if output else folder / 'pure-native'
+ result.parent.mkdir(parents=True, exist_ok=True)
+ connections = ROOT.parent / 'connections'
+ inputs = [prepared, ROOT / 'native/main.c', ROOT / 'native/app.c', ROOT / 'native/plan.c',
+ *(connections / name for name in ('runtime.c', 'evaluate.c', 'store.c', 'store-port.c')), library]
+ flags = ['-std=c17', '-O2', '-g', '-Wall', '-Wextra', '-Wpedantic', '-Werror']
+ subprocess.run([cc, *flags, '-I' + str(ROOT / 'native'), *(str(p) for p in inputs), '-lm', '-o', str(result)], check=True)
+ sha = lambda path: hashlib.sha256(path.read_bytes()).hexdigest()
+ (folder / 'build.json').write_text(json.dumps({'source': str(source.resolve()), 'sourceSha256': sha(source),
+ 'zigVersion': '0.15.2', 'zigFlags': ['-O', 'ReleaseSafe', '-fno-compiler-rt'], 'cFlags': flags,
+ 'binary': str(result), 'binarySha256': sha(result), 'librarySha256': sha(library),
+ 'preparedSha256': sha(prepared),
+ 'implementationSha256': {str(p.relative_to(ROOT.parent.parent)): sha(p) for p in [
+ ROOT / 'engine.zig', ROOT / 'evaluation.zig', ROOT / 'native.zig', ROOT / 'native/prepare.mjs',
+ ROOT / 'native/scalar.h', ROOT / 'native/plan.h', ROOT / 'native/app.h', ROOT / 'web/compiler.mjs',
+ *(p for p in inputs if p.suffix == '.c' and p != prepared)]}}, indent=2) + '\n')
+ print(f'Built native application: {result}')
+
+
+class Handler(SimpleHTTPRequestHandler):
+ extensions_map = {**SimpleHTTPRequestHandler.extensions_map, '.wasm': 'application/wasm', '.mjs': 'text/javascript', '.pure': 'text/plain'}
+
+ def end_headers(self):
+ self.send_header('Cache-Control', 'no-store')
+ super().end_headers()
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ sub = parser.add_subparsers(dest='action', required=True)
+ b = sub.add_parser('build')
+ b.add_argument('--zig', default='zig')
+ n = sub.add_parser('native', help='prepare an existing .pure definition as a standalone native CLI')
+ n.add_argument('source', type=Path)
+ n.add_argument('--zig', default='zig')
+ n.add_argument('--cc', default='cc')
+ n.add_argument('--output', type=Path)
+ s = sub.add_parser('serve')
+ s.add_argument('--port', type=int, default=8766)
+ args = parser.parse_args()
+ if args.action == 'build':
+ build(args.zig)
+ return
+ if args.action == 'native':
+ native(args.zig, args.source, args.output, args.cc)
+ return
+ if not 1 <= args.port <= 65535:
+ parser.error('port must be 1..65535')
+ if not (ROOT / 'web/core.wasm').is_file():
+ parser.error('먼저 manage.py build를 실행하세요.')
+ sync_examples()
+ with ThreadingHTTPServer(('127.0.0.1', args.port), partial(Handler, directory=str(ROOT / 'web'))) as server:
+ print(f'Pure workbench: http://127.0.0.1:{args.port}', flush=True)
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+
+
+if __name__ == '__main__':
+ main()
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native.zig b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native.zig
new file mode 100644
index 0000000..30d15d4
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native.zig
@@ -0,0 +1,127 @@
+//! Native C ABI. The caller supplies aligned instance storage; no global VM,
+//! allocator, OS call or browser dependency is part of this selected module.
+const Engine = @import("engine.zig").Engine;
+
+// Keep ReleaseSafe bounds/overflow checks. Unexpected internal invariant
+// failures trap without importing a process-wide stack unwinder or I/O layer.
+// Expected authored errors still return the ordinary scalar status codes.
+pub const panic = @import("std").debug.no_panic;
+
+export fn pure_scalar_bytes() usize {
+ return @sizeOf(Engine);
+}
+export fn pure_scalar_alignment() usize {
+ return @alignOf(Engine);
+}
+
+export fn pure_scalar_initialize(memory: ?*anyopaque, size: usize) ?*Engine {
+ const pointer = memory orelse return null;
+ if (size < @sizeOf(Engine) or @intFromPtr(pointer) % @alignOf(Engine) != 0) return null;
+ const instance: *Engine = @ptrCast(@alignCast(pointer));
+ instance.* = .{};
+ return instance;
+}
+
+export fn pure_scalar_reset_all(instance: *Engine) void {
+ instance.reset_all();
+}
+
+export fn pure_scalar_begin_program(instance: *Engine, state_count: u32, input_count: u32) u32 {
+ return instance.begin_program(state_count, input_count);
+}
+
+export fn pure_scalar_abort_program(instance: *Engine) void {
+ instance.abort_program();
+}
+
+export fn pure_scalar_put_state(instance: *Engine, id: u32, initial: f64, old_id: i32) u32 {
+ return instance.put_state(id, initial, old_id);
+}
+
+export fn pure_scalar_put_instruction(instance: *Engine, op: u32, arg: u32, value: f64) u32 {
+ return instance.put_instruction(op, arg, value);
+}
+
+export fn pure_scalar_put_handler(instance: *Engine, event: u32, start: u32, length: u32) u32 {
+ return instance.put_handler(event, start, length);
+}
+
+export fn pure_scalar_bind_handler(instance: *Engine, handler: u32, base: u32, count: u32) u32 {
+ return instance.bind_handler(handler, base, count);
+}
+
+export fn pure_scalar_put_pointer_target(instance: *Engine, input: u32, base: u32, count: u32) u32 {
+ return instance.put_pointer_target(input, base, count);
+}
+
+export fn pure_scalar_bind_target_handler(instance: *Engine, handler: u32, count: u32) u32 {
+ return instance.bind_target_handler(handler, count);
+}
+
+export fn pure_scalar_put_restored_value(instance: *Engine, id: u32, value: f64) u32 {
+ return instance.put_restored_value(id, value);
+}
+
+export fn pure_scalar_commit_program(instance: *Engine) u32 {
+ return instance.commit_program();
+}
+
+export fn pure_scalar_commit_restored_program(instance: *Engine) u32 {
+ return instance.commit_restored_program();
+}
+
+export fn pure_scalar_reset_state(instance: *Engine) void {
+ instance.reset_state();
+}
+
+export fn pure_scalar_dispatch(instance: *Engine, event: u32, dt: f64) u32 {
+ return instance.dispatch(event, dt);
+}
+
+export fn pure_scalar_dispatch_point(instance: *Engine, event: u32, x: f64, y: f64) u32 {
+ return instance.dispatch_point(event, x, y);
+}
+
+export fn pure_scalar_dispatch_target(instance: *Engine, event: u32, target: u32, x: f64, y: f64) u32 {
+ return instance.dispatch_target(event, target, x, y);
+}
+
+export fn pure_scalar_dispatch_value(instance: *Engine, event: u32, value: f64) u32 {
+ return instance.dispatch_value(event, value);
+}
+
+export fn pure_scalar_eval_at(instance: *Engine, start: u32, length: u32) f64 {
+ return instance.eval_at(start, length);
+}
+
+export fn pure_scalar_configure_evaluation(instance: *Engine, cache: u32, history: u32) u32 {
+ return instance.configure_evaluation(cache, history);
+}
+
+export fn pure_scalar_forget_evaluation(instance: *Engine, flags: u32) void {
+ instance.forget_evaluation(flags);
+}
+
+export fn pure_scalar_begin_evaluation(instance: *Engine, limit: u32) void {
+ instance.begin_evaluation(limit);
+}
+
+export fn pure_scalar_eval_bounded(instance: *Engine, start: u32, length: u32) f64 {
+ return instance.eval_bounded(start, length);
+}
+
+export fn pure_scalar_evaluation_info(instance: *Engine, id: u32) u32 {
+ return instance.evaluation_info(id);
+}
+
+export fn pure_scalar_get_state(instance: *Engine, id: u32) f64 {
+ return instance.get_state(id);
+}
+
+export fn pure_scalar_info(instance: *Engine, id: u32) u32 {
+ return instance.info(id);
+}
+
+export fn pure_scalar_layout(instance: *Engine, id: u32) u32 {
+ return instance.layout(id);
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/app.c b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/app.c
new file mode 100644
index 0000000..38da4ab
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/app.c
@@ -0,0 +1,208 @@
+#include "app.h"
+#include "../../connections/store-port.h"
+#include "../../connections/store.h"
+#include <stdlib.h>
+#include <string.h>
+#include <float.h>
+
+_Static_assert(sizeof(double) == 8 && FLT_RADIX == 2 && DBL_MANT_DIG == 53 && DBL_MAX_EXP == 1024,
+ "the current snapshot boundary requires IEEE binary64");
+
+static const PureProgram connections = {.read = {.to_end = true}, .write = {.to_end = true}};
+static const unsigned char marker[8] = {'P','U','R','E','S','0','0','1'};
+enum { SNAPSHOT_HEADER = 44 };
+
+static void release(void *memory, size_t size) { (void)size; free(memory); }
+static bool allocate(void *context, size_t size, PureResource *out) {
+ (void)context;
+ void *memory = malloc(size);
+ if (!memory) return false;
+ *out = (PureResource){.body = {memory, size}, .allocation = memory,
+ .allocation_size = size, .release = release};
+ return true;
+}
+static uint64_t decode(const unsigned char *bytes, unsigned count) {
+ uint64_t value = 0;
+ for (unsigned i = 0; i < count; ++i) value |= (uint64_t)bytes[i] << (8 * i);
+ return value;
+}
+static void encode(unsigned char *bytes, uint64_t value, unsigned count) {
+ for (unsigned i = 0; i < count; ++i) bytes[i] = (unsigned char)(value >> (8 * i));
+}
+
+static PureResult read_value(void *context, const char *key, PureRegion region, size_t minimum,
+ size_t available, PureResource *out, int64_t *revision) {
+ PureNativeApp *app = context;
+ if (!strcmp(key, "load")) {
+ PureHost store = pure_store_host(app->directory);
+ return store.read(store.context, app->document, region, minimum, available, out, revision);
+ }
+ if (strcmp(key, "snapshot")) return PURE_INVALID;
+ if (app->held) return PURE_BUSY;
+ size_t size = SNAPSHOT_HEADER + 8u * app->plan->state_count;
+ /* This host contract currently exports the complete typed snapshot. */
+ if (minimum > size || !region.to_end || region.offset) return PURE_LIMIT;
+ if (size > available) return PURE_BUDGET;
+ if (!allocate(NULL, size, out)) return PURE_ALLOCATION;
+ unsigned char *bytes = out->allocation;
+ memcpy(bytes, marker, sizeof(marker));
+ memcpy(bytes + 8, app->plan->identity, 32);
+ encode(bytes + 40, app->plan->state_count, 4);
+ for (uint32_t i = 0; i < app->plan->state_count; ++i) {
+ double value = pure_scalar_get_state(app->scalar, i);
+ uint64_t bits;
+ memcpy(&bits, &value, sizeof(bits));
+ encode(bytes + SNAPSHOT_HEADER + i * 8, bits, 8);
+ }
+ *revision = app->revision;
+ return PURE_OK;
+}
+
+static PureResult action(PureNativeApp *app, PureAction a) {
+ if (a.phase == PURE_RESET) {
+ if (app->held) return PURE_BUSY;
+ pure_scalar_reset_state(app->scalar);
+ return PURE_OK;
+ }
+ if (a.phase == PURE_FRAME) {
+ app->last_scalar_error = pure_scalar_dispatch(app->scalar, 0, 1.0 / 60.0);
+ return app->last_scalar_error ? PURE_INVALID : PURE_OK;
+ }
+ if (a.input >= app->plan->input_count || a.input >= 8) return PURE_INVALID;
+ PureScalarInputKind kind = app->plan->inputs[a.input].kind;
+ uint32_t bit = 1u << a.input, event;
+ if (kind == PURE_VALUE) {
+ if (a.phase != PURE_CHANGE) return PURE_INVALID;
+ app->last_scalar_error = pure_scalar_dispatch_value(app->scalar, 33 + a.input, a.x);
+ return app->last_scalar_error ? PURE_INVALID : PURE_OK;
+ }
+ if (kind == PURE_KEY) {
+ if ((a.phase != PURE_DOWN && a.phase != PURE_UP) || a.source >= 32) return PURE_INVALID;
+ uint32_t source = 1u << a.source, before = app->key_sources[a.input];
+ if (a.phase == PURE_DOWN) {
+ app->key_sources[a.input] |= source;
+ app->held |= bit;
+ if (before) return PURE_OK;
+ } else {
+ if (!(before & source)) return PURE_OK;
+ app->key_sources[a.input] &= ~source;
+ if (app->key_sources[a.input]) return PURE_OK;
+ app->held &= ~bit;
+ }
+ event = a.input * 2 + (a.phase == PURE_DOWN ? 1u : 2u);
+ app->last_scalar_error = pure_scalar_dispatch(app->scalar, event, 0);
+ } else {
+ if (a.phase > PURE_CANCEL) return PURE_INVALID;
+ uint32_t slot = a.input;
+ if (kind == PURE_ROUTED) {
+ if (a.target >= app->plan->target_count || a.target >= 24 || app->plan->targets[a.target].input != a.input) return PURE_INVALID;
+ slot = 8 + a.target;
+ }
+ bit = 1u << slot;
+ if (a.phase == PURE_DOWN) {
+ if (app->held & bit) return app->pointer_owners[slot] == a.source ? PURE_OK : PURE_BUSY;
+ app->held |= bit;
+ app->pointer_owners[slot] = a.source;
+ } else {
+ if (!(app->held & bit) || app->pointer_owners[slot] != a.source) return PURE_INVALID;
+ if (a.phase == PURE_UP || a.phase == PURE_CANCEL) app->held &= ~bit;
+ }
+ event = a.phase == PURE_MOVE ? 17 + a.input : a.phase == PURE_CANCEL ? 25 + a.input
+ : a.input * 2 + (a.phase == PURE_DOWN ? 1u : 2u);
+ app->last_scalar_error = kind == PURE_ROUTED
+ ? pure_scalar_dispatch_target(app->scalar, event, a.target, a.x, a.y)
+ : pure_scalar_dispatch_point(app->scalar, event, a.x, a.y);
+ }
+ /* Input ownership follows the physical phases even if an authored handler
+ * fails; its scalar writes remain atomic. Each routed target has its owner. */
+ return app->last_scalar_error ? PURE_INVALID : PURE_OK;
+}
+
+static PureResult write_value(void *context, const char *key, int64_t expected,
+ PureSpan bytes, int64_t *revision) {
+ PureNativeApp *app = context;
+ if (!strcmp(key, "save")) return pure_store_put(app->directory, app->document, expected, bytes, revision);
+ *revision = app->revision;
+ if (expected != app->revision) return PURE_CONFLICT;
+ if (app->revision == INT64_MAX) return PURE_LIMIT;
+ app->last_scalar_error = 0;
+ if (!strcmp(key, "event")) {
+ if (bytes.size != sizeof(PureAction)) return PURE_LIMIT;
+ PureAction input;
+ memcpy(&input, bytes.data, sizeof(input));
+ PureResult status = action(app, input);
+ if (status != PURE_OK) return status;
+ } else if (!strcmp(key, "restore")) {
+ if (app->held) return PURE_BUSY;
+ if (bytes.size != SNAPSHOT_HEADER + 8u * app->plan->state_count ||
+ memcmp(bytes.data, marker, sizeof(marker)) || memcmp(bytes.data + 8, app->plan->identity, 32) ||
+ decode(bytes.data + 40, 4) != app->plan->state_count) return PURE_INVALID;
+ double values[32];
+ for (uint32_t i = 0; i < app->plan->state_count; ++i) {
+ uint64_t bits = decode(bytes.data + SNAPSHOT_HEADER + 8 * i, 8);
+ memcpy(values + i, &bits, sizeof(bits));
+ }
+ app->last_scalar_error = pure_scalar_load(app->scalar, app->plan, NULL, values);
+ if (app->last_scalar_error) return PURE_INVALID;
+ } else return PURE_INVALID;
+ *revision = ++app->revision;
+ return PURE_OK;
+}
+
+PureResult pure_native_app_init(PureNativeApp *app, const PureScalarPlan *plan, void *memory,
+ size_t size, const char *directory, const char *key, size_t budget) {
+ if (!app || !plan || !directory || !key || !*key || strlen(key) > 64 || plan->state_count > 32) return PURE_INVALID;
+ for (const char *p = key; *p; ++p)
+ if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') ||
+ (*p >= '0' && *p <= '9') || *p == '_' || *p == '-')) return PURE_INVALID;
+ PureScalar *scalar = pure_scalar_initialize(memory, size);
+ if (!scalar) return PURE_INVALID;
+ if (pure_scalar_load(scalar, plan, NULL, NULL)) return PURE_INVALID;
+ PureResult status = pure_store_init(directory);
+ if (status != PURE_OK) return status;
+ *app = (PureNativeApp){.scalar = scalar, .plan = plan, .directory = directory};
+ strcpy(app->document, key);
+ PureHost host = {.context = app, .allocate = allocate, .read = read_value, .write = write_value};
+ return pure_runtime_init(&app->execution, app->slots, 2, &connections, host, budget) ? PURE_OK : PURE_INVALID;
+}
+
+static PureOutcome finish(PureNativeApp *app, PureTicket ticket) {
+ PureOutcome result;
+ while (!pure_runtime_result(ticket, &result)) {
+ if (!pure_runtime_step(&app->execution)) return (PureOutcome){.status = PURE_INVALID};
+ }
+ return result;
+}
+
+PureOutcome pure_native_app_action(PureNativeApp *app, PureAction input) {
+ PureTicket ticket;
+ PureResult status = pure_runtime_accept(&app->execution, PURE_INPUT_WRITE, "event", app->revision, sizeof(input), &ticket);
+ if (status != PURE_OK) return (PureOutcome){.status = status};
+ memcpy(pure_runtime_ingress(ticket, NULL), &input, sizeof(input));
+ pure_runtime_ready(ticket);
+ PureOutcome result = finish(app, ticket);
+ pure_runtime_release(ticket);
+ return result;
+}
+
+static PureOutcome transfer(PureNativeApp *app, bool restore) {
+ PureTicket source, destination;
+ PureResult status = pure_runtime_accept(&app->execution, PURE_INPUT_READ, restore ? "load" : "snapshot", 0, 0, &source);
+ if (status != PURE_OK) return (PureOutcome){.status = status};
+ PureOutcome result = finish(app, source);
+ int64_t stored = result.revision;
+ if (result.status == PURE_OK) {
+ status = pure_runtime_forward(source, &app->execution, restore ? "restore" : "save",
+ restore ? app->revision : app->stored_revision, &destination);
+ pure_runtime_release(source);
+ if (status != PURE_OK) return (PureOutcome){.status = status};
+ result = finish(app, destination);
+ pure_runtime_release(destination);
+ if (result.status == PURE_OK) app->stored_revision = restore ? stored : result.revision;
+ } else pure_runtime_release(source);
+ /* Bodies are consumed internally before this API returns. */
+ result.body = (PureSpan){0};
+ return result;
+}
+PureOutcome pure_native_app_save(PureNativeApp *app) { return transfer(app, false); }
+PureOutcome pure_native_app_restore(PureNativeApp *app) { return transfer(app, true); }
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/app.h b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/app.h
new file mode 100644
index 0000000..bde0db5
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/app.h
@@ -0,0 +1,27 @@
+#ifndef PURE_NATIVE_APP_H
+#define PURE_NATIVE_APP_H
+#include "plan.h"
+#include "../../connections/runtime.h"
+
+typedef enum { PURE_DOWN, PURE_UP, PURE_MOVE, PURE_CANCEL, PURE_CHANGE, PURE_FRAME, PURE_RESET } PureActionPhase;
+/* In-process event value, never a persistent or network representation. */
+typedef struct { uint32_t input, phase, target, source; double x, y; } PureAction;
+typedef struct {
+ PureScalar *scalar;
+ const PureScalarPlan *plan;
+ const char *directory;
+ char document[65];
+ PureRuntime execution;
+ PureWork slots[2];
+ int64_t revision, stored_revision;
+ uint32_t last_scalar_error, held, pointer_owners[32], key_sources[8];
+} PureNativeApp;
+
+/* Caller supplies instance memory and keeps plan/directory alive. This selected
+ * host uses the existing SQL-free store. Single owner; effects are synchronous. */
+PureResult pure_native_app_init(PureNativeApp *, const PureScalarPlan *, void *memory,
+ size_t size, const char *directory, const char *key, size_t budget);
+PureOutcome pure_native_app_action(PureNativeApp *, PureAction);
+PureOutcome pure_native_app_save(PureNativeApp *);
+PureOutcome pure_native_app_restore(PureNativeApp *);
+#endif
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/check.mjs b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/check.mjs
new file mode 100644
index 0000000..4985dfa
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/check.mjs
@@ -0,0 +1,129 @@
+#!/usr/bin/env node
+import assert from 'node:assert/strict';
+import { readFile, writeFile, mkdir, mkdtemp, rm } from 'node:fs/promises';
+import { spawnSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import { tmpdir } from 'node:os';
+import { resolve, join, relative } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { compile } from '../web/compiler.mjs';
+import { createRuntime } from '../web/runtime.mjs';
+
+const root = fileURLToPath(new URL('../', import.meta.url));
+const repo = resolve(root, '../..');
+const connections = resolve(root, '../connections');
+const args = process.argv.slice(2);
+const sanitized = args.includes('--sanitize');
+const index = args.indexOf('--output');
+const output = index >= 0 ? resolve(args[index + 1]) : null;
+const temporary = await mkdtemp(join(tmpdir(), 'pure-native-parity-'));
+const library = resolve(root, 'build/native/libpure-scalar.a');
+const wasm = await readFile(resolve(root, 'web/core.wasm'));
+const hash = data => createHash('sha256').update(data).digest('hex');
+const compiler = process.env.CC || 'cc';
+const flags = ['-std=c17', sanitized ? '-O1' : '-O2', '-g', '-Wall', '-Wextra', '-Wpedantic', '-Werror',
+ ...(sanitized ? ['-fsanitize=address,undefined', '-fno-omit-frame-pointer'] : []), '-I' + resolve(root, 'native')];
+function run(command, arguments_, options = {}) {
+ const p = spawnSync(command, arguments_, { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: 60000, ...options });
+ assert.ifError(p.error);
+ return p;
+}
+function successful(command, arguments_, options) {
+ const p = run(command, arguments_, options);
+ assert.equal(p.status, 0, p.stdout + p.stderr);
+ return p.stdout;
+}
+const units = [resolve(root, 'native/app.c'), resolve(root, 'native/plan.c'),
+ ...['runtime.c', 'evaluate.c', 'store.c', 'store-port.c'].map(name => resolve(connections, name))];
+const objects = [];
+const cases = [];
+let unitResult;
+try {
+ for (const [i, path] of units.entries()) {
+ const object = join(temporary, `unit-${i}.o`);
+ successful(compiler, [...flags, '-c', path, '-o', object]);
+ objects.push(object);
+ }
+ const main = join(temporary, 'main.o');
+ successful(compiler, [...flags, '-c', resolve(root, 'native/main.c'), '-o', main]);
+ const definition = name => readFile(resolve(root, 'examples', name + '.pure'), 'utf8');
+ const repeat = (line, n) => Array(n).fill(line);
+ const definitions = [
+ ['counter', await definition('counter'), ['key tap down', 'key tap down', 'key tap up', 'key tap down', 'key tap up']],
+ ['toggle', await definition('toggle'), ['key toggle down', 'key toggle up', ...Array.from({length: 13}, () => ['key move down', 'key move up']).flat()]],
+ ['charge', await definition('charge'), ['key jump down', ...repeat('frame', 12), 'key jump up', ...repeat('frame', 90)]],
+ ['direct', await definition('direct'), ['point drag down tile 90 180', 'point drag move tile 250 220', 'point drag up tile 250 220']],
+ ['routed', await definition('routed-layout'), ['point drag down green_box 90 130', 'point drag down blue_box 290 170',
+ 'point drag move green_box 200 210', 'point drag move blue_box 500 270', 'point drag cancel green_box 200 210',
+ 'point drag up blue_box 500 270', 'key left down', 'key left up']],
+ ['document', await definition('document'), ['value split 99', 'value split -10', 'value split 61.5', 'key reset down', 'key reset up']],
+ ['atomic-error', 'state x=7\nstate y=2\ninput n=value\non n.change {x=event_value; y=1/event_value}\ntext v(1,2,y,"value: ")',
+ ['value n 0', 'value n 4']],
+ ['blank', '', []],
+ ['literal-boundary', 'state x=-0\ninput n=value\non n.change {x=event_value}\ntext v(1,2,x,"한글 \\\" \\\\ \\n")', ['value n -0']],
+ ];
+ for (const [name, source, commands] of definitions) {
+ const sourcePath = join(temporary, name + '.pure'), prepared = join(temporary, name + '.c'), binary = join(temporary, name);
+ await writeFile(sourcePath, source);
+ successful(process.execPath, [resolve(root, 'native/prepare.mjs'), sourcePath, prepared]);
+ successful(compiler, [...flags, prepared, main, ...objects, library, '-lm', '-o', binary]);
+ const native = run(binary, [join(temporary, name + '-store'), 'project'], { input: commands.concat(['save', 'quit']).join('\n') + '\n' });
+ const rows = native.stdout.trim().split('\n').map(line => JSON.parse(line));
+ const runtime = await createRuntime(wasm);
+ const plan = compile(source);
+ runtime.apply(source);
+ function equivalent(row) {
+ const snapshot = runtime.snapshot();
+ assert.deepEqual(row.states, snapshot.states, name);
+ const views = Object.fromEntries(snapshot.views.map(view => [view.id, Object.fromEntries(Object.keys(plan.views.find(v => v.id === view.id).bindings).map(key => [key, view[key]]))]));
+ assert.deepEqual(row.views, views, name);
+ }
+ equivalent(rows[0]);
+ let failures = 0;
+ commands.forEach((command, i) => {
+ const [verb, input, phase, target, x, y] = command.split(' ');
+ let code = 0;
+ if (verb === 'key') code = phase === 'down' ? runtime.down(input) : runtime.up(input);
+ else if (verb === 'frame') code = runtime.frame(1);
+ else if (verb === 'value') code = runtime.change(input, Number(phase));
+ else if (verb === 'point') {
+ const routed = plan.inputs.find(p => p.name === input).routes;
+ code = runtime.point(input, phase, Number(x), Number(y), target, routed ? target : undefined);
+ }
+ if (code) ++failures;
+ assert.equal(rows[i + 1].status === 0, code === 0, command);
+ if (code) assert.equal(rows[i + 1].scalar_error, code, command);
+ equivalent(rows[i + 1]);
+ });
+ assert.equal(rows.length, commands.length + 2);
+ assert.equal(rows.at(-1).status, 0);
+ assert.equal(native.status, failures ? 1 : 0, native.stderr);
+ const restored = JSON.parse(successful(binary, [join(temporary, name + '-store'), 'project'], {input: 'load\nquit\n'}).trim().split('\n').at(-1));
+ equivalent(restored);
+ assert.equal(restored.stored_revision, '1');
+ if (name === 'routed') {
+ const unit = join(temporary, 'test-native');
+ successful(compiler, [...flags, prepared, resolve(root, 'native/test.c'), ...objects, library, '-lm', '-o', unit]);
+ unitResult = JSON.parse(successful(unit, []));
+ }
+ cases.push({name, commands: commands.length, failed_events: failures, states_and_views_match: true,
+ fresh_process_restore: true, source, source_sha256: hash(source), prepared_sha256: hash(await readFile(prepared)),
+ native_binary_sha256: hash(await readFile(binary)), native_binary_bytes: (await readFile(binary)).length});
+ }
+ const paths = [resolve(root, 'core.zig'), resolve(root, 'engine.zig'), resolve(root, 'evaluation.zig'), resolve(root, 'native.zig'),
+ resolve(root, 'manage.py'), ...['scalar.h','plan.h','plan.c','app.h','app.c','main.c','prepare.mjs','test.c','check.mjs'].map(name => resolve(root, 'native', name)),
+ ...['compiler.mjs','runtime.mjs','project.mjs','core.wasm'].map(name => resolve(root, 'web', name)),
+ ...['runtime.c','runtime.h','evaluate.c','program.h','flow.h','result.h','store.c','store.h','store-port.c','store-port.h'].map(name => resolve(connections, name))];
+ const sourceHashes = Object.fromEntries(await Promise.all(paths.map(async path => [relative(repo, path), hash(await readFile(path))])));
+ const result = {kind:'native-instance-and-authored-behavior-correctness-not-performance', sanitized_c_host: sanitized,
+ zig_mode:'ReleaseSafe; Zig object is not ASan-instrumented', compiler: successful(compiler,['--version']).split('\n')[0],
+ cases, units: unitResult, source_sha256: sourceHashes, scalar_archive_sha256: hash(await readFile(library)),
+ scalar_archive_size: successful('size',[library]), scalar_archive_undefined: successful('nm',['-u',library])};
+ if (output) {
+ await mkdir(resolve(output, '..'), {recursive: true});
+ await writeFile(output, JSON.stringify(result,null,2)+'\n', {flag:'wx'});
+ }
+ console.log(JSON.stringify({cases:cases.length, event_comparisons:cases.reduce((n,c)=>n+c.commands,0), units:unitResult, sanitized_c_host:sanitized}));
+} finally {
+ await rm(temporary, {recursive:true, force:true});
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/main.c b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/main.c
new file mode 100644
index 0000000..3756297
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/main.c
@@ -0,0 +1,135 @@
+#include "app.h"
+#include <errno.h>
+#include <inttypes.h>
+#include <math.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+extern const PureScalarPlan pure_app_plan;
+
+static void string(const char *value) {
+ putchar('"');
+ for (const unsigned char *p = (const unsigned char *)value; *p; ++p) {
+ if (*p == '"' || *p == '\\') { putchar('\\'); putchar(*p); }
+ else if (*p < 32) printf("\\u%04x", *p);
+ else putchar(*p);
+ }
+ putchar('"');
+}
+static void number(double value) {
+ if (isfinite(value)) printf("%.17g", value); else fputs("null", stdout);
+}
+static void show(PureNativeApp *app, PureOutcome result) {
+ printf("{\"status\":%u,\"scalar_error\":%u,\"revision\":\"%" PRId64 "\",\"stored_revision\":\"%" PRId64
+ "\",\"result_revision\":\"%" PRId64 "\",\"held\":%u,\"states\":{",
+ (unsigned)result.status, app->last_scalar_error, app->revision, app->stored_revision, result.revision, app->held);
+ for (uint32_t i = 0; i < app->plan->state_count; ++i) {
+ if (i) putchar(',');
+ string(app->plan->states[i].name); putchar(':'); number(pure_scalar_get_state(app->scalar, i));
+ }
+ fputs("},\"views\":{", stdout);
+ pure_scalar_begin_evaluation(app->scalar, 2048);
+ uint32_t display_error = 0;
+ for (uint32_t i = 0; i < app->plan->view_count; ++i) {
+ const PureScalarView *view = &app->plan->views[i];
+ if (i) putchar(',');
+ string(view->id); fputs(":{", stdout);
+ const char *fields[] = {"x", "y", !strcmp(view->type, "text") ? "value" : !strcmp(view->type, "circle") ? "radius" : "width", "height"};
+ for (uint32_t j = 0; j < view->count; ++j) {
+ if (j) putchar(',');
+ string(fields[j]); putchar(':');
+ double value = pure_scalar_eval_bounded(app->scalar, view->fields[j].start, view->fields[j].length);
+ uint32_t code = pure_scalar_evaluation_info(app->scalar, 0);
+ if (code && !display_error) display_error = code;
+ if (j >= 2 && strcmp(view->type, "text") && value < 0) { display_error = 100; value = NAN; }
+ number(value);
+ }
+ putchar('}');
+ }
+ printf("},\"display_error\":%u}\n", display_error);
+ fflush(stdout);
+}
+static bool real(const char *text, double *out) {
+ if (!text || !*text) return false;
+ char *end;
+ errno = 0;
+ double value = strtod(text, &end);
+ if (errno || *end || !isfinite(value)) return false;
+ *out = value;
+ return true;
+}
+
+int main(int argc, char **argv) {
+ if (argc != 3) {
+ fputs("pure-native STORE_DIRECTORY DOCUMENT_KEY\n"
+ "Commands: show, key INPUT down|up, value INPUT NUMBER,\n"
+ "point INPUT down|move|up|cancel TARGET X Y, frame, reset, save, load, quit.\n", stderr);
+ return 2;
+ }
+ size_t bytes = pure_scalar_bytes();
+ void *memory = malloc(bytes);
+ if (!memory) return 2;
+ PureNativeApp app;
+ PureResult status = pure_native_app_init(&app, &pure_app_plan, memory, bytes, argv[1], argv[2], 4096);
+ if (status != PURE_OK) { fprintf(stderr, "initialization failed: %u\n", status); free(memory); return 2; }
+ show(&app, (PureOutcome){.status = PURE_OK});
+ int failed = 0;
+ char line[512];
+ while (fgets(line, sizeof(line), stdin)) {
+ if (!strchr(line, '\n') && !feof(stdin)) {
+ int c;
+ while ((c = getchar()) != '\n' && c != EOF) {}
+ fputs("command exceeds 510 bytes\n", stderr); failed = 1; continue;
+ }
+ char *words[8];
+ size_t count = 0;
+ for (char *token = strtok(line, " \t\r\n"); token; token = strtok(NULL, " \t\r\n")) {
+ if (count == 8) break;
+ words[count++] = token;
+ }
+ if (!count) continue;
+ PureOutcome result = {.status = PURE_INVALID};
+ if (count == 1) {
+ if (!strcmp(words[0], "quit")) break;
+ if (!strcmp(words[0], "show")) result.status = PURE_OK;
+ else if (!strcmp(words[0], "save")) result = pure_native_app_save(&app);
+ else if (!strcmp(words[0], "load")) result = pure_native_app_restore(&app);
+ else if (!strcmp(words[0], "frame") || !strcmp(words[0], "reset"))
+ result = pure_native_app_action(&app, (PureAction){.phase = !strcmp(words[0], "frame") ? PURE_FRAME : PURE_RESET});
+ } else {
+ uint32_t input = app.plan->input_count;
+ for (uint32_t i = 0; i < app.plan->input_count; ++i)
+ if (!strcmp(words[1], app.plan->inputs[i].name)) { input = i; break; }
+ if (input < app.plan->input_count) {
+ PureScalarInputKind kind = app.plan->inputs[input].kind;
+ PureAction action = {.input = input, .phase = UINT32_MAX, .target = UINT32_MAX};
+ if (count == 3 && !strcmp(words[0], "key") && kind == PURE_KEY) {
+ if (!strcmp(words[2], "down")) action.phase = PURE_DOWN;
+ if (!strcmp(words[2], "up")) action.phase = PURE_UP;
+ } else if (count == 3 && !strcmp(words[0], "value") && kind == PURE_VALUE) {
+ if (real(words[2], &action.x)) action.phase = PURE_CHANGE;
+ } else if (count == 6 && !strcmp(words[0], "point") && (kind == PURE_POINT || kind == PURE_ROUTED)) {
+ bool target = kind == PURE_POINT && !strcmp(words[3], app.plan->inputs[input].target);
+ if (kind == PURE_ROUTED)
+ for (uint32_t i = 0; i < app.plan->target_count; ++i)
+ if (app.plan->targets[i].input == input && !strcmp(words[3], app.plan->targets[i].view)) {
+ target = true; action.target = i; break;
+ }
+ if (target && real(words[4], &action.x) && real(words[5], &action.y)) {
+ const char *phases[] = {"down", "up", "move", "cancel"};
+ for (uint32_t i = 0; i < 4; ++i) if (!strcmp(words[2], phases[i])) action.phase = i;
+ }
+ }
+ if (action.phase != UINT32_MAX) result = pure_native_app_action(&app, action);
+ }
+ }
+ if (result.status != PURE_OK) failed = 1;
+ show(&app, result);
+ }
+ if (ferror(stdin) || ferror(stdout)) failed = 1;
+ pure_runtime_stop(&app.execution);
+ if (app.execution.active || app.execution.retained) failed = 1;
+ free(memory);
+ return failed;
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/plan.c b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/plan.c
new file mode 100644
index 0000000..3ddfeda
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/plan.c
@@ -0,0 +1,38 @@
+#include "plan.h"
+#include <string.h>
+
+uint32_t pure_scalar_load(PureScalar *vm, const PureScalarPlan *plan,
+ const PureScalarPlan *previous, const double *restored) {
+ uint32_t code = pure_scalar_begin_program(vm, plan->state_count, plan->input_count);
+ if (code) return code;
+ for (uint32_t i = 0; i < plan->state_count; ++i) {
+ int32_t old = -1;
+ if (!restored && previous)
+ for (uint32_t j = 0; j < previous->state_count; ++j)
+ if (!strcmp(plan->states[i].name, previous->states[j].name)) { old = (int32_t)j; break; }
+ code = pure_scalar_put_state(vm, i, plan->states[i].initial, old);
+ if (code) goto fail;
+ if (restored && (code = pure_scalar_put_restored_value(vm, i, restored[i]))) goto fail;
+ }
+ for (uint32_t i = 0; i < plan->instruction_count; ++i) {
+ const PureScalarInstruction *op = &plan->instructions[i];
+ if ((code = pure_scalar_put_instruction(vm, op->op, op->arg, op->value))) goto fail;
+ }
+ for (uint32_t i = 0; i < plan->target_count; ++i) {
+ const PureScalarTarget *target = &plan->targets[i];
+ if ((code = pure_scalar_put_pointer_target(vm, target->input, target->base, target->count))) goto fail;
+ }
+ for (uint32_t i = 0; i < plan->handler_count; ++i) {
+ const PureScalarHandler *handler = &plan->handlers[i];
+ if ((code = pure_scalar_put_handler(vm, handler->event, handler->start, handler->length))) goto fail;
+ if (handler->count) {
+ code = handler->base == 255 ? pure_scalar_bind_target_handler(vm, i, handler->count)
+ : pure_scalar_bind_handler(vm, i, handler->base, handler->count);
+ if (code) goto fail;
+ }
+ }
+ return restored ? pure_scalar_commit_restored_program(vm) : pure_scalar_commit_program(vm);
+fail:
+ pure_scalar_abort_program(vm);
+ return code;
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/plan.h b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/plan.h
new file mode 100644
index 0000000..8de81df
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/plan.h
@@ -0,0 +1,36 @@
+#ifndef PURE_SCALAR_PLAN_H
+#define PURE_SCALAR_PLAN_H
+#include "scalar.h"
+#include <stdbool.h>
+
+typedef struct { const char *name; double initial; } PureScalarState;
+typedef struct { uint32_t op, arg; double value; } PureScalarInstruction;
+typedef struct { uint32_t event, start, length, base, count; } PureScalarHandler;
+typedef struct { uint32_t input, base, count; const char *view; } PureScalarTarget;
+typedef enum { PURE_KEY, PURE_POINT, PURE_ROUTED, PURE_VALUE } PureScalarInputKind;
+typedef struct { const char *name, *key, *target; PureScalarInputKind kind; } PureScalarInput;
+typedef struct { uint32_t start, length; } PureScalarBinding;
+typedef struct {
+ const char *id, *type, *decoration;
+ uint32_t count;
+ PureScalarBinding fields[4];
+} PureScalarView;
+/* Immutable, build-prepared application data. C layout is an internal build
+ * contract; no pointers or native structs are persisted or sent on the wire. */
+typedef struct {
+ uint32_t state_count, input_count, instruction_count, handler_count, target_count, view_count;
+ const PureScalarState *states;
+ const PureScalarInput *inputs;
+ const PureScalarInstruction *instructions;
+ const PureScalarHandler *handlers;
+ const PureScalarTarget *targets;
+ const PureScalarView *views;
+ unsigned char identity[32];
+} PureScalarPlan;
+
+/* Commit a prepared definition with initials, name-preserved previous values,
+ * or a complete restored state vector. A failed candidate leaves active state
+ * intact. The caller ensures the application is idle before replacing it. */
+uint32_t pure_scalar_load(PureScalar *, const PureScalarPlan *,
+ const PureScalarPlan *previous, const double *restored);
+#endif
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/prepare.mjs b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/prepare.mjs
new file mode 100644
index 0000000..f3ff288
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/prepare.mjs
@@ -0,0 +1,35 @@
+#!/usr/bin/env node
+// Build tool only: reuse the existing authoring compiler. Its parser, JS runtime
+// and source syntax do not become dependencies of the resulting native app.
+import { readFile, writeFile } from 'node:fs/promises';
+import { createHash } from 'node:crypto';
+import { compile } from '../web/compiler.mjs';
+
+const [input, output] = process.argv.slice(2);
+if (!input || !output || process.argv.length !== 4) throw new Error('prepare.mjs SOURCE.pure OUTPUT.c');
+const source = await readFile(input, 'utf8');
+const p = compile(source);
+// Encode strings as byte escapes, not JSON/C escape substitution. No source text
+// or user-authored identifier can become executable C through this preparation.
+const string = s => '"' + [...Buffer.from(s ?? '', 'utf8')].map(b => `\\${b.toString(8).padStart(3, '0')}`).join('') + '"';
+const number = n => Object.is(n, -0) ? '-0.0' : Number.isFinite(n) ? String(n) : (() => { throw new Error('nonfinite prepared constant'); })();
+const declaration = (type, name, rows) => `static const ${type} ${name}[] = {\n${rows.length ? rows.map(r => ` ${r},`).join('\n') : ' {0},'}\n};\n`;
+let text = '#include "plan.h"\n\n';
+text += declaration('PureScalarState', 'states', p.states.map(s => `{${string(s.name)}, ${number(s.initial)}}`));
+text += declaration('PureScalarInput', 'inputs', p.inputs.map(i => `{${string(i.name)}, ${string(i.key)}, ${string(i.target)}, ${i.kind === 'value' ? 'PURE_VALUE' : i.routes ? 'PURE_ROUTED' : i.target ? 'PURE_POINT' : 'PURE_KEY'}}`));
+text += declaration('PureScalarInstruction', 'instructions', p.instructions.map(i => `{${i.op}, ${i.arg}, ${number(i.value)}}`));
+text += declaration('PureScalarHandler', 'handlers', p.handlers.map(h => `{${h.event}, ${h.start}, ${h.length}, ${h.parameterBase ?? 0}, ${h.parameterCount ?? 0}}`));
+text += declaration('PureScalarTarget', 'targets', p.pointerTargets.map((t, index) => {
+ const route = p.inputs[t.input].routes.find(r => r.route === index);
+ if (!route) throw new Error('missing prepared pointer target');
+ return `{${t.input}, ${t.base}, ${t.count}, ${string(route.target)}}`;
+}));
+text += declaration('PureScalarView', 'views', p.views.map(v => {
+ const fields = Object.values(v.bindings);
+ return `{${string(v.id)}, ${string(v.type)}, ${string(v.color ?? v.prefix)}, ${fields.length}, {${fields.map(b => `{${b.start}, ${b.length}}`).join(', ')}}}`;
+}));
+// Definition identity protects restore compatibility, not authenticity. Persisted
+// values from a different definition are rejected; storage authentication is separate.
+const identity = createHash('sha256').update(JSON.stringify(p)).digest();
+text += `\nconst PureScalarPlan pure_app_plan = {\n ${p.states.length}, ${p.inputs.length}, ${p.instructions.length}, ${p.handlers.length}, ${p.pointerTargets.length}, ${p.views.length},\n states, inputs, instructions, handlers, targets, views,\n {${[...identity].join(', ')}}\n};\n`;
+await writeFile(output, text);
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/scalar.h b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/scalar.h
new file mode 100644
index 0000000..dee26fe
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/scalar.h
@@ -0,0 +1,46 @@
+#ifndef PURE_SCALAR_H
+#define PURE_SCALAR_H
+#include <stddef.h>
+#include <stdint.h>
+#ifdef __cplusplus
+extern "C" {
+#endif
+/* This selected scalar module has no default instance or allocator. Allocate
+ * bytes() aligned to alignment(), initialize once, and retain it through all
+ * calls. Each instance has one owner at a time; independent instances share
+ * no mutable state. Passing invalid pointers is outside this trusted C ABI.
+ * This is an in-process API, not a stable wire or persistent representation. */
+typedef struct PureScalar PureScalar;
+size_t pure_scalar_bytes(void);
+size_t pure_scalar_alignment(void);
+PureScalar *pure_scalar_initialize(void *memory, size_t size);
+void pure_scalar_reset_all(PureScalar *instance);
+uint32_t pure_scalar_begin_program(PureScalar *instance, uint32_t state_count, uint32_t input_count);
+void pure_scalar_abort_program(PureScalar *instance);
+uint32_t pure_scalar_put_state(PureScalar *instance, uint32_t id, double initial, int32_t old_id);
+uint32_t pure_scalar_put_instruction(PureScalar *instance, uint32_t op, uint32_t arg, double value);
+uint32_t pure_scalar_put_handler(PureScalar *instance, uint32_t event, uint32_t start, uint32_t length);
+uint32_t pure_scalar_bind_handler(PureScalar *instance, uint32_t handler, uint32_t base, uint32_t count);
+uint32_t pure_scalar_put_pointer_target(PureScalar *instance, uint32_t input, uint32_t base, uint32_t count);
+uint32_t pure_scalar_bind_target_handler(PureScalar *instance, uint32_t handler, uint32_t count);
+uint32_t pure_scalar_put_restored_value(PureScalar *instance, uint32_t id, double value);
+uint32_t pure_scalar_commit_program(PureScalar *instance);
+uint32_t pure_scalar_commit_restored_program(PureScalar *instance);
+void pure_scalar_reset_state(PureScalar *instance);
+uint32_t pure_scalar_dispatch(PureScalar *instance, uint32_t event, double dt);
+uint32_t pure_scalar_dispatch_point(PureScalar *instance, uint32_t event, double x, double y);
+uint32_t pure_scalar_dispatch_target(PureScalar *instance, uint32_t event, uint32_t target, double x, double y);
+uint32_t pure_scalar_dispatch_value(PureScalar *instance, uint32_t event, double value);
+double pure_scalar_eval_at(PureScalar *instance, uint32_t start, uint32_t length);
+uint32_t pure_scalar_configure_evaluation(PureScalar *instance, uint32_t cache, uint32_t history);
+void pure_scalar_forget_evaluation(PureScalar *instance, uint32_t flags);
+void pure_scalar_begin_evaluation(PureScalar *instance, uint32_t limit);
+double pure_scalar_eval_bounded(PureScalar *instance, uint32_t start, uint32_t length);
+uint32_t pure_scalar_evaluation_info(PureScalar *instance, uint32_t id);
+double pure_scalar_get_state(PureScalar *instance, uint32_t id);
+uint32_t pure_scalar_info(PureScalar *instance, uint32_t id);
+uint32_t pure_scalar_layout(PureScalar *instance, uint32_t id);
+#ifdef __cplusplus
+}
+#endif
+#endif
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/test.c b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/test.c
new file mode 100644
index 0000000..18a049d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/test.c
@@ -0,0 +1,120 @@
+#define _POSIX_C_SOURCE 200809L
+#include "app.h"
+#include "../../connections/store.h"
+#include <assert.h>
+#include <inttypes.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+extern const PureScalarPlan pure_app_plan;
+static void move(PureNativeApp *app, uint32_t target, double x, double y) {
+ assert(pure_native_app_action(app, (PureAction){.input = 4, .phase = PURE_DOWN, .target = target, .x = x, .y = y}).status == PURE_OK);
+ assert(pure_native_app_action(app, (PureAction){.input = 4, .phase = PURE_MOVE, .target = target, .x = x + 70, .y = y + 40}).status == PURE_OK);
+ assert(pure_native_app_action(app, (PureAction){.input = 4, .phase = PURE_UP, .target = target, .x = x + 70, .y = y + 40}).status == PURE_OK);
+}
+static void drained(PureNativeApp *app) {
+ assert(app->execution.active == 0 && app->execution.retained == 0);
+ assert(app->execution.peak_active <= 2 && app->execution.peak_retained <= app->execution.budget);
+}
+static void values(PureNativeApp *app, double output[32]) {
+ for (uint32_t i = 0; i < app->plan->state_count; ++i) output[i] = pure_scalar_get_state(app->scalar, i);
+}
+static void same(PureNativeApp *app, const double *expected) {
+ for (uint32_t i = 0; i < app->plan->state_count; ++i) assert(pure_scalar_get_state(app->scalar, i) == expected[i]);
+}
+
+int main(void) {
+ size_t size = pure_scalar_bytes();
+ unsigned char *a = malloc(size), *b = malloc(size), *c = malloc(size);
+ assert(a && b && c);
+ assert(!pure_scalar_initialize(NULL, size));
+ assert(!pure_scalar_initialize(a, size - 1));
+ assert(pure_scalar_alignment() > 1 && !pure_scalar_initialize(a + 1, size));
+ char path[] = "/tmp/pure-native-instance-XXXXXX";
+ assert(mkdtemp(path));
+ PureNativeApp first, second, limited;
+ assert(pure_native_app_init(&first, &pure_app_plan, a, size, path, "scene", 4096) == PURE_OK);
+ assert(pure_native_app_init(&second, &pure_app_plan, b, size, path, "scene", 4096) == PURE_OK);
+ assert(pure_native_app_init(&limited, &pure_app_plan, c, size, path, "limited", sizeof(PureAction) - 1) == PURE_OK);
+ assert(pure_native_app_action(&limited, (PureAction){.phase = PURE_FRAME}).status == PURE_BUDGET);
+ assert(pure_scalar_info(limited.scalar, 9) == 0);
+
+ assert(pure_native_app_action(&first, (PureAction){.input = 4, .phase = PURE_DOWN, .target = 0, .x = 90, .y = 130}).status == PURE_OK);
+ assert(pure_native_app_save(&first).status == PURE_BUSY);
+ assert(pure_native_app_action(&first, (PureAction){.input = 4, .phase = PURE_MOVE, .target = 1, .x = 230, .y = 210}).status == PURE_INVALID);
+ assert(pure_native_app_action(&first, (PureAction){.input = 4, .phase = PURE_MOVE, .target = 0, .x = 230, .y = 210}).status == PURE_OK);
+ move(&second, 1, 290, 170);
+ assert(pure_scalar_get_state(second.scalar, 1) == 80 && pure_scalar_get_state(second.scalar, 9) == 350);
+ assert(pure_scalar_get_state(first.scalar, 1) == 220 && pure_scalar_get_state(first.scalar, 9) == 280);
+ assert(pure_native_app_action(&first, (PureAction){.input = 4, .phase = PURE_UP, .target = 0, .x = 230, .y = 210}).status == PURE_OK);
+ assert(first.held == 0 && second.held == 0);
+ assert(pure_native_app_save(&first).status == PURE_OK && first.stored_revision == 1);
+ double second_values[32] = {0};
+ values(&second, second_values);
+ PureOutcome conflict = pure_native_app_save(&second);
+ assert(conflict.status == PURE_CONFLICT && conflict.revision == 1 && second.stored_revision == 0);
+ same(&second, second_values);
+ assert(pure_native_app_restore(&second).status == PURE_OK && second.stored_revision == 1);
+ assert(pure_scalar_get_state(second.scalar, 1) == 220 && pure_scalar_get_state(second.scalar, 9) == 280);
+
+ /* Staging/error/evaluation state also belongs to the selected instance. */
+ assert(pure_scalar_begin_program(first.scalar, 1, 0) == 0);
+ assert(pure_scalar_put_state(first.scalar, 0, 999, -1) == 0);
+ assert(pure_scalar_configure_evaluation(first.scalar, 2, 2) == 0);
+ move(&second, 2, 460, 230);
+ assert(pure_scalar_put_instruction(first.scalar, 999, 0, 0) != 0);
+ assert(pure_scalar_commit_program(first.scalar) != 0);
+ assert(pure_scalar_get_state(first.scalar, 1) == 220);
+ assert(pure_scalar_get_state(first.scalar, 17) == 450 && pure_scalar_get_state(second.scalar, 17) == 520);
+ assert(pure_native_app_save(&second).status == PURE_OK && second.stored_revision == 2);
+ assert(pure_native_app_save(&first).status == PURE_CONFLICT && first.stored_revision == 1);
+
+ /* Mismatched definition and nonfinite restored values never replace state. */
+ PureStoredView saved;
+ assert(pure_store_get(path, "scene", 300, &saved) == PURE_OK);
+ unsigned char invalid[300];
+ assert(saved.body.size <= sizeof(invalid));
+ size_t length = saved.body.size;
+ memcpy(invalid, saved.body.data, length);
+ pure_store_release(&saved);
+ invalid[8] ^= 1;
+ int64_t revision;
+ assert(pure_store_put(path, "scene", 2, (PureSpan){invalid, length}, &revision) == PURE_OK);
+ double first_values[32] = {0};
+ values(&first, first_values);
+ assert(pure_native_app_restore(&first).status == PURE_INVALID && first.stored_revision == 1);
+ same(&first, first_values);
+ invalid[8] ^= 1;
+ const unsigned char nan[8] = {0,0,0,0,0,0,248,127};
+ memcpy(invalid + 44, nan, 8);
+ assert(pure_store_put(path, "scene", 3, (PureSpan){invalid, length}, &revision) == PURE_OK);
+ assert(pure_native_app_restore(&first).status == PURE_INVALID && first.last_scalar_error == 5);
+ same(&first, first_values);
+ assert(first.stored_revision == 1);
+ assert(pure_native_app_action(&first, (PureAction){.input = 4, .phase = PURE_DOWN, .target = 0, .source = 11, .x = 230, .y = 210}).status == PURE_OK);
+ assert(pure_native_app_action(&first, (PureAction){.input = 4, .phase = PURE_DOWN, .target = 1, .source = 22, .x = 290, .y = 170}).status == PURE_OK);
+ assert(pure_native_app_action(&first, (PureAction){.input = 4, .phase = PURE_MOVE, .target = 0, .source = 22}).status == PURE_INVALID);
+ assert(pure_native_app_action(&first, (PureAction){.input = 4, .phase = PURE_CANCEL, .target = 0, .source = 11}).status == PURE_OK);
+ assert(first.held && pure_native_app_save(&first).status == PURE_BUSY);
+ assert(pure_native_app_action(&first, (PureAction){.input = 4, .phase = PURE_UP, .target = 1, .source = 22}).status == PURE_OK);
+ assert(first.held == 0);
+ assert(pure_native_app_action(&first, (PureAction){.input = 1, .phase = PURE_DOWN, .source = 1}).status == PURE_OK);
+ double after_key = pure_scalar_get_state(first.scalar, 9);
+ assert(pure_native_app_action(&first, (PureAction){.input = 1, .phase = PURE_DOWN, .source = 2}).status == PURE_OK);
+ assert(pure_scalar_get_state(first.scalar, 9) == after_key);
+ assert(pure_native_app_action(&first, (PureAction){.input = 1, .phase = PURE_UP, .source = 1}).status == PURE_OK && first.held);
+ assert(pure_native_app_action(&first, (PureAction){.input = 1, .phase = PURE_UP, .source = 2}).status == PURE_OK && !first.held);
+ drained(&first); drained(&second); drained(&limited);
+
+ char file[256];
+ snprintf(file, sizeof(file), "%s/obj-scene", path); assert(unlink(file) == 0);
+ snprintf(file, sizeof(file), "%s/.writer.lock", path); assert(unlink(file) == 0);
+ assert(rmdir(path) == 0);
+ free(a); free(b); free(c);
+ printf("{\"native_instances\":3,\"checks\":\"independent-state-staging-inputs-errors-budget-snapshot-conflict-restore\","
+ "\"scalar_instance_bytes\":%zu,\"scalar_alignment\":%zu,\"app_controller_bytes\":%zu,\"action_bytes\":%zu,"
+ "\"snapshot_bytes\":%zu,\"all_work_drained\":true}\n", size, pure_scalar_alignment(), sizeof(PureNativeApp), sizeof(PureAction), length);
+ return 0;
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/pointer.test.mjs b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/pointer.test.mjs
new file mode 100644
index 0000000..8bf530d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/pointer.test.mjs
@@ -0,0 +1,118 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { compile } from './web/compiler.mjs';
+import { createRuntime } from './web/runtime.mjs';
+
+const wasm = await readFile(new URL('./web/core.wasm', import.meta.url));
+const source = await readFile(new URL('./examples/direct.pure', import.meta.url), 'utf8');
+const fresh = async () => { const r = await createRuntime(wasm, {cacheEntries:32}); r.apply(source); return r; };
+
+test('pointer declarations resolve forward targets and reject ambiguous or contextless inputs', () => {
+ const p = compile(source);
+ assert.equal(p.inputs[0].target, 'tile');
+ assert(p.handlers.some(h => h.event === 17)); assert(p.handlers.some(h => h.event === 25));
+ assert(p.instructions.some(i => i.op === 22)); assert(p.instructions.some(i => i.op === 23));
+ for (const [code, message] of [
+ ['input p = pointer(missing)', /Unknown pointer target/],
+ ['input p=pointer(tile)\ninput q=pointer(tile)\ntext tile(0,0,0)', /Duplicate pointer/],
+ ['input p=Space\non p.move {}', /require a pointer/],
+ ['input p=Space\non p.cancel {}', /require a pointer/],
+ ['state x=0\non frame { x=event_x }', /only available/],
+ ['text v(event_y,0,0)', /only available/],
+ ['state event_x=0', /Reserved name/],
+ ]) assert.throws(() => compile(code), message);
+ const full = compile(Array.from({length:8}, (_, i) => `input p${i}=pointer(v${i})\ntext v${i}(0,0,0)\non p${i}.move {}\non p${i}.cancel {}`).join('\n'));
+ assert.deepEqual(full.handlers.map(h => h.event), Array.from({length:8}, (_, i) => [17+i,25+i]).flat());
+});
+
+test('authored drag keeps grab offset and commits the displayed position even if release coordinates differ', async () => {
+ const r = await fresh();
+ r.point('drag', 'down', 90, 170, 'pen');
+ assert.equal(r.snapshot().states.dragging, 1);
+ assert.throws(() => r.apply(''), /놓은 뒤/);
+ r.point('drag', 'move', 360, 220, 'pen');
+ let s = r.snapshot();
+ assert.equal(s.states.x, 350); assert.equal(s.states.y, 210);
+ assert.equal(s.views.find(v => v.id === 'region').value, 1);
+ r.point('drag', 'up', 410, 240, 'pen');
+ s = r.snapshot();
+ assert.equal(s.states.x, 350); assert.equal(s.states.y, 210);
+ assert.equal(s.states.dragging, 0); assert.deepEqual(s.held, []);
+ assert.equal(s.successfulEvents, 3);
+ assert.equal(s.lastEvent.x, 410);
+ r.down('left'); r.up('left');
+ assert.equal(r.snapshot().states.x, 340);
+ r.point('drag', 'down', 350, 220, 'pen');
+ r.point('drag', 'move', 450, 250, 'pen');
+ r.point('drag', 'up', 350, 220, 'pen'); // A host may repeat the press coordinates.
+ assert.equal(r.snapshot().states.x, 440);
+ assert.equal(r.snapshot().states.y, 240);
+ r.apply('state x=0\nstate y=0\ninput p=pointer(tile)\ntext tile(0,0,x)\non p.up { x=event_x\ny=event_y }');
+ r.point('p','down',1,2); r.point('p','up',30,40);
+ assert.deepEqual(r.snapshot().states, {x:30,y:40}); // Payload remains available to authors.
+});
+
+test('ownership, duplicate downs, stale moves, cancellation and focus cleanup cannot leave held input', async () => {
+ const r = await fresh();
+ r.point('drag', 'down', 90, 170, 'a');
+ r.point('drag', 'down', 500, 500, 'a');
+ assert.throws(() => r.point('drag', 'down', 90, 170, 'b'), /다른 포인터/);
+ r.point('drag', 'move', 400, 200, 'b');
+ assert.equal(r.snapshot().successfulEvents, 1);
+ r.point('drag', 'move', 400, 200, 'a');
+ r.releaseAll();
+ const canceled = r.snapshot();
+ assert.equal(canceled.states.x, 80); assert.equal(canceled.states.y, 160);
+ assert.equal(canceled.states.dragging, 0); assert.equal(canceled.lastEvent.phase, 'cancel');
+ assert.deepEqual(canceled.held, []);
+ r.point('drag', 'cancel', 1, 1, 'a'); r.point('drag', 'move', 500, 500, 'a'); r.releaseAll();
+ assert.deepEqual(r.snapshot(), canceled);
+ r.point('drag', 'down', 90, 170, 'new'); r.reset();
+ r.point('drag', 'up', 600, 500, 'new');
+ assert.equal(r.snapshot().states.x, 80); assert.deepEqual(r.snapshot().held, []);
+});
+
+test('invalid coordinates and modes do not mutate a held gesture; arithmetic failure rolls back one event', async () => {
+ const r = await fresh(); r.point('drag', 'down', 90, 170);
+ const before = r.snapshot();
+ for (const invoke of [() => r.point('drag','move',NaN,1), () => r.point('drag','up',1,Infinity),
+ () => r.point('drag','unknown',1,2), () => r.point('left','down',1,2),
+ () => r.down('drag'), () => r.up('drag'), () => r.point('drag','move',1,2,'')]) assert.throws(invoke);
+ assert.deepEqual(r.snapshot(), before);
+ r.releaseAll();
+ r.apply(source.replace('x = clamp(event_x - grab_x, 20, 564)', 'x = event_x\n y = 1 / 0'));
+ r.point('drag', 'down', 90, 170);
+ assert.equal(r.point('drag', 'move', 500, 220), 4);
+ let s = r.snapshot(); assert.equal(s.states.x, 80); assert.equal(s.states.y, 160);
+ assert.equal(s.lastFailure.phase, 'move');
+ r.releaseAll(); s = r.snapshot(); assert.deepEqual(s.held, []); assert.equal(s.lastError, 4);
+ assert.equal(s.states.dragging, 0);
+});
+
+test('independent targets share the event pipeline and publication remains non-reentrant', async () => {
+ const r = await createRuntime(wasm);
+ r.apply('state x=0\nstate y=0\ninput a=pointer(one)\ninput b=pointer(two)\ntext one(0,0,x)\ntext two(0,0,y)\non a.move { x=event_x }\non b.move { y=event_y }');
+ r.point('a','down',1,2,'first'); r.point('b','down',3,4,'second');
+ r.point('a','move',10,20,'first'); r.point('b','move',30,40,'second');
+ assert.deepEqual(r.snapshot().states,{x:10,y:40});
+ r.present({begin(){assert.throws(() => r.point('a','move',0,0,'first'), /출력 전달 중/);},state(){},view(){},value(){},end(){}});
+ r.releaseAll(); assert.deepEqual(r.snapshot().held, []);
+});
+
+test('failing down, up and cancel never prevent terminal ownership cleanup', async () => {
+ for (const phase of ['down', 'up', 'cancel']) {
+ const r = await createRuntime(wasm);
+ r.apply(`state x=7\ninput p=pointer(tile)\ntext tile(0,0,x)\non p.${phase} { x=event_x\nx=1/0 }`);
+ const code = r.point('p','down',10,20,'owner');
+ assert.equal(code, phase === 'down' ? 4 : 0);
+ assert.deepEqual(r.snapshot().held, ['p']);
+ if (phase === 'up') assert.equal(r.point('p','up',30,40,'owner'), 4);
+ else assert.equal(r.releaseAll(), phase === 'cancel' ? 4 : 0);
+ const s = r.snapshot();
+ assert.equal(s.states.x, 7); assert.equal(s.lastError, 4);
+ assert.equal(s.lastFailure.phase, phase); assert.deepEqual(s.held, []);
+ r.apply('state x=100'); // Cleanup also unblocks definition changes.
+ assert.equal(r.snapshot().states.x, 7);
+ }
+});
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/presentation.test.mjs b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/presentation.test.mjs
new file mode 100644
index 0000000..98bef6e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/presentation.test.mjs
@@ -0,0 +1,132 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { createRuntime } from './web/runtime.mjs';
+import { createRuntime as before } from './results/2026-09-23-presentation/before/web/runtime.mjs';
+
+const wasm = await readFile(new URL('./web/core.wasm', import.meta.url));
+const source = `state x = 3
+state radius = 5
+input move = KeyM
+input shrink = KeyS
+box tile(x, 10, x * 2, 20, "#123456")
+circle dot(50, 50, radius, "#abcdef")
+text label(0, 0, 10 / x, "Value: ")
+on move.down { x = x + 0.25 }
+on shrink.down { radius = radius - 10 }
+on frame { x = x + dt }
+`;
+
+// The archived adapter predates route diagnostics. These legacy fixtures have
+// no routes; keep all original fields in the equality comparison.
+function archivedSnapshot(runtime) {
+ const value = runtime.snapshot();
+ value.memory.pointerTargetBytes = 3;
+ value.memory.pointerTargets = 0;
+ return value;
+}
+
+// Reconstruct only in tests, for comparison with the archived snapshot path.
+function collect(runtime, hooks = {}) {
+ let states, views, declared;
+ const result = runtime.present({
+ begin(program) {
+ declared = program; states = {}; views = [];
+ hooks.begin?.();
+ },
+ state(i, value) { states[declared.states[i].name] = value; },
+ view(i, valid) {
+ if (!valid) return;
+ const {id, type, color, prefix} = declared.views[i];
+ views.push({id, type, color, prefix});
+ },
+ value(i, name, value) { views.at(-1)[name] = value; hooks.value?.(); },
+ end(count) { assert.equal(views.length, count); },
+ });
+ return {states, views, ...result};
+}
+
+test('direct publication matches archived snapshots and work counts through authored changes', async () => {
+ for (const cacheEntries of [0, 1, 32]) {
+ const left = await createRuntime(wasm, {cacheEntries}), right = await before(wasm, {cacheEntries});
+ const compare = () => {
+ assert.deepEqual(collect(left), archivedSnapshot(right));
+ assert.deepEqual(left.evaluationStats(), right.evaluationStats());
+ };
+ compare();
+ for (const r of [left, right]) r.apply(source);
+ compare();
+ for (let i = 0; i < 120; i++) {
+ for (const r of [left, right]) {
+ r.down('move', 'keyboard'); r.down('move', 'pointer');
+ r.up('move', 'keyboard'); r.up('move', 'pointer');
+ if (i % 11 === 0) r.frame(2);
+ if (i % 17 === 0) r.forgetEvaluation('history');
+ if (i % 23 === 0) r.reset();
+ }
+ compare();
+ }
+ for (const r of [left, right]) { r.down('shrink'); r.up('shrink'); }
+ compare(); // invalid circle only; other views still publish
+ for (const r of [left, right]) assert.throws(() => r.apply('state broken ='));
+ compare();
+ for (const r of [left, right]) r.apply('state radius = 1\nstate x = 0\ntext label(1, 2, x)');
+ compare();
+ for (const r of [left, right]) r.apply('');
+ compare();
+ }
+});
+
+test('whole-scene budget failure never publishes partial bindings and recovers exactly', async () => {
+ const left = await createRuntime(wasm, {cacheEntries:32}), right = await before(wasm, {cacheEntries:32});
+ for (const r of [left, right]) r.apply(source);
+ for (let workLimit = 0; workLimit <= 80; workLimit++) {
+ for (const r of [left, right]) r.configureEvaluation({workLimit});
+ let calls = 0;
+ const result = collect(left, {value: () => calls++});
+ assert.deepEqual(result, archivedSnapshot(right));
+ assert.deepEqual(left.evaluationStats(), right.evaluationStats());
+ if (result.displayError?.code === 101) assert.equal(calls, 0);
+ else assert.equal(calls, 10);
+ }
+});
+
+test('latched event rollback, nonfinite bindings, reset and signed zero keep their semantics', async () => {
+ const left = await createRuntime(wasm), right = await before(wasm);
+ const definition = 'state x = 0\ninput fail = Space\ntext value(0, 0, 1 / x)\non fail.down { x = 2\nx = 1 / 0 }';
+ for (const r of [left, right]) { r.apply(definition); r.down('fail'); r.up('fail'); }
+ assert.deepEqual(collect(left), archivedSnapshot(right));
+ for (const r of [left, right]) r.configureEvaluation({workLimit:0});
+ assert.deepEqual(collect(left), archivedSnapshot(right));
+ for (const r of [left, right]) {
+ r.apply('state x = -0\ntext value(x, 0, x)'); r.reset(); r.configureEvaluation({workLimit:2048});
+ }
+ const result = collect(left);
+ assert.deepEqual(result, archivedSnapshot(right));
+ assert(Object.is(result.states.x, -0));
+});
+
+test('publication rejects reentrant runtime changes and reads before they affect the scene', async () => {
+ const runtime = await createRuntime(wasm);
+ runtime.apply(source);
+ const calls = [() => runtime.apply(''), () => runtime.reset(), () => runtime.frame(),
+ () => runtime.down('move'), () => runtime.up('move'), () => runtime.releaseAll(),
+ () => runtime.configureEvaluation({workLimit:0}), () => runtime.forgetEvaluation(),
+ () => runtime.snapshot(), () => runtime.present({})];
+ const expected = runtime.snapshot();
+ assert.deepEqual(collect(runtime, {begin() {
+ for (const call of calls) assert.throws(call, /출력 전달 중/);
+ }}), expected);
+ assert.deepEqual(runtime.snapshot(), expected);
+});
+
+test('invalid or throwing output adapters do not strand the runtime or alter authored state', async () => {
+ const runtime = await createRuntime(wasm);
+ runtime.apply(source);
+ const expected = runtime.snapshot();
+ assert.throws(() => runtime.present({}), TypeError);
+ assert.throws(() => collect(runtime, {value() { throw new Error('destination failed'); }}), /destination failed/);
+ assert.deepEqual(collect(runtime), expected);
+ runtime.down('move'); runtime.up('move');
+ assert.equal(collect(runtime).states.x, 3.25);
+});
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/project.test.mjs b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/project.test.mjs
new file mode 100644
index 0000000..ad8a48d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/project.test.mjs
@@ -0,0 +1,79 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {readFile} from 'node:fs/promises';
+import {createRuntime} from './web/runtime.mjs';
+import {parseProject, MAX_PROJECT_BYTES} from './web/project.mjs';
+const wasm = await readFile(new URL('./web/core.wasm',import.meta.url));
+const layout = await readFile(new URL('./examples/layout.pure',import.meta.url),'utf8');
+const fresh = () => createRuntime(wasm,{cacheEntries:32});
+const roundTrip = value => parseProject(JSON.stringify(value));
+
+test('multiple authored placements and a separate invalid draft survive a fresh runtime restore',async()=>{
+ const a=await fresh(); a.apply(layout);
+ for(const [name,x,y,nx,ny] of [['drag1',90,130,180,200],['drag2',290,170,370,210],['drag3',460,230,520,280]]) {
+ a.point(name,'down',x,y);a.point(name,'move',nx,ny);a.point(name,'up',x,y);
+ }
+ a.down('left');a.up('left');
+ const before=a.snapshot(), document=roundTrip(a.exportDocument('state unfinished ='));
+ assert.equal(document.version,2);assert.equal(document.source,layout);
+ const b=await fresh(); b.apply('state old=99'); b.frame(5);
+ const restored=b.restoreDocument(document), after=b.snapshot();
+ assert.equal(restored.draft,'state unfinished =');assert.equal(restored.source,layout);
+ assert.deepEqual(after.states,before.states);assert.deepEqual(after.views,before.views);
+ assert.equal(after.tick,0);assert.equal(after.successfulEvents,0);assert.equal(after.lastEvent,null);assert.deepEqual(after.held,[]);
+ b.down('right');b.up('right');assert.equal(b.snapshot().states.x3,before.states.x3+10);
+ b.reset();assert.equal(b.snapshot().states.x1,80);assert.equal(b.snapshot().states.x2,280);assert.equal(b.snapshot().states.x3,450);
+});
+
+test('malformed files reject before changing active code, values, diagnostics or warm display records',async()=>{
+ const r=await fresh();r.apply('state x=5\ntext t(0,0,x)');r.snapshot();
+ const good=r.exportDocument(), before=r.snapshot(), active=r.currentProgram;
+ const bad=[null,{}, {...good,version:99},{...good,draft:null},{...good,source:'state x='},{...good,states:[]},
+ {...good,states:[{name:'other',value:2}]},{...good,states:[{name:'x',value:null}]},
+ {...good,states:[{name:'x',value:'2'}]},{...good,states:[{name:'x',value:Infinity}]},
+ {...good,states:[{name:'x',value:NaN}]},{...good,draft:'x'.repeat(32769)},
+ {...good,source:'state x=0\nstate y=0',states:[{name:'x',value:1},{name:'x',value:2}]}];
+ for(const doc of bad){assert.throws(()=>r.restoreDocument(doc));assert.equal(r.currentProgram,active);assert.deepEqual(r.snapshot(),before);}
+ assert(r.evaluationStats().cacheHits>0);
+ assert.throws(()=>parseProject('{'));assert.throws(()=>parseProject(' '.repeat(MAX_PROJECT_BYTES+1)));
+});
+
+test('restoration matches names rather than ordering and never reuses stale cached values',async()=>{
+ const r=await fresh();r.apply('state x=1\nstate y=2\ntext t(0,0,x+y)');r.snapshot();r.snapshot();
+ const doc=r.exportDocument();doc.states=[{name:'y',value:20},{name:'x',value:10}];
+ r.restoreDocument(roundTrip(doc));assert.deepEqual(r.snapshot().states,{x:10,y:20});assert.equal(r.snapshot().views[0].value,30);
+ r.apply('state y=200\nstate x=100');assert.deepEqual(r.snapshot().states,{y:20,x:10});
+ r.reset();assert.deepEqual(r.snapshot().states,{y:200,x:100});
+});
+
+test('finite float values including negative zero round trip without changing authored reset values',async()=>{
+ const r=await fresh();r.apply('state z=-0\nstate tiny=5e-324\nstate large=1.7976931348623157e308\nstate constructor=1');
+ const doc=roundTrip(r.exportDocument());assert.equal(doc.states[0].value,'-0');
+ r.restoreDocument(doc);const s=r.snapshot().states;
+ assert(Object.is(s.z,-0));assert.equal(s.tiny,Number.MIN_VALUE);assert.equal(s.large,Number.MAX_VALUE);assert.equal(s.constructor,1);
+});
+
+test('held and failed interactions cannot be saved and restoration cannot steal an active input',async()=>{
+ const r=await fresh();r.apply(layout);const saved=r.exportDocument();
+ r.point('drag1','down',90,130);const before=r.snapshot();
+ assert.throws(()=>r.exportDocument(),/조작/);assert.throws(()=>r.restoreDocument(saved),/놓은 뒤/);assert.deepEqual(r.snapshot(),before);
+ r.releaseAll();r.apply('state x=0\ninput fail=Space\non fail.down { x=1/0 }');
+ r.down('fail');r.up('fail');assert.throws(()=>r.exportDocument(),/오류/);
+ r.restoreDocument(saved);assert.equal(r.snapshot().lastError,0);
+});
+
+test('empty and full documents restore without imposing a new source size or state count',async()=>{
+ const r=await fresh();r.apply('');r.restoreDocument(roundTrip(r.exportDocument()));assert.deepEqual(r.snapshot().states,{});
+ r.apply(Array.from({length:32},(_,i)=>`state s${i}=${i}`).join('\n'));const doc=r.exportDocument();
+ for(const s of doc.states) s.value+=100;
+ r.restoreDocument(roundTrip(doc));assert.equal(r.snapshot().states.s31,131);
+ assert.equal(parseProject(JSON.stringify({format:'pure-workbench',version:1,source:'state unfinished ='})).version,1);
+ assert.throws(()=>r.restoreDocument({format:'pure-workbench',version:1,source:''}));
+});
+
+test('document APIs respect the output callback boundary and export independent values',async()=>{
+ const r=await fresh();r.apply('state x=1');const doc=r.exportDocument();doc.states[0].value=500;
+ assert.equal(r.snapshot().states.x,1);
+ r.present({begin(){assert.throws(()=>r.restoreDocument(doc),/출력 전달/);assert.throws(()=>r.exportDocument(),/출력 전달/);},state(){},view(){},value(){},end(){}});
+ assert.equal(r.snapshot().states.x,1);
+});
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/remote.mjs b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/remote.mjs
new file mode 100644
index 0000000..6ee6a59
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/remote.mjs
@@ -0,0 +1,89 @@
+import http from 'node:http';
+import { MAX_PROJECT_BYTES, parseProject } from './web/project.mjs';
+
+const maxRevision = 9223372036854775807n;
+function revisionText(value, allowZero = false) {
+ if (typeof value !== 'string' || !/^(0|[1-9]\d*)$/.test(value) ||
+ value.length > 19 || BigInt(value) > maxRevision || (!allowZero && value === '0'))
+ throw new Error('revision은 범위 안의 정수 문자열이어야 합니다.');
+ return value;
+}
+
+export function remoteTarget(base, key) {
+ const url = new URL(base);
+ if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost'].includes(url.hostname) ||
+ url.username || url.password || url.pathname !== '/' || url.search || url.hash)
+ throw new Error('현재 연결 호스트는 로컬 http://127.0.0.1:PORT 형식을 사용합니다.');
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(key)) throw new Error('작업물 이름은 영문·숫자·_·- 1~64자입니다.');
+ url.hostname = '127.0.0.1';
+ url.pathname = `/objects/${key}`;
+ return url;
+}
+
+function request(url, method, body, headers = {}) {
+ return new Promise((resolve, reject) => {
+ let done = false;
+ const finish = (error, value) => {
+ if (done) return;
+ done = true;
+ clearTimeout(deadline);
+ if (error) reject(error); else resolve(value);
+ };
+ const req = http.request(url, { method, agent: false, headers: { ...headers,
+ 'Content-Length': body?.byteLength ?? 0, Connection: 'close' } }, (res) => {
+ const chunks = [];
+ let size = 0;
+ res.on('data', (chunk) => {
+ size += chunk.length;
+ if (size > MAX_PROJECT_BYTES) {
+ const error = new Error('서버 작업물이 허용 크기를 넘었습니다.');
+ finish(error);
+ res.destroy(error);
+ req.destroy(error);
+ } else chunks.push(chunk);
+ });
+ res.on('error', (error) => finish(error));
+ res.on('aborted', () => finish(new Error('응답이 완료되기 전에 연결이 끊겼습니다.')));
+ res.on('end', () => {
+ if (res.statusCode !== 200) {
+ const error = new Error(`원격 작업 실패: ${res.statusCode} ${res.headers['x-pure-error'] ?? ''}`.trim());
+ error.status = res.statusCode;
+ error.etag = res.headers.etag;
+ finish(error);
+ return;
+ }
+ try {
+ const match = /^"([1-9]\d*)"$/.exec(res.headers.etag ?? '');
+ if (!match) throw new Error('서버의 revision 응답이 올바르지 않습니다.');
+ const revision = revisionText(match[1]);
+ finish(null, { revision, bytes: Buffer.concat(chunks, size) });
+ } catch (error) { finish(error); }
+ });
+ });
+ const deadline = setTimeout(() => {
+ const error = new Error('원격 작업 시간 초과: 저장 완료 여부는 다시 확인해야 합니다.');
+ finish(error);
+ req.destroy(error);
+ }, 12000);
+ req.on('error', (error) => finish(error));
+ req.end(body);
+ });
+}
+
+export async function pushProject(target, expected, project) {
+ revisionText(expected, true);
+ const text = JSON.stringify(project);
+ parseProject(text);
+ const body = Buffer.from(text, 'utf8');
+ const headers = expected === '0' ? { 'If-None-Match': '*' } : { 'If-Match': `"${expected}"` };
+ const result = await request(target, 'PUT', body, headers);
+ return result.revision;
+}
+
+export async function pullProject(target) {
+ const result = await request(target, 'GET');
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(result.bytes);
+ const project = parseProject(text);
+ if (project.version !== 2) throw new Error('현재 배치를 포함한 version 2 작업물이 필요합니다.');
+ return { project, revision: result.revision };
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/routing.test.mjs b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/routing.test.mjs
new file mode 100644
index 0000000..bae04de
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/routing.test.mjs
@@ -0,0 +1,114 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {readFile} from 'node:fs/promises';
+import {spawnSync} from 'node:child_process';
+import {compile} from './web/compiler.mjs';
+import {createRuntime} from './web/runtime.mjs';
+const wasm=await readFile(new URL('./web/core.wasm',import.meta.url));
+const shared=await readFile(new URL('./examples/shared-layout.pure',import.meta.url),'utf8');
+const routed=await readFile(new URL('./examples/routed-layout.pure',import.meta.url),'utf8');
+const fresh=()=>createRuntime(wasm,{cacheEntries:32});
+const fixture=n=>`input drag=pointer {\n${Array.from({length:n},(_,i)=>`v${i}:c${i}`).join('\n')}\n}\non drag.move use move(drag.target)\nrule move(t:Cell) {t.x=event_x}\nrecord Cell {x=0}\n`+Array.from({length:n},(_,i)=>`item c${i}=Cell()\nbox v${i}(c${i}.x,0,10,10,"#123456")`).join('\n');
+
+test('one input and one handler serve up to 24 static targets without instruction or handler expansion',async()=>{
+ for(const n of [1,3,9,24]){
+ const p=compile(fixture(n));assert.equal(p.inputs.length,1);assert.equal(p.handlers.length,1);
+ assert.equal(p.handlers[0].length,2);assert.equal(p.pointerTargets.length,n);
+ assert.equal(p.handlers[0].parameterBase,255);
+ const r=await fresh();r.apply(fixture(n));
+ r.point('drag','down',0,0,'p',`v${n-1}`);r.point('drag','move',37,0,'p',`v${n-1}`);r.point('drag','up',0,0,'p',`v${n-1}`);
+ const s=r.snapshot();assert.equal(s.states[`c${n-1}.x`],37);
+ for(let i=0;i<n-1;i++)assert.equal(s.states[`c${i}.x`],0);
+ assert.equal(s.memory.pointerTargets,n);assert.equal(s.memory.pointerTargetBytes,3);
+ }
+});
+
+test('routes validate forward references, exact types, unique views/items and current input target',()=>{
+ const base=fixture(2);
+ for(const source of [
+ base.replace('v0:c0','missing:c0'),base.replace('v0:c0','v0:missing'),
+ base.replace('v1:c1','v0:c1'),base.replace('v1:c1','v1:c0'),
+ base.replace('c1=Cell()','c1=Other()')+'\nrecord Other {x=0}',
+ base.replace('use move(drag.target)','use move(other.target)'),
+ base.replace('use move(drag.target)','use move(drag.missing)'),
+ base+'\ninput another=pointer(v0)',
+ base+'\ninput other=pointer { v2:c0 }\nbox v2(0,0,1,1,"#000000")',
+ 'input empty=pointer {}',
+ base.replace('on drag.move','on frame'),
+ base.replace('rule move(t:Cell)','rule move(t:Other)')+'\nrecord Other {x=0}',
+ ])assert.throws(()=>compile(source),source);
+});
+
+test('grouped layout matches explicit bindings through concurrent capture, partial cancel, keyboard and restore',async()=>{
+ const a=await fresh(),b=await fresh();a.apply(shared);b.apply(routed);
+ const compare=()=>{const x=a.snapshot(),y=b.snapshot();assert.deepEqual(y.states,x.states);assert.deepEqual(y.views,x.views);assert.equal(y.lastError,x.lastError);};
+ const point=(name,phase,x,y)=>{a.point(`drag_${name}`,phase,x,y,name);b.point('drag',phase,x,y,name,`${name}_box`);compare();};
+ point('green','down',90,130);point('blue','down',290,170);
+ assert.equal(b.snapshot().held.length,2);
+ assert.throws(()=>b.apply(routed),/놓은/);assert.throws(()=>b.exportDocument(),/조작/);
+ assert.throws(()=>b.point('drag','down',0,0,'intruder','green_box'),/다른 포인터/);
+ b.point('drag','move',999,999,'intruder','green_box');compare();
+ point('green','move',200,210);point('blue','move',500,270);point('green','cancel',200,210);
+ assert.equal(b.snapshot().held.length,1);point('blue','up',290,170);
+ for(const key of ['left','up','right','down']){a.down(key);b.down(key);a.up(key);b.up(key);compare();}
+ point('amber','down',460,230);point('amber','move',900,500);point('amber','up',460,230);
+ const c=await fresh();c.restoreDocument(JSON.parse(JSON.stringify(b.exportDocument())));
+ assert.deepEqual(c.snapshot().states,b.snapshot().states);
+ for(const r of [b,c]){r.point('drag','down',570,290,'new','amber_box');r.point('drag','move',100,150,'new','amber_box');r.releaseAll();}
+ assert.deepEqual(c.snapshot().states,b.snapshot().states);
+ assert.throws(()=>b.point('drag','down',0,0),/도형/);
+ assert.throws(()=>b.point('drag','down',0,0,'p','board'),/도형/);
+ assert.throws(()=>b.down('drag'),/좌표/);
+});
+
+test('capture remains bound to its target and releaseAll cancels each target exactly once',async()=>{
+ const r=await fresh();r.apply(routed+'\nstate cancelled=0\non drag.cancel {cancelled=cancelled+1}');
+ r.point('drag','down',90,130,'a','green_box');r.point('drag','move',200,200,'a','green_box');
+ r.point('drag','down',290,170,'b','blue_box');r.point('drag','move',400,250,'b','blue_box');
+ r.point('drag','up',0,0,'a','blue_box');assert.equal(r.snapshot().held.length,2);
+ r.releaseAll();r.releaseAll();const s=r.snapshot();assert.equal(s.held.length,0);
+ assert.equal(s.states.cancelled,2);assert.equal(s.states['green.x'],80);assert.equal(s.states['blue.x'],280);
+});
+
+test('reorder and remove/readd rebuild routes by names without persisting physical indices',async()=>{
+ const r=await fresh();r.apply(fixture(3));
+ r.point('drag','down',0,0,'p','v2');r.point('drag','move',55,0,'p','v2');r.releaseAll();
+ const reordered=fixture(3).replace('v0:c0\nv1:c1\nv2:c2','v2:c2\nv1:c1\nv0:c0').replace('item c0=Cell()','').replace('item c2=Cell()','item c2=Cell()\nitem c0=Cell()');
+ r.apply(reordered);assert.equal(r.snapshot().states['c2.x'],55);
+ r.point('drag','down',0,0,'p','v2');r.point('drag','move',66,0,'p','v2');r.releaseAll();
+ assert.equal(r.snapshot().states['c2.x'],66);assert.equal(r.snapshot().states['c0.x'],0);
+ const saved=r.exportDocument();const c=await fresh();c.restoreDocument(saved);assert.deepEqual(c.snapshot().states,r.snapshot().states);
+ r.apply(fixture(2));r.apply(fixture(3));assert.equal(r.snapshot().states['c2.x'],0);
+ r.point('drag','move',999,0,'p','v2');assert.equal(r.snapshot().states['c2.x'],0);
+});
+
+test('routed writes invalidate cached displays and failed handlers rollback without losing another target owner',async()=>{
+ const source=fixture(2)+'\nstate total=0\non drag.move {total=total+1; total=1/event_y}';
+ const r=await fresh();r.apply(source);r.snapshot();r.snapshot();
+ r.point('drag','down',0,0,'a','v0');r.point('drag','down',0,0,'b','v1');
+ assert.equal(r.point('drag','move',50,2,'a','v0'),0);assert.equal(r.snapshot().views[0].x,50);
+ assert.equal(r.point('drag','move',99,0,'b','v1'),4);let s=r.snapshot();
+ assert.equal(s.states['c1.x'],0);assert.equal(s.states['c0.x'],50);assert.equal(s.states.total,0.5);
+ assert.equal(s.lastFailure.target,'v1');r.releaseAll();assert.equal(r.snapshot().lastFailure.status,4);
+ const previous=r.currentProgram;assert.throws(()=>r.apply(source.replace('v0:c0','v0:missing')));
+ assert.equal(r.currentProgram,previous);r.reset();assert.equal(r.snapshot().held.length,0);
+});
+
+test('CLI sends grouped pointer events and rejects an unknown target',()=>{
+ const path=new URL('./examples/routed-layout.pure',import.meta.url).pathname;
+ const run=input=>spawnSync(process.execPath,[new URL('./cli.mjs',import.meta.url).pathname,path],{input,encoding:'utf8'});
+ const ok=run('point-target drag green_box down 90 130\npoint-target drag green_box move 210 220\npoint-target drag green_box up 90 130\nquit\n');
+ assert.equal(ok.status,0,ok.stderr);const rows=ok.stdout.trim().split('\n').map(JSON.parse);
+ assert.equal(rows.at(-1).states['green.x'],200);assert.equal(rows.at(-1).states['blue.x'],280);assert.equal(rows.at(-1).held.length,0);
+ const bad=run('point-target drag missing down 0 0\nquit\n');assert.equal(bad.status,1);assert.match(bad.stderr,/도형/);
+});
+
+
+test('a route from a different input group cannot be delivered through the named group',async()=>{
+ const source=fixture(2).replace('v1:c1','')+'\ninput second=pointer {v1:c1}\non second.move use move(second.target)';
+ const r=await fresh();r.apply(source);
+ assert.throws(()=>r.point('drag','down',0,0,'p','v1'),/도형/);
+ assert.throws(()=>r.point('second','down',0,0,'p','v0'),/도형/);
+ r.point('second','down',0,0,'p','v1');r.point('second','move',77,0,'p','v1');r.releaseAll();
+ assert.equal(r.snapshot().states['c0.x'],0);assert.equal(r.snapshot().states['c1.x'],77);
+});
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/rules.test.mjs b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/rules.test.mjs
new file mode 100644
index 0000000..b4c4969
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/rules.test.mjs
@@ -0,0 +1,94 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {readFile} from 'node:fs/promises';
+import {compile} from './web/compiler.mjs';
+import {createRuntime} from './web/runtime.mjs';
+import {parseProject} from './web/project.mjs';
+const wasm=await readFile(new URL('./web/core.wasm',import.meta.url));
+const shared=await readFile(new URL('./examples/shared-layout.pure',import.meta.url),'utf8');
+const explicit=await readFile(new URL('./examples/layout.pure',import.meta.url),'utf8');
+const fresh=()=>createRuntime(wasm,{cacheEntries:32});
+const named=['green','blue','amber'];
+
+function simple(n){return 'record Cell { value=1 }\ninput tap=Space\nrule inc(t: Cell) { t.value=t.value+1 }\n'+Array.from({length:n},(_,i)=>`item cell${i}=Cell()\non tap.down use inc(cell${i})`).join('\n');}
+
+test('binding more objects shares one VM body instead of expanding its instructions',()=>{
+ for(const n of [1,2,4,8]){
+ const p=compile(simple(n));
+ assert.equal(p.instructions.length,4);assert.equal(p.handlers.length,n);
+ assert.deepEqual(p.handlers.map(h=>h.start),Array(n).fill(0));
+ assert.deepEqual(p.handlers.map(h=>[h.parameterBase,h.parameterCount]),Array.from({length:n},(_,i)=>[i,1]));
+ }
+ assert.equal(compile('record R { value=0 }\nrule unused(t:R) { t.value=t.value+1 }').instructions.length,0);
+});
+
+test('records and rules resolve forward names and reject invalid types fields and bindings',()=>{
+ const p=compile('on tap.down use inc(a)\nitem a=R(v=4)\ninput tap=Space\nrule inc(t:R) { t.v=t.v+1 }\nrecord R { v=0 }');
+ assert.equal(p.states[0].name,'a.v');assert.deepEqual([p.handlers[0].parameterBase,p.handlers[0].parameterCount],[0,1]);
+ for(const code of [
+ 'record R {}','record R { x=0; x=1 }','record R { event_x=0 }',
+ 'item a=Missing()','record R {x=0}\nitem a=R(y=1)',
+ 'record R {x=0}\nitem a=R(x=1,x=2)','record R {x=0}\nstate a=0\nitem a=R()',
+ 'record R {x=0}\nrule bad(t:R) {t.missing=0}',
+ 'record R {x=0}\nrecord S {y=0}\nitem t=S()\nrule bad(t:R) {t.y=1}',
+ 'state value=0\nrecord R {x=0}\nrecord S {y=0}\nitem t=S()\nrule bad(t:R) {value=t.y}',
+ 'rule bad(t:Missing) {}','record R {x=0}\nitem a=R()\ninput k=Space\non k.down use missing(a)',
+ 'record R {x=0}\nrule r(t:R) {}\ninput k=Space\non k.down use r(missing)',
+ 'record R {x=0}\nrecord S {x=0}\nitem a=S()\nrule r(t:R) {}\ninput k=Space\non k.down use r(a)',
+ 'record R {x=0}\nitem a=R()\nrule r(t:R) {t.x=event_x}\ninput k=Space\non k.down use r(a)',
+ 'record R {x=0}\nitem a=R()\nrule r(t:R) {t.x=event_y}\non frame use r(a)',
+ 'record R {x=0}\nitem a=R()\ntext label(0,0,t.x)',
+ `record R { ${Array.from({length:9},(_,i)=>`f${i}=0`).join(';')} }`,
+ ]) assert.throws(()=>compile(code),code);
+ assert.throws(()=>compile(simple(33)),/32 states/);
+});
+
+test('shared layout matches the existing authored layout across independent and simultaneous gestures',async()=>{
+ const a=await fresh(),b=await fresh();a.apply(explicit);b.apply(shared);
+ const compare=()=>{
+ const old=a.snapshot(),next=b.snapshot();assert.equal(next.lastError,old.lastError);
+ assert.equal(next.states.selected,old.states.selected);
+ for(let i=1;i<=3;i++)for(const field of ['x','y','ox','oy','sx','sy','held'])assert.equal(next.states[`${named[i-1]}.${field}`],old.states[`${field}${i}`]);
+ for(let i=0;i<old.views.length;i++)for(const field of ['x','y','width','height','value'])assert.equal(next.views[i][field],old.views[i][field]);
+ };
+ const point=(i,phase,x,y)=>{a.point(`drag${i}`,phase,x,y,`p${i}`);b.point(`drag_${named[i-1]}`,phase,x,y,`p${i}`);compare();};
+ compare();point(1,'down',90,130);point(2,'down',290,170);point(1,'move',350,200);point(2,'move',500,300);point(2,'up',290,170);point(1,'cancel',350,200);
+ for(const key of ['left','right','up','down']){a.down(key);b.down(key);a.up(key);b.up(key);compare();}
+ point(3,'down',460,230);point(3,'move',900,500);point(3,'up',460,230);
+ const saved=JSON.parse(JSON.stringify(b.exportDocument()));const c=await fresh();c.restoreDocument(saved);
+ assert.deepEqual(c.snapshot().states,b.snapshot().states);assert.deepEqual(c.snapshot().views,b.snapshot().views);
+ c.down('left');c.up('left');assert.equal(c.snapshot().states['amber.x'],b.snapshot().states['amber.x']-10);
+});
+
+test('field names retain identity across schema and instance reorder, removal and restoration',async()=>{
+ const r=await fresh();
+ const source='record R { x=1;y=2 }\nitem a=R(x=10)\nitem b=R(x=20)\ninput k=Space\nrule step(t:R) {t.x=t.x+1}\non k.down use step(b)';
+ r.apply(source);r.down('k');r.up('k');
+ r.apply(source.replace('x=1;y=2','y=2;x=1').replace('item a=R(x=10)\nitem b=R(x=20)','item b=R(x=20)\nitem a=R(x=10)'));
+ r.down('k');r.up('k');assert.equal(r.snapshot().states['b.x'],22);assert.equal(r.snapshot().states['a.x'],10);
+ r.apply(source.replace('item a=R(x=10)\n',''));r.down('k');r.up('k');assert.equal(r.snapshot().states['b.x'],23);assert(!Object.hasOwn(r.snapshot().states,'a.x'));
+ const saved=r.exportDocument();r.apply(source);assert.equal(r.snapshot().states['a.x'],10);
+ r.restoreDocument(saved);assert.equal(r.snapshot().states['b.x'],23);assert(!Object.hasOwn(r.snapshot().states,'a.x'));
+});
+
+test('bindings share one event transaction including global effects and multiple uses of one item',async()=>{
+ const r=await fresh();r.apply('state total=0\nrecord R {x=8;divisor=2}\nitem a=R()\nitem b=R(divisor=0)\ninput k=Space\nrule divide(t:R) {total=total+1\nt.x=t.x/t.divisor}\non k.down use divide(a)\non k.down use divide(b)');
+ r.down('k');assert.equal(r.snapshot().lastError,4);assert.equal(r.snapshot().states.total,0);assert.equal(r.snapshot().states['a.x'],8);
+ r.up('k');r.apply(simple(1)+'\non tap.down use inc(cell0)');r.down('tap');r.up('tap');assert.equal(r.snapshot().states['cell0.value'],3);
+});
+
+test('bound writes invalidate affected display caches and failed edits preserve active bindings',async()=>{
+ const r=await fresh();const source=simple(2)+'\ntext first(0,0,cell0.value)\ntext second(0,0,cell1.value)';r.apply(source);r.snapshot();r.snapshot();
+ r.down('tap');r.up('tap');assert.deepEqual(r.snapshot().views.map(v=>v.value),[2,2]);
+ const before=r.snapshot();assert.throws(()=>r.apply(source.replace('inc(cell1)','inc(missing)')));assert.deepEqual(r.snapshot(),before);
+ r.down('tap');r.up('tap');assert.deepEqual(r.snapshot().views.map(v=>v.value),[3,3]);
+});
+
+test('generated qualified names cannot make a valid exported document unreadable by its own loader',async()=>{
+ const field='f'.repeat(18000);
+ const source=`record R { ${field}=1 }\n`+Array.from({length:32},(_,i)=>`item i${i}=R()`).join('\n');
+ const r=await fresh();r.apply(source);
+ const saved=JSON.stringify(r.exportDocument('\u0001'.repeat(32768)));
+ r.restoreDocument(parseProject(saved));
+ assert.equal(r.snapshot().states[`i31.${field}`],1);
+});
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/runtime.test.mjs b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/runtime.test.mjs
new file mode 100644
index 0000000..86bc9ba
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/runtime.test.mjs
@@ -0,0 +1,197 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { createRuntime } from './web/runtime.mjs';
+
+const wasm = await readFile(new URL('./web/core.wasm', import.meta.url));
+const fresh = () => createRuntime(wasm);
+const countSource = `state total = 3
+input add = KeyA
+box bar(20, 80, total * 10, 30, "#abcdef")
+text label(20, 60, total, "Total: ")
+on add.down { total = total + 2 }
+on add.up { total = total + 1 }
+`;
+
+test('blank startup and applied empty source contain no application objects or behavior', async () => {
+ const runtime = await fresh();
+ assert.equal(runtime.currentProgram, null);
+ assert.deepEqual(runtime.snapshot().states, {});
+ assert.deepEqual(runtime.snapshot().views, []);
+ runtime.apply('');
+ runtime.frame(60);
+ assert.deepEqual(runtime.snapshot().states, {});
+ assert.deepEqual(runtime.snapshot().views, []);
+ assert.equal(runtime.snapshot().tick, 60);
+ assert.throws(() => runtime.down('jump'), /jump/);
+});
+
+test('author can add unrelated states inputs views and behavior entirely through source', async () => {
+ const runtime = await fresh();
+ runtime.apply(countSource);
+ runtime.down('add'); runtime.up('add');
+ assert.equal(runtime.snapshot().states.total, 6);
+ const added = `${countSource}\nstate radius = 12
+input inflate = KeyI
+circle bubble(240, 160, radius, "#123456")
+on inflate.down { radius = radius + total }
+on frame { radius = radius + 60 * dt }
+`;
+ runtime.apply(added);
+ runtime.down('inflate'); runtime.up('inflate'); runtime.frame(2);
+ assert.deepEqual(runtime.snapshot().states, { total: 6, radius: 20 });
+ assert.equal(runtime.snapshot().views.find(view => view.id === 'bubble').radius, 20);
+ assert.equal(runtime.snapshot().views.length, 3);
+});
+
+test('keyboard and pointer sources aggregate first down and last up; repeats are ignored', async () => {
+ const runtime = await fresh(); runtime.apply(countSource);
+ runtime.down('add', 'keyboard'); runtime.down('add', 'keyboard'); runtime.down('add', 'pointer');
+ assert.equal(runtime.snapshot().states.total, 5);
+ runtime.up('add', 'unknown'); runtime.up('add', 'keyboard');
+ assert.equal(runtime.snapshot().states.total, 5);
+ assert.deepEqual(runtime.snapshot().held, ['add']);
+ runtime.releaseAll(); runtime.up('add', 'pointer');
+ assert.equal(runtime.snapshot().states.total, 6);
+ assert.deepEqual(runtime.snapshot().held, []);
+ assert.equal(runtime.snapshot().successfulEvents, 2);
+});
+
+test('held input rejects apply without changing applied source, metadata, or values', async () => {
+ const runtime = await fresh(); runtime.apply(countSource); runtime.down('add');
+ const before = runtime.snapshot();
+ assert.throws(() => runtime.apply('state changed = 9'), /놓은 뒤/);
+ assert.deepEqual(runtime.snapshot(), before);
+ assert.equal(runtime.exportProject().source, countSource);
+ runtime.up('add'); runtime.apply('state changed = 9');
+ assert.deepEqual(runtime.snapshot().states, { changed: 9 });
+});
+
+test('invalid syntax, unknown references and capacity overflow preserve the previous program', async () => {
+ const runtime = await fresh(); runtime.apply(countSource); runtime.down('add'); runtime.up('add');
+ const before = runtime.snapshot();
+ for (const source of ['state broken =', 'state n = 0\non frame { n = missing }',
+ Array.from({ length: 33 }, (_, i) => `state v${i} = ${i}`).join('\n'), '#'.repeat(32769)]) {
+ assert.throws(() => runtime.apply(source));
+ assert.deepEqual(runtime.snapshot(), before);
+ assert.equal(runtime.exportProject().source, countSource);
+ }
+});
+
+test('state preservation follows names across reorder/add/remove; reset uses new initializers', async () => {
+ const runtime = await fresh();
+ runtime.apply('state x = 11\nstate y = 22');
+ runtime.apply('state z = 30\nstate y = 2\nstate x = 1');
+ assert.deepEqual(runtime.snapshot().states, { z: 30, y: 22, x: 11 });
+ runtime.apply('state y = 200\nstate renamed = 9');
+ assert.deepEqual(runtime.snapshot().states, { y: 22, renamed: 9 });
+ runtime.reset();
+ assert.deepEqual(runtime.snapshot().states, { y: 200, renamed: 9 });
+ assert.equal(runtime.snapshot().revision, 3);
+});
+
+test('callers cannot mutate active metadata and corrupt name-based state preservation', async () => {
+ const runtime = await fresh(); const program = runtime.apply('state x = 11\nstate y = 22');
+ assert.throws(() => program.states.reverse(), TypeError);
+ assert.throws(() => { runtime.currentProgram.states[0].name = 'y'; }, TypeError);
+ runtime.apply('state x = 0\nstate y = 0');
+ assert.deepEqual(runtime.snapshot().states, { x: 11, y: 22 });
+});
+
+test('conditions execute once, and assignments and multiple handlers follow source order', async () => {
+ const runtime = await fresh();
+ runtime.apply(`state x = 0
+state seen = 0
+on frame {
+ if x == 0 {
+ x = 1
+ seen = x + 1
+ }
+ x = x + 3
+}
+on frame { seen = seen + x }
+`);
+ runtime.frame();
+ assert.deepEqual(runtime.snapshot().states, { x: 4, seen: 6 });
+ runtime.frame();
+ assert.deepEqual(runtime.snapshot().states, { x: 7, seen: 13 });
+});
+
+test('a failed event rolls back all handlers and keeps its source diagnostic through release', async () => {
+ const runtime = await fresh();
+ const source = `state x = 7
+state y = 9
+input breakit = Space
+on breakit.down { x = 99 }
+on breakit.down {
+ y = 2
+ x = 1 / 0
+}
+`;
+ runtime.apply(source);
+ assert.equal(runtime.down('breakit'), 4);
+ assert.deepEqual(runtime.snapshot().states, { x: 7, y: 9 });
+ assert.equal(runtime.snapshot().successfulEvents, 0);
+ assert.equal(runtime.snapshot().errorLine, 7);
+ runtime.up('breakit'); runtime.frame();
+ assert.equal(runtime.snapshot().lastEvent.status, 0);
+ assert.equal(runtime.snapshot().lastFailure.phase, 'down');
+ assert.equal(runtime.snapshot().lastError, 4);
+ assert.equal(runtime.snapshot().errorLine, 7);
+ assert.throws(() => runtime.apply('bad'));
+ assert.equal(runtime.snapshot().lastError, 4);
+ runtime.apply(source.replace('1 / 0', '10'));
+ assert.equal(runtime.snapshot().lastError, 0);
+ runtime.down('breakit'); runtime.up('breakit');
+ assert.deepEqual(runtime.snapshot().states, { x: 10, y: 2 });
+});
+
+test('reset clears held inputs and latched errors; invalid frame counts have no effects', async () => {
+ const runtime = await fresh();
+ runtime.apply('state x = 5\ninput tap = Space\non tap.down { x = 1 / 0 }');
+ runtime.down('tap'); runtime.reset();
+ const before = runtime.snapshot();
+ assert.equal(before.lastError, 0); assert.equal(before.lastFailure, null);
+ assert.deepEqual(before.held, []);
+ for (const count of [-1, 601, 0.5, NaN, Infinity]) assert.throws(() => runtime.frame(count));
+ assert.deepEqual(runtime.snapshot(), before);
+});
+
+test('render evaluation is pure and invalid binding is distinct from an event rollback', async () => {
+ const runtime = await fresh(); runtime.apply(countSource); runtime.down('add'); runtime.up('add');
+ const before = runtime.snapshot();
+ for (let i = 0; i < 100; i++) assert.deepEqual(runtime.snapshot(), before);
+ runtime.apply('state n = 2\ncircle bad(20, 20, -n, "#123456")\ntext fine(0, 0, n)');
+ const result = runtime.snapshot();
+ assert.equal(result.lastError, 100); assert.equal(result.errorLine, 2);
+ assert.deepEqual(result.states, { n: 2 }); assert.equal(result.views.length, 1);
+ assert.equal(result.lastFailure, null);
+ assert.deepEqual(runtime.snapshot(), result);
+});
+
+test('independent adapters replay the same author program without sharing live state', async () => {
+ const left = await fresh(), right = await fresh();
+ left.apply(countSource); right.apply(countSource);
+ for (const runtime of [left, right]) {
+ runtime.down('add', 'keyboard'); runtime.frame(3); runtime.up('add', 'keyboard');
+ }
+ assert.deepEqual(left.snapshot(), right.snapshot());
+ left.down('add');
+ assert.equal(right.snapshot().states.total, 6);
+ assert.equal(left.snapshot().states.total, 8);
+});
+
+test('Wasm has no imports and reports fixed memory and exact active IR/state storage', async () => {
+ const module = await WebAssembly.compile(wasm);
+ assert.deepEqual(WebAssembly.Module.imports(module), []);
+ const runtime = await fresh(); const program = runtime.apply(countSource);
+ const before = runtime.snapshot().memory;
+ assert.equal(before.linearBytes, 65536);
+ assert.equal(before.instructionBytes, 16); assert.equal(before.handlerBytes, 16);
+ assert.equal(before.activeIRBytes, program.instructions.length * 16 + program.handlers.length * 16);
+ assert.equal(before.activeStateBytes, program.states.length * 8);
+ assert(before.stagingReservedBytes < before.reservedEngineBytes);
+ assert(before.reservedEngineBytes < before.linearBytes);
+ for (let i = 0; i < 20; i++) runtime.frame(600);
+ assert.deepEqual(runtime.snapshot().memory, before);
+});
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/value-input.test.mjs b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/value-input.test.mjs
new file mode 100644
index 0000000..3044048
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/value-input.test.mjs
@@ -0,0 +1,50 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {readFile} from 'node:fs/promises';
+import {spawnSync} from 'node:child_process';
+import {compile} from './web/compiler.mjs';
+import {createRuntime} from './web/runtime.mjs';
+const wasm=await readFile(new URL('./web/core.wasm',import.meta.url));
+const source=await readFile(new URL('./examples/document.pure',import.meta.url),'utf8');
+
+test('numeric controls use explicit change context and cannot read pointer or frame payloads',()=>{
+ const p=compile(source);assert.equal(p.states.length,1);assert.equal(p.views.length,0);assert.equal(p.handlers[0].event,33);
+ for(const s of [
+ 'state x=0\ninput n=value\non n.down {x=1}',
+ 'state x=0\ninput k=Space\non k.change {x=1}',
+ 'state x=0\ninput n=value\non n.change {x=event_x}',
+ 'state x=0\non frame {x=event_value}',
+ 'input n=value\ntext v(0,0,event_value)',
+ 'record R {x=0}\nitem a=R()\ninput k=Space\nrule r(t:R){t.x=event_value}\non k.down use r(a)',
+ 'state event_value=0',
+ ])assert.throws(()=>compile(s),s);
+});
+
+test('numeric input handles scalar rules, rollback, cache invalidation and signed zero',async()=>{
+ const r=await createRuntime(wasm,{cacheEntries:32});
+ const code='record R {x=10}\nitem a=R()\ninput n=value\nrule r(t:R){t.x=event_value}\non n.change use r(a)\ntext v(0,0,a.x)';
+ r.apply(code);r.snapshot();r.snapshot();r.change('n',25);assert.equal(r.snapshot().views[0].value,25);
+ r.change('n',-0);assert(Object.is(r.snapshot().states['a.x'],-0));assert.deepEqual(r.snapshot().held,[]);
+ const before=r.snapshot();assert.throws(()=>r.change('n',Infinity));assert.throws(()=>r.down('n'));assert.throws(()=>r.point('n','down',1,2));
+ assert.equal(r.snapshot().successfulEvents,before.successfulEvents);
+ r.apply(code+'\non n.change {a.x=1/0}');assert.equal(r.change('n',80),4);assert(Object.is(r.snapshot().states['a.x'],-0));
+ assert.equal(r.snapshot().lastFailure.value,80);assert.throws(()=>r.exportDocument(),/오류/);
+ r.apply(code);const saved=r.exportDocument();const next=await createRuntime(wasm);next.restoreDocument(saved);next.change('n',32);assert.equal(next.snapshot().states['a.x'],32);
+});
+
+test('the document policy receives range, keyboard and pointer requests without layouts or text in VM state',async()=>{
+ const r=await createRuntime(wasm);r.apply(source);
+ for(const [value,expected] of [[60,60],[1000,80],[-1000,20],[45.5,45.5]]){assert.equal(r.change('split',value,'control'),0);assert.deepEqual(r.snapshot().states,{ratio:expected});}
+ r.down('reset');r.up('reset');assert.deepEqual(r.snapshot().states,{ratio:50});
+ assert.equal(r.snapshot().views.length,0);assert.equal(r.snapshot().memory.activeStateBytes,8);
+ const published=[];const target={begin(){},view(){assert.fail('no declared views');},value(){assert.fail('no view values');},state(i,v){published.push(v);},end(n){assert.equal(n,0);}};
+ r.present(target);assert.deepEqual(published,[50]);
+ r.apply(source.replace('20, 80','30, 70'));r.change('split',10);assert.equal(r.snapshot().states.ratio,30);
+ const current=r.currentProgram;assert.throws(()=>r.apply(source+'\non split.down {ratio=0}'));assert.equal(r.currentProgram,current);
+});
+
+test('CLI supplies the same numeric control value and rejects nonfinite input',()=>{
+ const run=input=>spawnSync(process.execPath,[new URL('./cli.mjs',import.meta.url).pathname,new URL('./examples/document.pure',import.meta.url).pathname],{input,encoding:'utf8'});
+ const ok=run('change split 95\nquit\n');assert.equal(ok.status,0,ok.stderr);assert.equal(JSON.parse(ok.stdout.trim()).states.ratio,80);
+ const bad=run('change split NaN\nquit\n');assert.equal(bad.status,1);assert.match(bad.stderr,/유한/);
+});
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/baseline.mjs b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/baseline.mjs
new file mode 100644
index 0000000..3873e31
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/baseline.mjs
@@ -0,0 +1,68 @@
+// Handwritten JavaScript reference for examples/charge.pure.
+// This fixed app has no source compiler, live apply, or state-name migration.
+// Its scene and behavior must be changed by editing this JavaScript file.
+export function createChargeModel() {
+ const held = new Set();
+ let holding = 0, charge = 0, y = 240, velocity = 0, tick = 0;
+ const checkInput = name => {
+ if (name !== 'jump') throw new Error(`Unknown input: ${name}`);
+ };
+
+ const model = {
+ down(name = 'jump', source = 'external') {
+ checkInput(name);
+ const wasDown = held.size > 0;
+ held.add(source);
+ if (!wasDown && y >= 240) {
+ holding = 1;
+ charge = 0;
+ }
+ return 0;
+ },
+ up(name = 'jump', source = 'external') {
+ checkInput(name);
+ if (!held.delete(source) || held.size) return 0;
+ if (holding) {
+ velocity = -(160 + charge * 300);
+ holding = 0;
+ charge = 0;
+ }
+ return 0;
+ },
+ frame(count = 1) {
+ if (!Number.isInteger(count) || count < 0 || count > 600) {
+ throw new Error('Frame count must be an integer from 0 through 600.');
+ }
+ const dt = 1 / 60;
+ for (let frame = 0; frame < count; frame++) {
+ if (holding) charge = Math.min(Math.max(charge + dt, 0), 1);
+ velocity = velocity + 600 * dt;
+ y = y + velocity * dt;
+ if (y >= 240) { y = 240; velocity = 0; }
+ tick++;
+ }
+ return 0;
+ },
+ releaseAll() {
+ for (const source of [...held]) model.up('jump', source);
+ return 0;
+ },
+ reset() {
+ held.clear();
+ holding = 0; charge = 0; y = 240; velocity = 0; tick = 0;
+ },
+ snapshot() {
+ return {
+ states: { holding, charge, y, velocity }, tick,
+ held: held.size ? ['jump'] : [],
+ views: [
+ { id: 'floor', type: 'box', x: 40, y: 261, width: 560, height: 4, color: '#356548' },
+ { id: 'actor', type: 'circle', x: 180, y, radius: 20, color: '#c78638' },
+ { id: 'meter', type: 'box', x: 80, y: 80, width: charge * 200, height: 12, color: '#c78638' },
+ { id: 'amount', type: 'text', x: 80, y: 60, value: charge, prefix: 'Charge: ' },
+ ],
+ };
+ },
+ };
+ return Object.freeze(model);
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/compiler.mjs b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/compiler.mjs
new file mode 100644
index 0000000..6aec335
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/compiler.mjs
@@ -0,0 +1,496 @@
+// Pure's experimental scalar interaction language. No host code is evaluated.
+const LIMITS = Object.freeze({ sourceBytes: 32768, states: 32, inputs: 8,
+ views: 24, handlers: 64, instructions: 512, depth: 64, stack: 64 });
+const RESERVED = new Set(['frame', 'dt', 'event_x', 'event_y', 'event_value', 'min', 'max', 'clamp']);
+const BINARY = new Map([
+ ['||', [1, 15]], ['&&', [2, 14]], ['==', [3, 12]], ['!=', [3, 13]],
+ ['<', [4, 8]], ['<=', [4, 9]], ['>', [4, 10]], ['>=', [4, 11]],
+ ['+', [5, 3]], ['-', [5, 4]], ['*', [6, 5]], ['/', [6, 6]],
+]);
+const FUNCTIONS = { min: [2, 17], max: [2, 18], clamp: [3, 19] };
+const VIEW_FIELDS = { box: ['x', 'y', 'width', 'height'],
+ circle: ['x', 'y', 'radius'], text: ['x', 'y', 'value'] };
+
+function fail(token, message) {
+ const error = new Error(`${message} (${token.line}:${token.column})`);
+ error.line = token.line;
+ error.column = token.column;
+ throw error;
+}
+
+function lex(source) {
+ const tokens = [];
+ let offset = 0, line = 1, column = 1;
+ const advance = () => {
+ const char = source[offset++];
+ if (char === '\n') { line++; column = 1; } else column++;
+ return char;
+ };
+ while (offset < source.length) {
+ const char = source[offset];
+ if (char === ' ' || char === '\t' || char === '\r') { advance(); continue; }
+ if (char === '#') { while (offset < source.length && source[offset] !== '\n') advance(); continue; }
+ const token = { line, column };
+ if (char === '\n') { advance(); tokens.push({ ...token, kind: 'nl' }); continue; }
+ if (char === '"') {
+ advance();
+ let value = '', closed = false;
+ while (offset < source.length) {
+ const next = advance();
+ if (next === '"') { closed = true; break; }
+ if (next === '\n' || next === '\r') fail(token, 'Strings must fit on one line');
+ if (next === '\\') {
+ const escaped = advance();
+ const escapes = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' };
+ if (!Object.hasOwn(escapes, escaped)) fail(token, 'Unsupported string escape');
+ value += escapes[escaped];
+ } else value += next;
+ }
+ if (!closed) fail(token, 'Unterminated string');
+ tokens.push({ ...token, kind: 'string', value }); continue;
+ }
+ const remaining = source.slice(offset);
+ const number = remaining.match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/);
+ if (number) {
+ const value = Number(number[0]);
+ if (!Number.isFinite(value)) fail(token, 'Number must be finite');
+ for (let i = 0; i < number[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'number', value }); continue;
+ }
+ const name = remaining.match(/^[A-Za-z_][A-Za-z_0-9]*/);
+ if (name) {
+ for (let i = 0; i < name[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'name', value: name[0] }); continue;
+ }
+ const pair = source.slice(offset, offset + 2);
+ if (['<=', '>=', '==', '!=', '&&', '||'].includes(pair)) {
+ advance(); advance(); tokens.push({ ...token, kind: pair }); continue;
+ }
+ if ('=+-*/!<>(){},.;:'.includes(char)) {
+ advance(); tokens.push({ ...token, kind: char }); continue;
+ }
+ fail(token, `Unexpected character ${JSON.stringify(char)}`);
+ }
+ tokens.push({ kind: 'eof', line, column });
+ return tokens;
+}
+
+class Parser {
+ constructor(tokens) { this.tokens = tokens; this.position = 0; this.depth = 0; }
+ peek() { return this.tokens[this.position]; }
+ take() { return this.tokens[this.position++]; }
+ is(kind, value) { return this.peek().kind === kind && (value === undefined || this.peek().value === value); }
+ expect(kind, description = kind) {
+ if (!this.is(kind)) fail(this.peek(), `Expected ${description}`);
+ return this.take();
+ }
+ newlines() { while (this.is('nl')) this.take(); }
+ separators() { while (this.is('nl') || this.is(';')) this.take(); }
+ end() {
+ if (!['nl', ';', '}', 'eof'].includes(this.peek().kind)) fail(this.peek(), 'Expected newline or semicolon');
+ this.separators();
+ }
+ reference(token) {
+ if (!this.is('.')) return token.value;
+ this.take();
+ return `${token.value}.${this.expect('name', 'field name').value}`;
+ }
+ initial() {
+ let sign = 1;
+ if (this.is('-') || this.is('+')) sign = this.take().kind === '-' ? -1 : 1;
+ return sign * this.expect('number', 'numeric state initializer').value;
+ }
+ node(token, properties, children = []) {
+ const depth = 1 + Math.max(0, ...children.map(child => child.depth));
+ if (depth > LIMITS.depth) fail(token, 'Expression nesting exceeds 64');
+ return { token, depth, ...properties };
+ }
+ expression(minimum = 0, multiline = false) {
+ if (++this.depth > LIMITS.depth) fail(this.peek(), 'Expression nesting exceeds 64');
+ if (multiline) this.newlines();
+ const token = this.take();
+ let left;
+ if (token.kind === 'number') left = this.node(token, { kind: 'constant', value: token.value });
+ else if (token.kind === '-' || token.kind === '!') {
+ const child = this.expression(7, multiline);
+ left = this.node(token, { kind: 'unary', op: token.kind === '-' ? 7 : 16, child }, [child]);
+ } else if (token.kind === '(') {
+ left = this.expression(0, true); this.newlines(); this.expect(')');
+ } else if (token.kind === 'name') {
+ if (Object.hasOwn(FUNCTIONS, token.value) && this.is('(')) {
+ this.take();
+ const args = [];
+ this.newlines();
+ if (!this.is(')')) {
+ for (;;) {
+ args.push(this.expression(0, true)); this.newlines();
+ if (!this.is(',')) break;
+ this.take();
+ }
+ }
+ this.expect(')');
+ if (args.length !== FUNCTIONS[token.value][0]) fail(token, `${token.value} expects ${FUNCTIONS[token.value][0]} arguments`);
+ left = this.node(token, { kind: 'call', op: FUNCTIONS[token.value][1], args }, args);
+ } else left = this.node(token, { kind: 'reference', name: this.reference(token) });
+ } else fail(token, 'Expected a number, state, or expression');
+ for (;;) {
+ if (multiline) this.newlines();
+ const entry = BINARY.get(this.peek().kind);
+ if (!entry || entry[0] < minimum) break;
+ const operator = this.take();
+ const right = this.expression(entry[0] + 1, multiline);
+ left = this.node(operator, { kind: 'binary', op: entry[1], left, right }, [left, right]);
+ }
+ this.depth--;
+ return left;
+ }
+ block(depth = 0) {
+ if (depth >= LIMITS.depth) fail(this.peek(), 'Handler nesting exceeds 64');
+ this.newlines(); this.expect('{'); this.separators();
+ const statements = [];
+ while (!this.is('}')) {
+ const token = this.expect('name', 'assignment, if, or closing brace');
+ if (token.value === 'if' && !this.is('=')) {
+ const condition = this.expression();
+ const body = this.block(depth + 1);
+ statements.push({ kind: 'if', token, condition, body });
+ } else {
+ const name = this.reference(token);
+ this.expect('=');
+ statements.push({ kind: 'assign', token, name, expression: this.expression() });
+ }
+ this.end();
+ }
+ this.take();
+ return statements;
+ }
+ parse() {
+ const declarations = [];
+ this.separators();
+ while (!this.is('eof')) {
+ const token = this.expect('name', 'declaration');
+ const kind = token.value;
+ if (kind === 'state') {
+ const name = this.expect('name', 'state name'); this.expect('=');
+ declarations.push({ kind, token, name, initial: this.initial() });
+ } else if (kind === 'record') {
+ const name = this.expect('name', 'record type'); this.newlines(); this.expect('{'); this.separators();
+ const fields = [];
+ while (!this.is('}')) {
+ const field = this.expect('name', 'field name'); this.expect('=');
+ fields.push({name:field, initial:this.initial()}); this.end();
+ }
+ this.take(); declarations.push({kind,token,name,fields});
+ } else if (kind === 'item') {
+ const name = this.expect('name', 'item name'); this.expect('=');
+ const type = this.expect('name', 'record type'); this.expect('('); this.newlines();
+ const fields = [];
+ while (!this.is(')')) {
+ const field = this.expect('name', 'field name'); this.expect('=');
+ fields.push({name:field, initial:this.initial()}); this.newlines();
+ if (!this.is(',')) break;
+ this.take(); this.newlines();
+ }
+ this.expect(')'); declarations.push({kind,token,name,type,fields});
+ } else if (kind === 'rule') {
+ const name = this.expect('name', 'rule name'); this.expect('(');
+ const parameter = this.expect('name', 'record parameter'); this.expect(':');
+ const type = this.expect('name', 'record type'); this.expect(')');
+ declarations.push({kind,token,name,parameter,type,body:this.block()});
+ } else if (kind === 'input') {
+ const name = this.expect('name', 'input name'); this.expect('=');
+ const key = this.expect('name', 'keyboard code or pointer(view)');
+ if (key.value === 'value') {
+ declarations.push({kind,token,name,valueInput:true}); this.end(); continue;
+ }
+ if (key.value === 'pointer') {
+ if (this.is('{')) {
+ this.take(); this.separators(); const routes = [];
+ while (!this.is('}')) {
+ const target = this.expect('name', 'view ID'); this.expect(':');
+ const item = this.expect('name', 'item name'); routes.push({target,item}); this.end();
+ }
+ this.take(); declarations.push({kind,token,name,routes}); this.end(); continue;
+ }
+ this.expect('(');
+ const target = this.expect('name', 'view ID'); this.expect(')');
+ declarations.push({ kind, token, name, target });
+ this.end(); continue;
+ }
+ if (!/^(?:Space|Enter|Arrow(?:Left|Right|Up|Down)|Key[A-Z]|Digit[0-9])$/.test(key.value)) fail(key, 'Unsupported keyboard code');
+ declarations.push({ kind, token, name, key });
+ } else if (Object.hasOwn(VIEW_FIELDS, kind)) {
+ const name = this.expect('name', 'view ID'); this.expect('(');
+ const expressions = [];
+ for (let i = 0; i < VIEW_FIELDS[kind].length; i++) {
+ if (i) { this.newlines(); this.expect(','); }
+ expressions.push(this.expression(0, true));
+ }
+ this.newlines();
+ let color, prefix = '';
+ if (kind !== 'text') {
+ this.expect(','); this.newlines();
+ const value = this.expect('string', 'literal hex color');
+ if (!/^#[0-9a-fA-F]{6}$/.test(value.value)) fail(value, 'Color must use six hexadecimal digits, such as "#356548"');
+ color = value.value;
+ } else if (this.is(',')) { this.take(); this.newlines(); prefix = this.expect('string', 'literal text prefix').value; }
+ this.newlines(); this.expect(')');
+ declarations.push({ kind: 'view', type: kind, token, name, expressions, color, prefix });
+ } else if (kind === 'on') {
+ const event = this.expect('name', 'input name or frame');
+ let edge;
+ if (event.value !== 'frame') {
+ this.expect('.'); edge = this.expect('name', 'down, up, move or cancel');
+ if (!['down', 'up', 'move', 'cancel', 'change'].includes(edge.value)) fail(edge, 'Expected down or up, move, cancel or change');
+ }
+ if (this.is('name', 'use')) {
+ this.take(); const rule = this.expect('name', 'rule name'); this.expect('(');
+ const item = this.expect('name', 'item name or input.target');
+ const argument = this.reference(item); this.expect(')');
+ declarations.push({kind:'handler',token,event,edge,rule,item,argument});
+ } else declarations.push({ kind: 'handler', token, event, edge, body: this.block() });
+ } else fail(token, `Unknown declaration ${kind}`);
+ this.end();
+ }
+ return declarations;
+ }
+}
+
+export function compile(source) {
+ if (typeof source !== 'string') fail({ line: 1, column: 1 }, 'Source must be a string');
+ const sourceBytes = new TextEncoder().encode(source).length;
+ if (sourceBytes > LIMITS.sourceBytes) fail({ line: 1, column: 1 }, 'Source exceeds 32768 UTF-8 bytes');
+ const declarations = new Parser(lex(source)).parse();
+ const program = { states: [], inputs: [], instructions: [], handlers: [], views: [], pointerTargets: [], metrics: {} };
+ const states = new Map(), inputs = new Map(), views = new Set(), keys = new Set(), targets = new Set();
+ const records = new Map(), items = new Map(), rules = new Map();
+ const scalarNames = new Set(declarations.filter(d => d.kind === 'state').map(d => d.name.value));
+ for (const declaration of declarations) {
+ if (!['record','rule'].includes(declaration.kind)) continue;
+ const {name,fields,kind} = declaration, symbols = kind === 'record' ? records : rules;
+ if (RESERVED.has(name.value)) fail(name, `Reserved name ${name.value}`);
+ if (symbols.has(name.value)) fail(name, `Duplicate ${kind} ${name.value}`);
+ if (kind === 'record') {
+ if (!fields.length || fields.length > 8) fail(name, 'A record needs 1..8 fields');
+ const used = new Set();
+ for (const field of fields) {
+ if (used.has(field.name.value)) fail(field.name, `Duplicate field ${field.name.value}`);
+ if (RESERVED.has(field.name.value)) fail(field.name, `Reserved name ${field.name.value}`);
+ used.add(field.name.value);
+ }
+ }
+ symbols.set(name.value, declaration);
+ }
+ let handlerCount = 0;
+ for (const declaration of declarations) {
+ const { kind, name, token } = declaration;
+ if (name && RESERVED.has(name.value)) fail(name, `Reserved name ${name.value}`);
+ if (kind === 'record' || kind === 'rule') continue;
+ if (kind === 'item') {
+ if (items.has(name.value) || scalarNames.has(name.value)) fail(name, `Duplicate state/item ${name.value}`);
+ const record = records.get(declaration.type.value);
+ if (!record) fail(declaration.type, `Unknown record ${declaration.type.value}`);
+ const overrides = new Map();
+ for (const field of declaration.fields) {
+ if (!record.fields.some(f => f.name.value === field.name.value)) fail(field.name, `Unknown field ${field.name.value}`);
+ if (overrides.has(field.name.value)) fail(field.name, `Duplicate field ${field.name.value}`);
+ overrides.set(field.name.value, field.initial);
+ }
+ const base = states.size;
+ for (const field of record.fields) {
+ if (states.size >= LIMITS.states) fail(name, 'At most 32 states are allowed, including item fields');
+ const qualified = `${name.value}.${field.name.value}`, index = states.size;
+ states.set(qualified,index);
+ program.states.push({name:qualified,initial:overrides.has(field.name.value) ? overrides.get(field.name.value) : field.initial,line:token.line});
+ }
+ items.set(name.value,{type:declaration.type.value,base,count:record.fields.length});
+ } else if (kind === 'state' || kind === 'input') {
+ const symbols = kind === 'state' ? states : inputs;
+ if (symbols.has(name.value)) fail(name, `Duplicate ${kind} ${name.value}`);
+ if (symbols.size >= LIMITS[`${kind}s`]) fail(name, `At most ${LIMITS[`${kind}s`]} ${kind}s are allowed`);
+ symbols.set(name.value, symbols.size);
+ if (kind === 'state') program.states.push({ name: name.value, initial: declaration.initial, line: token.line });
+ else if (declaration.valueInput) {
+ program.inputs.push({name:name.value,kind:'value',line:token.line});
+ } else if (declaration.routes) {
+ if (!declaration.routes.length) fail(name, 'A pointer group needs at least one target');
+ program.inputs.push({name:name.value, routes:[], line:token.line});
+ } else if (declaration.target) {
+ const target = declaration.target;
+ if (targets.has(target.value)) fail(target, `Duplicate pointer target ${target.value}`);
+ targets.add(target.value);
+ program.inputs.push({ name: name.value, target: target.value, line: token.line });
+ } else {
+ if (keys.has(declaration.key.value)) fail(declaration.key, `Duplicate keyboard code ${declaration.key.value}`);
+ keys.add(declaration.key.value);
+ program.inputs.push({ name: name.value, key: declaration.key.value, line: token.line });
+ }
+ } else if (kind === 'view') {
+ if (views.has(name.value)) fail(name, `Duplicate view ID ${name.value}`);
+ if (views.size >= LIMITS.views) fail(name, 'At most 24 views are allowed');
+ views.add(name.value);
+ } else if (++handlerCount > LIMITS.handlers) fail(token, 'At most 64 handlers are allowed');
+ }
+ for (const declaration of declarations) {
+ if (declaration.target && !views.has(declaration.target.value))
+ fail(declaration.target, `Unknown pointer target ${declaration.target.value}`);
+ }
+ const routedItems = new Set();
+ for (const declaration of declarations) {
+ if (!declaration.routes) continue;
+ const input = inputs.get(declaration.name.value), resolved = program.inputs[input];
+ let type;
+ for (const {target,item:token} of declaration.routes) {
+ if (!views.has(target.value)) fail(target, `Unknown pointer target ${target.value}`);
+ if (targets.has(target.value)) fail(target, `Duplicate pointer target ${target.value}`);
+ const item = items.get(token.value);
+ if (!item) fail(token, `Unknown item ${token.value}`);
+ if (type && type !== item.type) fail(token, 'Pointer group targets must share one record type');
+ if (routedItems.has(token.value)) fail(token, `Item ${token.value} already has a pointer route`);
+ targets.add(target.value); routedItems.add(token.value); type = item.type;
+ const route = program.pointerTargets.length;
+ if (route >= 24) fail(target, 'At most 24 pointer targets are allowed');
+ program.pointerTargets.push({input,base:item.base,count:item.count});
+ resolved.routes.push({target:target.value, item:token.value, route});
+ }
+ resolved.type = type;
+ }
+ for (const rule of rules.values()) {
+ if (!records.has(rule.type.value)) fail(rule.type, `Unknown record ${rule.type.value}`);
+ if (RESERVED.has(rule.parameter.value)) fail(rule.parameter, `Reserved name ${rule.parameter.value}`);
+ }
+ let pointHandler = false, valueHandler = false;
+ let parameters = null;
+ let parameterPrefix = null;
+ let stack = 0, maxStack = 0;
+ const emit = (op, token, arg = 0, value = 0, delta = 0) => {
+ if (program.instructions.length >= LIMITS.instructions) fail(token, 'At most 512 instructions are allowed');
+ stack += delta;
+ if (stack < 0 || stack > LIMITS.stack) fail(token, 'Expression stack exceeds 64');
+ maxStack = Math.max(maxStack, stack);
+ program.instructions.push({ op, arg, value, line: token.line });
+ };
+ const expression = (node, binding = false) => {
+ const { token } = node;
+ if (node.kind === 'constant') emit(0, token, 0, node.value, 1);
+ else if (node.kind === 'reference') {
+ if (node.name === 'event_value') {
+ if (binding || !valueHandler) fail(token, 'event_value is only available in value change handlers');
+ emit(26, token, 0, 0, 1);
+ } else if (node.name === 'event_x' || node.name === 'event_y') {
+ if (binding || !pointHandler) fail(token, 'event_x and event_y are only available in pointer handlers');
+ emit(node.name === 'event_x' ? 22 : 23, token, 0, 0, 1);
+ } else if (node.name === 'dt') {
+ if (binding) fail(token, 'dt is only available in handlers');
+ emit(2, token, 0, 0, 1);
+ } else {
+ if (parameterPrefix && node.name.startsWith(parameterPrefix) && !parameters.has(node.name))
+ fail(token, `Unknown parameter field ${node.name}`);
+ if (parameters?.has(node.name)) {
+ emit(24, token, parameters.get(node.name), 0, 1);
+ return;
+ }
+ if (!states.has(node.name)) fail(token, `Unknown state ${node.name}`);
+ emit(1, token, states.get(node.name), 0, 1);
+ }
+ } else if (node.kind === 'unary') { expression(node.child, binding); emit(node.op, token); }
+ else if (node.kind === 'binary') {
+ expression(node.left, binding); expression(node.right, binding); emit(node.op, token, 0, 0, -1);
+ } else {
+ for (const arg of node.args) expression(arg, binding);
+ emit(node.op, token, 0, 0, 1 - node.args.length);
+ }
+ };
+ const statements = body => {
+ for (const statement of body) {
+ if (statement.kind === 'assign') {
+ if (parameterPrefix && statement.name.startsWith(parameterPrefix) && !parameters.has(statement.name))
+ fail(statement.token, `Unknown parameter field ${statement.name}`);
+ const parameter = parameters?.get(statement.name);
+ if (parameter === undefined && !states.has(statement.name)) fail(statement.token, `Unknown state ${statement.name}`);
+ expression(statement.expression);
+ emit(parameter === undefined ? 20 : 25, statement.token, parameter === undefined ? states.get(statement.name) : parameter, 0, -1);
+ } else {
+ expression(statement.condition);
+ const branch = program.instructions.length;
+ emit(21, statement.token, 0, 0, -1);
+ statements(statement.body);
+ program.instructions[branch].arg = program.instructions.length;
+ }
+ }
+ };
+ const bodies = new Map();
+ function ruleBody(rule) {
+ if (bodies.has(rule.name.value)) return bodies.get(rule.name.value);
+ parameterPrefix = `${rule.parameter.value}.`;
+ parameters = new Map(records.get(rule.type.value).fields.map((field,index) => [`${rule.parameter.value}.${field.name.value}`,index]));
+ // A shared body may read coordinates; every binding is checked below.
+ pointHandler = true; valueHandler = true; stack = 0;
+ const start = program.instructions.length;
+ statements(rule.body);
+ const length = program.instructions.length - start;
+ const point = program.instructions.slice(start).some(op => op.op === 22 || op.op === 23);
+ parameters = null; parameterPrefix = null;
+ const value = program.instructions.slice(start).some(op => op.op === 26);
+ const body = {start,length,point,value}; bodies.set(rule.name.value,body); return body;
+ }
+ for (const declaration of declarations) {
+ if (declaration.kind === 'handler') {
+ let event = 0;
+ pointHandler = false; valueHandler = false;
+ if (declaration.event.value !== 'frame') {
+ if (!inputs.has(declaration.event.value)) fail(declaration.event, `Unknown input ${declaration.event.value}`);
+ const index = inputs.get(declaration.event.value), edge = declaration.edge.value;
+ pointHandler = Boolean(program.inputs[index].target || program.inputs[index].routes);
+ valueHandler = program.inputs[index].kind === 'value';
+ if (valueHandler !== (edge === 'change')) fail(declaration.edge, 'Value inputs require change; change requires a value input');
+ if (!pointHandler && (edge === 'move' || edge === 'cancel')) fail(declaration.edge, 'move and cancel require a pointer input');
+ event = edge === 'change' ? 33 + index : edge === 'move' ? 17 + index : edge === 'cancel' ? 25 + index : 1 + index * 2 + (edge === 'up' ? 1 : 0);
+ }
+ if (declaration.rule) {
+ const rule = rules.get(declaration.rule.value);
+ const routed = declaration.argument.includes('.');
+ const input = program.inputs[inputs.get(declaration.event.value)];
+ if (routed && (!input?.routes || declaration.argument !== `${declaration.event.value}.target`))
+ fail(declaration.item, 'Use the current pointer group target');
+ const item = routed ? {type:input.type, count:records.get(input.type).fields.length} : items.get(declaration.item.value);
+ if (!rule) fail(declaration.rule, `Unknown rule ${declaration.rule.value}`);
+ if (!item) fail(declaration.item, `Unknown item ${declaration.item.value}`);
+ if (item.type !== rule.type.value) fail(declaration.item, `Rule ${rule.name.value} requires ${rule.type.value}`);
+ const pointer = pointHandler, value = valueHandler;
+ const body = ruleBody(rule);
+ if (body.value && !value) fail(declaration.rule, 'Value-reading rules require a value input');
+ if (body.point && !pointer) fail(declaration.rule, 'Coordinate-reading rules require a pointer input');
+ program.handlers.push({event,start:body.start,length:body.length,line:declaration.token.line,parameterBase:routed ? 255 : item.base,parameterCount:item.count});
+ } else {
+ const start = program.instructions.length;
+ stack = 0; statements(declaration.body);
+ program.handlers.push({ event, start, length: program.instructions.length - start, line: declaration.token.line });
+ }
+ } else if (declaration.kind === 'view') {
+ const view = { id: declaration.name.value, type: declaration.type, line: declaration.token.line, bindings: {} };
+ if (view.type === 'text') view.prefix = declaration.prefix;
+ else view.color = declaration.color;
+ declaration.expressions.forEach((node, index) => {
+ const start = program.instructions.length;
+ stack = 0; expression(node, true);
+ view.bindings[VIEW_FIELDS[view.type][index]] = { start, length: program.instructions.length - start };
+ });
+ program.views.push(view);
+ }
+ }
+ // Unused definitions still receive name/type checking, but reserve no VM body.
+ for (const rule of rules.values()) {
+ if (bodies.has(rule.name.value)) continue;
+ const instructions = program.instructions, previousMax = maxStack;
+ program.instructions = [];
+ ruleBody(rule);
+ program.instructions = instructions; maxStack = previousMax;
+ }
+ program.metrics = { sourceBytes, sourceLines: source === '' ? 0 : source.split('\n').length,
+ states: states.size, inputs: inputs.size, views: views.size,
+ handlers: program.handlers.length, instructions: program.instructions.length, maxStack };
+ return program;
+}
+
+export { LIMITS };
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/core.wasm b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/core.wasm
new file mode 100644
index 0000000..527c57e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/core.wasm
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/project.mjs b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/project.mjs
new file mode 100644
index 0000000..861e0fc
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/project.mjs
@@ -0,0 +1,47 @@
+import { compile } from './compiler.mjs';
+
+// Two escaped source strings plus up to 32 generated item.field names. A long
+// schema field can appear in every item without repeating in the source text.
+export const MAX_PROJECT_BYTES = (2 * 6 + 32) * 32768 + 8192;
+const encoder = new TextEncoder();
+function sourceText(value, label) {
+ if (typeof value !== 'string' || encoder.encode(value).length > 32768)
+ throw new Error(`${label}: UTF-8 32 KiB 이하의 코드가 필요합니다.`);
+ return value;
+}
+
+// No runtime mutation. Validate every saved value before opening core staging.
+export function validateDocument(document) {
+ if (!document || typeof document !== 'object' || Array.isArray(document) ||
+ document.format !== 'pure-workbench' || document.version !== 2)
+ throw new Error('배치 파일 형식이 맞지 않습니다. pure-workbench version 2가 필요합니다.');
+ const source = sourceText(document.source, '적용된 코드');
+ const draft = sourceText(Object.hasOwn(document, 'draft') ? document.draft : source, '편집 중인 코드');
+ const program = compile(source);
+ if (!Array.isArray(document.states) || document.states.length !== program.states.length)
+ throw new Error('저장된 상태 목록이 적용된 코드와 일치하지 않습니다.');
+ const values = new Map();
+ for (const entry of document.states) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry) || typeof entry.name !== 'string' || values.has(entry.name))
+ throw new Error('상태 이름이 잘못됐거나 중복됐습니다.');
+ const value = entry.value === '-0' ? -0 : entry.value;
+ if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error('상태 값은 유한한 숫자여야 합니다.');
+ values.set(entry.name, value);
+ }
+ for (const state of program.states)
+ if (!values.has(state.name)) throw new Error(`저장된 상태가 없습니다: ${state.name}`);
+ return { source, draft, program, values: program.states.map(state => values.get(state.name)) };
+}
+
+export function parseProject(text) {
+ if (typeof text !== 'string' || encoder.encode(text).length > MAX_PROJECT_BYTES)
+ throw new Error('작업 파일이 너무 큽니다.');
+ let project;
+ try { project = JSON.parse(text); } catch { throw new Error('올바른 JSON 파일이 아닙니다.'); }
+ if (!project || typeof project !== 'object' || Array.isArray(project) || project.format !== 'pure-workbench')
+ throw new Error('Pure 작업물 형식이 아닙니다.');
+ if (project.version === 1) sourceText(project.source, '코드');
+ else if (project.version === 2) validateDocument(project);
+ else throw new Error('지원하지 않는 작업 파일 버전입니다.');
+ return project;
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/runtime.mjs b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/runtime.mjs
new file mode 100644
index 0000000..d8d6618
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/runtime.mjs
@@ -0,0 +1,350 @@
+import { compile } from './compiler.mjs';
+import { validateDocument } from './project.mjs';
+
+const messages = [
+ '', '실행기의 용량 한도를 넘었습니다.', '실행 정의가 유효하지 않습니다.',
+ '계산 스택의 범위를 벗어났습니다.', '0으로 나눌 수 없습니다.',
+ '계산 결과가 유한한 수의 범위를 벗어났습니다.', '조건 분기 범위가 유효하지 않습니다.',
+ '존재하지 않는 상태를 참조했습니다.', '시간 간격이 유효하지 않습니다.',
+ '정의되지 않은 입력 사건입니다.',
+];
+const sourceLimit = 32768;
+
+function problem(message, line = null, column = null) {
+ return Object.assign(new Error(message), { line, column });
+}
+
+function freezeProgram(value) {
+ if (value && typeof value === 'object') {
+ for (const child of Object.values(value)) freezeProgram(child);
+ Object.freeze(value);
+ }
+ return value;
+}
+
+export async function createRuntime(wasmBytes, evaluationOptions = {}) {
+ if (wasmBytes === undefined) {
+ const response = await fetch(new URL('./core.wasm', import.meta.url), { cache: 'no-store' });
+ if (!response.ok) throw new Error(`실행기를 불러오지 못했습니다 (HTTP ${response.status}).`);
+ wasmBytes = await response.arrayBuffer();
+ }
+ const module = await WebAssembly.compile(wasmBytes);
+ if (WebAssembly.Module.imports(module).length) throw new Error('이 실행기는 호스트 함수 없이 동작해야 합니다.');
+ const { exports: core } = await WebAssembly.instantiate(module, {});
+ if (typeof core.eval_bounded !== 'function' || typeof core.abort_program !== 'function' || typeof core.dispatch_point !== 'function' || typeof core.put_restored_value !== 'function' || typeof core.commit_restored_program !== 'function' || typeof core.bind_handler !== 'function' || typeof core.put_pointer_target !== 'function' || typeof core.bind_target_handler !== 'function' || typeof core.dispatch_target !== 'function' || typeof core.dispatch_value !== 'function')
+ throw new Error('실행기 버전이 맞지 않습니다. 새로고침하거나 core.wasm을 다시 빌드하세요.');
+ core.reset_all();
+ let program = null;
+ let appliedSource = '';
+ let lastEvent = null;
+ // Keep a failed event visible through its release/focus-loss cleanup.
+ // The active program can clear this diagnostic by apply or explicit reset.
+ let lastFailure = null;
+ const held = new Map();
+ const points = new Map();
+ let presenting = false;
+ let bindings = [];
+ let pointRoutes = new Map();
+ // One bounded publication buffer, never exposed to callers. No state mirror.
+ let displayValues = null, displayValid = null;
+ function available() {
+ if (presenting) throw new Error('출력 전달 중에는 실행기를 다시 호출할 수 없습니다.');
+ }
+ let evaluationPolicy = { cacheEntries: 0, historyEntries: 8, workLimit: 2048 };
+
+ function checked(code, line = null) {
+ if (code !== 0) throw problem(messages[code] || `실행기 오류 ${code}`, line);
+ }
+ function inputIndex(name) {
+ const index = program?.inputs.findIndex((input) => input.name === name) ?? -1;
+ if (index < 0) throw new Error(`정의되지 않은 입력: ${name}`);
+ return index;
+ }
+ function dispatch(event, name, phase, source, point = null, value = undefined) {
+ const status = value !== undefined ? core.dispatch_value(event, value) : point ? (point.route === undefined ? core.dispatch_point(event, point.x, point.y)
+ : core.dispatch_target(event, point.route, point.x, point.y)) : core.dispatch(event, event === 0 ? 1 / 60 : 0);
+ const pc = core.info(2) >>> 0;
+ lastEvent = { name, phase, source, status,
+ line: program?.instructions[pc]?.line ?? null, ops: core.info(3) >>> 0 };
+ if (point) { lastEvent.x = point.x; lastEvent.y = point.y; if (point.target) lastEvent.target = point.target; }
+ if (value !== undefined) lastEvent.value = value;
+ if (status) lastFailure = { ...lastEvent };
+ return status;
+ }
+
+ function evaluateDisplay() {
+ core.begin_evaluation(evaluationPolicy.workLimit);
+ if (!bindings.length) return null;
+ displayValues ??= new Float64Array(96);
+ displayValid ??= new Uint8Array(24);
+ let error = null, slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ displayValid[i] = 1;
+ for (const [name, binding] of bindings[i]) {
+ const value = core.eval_bounded(binding.start, binding.length);
+ if (core.evaluation_info(0) === 10) {
+ displayValid.fill(0);
+ return { code: 101, line: view.line,
+ message: '표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.' };
+ }
+ if (!Number.isFinite(value) || ((name === 'width' || name === 'height' || name === 'radius') && value < 0)) {
+ error ??= { line: view.line, message: `${view.id}.${name}: 표시할 수 없는 값입니다. 0 나눗셈이나 음수 크기를 확인하세요.` };
+ displayValid[i] = 0;
+ }
+ displayValues[slot++] = value;
+ }
+ }
+ return error;
+ }
+
+ function status(viewError) {
+ const executionError = lastFailure?.status || 0;
+ return {
+ tick: core.info(0) >>> 0, revision: core.info(4) >>> 0,
+ displayError: viewError ? { code: viewError.code || 100, ...viewError } : null,
+ lastError: executionError || (viewError ? viewError.code || 100 : 0),
+ errorMessage: executionError ? messages[executionError] || `실행 오류 ${executionError}` : viewError?.message || '',
+ errorLine: executionError ? lastFailure.line : viewError?.line ?? null,
+ lastOps: core.info(3) >>> 0, successfulEvents: core.info(9) >>> 0,
+ held: [...held.keys()], lastEvent: lastEvent ? { ...lastEvent } : null,
+ lastFailure: lastFailure ? { ...lastFailure } : null,
+ memory: {
+ linearBytes: core.memory.buffer.byteLength,
+ reservedEngineBytes: core.layout(0), programReservedBytes: core.layout(1),
+ instructionBytes: core.layout(2), handlerBytes: core.layout(3),
+ activeIRBytes: core.layout(4), activeStateBytes: core.layout(5),
+ stagingReservedBytes: core.layout(6), evalStackBytes: core.layout(7),
+ pointerTargetBytes: core.layout(8), pointerTargets: core.layout(9),
+ },
+ };
+ }
+
+ function load(next, source, restored = null) {
+ const oldIds = new Map(restored ? [] : program?.states.map((state, index) => [state.name, index]) ?? []);
+ checked(core.begin_program(next.states.length, next.inputs.length));
+ try {
+ for (let index = 0; index < next.states.length; index++) {
+ const state = next.states[index];
+ checked(core.put_state(index, state.initial, oldIds.get(state.name) ?? -1), state.line);
+ if (restored) checked(core.put_restored_value(index, restored[index]), state.line);
+ }
+ for (const op of next.instructions) checked(core.put_instruction(op.op, op.arg, op.value), op.line);
+ for (const target of next.pointerTargets) checked(core.put_pointer_target(target.input, target.base, target.count));
+ for (const [index, handler] of next.handlers.entries()) {
+ checked(core.put_handler(handler.event, handler.start, handler.length), handler.line);
+ if (handler.parameterCount)
+ checked(handler.parameterBase === 255 ? core.bind_target_handler(index, handler.parameterCount)
+ : core.bind_handler(index, handler.parameterBase, handler.parameterCount), handler.line);
+ }
+ checked(restored ? core.commit_restored_program() : core.commit_program());
+ } catch (error) {
+ core.abort_program();
+ throw error;
+ }
+ pointRoutes = new Map(next.inputs.flatMap(input => (input.routes ?? []).map(route => [route.target, route])));
+ program = next;
+ bindings = next.views.map(view => Object.entries(view.bindings));
+ appliedSource = source;
+ lastEvent = null;
+ lastFailure = null;
+ }
+
+ const api = {
+ get currentProgram() { return program; },
+ configureEvaluation({ cacheEntries = evaluationPolicy.cacheEntries,
+ historyEntries = evaluationPolicy.historyEntries, workLimit = evaluationPolicy.workLimit } = {}) {
+ available();
+ for (const [name, value, maximum] of [['cacheEntries', cacheEntries, 32],
+ ['historyEntries', historyEntries, 8], ['workLimit', workLimit, 0xffffffff]]) {
+ if (!Number.isInteger(value) || value < 0 || value > maximum)
+ throw new Error(`${name}: 0~${maximum} 사이의 정수여야 합니다.`);
+ }
+ checked(core.configure_evaluation(cacheEntries, historyEntries));
+ evaluationPolicy = { cacheEntries, historyEntries, workLimit };
+ return { ...evaluationPolicy };
+ },
+ forgetEvaluation(part = 'all') {
+ available();
+ const flags = part === 'all' ? 3 : part === 'values' ? 1 : part === 'history' ? 2 : undefined;
+ if (flags === undefined) throw new Error('삭제할 기록은 values, history, all 중 하나여야 합니다.');
+ core.forget_evaluation(flags);
+ },
+ evaluationStats() {
+ return { ...evaluationPolicy, work: core.evaluation_info(1),
+ instructions: core.evaluation_info(2), cacheHits: core.evaluation_info(3),
+ reservedBytes: core.evaluation_info(6), dedicatedBytes: core.evaluation_info(7),
+ sharedBytes: core.evaluation_info(8), reuseAvailable: Boolean(core.evaluation_info(9)),
+ workspaceTagBytes: core.evaluation_info(10) };
+ },
+ apply(source) {
+ available();
+ if (typeof source !== 'string' || new TextEncoder().encode(source).length > sourceLimit) {
+ throw problem('정의는 UTF-8 기준 32,768바이트 이하의 텍스트여야 합니다.');
+ }
+ if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 적용하세요. 현재 작업은 그대로 유지합니다.');
+ const next = freezeProgram(compile(source));
+ load(next, source);
+ return next;
+ },
+ restoreDocument(document) {
+ available();
+ if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 복원하세요.');
+ const next = validateDocument(document);
+ load(freezeProgram(next.program), next.source, next.values);
+ return { source: next.source, draft: next.draft };
+ },
+ exportDocument(draft = appliedSource) {
+ available();
+ if (!program) throw problem('먼저 코드를 적용하세요.');
+ if (held.size) throw problem('조작을 끝낸 뒤 배치를 저장하세요.');
+ if (lastFailure) throw problem('실행 오류를 해결한 뒤 배치를 저장하세요.');
+ if (typeof draft !== 'string' || new TextEncoder().encode(draft).length > sourceLimit)
+ throw problem('편집 중인 코드는 UTF-8 32 KiB 이하여야 합니다.');
+ return { format: 'pure-workbench', version: 2, source: appliedSource, draft,
+ states: program.states.map((state, i) => {
+ const value = core.get_state(i);
+ return { name: state.name, value: Object.is(value, -0) ? '-0' : value };
+ }) };
+ },
+ reset() {
+ available();
+ held.clear();
+ points.clear();
+ core.reset_state();
+ lastEvent = null;
+ lastFailure = null;
+ },
+ frame(count = 1) {
+ available();
+ if (!Number.isInteger(count) || count < 0 || count > 600) throw new Error('프레임 수는 0~600 사이의 정수여야 합니다.');
+ if (!program) return 0;
+ for (let i = 0; i < count; i++) {
+ const code = dispatch(0, 'frame', 'frame', 'clock');
+ if (code !== 0) return code;
+ }
+ return 0;
+ },
+ down(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (program.inputs[index].kind === 'value') throw new Error('숫자 입력은 change(name, value)를 사용하세요.');
+ if (program.inputs[index].target || program.inputs[index].routes) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.');
+ const sources = held.get(name) || new Set();
+ if (sources.has(source)) return 0;
+ const wasDown = sources.size > 0;
+ sources.add(source);
+ held.set(name, sources);
+ return wasDown ? 0 : dispatch(1 + index * 2, name, 'down', source);
+ },
+ up(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (program.inputs[index].kind === 'value') throw new Error('숫자 입력은 change(name, value)를 사용하세요.');
+ if (program.inputs[index].target || program.inputs[index].routes) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.');
+ const sources = held.get(name);
+ if (!sources?.delete(source)) return 0;
+ if (sources.size) return 0;
+ held.delete(name);
+ return dispatch(2 + index * 2, name, 'up', source);
+ },
+ change(name, value, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (program.inputs[index].kind !== 'value') throw new Error('숫자 값에 연결된 입력이 아닙니다.');
+ if (!Number.isFinite(value)) throw new Error('입력 값은 유한한 숫자여야 합니다.');
+ return dispatch(33 + index, name, 'change', source, null, value);
+ },
+ point(name, phase, x, y, source = 'external', target = undefined) {
+ available();
+ const index = inputIndex(name);
+ const input = program.inputs[index];
+ if (!input.target && !input.routes) throw new Error('포인터에 연결된 입력이 아닙니다.');
+ const route = input.routes ? pointRoutes.get(target) : undefined;
+ if (input.routes && (!route || program.pointerTargets[route.route].input !== index)) throw new Error('포인터 그룹에 연결된 도형 이름이 필요합니다.');
+ if (!input.routes && target !== undefined) throw new Error('단일 포인터 입력에는 대상 인자를 지정하지 않습니다.');
+ const owner = route ? `${name}:${target}` : name;
+ if (!['down', 'move', 'up', 'cancel'].includes(phase)) throw new Error('포인터 단계는 down, move, up, cancel 중 하나여야 합니다.');
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error('포인터 좌표는 유한한 숫자여야 합니다.');
+ if (typeof source !== 'string' || source.length === 0) throw new Error('포인터 출처 이름이 필요합니다.');
+ let point = points.get(owner);
+ if (phase === 'down') {
+ if (point?.source === source) return 0;
+ if (point) throw new Error('이 입력은 다른 포인터가 사용 중입니다.');
+ point = {name, source, x, y, target, route:route?.route};
+ points.set(owner, point);
+ held.set(owner, new Set([source]));
+ } else {
+ if (!point || point.source !== source) return 0;
+ point.x = x; point.y = y;
+ if (phase === 'up' || phase === 'cancel') { points.delete(owner); held.delete(owner); }
+ }
+ const event = phase === 'move' ? 17 + index : phase === 'cancel' ? 25 + index : 1 + index * 2 + (phase === 'up' ? 1 : 0);
+ return dispatch(event, name, phase, source, point);
+ },
+ releaseAll() {
+ available();
+ let firstError = 0;
+ for (const point of [...points.values()]) {
+ const status = api.point(point.name, 'cancel', point.x, point.y, point.source, point.target);
+ if (!firstError) firstError = status;
+ }
+ for (const [name, sources] of [...held]) {
+ for (const source of [...sources]) {
+ const status = api.up(name, source);
+ if (!firstError) firstError = status;
+ }
+ }
+ return firstError;
+ },
+ exportProject() {
+ return { format: 'pure-workbench', version: 1, source: appliedSource };
+ },
+ // Synchronous trusted output adapter. Callbacks run only after the whole
+ // display has been evaluated. Runtime mutation/reentrant reads are refused.
+ // Exceptions from an adapter propagate; the next call republishes all values.
+ present(target) {
+ available();
+ presenting = true;
+ try {
+ const { begin, state, view, value, end } = target;
+ for (const callback of [begin, state, view, value, end])
+ if (typeof callback !== 'function') throw new TypeError('출력 어댑터 함수가 필요합니다.');
+ const error = evaluateDisplay();
+ begin.call(target, program);
+ for (let i = 0; i < (program?.states.length ?? 0); i++) state.call(target, i, core.get_state(i));
+ let slot = 0, visible = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const valid = Boolean(displayValid[i]);
+ view.call(target, i, valid);
+ for (const [name] of bindings[i]) {
+ if (valid) value.call(target, i, name, displayValues[slot]);
+ slot++;
+ }
+ if (valid) visible++;
+ }
+ end.call(target, visible);
+ return status(error);
+ } finally { presenting = false; }
+ },
+ snapshot() {
+ available();
+ const error = evaluateDisplay();
+ const states = Object.fromEntries((program?.states ?? []).map((state, index) => [state.name, core.get_state(index)]));
+ const views = [];
+ let slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ const rendered = displayValid[i] ? { id: view.id, type: view.type, color: view.color, prefix: view.prefix } : null;
+ for (const [name] of bindings[i]) {
+ if (rendered) rendered[name] = displayValues[slot];
+ slot++;
+ }
+ if (rendered) views.push(rendered);
+ }
+ return { states, views, ...status(error) };
+ },
+ };
+ api.configureEvaluation(evaluationOptions);
+ return Object.freeze(api);
+}
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/workspace-check.mjs b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/workspace-check.mjs
new file mode 100644
index 0000000..063ba42
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/workspace-check.mjs
@@ -0,0 +1,62 @@
+// Compare the archived separate-storage build with the shared-workspace build.
+// This is a correctness and storage check, not another speed benchmark.
+import assert from 'node:assert/strict';
+import { readFile, writeFile } from 'node:fs/promises';
+import { createHash } from 'node:crypto';
+import { createRuntime } from './web/runtime.mjs';
+import { createRuntime as createPrevious } from './results/2026-09-22-evaluation/sources/web/runtime.mjs';
+
+const args = process.argv.slice(2);
+if (args.length !== 2 || args[0] !== '--output') throw new Error('Use --output NEW.json');
+const root = new URL('.', import.meta.url);
+const archived = new URL('./results/2026-09-22-evaluation/sources/', root);
+const sha = bytes => createHash('sha256').update(bytes).digest('hex');
+const manifest = JSON.parse(await readFile(new URL('manifest.json', archived), 'utf8'));
+for (const [path, expected] of Object.entries(manifest))
+ assert.equal(sha(await readFile(new URL(path, archived))), expected, path);
+const oldWasm = await readFile(new URL('web/core.wasm', archived));
+const wasm = await readFile(new URL('web/core.wasm', root));
+const record = {utc:new Date().toISOString(),kind:'workspace-reuse-correctness-and-storage',
+ baselineManifestSha256:sha(await readFile(new URL('manifest.json', archived))),
+ sourceSha256:{},cases:[],node:process.version,platform:process.platform,arch:process.arch};
+for (const path of ['core.zig','evaluation.zig','web/runtime.mjs','web/compiler.mjs','web/core.wasm','workspace-check.mjs'])
+ record.sourceSha256[path] = sha(await readFile(new URL(path,root)));
+
+for (const name of ['counter','toggle','charge']) {
+ const path = `examples/${name}.pure`, source = await readFile(new URL(path,root),'utf8');
+ record.sourceSha256[path] = sha(source);
+ const old = await createPrevious(oldWasm,{cacheEntries:32});
+ const current = await createRuntime(wasm,{cacheEntries:32});
+ const program = old.apply(source); current.apply(source);
+ const input = program.inputs[0].name;
+ let snapshots = 0;
+ function check() {
+ const before = old.snapshot(), after = current.snapshot();
+ const {memory:oldMemory,...oldObservable} = before;
+ const {memory:newMemory,...newObservable} = after;
+ assert.deepEqual(newObservable,oldObservable);
+ for (const field of ['work','instructions','cacheHits'])
+ assert.equal(current.evaluationStats()[field],old.evaluationStats()[field]);
+ assert.equal(oldMemory.linearBytes,newMemory.linearBytes);
+ ++snapshots;
+ }
+ check();
+ for (let i=0;i<256;i++) {
+ for (const r of [old,current]) {
+ if (i%23===0) {r.down(input);r.up(input);}
+ if (i%31===0) r.reset();
+ if (i%47===0) r.apply(source+`\n# replacement ${i}\n`);
+ if (i%7===0) r.forgetEvaluation('history');
+ if (i%13===0) r.forgetEvaluation('all');
+ assert.equal(r.frame(),0);
+ }
+ check();
+ }
+ record.cases.push({name,snapshots,before:old.snapshot().memory,after:current.snapshot().memory,
+ evaluation:current.evaluationStats()});
+}
+record.wasmFileBytes = {before:oldWasm.length,after:wasm.length};
+await writeFile(args[1],JSON.stringify(record,null,2)+'\n',{flag:'wx'});
+console.log(JSON.stringify({checkedSnapshots:record.cases.reduce((sum,c)=>sum+c.snapshots,0),
+ engineBefore:record.cases[0].before.reservedEngineBytes,engineAfter:record.cases[0].after.reservedEngineBytes,
+ wasmFileBytes:record.wasmFileBytes,evaluation:record.cases[0].evaluation}));
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/workspace.test.mjs b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/workspace.test.mjs
new file mode 100644
index 0000000..799f056
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/workspace.test.mjs
@@ -0,0 +1,91 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { compile } from './web/compiler.mjs';
+import { createRuntime } from './web/runtime.mjs';
+
+const wasm = await readFile(new URL('./web/core.wasm', import.meta.url));
+const source = factor => `state x = 10
+input next = KeyN
+text label(0, 0, x * ${factor})
+on next.down { x = x + 0.25 }
+`;
+
+function begin(core, program, preserve) {
+ assert.equal(core.begin_program(program.states.length, program.inputs.length), 0);
+ program.states.forEach((state, i) => assert.equal(core.put_state(i, state.initial, preserve ? i : -1), 0));
+}
+
+function finish(core, program, during = () => {}) {
+ for (const op of program.instructions) {
+ assert.equal(core.put_instruction(op.op, op.arg, op.value), 0);
+ during();
+ }
+ for (const handler of program.handlers)
+ assert.equal(core.put_handler(handler.event, handler.start, handler.length), 0);
+ assert.equal(core.commit_program(), 0);
+}
+
+function observe(core, binding) {
+ core.begin_evaluation(100);
+ const value = core.eval_bounded(binding.start, binding.length);
+ assert.equal(core.evaluation_info(0), 0);
+ return value;
+}
+
+test('Wasm executes old events and output during staged replacement without corrupting either phase', async () => {
+ const {instance:{exports:core}} = await WebAssembly.instantiate(wasm);
+ core.reset_all();
+ const old = compile(source(2)), next = compile(source(3));
+ begin(core, old, false); finish(core, old);
+ assert.equal(core.configure_evaluation(32, 8), 0);
+ const binding = old.views[0].bindings.value;
+ assert.equal(observe(core, binding), 20);
+ assert.equal(observe(core, binding), 20);
+ assert.equal(core.evaluation_info(3), 1);
+ begin(core, next, true);
+ let value = 10;
+ finish(core, next, () => {
+ assert.equal(core.evaluation_info(9), 0);
+ assert.equal(core.dispatch(1, 0), 0); value += 0.25;
+ assert.equal(core.configure_evaluation(32, 8), 0);
+ core.forget_evaluation(3);
+ assert.equal(observe(core, binding), value * 2);
+ assert.equal(core.evaluation_info(3), 0);
+ });
+ assert.equal(core.evaluation_info(9), 1);
+ assert.equal(core.get_state(0), value);
+ const replaced = next.views[0].bindings.value;
+ assert.equal(observe(core, replaced), value * 3);
+ assert.equal(observe(core, replaced), value * 3);
+ assert.equal(core.evaluation_info(3), 1);
+
+ const revision = core.info(4);
+ begin(core, old, true);
+ assert.equal(core.put_instruction(0, 0, Infinity), 5);
+ assert.equal(core.commit_program(), 5);
+ assert.equal(observe(core, replaced), value * 3);
+ core.abort_program();
+ assert.equal(core.info(4), revision);
+ assert.equal(core.get_state(0), value);
+ assert.equal(core.evaluation_info(9), 1);
+ assert.equal(observe(core, replaced), value * 3);
+ assert.equal(core.memory.buffer.byteLength, 65536);
+});
+
+test('workspace accounting reports shared payload and safety tag without double-counting', async () => {
+ const runtime = await createRuntime(wasm, {cacheEntries:32});
+ runtime.apply(source(2)); runtime.snapshot();
+ const stats = runtime.evaluationStats(), memory = runtime.snapshot().memory;
+ assert.equal(stats.dedicatedBytes, 48);
+ assert.equal(stats.sharedBytes, 1152);
+ assert.equal(stats.workspaceTagBytes, 8);
+ assert.equal(stats.reservedBytes, stats.dedicatedBytes + stats.sharedBytes);
+ const restoreStorage = memory.stagingReservedBytes - memory.programReservedBytes;
+ assert.equal(restoreStorage, 264); // 32 values + mask/alignment, only in pending storage.
+ assert.equal(memory.reservedEngineBytes, 19072 + stats.dedicatedBytes + stats.workspaceTagBytes + restoreStorage + 2 * 64 * (memory.handlerBytes - 12) + 2 * 80);
+ // Each program adds 24 three-byte routes, count/mask and alignment (80 B).
+ assert.equal(memory.pointerTargetBytes, 3);
+ assert(stats.sharedBytes < memory.stagingReservedBytes);
+ assert.equal(stats.reuseAvailable, true);
+});
diff --git a/prototypes/workbench/results/2026-09-23-native-instances/verification.json b/prototypes/workbench/results/2026-09-23-native-instances/verification.json
new file mode 100644
index 0000000..29e827d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-native-instances/verification.json
@@ -0,0 +1,39 @@
+{
+ "sources_verified_and_frozen": 28,
+ "native_and_sanitized_source_hashes_match": true,
+ "c_host_sanitizers_only": true,
+ "zig_tests_passed": 37,
+ "existing_browser_and_cli_tests_passed": 87,
+ "authored_cases_per_build": 9,
+ "event_state_and_view_comparisons_per_build": 156,
+ "selected_module": {
+ "native_instances": 3,
+ "checks": "independent-state-staging-inputs-errors-budget-snapshot-conflict-restore",
+ "scalar_instance_bytes": 20064,
+ "scalar_alignment": 8,
+ "app_controller_bytes": 928,
+ "action_bytes": 32,
+ "snapshot_bytes": 244,
+ "all_work_drained": true
+ },
+ "native_library_sections": " text\t data\t bss\t dec\t hex\tfilename\n 33020\t 0\t 0\t 33020\t 80fc\tprototypes/workbench/build/cache/tmp/3cadbbd6974d5d26/libpure-scalar_zcu.o (ex /home/eunhhu/work/pure/prototypes/workbench/build/native/libpure-scalar.a)\n",
+ "native_library_externals": "\nprototypes/workbench/build/cache/tmp/3cadbbd6974d5d26/libpure-scalar_zcu.o:\n U memcpy\n U memset\n",
+ "selected_native_binary": {
+ "sha256": "6d587d072b6cfc93777fece74b99cde1ea5ff581b3276a6de545568bbcd5c69c",
+ "debug_file_bytes": 213720,
+ "stripped_file_bytes": 68000,
+ "size": " text\t data\t bss\t dec\t hex\tfilename\n 62827\t 2040\t 16\t 64883\t fd73\t/home/eunhhu/work/pure/prototypes/workbench/build/native/pure-native\n",
+ "direct_dynamic_libraries": [
+ "0x0000000000000001 (NEEDED) Shared library: [libc.so.6]"
+ ]
+ },
+ "common_execution_core_unchanged": true,
+ "limits": [
+ "No native window yet; CLI output adapter only",
+ "Node and Zig are build tools; not runtime dependencies",
+ "Existing scalar capacities remain 32 states, 8 inputs, 24 views, 512 instructions",
+ "Single-owner APIs; no strong native-code isolation",
+ "Synchronous storage; no secure peer RPC",
+ "No macOS execution verification or throughput/RSS claim"
+ ]
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/README.md b/prototypes/workbench/results/2026-09-23-pointer-routing/README.md
new file mode 100644
index 0000000..9f9f93b
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/README.md
@@ -0,0 +1,51 @@
+# Pointer group routing evidence — 2026-09-23
+
+This is functional validation and compiled-representation accounting, not a
+throughput, latency, browser-heap, or large-application benchmark.
+
+- `build.json`: Zig 0.15.2 native tests (35), final core/Wasm hashes and build flags.
+- `node-tests.log`, `verification.json`: final 83 Node tests, including 8 route tests
+ and existing compiler/runtime/CLI/display/persistence behavior.
+- `representation.json`: shared-layout and routed-layout compiled on the same final
+ core. Includes used route bytes, fixed engine reservation, and unchanged 64 KiB memory.
+- `browser.json`: 30 real Chromium checks on grouped input, including two CDP contacts,
+ rejecting a second contact on one view, releasing one then moving/canceling the other,
+ actual mouse placement, source editing/reordering, file download/new-page restoration,
+ stale file-read rejection and mobile width.
+- `authoring.json`: 44 existing authoring-flow checks.
+- `legacy-browser.json`: 23 existing separate-pointer-input/shared-rule checks.
+- `pure-routed-layout-saved.json`: actual downloaded document, including unfinished draft.
+- `pure-routed-layout-{desktop,mobile}.png`: final grouped example screenshots.
+- `sources/`: final implementation, contract and relevant test snapshots.
+- `before/`: pre-change core/compiler/runtime/Wasm for auditing; no timing comparison.
+
+Reproduce from repository root:
+
+```sh
+python3 prototypes/workbench/manage.py build --zig /path/to/zig-0.15.2
+node --test prototypes/workbench/*.test.mjs
+python3 prototypes/workbench/manage.py serve --port 8766
+```
+
+Open a fresh Chromium page with Playwright CLI, then pass the function in
+`prototypes/workbench/qa-routed-input.js` to `run-code`. Read
+`window.__pureRoutedInputEvidence` after the function completes. Dialog/download
+handling may cause CLI to yield early; a CLI return is not proof of completion.
+`qa-authoring.js` and `qa-legacy-pointer.js` preserve the regression functions;
+run authoring on a blank page. They expose `__pureWorkbenchQAEvidence` and
+`__pureSharedLayoutEvidence` respectively.
+
+The initial partial-release QA used the wrong contact in the CDP touchEnd call.
+After correcting it, the following move check also read before pointermove had
+arrived. `browser-initial-failure.json` retains that intermediate failure;
+`touch-evidence.json` records actual down/up/move events and retained owners.
+Final QA identifies the ending contact and waits for observed movement. These were
+QA input/observation corrections; product code was not changed to suppress the checks.
+`touch-reproduction.js` is a debugging artifact for this Chromium CDP behavior;
+its protocol behavior is not a general claim about mobile hardware.
+
+All successful browser reports are from the final runtime and Wasm. Browser is
+Linux Chromium; touch is injected through CDP, not a physical touchscreen or a
+user interaction in the Codex embedded browser. Static route addresses are rebuilt
+on apply/restore, not persisted as dynamic lifetime identities. Keyboard selection
+still uses the authored ID conditions.
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/authoring-result.log b/prototypes/workbench/results/2026-09-23-pointer-routing/authoring-result.log
new file mode 100644
index 0000000..4f20964
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/authoring-result.log
@@ -0,0 +1,13 @@
+### Result
+{"recordedUtc":"2026-09-23T08:33:23.588Z","browser":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36","checks":["initial editor has literally empty source","initial scene has no built-in entities, states, inputs, or running clock","controls require an authored program","authoring a new program is a draft until Apply","new custom program constructs states and multiple views from source","input buttons are generated from the authored declarations","view bindings render the authored state value","Space and arrows in the editor never reach authored inputs","pointer down dispatches once while the clock is paused","pointer up releases without double dispatch from click","keyboard repeat is ignored","keyboard activation on a generated button is one complete tap","assistive click activation is one complete tap","keyboard and pointer sources share one logical input","releasing one source does not release the other source","last source releases the logical input","authored down/frame behavior uses the declared dt without built-in motion","keyup releases even after focus moves into the editor","focus loss sends release and pauses the clock","Play advances and Pause freezes the clock","invalid code preserves the previous executable program and states","compile error exposes a source-line diagnostic","diagnostic location moves the editor caret to the problem","apply preserves matching state names and initializes newly authored states","new input, entity, binding, and behavior work with source changes alone","changed behavior takes effect without resetting existing values","explicit Reset restores active initializers and keeps the draft","Apply is refused while an input is held","clear editor is explicit and does not clear the running scene before Apply","downloaded project restores the exact un-applied draft without execution","invalid JSON project schema cannot replace the draft","failed assistive down event rolls back and its error survives the following successful up","latched runtime failure is visible and labeled separately from the latest input","explicit Reset clears the failure record","applying empty source removes all authored states, inputs, and scene contents","explicit example loading only fills the editor and never auto-applies","authored scene executes in the actual fixed 64 KiB Wasm memory","cost inspector shows measured Wasm memory without claiming browser heap totals","390px mobile viewport has no horizontal overflow","generated mobile input buttons remain usable","ordinary-JavaScript comparison and equivalent Pure example are linked and served","missing Wasm shows an actionable load failure and disables execution controls","fresh load always returns to a blank authoring environment","no uncaught page errors during the browser scenarios"],"authoredSource":"# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n","authoredSnapshot":{"states":{"x":114,"moving":0,"count":2},"views":[{"id":"marker","type":"box","color":"#7ed9ae","x":114,"y":170,"width":48,"height":48},{"id":"reading","type":"text","prefix":"Signals: ","x":40,"y":70,"value":2}],"tick":12,"revision":5,"displayError":null,"lastError":0,"errorMessage":"","errorLine":null,"lastOps":2,"successfulEvents":18,"held":[],"lastEvent":{"name":"advance","phase":"up","source":"keyboard:ArrowRight","status":0,"line":null,"ops":2},"lastFailure":null,"memory":{"linearBytes":65536,"reservedEngineBytes":20064,"programReservedBytes":9728,"instructionBytes":16,"handlerBytes":16,"activeIRBytes":480,"activeStateBytes":24,"stagingReservedBytes":9992,"evalStackBytes":512,"pointerTargetBytes":3,"pointerTargets":0},"ready":true,"source":"# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n","appliedSource":"# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n","dirty":false,"running":false,"droppedTicks":0,"diagnostic":"코드를 적용했습니다. 같은 이름의 상태는 유지됩니다."},"mobile":{"viewport":390,"content":390},"downloaded":{"filename":"my-work.pure.json","path":"output/playwright/pure-routing-authoring-draft.json","expectedSource":"# A new authored interaction, not a bundled example.\nstate x = 500\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 5 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\nstate bonus = 7\ninput reverse = KeyA\ncircle beacon(300, 190, 15 + bonus, \"#c9f08b\")\non reverse.down { bonus = bonus + 2 }\n# This un-applied draft must survive Reset.\n"},"errors":[],"screenshots":["output/playwright/pure-routing-authoring-blank.png","output/playwright/pure-routing-authoring-authored.png","output/playwright/pure-routing-authoring-mobile.png"]}
+### Ran Playwright code
+```js
+await (async page => await page.evaluate(() => window.__pureWorkbenchQAEvidence ?? {pending:true}))(page);
+```
+### Page
+- Page URL: http://127.0.0.1:8766/
+- Page Title: Pure · 빈 작업물에서 시작하기
+### Events
+- New console entries: .playwright-cli/console-2026-09-23T08-31-21-228Z.log#L1
+- Downloading file my-work.pure.json ...
+- Downloaded file my-work.pure.json to ".playwright-cli/my-work-pure.json"
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/authoring-run.log b/prototypes/workbench/results/2026-09-23-pointer-routing/authoring-run.log
new file mode 100644
index 0000000..b35bee9
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/authoring-run.log
@@ -0,0 +1,237 @@
+### Ran Playwright code
+```js
+await (// Run this expression with Playwright CLI run-code after opening the local host.
+// All behavior is authored through the editor; snapshot() only observes results.
+async (page) => {
+ const checks = [];
+ const errors = [];
+ const onError = error => errors.push(error.message);
+ const acceptDialog = dialog => dialog.accept();
+ const check = (condition, label) => {
+ if (!condition) throw new Error(`Check ${checks.length + 1} failed: ${label}`);
+ checks.push(label);
+ };
+ const state = () => page.evaluate(() => window.pureWorkbench.snapshot());
+ const apply = async text => { await page.locator('#source').fill(text); await page.locator('#apply').click(); };
+ const authorSource = [
+ '# A new authored interaction, not a bundled example.',
+ 'state x = 90', 'state moving = 0', 'state count = 0',
+ 'input advance = ArrowRight', 'input pulse = Space',
+ 'box marker(x, 170, 48, 48, "#7ed9ae")',
+ 'text reading(40, 70, count, "Signals: ")',
+ 'on advance.down { moving = 1 }', 'on advance.up { moving = 0 }',
+ 'on pulse.down { count = count + 1 }',
+ 'on frame { x = clamp(x + moving * 120 * dt, 0, 580) }', '',
+ ].join('\n');
+ let lastAuthored = null;
+ let mobile = null;
+ let downloaded = null;
+ const shots = ['output/playwright/pure-routing-authoring-blank.png', 'output/playwright/pure-routing-authoring-authored.png', 'output/playwright/pure-routing-authoring-mobile.png'];
+ page.on('pageerror', onError);
+ page.on('dialog', acceptDialog);
+ try {
+ await page.reload();
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.reload();
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ let s = await state();
+ check(s.source === '' && s.appliedSource === '' && !s.dirty, 'initial editor has literally empty source');
+ check(s.views.length === 0 && Object.keys(s.states).length === 0 && s.held.length === 0 && !s.running, 'initial scene has no built-in entities, states, inputs, or running clock');
+ check(await page.locator('#input-controls button').count() === 0 && await page.locator('#play').isDisabled(), 'controls require an authored program');
+ await page.screenshot({ path: shots[0], fullPage: true });
+
+ await page.locator('#source').fill(authorSource);
+ s = await state();
+ check(s.dirty && s.views.length === 0 && s.appliedSource === '', 'authoring a new program is a draft until Apply');
+ await page.locator('#apply').click();
+ s = await state();
+ check(!s.dirty && s.appliedSource === authorSource && s.views.length === 2 && s.states.x === 90 && s.states.count === 0, 'new custom program constructs states and multiple views from source');
+ check(await page.locator('#input-controls button').count() === 2 && await page.locator('[data-input="advance"]').count() === 1 && await page.locator('[data-input="pulse"]').count() === 1, 'input buttons are generated from the authored declarations');
+ check(await page.locator('#scene-content [data-view-id="marker"]').getAttribute('x') === '90', 'view bindings render the authored state value');
+
+ await page.locator('#source').focus();
+ await page.keyboard.press('End');
+ await page.keyboard.press('Space');
+ await page.keyboard.press('ArrowRight');
+ check((await state()).states.count === 0 && (await state()).states.moving === 0, 'Space and arrows in the editor never reach authored inputs');
+ await page.locator('#source').fill(authorSource);
+
+ const pulse = page.locator('[data-input="pulse"]');
+ let box = await pulse.boundingBox();
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ s = await state();
+ check(s.states.count === 1 && s.held.includes('pulse') && !s.running && s.tick === 0, 'pointer down dispatches once while the clock is paused');
+ await page.mouse.up();
+ check((await state()).held.length === 0 && (await state()).states.count === 1, 'pointer up releases without double dispatch from click');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('Space');
+ await page.keyboard.down('Space');
+ await page.keyboard.up('Space');
+ check((await state()).states.count === 2, 'keyboard repeat is ignored');
+ await pulse.focus();
+ await page.keyboard.press('Enter');
+ check((await state()).states.count === 3 && (await state()).held.length === 0, 'keyboard activation on a generated button is one complete tap');
+ await pulse.dispatchEvent('click', { detail: 0 });
+ check((await state()).states.count === 4 && (await state()).held.length === 0, 'assistive click activation is one complete tap');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('Space');
+ box = await pulse.boundingBox();
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ check((await state()).states.count === 5 && (await state()).held.includes('pulse'), 'keyboard and pointer sources share one logical input');
+ await page.mouse.up();
+ check((await state()).held.includes('pulse'), 'releasing one source does not release the other source');
+ await page.keyboard.up('Space');
+ check((await state()).held.length === 0, 'last source releases the logical input');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ await page.locator('#step').click();
+ await page.locator('#step').click();
+ s = await state();
+ check(s.states.x === 94 && s.states.moving === 1 && s.tick === 2, 'authored down/frame behavior uses the declared dt without built-in motion');
+ await page.locator('#source').focus();
+ await page.keyboard.up('ArrowRight');
+ check((await state()).states.moving === 0 && (await state()).held.length === 0, 'keyup releases even after focus moves into the editor');
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ await page.evaluate(() => window.dispatchEvent(new Event('blur')));
+ await page.keyboard.up('ArrowRight');
+ check((await state()).states.moving === 0 && !(await state()).running && (await state()).held.length === 0, 'focus loss sends release and pauses the clock');
+
+ await page.locator('#play').click();
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().tick >= 5);
+ await page.locator('#play').click();
+ const paused = await state();
+ await page.waitForTimeout(100);
+ check(!paused.running && paused.tick === (await state()).tick && paused.tick >= 5, 'Play advances and Pause freezes the clock');
+
+ const beforeFailure = await state();
+ await apply(authorSource + 'state broken =\n');
+ s = await state();
+ check(s.appliedSource === authorSource && s.revision === beforeFailure.revision && JSON.stringify(s.states) === JSON.stringify(beforeFailure.states), 'invalid code preserves the previous executable program and states');
+ check(await page.locator('#error-location').isVisible() && /행/.test(s.diagnostic) && await page.locator('#source').getAttribute('aria-invalid') === 'true', 'compile error exposes a source-line diagnostic');
+ await page.locator('#error-location').click();
+ check(await page.locator('#source').evaluate(el => el.selectionStart >= el.value.indexOf('state broken')), 'diagnostic location moves the editor caret to the problem');
+
+ const expanded = authorSource.replace('state x = 90', 'state x = 500').replace('count = count + 1', 'count = count + 5') + [
+ 'state bonus = 7', 'input reverse = KeyA',
+ 'circle beacon(300, 190, 15 + bonus, "#c9f08b")',
+ 'on reverse.down { bonus = bonus + 2 }', '',
+ ].join('\n');
+ await apply(expanded);
+ s = await state();
+ check(s.states.x === beforeFailure.states.x && s.states.count === 5 && s.states.bonus === 7 && s.views.length === 3, 'apply preserves matching state names and initializes newly authored states');
+ await page.locator('[data-input="reverse"]').click();
+ check((await state()).states.bonus === 9 && await page.locator('#scene-content [data-view-id="beacon"]').getAttribute('r') === '24', 'new input, entity, binding, and behavior work with source changes alone');
+ await page.locator('[data-input="pulse"]').click();
+ check((await state()).states.count === 10, 'changed behavior takes effect without resetting existing values');
+
+ const withDraft = expanded + '# This un-applied draft must survive Reset.\n';
+ await page.locator('#source').fill(withDraft);
+ await page.locator('#reset').click();
+ s = await state();
+ check(s.states.x === 500 && s.states.count === 0 && s.states.bonus === 7 && s.tick === 0 && s.source === withDraft && s.dirty, 'explicit Reset restores active initializers and keeps the draft');
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ const heldRevision = (await state()).revision;
+ await page.locator('#apply').click();
+ check((await state()).revision === heldRevision && /놓은/.test((await state()).diagnostic), 'Apply is refused while an input is held');
+ await page.keyboard.up('ArrowRight');
+
+ const downloadPromise = page.waitForEvent('download');
+ await page.locator('#download-project').click();
+ const projectDownload = await downloadPromise;
+ const downloadPath = 'output/playwright/pure-routing-authoring-draft.json';
+ await projectDownload.saveAs(downloadPath);
+ downloaded = { filename: projectDownload.suggestedFilename(), path: downloadPath, expectedSource: withDraft };
+ await page.locator('#new').click();
+ s = await state();
+ check(s.source === '' && s.views.length === 3 && s.appliedSource === expanded, 'clear editor is explicit and does not clear the running scene before Apply');
+ await page.locator('#file-input').setInputFiles(downloadPath);
+ await page.waitForFunction(expected => window.pureWorkbench.snapshot().source === expected, withDraft);
+ check((await state()).source === withDraft && (await state()).appliedSource === expanded && (await state()).dirty, 'downloaded project restores the exact un-applied draft without execution');
+ await page.locator('#file-input').evaluate(input => {
+ const transfer = new DataTransfer();
+ transfer.items.add(new File(['{"format":"other","version":1,"source":"state no = 1"}'], 'invalid.json', { type: 'application/json' }));
+ input.files = transfer.files;
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ });
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().diagnostic.includes('형식'));
+ check((await state()).source === withDraft && (await state()).appliedSource === expanded, 'invalid JSON project schema cannot replace the draft');
+
+ await apply('state n = 1\ninput fail = Space\ntext label(40, 60, n)\non fail.down { n = n / 0 }\n');
+ await page.locator('[data-input="fail"]').dispatchEvent('click', { detail: 0 });
+ s = await state();
+ check(s.states.n === 1 && s.lastError === 4 && s.held.length === 0 && s.lastEvent.phase === 'up', 'failed assistive down event rolls back and its error survives the following successful up');
+ check(await page.locator('#runtime-error').isVisible() && (await page.locator('#runtime-error').textContent()).includes('마지막 실패'), 'latched runtime failure is visible and labeled separately from the latest input');
+ await page.locator('#reset').click();
+ check((await state()).lastError === 0 && await page.locator('#runtime-error').isHidden(), 'explicit Reset clears the failure record');
+
+ await page.locator('#new').click();
+ await page.locator('#apply').click();
+ s = await state();
+ check(s.source === '' && s.appliedSource === '' && s.views.length === 0 && Object.keys(s.states).length === 0 && await page.locator('#input-controls button').count() === 0, 'applying empty source removes all authored states, inputs, and scene contents');
+ await page.locator('#example').selectOption('counter');
+ await page.locator('#load-example').click();
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().source.includes('state count'));
+ check((await state()).views.length === 0 && (await state()).appliedSource === '' && (await state()).dirty, 'explicit example loading only fills the editor and never auto-applies');
+
+ await apply(authorSource);
+ await page.locator('[data-input="pulse"]').click();
+ await page.locator('[data-input="pulse"]').click();
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ for (let i = 0; i < 12; i++) await page.locator('#step').click();
+ await page.keyboard.up('ArrowRight');
+ lastAuthored = await state();
+ check(lastAuthored.states.x === 114 && lastAuthored.states.count === 2 && lastAuthored.memory.linearBytes === 65536, 'authored scene executes in the actual fixed 64 KiB Wasm memory');
+ await page.locator('.cost-details summary').click();
+ check((await page.locator('#cost-values').textContent()).includes('65,536 B'), 'cost inspector shows measured Wasm memory without claiming browser heap totals');
+ await page.locator('.cost-details summary').click();
+ await page.screenshot({ path: shots[1], fullPage: true });
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ mobile = await page.evaluate(() => ({ viewport: innerWidth, content: document.documentElement.scrollWidth }));
+ check(mobile.content <= mobile.viewport, '390px mobile viewport has no horizontal overflow');
+ await page.locator('[data-input="pulse"]').click();
+ check((await state()).states.count === 3, 'generated mobile input buttons remain usable');
+ await page.screenshot({ path: shots[2], fullPage: true });
+ const comparison = await page.request.get('http://127.0.0.1:8766/baseline.html');
+ const equivalent = await page.request.get('http://127.0.0.1:8766/examples/charge.pure');
+ check(comparison.ok() && equivalent.ok() && await page.locator('a[href="./baseline.html"]').count() === 1, 'ordinary-JavaScript comparison and equivalent Pure example are linked and served');
+
+ await page.route('**/core.wasm', route => route.abort());
+ await page.reload();
+ await page.waitForFunction(() => document.getElementById('ready-status').textContent.includes('실패'));
+ check(await page.locator('#apply').isDisabled() && await page.locator('#play').isDisabled() && await page.locator('#step').isDisabled() && /실행기를 열지/.test(await page.locator('#diagnostic').textContent()), 'missing Wasm shows an actionable load failure and disables execution controls');
+ await page.unroute('**/core.wasm');
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.reload();
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ check((await state()).source === '' && (await state()).views.length === 0, 'fresh load always returns to a blank authoring environment');
+ check(errors.length === 0, 'no uncaught page errors during the browser scenarios');
+ const evidence = { recordedUtc: new Date().toISOString(), browser: await page.evaluate(() => navigator.userAgent),
+ checks, authoredSource: authorSource, authoredSnapshot: lastAuthored, mobile, downloaded, errors, screenshots: shots };
+ // CLI can yield early when native dialogs/download events occur. Retain the
+ // observation report so a follow-up CLI call can retrieve the complete run.
+ await page.evaluate(value => { window.__pureWorkbenchQAEvidence = value; }, evidence);
+ return evidence;
+ } catch (error) {
+ const evidence = { failed: true, error: error.message, stack: error.stack, checks, errors };
+ await page.evaluate(value => { window.__pureWorkbenchQAEvidence = value; }, evidence);
+ return evidence;
+ } finally {
+ page.off('pageerror', onError);
+ page.off('dialog', acceptDialog);
+ }
+}
+)(page);
+```
+### Page
+- Page URL: http://127.0.0.1:8766/
+### Modal state
+- ["beforeunload" dialog with message ""]: can be handled by dialog-accept or dialog-dismiss
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/authoring.json b/prototypes/workbench/results/2026-09-23-pointer-routing/authoring.json
new file mode 100644
index 0000000..ae4aee2
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/authoring.json
@@ -0,0 +1,130 @@
+{
+ "recordedUtc": "2026-09-23T08:33:23.588Z",
+ "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36",
+ "checks": [
+ "initial editor has literally empty source",
+ "initial scene has no built-in entities, states, inputs, or running clock",
+ "controls require an authored program",
+ "authoring a new program is a draft until Apply",
+ "new custom program constructs states and multiple views from source",
+ "input buttons are generated from the authored declarations",
+ "view bindings render the authored state value",
+ "Space and arrows in the editor never reach authored inputs",
+ "pointer down dispatches once while the clock is paused",
+ "pointer up releases without double dispatch from click",
+ "keyboard repeat is ignored",
+ "keyboard activation on a generated button is one complete tap",
+ "assistive click activation is one complete tap",
+ "keyboard and pointer sources share one logical input",
+ "releasing one source does not release the other source",
+ "last source releases the logical input",
+ "authored down/frame behavior uses the declared dt without built-in motion",
+ "keyup releases even after focus moves into the editor",
+ "focus loss sends release and pauses the clock",
+ "Play advances and Pause freezes the clock",
+ "invalid code preserves the previous executable program and states",
+ "compile error exposes a source-line diagnostic",
+ "diagnostic location moves the editor caret to the problem",
+ "apply preserves matching state names and initializes newly authored states",
+ "new input, entity, binding, and behavior work with source changes alone",
+ "changed behavior takes effect without resetting existing values",
+ "explicit Reset restores active initializers and keeps the draft",
+ "Apply is refused while an input is held",
+ "clear editor is explicit and does not clear the running scene before Apply",
+ "downloaded project restores the exact un-applied draft without execution",
+ "invalid JSON project schema cannot replace the draft",
+ "failed assistive down event rolls back and its error survives the following successful up",
+ "latched runtime failure is visible and labeled separately from the latest input",
+ "explicit Reset clears the failure record",
+ "applying empty source removes all authored states, inputs, and scene contents",
+ "explicit example loading only fills the editor and never auto-applies",
+ "authored scene executes in the actual fixed 64 KiB Wasm memory",
+ "cost inspector shows measured Wasm memory without claiming browser heap totals",
+ "390px mobile viewport has no horizontal overflow",
+ "generated mobile input buttons remain usable",
+ "ordinary-JavaScript comparison and equivalent Pure example are linked and served",
+ "missing Wasm shows an actionable load failure and disables execution controls",
+ "fresh load always returns to a blank authoring environment",
+ "no uncaught page errors during the browser scenarios"
+ ],
+ "authoredSource": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "authoredSnapshot": {
+ "states": {
+ "x": 114,
+ "moving": 0,
+ "count": 2
+ },
+ "views": [
+ {
+ "id": "marker",
+ "type": "box",
+ "color": "#7ed9ae",
+ "x": 114,
+ "y": 170,
+ "width": 48,
+ "height": 48
+ },
+ {
+ "id": "reading",
+ "type": "text",
+ "prefix": "Signals: ",
+ "x": 40,
+ "y": 70,
+ "value": 2
+ }
+ ],
+ "tick": 12,
+ "revision": 5,
+ "displayError": null,
+ "lastError": 0,
+ "errorMessage": "",
+ "errorLine": null,
+ "lastOps": 2,
+ "successfulEvents": 18,
+ "held": [],
+ "lastEvent": {
+ "name": "advance",
+ "phase": "up",
+ "source": "keyboard:ArrowRight",
+ "status": 0,
+ "line": null,
+ "ops": 2
+ },
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 20064,
+ "programReservedBytes": 9728,
+ "instructionBytes": 16,
+ "handlerBytes": 16,
+ "activeIRBytes": 480,
+ "activeStateBytes": 24,
+ "stagingReservedBytes": 9992,
+ "evalStackBytes": 512,
+ "pointerTargetBytes": 3,
+ "pointerTargets": 0
+ },
+ "ready": true,
+ "source": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "appliedSource": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "dirty": false,
+ "running": false,
+ "droppedTicks": 0,
+ "diagnostic": "코드를 적용했습니다. 같은 이름의 상태는 유지됩니다."
+ },
+ "mobile": {
+ "viewport": 390,
+ "content": 390
+ },
+ "downloaded": {
+ "filename": "my-work.pure.json",
+ "path": "output/playwright/pure-routing-authoring-draft.json",
+ "expectedSource": "# A new authored interaction, not a bundled example.\nstate x = 500\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 5 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\nstate bonus = 7\ninput reverse = KeyA\ncircle beacon(300, 190, 15 + bonus, \"#c9f08b\")\non reverse.down { bonus = bonus + 2 }\n# This un-applied draft must survive Reset.\n"
+ },
+ "errors": [],
+ "screenshots": [
+ "output/playwright/pure-routing-authoring-blank.png",
+ "output/playwright/pure-routing-authoring-authored.png",
+ "output/playwright/pure-routing-authoring-mobile.png"
+ ]
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/before/compiler.mjs b/prototypes/workbench/results/2026-09-23-pointer-routing/before/compiler.mjs
new file mode 100644
index 0000000..7e8bff3
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/before/compiler.mjs
@@ -0,0 +1,447 @@
+// Pure's experimental scalar interaction language. No host code is evaluated.
+const LIMITS = Object.freeze({ sourceBytes: 32768, states: 32, inputs: 8,
+ views: 24, handlers: 64, instructions: 512, depth: 64, stack: 64 });
+const RESERVED = new Set(['frame', 'dt', 'event_x', 'event_y', 'min', 'max', 'clamp']);
+const BINARY = new Map([
+ ['||', [1, 15]], ['&&', [2, 14]], ['==', [3, 12]], ['!=', [3, 13]],
+ ['<', [4, 8]], ['<=', [4, 9]], ['>', [4, 10]], ['>=', [4, 11]],
+ ['+', [5, 3]], ['-', [5, 4]], ['*', [6, 5]], ['/', [6, 6]],
+]);
+const FUNCTIONS = { min: [2, 17], max: [2, 18], clamp: [3, 19] };
+const VIEW_FIELDS = { box: ['x', 'y', 'width', 'height'],
+ circle: ['x', 'y', 'radius'], text: ['x', 'y', 'value'] };
+
+function fail(token, message) {
+ const error = new Error(`${message} (${token.line}:${token.column})`);
+ error.line = token.line;
+ error.column = token.column;
+ throw error;
+}
+
+function lex(source) {
+ const tokens = [];
+ let offset = 0, line = 1, column = 1;
+ const advance = () => {
+ const char = source[offset++];
+ if (char === '\n') { line++; column = 1; } else column++;
+ return char;
+ };
+ while (offset < source.length) {
+ const char = source[offset];
+ if (char === ' ' || char === '\t' || char === '\r') { advance(); continue; }
+ if (char === '#') { while (offset < source.length && source[offset] !== '\n') advance(); continue; }
+ const token = { line, column };
+ if (char === '\n') { advance(); tokens.push({ ...token, kind: 'nl' }); continue; }
+ if (char === '"') {
+ advance();
+ let value = '', closed = false;
+ while (offset < source.length) {
+ const next = advance();
+ if (next === '"') { closed = true; break; }
+ if (next === '\n' || next === '\r') fail(token, 'Strings must fit on one line');
+ if (next === '\\') {
+ const escaped = advance();
+ const escapes = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' };
+ if (!Object.hasOwn(escapes, escaped)) fail(token, 'Unsupported string escape');
+ value += escapes[escaped];
+ } else value += next;
+ }
+ if (!closed) fail(token, 'Unterminated string');
+ tokens.push({ ...token, kind: 'string', value }); continue;
+ }
+ const remaining = source.slice(offset);
+ const number = remaining.match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/);
+ if (number) {
+ const value = Number(number[0]);
+ if (!Number.isFinite(value)) fail(token, 'Number must be finite');
+ for (let i = 0; i < number[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'number', value }); continue;
+ }
+ const name = remaining.match(/^[A-Za-z_][A-Za-z_0-9]*/);
+ if (name) {
+ for (let i = 0; i < name[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'name', value: name[0] }); continue;
+ }
+ const pair = source.slice(offset, offset + 2);
+ if (['<=', '>=', '==', '!=', '&&', '||'].includes(pair)) {
+ advance(); advance(); tokens.push({ ...token, kind: pair }); continue;
+ }
+ if ('=+-*/!<>(){},.;:'.includes(char)) {
+ advance(); tokens.push({ ...token, kind: char }); continue;
+ }
+ fail(token, `Unexpected character ${JSON.stringify(char)}`);
+ }
+ tokens.push({ kind: 'eof', line, column });
+ return tokens;
+}
+
+class Parser {
+ constructor(tokens) { this.tokens = tokens; this.position = 0; this.depth = 0; }
+ peek() { return this.tokens[this.position]; }
+ take() { return this.tokens[this.position++]; }
+ is(kind, value) { return this.peek().kind === kind && (value === undefined || this.peek().value === value); }
+ expect(kind, description = kind) {
+ if (!this.is(kind)) fail(this.peek(), `Expected ${description}`);
+ return this.take();
+ }
+ newlines() { while (this.is('nl')) this.take(); }
+ separators() { while (this.is('nl') || this.is(';')) this.take(); }
+ end() {
+ if (!['nl', ';', '}', 'eof'].includes(this.peek().kind)) fail(this.peek(), 'Expected newline or semicolon');
+ this.separators();
+ }
+ reference(token) {
+ if (!this.is('.')) return token.value;
+ this.take();
+ return `${token.value}.${this.expect('name', 'field name').value}`;
+ }
+ initial() {
+ let sign = 1;
+ if (this.is('-') || this.is('+')) sign = this.take().kind === '-' ? -1 : 1;
+ return sign * this.expect('number', 'numeric state initializer').value;
+ }
+ node(token, properties, children = []) {
+ const depth = 1 + Math.max(0, ...children.map(child => child.depth));
+ if (depth > LIMITS.depth) fail(token, 'Expression nesting exceeds 64');
+ return { token, depth, ...properties };
+ }
+ expression(minimum = 0, multiline = false) {
+ if (++this.depth > LIMITS.depth) fail(this.peek(), 'Expression nesting exceeds 64');
+ if (multiline) this.newlines();
+ const token = this.take();
+ let left;
+ if (token.kind === 'number') left = this.node(token, { kind: 'constant', value: token.value });
+ else if (token.kind === '-' || token.kind === '!') {
+ const child = this.expression(7, multiline);
+ left = this.node(token, { kind: 'unary', op: token.kind === '-' ? 7 : 16, child }, [child]);
+ } else if (token.kind === '(') {
+ left = this.expression(0, true); this.newlines(); this.expect(')');
+ } else if (token.kind === 'name') {
+ if (Object.hasOwn(FUNCTIONS, token.value) && this.is('(')) {
+ this.take();
+ const args = [];
+ this.newlines();
+ if (!this.is(')')) {
+ for (;;) {
+ args.push(this.expression(0, true)); this.newlines();
+ if (!this.is(',')) break;
+ this.take();
+ }
+ }
+ this.expect(')');
+ if (args.length !== FUNCTIONS[token.value][0]) fail(token, `${token.value} expects ${FUNCTIONS[token.value][0]} arguments`);
+ left = this.node(token, { kind: 'call', op: FUNCTIONS[token.value][1], args }, args);
+ } else left = this.node(token, { kind: 'reference', name: this.reference(token) });
+ } else fail(token, 'Expected a number, state, or expression');
+ for (;;) {
+ if (multiline) this.newlines();
+ const entry = BINARY.get(this.peek().kind);
+ if (!entry || entry[0] < minimum) break;
+ const operator = this.take();
+ const right = this.expression(entry[0] + 1, multiline);
+ left = this.node(operator, { kind: 'binary', op: entry[1], left, right }, [left, right]);
+ }
+ this.depth--;
+ return left;
+ }
+ block(depth = 0) {
+ if (depth >= LIMITS.depth) fail(this.peek(), 'Handler nesting exceeds 64');
+ this.newlines(); this.expect('{'); this.separators();
+ const statements = [];
+ while (!this.is('}')) {
+ const token = this.expect('name', 'assignment, if, or closing brace');
+ if (token.value === 'if' && !this.is('=')) {
+ const condition = this.expression();
+ const body = this.block(depth + 1);
+ statements.push({ kind: 'if', token, condition, body });
+ } else {
+ const name = this.reference(token);
+ this.expect('=');
+ statements.push({ kind: 'assign', token, name, expression: this.expression() });
+ }
+ this.end();
+ }
+ this.take();
+ return statements;
+ }
+ parse() {
+ const declarations = [];
+ this.separators();
+ while (!this.is('eof')) {
+ const token = this.expect('name', 'declaration');
+ const kind = token.value;
+ if (kind === 'state') {
+ const name = this.expect('name', 'state name'); this.expect('=');
+ declarations.push({ kind, token, name, initial: this.initial() });
+ } else if (kind === 'record') {
+ const name = this.expect('name', 'record type'); this.newlines(); this.expect('{'); this.separators();
+ const fields = [];
+ while (!this.is('}')) {
+ const field = this.expect('name', 'field name'); this.expect('=');
+ fields.push({name:field, initial:this.initial()}); this.end();
+ }
+ this.take(); declarations.push({kind,token,name,fields});
+ } else if (kind === 'item') {
+ const name = this.expect('name', 'item name'); this.expect('=');
+ const type = this.expect('name', 'record type'); this.expect('('); this.newlines();
+ const fields = [];
+ while (!this.is(')')) {
+ const field = this.expect('name', 'field name'); this.expect('=');
+ fields.push({name:field, initial:this.initial()}); this.newlines();
+ if (!this.is(',')) break;
+ this.take(); this.newlines();
+ }
+ this.expect(')'); declarations.push({kind,token,name,type,fields});
+ } else if (kind === 'rule') {
+ const name = this.expect('name', 'rule name'); this.expect('(');
+ const parameter = this.expect('name', 'record parameter'); this.expect(':');
+ const type = this.expect('name', 'record type'); this.expect(')');
+ declarations.push({kind,token,name,parameter,type,body:this.block()});
+ } else if (kind === 'input') {
+ const name = this.expect('name', 'input name'); this.expect('=');
+ const key = this.expect('name', 'keyboard code or pointer(view)');
+ if (key.value === 'pointer') {
+ this.expect('(');
+ const target = this.expect('name', 'view ID'); this.expect(')');
+ declarations.push({ kind, token, name, target });
+ this.end(); continue;
+ }
+ if (!/^(?:Space|Enter|Arrow(?:Left|Right|Up|Down)|Key[A-Z]|Digit[0-9])$/.test(key.value)) fail(key, 'Unsupported keyboard code');
+ declarations.push({ kind, token, name, key });
+ } else if (Object.hasOwn(VIEW_FIELDS, kind)) {
+ const name = this.expect('name', 'view ID'); this.expect('(');
+ const expressions = [];
+ for (let i = 0; i < VIEW_FIELDS[kind].length; i++) {
+ if (i) { this.newlines(); this.expect(','); }
+ expressions.push(this.expression(0, true));
+ }
+ this.newlines();
+ let color, prefix = '';
+ if (kind !== 'text') {
+ this.expect(','); this.newlines();
+ const value = this.expect('string', 'literal hex color');
+ if (!/^#[0-9a-fA-F]{6}$/.test(value.value)) fail(value, 'Color must use six hexadecimal digits, such as "#356548"');
+ color = value.value;
+ } else if (this.is(',')) { this.take(); this.newlines(); prefix = this.expect('string', 'literal text prefix').value; }
+ this.newlines(); this.expect(')');
+ declarations.push({ kind: 'view', type: kind, token, name, expressions, color, prefix });
+ } else if (kind === 'on') {
+ const event = this.expect('name', 'input name or frame');
+ let edge;
+ if (event.value !== 'frame') {
+ this.expect('.'); edge = this.expect('name', 'down, up, move or cancel');
+ if (!['down', 'up', 'move', 'cancel'].includes(edge.value)) fail(edge, 'Expected down or up, move or cancel');
+ }
+ if (this.is('name', 'use')) {
+ this.take(); const rule = this.expect('name', 'rule name'); this.expect('(');
+ const item = this.expect('name', 'item name'); this.expect(')');
+ declarations.push({kind:'handler',token,event,edge,rule,item});
+ } else declarations.push({ kind: 'handler', token, event, edge, body: this.block() });
+ } else fail(token, `Unknown declaration ${kind}`);
+ this.end();
+ }
+ return declarations;
+ }
+}
+
+export function compile(source) {
+ if (typeof source !== 'string') fail({ line: 1, column: 1 }, 'Source must be a string');
+ const sourceBytes = new TextEncoder().encode(source).length;
+ if (sourceBytes > LIMITS.sourceBytes) fail({ line: 1, column: 1 }, 'Source exceeds 32768 UTF-8 bytes');
+ const declarations = new Parser(lex(source)).parse();
+ const program = { states: [], inputs: [], instructions: [], handlers: [], views: [], metrics: {} };
+ const states = new Map(), inputs = new Map(), views = new Set(), keys = new Set(), targets = new Set();
+ const records = new Map(), items = new Map(), rules = new Map();
+ const scalarNames = new Set(declarations.filter(d => d.kind === 'state').map(d => d.name.value));
+ for (const declaration of declarations) {
+ if (!['record','rule'].includes(declaration.kind)) continue;
+ const {name,fields,kind} = declaration, symbols = kind === 'record' ? records : rules;
+ if (RESERVED.has(name.value)) fail(name, `Reserved name ${name.value}`);
+ if (symbols.has(name.value)) fail(name, `Duplicate ${kind} ${name.value}`);
+ if (kind === 'record') {
+ if (!fields.length || fields.length > 8) fail(name, 'A record needs 1..8 fields');
+ const used = new Set();
+ for (const field of fields) {
+ if (used.has(field.name.value)) fail(field.name, `Duplicate field ${field.name.value}`);
+ if (RESERVED.has(field.name.value)) fail(field.name, `Reserved name ${field.name.value}`);
+ used.add(field.name.value);
+ }
+ }
+ symbols.set(name.value, declaration);
+ }
+ let handlerCount = 0;
+ for (const declaration of declarations) {
+ const { kind, name, token } = declaration;
+ if (name && RESERVED.has(name.value)) fail(name, `Reserved name ${name.value}`);
+ if (kind === 'record' || kind === 'rule') continue;
+ if (kind === 'item') {
+ if (items.has(name.value) || scalarNames.has(name.value)) fail(name, `Duplicate state/item ${name.value}`);
+ const record = records.get(declaration.type.value);
+ if (!record) fail(declaration.type, `Unknown record ${declaration.type.value}`);
+ const overrides = new Map();
+ for (const field of declaration.fields) {
+ if (!record.fields.some(f => f.name.value === field.name.value)) fail(field.name, `Unknown field ${field.name.value}`);
+ if (overrides.has(field.name.value)) fail(field.name, `Duplicate field ${field.name.value}`);
+ overrides.set(field.name.value, field.initial);
+ }
+ const base = states.size;
+ for (const field of record.fields) {
+ if (states.size >= LIMITS.states) fail(name, 'At most 32 states are allowed, including item fields');
+ const qualified = `${name.value}.${field.name.value}`, index = states.size;
+ states.set(qualified,index);
+ program.states.push({name:qualified,initial:overrides.has(field.name.value) ? overrides.get(field.name.value) : field.initial,line:token.line});
+ }
+ items.set(name.value,{type:declaration.type.value,base,count:record.fields.length});
+ } else if (kind === 'state' || kind === 'input') {
+ const symbols = kind === 'state' ? states : inputs;
+ if (symbols.has(name.value)) fail(name, `Duplicate ${kind} ${name.value}`);
+ if (symbols.size >= LIMITS[`${kind}s`]) fail(name, `At most ${LIMITS[`${kind}s`]} ${kind}s are allowed`);
+ symbols.set(name.value, symbols.size);
+ if (kind === 'state') program.states.push({ name: name.value, initial: declaration.initial, line: token.line });
+ else if (declaration.target) {
+ const target = declaration.target;
+ if (targets.has(target.value)) fail(target, `Duplicate pointer target ${target.value}`);
+ targets.add(target.value);
+ program.inputs.push({ name: name.value, target: target.value, line: token.line });
+ } else {
+ if (keys.has(declaration.key.value)) fail(declaration.key, `Duplicate keyboard code ${declaration.key.value}`);
+ keys.add(declaration.key.value);
+ program.inputs.push({ name: name.value, key: declaration.key.value, line: token.line });
+ }
+ } else if (kind === 'view') {
+ if (views.has(name.value)) fail(name, `Duplicate view ID ${name.value}`);
+ if (views.size >= LIMITS.views) fail(name, 'At most 24 views are allowed');
+ views.add(name.value);
+ } else if (++handlerCount > LIMITS.handlers) fail(token, 'At most 64 handlers are allowed');
+ }
+ for (const declaration of declarations) {
+ if (declaration.target && !views.has(declaration.target.value))
+ fail(declaration.target, `Unknown pointer target ${declaration.target.value}`);
+ }
+ for (const rule of rules.values()) {
+ if (!records.has(rule.type.value)) fail(rule.type, `Unknown record ${rule.type.value}`);
+ if (RESERVED.has(rule.parameter.value)) fail(rule.parameter, `Reserved name ${rule.parameter.value}`);
+ }
+ let pointHandler = false;
+ let parameters = null;
+ let parameterPrefix = null;
+ let stack = 0, maxStack = 0;
+ const emit = (op, token, arg = 0, value = 0, delta = 0) => {
+ if (program.instructions.length >= LIMITS.instructions) fail(token, 'At most 512 instructions are allowed');
+ stack += delta;
+ if (stack < 0 || stack > LIMITS.stack) fail(token, 'Expression stack exceeds 64');
+ maxStack = Math.max(maxStack, stack);
+ program.instructions.push({ op, arg, value, line: token.line });
+ };
+ const expression = (node, binding = false) => {
+ const { token } = node;
+ if (node.kind === 'constant') emit(0, token, 0, node.value, 1);
+ else if (node.kind === 'reference') {
+ if (node.name === 'event_x' || node.name === 'event_y') {
+ if (binding || !pointHandler) fail(token, 'event_x and event_y are only available in pointer handlers');
+ emit(node.name === 'event_x' ? 22 : 23, token, 0, 0, 1);
+ } else if (node.name === 'dt') {
+ if (binding) fail(token, 'dt is only available in handlers');
+ emit(2, token, 0, 0, 1);
+ } else {
+ if (parameterPrefix && node.name.startsWith(parameterPrefix) && !parameters.has(node.name))
+ fail(token, `Unknown parameter field ${node.name}`);
+ if (parameters?.has(node.name)) {
+ emit(24, token, parameters.get(node.name), 0, 1);
+ return;
+ }
+ if (!states.has(node.name)) fail(token, `Unknown state ${node.name}`);
+ emit(1, token, states.get(node.name), 0, 1);
+ }
+ } else if (node.kind === 'unary') { expression(node.child, binding); emit(node.op, token); }
+ else if (node.kind === 'binary') {
+ expression(node.left, binding); expression(node.right, binding); emit(node.op, token, 0, 0, -1);
+ } else {
+ for (const arg of node.args) expression(arg, binding);
+ emit(node.op, token, 0, 0, 1 - node.args.length);
+ }
+ };
+ const statements = body => {
+ for (const statement of body) {
+ if (statement.kind === 'assign') {
+ if (parameterPrefix && statement.name.startsWith(parameterPrefix) && !parameters.has(statement.name))
+ fail(statement.token, `Unknown parameter field ${statement.name}`);
+ const parameter = parameters?.get(statement.name);
+ if (parameter === undefined && !states.has(statement.name)) fail(statement.token, `Unknown state ${statement.name}`);
+ expression(statement.expression);
+ emit(parameter === undefined ? 20 : 25, statement.token, parameter === undefined ? states.get(statement.name) : parameter, 0, -1);
+ } else {
+ expression(statement.condition);
+ const branch = program.instructions.length;
+ emit(21, statement.token, 0, 0, -1);
+ statements(statement.body);
+ program.instructions[branch].arg = program.instructions.length;
+ }
+ }
+ };
+ const bodies = new Map();
+ function ruleBody(rule) {
+ if (bodies.has(rule.name.value)) return bodies.get(rule.name.value);
+ parameterPrefix = `${rule.parameter.value}.`;
+ parameters = new Map(records.get(rule.type.value).fields.map((field,index) => [`${rule.parameter.value}.${field.name.value}`,index]));
+ // A shared body may read coordinates; every binding is checked below.
+ pointHandler = true; stack = 0;
+ const start = program.instructions.length;
+ statements(rule.body);
+ const length = program.instructions.length - start;
+ const point = program.instructions.slice(start).some(op => op.op === 22 || op.op === 23);
+ parameters = null; parameterPrefix = null;
+ const body = {start,length,point}; bodies.set(rule.name.value,body); return body;
+ }
+ for (const declaration of declarations) {
+ if (declaration.kind === 'handler') {
+ let event = 0;
+ pointHandler = false;
+ if (declaration.event.value !== 'frame') {
+ if (!inputs.has(declaration.event.value)) fail(declaration.event, `Unknown input ${declaration.event.value}`);
+ const index = inputs.get(declaration.event.value), edge = declaration.edge.value;
+ pointHandler = Boolean(program.inputs[index].target);
+ if (!pointHandler && (edge === 'move' || edge === 'cancel')) fail(declaration.edge, 'move and cancel require a pointer input');
+ event = edge === 'move' ? 17 + index : edge === 'cancel' ? 25 + index : 1 + index * 2 + (edge === 'up' ? 1 : 0);
+ }
+ if (declaration.rule) {
+ const rule = rules.get(declaration.rule.value), item = items.get(declaration.item.value);
+ if (!rule) fail(declaration.rule, `Unknown rule ${declaration.rule.value}`);
+ if (!item) fail(declaration.item, `Unknown item ${declaration.item.value}`);
+ if (item.type !== rule.type.value) fail(declaration.item, `Rule ${rule.name.value} requires ${rule.type.value}`);
+ const pointer = pointHandler;
+ const body = ruleBody(rule);
+ if (body.point && !pointer) fail(declaration.rule, 'Coordinate-reading rules require a pointer input');
+ program.handlers.push({event,start:body.start,length:body.length,line:declaration.token.line,parameterBase:item.base,parameterCount:item.count});
+ } else {
+ const start = program.instructions.length;
+ stack = 0; statements(declaration.body);
+ program.handlers.push({ event, start, length: program.instructions.length - start, line: declaration.token.line });
+ }
+ } else if (declaration.kind === 'view') {
+ const view = { id: declaration.name.value, type: declaration.type, line: declaration.token.line, bindings: {} };
+ if (view.type === 'text') view.prefix = declaration.prefix;
+ else view.color = declaration.color;
+ declaration.expressions.forEach((node, index) => {
+ const start = program.instructions.length;
+ stack = 0; expression(node, true);
+ view.bindings[VIEW_FIELDS[view.type][index]] = { start, length: program.instructions.length - start };
+ });
+ program.views.push(view);
+ }
+ }
+ // Unused definitions still receive name/type checking, but reserve no VM body.
+ for (const rule of rules.values()) {
+ if (bodies.has(rule.name.value)) continue;
+ const instructions = program.instructions, previousMax = maxStack;
+ program.instructions = [];
+ ruleBody(rule);
+ program.instructions = instructions; maxStack = previousMax;
+ }
+ program.metrics = { sourceBytes, sourceLines: source === '' ? 0 : source.split('\n').length,
+ states: states.size, inputs: inputs.size, views: views.size,
+ handlers: program.handlers.length, instructions: program.instructions.length, maxStack };
+ return program;
+}
+
+export { LIMITS };
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/before/core.wasm b/prototypes/workbench/results/2026-09-23-pointer-routing/before/core.wasm
new file mode 100755
index 0000000..8c7b1c5
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/before/core.wasm
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/before/core.zig b/prototypes/workbench/results/2026-09-23-pointer-routing/before/core.zig
new file mode 100644
index 0000000..eedeeb5
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/before/core.zig
@@ -0,0 +1,1230 @@
+//! Bounded scalar interaction VM experiment. No application behavior is built in.
+//! Same source for native tests and freestanding Wasm; no allocation or imports.
+//! Error ABI: 0 ok, 1 capacity, 2 invalid, 3 stack, 4 division by zero,
+//! 5 non-finite, 6 bad branch, 7 bad state, 8 bad frame, 9 bad event.
+//! Counters wrap modulo 2^32. Failed loads preserve the active program, authored
+//! state and event diagnostics; beginning a load discards optional reuse records.
+//! Failed events preserve values and counters but
+//! report their error and attempted instruction count. Binding evaluation is pure.
+
+const std = @import("std");
+const Evaluation = @import("evaluation.zig").Evaluation;
+const Tables = @import("evaluation.zig").Tables;
+
+const max_states = 32;
+const max_inputs = 8;
+const max_instructions = 512;
+const max_handlers = 64;
+const max_stack = 64;
+const no_pc = std.math.maxInt(u32);
+const frame_dt: f64 = 1.0 / 60.0;
+
+const Code = enum(u32) {
+ ok = 0,
+ capacity = 1,
+ invalid = 2,
+ stack = 3,
+ division_by_zero = 4,
+ non_finite = 5,
+ bad_branch = 6,
+ bad_state = 7,
+ bad_frame = 8,
+ bad_event = 9,
+ work_limit = 10,
+};
+
+const Instruction = struct { value: f64 = 0, op: u32 = 0, arg: u32 = 0 };
+const Handler = struct {
+ event: u32 = 0, start: u32 = 0, length: u32 = 0,
+ parameter_base: u8 = 0, parameter_count: u8 = 0,
+};
+const Program = struct {
+ instructions: [max_instructions]Instruction = @splat(.{}),
+ handlers: [max_handlers]Handler = @splat(.{}),
+ initials: [max_states]f64 = @splat(0),
+ old_ids: [max_states]i32 = @splat(-1),
+ declared: [max_states]bool = @splat(false),
+ state_count: u32 = 0,
+ input_count: u32 = 0,
+ instruction_count: u32 = 0,
+ handler_count: u32 = 0,
+};
+// Loading a definition/optional restored values and result reuse are mutually exclusive phases. Current execution
+// always reads active, and can recompute bindings while this workspace loads.
+const Pending = struct {
+ program: Program = .{},
+ values: [max_states]f64 = @splat(0),
+ value_mask: u32 = 0,
+};
+const Workspace = union { staging: Pending, reuse: Tables };
+const Engine = struct {
+ active: Program = .{},
+ workspace: Workspace = .{ .reuse = .{} },
+ values: [max_states]f64 = @splat(0),
+ staging_open: bool = false,
+ staging_error: Code = .ok,
+ tick: u32 = 0,
+ last_error: Code = .ok,
+ error_pc: u32 = no_pc,
+ last_ops: u32 = 0,
+ revision: u32 = 0,
+ successful_events: u32 = 0,
+ evaluation: Evaluation = .{},
+};
+var engine: Engine = .{};
+
+fn reuseTables() ?*Tables {
+ return if (engine.staging_open) null else &engine.workspace.reuse;
+}
+
+fn finishStaging() void {
+ engine.workspace = .{ .reuse = .{} };
+ engine.staging_open = false;
+ engine.evaluation.forget(null, 3);
+}
+
+fn public(code: Code) u32 {
+ return @intFromEnum(code);
+}
+
+fn stagingFail(code: Code) u32 {
+ if (engine.staging_error == .ok) engine.staging_error = code;
+ return public(code);
+}
+
+export fn reset_all() void {
+ engine = .{};
+}
+
+export fn begin_program(state_count: u32, input_count: u32) u32 {
+ engine.staging_error = .ok;
+ if (state_count > max_states or input_count > max_inputs) {
+ if (engine.staging_open) finishStaging();
+ return stagingFail(.capacity);
+ }
+ engine.workspace = .{ .staging = .{ .program = .{ .state_count = state_count, .input_count = input_count } } };
+ engine.staging_open = true;
+ engine.evaluation.forget(null, 3);
+ return 0;
+}
+
+export fn abort_program() void {
+ if (engine.staging_open) finishStaging();
+}
+
+export fn put_state(id: u32, initial: f64, old_id: i32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ if (id >= engine.workspace.staging.program.state_count or old_id < -1) return stagingFail(.bad_state);
+ if (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count) return stagingFail(.bad_state);
+ if (engine.workspace.staging.program.declared[id]) return stagingFail(.bad_state);
+ if (!std.math.isFinite(initial)) return stagingFail(.non_finite);
+ engine.workspace.staging.program.initials[id] = initial;
+ engine.workspace.staging.program.old_ids[id] = old_id;
+ engine.workspace.staging.program.declared[id] = true;
+ return 0;
+}
+
+export fn put_instruction(op: u32, arg: u32, value: f64) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (p.instruction_count == max_instructions) return stagingFail(.capacity);
+ if (op > 25) return stagingFail(.invalid);
+ if (!std.math.isFinite(value)) return stagingFail(.non_finite);
+ p.instructions[p.instruction_count] = .{ .op = op, .arg = arg, .value = value };
+ p.instruction_count += 1;
+ return 0;
+}
+
+fn validEvent(event: u32, inputs: u32) bool {
+ return event <= inputs * 2 or
+ (event >= 17 and event - 17 < inputs) or
+ (event >= 25 and event - 25 < inputs);
+}
+
+export fn put_handler(event: u32, start: u32, length: u32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (p.handler_count == max_handlers) return stagingFail(.capacity);
+ if (!validEvent(event, p.input_count)) return stagingFail(.bad_event);
+ // Handlers may be appended before their instructions, so range validation
+ // is deferred until commit. Arithmetic remains guarded there.
+ p.handlers[p.handler_count] = .{ .event = event, .start = start, .length = length };
+ p.handler_count += 1;
+ return 0;
+}
+
+export fn bind_handler(handler: u32, base: u32, count: u32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (handler >= p.handler_count or base >= p.state_count) return stagingFail(.bad_state);
+ if (count == 0 or count > 8 or count > p.state_count - base) return stagingFail(.bad_state);
+ const h = &p.handlers[handler];
+ if (h.parameter_count != 0) return stagingFail(.invalid);
+ h.parameter_base = @intCast(base);
+ h.parameter_count = @intCast(count);
+ return 0;
+}
+
+fn validate(p: *const Program) Code {
+ for (0..p.state_count) |id| {
+ if (!p.declared[id]) return .bad_state;
+ if (!std.math.isFinite(p.initials[id])) return .non_finite;
+ const old_id = p.old_ids[id];
+ if (old_id < -1 or (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count)) return .bad_state;
+ }
+ for (p.handlers[0..p.handler_count], 0..) |h, i| {
+ if (!validEvent(h.event, p.input_count)) return .bad_event;
+ if (h.start > p.instruction_count or h.length > p.instruction_count - h.start) return .invalid;
+ if (h.parameter_count > 8 or @as(u32, h.parameter_base) + h.parameter_count > p.state_count) return .bad_state;
+ if (h.length == 0) continue;
+ for (p.handlers[0..i]) |other| {
+ if (other.length != 0 and h.start < other.start + other.length and other.start < h.start + h.length) {
+ // Bound rules may share exactly one body, never a partial range.
+ if (h.parameter_count == 0 or h.parameter_count != other.parameter_count or
+ h.start != other.start or h.length != other.length) return .invalid;
+ }
+ }
+ }
+ for (p.instructions[0..p.instruction_count], 0..) |inst, pc| {
+ if (inst.op > 25) return .invalid;
+ if (!std.math.isFinite(inst.value)) return .non_finite;
+ if ((inst.op == 1 or inst.op == 20) and inst.arg >= p.state_count) return .bad_state;
+ if (inst.op >= 20) {
+ var owner: ?Handler = null;
+ for (p.handlers[0..p.handler_count]) |h| {
+ if (pc >= h.start and pc < h.start + h.length) {
+ owner = h;
+ if (inst.op == 21 and (inst.arg <= pc or inst.arg > h.start + h.length)) return .bad_branch;
+ if ((inst.op == 24 or inst.op == 25) and inst.arg >= h.parameter_count) return .bad_state;
+ }
+ }
+ if (owner == null) return .invalid;
+ }
+ }
+ return .ok;
+}
+
+export fn put_restored_value(id: u32, value: f64) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const pending = &engine.workspace.staging;
+ if (id >= pending.program.state_count) return stagingFail(.bad_state);
+ const bit = @as(u32, 1) << @as(u5, @intCast(id));
+ if (pending.value_mask & bit != 0) return stagingFail(.bad_state);
+ if (!std.math.isFinite(value)) return stagingFail(.non_finite);
+ pending.values[id] = value;
+ pending.value_mask |= bit;
+ return 0;
+}
+
+export fn commit_program() u32 {
+ return commit(false);
+}
+
+export fn commit_restored_program() u32 {
+ return commit(true);
+}
+
+fn commit(restoring: bool) u32 {
+ if (!engine.staging_open) return public(.invalid);
+ if (engine.staging_error != .ok) return public(engine.staging_error);
+ const count = engine.workspace.staging.program.state_count;
+ const expected: u32 = if (count == 32) 0xffffffff else (@as(u32, 1) << @as(u5, @intCast(count))) - 1;
+ if (engine.workspace.staging.value_mask != (if (restoring) expected else @as(u32, 0)))
+ return stagingFail(.bad_state);
+ const code = validate(&engine.workspace.staging.program);
+ if (code != .ok) return stagingFail(code);
+ var next: [max_states]f64 = @splat(0);
+ for (0..engine.workspace.staging.program.state_count) |i| {
+ const old_id = engine.workspace.staging.program.old_ids[i];
+ next[i] = if (restoring) engine.workspace.staging.values[i] else if (old_id < 0) engine.workspace.staging.program.initials[i] else engine.values[@intCast(old_id)];
+ }
+ engine.active = engine.workspace.staging.program;
+ engine.values = next;
+ finishStaging();
+ if (restoring) {
+ engine.tick = 0;
+ engine.successful_events = 0;
+ }
+ engine.revision +%= 1;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ return 0;
+}
+
+export fn reset_state() void {
+ engine.values = engine.active.initials;
+ engine.evaluation.forget(reuseTables(), 3);
+ engine.tick = 0;
+ engine.successful_events = 0;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+}
+
+const Execution = struct {
+ code: Code = .ok,
+ pc: u32 = no_pc,
+ ops: u32 = 0,
+ value: f64 = 0,
+ dependencies: u32 = 0,
+};
+
+fn executionFail(result: Execution, code: Code, pc: u32) Execution {
+ var failed = result;
+ failed.code = code;
+ failed.pc = pc;
+ return failed;
+}
+
+const EventContext = struct { dt: f64 = 0, x: f64 = 0, y: f64 = 0, parameter_base: u32 = 0, parameter_count: u32 = 0, point: bool = false };
+
+fn execute(values: *[max_states]f64, start: u32, length: u32, context: EventContext, pure: bool, budget: ?*Evaluation) Execution {
+ const p = &engine.active;
+ var result: Execution = .{};
+ if (start > p.instruction_count or length > p.instruction_count - start) return executionFail(result, .invalid, no_pc);
+ const end = start + length;
+ var stack: [max_stack]f64 = undefined;
+ var sp: usize = 0;
+ var pc = start;
+ while (pc < end) {
+ if (budget) |b| {
+ if (!b.spend()) return executionFail(result, .work_limit, pc);
+ b.instructions += 1;
+ }
+ const inst = p.instructions[pc];
+ result.ops += 1;
+ if (pure and (inst.op == 2 or inst.op >= 20)) return executionFail(result, .invalid, pc);
+ const need: usize = switch (inst.op) {
+ 0...2, 22, 23, 24 => 0,
+ 7, 16, 20, 21, 25 => 1,
+ 3...6, 8...15, 17, 18 => 2,
+ 19 => 3,
+ else => return executionFail(result, .invalid, pc),
+ };
+ if (sp < need) return executionFail(result, .stack, pc);
+ if (inst.op <= 2 or inst.op == 22 or inst.op == 23 or inst.op == 24) {
+ if (sp == max_stack) return executionFail(result, .stack, pc);
+ const value = switch (inst.op) {
+ 0 => inst.value,
+ 1 => blk: {
+ if (inst.arg >= p.state_count) return executionFail(result, .bad_state, pc);
+ if (budget != null and budget.?.cache_count != 0)
+ result.dependencies |= @as(u32, 1) << @as(u5, @intCast(inst.arg));
+ break :blk values[inst.arg];
+ },
+ 24 => blk: {
+ if (inst.arg >= context.parameter_count) return executionFail(result, .bad_state, pc);
+ const state = context.parameter_base + inst.arg;
+ if (state >= p.state_count) return executionFail(result, .bad_state, pc);
+ break :blk values[state];
+ },
+ 2 => context.dt,
+ 22, 23 => if (!context.point) return executionFail(result, .bad_event, pc) else if (inst.op == 22) context.x else context.y,
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ stack[sp] = value;
+ sp += 1;
+ } else if (inst.op == 20 or inst.op == 25) {
+ if (inst.op == 25 and inst.arg >= context.parameter_count) return executionFail(result, .bad_state, pc);
+ const state = if (inst.op == 25) context.parameter_base + inst.arg else inst.arg;
+ if (state >= p.state_count) return executionFail(result, .bad_state, pc);
+ sp -= 1;
+ values[state] = stack[sp];
+ } else if (inst.op == 21) {
+ if (inst.arg <= pc or inst.arg > end) return executionFail(result, .bad_branch, pc);
+ sp -= 1;
+ if (stack[sp] == 0) {
+ pc = inst.arg;
+ continue;
+ }
+ } else {
+ const a = stack[sp - need];
+ const b = if (need >= 2) stack[sp - need + 1] else 0;
+ const value: f64 = switch (inst.op) {
+ 3 => a + b,
+ 4 => a - b,
+ 5 => a * b,
+ 6 => blk: {
+ if (b == 0) return executionFail(result, .division_by_zero, pc);
+ break :blk a / b;
+ },
+ 7 => -a,
+ 8 => @floatFromInt(@intFromBool(a < b)),
+ 9 => @floatFromInt(@intFromBool(a <= b)),
+ 10 => @floatFromInt(@intFromBool(a > b)),
+ 11 => @floatFromInt(@intFromBool(a >= b)),
+ 12 => @floatFromInt(@intFromBool(a == b)),
+ 13 => @floatFromInt(@intFromBool(a != b)),
+ 14 => @floatFromInt(@intFromBool(a != 0 and b != 0)),
+ 15 => @floatFromInt(@intFromBool(a != 0 or b != 0)),
+ 16 => @floatFromInt(@intFromBool(a == 0)),
+ 17 => @min(a, b),
+ 18 => @max(a, b),
+ 19 => blk: {
+ const hi = stack[sp - 1];
+ if (b > hi) return executionFail(result, .invalid, pc);
+ break :blk @max(b, @min(a, hi));
+ },
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ sp = sp - need + 1;
+ stack[sp - 1] = value;
+ }
+ pc += 1;
+ }
+ if (sp != @as(usize, if (pure) 1 else 0)) return executionFail(result, .stack, end);
+ if (pure) result.value = stack[0];
+ return result;
+}
+
+export fn dispatch(event: u32, dt: f64) u32 {
+ return dispatchContext(event, .{ .dt = dt });
+}
+
+export fn dispatch_point(event: u32, x: f64, y: f64) u32 {
+ return dispatchContext(event, .{ .x = x, .y = y, .point = true });
+}
+
+fn dispatchContext(event: u32, context: EventContext) u32 {
+ const dt = context.dt;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ if (!validEvent(event, engine.active.input_count) or
+ (context.point and event == 0) or (!context.point and event > engine.active.input_count * 2)) {
+ engine.last_error = .bad_event;
+ return public(.bad_event);
+ }
+ if (!std.math.isFinite(dt) or !std.math.isFinite(context.x) or !std.math.isFinite(context.y)) {
+ engine.last_error = .non_finite;
+ return public(.non_finite);
+ }
+ if ((event == 0 and dt != frame_dt) or (event != 0 and dt != 0)) {
+ engine.last_error = .bad_frame;
+ return public(.bad_frame);
+ }
+ var pending = engine.values;
+ for (engine.active.handlers[0..engine.active.handler_count]) |h| {
+ if (h.event != event) continue;
+ var bound = context;
+ bound.parameter_base = h.parameter_base;
+ bound.parameter_count = h.parameter_count;
+ const result = execute(&pending, h.start, h.length, bound, false, null);
+ engine.last_ops += result.ops;
+ if (result.code != .ok) {
+ engine.last_error = result.code;
+ engine.error_pc = result.pc;
+ return public(result.code);
+ }
+ }
+ if (engine.evaluation.cache_count != 0 and !engine.staging_open) {
+ var changed: u32 = 0;
+ for (0..engine.active.state_count) |i| {
+ // Signed zero is observable, so compare representation, not ==.
+ if (@as(u64, @bitCast(pending[i])) != @as(u64, @bitCast(engine.values[i])))
+ changed |= @as(u32, 1) << @as(u5, @intCast(i));
+ }
+ engine.evaluation.changed(reuseTables(), changed);
+ }
+ engine.values = pending;
+ if (event == 0) engine.tick +%= 1;
+ engine.successful_events +%= 1;
+ return 0;
+}
+
+export fn eval_at(start: u32, length: u32) f64 {
+ // A local copy makes the no-side-effects boundary structural as well as
+ // checked by the pure instruction filter. Diagnostics are never touched.
+ var values = engine.values;
+ const result = execute(&values, start, length, .{}, true, null);
+ return if (result.code == .ok) result.value else std.math.nan(f64);
+}
+
+export fn configure_evaluation(cache: u32, history: u32) u32 {
+ return if (engine.evaluation.configure(reuseTables(), cache, history)) 0 else public(.capacity);
+}
+
+export fn forget_evaluation(flags: u32) void {
+ engine.evaluation.forget(reuseTables(), flags);
+}
+
+export fn begin_evaluation(limit: u32) void {
+ engine.evaluation.begin(limit);
+}
+
+export fn eval_bounded(start: u32, length: u32) f64 {
+ const e = &engine.evaluation;
+ e.status = 0;
+ // Charge per request as well as per instruction and validation probe.
+ if (!e.spend()) return std.math.nan(f64);
+ if (e.lookup(reuseTables(), start, length)) |value| return value;
+ if (e.status != 0) return std.math.nan(f64);
+ var values = engine.values;
+ const result = execute(&values, start, length, .{}, true, e);
+ e.status = public(result.code);
+ if (result.code != .ok) return std.math.nan(f64);
+ e.remember(reuseTables(), start, length, result.value, result.dependencies);
+ return result.value;
+}
+
+export fn evaluation_info(id: u32) u32 {
+ const e = &engine.evaluation;
+ return switch (id) {
+ 0 => e.status,
+ 1 => e.work,
+ 2 => e.instructions,
+ 3 => e.hits,
+ 4 => e.cache_count,
+ 5 => e.history_count,
+ 6 => @sizeOf(Evaluation) + @sizeOf(Tables),
+ 7 => @sizeOf(Evaluation),
+ 8 => @sizeOf(Tables),
+ 9 => @intFromBool(!engine.staging_open),
+ 10 => @sizeOf(Workspace) - @sizeOf(Pending),
+ else => 0,
+ };
+}
+
+export fn get_state(id: u32) f64 {
+ return if (id < engine.active.state_count) engine.values[id] else std.math.nan(f64);
+}
+
+export fn info(id: u32) u32 {
+ return switch (id) {
+ 0 => engine.tick,
+ 1 => public(engine.last_error),
+ 2 => engine.error_pc,
+ 3 => engine.last_ops,
+ 4 => engine.revision,
+ 5 => engine.active.state_count,
+ 6 => engine.active.instruction_count,
+ 7 => engine.active.handler_count,
+ 8 => engine.active.input_count,
+ 9 => engine.successful_events,
+ else => 0,
+ };
+}
+
+export fn layout(id: u32) u32 {
+ return switch (id) {
+ 0 => @sizeOf(Engine),
+ 1 => @sizeOf(Program),
+ 2 => @sizeOf(Instruction),
+ 3 => @sizeOf(Handler),
+ 4 => engine.active.instruction_count * @sizeOf(Instruction) + engine.active.handler_count * @sizeOf(Handler),
+ 5 => engine.active.state_count * @sizeOf(f64),
+ 6 => @sizeOf(Pending),
+ 7 => max_stack * @sizeOf(f64),
+ else => 0,
+ };
+}
+
+// Test helpers describe programs directly; the product compiler is separately
+// tested against this ABI. These tests exercise semantics and failure boundaries.
+fn ok(code: u32) !void {
+ try std.testing.expectEqual(@as(u32, 0), code);
+}
+
+fn emit(op: u32, arg: u32, value: f64) !void {
+ try ok(put_instruction(op, arg, value));
+}
+
+fn counterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try ok(commit_program());
+}
+
+test "blank program, no hidden application behavior, and reset" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 1), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(3));
+ try std.testing.expectEqual(@as(u32, 0), info(5));
+ try std.testing.expect(std.math.isNan(get_state(0)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 0)));
+ reset_state();
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 1), info(4));
+}
+
+test "arbitrary input counter and unused events" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(dispatch(2, 0));
+ try ok(dispatch(0, frame_dt));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(3));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(6));
+}
+
+test "charge and release behavior exists only in the authored instruction program" {
+ reset_all();
+ try ok(begin_program(3, 1)); // held, charge, released_value
+ for (0..3) |i| try ok(put_state(@intCast(i), 0, -1));
+ try emit(0, 0, 1);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 2));
+ try emit(1, 1, 0);
+ try emit(1, 0, 0);
+ try emit(2, 0, 0);
+ try emit(5, 0, 0);
+ try emit(3, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(0, 2, 6));
+ try emit(1, 1, 0);
+ try emit(20, 2, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(2, 8, 6));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ try ok(dispatch(1, 0));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try ok(dispatch(2, 0));
+ try std.testing.expectApproxEqAbs(@as(f64, 0.5), get_state(2), 1e-12);
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+}
+
+test "forward condition executes once and assignments and handlers are ordered" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 1, -1));
+ try ok(put_state(1, 0, -1));
+ try emit(1, 0, 0);
+ try emit(21, 6, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 7);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 0, 6));
+ try emit(1, 1, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 6, 4));
+ try ok(commit_program());
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 14), get_state(1));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 28), get_state(1));
+ try std.testing.expectEqual(@as(u32, 6), info(3));
+}
+
+test "branch to program end is valid" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, 2, 0);
+ try ok(put_handler(0, 0, 2));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+}
+
+test "one event rolls back writes from all handlers on arithmetic failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 8, -1));
+ try emit(0, 0, 99);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 2));
+ try emit(0, 0, 1);
+ try emit(0, 0, 0);
+ try emit(6, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 2, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 8), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(2));
+ try std.testing.expectEqual(@as(u32, 5), info(3));
+}
+
+test "nonfinite arithmetic and invalid clamp fail atomically" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, -1));
+ try emit(0, 0, 1e308);
+ try emit(0, 0, 1e308);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, 0));
+ try emit(0, 0, 1);
+ try emit(0, 0, 10);
+ try emit(0, 0, 2);
+ try emit(19, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 5));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.invalid), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+}
+
+test "stack underflow overflow and leftover values reject the event" {
+ for (0..3) |mode| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ if (mode == 0) {
+ try emit(3, 0, 0);
+ } else {
+ for (0..@as(usize, if (mode == 1) 65 else 1)) |_| try emit(0, 0, 7);
+ }
+ try ok(put_handler(0, 0, engine.workspace.staging.program.instruction_count));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.stack), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ }
+}
+
+test "failed candidates leave active values program revision and diagnostics unchanged" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ const old_revision = info(4);
+ const old_ops = info(3);
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(1, 5, 0);
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try std.testing.expectEqual(old_revision, info(4));
+ try std.testing.expectEqual(old_ops, info(3));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.invalid), put_instruction(100, 0, 0));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+}
+
+test "state preservation mapping reads active values at commit and reset uses new initials" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(2, 0));
+ try ok(put_state(0, 42, -1)); // new symbol first
+ try ok(put_state(1, 100, 0)); // old symbol moves from id 0 to 1
+ try ok(dispatch(1, 0)); // changed after staging began
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 42), get_state(0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 100), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(4));
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try std.testing.expect(std.math.isNan(get_state(0)));
+}
+
+test "capacity boundaries and sticky load errors" {
+ reset_all();
+ try std.testing.expectEqual(public(.capacity), begin_program(33, 0));
+ try std.testing.expectEqual(public(.capacity), begin_program(0, 9));
+ try ok(begin_program(32, 8));
+ for (0..32) |i| try ok(put_state(@intCast(i), @floatFromInt(i), -1));
+ for (0..512) |_| try emit(0, 0, 0);
+ for (0..64) |_| try ok(put_handler(16, 0, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 512), info(6));
+ try std.testing.expectEqual(@as(u32, 64), info(7));
+ try std.testing.expectEqual(@as(f64, 31), get_state(31));
+ try ok(begin_program(0, 0));
+ for (0..512) |_| try emit(0, 0, 0);
+ try std.testing.expectEqual(public(.capacity), put_instruction(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+ try ok(begin_program(0, 0));
+ for (0..64) |_| try ok(put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+}
+
+test "invalid declaration values state mappings event ids and handler ranges" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 0, 0));
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.non_finite), put_state(0, std.math.inf(f64), -1));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 1, -1));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.non_finite), put_instruction(0, 0, std.math.nan(f64)));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.bad_event), put_handler(1, 0, 0));
+ try ok(begin_program(0, 0));
+ try ok(put_handler(0, no_pc, 5));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 1);
+ try ok(put_handler(0, 0, 1));
+ try ok(put_handler(0, 0, 1));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "backward self out-of-handler branches and stores without handlers are rejected" {
+ for ([_]u32{ 0, 1, 3 }) |target| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, target, 0);
+ try emit(0, 0, 7);
+ try ok(put_handler(0, 0, 2));
+ try std.testing.expectEqual(public(.bad_branch), commit_program());
+ }
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try emit(0, 0, 5);
+ try emit(20, 0, 0);
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "event inputs and dt enforce deterministic frame contract" {
+ reset_all();
+ try counterProgram();
+ try std.testing.expectEqual(public(.bad_event), dispatch(3, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch(no_pc, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(1, frame_dt));
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, std.math.nan(f64)));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(u32, 0), info(1));
+ try std.testing.expectEqual(no_pc, info(2));
+}
+
+test "binding evaluation is pure on both success and failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 4, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try emit(2, 0, 0);
+ try emit(0, 0, 0);
+ try emit(21, 7, 0);
+ try ok(put_handler(0, 3, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ const before = engine;
+ try std.testing.expectEqual(@as(f64, 12), eval_at(0, 3));
+ try std.testing.expect(std.math.isNan(eval_at(0, 4))); // attempted store
+ try std.testing.expect(std.math.isNan(eval_at(4, 1))); // dt forbidden in views
+ try std.testing.expect(std.math.isNan(eval_at(5, 2))); // branch forbidden
+ try std.testing.expect(std.math.isNan(eval_at(no_pc, 7)));
+ try std.testing.expect(std.math.isNan(eval_at(0, no_pc)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 2))); // two results
+ // Compare only the live member of the untagged workspace, never padding
+ // or the inactive member's indeterminate storage.
+ try std.testing.expectEqual(before.staging_open, engine.staging_open);
+ inline for (std.meta.fields(Engine)) |field| {
+ if (comptime std.mem.eql(u8, field.name, "workspace")) {
+ if (before.staging_open) {
+ try std.testing.expectEqualDeep(before.workspace.staging, engine.workspace.staging);
+ } else {
+ try std.testing.expectEqualDeep(before.workspace.reuse, engine.workspace.reuse);
+ }
+ } else try std.testing.expectEqualDeep(@field(before, field.name), @field(engine, field.name));
+ }
+}
+
+test "arithmetic comparison logical min max and clamp expression semantics" {
+ const cases = [_]struct { op: u32, a: f64, b: f64, want: f64 }{
+ .{ .op = 3, .a = 7, .b = 2, .want = 9 },
+ .{ .op = 4, .a = 7, .b = 2, .want = 5 },
+ .{ .op = 5, .a = 7, .b = 2, .want = 14 },
+ .{ .op = 6, .a = 7, .b = 2, .want = 3.5 },
+ .{ .op = 8, .a = 2, .b = 7, .want = 1 },
+ .{ .op = 9, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 10, .a = 2, .b = 7, .want = 0 },
+ .{ .op = 11, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 12, .a = 7, .b = 2, .want = 0 },
+ .{ .op = 13, .a = 7, .b = 2, .want = 1 },
+ .{ .op = 14, .a = -7, .b = 2, .want = 1 },
+ .{ .op = 15, .a = 0, .b = 2, .want = 1 },
+ .{ .op = 17, .a = 7, .b = 2, .want = 2 },
+ .{ .op = 18, .a = 7, .b = 2, .want = 7 },
+ };
+ for (cases) |case| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, case.a);
+ try emit(0, 0, case.b);
+ try emit(case.op, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(case.want, eval_at(0, 3));
+ }
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, -8);
+ try emit(7, 0, 0);
+ try emit(0, 0, 0);
+ try emit(0, 0, 5);
+ try emit(19, 0, 0);
+ try emit(16, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 5), eval_at(0, 5));
+ try std.testing.expectEqual(@as(f64, 0), eval_at(0, 6));
+}
+
+test "diagnostic counters wrap without affecting scalar values" {
+ reset_all();
+ try counterProgram();
+ engine.tick = no_pc;
+ engine.successful_events = no_pc;
+ engine.revision = no_pc;
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 99, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 0), info(4));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+}
+
+test "memory accounting separates reserved storage used IR and stack capacity" {
+ reset_all();
+ try std.testing.expectEqual(@as(u32, 0), layout(4));
+ try std.testing.expectEqual(@as(u32, 0), layout(5));
+ try counterProgram();
+ try std.testing.expectEqual(@as(u32, @sizeOf(Engine)), layout(0));
+ try std.testing.expectEqual(@as(u32, 16), layout(2));
+ try std.testing.expectEqual(@as(u32, 16), layout(3));
+ try std.testing.expectEqual(@as(u32, 80), layout(4));
+ try std.testing.expectEqual(@as(u32, 8), layout(5));
+ try std.testing.expectEqual(@as(u32, @sizeOf(Pending)), layout(6));
+ try std.testing.expect(layout(6) >= layout(1) + max_states * @sizeOf(f64));
+ try std.testing.expect(layout(0) >= layout(1) * 2);
+ try std.testing.expectEqual(@as(u32, 512), layout(7));
+ try std.testing.expect(layout(0) + 16 * 1024 < 64 * 1024);
+}
+
+fn displayCounterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try emit(1, 0, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try ok(configure_evaluation(32, 8));
+}
+
+fn boundedEquals(want: f64, start: u32, length: u32) !void {
+ begin_evaluation(100);
+ try std.testing.expectEqual(want, eval_bounded(start, length));
+ try ok(evaluation_info(0));
+}
+
+test "point coordinates are ephemeral event operands and unavailable to pure evaluation" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 0, -1));
+ try ok(put_state(1, 0, -1));
+ const events = [_]u32{ 1, 17, 2, 25 };
+ for (events, 0..) |event, i| {
+ try emit(22, 0, 0);
+ try emit(20, 0, 0);
+ try emit(23, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(event, @intCast(i * 4), 4));
+ }
+ try ok(commit_program());
+ for (events, 0..) |event, i| {
+ const x: f64 = @floatFromInt(i + 10);
+ try ok(dispatch_point(event, x, -x));
+ try std.testing.expectEqual(x, get_state(0));
+ try std.testing.expectEqual(-x, get_state(1));
+ }
+ try std.testing.expectEqual(public(.bad_event), dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 13), get_state(0));
+ const diagnostics = [_]u32{ info(1), info(2), info(3), info(9) };
+ try std.testing.expect(std.math.isNan(eval_at(0, 1)));
+ try std.testing.expectEqualSlices(u32, &diagnostics, &.{ info(1), info(2), info(3), info(9) });
+ try std.testing.expectEqual(@as(u32, 4), info(9));
+}
+
+test "point event ranges reject holes and invalid payloads without committing" {
+ reset_all();
+ try counterProgram();
+ for ([_]u32{ 0, 3, 16, 18, 24, 26, 32, 33, no_pc }) |event|
+ try std.testing.expectEqual(public(.bad_event), dispatch_point(event, 1, 2));
+ try std.testing.expectEqual(public(.non_finite), dispatch_point(1, std.math.nan(f64), 0));
+ try std.testing.expectEqual(public(.non_finite), dispatch_point(1, 0, std.math.inf(f64)));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try ok(begin_program(0, 8));
+ try ok(put_handler(24, 0, 0));
+ try ok(put_handler(32, 0, 0));
+ try ok(commit_program());
+ try ok(dispatch_point(24, 1, 2));
+ try ok(dispatch_point(32, 1, 2));
+ try std.testing.expectEqual(public(.bad_event), dispatch(24, 0));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+}
+
+test "point event failure rolls back writes from earlier matching handlers" {
+ reset_all();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 7, -1));
+ try emit(22, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(17, 0, 2));
+ try emit(23, 0, 0);
+ try emit(0, 0, 0);
+ try emit(6, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(17, 2, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch_point(17, 400, 200));
+ try std.testing.expectEqual(@as(f64, 7), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(2));
+}
+
+test "loading shares storage with records while old events and bindings remain executable" {
+ reset_all();
+ try displayCounterProgram();
+ try boundedEquals(20, 4, 3);
+ try boundedEquals(20, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(9));
+ try emit(1, 0, 0);
+ try ok(dispatch(1, 0)); // Active state advances after staging begins.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(3));
+ try ok(configure_evaluation(32, 1));
+ forget_evaluation(3); // Must not overwrite the pending program.
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(33, 0, 3);
+ try boundedEquals(33, 0, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+}
+
+test "failed and abandoned candidates restore empty reuse storage without changing active work" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(20, 0, 0); // Store without a handler fails validation.
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try boundedEquals(20, 4, 3);
+ try ok(dispatch(1, 0));
+ abort_program();
+ try boundedEquals(22, 4, 3);
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ abort_program(); // Inactive abort is a no-op, including for warmed records.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try std.testing.expectEqual(public(.capacity), begin_program(max_states + 1, 0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+}
+
+test "full pending program survives repeated evaluation configuration record loss and reset" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ for (0..max_instructions) |i| {
+ try emit(0, 0, @floatFromInt(i));
+ try ok(configure_evaluation(32, @intCast(i % 9)));
+ forget_evaluation(3);
+ try boundedEquals(22, 4, 3);
+ }
+ reset_state(); // The pending definition and old-id mapping survive reset.
+ try boundedEquals(20, 4, 3);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, max_instructions), info(6));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try boundedEquals(511, 511, 1);
+ // Safe Zig builds may reserve a hidden union tag in addition to the payload.
+ try std.testing.expect(@sizeOf(Workspace) >= @sizeOf(Pending));
+ try std.testing.expect(@sizeOf(Workspace) <= @sizeOf(Pending) + @alignOf(Pending));
+ try std.testing.expectEqual(@as(u32, @sizeOf(Tables)), evaluation_info(8));
+}
+
+test "restored definition and values commit together without changing reset initializers" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(dispatch(0, frame_dt));
+ const revision = info(4);
+ try ok(begin_program(2, 0));
+ try ok(put_state(0, 100, -1));
+ try ok(put_state(1, 200, -1));
+ try ok(put_restored_value(1, -0.0));
+ try ok(put_restored_value(0, 37));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try ok(dispatch(1, 0)); // Old execution remains independent of pending values.
+ try ok(commit_restored_program());
+ try std.testing.expectEqual(@as(f64, 37), get_state(0));
+ try std.testing.expectEqual(@as(u64, @bitCast(@as(f64, -0.0))), @as(u64, @bitCast(get_state(1))));
+ try std.testing.expectEqual(revision + 1, info(4));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 100), get_state(0));
+ try std.testing.expectEqual(@as(f64, 200), get_state(1));
+}
+
+test "missing duplicate nonfinite or invalid restored candidates leave active work untouched" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ const revision = info(4);
+ for (0..4) |mode| {
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 999, -1));
+ if (mode == 1) {
+ try ok(put_restored_value(0, 30));
+ try std.testing.expectEqual(public(.bad_state), put_restored_value(0, 40));
+ } else if (mode == 2) {
+ try std.testing.expectEqual(public(.non_finite), put_restored_value(0, std.math.inf(f64)));
+ } else if (mode == 3) {
+ try ok(put_restored_value(0, 30));
+ try emit(20, 0, 0); // Invalid store outside a handler.
+ }
+ try std.testing.expect(commit_restored_program() != 0);
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try std.testing.expectEqual(revision, info(4));
+ try std.testing.expectEqual(@as(u32, 1), info(9));
+ abort_program();
+ }
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 100, -1));
+ try ok(put_restored_value(0, 42));
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ abort_program();
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+}
+
+test "empty and full restored programs validate all values and discard old display records" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(configure_evaluation(32, 8));
+ try boundedEquals(20, 4, 3);
+ try ok(begin_program(32, 0));
+ for (0..32) |i| {
+ try ok(put_state(@intCast(i), 0, -1));
+ try ok(put_restored_value(@intCast(i), @floatFromInt(i + 1)));
+ }
+ try emit(1, 31, 0);
+ try ok(commit_restored_program());
+ try boundedEquals(32, 0, 1);
+ try ok(begin_program(0, 0));
+ try ok(commit_restored_program());
+ try std.testing.expectEqual(@as(u32, 0), info(5));
+ try std.testing.expect(std.math.isNan(get_state(0)));
+}
+
+test "shared handler bodies access their own bound state slots in source order" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 10, -1));
+ try ok(put_state(1, 20, -1));
+ try emit(24, 0, 0); try emit(0, 0, 1); try emit(3, 0, 0); try emit(25, 0, 0);
+ try ok(put_handler(1, 0, 4)); try ok(bind_handler(0, 0, 1));
+ try ok(put_handler(1, 0, 4)); try ok(bind_handler(1, 1, 1));
+ try ok(put_handler(1, 0, 4)); try ok(bind_handler(2, 0, 1));
+ try ok(commit_program());
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try std.testing.expectEqual(@as(f64, 21), get_state(1));
+ try std.testing.expectEqual(@as(u32, 4), info(6)); // Three bindings, one body.
+ try std.testing.expectEqual(@as(u32, 12), info(3));
+ const diagnostics = [_]u32{ info(1), info(2), info(3), info(9) };
+ try std.testing.expect(std.math.isNan(eval_at(0, 1)));
+ try std.testing.expectEqualSlices(u32, &diagnostics, &.{ info(1), info(2), info(3), info(9) });
+}
+
+test "failure in a later shared binding rolls back all earlier bindings" {
+ reset_all();
+ try ok(begin_program(4, 1));
+ for ([_]f64{ 8, 2, 12, 0 }, 0..) |value, i| try ok(put_state(@intCast(i), value, -1));
+ try emit(24, 0, 0); try emit(24, 1, 0); try emit(6, 0, 0); try emit(25, 0, 0);
+ for (0..2) |i| {
+ try ok(put_handler(1, 0, 4));
+ try ok(bind_handler(@intCast(i), @intCast(i * 2), 2));
+ }
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 8), get_state(0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(2));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+}
+
+test "shared bindings reject missing arguments partial overlaps and invalid slot mappings" {
+ reset_all();
+ try counterProgram();
+ const revision = info(4);
+ for (0..5) |mode| {
+ try ok(begin_program(1, 1)); try ok(put_state(0, 9, -1));
+ try emit(24, 0, 0); try emit(25, 0, 0);
+ try ok(put_handler(1, 0, 2));
+ switch (mode) {
+ 0 => {}, // Parameter op without a binding.
+ 1 => { try ok(bind_handler(0, 0, 1)); try std.testing.expectEqual(public(.invalid), bind_handler(0, 0, 1)); },
+ 2 => { try std.testing.expectEqual(public(.bad_state), bind_handler(0, 0, 2)); },
+ 3 => {
+ try ok(bind_handler(0, 0, 1));
+ try ok(put_handler(1, 1, 1)); try ok(bind_handler(1, 0, 1));
+ },
+ else => {
+ try ok(bind_handler(0, 0, 1));
+ try ok(put_handler(1, 0, 2)); // Another owner missing its mapping.
+ },
+ }
+ try std.testing.expect(commit_program() != 0); abort_program();
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(revision, info(4));
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/before/runtime.mjs b/prototypes/workbench/results/2026-09-23-pointer-routing/before/runtime.mjs
new file mode 100644
index 0000000..4a9cd14
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/before/runtime.mjs
@@ -0,0 +1,328 @@
+import { compile } from './compiler.mjs';
+import { validateDocument } from './project.mjs';
+
+const messages = [
+ '', '실행기의 용량 한도를 넘었습니다.', '실행 정의가 유효하지 않습니다.',
+ '계산 스택의 범위를 벗어났습니다.', '0으로 나눌 수 없습니다.',
+ '계산 결과가 유한한 수의 범위를 벗어났습니다.', '조건 분기 범위가 유효하지 않습니다.',
+ '존재하지 않는 상태를 참조했습니다.', '시간 간격이 유효하지 않습니다.',
+ '정의되지 않은 입력 사건입니다.',
+];
+const sourceLimit = 32768;
+
+function problem(message, line = null, column = null) {
+ return Object.assign(new Error(message), { line, column });
+}
+
+function freezeProgram(value) {
+ if (value && typeof value === 'object') {
+ for (const child of Object.values(value)) freezeProgram(child);
+ Object.freeze(value);
+ }
+ return value;
+}
+
+export async function createRuntime(wasmBytes, evaluationOptions = {}) {
+ if (wasmBytes === undefined) {
+ const response = await fetch(new URL('./core.wasm', import.meta.url), { cache: 'no-store' });
+ if (!response.ok) throw new Error(`실행기를 불러오지 못했습니다 (HTTP ${response.status}).`);
+ wasmBytes = await response.arrayBuffer();
+ }
+ const module = await WebAssembly.compile(wasmBytes);
+ if (WebAssembly.Module.imports(module).length) throw new Error('이 실행기는 호스트 함수 없이 동작해야 합니다.');
+ const { exports: core } = await WebAssembly.instantiate(module, {});
+ if (typeof core.eval_bounded !== 'function' || typeof core.abort_program !== 'function' || typeof core.dispatch_point !== 'function' || typeof core.put_restored_value !== 'function' || typeof core.commit_restored_program !== 'function' || typeof core.bind_handler !== 'function')
+ throw new Error('실행기 버전이 맞지 않습니다. 새로고침하거나 core.wasm을 다시 빌드하세요.');
+ core.reset_all();
+ let program = null;
+ let appliedSource = '';
+ let lastEvent = null;
+ // Keep a failed event visible through its release/focus-loss cleanup.
+ // The active program can clear this diagnostic by apply or explicit reset.
+ let lastFailure = null;
+ const held = new Map();
+ const points = new Map();
+ let presenting = false;
+ let bindings = [];
+ // One bounded publication buffer, never exposed to callers. No state mirror.
+ let displayValues = null, displayValid = null;
+ function available() {
+ if (presenting) throw new Error('출력 전달 중에는 실행기를 다시 호출할 수 없습니다.');
+ }
+ let evaluationPolicy = { cacheEntries: 0, historyEntries: 8, workLimit: 2048 };
+
+ function checked(code, line = null) {
+ if (code !== 0) throw problem(messages[code] || `실행기 오류 ${code}`, line);
+ }
+ function inputIndex(name) {
+ const index = program?.inputs.findIndex((input) => input.name === name) ?? -1;
+ if (index < 0) throw new Error(`정의되지 않은 입력: ${name}`);
+ return index;
+ }
+ function dispatch(event, name, phase, source, point = null) {
+ const status = point ? core.dispatch_point(event, point.x, point.y) : core.dispatch(event, event === 0 ? 1 / 60 : 0);
+ const pc = core.info(2) >>> 0;
+ lastEvent = { name, phase, source, status,
+ line: program?.instructions[pc]?.line ?? null, ops: core.info(3) >>> 0 };
+ if (point) { lastEvent.x = point.x; lastEvent.y = point.y; }
+ if (status) lastFailure = { ...lastEvent };
+ return status;
+ }
+
+ function evaluateDisplay() {
+ displayValues ??= new Float64Array(96);
+ displayValid ??= new Uint8Array(24);
+ core.begin_evaluation(evaluationPolicy.workLimit);
+ let error = null, slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ displayValid[i] = 1;
+ for (const [name, binding] of bindings[i]) {
+ const value = core.eval_bounded(binding.start, binding.length);
+ if (core.evaluation_info(0) === 10) {
+ displayValid.fill(0);
+ return { code: 101, line: view.line,
+ message: '표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.' };
+ }
+ if (!Number.isFinite(value) || ((name === 'width' || name === 'height' || name === 'radius') && value < 0)) {
+ error ??= { line: view.line, message: `${view.id}.${name}: 표시할 수 없는 값입니다. 0 나눗셈이나 음수 크기를 확인하세요.` };
+ displayValid[i] = 0;
+ }
+ displayValues[slot++] = value;
+ }
+ }
+ return error;
+ }
+
+ function status(viewError) {
+ const executionError = lastFailure?.status || 0;
+ return {
+ tick: core.info(0) >>> 0, revision: core.info(4) >>> 0,
+ displayError: viewError ? { code: viewError.code || 100, ...viewError } : null,
+ lastError: executionError || (viewError ? viewError.code || 100 : 0),
+ errorMessage: executionError ? messages[executionError] || `실행 오류 ${executionError}` : viewError?.message || '',
+ errorLine: executionError ? lastFailure.line : viewError?.line ?? null,
+ lastOps: core.info(3) >>> 0, successfulEvents: core.info(9) >>> 0,
+ held: [...held.keys()], lastEvent: lastEvent ? { ...lastEvent } : null,
+ lastFailure: lastFailure ? { ...lastFailure } : null,
+ memory: {
+ linearBytes: core.memory.buffer.byteLength,
+ reservedEngineBytes: core.layout(0), programReservedBytes: core.layout(1),
+ instructionBytes: core.layout(2), handlerBytes: core.layout(3),
+ activeIRBytes: core.layout(4), activeStateBytes: core.layout(5),
+ stagingReservedBytes: core.layout(6), evalStackBytes: core.layout(7),
+ },
+ };
+ }
+
+ function load(next, source, restored = null) {
+ const oldIds = new Map(restored ? [] : program?.states.map((state, index) => [state.name, index]) ?? []);
+ checked(core.begin_program(next.states.length, next.inputs.length));
+ try {
+ for (let index = 0; index < next.states.length; index++) {
+ const state = next.states[index];
+ checked(core.put_state(index, state.initial, oldIds.get(state.name) ?? -1), state.line);
+ if (restored) checked(core.put_restored_value(index, restored[index]), state.line);
+ }
+ for (const op of next.instructions) checked(core.put_instruction(op.op, op.arg, op.value), op.line);
+ for (const [index, handler] of next.handlers.entries()) {
+ checked(core.put_handler(handler.event, handler.start, handler.length), handler.line);
+ if (handler.parameterCount)
+ checked(core.bind_handler(index, handler.parameterBase, handler.parameterCount), handler.line);
+ }
+ checked(restored ? core.commit_restored_program() : core.commit_program());
+ } catch (error) {
+ core.abort_program();
+ throw error;
+ }
+ program = next;
+ bindings = next.views.map(view => Object.entries(view.bindings));
+ appliedSource = source;
+ lastEvent = null;
+ lastFailure = null;
+ }
+
+ const api = {
+ get currentProgram() { return program; },
+ configureEvaluation({ cacheEntries = evaluationPolicy.cacheEntries,
+ historyEntries = evaluationPolicy.historyEntries, workLimit = evaluationPolicy.workLimit } = {}) {
+ available();
+ for (const [name, value, maximum] of [['cacheEntries', cacheEntries, 32],
+ ['historyEntries', historyEntries, 8], ['workLimit', workLimit, 0xffffffff]]) {
+ if (!Number.isInteger(value) || value < 0 || value > maximum)
+ throw new Error(`${name}: 0~${maximum} 사이의 정수여야 합니다.`);
+ }
+ checked(core.configure_evaluation(cacheEntries, historyEntries));
+ evaluationPolicy = { cacheEntries, historyEntries, workLimit };
+ return { ...evaluationPolicy };
+ },
+ forgetEvaluation(part = 'all') {
+ available();
+ const flags = part === 'all' ? 3 : part === 'values' ? 1 : part === 'history' ? 2 : undefined;
+ if (flags === undefined) throw new Error('삭제할 기록은 values, history, all 중 하나여야 합니다.');
+ core.forget_evaluation(flags);
+ },
+ evaluationStats() {
+ return { ...evaluationPolicy, work: core.evaluation_info(1),
+ instructions: core.evaluation_info(2), cacheHits: core.evaluation_info(3),
+ reservedBytes: core.evaluation_info(6), dedicatedBytes: core.evaluation_info(7),
+ sharedBytes: core.evaluation_info(8), reuseAvailable: Boolean(core.evaluation_info(9)),
+ workspaceTagBytes: core.evaluation_info(10) };
+ },
+ apply(source) {
+ available();
+ if (typeof source !== 'string' || new TextEncoder().encode(source).length > sourceLimit) {
+ throw problem('정의는 UTF-8 기준 32,768바이트 이하의 텍스트여야 합니다.');
+ }
+ if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 적용하세요. 현재 작업은 그대로 유지합니다.');
+ const next = freezeProgram(compile(source));
+ load(next, source);
+ return next;
+ },
+ restoreDocument(document) {
+ available();
+ if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 복원하세요.');
+ const next = validateDocument(document);
+ load(freezeProgram(next.program), next.source, next.values);
+ return { source: next.source, draft: next.draft };
+ },
+ exportDocument(draft = appliedSource) {
+ available();
+ if (!program) throw problem('먼저 코드를 적용하세요.');
+ if (held.size) throw problem('조작을 끝낸 뒤 배치를 저장하세요.');
+ if (lastFailure) throw problem('실행 오류를 해결한 뒤 배치를 저장하세요.');
+ if (typeof draft !== 'string' || new TextEncoder().encode(draft).length > sourceLimit)
+ throw problem('편집 중인 코드는 UTF-8 32 KiB 이하여야 합니다.');
+ return { format: 'pure-workbench', version: 2, source: appliedSource, draft,
+ states: program.states.map((state, i) => {
+ const value = core.get_state(i);
+ return { name: state.name, value: Object.is(value, -0) ? '-0' : value };
+ }) };
+ },
+ reset() {
+ available();
+ held.clear();
+ points.clear();
+ core.reset_state();
+ lastEvent = null;
+ lastFailure = null;
+ },
+ frame(count = 1) {
+ available();
+ if (!Number.isInteger(count) || count < 0 || count > 600) throw new Error('프레임 수는 0~600 사이의 정수여야 합니다.');
+ if (!program) return 0;
+ for (let i = 0; i < count; i++) {
+ const code = dispatch(0, 'frame', 'frame', 'clock');
+ if (code !== 0) return code;
+ }
+ return 0;
+ },
+ down(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (program.inputs[index].target) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.');
+ const sources = held.get(name) || new Set();
+ if (sources.has(source)) return 0;
+ const wasDown = sources.size > 0;
+ sources.add(source);
+ held.set(name, sources);
+ return wasDown ? 0 : dispatch(1 + index * 2, name, 'down', source);
+ },
+ up(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (program.inputs[index].target) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.');
+ const sources = held.get(name);
+ if (!sources?.delete(source)) return 0;
+ if (sources.size) return 0;
+ held.delete(name);
+ return dispatch(2 + index * 2, name, 'up', source);
+ },
+ point(name, phase, x, y, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (!program.inputs[index].target) throw new Error('포인터에 연결된 입력이 아닙니다.');
+ if (!['down', 'move', 'up', 'cancel'].includes(phase)) throw new Error('포인터 단계는 down, move, up, cancel 중 하나여야 합니다.');
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error('포인터 좌표는 유한한 숫자여야 합니다.');
+ if (typeof source !== 'string' || source.length === 0) throw new Error('포인터 출처 이름이 필요합니다.');
+ let point = points.get(name);
+ if (phase === 'down') {
+ if (point?.source === source) return 0;
+ if (point) throw new Error('이 입력은 다른 포인터가 사용 중입니다.');
+ point = {source, x, y};
+ points.set(name, point);
+ held.set(name, new Set([source]));
+ } else {
+ if (!point || point.source !== source) return 0;
+ point.x = x; point.y = y;
+ if (phase === 'up' || phase === 'cancel') { points.delete(name); held.delete(name); }
+ }
+ const event = phase === 'move' ? 17 + index : phase === 'cancel' ? 25 + index : 1 + index * 2 + (phase === 'up' ? 1 : 0);
+ return dispatch(event, name, phase, source, point);
+ },
+ releaseAll() {
+ available();
+ let firstError = 0;
+ for (const [name, point] of [...points]) {
+ const status = api.point(name, 'cancel', point.x, point.y, point.source);
+ if (!firstError) firstError = status;
+ }
+ for (const [name, sources] of [...held]) {
+ for (const source of [...sources]) {
+ const status = api.up(name, source);
+ if (!firstError) firstError = status;
+ }
+ }
+ return firstError;
+ },
+ exportProject() {
+ return { format: 'pure-workbench', version: 1, source: appliedSource };
+ },
+ // Synchronous trusted output adapter. Callbacks run only after the whole
+ // display has been evaluated. Runtime mutation/reentrant reads are refused.
+ // Exceptions from an adapter propagate; the next call republishes all values.
+ present(target) {
+ available();
+ presenting = true;
+ try {
+ const { begin, state, view, value, end } = target;
+ for (const callback of [begin, state, view, value, end])
+ if (typeof callback !== 'function') throw new TypeError('출력 어댑터 함수가 필요합니다.');
+ const error = evaluateDisplay();
+ begin.call(target, program);
+ for (let i = 0; i < (program?.states.length ?? 0); i++) state.call(target, i, core.get_state(i));
+ let slot = 0, visible = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const valid = Boolean(displayValid[i]);
+ view.call(target, i, valid);
+ for (const [name] of bindings[i]) {
+ if (valid) value.call(target, i, name, displayValues[slot]);
+ slot++;
+ }
+ if (valid) visible++;
+ }
+ end.call(target, visible);
+ return status(error);
+ } finally { presenting = false; }
+ },
+ snapshot() {
+ available();
+ const error = evaluateDisplay();
+ const states = Object.fromEntries((program?.states ?? []).map((state, index) => [state.name, core.get_state(index)]));
+ const views = [];
+ let slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ const rendered = displayValid[i] ? { id: view.id, type: view.type, color: view.color, prefix: view.prefix } : null;
+ for (const [name] of bindings[i]) {
+ if (rendered) rendered[name] = displayValues[slot];
+ slot++;
+ }
+ if (rendered) views.push(rendered);
+ }
+ return { states, views, ...status(error) };
+ },
+ };
+ api.configureEvaluation(evaluationOptions);
+ return Object.freeze(api);
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/browser-initial-failure.json b/prototypes/workbench/results/2026-09-23-pointer-routing/browser-initial-failure.json
new file mode 100644
index 0000000..e0f1d95
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/browser-initial-failure.json
@@ -0,0 +1,32 @@
+{
+ "failed": "Error: the remaining contact keeps moving only its own target",
+ "checks": [
+ "placement save requires an applied definition",
+ "one authored pointer input connects all three view targets",
+ "DOM destinations retain distinct target identities",
+ "last input identifies the released target within its group",
+ "three independently authored targets move and selection directs keyboard adjustment",
+ "placement download is a separate document file",
+ "saving neither applies nor discards the unfinished draft",
+ "reset returns to authored initial positions",
+ "downloaded file restores all states and visible positions",
+ "the draft remains separate from the restored executable definition",
+ "restored work starts stopped with no replayed inputs or clock",
+ "restored selection and bindings remain editable",
+ "a fresh browser page reconstructs the saved work without old runtime state",
+ "invalid saved state cannot partially replace code, draft or scene",
+ "a delayed file read cannot overwrite a newer draft",
+ "a delayed restore cannot overwrite placement changes made while reading",
+ "saving while time advances requests a stopped document",
+ "saving with a held input is refused",
+ "editing the shared rule changes the selected amber target",
+ "the same single rule edit also changes the blue target",
+ "reordering instance declarations preserves every field by qualified name",
+ "rebuilt parameter maps still send selection input to blue after reorder",
+ "simultaneous touches share code but retain independent field bindings",
+ "shared cancel restores each contact to its own start position",
+ "a second touch on the same target cannot steal ownership",
+ "releasing green keeps blue captured within the same input"
+ ],
+ "errors": []
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/browser-result.log b/prototypes/workbench/results/2026-09-23-pointer-routing/browser-result.log
new file mode 100644
index 0000000..5b01c2e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/browser-result.log
@@ -0,0 +1,12 @@
+### Result
+{"checks":["placement save requires an applied definition","one authored pointer input connects all three view targets","DOM destinations retain distinct target identities","last input identifies the released target within its group","three independently authored targets move and selection directs keyboard adjustment","placement download is a separate document file","saving neither applies nor discards the unfinished draft","reset returns to authored initial positions","downloaded file restores all states and visible positions","the draft remains separate from the restored executable definition","restored work starts stopped with no replayed inputs or clock","restored selection and bindings remain editable","a fresh browser page reconstructs the saved work without old runtime state","invalid saved state cannot partially replace code, draft or scene","a delayed file read cannot overwrite a newer draft","a delayed restore cannot overwrite placement changes made while reading","saving while time advances requests a stopped document","saving with a held input is refused","editing the shared rule changes the selected amber target","the same single rule edit also changes the blue target","reordering instance declarations preserves every field by qualified name","rebuilt parameter maps still send selection input to blue after reorder","simultaneous touches share code but retain independent field bindings","shared cancel restores each contact to its own start position","a second touch on the same target cannot steal ownership","releasing green keeps blue captured within the same input","the remaining contact keeps moving only its own target","canceling the remaining target preserves the already released position","extra persistence control fits a mobile viewport","no uncaught page errors"],"errors":[],"saved":"output/playwright/pure-routed-layout-saved.json","expected":{"states":{"selected":3,"green.x":150.00000659728084,"green.y":169.9999983506798,"green.ox":9.999986805438311,"green.oy":10.00000164932024,"green.sx":80,"green.sy":120,"green.held":0,"green.id":1,"blue.x":350.00000659728073,"blue.y":209.9999983506798,"blue.ox":9.999980208157467,"blue.oy":10.00000329864045,"blue.sx":280,"blue.sy":160,"blue.held":0,"blue.id":2,"amber.x":509.999947221753,"amber.y":260.0000016493202,"amber.ox":10.000013194561802,"amber.oy":9.999998350679817,"amber.sx":450,"amber.sy":220,"amber.held":0,"amber.id":3},"views":[{"id":"board","type":"box","color":"#233a31","x":20,"y":90,"width":600,"height":240},{"id":"help","type":"text","prefix":"선택한 도형: ","x":25,"y":40,"value":3},{"id":"green_box","type":"box","color":"#c9f08b","x":150.00000659728084,"y":169.9999983506798,"width":56,"height":44},{"id":"blue_box","type":"box","color":"#74c4d4","x":350.00000659728073,"y":209.9999983506798,"width":56,"height":44},{"id":"amber_box","type":"box","color":"#dfb184","x":509.999947221753,"y":260.0000016493202,"width":56,"height":44}]},"source":"# 하나의 포인터 입력과 네 반응을 모든 도형이 공유합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag = pointer {\n green_box: green\n blue_box: blue\n amber_box: amber\n}\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag.down use grab(drag.target)\non drag.move use move(drag.target)\non drag.up use drop(drag.target)\non drag.cancel use cancel(drag.target)\n\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n","draft":"# 하나의 포인터 입력과 네 반응을 모든 도형이 공유합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag = pointer {\n green_box: green\n blue_box: blue\n amber_box: amber\n}\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag.down use grab(drag.target)\non drag.move use move(drag.target)\non drag.up use drop(drag.target)\non drag.cancel use cancel(drag.target)\n\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n\nstate unfinished ="}
+### Ran Playwright code
+```js
+await (async page => await page.evaluate(() => window.__pureRoutedInputEvidence ?? {pending:true}))(page);
+```
+### Page
+- Page URL: http://127.0.0.1:8766/
+- Page Title: Pure · 빈 작업물에서 시작하기
+### Events
+- Downloading file my-layout.pure.json ...
+- Downloaded file my-layout.pure.json to ".playwright-cli/my-layout-pure.json"
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/browser-run.log b/prototypes/workbench/results/2026-09-23-pointer-routing/browser-run.log
new file mode 100644
index 0000000..85b489e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/browser-run.log
@@ -0,0 +1,172 @@
+### Ran Playwright code
+```js
+await (// Functional end-to-end persistence checks. Run with Playwright CLI run-code.
+async page => {
+ const checks=[],errors=[];
+ const check=(ok,label)=>{if(!ok)throw new Error(label);checks.push(label);};
+ const state=()=>page.evaluate(()=>window.pureWorkbench.snapshot());
+ const onDialog=dialog=>dialog.accept(), onError=error=>errors.push(error.message);
+ page.on('dialog',onDialog);page.on('pageerror',onError);
+ const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
+ const client=(x,y)=>page.evaluate(({x,y})=>{const p=new DOMPoint(x,y).matrixTransform(document.querySelector('#scene').getScreenCTM());return{x:p.x,y:p.y};},{x,y});
+ const move=async(x,y)=>{const p=await client(x,y);await page.mouse.move(p.x,p.y);};
+ const drag=async(x,y,nx,ny)=>{await page.locator('#scene').scrollIntoViewIfNeeded();await move(x,y);await page.mouse.down();await move(nx,ny);await page.mouse.up();};
+ const upload=async(text,name='layout.json')=>{
+ await page.locator('#file-input').evaluate((input,{text,name})=>{
+ const transfer=new DataTransfer();transfer.items.add(new File([text],name,{type:'application/json'}));
+ input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
+ },{text,name});
+ };
+ try {
+ await page.setViewportSize({width:1440,height:1000});
+ await page.waitForFunction(()=>window.pureWorkbench?.snapshot().ready);
+ check(await page.locator('#download-layout').isDisabled(),'placement save requires an applied definition');
+ await page.locator('#example').selectOption('routed-layout');await page.locator('#load-example').click();await page.locator('#apply').click();
+ const source=await page.locator('#source').inputValue();
+ check(await page.locator('[data-point-input="drag"]').count()===3,'one authored pointer input connects all three view targets');
+ check(await page.locator('[data-point-target]').count()===3,'DOM destinations retain distinct target identities');
+ await drag(90,130,160,180);await drag(290,170,360,220);await drag(460,230,530,270);
+ check((await page.locator('#last-event').textContent()).includes('drag.up → amber_box'),'last input identifies the released target within its group');
+ await page.keyboard.press('ArrowLeft');
+ let s=await state();
+ check(Math.abs(s.states['green.x']-150)<0.1&&Math.abs(s.states['blue.x']-350)<0.1&&Math.abs(s.states['amber.x']-510)<0.1,'three independently authored targets move and selection directs keyboard adjustment');
+ const expected={states:s.states,views:s.views};
+ const draft=source+'\nstate unfinished =';
+ await page.locator('#source').fill(draft);
+ const pending=page.waitForEvent('download');await page.locator('#download-layout').click();
+ const download=await pending;const saved='output/playwright/pure-routed-layout-saved.json';await download.saveAs(saved);
+ check(download.suggestedFilename()==='my-layout.pure.json','placement download is a separate document file');
+ check((await state()).source===draft&&(await state()).appliedSource===source,'saving neither applies nor discards the unfinished draft');
+ await page.locator('#reset').click();
+ check((await state()).states['green.x']===80,'reset returns to authored initial positions');
+ await page.locator('#file-input').setInputFiles(saved);
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
+ s=await state();
+ check(same(s.states,expected.states)&&same(s.views,expected.views),'downloaded file restores all states and visible positions');
+ check(s.source===draft&&s.appliedSource===source&&s.dirty,'the draft remains separate from the restored executable definition');
+ check(!s.running&&!s.held.length&&s.tick===0&&s.successfulEvents===0,'restored work starts stopped with no replayed inputs or clock');
+ await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');
+ check(Math.abs((await state()).states['amber.x']-(expected.states['amber.x']+10))<0.1,'restored selection and bindings remain editable');
+ // Use a fresh page/runtime, not just preservation inside the original instance.
+ const fresh=await page.context().newPage();await fresh.goto(page.url());
+ await fresh.waitForFunction(()=>window.pureWorkbench?.snapshot().ready);
+ await fresh.locator('#file-input').setInputFiles(saved);
+ await fresh.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
+ const reopened=await fresh.evaluate(()=>window.pureWorkbench.snapshot());
+ check(same(reopened.states,expected.states)&&same(reopened.views,expected.views)&&reopened.source===draft,'a fresh browser page reconstructs the saved work without old runtime state');
+ await fresh.close();await page.bringToFront();
+
+ const before=await state();
+ await upload(JSON.stringify({format:'pure-workbench',version:2,source:'state changed=99',draft:'replacement',states:[]}));
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('상태 목록'));
+ s=await state();
+ check(same(s.states,before.states)&&s.source===before.source&&s.appliedSource===before.appliedSource&&s.revision===before.revision,'invalid saved state cannot partially replace code, draft or scene');
+ // Delay File.text, then edit before it completes. Restoring must not overwrite the edit.
+ await page.locator('#file-input').evaluate(input=>{
+ const document={format:'pure-workbench',version:2,source:'state other=4',draft:'state other=4',states:[{name:'other',value:55}]};
+ const transfer=new DataTransfer(),file=new File([JSON.stringify(document)],'delayed.json',{type:'application/json'});
+ const text=JSON.stringify(document);file.text=()=>new Promise(resolve=>{window.__finishLayoutRead=()=>resolve(text);});
+ transfer.items.add(file);input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
+ });
+ await page.locator('#source').fill(draft+'\n# edit during file read');
+ await page.evaluate(()=>window.__finishLayoutRead());
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('작업이 바뀌'));
+ s=await state();check(s.source.endsWith('# edit during file read')&&same(s.states,before.states),'a delayed file read cannot overwrite a newer draft');
+ // Repeat with an input event while file content is pending.
+ await page.locator('#file-input').evaluate(input=>{
+ const text=JSON.stringify({format:'pure-workbench',version:2,source:'state other=4',states:[{name:'other',value:55}]});
+ const file=new File([text],'delayed.json',{type:'application/json'}),transfer=new DataTransfer();
+ file.text=()=>new Promise(resolve=>{window.__finishLayoutRead=()=>resolve(text);});
+ transfer.items.add(file);input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
+ });
+ await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');const changed=await state();
+ await page.evaluate(()=>window.__finishLayoutRead());
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('작업이 바뀌'));
+ check(same((await state()).states,changed.states),'a delayed restore cannot overwrite placement changes made while reading');
+ // UI-only run state is deliberately not persisted.
+ await page.locator('#play').click();await page.locator('#download-layout').click();
+ check((await state()).diagnostic.includes('정지한 뒤'),'saving while time advances requests a stopped document');
+ await page.locator('#play').click();
+ // Refuse while a logical input remains held, with no lost source or partial save.
+ await page.locator('#scene').focus();await page.keyboard.down('ArrowRight');
+ await page.locator('#download-layout').click();
+ check((await state()).diagnostic.includes('조작을 끝낸 뒤'),'saving with a held input is refused');
+ await page.keyboard.up('ArrowRight');
+ await page.locator('#file-input').setInputFiles(saved);
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
+ // One source edit affects every bound target, without editing input adapters.
+ const edited=source.replace('t.x = clamp(t.x + 10,','t.x = clamp(t.x + 25,');
+ await page.locator('#source').fill(edited);await page.locator('#apply').click();
+ const beforeEdit=await state();
+ await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');
+ check(Math.abs((await state()).states['amber.x']-(beforeEdit.states['amber.x']+25))<0.1,'editing the shared rule changes the selected amber target');
+ await drag(beforeEdit.states['blue.x']+10,beforeEdit.states['blue.y']+10,beforeEdit.states['blue.x']+10,beforeEdit.states['blue.y']+10);
+ const blueBefore=(await state()).states['blue.x'];await page.keyboard.press('ArrowRight');
+ check(Math.abs((await state()).states['blue.x']-(blueBefore+25))<0.1,'the same single rule edit also changes the blue target');
+ const lines=edited.split('\n'),items=lines.filter(line=>line.startsWith('item ')).reverse();
+ const reordered=lines.map(line=>line.startsWith('item ')?items.shift():line).join('\n');
+ const beforeReorder=await state();await page.locator('#source').fill(reordered);await page.locator('#apply').click();
+ const afterReorder=await state();
+ check(Object.entries(beforeReorder.states).every(([name,value])=>afterReorder.states[name]===value),'reordering instance declarations preserves every field by qualified name');
+ await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');
+ check(Math.abs((await state()).states['blue.x']-(afterReorder.states['blue.x']+25))<0.1,'rebuilt parameter maps still send selection input to blue after reorder');
+ // Two contacts execute the same body with different bound fields.
+ await page.locator('#source').fill(source);await page.locator('#apply').click();await page.locator('#reset').click();
+ await page.locator('#scene').scrollIntoViewIfNeeded();
+ const session=await page.context().newCDPSession(page);
+ try {
+ await session.send('Emulation.setTouchEmulationEnabled',{enabled:true,maxTouchPoints:2});
+ const p1={id:1,...await client(90,130)},p2={id:2,...await client(290,170)};
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1]});
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1,p2]});
+ await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{id:1,...await client(160,180)},{id:2,...await client(360,220)}]});
+ await page.waitForFunction(()=>{const s=window.pureWorkbench.snapshot().states;return Math.abs(s['green.x']-150)<0.1&&Math.abs(s['blue.x']-350)<0.1;});
+ const touch=await state();
+ check(Math.abs(touch.states['green.x']-150)<0.1&&Math.abs(touch.states['blue.x']-350)<0.1&&touch.held.length===2,'simultaneous touches share code but retain independent field bindings');
+ await session.send('Input.dispatchTouchEvent',{type:'touchCancel',touchPoints:[]});
+ const canceled=await state();
+ check(canceled.states['green.x']===80&&canceled.states['blue.x']===280&&!canceled.held.length,'shared cancel restores each contact to its own start position');
+
+ // A real second contact on the same view must not steal its owner.
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1]});
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1,{id:2,...await client(100,140)}]});
+ check((await state()).held.length===1,'a second touch on the same target cannot steal ownership');
+ await session.send('Input.dispatchTouchEvent',{type:'touchCancel',touchPoints:[]});
+ // End one actual contact and continue the other in the same logical input.
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1]});
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1,p2]});
+ const m1={id:1,...await client(160,180)},m2={id:2,...await client(360,220)};
+ await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[m1,m2]});
+ // In this Chromium, touchEnd's supplied point identifies the contact ending.
+ // touch-reproduction.js records the resulting pointerup and retained blue owner.
+ await session.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[m1]});
+ let partial=await state();
+ check(partial.held.length===1&&partial.states['green.held']===0&&partial.states['blue.held']===1,'releasing green keeps blue captured within the same input');
+ await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{id:2,...await client(400,250)}]});
+ await page.waitForFunction(()=>Math.abs(window.pureWorkbench.snapshot().states['blue.x']-390)<0.1);
+ partial=await state();
+ check(Math.abs(partial.states['green.x']-150)<0.1&&Math.abs(partial.states['blue.x']-390)<0.1,'the remaining contact keeps moving only its own target');
+ await session.send('Input.dispatchTouchEvent',{type:'touchCancel',touchPoints:[]});
+ partial=await state();
+ check(Math.abs(partial.states['green.x']-150)<0.1&&partial.states['blue.x']===280&&!partial.held.length,'canceling the remaining target preserves the already released position');
+ } finally {await session.send('Emulation.setTouchEmulationEnabled',{enabled:false});await session.detach();}
+ await page.locator('#file-input').setInputFiles(saved);
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
+ await page.screenshot({path:'output/playwright/pure-routed-layout-desktop.png',fullPage:true});
+ await page.setViewportSize({width:390,height:844});
+ check(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'extra persistence control fits a mobile viewport');
+ await page.screenshot({path:'output/playwright/pure-routed-layout-mobile.png',fullPage:true});
+ check(errors.length===0,'no uncaught page errors');
+ const result={checks,errors,saved,expected,source,draft};
+ await page.evaluate(result=>{window.__pureRoutedInputEvidence=result;},result);
+ return {checks:checks.length,errors};
+ } catch(error) {
+ await page.evaluate(result=>{window.__pureRoutedInputEvidence=result;},{failed:String(error),checks,errors});throw error;
+ } finally {page.off('dialog',onDialog);page.off('pageerror',onError);}
+}
+)(page);
+```
+### Page
+- Page URL: http://127.0.0.1:8766/
+### Modal state
+- ["confirm" dialog with message "현재 코드와 배치를 파일의 내용으로 복원할까요? 저장하지 않은 작업은 사라집니다."]: can be handled by dialog-accept or dialog-dismiss
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/browser.json b/prototypes/workbench/results/2026-09-23-pointer-routing/browser.json
new file mode 100644
index 0000000..4e0f11b
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/browser.json
@@ -0,0 +1,113 @@
+{
+ "checks": [
+ "placement save requires an applied definition",
+ "one authored pointer input connects all three view targets",
+ "DOM destinations retain distinct target identities",
+ "last input identifies the released target within its group",
+ "three independently authored targets move and selection directs keyboard adjustment",
+ "placement download is a separate document file",
+ "saving neither applies nor discards the unfinished draft",
+ "reset returns to authored initial positions",
+ "downloaded file restores all states and visible positions",
+ "the draft remains separate from the restored executable definition",
+ "restored work starts stopped with no replayed inputs or clock",
+ "restored selection and bindings remain editable",
+ "a fresh browser page reconstructs the saved work without old runtime state",
+ "invalid saved state cannot partially replace code, draft or scene",
+ "a delayed file read cannot overwrite a newer draft",
+ "a delayed restore cannot overwrite placement changes made while reading",
+ "saving while time advances requests a stopped document",
+ "saving with a held input is refused",
+ "editing the shared rule changes the selected amber target",
+ "the same single rule edit also changes the blue target",
+ "reordering instance declarations preserves every field by qualified name",
+ "rebuilt parameter maps still send selection input to blue after reorder",
+ "simultaneous touches share code but retain independent field bindings",
+ "shared cancel restores each contact to its own start position",
+ "a second touch on the same target cannot steal ownership",
+ "releasing green keeps blue captured within the same input",
+ "the remaining contact keeps moving only its own target",
+ "canceling the remaining target preserves the already released position",
+ "extra persistence control fits a mobile viewport",
+ "no uncaught page errors"
+ ],
+ "errors": [],
+ "saved": "output/playwright/pure-routed-layout-saved.json",
+ "expected": {
+ "states": {
+ "selected": 3,
+ "green.x": 150.00000659728084,
+ "green.y": 169.9999983506798,
+ "green.ox": 9.999986805438311,
+ "green.oy": 10.00000164932024,
+ "green.sx": 80,
+ "green.sy": 120,
+ "green.held": 0,
+ "green.id": 1,
+ "blue.x": 350.00000659728073,
+ "blue.y": 209.9999983506798,
+ "blue.ox": 9.999980208157467,
+ "blue.oy": 10.00000329864045,
+ "blue.sx": 280,
+ "blue.sy": 160,
+ "blue.held": 0,
+ "blue.id": 2,
+ "amber.x": 509.999947221753,
+ "amber.y": 260.0000016493202,
+ "amber.ox": 10.000013194561802,
+ "amber.oy": 9.999998350679817,
+ "amber.sx": 450,
+ "amber.sy": 220,
+ "amber.held": 0,
+ "amber.id": 3
+ },
+ "views": [
+ {
+ "id": "board",
+ "type": "box",
+ "color": "#233a31",
+ "x": 20,
+ "y": 90,
+ "width": 600,
+ "height": 240
+ },
+ {
+ "id": "help",
+ "type": "text",
+ "prefix": "선택한 도형: ",
+ "x": 25,
+ "y": 40,
+ "value": 3
+ },
+ {
+ "id": "green_box",
+ "type": "box",
+ "color": "#c9f08b",
+ "x": 150.00000659728084,
+ "y": 169.9999983506798,
+ "width": 56,
+ "height": 44
+ },
+ {
+ "id": "blue_box",
+ "type": "box",
+ "color": "#74c4d4",
+ "x": 350.00000659728073,
+ "y": 209.9999983506798,
+ "width": 56,
+ "height": 44
+ },
+ {
+ "id": "amber_box",
+ "type": "box",
+ "color": "#dfb184",
+ "x": 509.999947221753,
+ "y": 260.0000016493202,
+ "width": 56,
+ "height": 44
+ }
+ ]
+ },
+ "source": "# 하나의 포인터 입력과 네 반응을 모든 도형이 공유합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag = pointer {\n green_box: green\n blue_box: blue\n amber_box: amber\n}\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag.down use grab(drag.target)\non drag.move use move(drag.target)\non drag.up use drop(drag.target)\non drag.cancel use cancel(drag.target)\n\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n",
+ "draft": "# 하나의 포인터 입력과 네 반응을 모든 도형이 공유합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag = pointer {\n green_box: green\n blue_box: blue\n amber_box: amber\n}\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag.down use grab(drag.target)\non drag.move use move(drag.target)\non drag.up use drop(drag.target)\non drag.cancel use cancel(drag.target)\n\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n\nstate unfinished ="
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/build.json b/prototypes/workbench/results/2026-09-23-pointer-routing/build.json
new file mode 100644
index 0000000..48bb44e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/build.json
@@ -0,0 +1,23 @@
+{
+ "builtUtc": "2026-09-23T07:22:21.403663+00:00",
+ "zigVersion": "0.15.2",
+ "sourceSha256": "56b8db87bcf7e49ab2b2e8d4166ade6ed04c77571fbb69c60633e76376b46976",
+ "wasmSha256": "b494269c1332f71de9725a4c7f0b58c074b669403d5d9dc95ddfeb7b16967c0d",
+ "evaluationSourceSha256": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "wasmFileBytes": 60774,
+ "flags": [
+ "-target",
+ "wasm32-freestanding",
+ "-O",
+ "ReleaseSafe",
+ "-fstrip",
+ "-fno-entry",
+ "-rdynamic",
+ "--export-memory",
+ "--stack",
+ "16384",
+ "--initial-memory=65536",
+ "--max-memory=65536"
+ ],
+ "nativeTests": "1/35 core.test.blank program, no hidden application behavior, and reset...OK\n2/35 core.test.arbitrary input counter and unused events...OK\n3/35 core.test.charge and release behavior exists only in the authored instruction program...OK\n4/35 core.test.forward condition executes once and assignments and handlers are ordered...OK\n5/35 core.test.branch to program end is valid...OK\n6/35 core.test.one event rolls back writes from all handlers on arithmetic failure...OK\n7/35 core.test.nonfinite arithmetic and invalid clamp fail atomically...OK\n8/35 core.test.stack underflow overflow and leftover values reject the event...OK\n9/35 core.test.failed candidates leave active values program revision and diagnostics unchanged...OK\n10/35 core.test.state preservation mapping reads active values at commit and reset uses new initials...OK\n11/35 core.test.capacity boundaries and sticky load errors...OK\n12/35 core.test.invalid declaration values state mappings event ids and handler ranges...OK\n13/35 core.test.backward self out-of-handler branches and stores without handlers are rejected...OK\n14/35 core.test.event inputs and dt enforce deterministic frame contract...OK\n15/35 core.test.binding evaluation is pure on both success and failure...OK\n16/35 core.test.arithmetic comparison logical min max and clamp expression semantics...OK\n17/35 core.test.diagnostic counters wrap without affecting scalar values...OK\n18/35 core.test.memory accounting separates reserved storage used IR and stack capacity...OK\n19/35 core.test.point coordinates are ephemeral event operands and unavailable to pure evaluation...OK\n20/35 core.test.point event ranges reject holes and invalid payloads without committing...OK\n21/35 core.test.point event failure rolls back writes from earlier matching handlers...OK\n22/35 core.test.loading shares storage with records while old events and bindings remain executable...OK\n23/35 core.test.failed and abandoned candidates restore empty reuse storage without changing active work...OK\n24/35 core.test.full pending program survives repeated evaluation configuration record loss and reset...OK\n25/35 core.test.restored definition and values commit together without changing reset initializers...OK\n26/35 core.test.missing duplicate nonfinite or invalid restored candidates leave active work untouched...OK\n27/35 core.test.empty and full restored programs validate all values and discard old display records...OK\n28/35 core.test.shared handler bodies access their own bound state slots in source order...OK\n29/35 core.test.failure in a later shared binding rolls back all earlier bindings...OK\n30/35 core.test.shared bindings reject missing arguments partial overlaps and invalid slot mappings...OK\n31/35 core.test.one routed handler changes only its event target and rejects missing or foreign routes...OK\n32/35 core.test.routes reject invalid mappings and bindings without replacing active work...OK\n33/35 core.test.routed event rollback includes globals and earlier handlers and survives staged route replacement...OK\n34/35 evaluation.test.record loss cannot authorize an old memo; unrelated changes can...OK\n35/35 evaluation.test.budget, collisions, invalid settings, and disabled history are conservative...OK\nAll 35 tests passed.\n"
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/legacy-browser.json b/prototypes/workbench/results/2026-09-23-pointer-routing/legacy-browser.json
new file mode 100644
index 0000000..edfd419
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/legacy-browser.json
@@ -0,0 +1,106 @@
+{
+ "checks": [
+ "placement save requires an applied definition",
+ "three independently authored targets move and selection directs keyboard adjustment",
+ "placement download is a separate document file",
+ "saving neither applies nor discards the unfinished draft",
+ "reset returns to authored initial positions",
+ "downloaded file restores all states and visible positions",
+ "the draft remains separate from the restored executable definition",
+ "restored work starts stopped with no replayed inputs or clock",
+ "restored selection and bindings remain editable",
+ "a fresh browser page reconstructs the saved work without old runtime state",
+ "invalid saved state cannot partially replace code, draft or scene",
+ "a delayed file read cannot overwrite a newer draft",
+ "a delayed restore cannot overwrite placement changes made while reading",
+ "saving while time advances requests a stopped document",
+ "saving with a held input is refused",
+ "editing the shared rule changes the selected amber target",
+ "the same single rule edit also changes the blue target",
+ "reordering instance declarations preserves every field by qualified name",
+ "rebuilt parameter maps still send selection input to blue after reorder",
+ "simultaneous touches share code but retain independent field bindings",
+ "shared cancel restores each contact to its own start position",
+ "extra persistence control fits a mobile viewport",
+ "no uncaught page errors"
+ ],
+ "errors": [],
+ "saved": "output/playwright/pure-routing-legacy-saved.json",
+ "expected": {
+ "states": {
+ "selected": 3,
+ "green.x": 150.00000659728084,
+ "green.y": 169.9999983506798,
+ "green.ox": 9.999986805438311,
+ "green.oy": 10.00000164932024,
+ "green.sx": 80,
+ "green.sy": 120,
+ "green.held": 0,
+ "green.id": 1,
+ "blue.x": 350.00000659728073,
+ "blue.y": 209.9999983506798,
+ "blue.ox": 9.999980208157467,
+ "blue.oy": 10.00000329864045,
+ "blue.sx": 280,
+ "blue.sy": 160,
+ "blue.held": 0,
+ "blue.id": 2,
+ "amber.x": 509.999947221753,
+ "amber.y": 260.0000016493202,
+ "amber.ox": 10.000013194561802,
+ "amber.oy": 9.999998350679817,
+ "amber.sx": 450,
+ "amber.sy": 220,
+ "amber.held": 0,
+ "amber.id": 3
+ },
+ "views": [
+ {
+ "id": "board",
+ "type": "box",
+ "color": "#233a31",
+ "x": 20,
+ "y": 90,
+ "width": 600,
+ "height": 240
+ },
+ {
+ "id": "help",
+ "type": "text",
+ "prefix": "선택한 도형: ",
+ "x": 25,
+ "y": 40,
+ "value": 3
+ },
+ {
+ "id": "green_box",
+ "type": "box",
+ "color": "#c9f08b",
+ "x": 150.00000659728084,
+ "y": 169.9999983506798,
+ "width": 56,
+ "height": 44
+ },
+ {
+ "id": "blue_box",
+ "type": "box",
+ "color": "#74c4d4",
+ "x": 350.00000659728073,
+ "y": 209.9999983506798,
+ "width": 56,
+ "height": 44
+ },
+ {
+ "id": "amber_box",
+ "type": "box",
+ "color": "#dfb184",
+ "x": 509.999947221753,
+ "y": 260.0000016493202,
+ "width": 56,
+ "height": 44
+ }
+ ]
+ },
+ "source": "# 대상별 값은 item에, 공통 반응은 rule에 한 번만 작성합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag_green = pointer(green_box)\ninput drag_blue = pointer(blue_box)\ninput drag_amber = pointer(amber_box)\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag_green.down use grab(green)\non drag_green.move use move(green)\non drag_green.up use drop(green)\non drag_green.cancel use cancel(green)\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non drag_blue.down use grab(blue)\non drag_blue.move use move(blue)\non drag_blue.up use drop(blue)\non drag_blue.cancel use cancel(blue)\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non drag_amber.down use grab(amber)\non drag_amber.move use move(amber)\non drag_amber.up use drop(amber)\non drag_amber.cancel use cancel(amber)\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n",
+ "draft": "# 대상별 값은 item에, 공통 반응은 rule에 한 번만 작성합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag_green = pointer(green_box)\ninput drag_blue = pointer(blue_box)\ninput drag_amber = pointer(amber_box)\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag_green.down use grab(green)\non drag_green.move use move(green)\non drag_green.up use drop(green)\non drag_green.cancel use cancel(green)\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non drag_blue.down use grab(blue)\non drag_blue.move use move(blue)\non drag_blue.up use drop(blue)\non drag_blue.cancel use cancel(blue)\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non drag_amber.down use grab(amber)\non drag_amber.move use move(amber)\non drag_amber.up use drop(amber)\non drag_amber.cancel use cancel(amber)\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n\nstate unfinished ="
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/legacy-result.log b/prototypes/workbench/results/2026-09-23-pointer-routing/legacy-result.log
new file mode 100644
index 0000000..d1ef7bd
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/legacy-result.log
@@ -0,0 +1,12 @@
+### Result
+{"checks":["placement save requires an applied definition","three independently authored targets move and selection directs keyboard adjustment","placement download is a separate document file","saving neither applies nor discards the unfinished draft","reset returns to authored initial positions","downloaded file restores all states and visible positions","the draft remains separate from the restored executable definition","restored work starts stopped with no replayed inputs or clock","restored selection and bindings remain editable","a fresh browser page reconstructs the saved work without old runtime state","invalid saved state cannot partially replace code, draft or scene","a delayed file read cannot overwrite a newer draft","a delayed restore cannot overwrite placement changes made while reading","saving while time advances requests a stopped document","saving with a held input is refused","editing the shared rule changes the selected amber target","the same single rule edit also changes the blue target","reordering instance declarations preserves every field by qualified name","rebuilt parameter maps still send selection input to blue after reorder","simultaneous touches share code but retain independent field bindings","shared cancel restores each contact to its own start position","extra persistence control fits a mobile viewport","no uncaught page errors"],"errors":[],"saved":"output/playwright/pure-routing-legacy-saved.json","expected":{"states":{"selected":3,"green.x":150.00000659728084,"green.y":169.9999983506798,"green.ox":9.999986805438311,"green.oy":10.00000164932024,"green.sx":80,"green.sy":120,"green.held":0,"green.id":1,"blue.x":350.00000659728073,"blue.y":209.9999983506798,"blue.ox":9.999980208157467,"blue.oy":10.00000329864045,"blue.sx":280,"blue.sy":160,"blue.held":0,"blue.id":2,"amber.x":509.999947221753,"amber.y":260.0000016493202,"amber.ox":10.000013194561802,"amber.oy":9.999998350679817,"amber.sx":450,"amber.sy":220,"amber.held":0,"amber.id":3},"views":[{"id":"board","type":"box","color":"#233a31","x":20,"y":90,"width":600,"height":240},{"id":"help","type":"text","prefix":"선택한 도형: ","x":25,"y":40,"value":3},{"id":"green_box","type":"box","color":"#c9f08b","x":150.00000659728084,"y":169.9999983506798,"width":56,"height":44},{"id":"blue_box","type":"box","color":"#74c4d4","x":350.00000659728073,"y":209.9999983506798,"width":56,"height":44},{"id":"amber_box","type":"box","color":"#dfb184","x":509.999947221753,"y":260.0000016493202,"width":56,"height":44}]},"source":"# 대상별 값은 item에, 공통 반응은 rule에 한 번만 작성합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag_green = pointer(green_box)\ninput drag_blue = pointer(blue_box)\ninput drag_amber = pointer(amber_box)\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag_green.down use grab(green)\non drag_green.move use move(green)\non drag_green.up use drop(green)\non drag_green.cancel use cancel(green)\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non drag_blue.down use grab(blue)\non drag_blue.move use move(blue)\non drag_blue.up use drop(blue)\non drag_blue.cancel use cancel(blue)\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non drag_amber.down use grab(amber)\non drag_amber.move use move(amber)\non drag_amber.up use drop(amber)\non drag_amber.cancel use cancel(amber)\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n","draft":"# 대상별 값은 item에, 공통 반응은 rule에 한 번만 작성합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag_green = pointer(green_box)\ninput drag_blue = pointer(blue_box)\ninput drag_amber = pointer(amber_box)\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag_green.down use grab(green)\non drag_green.move use move(green)\non drag_green.up use drop(green)\non drag_green.cancel use cancel(green)\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non drag_blue.down use grab(blue)\non drag_blue.move use move(blue)\non drag_blue.up use drop(blue)\non drag_blue.cancel use cancel(blue)\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non drag_amber.down use grab(amber)\non drag_amber.move use move(amber)\non drag_amber.up use drop(amber)\non drag_amber.cancel use cancel(amber)\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n\nstate unfinished ="}
+### Ran Playwright code
+```js
+await (async page => await page.evaluate(() => window.__pureSharedLayoutEvidence ?? {pending:true}))(page);
+```
+### Page
+- Page URL: http://127.0.0.1:8766/
+- Page Title: Pure · 빈 작업물에서 시작하기
+### Events
+- Downloading file my-layout.pure.json ...
+- Downloaded file my-layout.pure.json to ".playwright-cli/my-layout-pure.json"
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/legacy-run.log b/prototypes/workbench/results/2026-09-23-pointer-routing/legacy-run.log
new file mode 100644
index 0000000..f997728
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/legacy-run.log
@@ -0,0 +1,146 @@
+### Ran Playwright code
+```js
+await (// Functional end-to-end persistence checks. Run with Playwright CLI run-code.
+async page => {
+ const checks=[],errors=[];
+ const check=(ok,label)=>{if(!ok)throw new Error(label);checks.push(label);};
+ const state=()=>page.evaluate(()=>window.pureWorkbench.snapshot());
+ const onDialog=dialog=>dialog.accept(), onError=error=>errors.push(error.message);
+ page.on('dialog',onDialog);page.on('pageerror',onError);
+ const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
+ const client=(x,y)=>page.evaluate(({x,y})=>{const p=new DOMPoint(x,y).matrixTransform(document.querySelector('#scene').getScreenCTM());return{x:p.x,y:p.y};},{x,y});
+ const move=async(x,y)=>{const p=await client(x,y);await page.mouse.move(p.x,p.y);};
+ const drag=async(x,y,nx,ny)=>{await page.locator('#scene').scrollIntoViewIfNeeded();await move(x,y);await page.mouse.down();await move(nx,ny);await page.mouse.up();};
+ const upload=async(text,name='layout.json')=>{
+ await page.locator('#file-input').evaluate((input,{text,name})=>{
+ const transfer=new DataTransfer();transfer.items.add(new File([text],name,{type:'application/json'}));
+ input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
+ },{text,name});
+ };
+ try {
+ await page.reload();
+ await page.setViewportSize({width:1440,height:1000});
+ await page.waitForFunction(()=>window.pureWorkbench?.snapshot().ready);
+ check(await page.locator('#download-layout').isDisabled(),'placement save requires an applied definition');
+ await page.locator('#example').selectOption('shared-layout');await page.locator('#load-example').click();await page.locator('#apply').click();
+ const source=await page.locator('#source').inputValue();
+ await drag(90,130,160,180);await drag(290,170,360,220);await drag(460,230,530,270);
+ await page.keyboard.press('ArrowLeft');
+ let s=await state();
+ check(Math.abs(s.states['green.x']-150)<0.1&&Math.abs(s.states['blue.x']-350)<0.1&&Math.abs(s.states['amber.x']-510)<0.1,'three independently authored targets move and selection directs keyboard adjustment');
+ const expected={states:s.states,views:s.views};
+ const draft=source+'\nstate unfinished =';
+ await page.locator('#source').fill(draft);
+ const pending=page.waitForEvent('download');await page.locator('#download-layout').click();
+ const download=await pending;const saved='output/playwright/pure-routing-legacy-saved.json';await download.saveAs(saved);
+ check(download.suggestedFilename()==='my-layout.pure.json','placement download is a separate document file');
+ check((await state()).source===draft&&(await state()).appliedSource===source,'saving neither applies nor discards the unfinished draft');
+ await page.locator('#reset').click();
+ check((await state()).states['green.x']===80,'reset returns to authored initial positions');
+ await page.locator('#file-input').setInputFiles(saved);
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
+ s=await state();
+ check(same(s.states,expected.states)&&same(s.views,expected.views),'downloaded file restores all states and visible positions');
+ check(s.source===draft&&s.appliedSource===source&&s.dirty,'the draft remains separate from the restored executable definition');
+ check(!s.running&&!s.held.length&&s.tick===0&&s.successfulEvents===0,'restored work starts stopped with no replayed inputs or clock');
+ await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');
+ check(Math.abs((await state()).states['amber.x']-(expected.states['amber.x']+10))<0.1,'restored selection and bindings remain editable');
+ // Use a fresh page/runtime, not just preservation inside the original instance.
+ const fresh=await page.context().newPage();await fresh.goto(page.url());
+ await fresh.waitForFunction(()=>window.pureWorkbench?.snapshot().ready);
+ await fresh.locator('#file-input').setInputFiles(saved);
+ await fresh.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
+ const reopened=await fresh.evaluate(()=>window.pureWorkbench.snapshot());
+ check(same(reopened.states,expected.states)&&same(reopened.views,expected.views)&&reopened.source===draft,'a fresh browser page reconstructs the saved work without old runtime state');
+ await fresh.close();await page.bringToFront();
+
+ const before=await state();
+ await upload(JSON.stringify({format:'pure-workbench',version:2,source:'state changed=99',draft:'replacement',states:[]}));
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('상태 목록'));
+ s=await state();
+ check(same(s.states,before.states)&&s.source===before.source&&s.appliedSource===before.appliedSource&&s.revision===before.revision,'invalid saved state cannot partially replace code, draft or scene');
+ // Delay File.text, then edit before it completes. Restoring must not overwrite the edit.
+ await page.locator('#file-input').evaluate(input=>{
+ const document={format:'pure-workbench',version:2,source:'state other=4',draft:'state other=4',states:[{name:'other',value:55}]};
+ const transfer=new DataTransfer(),file=new File([JSON.stringify(document)],'delayed.json',{type:'application/json'});
+ const text=JSON.stringify(document);file.text=()=>new Promise(resolve=>{window.__finishLayoutRead=()=>resolve(text);});
+ transfer.items.add(file);input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
+ });
+ await page.locator('#source').fill(draft+'\n# edit during file read');
+ await page.evaluate(()=>window.__finishLayoutRead());
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('작업이 바뀌'));
+ s=await state();check(s.source.endsWith('# edit during file read')&&same(s.states,before.states),'a delayed file read cannot overwrite a newer draft');
+ // Repeat with an input event while file content is pending.
+ await page.locator('#file-input').evaluate(input=>{
+ const text=JSON.stringify({format:'pure-workbench',version:2,source:'state other=4',states:[{name:'other',value:55}]});
+ const file=new File([text],'delayed.json',{type:'application/json'}),transfer=new DataTransfer();
+ file.text=()=>new Promise(resolve=>{window.__finishLayoutRead=()=>resolve(text);});
+ transfer.items.add(file);input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
+ });
+ await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');const changed=await state();
+ await page.evaluate(()=>window.__finishLayoutRead());
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('작업이 바뀌'));
+ check(same((await state()).states,changed.states),'a delayed restore cannot overwrite placement changes made while reading');
+ // UI-only run state is deliberately not persisted.
+ await page.locator('#play').click();await page.locator('#download-layout').click();
+ check((await state()).diagnostic.includes('정지한 뒤'),'saving while time advances requests a stopped document');
+ await page.locator('#play').click();
+ // Refuse while a logical input remains held, with no lost source or partial save.
+ await page.locator('#scene').focus();await page.keyboard.down('ArrowRight');
+ await page.locator('#download-layout').click();
+ check((await state()).diagnostic.includes('조작을 끝낸 뒤'),'saving with a held input is refused');
+ await page.keyboard.up('ArrowRight');
+ await page.locator('#file-input').setInputFiles(saved);
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
+ // One source edit affects every bound target, without editing input adapters.
+ const edited=source.replace('t.x = clamp(t.x + 10,','t.x = clamp(t.x + 25,');
+ await page.locator('#source').fill(edited);await page.locator('#apply').click();
+ const beforeEdit=await state();
+ await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');
+ check(Math.abs((await state()).states['amber.x']-(beforeEdit.states['amber.x']+25))<0.1,'editing the shared rule changes the selected amber target');
+ await drag(beforeEdit.states['blue.x']+10,beforeEdit.states['blue.y']+10,beforeEdit.states['blue.x']+10,beforeEdit.states['blue.y']+10);
+ const blueBefore=(await state()).states['blue.x'];await page.keyboard.press('ArrowRight');
+ check(Math.abs((await state()).states['blue.x']-(blueBefore+25))<0.1,'the same single rule edit also changes the blue target');
+ const lines=edited.split('\n'),items=lines.filter(line=>line.startsWith('item ')).reverse();
+ const reordered=lines.map(line=>line.startsWith('item ')?items.shift():line).join('\n');
+ const beforeReorder=await state();await page.locator('#source').fill(reordered);await page.locator('#apply').click();
+ const afterReorder=await state();
+ check(Object.entries(beforeReorder.states).every(([name,value])=>afterReorder.states[name]===value),'reordering instance declarations preserves every field by qualified name');
+ await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');
+ check(Math.abs((await state()).states['blue.x']-(afterReorder.states['blue.x']+25))<0.1,'rebuilt parameter maps still send selection input to blue after reorder');
+ // Two contacts execute the same body with different bound fields.
+ await page.locator('#source').fill(source);await page.locator('#apply').click();await page.locator('#reset').click();
+ await page.locator('#scene').scrollIntoViewIfNeeded();
+ const session=await page.context().newCDPSession(page);
+ try {
+ await session.send('Emulation.setTouchEmulationEnabled',{enabled:true,maxTouchPoints:2});
+ const p1={id:1,...await client(90,130)},p2={id:2,...await client(290,170)};
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1]});
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1,p2]});
+ await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{id:1,...await client(160,180)},{id:2,...await client(360,220)}]});
+ const touch=await state();
+ check(Math.abs(touch.states['green.x']-150)<0.1&&Math.abs(touch.states['blue.x']-350)<0.1&&touch.held.length===2,'simultaneous touches share code but retain independent field bindings');
+ await session.send('Input.dispatchTouchEvent',{type:'touchCancel',touchPoints:[]});
+ const canceled=await state();
+ check(canceled.states['green.x']===80&&canceled.states['blue.x']===280&&!canceled.held.length,'shared cancel restores each contact to its own start position');
+ } finally {await session.send('Emulation.setTouchEmulationEnabled',{enabled:false});await session.detach();}
+ await page.locator('#file-input').setInputFiles(saved);
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
+ await page.screenshot({path:'output/playwright/pure-routing-legacy-desktop.png',fullPage:true});
+ await page.setViewportSize({width:390,height:844});
+ check(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'extra persistence control fits a mobile viewport');
+ await page.screenshot({path:'output/playwright/pure-routing-legacy-mobile.png',fullPage:true});
+ check(errors.length===0,'no uncaught page errors');
+ const result={checks,errors,saved,expected,source,draft};
+ await page.evaluate(result=>{window.__pureSharedLayoutEvidence=result;},result);
+ return {checks:checks.length,errors};
+ } catch(error) {
+ await page.evaluate(result=>{window.__pureSharedLayoutEvidence=result;},{failed:String(error),checks,errors});throw error;
+ } finally {page.off('dialog',onDialog);page.off('pageerror',onError);}
+}
+)(page);
+```
+### Page
+- Page URL: http://127.0.0.1:8766/
+### Modal state
+- ["beforeunload" dialog with message ""]: can be handled by dialog-accept or dialog-dismiss
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/manifest.json b/prototypes/workbench/results/2026-09-23-pointer-routing/manifest.json
new file mode 100644
index 0000000..7673700
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/manifest.json
@@ -0,0 +1,47 @@
+{
+ "README.md": "62f6481f9b60250875595e3004258fec360212215b8d392c88dfe2561e3346c2",
+ "authoring-result.log": "5a8a821aa3cbdaf8ea3061bb3187fbc16a147738f577dca8dd10c98cef59ea98",
+ "authoring-run.log": "08386adebb2e042f9a3a56f32998fb292df07d3f8cef471ac8ee6491027a17c6",
+ "authoring.json": "97711e1382629589de96c163770cf5c162d2c5a3c7c8e0bf1256daf3c2ae6bd0",
+ "before/compiler.mjs": "033227dcee1ecfe79aded2a3b5dd5f28b896377e27671347767ef99e2eb0cf56",
+ "before/core.wasm": "2b1b6d2bbf9005f5c13275d6013cc4c21c155c7474d334c470fcb11094818b52",
+ "before/core.zig": "630aee1cc20b5803657f75e541cb6b706e2b99dd00218ac71afd78b82259621b",
+ "before/runtime.mjs": "d4d8fa382f501e1cdb293a1079e5dcf3cf4e65ccfc860f800b51e32cc098cf51",
+ "browser-initial-failure.json": "1e8d4c0e447640c1b6c521d10f859e8a32bc9bebedce52dfdc30ae961c315be5",
+ "browser-result.log": "537e5bac498d74c447592e2fe36f80c5640aedee54420e37e965a61700518abb",
+ "browser-run.log": "4634dde958e1cb0e86a06272a40ebc03830d711569dfe04c3c2eeb005dd27f3d",
+ "browser.json": "89aa8e088c523b3509b64064a887e478699ccfb88eb3f01cb8637ba476d6cdd6",
+ "build.json": "257ec939e124b7ab56b5f74d35dd84000b1a0600e4694d9c2f0c178ee2e56530",
+ "legacy-browser.json": "a661498c06a876cc384d02cb2bba3b624f47d6b4441a8bc63cd29d3c7a496f48",
+ "legacy-result.log": "2f5610e40f4abbd37e63848c1b8a4a46fff988894cd15a0b35a02417d55390fb",
+ "legacy-run.log": "0f1b6298cb577c958238ab8b095663bed6d24de539f12042e713e35d85b0a030",
+ "node-tests.log": "b99e5cbe8f90f8af63557cd624da1407142be305656d7fcf180c9e74896177b8",
+ "pure-routed-layout-desktop.png": "a8e92dccce424d9888ad7e72bd004b68cb5d09a9bc39bb9c90be96b4b2f71ff7",
+ "pure-routed-layout-mobile.png": "1bec982d9c6a85f80fe07aa4e4d2e9bfe5eec13f5e379c34c6275994520c0c5b",
+ "pure-routed-layout-saved.json": "c229dfac9fb29908842a331c6db7d0247f90d48c7ff355342bf711e9c448661c",
+ "qa-authoring.js": "be0a5fef207cd4fa744228da3cf33d672ca0f69d09b8f522850a8acf1acafe2e",
+ "qa-legacy-pointer.js": "ecea8183330354da9da20eaac8bca6660ab0d43e2d50b240ea0b098c871c73e5",
+ "representation.json": "b307c4aba3ff4c31e94ffb04bf2f08ab8fe0454c4fd34e55b77e72ba3673e9f7",
+ "sources/CONTRACT.md": "58808e2d310bbf1103d42e731e63c26a1f1baf580e3807945bda2eafdae55178",
+ "sources/cli.mjs": "d5be26735d41e1ac023802e21298f55f6dfc39d011ea810aee1c3bc2d17d11f4",
+ "sources/core.zig": "56b8db87bcf7e49ab2b2e8d4166ade6ed04c77571fbb69c60633e76376b46976",
+ "sources/evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "sources/examples/routed-layout.pure": "656e81577c250df704ba744b1ea6ec9c987ab3b8ee90c758062ac4beaf195650",
+ "sources/presentation.test.mjs": "1596733634b31125d81a324ca633c51b90ce848486f7909ecc0619f949ead4d1",
+ "sources/qa-routed-input.js": "9a27e06027ad12f4a205893eca7dce5d82c5b8011da8692beed5757039c66573",
+ "sources/routing.test.mjs": "37f6705a496599778eed306e554147b3d9e408447043f4e75abb7225e548999a",
+ "sources/web/app.js": "1749df4fd0817fa6e503e9474ff5ce59a2a30f5001cf710e12499619ffa86e95",
+ "sources/web/compiler.mjs": "a9d5d1a863519e86c37df24385ae9cf605384bea5e6b4298f6e67177e6a2a811",
+ "sources/web/core.wasm": "b494269c1332f71de9725a4c7f0b58c074b669403d5d9dc95ddfeb7b16967c0d",
+ "sources/web/index.html": "3c312e7b2efcc4f39f0682305e423a5dd10ae77457e988771f7e6e18cba4e1ca",
+ "sources/web/output.mjs": "fed2d41d4027c4b388174bfcb20e852e9443073afb8b508919371b7da95d6f8b",
+ "sources/web/runtime.mjs": "cb22adac57452453fe3add6cd885b5636d49fc946b1cd54e9e8ee502d167e7e2",
+ "sources/web/scene-input.mjs": "88d93c3898ad8ae3236f0ebaede3a51ecb5dd8702fbf7fe9a6ae45f000a40306",
+ "sources/workspace.test.mjs": "053c3883d69a528c244d5298bd67b95642a5d6e8ceceea3d189dce6c9747f6ac",
+ "touch-evidence.json": "0adb9592f691461f9ec830353536b42d86ed6d33c5117917bebee5b231bde1fc",
+ "touch-evidence.log": "e6d3b52b2531334e33032b6705ae1dbe11b35deadeb23c6c8d59421ce3d643a5",
+ "touch-reproduction.js": "445d04eaba070d885aad602ffc7f7e60a4e782e7d12a08efe7142a1c1ae8123e",
+ "touch-reproduction.log": "7612d377829dd98edc96dc114e09b2a7c8bb7da5ff1be0cae96732994bc68a09",
+ "touch-run.log": "6e0082768745024dfee33b613c9f120d2f165a57e76082245a1ed628d33df40c",
+ "verification.json": "0e756fe566d8bbc5fa3c837020ea9000fc1a231606226c669e37af7ea0583b60"
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/node-tests.log b/prototypes/workbench/results/2026-09-23-pointer-routing/node-tests.log
new file mode 100644
index 0000000..fb311d7
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/node-tests.log
@@ -0,0 +1,91 @@
+✔ reference begins with the authored states and matching scene (2.771357ms)
+✔ press, hold, release, and frame order produce exact expected values (0.650246ms)
+✔ charge clamps at one and explicit floor restores rest (1.551179ms)
+✔ repeat and concurrent physical sources produce only logical edges (0.734062ms)
+✔ airborne presses cannot charge or cause another jump (0.371855ms)
+✔ releaseAll emits release and reset clears latches without a jump (0.55832ms)
+✔ unknown inputs and invalid frame counts reject without mutation (1.155825ms)
+✔ terminal starts empty and produces the same charge trace as the shared runtime (202.764904ms)
+✔ invalid terminal commands do not alter state and fail piped execution (71.118981ms)
+✔ terminal replays authored pointer movement and cancellation through the shared runtime (132.226616ms)
+✔ file load preserves by name, rejected load preserves active work, and save never overwrites (76.761233ms)
+✔ terminal exposes arithmetic rollback and retains failure through matching release (89.163691ms)
+✔ terminal configures and discards evaluation records and recovers from a display limit (68.518607ms)
+✔ terminal saves and restores placement independently of reset and preserves a browser draft (213.096866ms)
+✔ invalid restoration leaves terminal code and state unchanged (51.790723ms)
+✔ a blank document is an empty program with no implied behavior (6.111015ms)
+✔ forward references and separate state/input namespaces preserve declaration IDs (3.324695ms)
+✔ ordered assignments, repeated handlers, and condition evaluation once (1.55018ms)
+✔ nested branches use absolute forward targets and skip correct bodies (1.790478ms)
+✔ operator precedence, associativity, unary and builtin function lowering (2.619967ms)
+✔ multiline parentheses, scientific literals, comments, and string escapes (1.105621ms)
+✔ all instruction fields are numeric and carry original source line (0.554227ms)
+✔ contextual declaration words remain valid names outside reserved builtins (0.491449ms)
+✔ unknown and reserved symbols have useful one-based locations (2.928562ms)
+✔ duplicates, unsupported keys, initializers, and invalid syntax are rejected (2.478243ms)
+✔ document, declaration, instruction, and nesting limits reject overflow (9.074281ms)
+✔ every supported input maps to a bounded ordered event ID (0.836026ms)
+✔ optional examples compile and charge behavior is entirely authored (30.732504ms)
+✔ cache reuse, unrelated changes and discarded records preserve authored values (27.598773ms)
+✔ committed changes only: failed events and failed edits preserve reusable output (6.144201ms)
+✔ program edits, state reordering and reset cannot reuse old definitions (6.137257ms)
+✔ finite float semantics include signed zero, high state bit and arithmetic errors (8.245478ms)
+✔ one budget covers the whole scene; failures publish no partial scene or state rollback (3.888533ms)
+✔ small caches, no history and every small work limit give exact results or explicit failure (44.507549ms)
+✔ invalid settings and forget names preserve policy and state (4.394927ms)
+✔ pointer declarations resolve forward targets and reject ambiguous or contextless inputs (18.675568ms)
+✔ authored drag keeps grab offset and commits the displayed position even if release coordinates differ (23.653444ms)
+✔ ownership, duplicate downs, stale moves, cancellation and focus cleanup cannot leave held input (9.043948ms)
+✔ invalid coordinates and modes do not mutate a held gesture; arithmetic failure rolls back one event (6.245165ms)
+✔ independent targets share the event pipeline and publication remains non-reentrant (7.073228ms)
+✔ failing down, up and cancel never prevent terminal ownership cleanup (5.839162ms)
+✔ direct publication matches archived snapshots and work counts through authored changes (86.430631ms)
+✔ whole-scene budget failure never publishes partial bindings and recovers exactly (13.208058ms)
+✔ latched event rollback, nonfinite bindings, reset and signed zero keep their semantics (12.962871ms)
+✔ publication rejects reentrant runtime changes and reads before they affect the scene (7.829326ms)
+✔ invalid or throwing output adapters do not strand the runtime or alter authored state (2.548948ms)
+✔ multiple authored placements and a separate invalid draft survive a fresh runtime restore (33.184081ms)
+✔ malformed files reject before changing active code, values, diagnostics or warm display records (17.820004ms)
+✔ restoration matches names rather than ordering and never reuses stale cached values (6.060256ms)
+✔ finite float values including negative zero round trip without changing authored reset values (2.317002ms)
+✔ held and failed interactions cannot be saved and restoration cannot steal an active input (7.353748ms)
+✔ empty and full documents restore without imposing a new source size or state count (8.226719ms)
+✔ document APIs respect the output callback boundary and export independent values (2.919229ms)
+✔ one input and one handler serve up to 24 static targets without instruction or handler expansion (40.29442ms)
+✔ routes validate forward references, exact types, unique views/items and current input target (4.661207ms)
+✔ grouped layout matches explicit bindings through concurrent capture, partial cancel, keyboard and restore (22.094263ms)
+✔ capture remains bound to its target and releaseAll cancels each target exactly once (3.617846ms)
+✔ reorder and remove/readd rebuild routes by names without persisting physical indices (5.608048ms)
+✔ routed writes invalidate cached displays and failed handlers rollback without losing another target owner (4.362797ms)
+✔ CLI sends grouped pointer events and rejects an unknown target (160.551933ms)
+✔ a route from a different input group cannot be delivered through the named group (3.526419ms)
+✔ binding more objects shares one VM body instead of expanding its instructions (9.978419ms)
+✔ records and rules resolve forward names and reject invalid types fields and bindings (7.630825ms)
+✔ shared layout matches the existing authored layout across independent and simultaneous gestures (35.957197ms)
+✔ field names retain identity across schema and instance reorder, removal and restoration (10.715777ms)
+✔ bindings share one event transaction including global effects and multiple uses of one item (3.94759ms)
+✔ bound writes invalidate affected display caches and failed edits preserve active bindings (5.036266ms)
+✔ generated qualified names cannot make a valid exported document unreadable by its own loader (13.463153ms)
+✔ blank startup and applied empty source contain no application objects or behavior (20.961254ms)
+✔ author can add unrelated states inputs views and behavior entirely through source (9.527507ms)
+✔ keyboard and pointer sources aggregate first down and last up; repeats are ignored (4.848931ms)
+✔ held input rejects apply without changing applied source, metadata, or values (4.100868ms)
+✔ invalid syntax, unknown references and capacity overflow preserve the previous program (4.786707ms)
+✔ state preservation follows names across reorder/add/remove; reset uses new initializers (3.01036ms)
+✔ callers cannot mutate active metadata and corrupt name-based state preservation (1.409808ms)
+✔ conditions execute once, and assignments and multiple handlers follow source order (1.689163ms)
+✔ a failed event rolls back all handlers and keeps its source diagnostic through release (2.460484ms)
+✔ reset clears held inputs and latched errors; invalid frame counts have no effects (2.042277ms)
+✔ render evaluation is pure and invalid binding is distinct from an event rollback (9.750676ms)
+✔ independent adapters replay the same author program without sharing live state (4.353464ms)
+✔ Wasm has no imports and reports fixed memory and exact active IR/state storage (14.504051ms)
+✔ Wasm executes old events and output during staged replacement without corrupting either phase (12.059677ms)
+✔ workspace accounting reports shared payload and safety tag without double-counting (5.505491ms)
+ℹ tests 83
+ℹ suites 0
+ℹ pass 83
+ℹ fail 0
+ℹ cancelled 0
+ℹ skipped 0
+ℹ todo 0
+ℹ duration_ms 1006.837988
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/pure-routed-layout-desktop.png b/prototypes/workbench/results/2026-09-23-pointer-routing/pure-routed-layout-desktop.png
new file mode 100644
index 0000000..85cf74d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/pure-routed-layout-desktop.png
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/pure-routed-layout-mobile.png b/prototypes/workbench/results/2026-09-23-pointer-routing/pure-routed-layout-mobile.png
new file mode 100644
index 0000000..8d24e2a
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/pure-routed-layout-mobile.png
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/pure-routed-layout-saved.json b/prototypes/workbench/results/2026-09-23-pointer-routing/pure-routed-layout-saved.json
new file mode 100644
index 0000000..20d88d4
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/pure-routed-layout-saved.json
@@ -0,0 +1,108 @@
+{
+ "format": "pure-workbench",
+ "version": 2,
+ "source": "# 하나의 포인터 입력과 네 반응을 모든 도형이 공유합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag = pointer {\n green_box: green\n blue_box: blue\n amber_box: amber\n}\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag.down use grab(drag.target)\non drag.move use move(drag.target)\non drag.up use drop(drag.target)\non drag.cancel use cancel(drag.target)\n\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n",
+ "draft": "# 하나의 포인터 입력과 네 반응을 모든 도형이 공유합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag = pointer {\n green_box: green\n blue_box: blue\n amber_box: amber\n}\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag.down use grab(drag.target)\non drag.move use move(drag.target)\non drag.up use drop(drag.target)\non drag.cancel use cancel(drag.target)\n\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n\nstate unfinished =",
+ "states": [
+ {
+ "name": "selected",
+ "value": 3
+ },
+ {
+ "name": "green.x",
+ "value": 150.00000659728084
+ },
+ {
+ "name": "green.y",
+ "value": 169.9999983506798
+ },
+ {
+ "name": "green.ox",
+ "value": 9.999986805438311
+ },
+ {
+ "name": "green.oy",
+ "value": 10.00000164932024
+ },
+ {
+ "name": "green.sx",
+ "value": 80
+ },
+ {
+ "name": "green.sy",
+ "value": 120
+ },
+ {
+ "name": "green.held",
+ "value": 0
+ },
+ {
+ "name": "green.id",
+ "value": 1
+ },
+ {
+ "name": "blue.x",
+ "value": 350.00000659728073
+ },
+ {
+ "name": "blue.y",
+ "value": 209.9999983506798
+ },
+ {
+ "name": "blue.ox",
+ "value": 9.999980208157467
+ },
+ {
+ "name": "blue.oy",
+ "value": 10.00000329864045
+ },
+ {
+ "name": "blue.sx",
+ "value": 280
+ },
+ {
+ "name": "blue.sy",
+ "value": 160
+ },
+ {
+ "name": "blue.held",
+ "value": 0
+ },
+ {
+ "name": "blue.id",
+ "value": 2
+ },
+ {
+ "name": "amber.x",
+ "value": 509.999947221753
+ },
+ {
+ "name": "amber.y",
+ "value": 260.0000016493202
+ },
+ {
+ "name": "amber.ox",
+ "value": 10.000013194561802
+ },
+ {
+ "name": "amber.oy",
+ "value": 9.999998350679817
+ },
+ {
+ "name": "amber.sx",
+ "value": 450
+ },
+ {
+ "name": "amber.sy",
+ "value": 220
+ },
+ {
+ "name": "amber.held",
+ "value": 0
+ },
+ {
+ "name": "amber.id",
+ "value": 3
+ }
+ ]
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/qa-authoring.js b/prototypes/workbench/results/2026-09-23-pointer-routing/qa-authoring.js
new file mode 100644
index 0000000..ba4906f
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/qa-authoring.js
@@ -0,0 +1,228 @@
+// Run this expression with Playwright CLI run-code after opening the local host.
+// All behavior is authored through the editor; snapshot() only observes results.
+async (page) => {
+ const checks = [];
+ const errors = [];
+ const onError = error => errors.push(error.message);
+ const acceptDialog = dialog => dialog.accept();
+ const check = (condition, label) => {
+ if (!condition) throw new Error(`Check ${checks.length + 1} failed: ${label}`);
+ checks.push(label);
+ };
+ const state = () => page.evaluate(() => window.pureWorkbench.snapshot());
+ const apply = async text => { await page.locator('#source').fill(text); await page.locator('#apply').click(); };
+ const authorSource = [
+ '# A new authored interaction, not a bundled example.',
+ 'state x = 90', 'state moving = 0', 'state count = 0',
+ 'input advance = ArrowRight', 'input pulse = Space',
+ 'box marker(x, 170, 48, 48, "#7ed9ae")',
+ 'text reading(40, 70, count, "Signals: ")',
+ 'on advance.down { moving = 1 }', 'on advance.up { moving = 0 }',
+ 'on pulse.down { count = count + 1 }',
+ 'on frame { x = clamp(x + moving * 120 * dt, 0, 580) }', '',
+ ].join('\n');
+ let lastAuthored = null;
+ let mobile = null;
+ let downloaded = null;
+ const shots = ['output/playwright/pure-routing-authoring-blank.png', 'output/playwright/pure-routing-authoring-authored.png', 'output/playwright/pure-routing-authoring-mobile.png'];
+ page.on('pageerror', onError);
+ page.on('dialog', acceptDialog);
+ try {
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.reload();
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ let s = await state();
+ check(s.source === '' && s.appliedSource === '' && !s.dirty, 'initial editor has literally empty source');
+ check(s.views.length === 0 && Object.keys(s.states).length === 0 && s.held.length === 0 && !s.running, 'initial scene has no built-in entities, states, inputs, or running clock');
+ check(await page.locator('#input-controls button').count() === 0 && await page.locator('#play').isDisabled(), 'controls require an authored program');
+ await page.screenshot({ path: shots[0], fullPage: true });
+
+ await page.locator('#source').fill(authorSource);
+ s = await state();
+ check(s.dirty && s.views.length === 0 && s.appliedSource === '', 'authoring a new program is a draft until Apply');
+ await page.locator('#apply').click();
+ s = await state();
+ check(!s.dirty && s.appliedSource === authorSource && s.views.length === 2 && s.states.x === 90 && s.states.count === 0, 'new custom program constructs states and multiple views from source');
+ check(await page.locator('#input-controls button').count() === 2 && await page.locator('[data-input="advance"]').count() === 1 && await page.locator('[data-input="pulse"]').count() === 1, 'input buttons are generated from the authored declarations');
+ check(await page.locator('#scene-content [data-view-id="marker"]').getAttribute('x') === '90', 'view bindings render the authored state value');
+
+ await page.locator('#source').focus();
+ await page.keyboard.press('End');
+ await page.keyboard.press('Space');
+ await page.keyboard.press('ArrowRight');
+ check((await state()).states.count === 0 && (await state()).states.moving === 0, 'Space and arrows in the editor never reach authored inputs');
+ await page.locator('#source').fill(authorSource);
+
+ const pulse = page.locator('[data-input="pulse"]');
+ let box = await pulse.boundingBox();
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ s = await state();
+ check(s.states.count === 1 && s.held.includes('pulse') && !s.running && s.tick === 0, 'pointer down dispatches once while the clock is paused');
+ await page.mouse.up();
+ check((await state()).held.length === 0 && (await state()).states.count === 1, 'pointer up releases without double dispatch from click');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('Space');
+ await page.keyboard.down('Space');
+ await page.keyboard.up('Space');
+ check((await state()).states.count === 2, 'keyboard repeat is ignored');
+ await pulse.focus();
+ await page.keyboard.press('Enter');
+ check((await state()).states.count === 3 && (await state()).held.length === 0, 'keyboard activation on a generated button is one complete tap');
+ await pulse.dispatchEvent('click', { detail: 0 });
+ check((await state()).states.count === 4 && (await state()).held.length === 0, 'assistive click activation is one complete tap');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('Space');
+ box = await pulse.boundingBox();
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ check((await state()).states.count === 5 && (await state()).held.includes('pulse'), 'keyboard and pointer sources share one logical input');
+ await page.mouse.up();
+ check((await state()).held.includes('pulse'), 'releasing one source does not release the other source');
+ await page.keyboard.up('Space');
+ check((await state()).held.length === 0, 'last source releases the logical input');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ await page.locator('#step').click();
+ await page.locator('#step').click();
+ s = await state();
+ check(s.states.x === 94 && s.states.moving === 1 && s.tick === 2, 'authored down/frame behavior uses the declared dt without built-in motion');
+ await page.locator('#source').focus();
+ await page.keyboard.up('ArrowRight');
+ check((await state()).states.moving === 0 && (await state()).held.length === 0, 'keyup releases even after focus moves into the editor');
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ await page.evaluate(() => window.dispatchEvent(new Event('blur')));
+ await page.keyboard.up('ArrowRight');
+ check((await state()).states.moving === 0 && !(await state()).running && (await state()).held.length === 0, 'focus loss sends release and pauses the clock');
+
+ await page.locator('#play').click();
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().tick >= 5);
+ await page.locator('#play').click();
+ const paused = await state();
+ await page.waitForTimeout(100);
+ check(!paused.running && paused.tick === (await state()).tick && paused.tick >= 5, 'Play advances and Pause freezes the clock');
+
+ const beforeFailure = await state();
+ await apply(authorSource + 'state broken =\n');
+ s = await state();
+ check(s.appliedSource === authorSource && s.revision === beforeFailure.revision && JSON.stringify(s.states) === JSON.stringify(beforeFailure.states), 'invalid code preserves the previous executable program and states');
+ check(await page.locator('#error-location').isVisible() && /행/.test(s.diagnostic) && await page.locator('#source').getAttribute('aria-invalid') === 'true', 'compile error exposes a source-line diagnostic');
+ await page.locator('#error-location').click();
+ check(await page.locator('#source').evaluate(el => el.selectionStart >= el.value.indexOf('state broken')), 'diagnostic location moves the editor caret to the problem');
+
+ const expanded = authorSource.replace('state x = 90', 'state x = 500').replace('count = count + 1', 'count = count + 5') + [
+ 'state bonus = 7', 'input reverse = KeyA',
+ 'circle beacon(300, 190, 15 + bonus, "#c9f08b")',
+ 'on reverse.down { bonus = bonus + 2 }', '',
+ ].join('\n');
+ await apply(expanded);
+ s = await state();
+ check(s.states.x === beforeFailure.states.x && s.states.count === 5 && s.states.bonus === 7 && s.views.length === 3, 'apply preserves matching state names and initializes newly authored states');
+ await page.locator('[data-input="reverse"]').click();
+ check((await state()).states.bonus === 9 && await page.locator('#scene-content [data-view-id="beacon"]').getAttribute('r') === '24', 'new input, entity, binding, and behavior work with source changes alone');
+ await page.locator('[data-input="pulse"]').click();
+ check((await state()).states.count === 10, 'changed behavior takes effect without resetting existing values');
+
+ const withDraft = expanded + '# This un-applied draft must survive Reset.\n';
+ await page.locator('#source').fill(withDraft);
+ await page.locator('#reset').click();
+ s = await state();
+ check(s.states.x === 500 && s.states.count === 0 && s.states.bonus === 7 && s.tick === 0 && s.source === withDraft && s.dirty, 'explicit Reset restores active initializers and keeps the draft');
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ const heldRevision = (await state()).revision;
+ await page.locator('#apply').click();
+ check((await state()).revision === heldRevision && /놓은/.test((await state()).diagnostic), 'Apply is refused while an input is held');
+ await page.keyboard.up('ArrowRight');
+
+ const downloadPromise = page.waitForEvent('download');
+ await page.locator('#download-project').click();
+ const projectDownload = await downloadPromise;
+ const downloadPath = 'output/playwright/pure-routing-authoring-draft.json';
+ await projectDownload.saveAs(downloadPath);
+ downloaded = { filename: projectDownload.suggestedFilename(), path: downloadPath, expectedSource: withDraft };
+ await page.locator('#new').click();
+ s = await state();
+ check(s.source === '' && s.views.length === 3 && s.appliedSource === expanded, 'clear editor is explicit and does not clear the running scene before Apply');
+ await page.locator('#file-input').setInputFiles(downloadPath);
+ await page.waitForFunction(expected => window.pureWorkbench.snapshot().source === expected, withDraft);
+ check((await state()).source === withDraft && (await state()).appliedSource === expanded && (await state()).dirty, 'downloaded project restores the exact un-applied draft without execution');
+ await page.locator('#file-input').evaluate(input => {
+ const transfer = new DataTransfer();
+ transfer.items.add(new File(['{"format":"other","version":1,"source":"state no = 1"}'], 'invalid.json', { type: 'application/json' }));
+ input.files = transfer.files;
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ });
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().diagnostic.includes('형식'));
+ check((await state()).source === withDraft && (await state()).appliedSource === expanded, 'invalid JSON project schema cannot replace the draft');
+
+ await apply('state n = 1\ninput fail = Space\ntext label(40, 60, n)\non fail.down { n = n / 0 }\n');
+ await page.locator('[data-input="fail"]').dispatchEvent('click', { detail: 0 });
+ s = await state();
+ check(s.states.n === 1 && s.lastError === 4 && s.held.length === 0 && s.lastEvent.phase === 'up', 'failed assistive down event rolls back and its error survives the following successful up');
+ check(await page.locator('#runtime-error').isVisible() && (await page.locator('#runtime-error').textContent()).includes('마지막 실패'), 'latched runtime failure is visible and labeled separately from the latest input');
+ await page.locator('#reset').click();
+ check((await state()).lastError === 0 && await page.locator('#runtime-error').isHidden(), 'explicit Reset clears the failure record');
+
+ await page.locator('#new').click();
+ await page.locator('#apply').click();
+ s = await state();
+ check(s.source === '' && s.appliedSource === '' && s.views.length === 0 && Object.keys(s.states).length === 0 && await page.locator('#input-controls button').count() === 0, 'applying empty source removes all authored states, inputs, and scene contents');
+ await page.locator('#example').selectOption('counter');
+ await page.locator('#load-example').click();
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().source.includes('state count'));
+ check((await state()).views.length === 0 && (await state()).appliedSource === '' && (await state()).dirty, 'explicit example loading only fills the editor and never auto-applies');
+
+ await apply(authorSource);
+ await page.locator('[data-input="pulse"]').click();
+ await page.locator('[data-input="pulse"]').click();
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ for (let i = 0; i < 12; i++) await page.locator('#step').click();
+ await page.keyboard.up('ArrowRight');
+ lastAuthored = await state();
+ check(lastAuthored.states.x === 114 && lastAuthored.states.count === 2 && lastAuthored.memory.linearBytes === 65536, 'authored scene executes in the actual fixed 64 KiB Wasm memory');
+ await page.locator('.cost-details summary').click();
+ check((await page.locator('#cost-values').textContent()).includes('65,536 B'), 'cost inspector shows measured Wasm memory without claiming browser heap totals');
+ await page.locator('.cost-details summary').click();
+ await page.screenshot({ path: shots[1], fullPage: true });
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ mobile = await page.evaluate(() => ({ viewport: innerWidth, content: document.documentElement.scrollWidth }));
+ check(mobile.content <= mobile.viewport, '390px mobile viewport has no horizontal overflow');
+ await page.locator('[data-input="pulse"]').click();
+ check((await state()).states.count === 3, 'generated mobile input buttons remain usable');
+ await page.screenshot({ path: shots[2], fullPage: true });
+ const comparison = await page.request.get('http://127.0.0.1:8766/baseline.html');
+ const equivalent = await page.request.get('http://127.0.0.1:8766/examples/charge.pure');
+ check(comparison.ok() && equivalent.ok() && await page.locator('a[href="./baseline.html"]').count() === 1, 'ordinary-JavaScript comparison and equivalent Pure example are linked and served');
+
+ await page.route('**/core.wasm', route => route.abort());
+ await page.reload();
+ await page.waitForFunction(() => document.getElementById('ready-status').textContent.includes('실패'));
+ check(await page.locator('#apply').isDisabled() && await page.locator('#play').isDisabled() && await page.locator('#step').isDisabled() && /실행기를 열지/.test(await page.locator('#diagnostic').textContent()), 'missing Wasm shows an actionable load failure and disables execution controls');
+ await page.unroute('**/core.wasm');
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.reload();
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ check((await state()).source === '' && (await state()).views.length === 0, 'fresh load always returns to a blank authoring environment');
+ check(errors.length === 0, 'no uncaught page errors during the browser scenarios');
+ const evidence = { recordedUtc: new Date().toISOString(), browser: await page.evaluate(() => navigator.userAgent),
+ checks, authoredSource: authorSource, authoredSnapshot: lastAuthored, mobile, downloaded, errors, screenshots: shots };
+ // CLI can yield early when native dialogs/download events occur. Retain the
+ // observation report so a follow-up CLI call can retrieve the complete run.
+ await page.evaluate(value => { window.__pureWorkbenchQAEvidence = value; }, evidence);
+ return evidence;
+ } catch (error) {
+ const evidence = { failed: true, error: error.message, stack: error.stack, checks, errors };
+ await page.evaluate(value => { window.__pureWorkbenchQAEvidence = value; }, evidence);
+ return evidence;
+ } finally {
+ page.off('pageerror', onError);
+ page.off('dialog', acceptDialog);
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/qa-legacy-pointer.js b/prototypes/workbench/results/2026-09-23-pointer-routing/qa-legacy-pointer.js
new file mode 100644
index 0000000..5256227
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/qa-legacy-pointer.js
@@ -0,0 +1,138 @@
+// Functional end-to-end persistence checks. Run with Playwright CLI run-code.
+async page => {
+ const checks=[],errors=[];
+ const check=(ok,label)=>{if(!ok)throw new Error(label);checks.push(label);};
+ const state=()=>page.evaluate(()=>window.pureWorkbench.snapshot());
+ const onDialog=dialog=>dialog.accept(), onError=error=>errors.push(error.message);
+ page.on('dialog',onDialog);page.on('pageerror',onError);
+ const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
+ const client=(x,y)=>page.evaluate(({x,y})=>{const p=new DOMPoint(x,y).matrixTransform(document.querySelector('#scene').getScreenCTM());return{x:p.x,y:p.y};},{x,y});
+ const move=async(x,y)=>{const p=await client(x,y);await page.mouse.move(p.x,p.y);};
+ const drag=async(x,y,nx,ny)=>{await page.locator('#scene').scrollIntoViewIfNeeded();await move(x,y);await page.mouse.down();await move(nx,ny);await page.mouse.up();};
+ const upload=async(text,name='layout.json')=>{
+ await page.locator('#file-input').evaluate((input,{text,name})=>{
+ const transfer=new DataTransfer();transfer.items.add(new File([text],name,{type:'application/json'}));
+ input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
+ },{text,name});
+ };
+ try {
+ await page.reload();
+ await page.setViewportSize({width:1440,height:1000});
+ await page.waitForFunction(()=>window.pureWorkbench?.snapshot().ready);
+ check(await page.locator('#download-layout').isDisabled(),'placement save requires an applied definition');
+ await page.locator('#example').selectOption('shared-layout');await page.locator('#load-example').click();await page.locator('#apply').click();
+ const source=await page.locator('#source').inputValue();
+ await drag(90,130,160,180);await drag(290,170,360,220);await drag(460,230,530,270);
+ await page.keyboard.press('ArrowLeft');
+ let s=await state();
+ check(Math.abs(s.states['green.x']-150)<0.1&&Math.abs(s.states['blue.x']-350)<0.1&&Math.abs(s.states['amber.x']-510)<0.1,'three independently authored targets move and selection directs keyboard adjustment');
+ const expected={states:s.states,views:s.views};
+ const draft=source+'\nstate unfinished =';
+ await page.locator('#source').fill(draft);
+ const pending=page.waitForEvent('download');await page.locator('#download-layout').click();
+ const download=await pending;const saved='output/playwright/pure-routing-legacy-saved.json';await download.saveAs(saved);
+ check(download.suggestedFilename()==='my-layout.pure.json','placement download is a separate document file');
+ check((await state()).source===draft&&(await state()).appliedSource===source,'saving neither applies nor discards the unfinished draft');
+ await page.locator('#reset').click();
+ check((await state()).states['green.x']===80,'reset returns to authored initial positions');
+ await page.locator('#file-input').setInputFiles(saved);
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
+ s=await state();
+ check(same(s.states,expected.states)&&same(s.views,expected.views),'downloaded file restores all states and visible positions');
+ check(s.source===draft&&s.appliedSource===source&&s.dirty,'the draft remains separate from the restored executable definition');
+ check(!s.running&&!s.held.length&&s.tick===0&&s.successfulEvents===0,'restored work starts stopped with no replayed inputs or clock');
+ await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');
+ check(Math.abs((await state()).states['amber.x']-(expected.states['amber.x']+10))<0.1,'restored selection and bindings remain editable');
+ // Use a fresh page/runtime, not just preservation inside the original instance.
+ const fresh=await page.context().newPage();await fresh.goto(page.url());
+ await fresh.waitForFunction(()=>window.pureWorkbench?.snapshot().ready);
+ await fresh.locator('#file-input').setInputFiles(saved);
+ await fresh.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
+ const reopened=await fresh.evaluate(()=>window.pureWorkbench.snapshot());
+ check(same(reopened.states,expected.states)&&same(reopened.views,expected.views)&&reopened.source===draft,'a fresh browser page reconstructs the saved work without old runtime state');
+ await fresh.close();await page.bringToFront();
+
+ const before=await state();
+ await upload(JSON.stringify({format:'pure-workbench',version:2,source:'state changed=99',draft:'replacement',states:[]}));
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('상태 목록'));
+ s=await state();
+ check(same(s.states,before.states)&&s.source===before.source&&s.appliedSource===before.appliedSource&&s.revision===before.revision,'invalid saved state cannot partially replace code, draft or scene');
+ // Delay File.text, then edit before it completes. Restoring must not overwrite the edit.
+ await page.locator('#file-input').evaluate(input=>{
+ const document={format:'pure-workbench',version:2,source:'state other=4',draft:'state other=4',states:[{name:'other',value:55}]};
+ const transfer=new DataTransfer(),file=new File([JSON.stringify(document)],'delayed.json',{type:'application/json'});
+ const text=JSON.stringify(document);file.text=()=>new Promise(resolve=>{window.__finishLayoutRead=()=>resolve(text);});
+ transfer.items.add(file);input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
+ });
+ await page.locator('#source').fill(draft+'\n# edit during file read');
+ await page.evaluate(()=>window.__finishLayoutRead());
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('작업이 바뀌'));
+ s=await state();check(s.source.endsWith('# edit during file read')&&same(s.states,before.states),'a delayed file read cannot overwrite a newer draft');
+ // Repeat with an input event while file content is pending.
+ await page.locator('#file-input').evaluate(input=>{
+ const text=JSON.stringify({format:'pure-workbench',version:2,source:'state other=4',states:[{name:'other',value:55}]});
+ const file=new File([text],'delayed.json',{type:'application/json'}),transfer=new DataTransfer();
+ file.text=()=>new Promise(resolve=>{window.__finishLayoutRead=()=>resolve(text);});
+ transfer.items.add(file);input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
+ });
+ await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');const changed=await state();
+ await page.evaluate(()=>window.__finishLayoutRead());
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('작업이 바뀌'));
+ check(same((await state()).states,changed.states),'a delayed restore cannot overwrite placement changes made while reading');
+ // UI-only run state is deliberately not persisted.
+ await page.locator('#play').click();await page.locator('#download-layout').click();
+ check((await state()).diagnostic.includes('정지한 뒤'),'saving while time advances requests a stopped document');
+ await page.locator('#play').click();
+ // Refuse while a logical input remains held, with no lost source or partial save.
+ await page.locator('#scene').focus();await page.keyboard.down('ArrowRight');
+ await page.locator('#download-layout').click();
+ check((await state()).diagnostic.includes('조작을 끝낸 뒤'),'saving with a held input is refused');
+ await page.keyboard.up('ArrowRight');
+ await page.locator('#file-input').setInputFiles(saved);
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
+ // One source edit affects every bound target, without editing input adapters.
+ const edited=source.replace('t.x = clamp(t.x + 10,','t.x = clamp(t.x + 25,');
+ await page.locator('#source').fill(edited);await page.locator('#apply').click();
+ const beforeEdit=await state();
+ await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');
+ check(Math.abs((await state()).states['amber.x']-(beforeEdit.states['amber.x']+25))<0.1,'editing the shared rule changes the selected amber target');
+ await drag(beforeEdit.states['blue.x']+10,beforeEdit.states['blue.y']+10,beforeEdit.states['blue.x']+10,beforeEdit.states['blue.y']+10);
+ const blueBefore=(await state()).states['blue.x'];await page.keyboard.press('ArrowRight');
+ check(Math.abs((await state()).states['blue.x']-(blueBefore+25))<0.1,'the same single rule edit also changes the blue target');
+ const lines=edited.split('\n'),items=lines.filter(line=>line.startsWith('item ')).reverse();
+ const reordered=lines.map(line=>line.startsWith('item ')?items.shift():line).join('\n');
+ const beforeReorder=await state();await page.locator('#source').fill(reordered);await page.locator('#apply').click();
+ const afterReorder=await state();
+ check(Object.entries(beforeReorder.states).every(([name,value])=>afterReorder.states[name]===value),'reordering instance declarations preserves every field by qualified name');
+ await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');
+ check(Math.abs((await state()).states['blue.x']-(afterReorder.states['blue.x']+25))<0.1,'rebuilt parameter maps still send selection input to blue after reorder');
+ // Two contacts execute the same body with different bound fields.
+ await page.locator('#source').fill(source);await page.locator('#apply').click();await page.locator('#reset').click();
+ await page.locator('#scene').scrollIntoViewIfNeeded();
+ const session=await page.context().newCDPSession(page);
+ try {
+ await session.send('Emulation.setTouchEmulationEnabled',{enabled:true,maxTouchPoints:2});
+ const p1={id:1,...await client(90,130)},p2={id:2,...await client(290,170)};
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1]});
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1,p2]});
+ await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{id:1,...await client(160,180)},{id:2,...await client(360,220)}]});
+ const touch=await state();
+ check(Math.abs(touch.states['green.x']-150)<0.1&&Math.abs(touch.states['blue.x']-350)<0.1&&touch.held.length===2,'simultaneous touches share code but retain independent field bindings');
+ await session.send('Input.dispatchTouchEvent',{type:'touchCancel',touchPoints:[]});
+ const canceled=await state();
+ check(canceled.states['green.x']===80&&canceled.states['blue.x']===280&&!canceled.held.length,'shared cancel restores each contact to its own start position');
+ } finally {await session.send('Emulation.setTouchEmulationEnabled',{enabled:false});await session.detach();}
+ await page.locator('#file-input').setInputFiles(saved);
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
+ await page.screenshot({path:'output/playwright/pure-routing-legacy-desktop.png',fullPage:true});
+ await page.setViewportSize({width:390,height:844});
+ check(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'extra persistence control fits a mobile viewport');
+ await page.screenshot({path:'output/playwright/pure-routing-legacy-mobile.png',fullPage:true});
+ check(errors.length===0,'no uncaught page errors');
+ const result={checks,errors,saved,expected,source,draft};
+ await page.evaluate(result=>{window.__pureSharedLayoutEvidence=result;},result);
+ return {checks:checks.length,errors};
+ } catch(error) {
+ await page.evaluate(result=>{window.__pureSharedLayoutEvidence=result;},{failed:String(error),checks,errors});throw error;
+ } finally {page.off('dialog',onDialog);page.off('pageerror',onError);}
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/representation.json b/prototypes/workbench/results/2026-09-23-pointer-routing/representation.json
new file mode 100644
index 0000000..1bdce06
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/representation.json
@@ -0,0 +1,73 @@
+{
+ "purpose": "Same final core, compiled storage accounting; no performance benchmark",
+ "cases": {
+ "shared-layout": {
+ "metrics": {
+ "sourceBytes": 2271,
+ "sourceLines": 76,
+ "states": 25,
+ "inputs": 7,
+ "views": 5,
+ "handlers": 24,
+ "instructions": 117,
+ "maxStack": 3
+ },
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 20064,
+ "programReservedBytes": 9728,
+ "instructionBytes": 16,
+ "handlerBytes": 16,
+ "activeIRBytes": 2256,
+ "activeStateBytes": 200,
+ "stagingReservedBytes": 9992,
+ "evalStackBytes": 512,
+ "pointerTargetBytes": 3,
+ "pointerTargets": 0
+ },
+ "routes": []
+ },
+ "routed-layout": {
+ "metrics": {
+ "sourceBytes": 1963,
+ "sourceLines": 71,
+ "states": 25,
+ "inputs": 5,
+ "views": 5,
+ "handlers": 16,
+ "instructions": 117,
+ "maxStack": 3
+ },
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 20064,
+ "programReservedBytes": 9728,
+ "instructionBytes": 16,
+ "handlerBytes": 16,
+ "activeIRBytes": 2137,
+ "activeStateBytes": 200,
+ "stagingReservedBytes": 9992,
+ "evalStackBytes": 512,
+ "pointerTargetBytes": 3,
+ "pointerTargets": 3
+ },
+ "routes": [
+ {
+ "input": 4,
+ "base": 1,
+ "count": 8
+ },
+ {
+ "input": 4,
+ "base": 9,
+ "count": 8
+ },
+ {
+ "input": 4,
+ "base": 17,
+ "count": 8
+ }
+ ]
+ }
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/sources/CONTRACT.md b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/CONTRACT.md
new file mode 100644
index 0000000..91e9f94
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/CONTRACT.md
@@ -0,0 +1,496 @@
+# Workbench experiment contract
+
+An author starts with empty source. There is no built-in player, physics, jump,
+score, or timer behavior. All behavior is declared by the author. This is a
+bounded scalar interaction language experiment, not a general language claim.
+
+## Source language
+
+```text
+state count = 0
+input tap = Space
+box tile(80, 150, 60, 60, "#356548")
+circle dot(200, 180, 20, "#c78638")
+text label(80, 100, count, "Count: ")
+on tap.down {
+ count = count + 1
+}
+on frame {
+ if count > 20 {
+ count = 0
+ }
+}
+```
+
+Declarations and statements end at a newline or semicolon; `#` comments outside
+strings. ASCII identifiers, finite numeric literals, state initializers must be
+numeric constants (optional unary sign). Input key codes accepted:
+Space, Enter, ArrowLeft/Right/Up/Down, KeyA..KeyZ, Digit0..Digit9. Input names
+must be unique, key codes unique, names cannot shadow `frame`, `dt`, `min`, `max`,
+`clamp`, `event_x`, `event_y`. State and input names use separate namespaces, entity IDs unique.
+
+Expressions: parentheses, state references, dt (seconds on frame, zero on input),
+unary - and !, + - * /, < <= > >= == !=, && ||, min(a,b), max(a,b), clamp(x,lo,hi).
+Logical operators evaluate both sides. Division by zero/non-finite results fail
+the whole event without committing state changes. No loops, recursion or eval.
+An if without else executes its condition once; assignment statements are ordered.
+Multiple handlers for the same event execute in source order within one atomic event.
+`dt` is allowed in handlers, not view bindings. A frame always uses 1/60 seconds.
+
+box: x,y,width,height expressions + literal hex color; circle: x,y,radius + color;
+text: x,y,value expression + optional literal prefix. Values are world units in a
+640x360 view. No implicit clipping/collision/physics. Render expressions are pure.
+Unknown identifiers and invalid syntax include 1-based line+column diagnostics.
+
+Initial scene is empty. Optional examples are explicit insertions, never auto-run.
+Successful apply preserves scalar states by exact name; new names use initializers,
+removed names disappear. Apply/reset are separate. Failed parse/load changes nothing.
+Apply is refused while any input is held; release first. This avoids inventing
+release events across changed mappings. Focus loss sends .up to ordinary buttons
+and .cancel to pointer inputs.
+
+## Compiler interface (web/compiler.mjs, browser and Node)
+
+`compile(source)` returns:
+```
+{
+ states:[{name,initial,line}], inputs:[{name,key,line} | {name,target,line} | {name,type,routes,line}],
+ instructions:[{op,arg,value,line}], handlers:[{event,start,length,line}],
+ views:[{id,type,line,color?,prefix?,bindings:{x:{start,length},...}}],
+ pointerTargets:[{input,base,count}], metrics:{...}
+}
+```
+Bindings: box x/y/width/height; circle x/y/radius; text x/y/value.
+Compiler throws an Error with .line and .column. Empty source is a valid empty program.
+Handlers and binding expressions all occupy instructions (maximum 512 total), but
+only handlers execute on events; bindings are evaluated on demand by VM eval_at.
+Limits: 32 states, 8 inputs, 24 views, 64 handlers, 512 instructions, stack 64,
+source 32768 UTF-8 bytes. State IDs follow declaration order. Input event IDs:
+frame=0; input i down=1+2*i, up=2+2*i, move=17+i, cancel=25+i.
+Move/cancel handlers require a pointer input in source. States may be referenced before declaration;
+symbols must be resolved after gathering top-level declarations. `metrics` can
+record source bytes/lines and instruction/handler/view/state counts.
+
+## VM opcodes
+
+| op | operation |
+|---:|---|
+|0| push constant `value` |
+|1| push state `arg` |
+|2| push dt |
+|3..6| add, subtract, multiply, divide |
+|7| negate |
+|8..13| less, less-equal, greater, greater-equal, equal, not-equal |
+|14..16| AND, OR, NOT (nonzero true) |
+|17..19| min, max, clamp (push x,lo,hi; invalid lo>hi fails) |
+|20| store state `arg` (pop) |
+|21| jump-if-zero to absolute instruction index `arg` (pop), forward only |
+|22,23| push current pointer event x,y; fail without point context |
+|24,25| load/store field at handler parameter_base + arg; handler-bound only |
+
+Rules start with an empty stack and must end with empty stack. Bindings must finish
+with exactly one value and cannot STORE, branch, or read dt/event coordinates. Branch targets may be handler end.
+
+## Zig exported ABI
+
+`reset_all()` clears programs and state. `begin_program(state_count,input_count)`
+starts staging, returns0 success; `put_state(id,initial,old_id)` old_id=-1 means init;
+`put_instruction(op,arg,value)` append; `put_handler(event,start,length)` append;
+`commit_program()` all0 success otherwise error code, atomic; candidate is separate
+from the active program, but shares its workspace with optional evaluation records.
+Preserved IDs read active values only at successful commit. Validate limits,
+indices, finite values and forward branch ranges; runtime stack errors are checked.
+`reset_state()` restores active initializers, resets tick/event diagnostics.
+`dispatch(event,dt)` accepts0..16 and finite dt; event0 dt must equal1/60, input dt0;
+returns0 success, other code failure with no state mutation. Tick increments for
+successful frame; one event runs all matching handlers in source order.
+`dispatch_point(event,x,y)` accepts existing input down/up IDs plus move/cancel IDs
+within input_count, never frame0. Coordinates must be finite; dt is implicitly0.
+The core only knows input count, not host device kinds. The compiler and host enforce
+pointer ownership and device mapping. Calling legacy dispatch on a coordinate-reading
+handler fails with bad_event9. Point operands are ephemeral execution context, not
+extra persistent state slots. Both dispatch paths use the same atomic commit/history.
+The host requires this new export; older Wasm must be rebuilt.
+`eval_at(start,length)` returns f64 or NaN on invalid/pure-expression failure;
+must not mutate state or event diagnostics. `get_state(id)` returns f64 orNaN.
+`info(id)` u32: 0 tick,1 last_error,2 error_pc (0xffffffff none),3 last_executed_ops,
+4 revision,5 state_count,6 instruction_count,7 handler_count,8 input_count,
+9 successful_event_count. `layout(id)` u32: 0 Engine reserved @sizeOf,
+1 Program reserved @sizeOf,2 Instruction @sizeOf,3 Handler @sizeOf,
+4 active used IR bytes (instructions+handlers+pointer targets),5 active state-value bytes,
+6 staging reserved bytes (subset of Engine),7 evaluation stack capacity bytes,8 pointer target entry bytes,9 active pointer target count.
+`error_message` belongs to host map; core error codes documented in core.zig.
+No allocation or imported runtime in Wasm. Fixed64KiB linear memory target with
+16KiB stack; increase only if actual linker evidence requires and document it.
+
+## Host interface (web/runtime.mjs)
+
+`await createRuntime(wasmBytes?, evaluationOptions?)` fetches core.wasm in browser if omitted.
+Returns object with apply(source), reset(), frame(count=1), down(name,source),
+up(name,source), point(name,phase,x,y,source,target?), releaseAll(), snapshot(), exportProject(), currentProgram.
+Multiple sources per input share one logical held state; down emits only first
+source, up only last. Source strings identify keyboard vs onscreen pointer.
+`apply` returns compiled program or throws with diagnostic. It must fully validate
+in host and stage+commit in core. It preserves by name. `reset` keeps active program.
+`snapshot` returns {states:{name:value}, views:[{id,type,x,y,width?,height?,radius?,
+value?,prefix?,color?}], tick,revision,lastError,errorLine,lastOps,memory:{...},held:[]}
+`currentProgram` is the latest successfully applied compiled program or null.
+The program and all nested metadata are frozen so readers cannot corrupt state
+name mappings or binding ranges. `lastFailure` records the last failed event;
+its `lastError`/line remain visible through successful release or focus cleanup
+until successful apply or explicit reset. `lastEvent` still describes the latest
+event. A view evaluation failure uses host error100 and does not roll back events.
+UI draft and applied code are distinct. Browser/CLI share compiler/runtime/wasm;
+instances are separate, not a live collaborative session. No arbitrary JS evaluation.
+
+## Bounded display evaluation (2026-09-22)
+
+The existing event and float semantics above are unchanged. The forgetful C
+prototype's contract is adapted into `evaluation.zig`; its uint64 DAG is not
+used as a substitute for this VM's float instructions.
+
+Each snapshot starts one display budget, shared by all bindings. Default options
+are `{cacheEntries:0, historyEntries:8, workLimit:2048}`. A unit is a binding
+request, a candidate-cache probe, a change-record probe, or an executed VM
+instruction. State extraction, host object creation, event dispatch and DOM work
+are outside this budget. It is not a time limit or a bound on whole-app work.
+For the current compiler limits, 96 bindings * 10 units plus 512 instructions
+is at most 1,472 units; the default admits even an uncached valid scene with
+cache lookup/validation enabled. Malformed bindings may fail sooner.
+
+`configureEvaluation({cacheEntries?,historyEntries?,workLimit?})` accepts integers
+0..32, 0..8, and 0..4294967295 respectively. Missing fields retain prior settings.
+Valid configuration clears reuse records; invalid configuration changes nothing.
+`forgetEvaluation('values'|'history'|'all')` drops optional records without
+changing authored state, event diagnostics or program identity.
+`evaluationStats()` returns the options plus work, instructions, cacheHits for
+the last snapshot, and reservedBytes for the complete fixed evaluation structure.
+Statistics are separate from snapshots so repeated observations remain equivalent.
+
+Memo entries contain the value, instruction range, input dependency bit mask
+and validated revision. Direct mapping uses one candidate probe, with collisions
+causing recomputation. Only successful computations are cached. Constant results
+need no input-change history. All other reuse requires a complete retained
+history since validation and no intersection with changed states. Dependencies
+are collected from actual VM LOADs, not trusted compiler-supplied annotations.
+History loss causes recomputation. All history and memo storage is fixed size;
+setting capacities to zero does not return reserved linear memory.
+
+Only bitwise-different committed states advance change history (including signed
+zero). Failed events and net-zero updates do not. Successful apply and reset
+discard all reuse records. Parse/host validation failure before staging preserves
+records. Beginning core staging discards them, including if that load is later
+rejected or aborted. Epoch wrap also clears records. No inputs or effectful event
+handlers are replayed to rebuild a binding.
+
+Budget exhaustion returns an empty views array for the entire snapshot, with
+displayError `{code:101,line,message}`. Successfully committed event state remains.
+There is no automatic retry or silent return of an old scene. Raising the budget
+allows a fresh attempt. Arithmetic/size errors retain the earlier per-view error100
+behavior. `displayError` is null on display success; a latched execution error
+still takes precedence in lastError/errorMessage, while displayError independently
+reports display failure. Partial successful binding memos may survive a failed
+snapshot but no partial scene is published.
+
+New core exports: `configure_evaluation(cache,history)` returns 0 or capacity1;
+`forget_evaluation(flags)` bits 1=values, 2=history; `begin_evaluation(limit)`;
+`eval_bounded(start,length)` returns f64 or NaN and consumes that shared budget.
+`evaluation_info(id)` returns 0 status (10=work limit), 1 work, 2 instructions,
+3 cache hits, 4 cache capacity, 5 history capacity, 6 reserved bytes. These
+diagnostics do not replace event info. The unbounded pure `eval_at` remains for
+reference checks and is not the host snapshot path.
+
+## Shared load/reuse workspace (2026-09-23)
+
+The pending definition/restore values and evaluation Tables occupy one untagged union. The active
+Program, current state and evaluation control fields remain separate. All core
+calls are single-threaded and synchronous; no callback can reenter a phase change.
+No pointer to the reused tables is retained between calls.
+
+`begin_program` switches the workspace to staging and drops optional records.
+Until commit/abort, old events and bindings still run against active. Binding
+evaluation bypasses reuse and recomputes within its budget; cache configuration,
+forget and reset must not write the staging payload. Old-id state preservation
+reads the current values at successful commit, including events/reset during load.
+
+Successful commit publishes the new Program and then initializes empty Tables in
+the shared workspace. `abort_program()` discards a pending definition and restores
+empty Tables without changing active program/state/event diagnostics. Inactive
+abort is a no-op. A failed commit leaves staging open for explicit abort or the
+next begin. An invalid begin closes any previous pending load. Host apply aborts
+after a core loading failure. Lost reuse may make a previously warm tiny budget
+insufficient; neither a preserved cache nor completion at that budget is promised.
+
+`evaluation_info` additionally reports 7 dedicated control bytes, 8 shared Tables
+bytes, 9 reuse available (false during load), and 10 union safety-tag overhead.
+The host exposes these as dedicatedBytes, sharedBytes, reuseAvailable and
+workspaceTagBytes. reservedBytes is control + Tables capacity, not incremental
+Engine storage. The Tables capacity overlaps stagingReservedBytes and must not
+be added to it. Before document restoration was added, the Wasm control was 48 B, shared Tables
+1,152 B, union overhead 8 B. That Engine was 19,128 B, only 56 B larger than the
+19,072 B pre-evaluation core; linear memory remains 65,536 B. No OS pages are
+returned and this is not a measurement of browser/process memory.
+
+## Direct output publication (2026-09-23)
+
+`present(target)` evaluates the same bindings, in the same order and shared
+budget as `snapshot()`, then calls a synchronous trusted output adapter:
+
+- `begin(program)`: the frozen current definition, or null at startup.
+- `state(index, value)`: each current authored state, without a host state map.
+- `view(index, valid)`: every declared view's publication status.
+- `value(viewIndex, bindingName, value)`: each binding of a valid view only.
+- `end(visibleCount)`: number of valid views.
+
+It returns the snapshot's diagnostics, held inputs, event information and memory
+report, without `states` or `views`. Evaluation finishes before the first adapter
+callback. On budget error101 every view is invalid and no binding values are
+published; committed state values are still published. Error100 invalidates only
+the affected views, as before. No stale value is silently substituted.
+
+Runtime mutations, `snapshot()` and nested `present()` are rejected during
+callbacks. Read-only program/export/statistics access remains available. Adapters
+must be synchronous and must not throw. If one does throw, the exception
+propagates and the runtime unlocks in a finally block; arbitrary external adapter
+effects cannot be rolled back. A later call republishes every value. This is not
+a transaction across arbitrary output devices or an untrusted plugin boundary.
+
+Evaluation uses a private reusable Float64Array(96) and Uint8Array(24), allocated
+on first snapshot/presentation. Their payload totals 792 host bytes, outside the
+64 KiB Wasm memory. Typed-array object overhead, binding-entry arrays built at
+apply, and DOM destination references are additional host memory. The numeric
+buffer is a publication barrier, not another authoritative state or public
+mutable scene. `snapshot()` remains an independent object representation for
+CLI/export/debugging, built only when explicitly requested.
+
+The browser calls `present()` for normal refreshes. Its output adapter binds DOM
+destinations once per successful definition change and compares values with the
+existing DOM before writing. Input refreshes reuse scene, state and cost elements.
+Invalid views remain allocated but are hidden using SVG `display="none"`; recovery
+reveals them. A definition change can replace all scene/state destinations. This
+does not skip unchanged binding evaluation, eliminate all host allocations, or
+provide measured process-memory/paint-latency savings.
+
+## Authored direct manipulation (2026-09-23)
+
+`input drag = pointer(tile)` binds a logical input to a declared view, including a
+forward declaration. One input per target; it shares the existing 8-input limit
+with keyboard inputs. `on drag.down/move/up/cancel` handlers may read `event_x`
+and `event_y` in the same 640x360 world coordinates as output. They are unavailable
+in keyboard/frame handlers and display bindings; copy to authored state to retain
+them. There is no built-in drag constraint, snap, grab offset, or gesture rollback.
+The direct.pure example authors these using ordinary assignments and expressions.
+
+Host `point` requires a pointer input, valid phase, finite coordinates and nonempty
+source string. Each logical input has one owning source; different targets may
+have independent active contacts. Same-owner repeated down is a no-op; another
+owner's down throws. Move/up/cancel without that owner are no-ops. Down establishes
+ownership before dispatch; up/cancel release it before dispatch, even if authored
+arithmetic fails. An event failure rolls back that event's state writes, not the
+whole gesture. `releaseAll` cancels owned pointers at their last delivered position,
+then releases ordinary buttons. `reset` directly clears ownership and resets state.
+Last point events include x,y. A failed cancel can leave authored state unchanged,
+but cannot strand host ownership; the failure remains visible until reset/apply.
+
+Browser hit testing selects the declared target on primary-button pointerdown.
+The stable SVG root captures that pointer, including movement/release outside the
+scene. Client coordinates are transformed through the inverse SVG screen matrix.
+Pointerup exposes its own final coordinates to authored handlers. The direct.pure
+example updates position only on move and ends dragging on up, preserving the
+last displayed position even if a host repeats stale coordinates on release. Termination is observed in the window capture phase, including releases outside
+the SVG. A primary mouseup can finish an owned mouse gesture if pointerup was
+not delivered; it cannot finish a touch gesture. Duplicate termination is ignored.
+Only capture loss of the SVG root ends its owner, not a bubbled descendant event.
+Pointercancel, lostpointercapture and
+focus loss use the last delivered position and dispatch cancel, never fake up.
+Missing/non-invertible coordinate transforms cancel an active gesture. A second
+contact on the same target is ignored; contacts on different targets are independent.
+Touch scenes disable native panning on the SVG. Mouse/touch share this adapter;
+pen-specific pressure, tilt and button meanings are not implemented or validated.
+
+Every delivered pointermove executes synchronously; this layer adds no event queue,
+coalescing, frame gating or newest-only drop policy. This is not a guarantee that
+browsers expose every hardware sample. Keyboard commands and pointer coordinates
+enter the same authored state/commit/output path. CLI `point NAME PHASE X Y` uses
+that host API and core with source `terminal`; it does not replay browser hit testing.
+
+
+## Placement documents (2026-09-23)
+
+Code-only version1 JSON and .pure files keep their earlier behavior. Version2
+stores a restorable authored document, not a suspended execution image:
+
+```json
+{"format":"pure-workbench","version":2,"source":"state x=80","draft":"state x=80",
+ "states":[{"name":"x","value":350}]}
+```
+
+`source` is the applied definition; optional `draft` is separate unapplied editor
+text (default source). Each is at most 32768 UTF-8 bytes. Draft syntax may be
+unfinished. State records must contain exactly one finite value for every declared
+state name, with no extra, missing or duplicate names. Record order is irrelevant.
+JSON numbers preserve finite f64 values, except negative zero which is encoded as
+the exact string `"-0"`. No other numeric string is accepted. Input latches, running
+clock, diagnostics, caches, DOM, compiled IR and external resources are not saved.
+The serialized input file limit is 1449984 bytes, including escaped source/draft
+and up to 32 generated item.field names (which can repeat a long schema name).
+
+`exportDocument(draft?)` returns an independent version2 object. It refuses an
+unapplied runtime, held input or latched execution failure. It reads core state,
+without constructing an output snapshot. `restoreDocument(document)` validates
+and compiles before staging, refuses held input, then commits the definition and
+all saved values together. It returns `{source,draft}`. Failed validation leaves
+active program/values/diagnostics intact. A failed core load is aborted; optional
+reuse records may be lost as with ordinary apply. Successful restore discards old
+reuse, clears diagnostics, resets tick/event count and starts with no held inputs.
+The original source initializers remain intact: explicit reset restores those,
+not saved values. Existing `apply` continues to preserve current values by name.
+Both new document APIs reject reentry during output publication.
+
+Core exports `put_restored_value(id,value)` and `commit_restored_program()` extend
+the existing begin/put/commit loader. Every declared state needs exactly one finite
+restored value; duplicate/out-of-range/nonfinite input poisons staging. Restored
+commit checks completeness and program validity before touching active state.
+Ordinary commit rejects staged restored values. Pending values live alongside the
+pending Program in the same union as optional evaluation Tables; active Program
+layout does not grow. `layout(6)` now includes Pending, rather than Program only.
+Before shared rule bindings, the Wasm Pending was 9656 B (264 B added for 32 values, mask and alignment),
+Engine was 19392 B; linear memory remains fixed at 65536 B. These are build-layout
+facts, not a browser RAM or performance improvement claim. `evaluation_info(10)`
+continues to report only union tag overhead, excluding restoration payload.
+
+Browser **배치 저장** stores applied source, all current values and editor draft.
+It requires a stopped clock. **파일 열기** restores version2 after replacement
+confirmation when existing work is present; it starts stopped. Version1/plain
+source still only replaces the editor draft. Async reading checks draft generation,
+program identity and successful event count so intervening edits/inputs cannot be
+overwritten by a stale read. Invalid files do not alter the working document.
+**.pure 저장 / 코드 JSON 저장** remain draft-only exports.
+
+CLI `save-project PATH` writes version2 with exclusive creation; `restore PATH`
+restores it. Loading a .json path (including at startup) distinguishes versions.
+A browser draft is retained through CLI save-project, but never applied implicitly.
+Existing `save PATH` remains applied-source-only. No filesystem overwrite is added.
+
+
+## Shared rules and named records (2026-09-23)
+
+```text
+record Position { x=0; y=0 }
+item green = Position(x=80,y=120)
+item blue = Position(x=280,y=160)
+rule move(t: Position) { t.x=event_x; t.y=event_y }
+input drag_green = pointer(green_box)
+input drag_blue = pointer(blue_box)
+box green_box(green.x,green.y,56,44,"#c9f08b")
+box blue_box(blue.x,blue.y,56,44,"#74c4d4")
+on drag_green.move use move(green)
+on drag_blue.move use move(blue)
+```
+
+A record defines 1..8 named finite numeric initializers. An item has its own values;
+optional literal field overrides replace defaults. Its fields occupy contiguous
+slots in the existing scalar state array, with public names `item.field`. They
+count toward the same 32-state limit. Schemas and items may be forward-referenced.
+Unknown/duplicate fields, types or items fail compilation. Scalar and item names
+cannot collide; type and rule names have separate namespaces. There are no runtime
+record objects, nested records, record copying, dynamic item creation or deletion.
+
+A rule takes exactly one named record parameter with an exact schema name match.
+Its body uses the existing expressions, ordered assignments and conditional blocks.
+Parameter fields read/write the connected item's current slots; globals and other
+qualified state names remain accessible. The parameter shadows the same qualified
+prefix only inside its body. No rule calls from expressions or rule bodies, recursion,
+return values, stack frames, or implicit event loop are introduced. Frame handlers
+may use rules; coordinate-reading rules may only bind to pointer events.
+Unused rules are name/type checked without emitting a body into the VM program.
+
+`on input.edge use rule(item)` is one event handler. Each used rule's instructions
+are emitted once at its first use. Every use adds handler metadata referencing that
+same exact instruction range, with parameterBase/parameterCount. Bindings run in
+source order, including repeated uses of one item. All matching bindings and
+ordinary handlers participate in the same atomic event: a failure in any rolls
+back every state write in that event. Error PCs refer to the shared source body.
+
+Core `bind_handler(handler_index,base,count)` sets a previously unbound staged
+handler's contiguous state range. Counts are 1..8; the entire range must fit current
+staged state_count. A duplicate binding poisons staging. All owners of shared code
+must have the same nonzero field count and exactly the same range. Partial overlap
+remains invalid. Parameter opcodes must belong to a bound handler and use an index
+below every owner's field count. Pure display evaluation rejects parameter ops;
+view bindings use ordinary resolved state loads and their normal cache dependencies.
+
+Handler metadata grows from 12 to 16 B (base/count plus alignment). There is no
+per-field indirection list or per-event parameter-value copy. At the shared-rules step, Wasm Program
+was 9648 B, Pending 9912 B, Engine 19904 B: 512 B more fixed reservation across active
+and pending handler arrays. The linear-memory limit stays 64 KiB. The required
+bind_handler export makes the host reject older incompatible Wasm.
+
+Apply preserves each qualified field by name. Reordering schema fields or item
+instances rebuilds physical bindings without changing their named values. Removing
+an item removes its fields; adding it again initializes new fields. An active
+input still blocks definition changes. Version2 documents save qualified field
+names, not physical offsets; restoration recompiles all bindings and validates all
+values before commit. This is static named-field identity, not a general lifetime
+handle for dynamically deleted/reused objects. Example `id` and `selected` are
+ordinary author-defined numbers; uniqueness/selection policy is not enforced by
+an implicit object system.
+
+
+## Target-routed pointer groups (2026-09-23)
+
+```text
+input drag = pointer { green_box: green; blue_box: blue }
+on drag.move use move(drag.target)
+```
+
+A group resolves named views to named items of exactly one record type. Forward
+references work. Every view may be connected only once across single-target inputs
+and groups. Every item may occur in only one group route, even across groups;
+this prevents two group captures from writing aliased field ranges. Explicit
+ordinary handlers and statically bound rules may still write other states as authored.
+Groups must be nonempty. There are at most 24 routes in the program, independently
+of the existing eight logical inputs. State/view/instruction limits are unchanged.
+`input.target` is a contextual rule argument for the current group's event only,
+not a scalar expression or persistent object handle. Wrong groups/types are rejected.
+
+A group handler is emitted once per authored `on`, independent of target count.
+It can share its exact body with statically bound handlers. One route consists of
+three bytes: logical input index, field base, field count. The compiler's routes
+also retain source view/item names for the host. The host builds one bounded view
+lookup Map per successful load; target-name lookup does not scan all group targets
+on each event. JS Map/metadata allocations are outside the core memory accounting.
+
+`put_pointer_target(input,base,count)` appends a staged route, with count 1..8 and
+an in-bounds, nonoverlapping state range. Same-input routes must have equal counts.
+`bind_target_handler(handler,count)` binds a previously unbound staged handler to
+the event target, encoded as parameter_base=255. Commit requires a non-frame event
+with matching routes/counts. Existing `bind_handler` accepts only concrete bases.
+`dispatch_target(event,route,x,y)` validates the active route's membership in the
+event's logical input, then supplies its base to the matching handlers. Missing,
+foreign, and out-of-range targets fail before any authored writes. Ordinary dispatch
+and dispatch_point reject inputs that require a route. All matching static, routed,
+and inline handlers still run in source order within one atomic event.
+The VM indexes the route directly; it still scans the bounded handler list to match
+event IDs. No claim of constant-time complete dispatch is made.
+
+Host `point(name,phase,x,y,source,target)` requires a group view name in the final
+argument. Legacy single-target calls keep the old form and reject an extra target.
+Capture/held ownership is `(input,view)`, so two views in a group can be manipulated
+concurrently; a second source cannot steal the same view. Continuations from a
+non-owner are ignored. Browser capture records retain the initial view across
+movement, release outside the view, and cancellation. `releaseAll` cancels every
+owned view once. `held` uses `input:view` for groups and the old input names for
+legacy inputs; lastEvent/lastFailure add `target` for group events.
+
+Route indices live only in the active definition. Apply/restore remain blocked
+while inputs are held. Reset clears owners; load rebuilds routes by source names.
+Documents still store source and named scalar fields, never physical route indices.
+Deletion/recreation at runtime and generation-checked dynamic identity are not added.
+CLI uses `point-target NAME VIEW PHASE X Y`; existing `point` is unchanged.
+
+Each Program adds 80 B (24 three-byte routes, mask/count and alignment). Current
+Program=9728 B, Pending=9992 B, Engine=20064 B, an additional 160 B of fixed engine
+reservation over the shared-rules step. Handler remains 16 B, Instruction 16 B.
+The active IR byte count now includes used routes. Linear memory remains 65536 B.
+The host requires put_pointer_target, bind_target_handler, and dispatch_target.
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/sources/cli.mjs b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/cli.mjs
new file mode 100644
index 0000000..100fa89
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/cli.mjs
@@ -0,0 +1,98 @@
+#!/usr/bin/env node
+import { readFile, writeFile } from 'node:fs/promises';
+import { createInterface } from 'node:readline';
+import { createRuntime } from './web/runtime.mjs';
+import { parseProject } from './web/project.mjs';
+
+const help = `Pure authoring workbench · 같은 정의와 Zig 실행기
+ load PATH .pure 파일을 적용 (같은 이름의 상태 보존)
+ down NAME 정의한 입력 누르기
+ up NAME 정의한 입력 놓기
+ point NAME PHASE X Y 좌표 입력 (down / move / up / cancel)
+ point-target NAME VIEW PHASE X Y 그룹의 대상에 좌표 입력
+ frame [1..600] 시간 진행, 1프레임 = 1/60초
+ show 상태와 도형 출력
+ limits C H W 표시 캐시(0..32)·변경 기록(0..8)·작업량(0..4294967295) 설정
+ forget [PART] 표시 기록 삭제: all, values, history (상태는 유지)
+ costs 마지막 표시 계산의 작업량·명령 수·캐시 적중 출력
+ reset 현재 정의의 초기 상태로
+ save PATH 적용한 정의를 저장
+ save-project PATH 적용 코드와 현재 배치를 JSON으로 저장
+ restore PATH 저장한 배치를 복원 (초안이 있으면 파일에 보존)
+ help / quit
+브라우저와 별도 인스턴스입니다. 임의 JavaScript는 실행하지 않습니다.`;
+
+async function main() {
+ const args = process.argv.slice(2);
+ if (args.includes('--help')) { console.log(help); return; }
+ if (args.length > 1) throw new Error('사용법: node cli.mjs [project.pure]');
+ const runtime = await createRuntime(await readFile(new URL('./web/core.wasm', import.meta.url)));
+ let savedDraft = null;
+ async function loadFile(path, restoreOnly = false) {
+ const content = await readFile(path, 'utf8');
+ if (restoreOnly || path.toLowerCase().endsWith('.json')) {
+ const project = parseProject(content);
+ if (project.version === 2) {
+ savedDraft = runtime.restoreDocument(project).draft;
+ return;
+ }
+ if (restoreOnly) throw new Error('현재 배치가 포함된 version 2 파일이 필요합니다.');
+ runtime.apply(project.source);
+ } else runtime.apply(content);
+ savedDraft = null;
+ }
+ if (args[0]) await loadFile(args[0]); else runtime.apply('');
+ const terminal = createInterface({ input: process.stdin, output: process.stdout, terminal: Boolean(process.stdin.isTTY) });
+ if (process.stdin.isTTY) console.log(help);
+ terminal.setPrompt('pure> ');
+ if (process.stdin.isTTY) terminal.prompt();
+ for await (const line of terminal) {
+ try {
+ const match = /^(\S+)(?:\s+(.+))?$/.exec(line.trim());
+ const name = match?.[1] || 'show';
+ const arg = match?.[2];
+ if (name === 'quit') { terminal.close(); break; }
+ if (name === 'help') console.log(help);
+ else if (name === 'costs' && !arg) console.log(JSON.stringify({ evaluation: runtime.evaluationStats() }));
+ else {
+ if (name === 'load' && arg) await loadFile(arg);
+ else if (name === 'restore' && arg) await loadFile(arg, true);
+ else if (name === 'save-project' && arg) await writeFile(arg, JSON.stringify(runtime.exportDocument(savedDraft ?? undefined), null, 2) + '\n', {flag:'wx'});
+ else if (name === 'save' && arg) await writeFile(arg, runtime.exportProject().source, { flag: 'wx' });
+ else if (name === 'down' && arg) runtime.down(arg, 'terminal');
+ else if (name === 'up' && arg) runtime.up(arg, 'terminal');
+ else if (name === 'point' && arg) {
+ const parts = arg.split(/\s+/);
+ if (parts.length !== 4) throw new Error('point NAME PHASE X Y 형식을 사용하세요.');
+ runtime.point(parts[0], parts[1], Number(parts[2]), Number(parts[3]), 'terminal');
+ } else if (name === 'point-target' && arg) {
+ const parts = arg.split(/\s+/);
+ if (parts.length !== 5) throw new Error('point-target NAME VIEW PHASE X Y 형식을 사용하세요.');
+ runtime.point(parts[0], parts[2], Number(parts[3]), Number(parts[4]), 'terminal', parts[1]);
+ } else if (name === 'frame') {
+ if (arg !== undefined && !/^\d+$/.test(arg)) throw new Error('frame 1..600 정수를 입력하세요.');
+ const count = arg === undefined ? 1 : Number(arg);
+ if (count < 1 || count > 600) throw new Error('frame 1..600 정수를 입력하세요.');
+ runtime.frame(count);
+ } else if (name === 'reset' && !arg) runtime.reset();
+ else if (name === 'limits' && arg) {
+ const parts = arg.split(/\s+/);
+ if (parts.length !== 3 || parts.some(part => !/^\d+$/.test(part)))
+ throw new Error('limits C H W 정수 세 개를 입력하세요.');
+ const [cacheEntries, historyEntries, workLimit] = parts.map(Number);
+ runtime.configureEvaluation({ cacheEntries, historyEntries, workLimit });
+ } else if (name === 'forget') runtime.forgetEvaluation(arg || 'all');
+ else if (name !== 'show' || arg) throw new Error('명령 형식을 확인하세요. help로 안내를 볼 수 있습니다.');
+ const result = runtime.snapshot();
+ console.log(JSON.stringify(result));
+ if (result.lastError && !process.stdin.isTTY) process.exitCode = 1;
+ }
+ } catch (error) {
+ console.error(`${error.line ? `${error.line}:${error.column || 1} ` : ''}${error.message}`);
+ if (!process.stdin.isTTY) process.exitCode = 1;
+ }
+ if (process.stdin.isTTY) terminal.prompt();
+ }
+}
+
+main().catch((error) => { console.error(error.message); process.exitCode = 1; });
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/sources/core.zig b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/core.zig
new file mode 100644
index 0000000..a68f483
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/core.zig
@@ -0,0 +1,1398 @@
+//! Bounded scalar interaction VM experiment. No application behavior is built in.
+//! Same source for native tests and freestanding Wasm; no allocation or imports.
+//! Error ABI: 0 ok, 1 capacity, 2 invalid, 3 stack, 4 division by zero,
+//! 5 non-finite, 6 bad branch, 7 bad state, 8 bad frame, 9 bad event.
+//! Counters wrap modulo 2^32. Failed loads preserve the active program, authored
+//! state and event diagnostics; beginning a load discards optional reuse records.
+//! Failed events preserve values and counters but
+//! report their error and attempted instruction count. Binding evaluation is pure.
+
+const std = @import("std");
+const Evaluation = @import("evaluation.zig").Evaluation;
+const Tables = @import("evaluation.zig").Tables;
+
+const max_states = 32;
+const max_inputs = 8;
+const max_instructions = 512;
+const max_handlers = 64;
+const max_stack = 64;
+const max_pointer_targets = 24;
+const event_target = 255;
+const no_pc = std.math.maxInt(u32);
+const frame_dt: f64 = 1.0 / 60.0;
+
+const Code = enum(u32) {
+ ok = 0,
+ capacity = 1,
+ invalid = 2,
+ stack = 3,
+ division_by_zero = 4,
+ non_finite = 5,
+ bad_branch = 6,
+ bad_state = 7,
+ bad_frame = 8,
+ bad_event = 9,
+ work_limit = 10,
+};
+
+const Instruction = struct { value: f64 = 0, op: u32 = 0, arg: u32 = 0 };
+const Handler = struct {
+ event: u32 = 0, start: u32 = 0, length: u32 = 0,
+ parameter_base: u8 = 0, parameter_count: u8 = 0,
+};
+const PointerTarget = struct { input: u8, base: u8, count: u8 };
+const Program = struct {
+ pointer_targets: [max_pointer_targets]PointerTarget = undefined,
+ pointer_target_count: u8 = 0,
+ routed_inputs: u8 = 0,
+ instructions: [max_instructions]Instruction = @splat(.{}),
+ handlers: [max_handlers]Handler = @splat(.{}),
+ initials: [max_states]f64 = @splat(0),
+ old_ids: [max_states]i32 = @splat(-1),
+ declared: [max_states]bool = @splat(false),
+ state_count: u32 = 0,
+ input_count: u32 = 0,
+ instruction_count: u32 = 0,
+ handler_count: u32 = 0,
+};
+// Loading a definition/optional restored values and result reuse are mutually exclusive phases. Current execution
+// always reads active, and can recompute bindings while this workspace loads.
+const Pending = struct {
+ program: Program = .{},
+ values: [max_states]f64 = @splat(0),
+ value_mask: u32 = 0,
+};
+const Workspace = union { staging: Pending, reuse: Tables };
+const Engine = struct {
+ active: Program = .{},
+ workspace: Workspace = .{ .reuse = .{} },
+ values: [max_states]f64 = @splat(0),
+ staging_open: bool = false,
+ staging_error: Code = .ok,
+ tick: u32 = 0,
+ last_error: Code = .ok,
+ error_pc: u32 = no_pc,
+ last_ops: u32 = 0,
+ revision: u32 = 0,
+ successful_events: u32 = 0,
+ evaluation: Evaluation = .{},
+};
+var engine: Engine = .{};
+
+fn reuseTables() ?*Tables {
+ return if (engine.staging_open) null else &engine.workspace.reuse;
+}
+
+fn finishStaging() void {
+ engine.workspace = .{ .reuse = .{} };
+ engine.staging_open = false;
+ engine.evaluation.forget(null, 3);
+}
+
+fn public(code: Code) u32 {
+ return @intFromEnum(code);
+}
+
+fn stagingFail(code: Code) u32 {
+ if (engine.staging_error == .ok) engine.staging_error = code;
+ return public(code);
+}
+
+export fn reset_all() void {
+ engine = .{};
+}
+
+export fn begin_program(state_count: u32, input_count: u32) u32 {
+ engine.staging_error = .ok;
+ if (state_count > max_states or input_count > max_inputs) {
+ if (engine.staging_open) finishStaging();
+ return stagingFail(.capacity);
+ }
+ engine.workspace = .{ .staging = .{ .program = .{ .state_count = state_count, .input_count = input_count } } };
+ engine.staging_open = true;
+ engine.evaluation.forget(null, 3);
+ return 0;
+}
+
+export fn abort_program() void {
+ if (engine.staging_open) finishStaging();
+}
+
+export fn put_state(id: u32, initial: f64, old_id: i32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ if (id >= engine.workspace.staging.program.state_count or old_id < -1) return stagingFail(.bad_state);
+ if (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count) return stagingFail(.bad_state);
+ if (engine.workspace.staging.program.declared[id]) return stagingFail(.bad_state);
+ if (!std.math.isFinite(initial)) return stagingFail(.non_finite);
+ engine.workspace.staging.program.initials[id] = initial;
+ engine.workspace.staging.program.old_ids[id] = old_id;
+ engine.workspace.staging.program.declared[id] = true;
+ return 0;
+}
+
+export fn put_instruction(op: u32, arg: u32, value: f64) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (p.instruction_count == max_instructions) return stagingFail(.capacity);
+ if (op > 25) return stagingFail(.invalid);
+ if (!std.math.isFinite(value)) return stagingFail(.non_finite);
+ p.instructions[p.instruction_count] = .{ .op = op, .arg = arg, .value = value };
+ p.instruction_count += 1;
+ return 0;
+}
+
+fn validEvent(event: u32, inputs: u32) bool {
+ return event <= inputs * 2 or
+ (event >= 17 and event - 17 < inputs) or
+ (event >= 25 and event - 25 < inputs);
+}
+
+export fn put_handler(event: u32, start: u32, length: u32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (p.handler_count == max_handlers) return stagingFail(.capacity);
+ if (!validEvent(event, p.input_count)) return stagingFail(.bad_event);
+ // Handlers may be appended before their instructions, so range validation
+ // is deferred until commit. Arithmetic remains guarded there.
+ p.handlers[p.handler_count] = .{ .event = event, .start = start, .length = length };
+ p.handler_count += 1;
+ return 0;
+}
+
+export fn bind_handler(handler: u32, base: u32, count: u32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (handler >= p.handler_count or base >= p.state_count) return stagingFail(.bad_state);
+ if (count == 0 or count > 8 or count > p.state_count - base) return stagingFail(.bad_state);
+ const h = &p.handlers[handler];
+ if (h.parameter_count != 0) return stagingFail(.invalid);
+ h.parameter_base = @intCast(base);
+ h.parameter_count = @intCast(count);
+ return 0;
+}
+
+// A route is a static program-local index, never a persistent object identity.
+export fn put_pointer_target(input: u32, base: u32, count: u32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (p.pointer_target_count == max_pointer_targets) return stagingFail(.capacity);
+ if (input >= p.input_count) return stagingFail(.bad_event);
+ if (base >= p.state_count or count == 0 or count > 8 or count > p.state_count - base) return stagingFail(.bad_state);
+ for (p.pointer_targets[0..p.pointer_target_count]) |t| {
+ if (base < @as(u32, t.base) + t.count and t.base < base + count) return stagingFail(.invalid);
+ if (t.input == input and t.count != count) return stagingFail(.bad_state);
+ }
+ p.pointer_targets[p.pointer_target_count] = .{ .input = @intCast(input), .base = @intCast(base), .count = @intCast(count) };
+ p.pointer_target_count += 1;
+ p.routed_inputs |= @as(u8, 1) << @as(u3, @intCast(input));
+ return 0;
+}
+
+export fn bind_target_handler(handler: u32, count: u32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (handler >= p.handler_count or count == 0 or count > 8) return stagingFail(.bad_state);
+ const h = &p.handlers[handler];
+ if (h.parameter_count != 0) return stagingFail(.invalid);
+ h.parameter_base = event_target;
+ h.parameter_count = @intCast(count);
+ return 0;
+}
+
+fn eventInput(event: u32) u32 {
+ return if (event >= 25) event - 25 else if (event >= 17) event - 17 else (event - 1) / 2;
+}
+
+fn validate(p: *const Program) Code {
+ for (0..p.state_count) |id| {
+ if (!p.declared[id]) return .bad_state;
+ if (!std.math.isFinite(p.initials[id])) return .non_finite;
+ const old_id = p.old_ids[id];
+ if (old_id < -1 or (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count)) return .bad_state;
+ }
+ for (p.handlers[0..p.handler_count], 0..) |h, i| {
+ if (!validEvent(h.event, p.input_count)) return .bad_event;
+ if (h.start > p.instruction_count or h.length > p.instruction_count - h.start) return .invalid;
+ if (h.parameter_base == event_target) {
+ if (h.event == 0 or h.parameter_count == 0 or h.parameter_count > 8) return .bad_event;
+ var found = false;
+ for (p.pointer_targets[0..p.pointer_target_count]) |t| {
+ if (t.input != eventInput(h.event)) continue;
+ if (t.count != h.parameter_count) return .bad_state;
+ found = true;
+ }
+ if (!found) return .bad_event;
+ } else if (h.parameter_count > 8 or @as(u32, h.parameter_base) + h.parameter_count > p.state_count) return .bad_state;
+ if (h.length == 0) continue;
+ for (p.handlers[0..i]) |other| {
+ if (other.length != 0 and h.start < other.start + other.length and other.start < h.start + h.length) {
+ // Bound rules may share exactly one body, never a partial range.
+ if (h.parameter_count == 0 or h.parameter_count != other.parameter_count or
+ h.start != other.start or h.length != other.length) return .invalid;
+ }
+ }
+ }
+ for (p.instructions[0..p.instruction_count], 0..) |inst, pc| {
+ if (inst.op > 25) return .invalid;
+ if (!std.math.isFinite(inst.value)) return .non_finite;
+ if ((inst.op == 1 or inst.op == 20) and inst.arg >= p.state_count) return .bad_state;
+ if (inst.op >= 20) {
+ var owner: ?Handler = null;
+ for (p.handlers[0..p.handler_count]) |h| {
+ if (pc >= h.start and pc < h.start + h.length) {
+ owner = h;
+ if (inst.op == 21 and (inst.arg <= pc or inst.arg > h.start + h.length)) return .bad_branch;
+ if ((inst.op == 24 or inst.op == 25) and inst.arg >= h.parameter_count) return .bad_state;
+ }
+ }
+ if (owner == null) return .invalid;
+ }
+ }
+ return .ok;
+}
+
+export fn put_restored_value(id: u32, value: f64) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const pending = &engine.workspace.staging;
+ if (id >= pending.program.state_count) return stagingFail(.bad_state);
+ const bit = @as(u32, 1) << @as(u5, @intCast(id));
+ if (pending.value_mask & bit != 0) return stagingFail(.bad_state);
+ if (!std.math.isFinite(value)) return stagingFail(.non_finite);
+ pending.values[id] = value;
+ pending.value_mask |= bit;
+ return 0;
+}
+
+export fn commit_program() u32 {
+ return commit(false);
+}
+
+export fn commit_restored_program() u32 {
+ return commit(true);
+}
+
+fn commit(restoring: bool) u32 {
+ if (!engine.staging_open) return public(.invalid);
+ if (engine.staging_error != .ok) return public(engine.staging_error);
+ const count = engine.workspace.staging.program.state_count;
+ const expected: u32 = if (count == 32) 0xffffffff else (@as(u32, 1) << @as(u5, @intCast(count))) - 1;
+ if (engine.workspace.staging.value_mask != (if (restoring) expected else @as(u32, 0)))
+ return stagingFail(.bad_state);
+ const code = validate(&engine.workspace.staging.program);
+ if (code != .ok) return stagingFail(code);
+ var next: [max_states]f64 = @splat(0);
+ for (0..engine.workspace.staging.program.state_count) |i| {
+ const old_id = engine.workspace.staging.program.old_ids[i];
+ next[i] = if (restoring) engine.workspace.staging.values[i] else if (old_id < 0) engine.workspace.staging.program.initials[i] else engine.values[@intCast(old_id)];
+ }
+ engine.active = engine.workspace.staging.program;
+ engine.values = next;
+ finishStaging();
+ if (restoring) {
+ engine.tick = 0;
+ engine.successful_events = 0;
+ }
+ engine.revision +%= 1;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ return 0;
+}
+
+export fn reset_state() void {
+ engine.values = engine.active.initials;
+ engine.evaluation.forget(reuseTables(), 3);
+ engine.tick = 0;
+ engine.successful_events = 0;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+}
+
+const Execution = struct {
+ code: Code = .ok,
+ pc: u32 = no_pc,
+ ops: u32 = 0,
+ value: f64 = 0,
+ dependencies: u32 = 0,
+};
+
+fn executionFail(result: Execution, code: Code, pc: u32) Execution {
+ var failed = result;
+ failed.code = code;
+ failed.pc = pc;
+ return failed;
+}
+
+const EventContext = struct { dt: f64 = 0, x: f64 = 0, y: f64 = 0, parameter_base: u32 = 0, parameter_count: u32 = 0, point: bool = false, target: ?u32 = null };
+
+fn execute(values: *[max_states]f64, start: u32, length: u32, context: EventContext, pure: bool, budget: ?*Evaluation) Execution {
+ const p = &engine.active;
+ var result: Execution = .{};
+ if (start > p.instruction_count or length > p.instruction_count - start) return executionFail(result, .invalid, no_pc);
+ const end = start + length;
+ var stack: [max_stack]f64 = undefined;
+ var sp: usize = 0;
+ var pc = start;
+ while (pc < end) {
+ if (budget) |b| {
+ if (!b.spend()) return executionFail(result, .work_limit, pc);
+ b.instructions += 1;
+ }
+ const inst = p.instructions[pc];
+ result.ops += 1;
+ if (pure and (inst.op == 2 or inst.op >= 20)) return executionFail(result, .invalid, pc);
+ const need: usize = switch (inst.op) {
+ 0...2, 22, 23, 24 => 0,
+ 7, 16, 20, 21, 25 => 1,
+ 3...6, 8...15, 17, 18 => 2,
+ 19 => 3,
+ else => return executionFail(result, .invalid, pc),
+ };
+ if (sp < need) return executionFail(result, .stack, pc);
+ if (inst.op <= 2 or inst.op == 22 or inst.op == 23 or inst.op == 24) {
+ if (sp == max_stack) return executionFail(result, .stack, pc);
+ const value = switch (inst.op) {
+ 0 => inst.value,
+ 1 => blk: {
+ if (inst.arg >= p.state_count) return executionFail(result, .bad_state, pc);
+ if (budget != null and budget.?.cache_count != 0)
+ result.dependencies |= @as(u32, 1) << @as(u5, @intCast(inst.arg));
+ break :blk values[inst.arg];
+ },
+ 24 => blk: {
+ if (inst.arg >= context.parameter_count) return executionFail(result, .bad_state, pc);
+ const state = context.parameter_base + inst.arg;
+ if (state >= p.state_count) return executionFail(result, .bad_state, pc);
+ break :blk values[state];
+ },
+ 2 => context.dt,
+ 22, 23 => if (!context.point) return executionFail(result, .bad_event, pc) else if (inst.op == 22) context.x else context.y,
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ stack[sp] = value;
+ sp += 1;
+ } else if (inst.op == 20 or inst.op == 25) {
+ if (inst.op == 25 and inst.arg >= context.parameter_count) return executionFail(result, .bad_state, pc);
+ const state = if (inst.op == 25) context.parameter_base + inst.arg else inst.arg;
+ if (state >= p.state_count) return executionFail(result, .bad_state, pc);
+ sp -= 1;
+ values[state] = stack[sp];
+ } else if (inst.op == 21) {
+ if (inst.arg <= pc or inst.arg > end) return executionFail(result, .bad_branch, pc);
+ sp -= 1;
+ if (stack[sp] == 0) {
+ pc = inst.arg;
+ continue;
+ }
+ } else {
+ const a = stack[sp - need];
+ const b = if (need >= 2) stack[sp - need + 1] else 0;
+ const value: f64 = switch (inst.op) {
+ 3 => a + b,
+ 4 => a - b,
+ 5 => a * b,
+ 6 => blk: {
+ if (b == 0) return executionFail(result, .division_by_zero, pc);
+ break :blk a / b;
+ },
+ 7 => -a,
+ 8 => @floatFromInt(@intFromBool(a < b)),
+ 9 => @floatFromInt(@intFromBool(a <= b)),
+ 10 => @floatFromInt(@intFromBool(a > b)),
+ 11 => @floatFromInt(@intFromBool(a >= b)),
+ 12 => @floatFromInt(@intFromBool(a == b)),
+ 13 => @floatFromInt(@intFromBool(a != b)),
+ 14 => @floatFromInt(@intFromBool(a != 0 and b != 0)),
+ 15 => @floatFromInt(@intFromBool(a != 0 or b != 0)),
+ 16 => @floatFromInt(@intFromBool(a == 0)),
+ 17 => @min(a, b),
+ 18 => @max(a, b),
+ 19 => blk: {
+ const hi = stack[sp - 1];
+ if (b > hi) return executionFail(result, .invalid, pc);
+ break :blk @max(b, @min(a, hi));
+ },
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ sp = sp - need + 1;
+ stack[sp - 1] = value;
+ }
+ pc += 1;
+ }
+ if (sp != @as(usize, if (pure) 1 else 0)) return executionFail(result, .stack, end);
+ if (pure) result.value = stack[0];
+ return result;
+}
+
+export fn dispatch(event: u32, dt: f64) u32 {
+ return dispatchContext(event, .{ .dt = dt });
+}
+
+export fn dispatch_point(event: u32, x: f64, y: f64) u32 {
+ return dispatchContext(event, .{ .x = x, .y = y, .point = true });
+}
+
+export fn dispatch_target(event: u32, target: u32, x: f64, y: f64) u32 {
+ return dispatchContext(event, .{ .x = x, .y = y, .point = true, .target = target });
+}
+
+fn dispatchContext(event: u32, context: EventContext) u32 {
+ const dt = context.dt;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ if (!validEvent(event, engine.active.input_count) or
+ (context.point and event == 0) or (!context.point and event > engine.active.input_count * 2)) {
+ engine.last_error = .bad_event;
+ return public(.bad_event);
+ }
+ if (!std.math.isFinite(dt) or !std.math.isFinite(context.x) or !std.math.isFinite(context.y)) {
+ engine.last_error = .non_finite;
+ return public(.non_finite);
+ }
+ if ((event == 0 and dt != frame_dt) or (event != 0 and dt != 0)) {
+ engine.last_error = .bad_frame;
+ return public(.bad_frame);
+ }
+ var route: ?PointerTarget = null;
+ const routed = event != 0 and engine.active.routed_inputs & (@as(u8, 1) << @as(u3, @intCast(eventInput(event)))) != 0;
+ if (context.target) |target| {
+ if (!routed or target >= engine.active.pointer_target_count or
+ engine.active.pointer_targets[target].input != eventInput(event)) {
+ engine.last_error = .bad_event;
+ return public(.bad_event);
+ }
+ route = engine.active.pointer_targets[target];
+ } else if (routed) {
+ engine.last_error = .bad_event;
+ return public(.bad_event);
+ }
+ var pending = engine.values;
+ for (engine.active.handlers[0..engine.active.handler_count]) |h| {
+ if (h.event != event) continue;
+ var bound = context;
+ bound.parameter_base = if (h.parameter_base == event_target) route.?.base else h.parameter_base;
+ bound.parameter_count = h.parameter_count;
+ const result = execute(&pending, h.start, h.length, bound, false, null);
+ engine.last_ops += result.ops;
+ if (result.code != .ok) {
+ engine.last_error = result.code;
+ engine.error_pc = result.pc;
+ return public(result.code);
+ }
+ }
+ if (engine.evaluation.cache_count != 0 and !engine.staging_open) {
+ var changed: u32 = 0;
+ for (0..engine.active.state_count) |i| {
+ // Signed zero is observable, so compare representation, not ==.
+ if (@as(u64, @bitCast(pending[i])) != @as(u64, @bitCast(engine.values[i])))
+ changed |= @as(u32, 1) << @as(u5, @intCast(i));
+ }
+ engine.evaluation.changed(reuseTables(), changed);
+ }
+ engine.values = pending;
+ if (event == 0) engine.tick +%= 1;
+ engine.successful_events +%= 1;
+ return 0;
+}
+
+export fn eval_at(start: u32, length: u32) f64 {
+ // A local copy makes the no-side-effects boundary structural as well as
+ // checked by the pure instruction filter. Diagnostics are never touched.
+ var values = engine.values;
+ const result = execute(&values, start, length, .{}, true, null);
+ return if (result.code == .ok) result.value else std.math.nan(f64);
+}
+
+export fn configure_evaluation(cache: u32, history: u32) u32 {
+ return if (engine.evaluation.configure(reuseTables(), cache, history)) 0 else public(.capacity);
+}
+
+export fn forget_evaluation(flags: u32) void {
+ engine.evaluation.forget(reuseTables(), flags);
+}
+
+export fn begin_evaluation(limit: u32) void {
+ engine.evaluation.begin(limit);
+}
+
+export fn eval_bounded(start: u32, length: u32) f64 {
+ const e = &engine.evaluation;
+ e.status = 0;
+ // Charge per request as well as per instruction and validation probe.
+ if (!e.spend()) return std.math.nan(f64);
+ if (e.lookup(reuseTables(), start, length)) |value| return value;
+ if (e.status != 0) return std.math.nan(f64);
+ var values = engine.values;
+ const result = execute(&values, start, length, .{}, true, e);
+ e.status = public(result.code);
+ if (result.code != .ok) return std.math.nan(f64);
+ e.remember(reuseTables(), start, length, result.value, result.dependencies);
+ return result.value;
+}
+
+export fn evaluation_info(id: u32) u32 {
+ const e = &engine.evaluation;
+ return switch (id) {
+ 0 => e.status,
+ 1 => e.work,
+ 2 => e.instructions,
+ 3 => e.hits,
+ 4 => e.cache_count,
+ 5 => e.history_count,
+ 6 => @sizeOf(Evaluation) + @sizeOf(Tables),
+ 7 => @sizeOf(Evaluation),
+ 8 => @sizeOf(Tables),
+ 9 => @intFromBool(!engine.staging_open),
+ 10 => @sizeOf(Workspace) - @sizeOf(Pending),
+ else => 0,
+ };
+}
+
+export fn get_state(id: u32) f64 {
+ return if (id < engine.active.state_count) engine.values[id] else std.math.nan(f64);
+}
+
+export fn info(id: u32) u32 {
+ return switch (id) {
+ 0 => engine.tick,
+ 1 => public(engine.last_error),
+ 2 => engine.error_pc,
+ 3 => engine.last_ops,
+ 4 => engine.revision,
+ 5 => engine.active.state_count,
+ 6 => engine.active.instruction_count,
+ 7 => engine.active.handler_count,
+ 8 => engine.active.input_count,
+ 9 => engine.successful_events,
+ else => 0,
+ };
+}
+
+export fn layout(id: u32) u32 {
+ return switch (id) {
+ 0 => @sizeOf(Engine),
+ 1 => @sizeOf(Program),
+ 2 => @sizeOf(Instruction),
+ 3 => @sizeOf(Handler),
+ 4 => engine.active.instruction_count * @sizeOf(Instruction) + engine.active.handler_count * @sizeOf(Handler) + @as(u32, engine.active.pointer_target_count) * @sizeOf(PointerTarget),
+ 5 => engine.active.state_count * @sizeOf(f64),
+ 6 => @sizeOf(Pending),
+ 7 => max_stack * @sizeOf(f64),
+ 8 => @sizeOf(PointerTarget),
+ 9 => engine.active.pointer_target_count,
+ else => 0,
+ };
+}
+
+// Test helpers describe programs directly; the product compiler is separately
+// tested against this ABI. These tests exercise semantics and failure boundaries.
+fn ok(code: u32) !void {
+ try std.testing.expectEqual(@as(u32, 0), code);
+}
+
+fn emit(op: u32, arg: u32, value: f64) !void {
+ try ok(put_instruction(op, arg, value));
+}
+
+fn counterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try ok(commit_program());
+}
+
+test "blank program, no hidden application behavior, and reset" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 1), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(3));
+ try std.testing.expectEqual(@as(u32, 0), info(5));
+ try std.testing.expect(std.math.isNan(get_state(0)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 0)));
+ reset_state();
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 1), info(4));
+}
+
+test "arbitrary input counter and unused events" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(dispatch(2, 0));
+ try ok(dispatch(0, frame_dt));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(3));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(6));
+}
+
+test "charge and release behavior exists only in the authored instruction program" {
+ reset_all();
+ try ok(begin_program(3, 1)); // held, charge, released_value
+ for (0..3) |i| try ok(put_state(@intCast(i), 0, -1));
+ try emit(0, 0, 1);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 2));
+ try emit(1, 1, 0);
+ try emit(1, 0, 0);
+ try emit(2, 0, 0);
+ try emit(5, 0, 0);
+ try emit(3, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(0, 2, 6));
+ try emit(1, 1, 0);
+ try emit(20, 2, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(2, 8, 6));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ try ok(dispatch(1, 0));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try ok(dispatch(2, 0));
+ try std.testing.expectApproxEqAbs(@as(f64, 0.5), get_state(2), 1e-12);
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+}
+
+test "forward condition executes once and assignments and handlers are ordered" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 1, -1));
+ try ok(put_state(1, 0, -1));
+ try emit(1, 0, 0);
+ try emit(21, 6, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 7);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 0, 6));
+ try emit(1, 1, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 6, 4));
+ try ok(commit_program());
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 14), get_state(1));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 28), get_state(1));
+ try std.testing.expectEqual(@as(u32, 6), info(3));
+}
+
+test "branch to program end is valid" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, 2, 0);
+ try ok(put_handler(0, 0, 2));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+}
+
+test "one event rolls back writes from all handlers on arithmetic failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 8, -1));
+ try emit(0, 0, 99);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 2));
+ try emit(0, 0, 1);
+ try emit(0, 0, 0);
+ try emit(6, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 2, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 8), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(2));
+ try std.testing.expectEqual(@as(u32, 5), info(3));
+}
+
+test "nonfinite arithmetic and invalid clamp fail atomically" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, -1));
+ try emit(0, 0, 1e308);
+ try emit(0, 0, 1e308);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, 0));
+ try emit(0, 0, 1);
+ try emit(0, 0, 10);
+ try emit(0, 0, 2);
+ try emit(19, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 5));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.invalid), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+}
+
+test "stack underflow overflow and leftover values reject the event" {
+ for (0..3) |mode| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ if (mode == 0) {
+ try emit(3, 0, 0);
+ } else {
+ for (0..@as(usize, if (mode == 1) 65 else 1)) |_| try emit(0, 0, 7);
+ }
+ try ok(put_handler(0, 0, engine.workspace.staging.program.instruction_count));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.stack), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ }
+}
+
+test "failed candidates leave active values program revision and diagnostics unchanged" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ const old_revision = info(4);
+ const old_ops = info(3);
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(1, 5, 0);
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try std.testing.expectEqual(old_revision, info(4));
+ try std.testing.expectEqual(old_ops, info(3));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.invalid), put_instruction(100, 0, 0));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+}
+
+test "state preservation mapping reads active values at commit and reset uses new initials" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(2, 0));
+ try ok(put_state(0, 42, -1)); // new symbol first
+ try ok(put_state(1, 100, 0)); // old symbol moves from id 0 to 1
+ try ok(dispatch(1, 0)); // changed after staging began
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 42), get_state(0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 100), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(4));
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try std.testing.expect(std.math.isNan(get_state(0)));
+}
+
+test "capacity boundaries and sticky load errors" {
+ reset_all();
+ try std.testing.expectEqual(public(.capacity), begin_program(33, 0));
+ try std.testing.expectEqual(public(.capacity), begin_program(0, 9));
+ try ok(begin_program(32, 8));
+ for (0..32) |i| try ok(put_state(@intCast(i), @floatFromInt(i), -1));
+ for (0..512) |_| try emit(0, 0, 0);
+ for (0..64) |_| try ok(put_handler(16, 0, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 512), info(6));
+ try std.testing.expectEqual(@as(u32, 64), info(7));
+ try std.testing.expectEqual(@as(f64, 31), get_state(31));
+ try ok(begin_program(0, 0));
+ for (0..512) |_| try emit(0, 0, 0);
+ try std.testing.expectEqual(public(.capacity), put_instruction(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+ try ok(begin_program(0, 0));
+ for (0..64) |_| try ok(put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+}
+
+test "invalid declaration values state mappings event ids and handler ranges" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 0, 0));
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.non_finite), put_state(0, std.math.inf(f64), -1));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 1, -1));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.non_finite), put_instruction(0, 0, std.math.nan(f64)));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.bad_event), put_handler(1, 0, 0));
+ try ok(begin_program(0, 0));
+ try ok(put_handler(0, no_pc, 5));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 1);
+ try ok(put_handler(0, 0, 1));
+ try ok(put_handler(0, 0, 1));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "backward self out-of-handler branches and stores without handlers are rejected" {
+ for ([_]u32{ 0, 1, 3 }) |target| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, target, 0);
+ try emit(0, 0, 7);
+ try ok(put_handler(0, 0, 2));
+ try std.testing.expectEqual(public(.bad_branch), commit_program());
+ }
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try emit(0, 0, 5);
+ try emit(20, 0, 0);
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "event inputs and dt enforce deterministic frame contract" {
+ reset_all();
+ try counterProgram();
+ try std.testing.expectEqual(public(.bad_event), dispatch(3, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch(no_pc, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(1, frame_dt));
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, std.math.nan(f64)));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(u32, 0), info(1));
+ try std.testing.expectEqual(no_pc, info(2));
+}
+
+test "binding evaluation is pure on both success and failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 4, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try emit(2, 0, 0);
+ try emit(0, 0, 0);
+ try emit(21, 7, 0);
+ try ok(put_handler(0, 3, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ const before = engine;
+ try std.testing.expectEqual(@as(f64, 12), eval_at(0, 3));
+ try std.testing.expect(std.math.isNan(eval_at(0, 4))); // attempted store
+ try std.testing.expect(std.math.isNan(eval_at(4, 1))); // dt forbidden in views
+ try std.testing.expect(std.math.isNan(eval_at(5, 2))); // branch forbidden
+ try std.testing.expect(std.math.isNan(eval_at(no_pc, 7)));
+ try std.testing.expect(std.math.isNan(eval_at(0, no_pc)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 2))); // two results
+ // Compare only the live member of the untagged workspace, never padding
+ // or the inactive member's indeterminate storage.
+ try std.testing.expectEqual(before.staging_open, engine.staging_open);
+ inline for (std.meta.fields(Engine)) |field| {
+ if (comptime std.mem.eql(u8, field.name, "workspace")) {
+ if (before.staging_open) {
+ try std.testing.expectEqualDeep(before.workspace.staging, engine.workspace.staging);
+ } else {
+ try std.testing.expectEqualDeep(before.workspace.reuse, engine.workspace.reuse);
+ }
+ } else try std.testing.expectEqualDeep(@field(before, field.name), @field(engine, field.name));
+ }
+}
+
+test "arithmetic comparison logical min max and clamp expression semantics" {
+ const cases = [_]struct { op: u32, a: f64, b: f64, want: f64 }{
+ .{ .op = 3, .a = 7, .b = 2, .want = 9 },
+ .{ .op = 4, .a = 7, .b = 2, .want = 5 },
+ .{ .op = 5, .a = 7, .b = 2, .want = 14 },
+ .{ .op = 6, .a = 7, .b = 2, .want = 3.5 },
+ .{ .op = 8, .a = 2, .b = 7, .want = 1 },
+ .{ .op = 9, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 10, .a = 2, .b = 7, .want = 0 },
+ .{ .op = 11, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 12, .a = 7, .b = 2, .want = 0 },
+ .{ .op = 13, .a = 7, .b = 2, .want = 1 },
+ .{ .op = 14, .a = -7, .b = 2, .want = 1 },
+ .{ .op = 15, .a = 0, .b = 2, .want = 1 },
+ .{ .op = 17, .a = 7, .b = 2, .want = 2 },
+ .{ .op = 18, .a = 7, .b = 2, .want = 7 },
+ };
+ for (cases) |case| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, case.a);
+ try emit(0, 0, case.b);
+ try emit(case.op, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(case.want, eval_at(0, 3));
+ }
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, -8);
+ try emit(7, 0, 0);
+ try emit(0, 0, 0);
+ try emit(0, 0, 5);
+ try emit(19, 0, 0);
+ try emit(16, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 5), eval_at(0, 5));
+ try std.testing.expectEqual(@as(f64, 0), eval_at(0, 6));
+}
+
+test "diagnostic counters wrap without affecting scalar values" {
+ reset_all();
+ try counterProgram();
+ engine.tick = no_pc;
+ engine.successful_events = no_pc;
+ engine.revision = no_pc;
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 99, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 0), info(4));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+}
+
+test "memory accounting separates reserved storage used IR and stack capacity" {
+ reset_all();
+ try std.testing.expectEqual(@as(u32, 0), layout(4));
+ try std.testing.expectEqual(@as(u32, 0), layout(5));
+ try counterProgram();
+ try std.testing.expectEqual(@as(u32, @sizeOf(Engine)), layout(0));
+ try std.testing.expectEqual(@as(u32, 16), layout(2));
+ try std.testing.expectEqual(@as(u32, 16), layout(3));
+ try std.testing.expectEqual(@as(u32, 80), layout(4));
+ try std.testing.expectEqual(@as(u32, 8), layout(5));
+ try std.testing.expectEqual(@as(u32, @sizeOf(Pending)), layout(6));
+ try std.testing.expect(layout(6) >= layout(1) + max_states * @sizeOf(f64));
+ try std.testing.expect(layout(0) >= layout(1) * 2);
+ try std.testing.expectEqual(@as(u32, 512), layout(7));
+ try std.testing.expect(layout(0) + 16 * 1024 < 64 * 1024);
+}
+
+fn displayCounterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try emit(1, 0, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try ok(configure_evaluation(32, 8));
+}
+
+fn boundedEquals(want: f64, start: u32, length: u32) !void {
+ begin_evaluation(100);
+ try std.testing.expectEqual(want, eval_bounded(start, length));
+ try ok(evaluation_info(0));
+}
+
+test "point coordinates are ephemeral event operands and unavailable to pure evaluation" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 0, -1));
+ try ok(put_state(1, 0, -1));
+ const events = [_]u32{ 1, 17, 2, 25 };
+ for (events, 0..) |event, i| {
+ try emit(22, 0, 0);
+ try emit(20, 0, 0);
+ try emit(23, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(event, @intCast(i * 4), 4));
+ }
+ try ok(commit_program());
+ for (events, 0..) |event, i| {
+ const x: f64 = @floatFromInt(i + 10);
+ try ok(dispatch_point(event, x, -x));
+ try std.testing.expectEqual(x, get_state(0));
+ try std.testing.expectEqual(-x, get_state(1));
+ }
+ try std.testing.expectEqual(public(.bad_event), dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 13), get_state(0));
+ const diagnostics = [_]u32{ info(1), info(2), info(3), info(9) };
+ try std.testing.expect(std.math.isNan(eval_at(0, 1)));
+ try std.testing.expectEqualSlices(u32, &diagnostics, &.{ info(1), info(2), info(3), info(9) });
+ try std.testing.expectEqual(@as(u32, 4), info(9));
+}
+
+test "point event ranges reject holes and invalid payloads without committing" {
+ reset_all();
+ try counterProgram();
+ for ([_]u32{ 0, 3, 16, 18, 24, 26, 32, 33, no_pc }) |event|
+ try std.testing.expectEqual(public(.bad_event), dispatch_point(event, 1, 2));
+ try std.testing.expectEqual(public(.non_finite), dispatch_point(1, std.math.nan(f64), 0));
+ try std.testing.expectEqual(public(.non_finite), dispatch_point(1, 0, std.math.inf(f64)));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try ok(begin_program(0, 8));
+ try ok(put_handler(24, 0, 0));
+ try ok(put_handler(32, 0, 0));
+ try ok(commit_program());
+ try ok(dispatch_point(24, 1, 2));
+ try ok(dispatch_point(32, 1, 2));
+ try std.testing.expectEqual(public(.bad_event), dispatch(24, 0));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+}
+
+test "point event failure rolls back writes from earlier matching handlers" {
+ reset_all();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 7, -1));
+ try emit(22, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(17, 0, 2));
+ try emit(23, 0, 0);
+ try emit(0, 0, 0);
+ try emit(6, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(17, 2, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch_point(17, 400, 200));
+ try std.testing.expectEqual(@as(f64, 7), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(2));
+}
+
+test "loading shares storage with records while old events and bindings remain executable" {
+ reset_all();
+ try displayCounterProgram();
+ try boundedEquals(20, 4, 3);
+ try boundedEquals(20, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(9));
+ try emit(1, 0, 0);
+ try ok(dispatch(1, 0)); // Active state advances after staging begins.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(3));
+ try ok(configure_evaluation(32, 1));
+ forget_evaluation(3); // Must not overwrite the pending program.
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(33, 0, 3);
+ try boundedEquals(33, 0, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+}
+
+test "failed and abandoned candidates restore empty reuse storage without changing active work" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(20, 0, 0); // Store without a handler fails validation.
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try boundedEquals(20, 4, 3);
+ try ok(dispatch(1, 0));
+ abort_program();
+ try boundedEquals(22, 4, 3);
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ abort_program(); // Inactive abort is a no-op, including for warmed records.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try std.testing.expectEqual(public(.capacity), begin_program(max_states + 1, 0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+}
+
+test "full pending program survives repeated evaluation configuration record loss and reset" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ for (0..max_instructions) |i| {
+ try emit(0, 0, @floatFromInt(i));
+ try ok(configure_evaluation(32, @intCast(i % 9)));
+ forget_evaluation(3);
+ try boundedEquals(22, 4, 3);
+ }
+ reset_state(); // The pending definition and old-id mapping survive reset.
+ try boundedEquals(20, 4, 3);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, max_instructions), info(6));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try boundedEquals(511, 511, 1);
+ // Safe Zig builds may reserve a hidden union tag in addition to the payload.
+ try std.testing.expect(@sizeOf(Workspace) >= @sizeOf(Pending));
+ try std.testing.expect(@sizeOf(Workspace) <= @sizeOf(Pending) + @alignOf(Pending));
+ try std.testing.expectEqual(@as(u32, @sizeOf(Tables)), evaluation_info(8));
+}
+
+test "restored definition and values commit together without changing reset initializers" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(dispatch(0, frame_dt));
+ const revision = info(4);
+ try ok(begin_program(2, 0));
+ try ok(put_state(0, 100, -1));
+ try ok(put_state(1, 200, -1));
+ try ok(put_restored_value(1, -0.0));
+ try ok(put_restored_value(0, 37));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try ok(dispatch(1, 0)); // Old execution remains independent of pending values.
+ try ok(commit_restored_program());
+ try std.testing.expectEqual(@as(f64, 37), get_state(0));
+ try std.testing.expectEqual(@as(u64, @bitCast(@as(f64, -0.0))), @as(u64, @bitCast(get_state(1))));
+ try std.testing.expectEqual(revision + 1, info(4));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 100), get_state(0));
+ try std.testing.expectEqual(@as(f64, 200), get_state(1));
+}
+
+test "missing duplicate nonfinite or invalid restored candidates leave active work untouched" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ const revision = info(4);
+ for (0..4) |mode| {
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 999, -1));
+ if (mode == 1) {
+ try ok(put_restored_value(0, 30));
+ try std.testing.expectEqual(public(.bad_state), put_restored_value(0, 40));
+ } else if (mode == 2) {
+ try std.testing.expectEqual(public(.non_finite), put_restored_value(0, std.math.inf(f64)));
+ } else if (mode == 3) {
+ try ok(put_restored_value(0, 30));
+ try emit(20, 0, 0); // Invalid store outside a handler.
+ }
+ try std.testing.expect(commit_restored_program() != 0);
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try std.testing.expectEqual(revision, info(4));
+ try std.testing.expectEqual(@as(u32, 1), info(9));
+ abort_program();
+ }
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 100, -1));
+ try ok(put_restored_value(0, 42));
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ abort_program();
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+}
+
+test "empty and full restored programs validate all values and discard old display records" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(configure_evaluation(32, 8));
+ try boundedEquals(20, 4, 3);
+ try ok(begin_program(32, 0));
+ for (0..32) |i| {
+ try ok(put_state(@intCast(i), 0, -1));
+ try ok(put_restored_value(@intCast(i), @floatFromInt(i + 1)));
+ }
+ try emit(1, 31, 0);
+ try ok(commit_restored_program());
+ try boundedEquals(32, 0, 1);
+ try ok(begin_program(0, 0));
+ try ok(commit_restored_program());
+ try std.testing.expectEqual(@as(u32, 0), info(5));
+ try std.testing.expect(std.math.isNan(get_state(0)));
+}
+
+test "shared handler bodies access their own bound state slots in source order" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 10, -1));
+ try ok(put_state(1, 20, -1));
+ try emit(24, 0, 0); try emit(0, 0, 1); try emit(3, 0, 0); try emit(25, 0, 0);
+ try ok(put_handler(1, 0, 4)); try ok(bind_handler(0, 0, 1));
+ try ok(put_handler(1, 0, 4)); try ok(bind_handler(1, 1, 1));
+ try ok(put_handler(1, 0, 4)); try ok(bind_handler(2, 0, 1));
+ try ok(commit_program());
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try std.testing.expectEqual(@as(f64, 21), get_state(1));
+ try std.testing.expectEqual(@as(u32, 4), info(6)); // Three bindings, one body.
+ try std.testing.expectEqual(@as(u32, 12), info(3));
+ const diagnostics = [_]u32{ info(1), info(2), info(3), info(9) };
+ try std.testing.expect(std.math.isNan(eval_at(0, 1)));
+ try std.testing.expectEqualSlices(u32, &diagnostics, &.{ info(1), info(2), info(3), info(9) });
+}
+
+test "failure in a later shared binding rolls back all earlier bindings" {
+ reset_all();
+ try ok(begin_program(4, 1));
+ for ([_]f64{ 8, 2, 12, 0 }, 0..) |value, i| try ok(put_state(@intCast(i), value, -1));
+ try emit(24, 0, 0); try emit(24, 1, 0); try emit(6, 0, 0); try emit(25, 0, 0);
+ for (0..2) |i| {
+ try ok(put_handler(1, 0, 4));
+ try ok(bind_handler(@intCast(i), @intCast(i * 2), 2));
+ }
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 8), get_state(0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(2));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+}
+
+test "shared bindings reject missing arguments partial overlaps and invalid slot mappings" {
+ reset_all();
+ try counterProgram();
+ const revision = info(4);
+ for (0..5) |mode| {
+ try ok(begin_program(1, 1)); try ok(put_state(0, 9, -1));
+ try emit(24, 0, 0); try emit(25, 0, 0);
+ try ok(put_handler(1, 0, 2));
+ switch (mode) {
+ 0 => {}, // Parameter op without a binding.
+ 1 => { try ok(bind_handler(0, 0, 1)); try std.testing.expectEqual(public(.invalid), bind_handler(0, 0, 1)); },
+ 2 => { try std.testing.expectEqual(public(.bad_state), bind_handler(0, 0, 2)); },
+ 3 => {
+ try ok(bind_handler(0, 0, 1));
+ try ok(put_handler(1, 1, 1)); try ok(bind_handler(1, 0, 1));
+ },
+ else => {
+ try ok(bind_handler(0, 0, 1));
+ try ok(put_handler(1, 0, 2)); // Another owner missing its mapping.
+ },
+ }
+ try std.testing.expect(commit_program() != 0); abort_program();
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(revision, info(4));
+ }
+}
+
+test "one routed handler changes only its event target and rejects missing or foreign routes" {
+ reset_all();
+ try ok(begin_program(2, 2));
+ try ok(put_state(0, 10, -1));
+ try ok(put_state(1, 20, -1));
+ try ok(put_pointer_target(0, 0, 1));
+ try ok(put_pointer_target(0, 1, 1));
+ try emit(22, 0, 0);
+ try emit(25, 0, 0);
+ try ok(put_handler(17, 0, 2));
+ try ok(bind_target_handler(0, 1));
+ try ok(commit_program());
+ try ok(dispatch_target(17, 1, 77, 0));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(f64, 77), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(3));
+ try std.testing.expectEqual(public(.bad_event), dispatch_point(17, 99, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch(1, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch_target(17, 2, 99, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch_target(17, no_pc, 99, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch_target(18, 0, 99, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch_target(0, 0, 99, 0));
+ try std.testing.expectEqual(public(.non_finite), dispatch_target(17, 0, std.math.inf(f64), 0));
+ try std.testing.expectEqual(@as(u32, 1), info(9));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(f64, 77), get_state(1));
+ try std.testing.expectEqual(@as(u32, 3), layout(8));
+ try std.testing.expectEqual(@as(u32, 2), layout(9));
+ try std.testing.expectEqual(@as(u32, 54), layout(4));
+}
+
+test "routes reject invalid mappings and bindings without replacing active work" {
+ for (0..7) |mode| {
+ reset_all();
+ try counterProgram();
+ try ok(begin_program(2, 2));
+ try ok(put_state(0, 0, -1));
+ try ok(put_state(1, 0, -1));
+ switch (mode) {
+ 0 => try std.testing.expectEqual(public(.bad_state), put_pointer_target(0, 1, 2)),
+ 1 => try std.testing.expectEqual(public(.bad_event), put_pointer_target(2, 0, 1)),
+ 2 => {
+ try ok(put_pointer_target(0, 0, 2));
+ try std.testing.expectEqual(public(.invalid), put_pointer_target(1, 1, 1));
+ },
+ else => {
+ if (mode != 3) try ok(put_pointer_target(0, 0, 1));
+ try emit(0, 0, 2);
+ try emit(25, 0, 0);
+ try ok(put_handler(if (mode == 4) 0 else 1, 0, 2));
+ try ok(bind_target_handler(0, if (mode == 5) 2 else 1));
+ if (mode == 6) try std.testing.expectEqual(public(.invalid), bind_target_handler(0, 1));
+ },
+ }
+ try std.testing.expect(commit_program() != 0);
+ // Active program still executes while the failed candidate occupies staging.
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ abort_program();
+ try std.testing.expectEqual(@as(u32, 0), layout(9));
+ }
+ reset_all();
+ try ok(begin_program(32, 1));
+ for (0..32) |i| try ok(put_state(@intCast(i), 0, -1));
+ for (0..24) |i| try ok(put_pointer_target(0, @intCast(i), 1));
+ try std.testing.expectEqual(public(.capacity), put_pointer_target(0, 24, 1));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+}
+
+test "routed event rollback includes globals and earlier handlers and survives staged route replacement" {
+ reset_all();
+ try ok(begin_program(3, 1));
+ for (0..3) |i| try ok(put_state(@intCast(i), @floatFromInt(i), -1));
+ try ok(put_pointer_target(0, 1, 1));
+ try ok(put_pointer_target(0, 2, 1));
+ try emit(22, 0, 0);
+ try emit(25, 0, 0);
+ try ok(put_handler(17, 0, 2));
+ try ok(bind_target_handler(0, 1));
+ try emit(0, 0, 99);
+ try emit(20, 0, 0);
+ try emit(0, 0, 1);
+ try emit(23, 0, 0);
+ try emit(6, 0, 0);
+ try emit(25, 0, 0);
+ try ok(put_handler(17, 2, 6));
+ try ok(bind_target_handler(1, 1));
+ try ok(commit_program());
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 0, -1));
+ try ok(put_pointer_target(0, 0, 1));
+ try std.testing.expectEqual(public(.division_by_zero), dispatch_target(17, 1, 88, 0));
+ try std.testing.expectEqual(@as(f64, 0), get_state(0));
+ try std.testing.expectEqual(@as(f64, 1), get_state(1));
+ try std.testing.expectEqual(@as(f64, 2), get_state(2));
+ abort_program();
+ try ok(dispatch_target(17, 1, 88, 2));
+ try std.testing.expectEqual(@as(f64, 99), get_state(0));
+ try std.testing.expectEqual(@as(f64, 1), get_state(1));
+ try std.testing.expectEqual(@as(f64, 0.5), get_state(2));
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/sources/evaluation.zig b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/evaluation.zig
new file mode 100644
index 0000000..56081d9
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/evaluation.zig
@@ -0,0 +1,164 @@
+//! Disposable binding results and change history. No event state lives here.
+const std = @import("std");
+
+const Memo = struct {
+ value: f64 = 0,
+ revision: u64 = 0,
+ dependencies: u32 = 0,
+ start: u32 = 0,
+ length: u32 = 0,
+};
+const Change = struct { revision: u64 = 0, mask: u32 = 0 };
+
+pub const Tables = struct {
+ memo: [32]Memo = @splat(.{}),
+ history: [8]Change = @splat(.{}),
+};
+
+pub const Evaluation = struct {
+ pub const max_cache = 32;
+ pub const max_history = 8;
+ revision: u64 = 1,
+ floor: u64 = 1,
+ cache_count: u32 = 0,
+ history_count: u32 = 8,
+ next_change: u32 = 0,
+ limit: u32 = 0,
+ work: u32 = 0,
+ instructions: u32 = 0,
+ hits: u32 = 0,
+ status: u32 = 0,
+
+ pub fn configure(self: *Evaluation, tables: ?*Tables, cache: u32, history: u32) bool {
+ if (cache > max_cache or history > max_history) return false;
+ self.cache_count = cache;
+ self.history_count = history;
+ self.forget(tables, 3);
+ return true;
+ }
+
+ pub fn forget(self: *Evaluation, tables: ?*Tables, flags: u32) void {
+ if (flags & 1 != 0) if (tables) |t| { t.memo = @splat(.{}); };
+ if (flags & 2 != 0) {
+ if (tables) |t| t.history = @splat(.{});
+ self.floor = self.revision;
+ self.next_change = 0;
+ }
+ }
+
+ pub fn changed(self: *Evaluation, tables: ?*Tables, mask: u32) void {
+ const t = tables orelse return;
+ if (mask == 0 or self.cache_count == 0) return;
+ if (self.revision == std.math.maxInt(u64)) {
+ self.revision = 1;
+ self.forget(t, 3);
+ }
+ self.revision += 1;
+ if (self.history_count == 0) {
+ self.floor = self.revision;
+ return;
+ }
+ const change = &t.history[self.next_change];
+ self.floor = @max(self.floor, change.revision);
+ change.* = .{ .revision = self.revision, .mask = mask };
+ self.next_change = (self.next_change + 1) % self.history_count;
+ }
+
+ pub fn begin(self: *Evaluation, limit: u32) void {
+ self.limit = limit;
+ self.work = 0;
+ self.instructions = 0;
+ self.hits = 0;
+ self.status = 0;
+ }
+
+ pub fn spend(self: *Evaluation) bool {
+ if (self.work == self.limit) {
+ self.status = 10;
+ return false;
+ }
+ self.work += 1;
+ return true;
+ }
+
+ fn slot(self: *const Evaluation, start: u32, length: u32) u32 {
+ // One candidate, including on misses; no scan proportional to cache size.
+ return (start ^ (start >> 5) ^ (length *% 0x9e3779b9)) % self.cache_count;
+ }
+
+ pub fn lookup(self: *Evaluation, tables: ?*Tables, start: u32, length: u32) ?f64 {
+ const t = tables orelse return null;
+ if (self.cache_count == 0) return null;
+ if (!self.spend()) return null;
+ const memo = &t.memo[self.slot(start, length)];
+ if (memo.revision == 0 or memo.start != start or memo.length != length) return null;
+ // Successful pure constants need no input-change evidence. A program
+ // replacement still clears them, just like every other binding memo.
+ if (memo.dependencies != 0 and memo.revision < self.floor) return null;
+ if (memo.dependencies != 0 and memo.revision != self.revision) {
+ for (t.history[0..self.history_count]) |change| {
+ if (!self.spend()) return null;
+ if (change.revision > memo.revision and change.mask & memo.dependencies != 0) return null;
+ }
+ }
+ memo.revision = self.revision;
+ self.hits += 1;
+ return memo.value;
+ }
+
+ pub fn remember(self: *Evaluation, tables: ?*Tables, start: u32, length: u32, value: f64, dependencies: u32) void {
+ const t = tables orelse return;
+ if (self.cache_count == 0) return;
+ t.memo[self.slot(start, length)] = .{ .start = start, .length = length,
+ .value = value, .dependencies = dependencies, .revision = self.revision };
+ }
+};
+
+test "record loss cannot authorize an old memo; unrelated changes can" {
+ var tables: Tables = .{};
+ var e: Evaluation = .{};
+ try std.testing.expect(e.configure(&tables, 1, 1));
+ e.begin(100);
+ e.remember(&tables, 0, 3, 12, 1);
+ e.changed(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, 12), e.lookup(&tables, 0, 3));
+ e.changed(&tables, 2);
+ e.changed(&tables, 2); // The certificate now predates the surviving history.
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 12, 1);
+ e.changed(&tables, 1);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 20, 1);
+ e.changed(&tables, 2);
+ e.forget(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 20, 1);
+ e.revision = std.math.maxInt(u64);
+ e.changed(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+}
+
+test "budget, collisions, invalid settings, and disabled history are conservative" {
+ var tables: Tables = .{};
+ var e: Evaluation = .{};
+ try std.testing.expect(e.configure(&tables, 1, 0));
+ e.begin(100);
+ e.remember(&tables, 0, 3, 12, 1);
+ e.remember(&tables, 1, 3, 15, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ try std.testing.expectEqual(@as(?f64, 15), e.lookup(&tables, 1, 3));
+ e.changed(&tables, 0x80000000);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 1, 3));
+ try std.testing.expect(!e.configure(&tables, 33, 0));
+ try std.testing.expectEqual(@as(u32, 1), e.cache_count);
+ e.begin(0);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 1, 3));
+ try std.testing.expectEqual(@as(u32, 10), e.status);
+ try std.testing.expectEqual(@as(u32, 0), e.work);
+ e.begin(100);
+ e.remember(&tables, 2, 1, 99, 0);
+ e.changed(&tables, 1);
+ e.forget(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, 99), e.lookup(&tables, 2, 1));
+ try std.testing.expectEqual(@as(u32, 1), e.work);
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/sources/examples/routed-layout.pure b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/examples/routed-layout.pure
new file mode 100644
index 0000000..8933c70
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/examples/routed-layout.pure
@@ -0,0 +1,70 @@
+# 하나의 포인터 입력과 네 반응을 모든 도형이 공유합니다.
+state selected = 0
+record Tile {
+ x = 0; y = 0
+ ox = 0; oy = 0
+ sx = 0; sy = 0
+ held = 0; id = 0
+}
+item green = Tile(x=80, y=120, sx=80, sy=120, id=1)
+item blue = Tile(x=280, y=160, sx=280, sy=160, id=2)
+item amber = Tile(x=450, y=220, sx=450, sy=220, id=3)
+input left = ArrowLeft
+input right = ArrowRight
+input up = ArrowUp
+input down = ArrowDown
+input drag = pointer {
+ green_box: green
+ blue_box: blue
+ amber_box: amber
+}
+box board(20,90,600,240,"#233a31")
+text help(25,40,selected,"선택한 도형: ")
+box green_box(green.x,green.y,56,44,"#c9f08b")
+box blue_box(blue.x,blue.y,56,44,"#74c4d4")
+box amber_box(amber.x,amber.y,56,44,"#dfb184")
+
+rule grab(t: Tile) {
+ selected = t.id
+ t.sx = t.x; t.sy = t.y
+ t.ox = event_x - t.x; t.oy = event_y - t.y
+ t.held = 1
+}
+rule move(t: Tile) {
+ t.x = clamp(event_x - t.ox, 20, 564)
+ t.y = clamp(event_y - t.oy, 90, 286)
+}
+rule drop(t: Tile) { t.held = 0 }
+rule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }
+rule go_left(t: Tile) {
+ if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }
+}
+rule go_right(t: Tile) {
+ if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }
+}
+rule go_up(t: Tile) {
+ if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }
+}
+rule go_down(t: Tile) {
+ if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }
+}
+
+on drag.down use grab(drag.target)
+on drag.move use move(drag.target)
+on drag.up use drop(drag.target)
+on drag.cancel use cancel(drag.target)
+
+on left.down use go_left(green)
+on right.down use go_right(green)
+on up.down use go_up(green)
+on down.down use go_down(green)
+
+on left.down use go_left(blue)
+on right.down use go_right(blue)
+on up.down use go_up(blue)
+on down.down use go_down(blue)
+
+on left.down use go_left(amber)
+on right.down use go_right(amber)
+on up.down use go_up(amber)
+on down.down use go_down(amber)
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/sources/presentation.test.mjs b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/presentation.test.mjs
new file mode 100644
index 0000000..98bef6e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/presentation.test.mjs
@@ -0,0 +1,132 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { createRuntime } from './web/runtime.mjs';
+import { createRuntime as before } from './results/2026-09-23-presentation/before/web/runtime.mjs';
+
+const wasm = await readFile(new URL('./web/core.wasm', import.meta.url));
+const source = `state x = 3
+state radius = 5
+input move = KeyM
+input shrink = KeyS
+box tile(x, 10, x * 2, 20, "#123456")
+circle dot(50, 50, radius, "#abcdef")
+text label(0, 0, 10 / x, "Value: ")
+on move.down { x = x + 0.25 }
+on shrink.down { radius = radius - 10 }
+on frame { x = x + dt }
+`;
+
+// The archived adapter predates route diagnostics. These legacy fixtures have
+// no routes; keep all original fields in the equality comparison.
+function archivedSnapshot(runtime) {
+ const value = runtime.snapshot();
+ value.memory.pointerTargetBytes = 3;
+ value.memory.pointerTargets = 0;
+ return value;
+}
+
+// Reconstruct only in tests, for comparison with the archived snapshot path.
+function collect(runtime, hooks = {}) {
+ let states, views, declared;
+ const result = runtime.present({
+ begin(program) {
+ declared = program; states = {}; views = [];
+ hooks.begin?.();
+ },
+ state(i, value) { states[declared.states[i].name] = value; },
+ view(i, valid) {
+ if (!valid) return;
+ const {id, type, color, prefix} = declared.views[i];
+ views.push({id, type, color, prefix});
+ },
+ value(i, name, value) { views.at(-1)[name] = value; hooks.value?.(); },
+ end(count) { assert.equal(views.length, count); },
+ });
+ return {states, views, ...result};
+}
+
+test('direct publication matches archived snapshots and work counts through authored changes', async () => {
+ for (const cacheEntries of [0, 1, 32]) {
+ const left = await createRuntime(wasm, {cacheEntries}), right = await before(wasm, {cacheEntries});
+ const compare = () => {
+ assert.deepEqual(collect(left), archivedSnapshot(right));
+ assert.deepEqual(left.evaluationStats(), right.evaluationStats());
+ };
+ compare();
+ for (const r of [left, right]) r.apply(source);
+ compare();
+ for (let i = 0; i < 120; i++) {
+ for (const r of [left, right]) {
+ r.down('move', 'keyboard'); r.down('move', 'pointer');
+ r.up('move', 'keyboard'); r.up('move', 'pointer');
+ if (i % 11 === 0) r.frame(2);
+ if (i % 17 === 0) r.forgetEvaluation('history');
+ if (i % 23 === 0) r.reset();
+ }
+ compare();
+ }
+ for (const r of [left, right]) { r.down('shrink'); r.up('shrink'); }
+ compare(); // invalid circle only; other views still publish
+ for (const r of [left, right]) assert.throws(() => r.apply('state broken ='));
+ compare();
+ for (const r of [left, right]) r.apply('state radius = 1\nstate x = 0\ntext label(1, 2, x)');
+ compare();
+ for (const r of [left, right]) r.apply('');
+ compare();
+ }
+});
+
+test('whole-scene budget failure never publishes partial bindings and recovers exactly', async () => {
+ const left = await createRuntime(wasm, {cacheEntries:32}), right = await before(wasm, {cacheEntries:32});
+ for (const r of [left, right]) r.apply(source);
+ for (let workLimit = 0; workLimit <= 80; workLimit++) {
+ for (const r of [left, right]) r.configureEvaluation({workLimit});
+ let calls = 0;
+ const result = collect(left, {value: () => calls++});
+ assert.deepEqual(result, archivedSnapshot(right));
+ assert.deepEqual(left.evaluationStats(), right.evaluationStats());
+ if (result.displayError?.code === 101) assert.equal(calls, 0);
+ else assert.equal(calls, 10);
+ }
+});
+
+test('latched event rollback, nonfinite bindings, reset and signed zero keep their semantics', async () => {
+ const left = await createRuntime(wasm), right = await before(wasm);
+ const definition = 'state x = 0\ninput fail = Space\ntext value(0, 0, 1 / x)\non fail.down { x = 2\nx = 1 / 0 }';
+ for (const r of [left, right]) { r.apply(definition); r.down('fail'); r.up('fail'); }
+ assert.deepEqual(collect(left), archivedSnapshot(right));
+ for (const r of [left, right]) r.configureEvaluation({workLimit:0});
+ assert.deepEqual(collect(left), archivedSnapshot(right));
+ for (const r of [left, right]) {
+ r.apply('state x = -0\ntext value(x, 0, x)'); r.reset(); r.configureEvaluation({workLimit:2048});
+ }
+ const result = collect(left);
+ assert.deepEqual(result, archivedSnapshot(right));
+ assert(Object.is(result.states.x, -0));
+});
+
+test('publication rejects reentrant runtime changes and reads before they affect the scene', async () => {
+ const runtime = await createRuntime(wasm);
+ runtime.apply(source);
+ const calls = [() => runtime.apply(''), () => runtime.reset(), () => runtime.frame(),
+ () => runtime.down('move'), () => runtime.up('move'), () => runtime.releaseAll(),
+ () => runtime.configureEvaluation({workLimit:0}), () => runtime.forgetEvaluation(),
+ () => runtime.snapshot(), () => runtime.present({})];
+ const expected = runtime.snapshot();
+ assert.deepEqual(collect(runtime, {begin() {
+ for (const call of calls) assert.throws(call, /출력 전달 중/);
+ }}), expected);
+ assert.deepEqual(runtime.snapshot(), expected);
+});
+
+test('invalid or throwing output adapters do not strand the runtime or alter authored state', async () => {
+ const runtime = await createRuntime(wasm);
+ runtime.apply(source);
+ const expected = runtime.snapshot();
+ assert.throws(() => runtime.present({}), TypeError);
+ assert.throws(() => collect(runtime, {value() { throw new Error('destination failed'); }}), /destination failed/);
+ assert.deepEqual(collect(runtime), expected);
+ runtime.down('move'); runtime.up('move');
+ assert.equal(collect(runtime).states.x, 3.25);
+});
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/sources/qa-routed-input.js b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/qa-routed-input.js
new file mode 100644
index 0000000..b935499
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/qa-routed-input.js
@@ -0,0 +1,164 @@
+// Functional end-to-end persistence checks. Run with Playwright CLI run-code.
+async page => {
+ const checks=[],errors=[];
+ const check=(ok,label)=>{if(!ok)throw new Error(label);checks.push(label);};
+ const state=()=>page.evaluate(()=>window.pureWorkbench.snapshot());
+ const onDialog=dialog=>dialog.accept(), onError=error=>errors.push(error.message);
+ page.on('dialog',onDialog);page.on('pageerror',onError);
+ const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
+ const client=(x,y)=>page.evaluate(({x,y})=>{const p=new DOMPoint(x,y).matrixTransform(document.querySelector('#scene').getScreenCTM());return{x:p.x,y:p.y};},{x,y});
+ const move=async(x,y)=>{const p=await client(x,y);await page.mouse.move(p.x,p.y);};
+ const drag=async(x,y,nx,ny)=>{await page.locator('#scene').scrollIntoViewIfNeeded();await move(x,y);await page.mouse.down();await move(nx,ny);await page.mouse.up();};
+ const upload=async(text,name='layout.json')=>{
+ await page.locator('#file-input').evaluate((input,{text,name})=>{
+ const transfer=new DataTransfer();transfer.items.add(new File([text],name,{type:'application/json'}));
+ input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
+ },{text,name});
+ };
+ try {
+ await page.setViewportSize({width:1440,height:1000});
+ await page.waitForFunction(()=>window.pureWorkbench?.snapshot().ready);
+ check(await page.locator('#download-layout').isDisabled(),'placement save requires an applied definition');
+ await page.locator('#example').selectOption('routed-layout');await page.locator('#load-example').click();await page.locator('#apply').click();
+ const source=await page.locator('#source').inputValue();
+ check(await page.locator('[data-point-input="drag"]').count()===3,'one authored pointer input connects all three view targets');
+ check(await page.locator('[data-point-target]').count()===3,'DOM destinations retain distinct target identities');
+ await drag(90,130,160,180);await drag(290,170,360,220);await drag(460,230,530,270);
+ check((await page.locator('#last-event').textContent()).includes('drag.up → amber_box'),'last input identifies the released target within its group');
+ await page.keyboard.press('ArrowLeft');
+ let s=await state();
+ check(Math.abs(s.states['green.x']-150)<0.1&&Math.abs(s.states['blue.x']-350)<0.1&&Math.abs(s.states['amber.x']-510)<0.1,'three independently authored targets move and selection directs keyboard adjustment');
+ const expected={states:s.states,views:s.views};
+ const draft=source+'\nstate unfinished =';
+ await page.locator('#source').fill(draft);
+ const pending=page.waitForEvent('download');await page.locator('#download-layout').click();
+ const download=await pending;const saved='output/playwright/pure-routed-layout-saved.json';await download.saveAs(saved);
+ check(download.suggestedFilename()==='my-layout.pure.json','placement download is a separate document file');
+ check((await state()).source===draft&&(await state()).appliedSource===source,'saving neither applies nor discards the unfinished draft');
+ await page.locator('#reset').click();
+ check((await state()).states['green.x']===80,'reset returns to authored initial positions');
+ await page.locator('#file-input').setInputFiles(saved);
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
+ s=await state();
+ check(same(s.states,expected.states)&&same(s.views,expected.views),'downloaded file restores all states and visible positions');
+ check(s.source===draft&&s.appliedSource===source&&s.dirty,'the draft remains separate from the restored executable definition');
+ check(!s.running&&!s.held.length&&s.tick===0&&s.successfulEvents===0,'restored work starts stopped with no replayed inputs or clock');
+ await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');
+ check(Math.abs((await state()).states['amber.x']-(expected.states['amber.x']+10))<0.1,'restored selection and bindings remain editable');
+ // Use a fresh page/runtime, not just preservation inside the original instance.
+ const fresh=await page.context().newPage();await fresh.goto(page.url());
+ await fresh.waitForFunction(()=>window.pureWorkbench?.snapshot().ready);
+ await fresh.locator('#file-input').setInputFiles(saved);
+ await fresh.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
+ const reopened=await fresh.evaluate(()=>window.pureWorkbench.snapshot());
+ check(same(reopened.states,expected.states)&&same(reopened.views,expected.views)&&reopened.source===draft,'a fresh browser page reconstructs the saved work without old runtime state');
+ await fresh.close();await page.bringToFront();
+
+ const before=await state();
+ await upload(JSON.stringify({format:'pure-workbench',version:2,source:'state changed=99',draft:'replacement',states:[]}));
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('상태 목록'));
+ s=await state();
+ check(same(s.states,before.states)&&s.source===before.source&&s.appliedSource===before.appliedSource&&s.revision===before.revision,'invalid saved state cannot partially replace code, draft or scene');
+ // Delay File.text, then edit before it completes. Restoring must not overwrite the edit.
+ await page.locator('#file-input').evaluate(input=>{
+ const document={format:'pure-workbench',version:2,source:'state other=4',draft:'state other=4',states:[{name:'other',value:55}]};
+ const transfer=new DataTransfer(),file=new File([JSON.stringify(document)],'delayed.json',{type:'application/json'});
+ const text=JSON.stringify(document);file.text=()=>new Promise(resolve=>{window.__finishLayoutRead=()=>resolve(text);});
+ transfer.items.add(file);input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
+ });
+ await page.locator('#source').fill(draft+'\n# edit during file read');
+ await page.evaluate(()=>window.__finishLayoutRead());
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('작업이 바뀌'));
+ s=await state();check(s.source.endsWith('# edit during file read')&&same(s.states,before.states),'a delayed file read cannot overwrite a newer draft');
+ // Repeat with an input event while file content is pending.
+ await page.locator('#file-input').evaluate(input=>{
+ const text=JSON.stringify({format:'pure-workbench',version:2,source:'state other=4',states:[{name:'other',value:55}]});
+ const file=new File([text],'delayed.json',{type:'application/json'}),transfer=new DataTransfer();
+ file.text=()=>new Promise(resolve=>{window.__finishLayoutRead=()=>resolve(text);});
+ transfer.items.add(file);input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
+ });
+ await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');const changed=await state();
+ await page.evaluate(()=>window.__finishLayoutRead());
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('작업이 바뀌'));
+ check(same((await state()).states,changed.states),'a delayed restore cannot overwrite placement changes made while reading');
+ // UI-only run state is deliberately not persisted.
+ await page.locator('#play').click();await page.locator('#download-layout').click();
+ check((await state()).diagnostic.includes('정지한 뒤'),'saving while time advances requests a stopped document');
+ await page.locator('#play').click();
+ // Refuse while a logical input remains held, with no lost source or partial save.
+ await page.locator('#scene').focus();await page.keyboard.down('ArrowRight');
+ await page.locator('#download-layout').click();
+ check((await state()).diagnostic.includes('조작을 끝낸 뒤'),'saving with a held input is refused');
+ await page.keyboard.up('ArrowRight');
+ await page.locator('#file-input').setInputFiles(saved);
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
+ // One source edit affects every bound target, without editing input adapters.
+ const edited=source.replace('t.x = clamp(t.x + 10,','t.x = clamp(t.x + 25,');
+ await page.locator('#source').fill(edited);await page.locator('#apply').click();
+ const beforeEdit=await state();
+ await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');
+ check(Math.abs((await state()).states['amber.x']-(beforeEdit.states['amber.x']+25))<0.1,'editing the shared rule changes the selected amber target');
+ await drag(beforeEdit.states['blue.x']+10,beforeEdit.states['blue.y']+10,beforeEdit.states['blue.x']+10,beforeEdit.states['blue.y']+10);
+ const blueBefore=(await state()).states['blue.x'];await page.keyboard.press('ArrowRight');
+ check(Math.abs((await state()).states['blue.x']-(blueBefore+25))<0.1,'the same single rule edit also changes the blue target');
+ const lines=edited.split('\n'),items=lines.filter(line=>line.startsWith('item ')).reverse();
+ const reordered=lines.map(line=>line.startsWith('item ')?items.shift():line).join('\n');
+ const beforeReorder=await state();await page.locator('#source').fill(reordered);await page.locator('#apply').click();
+ const afterReorder=await state();
+ check(Object.entries(beforeReorder.states).every(([name,value])=>afterReorder.states[name]===value),'reordering instance declarations preserves every field by qualified name');
+ await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');
+ check(Math.abs((await state()).states['blue.x']-(afterReorder.states['blue.x']+25))<0.1,'rebuilt parameter maps still send selection input to blue after reorder');
+ // Two contacts execute the same body with different bound fields.
+ await page.locator('#source').fill(source);await page.locator('#apply').click();await page.locator('#reset').click();
+ await page.locator('#scene').scrollIntoViewIfNeeded();
+ const session=await page.context().newCDPSession(page);
+ try {
+ await session.send('Emulation.setTouchEmulationEnabled',{enabled:true,maxTouchPoints:2});
+ const p1={id:1,...await client(90,130)},p2={id:2,...await client(290,170)};
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1]});
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1,p2]});
+ await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{id:1,...await client(160,180)},{id:2,...await client(360,220)}]});
+ await page.waitForFunction(()=>{const s=window.pureWorkbench.snapshot().states;return Math.abs(s['green.x']-150)<0.1&&Math.abs(s['blue.x']-350)<0.1;});
+ const touch=await state();
+ check(Math.abs(touch.states['green.x']-150)<0.1&&Math.abs(touch.states['blue.x']-350)<0.1&&touch.held.length===2,'simultaneous touches share code but retain independent field bindings');
+ await session.send('Input.dispatchTouchEvent',{type:'touchCancel',touchPoints:[]});
+ const canceled=await state();
+ check(canceled.states['green.x']===80&&canceled.states['blue.x']===280&&!canceled.held.length,'shared cancel restores each contact to its own start position');
+
+ // A real second contact on the same view must not steal its owner.
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1]});
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1,{id:2,...await client(100,140)}]});
+ check((await state()).held.length===1,'a second touch on the same target cannot steal ownership');
+ await session.send('Input.dispatchTouchEvent',{type:'touchCancel',touchPoints:[]});
+ // End one actual contact and continue the other in the same logical input.
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1]});
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1,p2]});
+ const m1={id:1,...await client(160,180)},m2={id:2,...await client(360,220)};
+ await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[m1,m2]});
+ // In this Chromium, touchEnd's supplied point identifies the contact ending.
+ // touch-reproduction.js records the resulting pointerup and retained blue owner.
+ await session.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[m1]});
+ let partial=await state();
+ check(partial.held.length===1&&partial.states['green.held']===0&&partial.states['blue.held']===1,'releasing green keeps blue captured within the same input');
+ await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{id:2,...await client(400,250)}]});
+ await page.waitForFunction(()=>Math.abs(window.pureWorkbench.snapshot().states['blue.x']-390)<0.1);
+ partial=await state();
+ check(Math.abs(partial.states['green.x']-150)<0.1&&Math.abs(partial.states['blue.x']-390)<0.1,'the remaining contact keeps moving only its own target');
+ await session.send('Input.dispatchTouchEvent',{type:'touchCancel',touchPoints:[]});
+ partial=await state();
+ check(Math.abs(partial.states['green.x']-150)<0.1&&partial.states['blue.x']===280&&!partial.held.length,'canceling the remaining target preserves the already released position');
+ } finally {await session.send('Emulation.setTouchEmulationEnabled',{enabled:false});await session.detach();}
+ await page.locator('#file-input').setInputFiles(saved);
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
+ await page.screenshot({path:'output/playwright/pure-routed-layout-desktop.png',fullPage:true});
+ await page.setViewportSize({width:390,height:844});
+ check(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'extra persistence control fits a mobile viewport');
+ await page.screenshot({path:'output/playwright/pure-routed-layout-mobile.png',fullPage:true});
+ check(errors.length===0,'no uncaught page errors');
+ const result={checks,errors,saved,expected,source,draft};
+ await page.evaluate(result=>{window.__pureRoutedInputEvidence=result;},result);
+ return {checks:checks.length,errors};
+ } catch(error) {
+ await page.evaluate(result=>{window.__pureRoutedInputEvidence=result;},{failed:String(error),checks,errors});throw error;
+ } finally {page.off('dialog',onDialog);page.off('pageerror',onError);}
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/sources/routing.test.mjs b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/routing.test.mjs
new file mode 100644
index 0000000..bae04de
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/routing.test.mjs
@@ -0,0 +1,114 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {readFile} from 'node:fs/promises';
+import {spawnSync} from 'node:child_process';
+import {compile} from './web/compiler.mjs';
+import {createRuntime} from './web/runtime.mjs';
+const wasm=await readFile(new URL('./web/core.wasm',import.meta.url));
+const shared=await readFile(new URL('./examples/shared-layout.pure',import.meta.url),'utf8');
+const routed=await readFile(new URL('./examples/routed-layout.pure',import.meta.url),'utf8');
+const fresh=()=>createRuntime(wasm,{cacheEntries:32});
+const fixture=n=>`input drag=pointer {\n${Array.from({length:n},(_,i)=>`v${i}:c${i}`).join('\n')}\n}\non drag.move use move(drag.target)\nrule move(t:Cell) {t.x=event_x}\nrecord Cell {x=0}\n`+Array.from({length:n},(_,i)=>`item c${i}=Cell()\nbox v${i}(c${i}.x,0,10,10,"#123456")`).join('\n');
+
+test('one input and one handler serve up to 24 static targets without instruction or handler expansion',async()=>{
+ for(const n of [1,3,9,24]){
+ const p=compile(fixture(n));assert.equal(p.inputs.length,1);assert.equal(p.handlers.length,1);
+ assert.equal(p.handlers[0].length,2);assert.equal(p.pointerTargets.length,n);
+ assert.equal(p.handlers[0].parameterBase,255);
+ const r=await fresh();r.apply(fixture(n));
+ r.point('drag','down',0,0,'p',`v${n-1}`);r.point('drag','move',37,0,'p',`v${n-1}`);r.point('drag','up',0,0,'p',`v${n-1}`);
+ const s=r.snapshot();assert.equal(s.states[`c${n-1}.x`],37);
+ for(let i=0;i<n-1;i++)assert.equal(s.states[`c${i}.x`],0);
+ assert.equal(s.memory.pointerTargets,n);assert.equal(s.memory.pointerTargetBytes,3);
+ }
+});
+
+test('routes validate forward references, exact types, unique views/items and current input target',()=>{
+ const base=fixture(2);
+ for(const source of [
+ base.replace('v0:c0','missing:c0'),base.replace('v0:c0','v0:missing'),
+ base.replace('v1:c1','v0:c1'),base.replace('v1:c1','v1:c0'),
+ base.replace('c1=Cell()','c1=Other()')+'\nrecord Other {x=0}',
+ base.replace('use move(drag.target)','use move(other.target)'),
+ base.replace('use move(drag.target)','use move(drag.missing)'),
+ base+'\ninput another=pointer(v0)',
+ base+'\ninput other=pointer { v2:c0 }\nbox v2(0,0,1,1,"#000000")',
+ 'input empty=pointer {}',
+ base.replace('on drag.move','on frame'),
+ base.replace('rule move(t:Cell)','rule move(t:Other)')+'\nrecord Other {x=0}',
+ ])assert.throws(()=>compile(source),source);
+});
+
+test('grouped layout matches explicit bindings through concurrent capture, partial cancel, keyboard and restore',async()=>{
+ const a=await fresh(),b=await fresh();a.apply(shared);b.apply(routed);
+ const compare=()=>{const x=a.snapshot(),y=b.snapshot();assert.deepEqual(y.states,x.states);assert.deepEqual(y.views,x.views);assert.equal(y.lastError,x.lastError);};
+ const point=(name,phase,x,y)=>{a.point(`drag_${name}`,phase,x,y,name);b.point('drag',phase,x,y,name,`${name}_box`);compare();};
+ point('green','down',90,130);point('blue','down',290,170);
+ assert.equal(b.snapshot().held.length,2);
+ assert.throws(()=>b.apply(routed),/놓은/);assert.throws(()=>b.exportDocument(),/조작/);
+ assert.throws(()=>b.point('drag','down',0,0,'intruder','green_box'),/다른 포인터/);
+ b.point('drag','move',999,999,'intruder','green_box');compare();
+ point('green','move',200,210);point('blue','move',500,270);point('green','cancel',200,210);
+ assert.equal(b.snapshot().held.length,1);point('blue','up',290,170);
+ for(const key of ['left','up','right','down']){a.down(key);b.down(key);a.up(key);b.up(key);compare();}
+ point('amber','down',460,230);point('amber','move',900,500);point('amber','up',460,230);
+ const c=await fresh();c.restoreDocument(JSON.parse(JSON.stringify(b.exportDocument())));
+ assert.deepEqual(c.snapshot().states,b.snapshot().states);
+ for(const r of [b,c]){r.point('drag','down',570,290,'new','amber_box');r.point('drag','move',100,150,'new','amber_box');r.releaseAll();}
+ assert.deepEqual(c.snapshot().states,b.snapshot().states);
+ assert.throws(()=>b.point('drag','down',0,0),/도형/);
+ assert.throws(()=>b.point('drag','down',0,0,'p','board'),/도형/);
+ assert.throws(()=>b.down('drag'),/좌표/);
+});
+
+test('capture remains bound to its target and releaseAll cancels each target exactly once',async()=>{
+ const r=await fresh();r.apply(routed+'\nstate cancelled=0\non drag.cancel {cancelled=cancelled+1}');
+ r.point('drag','down',90,130,'a','green_box');r.point('drag','move',200,200,'a','green_box');
+ r.point('drag','down',290,170,'b','blue_box');r.point('drag','move',400,250,'b','blue_box');
+ r.point('drag','up',0,0,'a','blue_box');assert.equal(r.snapshot().held.length,2);
+ r.releaseAll();r.releaseAll();const s=r.snapshot();assert.equal(s.held.length,0);
+ assert.equal(s.states.cancelled,2);assert.equal(s.states['green.x'],80);assert.equal(s.states['blue.x'],280);
+});
+
+test('reorder and remove/readd rebuild routes by names without persisting physical indices',async()=>{
+ const r=await fresh();r.apply(fixture(3));
+ r.point('drag','down',0,0,'p','v2');r.point('drag','move',55,0,'p','v2');r.releaseAll();
+ const reordered=fixture(3).replace('v0:c0\nv1:c1\nv2:c2','v2:c2\nv1:c1\nv0:c0').replace('item c0=Cell()','').replace('item c2=Cell()','item c2=Cell()\nitem c0=Cell()');
+ r.apply(reordered);assert.equal(r.snapshot().states['c2.x'],55);
+ r.point('drag','down',0,0,'p','v2');r.point('drag','move',66,0,'p','v2');r.releaseAll();
+ assert.equal(r.snapshot().states['c2.x'],66);assert.equal(r.snapshot().states['c0.x'],0);
+ const saved=r.exportDocument();const c=await fresh();c.restoreDocument(saved);assert.deepEqual(c.snapshot().states,r.snapshot().states);
+ r.apply(fixture(2));r.apply(fixture(3));assert.equal(r.snapshot().states['c2.x'],0);
+ r.point('drag','move',999,0,'p','v2');assert.equal(r.snapshot().states['c2.x'],0);
+});
+
+test('routed writes invalidate cached displays and failed handlers rollback without losing another target owner',async()=>{
+ const source=fixture(2)+'\nstate total=0\non drag.move {total=total+1; total=1/event_y}';
+ const r=await fresh();r.apply(source);r.snapshot();r.snapshot();
+ r.point('drag','down',0,0,'a','v0');r.point('drag','down',0,0,'b','v1');
+ assert.equal(r.point('drag','move',50,2,'a','v0'),0);assert.equal(r.snapshot().views[0].x,50);
+ assert.equal(r.point('drag','move',99,0,'b','v1'),4);let s=r.snapshot();
+ assert.equal(s.states['c1.x'],0);assert.equal(s.states['c0.x'],50);assert.equal(s.states.total,0.5);
+ assert.equal(s.lastFailure.target,'v1');r.releaseAll();assert.equal(r.snapshot().lastFailure.status,4);
+ const previous=r.currentProgram;assert.throws(()=>r.apply(source.replace('v0:c0','v0:missing')));
+ assert.equal(r.currentProgram,previous);r.reset();assert.equal(r.snapshot().held.length,0);
+});
+
+test('CLI sends grouped pointer events and rejects an unknown target',()=>{
+ const path=new URL('./examples/routed-layout.pure',import.meta.url).pathname;
+ const run=input=>spawnSync(process.execPath,[new URL('./cli.mjs',import.meta.url).pathname,path],{input,encoding:'utf8'});
+ const ok=run('point-target drag green_box down 90 130\npoint-target drag green_box move 210 220\npoint-target drag green_box up 90 130\nquit\n');
+ assert.equal(ok.status,0,ok.stderr);const rows=ok.stdout.trim().split('\n').map(JSON.parse);
+ assert.equal(rows.at(-1).states['green.x'],200);assert.equal(rows.at(-1).states['blue.x'],280);assert.equal(rows.at(-1).held.length,0);
+ const bad=run('point-target drag missing down 0 0\nquit\n');assert.equal(bad.status,1);assert.match(bad.stderr,/도형/);
+});
+
+
+test('a route from a different input group cannot be delivered through the named group',async()=>{
+ const source=fixture(2).replace('v1:c1','')+'\ninput second=pointer {v1:c1}\non second.move use move(second.target)';
+ const r=await fresh();r.apply(source);
+ assert.throws(()=>r.point('drag','down',0,0,'p','v1'),/도형/);
+ assert.throws(()=>r.point('second','down',0,0,'p','v0'),/도형/);
+ r.point('second','down',0,0,'p','v1');r.point('second','move',77,0,'p','v1');r.releaseAll();
+ assert.equal(r.snapshot().states['c0.x'],0);assert.equal(r.snapshot().states['c1.x'],77);
+});
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/app.js b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/app.js
new file mode 100644
index 0000000..4b367de
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/app.js
@@ -0,0 +1,403 @@
+import { createRuntime } from './runtime.mjs';
+import { createOutput } from './output.mjs';
+import { bindSceneInput } from './scene-input.mjs';
+import { parseProject, MAX_PROJECT_BYTES } from './project.mjs';
+
+const $ = id => document.getElementById(id);
+const source = $('source');
+const MAX_SOURCE_BYTES = 32768;
+const encoder = new TextEncoder();
+const output = createOutput(document);
+const snippets = {
+ state: 'state count = 0\nstate x = 80\n',
+ input: 'input tap = Space\non tap.down {\n count = count + 1\n}\n',
+ view: 'box tile(x, 180, 40, 40, "#7ed9ae")\ntext label(40, 60, count, "Count: ")\n',
+ frame: 'on frame {\n x = x + 30 * dt\n if x > 600 { x = 0 }\n}\n',
+};
+let runtime = null;
+let appliedSource = '';
+let running = false;
+let previousTime = null;
+let accumulator = 0;
+let droppedTicks = 0;
+let diagnosticLocation = null;
+let draftGeneration = 0;
+const keyboardSources = new Map();
+const pointerSources = new Map();
+const inputButtons = new Map();
+const sceneInput = bindSceneInput($('scene'), () => runtime, safeAction);
+
+const bytes = text => encoder.encode(text).byteLength;
+const dirty = () => source.value !== appliedSource;
+// DOM is the published value. Avoid rewriting unchanged diagnostics/controls.
+function text(id, value) {
+ const node = $(id);
+ if (node.textContent !== value) node.textContent = value;
+}
+function pressed(node, value) {
+ const next = String(value);
+ if (node.getAttribute('aria-pressed') !== next) node.setAttribute('aria-pressed', next);
+}
+function draftStatus() {
+ const changed = dirty();
+ text('draft-status', changed ? '미적용 변경' : runtime?.currentProgram ? '적용됨' : '빈 작업물');
+ $('draft-status').classList.toggle('dirty', changed);
+}
+
+function setDiagnostic(message, kind = '', location = null) {
+ $('diagnostic-text').textContent = message;
+ $('diagnostic').className = `diagnostic ${kind}`;
+ diagnosticLocation = location;
+ $('error-location').hidden = !location;
+ source.setAttribute('aria-invalid', kind === 'error' && location ? 'true' : 'false');
+}
+
+function cursorPosition() {
+ const before = source.value.slice(0, source.selectionStart);
+ const lines = before.split('\n');
+ text('cursor-position', `${lines.length}행 · ${lines.at(-1).length + 1}열`);
+}
+
+function draftChanged() {
+ draftGeneration++;
+ // Read scroll position before writes; this path runs only for source edits.
+ const scrollTop = source.scrollTop;
+ const count = source.value.split('\n').length;
+ const size = bytes(source.value);
+ text('line-numbers', Array.from({ length: count }, (_, i) => i + 1).join('\n'));
+ $('line-numbers').scrollTop = scrollTop;
+ text('source-size', `${size.toLocaleString('en-US')} / 32,768 bytes`);
+ $('source-size').style.color = size > MAX_SOURCE_BYTES ? 'var(--danger)' : '';
+ draftStatus();
+ cursorPosition();
+}
+
+function pause() {
+ running = false;
+ previousTime = null;
+ accumulator = 0;
+ text('play', '▶ 실행');
+ pressed($('play'), false);
+}
+
+function safeAction(action) {
+ if (!runtime) return;
+ try { action(); }
+ catch (error) { pause(); setDiagnostic(error.message, 'error', error.line ? { line: error.line, column: error.column || 1 } : null); }
+ refresh();
+}
+
+function releaseEverything() {
+ keyboardSources.clear();
+ pointerSources.clear();
+ sceneInput.releaseCaptures();
+ if (runtime) safeAction(() => runtime.releaseAll());
+}
+
+function loseFocus() {
+ pause();
+ releaseEverything();
+ refresh();
+}
+
+function refresh() {
+ if (!runtime) return;
+ const snapshot = runtime.present(output);
+ if (snapshot.lastError) pause();
+ const held = new Set((snapshot.held || []).map(input => typeof input === 'string' ? input : input.name));
+ for (const [name, button] of inputButtons) {
+ button.classList.toggle('held', held.has(name));
+ pressed(button, held.has(name));
+ }
+ text('scene-status', runtime.currentProgram ? (running ? '실행 중' : '정지') : '적용 전');
+ text('clock-status', `${snapshot.tick || 0} frame · ${running ? '60 Hz' : '정지'}`);
+ text('revision', `revision ${snapshot.revision || 0}`);
+ for (const id of ['play', 'step', 'reset', 'download-layout']) {
+ if ($(id).disabled !== !runtime.currentProgram) $(id).disabled = !runtime.currentProgram;
+ }
+ const event = snapshot.lastEvent;
+ text('last-event', event ? `${event.name}${event.phase && event.phase !== 'frame' ? `.${event.phase}` : ''}${event.target ? ` → ${event.target}` : ''} · ${event.status ? '실패' : '완료'}${event.line ? ` · ${event.line}행` : ''}` : '아직 없음');
+ if ($('runtime-error').hidden !== !snapshot.lastError) $('runtime-error').hidden = !snapshot.lastError;
+ const displayError = snapshot.lastError === 100 || snapshot.lastError === 101;
+ let message = snapshot.lastError ? `${displayError ? '표시 오류' : '마지막 실패'} · ${snapshot.errorLine ? `${snapshot.errorLine}행 · ` : ''}${snapshot.errorMessage || `실행 오류 ${snapshot.lastError}`}${snapshot.lastError === 101 ? ' — 이번 장면을 표시하지 않았습니다. 상태 변경은 유지됩니다.' : displayError ? ' — 해당 도형을 표시할 수 없습니다.' : ' — 실패한 이벤트의 상태 변경은 취소됐습니다. 코드를 적용하거나 초깃값으로 되돌리면 이 기록을 지웁니다.'}` : '';
+ if (!displayError && snapshot.displayError?.code === 101)
+ message += ` · ${snapshot.displayError.message}`;
+ text('runtime-error', message);
+ output.costs(snapshot, runtime.currentProgram?.metrics, droppedTicks);
+}
+
+function endPointer(event) {
+ const entry = pointerSources.get(event.pointerId);
+ if (!entry) return;
+ event.preventDefault();
+ pointerSources.delete(event.pointerId);
+ safeAction(() => runtime.up(entry.name, entry.source));
+}
+
+function rebuildInputControls() {
+ inputButtons.clear();
+ const children = [];
+ for (const input of runtime.currentProgram?.inputs || []) {
+ if (input.target || input.routes) {
+ const note = document.createElement('p');
+ note.className = 'empty-note';
+ note.textContent = `${input.name} · 장면의 ${input.target || input.routes.map(route => route.target).join(', ')}을 잡아 조작하세요.`;
+ children.push(note);
+ continue;
+ }
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'input-button';
+ button.dataset.input = input.name;
+ button.setAttribute('aria-pressed', 'false');
+ const name = document.createElement('span'); name.textContent = input.name;
+ const key = document.createElement('kbd'); key.textContent = input.key;
+ button.append(name, key);
+ button.addEventListener('pointerdown', event => {
+ if (event.button !== 0) return;
+ event.preventDefault();
+ const identity = `pointer:${event.pointerId}`;
+ pointerSources.set(event.pointerId, { name: input.name, source: identity });
+ button.setPointerCapture(event.pointerId);
+ safeAction(() => runtime.down(input.name, identity));
+ });
+ button.addEventListener('pointerup', endPointer);
+ button.addEventListener('pointercancel', endPointer);
+ button.addEventListener('lostpointercapture', endPointer);
+ // Keyboard activation and assistive technology clicks are complete taps.
+ // Pointer clicks already delivered down/up via the handlers above.
+ button.addEventListener('click', event => {
+ if (event.detail !== 0) return;
+ safeAction(() => { runtime.down(input.name, 'activation'); runtime.up(input.name, 'activation'); });
+ });
+ inputButtons.set(input.name, button);
+ children.push(button);
+ }
+ if (!children.length) {
+ const message = document.createElement('p');
+ message.className = 'empty-note';
+ message.textContent = '입력을 선언하면 여기에 버튼이 생깁니다.';
+ children.push(message);
+ }
+ $('input-controls').replaceChildren(...children);
+}
+
+function isEditing(target) {
+ return target instanceof Element && Boolean(target.closest('textarea,input,select,button,[contenteditable="true"],[role="textbox"]'));
+}
+
+$('scene').addEventListener('pointerdown', () => $('scene').focus());
+
+document.addEventListener('keydown', event => {
+ if (!runtime || event.repeat || event.isComposing || event.ctrlKey || event.metaKey || event.altKey || isEditing(event.target)) return;
+ const input = runtime.currentProgram?.inputs.find(item => item.key === event.code);
+ if (!input) return;
+ event.preventDefault();
+ if (keyboardSources.has(event.code)) return;
+ keyboardSources.set(event.code, input.name);
+ safeAction(() => runtime.down(input.name, `keyboard:${event.code}`));
+});
+
+document.addEventListener('keyup', event => {
+ const name = keyboardSources.get(event.code);
+ if (!name) return;
+ event.preventDefault();
+ keyboardSources.delete(event.code);
+ safeAction(() => runtime.up(name, `keyboard:${event.code}`));
+});
+
+source.addEventListener('input', () => {
+ draftChanged();
+ if (diagnosticLocation) setDiagnostic('코드를 수정했습니다. 적용해서 다시 확인하세요.');
+});
+source.addEventListener('scroll', () => { $('line-numbers').scrollTop = source.scrollTop; });
+for (const name of ['click', 'keyup', 'select']) source.addEventListener(name, cursorPosition);
+source.addEventListener('keydown', event => {
+ if (event.key === 'Tab' && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
+ event.preventDefault();
+ source.setRangeText(' ', source.selectionStart, source.selectionEnd, 'end');
+ draftChanged();
+ }
+});
+
+$('apply').addEventListener('click', () => {
+ if (!runtime) return;
+ try {
+ runtime.apply(source.value);
+ appliedSource = source.value;
+ rebuildInputControls();
+ setDiagnostic('코드를 적용했습니다. 같은 이름의 상태는 유지됩니다.', 'success');
+ } catch (error) {
+ const location = error.line ? { line: error.line, column: error.column || 1 } : null;
+ setDiagnostic(`${location ? `${location.line}행 ${location.column}열 · ` : ''}${error.message} · 기존 장면은 그대로 유지됩니다.`, 'error', location);
+ }
+ draftStatus();
+ refresh();
+});
+
+$('error-location').addEventListener('click', () => {
+ if (!diagnosticLocation) return;
+ const lines = source.value.split('\n');
+ const lineIndex = Math.min(lines.length - 1, diagnosticLocation.line - 1);
+ const start = lines.slice(0, lineIndex).reduce((sum, line) => sum + line.length + 1, 0);
+ const at = start + Math.min(lines[lineIndex].length, diagnosticLocation.column - 1);
+ source.focus();
+ source.setSelectionRange(at, Math.min(at + 1, source.value.length));
+ const lineHeight = parseFloat(getComputedStyle(source).lineHeight);
+ source.scrollTop = Math.max(0, lineIndex * lineHeight - source.clientHeight / 3);
+ $('line-numbers').scrollTop = source.scrollTop;
+ cursorPosition();
+});
+
+$('play').addEventListener('click', () => {
+ if (running) pause();
+ else { running = true; previousTime = null; accumulator = 0; $('play').textContent = 'Ⅱ 정지'; $('play').setAttribute('aria-pressed', 'true'); }
+ refresh();
+});
+$('step').addEventListener('click', () => { pause(); safeAction(() => runtime.frame()); });
+$('reset').addEventListener('click', () => {
+ pause(); releaseEverything(); droppedTicks = 0;
+ safeAction(() => runtime.reset());
+ setDiagnostic('적용된 코드의 초깃값으로 되돌렸습니다. 편집 중인 코드는 유지됩니다.');
+});
+
+function replaceDraft(text, message) {
+ source.value = text;
+ source.scrollTop = 0;
+ source.setSelectionRange(0, 0);
+ setDiagnostic(message);
+ draftChanged();
+}
+
+function mayReplaceDraft() {
+ return !source.value || window.confirm('현재 편집 중인 코드를 바꿀까요? 저장하지 않은 내용은 사라집니다. 실행 장면은 적용 전까지 유지됩니다.');
+}
+
+$('new').addEventListener('click', () => {
+ if (mayReplaceDraft()) replaceDraft('', '편집기만 비웠습니다. 빈 코드를 적용하면 장면도 비워집니다.');
+});
+
+for (const button of document.querySelectorAll('.insert-snippet')) {
+ button.addEventListener('click', () => {
+ const at = source.selectionStart;
+ const prefix = at > 0 && source.value[at - 1] !== '\n' ? '\n' : '';
+ source.setRangeText(prefix + snippets[button.dataset.snippet], source.selectionStart, source.selectionEnd, 'end');
+ source.focus();
+ draftChanged();
+ setDiagnostic('조각을 삽입했습니다. 이름과 동작을 원하는 대로 바꾸고 적용하세요.');
+ });
+}
+
+$('load-example').addEventListener('click', async () => {
+ if (!mayReplaceDraft()) return;
+ const selected = $('example').value;
+ const before = source.value;
+ const button = $('load-example');
+ button.disabled = true;
+ try {
+ const response = await fetch(`./examples/${selected}.pure`);
+ if (!response.ok) throw new Error(`예제를 불러오지 못했습니다 (${response.status}).`);
+ const text = await response.text();
+ if (bytes(text) > MAX_SOURCE_BYTES) throw new Error('예제가 코드 크기 제한을 넘었습니다.');
+ if (source.value !== before) throw new Error('불러오는 동안 코드가 바뀌었습니다. 변경 내용을 지키기 위해 불러오기를 취소했습니다.');
+ replaceDraft(text, '예제를 편집기에 불러왔습니다. 아직 적용하지 않았습니다.');
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+ finally { button.disabled = false; }
+});
+
+function download(filename, content, mime, message = '편집 중인 코드를 파일로 저장했습니다. 적용되지 않은 변경도 포함합니다.') {
+ const url = URL.createObjectURL(new Blob([content], { type: mime }));
+ const link = document.createElement('a');
+ link.href = url; link.download = filename;
+ document.body.append(link); link.click(); link.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ setDiagnostic(message);
+}
+$('download').addEventListener('click', () => download('my-work.pure', source.value, 'text/plain;charset=utf-8'));
+$('download-project').addEventListener('click', () => download('my-work.pure.json', JSON.stringify({ format: 'pure-workbench', version: 1, source: source.value }, null, 2) + '\n', 'application/json'));
+$('download-layout').addEventListener('click', () => {
+ try {
+ if (running) throw new Error('시간 진행을 정지한 뒤 배치를 저장하세요.');
+ const project = runtime.exportDocument(source.value);
+ download('my-layout.pure.json', JSON.stringify(project, null, 2) + '\n', 'application/json',
+ '적용된 코드·현재 배치·미적용 초안을 함께 저장했습니다.');
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+});
+$('upload').addEventListener('click', () => $('file-input').click());
+$('file-input').addEventListener('change', async event => {
+ const file = event.target.files[0];
+ event.target.value = '';
+ if (!file) return;
+ try {
+ if (file.size > MAX_PROJECT_BYTES) throw new Error('파일이 너무 큽니다. 코드 제한은 32 KiB입니다.');
+ const before = draftGeneration;
+ const active = runtime?.currentProgram;
+ const events = runtime?.snapshot().successfulEvents;
+ let content = await file.text();
+ if (file.name.toLowerCase().endsWith('.json')) {
+ const project = parseProject(content);
+ if (project.version === 2) {
+ if (!runtime) throw new Error('실행기가 준비되지 않았습니다.');
+ if (draftGeneration !== before || runtime.currentProgram !== active || runtime.snapshot().successfulEvents !== events)
+ throw new Error('파일을 읽는 동안 작업이 바뀌었습니다. 정지한 뒤 다시 불러오세요.');
+ if (running) throw new Error('시간 진행을 정지한 뒤 배치를 복원하세요.');
+ if ((source.value || appliedSource) && !window.confirm('현재 코드와 배치를 파일의 내용으로 복원할까요? 저장하지 않은 작업은 사라집니다.')) return;
+ const restored = runtime.restoreDocument(project);
+ pause(); droppedTicks = 0;
+ appliedSource = restored.source;
+ rebuildInputControls();
+ replaceDraft(restored.draft, '배치를 복원했습니다. 미적용 초안도 보존했고, 시간은 정지 상태입니다.');
+ refresh();
+ return;
+ }
+ content = project.source;
+ }
+ if (bytes(content) > MAX_SOURCE_BYTES) throw new Error('코드가 32 KiB 제한을 넘었습니다.');
+ if (draftGeneration !== before) throw new Error('파일을 읽는 동안 코드가 바뀌었습니다. 불러오기를 다시 시도하세요.');
+ if (mayReplaceDraft()) replaceDraft(content, `${file.name}을 편집기에 불러왔습니다. 아직 적용하지 않았습니다.`);
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+});
+
+window.addEventListener('blur', loseFocus);
+document.addEventListener('visibilitychange', () => { if (document.hidden) loseFocus(); });
+window.addEventListener('beforeunload', event => {
+ // Applying is not persistence: even applied code can be lost when leaving.
+ if (source.value || appliedSource) { event.preventDefault(); event.returnValue = ''; }
+});
+
+function animate(time) {
+ if (running && runtime) {
+ if (previousTime !== null) {
+ accumulator += Math.max(0, time - previousTime);
+ const wanted = Math.floor(accumulator / (1000 / 60));
+ if (wanted > 0) {
+ const steps = Math.min(6, wanted);
+ accumulator -= wanted * (1000 / 60);
+ droppedTicks += wanted - steps;
+ safeAction(() => runtime.frame(steps));
+ }
+ }
+ previousTime = running ? time : null;
+ }
+ requestAnimationFrame(animate);
+}
+
+Object.defineProperty(window, 'pureWorkbench', {
+ configurable: false,
+ writable: false,
+ value: Object.freeze({ snapshot: () => structuredClone({ ...(runtime?.snapshot() || {}), ready: Boolean(runtime), source: source.value, appliedSource, dirty: dirty(), running, droppedTicks, diagnostic: $('diagnostic-text').textContent }) }),
+});
+
+draftChanged();
+requestAnimationFrame(animate);
+try {
+ runtime = await createRuntime();
+ $('ready-status').textContent = '실행기 준비됨';
+ $('ready-dot').classList.add('ready');
+ $('apply').disabled = false;
+ refresh();
+} catch (error) {
+ $('ready-status').textContent = '실행기 준비 실패';
+ $('ready-dot').classList.add('failed');
+ setDiagnostic(`실행기를 열지 못했습니다. ${error.message}`, 'error');
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/compiler.mjs b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/compiler.mjs
new file mode 100644
index 0000000..bfe671b
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/compiler.mjs
@@ -0,0 +1,484 @@
+// Pure's experimental scalar interaction language. No host code is evaluated.
+const LIMITS = Object.freeze({ sourceBytes: 32768, states: 32, inputs: 8,
+ views: 24, handlers: 64, instructions: 512, depth: 64, stack: 64 });
+const RESERVED = new Set(['frame', 'dt', 'event_x', 'event_y', 'min', 'max', 'clamp']);
+const BINARY = new Map([
+ ['||', [1, 15]], ['&&', [2, 14]], ['==', [3, 12]], ['!=', [3, 13]],
+ ['<', [4, 8]], ['<=', [4, 9]], ['>', [4, 10]], ['>=', [4, 11]],
+ ['+', [5, 3]], ['-', [5, 4]], ['*', [6, 5]], ['/', [6, 6]],
+]);
+const FUNCTIONS = { min: [2, 17], max: [2, 18], clamp: [3, 19] };
+const VIEW_FIELDS = { box: ['x', 'y', 'width', 'height'],
+ circle: ['x', 'y', 'radius'], text: ['x', 'y', 'value'] };
+
+function fail(token, message) {
+ const error = new Error(`${message} (${token.line}:${token.column})`);
+ error.line = token.line;
+ error.column = token.column;
+ throw error;
+}
+
+function lex(source) {
+ const tokens = [];
+ let offset = 0, line = 1, column = 1;
+ const advance = () => {
+ const char = source[offset++];
+ if (char === '\n') { line++; column = 1; } else column++;
+ return char;
+ };
+ while (offset < source.length) {
+ const char = source[offset];
+ if (char === ' ' || char === '\t' || char === '\r') { advance(); continue; }
+ if (char === '#') { while (offset < source.length && source[offset] !== '\n') advance(); continue; }
+ const token = { line, column };
+ if (char === '\n') { advance(); tokens.push({ ...token, kind: 'nl' }); continue; }
+ if (char === '"') {
+ advance();
+ let value = '', closed = false;
+ while (offset < source.length) {
+ const next = advance();
+ if (next === '"') { closed = true; break; }
+ if (next === '\n' || next === '\r') fail(token, 'Strings must fit on one line');
+ if (next === '\\') {
+ const escaped = advance();
+ const escapes = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' };
+ if (!Object.hasOwn(escapes, escaped)) fail(token, 'Unsupported string escape');
+ value += escapes[escaped];
+ } else value += next;
+ }
+ if (!closed) fail(token, 'Unterminated string');
+ tokens.push({ ...token, kind: 'string', value }); continue;
+ }
+ const remaining = source.slice(offset);
+ const number = remaining.match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/);
+ if (number) {
+ const value = Number(number[0]);
+ if (!Number.isFinite(value)) fail(token, 'Number must be finite');
+ for (let i = 0; i < number[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'number', value }); continue;
+ }
+ const name = remaining.match(/^[A-Za-z_][A-Za-z_0-9]*/);
+ if (name) {
+ for (let i = 0; i < name[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'name', value: name[0] }); continue;
+ }
+ const pair = source.slice(offset, offset + 2);
+ if (['<=', '>=', '==', '!=', '&&', '||'].includes(pair)) {
+ advance(); advance(); tokens.push({ ...token, kind: pair }); continue;
+ }
+ if ('=+-*/!<>(){},.;:'.includes(char)) {
+ advance(); tokens.push({ ...token, kind: char }); continue;
+ }
+ fail(token, `Unexpected character ${JSON.stringify(char)}`);
+ }
+ tokens.push({ kind: 'eof', line, column });
+ return tokens;
+}
+
+class Parser {
+ constructor(tokens) { this.tokens = tokens; this.position = 0; this.depth = 0; }
+ peek() { return this.tokens[this.position]; }
+ take() { return this.tokens[this.position++]; }
+ is(kind, value) { return this.peek().kind === kind && (value === undefined || this.peek().value === value); }
+ expect(kind, description = kind) {
+ if (!this.is(kind)) fail(this.peek(), `Expected ${description}`);
+ return this.take();
+ }
+ newlines() { while (this.is('nl')) this.take(); }
+ separators() { while (this.is('nl') || this.is(';')) this.take(); }
+ end() {
+ if (!['nl', ';', '}', 'eof'].includes(this.peek().kind)) fail(this.peek(), 'Expected newline or semicolon');
+ this.separators();
+ }
+ reference(token) {
+ if (!this.is('.')) return token.value;
+ this.take();
+ return `${token.value}.${this.expect('name', 'field name').value}`;
+ }
+ initial() {
+ let sign = 1;
+ if (this.is('-') || this.is('+')) sign = this.take().kind === '-' ? -1 : 1;
+ return sign * this.expect('number', 'numeric state initializer').value;
+ }
+ node(token, properties, children = []) {
+ const depth = 1 + Math.max(0, ...children.map(child => child.depth));
+ if (depth > LIMITS.depth) fail(token, 'Expression nesting exceeds 64');
+ return { token, depth, ...properties };
+ }
+ expression(minimum = 0, multiline = false) {
+ if (++this.depth > LIMITS.depth) fail(this.peek(), 'Expression nesting exceeds 64');
+ if (multiline) this.newlines();
+ const token = this.take();
+ let left;
+ if (token.kind === 'number') left = this.node(token, { kind: 'constant', value: token.value });
+ else if (token.kind === '-' || token.kind === '!') {
+ const child = this.expression(7, multiline);
+ left = this.node(token, { kind: 'unary', op: token.kind === '-' ? 7 : 16, child }, [child]);
+ } else if (token.kind === '(') {
+ left = this.expression(0, true); this.newlines(); this.expect(')');
+ } else if (token.kind === 'name') {
+ if (Object.hasOwn(FUNCTIONS, token.value) && this.is('(')) {
+ this.take();
+ const args = [];
+ this.newlines();
+ if (!this.is(')')) {
+ for (;;) {
+ args.push(this.expression(0, true)); this.newlines();
+ if (!this.is(',')) break;
+ this.take();
+ }
+ }
+ this.expect(')');
+ if (args.length !== FUNCTIONS[token.value][0]) fail(token, `${token.value} expects ${FUNCTIONS[token.value][0]} arguments`);
+ left = this.node(token, { kind: 'call', op: FUNCTIONS[token.value][1], args }, args);
+ } else left = this.node(token, { kind: 'reference', name: this.reference(token) });
+ } else fail(token, 'Expected a number, state, or expression');
+ for (;;) {
+ if (multiline) this.newlines();
+ const entry = BINARY.get(this.peek().kind);
+ if (!entry || entry[0] < minimum) break;
+ const operator = this.take();
+ const right = this.expression(entry[0] + 1, multiline);
+ left = this.node(operator, { kind: 'binary', op: entry[1], left, right }, [left, right]);
+ }
+ this.depth--;
+ return left;
+ }
+ block(depth = 0) {
+ if (depth >= LIMITS.depth) fail(this.peek(), 'Handler nesting exceeds 64');
+ this.newlines(); this.expect('{'); this.separators();
+ const statements = [];
+ while (!this.is('}')) {
+ const token = this.expect('name', 'assignment, if, or closing brace');
+ if (token.value === 'if' && !this.is('=')) {
+ const condition = this.expression();
+ const body = this.block(depth + 1);
+ statements.push({ kind: 'if', token, condition, body });
+ } else {
+ const name = this.reference(token);
+ this.expect('=');
+ statements.push({ kind: 'assign', token, name, expression: this.expression() });
+ }
+ this.end();
+ }
+ this.take();
+ return statements;
+ }
+ parse() {
+ const declarations = [];
+ this.separators();
+ while (!this.is('eof')) {
+ const token = this.expect('name', 'declaration');
+ const kind = token.value;
+ if (kind === 'state') {
+ const name = this.expect('name', 'state name'); this.expect('=');
+ declarations.push({ kind, token, name, initial: this.initial() });
+ } else if (kind === 'record') {
+ const name = this.expect('name', 'record type'); this.newlines(); this.expect('{'); this.separators();
+ const fields = [];
+ while (!this.is('}')) {
+ const field = this.expect('name', 'field name'); this.expect('=');
+ fields.push({name:field, initial:this.initial()}); this.end();
+ }
+ this.take(); declarations.push({kind,token,name,fields});
+ } else if (kind === 'item') {
+ const name = this.expect('name', 'item name'); this.expect('=');
+ const type = this.expect('name', 'record type'); this.expect('('); this.newlines();
+ const fields = [];
+ while (!this.is(')')) {
+ const field = this.expect('name', 'field name'); this.expect('=');
+ fields.push({name:field, initial:this.initial()}); this.newlines();
+ if (!this.is(',')) break;
+ this.take(); this.newlines();
+ }
+ this.expect(')'); declarations.push({kind,token,name,type,fields});
+ } else if (kind === 'rule') {
+ const name = this.expect('name', 'rule name'); this.expect('(');
+ const parameter = this.expect('name', 'record parameter'); this.expect(':');
+ const type = this.expect('name', 'record type'); this.expect(')');
+ declarations.push({kind,token,name,parameter,type,body:this.block()});
+ } else if (kind === 'input') {
+ const name = this.expect('name', 'input name'); this.expect('=');
+ const key = this.expect('name', 'keyboard code or pointer(view)');
+ if (key.value === 'pointer') {
+ if (this.is('{')) {
+ this.take(); this.separators(); const routes = [];
+ while (!this.is('}')) {
+ const target = this.expect('name', 'view ID'); this.expect(':');
+ const item = this.expect('name', 'item name'); routes.push({target,item}); this.end();
+ }
+ this.take(); declarations.push({kind,token,name,routes}); this.end(); continue;
+ }
+ this.expect('(');
+ const target = this.expect('name', 'view ID'); this.expect(')');
+ declarations.push({ kind, token, name, target });
+ this.end(); continue;
+ }
+ if (!/^(?:Space|Enter|Arrow(?:Left|Right|Up|Down)|Key[A-Z]|Digit[0-9])$/.test(key.value)) fail(key, 'Unsupported keyboard code');
+ declarations.push({ kind, token, name, key });
+ } else if (Object.hasOwn(VIEW_FIELDS, kind)) {
+ const name = this.expect('name', 'view ID'); this.expect('(');
+ const expressions = [];
+ for (let i = 0; i < VIEW_FIELDS[kind].length; i++) {
+ if (i) { this.newlines(); this.expect(','); }
+ expressions.push(this.expression(0, true));
+ }
+ this.newlines();
+ let color, prefix = '';
+ if (kind !== 'text') {
+ this.expect(','); this.newlines();
+ const value = this.expect('string', 'literal hex color');
+ if (!/^#[0-9a-fA-F]{6}$/.test(value.value)) fail(value, 'Color must use six hexadecimal digits, such as "#356548"');
+ color = value.value;
+ } else if (this.is(',')) { this.take(); this.newlines(); prefix = this.expect('string', 'literal text prefix').value; }
+ this.newlines(); this.expect(')');
+ declarations.push({ kind: 'view', type: kind, token, name, expressions, color, prefix });
+ } else if (kind === 'on') {
+ const event = this.expect('name', 'input name or frame');
+ let edge;
+ if (event.value !== 'frame') {
+ this.expect('.'); edge = this.expect('name', 'down, up, move or cancel');
+ if (!['down', 'up', 'move', 'cancel'].includes(edge.value)) fail(edge, 'Expected down or up, move or cancel');
+ }
+ if (this.is('name', 'use')) {
+ this.take(); const rule = this.expect('name', 'rule name'); this.expect('(');
+ const item = this.expect('name', 'item name or input.target');
+ const argument = this.reference(item); this.expect(')');
+ declarations.push({kind:'handler',token,event,edge,rule,item,argument});
+ } else declarations.push({ kind: 'handler', token, event, edge, body: this.block() });
+ } else fail(token, `Unknown declaration ${kind}`);
+ this.end();
+ }
+ return declarations;
+ }
+}
+
+export function compile(source) {
+ if (typeof source !== 'string') fail({ line: 1, column: 1 }, 'Source must be a string');
+ const sourceBytes = new TextEncoder().encode(source).length;
+ if (sourceBytes > LIMITS.sourceBytes) fail({ line: 1, column: 1 }, 'Source exceeds 32768 UTF-8 bytes');
+ const declarations = new Parser(lex(source)).parse();
+ const program = { states: [], inputs: [], instructions: [], handlers: [], views: [], pointerTargets: [], metrics: {} };
+ const states = new Map(), inputs = new Map(), views = new Set(), keys = new Set(), targets = new Set();
+ const records = new Map(), items = new Map(), rules = new Map();
+ const scalarNames = new Set(declarations.filter(d => d.kind === 'state').map(d => d.name.value));
+ for (const declaration of declarations) {
+ if (!['record','rule'].includes(declaration.kind)) continue;
+ const {name,fields,kind} = declaration, symbols = kind === 'record' ? records : rules;
+ if (RESERVED.has(name.value)) fail(name, `Reserved name ${name.value}`);
+ if (symbols.has(name.value)) fail(name, `Duplicate ${kind} ${name.value}`);
+ if (kind === 'record') {
+ if (!fields.length || fields.length > 8) fail(name, 'A record needs 1..8 fields');
+ const used = new Set();
+ for (const field of fields) {
+ if (used.has(field.name.value)) fail(field.name, `Duplicate field ${field.name.value}`);
+ if (RESERVED.has(field.name.value)) fail(field.name, `Reserved name ${field.name.value}`);
+ used.add(field.name.value);
+ }
+ }
+ symbols.set(name.value, declaration);
+ }
+ let handlerCount = 0;
+ for (const declaration of declarations) {
+ const { kind, name, token } = declaration;
+ if (name && RESERVED.has(name.value)) fail(name, `Reserved name ${name.value}`);
+ if (kind === 'record' || kind === 'rule') continue;
+ if (kind === 'item') {
+ if (items.has(name.value) || scalarNames.has(name.value)) fail(name, `Duplicate state/item ${name.value}`);
+ const record = records.get(declaration.type.value);
+ if (!record) fail(declaration.type, `Unknown record ${declaration.type.value}`);
+ const overrides = new Map();
+ for (const field of declaration.fields) {
+ if (!record.fields.some(f => f.name.value === field.name.value)) fail(field.name, `Unknown field ${field.name.value}`);
+ if (overrides.has(field.name.value)) fail(field.name, `Duplicate field ${field.name.value}`);
+ overrides.set(field.name.value, field.initial);
+ }
+ const base = states.size;
+ for (const field of record.fields) {
+ if (states.size >= LIMITS.states) fail(name, 'At most 32 states are allowed, including item fields');
+ const qualified = `${name.value}.${field.name.value}`, index = states.size;
+ states.set(qualified,index);
+ program.states.push({name:qualified,initial:overrides.has(field.name.value) ? overrides.get(field.name.value) : field.initial,line:token.line});
+ }
+ items.set(name.value,{type:declaration.type.value,base,count:record.fields.length});
+ } else if (kind === 'state' || kind === 'input') {
+ const symbols = kind === 'state' ? states : inputs;
+ if (symbols.has(name.value)) fail(name, `Duplicate ${kind} ${name.value}`);
+ if (symbols.size >= LIMITS[`${kind}s`]) fail(name, `At most ${LIMITS[`${kind}s`]} ${kind}s are allowed`);
+ symbols.set(name.value, symbols.size);
+ if (kind === 'state') program.states.push({ name: name.value, initial: declaration.initial, line: token.line });
+ else if (declaration.routes) {
+ if (!declaration.routes.length) fail(name, 'A pointer group needs at least one target');
+ program.inputs.push({name:name.value, routes:[], line:token.line});
+ } else if (declaration.target) {
+ const target = declaration.target;
+ if (targets.has(target.value)) fail(target, `Duplicate pointer target ${target.value}`);
+ targets.add(target.value);
+ program.inputs.push({ name: name.value, target: target.value, line: token.line });
+ } else {
+ if (keys.has(declaration.key.value)) fail(declaration.key, `Duplicate keyboard code ${declaration.key.value}`);
+ keys.add(declaration.key.value);
+ program.inputs.push({ name: name.value, key: declaration.key.value, line: token.line });
+ }
+ } else if (kind === 'view') {
+ if (views.has(name.value)) fail(name, `Duplicate view ID ${name.value}`);
+ if (views.size >= LIMITS.views) fail(name, 'At most 24 views are allowed');
+ views.add(name.value);
+ } else if (++handlerCount > LIMITS.handlers) fail(token, 'At most 64 handlers are allowed');
+ }
+ for (const declaration of declarations) {
+ if (declaration.target && !views.has(declaration.target.value))
+ fail(declaration.target, `Unknown pointer target ${declaration.target.value}`);
+ }
+ const routedItems = new Set();
+ for (const declaration of declarations) {
+ if (!declaration.routes) continue;
+ const input = inputs.get(declaration.name.value), resolved = program.inputs[input];
+ let type;
+ for (const {target,item:token} of declaration.routes) {
+ if (!views.has(target.value)) fail(target, `Unknown pointer target ${target.value}`);
+ if (targets.has(target.value)) fail(target, `Duplicate pointer target ${target.value}`);
+ const item = items.get(token.value);
+ if (!item) fail(token, `Unknown item ${token.value}`);
+ if (type && type !== item.type) fail(token, 'Pointer group targets must share one record type');
+ if (routedItems.has(token.value)) fail(token, `Item ${token.value} already has a pointer route`);
+ targets.add(target.value); routedItems.add(token.value); type = item.type;
+ const route = program.pointerTargets.length;
+ if (route >= 24) fail(target, 'At most 24 pointer targets are allowed');
+ program.pointerTargets.push({input,base:item.base,count:item.count});
+ resolved.routes.push({target:target.value, item:token.value, route});
+ }
+ resolved.type = type;
+ }
+ for (const rule of rules.values()) {
+ if (!records.has(rule.type.value)) fail(rule.type, `Unknown record ${rule.type.value}`);
+ if (RESERVED.has(rule.parameter.value)) fail(rule.parameter, `Reserved name ${rule.parameter.value}`);
+ }
+ let pointHandler = false;
+ let parameters = null;
+ let parameterPrefix = null;
+ let stack = 0, maxStack = 0;
+ const emit = (op, token, arg = 0, value = 0, delta = 0) => {
+ if (program.instructions.length >= LIMITS.instructions) fail(token, 'At most 512 instructions are allowed');
+ stack += delta;
+ if (stack < 0 || stack > LIMITS.stack) fail(token, 'Expression stack exceeds 64');
+ maxStack = Math.max(maxStack, stack);
+ program.instructions.push({ op, arg, value, line: token.line });
+ };
+ const expression = (node, binding = false) => {
+ const { token } = node;
+ if (node.kind === 'constant') emit(0, token, 0, node.value, 1);
+ else if (node.kind === 'reference') {
+ if (node.name === 'event_x' || node.name === 'event_y') {
+ if (binding || !pointHandler) fail(token, 'event_x and event_y are only available in pointer handlers');
+ emit(node.name === 'event_x' ? 22 : 23, token, 0, 0, 1);
+ } else if (node.name === 'dt') {
+ if (binding) fail(token, 'dt is only available in handlers');
+ emit(2, token, 0, 0, 1);
+ } else {
+ if (parameterPrefix && node.name.startsWith(parameterPrefix) && !parameters.has(node.name))
+ fail(token, `Unknown parameter field ${node.name}`);
+ if (parameters?.has(node.name)) {
+ emit(24, token, parameters.get(node.name), 0, 1);
+ return;
+ }
+ if (!states.has(node.name)) fail(token, `Unknown state ${node.name}`);
+ emit(1, token, states.get(node.name), 0, 1);
+ }
+ } else if (node.kind === 'unary') { expression(node.child, binding); emit(node.op, token); }
+ else if (node.kind === 'binary') {
+ expression(node.left, binding); expression(node.right, binding); emit(node.op, token, 0, 0, -1);
+ } else {
+ for (const arg of node.args) expression(arg, binding);
+ emit(node.op, token, 0, 0, 1 - node.args.length);
+ }
+ };
+ const statements = body => {
+ for (const statement of body) {
+ if (statement.kind === 'assign') {
+ if (parameterPrefix && statement.name.startsWith(parameterPrefix) && !parameters.has(statement.name))
+ fail(statement.token, `Unknown parameter field ${statement.name}`);
+ const parameter = parameters?.get(statement.name);
+ if (parameter === undefined && !states.has(statement.name)) fail(statement.token, `Unknown state ${statement.name}`);
+ expression(statement.expression);
+ emit(parameter === undefined ? 20 : 25, statement.token, parameter === undefined ? states.get(statement.name) : parameter, 0, -1);
+ } else {
+ expression(statement.condition);
+ const branch = program.instructions.length;
+ emit(21, statement.token, 0, 0, -1);
+ statements(statement.body);
+ program.instructions[branch].arg = program.instructions.length;
+ }
+ }
+ };
+ const bodies = new Map();
+ function ruleBody(rule) {
+ if (bodies.has(rule.name.value)) return bodies.get(rule.name.value);
+ parameterPrefix = `${rule.parameter.value}.`;
+ parameters = new Map(records.get(rule.type.value).fields.map((field,index) => [`${rule.parameter.value}.${field.name.value}`,index]));
+ // A shared body may read coordinates; every binding is checked below.
+ pointHandler = true; stack = 0;
+ const start = program.instructions.length;
+ statements(rule.body);
+ const length = program.instructions.length - start;
+ const point = program.instructions.slice(start).some(op => op.op === 22 || op.op === 23);
+ parameters = null; parameterPrefix = null;
+ const body = {start,length,point}; bodies.set(rule.name.value,body); return body;
+ }
+ for (const declaration of declarations) {
+ if (declaration.kind === 'handler') {
+ let event = 0;
+ pointHandler = false;
+ if (declaration.event.value !== 'frame') {
+ if (!inputs.has(declaration.event.value)) fail(declaration.event, `Unknown input ${declaration.event.value}`);
+ const index = inputs.get(declaration.event.value), edge = declaration.edge.value;
+ pointHandler = Boolean(program.inputs[index].target || program.inputs[index].routes);
+ if (!pointHandler && (edge === 'move' || edge === 'cancel')) fail(declaration.edge, 'move and cancel require a pointer input');
+ event = edge === 'move' ? 17 + index : edge === 'cancel' ? 25 + index : 1 + index * 2 + (edge === 'up' ? 1 : 0);
+ }
+ if (declaration.rule) {
+ const rule = rules.get(declaration.rule.value);
+ const routed = declaration.argument.includes('.');
+ const input = program.inputs[inputs.get(declaration.event.value)];
+ if (routed && (!input?.routes || declaration.argument !== `${declaration.event.value}.target`))
+ fail(declaration.item, 'Use the current pointer group target');
+ const item = routed ? {type:input.type, count:records.get(input.type).fields.length} : items.get(declaration.item.value);
+ if (!rule) fail(declaration.rule, `Unknown rule ${declaration.rule.value}`);
+ if (!item) fail(declaration.item, `Unknown item ${declaration.item.value}`);
+ if (item.type !== rule.type.value) fail(declaration.item, `Rule ${rule.name.value} requires ${rule.type.value}`);
+ const pointer = pointHandler;
+ const body = ruleBody(rule);
+ if (body.point && !pointer) fail(declaration.rule, 'Coordinate-reading rules require a pointer input');
+ program.handlers.push({event,start:body.start,length:body.length,line:declaration.token.line,parameterBase:routed ? 255 : item.base,parameterCount:item.count});
+ } else {
+ const start = program.instructions.length;
+ stack = 0; statements(declaration.body);
+ program.handlers.push({ event, start, length: program.instructions.length - start, line: declaration.token.line });
+ }
+ } else if (declaration.kind === 'view') {
+ const view = { id: declaration.name.value, type: declaration.type, line: declaration.token.line, bindings: {} };
+ if (view.type === 'text') view.prefix = declaration.prefix;
+ else view.color = declaration.color;
+ declaration.expressions.forEach((node, index) => {
+ const start = program.instructions.length;
+ stack = 0; expression(node, true);
+ view.bindings[VIEW_FIELDS[view.type][index]] = { start, length: program.instructions.length - start };
+ });
+ program.views.push(view);
+ }
+ }
+ // Unused definitions still receive name/type checking, but reserve no VM body.
+ for (const rule of rules.values()) {
+ if (bodies.has(rule.name.value)) continue;
+ const instructions = program.instructions, previousMax = maxStack;
+ program.instructions = [];
+ ruleBody(rule);
+ program.instructions = instructions; maxStack = previousMax;
+ }
+ program.metrics = { sourceBytes, sourceLines: source === '' ? 0 : source.split('\n').length,
+ states: states.size, inputs: inputs.size, views: views.size,
+ handlers: program.handlers.length, instructions: program.instructions.length, maxStack };
+ return program;
+}
+
+export { LIMITS };
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/core.wasm b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/core.wasm
new file mode 100644
index 0000000..117f25d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/core.wasm
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/index.html b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/index.html
new file mode 100644
index 0000000..a4bc2af
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/index.html
@@ -0,0 +1,94 @@
+<!doctype html>
+<html lang="ko">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="color-scheme" content="dark">
+ <title>Pure · 빈 작업물에서 시작하기</title>
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23151918'/%3E%3Ctext x='9' y='23' fill='%23c9f08b' font-size='24' font-family='sans-serif'%3Ep%3C/text%3E%3C/svg%3E">
+ <link rel="stylesheet" href="./style.css">
+</head>
+<body>
+ <header class="masthead">
+ <a class="wordmark" href="./" aria-label="Pure 작업 공간">pure<span> / workbench</span></a>
+ <span class="experiment">제작 경험 실험 004</span>
+ <div class="connection"><span id="ready-dot" class="dot"></span><span id="ready-status" role="status">실행기 준비 중</span></div>
+ </header>
+
+ <main>
+ <section class="intro" aria-labelledby="title">
+ <div><p class="eyebrow">빈 코드 · 직접 만든 동작</p><h1 id="title">무엇을 움직이게 할까요?</h1></div>
+ <p>상태를 만들고, 입력에 동작을 연결하세요.<br>코드를 <strong>적용</strong>하면 장면이 바뀝니다. 같은 이름의 상태는 이어집니다.</p>
+ </section>
+
+ <div class="workspace">
+ <section class="editor-panel panel" aria-labelledby="editor-title">
+ <div class="panel-heading"><h2 id="editor-title"><span class="section-index">01</span> 코드</h2><span id="draft-status" class="badge">빈 작업물</span></div>
+ <div class="file-tools" aria-label="파일 관리">
+ <button id="new" class="quiet" type="button">편집기 비우기</button>
+ <button id="upload" class="quiet" type="button">파일 열기</button>
+ <button id="download" class="quiet" type="button">.pure 저장</button>
+ <button id="download-project" class="quiet" type="button">코드 JSON 저장</button>
+ <button id="download-layout" class="quiet" type="button" disabled>배치 저장</button>
+ <input id="file-input" type="file" accept=".pure,.txt,.json,text/plain,application/json" hidden>
+ </div>
+ <label class="sr-only" for="source">Pure 코드 편집기</label>
+ <div class="code-editor">
+ <div id="line-numbers" class="line-numbers" aria-hidden="true">1</div>
+ <textarea id="source" spellcheck="false" autocapitalize="off" autocomplete="off" autocorrect="off" aria-describedby="editor-hint diagnostic" placeholder="여기서 시작하세요.&#10;&#10;아래 ‘언어 안내’에서 작은 조각을 가져올 수 있습니다."></textarea>
+ </div>
+ <div class="editor-footer"><span id="source-size">0 / 32,768 bytes</span><span id="cursor-position">1행 · 1열</span></div>
+ <div class="apply-row"><button id="apply" class="primary" type="button" disabled>코드 적용 <span aria-hidden="true">↗</span></button><p id="editor-hint">편집만으로 실행 중인 코드는 바뀌지 않습니다.</p></div>
+ <div id="diagnostic" class="diagnostic" role="status" aria-live="polite"><span id="diagnostic-text">아직 작성한 코드가 없습니다.</span><button id="error-location" type="button" class="quiet" hidden>문제 위치로</button></div>
+ </section>
+
+ <div class="right-column">
+ <section class="scene-panel panel" aria-labelledby="scene-title">
+ <div class="panel-heading"><h2 id="scene-title"><span class="section-index">02</span> 실행 장면</h2><span id="scene-status" class="badge">적용 전</span></div>
+ <div class="stage-wrap">
+ <svg id="scene" viewBox="0 0 640 360" role="img" tabindex="0" aria-labelledby="scene-description"><title id="scene-description">사용자가 코드로 만든 장면. 현재 비어 있습니다.</title><g id="scene-content"></g></svg>
+ <div id="empty-scene" class="empty-scene"><span class="empty-cross" aria-hidden="true">+</span><strong>아직 아무것도 없습니다</strong><span>도형이나 글자를 선언하고 코드를 적용하세요.</span></div>
+ <span class="stage-dimensions" aria-hidden="true">640 × 360</span>
+ </div>
+ <div class="transport"><button id="play" class="secondary" type="button" disabled>▶ 실행</button><button id="step" type="button" disabled>한 프레임</button><button id="reset" type="button" disabled>초깃값으로</button><span id="clock-status">0 frame · 정지</span></div>
+ <p class="control-note">정지하면 시간만 멈춥니다. 아래 입력은 정지 중에도 작동합니다.</p>
+ <div class="input-heading"><h3>내가 선언한 입력</h3><span>눌렀을 때 .down / 놓았을 때 .up</span></div>
+ <div id="input-controls" class="input-controls"><p class="empty-note">입력을 선언하면 여기에 버튼이 생깁니다.</p></div>
+ <p class="keyboard-note">장면을 클릭하면 키보드로 입력할 수 있습니다. 코드나 폼을 편집하는 동안에는 전달되지 않습니다.</p>
+ </section>
+
+ <section class="inspect-panel panel" aria-labelledby="inspect-title">
+ <div class="panel-heading"><h2 id="inspect-title"><span class="section-index">03</span> 현재 상태</h2><span id="revision" class="mono subdued">revision 0</span></div>
+ <div id="state-values" class="state-values"><p class="empty-note">선언한 상태의 값이 표시됩니다.</p></div>
+ <div class="event-line"><span class="subdued">마지막 입력</span><span id="last-event" class="mono">아직 없음</span></div>
+ <div id="runtime-error" class="runtime-error" role="status" hidden></div>
+ <details class="cost-details"><summary>실행 비용과 범위</summary><dl id="cost-values" class="cost-values"></dl><p>코어가 보고한 WebAssembly 메모리입니다. 브라우저·편집기·렌더러의 메모리와 프로세스 전체 사용량은 포함하지 않습니다. 예약 공간 안에 사용 중인 코드·상태·스테이징 공간이 포함되므로 합산하지 않습니다.</p><p id="dropped-ticks">시간 지연으로 건너뛴 프레임: 0</p></details>
+ </section>
+ </div>
+ </div>
+
+ <section class="reference panel" aria-labelledby="reference-title">
+ <div class="panel-heading"><h2 id="reference-title">언어 안내</h2><span class="subdued">조각을 조합해 자신의 동작을 만드세요.</span></div>
+ <div class="reference-grid">
+ <article><h3>1. 기억할 값</h3><p>이름은 영문으로, 처음 값은 숫자로 정합니다.</p><pre><code>state count = 0
+state x = 80</code></pre><button class="insert-snippet quiet" data-snippet="state" type="button">상태 조각 삽입</button></article>
+ <article><h3>2. 입력과 반응</h3><p>버튼 이름과 키를 선언하고 .down / .up에 연결합니다.</p><pre><code>input tap = Space
+on tap.down {
+ count = count + 1
+}</code></pre><button class="insert-snippet quiet" data-snippet="input" type="button">입력 조각 삽입</button></article>
+ <article><h3>3. 보일 것</h3><p>좌표와 크기를 상태에 연결할 수 있습니다.</p><pre><code>box tile(x, 180, 40, 40, "#7ed9ae")
+text label(40, 60, count, "Count: ")</code></pre><button class="insert-snippet quiet" data-snippet="view" type="button">장면 조각 삽입</button></article>
+ <article><h3>4. 시간에 따른 변화</h3><p>실행하면 매 프레임 반응합니다. dt는 1/60초입니다.</p><pre><code>on frame {
+ x = x + 30 * dt
+ if x > 600 { x = 0 }
+}</code></pre><button class="insert-snippet quiet" data-snippet="frame" type="button">시간 조각 삽입</button></article>
+ </div>
+ <details class="syntax-details"><summary>표현식 · 입력 키 · 제한 보기</summary><div class="syntax-columns"><div><p><strong>표현식</strong><br><code>+ - * /</code>, <code>&lt; &lt;= &gt; &gt;= == !=</code>, <code>&amp;&amp; || !</code>, 괄호, <code>min(a,b)</code>, <code>max(a,b)</code>, <code>clamp(x,lo,hi)</code>를 사용합니다. 논리 연산은 양쪽을 모두 계산합니다.</p><p><strong>도형</strong><br><code>box id(x,y,width,height,"#rrggbb")</code><br><code>circle id(x,y,radius,"#rrggbb")</code><br><code>text id(x,y,value,"접두사")</code><br>글자의 value는 숫자 표현식입니다. 문자 접두사는 생략할 수 있습니다.</p></div><div><p><strong>입력</strong><br><code>Space</code>, <code>Enter</code>, <code>ArrowLeft/Right/Up/Down</code>, <code>KeyA</code>~<code>KeyZ</code>, <code>Digit0</code>~<code>Digit9</code>. 같은 키는 하나의 입력에 연결합니다.</p><p><strong>직접 조작</strong><br><code>input drag = pointer(tile)</code>로 도형에 입력을 연결합니다. <code>on drag.down / .move / .up / .cancel</code>에서 <code>event_x</code>, <code>event_y</code>는 장면 좌표입니다. 이동 방식과 취소 시 동작도 코드로 정합니다. 초점이나 포인터 캡처를 잃으면 <code>.cancel</code>이 전달됩니다.</p><p><strong>값과 규칙 재사용</strong><br><code>record Tile { x=0; y=0 }</code>로 필드를 정하고 <code>item green = Tile(x=80)</code>으로 대상을 만듭니다. <code>rule move(t: Tile) { t.x=event_x }</code>를 <code>on drag.move use move(green)</code>으로 연결합니다. 화면에서는 <code>green.x</code>를 읽습니다. 규칙은 연결한 대상의 값을 직접 바꿉니다.</p><p><strong>여러 도형의 입력 묶기</strong><br><code>input drag = pointer { green_box: green; blue_box: blue }</code>로 연결하고 <code>on drag.move use move(drag.target)</code>으로 현재 조작 대상을 전달합니다. 같은 레코드 타입의 대상끼리 묶으며, 각 도형은 따로 잡고 놓을 수 있습니다.</p><p><strong>이 실험의 범위</strong><br>상태 32개 · 입력 8개 · 도형/글자 24개 · 반응 64개 · 컴파일된 명령 512개 · 코드 32 KiB. 반복문, 함수 선언, 자동 충돌·물리는 없습니다. 줄바꿈 또는 세미콜론으로 구분하고 <code>#</code>으로 주석을 씁니다.</p><p>0으로 나누기 등 계산 실패 시 해당 이벤트의 상태 변경 전체를 취소합니다. 입력을 누르고 있을 때는 새 코드를 적용할 수 없습니다.</p></div></div></details>
+ <div class="example-row"><div><label for="example">완성된 예제 살펴보기</label><p>선택한 코드를 편집기에 불러옵니다. 자동으로 적용되지 않습니다.</p></div><select id="example"><option value="counter">카운터</option><option value="charge">누르는 동안 충전</option><option value="toggle">입력으로 전환</option><option value="direct">직접 잡아 이동</option><option value="layout">세 도형 배치</option><option value="shared-layout">공유 규칙으로 배치</option><option value="routed-layout">입력 묶어서 배치</option></select><button id="load-example" type="button">예제 불러오기</button></div>
+ <div class="baseline-row"><a href="./baseline.html" target="_blank" rel="noopener">같은 동작을 일반 JavaScript로 만든 비교 앱 ↗</a><span>충전 예제와 비교할 수 있습니다. 새 탭에서 열립니다.</span></div>
+ </section>
+ <footer class="page-footer"><span>Pure · authoring experiment</span><p>원하는 동작을 만들어보고, <strong>하려던 일 / 실제로 일어난 일 / 기대했던 일</strong>을 남겨주세요.</p></footer>
+ </main>
+ <script type="module" src="./app.js"></script>
+</body>
+</html>
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/output.mjs b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/output.mjs
new file mode 100644
index 0000000..694fce6
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/output.mjs
@@ -0,0 +1,103 @@
+// Bind authored declarations to concrete DOM destinations once per apply.
+// The DOM holds the published values; no parallel scene/value model is kept.
+const NS = 'http://www.w3.org/2000/svg';
+const number = value => Number.isInteger(value) ? String(value) : Number(value.toPrecision(7)).toString();
+const attributes = { box: { x: 'x', y: 'y', width: 'width', height: 'height' },
+ circle: { x: 'cx', y: 'cy', radius: 'r' }, text: { x: 'x', y: 'y' } };
+
+function text(node, value) {
+ if (node.textContent !== value) node.textContent = value;
+}
+function attribute(node, name, value) {
+ const next = String(value);
+ if (node.getAttribute(name) !== next) node.setAttribute(name, next);
+}
+
+export function createOutput(document) {
+ const get = id => document.getElementById(id);
+ const scene = get('scene-content'), states = get('state-values');
+ const empty = get('empty-scene'), description = get('scene-description');
+ let active = undefined, nodes = [], amounts = [];
+ const costNames = ['Wasm 선형 메모리', '코어 예약 공간', '사용 중인 명령·연결',
+ '현재 상태 값', '새 코드 준비 공간 (예약에 포함)', '평가 스택 용량',
+ '마지막 이벤트 실행 명령', '적용된 소스 크기'];
+ const costs = costNames.map(label => {
+ const dt = document.createElement('dt'), dd = document.createElement('dd');
+ dt.textContent = label;
+ return { dt, dd };
+ });
+ get('cost-values').replaceChildren(...costs.flatMap(({dt, dd}) => [dt, dd]));
+
+ return {
+ begin(program) {
+ if (program === active) return;
+ active = program;
+ const targets = new Map();
+ for (const input of program?.inputs ?? []) {
+ if (input.target) targets.set(input.target, {name:input.name});
+ for (const route of input.routes ?? []) targets.set(route.target, {name:input.name,target:route.target});
+ }
+ scene.ownerSVGElement.classList.toggle('direct-input', targets.size > 0);
+ nodes = (program?.views ?? []).map(view => {
+ const node = document.createElementNS(NS, {box:'rect', circle:'circle', text:'text'}[view.type]);
+ node.setAttribute('data-view-id', view.id);
+ if (targets.has(view.id)) {
+ const input = targets.get(view.id);
+ node.setAttribute('data-point-input', input.name);
+ if (input.target) node.setAttribute('data-point-target', input.target);
+ }
+ node.setAttribute('fill', view.color ?? '#e7ece6');
+ if (view.type === 'text') {
+ node.setAttribute('font-family', 'ui-monospace, monospace');
+ node.setAttribute('font-size', '20');
+ }
+ return node;
+ });
+ scene.replaceChildren(...nodes);
+ const holders = [];
+ amounts = (program?.states ?? []).map(state => {
+ const dl = document.createElement('dl'), dt = document.createElement('dt'), dd = document.createElement('dd');
+ dl.className = 'state-value'; dt.textContent = state.name; dd.dataset.state = state.name;
+ dl.append(dt, dd); holders.push(dl);
+ return dd;
+ });
+ if (!holders.length) {
+ const note = document.createElement('p');
+ note.className = 'empty-note'; note.textContent = '선언한 상태의 값이 표시됩니다.';
+ holders.push(note);
+ }
+ states.replaceChildren(...holders);
+ },
+ state(index, value) { text(amounts[index], number(value)); },
+ view(index, valid) {
+ const node = nodes[index];
+ if (valid) { if (node.hasAttribute('display')) node.removeAttribute('display'); }
+ else attribute(node, 'display', 'none');
+ },
+ value(index, name, value) {
+ const view = active.views[index], node = nodes[index];
+ if (name === 'value') text(node, `${view.prefix || ''}${number(value)}`);
+ else attribute(node, attributes[view.type][name], value);
+ },
+ end(visible) {
+ const hidden = visible !== 0;
+ if (empty.hidden !== hidden) empty.hidden = hidden;
+ const failed = !visible && nodes.length > 0;
+ text(empty.querySelector('strong'), failed ? '장면을 표시할 수 없습니다' : '아직 아무것도 없습니다');
+ text(empty.querySelector('span:last-child'), failed ? '현재 상태 아래의 표시 오류를 확인하세요.' : '도형이나 글자를 선언하고 코드를 적용하세요.');
+ text(description, visible ? `사용자가 만든 장면. 도형과 글자 ${visible}개.` : failed ? '표시 오류로 장면을 표시할 수 없습니다.' : '사용자가 코드로 만든 장면. 현재 비어 있습니다.');
+ },
+ costs(status, metrics, droppedTicks) {
+ const m = status.memory;
+ const values = [m.linearBytes, m.reservedEngineBytes, m.activeIRBytes, m.activeStateBytes,
+ m.stagingReservedBytes, m.evalStackBytes, status.lastOps, metrics?.sourceBytes];
+ for (let i = 0; i < values.length; i++) {
+ const value = values[i], {dt, dd} = costs[i], hidden = !Number.isFinite(value);
+ if (dt.hidden !== hidden) dt.hidden = hidden;
+ if (dd.hidden !== hidden) dd.hidden = hidden;
+ if (!hidden) text(dd, i === 6 ? `${value}개` : `${value.toLocaleString('en-US')} B`);
+ }
+ text(get('dropped-ticks'), `시간 지연으로 건너뛴 프레임: ${droppedTicks}`);
+ },
+ };
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/runtime.mjs b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/runtime.mjs
new file mode 100644
index 0000000..eb36839
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/runtime.mjs
@@ -0,0 +1,339 @@
+import { compile } from './compiler.mjs';
+import { validateDocument } from './project.mjs';
+
+const messages = [
+ '', '실행기의 용량 한도를 넘었습니다.', '실행 정의가 유효하지 않습니다.',
+ '계산 스택의 범위를 벗어났습니다.', '0으로 나눌 수 없습니다.',
+ '계산 결과가 유한한 수의 범위를 벗어났습니다.', '조건 분기 범위가 유효하지 않습니다.',
+ '존재하지 않는 상태를 참조했습니다.', '시간 간격이 유효하지 않습니다.',
+ '정의되지 않은 입력 사건입니다.',
+];
+const sourceLimit = 32768;
+
+function problem(message, line = null, column = null) {
+ return Object.assign(new Error(message), { line, column });
+}
+
+function freezeProgram(value) {
+ if (value && typeof value === 'object') {
+ for (const child of Object.values(value)) freezeProgram(child);
+ Object.freeze(value);
+ }
+ return value;
+}
+
+export async function createRuntime(wasmBytes, evaluationOptions = {}) {
+ if (wasmBytes === undefined) {
+ const response = await fetch(new URL('./core.wasm', import.meta.url), { cache: 'no-store' });
+ if (!response.ok) throw new Error(`실행기를 불러오지 못했습니다 (HTTP ${response.status}).`);
+ wasmBytes = await response.arrayBuffer();
+ }
+ const module = await WebAssembly.compile(wasmBytes);
+ if (WebAssembly.Module.imports(module).length) throw new Error('이 실행기는 호스트 함수 없이 동작해야 합니다.');
+ const { exports: core } = await WebAssembly.instantiate(module, {});
+ if (typeof core.eval_bounded !== 'function' || typeof core.abort_program !== 'function' || typeof core.dispatch_point !== 'function' || typeof core.put_restored_value !== 'function' || typeof core.commit_restored_program !== 'function' || typeof core.bind_handler !== 'function' || typeof core.put_pointer_target !== 'function' || typeof core.bind_target_handler !== 'function' || typeof core.dispatch_target !== 'function')
+ throw new Error('실행기 버전이 맞지 않습니다. 새로고침하거나 core.wasm을 다시 빌드하세요.');
+ core.reset_all();
+ let program = null;
+ let appliedSource = '';
+ let lastEvent = null;
+ // Keep a failed event visible through its release/focus-loss cleanup.
+ // The active program can clear this diagnostic by apply or explicit reset.
+ let lastFailure = null;
+ const held = new Map();
+ const points = new Map();
+ let presenting = false;
+ let bindings = [];
+ let pointRoutes = new Map();
+ // One bounded publication buffer, never exposed to callers. No state mirror.
+ let displayValues = null, displayValid = null;
+ function available() {
+ if (presenting) throw new Error('출력 전달 중에는 실행기를 다시 호출할 수 없습니다.');
+ }
+ let evaluationPolicy = { cacheEntries: 0, historyEntries: 8, workLimit: 2048 };
+
+ function checked(code, line = null) {
+ if (code !== 0) throw problem(messages[code] || `실행기 오류 ${code}`, line);
+ }
+ function inputIndex(name) {
+ const index = program?.inputs.findIndex((input) => input.name === name) ?? -1;
+ if (index < 0) throw new Error(`정의되지 않은 입력: ${name}`);
+ return index;
+ }
+ function dispatch(event, name, phase, source, point = null) {
+ const status = point ? (point.route === undefined ? core.dispatch_point(event, point.x, point.y)
+ : core.dispatch_target(event, point.route, point.x, point.y)) : core.dispatch(event, event === 0 ? 1 / 60 : 0);
+ const pc = core.info(2) >>> 0;
+ lastEvent = { name, phase, source, status,
+ line: program?.instructions[pc]?.line ?? null, ops: core.info(3) >>> 0 };
+ if (point) { lastEvent.x = point.x; lastEvent.y = point.y; if (point.target) lastEvent.target = point.target; }
+ if (status) lastFailure = { ...lastEvent };
+ return status;
+ }
+
+ function evaluateDisplay() {
+ displayValues ??= new Float64Array(96);
+ displayValid ??= new Uint8Array(24);
+ core.begin_evaluation(evaluationPolicy.workLimit);
+ let error = null, slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ displayValid[i] = 1;
+ for (const [name, binding] of bindings[i]) {
+ const value = core.eval_bounded(binding.start, binding.length);
+ if (core.evaluation_info(0) === 10) {
+ displayValid.fill(0);
+ return { code: 101, line: view.line,
+ message: '표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.' };
+ }
+ if (!Number.isFinite(value) || ((name === 'width' || name === 'height' || name === 'radius') && value < 0)) {
+ error ??= { line: view.line, message: `${view.id}.${name}: 표시할 수 없는 값입니다. 0 나눗셈이나 음수 크기를 확인하세요.` };
+ displayValid[i] = 0;
+ }
+ displayValues[slot++] = value;
+ }
+ }
+ return error;
+ }
+
+ function status(viewError) {
+ const executionError = lastFailure?.status || 0;
+ return {
+ tick: core.info(0) >>> 0, revision: core.info(4) >>> 0,
+ displayError: viewError ? { code: viewError.code || 100, ...viewError } : null,
+ lastError: executionError || (viewError ? viewError.code || 100 : 0),
+ errorMessage: executionError ? messages[executionError] || `실행 오류 ${executionError}` : viewError?.message || '',
+ errorLine: executionError ? lastFailure.line : viewError?.line ?? null,
+ lastOps: core.info(3) >>> 0, successfulEvents: core.info(9) >>> 0,
+ held: [...held.keys()], lastEvent: lastEvent ? { ...lastEvent } : null,
+ lastFailure: lastFailure ? { ...lastFailure } : null,
+ memory: {
+ linearBytes: core.memory.buffer.byteLength,
+ reservedEngineBytes: core.layout(0), programReservedBytes: core.layout(1),
+ instructionBytes: core.layout(2), handlerBytes: core.layout(3),
+ activeIRBytes: core.layout(4), activeStateBytes: core.layout(5),
+ stagingReservedBytes: core.layout(6), evalStackBytes: core.layout(7),
+ pointerTargetBytes: core.layout(8), pointerTargets: core.layout(9),
+ },
+ };
+ }
+
+ function load(next, source, restored = null) {
+ const oldIds = new Map(restored ? [] : program?.states.map((state, index) => [state.name, index]) ?? []);
+ checked(core.begin_program(next.states.length, next.inputs.length));
+ try {
+ for (let index = 0; index < next.states.length; index++) {
+ const state = next.states[index];
+ checked(core.put_state(index, state.initial, oldIds.get(state.name) ?? -1), state.line);
+ if (restored) checked(core.put_restored_value(index, restored[index]), state.line);
+ }
+ for (const op of next.instructions) checked(core.put_instruction(op.op, op.arg, op.value), op.line);
+ for (const target of next.pointerTargets) checked(core.put_pointer_target(target.input, target.base, target.count));
+ for (const [index, handler] of next.handlers.entries()) {
+ checked(core.put_handler(handler.event, handler.start, handler.length), handler.line);
+ if (handler.parameterCount)
+ checked(handler.parameterBase === 255 ? core.bind_target_handler(index, handler.parameterCount)
+ : core.bind_handler(index, handler.parameterBase, handler.parameterCount), handler.line);
+ }
+ checked(restored ? core.commit_restored_program() : core.commit_program());
+ } catch (error) {
+ core.abort_program();
+ throw error;
+ }
+ pointRoutes = new Map(next.inputs.flatMap(input => (input.routes ?? []).map(route => [route.target, route])));
+ program = next;
+ bindings = next.views.map(view => Object.entries(view.bindings));
+ appliedSource = source;
+ lastEvent = null;
+ lastFailure = null;
+ }
+
+ const api = {
+ get currentProgram() { return program; },
+ configureEvaluation({ cacheEntries = evaluationPolicy.cacheEntries,
+ historyEntries = evaluationPolicy.historyEntries, workLimit = evaluationPolicy.workLimit } = {}) {
+ available();
+ for (const [name, value, maximum] of [['cacheEntries', cacheEntries, 32],
+ ['historyEntries', historyEntries, 8], ['workLimit', workLimit, 0xffffffff]]) {
+ if (!Number.isInteger(value) || value < 0 || value > maximum)
+ throw new Error(`${name}: 0~${maximum} 사이의 정수여야 합니다.`);
+ }
+ checked(core.configure_evaluation(cacheEntries, historyEntries));
+ evaluationPolicy = { cacheEntries, historyEntries, workLimit };
+ return { ...evaluationPolicy };
+ },
+ forgetEvaluation(part = 'all') {
+ available();
+ const flags = part === 'all' ? 3 : part === 'values' ? 1 : part === 'history' ? 2 : undefined;
+ if (flags === undefined) throw new Error('삭제할 기록은 values, history, all 중 하나여야 합니다.');
+ core.forget_evaluation(flags);
+ },
+ evaluationStats() {
+ return { ...evaluationPolicy, work: core.evaluation_info(1),
+ instructions: core.evaluation_info(2), cacheHits: core.evaluation_info(3),
+ reservedBytes: core.evaluation_info(6), dedicatedBytes: core.evaluation_info(7),
+ sharedBytes: core.evaluation_info(8), reuseAvailable: Boolean(core.evaluation_info(9)),
+ workspaceTagBytes: core.evaluation_info(10) };
+ },
+ apply(source) {
+ available();
+ if (typeof source !== 'string' || new TextEncoder().encode(source).length > sourceLimit) {
+ throw problem('정의는 UTF-8 기준 32,768바이트 이하의 텍스트여야 합니다.');
+ }
+ if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 적용하세요. 현재 작업은 그대로 유지합니다.');
+ const next = freezeProgram(compile(source));
+ load(next, source);
+ return next;
+ },
+ restoreDocument(document) {
+ available();
+ if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 복원하세요.');
+ const next = validateDocument(document);
+ load(freezeProgram(next.program), next.source, next.values);
+ return { source: next.source, draft: next.draft };
+ },
+ exportDocument(draft = appliedSource) {
+ available();
+ if (!program) throw problem('먼저 코드를 적용하세요.');
+ if (held.size) throw problem('조작을 끝낸 뒤 배치를 저장하세요.');
+ if (lastFailure) throw problem('실행 오류를 해결한 뒤 배치를 저장하세요.');
+ if (typeof draft !== 'string' || new TextEncoder().encode(draft).length > sourceLimit)
+ throw problem('편집 중인 코드는 UTF-8 32 KiB 이하여야 합니다.');
+ return { format: 'pure-workbench', version: 2, source: appliedSource, draft,
+ states: program.states.map((state, i) => {
+ const value = core.get_state(i);
+ return { name: state.name, value: Object.is(value, -0) ? '-0' : value };
+ }) };
+ },
+ reset() {
+ available();
+ held.clear();
+ points.clear();
+ core.reset_state();
+ lastEvent = null;
+ lastFailure = null;
+ },
+ frame(count = 1) {
+ available();
+ if (!Number.isInteger(count) || count < 0 || count > 600) throw new Error('프레임 수는 0~600 사이의 정수여야 합니다.');
+ if (!program) return 0;
+ for (let i = 0; i < count; i++) {
+ const code = dispatch(0, 'frame', 'frame', 'clock');
+ if (code !== 0) return code;
+ }
+ return 0;
+ },
+ down(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (program.inputs[index].target || program.inputs[index].routes) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.');
+ const sources = held.get(name) || new Set();
+ if (sources.has(source)) return 0;
+ const wasDown = sources.size > 0;
+ sources.add(source);
+ held.set(name, sources);
+ return wasDown ? 0 : dispatch(1 + index * 2, name, 'down', source);
+ },
+ up(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (program.inputs[index].target || program.inputs[index].routes) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.');
+ const sources = held.get(name);
+ if (!sources?.delete(source)) return 0;
+ if (sources.size) return 0;
+ held.delete(name);
+ return dispatch(2 + index * 2, name, 'up', source);
+ },
+ point(name, phase, x, y, source = 'external', target = undefined) {
+ available();
+ const index = inputIndex(name);
+ const input = program.inputs[index];
+ if (!input.target && !input.routes) throw new Error('포인터에 연결된 입력이 아닙니다.');
+ const route = input.routes ? pointRoutes.get(target) : undefined;
+ if (input.routes && (!route || program.pointerTargets[route.route].input !== index)) throw new Error('포인터 그룹에 연결된 도형 이름이 필요합니다.');
+ if (!input.routes && target !== undefined) throw new Error('단일 포인터 입력에는 대상 인자를 지정하지 않습니다.');
+ const owner = route ? `${name}:${target}` : name;
+ if (!['down', 'move', 'up', 'cancel'].includes(phase)) throw new Error('포인터 단계는 down, move, up, cancel 중 하나여야 합니다.');
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error('포인터 좌표는 유한한 숫자여야 합니다.');
+ if (typeof source !== 'string' || source.length === 0) throw new Error('포인터 출처 이름이 필요합니다.');
+ let point = points.get(owner);
+ if (phase === 'down') {
+ if (point?.source === source) return 0;
+ if (point) throw new Error('이 입력은 다른 포인터가 사용 중입니다.');
+ point = {name, source, x, y, target, route:route?.route};
+ points.set(owner, point);
+ held.set(owner, new Set([source]));
+ } else {
+ if (!point || point.source !== source) return 0;
+ point.x = x; point.y = y;
+ if (phase === 'up' || phase === 'cancel') { points.delete(owner); held.delete(owner); }
+ }
+ const event = phase === 'move' ? 17 + index : phase === 'cancel' ? 25 + index : 1 + index * 2 + (phase === 'up' ? 1 : 0);
+ return dispatch(event, name, phase, source, point);
+ },
+ releaseAll() {
+ available();
+ let firstError = 0;
+ for (const point of [...points.values()]) {
+ const status = api.point(point.name, 'cancel', point.x, point.y, point.source, point.target);
+ if (!firstError) firstError = status;
+ }
+ for (const [name, sources] of [...held]) {
+ for (const source of [...sources]) {
+ const status = api.up(name, source);
+ if (!firstError) firstError = status;
+ }
+ }
+ return firstError;
+ },
+ exportProject() {
+ return { format: 'pure-workbench', version: 1, source: appliedSource };
+ },
+ // Synchronous trusted output adapter. Callbacks run only after the whole
+ // display has been evaluated. Runtime mutation/reentrant reads are refused.
+ // Exceptions from an adapter propagate; the next call republishes all values.
+ present(target) {
+ available();
+ presenting = true;
+ try {
+ const { begin, state, view, value, end } = target;
+ for (const callback of [begin, state, view, value, end])
+ if (typeof callback !== 'function') throw new TypeError('출력 어댑터 함수가 필요합니다.');
+ const error = evaluateDisplay();
+ begin.call(target, program);
+ for (let i = 0; i < (program?.states.length ?? 0); i++) state.call(target, i, core.get_state(i));
+ let slot = 0, visible = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const valid = Boolean(displayValid[i]);
+ view.call(target, i, valid);
+ for (const [name] of bindings[i]) {
+ if (valid) value.call(target, i, name, displayValues[slot]);
+ slot++;
+ }
+ if (valid) visible++;
+ }
+ end.call(target, visible);
+ return status(error);
+ } finally { presenting = false; }
+ },
+ snapshot() {
+ available();
+ const error = evaluateDisplay();
+ const states = Object.fromEntries((program?.states ?? []).map((state, index) => [state.name, core.get_state(index)]));
+ const views = [];
+ let slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ const rendered = displayValid[i] ? { id: view.id, type: view.type, color: view.color, prefix: view.prefix } : null;
+ for (const [name] of bindings[i]) {
+ if (rendered) rendered[name] = displayValues[slot];
+ slot++;
+ }
+ if (rendered) views.push(rendered);
+ }
+ return { states, views, ...status(error) };
+ },
+ };
+ api.configureEvaluation(evaluationOptions);
+ return Object.freeze(api);
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/scene-input.mjs b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/scene-input.mjs
new file mode 100644
index 0000000..0daf079
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/scene-input.mjs
@@ -0,0 +1,84 @@
+// This adapter owns browser capture only. Authored state changes stay in the VM.
+export function bindSceneInput(scene, runtime, act) {
+ const captures = new Map();
+ const events = scene.ownerDocument.defaultView;
+ const coordinates = event => {
+ const matrix = scene.getScreenCTM();
+ if (!matrix) return null;
+ try {
+ const p = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse());
+ return Number.isFinite(p.x) && Number.isFinite(p.y) ? p : null;
+ } catch { return null; }
+ };
+ const release = id => {
+ if (scene.hasPointerCapture(id)) scene.releasePointerCapture(id);
+ };
+ function finish(event, phase, pointerId = event.pointerId) {
+ const entry = captures.get(pointerId);
+ if (!entry) return;
+ event.preventDefault();
+ // Capture loss/cancel may not carry a meaningful position. Use the last
+ // delivered coordinates. Up must use its own final position.
+ const p = phase === 'up' ? coordinates(event) : null;
+ if (p) { entry.x = p.x; entry.y = p.y; }
+ if (phase === 'up' && !p) phase = 'cancel';
+ captures.delete(pointerId);
+ release(pointerId);
+ act(() => runtime().point(entry.name, phase, entry.x, entry.y, entry.source, entry.target));
+ }
+ scene.addEventListener('pointerdown', event => {
+ if (event.button !== 0 || !runtime() || captures.has(event.pointerId)) return;
+ const target = event.target.closest?.('[data-point-input]');
+ if (!target || !scene.contains(target)) return;
+ const name = target.getAttribute('data-point-input');
+ const targetId = target.getAttribute('data-point-target') ?? undefined;
+ // Each target has one coordinate owner. A second contact on it must not
+ // steal the gesture; other authored targets can be manipulated independently.
+ if ([...captures.values()].some(entry => entry.name === name && entry.target === targetId)) return;
+ const p = coordinates(event);
+ if (!p) return;
+ event.preventDefault(); scene.focus();
+ act(() => {
+ scene.setPointerCapture(event.pointerId);
+ const entry = {name, target:targetId, source:`scene:${event.pointerId}`, pointerType:event.pointerType, x:p.x, y:p.y};
+ captures.set(event.pointerId, entry);
+ try { runtime().point(name, 'down', p.x, p.y, entry.source, entry.target); }
+ catch (error) { captures.delete(event.pointerId); release(event.pointerId); throw error; }
+ });
+ });
+ scene.addEventListener('pointermove', event => {
+ const entry = captures.get(event.pointerId);
+ if (!entry) return;
+ const p = coordinates(event);
+ if (!p) { finish(event, 'cancel'); return; }
+ event.preventDefault();
+ entry.x = p.x; entry.y = p.y;
+ act(() => runtime().point(entry.name, 'move', p.x, p.y, entry.source, entry.target));
+ });
+ // A host may deliver release outside the SVG even while it owns capture.
+ // Observe termination before target handlers or capture cleanup can cancel it.
+ events.addEventListener('pointerup', event => finish(event, 'up'), true);
+ events.addEventListener('pointercancel', event => finish(event, 'cancel'), true);
+ events.addEventListener('mouseup', event => {
+ if (event.button !== 0) return;
+ // Mouse-only fallback when the host delivers mouseup without pointerup.
+ // A normal pointerup already removed the owner; touch compatibility events
+ // must never finish a different contact.
+ for (const [id, entry] of captures) {
+ if (entry.pointerType === 'mouse') finish(event, 'up', id);
+ }
+ }, true);
+ scene.addEventListener('lostpointercapture', event => {
+ // Descendant capture transitions bubble here but do not release our owner.
+ if (event.target === scene) finish(event, 'cancel');
+ });
+ return Object.freeze({
+ releaseCaptures() {
+ const ids = [...captures.keys()];
+ captures.clear();
+ for (const id of ids) release(id);
+ // The caller subsequently invokes runtime.releaseAll(), which dispatches
+ // each pending cancel once. Clearing first makes lostcapture harmless.
+ },
+ });
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/sources/workspace.test.mjs b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/workspace.test.mjs
new file mode 100644
index 0000000..799f056
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/workspace.test.mjs
@@ -0,0 +1,91 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { compile } from './web/compiler.mjs';
+import { createRuntime } from './web/runtime.mjs';
+
+const wasm = await readFile(new URL('./web/core.wasm', import.meta.url));
+const source = factor => `state x = 10
+input next = KeyN
+text label(0, 0, x * ${factor})
+on next.down { x = x + 0.25 }
+`;
+
+function begin(core, program, preserve) {
+ assert.equal(core.begin_program(program.states.length, program.inputs.length), 0);
+ program.states.forEach((state, i) => assert.equal(core.put_state(i, state.initial, preserve ? i : -1), 0));
+}
+
+function finish(core, program, during = () => {}) {
+ for (const op of program.instructions) {
+ assert.equal(core.put_instruction(op.op, op.arg, op.value), 0);
+ during();
+ }
+ for (const handler of program.handlers)
+ assert.equal(core.put_handler(handler.event, handler.start, handler.length), 0);
+ assert.equal(core.commit_program(), 0);
+}
+
+function observe(core, binding) {
+ core.begin_evaluation(100);
+ const value = core.eval_bounded(binding.start, binding.length);
+ assert.equal(core.evaluation_info(0), 0);
+ return value;
+}
+
+test('Wasm executes old events and output during staged replacement without corrupting either phase', async () => {
+ const {instance:{exports:core}} = await WebAssembly.instantiate(wasm);
+ core.reset_all();
+ const old = compile(source(2)), next = compile(source(3));
+ begin(core, old, false); finish(core, old);
+ assert.equal(core.configure_evaluation(32, 8), 0);
+ const binding = old.views[0].bindings.value;
+ assert.equal(observe(core, binding), 20);
+ assert.equal(observe(core, binding), 20);
+ assert.equal(core.evaluation_info(3), 1);
+ begin(core, next, true);
+ let value = 10;
+ finish(core, next, () => {
+ assert.equal(core.evaluation_info(9), 0);
+ assert.equal(core.dispatch(1, 0), 0); value += 0.25;
+ assert.equal(core.configure_evaluation(32, 8), 0);
+ core.forget_evaluation(3);
+ assert.equal(observe(core, binding), value * 2);
+ assert.equal(core.evaluation_info(3), 0);
+ });
+ assert.equal(core.evaluation_info(9), 1);
+ assert.equal(core.get_state(0), value);
+ const replaced = next.views[0].bindings.value;
+ assert.equal(observe(core, replaced), value * 3);
+ assert.equal(observe(core, replaced), value * 3);
+ assert.equal(core.evaluation_info(3), 1);
+
+ const revision = core.info(4);
+ begin(core, old, true);
+ assert.equal(core.put_instruction(0, 0, Infinity), 5);
+ assert.equal(core.commit_program(), 5);
+ assert.equal(observe(core, replaced), value * 3);
+ core.abort_program();
+ assert.equal(core.info(4), revision);
+ assert.equal(core.get_state(0), value);
+ assert.equal(core.evaluation_info(9), 1);
+ assert.equal(observe(core, replaced), value * 3);
+ assert.equal(core.memory.buffer.byteLength, 65536);
+});
+
+test('workspace accounting reports shared payload and safety tag without double-counting', async () => {
+ const runtime = await createRuntime(wasm, {cacheEntries:32});
+ runtime.apply(source(2)); runtime.snapshot();
+ const stats = runtime.evaluationStats(), memory = runtime.snapshot().memory;
+ assert.equal(stats.dedicatedBytes, 48);
+ assert.equal(stats.sharedBytes, 1152);
+ assert.equal(stats.workspaceTagBytes, 8);
+ assert.equal(stats.reservedBytes, stats.dedicatedBytes + stats.sharedBytes);
+ const restoreStorage = memory.stagingReservedBytes - memory.programReservedBytes;
+ assert.equal(restoreStorage, 264); // 32 values + mask/alignment, only in pending storage.
+ assert.equal(memory.reservedEngineBytes, 19072 + stats.dedicatedBytes + stats.workspaceTagBytes + restoreStorage + 2 * 64 * (memory.handlerBytes - 12) + 2 * 80);
+ // Each program adds 24 three-byte routes, count/mask and alignment (80 B).
+ assert.equal(memory.pointerTargetBytes, 3);
+ assert(stats.sharedBytes < memory.stagingReservedBytes);
+ assert.equal(stats.reuseAvailable, true);
+});
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/touch-evidence.json b/prototypes/workbench/results/2026-09-23-pointer-routing/touch-evidence.json
new file mode 100644
index 0000000..18c5013
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/touch-evidence.json
@@ -0,0 +1,246 @@
+[
+ {
+ "label": "start",
+ "log": [
+ {
+ "type": "pointerdown",
+ "id": 2,
+ "x": 808.8055419921875,
+ "y": 133.63368225097656
+ },
+ {
+ "type": "pointerdown",
+ "id": 3,
+ "x": 1014.3958129882812,
+ "y": 174.75173950195312
+ }
+ ],
+ "held": [
+ "drag:green_box",
+ "drag:blue_box"
+ ],
+ "states": {
+ "selected": 2,
+ "green.x": 80,
+ "green.y": 120,
+ "green.ox": 9.999986805438311,
+ "green.oy": 10.00000164932024,
+ "green.sx": 80,
+ "green.sy": 120,
+ "green.held": 1,
+ "green.id": 1,
+ "blue.x": 280,
+ "blue.y": 160,
+ "blue.ox": 9.999980208157467,
+ "blue.oy": 10.00000329864045,
+ "blue.sx": 280,
+ "blue.sy": 160,
+ "blue.held": 1,
+ "blue.id": 2,
+ "amber.x": 450,
+ "amber.y": 220,
+ "amber.ox": 0,
+ "amber.oy": 0,
+ "amber.sx": 450,
+ "amber.sy": 220,
+ "amber.held": 0,
+ "amber.id": 3
+ }
+ },
+ {
+ "label": "move",
+ "log": [
+ {
+ "type": "pointerdown",
+ "id": 2,
+ "x": 808.8055419921875,
+ "y": 133.63368225097656
+ },
+ {
+ "type": "pointerdown",
+ "id": 3,
+ "x": 1014.3958129882812,
+ "y": 174.75173950195312
+ },
+ {
+ "type": "pointermove",
+ "id": 3,
+ "x": 1086.3524169921875,
+ "y": 226.14930725097656
+ },
+ {
+ "type": "pointermove",
+ "id": 2,
+ "x": 880.7621459960938,
+ "y": 185.03125
+ }
+ ],
+ "held": [
+ "drag:green_box",
+ "drag:blue_box"
+ ],
+ "states": {
+ "selected": 2,
+ "green.x": 150.00000659728084,
+ "green.y": 169.9999983506798,
+ "green.ox": 9.999986805438311,
+ "green.oy": 10.00000164932024,
+ "green.sx": 80,
+ "green.sy": 120,
+ "green.held": 1,
+ "green.id": 1,
+ "blue.x": 350.00000659728073,
+ "blue.y": 209.9999983506798,
+ "blue.ox": 9.999980208157467,
+ "blue.oy": 10.00000329864045,
+ "blue.sx": 280,
+ "blue.sy": 160,
+ "blue.held": 1,
+ "blue.id": 2,
+ "amber.x": 450,
+ "amber.y": 220,
+ "amber.ox": 0,
+ "amber.oy": 0,
+ "amber.sx": 450,
+ "amber.sy": 220,
+ "amber.held": 0,
+ "amber.id": 3
+ }
+ },
+ {
+ "label": "remaining blue",
+ "log": [
+ {
+ "type": "pointerdown",
+ "id": 2,
+ "x": 808.8055419921875,
+ "y": 133.63368225097656
+ },
+ {
+ "type": "pointerdown",
+ "id": 3,
+ "x": 1014.3958129882812,
+ "y": 174.75173950195312
+ },
+ {
+ "type": "pointermove",
+ "id": 3,
+ "x": 1086.3524169921875,
+ "y": 226.14930725097656
+ },
+ {
+ "type": "pointermove",
+ "id": 2,
+ "x": 880.7621459960938,
+ "y": 185.03125
+ },
+ {
+ "type": "pointerup",
+ "id": 2,
+ "x": 880.7621459960938,
+ "y": 185.03125
+ }
+ ],
+ "held": [
+ "drag:blue_box"
+ ],
+ "states": {
+ "selected": 2,
+ "green.x": 150.00000659728084,
+ "green.y": 169.9999983506798,
+ "green.ox": 9.999986805438311,
+ "green.oy": 10.00000164932024,
+ "green.sx": 80,
+ "green.sy": 120,
+ "green.held": 0,
+ "green.id": 1,
+ "blue.x": 350.00000659728073,
+ "blue.y": 209.9999983506798,
+ "blue.ox": 9.999980208157467,
+ "blue.oy": 10.00000329864045,
+ "blue.sx": 280,
+ "blue.sy": 160,
+ "blue.held": 1,
+ "blue.id": 2,
+ "amber.x": 450,
+ "amber.y": 220,
+ "amber.ox": 0,
+ "amber.oy": 0,
+ "amber.sx": 450,
+ "amber.sy": 220,
+ "amber.held": 0,
+ "amber.id": 3
+ }
+ },
+ {
+ "label": "move remaining blue",
+ "log": [
+ {
+ "type": "pointerdown",
+ "id": 2,
+ "x": 808.8055419921875,
+ "y": 133.63368225097656
+ },
+ {
+ "type": "pointerdown",
+ "id": 3,
+ "x": 1014.3958129882812,
+ "y": 174.75173950195312
+ },
+ {
+ "type": "pointermove",
+ "id": 3,
+ "x": 1086.3524169921875,
+ "y": 226.14930725097656
+ },
+ {
+ "type": "pointermove",
+ "id": 2,
+ "x": 880.7621459960938,
+ "y": 185.03125
+ },
+ {
+ "type": "pointerup",
+ "id": 2,
+ "x": 880.7621459960938,
+ "y": 185.03125
+ },
+ {
+ "type": "pointermove",
+ "id": 3,
+ "x": 1127.470458984375,
+ "y": 256.98785400390625
+ }
+ ],
+ "held": [
+ "drag:blue_box"
+ ],
+ "states": {
+ "selected": 2,
+ "green.x": 150.00000659728084,
+ "green.y": 169.9999983506798,
+ "green.ox": 9.999986805438311,
+ "green.oy": 10.00000164932024,
+ "green.sx": 80,
+ "green.sy": 120,
+ "green.held": 0,
+ "green.id": 1,
+ "blue.x": 389.99999340271904,
+ "blue.y": 240.00000329864045,
+ "blue.ox": 9.999980208157467,
+ "blue.oy": 10.00000329864045,
+ "blue.sx": 280,
+ "blue.sy": 160,
+ "blue.held": 1,
+ "blue.id": 2,
+ "amber.x": 450,
+ "amber.y": 220,
+ "amber.ox": 0,
+ "amber.oy": 0,
+ "amber.sx": 450,
+ "amber.sy": 220,
+ "amber.held": 0,
+ "amber.id": 3
+ }
+ }
+]
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/touch-evidence.log b/prototypes/workbench/results/2026-09-23-pointer-routing/touch-evidence.log
new file mode 100644
index 0000000..1a55063
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/touch-evidence.log
@@ -0,0 +1,12 @@
+### Error
+dialog.accept: Cannot accept dialog which is already handled!
+ at _Page.<anonymous> (evalmachine.<anonymous>:2:25)
+### Result
+[{"label":"start","log":[{"type":"pointerdown","id":2,"x":808.8055419921875,"y":133.63368225097656},{"type":"pointerdown","id":3,"x":1014.3958129882812,"y":174.75173950195312}],"held":["drag:green_box","drag:blue_box"],"states":{"selected":2,"green.x":80,"green.y":120,"green.ox":9.999986805438311,"green.oy":10.00000164932024,"green.sx":80,"green.sy":120,"green.held":1,"green.id":1,"blue.x":280,"blue.y":160,"blue.ox":9.999980208157467,"blue.oy":10.00000329864045,"blue.sx":280,"blue.sy":160,"blue.held":1,"blue.id":2,"amber.x":450,"amber.y":220,"amber.ox":0,"amber.oy":0,"amber.sx":450,"amber.sy":220,"amber.held":0,"amber.id":3}},{"label":"move","log":[{"type":"pointerdown","id":2,"x":808.8055419921875,"y":133.63368225097656},{"type":"pointerdown","id":3,"x":1014.3958129882812,"y":174.75173950195312},{"type":"pointermove","id":3,"x":1086.3524169921875,"y":226.14930725097656},{"type":"pointermove","id":2,"x":880.7621459960938,"y":185.03125}],"held":["drag:green_box","drag:blue_box"],"states":{"selected":2,"green.x":150.00000659728084,"green.y":169.9999983506798,"green.ox":9.999986805438311,"green.oy":10.00000164932024,"green.sx":80,"green.sy":120,"green.held":1,"green.id":1,"blue.x":350.00000659728073,"blue.y":209.9999983506798,"blue.ox":9.999980208157467,"blue.oy":10.00000329864045,"blue.sx":280,"blue.sy":160,"blue.held":1,"blue.id":2,"amber.x":450,"amber.y":220,"amber.ox":0,"amber.oy":0,"amber.sx":450,"amber.sy":220,"amber.held":0,"amber.id":3}},{"label":"remaining blue","log":[{"type":"pointerdown","id":2,"x":808.8055419921875,"y":133.63368225097656},{"type":"pointerdown","id":3,"x":1014.3958129882812,"y":174.75173950195312},{"type":"pointermove","id":3,"x":1086.3524169921875,"y":226.14930725097656},{"type":"pointermove","id":2,"x":880.7621459960938,"y":185.03125},{"type":"pointerup","id":2,"x":880.7621459960938,"y":185.03125}],"held":["drag:blue_box"],"states":{"selected":2,"green.x":150.00000659728084,"green.y":169.9999983506798,"green.ox":9.999986805438311,"green.oy":10.00000164932024,"green.sx":80,"green.sy":120,"green.held":0,"green.id":1,"blue.x":350.00000659728073,"blue.y":209.9999983506798,"blue.ox":9.999980208157467,"blue.oy":10.00000329864045,"blue.sx":280,"blue.sy":160,"blue.held":1,"blue.id":2,"amber.x":450,"amber.y":220,"amber.ox":0,"amber.oy":0,"amber.sx":450,"amber.sy":220,"amber.held":0,"amber.id":3}},{"label":"move remaining blue","log":[{"type":"pointerdown","id":2,"x":808.8055419921875,"y":133.63368225097656},{"type":"pointerdown","id":3,"x":1014.3958129882812,"y":174.75173950195312},{"type":"pointermove","id":3,"x":1086.3524169921875,"y":226.14930725097656},{"type":"pointermove","id":2,"x":880.7621459960938,"y":185.03125},{"type":"pointerup","id":2,"x":880.7621459960938,"y":185.03125},{"type":"pointermove","id":3,"x":1127.470458984375,"y":256.98785400390625}],"held":["drag:blue_box"],"states":{"selected":2,"green.x":150.00000659728084,"green.y":169.9999983506798,"green.ox":9.999986805438311,"green.oy":10.00000164932024,"green.sx":80,"green.sy":120,"green.held":0,"green.id":1,"blue.x":389.99999340271904,"blue.y":240.00000329864045,"blue.ox":9.999980208157467,"blue.oy":10.00000329864045,"blue.sx":280,"blue.sy":160,"blue.held":1,"blue.id":2,"amber.x":450,"amber.y":220,"amber.ox":0,"amber.oy":0,"amber.sx":450,"amber.sy":220,"amber.held":0,"amber.id":3}}]
+### Ran Playwright code
+```js
+await (async page => await page.evaluate(() => window.touchDebug))(page);
+```
+### Page
+- Page URL: http://127.0.0.1:8766/
+- Page Title: Pure · 빈 작업물에서 시작하기
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/touch-reproduction.js b/prototypes/workbench/results/2026-09-23-pointer-routing/touch-reproduction.js
new file mode 100644
index 0000000..11ddd85
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/touch-reproduction.js
@@ -0,0 +1,21 @@
+async page => {
+ page.on('dialog',d=>d.accept());
+ await page.reload();await page.waitForFunction(()=>window.pureWorkbench?.snapshot().ready);
+ await page.locator('#example').selectOption('routed-layout');await page.locator('#load-example').click();await page.locator('#apply').click();await page.locator('#reset').click();
+ await page.locator('#scene').scrollIntoViewIfNeeded();
+ await page.evaluate(()=>{window.pointerLog=[];for(const type of ['pointerdown','pointerup','pointercancel','pointermove'])window.addEventListener(type,e=>window.pointerLog.push({type,id:e.pointerId,x:e.clientX,y:e.clientY}),true);});
+ const session=await page.context().newCDPSession(page);
+ const xy=(x,y)=>page.evaluate(({x,y})=>{const p=new DOMPoint(x,y).matrixTransform(document.querySelector('#scene').getScreenCTM());return{x:p.x,y:p.y}},{x,y});
+ const results=[];const snap=async label=>results.push({label,...await page.evaluate(()=>{const s=window.pureWorkbench.snapshot();return{log:window.pointerLog.slice(),held:s.held,states:s.states};})});
+ await session.send('Emulation.setTouchEmulationEnabled',{enabled:true,maxTouchPoints:2});
+ const p1={id:1,...await xy(90,130)},p2={id:2,...await xy(290,170)};
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1]});
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1,p2]});await snap('start');
+ const m1={id:1,...await xy(160,180)},m2={id:2,...await xy(360,220)};
+ await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[m1,m2]});await snap('move');
+ await session.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[m1]});await snap('remaining blue');
+ await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{id:2,...await xy(400,250)}]});await page.waitForFunction(()=>Math.abs(window.pureWorkbench.snapshot().states['blue.x']-390)<0.1);await snap('move remaining blue');
+ await session.send('Input.dispatchTouchEvent',{type:'touchCancel',touchPoints:[]});
+ await session.send('Emulation.setTouchEmulationEnabled',{enabled:false});await session.detach();
+ await page.evaluate(e=>window.touchDebug=e,results);return results;
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/touch-reproduction.log b/prototypes/workbench/results/2026-09-23-pointer-routing/touch-reproduction.log
new file mode 100644
index 0000000..399b467
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/touch-reproduction.log
@@ -0,0 +1,28 @@
+### Ran Playwright code
+```js
+await (async page => {
+ page.on('dialog',d=>d.accept());
+ await page.reload();await page.waitForFunction(()=>window.pureWorkbench?.snapshot().ready);
+ await page.locator('#example').selectOption('routed-layout');await page.locator('#load-example').click();await page.locator('#apply').click();await page.locator('#reset').click();
+ await page.locator('#scene').scrollIntoViewIfNeeded();
+ await page.evaluate(()=>{window.pointerLog=[];for(const type of ['pointerdown','pointerup','pointercancel'])window.addEventListener(type,e=>window.pointerLog.push({type,id:e.pointerId}),true);});
+ const session=await page.context().newCDPSession(page);
+ const xy=(x,y)=>page.evaluate(({x,y})=>{const p=new DOMPoint(x,y).matrixTransform(document.querySelector('#scene').getScreenCTM());return{x:p.x,y:p.y}},{x,y});
+ const results=[];const snap=async label=>results.push({label,...await page.evaluate(()=>{const s=window.pureWorkbench.snapshot();return{log:window.pointerLog.slice(),held:s.held,states:s.states};})});
+ await session.send('Emulation.setTouchEmulationEnabled',{enabled:true,maxTouchPoints:2});
+ const p1={id:1,...await xy(90,130)},p2={id:2,...await xy(290,170)};
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1]});
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1,p2]});await snap('start');
+ const m1={id:1,...await xy(160,180)},m2={id:2,...await xy(360,220)};
+ await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[m1,m2]});await snap('move');
+ await session.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[m1]});await snap('remaining blue');
+ await session.send('Input.dispatchTouchEvent',{type:'touchCancel',touchPoints:[]});
+ await session.send('Emulation.setTouchEmulationEnabled',{enabled:false});await session.detach();
+ await page.evaluate(e=>window.touchDebug=e,results);return results;
+}
+)(page);
+```
+### Page
+- Page URL: http://127.0.0.1:8766/
+### Modal state
+- ["beforeunload" dialog with message ""]: can be handled by dialog-accept or dialog-dismiss
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/touch-run.log b/prototypes/workbench/results/2026-09-23-pointer-routing/touch-run.log
new file mode 100644
index 0000000..06bee36
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/touch-run.log
@@ -0,0 +1,29 @@
+### Ran Playwright code
+```js
+await (async page => {
+ page.on('dialog',d=>d.accept());
+ await page.reload();await page.waitForFunction(()=>window.pureWorkbench?.snapshot().ready);
+ await page.locator('#example').selectOption('routed-layout');await page.locator('#load-example').click();await page.locator('#apply').click();await page.locator('#reset').click();
+ await page.locator('#scene').scrollIntoViewIfNeeded();
+ await page.evaluate(()=>{window.pointerLog=[];for(const type of ['pointerdown','pointerup','pointercancel','pointermove'])window.addEventListener(type,e=>window.pointerLog.push({type,id:e.pointerId,x:e.clientX,y:e.clientY}),true);});
+ const session=await page.context().newCDPSession(page);
+ const xy=(x,y)=>page.evaluate(({x,y})=>{const p=new DOMPoint(x,y).matrixTransform(document.querySelector('#scene').getScreenCTM());return{x:p.x,y:p.y}},{x,y});
+ const results=[];const snap=async label=>results.push({label,...await page.evaluate(()=>{const s=window.pureWorkbench.snapshot();return{log:window.pointerLog.slice(),held:s.held,states:s.states};})});
+ await session.send('Emulation.setTouchEmulationEnabled',{enabled:true,maxTouchPoints:2});
+ const p1={id:1,...await xy(90,130)},p2={id:2,...await xy(290,170)};
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1]});
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1,p2]});await snap('start');
+ const m1={id:1,...await xy(160,180)},m2={id:2,...await xy(360,220)};
+ await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[m1,m2]});await snap('move');
+ await session.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[m1]});await snap('remaining blue');
+ await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{id:2,...await xy(400,250)}]});await page.waitForFunction(()=>Math.abs(window.pureWorkbench.snapshot().states['blue.x']-390)<0.1);await snap('move remaining blue');
+ await session.send('Input.dispatchTouchEvent',{type:'touchCancel',touchPoints:[]});
+ await session.send('Emulation.setTouchEmulationEnabled',{enabled:false});await session.detach();
+ await page.evaluate(e=>window.touchDebug=e,results);return results;
+}
+)(page);
+```
+### Page
+- Page URL: http://127.0.0.1:8766/
+### Modal state
+- ["beforeunload" dialog with message ""]: can be handled by dialog-accept or dialog-dismiss
diff --git a/prototypes/workbench/results/2026-09-23-pointer-routing/verification.json b/prototypes/workbench/results/2026-09-23-pointer-routing/verification.json
new file mode 100644
index 0000000..1f5189a
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer-routing/verification.json
@@ -0,0 +1,13 @@
+{
+ "nodeTests": 83,
+ "nativeTests": 35,
+ "command": "node --test prototypes/workbench/*.test.mjs",
+ "nodeLog": "node-tests.log",
+ "sourceSha256": "56b8db87bcf7e49ab2b2e8d4166ade6ed04c77571fbb69c60633e76376b46976",
+ "wasmSha256": "b494269c1332f71de9725a4c7f0b58c074b669403d5d9dc95ddfeb7b16967c0d",
+ "browserChecks": {
+ "grouped": 30,
+ "authoring": 44,
+ "legacyPointers": 23
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer/README.md b/prototypes/workbench/results/2026-09-23-pointer/README.md
new file mode 100644
index 0000000..5185e35
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/README.md
@@ -0,0 +1,23 @@
+# Pointer input verification — 2026-09-23
+
+Functional validation, not a speed or memory comparison.
+
+- `build.json`: exact source/Wasm hashes and 26 passing native tests.
+- `verification.json`: 59 passing Node tests; each suite runs with child-process support.
+- `pointer-browser.json`: 26 direct manipulation checks, no page errors.
+- `authoring-browser.json`: 44 existing authoring checks, no page errors.
+- `before/`, `after/`, `manifest.json`: source artifacts and SHA-256 identities.
+- `qa-pointer.js`, `qa-browser.js`: executed browser scripts. The authoring script
+ changes only artifact path prefixes to avoid overwriting earlier evidence.
+- `pure-pointer-desktop.png`, `pure-pointer-mobile.png`: actual browser captures.
+
+Build using Zig 0.15.2 with `python3 prototypes/workbench/manage.py build --zig PATH`.
+Run `node --test prototypes/workbench/*.test.mjs` with Node child-process support.
+Serve with `python3 prototypes/workbench/manage.py serve --port 8766` and execute
+`qa-pointer.js` as a Playwright CLI run-code expression against Chromium.
+Browser tests exercise real mouse input and CDP touch emulation, including two
+contacts. Blur/single-pointer cancellation use explicit event injection. They do
+not validate physical touchscreen hardware or every browser engine.
+
+Test runner elapsed times and build artifact sizes are incidental reproducibility
+metadata, not performance conclusions. No throughput/latency benchmark was run.
diff --git a/prototypes/workbench/results/2026-09-23-pointer/after/CONTRACT.md b/prototypes/workbench/results/2026-09-23-pointer/after/CONTRACT.md
new file mode 100644
index 0000000..3de96b3
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/after/CONTRACT.md
@@ -0,0 +1,308 @@
+# Workbench experiment contract
+
+An author starts with empty source. There is no built-in player, physics, jump,
+score, or timer behavior. All behavior is declared by the author. This is a
+bounded scalar interaction language experiment, not a general language claim.
+
+## Source language
+
+```text
+state count = 0
+input tap = Space
+box tile(80, 150, 60, 60, "#356548")
+circle dot(200, 180, 20, "#c78638")
+text label(80, 100, count, "Count: ")
+on tap.down {
+ count = count + 1
+}
+on frame {
+ if count > 20 {
+ count = 0
+ }
+}
+```
+
+Declarations and statements end at a newline or semicolon; `#` comments outside
+strings. ASCII identifiers, finite numeric literals, state initializers must be
+numeric constants (optional unary sign). Input key codes accepted:
+Space, Enter, ArrowLeft/Right/Up/Down, KeyA..KeyZ, Digit0..Digit9. Input names
+must be unique, key codes unique, names cannot shadow `frame`, `dt`, `min`, `max`,
+`clamp`, `event_x`, `event_y`. State and input names use separate namespaces, entity IDs unique.
+
+Expressions: parentheses, state references, dt (seconds on frame, zero on input),
+unary - and !, + - * /, < <= > >= == !=, && ||, min(a,b), max(a,b), clamp(x,lo,hi).
+Logical operators evaluate both sides. Division by zero/non-finite results fail
+the whole event without committing state changes. No loops, recursion or eval.
+An if without else executes its condition once; assignment statements are ordered.
+Multiple handlers for the same event execute in source order within one atomic event.
+`dt` is allowed in handlers, not view bindings. A frame always uses 1/60 seconds.
+
+box: x,y,width,height expressions + literal hex color; circle: x,y,radius + color;
+text: x,y,value expression + optional literal prefix. Values are world units in a
+640x360 view. No implicit clipping/collision/physics. Render expressions are pure.
+Unknown identifiers and invalid syntax include 1-based line+column diagnostics.
+
+Initial scene is empty. Optional examples are explicit insertions, never auto-run.
+Successful apply preserves scalar states by exact name; new names use initializers,
+removed names disappear. Apply/reset are separate. Failed parse/load changes nothing.
+Apply is refused while any input is held; release first. This avoids inventing
+release events across changed mappings. Focus loss sends .up to ordinary buttons
+and .cancel to pointer inputs.
+
+## Compiler interface (web/compiler.mjs, browser and Node)
+
+`compile(source)` returns:
+```
+{
+ states:[{name,initial,line}], inputs:[{name,key,line} | {name,target,line}],
+ instructions:[{op,arg,value,line}], handlers:[{event,start,length,line}],
+ views:[{id,type,line,color?,prefix?,bindings:{x:{start,length},...}}],
+ metrics:{...}
+}
+```
+Bindings: box x/y/width/height; circle x/y/radius; text x/y/value.
+Compiler throws an Error with .line and .column. Empty source is a valid empty program.
+Handlers and binding expressions all occupy instructions (maximum 512 total), but
+only handlers execute on events; bindings are evaluated on demand by VM eval_at.
+Limits: 32 states, 8 inputs, 24 views, 64 handlers, 512 instructions, stack 64,
+source 32768 UTF-8 bytes. State IDs follow declaration order. Input event IDs:
+frame=0; input i down=1+2*i, up=2+2*i, move=17+i, cancel=25+i.
+Move/cancel handlers require a pointer input in source. States may be referenced before declaration;
+symbols must be resolved after gathering top-level declarations. `metrics` can
+record source bytes/lines and instruction/handler/view/state counts.
+
+## VM opcodes
+
+| op | operation |
+|---:|---|
+|0| push constant `value` |
+|1| push state `arg` |
+|2| push dt |
+|3..6| add, subtract, multiply, divide |
+|7| negate |
+|8..13| less, less-equal, greater, greater-equal, equal, not-equal |
+|14..16| AND, OR, NOT (nonzero true) |
+|17..19| min, max, clamp (push x,lo,hi; invalid lo>hi fails) |
+|20| store state `arg` (pop) |
+|21| jump-if-zero to absolute instruction index `arg` (pop), forward only |
+|22,23| push current pointer event x,y; fail without point context |
+
+Rules start with an empty stack and must end with empty stack. Bindings must finish
+with exactly one value and cannot STORE, branch, or read dt/event coordinates. Branch targets may be handler end.
+
+## Zig exported ABI
+
+`reset_all()` clears programs and state. `begin_program(state_count,input_count)`
+starts staging, returns0 success; `put_state(id,initial,old_id)` old_id=-1 means init;
+`put_instruction(op,arg,value)` append; `put_handler(event,start,length)` append;
+`commit_program()` all0 success otherwise error code, atomic; candidate is separate
+from the active program, but shares its workspace with optional evaluation records.
+Preserved IDs read active values only at successful commit. Validate limits,
+indices, finite values and forward branch ranges; runtime stack errors are checked.
+`reset_state()` restores active initializers, resets tick/event diagnostics.
+`dispatch(event,dt)` accepts0..16 and finite dt; event0 dt must equal1/60, input dt0;
+returns0 success, other code failure with no state mutation. Tick increments for
+successful frame; one event runs all matching handlers in source order.
+`dispatch_point(event,x,y)` accepts existing input down/up IDs plus move/cancel IDs
+within input_count, never frame0. Coordinates must be finite; dt is implicitly0.
+The core only knows input count, not host device kinds. The compiler and host enforce
+pointer ownership and device mapping. Calling legacy dispatch on a coordinate-reading
+handler fails with bad_event9. Point operands are ephemeral execution context, not
+extra persistent state slots. Both dispatch paths use the same atomic commit/history.
+The host requires this new export; older Wasm must be rebuilt.
+`eval_at(start,length)` returns f64 or NaN on invalid/pure-expression failure;
+must not mutate state or event diagnostics. `get_state(id)` returns f64 orNaN.
+`info(id)` u32: 0 tick,1 last_error,2 error_pc (0xffffffff none),3 last_executed_ops,
+4 revision,5 state_count,6 instruction_count,7 handler_count,8 input_count,
+9 successful_event_count. `layout(id)` u32: 0 Engine reserved @sizeOf,
+1 Program reserved @sizeOf,2 Instruction @sizeOf,3 Handler @sizeOf,
+4 active used IR bytes (instructions+handlers),5 active state-value bytes,
+6 staging reserved bytes (subset of Engine),7 evaluation stack capacity bytes.
+`error_message` belongs to host map; core error codes documented in core.zig.
+No allocation or imported runtime in Wasm. Fixed64KiB linear memory target with
+16KiB stack; increase only if actual linker evidence requires and document it.
+
+## Host interface (web/runtime.mjs)
+
+`await createRuntime(wasmBytes?, evaluationOptions?)` fetches core.wasm in browser if omitted.
+Returns object with apply(source), reset(), frame(count=1), down(name,source),
+up(name,source), point(name,phase,x,y,source), releaseAll(), snapshot(), exportProject(), currentProgram.
+Multiple sources per input share one logical held state; down emits only first
+source, up only last. Source strings identify keyboard vs onscreen pointer.
+`apply` returns compiled program or throws with diagnostic. It must fully validate
+in host and stage+commit in core. It preserves by name. `reset` keeps active program.
+`snapshot` returns {states:{name:value}, views:[{id,type,x,y,width?,height?,radius?,
+value?,prefix?,color?}], tick,revision,lastError,errorLine,lastOps,memory:{...},held:[]}
+`currentProgram` is the latest successfully applied compiled program or null.
+The program and all nested metadata are frozen so readers cannot corrupt state
+name mappings or binding ranges. `lastFailure` records the last failed event;
+its `lastError`/line remain visible through successful release or focus cleanup
+until successful apply or explicit reset. `lastEvent` still describes the latest
+event. A view evaluation failure uses host error100 and does not roll back events.
+UI draft and applied code are distinct. Browser/CLI share compiler/runtime/wasm;
+instances are separate, not a live collaborative session. No arbitrary JS evaluation.
+
+## Bounded display evaluation (2026-09-22)
+
+The existing event and float semantics above are unchanged. The forgetful C
+prototype's contract is adapted into `evaluation.zig`; its uint64 DAG is not
+used as a substitute for this VM's float instructions.
+
+Each snapshot starts one display budget, shared by all bindings. Default options
+are `{cacheEntries:0, historyEntries:8, workLimit:2048}`. A unit is a binding
+request, a candidate-cache probe, a change-record probe, or an executed VM
+instruction. State extraction, host object creation, event dispatch and DOM work
+are outside this budget. It is not a time limit or a bound on whole-app work.
+For the current compiler limits, 96 bindings * 10 units plus 512 instructions
+is at most 1,472 units; the default admits even an uncached valid scene with
+cache lookup/validation enabled. Malformed bindings may fail sooner.
+
+`configureEvaluation({cacheEntries?,historyEntries?,workLimit?})` accepts integers
+0..32, 0..8, and 0..4294967295 respectively. Missing fields retain prior settings.
+Valid configuration clears reuse records; invalid configuration changes nothing.
+`forgetEvaluation('values'|'history'|'all')` drops optional records without
+changing authored state, event diagnostics or program identity.
+`evaluationStats()` returns the options plus work, instructions, cacheHits for
+the last snapshot, and reservedBytes for the complete fixed evaluation structure.
+Statistics are separate from snapshots so repeated observations remain equivalent.
+
+Memo entries contain the value, instruction range, input dependency bit mask
+and validated revision. Direct mapping uses one candidate probe, with collisions
+causing recomputation. Only successful computations are cached. Constant results
+need no input-change history. All other reuse requires a complete retained
+history since validation and no intersection with changed states. Dependencies
+are collected from actual VM LOADs, not trusted compiler-supplied annotations.
+History loss causes recomputation. All history and memo storage is fixed size;
+setting capacities to zero does not return reserved linear memory.
+
+Only bitwise-different committed states advance change history (including signed
+zero). Failed events and net-zero updates do not. Successful apply and reset
+discard all reuse records. Parse/host validation failure before staging preserves
+records. Beginning core staging discards them, including if that load is later
+rejected or aborted. Epoch wrap also clears records. No inputs or effectful event
+handlers are replayed to rebuild a binding.
+
+Budget exhaustion returns an empty views array for the entire snapshot, with
+displayError `{code:101,line,message}`. Successfully committed event state remains.
+There is no automatic retry or silent return of an old scene. Raising the budget
+allows a fresh attempt. Arithmetic/size errors retain the earlier per-view error100
+behavior. `displayError` is null on display success; a latched execution error
+still takes precedence in lastError/errorMessage, while displayError independently
+reports display failure. Partial successful binding memos may survive a failed
+snapshot but no partial scene is published.
+
+New core exports: `configure_evaluation(cache,history)` returns 0 or capacity1;
+`forget_evaluation(flags)` bits 1=values, 2=history; `begin_evaluation(limit)`;
+`eval_bounded(start,length)` returns f64 or NaN and consumes that shared budget.
+`evaluation_info(id)` returns 0 status (10=work limit), 1 work, 2 instructions,
+3 cache hits, 4 cache capacity, 5 history capacity, 6 reserved bytes. These
+diagnostics do not replace event info. The unbounded pure `eval_at` remains for
+reference checks and is not the host snapshot path.
+
+## Shared load/reuse workspace (2026-09-23)
+
+The pending Program and evaluation Tables occupy one untagged union. The active
+Program, current state and evaluation control fields remain separate. All core
+calls are single-threaded and synchronous; no callback can reenter a phase change.
+No pointer to the reused tables is retained between calls.
+
+`begin_program` switches the workspace to staging and drops optional records.
+Until commit/abort, old events and bindings still run against active. Binding
+evaluation bypasses reuse and recomputes within its budget; cache configuration,
+forget and reset must not write the staging payload. Old-id state preservation
+reads the current values at successful commit, including events/reset during load.
+
+Successful commit publishes the new Program and then initializes empty Tables in
+the shared workspace. `abort_program()` discards a pending definition and restores
+empty Tables without changing active program/state/event diagnostics. Inactive
+abort is a no-op. A failed commit leaves staging open for explicit abort or the
+next begin. An invalid begin closes any previous pending load. Host apply aborts
+after a core loading failure. Lost reuse may make a previously warm tiny budget
+insufficient; neither a preserved cache nor completion at that budget is promised.
+
+`evaluation_info` additionally reports 7 dedicated control bytes, 8 shared Tables
+bytes, 9 reuse available (false during load), and 10 union safety-tag overhead.
+The host exposes these as dedicatedBytes, sharedBytes, reuseAvailable and
+workspaceTagBytes. reservedBytes is control + Tables capacity, not incremental
+Engine storage. The Tables capacity overlaps stagingReservedBytes and must not
+be added to it. In the current Wasm build the control is 48 B, shared Tables
+1,152 B, union overhead 8 B. The Engine is 19,128 B, only 56 B larger than the
+19,072 B pre-evaluation core; linear memory remains 65,536 B. No OS pages are
+returned and this is not a measurement of browser/process memory.
+
+## Direct output publication (2026-09-23)
+
+`present(target)` evaluates the same bindings, in the same order and shared
+budget as `snapshot()`, then calls a synchronous trusted output adapter:
+
+- `begin(program)`: the frozen current definition, or null at startup.
+- `state(index, value)`: each current authored state, without a host state map.
+- `view(index, valid)`: every declared view's publication status.
+- `value(viewIndex, bindingName, value)`: each binding of a valid view only.
+- `end(visibleCount)`: number of valid views.
+
+It returns the snapshot's diagnostics, held inputs, event information and memory
+report, without `states` or `views`. Evaluation finishes before the first adapter
+callback. On budget error101 every view is invalid and no binding values are
+published; committed state values are still published. Error100 invalidates only
+the affected views, as before. No stale value is silently substituted.
+
+Runtime mutations, `snapshot()` and nested `present()` are rejected during
+callbacks. Read-only program/export/statistics access remains available. Adapters
+must be synchronous and must not throw. If one does throw, the exception
+propagates and the runtime unlocks in a finally block; arbitrary external adapter
+effects cannot be rolled back. A later call republishes every value. This is not
+a transaction across arbitrary output devices or an untrusted plugin boundary.
+
+Evaluation uses a private reusable Float64Array(96) and Uint8Array(24), allocated
+on first snapshot/presentation. Their payload totals 792 host bytes, outside the
+64 KiB Wasm memory. Typed-array object overhead, binding-entry arrays built at
+apply, and DOM destination references are additional host memory. The numeric
+buffer is a publication barrier, not another authoritative state or public
+mutable scene. `snapshot()` remains an independent object representation for
+CLI/export/debugging, built only when explicitly requested.
+
+The browser calls `present()` for normal refreshes. Its output adapter binds DOM
+destinations once per successful definition change and compares values with the
+existing DOM before writing. Input refreshes reuse scene, state and cost elements.
+Invalid views remain allocated but are hidden using SVG `display="none"`; recovery
+reveals them. A definition change can replace all scene/state destinations. This
+does not skip unchanged binding evaluation, eliminate all host allocations, or
+provide measured process-memory/paint-latency savings.
+
+## Authored direct manipulation (2026-09-23)
+
+`input drag = pointer(tile)` binds a logical input to a declared view, including a
+forward declaration. One input per target; it shares the existing 8-input limit
+with keyboard inputs. `on drag.down/move/up/cancel` handlers may read `event_x`
+and `event_y` in the same 640x360 world coordinates as output. They are unavailable
+in keyboard/frame handlers and display bindings; copy to authored state to retain
+them. There is no built-in drag constraint, snap, grab offset, or gesture rollback.
+The direct.pure example authors these using ordinary assignments and expressions.
+
+Host `point` requires a pointer input, valid phase, finite coordinates and nonempty
+source string. Each logical input has one owning source; different targets may
+have independent active contacts. Same-owner repeated down is a no-op; another
+owner's down throws. Move/up/cancel without that owner are no-ops. Down establishes
+ownership before dispatch; up/cancel release it before dispatch, even if authored
+arithmetic fails. An event failure rolls back that event's state writes, not the
+whole gesture. `releaseAll` cancels owned pointers at their last delivered position,
+then releases ordinary buttons. `reset` directly clears ownership and resets state.
+Last point events include x,y. A failed cancel can leave authored state unchanged,
+but cannot strand host ownership; the failure remains visible until reset/apply.
+
+Browser hit testing selects the declared target on primary-button pointerdown.
+The stable SVG root captures that pointer, including movement/release outside the
+scene. Client coordinates are transformed through the inverse SVG screen matrix.
+Pointerup uses its own final coordinates. Pointercancel, lostpointercapture and
+focus loss use the last delivered position and dispatch cancel, never fake up.
+Missing/non-invertible coordinate transforms cancel an active gesture. A second
+contact on the same target is ignored; contacts on different targets are independent.
+Touch scenes disable native panning on the SVG. Mouse/touch share this adapter;
+pen-specific pressure, tilt and button meanings are not implemented or validated.
+
+Every delivered pointermove executes synchronously; this layer adds no event queue,
+coalescing, frame gating or newest-only drop policy. This is not a guarantee that
+browsers expose every hardware sample. Keyboard commands and pointer coordinates
+enter the same authored state/commit/output path. CLI `point NAME PHASE X Y` uses
+that host API and core with source `terminal`; it does not replay browser hit testing.
diff --git a/prototypes/workbench/results/2026-09-23-pointer/after/cli.mjs b/prototypes/workbench/results/2026-09-23-pointer/after/cli.mjs
new file mode 100644
index 0000000..835fd35
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/after/cli.mjs
@@ -0,0 +1,74 @@
+#!/usr/bin/env node
+import { readFile, writeFile } from 'node:fs/promises';
+import { createInterface } from 'node:readline';
+import { createRuntime } from './web/runtime.mjs';
+
+const help = `Pure authoring workbench · 같은 정의와 Zig 실행기
+ load PATH .pure 파일을 적용 (같은 이름의 상태 보존)
+ down NAME 정의한 입력 누르기
+ up NAME 정의한 입력 놓기
+ point NAME PHASE X Y 좌표 입력 (down / move / up / cancel)
+ frame [1..600] 시간 진행, 1프레임 = 1/60초
+ show 상태와 도형 출력
+ limits C H W 표시 캐시(0..32)·변경 기록(0..8)·작업량(0..4294967295) 설정
+ forget [PART] 표시 기록 삭제: all, values, history (상태는 유지)
+ costs 마지막 표시 계산의 작업량·명령 수·캐시 적중 출력
+ reset 현재 정의의 초기 상태로
+ save PATH 적용한 정의를 저장
+ help / quit
+브라우저와 별도 인스턴스입니다. 임의 JavaScript는 실행하지 않습니다.`;
+
+async function main() {
+ const args = process.argv.slice(2);
+ if (args.includes('--help')) { console.log(help); return; }
+ if (args.length > 1) throw new Error('사용법: node cli.mjs [project.pure]');
+ const runtime = await createRuntime(await readFile(new URL('./web/core.wasm', import.meta.url)));
+ runtime.apply(args[0] ? await readFile(args[0], 'utf8') : '');
+ const terminal = createInterface({ input: process.stdin, output: process.stdout, terminal: Boolean(process.stdin.isTTY) });
+ if (process.stdin.isTTY) console.log(help);
+ terminal.setPrompt('pure> ');
+ if (process.stdin.isTTY) terminal.prompt();
+ for await (const line of terminal) {
+ try {
+ const match = /^(\S+)(?:\s+(.+))?$/.exec(line.trim());
+ const name = match?.[1] || 'show';
+ const arg = match?.[2];
+ if (name === 'quit') { terminal.close(); break; }
+ if (name === 'help') console.log(help);
+ else if (name === 'costs' && !arg) console.log(JSON.stringify({ evaluation: runtime.evaluationStats() }));
+ else {
+ if (name === 'load' && arg) runtime.apply(await readFile(arg, 'utf8'));
+ else if (name === 'save' && arg) await writeFile(arg, runtime.exportProject().source, { flag: 'wx' });
+ else if (name === 'down' && arg) runtime.down(arg, 'terminal');
+ else if (name === 'up' && arg) runtime.up(arg, 'terminal');
+ else if (name === 'point' && arg) {
+ const parts = arg.split(/\s+/);
+ if (parts.length !== 4) throw new Error('point NAME PHASE X Y 형식을 사용하세요.');
+ runtime.point(parts[0], parts[1], Number(parts[2]), Number(parts[3]), 'terminal');
+ } else if (name === 'frame') {
+ if (arg !== undefined && !/^\d+$/.test(arg)) throw new Error('frame 1..600 정수를 입력하세요.');
+ const count = arg === undefined ? 1 : Number(arg);
+ if (count < 1 || count > 600) throw new Error('frame 1..600 정수를 입력하세요.');
+ runtime.frame(count);
+ } else if (name === 'reset' && !arg) runtime.reset();
+ else if (name === 'limits' && arg) {
+ const parts = arg.split(/\s+/);
+ if (parts.length !== 3 || parts.some(part => !/^\d+$/.test(part)))
+ throw new Error('limits C H W 정수 세 개를 입력하세요.');
+ const [cacheEntries, historyEntries, workLimit] = parts.map(Number);
+ runtime.configureEvaluation({ cacheEntries, historyEntries, workLimit });
+ } else if (name === 'forget') runtime.forgetEvaluation(arg || 'all');
+ else if (name !== 'show' || arg) throw new Error('명령 형식을 확인하세요. help로 안내를 볼 수 있습니다.');
+ const result = runtime.snapshot();
+ console.log(JSON.stringify(result));
+ if (result.lastError && !process.stdin.isTTY) process.exitCode = 1;
+ }
+ } catch (error) {
+ console.error(`${error.line ? `${error.line}:${error.column || 1} ` : ''}${error.message}`);
+ if (!process.stdin.isTTY) process.exitCode = 1;
+ }
+ if (process.stdin.isTTY) terminal.prompt();
+ }
+}
+
+main().catch((error) => { console.error(error.message); process.exitCode = 1; });
diff --git a/prototypes/workbench/results/2026-09-23-pointer/after/cli.test.mjs b/prototypes/workbench/results/2026-09-23-pointer/after/cli.test.mjs
new file mode 100644
index 0000000..8dbabd7
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/after/cli.test.mjs
@@ -0,0 +1,97 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createRuntime } from './web/runtime.mjs';
+
+const root = fileURLToPath(new URL('.', import.meta.url));
+function run(input, file) {
+ const result = spawnSync(process.execPath, [join(root, 'cli.mjs'), ...(file ? [file] : [])], {
+ input, encoding: 'utf8', cwd: root,
+ });
+ if (result.error) throw result.error;
+ return { ...result, snapshots: result.stdout.split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line)) };
+}
+
+test('terminal starts empty and produces the same charge trace as the shared runtime', async () => {
+ assert.deepEqual(run('show\nquit\n').snapshots[0].states, {});
+ const file = join(root, 'examples/charge.pure');
+ const result = run('down jump\nframe 30\nup jump\nframe 1\nshow\nquit\n', file);
+ assert.equal(result.status, 0, result.stderr);
+ const runtime = await createRuntime(await readFile(join(root, 'web/core.wasm')));
+ runtime.apply(await readFile(file, 'utf8'));
+ runtime.down('jump', 'terminal'); runtime.frame(30); runtime.up('jump', 'terminal'); runtime.frame();
+ // JSON has no undefined-valued optional fields; compare the same wire form.
+ assert.deepEqual(result.snapshots.at(-1), JSON.parse(JSON.stringify(runtime.snapshot())));
+ assert(result.snapshots.at(-1).states.y < 240);
+});
+
+test('invalid terminal commands do not alter state and fail piped execution', () => {
+ const result = run('show\nframe 0\nframe -1\nframe 1.5\nframe 1e2\nframe 601\nshow extra\ndown unknown\nshow\nquit\n', join(root, 'examples/counter.pure'));
+ assert.equal(result.status, 1);
+ assert.deepEqual(result.snapshots[0], result.snapshots[1]);
+ assert.equal(result.stderr.trim().split('\n').length, 7);
+});
+
+test('terminal replays authored pointer movement and cancellation through the shared runtime', async () => {
+ const file = join(root, 'examples/direct.pure');
+ const result = run('point drag down 90 170\npoint drag move 360 220\npoint drag up 410 240\npoint drag down 410 240\npoint drag move 500 260\npoint drag cancel 500 260\nshow\nquit\n', file);
+ assert.equal(result.status, 0, result.stderr);
+ const runtime = await createRuntime(await readFile(join(root, 'web/core.wasm')));
+ runtime.apply(await readFile(file, 'utf8'));
+ for (const [phase, x, y] of [['down',90,170],['move',360,220],['up',410,240],['down',410,240],['move',500,260],['cancel',500,260]])
+ runtime.point('drag', phase, x, y, 'terminal');
+ assert.deepEqual(result.snapshots.at(-1), JSON.parse(JSON.stringify(runtime.snapshot())));
+ assert.equal(result.snapshots.at(-1).states.x, 400);
+ assert.equal(result.snapshots.at(-1).states.y, 230);
+ assert.deepEqual(result.snapshots.at(-1).held, []);
+});
+
+test('file load preserves by name, rejected load preserves active work, and save never overwrites', async t => {
+ const directory = await mkdtemp(join(tmpdir(), 'pure-author-cli-'));
+ t.after(() => rm(directory, { recursive: true, force: true }));
+ const first = join(directory, 'first.pure'), second = join(directory, 'second with space.pure');
+ const invalid = join(directory, 'invalid.pure'), saved = join(directory, 'saved.pure');
+ await writeFile(first, 'state n = 7');
+ await writeFile(second, 'state n = 100\nstate added = 9');
+ await writeFile(invalid, 'state n = broken');
+ const result = run(`load ${second}\nload ${invalid}\nshow\nsave ${saved}\nreset\nsave ${saved}\nquit\n`, first);
+ assert.equal(result.status, 1);
+ assert.deepEqual(result.snapshots[0].states, { n: 7, added: 9 });
+ assert.deepEqual(result.snapshots[1], result.snapshots[0]);
+ assert.deepEqual(result.snapshots.at(-1).states, { n: 100, added: 9 });
+ assert.equal(await readFile(saved, 'utf8'), await readFile(second, 'utf8'));
+ assert.match(result.stderr, /EEXIST/);
+});
+
+test('terminal exposes arithmetic rollback and retains failure through matching release', async t => {
+ const directory = await mkdtemp(join(tmpdir(), 'pure-author-error-'));
+ t.after(() => rm(directory, { recursive: true, force: true }));
+ const file = join(directory, 'error.pure');
+ await writeFile(file, 'state n = 7\ninput tap = Space\non tap.down { n = 1 / 0 }');
+ const result = run('down tap\nup tap\nshow\nreset\nquit\n', file);
+ assert.equal(result.status, 1);
+ for (const snapshot of result.snapshots.slice(0, 3)) {
+ assert.equal(snapshot.states.n, 7); assert.equal(snapshot.lastError, 4);
+ assert.equal(snapshot.errorLine, 3);
+ }
+ assert.equal(result.snapshots[3].lastError, 0);
+});
+
+test('terminal configures and discards evaluation records and recovers from a display limit', () => {
+ const result = run('limits 32 8 2048\nshow\ncosts\nforget all\nlimits 32 8 0\nlimits 32 8 2048\nshow\nquit\n', join(root, 'examples/counter.pure'));
+ assert.equal(result.status, 1); // The explicit zero-budget display failed.
+ assert.equal(result.stderr, '');
+ const [configured, warm, costs, forgotten, limited, recovered, final] = result.snapshots;
+ assert.deepEqual(warm.views, configured.views);
+ assert(costs.evaluation.cacheHits > 0);
+ assert.deepEqual(forgotten.states, configured.states);
+ assert.deepEqual(limited.views, []);
+ assert.equal(limited.lastError, 101);
+ assert.deepEqual(recovered, final);
+ assert.deepEqual(recovered.views, configured.views);
+ assert.deepEqual(recovered.states, configured.states);
+});
diff --git a/prototypes/workbench/results/2026-09-23-pointer/after/core.zig b/prototypes/workbench/results/2026-09-23-pointer/after/core.zig
new file mode 100644
index 0000000..2e3fd75
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/after/core.zig
@@ -0,0 +1,1027 @@
+//! Bounded scalar interaction VM experiment. No application behavior is built in.
+//! Same source for native tests and freestanding Wasm; no allocation or imports.
+//! Error ABI: 0 ok, 1 capacity, 2 invalid, 3 stack, 4 division by zero,
+//! 5 non-finite, 6 bad branch, 7 bad state, 8 bad frame, 9 bad event.
+//! Counters wrap modulo 2^32. Failed loads preserve the active program, authored
+//! state and event diagnostics; beginning a load discards optional reuse records.
+//! Failed events preserve values and counters but
+//! report their error and attempted instruction count. Binding evaluation is pure.
+
+const std = @import("std");
+const Evaluation = @import("evaluation.zig").Evaluation;
+const Tables = @import("evaluation.zig").Tables;
+
+const max_states = 32;
+const max_inputs = 8;
+const max_instructions = 512;
+const max_handlers = 64;
+const max_stack = 64;
+const no_pc = std.math.maxInt(u32);
+const frame_dt: f64 = 1.0 / 60.0;
+
+const Code = enum(u32) {
+ ok = 0,
+ capacity = 1,
+ invalid = 2,
+ stack = 3,
+ division_by_zero = 4,
+ non_finite = 5,
+ bad_branch = 6,
+ bad_state = 7,
+ bad_frame = 8,
+ bad_event = 9,
+ work_limit = 10,
+};
+
+const Instruction = struct { value: f64 = 0, op: u32 = 0, arg: u32 = 0 };
+const Handler = struct { event: u32 = 0, start: u32 = 0, length: u32 = 0 };
+const Program = struct {
+ instructions: [max_instructions]Instruction = @splat(.{}),
+ handlers: [max_handlers]Handler = @splat(.{}),
+ initials: [max_states]f64 = @splat(0),
+ old_ids: [max_states]i32 = @splat(-1),
+ declared: [max_states]bool = @splat(false),
+ state_count: u32 = 0,
+ input_count: u32 = 0,
+ instruction_count: u32 = 0,
+ handler_count: u32 = 0,
+};
+// Loading and result reuse are mutually exclusive phases. Current execution
+// always reads active, and can recompute bindings while this workspace loads.
+const Workspace = union { staging: Program, reuse: Tables };
+const Engine = struct {
+ active: Program = .{},
+ workspace: Workspace = .{ .reuse = .{} },
+ values: [max_states]f64 = @splat(0),
+ staging_open: bool = false,
+ staging_error: Code = .ok,
+ tick: u32 = 0,
+ last_error: Code = .ok,
+ error_pc: u32 = no_pc,
+ last_ops: u32 = 0,
+ revision: u32 = 0,
+ successful_events: u32 = 0,
+ evaluation: Evaluation = .{},
+};
+var engine: Engine = .{};
+
+fn reuseTables() ?*Tables {
+ return if (engine.staging_open) null else &engine.workspace.reuse;
+}
+
+fn finishStaging() void {
+ engine.workspace = .{ .reuse = .{} };
+ engine.staging_open = false;
+ engine.evaluation.forget(null, 3);
+}
+
+fn public(code: Code) u32 {
+ return @intFromEnum(code);
+}
+
+fn stagingFail(code: Code) u32 {
+ if (engine.staging_error == .ok) engine.staging_error = code;
+ return public(code);
+}
+
+export fn reset_all() void {
+ engine = .{};
+}
+
+export fn begin_program(state_count: u32, input_count: u32) u32 {
+ engine.staging_error = .ok;
+ if (state_count > max_states or input_count > max_inputs) {
+ if (engine.staging_open) finishStaging();
+ return stagingFail(.capacity);
+ }
+ engine.workspace = .{ .staging = .{ .state_count = state_count, .input_count = input_count } };
+ engine.staging_open = true;
+ engine.evaluation.forget(null, 3);
+ return 0;
+}
+
+export fn abort_program() void {
+ if (engine.staging_open) finishStaging();
+}
+
+export fn put_state(id: u32, initial: f64, old_id: i32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ if (id >= engine.workspace.staging.state_count or old_id < -1) return stagingFail(.bad_state);
+ if (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count) return stagingFail(.bad_state);
+ if (engine.workspace.staging.declared[id]) return stagingFail(.bad_state);
+ if (!std.math.isFinite(initial)) return stagingFail(.non_finite);
+ engine.workspace.staging.initials[id] = initial;
+ engine.workspace.staging.old_ids[id] = old_id;
+ engine.workspace.staging.declared[id] = true;
+ return 0;
+}
+
+export fn put_instruction(op: u32, arg: u32, value: f64) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging;
+ if (p.instruction_count == max_instructions) return stagingFail(.capacity);
+ if (op > 23) return stagingFail(.invalid);
+ if (!std.math.isFinite(value)) return stagingFail(.non_finite);
+ p.instructions[p.instruction_count] = .{ .op = op, .arg = arg, .value = value };
+ p.instruction_count += 1;
+ return 0;
+}
+
+fn validEvent(event: u32, inputs: u32) bool {
+ return event <= inputs * 2 or
+ (event >= 17 and event - 17 < inputs) or
+ (event >= 25 and event - 25 < inputs);
+}
+
+export fn put_handler(event: u32, start: u32, length: u32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging;
+ if (p.handler_count == max_handlers) return stagingFail(.capacity);
+ if (!validEvent(event, p.input_count)) return stagingFail(.bad_event);
+ // Handlers may be appended before their instructions, so range validation
+ // is deferred until commit. Arithmetic remains guarded there.
+ p.handlers[p.handler_count] = .{ .event = event, .start = start, .length = length };
+ p.handler_count += 1;
+ return 0;
+}
+
+fn validate(p: *const Program) Code {
+ for (0..p.state_count) |id| {
+ if (!p.declared[id]) return .bad_state;
+ if (!std.math.isFinite(p.initials[id])) return .non_finite;
+ const old_id = p.old_ids[id];
+ if (old_id < -1 or (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count)) return .bad_state;
+ }
+ for (p.handlers[0..p.handler_count], 0..) |h, i| {
+ if (!validEvent(h.event, p.input_count)) return .bad_event;
+ if (h.start > p.instruction_count or h.length > p.instruction_count - h.start) return .invalid;
+ if (h.length == 0) continue;
+ for (p.handlers[0..i]) |other| {
+ if (other.length != 0 and h.start < other.start + other.length and other.start < h.start + h.length) return .invalid;
+ }
+ }
+ for (p.instructions[0..p.instruction_count], 0..) |inst, pc| {
+ if (inst.op > 23) return .invalid;
+ if (!std.math.isFinite(inst.value)) return .non_finite;
+ if ((inst.op == 1 or inst.op == 20) and inst.arg >= p.state_count) return .bad_state;
+ if (inst.op >= 20) {
+ var owner: ?Handler = null;
+ for (p.handlers[0..p.handler_count]) |h| {
+ if (pc >= h.start and pc < h.start + h.length) {
+ owner = h;
+ break;
+ }
+ }
+ const h = owner orelse return .invalid;
+ if (inst.op == 21 and (inst.arg <= pc or inst.arg > h.start + h.length)) return .bad_branch;
+ }
+ }
+ return .ok;
+}
+
+export fn commit_program() u32 {
+ if (!engine.staging_open) return public(.invalid);
+ if (engine.staging_error != .ok) return public(engine.staging_error);
+ const code = validate(&engine.workspace.staging);
+ if (code != .ok) return stagingFail(code);
+ var next: [max_states]f64 = @splat(0);
+ for (0..engine.workspace.staging.state_count) |i| {
+ const old_id = engine.workspace.staging.old_ids[i];
+ next[i] = if (old_id < 0) engine.workspace.staging.initials[i] else engine.values[@intCast(old_id)];
+ }
+ engine.active = engine.workspace.staging;
+ engine.values = next;
+ finishStaging();
+ engine.revision +%= 1;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ return 0;
+}
+
+export fn reset_state() void {
+ engine.values = engine.active.initials;
+ engine.evaluation.forget(reuseTables(), 3);
+ engine.tick = 0;
+ engine.successful_events = 0;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+}
+
+const Execution = struct {
+ code: Code = .ok,
+ pc: u32 = no_pc,
+ ops: u32 = 0,
+ value: f64 = 0,
+ dependencies: u32 = 0,
+};
+
+fn executionFail(result: Execution, code: Code, pc: u32) Execution {
+ var failed = result;
+ failed.code = code;
+ failed.pc = pc;
+ return failed;
+}
+
+const EventContext = struct { dt: f64 = 0, x: f64 = 0, y: f64 = 0, point: bool = false };
+
+fn execute(values: *[max_states]f64, start: u32, length: u32, context: EventContext, pure: bool, budget: ?*Evaluation) Execution {
+ const p = &engine.active;
+ var result: Execution = .{};
+ if (start > p.instruction_count or length > p.instruction_count - start) return executionFail(result, .invalid, no_pc);
+ const end = start + length;
+ var stack: [max_stack]f64 = undefined;
+ var sp: usize = 0;
+ var pc = start;
+ while (pc < end) {
+ if (budget) |b| {
+ if (!b.spend()) return executionFail(result, .work_limit, pc);
+ b.instructions += 1;
+ }
+ const inst = p.instructions[pc];
+ result.ops += 1;
+ if (pure and (inst.op == 2 or inst.op >= 20)) return executionFail(result, .invalid, pc);
+ const need: usize = switch (inst.op) {
+ 0...2, 22, 23 => 0,
+ 7, 16, 20, 21 => 1,
+ 3...6, 8...15, 17, 18 => 2,
+ 19 => 3,
+ else => return executionFail(result, .invalid, pc),
+ };
+ if (sp < need) return executionFail(result, .stack, pc);
+ if (inst.op <= 2 or inst.op == 22 or inst.op == 23) {
+ if (sp == max_stack) return executionFail(result, .stack, pc);
+ const value = switch (inst.op) {
+ 0 => inst.value,
+ 1 => blk: {
+ if (inst.arg >= p.state_count) return executionFail(result, .bad_state, pc);
+ if (budget != null and budget.?.cache_count != 0)
+ result.dependencies |= @as(u32, 1) << @as(u5, @intCast(inst.arg));
+ break :blk values[inst.arg];
+ },
+ 2 => context.dt,
+ 22, 23 => if (!context.point) return executionFail(result, .bad_event, pc) else if (inst.op == 22) context.x else context.y,
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ stack[sp] = value;
+ sp += 1;
+ } else if (inst.op == 20) {
+ if (inst.arg >= p.state_count) return executionFail(result, .bad_state, pc);
+ sp -= 1;
+ values[inst.arg] = stack[sp];
+ } else if (inst.op == 21) {
+ if (inst.arg <= pc or inst.arg > end) return executionFail(result, .bad_branch, pc);
+ sp -= 1;
+ if (stack[sp] == 0) {
+ pc = inst.arg;
+ continue;
+ }
+ } else {
+ const a = stack[sp - need];
+ const b = if (need >= 2) stack[sp - need + 1] else 0;
+ const value: f64 = switch (inst.op) {
+ 3 => a + b,
+ 4 => a - b,
+ 5 => a * b,
+ 6 => blk: {
+ if (b == 0) return executionFail(result, .division_by_zero, pc);
+ break :blk a / b;
+ },
+ 7 => -a,
+ 8 => @floatFromInt(@intFromBool(a < b)),
+ 9 => @floatFromInt(@intFromBool(a <= b)),
+ 10 => @floatFromInt(@intFromBool(a > b)),
+ 11 => @floatFromInt(@intFromBool(a >= b)),
+ 12 => @floatFromInt(@intFromBool(a == b)),
+ 13 => @floatFromInt(@intFromBool(a != b)),
+ 14 => @floatFromInt(@intFromBool(a != 0 and b != 0)),
+ 15 => @floatFromInt(@intFromBool(a != 0 or b != 0)),
+ 16 => @floatFromInt(@intFromBool(a == 0)),
+ 17 => @min(a, b),
+ 18 => @max(a, b),
+ 19 => blk: {
+ const hi = stack[sp - 1];
+ if (b > hi) return executionFail(result, .invalid, pc);
+ break :blk @max(b, @min(a, hi));
+ },
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ sp = sp - need + 1;
+ stack[sp - 1] = value;
+ }
+ pc += 1;
+ }
+ if (sp != @as(usize, if (pure) 1 else 0)) return executionFail(result, .stack, end);
+ if (pure) result.value = stack[0];
+ return result;
+}
+
+export fn dispatch(event: u32, dt: f64) u32 {
+ return dispatchContext(event, .{ .dt = dt });
+}
+
+export fn dispatch_point(event: u32, x: f64, y: f64) u32 {
+ return dispatchContext(event, .{ .x = x, .y = y, .point = true });
+}
+
+fn dispatchContext(event: u32, context: EventContext) u32 {
+ const dt = context.dt;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ if (!validEvent(event, engine.active.input_count) or
+ (context.point and event == 0) or (!context.point and event > engine.active.input_count * 2)) {
+ engine.last_error = .bad_event;
+ return public(.bad_event);
+ }
+ if (!std.math.isFinite(dt) or !std.math.isFinite(context.x) or !std.math.isFinite(context.y)) {
+ engine.last_error = .non_finite;
+ return public(.non_finite);
+ }
+ if ((event == 0 and dt != frame_dt) or (event != 0 and dt != 0)) {
+ engine.last_error = .bad_frame;
+ return public(.bad_frame);
+ }
+ var pending = engine.values;
+ for (engine.active.handlers[0..engine.active.handler_count]) |h| {
+ if (h.event != event) continue;
+ const result = execute(&pending, h.start, h.length, context, false, null);
+ engine.last_ops += result.ops;
+ if (result.code != .ok) {
+ engine.last_error = result.code;
+ engine.error_pc = result.pc;
+ return public(result.code);
+ }
+ }
+ if (engine.evaluation.cache_count != 0 and !engine.staging_open) {
+ var changed: u32 = 0;
+ for (0..engine.active.state_count) |i| {
+ // Signed zero is observable, so compare representation, not ==.
+ if (@as(u64, @bitCast(pending[i])) != @as(u64, @bitCast(engine.values[i])))
+ changed |= @as(u32, 1) << @as(u5, @intCast(i));
+ }
+ engine.evaluation.changed(reuseTables(), changed);
+ }
+ engine.values = pending;
+ if (event == 0) engine.tick +%= 1;
+ engine.successful_events +%= 1;
+ return 0;
+}
+
+export fn eval_at(start: u32, length: u32) f64 {
+ // A local copy makes the no-side-effects boundary structural as well as
+ // checked by the pure instruction filter. Diagnostics are never touched.
+ var values = engine.values;
+ const result = execute(&values, start, length, .{}, true, null);
+ return if (result.code == .ok) result.value else std.math.nan(f64);
+}
+
+export fn configure_evaluation(cache: u32, history: u32) u32 {
+ return if (engine.evaluation.configure(reuseTables(), cache, history)) 0 else public(.capacity);
+}
+
+export fn forget_evaluation(flags: u32) void {
+ engine.evaluation.forget(reuseTables(), flags);
+}
+
+export fn begin_evaluation(limit: u32) void {
+ engine.evaluation.begin(limit);
+}
+
+export fn eval_bounded(start: u32, length: u32) f64 {
+ const e = &engine.evaluation;
+ e.status = 0;
+ // Charge per request as well as per instruction and validation probe.
+ if (!e.spend()) return std.math.nan(f64);
+ if (e.lookup(reuseTables(), start, length)) |value| return value;
+ if (e.status != 0) return std.math.nan(f64);
+ var values = engine.values;
+ const result = execute(&values, start, length, .{}, true, e);
+ e.status = public(result.code);
+ if (result.code != .ok) return std.math.nan(f64);
+ e.remember(reuseTables(), start, length, result.value, result.dependencies);
+ return result.value;
+}
+
+export fn evaluation_info(id: u32) u32 {
+ const e = &engine.evaluation;
+ return switch (id) {
+ 0 => e.status,
+ 1 => e.work,
+ 2 => e.instructions,
+ 3 => e.hits,
+ 4 => e.cache_count,
+ 5 => e.history_count,
+ 6 => @sizeOf(Evaluation) + @sizeOf(Tables),
+ 7 => @sizeOf(Evaluation),
+ 8 => @sizeOf(Tables),
+ 9 => @intFromBool(!engine.staging_open),
+ 10 => @sizeOf(Workspace) - @sizeOf(Program),
+ else => 0,
+ };
+}
+
+export fn get_state(id: u32) f64 {
+ return if (id < engine.active.state_count) engine.values[id] else std.math.nan(f64);
+}
+
+export fn info(id: u32) u32 {
+ return switch (id) {
+ 0 => engine.tick,
+ 1 => public(engine.last_error),
+ 2 => engine.error_pc,
+ 3 => engine.last_ops,
+ 4 => engine.revision,
+ 5 => engine.active.state_count,
+ 6 => engine.active.instruction_count,
+ 7 => engine.active.handler_count,
+ 8 => engine.active.input_count,
+ 9 => engine.successful_events,
+ else => 0,
+ };
+}
+
+export fn layout(id: u32) u32 {
+ return switch (id) {
+ 0 => @sizeOf(Engine),
+ 1 => @sizeOf(Program),
+ 2 => @sizeOf(Instruction),
+ 3 => @sizeOf(Handler),
+ 4 => engine.active.instruction_count * @sizeOf(Instruction) + engine.active.handler_count * @sizeOf(Handler),
+ 5 => engine.active.state_count * @sizeOf(f64),
+ 6 => @sizeOf(Program),
+ 7 => max_stack * @sizeOf(f64),
+ else => 0,
+ };
+}
+
+// Test helpers describe programs directly; the product compiler is separately
+// tested against this ABI. These tests exercise semantics and failure boundaries.
+fn ok(code: u32) !void {
+ try std.testing.expectEqual(@as(u32, 0), code);
+}
+
+fn emit(op: u32, arg: u32, value: f64) !void {
+ try ok(put_instruction(op, arg, value));
+}
+
+fn counterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try ok(commit_program());
+}
+
+test "blank program, no hidden application behavior, and reset" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 1), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(3));
+ try std.testing.expectEqual(@as(u32, 0), info(5));
+ try std.testing.expect(std.math.isNan(get_state(0)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 0)));
+ reset_state();
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 1), info(4));
+}
+
+test "arbitrary input counter and unused events" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(dispatch(2, 0));
+ try ok(dispatch(0, frame_dt));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(3));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(6));
+}
+
+test "charge and release behavior exists only in the authored instruction program" {
+ reset_all();
+ try ok(begin_program(3, 1)); // held, charge, released_value
+ for (0..3) |i| try ok(put_state(@intCast(i), 0, -1));
+ try emit(0, 0, 1);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 2));
+ try emit(1, 1, 0);
+ try emit(1, 0, 0);
+ try emit(2, 0, 0);
+ try emit(5, 0, 0);
+ try emit(3, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(0, 2, 6));
+ try emit(1, 1, 0);
+ try emit(20, 2, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(2, 8, 6));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ try ok(dispatch(1, 0));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try ok(dispatch(2, 0));
+ try std.testing.expectApproxEqAbs(@as(f64, 0.5), get_state(2), 1e-12);
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+}
+
+test "forward condition executes once and assignments and handlers are ordered" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 1, -1));
+ try ok(put_state(1, 0, -1));
+ try emit(1, 0, 0);
+ try emit(21, 6, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 7);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 0, 6));
+ try emit(1, 1, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 6, 4));
+ try ok(commit_program());
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 14), get_state(1));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 28), get_state(1));
+ try std.testing.expectEqual(@as(u32, 6), info(3));
+}
+
+test "branch to program end is valid" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, 2, 0);
+ try ok(put_handler(0, 0, 2));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+}
+
+test "one event rolls back writes from all handlers on arithmetic failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 8, -1));
+ try emit(0, 0, 99);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 2));
+ try emit(0, 0, 1);
+ try emit(0, 0, 0);
+ try emit(6, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 2, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 8), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(2));
+ try std.testing.expectEqual(@as(u32, 5), info(3));
+}
+
+test "nonfinite arithmetic and invalid clamp fail atomically" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, -1));
+ try emit(0, 0, 1e308);
+ try emit(0, 0, 1e308);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, 0));
+ try emit(0, 0, 1);
+ try emit(0, 0, 10);
+ try emit(0, 0, 2);
+ try emit(19, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 5));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.invalid), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+}
+
+test "stack underflow overflow and leftover values reject the event" {
+ for (0..3) |mode| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ if (mode == 0) {
+ try emit(3, 0, 0);
+ } else {
+ for (0..@as(usize, if (mode == 1) 65 else 1)) |_| try emit(0, 0, 7);
+ }
+ try ok(put_handler(0, 0, engine.workspace.staging.instruction_count));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.stack), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ }
+}
+
+test "failed candidates leave active values program revision and diagnostics unchanged" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ const old_revision = info(4);
+ const old_ops = info(3);
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(1, 5, 0);
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try std.testing.expectEqual(old_revision, info(4));
+ try std.testing.expectEqual(old_ops, info(3));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.invalid), put_instruction(100, 0, 0));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+}
+
+test "state preservation mapping reads active values at commit and reset uses new initials" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(2, 0));
+ try ok(put_state(0, 42, -1)); // new symbol first
+ try ok(put_state(1, 100, 0)); // old symbol moves from id 0 to 1
+ try ok(dispatch(1, 0)); // changed after staging began
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 42), get_state(0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 100), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(4));
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try std.testing.expect(std.math.isNan(get_state(0)));
+}
+
+test "capacity boundaries and sticky load errors" {
+ reset_all();
+ try std.testing.expectEqual(public(.capacity), begin_program(33, 0));
+ try std.testing.expectEqual(public(.capacity), begin_program(0, 9));
+ try ok(begin_program(32, 8));
+ for (0..32) |i| try ok(put_state(@intCast(i), @floatFromInt(i), -1));
+ for (0..512) |_| try emit(0, 0, 0);
+ for (0..64) |_| try ok(put_handler(16, 0, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 512), info(6));
+ try std.testing.expectEqual(@as(u32, 64), info(7));
+ try std.testing.expectEqual(@as(f64, 31), get_state(31));
+ try ok(begin_program(0, 0));
+ for (0..512) |_| try emit(0, 0, 0);
+ try std.testing.expectEqual(public(.capacity), put_instruction(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+ try ok(begin_program(0, 0));
+ for (0..64) |_| try ok(put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+}
+
+test "invalid declaration values state mappings event ids and handler ranges" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 0, 0));
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.non_finite), put_state(0, std.math.inf(f64), -1));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 1, -1));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.non_finite), put_instruction(0, 0, std.math.nan(f64)));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.bad_event), put_handler(1, 0, 0));
+ try ok(begin_program(0, 0));
+ try ok(put_handler(0, no_pc, 5));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 1);
+ try ok(put_handler(0, 0, 1));
+ try ok(put_handler(0, 0, 1));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "backward self out-of-handler branches and stores without handlers are rejected" {
+ for ([_]u32{ 0, 1, 3 }) |target| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, target, 0);
+ try emit(0, 0, 7);
+ try ok(put_handler(0, 0, 2));
+ try std.testing.expectEqual(public(.bad_branch), commit_program());
+ }
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try emit(0, 0, 5);
+ try emit(20, 0, 0);
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "event inputs and dt enforce deterministic frame contract" {
+ reset_all();
+ try counterProgram();
+ try std.testing.expectEqual(public(.bad_event), dispatch(3, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch(no_pc, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(1, frame_dt));
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, std.math.nan(f64)));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(u32, 0), info(1));
+ try std.testing.expectEqual(no_pc, info(2));
+}
+
+test "binding evaluation is pure on both success and failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 4, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try emit(2, 0, 0);
+ try emit(0, 0, 0);
+ try emit(21, 7, 0);
+ try ok(put_handler(0, 3, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ const before = engine;
+ try std.testing.expectEqual(@as(f64, 12), eval_at(0, 3));
+ try std.testing.expect(std.math.isNan(eval_at(0, 4))); // attempted store
+ try std.testing.expect(std.math.isNan(eval_at(4, 1))); // dt forbidden in views
+ try std.testing.expect(std.math.isNan(eval_at(5, 2))); // branch forbidden
+ try std.testing.expect(std.math.isNan(eval_at(no_pc, 7)));
+ try std.testing.expect(std.math.isNan(eval_at(0, no_pc)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 2))); // two results
+ // Compare only the live member of the untagged workspace, never padding
+ // or the inactive member's indeterminate storage.
+ try std.testing.expectEqual(before.staging_open, engine.staging_open);
+ inline for (std.meta.fields(Engine)) |field| {
+ if (comptime std.mem.eql(u8, field.name, "workspace")) {
+ if (before.staging_open) {
+ try std.testing.expectEqualDeep(before.workspace.staging, engine.workspace.staging);
+ } else {
+ try std.testing.expectEqualDeep(before.workspace.reuse, engine.workspace.reuse);
+ }
+ } else try std.testing.expectEqualDeep(@field(before, field.name), @field(engine, field.name));
+ }
+}
+
+test "arithmetic comparison logical min max and clamp expression semantics" {
+ const cases = [_]struct { op: u32, a: f64, b: f64, want: f64 }{
+ .{ .op = 3, .a = 7, .b = 2, .want = 9 },
+ .{ .op = 4, .a = 7, .b = 2, .want = 5 },
+ .{ .op = 5, .a = 7, .b = 2, .want = 14 },
+ .{ .op = 6, .a = 7, .b = 2, .want = 3.5 },
+ .{ .op = 8, .a = 2, .b = 7, .want = 1 },
+ .{ .op = 9, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 10, .a = 2, .b = 7, .want = 0 },
+ .{ .op = 11, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 12, .a = 7, .b = 2, .want = 0 },
+ .{ .op = 13, .a = 7, .b = 2, .want = 1 },
+ .{ .op = 14, .a = -7, .b = 2, .want = 1 },
+ .{ .op = 15, .a = 0, .b = 2, .want = 1 },
+ .{ .op = 17, .a = 7, .b = 2, .want = 2 },
+ .{ .op = 18, .a = 7, .b = 2, .want = 7 },
+ };
+ for (cases) |case| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, case.a);
+ try emit(0, 0, case.b);
+ try emit(case.op, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(case.want, eval_at(0, 3));
+ }
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, -8);
+ try emit(7, 0, 0);
+ try emit(0, 0, 0);
+ try emit(0, 0, 5);
+ try emit(19, 0, 0);
+ try emit(16, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 5), eval_at(0, 5));
+ try std.testing.expectEqual(@as(f64, 0), eval_at(0, 6));
+}
+
+test "diagnostic counters wrap without affecting scalar values" {
+ reset_all();
+ try counterProgram();
+ engine.tick = no_pc;
+ engine.successful_events = no_pc;
+ engine.revision = no_pc;
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 99, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 0), info(4));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+}
+
+test "memory accounting separates reserved storage used IR and stack capacity" {
+ reset_all();
+ try std.testing.expectEqual(@as(u32, 0), layout(4));
+ try std.testing.expectEqual(@as(u32, 0), layout(5));
+ try counterProgram();
+ try std.testing.expectEqual(@as(u32, @sizeOf(Engine)), layout(0));
+ try std.testing.expectEqual(@as(u32, 16), layout(2));
+ try std.testing.expectEqual(@as(u32, 12), layout(3));
+ try std.testing.expectEqual(@as(u32, 76), layout(4));
+ try std.testing.expectEqual(@as(u32, 8), layout(5));
+ try std.testing.expectEqual(layout(1), layout(6));
+ try std.testing.expect(layout(0) >= layout(1) * 2);
+ try std.testing.expectEqual(@as(u32, 512), layout(7));
+ try std.testing.expect(layout(0) + 16 * 1024 < 64 * 1024);
+}
+
+fn displayCounterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try emit(1, 0, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try ok(configure_evaluation(32, 8));
+}
+
+fn boundedEquals(want: f64, start: u32, length: u32) !void {
+ begin_evaluation(100);
+ try std.testing.expectEqual(want, eval_bounded(start, length));
+ try ok(evaluation_info(0));
+}
+
+test "point coordinates are ephemeral event operands and unavailable to pure evaluation" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 0, -1));
+ try ok(put_state(1, 0, -1));
+ const events = [_]u32{ 1, 17, 2, 25 };
+ for (events, 0..) |event, i| {
+ try emit(22, 0, 0);
+ try emit(20, 0, 0);
+ try emit(23, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(event, @intCast(i * 4), 4));
+ }
+ try ok(commit_program());
+ for (events, 0..) |event, i| {
+ const x: f64 = @floatFromInt(i + 10);
+ try ok(dispatch_point(event, x, -x));
+ try std.testing.expectEqual(x, get_state(0));
+ try std.testing.expectEqual(-x, get_state(1));
+ }
+ try std.testing.expectEqual(public(.bad_event), dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 13), get_state(0));
+ const diagnostics = [_]u32{ info(1), info(2), info(3), info(9) };
+ try std.testing.expect(std.math.isNan(eval_at(0, 1)));
+ try std.testing.expectEqualSlices(u32, &diagnostics, &.{ info(1), info(2), info(3), info(9) });
+ try std.testing.expectEqual(@as(u32, 4), info(9));
+}
+
+test "point event ranges reject holes and invalid payloads without committing" {
+ reset_all();
+ try counterProgram();
+ for ([_]u32{ 0, 3, 16, 18, 24, 26, 32, 33, no_pc }) |event|
+ try std.testing.expectEqual(public(.bad_event), dispatch_point(event, 1, 2));
+ try std.testing.expectEqual(public(.non_finite), dispatch_point(1, std.math.nan(f64), 0));
+ try std.testing.expectEqual(public(.non_finite), dispatch_point(1, 0, std.math.inf(f64)));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try ok(begin_program(0, 8));
+ try ok(put_handler(24, 0, 0));
+ try ok(put_handler(32, 0, 0));
+ try ok(commit_program());
+ try ok(dispatch_point(24, 1, 2));
+ try ok(dispatch_point(32, 1, 2));
+ try std.testing.expectEqual(public(.bad_event), dispatch(24, 0));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+}
+
+test "point event failure rolls back writes from earlier matching handlers" {
+ reset_all();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 7, -1));
+ try emit(22, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(17, 0, 2));
+ try emit(23, 0, 0);
+ try emit(0, 0, 0);
+ try emit(6, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(17, 2, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch_point(17, 400, 200));
+ try std.testing.expectEqual(@as(f64, 7), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(2));
+}
+
+test "loading shares storage with records while old events and bindings remain executable" {
+ reset_all();
+ try displayCounterProgram();
+ try boundedEquals(20, 4, 3);
+ try boundedEquals(20, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(9));
+ try emit(1, 0, 0);
+ try ok(dispatch(1, 0)); // Active state advances after staging begins.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(3));
+ try ok(configure_evaluation(32, 1));
+ forget_evaluation(3); // Must not overwrite the pending program.
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(33, 0, 3);
+ try boundedEquals(33, 0, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+}
+
+test "failed and abandoned candidates restore empty reuse storage without changing active work" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(20, 0, 0); // Store without a handler fails validation.
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try boundedEquals(20, 4, 3);
+ try ok(dispatch(1, 0));
+ abort_program();
+ try boundedEquals(22, 4, 3);
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ abort_program(); // Inactive abort is a no-op, including for warmed records.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try std.testing.expectEqual(public(.capacity), begin_program(max_states + 1, 0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+}
+
+test "full pending program survives repeated evaluation configuration record loss and reset" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ for (0..max_instructions) |i| {
+ try emit(0, 0, @floatFromInt(i));
+ try ok(configure_evaluation(32, @intCast(i % 9)));
+ forget_evaluation(3);
+ try boundedEquals(22, 4, 3);
+ }
+ reset_state(); // The pending definition and old-id mapping survive reset.
+ try boundedEquals(20, 4, 3);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, max_instructions), info(6));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try boundedEquals(511, 511, 1);
+ // Safe Zig builds may reserve a hidden union tag in addition to the payload.
+ try std.testing.expect(@sizeOf(Workspace) >= @sizeOf(Program));
+ try std.testing.expect(@sizeOf(Workspace) <= @sizeOf(Program) + @alignOf(Program));
+ try std.testing.expectEqual(@as(u32, @sizeOf(Tables)), evaluation_info(8));
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer/after/evaluation.zig b/prototypes/workbench/results/2026-09-23-pointer/after/evaluation.zig
new file mode 100644
index 0000000..56081d9
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/after/evaluation.zig
@@ -0,0 +1,164 @@
+//! Disposable binding results and change history. No event state lives here.
+const std = @import("std");
+
+const Memo = struct {
+ value: f64 = 0,
+ revision: u64 = 0,
+ dependencies: u32 = 0,
+ start: u32 = 0,
+ length: u32 = 0,
+};
+const Change = struct { revision: u64 = 0, mask: u32 = 0 };
+
+pub const Tables = struct {
+ memo: [32]Memo = @splat(.{}),
+ history: [8]Change = @splat(.{}),
+};
+
+pub const Evaluation = struct {
+ pub const max_cache = 32;
+ pub const max_history = 8;
+ revision: u64 = 1,
+ floor: u64 = 1,
+ cache_count: u32 = 0,
+ history_count: u32 = 8,
+ next_change: u32 = 0,
+ limit: u32 = 0,
+ work: u32 = 0,
+ instructions: u32 = 0,
+ hits: u32 = 0,
+ status: u32 = 0,
+
+ pub fn configure(self: *Evaluation, tables: ?*Tables, cache: u32, history: u32) bool {
+ if (cache > max_cache or history > max_history) return false;
+ self.cache_count = cache;
+ self.history_count = history;
+ self.forget(tables, 3);
+ return true;
+ }
+
+ pub fn forget(self: *Evaluation, tables: ?*Tables, flags: u32) void {
+ if (flags & 1 != 0) if (tables) |t| { t.memo = @splat(.{}); };
+ if (flags & 2 != 0) {
+ if (tables) |t| t.history = @splat(.{});
+ self.floor = self.revision;
+ self.next_change = 0;
+ }
+ }
+
+ pub fn changed(self: *Evaluation, tables: ?*Tables, mask: u32) void {
+ const t = tables orelse return;
+ if (mask == 0 or self.cache_count == 0) return;
+ if (self.revision == std.math.maxInt(u64)) {
+ self.revision = 1;
+ self.forget(t, 3);
+ }
+ self.revision += 1;
+ if (self.history_count == 0) {
+ self.floor = self.revision;
+ return;
+ }
+ const change = &t.history[self.next_change];
+ self.floor = @max(self.floor, change.revision);
+ change.* = .{ .revision = self.revision, .mask = mask };
+ self.next_change = (self.next_change + 1) % self.history_count;
+ }
+
+ pub fn begin(self: *Evaluation, limit: u32) void {
+ self.limit = limit;
+ self.work = 0;
+ self.instructions = 0;
+ self.hits = 0;
+ self.status = 0;
+ }
+
+ pub fn spend(self: *Evaluation) bool {
+ if (self.work == self.limit) {
+ self.status = 10;
+ return false;
+ }
+ self.work += 1;
+ return true;
+ }
+
+ fn slot(self: *const Evaluation, start: u32, length: u32) u32 {
+ // One candidate, including on misses; no scan proportional to cache size.
+ return (start ^ (start >> 5) ^ (length *% 0x9e3779b9)) % self.cache_count;
+ }
+
+ pub fn lookup(self: *Evaluation, tables: ?*Tables, start: u32, length: u32) ?f64 {
+ const t = tables orelse return null;
+ if (self.cache_count == 0) return null;
+ if (!self.spend()) return null;
+ const memo = &t.memo[self.slot(start, length)];
+ if (memo.revision == 0 or memo.start != start or memo.length != length) return null;
+ // Successful pure constants need no input-change evidence. A program
+ // replacement still clears them, just like every other binding memo.
+ if (memo.dependencies != 0 and memo.revision < self.floor) return null;
+ if (memo.dependencies != 0 and memo.revision != self.revision) {
+ for (t.history[0..self.history_count]) |change| {
+ if (!self.spend()) return null;
+ if (change.revision > memo.revision and change.mask & memo.dependencies != 0) return null;
+ }
+ }
+ memo.revision = self.revision;
+ self.hits += 1;
+ return memo.value;
+ }
+
+ pub fn remember(self: *Evaluation, tables: ?*Tables, start: u32, length: u32, value: f64, dependencies: u32) void {
+ const t = tables orelse return;
+ if (self.cache_count == 0) return;
+ t.memo[self.slot(start, length)] = .{ .start = start, .length = length,
+ .value = value, .dependencies = dependencies, .revision = self.revision };
+ }
+};
+
+test "record loss cannot authorize an old memo; unrelated changes can" {
+ var tables: Tables = .{};
+ var e: Evaluation = .{};
+ try std.testing.expect(e.configure(&tables, 1, 1));
+ e.begin(100);
+ e.remember(&tables, 0, 3, 12, 1);
+ e.changed(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, 12), e.lookup(&tables, 0, 3));
+ e.changed(&tables, 2);
+ e.changed(&tables, 2); // The certificate now predates the surviving history.
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 12, 1);
+ e.changed(&tables, 1);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 20, 1);
+ e.changed(&tables, 2);
+ e.forget(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 20, 1);
+ e.revision = std.math.maxInt(u64);
+ e.changed(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+}
+
+test "budget, collisions, invalid settings, and disabled history are conservative" {
+ var tables: Tables = .{};
+ var e: Evaluation = .{};
+ try std.testing.expect(e.configure(&tables, 1, 0));
+ e.begin(100);
+ e.remember(&tables, 0, 3, 12, 1);
+ e.remember(&tables, 1, 3, 15, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ try std.testing.expectEqual(@as(?f64, 15), e.lookup(&tables, 1, 3));
+ e.changed(&tables, 0x80000000);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 1, 3));
+ try std.testing.expect(!e.configure(&tables, 33, 0));
+ try std.testing.expectEqual(@as(u32, 1), e.cache_count);
+ e.begin(0);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 1, 3));
+ try std.testing.expectEqual(@as(u32, 10), e.status);
+ try std.testing.expectEqual(@as(u32, 0), e.work);
+ e.begin(100);
+ e.remember(&tables, 2, 1, 99, 0);
+ e.changed(&tables, 1);
+ e.forget(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, 99), e.lookup(&tables, 2, 1));
+ try std.testing.expectEqual(@as(u32, 1), e.work);
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer/after/examples/direct.pure b/prototypes/workbench/results/2026-09-23-pointer/after/examples/direct.pure
new file mode 100644
index 0000000..ecc54d5
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/after/examples/direct.pure
@@ -0,0 +1,48 @@
+# 도형을 잡은 위치를 유지해 이동합니다. 취소하면 잡기 전 위치로 돌아갑니다.
+state x = 80
+state y = 160
+state grab_x = 0
+state grab_y = 0
+state start_x = 80
+state start_y = 160
+state dragging = 0
+
+input drag = pointer(tile)
+input left = ArrowLeft
+input right = ArrowRight
+
+box left_area(20, 100, 280, 220, "#233a31")
+box right_area(340, 100, 280, 220, "#343b25")
+box tile(x, y, 56, 44, "#c9f08b")
+text horizontal(30, 45, x, "x: ")
+text vertical(180, 45, y, "y: ")
+text region(350, 70, x >= 320, "오른쪽 영역: ")
+text active(30, 75, dragging, "잡고 있음: ")
+
+on drag.down {
+ start_x = x
+ start_y = y
+ grab_x = event_x - x
+ grab_y = event_y - y
+ dragging = 1
+}
+on drag.move {
+ x = clamp(event_x - grab_x, 20, 564)
+ y = clamp(event_y - grab_y, 100, 276)
+}
+on drag.up {
+ x = clamp(event_x - grab_x, 20, 564)
+ y = clamp(event_y - grab_y, 100, 276)
+ dragging = 0
+}
+on drag.cancel {
+ x = start_x
+ y = start_y
+ dragging = 0
+}
+on left.down {
+ if dragging == 0 { x = max(20, x - 10) }
+}
+on right.down {
+ if dragging == 0 { x = min(564, x + 10) }
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer/after/pointer.test.mjs b/prototypes/workbench/results/2026-09-23-pointer/after/pointer.test.mjs
new file mode 100644
index 0000000..1c0e43c
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/after/pointer.test.mjs
@@ -0,0 +1,110 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { compile } from './web/compiler.mjs';
+import { createRuntime } from './web/runtime.mjs';
+
+const wasm = await readFile(new URL('./web/core.wasm', import.meta.url));
+const source = await readFile(new URL('./examples/direct.pure', import.meta.url), 'utf8');
+const fresh = async () => { const r = await createRuntime(wasm, {cacheEntries:32}); r.apply(source); return r; };
+
+test('pointer declarations resolve forward targets and reject ambiguous or contextless inputs', () => {
+ const p = compile(source);
+ assert.equal(p.inputs[0].target, 'tile');
+ assert(p.handlers.some(h => h.event === 17)); assert(p.handlers.some(h => h.event === 25));
+ assert(p.instructions.some(i => i.op === 22)); assert(p.instructions.some(i => i.op === 23));
+ for (const [code, message] of [
+ ['input p = pointer(missing)', /Unknown pointer target/],
+ ['input p=pointer(tile)\ninput q=pointer(tile)\ntext tile(0,0,0)', /Duplicate pointer/],
+ ['input p=Space\non p.move {}', /require a pointer/],
+ ['input p=Space\non p.cancel {}', /require a pointer/],
+ ['state x=0\non frame { x=event_x }', /only available/],
+ ['text v(event_y,0,0)', /only available/],
+ ['state event_x=0', /Reserved name/],
+ ]) assert.throws(() => compile(code), message);
+ const full = compile(Array.from({length:8}, (_, i) => `input p${i}=pointer(v${i})\ntext v${i}(0,0,0)\non p${i}.move {}\non p${i}.cancel {}`).join('\n'));
+ assert.deepEqual(full.handlers.map(h => h.event), Array.from({length:8}, (_, i) => [17+i,25+i]).flat());
+});
+
+test('authored drag keeps grab offset, uses final up coordinates, and updates linked output', async () => {
+ const r = await fresh();
+ r.point('drag', 'down', 90, 170, 'pen');
+ assert.equal(r.snapshot().states.dragging, 1);
+ assert.throws(() => r.apply(''), /놓은 뒤/);
+ r.point('drag', 'move', 360, 220, 'pen');
+ let s = r.snapshot();
+ assert.equal(s.states.x, 350); assert.equal(s.states.y, 210);
+ assert.equal(s.views.find(v => v.id === 'region').value, 1);
+ r.point('drag', 'up', 410, 240, 'pen');
+ s = r.snapshot();
+ assert.equal(s.states.x, 400); assert.equal(s.states.y, 230);
+ assert.equal(s.states.dragging, 0); assert.deepEqual(s.held, []);
+ assert.equal(s.successfulEvents, 3);
+ assert.equal(s.lastEvent.x, 410);
+ r.down('left'); r.up('left');
+ assert.equal(r.snapshot().states.x, 390);
+});
+
+test('ownership, duplicate downs, stale moves, cancellation and focus cleanup cannot leave held input', async () => {
+ const r = await fresh();
+ r.point('drag', 'down', 90, 170, 'a');
+ r.point('drag', 'down', 500, 500, 'a');
+ assert.throws(() => r.point('drag', 'down', 90, 170, 'b'), /다른 포인터/);
+ r.point('drag', 'move', 400, 200, 'b');
+ assert.equal(r.snapshot().successfulEvents, 1);
+ r.point('drag', 'move', 400, 200, 'a');
+ r.releaseAll();
+ const canceled = r.snapshot();
+ assert.equal(canceled.states.x, 80); assert.equal(canceled.states.y, 160);
+ assert.equal(canceled.states.dragging, 0); assert.equal(canceled.lastEvent.phase, 'cancel');
+ assert.deepEqual(canceled.held, []);
+ r.point('drag', 'cancel', 1, 1, 'a'); r.point('drag', 'move', 500, 500, 'a'); r.releaseAll();
+ assert.deepEqual(r.snapshot(), canceled);
+ r.point('drag', 'down', 90, 170, 'new'); r.reset();
+ r.point('drag', 'up', 600, 500, 'new');
+ assert.equal(r.snapshot().states.x, 80); assert.deepEqual(r.snapshot().held, []);
+});
+
+test('invalid coordinates and modes do not mutate a held gesture; arithmetic failure rolls back one event', async () => {
+ const r = await fresh(); r.point('drag', 'down', 90, 170);
+ const before = r.snapshot();
+ for (const invoke of [() => r.point('drag','move',NaN,1), () => r.point('drag','up',1,Infinity),
+ () => r.point('drag','unknown',1,2), () => r.point('left','down',1,2),
+ () => r.down('drag'), () => r.up('drag'), () => r.point('drag','move',1,2,'')]) assert.throws(invoke);
+ assert.deepEqual(r.snapshot(), before);
+ r.releaseAll();
+ r.apply(source.replace('x = clamp(event_x - grab_x, 20, 564)', 'x = event_x\n y = 1 / 0'));
+ r.point('drag', 'down', 90, 170);
+ assert.equal(r.point('drag', 'move', 500, 220), 4);
+ let s = r.snapshot(); assert.equal(s.states.x, 80); assert.equal(s.states.y, 160);
+ assert.equal(s.lastFailure.phase, 'move');
+ r.releaseAll(); s = r.snapshot(); assert.deepEqual(s.held, []); assert.equal(s.lastError, 4);
+ assert.equal(s.states.dragging, 0);
+});
+
+test('independent targets share the event pipeline and publication remains non-reentrant', async () => {
+ const r = await createRuntime(wasm);
+ r.apply('state x=0\nstate y=0\ninput a=pointer(one)\ninput b=pointer(two)\ntext one(0,0,x)\ntext two(0,0,y)\non a.move { x=event_x }\non b.move { y=event_y }');
+ r.point('a','down',1,2,'first'); r.point('b','down',3,4,'second');
+ r.point('a','move',10,20,'first'); r.point('b','move',30,40,'second');
+ assert.deepEqual(r.snapshot().states,{x:10,y:40});
+ r.present({begin(){assert.throws(() => r.point('a','move',0,0,'first'), /출력 전달 중/);},state(){},view(){},value(){},end(){}});
+ r.releaseAll(); assert.deepEqual(r.snapshot().held, []);
+});
+
+test('failing down, up and cancel never prevent terminal ownership cleanup', async () => {
+ for (const phase of ['down', 'up', 'cancel']) {
+ const r = await createRuntime(wasm);
+ r.apply(`state x=7\ninput p=pointer(tile)\ntext tile(0,0,x)\non p.${phase} { x=event_x\nx=1/0 }`);
+ const code = r.point('p','down',10,20,'owner');
+ assert.equal(code, phase === 'down' ? 4 : 0);
+ assert.deepEqual(r.snapshot().held, ['p']);
+ if (phase === 'up') assert.equal(r.point('p','up',30,40,'owner'), 4);
+ else assert.equal(r.releaseAll(), phase === 'cancel' ? 4 : 0);
+ const s = r.snapshot();
+ assert.equal(s.states.x, 7); assert.equal(s.lastError, 4);
+ assert.equal(s.lastFailure.phase, phase); assert.deepEqual(s.held, []);
+ r.apply('state x=100'); // Cleanup also unblocks definition changes.
+ assert.equal(r.snapshot().states.x, 7);
+ }
+});
diff --git a/prototypes/workbench/results/2026-09-23-pointer/after/qa-pointer.js b/prototypes/workbench/results/2026-09-23-pointer/after/qa-pointer.js
new file mode 100644
index 0000000..9a12cc4
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/after/qa-pointer.js
@@ -0,0 +1,120 @@
+// Playwright CLI run-code: real mouse / CDP touch input; no timing benchmark.
+async (page) => {
+ const checks = [], errors = [];
+ const check = (condition, label) => { if (!condition) throw new Error(label); checks.push(label); };
+ const close = (a, b) => Math.abs(a - b) < 0.1;
+ const state = () => page.evaluate(() => window.pureWorkbench.snapshot());
+ const client = (x, y) => page.evaluate(({x,y}) => {
+ const p = new DOMPoint(x,y).matrixTransform(document.querySelector('#scene').getScreenCTM());
+ return {x:p.x,y:p.y};
+ }, {x,y});
+ const move = async (x,y) => { const p = await client(x,y); await page.mouse.move(p.x,p.y); };
+ const start = async (x,y) => { await move(x,y); await page.mouse.down(); };
+ const reset = async () => { await page.locator('#reset').click(); await page.locator('#scene').scrollIntoViewIfNeeded(); };
+ const apply = async source => { await page.locator('#source').fill(source); await page.locator('#apply').click(); await page.locator('#scene').scrollIntoViewIfNeeded(); };
+ const onError = error => errors.push(error.message);
+ const onDialog = dialog => dialog.accept();
+ page.on('pageerror', onError); page.on('dialog', onDialog);
+ let session;
+ try {
+ await page.setViewportSize({width:1440,height:1000});
+ await page.reload();
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ await page.locator('#example').selectOption('direct');
+ await page.locator('#load-example').click();
+ const source = await page.locator('#source').inputValue();
+ check((await state()).views.length === 0, 'loading the pointer example leaves the running program unchanged until Apply');
+ await page.locator('#apply').click(); await page.locator('#scene').scrollIntoViewIfNeeded();
+ check(await page.locator('[data-point-input="drag"]').count() === 1, 'authored pointer declaration binds the visible target');
+ check(await page.locator('#input-controls button').count() === 2, 'keyboard alternatives remain available without a fake pointer button');
+ await page.evaluate(() => {
+ window.__pointerTile = document.querySelector('[data-point-input="drag"]');
+ document.querySelector('#scene').addEventListener('pointerdown', e => { window.__pointerID = e.pointerId; });
+ });
+ await start(90,170);
+ let s = await state();
+ check(s.states.dragging === 1 && s.held.includes('drag'), 'real mouse down reaches the authored rule');
+ await move(370,230); s = await state();
+ check(close(s.states.x,360) && close(s.states.y,220), 'drag retains grab offset in scene coordinates');
+ check(s.views.find(v => v.id === 'region').value === 1 && await page.locator('[data-view-id="region"]').textContent() === '오른쪽 영역: 1', 'linked output updates when the authored boundary is crossed');
+ check(await page.evaluate(() => window.__pointerTile === document.querySelector('[data-point-input="drag"]')), 'drag keeps its bound DOM target');
+ // Physical mouse outside the SVG still reaches the stable captured scene.
+ await move(750,400); s = await state();
+ check(close(s.states.x,564) && close(s.states.y,276), 'capture delivers movement outside the scene and authored clamp applies');
+ await page.mouse.up(); s = await state();
+ check(s.held.length === 0 && s.states.dragging === 0 && s.lastEvent.phase === 'up', 'release outside the scene ends the gesture once');
+ await reset();
+ await start(90,170); await move(380,240);
+ await page.evaluate(() => window.dispatchEvent(new Event('blur')));
+ s = await state();
+ check(close(s.states.x,80) && close(s.states.y,160) && s.states.dragging === 0 && s.held.length === 0 && s.lastEvent.phase === 'cancel', 'focus loss invokes authored rollback and clears ownership');
+ const ended = s.successfulEvents;
+ await page.mouse.up();
+ check((await state()).successfulEvents === ended, 'late mouse release after cancel does not dispatch a second terminal event');
+ await start(90,170); await move(350,220);
+ await page.evaluate(() => document.querySelector('#scene').releasePointerCapture(window.__pointerID));
+ await move(360,230); s = await state();
+ check(close(s.states.x,80) && s.held.length === 0 && s.lastEvent.phase === 'cancel', 'explicit capture loss cancels instead of committing a drop');
+ await page.mouse.up();
+ await start(90,170); await move(300,220);
+ await page.evaluate(() => document.querySelector('#scene').dispatchEvent(new PointerEvent('pointercancel', {pointerId:window.__pointerID,bubbles:true})));
+ check((await state()).held.length === 0 && close((await state()).states.x,80), 'browser cancellation routes to the authored cancel handler');
+ await page.mouse.up();
+ // A source edit, not a renderer option, changes the movement constraint.
+ await apply(source.replaceAll('20, 564','20, 200'));
+ await start(90,170); await move(500,220); await page.mouse.up(); s = await state();
+ check(close(s.states.x,200), 'editing the authored rule changes the drag limit');
+ await apply(source + '\non drag.move { x = 1 / 0 }\n'); await reset();
+ await start(90,170); await move(300,220); s = await state();
+ check(s.lastError === 4 && close(s.states.x,80) && close(s.states.y,160), 'later handler failure rolls back all earlier movement writes');
+ await page.evaluate(() => window.dispatchEvent(new Event('blur'))); await page.mouse.up();
+ s = await state();
+ check(s.lastError === 4 && s.held.length === 0 && s.states.dragging === 0, 'cleanup releases a failed gesture while preserving its error');
+ await apply(source); await reset();
+ await page.screenshot({path:'output/playwright/pure-pointer-desktop.png',fullPage:true});
+
+ await page.setViewportSize({width:390,height:844});
+ await page.locator('#scene').scrollIntoViewIfNeeded();
+ session = await page.context().newCDPSession(page);
+ await session.send('Emulation.setTouchEmulationEnabled',{enabled:true,maxTouchPoints:2});
+ const touch = async (type, points) => session.send('Input.dispatchTouchEvent',{type,touchPoints:points});
+ const a = await client(90,170), b = await client(270,210);
+ const scroll = await page.evaluate(() => window.scrollY);
+ await touch('touchStart',[{id:1,...a}]);
+ check((await state()).states.dragging === 1, 'touch contact starts the same authored interaction');
+ await touch('touchMove',[{id:1,...b}]); s = await state();
+ check(close(s.states.x,260) && close(s.states.y,200), 'mobile scaled SVG maps touch coordinates back to world coordinates');
+ check(await page.evaluate(() => window.scrollY) === scroll, 'touch drag manipulates the target without scrolling the page');
+ await touch('touchEnd',[]); s = await state();
+ check(s.held.length === 0 && s.states.dragging === 0 && s.lastEvent.phase === 'up', 'touch release clears capture ownership');
+ await page.screenshot({path:'output/playwright/pure-pointer-mobile.png',fullPage:true});
+ // Independent contacts are not collapsed into one global drag state.
+ const two = 'state x=0\nstate y=0\ninput a=pointer(one)\ninput b=pointer(two)\nbox one(20,100,100,80,"#c9f08b")\nbox two(300,100,100,80,"#c9f08b")\non a.move { x=event_x }\non b.move { y=event_y }';
+ await apply(two);
+ const p1 = {id:1,...await client(50,140)}, p2 = {id:2,...await client(330,140)};
+ await touch('touchStart',[p1]); await touch('touchStart',[p1,p2]);
+ check((await state()).held.length === 2, 'two contacts on different authored targets retain separate owners');
+ await touch('touchMove',[{id:1,...await client(180,160)},{id:2,...await client(430,200)}]); s = await state();
+ check(close(s.states.x,180) && close(s.states.y,200), 'independent touch payloads reach their own rules');
+ await touch('touchCancel',[]);
+ check((await state()).held.length === 0, 'touch cancellation releases both targets');
+ // A second contact on the same target cannot steal the first one.
+ await touch('touchStart',[p1]);
+ await touch('touchStart',[p1,{id:2,...await client(70,150)}]);
+ check((await state()).held.length === 1, 'second contact on an already owned target does not steal it');
+ await touch('touchCancel',[]);
+ check((await state()).held.length === 0, 'ignored second contact cannot strand a logical input');
+ check(errors.length === 0,'no browser page errors');
+ await session.send('Emulation.setTouchEmulationEnabled',{enabled:false});
+ await session.detach(); session = null;
+ await page.setViewportSize({width:1440,height:1000}); await apply(source); await reset();
+ await page.evaluate(result => { window.__purePointerEvidence = result; }, {checks,errors,kind:'functional input/output checks; no performance benchmark'});
+ return {checks:checks.length,errors};
+ } catch (error) {
+ await page.evaluate(result => { window.__purePointerEvidence = result; }, {failed:String(error),checks,errors});
+ throw error;
+ } finally {
+ if (session) { await session.send('Emulation.setTouchEmulationEnabled',{enabled:false}); await session.detach(); }
+ page.off('pageerror',onError); page.off('dialog',onDialog);
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer/after/web/app.js b/prototypes/workbench/results/2026-09-23-pointer/after/web/app.js
new file mode 100644
index 0000000..3f35a7d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/after/web/app.js
@@ -0,0 +1,378 @@
+import { createRuntime } from './runtime.mjs';
+import { createOutput } from './output.mjs';
+import { bindSceneInput } from './scene-input.mjs';
+
+const $ = id => document.getElementById(id);
+const source = $('source');
+const MAX_SOURCE_BYTES = 32768;
+const encoder = new TextEncoder();
+const output = createOutput(document);
+const snippets = {
+ state: 'state count = 0\nstate x = 80\n',
+ input: 'input tap = Space\non tap.down {\n count = count + 1\n}\n',
+ view: 'box tile(x, 180, 40, 40, "#7ed9ae")\ntext label(40, 60, count, "Count: ")\n',
+ frame: 'on frame {\n x = x + 30 * dt\n if x > 600 { x = 0 }\n}\n',
+};
+let runtime = null;
+let appliedSource = '';
+let running = false;
+let previousTime = null;
+let accumulator = 0;
+let droppedTicks = 0;
+let diagnosticLocation = null;
+const keyboardSources = new Map();
+const pointerSources = new Map();
+const inputButtons = new Map();
+const sceneInput = bindSceneInput($('scene'), () => runtime, safeAction);
+
+const bytes = text => encoder.encode(text).byteLength;
+const dirty = () => source.value !== appliedSource;
+// DOM is the published value. Avoid rewriting unchanged diagnostics/controls.
+function text(id, value) {
+ const node = $(id);
+ if (node.textContent !== value) node.textContent = value;
+}
+function pressed(node, value) {
+ const next = String(value);
+ if (node.getAttribute('aria-pressed') !== next) node.setAttribute('aria-pressed', next);
+}
+function draftStatus() {
+ const changed = dirty();
+ text('draft-status', changed ? '미적용 변경' : runtime?.currentProgram ? '적용됨' : '빈 작업물');
+ $('draft-status').classList.toggle('dirty', changed);
+}
+
+function setDiagnostic(message, kind = '', location = null) {
+ $('diagnostic-text').textContent = message;
+ $('diagnostic').className = `diagnostic ${kind}`;
+ diagnosticLocation = location;
+ $('error-location').hidden = !location;
+ source.setAttribute('aria-invalid', kind === 'error' && location ? 'true' : 'false');
+}
+
+function cursorPosition() {
+ const before = source.value.slice(0, source.selectionStart);
+ const lines = before.split('\n');
+ text('cursor-position', `${lines.length}행 · ${lines.at(-1).length + 1}열`);
+}
+
+function draftChanged() {
+ // Read scroll position before writes; this path runs only for source edits.
+ const scrollTop = source.scrollTop;
+ const count = source.value.split('\n').length;
+ const size = bytes(source.value);
+ text('line-numbers', Array.from({ length: count }, (_, i) => i + 1).join('\n'));
+ $('line-numbers').scrollTop = scrollTop;
+ text('source-size', `${size.toLocaleString('en-US')} / 32,768 bytes`);
+ $('source-size').style.color = size > MAX_SOURCE_BYTES ? 'var(--danger)' : '';
+ draftStatus();
+ cursorPosition();
+}
+
+function pause() {
+ running = false;
+ previousTime = null;
+ accumulator = 0;
+ text('play', '▶ 실행');
+ pressed($('play'), false);
+}
+
+function safeAction(action) {
+ if (!runtime) return;
+ try { action(); }
+ catch (error) { pause(); setDiagnostic(error.message, 'error', error.line ? { line: error.line, column: error.column || 1 } : null); }
+ refresh();
+}
+
+function releaseEverything() {
+ keyboardSources.clear();
+ pointerSources.clear();
+ sceneInput.releaseCaptures();
+ if (runtime) safeAction(() => runtime.releaseAll());
+}
+
+function loseFocus() {
+ pause();
+ releaseEverything();
+ refresh();
+}
+
+function refresh() {
+ if (!runtime) return;
+ const snapshot = runtime.present(output);
+ if (snapshot.lastError) pause();
+ const held = new Set((snapshot.held || []).map(input => typeof input === 'string' ? input : input.name));
+ for (const [name, button] of inputButtons) {
+ button.classList.toggle('held', held.has(name));
+ pressed(button, held.has(name));
+ }
+ text('scene-status', runtime.currentProgram ? (running ? '실행 중' : '정지') : '적용 전');
+ text('clock-status', `${snapshot.tick || 0} frame · ${running ? '60 Hz' : '정지'}`);
+ text('revision', `revision ${snapshot.revision || 0}`);
+ for (const id of ['play', 'step', 'reset']) {
+ if ($(id).disabled !== !runtime.currentProgram) $(id).disabled = !runtime.currentProgram;
+ }
+ const event = snapshot.lastEvent;
+ text('last-event', event ? `${event.name}${event.phase && event.phase !== 'frame' ? `.${event.phase}` : ''} · ${event.status ? '실패' : '완료'}${event.line ? ` · ${event.line}행` : ''}` : '아직 없음');
+ if ($('runtime-error').hidden !== !snapshot.lastError) $('runtime-error').hidden = !snapshot.lastError;
+ const displayError = snapshot.lastError === 100 || snapshot.lastError === 101;
+ let message = snapshot.lastError ? `${displayError ? '표시 오류' : '마지막 실패'} · ${snapshot.errorLine ? `${snapshot.errorLine}행 · ` : ''}${snapshot.errorMessage || `실행 오류 ${snapshot.lastError}`}${snapshot.lastError === 101 ? ' — 이번 장면을 표시하지 않았습니다. 상태 변경은 유지됩니다.' : displayError ? ' — 해당 도형을 표시할 수 없습니다.' : ' — 실패한 이벤트의 상태 변경은 취소됐습니다. 코드를 적용하거나 초깃값으로 되돌리면 이 기록을 지웁니다.'}` : '';
+ if (!displayError && snapshot.displayError?.code === 101)
+ message += ` · ${snapshot.displayError.message}`;
+ text('runtime-error', message);
+ output.costs(snapshot, runtime.currentProgram?.metrics, droppedTicks);
+}
+
+function endPointer(event) {
+ const entry = pointerSources.get(event.pointerId);
+ if (!entry) return;
+ event.preventDefault();
+ pointerSources.delete(event.pointerId);
+ safeAction(() => runtime.up(entry.name, entry.source));
+}
+
+function rebuildInputControls() {
+ inputButtons.clear();
+ const children = [];
+ for (const input of runtime.currentProgram?.inputs || []) {
+ if (input.target) {
+ const note = document.createElement('p');
+ note.className = 'empty-note';
+ note.textContent = `${input.name} · 장면의 ${input.target}을 잡아 조작하세요.`;
+ children.push(note);
+ continue;
+ }
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'input-button';
+ button.dataset.input = input.name;
+ button.setAttribute('aria-pressed', 'false');
+ const name = document.createElement('span'); name.textContent = input.name;
+ const key = document.createElement('kbd'); key.textContent = input.key;
+ button.append(name, key);
+ button.addEventListener('pointerdown', event => {
+ if (event.button !== 0) return;
+ event.preventDefault();
+ const identity = `pointer:${event.pointerId}`;
+ pointerSources.set(event.pointerId, { name: input.name, source: identity });
+ button.setPointerCapture(event.pointerId);
+ safeAction(() => runtime.down(input.name, identity));
+ });
+ button.addEventListener('pointerup', endPointer);
+ button.addEventListener('pointercancel', endPointer);
+ button.addEventListener('lostpointercapture', endPointer);
+ // Keyboard activation and assistive technology clicks are complete taps.
+ // Pointer clicks already delivered down/up via the handlers above.
+ button.addEventListener('click', event => {
+ if (event.detail !== 0) return;
+ safeAction(() => { runtime.down(input.name, 'activation'); runtime.up(input.name, 'activation'); });
+ });
+ inputButtons.set(input.name, button);
+ children.push(button);
+ }
+ if (!children.length) {
+ const message = document.createElement('p');
+ message.className = 'empty-note';
+ message.textContent = '입력을 선언하면 여기에 버튼이 생깁니다.';
+ children.push(message);
+ }
+ $('input-controls').replaceChildren(...children);
+}
+
+function isEditing(target) {
+ return target instanceof Element && Boolean(target.closest('textarea,input,select,button,[contenteditable="true"],[role="textbox"]'));
+}
+
+$('scene').addEventListener('pointerdown', () => $('scene').focus());
+
+document.addEventListener('keydown', event => {
+ if (!runtime || event.repeat || event.isComposing || event.ctrlKey || event.metaKey || event.altKey || isEditing(event.target)) return;
+ const input = runtime.currentProgram?.inputs.find(item => item.key === event.code);
+ if (!input) return;
+ event.preventDefault();
+ if (keyboardSources.has(event.code)) return;
+ keyboardSources.set(event.code, input.name);
+ safeAction(() => runtime.down(input.name, `keyboard:${event.code}`));
+});
+
+document.addEventListener('keyup', event => {
+ const name = keyboardSources.get(event.code);
+ if (!name) return;
+ event.preventDefault();
+ keyboardSources.delete(event.code);
+ safeAction(() => runtime.up(name, `keyboard:${event.code}`));
+});
+
+source.addEventListener('input', () => {
+ draftChanged();
+ if (diagnosticLocation) setDiagnostic('코드를 수정했습니다. 적용해서 다시 확인하세요.');
+});
+source.addEventListener('scroll', () => { $('line-numbers').scrollTop = source.scrollTop; });
+for (const name of ['click', 'keyup', 'select']) source.addEventListener(name, cursorPosition);
+source.addEventListener('keydown', event => {
+ if (event.key === 'Tab' && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
+ event.preventDefault();
+ source.setRangeText(' ', source.selectionStart, source.selectionEnd, 'end');
+ draftChanged();
+ }
+});
+
+$('apply').addEventListener('click', () => {
+ if (!runtime) return;
+ try {
+ runtime.apply(source.value);
+ appliedSource = source.value;
+ rebuildInputControls();
+ setDiagnostic('코드를 적용했습니다. 같은 이름의 상태는 유지됩니다.', 'success');
+ } catch (error) {
+ const location = error.line ? { line: error.line, column: error.column || 1 } : null;
+ setDiagnostic(`${location ? `${location.line}행 ${location.column}열 · ` : ''}${error.message} · 기존 장면은 그대로 유지됩니다.`, 'error', location);
+ }
+ draftStatus();
+ refresh();
+});
+
+$('error-location').addEventListener('click', () => {
+ if (!diagnosticLocation) return;
+ const lines = source.value.split('\n');
+ const lineIndex = Math.min(lines.length - 1, diagnosticLocation.line - 1);
+ const start = lines.slice(0, lineIndex).reduce((sum, line) => sum + line.length + 1, 0);
+ const at = start + Math.min(lines[lineIndex].length, diagnosticLocation.column - 1);
+ source.focus();
+ source.setSelectionRange(at, Math.min(at + 1, source.value.length));
+ const lineHeight = parseFloat(getComputedStyle(source).lineHeight);
+ source.scrollTop = Math.max(0, lineIndex * lineHeight - source.clientHeight / 3);
+ $('line-numbers').scrollTop = source.scrollTop;
+ cursorPosition();
+});
+
+$('play').addEventListener('click', () => {
+ if (running) pause();
+ else { running = true; previousTime = null; accumulator = 0; $('play').textContent = 'Ⅱ 정지'; $('play').setAttribute('aria-pressed', 'true'); }
+ refresh();
+});
+$('step').addEventListener('click', () => { pause(); safeAction(() => runtime.frame()); });
+$('reset').addEventListener('click', () => {
+ pause(); releaseEverything(); droppedTicks = 0;
+ safeAction(() => runtime.reset());
+ setDiagnostic('적용된 코드의 초깃값으로 되돌렸습니다. 편집 중인 코드는 유지됩니다.');
+});
+
+function replaceDraft(text, message) {
+ source.value = text;
+ source.scrollTop = 0;
+ source.setSelectionRange(0, 0);
+ setDiagnostic(message);
+ draftChanged();
+}
+
+function mayReplaceDraft() {
+ return !source.value || window.confirm('현재 편집 중인 코드를 바꿀까요? 저장하지 않은 내용은 사라집니다. 실행 장면은 적용 전까지 유지됩니다.');
+}
+
+$('new').addEventListener('click', () => {
+ if (mayReplaceDraft()) replaceDraft('', '편집기만 비웠습니다. 빈 코드를 적용하면 장면도 비워집니다.');
+});
+
+for (const button of document.querySelectorAll('.insert-snippet')) {
+ button.addEventListener('click', () => {
+ const at = source.selectionStart;
+ const prefix = at > 0 && source.value[at - 1] !== '\n' ? '\n' : '';
+ source.setRangeText(prefix + snippets[button.dataset.snippet], source.selectionStart, source.selectionEnd, 'end');
+ source.focus();
+ draftChanged();
+ setDiagnostic('조각을 삽입했습니다. 이름과 동작을 원하는 대로 바꾸고 적용하세요.');
+ });
+}
+
+$('load-example').addEventListener('click', async () => {
+ if (!mayReplaceDraft()) return;
+ const selected = $('example').value;
+ const before = source.value;
+ const button = $('load-example');
+ button.disabled = true;
+ try {
+ const response = await fetch(`./examples/${selected}.pure`);
+ if (!response.ok) throw new Error(`예제를 불러오지 못했습니다 (${response.status}).`);
+ const text = await response.text();
+ if (bytes(text) > MAX_SOURCE_BYTES) throw new Error('예제가 코드 크기 제한을 넘었습니다.');
+ if (source.value !== before) throw new Error('불러오는 동안 코드가 바뀌었습니다. 변경 내용을 지키기 위해 불러오기를 취소했습니다.');
+ replaceDraft(text, '예제를 편집기에 불러왔습니다. 아직 적용하지 않았습니다.');
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+ finally { button.disabled = false; }
+});
+
+function download(filename, content, mime) {
+ const url = URL.createObjectURL(new Blob([content], { type: mime }));
+ const link = document.createElement('a');
+ link.href = url; link.download = filename;
+ document.body.append(link); link.click(); link.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ setDiagnostic('편집 중인 코드를 파일로 저장했습니다. 적용되지 않은 변경도 포함합니다.');
+}
+$('download').addEventListener('click', () => download('my-work.pure', source.value, 'text/plain;charset=utf-8'));
+$('download-project').addEventListener('click', () => download('my-work.pure.json', JSON.stringify({ format: 'pure-workbench', version: 1, source: source.value }, null, 2) + '\n', 'application/json'));
+$('upload').addEventListener('click', () => $('file-input').click());
+$('file-input').addEventListener('change', async event => {
+ const file = event.target.files[0];
+ event.target.value = '';
+ if (!file) return;
+ try {
+ if (file.size > MAX_SOURCE_BYTES * 7 + 1024) throw new Error('파일이 너무 큽니다. 코드 제한은 32 KiB입니다.');
+ const before = source.value;
+ let content = await file.text();
+ if (file.name.toLowerCase().endsWith('.json')) {
+ let project;
+ try { project = JSON.parse(content); } catch { throw new Error('올바른 JSON 파일이 아닙니다.'); }
+ if (!project || typeof project !== 'object' || Array.isArray(project) || project.format !== 'pure-workbench' || project.version !== 1 || typeof project.source !== 'string') throw new Error('Pure 작업물 형식이 아닙니다. format, version, source를 확인하세요.');
+ content = project.source;
+ }
+ if (bytes(content) > MAX_SOURCE_BYTES) throw new Error('코드가 32 KiB 제한을 넘었습니다.');
+ if (source.value !== before) throw new Error('파일을 읽는 동안 코드가 바뀌었습니다. 불러오기를 다시 시도하세요.');
+ if (mayReplaceDraft()) replaceDraft(content, `${file.name}을 편집기에 불러왔습니다. 아직 적용하지 않았습니다.`);
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+});
+
+window.addEventListener('blur', loseFocus);
+document.addEventListener('visibilitychange', () => { if (document.hidden) loseFocus(); });
+window.addEventListener('beforeunload', event => {
+ // Applying is not persistence: even applied code can be lost when leaving.
+ if (source.value || appliedSource) { event.preventDefault(); event.returnValue = ''; }
+});
+
+function animate(time) {
+ if (running && runtime) {
+ if (previousTime !== null) {
+ accumulator += Math.max(0, time - previousTime);
+ const wanted = Math.floor(accumulator / (1000 / 60));
+ if (wanted > 0) {
+ const steps = Math.min(6, wanted);
+ accumulator -= wanted * (1000 / 60);
+ droppedTicks += wanted - steps;
+ safeAction(() => runtime.frame(steps));
+ }
+ }
+ previousTime = running ? time : null;
+ }
+ requestAnimationFrame(animate);
+}
+
+Object.defineProperty(window, 'pureWorkbench', {
+ configurable: false,
+ writable: false,
+ value: Object.freeze({ snapshot: () => structuredClone({ ...(runtime?.snapshot() || {}), ready: Boolean(runtime), source: source.value, appliedSource, dirty: dirty(), running, droppedTicks, diagnostic: $('diagnostic-text').textContent }) }),
+});
+
+draftChanged();
+requestAnimationFrame(animate);
+try {
+ runtime = await createRuntime();
+ $('ready-status').textContent = '실행기 준비됨';
+ $('ready-dot').classList.add('ready');
+ $('apply').disabled = false;
+ refresh();
+} catch (error) {
+ $('ready-status').textContent = '실행기 준비 실패';
+ $('ready-dot').classList.add('failed');
+ setDiagnostic(`실행기를 열지 못했습니다. ${error.message}`, 'error');
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer/after/web/compiler.mjs b/prototypes/workbench/results/2026-09-23-pointer/after/web/compiler.mjs
new file mode 100644
index 0000000..1ec0c7c
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/after/web/compiler.mjs
@@ -0,0 +1,326 @@
+// Pure's experimental scalar interaction language. No host code is evaluated.
+const LIMITS = Object.freeze({ sourceBytes: 32768, states: 32, inputs: 8,
+ views: 24, handlers: 64, instructions: 512, depth: 64, stack: 64 });
+const RESERVED = new Set(['frame', 'dt', 'event_x', 'event_y', 'min', 'max', 'clamp']);
+const BINARY = new Map([
+ ['||', [1, 15]], ['&&', [2, 14]], ['==', [3, 12]], ['!=', [3, 13]],
+ ['<', [4, 8]], ['<=', [4, 9]], ['>', [4, 10]], ['>=', [4, 11]],
+ ['+', [5, 3]], ['-', [5, 4]], ['*', [6, 5]], ['/', [6, 6]],
+]);
+const FUNCTIONS = { min: [2, 17], max: [2, 18], clamp: [3, 19] };
+const VIEW_FIELDS = { box: ['x', 'y', 'width', 'height'],
+ circle: ['x', 'y', 'radius'], text: ['x', 'y', 'value'] };
+
+function fail(token, message) {
+ const error = new Error(`${message} (${token.line}:${token.column})`);
+ error.line = token.line;
+ error.column = token.column;
+ throw error;
+}
+
+function lex(source) {
+ const tokens = [];
+ let offset = 0, line = 1, column = 1;
+ const advance = () => {
+ const char = source[offset++];
+ if (char === '\n') { line++; column = 1; } else column++;
+ return char;
+ };
+ while (offset < source.length) {
+ const char = source[offset];
+ if (char === ' ' || char === '\t' || char === '\r') { advance(); continue; }
+ if (char === '#') { while (offset < source.length && source[offset] !== '\n') advance(); continue; }
+ const token = { line, column };
+ if (char === '\n') { advance(); tokens.push({ ...token, kind: 'nl' }); continue; }
+ if (char === '"') {
+ advance();
+ let value = '', closed = false;
+ while (offset < source.length) {
+ const next = advance();
+ if (next === '"') { closed = true; break; }
+ if (next === '\n' || next === '\r') fail(token, 'Strings must fit on one line');
+ if (next === '\\') {
+ const escaped = advance();
+ const escapes = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' };
+ if (!Object.hasOwn(escapes, escaped)) fail(token, 'Unsupported string escape');
+ value += escapes[escaped];
+ } else value += next;
+ }
+ if (!closed) fail(token, 'Unterminated string');
+ tokens.push({ ...token, kind: 'string', value }); continue;
+ }
+ const remaining = source.slice(offset);
+ const number = remaining.match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/);
+ if (number) {
+ const value = Number(number[0]);
+ if (!Number.isFinite(value)) fail(token, 'Number must be finite');
+ for (let i = 0; i < number[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'number', value }); continue;
+ }
+ const name = remaining.match(/^[A-Za-z_][A-Za-z_0-9]*/);
+ if (name) {
+ for (let i = 0; i < name[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'name', value: name[0] }); continue;
+ }
+ const pair = source.slice(offset, offset + 2);
+ if (['<=', '>=', '==', '!=', '&&', '||'].includes(pair)) {
+ advance(); advance(); tokens.push({ ...token, kind: pair }); continue;
+ }
+ if ('=+-*/!<>(){},.;'.includes(char)) {
+ advance(); tokens.push({ ...token, kind: char }); continue;
+ }
+ fail(token, `Unexpected character ${JSON.stringify(char)}`);
+ }
+ tokens.push({ kind: 'eof', line, column });
+ return tokens;
+}
+
+class Parser {
+ constructor(tokens) { this.tokens = tokens; this.position = 0; this.depth = 0; }
+ peek() { return this.tokens[this.position]; }
+ take() { return this.tokens[this.position++]; }
+ is(kind, value) { return this.peek().kind === kind && (value === undefined || this.peek().value === value); }
+ expect(kind, description = kind) {
+ if (!this.is(kind)) fail(this.peek(), `Expected ${description}`);
+ return this.take();
+ }
+ newlines() { while (this.is('nl')) this.take(); }
+ separators() { while (this.is('nl') || this.is(';')) this.take(); }
+ end() {
+ if (!['nl', ';', '}', 'eof'].includes(this.peek().kind)) fail(this.peek(), 'Expected newline or semicolon');
+ this.separators();
+ }
+ node(token, properties, children = []) {
+ const depth = 1 + Math.max(0, ...children.map(child => child.depth));
+ if (depth > LIMITS.depth) fail(token, 'Expression nesting exceeds 64');
+ return { token, depth, ...properties };
+ }
+ expression(minimum = 0, multiline = false) {
+ if (++this.depth > LIMITS.depth) fail(this.peek(), 'Expression nesting exceeds 64');
+ if (multiline) this.newlines();
+ const token = this.take();
+ let left;
+ if (token.kind === 'number') left = this.node(token, { kind: 'constant', value: token.value });
+ else if (token.kind === '-' || token.kind === '!') {
+ const child = this.expression(7, multiline);
+ left = this.node(token, { kind: 'unary', op: token.kind === '-' ? 7 : 16, child }, [child]);
+ } else if (token.kind === '(') {
+ left = this.expression(0, true); this.newlines(); this.expect(')');
+ } else if (token.kind === 'name') {
+ if (Object.hasOwn(FUNCTIONS, token.value) && this.is('(')) {
+ this.take();
+ const args = [];
+ this.newlines();
+ if (!this.is(')')) {
+ for (;;) {
+ args.push(this.expression(0, true)); this.newlines();
+ if (!this.is(',')) break;
+ this.take();
+ }
+ }
+ this.expect(')');
+ if (args.length !== FUNCTIONS[token.value][0]) fail(token, `${token.value} expects ${FUNCTIONS[token.value][0]} arguments`);
+ left = this.node(token, { kind: 'call', op: FUNCTIONS[token.value][1], args }, args);
+ } else left = this.node(token, { kind: 'reference', name: token.value });
+ } else fail(token, 'Expected a number, state, or expression');
+ for (;;) {
+ if (multiline) this.newlines();
+ const entry = BINARY.get(this.peek().kind);
+ if (!entry || entry[0] < minimum) break;
+ const operator = this.take();
+ const right = this.expression(entry[0] + 1, multiline);
+ left = this.node(operator, { kind: 'binary', op: entry[1], left, right }, [left, right]);
+ }
+ this.depth--;
+ return left;
+ }
+ block(depth = 0) {
+ if (depth >= LIMITS.depth) fail(this.peek(), 'Handler nesting exceeds 64');
+ this.newlines(); this.expect('{'); this.separators();
+ const statements = [];
+ while (!this.is('}')) {
+ const token = this.expect('name', 'assignment, if, or closing brace');
+ if (token.value === 'if' && !this.is('=')) {
+ const condition = this.expression();
+ const body = this.block(depth + 1);
+ statements.push({ kind: 'if', token, condition, body });
+ } else {
+ this.expect('=');
+ statements.push({ kind: 'assign', token, name: token.value, expression: this.expression() });
+ }
+ this.end();
+ }
+ this.take();
+ return statements;
+ }
+ parse() {
+ const declarations = [];
+ this.separators();
+ while (!this.is('eof')) {
+ const token = this.expect('name', 'declaration');
+ const kind = token.value;
+ if (kind === 'state') {
+ const name = this.expect('name', 'state name'); this.expect('=');
+ let sign = 1;
+ if (this.is('-') || this.is('+')) sign = this.take().kind === '-' ? -1 : 1;
+ const value = this.expect('number', 'numeric state initializer');
+ declarations.push({ kind, token, name, initial: sign * value.value });
+ } else if (kind === 'input') {
+ const name = this.expect('name', 'input name'); this.expect('=');
+ const key = this.expect('name', 'keyboard code or pointer(view)');
+ if (key.value === 'pointer') {
+ this.expect('(');
+ const target = this.expect('name', 'view ID'); this.expect(')');
+ declarations.push({ kind, token, name, target });
+ this.end(); continue;
+ }
+ if (!/^(?:Space|Enter|Arrow(?:Left|Right|Up|Down)|Key[A-Z]|Digit[0-9])$/.test(key.value)) fail(key, 'Unsupported keyboard code');
+ declarations.push({ kind, token, name, key });
+ } else if (Object.hasOwn(VIEW_FIELDS, kind)) {
+ const name = this.expect('name', 'view ID'); this.expect('(');
+ const expressions = [];
+ for (let i = 0; i < VIEW_FIELDS[kind].length; i++) {
+ if (i) { this.newlines(); this.expect(','); }
+ expressions.push(this.expression(0, true));
+ }
+ this.newlines();
+ let color, prefix = '';
+ if (kind !== 'text') {
+ this.expect(','); this.newlines();
+ const value = this.expect('string', 'literal hex color');
+ if (!/^#[0-9a-fA-F]{6}$/.test(value.value)) fail(value, 'Color must use six hexadecimal digits, such as "#356548"');
+ color = value.value;
+ } else if (this.is(',')) { this.take(); this.newlines(); prefix = this.expect('string', 'literal text prefix').value; }
+ this.newlines(); this.expect(')');
+ declarations.push({ kind: 'view', type: kind, token, name, expressions, color, prefix });
+ } else if (kind === 'on') {
+ const event = this.expect('name', 'input name or frame');
+ let edge;
+ if (event.value !== 'frame') {
+ this.expect('.'); edge = this.expect('name', 'down, up, move or cancel');
+ if (!['down', 'up', 'move', 'cancel'].includes(edge.value)) fail(edge, 'Expected down or up, move or cancel');
+ }
+ declarations.push({ kind: 'handler', token, event, edge, body: this.block() });
+ } else fail(token, `Unknown declaration ${kind}`);
+ this.end();
+ }
+ return declarations;
+ }
+}
+
+export function compile(source) {
+ if (typeof source !== 'string') fail({ line: 1, column: 1 }, 'Source must be a string');
+ const sourceBytes = new TextEncoder().encode(source).length;
+ if (sourceBytes > LIMITS.sourceBytes) fail({ line: 1, column: 1 }, 'Source exceeds 32768 UTF-8 bytes');
+ const declarations = new Parser(lex(source)).parse();
+ const program = { states: [], inputs: [], instructions: [], handlers: [], views: [], metrics: {} };
+ const states = new Map(), inputs = new Map(), views = new Set(), keys = new Set(), targets = new Set();
+ let handlerCount = 0;
+ for (const declaration of declarations) {
+ const { kind, name, token } = declaration;
+ if (name && RESERVED.has(name.value)) fail(name, `Reserved name ${name.value}`);
+ if (kind === 'state' || kind === 'input') {
+ const symbols = kind === 'state' ? states : inputs;
+ if (symbols.has(name.value)) fail(name, `Duplicate ${kind} ${name.value}`);
+ if (symbols.size >= LIMITS[`${kind}s`]) fail(name, `At most ${LIMITS[`${kind}s`]} ${kind}s are allowed`);
+ symbols.set(name.value, symbols.size);
+ if (kind === 'state') program.states.push({ name: name.value, initial: declaration.initial, line: token.line });
+ else if (declaration.target) {
+ const target = declaration.target;
+ if (targets.has(target.value)) fail(target, `Duplicate pointer target ${target.value}`);
+ targets.add(target.value);
+ program.inputs.push({ name: name.value, target: target.value, line: token.line });
+ } else {
+ if (keys.has(declaration.key.value)) fail(declaration.key, `Duplicate keyboard code ${declaration.key.value}`);
+ keys.add(declaration.key.value);
+ program.inputs.push({ name: name.value, key: declaration.key.value, line: token.line });
+ }
+ } else if (kind === 'view') {
+ if (views.has(name.value)) fail(name, `Duplicate view ID ${name.value}`);
+ if (views.size >= LIMITS.views) fail(name, 'At most 24 views are allowed');
+ views.add(name.value);
+ } else if (++handlerCount > LIMITS.handlers) fail(token, 'At most 64 handlers are allowed');
+ }
+ for (const declaration of declarations) {
+ if (declaration.target && !views.has(declaration.target.value))
+ fail(declaration.target, `Unknown pointer target ${declaration.target.value}`);
+ }
+ let pointHandler = false;
+ let stack = 0, maxStack = 0;
+ const emit = (op, token, arg = 0, value = 0, delta = 0) => {
+ if (program.instructions.length >= LIMITS.instructions) fail(token, 'At most 512 instructions are allowed');
+ stack += delta;
+ if (stack < 0 || stack > LIMITS.stack) fail(token, 'Expression stack exceeds 64');
+ maxStack = Math.max(maxStack, stack);
+ program.instructions.push({ op, arg, value, line: token.line });
+ };
+ const expression = (node, binding = false) => {
+ const { token } = node;
+ if (node.kind === 'constant') emit(0, token, 0, node.value, 1);
+ else if (node.kind === 'reference') {
+ if (node.name === 'event_x' || node.name === 'event_y') {
+ if (binding || !pointHandler) fail(token, 'event_x and event_y are only available in pointer handlers');
+ emit(node.name === 'event_x' ? 22 : 23, token, 0, 0, 1);
+ } else if (node.name === 'dt') {
+ if (binding) fail(token, 'dt is only available in handlers');
+ emit(2, token, 0, 0, 1);
+ } else {
+ if (!states.has(node.name)) fail(token, `Unknown state ${node.name}`);
+ emit(1, token, states.get(node.name), 0, 1);
+ }
+ } else if (node.kind === 'unary') { expression(node.child, binding); emit(node.op, token); }
+ else if (node.kind === 'binary') {
+ expression(node.left, binding); expression(node.right, binding); emit(node.op, token, 0, 0, -1);
+ } else {
+ for (const arg of node.args) expression(arg, binding);
+ emit(node.op, token, 0, 0, 1 - node.args.length);
+ }
+ };
+ const statements = body => {
+ for (const statement of body) {
+ if (statement.kind === 'assign') {
+ if (!states.has(statement.name)) fail(statement.token, `Unknown state ${statement.name}`);
+ expression(statement.expression);
+ emit(20, statement.token, states.get(statement.name), 0, -1);
+ } else {
+ expression(statement.condition);
+ const branch = program.instructions.length;
+ emit(21, statement.token, 0, 0, -1);
+ statements(statement.body);
+ program.instructions[branch].arg = program.instructions.length;
+ }
+ }
+ };
+ for (const declaration of declarations) {
+ if (declaration.kind === 'handler') {
+ let event = 0;
+ pointHandler = false;
+ if (declaration.event.value !== 'frame') {
+ if (!inputs.has(declaration.event.value)) fail(declaration.event, `Unknown input ${declaration.event.value}`);
+ const index = inputs.get(declaration.event.value), edge = declaration.edge.value;
+ pointHandler = Boolean(program.inputs[index].target);
+ if (!pointHandler && (edge === 'move' || edge === 'cancel')) fail(declaration.edge, 'move and cancel require a pointer input');
+ event = edge === 'move' ? 17 + index : edge === 'cancel' ? 25 + index : 1 + index * 2 + (edge === 'up' ? 1 : 0);
+ }
+ const start = program.instructions.length;
+ stack = 0; statements(declaration.body);
+ program.handlers.push({ event, start, length: program.instructions.length - start, line: declaration.token.line });
+ } else if (declaration.kind === 'view') {
+ const view = { id: declaration.name.value, type: declaration.type, line: declaration.token.line, bindings: {} };
+ if (view.type === 'text') view.prefix = declaration.prefix;
+ else view.color = declaration.color;
+ declaration.expressions.forEach((node, index) => {
+ const start = program.instructions.length;
+ stack = 0; expression(node, true);
+ view.bindings[VIEW_FIELDS[view.type][index]] = { start, length: program.instructions.length - start };
+ });
+ program.views.push(view);
+ }
+ }
+ program.metrics = { sourceBytes, sourceLines: source === '' ? 0 : source.split('\n').length,
+ states: states.size, inputs: inputs.size, views: views.size,
+ handlers: program.handlers.length, instructions: program.instructions.length, maxStack };
+ return program;
+}
+
+export { LIMITS };
diff --git a/prototypes/workbench/results/2026-09-23-pointer/after/web/core.wasm b/prototypes/workbench/results/2026-09-23-pointer/after/web/core.wasm
new file mode 100644
index 0000000..b2f2cd1
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/after/web/core.wasm
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-23-pointer/after/web/index.html b/prototypes/workbench/results/2026-09-23-pointer/after/web/index.html
new file mode 100644
index 0000000..85c6493
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/after/web/index.html
@@ -0,0 +1,93 @@
+<!doctype html>
+<html lang="ko">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="color-scheme" content="dark">
+ <title>Pure · 빈 작업물에서 시작하기</title>
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23151918'/%3E%3Ctext x='9' y='23' fill='%23c9f08b' font-size='24' font-family='sans-serif'%3Ep%3C/text%3E%3C/svg%3E">
+ <link rel="stylesheet" href="./style.css">
+</head>
+<body>
+ <header class="masthead">
+ <a class="wordmark" href="./" aria-label="Pure 작업 공간">pure<span> / workbench</span></a>
+ <span class="experiment">제작 경험 실험 004</span>
+ <div class="connection"><span id="ready-dot" class="dot"></span><span id="ready-status" role="status">실행기 준비 중</span></div>
+ </header>
+
+ <main>
+ <section class="intro" aria-labelledby="title">
+ <div><p class="eyebrow">빈 코드 · 직접 만든 동작</p><h1 id="title">무엇을 움직이게 할까요?</h1></div>
+ <p>상태를 만들고, 입력에 동작을 연결하세요.<br>코드를 <strong>적용</strong>하면 장면이 바뀝니다. 같은 이름의 상태는 이어집니다.</p>
+ </section>
+
+ <div class="workspace">
+ <section class="editor-panel panel" aria-labelledby="editor-title">
+ <div class="panel-heading"><h2 id="editor-title"><span class="section-index">01</span> 코드</h2><span id="draft-status" class="badge">빈 작업물</span></div>
+ <div class="file-tools" aria-label="파일 관리">
+ <button id="new" class="quiet" type="button">편집기 비우기</button>
+ <button id="upload" class="quiet" type="button">파일 열기</button>
+ <button id="download" class="quiet" type="button">.pure 저장</button>
+ <button id="download-project" class="quiet" type="button">JSON 저장</button>
+ <input id="file-input" type="file" accept=".pure,.txt,.json,text/plain,application/json" hidden>
+ </div>
+ <label class="sr-only" for="source">Pure 코드 편집기</label>
+ <div class="code-editor">
+ <div id="line-numbers" class="line-numbers" aria-hidden="true">1</div>
+ <textarea id="source" spellcheck="false" autocapitalize="off" autocomplete="off" autocorrect="off" aria-describedby="editor-hint diagnostic" placeholder="여기서 시작하세요.&#10;&#10;아래 ‘언어 안내’에서 작은 조각을 가져올 수 있습니다."></textarea>
+ </div>
+ <div class="editor-footer"><span id="source-size">0 / 32,768 bytes</span><span id="cursor-position">1행 · 1열</span></div>
+ <div class="apply-row"><button id="apply" class="primary" type="button" disabled>코드 적용 <span aria-hidden="true">↗</span></button><p id="editor-hint">편집만으로 실행 중인 코드는 바뀌지 않습니다.</p></div>
+ <div id="diagnostic" class="diagnostic" role="status" aria-live="polite"><span id="diagnostic-text">아직 작성한 코드가 없습니다.</span><button id="error-location" type="button" class="quiet" hidden>문제 위치로</button></div>
+ </section>
+
+ <div class="right-column">
+ <section class="scene-panel panel" aria-labelledby="scene-title">
+ <div class="panel-heading"><h2 id="scene-title"><span class="section-index">02</span> 실행 장면</h2><span id="scene-status" class="badge">적용 전</span></div>
+ <div class="stage-wrap">
+ <svg id="scene" viewBox="0 0 640 360" role="img" tabindex="0" aria-labelledby="scene-description"><title id="scene-description">사용자가 코드로 만든 장면. 현재 비어 있습니다.</title><g id="scene-content"></g></svg>
+ <div id="empty-scene" class="empty-scene"><span class="empty-cross" aria-hidden="true">+</span><strong>아직 아무것도 없습니다</strong><span>도형이나 글자를 선언하고 코드를 적용하세요.</span></div>
+ <span class="stage-dimensions" aria-hidden="true">640 × 360</span>
+ </div>
+ <div class="transport"><button id="play" class="secondary" type="button" disabled>▶ 실행</button><button id="step" type="button" disabled>한 프레임</button><button id="reset" type="button" disabled>초깃값으로</button><span id="clock-status">0 frame · 정지</span></div>
+ <p class="control-note">정지하면 시간만 멈춥니다. 아래 입력은 정지 중에도 작동합니다.</p>
+ <div class="input-heading"><h3>내가 선언한 입력</h3><span>눌렀을 때 .down / 놓았을 때 .up</span></div>
+ <div id="input-controls" class="input-controls"><p class="empty-note">입력을 선언하면 여기에 버튼이 생깁니다.</p></div>
+ <p class="keyboard-note">장면을 클릭하면 키보드로 입력할 수 있습니다. 코드나 폼을 편집하는 동안에는 전달되지 않습니다.</p>
+ </section>
+
+ <section class="inspect-panel panel" aria-labelledby="inspect-title">
+ <div class="panel-heading"><h2 id="inspect-title"><span class="section-index">03</span> 현재 상태</h2><span id="revision" class="mono subdued">revision 0</span></div>
+ <div id="state-values" class="state-values"><p class="empty-note">선언한 상태의 값이 표시됩니다.</p></div>
+ <div class="event-line"><span class="subdued">마지막 입력</span><span id="last-event" class="mono">아직 없음</span></div>
+ <div id="runtime-error" class="runtime-error" role="status" hidden></div>
+ <details class="cost-details"><summary>실행 비용과 범위</summary><dl id="cost-values" class="cost-values"></dl><p>코어가 보고한 WebAssembly 메모리입니다. 브라우저·편집기·렌더러의 메모리와 프로세스 전체 사용량은 포함하지 않습니다. 예약 공간 안에 사용 중인 코드·상태·스테이징 공간이 포함되므로 합산하지 않습니다.</p><p id="dropped-ticks">시간 지연으로 건너뛴 프레임: 0</p></details>
+ </section>
+ </div>
+ </div>
+
+ <section class="reference panel" aria-labelledby="reference-title">
+ <div class="panel-heading"><h2 id="reference-title">언어 안내</h2><span class="subdued">조각을 조합해 자신의 동작을 만드세요.</span></div>
+ <div class="reference-grid">
+ <article><h3>1. 기억할 값</h3><p>이름은 영문으로, 처음 값은 숫자로 정합니다.</p><pre><code>state count = 0
+state x = 80</code></pre><button class="insert-snippet quiet" data-snippet="state" type="button">상태 조각 삽입</button></article>
+ <article><h3>2. 입력과 반응</h3><p>버튼 이름과 키를 선언하고 .down / .up에 연결합니다.</p><pre><code>input tap = Space
+on tap.down {
+ count = count + 1
+}</code></pre><button class="insert-snippet quiet" data-snippet="input" type="button">입력 조각 삽입</button></article>
+ <article><h3>3. 보일 것</h3><p>좌표와 크기를 상태에 연결할 수 있습니다.</p><pre><code>box tile(x, 180, 40, 40, "#7ed9ae")
+text label(40, 60, count, "Count: ")</code></pre><button class="insert-snippet quiet" data-snippet="view" type="button">장면 조각 삽입</button></article>
+ <article><h3>4. 시간에 따른 변화</h3><p>실행하면 매 프레임 반응합니다. dt는 1/60초입니다.</p><pre><code>on frame {
+ x = x + 30 * dt
+ if x > 600 { x = 0 }
+}</code></pre><button class="insert-snippet quiet" data-snippet="frame" type="button">시간 조각 삽입</button></article>
+ </div>
+ <details class="syntax-details"><summary>표현식 · 입력 키 · 제한 보기</summary><div class="syntax-columns"><div><p><strong>표현식</strong><br><code>+ - * /</code>, <code>&lt; &lt;= &gt; &gt;= == !=</code>, <code>&amp;&amp; || !</code>, 괄호, <code>min(a,b)</code>, <code>max(a,b)</code>, <code>clamp(x,lo,hi)</code>를 사용합니다. 논리 연산은 양쪽을 모두 계산합니다.</p><p><strong>도형</strong><br><code>box id(x,y,width,height,"#rrggbb")</code><br><code>circle id(x,y,radius,"#rrggbb")</code><br><code>text id(x,y,value,"접두사")</code><br>글자의 value는 숫자 표현식입니다. 문자 접두사는 생략할 수 있습니다.</p></div><div><p><strong>입력</strong><br><code>Space</code>, <code>Enter</code>, <code>ArrowLeft/Right/Up/Down</code>, <code>KeyA</code>~<code>KeyZ</code>, <code>Digit0</code>~<code>Digit9</code>. 같은 키는 하나의 입력에 연결합니다.</p><p><strong>직접 조작</strong><br><code>input drag = pointer(tile)</code>로 도형에 입력을 연결합니다. <code>on drag.down / .move / .up / .cancel</code>에서 <code>event_x</code>, <code>event_y</code>는 장면 좌표입니다. 이동 방식과 취소 시 동작도 코드로 정합니다. 초점이나 포인터 캡처를 잃으면 <code>.cancel</code>이 전달됩니다.</p><p><strong>이 실험의 범위</strong><br>상태 32개 · 입력 8개 · 도형/글자 24개 · 반응 64개 · 컴파일된 명령 512개 · 코드 32 KiB. 반복문, 함수 선언, 자동 충돌·물리는 없습니다. 줄바꿈 또는 세미콜론으로 구분하고 <code>#</code>으로 주석을 씁니다.</p><p>0으로 나누기 등 계산 실패 시 해당 이벤트의 상태 변경 전체를 취소합니다. 입력을 누르고 있을 때는 새 코드를 적용할 수 없습니다.</p></div></div></details>
+ <div class="example-row"><div><label for="example">완성된 예제 살펴보기</label><p>선택한 코드를 편집기에 불러옵니다. 자동으로 적용되지 않습니다.</p></div><select id="example"><option value="counter">카운터</option><option value="charge">누르는 동안 충전</option><option value="toggle">입력으로 전환</option><option value="direct">직접 잡아 이동</option></select><button id="load-example" type="button">예제 불러오기</button></div>
+ <div class="baseline-row"><a href="./baseline.html" target="_blank" rel="noopener">같은 동작을 일반 JavaScript로 만든 비교 앱 ↗</a><span>충전 예제와 비교할 수 있습니다. 새 탭에서 열립니다.</span></div>
+ </section>
+ <footer class="page-footer"><span>Pure · authoring experiment</span><p>원하는 동작을 만들어보고, <strong>하려던 일 / 실제로 일어난 일 / 기대했던 일</strong>을 남겨주세요.</p></footer>
+ </main>
+ <script type="module" src="./app.js"></script>
+</body>
+</html>
diff --git a/prototypes/workbench/results/2026-09-23-pointer/after/web/output.mjs b/prototypes/workbench/results/2026-09-23-pointer/after/web/output.mjs
new file mode 100644
index 0000000..93d771a
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/after/web/output.mjs
@@ -0,0 +1,95 @@
+// Bind authored declarations to concrete DOM destinations once per apply.
+// The DOM holds the published values; no parallel scene/value model is kept.
+const NS = 'http://www.w3.org/2000/svg';
+const number = value => Number.isInteger(value) ? String(value) : Number(value.toPrecision(7)).toString();
+const attributes = { box: { x: 'x', y: 'y', width: 'width', height: 'height' },
+ circle: { x: 'cx', y: 'cy', radius: 'r' }, text: { x: 'x', y: 'y' } };
+
+function text(node, value) {
+ if (node.textContent !== value) node.textContent = value;
+}
+function attribute(node, name, value) {
+ const next = String(value);
+ if (node.getAttribute(name) !== next) node.setAttribute(name, next);
+}
+
+export function createOutput(document) {
+ const get = id => document.getElementById(id);
+ const scene = get('scene-content'), states = get('state-values');
+ const empty = get('empty-scene'), description = get('scene-description');
+ let active = undefined, nodes = [], amounts = [];
+ const costNames = ['Wasm 선형 메모리', '코어 예약 공간', '사용 중인 명령·핸들러',
+ '현재 상태 값', '새 코드 준비 공간 (예약에 포함)', '평가 스택 용량',
+ '마지막 이벤트 실행 명령', '적용된 소스 크기'];
+ const costs = costNames.map(label => {
+ const dt = document.createElement('dt'), dd = document.createElement('dd');
+ dt.textContent = label;
+ return { dt, dd };
+ });
+ get('cost-values').replaceChildren(...costs.flatMap(({dt, dd}) => [dt, dd]));
+
+ return {
+ begin(program) {
+ if (program === active) return;
+ active = program;
+ const targets = new Map((program?.inputs ?? []).filter(input => input.target).map(input => [input.target, input.name]));
+ scene.ownerSVGElement.classList.toggle('direct-input', targets.size > 0);
+ nodes = (program?.views ?? []).map(view => {
+ const node = document.createElementNS(NS, {box:'rect', circle:'circle', text:'text'}[view.type]);
+ node.setAttribute('data-view-id', view.id);
+ if (targets.has(view.id)) node.setAttribute('data-point-input', targets.get(view.id));
+ node.setAttribute('fill', view.color ?? '#e7ece6');
+ if (view.type === 'text') {
+ node.setAttribute('font-family', 'ui-monospace, monospace');
+ node.setAttribute('font-size', '20');
+ }
+ return node;
+ });
+ scene.replaceChildren(...nodes);
+ const holders = [];
+ amounts = (program?.states ?? []).map(state => {
+ const dl = document.createElement('dl'), dt = document.createElement('dt'), dd = document.createElement('dd');
+ dl.className = 'state-value'; dt.textContent = state.name; dd.dataset.state = state.name;
+ dl.append(dt, dd); holders.push(dl);
+ return dd;
+ });
+ if (!holders.length) {
+ const note = document.createElement('p');
+ note.className = 'empty-note'; note.textContent = '선언한 상태의 값이 표시됩니다.';
+ holders.push(note);
+ }
+ states.replaceChildren(...holders);
+ },
+ state(index, value) { text(amounts[index], number(value)); },
+ view(index, valid) {
+ const node = nodes[index];
+ if (valid) { if (node.hasAttribute('display')) node.removeAttribute('display'); }
+ else attribute(node, 'display', 'none');
+ },
+ value(index, name, value) {
+ const view = active.views[index], node = nodes[index];
+ if (name === 'value') text(node, `${view.prefix || ''}${number(value)}`);
+ else attribute(node, attributes[view.type][name], value);
+ },
+ end(visible) {
+ const hidden = visible !== 0;
+ if (empty.hidden !== hidden) empty.hidden = hidden;
+ const failed = !visible && nodes.length > 0;
+ text(empty.querySelector('strong'), failed ? '장면을 표시할 수 없습니다' : '아직 아무것도 없습니다');
+ text(empty.querySelector('span:last-child'), failed ? '현재 상태 아래의 표시 오류를 확인하세요.' : '도형이나 글자를 선언하고 코드를 적용하세요.');
+ text(description, visible ? `사용자가 만든 장면. 도형과 글자 ${visible}개.` : failed ? '표시 오류로 장면을 표시할 수 없습니다.' : '사용자가 코드로 만든 장면. 현재 비어 있습니다.');
+ },
+ costs(status, metrics, droppedTicks) {
+ const m = status.memory;
+ const values = [m.linearBytes, m.reservedEngineBytes, m.activeIRBytes, m.activeStateBytes,
+ m.stagingReservedBytes, m.evalStackBytes, status.lastOps, metrics?.sourceBytes];
+ for (let i = 0; i < values.length; i++) {
+ const value = values[i], {dt, dd} = costs[i], hidden = !Number.isFinite(value);
+ if (dt.hidden !== hidden) dt.hidden = hidden;
+ if (dd.hidden !== hidden) dd.hidden = hidden;
+ if (!hidden) text(dd, i === 6 ? `${value}개` : `${value.toLocaleString('en-US')} B`);
+ }
+ text(get('dropped-ticks'), `시간 지연으로 건너뛴 프레임: ${droppedTicks}`);
+ },
+ };
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer/after/web/runtime.mjs b/prototypes/workbench/results/2026-09-23-pointer/after/web/runtime.mjs
new file mode 100644
index 0000000..28648cc
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/after/web/runtime.mjs
@@ -0,0 +1,298 @@
+import { compile } from './compiler.mjs';
+
+const messages = [
+ '', '실행기의 용량 한도를 넘었습니다.', '실행 정의가 유효하지 않습니다.',
+ '계산 스택의 범위를 벗어났습니다.', '0으로 나눌 수 없습니다.',
+ '계산 결과가 유한한 수의 범위를 벗어났습니다.', '조건 분기 범위가 유효하지 않습니다.',
+ '존재하지 않는 상태를 참조했습니다.', '시간 간격이 유효하지 않습니다.',
+ '정의되지 않은 입력 사건입니다.',
+];
+const sourceLimit = 32768;
+
+function problem(message, line = null, column = null) {
+ return Object.assign(new Error(message), { line, column });
+}
+
+function freezeProgram(value) {
+ if (value && typeof value === 'object') {
+ for (const child of Object.values(value)) freezeProgram(child);
+ Object.freeze(value);
+ }
+ return value;
+}
+
+export async function createRuntime(wasmBytes, evaluationOptions = {}) {
+ if (wasmBytes === undefined) {
+ const response = await fetch(new URL('./core.wasm', import.meta.url), { cache: 'no-store' });
+ if (!response.ok) throw new Error(`실행기를 불러오지 못했습니다 (HTTP ${response.status}).`);
+ wasmBytes = await response.arrayBuffer();
+ }
+ const module = await WebAssembly.compile(wasmBytes);
+ if (WebAssembly.Module.imports(module).length) throw new Error('이 실행기는 호스트 함수 없이 동작해야 합니다.');
+ const { exports: core } = await WebAssembly.instantiate(module, {});
+ if (typeof core.eval_bounded !== 'function' || typeof core.abort_program !== 'function' || typeof core.dispatch_point !== 'function')
+ throw new Error('실행기 버전이 맞지 않습니다. 새로고침하거나 core.wasm을 다시 빌드하세요.');
+ core.reset_all();
+ let program = null;
+ let appliedSource = '';
+ let lastEvent = null;
+ // Keep a failed event visible through its release/focus-loss cleanup.
+ // The active program can clear this diagnostic by apply or explicit reset.
+ let lastFailure = null;
+ const held = new Map();
+ const points = new Map();
+ let presenting = false;
+ let bindings = [];
+ // One bounded publication buffer, never exposed to callers. No state mirror.
+ let displayValues = null, displayValid = null;
+ function available() {
+ if (presenting) throw new Error('출력 전달 중에는 실행기를 다시 호출할 수 없습니다.');
+ }
+ let evaluationPolicy = { cacheEntries: 0, historyEntries: 8, workLimit: 2048 };
+
+ function checked(code, line = null) {
+ if (code !== 0) throw problem(messages[code] || `실행기 오류 ${code}`, line);
+ }
+ function inputIndex(name) {
+ const index = program?.inputs.findIndex((input) => input.name === name) ?? -1;
+ if (index < 0) throw new Error(`정의되지 않은 입력: ${name}`);
+ return index;
+ }
+ function dispatch(event, name, phase, source, point = null) {
+ const status = point ? core.dispatch_point(event, point.x, point.y) : core.dispatch(event, event === 0 ? 1 / 60 : 0);
+ const pc = core.info(2) >>> 0;
+ lastEvent = { name, phase, source, status,
+ line: program?.instructions[pc]?.line ?? null, ops: core.info(3) >>> 0 };
+ if (point) { lastEvent.x = point.x; lastEvent.y = point.y; }
+ if (status) lastFailure = { ...lastEvent };
+ return status;
+ }
+
+ function evaluateDisplay() {
+ displayValues ??= new Float64Array(96);
+ displayValid ??= new Uint8Array(24);
+ core.begin_evaluation(evaluationPolicy.workLimit);
+ let error = null, slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ displayValid[i] = 1;
+ for (const [name, binding] of bindings[i]) {
+ const value = core.eval_bounded(binding.start, binding.length);
+ if (core.evaluation_info(0) === 10) {
+ displayValid.fill(0);
+ return { code: 101, line: view.line,
+ message: '표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.' };
+ }
+ if (!Number.isFinite(value) || ((name === 'width' || name === 'height' || name === 'radius') && value < 0)) {
+ error ??= { line: view.line, message: `${view.id}.${name}: 표시할 수 없는 값입니다. 0 나눗셈이나 음수 크기를 확인하세요.` };
+ displayValid[i] = 0;
+ }
+ displayValues[slot++] = value;
+ }
+ }
+ return error;
+ }
+
+ function status(viewError) {
+ const executionError = lastFailure?.status || 0;
+ return {
+ tick: core.info(0) >>> 0, revision: core.info(4) >>> 0,
+ displayError: viewError ? { code: viewError.code || 100, ...viewError } : null,
+ lastError: executionError || (viewError ? viewError.code || 100 : 0),
+ errorMessage: executionError ? messages[executionError] || `실행 오류 ${executionError}` : viewError?.message || '',
+ errorLine: executionError ? lastFailure.line : viewError?.line ?? null,
+ lastOps: core.info(3) >>> 0, successfulEvents: core.info(9) >>> 0,
+ held: [...held.keys()], lastEvent: lastEvent ? { ...lastEvent } : null,
+ lastFailure: lastFailure ? { ...lastFailure } : null,
+ memory: {
+ linearBytes: core.memory.buffer.byteLength,
+ reservedEngineBytes: core.layout(0), programReservedBytes: core.layout(1),
+ instructionBytes: core.layout(2), handlerBytes: core.layout(3),
+ activeIRBytes: core.layout(4), activeStateBytes: core.layout(5),
+ stagingReservedBytes: core.layout(6), evalStackBytes: core.layout(7),
+ },
+ };
+ }
+
+ const api = {
+ get currentProgram() { return program; },
+ configureEvaluation({ cacheEntries = evaluationPolicy.cacheEntries,
+ historyEntries = evaluationPolicy.historyEntries, workLimit = evaluationPolicy.workLimit } = {}) {
+ available();
+ for (const [name, value, maximum] of [['cacheEntries', cacheEntries, 32],
+ ['historyEntries', historyEntries, 8], ['workLimit', workLimit, 0xffffffff]]) {
+ if (!Number.isInteger(value) || value < 0 || value > maximum)
+ throw new Error(`${name}: 0~${maximum} 사이의 정수여야 합니다.`);
+ }
+ checked(core.configure_evaluation(cacheEntries, historyEntries));
+ evaluationPolicy = { cacheEntries, historyEntries, workLimit };
+ return { ...evaluationPolicy };
+ },
+ forgetEvaluation(part = 'all') {
+ available();
+ const flags = part === 'all' ? 3 : part === 'values' ? 1 : part === 'history' ? 2 : undefined;
+ if (flags === undefined) throw new Error('삭제할 기록은 values, history, all 중 하나여야 합니다.');
+ core.forget_evaluation(flags);
+ },
+ evaluationStats() {
+ return { ...evaluationPolicy, work: core.evaluation_info(1),
+ instructions: core.evaluation_info(2), cacheHits: core.evaluation_info(3),
+ reservedBytes: core.evaluation_info(6), dedicatedBytes: core.evaluation_info(7),
+ sharedBytes: core.evaluation_info(8), reuseAvailable: Boolean(core.evaluation_info(9)),
+ workspaceTagBytes: core.evaluation_info(10) };
+ },
+ apply(source) {
+ available();
+ if (typeof source !== 'string' || new TextEncoder().encode(source).length > sourceLimit) {
+ throw problem('정의는 UTF-8 기준 32,768바이트 이하의 텍스트여야 합니다.');
+ }
+ if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 적용하세요. 현재 작업은 그대로 유지합니다.');
+ const next = freezeProgram(compile(source));
+ const oldIds = new Map(program?.states.map((state, index) => [state.name, index]) ?? []);
+ checked(core.begin_program(next.states.length, next.inputs.length));
+ try {
+ for (let index = 0; index < next.states.length; index++) {
+ const state = next.states[index];
+ checked(core.put_state(index, state.initial, oldIds.get(state.name) ?? -1), state.line);
+ }
+ for (const op of next.instructions) checked(core.put_instruction(op.op, op.arg, op.value), op.line);
+ for (const handler of next.handlers) checked(core.put_handler(handler.event, handler.start, handler.length), handler.line);
+ checked(core.commit_program());
+ } catch (error) {
+ core.abort_program();
+ throw error;
+ }
+ program = next;
+ bindings = next.views.map(view => Object.entries(view.bindings));
+ appliedSource = source;
+ lastEvent = null;
+ lastFailure = null;
+ return next;
+ },
+ reset() {
+ available();
+ held.clear();
+ points.clear();
+ core.reset_state();
+ lastEvent = null;
+ lastFailure = null;
+ },
+ frame(count = 1) {
+ available();
+ if (!Number.isInteger(count) || count < 0 || count > 600) throw new Error('프레임 수는 0~600 사이의 정수여야 합니다.');
+ if (!program) return 0;
+ for (let i = 0; i < count; i++) {
+ const code = dispatch(0, 'frame', 'frame', 'clock');
+ if (code !== 0) return code;
+ }
+ return 0;
+ },
+ down(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (program.inputs[index].target) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.');
+ const sources = held.get(name) || new Set();
+ if (sources.has(source)) return 0;
+ const wasDown = sources.size > 0;
+ sources.add(source);
+ held.set(name, sources);
+ return wasDown ? 0 : dispatch(1 + index * 2, name, 'down', source);
+ },
+ up(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (program.inputs[index].target) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.');
+ const sources = held.get(name);
+ if (!sources?.delete(source)) return 0;
+ if (sources.size) return 0;
+ held.delete(name);
+ return dispatch(2 + index * 2, name, 'up', source);
+ },
+ point(name, phase, x, y, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (!program.inputs[index].target) throw new Error('포인터에 연결된 입력이 아닙니다.');
+ if (!['down', 'move', 'up', 'cancel'].includes(phase)) throw new Error('포인터 단계는 down, move, up, cancel 중 하나여야 합니다.');
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error('포인터 좌표는 유한한 숫자여야 합니다.');
+ if (typeof source !== 'string' || source.length === 0) throw new Error('포인터 출처 이름이 필요합니다.');
+ let point = points.get(name);
+ if (phase === 'down') {
+ if (point?.source === source) return 0;
+ if (point) throw new Error('이 입력은 다른 포인터가 사용 중입니다.');
+ point = {source, x, y};
+ points.set(name, point);
+ held.set(name, new Set([source]));
+ } else {
+ if (!point || point.source !== source) return 0;
+ point.x = x; point.y = y;
+ if (phase === 'up' || phase === 'cancel') { points.delete(name); held.delete(name); }
+ }
+ const event = phase === 'move' ? 17 + index : phase === 'cancel' ? 25 + index : 1 + index * 2 + (phase === 'up' ? 1 : 0);
+ return dispatch(event, name, phase, source, point);
+ },
+ releaseAll() {
+ available();
+ let firstError = 0;
+ for (const [name, point] of [...points]) {
+ const status = api.point(name, 'cancel', point.x, point.y, point.source);
+ if (!firstError) firstError = status;
+ }
+ for (const [name, sources] of [...held]) {
+ for (const source of [...sources]) {
+ const status = api.up(name, source);
+ if (!firstError) firstError = status;
+ }
+ }
+ return firstError;
+ },
+ exportProject() {
+ return { format: 'pure-workbench', version: 1, source: appliedSource };
+ },
+ // Synchronous trusted output adapter. Callbacks run only after the whole
+ // display has been evaluated. Runtime mutation/reentrant reads are refused.
+ // Exceptions from an adapter propagate; the next call republishes all values.
+ present(target) {
+ available();
+ presenting = true;
+ try {
+ const { begin, state, view, value, end } = target;
+ for (const callback of [begin, state, view, value, end])
+ if (typeof callback !== 'function') throw new TypeError('출력 어댑터 함수가 필요합니다.');
+ const error = evaluateDisplay();
+ begin.call(target, program);
+ for (let i = 0; i < (program?.states.length ?? 0); i++) state.call(target, i, core.get_state(i));
+ let slot = 0, visible = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const valid = Boolean(displayValid[i]);
+ view.call(target, i, valid);
+ for (const [name] of bindings[i]) {
+ if (valid) value.call(target, i, name, displayValues[slot]);
+ slot++;
+ }
+ if (valid) visible++;
+ }
+ end.call(target, visible);
+ return status(error);
+ } finally { presenting = false; }
+ },
+ snapshot() {
+ available();
+ const error = evaluateDisplay();
+ const states = Object.fromEntries((program?.states ?? []).map((state, index) => [state.name, core.get_state(index)]));
+ const views = [];
+ let slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ const rendered = displayValid[i] ? { id: view.id, type: view.type, color: view.color, prefix: view.prefix } : null;
+ for (const [name] of bindings[i]) {
+ if (rendered) rendered[name] = displayValues[slot];
+ slot++;
+ }
+ if (rendered) views.push(rendered);
+ }
+ return { states, views, ...status(error) };
+ },
+ };
+ api.configureEvaluation(evaluationOptions);
+ return Object.freeze(api);
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer/after/web/scene-input.mjs b/prototypes/workbench/results/2026-09-23-pointer/after/web/scene-input.mjs
new file mode 100644
index 0000000..bd6dfd8
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/after/web/scene-input.mjs
@@ -0,0 +1,68 @@
+// This adapter owns browser capture only. Authored state changes stay in the VM.
+export function bindSceneInput(scene, runtime, act) {
+ const captures = new Map();
+ const coordinates = event => {
+ const matrix = scene.getScreenCTM();
+ if (!matrix) return null;
+ try {
+ const p = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse());
+ return Number.isFinite(p.x) && Number.isFinite(p.y) ? p : null;
+ } catch { return null; }
+ };
+ const release = id => {
+ if (scene.hasPointerCapture(id)) scene.releasePointerCapture(id);
+ };
+ function finish(event, phase) {
+ const entry = captures.get(event.pointerId);
+ if (!entry) return;
+ event.preventDefault();
+ // Capture loss/cancel may not carry a meaningful position. Use the last
+ // delivered coordinates. Up must use its own final position.
+ const p = phase === 'up' ? coordinates(event) : null;
+ if (p) { entry.x = p.x; entry.y = p.y; }
+ if (phase === 'up' && !p) phase = 'cancel';
+ captures.delete(event.pointerId);
+ release(event.pointerId);
+ act(() => runtime().point(entry.name, phase, entry.x, entry.y, entry.source));
+ }
+ scene.addEventListener('pointerdown', event => {
+ if (event.button !== 0 || !runtime() || captures.has(event.pointerId)) return;
+ const target = event.target.closest?.('[data-point-input]');
+ if (!target || !scene.contains(target)) return;
+ const name = target.getAttribute('data-point-input');
+ // A logical input has one coordinate owner. A second contact on it must not
+ // steal the gesture; other authored targets can be manipulated independently.
+ if ([...captures.values()].some(entry => entry.name === name)) return;
+ const p = coordinates(event);
+ if (!p) return;
+ event.preventDefault(); scene.focus();
+ act(() => {
+ scene.setPointerCapture(event.pointerId);
+ const entry = {name, source:`scene:${event.pointerId}`, x:p.x, y:p.y};
+ captures.set(event.pointerId, entry);
+ try { runtime().point(name, 'down', p.x, p.y, entry.source); }
+ catch (error) { captures.delete(event.pointerId); release(event.pointerId); throw error; }
+ });
+ });
+ scene.addEventListener('pointermove', event => {
+ const entry = captures.get(event.pointerId);
+ if (!entry) return;
+ const p = coordinates(event);
+ if (!p) { finish(event, 'cancel'); return; }
+ event.preventDefault();
+ entry.x = p.x; entry.y = p.y;
+ act(() => runtime().point(entry.name, 'move', p.x, p.y, entry.source));
+ });
+ scene.addEventListener('pointerup', event => finish(event, 'up'));
+ scene.addEventListener('pointercancel', event => finish(event, 'cancel'));
+ scene.addEventListener('lostpointercapture', event => finish(event, 'cancel'));
+ return Object.freeze({
+ releaseCaptures() {
+ const ids = [...captures.keys()];
+ captures.clear();
+ for (const id of ids) release(id);
+ // The caller subsequently invokes runtime.releaseAll(), which dispatches
+ // each pending cancel once. Clearing first makes lostcapture harmless.
+ },
+ });
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer/after/web/style.css b/prototypes/workbench/results/2026-09-23-pointer/after/web/style.css
new file mode 100644
index 0000000..702a6e4
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/after/web/style.css
@@ -0,0 +1,10 @@
+:root{color-scheme:dark;--bg:#151918;--panel:#1d2220;--field:#151a18;--line:#353e38;--text:#e7ece6;--muted:#a9b6aa;--faint:#7b8e7e;--accent:#c9f08b;--accent-dark:#222e18;--danger:#f6afa0;--radius:14px;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-synthesis:none}
+*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text)}button,input,select,textarea{font:inherit}button,select{border:1px solid var(--line);border-radius:7px;background:#282f2a;color:var(--text);padding:9px 12px;cursor:pointer;font-size:12px;font-weight:600;min-height:36px}button:hover:not(:disabled){background:#344035;border-color:#70836d}button:disabled{opacity:.4;cursor:not-allowed}button:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px}button.primary{background:var(--accent);color:var(--accent-dark);border-color:var(--accent);padding:11px 19px;font-size:14px;display:flex;gap:28px;justify-content:space-between;align-items:center}button.primary:hover:not(:disabled){background:#d8f7ad}button.secondary{background:#c9f08b16;border-color:#637545;color:#d7f0b5}button.quiet{background:transparent;border-color:transparent;color:var(--muted);padding:6px 8px;min-height:30px}button.quiet:hover{color:var(--text)}.masthead{height:76px;padding:0 4.5%;display:flex;align-items:center;gap:28px;border-bottom:1px solid var(--line)}.wordmark{font-size:23px;font-weight:750;letter-spacing:-1px;color:var(--accent);text-decoration:none}.wordmark span{font-size:13px;color:var(--muted);letter-spacing:0;font-weight:400}.experiment{font-size:11px;color:var(--faint);border-left:1px solid var(--line);padding-left:24px}.connection{margin-left:auto;display:flex;gap:9px;align-items:center;font-size:11px;color:var(--muted)}.dot{width:6px;height:6px;border-radius:100%;background:#aa8e5c}.dot.ready{background:var(--accent)}.dot.failed{background:var(--danger)}main{max-width:1540px;margin:auto;padding:38px 4.5% 0}.intro{display:flex;justify-content:space-between;align-items:flex-end;gap:24px;margin:0 0 27px}.eyebrow{font-size:10px;letter-spacing:1.4px;font-weight:600;color:var(--accent);margin:0 0 10px}h1{font-size:clamp(23px,2.5vw,35px);letter-spacing:-1px;margin:0;font-weight:600}.intro>p{font-size:12px;line-height:1.9;color:var(--muted);margin:0 0 2px}.intro strong{color:var(--text);font-weight:500}.workspace{display:grid;grid-template-columns:minmax(340px,1fr) minmax(380px,1.05fr);gap:22px;align-items:start}.panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}.panel-heading{height:56px;padding:0 20px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-bottom:1px solid var(--line)}h2{margin:0;font-size:13px;font-weight:650;display:flex;gap:10px;align-items:center}.section-index{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px;font-weight:400;color:var(--faint)}.badge{font-size:10px;border:1px solid var(--line);border-radius:4px;padding:4px 7px;white-space:nowrap;color:var(--muted)}.badge.dirty{color:var(--accent);border-color:#5f7247}.file-tools{display:flex;align-items:center;gap:3px;padding:8px 10px;border-bottom:1px solid var(--line);flex-wrap:wrap}.code-editor{height:514px;display:flex;overflow:hidden;background:var(--field);position:relative}.line-numbers{padding:20px 10px 20px 16px;min-width:45px;text-align:right;color:#667567;user-select:none;white-space:pre;font:12px/1.85 ui-monospace,SFMono-Regular,Consolas,monospace;overflow:hidden;flex-shrink:0;border-right:1px solid #2a322b}textarea#source{border:0;border-radius:0;outline:0;resize:none;padding:20px 16px;display:block;width:100%;height:100%;color:#d4e3ca;background:transparent;white-space:pre;overflow:auto;tab-size:2;font:12px/1.85 ui-monospace,SFMono-Regular,Consolas,monospace;caret-color:var(--accent)}textarea#source:focus{background:#171e19}textarea#source::placeholder{color:#7c8b7d;font:12px/1.9 ui-sans-serif,system-ui,sans-serif;white-space:pre-wrap}textarea#source[aria-invalid=true]{box-shadow:inset 3px 0 0 var(--danger)}.editor-footer{padding:8px 15px;font-size:10px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--faint);display:flex;justify-content:space-between;border-top:1px solid var(--line)}.apply-row{display:flex;gap:18px;align-items:center;padding:16px 18px 12px}.apply-row p{font-size:10px;color:var(--muted);line-height:1.7;margin:0;max-width:185px}.diagnostic{margin:0 18px 17px;font-size:11px;line-height:1.8;min-height:22px;color:var(--muted);display:flex;align-items:baseline;gap:6px;flex-wrap:wrap}.diagnostic.error{color:var(--danger)}.diagnostic.success{color:#c7dea9}.right-column{display:grid;gap:18px}.stage-wrap{position:relative;background-color:#121613;background-image:linear-gradient(#a6c39907 1px,transparent 1px),linear-gradient(90deg,#a6c39907 1px,transparent 1px);background-size:24px 24px}#scene{width:100%;height:auto;aspect-ratio:640/360;display:block;overflow:hidden}.empty-scene{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none;gap:9px;color:var(--faint);padding:24px;text-align:center}.empty-scene[hidden]{display:none}.empty-scene strong{font-size:13px;color:var(--muted);font-weight:400}.empty-scene>span:not(.empty-cross){font-size:11px}.empty-cross{font-size:31px;color:#52614d;font-weight:200;margin-bottom:7px}.stage-dimensions{position:absolute;right:14px;bottom:10px;font-size:9px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;color:#6d7f6d;pointer-events:none}.transport{display:flex;gap:7px;align-items:center;padding:14px 16px 0;flex-wrap:wrap}.transport button{font-size:11px;padding:7px 10px}.transport #clock-status{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px;color:var(--muted);margin-left:auto}.control-note,.keyboard-note{font-size:10px;line-height:1.7;color:var(--faint);margin:10px 18px 15px}.input-heading{display:flex;justify-content:space-between;align-items:center;gap:10px;margin:0 18px;padding-top:13px;border-top:1px solid var(--line)}h3{font-size:11px;font-weight:550;margin:0}.input-heading>span{font-size:9px;color:var(--faint)}.input-controls{display:flex;flex-wrap:wrap;gap:7px;padding:11px 17px 0;min-height:39px}.input-button{display:flex;align-items:center;gap:14px;touch-action:none;user-select:none;-webkit-user-select:none}.input-button kbd{font:9px ui-monospace,SFMono-Regular,Consolas,monospace;border:1px solid #5a6957;border-radius:3px;padding:3px 4px;color:var(--muted);min-width:22px;text-align:center}.input-button.held{background:var(--accent);border-color:var(--accent);color:var(--accent-dark)}.input-button.held kbd{color:var(--accent-dark);border-color:#829a62}.empty-note{font-size:11px;line-height:1.7;color:var(--faint);margin:0}.keyboard-note{margin-bottom:13px;font-size:9px}.state-values{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));padding:15px 19px;gap:13px;max-height:210px;overflow:auto}.state-value{display:flex;flex-direction:column;gap:6px;min-width:0}.state-value dt{font:10px ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--muted);overflow-wrap:anywhere}.state-value dd{font:17px ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--accent);margin:0;overflow-wrap:anywhere}.state-value dl{margin:0}.mono{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px}.subdued{color:var(--muted);font-size:10px}.event-line{padding:0 19px 13px;display:flex;gap:16px;align-items:baseline;flex-wrap:wrap}.event-line #last-event{color:var(--muted);font-size:10px;overflow-wrap:anywhere}.runtime-error{margin:0 18px 14px;padding:10px;border:1px solid #705049;border-radius:5px;background:#432c272e;color:var(--danger);font-size:11px;line-height:1.6}.cost-details{border-top:1px solid var(--line);padding:12px 19px;font-size:10px;color:var(--muted)}summary{cursor:pointer;font-size:11px;color:var(--muted)}.cost-details p{font-size:10px;line-height:1.8;color:var(--faint)}.cost-values{display:grid;grid-template-columns:1fr auto;gap:8px 20px;margin:16px 0}.cost-values dt,.cost-values dd{margin:0}.cost-values dd{text-align:right;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--text)}.reference{margin-top:25px}.reference-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));padding:22px 20px;gap:24px}.reference-grid article{min-width:0;display:flex;flex-direction:column;align-items:flex-start}.reference-grid h3{font-size:12px;color:#d2ddc8}.reference-grid p{font-size:10px;line-height:1.8;color:var(--muted);min-height:36px;margin:8px 0 10px}.reference-grid pre{font:10px/1.85 ui-monospace,SFMono-Regular,Consolas,monospace;background:#151b16;color:#bacead;width:100%;padding:10px;overflow:auto;border-radius:6px;min-height:115px;margin:0 0 8px}.reference-grid .quiet{font-size:10px;padding:5px 0}.syntax-details{padding:16px 20px;border-top:1px solid var(--line)}.syntax-columns{display:grid;grid-template-columns:1fr 1fr;gap:35px;margin-top:12px}.syntax-columns p{font-size:11px;line-height:1.95;color:var(--muted)}.syntax-columns strong{color:var(--text);font-weight:500}.syntax-columns code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px;color:#d0dcbe;overflow-wrap:anywhere}.example-row{padding:18px 20px;border-top:1px solid var(--line);display:flex;align-items:center;gap:10px}.example-row>div{margin-right:auto}.example-row label{font-size:11px;color:var(--text)}.example-row p{font-size:10px;color:var(--faint);margin:5px 0 0}.example-row button,.example-row select{font-size:10px}.page-footer{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:26px 0 30px;color:var(--faint);font-size:10px}.page-footer>span{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:9px}.page-footer p{margin:0;line-height:1.8}.page-footer strong{font-weight:400;color:var(--muted)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}[hidden]{display:none!important}
+.baseline-row{border-top:1px solid var(--line);padding:15px 20px;display:flex;flex-wrap:wrap;gap:8px 16px;align-items:center}.baseline-row a{font-size:11px;color:var(--accent);text-underline-offset:3px}.baseline-row span{font-size:10px;color:var(--faint)}#scene:focus-visible{outline:2px solid var(--accent);outline-offset:-3px}
+@media(min-width:1450px){.code-editor{height:558px}.reference-grid pre{font-size:11px}}
+@media(max-width:1050px){.masthead{padding:0 25px}.experiment{display:none}main{padding:29px 25px 0}.workspace{grid-template-columns:minmax(300px,1fr) minmax(340px,1fr);gap:15px}.intro>p{font-size:11px;max-width:290px}.reference-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:25px}.reference-grid p{min-height:0}.reference-grid pre{min-height:110px;font-size:11px}.transport #clock-status{width:100%;margin:3px 0 0}.code-editor{height:533px}.panel-heading{padding:0 16px}.apply-row{gap:10px;padding-left:14px;padding-right:14px}.apply-row p{font-size:9px}.input-heading{align-items:flex-start;flex-direction:column;gap:5px}.intro{gap:20px}button.primary{gap:14px;padding:11px 13px}}
+@media(max-width:760px){.masthead{height:63px;padding:0 20px}.wordmark{font-size:21px}.wordmark span{font-size:11px}.connection{font-size:10px}main{padding:25px 18px 0}.intro{display:block;margin-bottom:22px}.intro h1{font-size:28px}.intro>p{margin-top:14px;max-width:none;font-size:11px}.workspace{grid-template-columns:minmax(0,1fr);gap:17px}.code-editor{height:340px}.editor-panel{width:100%}.transport #clock-status{width:auto;margin-left:auto}.right-column{gap:17px}.input-heading{flex-direction:row;align-items:center}.reference{margin-top:20px}.reference-grid{padding:20px 16px;gap:24px 17px}.reference-grid pre{font-size:10px}.reference>.panel-heading{height:auto;min-height:57px;padding-top:12px;padding-bottom:12px;align-items:flex-start;flex-direction:column;gap:6px}.syntax-columns{grid-template-columns:1fr;gap:0}.example-row{flex-wrap:wrap;padding:17px 16px}.example-row>div{width:100%;margin-bottom:4px}.example-row select{flex:1}.page-footer{align-items:flex-start;flex-direction:column;gap:10px;padding:23px 0}.apply-row{padding:14px 16px 10px;gap:17px}.apply-row p{font-size:10px;max-width:none}.diagnostic{margin-left:16px}.reference-grid article{overflow:hidden}}
+@media(max-width:420px){main{padding-left:13px;padding-right:13px}.masthead{padding:0 15px}.wordmark span{font-size:10px}.connection{font-size:9px;gap:6px}.intro h1{font-size:25px}.transport{gap:5px;padding-left:12px;padding-right:12px}.transport button{font-size:10px;padding:7px 8px}.transport #clock-status{font-size:9px}.input-heading>span{font-size:8px}.reference-grid{grid-template-columns:minmax(0,1fr)}.reference-grid pre{min-height:0;font-size:11px}.reference-grid p{margin-bottom:8px}.file-tools{gap:0;padding:7px 5px}.file-tools .quiet{font-size:10px;padding:6px 7px}.apply-row p{font-size:9px}.line-numbers{padding-left:10px;min-width:36px}textarea#source{padding-left:12px;font-size:11px}.line-numbers{font-size:11px}}
+@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
+
+#scene.direct-input{touch-action:none;user-select:none;-webkit-user-select:none}#scene [data-point-input]{cursor:grab}
diff --git a/prototypes/workbench/results/2026-09-23-pointer/authoring-browser.json b/prototypes/workbench/results/2026-09-23-pointer/authoring-browser.json
new file mode 100644
index 0000000..419bafd
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/authoring-browser.json
@@ -0,0 +1,128 @@
+{
+ "recordedUtc": "2026-09-23T06:18:07.245Z",
+ "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36",
+ "checks": [
+ "initial editor has literally empty source",
+ "initial scene has no built-in entities, states, inputs, or running clock",
+ "controls require an authored program",
+ "authoring a new program is a draft until Apply",
+ "new custom program constructs states and multiple views from source",
+ "input buttons are generated from the authored declarations",
+ "view bindings render the authored state value",
+ "Space and arrows in the editor never reach authored inputs",
+ "pointer down dispatches once while the clock is paused",
+ "pointer up releases without double dispatch from click",
+ "keyboard repeat is ignored",
+ "keyboard activation on a generated button is one complete tap",
+ "assistive click activation is one complete tap",
+ "keyboard and pointer sources share one logical input",
+ "releasing one source does not release the other source",
+ "last source releases the logical input",
+ "authored down/frame behavior uses the declared dt without built-in motion",
+ "keyup releases even after focus moves into the editor",
+ "focus loss sends release and pauses the clock",
+ "Play advances and Pause freezes the clock",
+ "invalid code preserves the previous executable program and states",
+ "compile error exposes a source-line diagnostic",
+ "diagnostic location moves the editor caret to the problem",
+ "apply preserves matching state names and initializes newly authored states",
+ "new input, entity, binding, and behavior work with source changes alone",
+ "changed behavior takes effect without resetting existing values",
+ "explicit Reset restores active initializers and keeps the draft",
+ "Apply is refused while an input is held",
+ "clear editor is explicit and does not clear the running scene before Apply",
+ "downloaded project restores the exact un-applied draft without execution",
+ "invalid JSON project schema cannot replace the draft",
+ "failed assistive down event rolls back and its error survives the following successful up",
+ "latched runtime failure is visible and labeled separately from the latest input",
+ "explicit Reset clears the failure record",
+ "applying empty source removes all authored states, inputs, and scene contents",
+ "explicit example loading only fills the editor and never auto-applies",
+ "authored scene executes in the actual fixed 64 KiB Wasm memory",
+ "cost inspector shows measured Wasm memory without claiming browser heap totals",
+ "390px mobile viewport has no horizontal overflow",
+ "generated mobile input buttons remain usable",
+ "ordinary-JavaScript comparison and equivalent Pure example are linked and served",
+ "missing Wasm shows an actionable load failure and disables execution controls",
+ "fresh load always returns to a blank authoring environment",
+ "no uncaught page errors during the browser scenarios"
+ ],
+ "authoredSource": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "authoredSnapshot": {
+ "states": {
+ "x": 114,
+ "moving": 0,
+ "count": 2
+ },
+ "views": [
+ {
+ "id": "marker",
+ "type": "box",
+ "color": "#7ed9ae",
+ "x": 114,
+ "y": 170,
+ "width": 48,
+ "height": 48
+ },
+ {
+ "id": "reading",
+ "type": "text",
+ "prefix": "Signals: ",
+ "x": 40,
+ "y": 70,
+ "value": 2
+ }
+ ],
+ "tick": 12,
+ "revision": 5,
+ "displayError": null,
+ "lastError": 0,
+ "errorMessage": "",
+ "errorLine": null,
+ "lastOps": 2,
+ "successfulEvents": 18,
+ "held": [],
+ "lastEvent": {
+ "name": "advance",
+ "phase": "up",
+ "source": "keyboard:ArrowRight",
+ "status": 0,
+ "line": null,
+ "ops": 2
+ },
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19128,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 464,
+ "activeStateBytes": 24,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ },
+ "ready": true,
+ "source": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "appliedSource": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "dirty": false,
+ "running": false,
+ "droppedTicks": 0,
+ "diagnostic": "코드를 적용했습니다. 같은 이름의 상태는 유지됩니다."
+ },
+ "mobile": {
+ "viewport": 390,
+ "content": 390
+ },
+ "downloaded": {
+ "filename": "my-work.pure.json",
+ "path": "output/playwright/pure-pointer-workbench-draft.json",
+ "expectedSource": "# A new authored interaction, not a bundled example.\nstate x = 500\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 5 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\nstate bonus = 7\ninput reverse = KeyA\ncircle beacon(300, 190, 15 + bonus, \"#c9f08b\")\non reverse.down { bonus = bonus + 2 }\n# This un-applied draft must survive Reset.\n"
+ },
+ "errors": [],
+ "screenshots": [
+ "output/playwright/pure-pointer-workbench-blank.png",
+ "output/playwright/pure-pointer-workbench-authored.png",
+ "output/playwright/pure-pointer-workbench-mobile.png"
+ ]
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer/before/cli.mjs b/prototypes/workbench/results/2026-09-23-pointer/before/cli.mjs
new file mode 100644
index 0000000..aafcd51
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/before/cli.mjs
@@ -0,0 +1,69 @@
+#!/usr/bin/env node
+import { readFile, writeFile } from 'node:fs/promises';
+import { createInterface } from 'node:readline';
+import { createRuntime } from './web/runtime.mjs';
+
+const help = `Pure authoring workbench · 같은 정의와 Zig 실행기
+ load PATH .pure 파일을 적용 (같은 이름의 상태 보존)
+ down NAME 정의한 입력 누르기
+ up NAME 정의한 입력 놓기
+ frame [1..600] 시간 진행, 1프레임 = 1/60초
+ show 상태와 도형 출력
+ limits C H W 표시 캐시(0..32)·변경 기록(0..8)·작업량(0..4294967295) 설정
+ forget [PART] 표시 기록 삭제: all, values, history (상태는 유지)
+ costs 마지막 표시 계산의 작업량·명령 수·캐시 적중 출력
+ reset 현재 정의의 초기 상태로
+ save PATH 적용한 정의를 저장
+ help / quit
+브라우저와 별도 인스턴스입니다. 임의 JavaScript는 실행하지 않습니다.`;
+
+async function main() {
+ const args = process.argv.slice(2);
+ if (args.includes('--help')) { console.log(help); return; }
+ if (args.length > 1) throw new Error('사용법: node cli.mjs [project.pure]');
+ const runtime = await createRuntime(await readFile(new URL('./web/core.wasm', import.meta.url)));
+ runtime.apply(args[0] ? await readFile(args[0], 'utf8') : '');
+ const terminal = createInterface({ input: process.stdin, output: process.stdout, terminal: Boolean(process.stdin.isTTY) });
+ if (process.stdin.isTTY) console.log(help);
+ terminal.setPrompt('pure> ');
+ if (process.stdin.isTTY) terminal.prompt();
+ for await (const line of terminal) {
+ try {
+ const match = /^(\S+)(?:\s+(.+))?$/.exec(line.trim());
+ const name = match?.[1] || 'show';
+ const arg = match?.[2];
+ if (name === 'quit') { terminal.close(); break; }
+ if (name === 'help') console.log(help);
+ else if (name === 'costs' && !arg) console.log(JSON.stringify({ evaluation: runtime.evaluationStats() }));
+ else {
+ if (name === 'load' && arg) runtime.apply(await readFile(arg, 'utf8'));
+ else if (name === 'save' && arg) await writeFile(arg, runtime.exportProject().source, { flag: 'wx' });
+ else if (name === 'down' && arg) runtime.down(arg, 'terminal');
+ else if (name === 'up' && arg) runtime.up(arg, 'terminal');
+ else if (name === 'frame') {
+ if (arg !== undefined && !/^\d+$/.test(arg)) throw new Error('frame 1..600 정수를 입력하세요.');
+ const count = arg === undefined ? 1 : Number(arg);
+ if (count < 1 || count > 600) throw new Error('frame 1..600 정수를 입력하세요.');
+ runtime.frame(count);
+ } else if (name === 'reset' && !arg) runtime.reset();
+ else if (name === 'limits' && arg) {
+ const parts = arg.split(/\s+/);
+ if (parts.length !== 3 || parts.some(part => !/^\d+$/.test(part)))
+ throw new Error('limits C H W 정수 세 개를 입력하세요.');
+ const [cacheEntries, historyEntries, workLimit] = parts.map(Number);
+ runtime.configureEvaluation({ cacheEntries, historyEntries, workLimit });
+ } else if (name === 'forget') runtime.forgetEvaluation(arg || 'all');
+ else if (name !== 'show' || arg) throw new Error('명령 형식을 확인하세요. help로 안내를 볼 수 있습니다.');
+ const result = runtime.snapshot();
+ console.log(JSON.stringify(result));
+ if (result.lastError && !process.stdin.isTTY) process.exitCode = 1;
+ }
+ } catch (error) {
+ console.error(`${error.line ? `${error.line}:${error.column || 1} ` : ''}${error.message}`);
+ if (!process.stdin.isTTY) process.exitCode = 1;
+ }
+ if (process.stdin.isTTY) terminal.prompt();
+ }
+}
+
+main().catch((error) => { console.error(error.message); process.exitCode = 1; });
diff --git a/prototypes/workbench/results/2026-09-23-pointer/before/core.zig b/prototypes/workbench/results/2026-09-23-pointer/before/core.zig
new file mode 100644
index 0000000..84812a3
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/before/core.zig
@@ -0,0 +1,942 @@
+//! Bounded scalar interaction VM experiment. No application behavior is built in.
+//! Same source for native tests and freestanding Wasm; no allocation or imports.
+//! Error ABI: 0 ok, 1 capacity, 2 invalid, 3 stack, 4 division by zero,
+//! 5 non-finite, 6 bad branch, 7 bad state, 8 bad frame, 9 bad event.
+//! Counters wrap modulo 2^32. Failed loads preserve the active program, authored
+//! state and event diagnostics; beginning a load discards optional reuse records.
+//! Failed events preserve values and counters but
+//! report their error and attempted instruction count. Binding evaluation is pure.
+
+const std = @import("std");
+const Evaluation = @import("evaluation.zig").Evaluation;
+const Tables = @import("evaluation.zig").Tables;
+
+const max_states = 32;
+const max_inputs = 8;
+const max_instructions = 512;
+const max_handlers = 64;
+const max_stack = 64;
+const no_pc = std.math.maxInt(u32);
+const frame_dt: f64 = 1.0 / 60.0;
+
+const Code = enum(u32) {
+ ok = 0,
+ capacity = 1,
+ invalid = 2,
+ stack = 3,
+ division_by_zero = 4,
+ non_finite = 5,
+ bad_branch = 6,
+ bad_state = 7,
+ bad_frame = 8,
+ bad_event = 9,
+ work_limit = 10,
+};
+
+const Instruction = struct { value: f64 = 0, op: u32 = 0, arg: u32 = 0 };
+const Handler = struct { event: u32 = 0, start: u32 = 0, length: u32 = 0 };
+const Program = struct {
+ instructions: [max_instructions]Instruction = @splat(.{}),
+ handlers: [max_handlers]Handler = @splat(.{}),
+ initials: [max_states]f64 = @splat(0),
+ old_ids: [max_states]i32 = @splat(-1),
+ declared: [max_states]bool = @splat(false),
+ state_count: u32 = 0,
+ input_count: u32 = 0,
+ instruction_count: u32 = 0,
+ handler_count: u32 = 0,
+};
+// Loading and result reuse are mutually exclusive phases. Current execution
+// always reads active, and can recompute bindings while this workspace loads.
+const Workspace = union { staging: Program, reuse: Tables };
+const Engine = struct {
+ active: Program = .{},
+ workspace: Workspace = .{ .reuse = .{} },
+ values: [max_states]f64 = @splat(0),
+ staging_open: bool = false,
+ staging_error: Code = .ok,
+ tick: u32 = 0,
+ last_error: Code = .ok,
+ error_pc: u32 = no_pc,
+ last_ops: u32 = 0,
+ revision: u32 = 0,
+ successful_events: u32 = 0,
+ evaluation: Evaluation = .{},
+};
+var engine: Engine = .{};
+
+fn reuseTables() ?*Tables {
+ return if (engine.staging_open) null else &engine.workspace.reuse;
+}
+
+fn finishStaging() void {
+ engine.workspace = .{ .reuse = .{} };
+ engine.staging_open = false;
+ engine.evaluation.forget(null, 3);
+}
+
+fn public(code: Code) u32 {
+ return @intFromEnum(code);
+}
+
+fn stagingFail(code: Code) u32 {
+ if (engine.staging_error == .ok) engine.staging_error = code;
+ return public(code);
+}
+
+export fn reset_all() void {
+ engine = .{};
+}
+
+export fn begin_program(state_count: u32, input_count: u32) u32 {
+ engine.staging_error = .ok;
+ if (state_count > max_states or input_count > max_inputs) {
+ if (engine.staging_open) finishStaging();
+ return stagingFail(.capacity);
+ }
+ engine.workspace = .{ .staging = .{ .state_count = state_count, .input_count = input_count } };
+ engine.staging_open = true;
+ engine.evaluation.forget(null, 3);
+ return 0;
+}
+
+export fn abort_program() void {
+ if (engine.staging_open) finishStaging();
+}
+
+export fn put_state(id: u32, initial: f64, old_id: i32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ if (id >= engine.workspace.staging.state_count or old_id < -1) return stagingFail(.bad_state);
+ if (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count) return stagingFail(.bad_state);
+ if (engine.workspace.staging.declared[id]) return stagingFail(.bad_state);
+ if (!std.math.isFinite(initial)) return stagingFail(.non_finite);
+ engine.workspace.staging.initials[id] = initial;
+ engine.workspace.staging.old_ids[id] = old_id;
+ engine.workspace.staging.declared[id] = true;
+ return 0;
+}
+
+export fn put_instruction(op: u32, arg: u32, value: f64) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging;
+ if (p.instruction_count == max_instructions) return stagingFail(.capacity);
+ if (op > 21) return stagingFail(.invalid);
+ if (!std.math.isFinite(value)) return stagingFail(.non_finite);
+ p.instructions[p.instruction_count] = .{ .op = op, .arg = arg, .value = value };
+ p.instruction_count += 1;
+ return 0;
+}
+
+export fn put_handler(event: u32, start: u32, length: u32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging;
+ if (p.handler_count == max_handlers) return stagingFail(.capacity);
+ if (event > p.input_count * 2) return stagingFail(.bad_event);
+ // Handlers may be appended before their instructions, so range validation
+ // is deferred until commit. Arithmetic remains guarded there.
+ p.handlers[p.handler_count] = .{ .event = event, .start = start, .length = length };
+ p.handler_count += 1;
+ return 0;
+}
+
+fn validate(p: *const Program) Code {
+ for (0..p.state_count) |id| {
+ if (!p.declared[id]) return .bad_state;
+ if (!std.math.isFinite(p.initials[id])) return .non_finite;
+ const old_id = p.old_ids[id];
+ if (old_id < -1 or (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count)) return .bad_state;
+ }
+ for (p.handlers[0..p.handler_count], 0..) |h, i| {
+ if (h.event > 2 * p.input_count) return .bad_event;
+ if (h.start > p.instruction_count or h.length > p.instruction_count - h.start) return .invalid;
+ if (h.length == 0) continue;
+ for (p.handlers[0..i]) |other| {
+ if (other.length != 0 and h.start < other.start + other.length and other.start < h.start + h.length) return .invalid;
+ }
+ }
+ for (p.instructions[0..p.instruction_count], 0..) |inst, pc| {
+ if (inst.op > 21) return .invalid;
+ if (!std.math.isFinite(inst.value)) return .non_finite;
+ if ((inst.op == 1 or inst.op == 20) and inst.arg >= p.state_count) return .bad_state;
+ if (inst.op == 20 or inst.op == 21) {
+ var owner: ?Handler = null;
+ for (p.handlers[0..p.handler_count]) |h| {
+ if (pc >= h.start and pc < h.start + h.length) {
+ owner = h;
+ break;
+ }
+ }
+ const h = owner orelse return .invalid;
+ if (inst.op == 21 and (inst.arg <= pc or inst.arg > h.start + h.length)) return .bad_branch;
+ }
+ }
+ return .ok;
+}
+
+export fn commit_program() u32 {
+ if (!engine.staging_open) return public(.invalid);
+ if (engine.staging_error != .ok) return public(engine.staging_error);
+ const code = validate(&engine.workspace.staging);
+ if (code != .ok) return stagingFail(code);
+ var next: [max_states]f64 = @splat(0);
+ for (0..engine.workspace.staging.state_count) |i| {
+ const old_id = engine.workspace.staging.old_ids[i];
+ next[i] = if (old_id < 0) engine.workspace.staging.initials[i] else engine.values[@intCast(old_id)];
+ }
+ engine.active = engine.workspace.staging;
+ engine.values = next;
+ finishStaging();
+ engine.revision +%= 1;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ return 0;
+}
+
+export fn reset_state() void {
+ engine.values = engine.active.initials;
+ engine.evaluation.forget(reuseTables(), 3);
+ engine.tick = 0;
+ engine.successful_events = 0;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+}
+
+const Execution = struct {
+ code: Code = .ok,
+ pc: u32 = no_pc,
+ ops: u32 = 0,
+ value: f64 = 0,
+ dependencies: u32 = 0,
+};
+
+fn executionFail(result: Execution, code: Code, pc: u32) Execution {
+ var failed = result;
+ failed.code = code;
+ failed.pc = pc;
+ return failed;
+}
+
+fn execute(values: *[max_states]f64, start: u32, length: u32, dt: f64, pure: bool, budget: ?*Evaluation) Execution {
+ const p = &engine.active;
+ var result: Execution = .{};
+ if (start > p.instruction_count or length > p.instruction_count - start) return executionFail(result, .invalid, no_pc);
+ const end = start + length;
+ var stack: [max_stack]f64 = undefined;
+ var sp: usize = 0;
+ var pc = start;
+ while (pc < end) {
+ if (budget) |b| {
+ if (!b.spend()) return executionFail(result, .work_limit, pc);
+ b.instructions += 1;
+ }
+ const inst = p.instructions[pc];
+ result.ops += 1;
+ if (pure and (inst.op == 2 or inst.op == 20 or inst.op == 21)) return executionFail(result, .invalid, pc);
+ const need: usize = switch (inst.op) {
+ 0...2 => 0,
+ 7, 16, 20, 21 => 1,
+ 3...6, 8...15, 17, 18 => 2,
+ 19 => 3,
+ else => return executionFail(result, .invalid, pc),
+ };
+ if (sp < need) return executionFail(result, .stack, pc);
+ if (inst.op <= 2) {
+ if (sp == max_stack) return executionFail(result, .stack, pc);
+ const value = switch (inst.op) {
+ 0 => inst.value,
+ 1 => blk: {
+ if (inst.arg >= p.state_count) return executionFail(result, .bad_state, pc);
+ if (budget != null and budget.?.cache_count != 0)
+ result.dependencies |= @as(u32, 1) << @as(u5, @intCast(inst.arg));
+ break :blk values[inst.arg];
+ },
+ 2 => dt,
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ stack[sp] = value;
+ sp += 1;
+ } else if (inst.op == 20) {
+ if (inst.arg >= p.state_count) return executionFail(result, .bad_state, pc);
+ sp -= 1;
+ values[inst.arg] = stack[sp];
+ } else if (inst.op == 21) {
+ if (inst.arg <= pc or inst.arg > end) return executionFail(result, .bad_branch, pc);
+ sp -= 1;
+ if (stack[sp] == 0) {
+ pc = inst.arg;
+ continue;
+ }
+ } else {
+ const a = stack[sp - need];
+ const b = if (need >= 2) stack[sp - need + 1] else 0;
+ const value: f64 = switch (inst.op) {
+ 3 => a + b,
+ 4 => a - b,
+ 5 => a * b,
+ 6 => blk: {
+ if (b == 0) return executionFail(result, .division_by_zero, pc);
+ break :blk a / b;
+ },
+ 7 => -a,
+ 8 => @floatFromInt(@intFromBool(a < b)),
+ 9 => @floatFromInt(@intFromBool(a <= b)),
+ 10 => @floatFromInt(@intFromBool(a > b)),
+ 11 => @floatFromInt(@intFromBool(a >= b)),
+ 12 => @floatFromInt(@intFromBool(a == b)),
+ 13 => @floatFromInt(@intFromBool(a != b)),
+ 14 => @floatFromInt(@intFromBool(a != 0 and b != 0)),
+ 15 => @floatFromInt(@intFromBool(a != 0 or b != 0)),
+ 16 => @floatFromInt(@intFromBool(a == 0)),
+ 17 => @min(a, b),
+ 18 => @max(a, b),
+ 19 => blk: {
+ const hi = stack[sp - 1];
+ if (b > hi) return executionFail(result, .invalid, pc);
+ break :blk @max(b, @min(a, hi));
+ },
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ sp = sp - need + 1;
+ stack[sp - 1] = value;
+ }
+ pc += 1;
+ }
+ if (sp != @as(usize, if (pure) 1 else 0)) return executionFail(result, .stack, end);
+ if (pure) result.value = stack[0];
+ return result;
+}
+
+export fn dispatch(event: u32, dt: f64) u32 {
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ if (event > engine.active.input_count * 2) {
+ engine.last_error = .bad_event;
+ return public(.bad_event);
+ }
+ if (!std.math.isFinite(dt)) {
+ engine.last_error = .non_finite;
+ return public(.non_finite);
+ }
+ if ((event == 0 and dt != frame_dt) or (event != 0 and dt != 0)) {
+ engine.last_error = .bad_frame;
+ return public(.bad_frame);
+ }
+ var pending = engine.values;
+ for (engine.active.handlers[0..engine.active.handler_count]) |h| {
+ if (h.event != event) continue;
+ const result = execute(&pending, h.start, h.length, dt, false, null);
+ engine.last_ops += result.ops;
+ if (result.code != .ok) {
+ engine.last_error = result.code;
+ engine.error_pc = result.pc;
+ return public(result.code);
+ }
+ }
+ if (engine.evaluation.cache_count != 0 and !engine.staging_open) {
+ var changed: u32 = 0;
+ for (0..engine.active.state_count) |i| {
+ // Signed zero is observable, so compare representation, not ==.
+ if (@as(u64, @bitCast(pending[i])) != @as(u64, @bitCast(engine.values[i])))
+ changed |= @as(u32, 1) << @as(u5, @intCast(i));
+ }
+ engine.evaluation.changed(reuseTables(), changed);
+ }
+ engine.values = pending;
+ if (event == 0) engine.tick +%= 1;
+ engine.successful_events +%= 1;
+ return 0;
+}
+
+export fn eval_at(start: u32, length: u32) f64 {
+ // A local copy makes the no-side-effects boundary structural as well as
+ // checked by the pure instruction filter. Diagnostics are never touched.
+ var values = engine.values;
+ const result = execute(&values, start, length, 0, true, null);
+ return if (result.code == .ok) result.value else std.math.nan(f64);
+}
+
+export fn configure_evaluation(cache: u32, history: u32) u32 {
+ return if (engine.evaluation.configure(reuseTables(), cache, history)) 0 else public(.capacity);
+}
+
+export fn forget_evaluation(flags: u32) void {
+ engine.evaluation.forget(reuseTables(), flags);
+}
+
+export fn begin_evaluation(limit: u32) void {
+ engine.evaluation.begin(limit);
+}
+
+export fn eval_bounded(start: u32, length: u32) f64 {
+ const e = &engine.evaluation;
+ e.status = 0;
+ // Charge per request as well as per instruction and validation probe.
+ if (!e.spend()) return std.math.nan(f64);
+ if (e.lookup(reuseTables(), start, length)) |value| return value;
+ if (e.status != 0) return std.math.nan(f64);
+ var values = engine.values;
+ const result = execute(&values, start, length, 0, true, e);
+ e.status = public(result.code);
+ if (result.code != .ok) return std.math.nan(f64);
+ e.remember(reuseTables(), start, length, result.value, result.dependencies);
+ return result.value;
+}
+
+export fn evaluation_info(id: u32) u32 {
+ const e = &engine.evaluation;
+ return switch (id) {
+ 0 => e.status,
+ 1 => e.work,
+ 2 => e.instructions,
+ 3 => e.hits,
+ 4 => e.cache_count,
+ 5 => e.history_count,
+ 6 => @sizeOf(Evaluation) + @sizeOf(Tables),
+ 7 => @sizeOf(Evaluation),
+ 8 => @sizeOf(Tables),
+ 9 => @intFromBool(!engine.staging_open),
+ 10 => @sizeOf(Workspace) - @sizeOf(Program),
+ else => 0,
+ };
+}
+
+export fn get_state(id: u32) f64 {
+ return if (id < engine.active.state_count) engine.values[id] else std.math.nan(f64);
+}
+
+export fn info(id: u32) u32 {
+ return switch (id) {
+ 0 => engine.tick,
+ 1 => public(engine.last_error),
+ 2 => engine.error_pc,
+ 3 => engine.last_ops,
+ 4 => engine.revision,
+ 5 => engine.active.state_count,
+ 6 => engine.active.instruction_count,
+ 7 => engine.active.handler_count,
+ 8 => engine.active.input_count,
+ 9 => engine.successful_events,
+ else => 0,
+ };
+}
+
+export fn layout(id: u32) u32 {
+ return switch (id) {
+ 0 => @sizeOf(Engine),
+ 1 => @sizeOf(Program),
+ 2 => @sizeOf(Instruction),
+ 3 => @sizeOf(Handler),
+ 4 => engine.active.instruction_count * @sizeOf(Instruction) + engine.active.handler_count * @sizeOf(Handler),
+ 5 => engine.active.state_count * @sizeOf(f64),
+ 6 => @sizeOf(Program),
+ 7 => max_stack * @sizeOf(f64),
+ else => 0,
+ };
+}
+
+// Test helpers describe programs directly; the product compiler is separately
+// tested against this ABI. These tests exercise semantics and failure boundaries.
+fn ok(code: u32) !void {
+ try std.testing.expectEqual(@as(u32, 0), code);
+}
+
+fn emit(op: u32, arg: u32, value: f64) !void {
+ try ok(put_instruction(op, arg, value));
+}
+
+fn counterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try ok(commit_program());
+}
+
+test "blank program, no hidden application behavior, and reset" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 1), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(3));
+ try std.testing.expectEqual(@as(u32, 0), info(5));
+ try std.testing.expect(std.math.isNan(get_state(0)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 0)));
+ reset_state();
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 1), info(4));
+}
+
+test "arbitrary input counter and unused events" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(dispatch(2, 0));
+ try ok(dispatch(0, frame_dt));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(3));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(6));
+}
+
+test "charge and release behavior exists only in the authored instruction program" {
+ reset_all();
+ try ok(begin_program(3, 1)); // held, charge, released_value
+ for (0..3) |i| try ok(put_state(@intCast(i), 0, -1));
+ try emit(0, 0, 1);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 2));
+ try emit(1, 1, 0);
+ try emit(1, 0, 0);
+ try emit(2, 0, 0);
+ try emit(5, 0, 0);
+ try emit(3, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(0, 2, 6));
+ try emit(1, 1, 0);
+ try emit(20, 2, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(2, 8, 6));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ try ok(dispatch(1, 0));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try ok(dispatch(2, 0));
+ try std.testing.expectApproxEqAbs(@as(f64, 0.5), get_state(2), 1e-12);
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+}
+
+test "forward condition executes once and assignments and handlers are ordered" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 1, -1));
+ try ok(put_state(1, 0, -1));
+ try emit(1, 0, 0);
+ try emit(21, 6, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 7);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 0, 6));
+ try emit(1, 1, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 6, 4));
+ try ok(commit_program());
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 14), get_state(1));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 28), get_state(1));
+ try std.testing.expectEqual(@as(u32, 6), info(3));
+}
+
+test "branch to program end is valid" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, 2, 0);
+ try ok(put_handler(0, 0, 2));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+}
+
+test "one event rolls back writes from all handlers on arithmetic failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 8, -1));
+ try emit(0, 0, 99);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 2));
+ try emit(0, 0, 1);
+ try emit(0, 0, 0);
+ try emit(6, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 2, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 8), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(2));
+ try std.testing.expectEqual(@as(u32, 5), info(3));
+}
+
+test "nonfinite arithmetic and invalid clamp fail atomically" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, -1));
+ try emit(0, 0, 1e308);
+ try emit(0, 0, 1e308);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, 0));
+ try emit(0, 0, 1);
+ try emit(0, 0, 10);
+ try emit(0, 0, 2);
+ try emit(19, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 5));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.invalid), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+}
+
+test "stack underflow overflow and leftover values reject the event" {
+ for (0..3) |mode| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ if (mode == 0) {
+ try emit(3, 0, 0);
+ } else {
+ for (0..@as(usize, if (mode == 1) 65 else 1)) |_| try emit(0, 0, 7);
+ }
+ try ok(put_handler(0, 0, engine.workspace.staging.instruction_count));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.stack), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ }
+}
+
+test "failed candidates leave active values program revision and diagnostics unchanged" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ const old_revision = info(4);
+ const old_ops = info(3);
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(1, 5, 0);
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try std.testing.expectEqual(old_revision, info(4));
+ try std.testing.expectEqual(old_ops, info(3));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.invalid), put_instruction(100, 0, 0));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+}
+
+test "state preservation mapping reads active values at commit and reset uses new initials" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(2, 0));
+ try ok(put_state(0, 42, -1)); // new symbol first
+ try ok(put_state(1, 100, 0)); // old symbol moves from id 0 to 1
+ try ok(dispatch(1, 0)); // changed after staging began
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 42), get_state(0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 100), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(4));
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try std.testing.expect(std.math.isNan(get_state(0)));
+}
+
+test "capacity boundaries and sticky load errors" {
+ reset_all();
+ try std.testing.expectEqual(public(.capacity), begin_program(33, 0));
+ try std.testing.expectEqual(public(.capacity), begin_program(0, 9));
+ try ok(begin_program(32, 8));
+ for (0..32) |i| try ok(put_state(@intCast(i), @floatFromInt(i), -1));
+ for (0..512) |_| try emit(0, 0, 0);
+ for (0..64) |_| try ok(put_handler(16, 0, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 512), info(6));
+ try std.testing.expectEqual(@as(u32, 64), info(7));
+ try std.testing.expectEqual(@as(f64, 31), get_state(31));
+ try ok(begin_program(0, 0));
+ for (0..512) |_| try emit(0, 0, 0);
+ try std.testing.expectEqual(public(.capacity), put_instruction(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+ try ok(begin_program(0, 0));
+ for (0..64) |_| try ok(put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+}
+
+test "invalid declaration values state mappings event ids and handler ranges" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 0, 0));
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.non_finite), put_state(0, std.math.inf(f64), -1));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 1, -1));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.non_finite), put_instruction(0, 0, std.math.nan(f64)));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.bad_event), put_handler(1, 0, 0));
+ try ok(begin_program(0, 0));
+ try ok(put_handler(0, no_pc, 5));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 1);
+ try ok(put_handler(0, 0, 1));
+ try ok(put_handler(0, 0, 1));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "backward self out-of-handler branches and stores without handlers are rejected" {
+ for ([_]u32{ 0, 1, 3 }) |target| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, target, 0);
+ try emit(0, 0, 7);
+ try ok(put_handler(0, 0, 2));
+ try std.testing.expectEqual(public(.bad_branch), commit_program());
+ }
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try emit(0, 0, 5);
+ try emit(20, 0, 0);
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "event inputs and dt enforce deterministic frame contract" {
+ reset_all();
+ try counterProgram();
+ try std.testing.expectEqual(public(.bad_event), dispatch(3, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch(no_pc, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(1, frame_dt));
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, std.math.nan(f64)));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(u32, 0), info(1));
+ try std.testing.expectEqual(no_pc, info(2));
+}
+
+test "binding evaluation is pure on both success and failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 4, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try emit(2, 0, 0);
+ try emit(0, 0, 0);
+ try emit(21, 7, 0);
+ try ok(put_handler(0, 3, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ const before = engine;
+ try std.testing.expectEqual(@as(f64, 12), eval_at(0, 3));
+ try std.testing.expect(std.math.isNan(eval_at(0, 4))); // attempted store
+ try std.testing.expect(std.math.isNan(eval_at(4, 1))); // dt forbidden in views
+ try std.testing.expect(std.math.isNan(eval_at(5, 2))); // branch forbidden
+ try std.testing.expect(std.math.isNan(eval_at(no_pc, 7)));
+ try std.testing.expect(std.math.isNan(eval_at(0, no_pc)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 2))); // two results
+ // Compare only the live member of the untagged workspace, never padding
+ // or the inactive member's indeterminate storage.
+ try std.testing.expectEqual(before.staging_open, engine.staging_open);
+ inline for (std.meta.fields(Engine)) |field| {
+ if (comptime std.mem.eql(u8, field.name, "workspace")) {
+ if (before.staging_open) {
+ try std.testing.expectEqualDeep(before.workspace.staging, engine.workspace.staging);
+ } else {
+ try std.testing.expectEqualDeep(before.workspace.reuse, engine.workspace.reuse);
+ }
+ } else try std.testing.expectEqualDeep(@field(before, field.name), @field(engine, field.name));
+ }
+}
+
+test "arithmetic comparison logical min max and clamp expression semantics" {
+ const cases = [_]struct { op: u32, a: f64, b: f64, want: f64 }{
+ .{ .op = 3, .a = 7, .b = 2, .want = 9 },
+ .{ .op = 4, .a = 7, .b = 2, .want = 5 },
+ .{ .op = 5, .a = 7, .b = 2, .want = 14 },
+ .{ .op = 6, .a = 7, .b = 2, .want = 3.5 },
+ .{ .op = 8, .a = 2, .b = 7, .want = 1 },
+ .{ .op = 9, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 10, .a = 2, .b = 7, .want = 0 },
+ .{ .op = 11, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 12, .a = 7, .b = 2, .want = 0 },
+ .{ .op = 13, .a = 7, .b = 2, .want = 1 },
+ .{ .op = 14, .a = -7, .b = 2, .want = 1 },
+ .{ .op = 15, .a = 0, .b = 2, .want = 1 },
+ .{ .op = 17, .a = 7, .b = 2, .want = 2 },
+ .{ .op = 18, .a = 7, .b = 2, .want = 7 },
+ };
+ for (cases) |case| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, case.a);
+ try emit(0, 0, case.b);
+ try emit(case.op, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(case.want, eval_at(0, 3));
+ }
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, -8);
+ try emit(7, 0, 0);
+ try emit(0, 0, 0);
+ try emit(0, 0, 5);
+ try emit(19, 0, 0);
+ try emit(16, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 5), eval_at(0, 5));
+ try std.testing.expectEqual(@as(f64, 0), eval_at(0, 6));
+}
+
+test "diagnostic counters wrap without affecting scalar values" {
+ reset_all();
+ try counterProgram();
+ engine.tick = no_pc;
+ engine.successful_events = no_pc;
+ engine.revision = no_pc;
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 99, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 0), info(4));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+}
+
+test "memory accounting separates reserved storage used IR and stack capacity" {
+ reset_all();
+ try std.testing.expectEqual(@as(u32, 0), layout(4));
+ try std.testing.expectEqual(@as(u32, 0), layout(5));
+ try counterProgram();
+ try std.testing.expectEqual(@as(u32, @sizeOf(Engine)), layout(0));
+ try std.testing.expectEqual(@as(u32, 16), layout(2));
+ try std.testing.expectEqual(@as(u32, 12), layout(3));
+ try std.testing.expectEqual(@as(u32, 76), layout(4));
+ try std.testing.expectEqual(@as(u32, 8), layout(5));
+ try std.testing.expectEqual(layout(1), layout(6));
+ try std.testing.expect(layout(0) >= layout(1) * 2);
+ try std.testing.expectEqual(@as(u32, 512), layout(7));
+ try std.testing.expect(layout(0) + 16 * 1024 < 64 * 1024);
+}
+
+fn displayCounterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try emit(1, 0, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try ok(configure_evaluation(32, 8));
+}
+
+fn boundedEquals(want: f64, start: u32, length: u32) !void {
+ begin_evaluation(100);
+ try std.testing.expectEqual(want, eval_bounded(start, length));
+ try ok(evaluation_info(0));
+}
+
+test "loading shares storage with records while old events and bindings remain executable" {
+ reset_all();
+ try displayCounterProgram();
+ try boundedEquals(20, 4, 3);
+ try boundedEquals(20, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(9));
+ try emit(1, 0, 0);
+ try ok(dispatch(1, 0)); // Active state advances after staging begins.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(3));
+ try ok(configure_evaluation(32, 1));
+ forget_evaluation(3); // Must not overwrite the pending program.
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(33, 0, 3);
+ try boundedEquals(33, 0, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+}
+
+test "failed and abandoned candidates restore empty reuse storage without changing active work" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(20, 0, 0); // Store without a handler fails validation.
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try boundedEquals(20, 4, 3);
+ try ok(dispatch(1, 0));
+ abort_program();
+ try boundedEquals(22, 4, 3);
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ abort_program(); // Inactive abort is a no-op, including for warmed records.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try std.testing.expectEqual(public(.capacity), begin_program(max_states + 1, 0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+}
+
+test "full pending program survives repeated evaluation configuration record loss and reset" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ for (0..max_instructions) |i| {
+ try emit(0, 0, @floatFromInt(i));
+ try ok(configure_evaluation(32, @intCast(i % 9)));
+ forget_evaluation(3);
+ try boundedEquals(22, 4, 3);
+ }
+ reset_state(); // The pending definition and old-id mapping survive reset.
+ try boundedEquals(20, 4, 3);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, max_instructions), info(6));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try boundedEquals(511, 511, 1);
+ // Safe Zig builds may reserve a hidden union tag in addition to the payload.
+ try std.testing.expect(@sizeOf(Workspace) >= @sizeOf(Program));
+ try std.testing.expect(@sizeOf(Workspace) <= @sizeOf(Program) + @alignOf(Program));
+ try std.testing.expectEqual(@as(u32, @sizeOf(Tables)), evaluation_info(8));
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer/before/evaluation.zig b/prototypes/workbench/results/2026-09-23-pointer/before/evaluation.zig
new file mode 100644
index 0000000..56081d9
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/before/evaluation.zig
@@ -0,0 +1,164 @@
+//! Disposable binding results and change history. No event state lives here.
+const std = @import("std");
+
+const Memo = struct {
+ value: f64 = 0,
+ revision: u64 = 0,
+ dependencies: u32 = 0,
+ start: u32 = 0,
+ length: u32 = 0,
+};
+const Change = struct { revision: u64 = 0, mask: u32 = 0 };
+
+pub const Tables = struct {
+ memo: [32]Memo = @splat(.{}),
+ history: [8]Change = @splat(.{}),
+};
+
+pub const Evaluation = struct {
+ pub const max_cache = 32;
+ pub const max_history = 8;
+ revision: u64 = 1,
+ floor: u64 = 1,
+ cache_count: u32 = 0,
+ history_count: u32 = 8,
+ next_change: u32 = 0,
+ limit: u32 = 0,
+ work: u32 = 0,
+ instructions: u32 = 0,
+ hits: u32 = 0,
+ status: u32 = 0,
+
+ pub fn configure(self: *Evaluation, tables: ?*Tables, cache: u32, history: u32) bool {
+ if (cache > max_cache or history > max_history) return false;
+ self.cache_count = cache;
+ self.history_count = history;
+ self.forget(tables, 3);
+ return true;
+ }
+
+ pub fn forget(self: *Evaluation, tables: ?*Tables, flags: u32) void {
+ if (flags & 1 != 0) if (tables) |t| { t.memo = @splat(.{}); };
+ if (flags & 2 != 0) {
+ if (tables) |t| t.history = @splat(.{});
+ self.floor = self.revision;
+ self.next_change = 0;
+ }
+ }
+
+ pub fn changed(self: *Evaluation, tables: ?*Tables, mask: u32) void {
+ const t = tables orelse return;
+ if (mask == 0 or self.cache_count == 0) return;
+ if (self.revision == std.math.maxInt(u64)) {
+ self.revision = 1;
+ self.forget(t, 3);
+ }
+ self.revision += 1;
+ if (self.history_count == 0) {
+ self.floor = self.revision;
+ return;
+ }
+ const change = &t.history[self.next_change];
+ self.floor = @max(self.floor, change.revision);
+ change.* = .{ .revision = self.revision, .mask = mask };
+ self.next_change = (self.next_change + 1) % self.history_count;
+ }
+
+ pub fn begin(self: *Evaluation, limit: u32) void {
+ self.limit = limit;
+ self.work = 0;
+ self.instructions = 0;
+ self.hits = 0;
+ self.status = 0;
+ }
+
+ pub fn spend(self: *Evaluation) bool {
+ if (self.work == self.limit) {
+ self.status = 10;
+ return false;
+ }
+ self.work += 1;
+ return true;
+ }
+
+ fn slot(self: *const Evaluation, start: u32, length: u32) u32 {
+ // One candidate, including on misses; no scan proportional to cache size.
+ return (start ^ (start >> 5) ^ (length *% 0x9e3779b9)) % self.cache_count;
+ }
+
+ pub fn lookup(self: *Evaluation, tables: ?*Tables, start: u32, length: u32) ?f64 {
+ const t = tables orelse return null;
+ if (self.cache_count == 0) return null;
+ if (!self.spend()) return null;
+ const memo = &t.memo[self.slot(start, length)];
+ if (memo.revision == 0 or memo.start != start or memo.length != length) return null;
+ // Successful pure constants need no input-change evidence. A program
+ // replacement still clears them, just like every other binding memo.
+ if (memo.dependencies != 0 and memo.revision < self.floor) return null;
+ if (memo.dependencies != 0 and memo.revision != self.revision) {
+ for (t.history[0..self.history_count]) |change| {
+ if (!self.spend()) return null;
+ if (change.revision > memo.revision and change.mask & memo.dependencies != 0) return null;
+ }
+ }
+ memo.revision = self.revision;
+ self.hits += 1;
+ return memo.value;
+ }
+
+ pub fn remember(self: *Evaluation, tables: ?*Tables, start: u32, length: u32, value: f64, dependencies: u32) void {
+ const t = tables orelse return;
+ if (self.cache_count == 0) return;
+ t.memo[self.slot(start, length)] = .{ .start = start, .length = length,
+ .value = value, .dependencies = dependencies, .revision = self.revision };
+ }
+};
+
+test "record loss cannot authorize an old memo; unrelated changes can" {
+ var tables: Tables = .{};
+ var e: Evaluation = .{};
+ try std.testing.expect(e.configure(&tables, 1, 1));
+ e.begin(100);
+ e.remember(&tables, 0, 3, 12, 1);
+ e.changed(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, 12), e.lookup(&tables, 0, 3));
+ e.changed(&tables, 2);
+ e.changed(&tables, 2); // The certificate now predates the surviving history.
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 12, 1);
+ e.changed(&tables, 1);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 20, 1);
+ e.changed(&tables, 2);
+ e.forget(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 20, 1);
+ e.revision = std.math.maxInt(u64);
+ e.changed(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+}
+
+test "budget, collisions, invalid settings, and disabled history are conservative" {
+ var tables: Tables = .{};
+ var e: Evaluation = .{};
+ try std.testing.expect(e.configure(&tables, 1, 0));
+ e.begin(100);
+ e.remember(&tables, 0, 3, 12, 1);
+ e.remember(&tables, 1, 3, 15, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ try std.testing.expectEqual(@as(?f64, 15), e.lookup(&tables, 1, 3));
+ e.changed(&tables, 0x80000000);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 1, 3));
+ try std.testing.expect(!e.configure(&tables, 33, 0));
+ try std.testing.expectEqual(@as(u32, 1), e.cache_count);
+ e.begin(0);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 1, 3));
+ try std.testing.expectEqual(@as(u32, 10), e.status);
+ try std.testing.expectEqual(@as(u32, 0), e.work);
+ e.begin(100);
+ e.remember(&tables, 2, 1, 99, 0);
+ e.changed(&tables, 1);
+ e.forget(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, 99), e.lookup(&tables, 2, 1));
+ try std.testing.expectEqual(@as(u32, 1), e.work);
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer/before/manifest.json b/prototypes/workbench/results/2026-09-23-pointer/before/manifest.json
new file mode 100644
index 0000000..1a6fe94
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/before/manifest.json
@@ -0,0 +1,12 @@
+{
+ "web/app.js": "f91c076ff5242b10b390b57b4213d09b1ae74e61ea221845603428ecec5ea3f9",
+ "web/runtime.mjs": "d490c54fb2b422dfe89b90a594a8da9ebc95bb007910211cb4686ccb83ed75c7",
+ "web/output.mjs": "1d22c44955e812d671f1a3c3ae4b2f0642a05ebe614017bb041bdddc8c363be3",
+ "web/compiler.mjs": "a34f3beb069c811a9bd5be73dde9341907ba77756066030b8b95fc6d66a5cc02",
+ "web/core.wasm": "76823b024fa5f4a79cbf1ed722ab757614a81a9f1c81d1d6268781a38bed7cc9",
+ "web/index.html": "4fff6eb67d8cff098302465449251d17f0efb39d9144e87d15fefcc152e5d97e",
+ "web/style.css": "43301298028c5bc431eecd5ff30d7362fae83cce1fd0fdd7e8bea858100c22b9",
+ "core.zig": "dc84bcb75e4417fb1a804529c78ffa8cd1ddd079c00df4ca97a35da0b8fdbb8a",
+ "evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "cli.mjs": "2e0627ef84a45c8a9e91fb28c0010673212d459d0b0d09a5342856a049060030"
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer/before/web/app.js b/prototypes/workbench/results/2026-09-23-pointer/before/web/app.js
new file mode 100644
index 0000000..9327e53
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/before/web/app.js
@@ -0,0 +1,368 @@
+import { createRuntime } from './runtime.mjs';
+import { createOutput } from './output.mjs';
+
+const $ = id => document.getElementById(id);
+const source = $('source');
+const MAX_SOURCE_BYTES = 32768;
+const encoder = new TextEncoder();
+const output = createOutput(document);
+const snippets = {
+ state: 'state count = 0\nstate x = 80\n',
+ input: 'input tap = Space\non tap.down {\n count = count + 1\n}\n',
+ view: 'box tile(x, 180, 40, 40, "#7ed9ae")\ntext label(40, 60, count, "Count: ")\n',
+ frame: 'on frame {\n x = x + 30 * dt\n if x > 600 { x = 0 }\n}\n',
+};
+let runtime = null;
+let appliedSource = '';
+let running = false;
+let previousTime = null;
+let accumulator = 0;
+let droppedTicks = 0;
+let diagnosticLocation = null;
+const keyboardSources = new Map();
+const pointerSources = new Map();
+const inputButtons = new Map();
+
+const bytes = text => encoder.encode(text).byteLength;
+const dirty = () => source.value !== appliedSource;
+// DOM is the published value. Avoid rewriting unchanged diagnostics/controls.
+function text(id, value) {
+ const node = $(id);
+ if (node.textContent !== value) node.textContent = value;
+}
+function pressed(node, value) {
+ const next = String(value);
+ if (node.getAttribute('aria-pressed') !== next) node.setAttribute('aria-pressed', next);
+}
+function draftStatus() {
+ const changed = dirty();
+ text('draft-status', changed ? '미적용 변경' : runtime?.currentProgram ? '적용됨' : '빈 작업물');
+ $('draft-status').classList.toggle('dirty', changed);
+}
+
+function setDiagnostic(message, kind = '', location = null) {
+ $('diagnostic-text').textContent = message;
+ $('diagnostic').className = `diagnostic ${kind}`;
+ diagnosticLocation = location;
+ $('error-location').hidden = !location;
+ source.setAttribute('aria-invalid', kind === 'error' && location ? 'true' : 'false');
+}
+
+function cursorPosition() {
+ const before = source.value.slice(0, source.selectionStart);
+ const lines = before.split('\n');
+ text('cursor-position', `${lines.length}행 · ${lines.at(-1).length + 1}열`);
+}
+
+function draftChanged() {
+ // Read scroll position before writes; this path runs only for source edits.
+ const scrollTop = source.scrollTop;
+ const count = source.value.split('\n').length;
+ const size = bytes(source.value);
+ text('line-numbers', Array.from({ length: count }, (_, i) => i + 1).join('\n'));
+ $('line-numbers').scrollTop = scrollTop;
+ text('source-size', `${size.toLocaleString('en-US')} / 32,768 bytes`);
+ $('source-size').style.color = size > MAX_SOURCE_BYTES ? 'var(--danger)' : '';
+ draftStatus();
+ cursorPosition();
+}
+
+function pause() {
+ running = false;
+ previousTime = null;
+ accumulator = 0;
+ text('play', '▶ 실행');
+ pressed($('play'), false);
+}
+
+function safeAction(action) {
+ if (!runtime) return;
+ try { action(); }
+ catch (error) { pause(); setDiagnostic(error.message, 'error', error.line ? { line: error.line, column: error.column || 1 } : null); }
+ refresh();
+}
+
+function releaseEverything() {
+ keyboardSources.clear();
+ pointerSources.clear();
+ if (runtime) safeAction(() => runtime.releaseAll());
+}
+
+function loseFocus() {
+ pause();
+ releaseEverything();
+ refresh();
+}
+
+function refresh() {
+ if (!runtime) return;
+ const snapshot = runtime.present(output);
+ if (snapshot.lastError) pause();
+ const held = new Set((snapshot.held || []).map(input => typeof input === 'string' ? input : input.name));
+ for (const [name, button] of inputButtons) {
+ button.classList.toggle('held', held.has(name));
+ pressed(button, held.has(name));
+ }
+ text('scene-status', runtime.currentProgram ? (running ? '실행 중' : '정지') : '적용 전');
+ text('clock-status', `${snapshot.tick || 0} frame · ${running ? '60 Hz' : '정지'}`);
+ text('revision', `revision ${snapshot.revision || 0}`);
+ for (const id of ['play', 'step', 'reset']) {
+ if ($(id).disabled !== !runtime.currentProgram) $(id).disabled = !runtime.currentProgram;
+ }
+ const event = snapshot.lastEvent;
+ text('last-event', event ? `${event.name}${event.phase && event.phase !== 'frame' ? `.${event.phase}` : ''} · ${event.status ? '실패' : '완료'}${event.line ? ` · ${event.line}행` : ''}` : '아직 없음');
+ if ($('runtime-error').hidden !== !snapshot.lastError) $('runtime-error').hidden = !snapshot.lastError;
+ const displayError = snapshot.lastError === 100 || snapshot.lastError === 101;
+ let message = snapshot.lastError ? `${displayError ? '표시 오류' : '마지막 실패'} · ${snapshot.errorLine ? `${snapshot.errorLine}행 · ` : ''}${snapshot.errorMessage || `실행 오류 ${snapshot.lastError}`}${snapshot.lastError === 101 ? ' — 이번 장면을 표시하지 않았습니다. 상태 변경은 유지됩니다.' : displayError ? ' — 해당 도형을 표시할 수 없습니다.' : ' — 실패한 이벤트의 상태 변경은 취소됐습니다. 코드를 적용하거나 초깃값으로 되돌리면 이 기록을 지웁니다.'}` : '';
+ if (!displayError && snapshot.displayError?.code === 101)
+ message += ` · ${snapshot.displayError.message}`;
+ text('runtime-error', message);
+ output.costs(snapshot, runtime.currentProgram?.metrics, droppedTicks);
+}
+
+function endPointer(event) {
+ const entry = pointerSources.get(event.pointerId);
+ if (!entry) return;
+ event.preventDefault();
+ pointerSources.delete(event.pointerId);
+ safeAction(() => runtime.up(entry.name, entry.source));
+}
+
+function rebuildInputControls() {
+ inputButtons.clear();
+ const children = [];
+ for (const input of runtime.currentProgram?.inputs || []) {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'input-button';
+ button.dataset.input = input.name;
+ button.setAttribute('aria-pressed', 'false');
+ const name = document.createElement('span'); name.textContent = input.name;
+ const key = document.createElement('kbd'); key.textContent = input.key;
+ button.append(name, key);
+ button.addEventListener('pointerdown', event => {
+ if (event.button !== 0) return;
+ event.preventDefault();
+ const identity = `pointer:${event.pointerId}`;
+ pointerSources.set(event.pointerId, { name: input.name, source: identity });
+ button.setPointerCapture(event.pointerId);
+ safeAction(() => runtime.down(input.name, identity));
+ });
+ button.addEventListener('pointerup', endPointer);
+ button.addEventListener('pointercancel', endPointer);
+ button.addEventListener('lostpointercapture', endPointer);
+ // Keyboard activation and assistive technology clicks are complete taps.
+ // Pointer clicks already delivered down/up via the handlers above.
+ button.addEventListener('click', event => {
+ if (event.detail !== 0) return;
+ safeAction(() => { runtime.down(input.name, 'activation'); runtime.up(input.name, 'activation'); });
+ });
+ inputButtons.set(input.name, button);
+ children.push(button);
+ }
+ if (!children.length) {
+ const message = document.createElement('p');
+ message.className = 'empty-note';
+ message.textContent = '입력을 선언하면 여기에 버튼이 생깁니다.';
+ children.push(message);
+ }
+ $('input-controls').replaceChildren(...children);
+}
+
+function isEditing(target) {
+ return target instanceof Element && Boolean(target.closest('textarea,input,select,button,[contenteditable="true"],[role="textbox"]'));
+}
+
+$('scene').addEventListener('pointerdown', () => $('scene').focus());
+
+document.addEventListener('keydown', event => {
+ if (!runtime || event.repeat || event.isComposing || event.ctrlKey || event.metaKey || event.altKey || isEditing(event.target)) return;
+ const input = runtime.currentProgram?.inputs.find(item => item.key === event.code);
+ if (!input) return;
+ event.preventDefault();
+ if (keyboardSources.has(event.code)) return;
+ keyboardSources.set(event.code, input.name);
+ safeAction(() => runtime.down(input.name, `keyboard:${event.code}`));
+});
+
+document.addEventListener('keyup', event => {
+ const name = keyboardSources.get(event.code);
+ if (!name) return;
+ event.preventDefault();
+ keyboardSources.delete(event.code);
+ safeAction(() => runtime.up(name, `keyboard:${event.code}`));
+});
+
+source.addEventListener('input', () => {
+ draftChanged();
+ if (diagnosticLocation) setDiagnostic('코드를 수정했습니다. 적용해서 다시 확인하세요.');
+});
+source.addEventListener('scroll', () => { $('line-numbers').scrollTop = source.scrollTop; });
+for (const name of ['click', 'keyup', 'select']) source.addEventListener(name, cursorPosition);
+source.addEventListener('keydown', event => {
+ if (event.key === 'Tab' && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
+ event.preventDefault();
+ source.setRangeText(' ', source.selectionStart, source.selectionEnd, 'end');
+ draftChanged();
+ }
+});
+
+$('apply').addEventListener('click', () => {
+ if (!runtime) return;
+ try {
+ runtime.apply(source.value);
+ appliedSource = source.value;
+ rebuildInputControls();
+ setDiagnostic('코드를 적용했습니다. 같은 이름의 상태는 유지됩니다.', 'success');
+ } catch (error) {
+ const location = error.line ? { line: error.line, column: error.column || 1 } : null;
+ setDiagnostic(`${location ? `${location.line}행 ${location.column}열 · ` : ''}${error.message} · 기존 장면은 그대로 유지됩니다.`, 'error', location);
+ }
+ draftStatus();
+ refresh();
+});
+
+$('error-location').addEventListener('click', () => {
+ if (!diagnosticLocation) return;
+ const lines = source.value.split('\n');
+ const lineIndex = Math.min(lines.length - 1, diagnosticLocation.line - 1);
+ const start = lines.slice(0, lineIndex).reduce((sum, line) => sum + line.length + 1, 0);
+ const at = start + Math.min(lines[lineIndex].length, diagnosticLocation.column - 1);
+ source.focus();
+ source.setSelectionRange(at, Math.min(at + 1, source.value.length));
+ const lineHeight = parseFloat(getComputedStyle(source).lineHeight);
+ source.scrollTop = Math.max(0, lineIndex * lineHeight - source.clientHeight / 3);
+ $('line-numbers').scrollTop = source.scrollTop;
+ cursorPosition();
+});
+
+$('play').addEventListener('click', () => {
+ if (running) pause();
+ else { running = true; previousTime = null; accumulator = 0; $('play').textContent = 'Ⅱ 정지'; $('play').setAttribute('aria-pressed', 'true'); }
+ refresh();
+});
+$('step').addEventListener('click', () => { pause(); safeAction(() => runtime.frame()); });
+$('reset').addEventListener('click', () => {
+ pause(); releaseEverything(); droppedTicks = 0;
+ safeAction(() => runtime.reset());
+ setDiagnostic('적용된 코드의 초깃값으로 되돌렸습니다. 편집 중인 코드는 유지됩니다.');
+});
+
+function replaceDraft(text, message) {
+ source.value = text;
+ source.scrollTop = 0;
+ source.setSelectionRange(0, 0);
+ setDiagnostic(message);
+ draftChanged();
+}
+
+function mayReplaceDraft() {
+ return !source.value || window.confirm('현재 편집 중인 코드를 바꿀까요? 저장하지 않은 내용은 사라집니다. 실행 장면은 적용 전까지 유지됩니다.');
+}
+
+$('new').addEventListener('click', () => {
+ if (mayReplaceDraft()) replaceDraft('', '편집기만 비웠습니다. 빈 코드를 적용하면 장면도 비워집니다.');
+});
+
+for (const button of document.querySelectorAll('.insert-snippet')) {
+ button.addEventListener('click', () => {
+ const at = source.selectionStart;
+ const prefix = at > 0 && source.value[at - 1] !== '\n' ? '\n' : '';
+ source.setRangeText(prefix + snippets[button.dataset.snippet], source.selectionStart, source.selectionEnd, 'end');
+ source.focus();
+ draftChanged();
+ setDiagnostic('조각을 삽입했습니다. 이름과 동작을 원하는 대로 바꾸고 적용하세요.');
+ });
+}
+
+$('load-example').addEventListener('click', async () => {
+ if (!mayReplaceDraft()) return;
+ const selected = $('example').value;
+ const before = source.value;
+ const button = $('load-example');
+ button.disabled = true;
+ try {
+ const response = await fetch(`./examples/${selected}.pure`);
+ if (!response.ok) throw new Error(`예제를 불러오지 못했습니다 (${response.status}).`);
+ const text = await response.text();
+ if (bytes(text) > MAX_SOURCE_BYTES) throw new Error('예제가 코드 크기 제한을 넘었습니다.');
+ if (source.value !== before) throw new Error('불러오는 동안 코드가 바뀌었습니다. 변경 내용을 지키기 위해 불러오기를 취소했습니다.');
+ replaceDraft(text, '예제를 편집기에 불러왔습니다. 아직 적용하지 않았습니다.');
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+ finally { button.disabled = false; }
+});
+
+function download(filename, content, mime) {
+ const url = URL.createObjectURL(new Blob([content], { type: mime }));
+ const link = document.createElement('a');
+ link.href = url; link.download = filename;
+ document.body.append(link); link.click(); link.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ setDiagnostic('편집 중인 코드를 파일로 저장했습니다. 적용되지 않은 변경도 포함합니다.');
+}
+$('download').addEventListener('click', () => download('my-work.pure', source.value, 'text/plain;charset=utf-8'));
+$('download-project').addEventListener('click', () => download('my-work.pure.json', JSON.stringify({ format: 'pure-workbench', version: 1, source: source.value }, null, 2) + '\n', 'application/json'));
+$('upload').addEventListener('click', () => $('file-input').click());
+$('file-input').addEventListener('change', async event => {
+ const file = event.target.files[0];
+ event.target.value = '';
+ if (!file) return;
+ try {
+ if (file.size > MAX_SOURCE_BYTES * 7 + 1024) throw new Error('파일이 너무 큽니다. 코드 제한은 32 KiB입니다.');
+ const before = source.value;
+ let content = await file.text();
+ if (file.name.toLowerCase().endsWith('.json')) {
+ let project;
+ try { project = JSON.parse(content); } catch { throw new Error('올바른 JSON 파일이 아닙니다.'); }
+ if (!project || typeof project !== 'object' || Array.isArray(project) || project.format !== 'pure-workbench' || project.version !== 1 || typeof project.source !== 'string') throw new Error('Pure 작업물 형식이 아닙니다. format, version, source를 확인하세요.');
+ content = project.source;
+ }
+ if (bytes(content) > MAX_SOURCE_BYTES) throw new Error('코드가 32 KiB 제한을 넘었습니다.');
+ if (source.value !== before) throw new Error('파일을 읽는 동안 코드가 바뀌었습니다. 불러오기를 다시 시도하세요.');
+ if (mayReplaceDraft()) replaceDraft(content, `${file.name}을 편집기에 불러왔습니다. 아직 적용하지 않았습니다.`);
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+});
+
+window.addEventListener('blur', loseFocus);
+document.addEventListener('visibilitychange', () => { if (document.hidden) loseFocus(); });
+window.addEventListener('beforeunload', event => {
+ // Applying is not persistence: even applied code can be lost when leaving.
+ if (source.value || appliedSource) { event.preventDefault(); event.returnValue = ''; }
+});
+
+function animate(time) {
+ if (running && runtime) {
+ if (previousTime !== null) {
+ accumulator += Math.max(0, time - previousTime);
+ const wanted = Math.floor(accumulator / (1000 / 60));
+ if (wanted > 0) {
+ const steps = Math.min(6, wanted);
+ accumulator -= wanted * (1000 / 60);
+ droppedTicks += wanted - steps;
+ safeAction(() => runtime.frame(steps));
+ }
+ }
+ previousTime = running ? time : null;
+ }
+ requestAnimationFrame(animate);
+}
+
+Object.defineProperty(window, 'pureWorkbench', {
+ configurable: false,
+ writable: false,
+ value: Object.freeze({ snapshot: () => structuredClone({ ...(runtime?.snapshot() || {}), ready: Boolean(runtime), source: source.value, appliedSource, dirty: dirty(), running, droppedTicks, diagnostic: $('diagnostic-text').textContent }) }),
+});
+
+draftChanged();
+requestAnimationFrame(animate);
+try {
+ runtime = await createRuntime();
+ $('ready-status').textContent = '실행기 준비됨';
+ $('ready-dot').classList.add('ready');
+ $('apply').disabled = false;
+ refresh();
+} catch (error) {
+ $('ready-status').textContent = '실행기 준비 실패';
+ $('ready-dot').classList.add('failed');
+ setDiagnostic(`실행기를 열지 못했습니다. ${error.message}`, 'error');
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer/before/web/compiler.mjs b/prototypes/workbench/results/2026-09-23-pointer/before/web/compiler.mjs
new file mode 100644
index 0000000..016ea9e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/before/web/compiler.mjs
@@ -0,0 +1,303 @@
+// Pure's experimental scalar interaction language. No host code is evaluated.
+const LIMITS = Object.freeze({ sourceBytes: 32768, states: 32, inputs: 8,
+ views: 24, handlers: 64, instructions: 512, depth: 64, stack: 64 });
+const RESERVED = new Set(['frame', 'dt', 'min', 'max', 'clamp']);
+const BINARY = new Map([
+ ['||', [1, 15]], ['&&', [2, 14]], ['==', [3, 12]], ['!=', [3, 13]],
+ ['<', [4, 8]], ['<=', [4, 9]], ['>', [4, 10]], ['>=', [4, 11]],
+ ['+', [5, 3]], ['-', [5, 4]], ['*', [6, 5]], ['/', [6, 6]],
+]);
+const FUNCTIONS = { min: [2, 17], max: [2, 18], clamp: [3, 19] };
+const VIEW_FIELDS = { box: ['x', 'y', 'width', 'height'],
+ circle: ['x', 'y', 'radius'], text: ['x', 'y', 'value'] };
+
+function fail(token, message) {
+ const error = new Error(`${message} (${token.line}:${token.column})`);
+ error.line = token.line;
+ error.column = token.column;
+ throw error;
+}
+
+function lex(source) {
+ const tokens = [];
+ let offset = 0, line = 1, column = 1;
+ const advance = () => {
+ const char = source[offset++];
+ if (char === '\n') { line++; column = 1; } else column++;
+ return char;
+ };
+ while (offset < source.length) {
+ const char = source[offset];
+ if (char === ' ' || char === '\t' || char === '\r') { advance(); continue; }
+ if (char === '#') { while (offset < source.length && source[offset] !== '\n') advance(); continue; }
+ const token = { line, column };
+ if (char === '\n') { advance(); tokens.push({ ...token, kind: 'nl' }); continue; }
+ if (char === '"') {
+ advance();
+ let value = '', closed = false;
+ while (offset < source.length) {
+ const next = advance();
+ if (next === '"') { closed = true; break; }
+ if (next === '\n' || next === '\r') fail(token, 'Strings must fit on one line');
+ if (next === '\\') {
+ const escaped = advance();
+ const escapes = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' };
+ if (!Object.hasOwn(escapes, escaped)) fail(token, 'Unsupported string escape');
+ value += escapes[escaped];
+ } else value += next;
+ }
+ if (!closed) fail(token, 'Unterminated string');
+ tokens.push({ ...token, kind: 'string', value }); continue;
+ }
+ const remaining = source.slice(offset);
+ const number = remaining.match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/);
+ if (number) {
+ const value = Number(number[0]);
+ if (!Number.isFinite(value)) fail(token, 'Number must be finite');
+ for (let i = 0; i < number[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'number', value }); continue;
+ }
+ const name = remaining.match(/^[A-Za-z_][A-Za-z_0-9]*/);
+ if (name) {
+ for (let i = 0; i < name[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'name', value: name[0] }); continue;
+ }
+ const pair = source.slice(offset, offset + 2);
+ if (['<=', '>=', '==', '!=', '&&', '||'].includes(pair)) {
+ advance(); advance(); tokens.push({ ...token, kind: pair }); continue;
+ }
+ if ('=+-*/!<>(){},.;'.includes(char)) {
+ advance(); tokens.push({ ...token, kind: char }); continue;
+ }
+ fail(token, `Unexpected character ${JSON.stringify(char)}`);
+ }
+ tokens.push({ kind: 'eof', line, column });
+ return tokens;
+}
+
+class Parser {
+ constructor(tokens) { this.tokens = tokens; this.position = 0; this.depth = 0; }
+ peek() { return this.tokens[this.position]; }
+ take() { return this.tokens[this.position++]; }
+ is(kind, value) { return this.peek().kind === kind && (value === undefined || this.peek().value === value); }
+ expect(kind, description = kind) {
+ if (!this.is(kind)) fail(this.peek(), `Expected ${description}`);
+ return this.take();
+ }
+ newlines() { while (this.is('nl')) this.take(); }
+ separators() { while (this.is('nl') || this.is(';')) this.take(); }
+ end() {
+ if (!['nl', ';', '}', 'eof'].includes(this.peek().kind)) fail(this.peek(), 'Expected newline or semicolon');
+ this.separators();
+ }
+ node(token, properties, children = []) {
+ const depth = 1 + Math.max(0, ...children.map(child => child.depth));
+ if (depth > LIMITS.depth) fail(token, 'Expression nesting exceeds 64');
+ return { token, depth, ...properties };
+ }
+ expression(minimum = 0, multiline = false) {
+ if (++this.depth > LIMITS.depth) fail(this.peek(), 'Expression nesting exceeds 64');
+ if (multiline) this.newlines();
+ const token = this.take();
+ let left;
+ if (token.kind === 'number') left = this.node(token, { kind: 'constant', value: token.value });
+ else if (token.kind === '-' || token.kind === '!') {
+ const child = this.expression(7, multiline);
+ left = this.node(token, { kind: 'unary', op: token.kind === '-' ? 7 : 16, child }, [child]);
+ } else if (token.kind === '(') {
+ left = this.expression(0, true); this.newlines(); this.expect(')');
+ } else if (token.kind === 'name') {
+ if (Object.hasOwn(FUNCTIONS, token.value) && this.is('(')) {
+ this.take();
+ const args = [];
+ this.newlines();
+ if (!this.is(')')) {
+ for (;;) {
+ args.push(this.expression(0, true)); this.newlines();
+ if (!this.is(',')) break;
+ this.take();
+ }
+ }
+ this.expect(')');
+ if (args.length !== FUNCTIONS[token.value][0]) fail(token, `${token.value} expects ${FUNCTIONS[token.value][0]} arguments`);
+ left = this.node(token, { kind: 'call', op: FUNCTIONS[token.value][1], args }, args);
+ } else left = this.node(token, { kind: 'reference', name: token.value });
+ } else fail(token, 'Expected a number, state, or expression');
+ for (;;) {
+ if (multiline) this.newlines();
+ const entry = BINARY.get(this.peek().kind);
+ if (!entry || entry[0] < minimum) break;
+ const operator = this.take();
+ const right = this.expression(entry[0] + 1, multiline);
+ left = this.node(operator, { kind: 'binary', op: entry[1], left, right }, [left, right]);
+ }
+ this.depth--;
+ return left;
+ }
+ block(depth = 0) {
+ if (depth >= LIMITS.depth) fail(this.peek(), 'Handler nesting exceeds 64');
+ this.newlines(); this.expect('{'); this.separators();
+ const statements = [];
+ while (!this.is('}')) {
+ const token = this.expect('name', 'assignment, if, or closing brace');
+ if (token.value === 'if' && !this.is('=')) {
+ const condition = this.expression();
+ const body = this.block(depth + 1);
+ statements.push({ kind: 'if', token, condition, body });
+ } else {
+ this.expect('=');
+ statements.push({ kind: 'assign', token, name: token.value, expression: this.expression() });
+ }
+ this.end();
+ }
+ this.take();
+ return statements;
+ }
+ parse() {
+ const declarations = [];
+ this.separators();
+ while (!this.is('eof')) {
+ const token = this.expect('name', 'declaration');
+ const kind = token.value;
+ if (kind === 'state') {
+ const name = this.expect('name', 'state name'); this.expect('=');
+ let sign = 1;
+ if (this.is('-') || this.is('+')) sign = this.take().kind === '-' ? -1 : 1;
+ const value = this.expect('number', 'numeric state initializer');
+ declarations.push({ kind, token, name, initial: sign * value.value });
+ } else if (kind === 'input') {
+ const name = this.expect('name', 'input name'); this.expect('=');
+ const key = this.expect('name', 'keyboard code');
+ if (!/^(?:Space|Enter|Arrow(?:Left|Right|Up|Down)|Key[A-Z]|Digit[0-9])$/.test(key.value)) fail(key, 'Unsupported keyboard code');
+ declarations.push({ kind, token, name, key });
+ } else if (Object.hasOwn(VIEW_FIELDS, kind)) {
+ const name = this.expect('name', 'view ID'); this.expect('(');
+ const expressions = [];
+ for (let i = 0; i < VIEW_FIELDS[kind].length; i++) {
+ if (i) { this.newlines(); this.expect(','); }
+ expressions.push(this.expression(0, true));
+ }
+ this.newlines();
+ let color, prefix = '';
+ if (kind !== 'text') {
+ this.expect(','); this.newlines();
+ const value = this.expect('string', 'literal hex color');
+ if (!/^#[0-9a-fA-F]{6}$/.test(value.value)) fail(value, 'Color must use six hexadecimal digits, such as "#356548"');
+ color = value.value;
+ } else if (this.is(',')) { this.take(); this.newlines(); prefix = this.expect('string', 'literal text prefix').value; }
+ this.newlines(); this.expect(')');
+ declarations.push({ kind: 'view', type: kind, token, name, expressions, color, prefix });
+ } else if (kind === 'on') {
+ const event = this.expect('name', 'input name or frame');
+ let edge;
+ if (event.value !== 'frame') {
+ this.expect('.'); edge = this.expect('name', 'down or up');
+ if (edge.value !== 'down' && edge.value !== 'up') fail(edge, 'Expected down or up');
+ }
+ declarations.push({ kind: 'handler', token, event, edge, body: this.block() });
+ } else fail(token, `Unknown declaration ${kind}`);
+ this.end();
+ }
+ return declarations;
+ }
+}
+
+export function compile(source) {
+ if (typeof source !== 'string') fail({ line: 1, column: 1 }, 'Source must be a string');
+ const sourceBytes = new TextEncoder().encode(source).length;
+ if (sourceBytes > LIMITS.sourceBytes) fail({ line: 1, column: 1 }, 'Source exceeds 32768 UTF-8 bytes');
+ const declarations = new Parser(lex(source)).parse();
+ const program = { states: [], inputs: [], instructions: [], handlers: [], views: [], metrics: {} };
+ const states = new Map(), inputs = new Map(), views = new Set(), keys = new Set();
+ let handlerCount = 0;
+ for (const declaration of declarations) {
+ const { kind, name, token } = declaration;
+ if (name && RESERVED.has(name.value)) fail(name, `Reserved name ${name.value}`);
+ if (kind === 'state' || kind === 'input') {
+ const symbols = kind === 'state' ? states : inputs;
+ if (symbols.has(name.value)) fail(name, `Duplicate ${kind} ${name.value}`);
+ if (symbols.size >= LIMITS[`${kind}s`]) fail(name, `At most ${LIMITS[`${kind}s`]} ${kind}s are allowed`);
+ symbols.set(name.value, symbols.size);
+ if (kind === 'state') program.states.push({ name: name.value, initial: declaration.initial, line: token.line });
+ else {
+ if (keys.has(declaration.key.value)) fail(declaration.key, `Duplicate keyboard code ${declaration.key.value}`);
+ keys.add(declaration.key.value);
+ program.inputs.push({ name: name.value, key: declaration.key.value, line: token.line });
+ }
+ } else if (kind === 'view') {
+ if (views.has(name.value)) fail(name, `Duplicate view ID ${name.value}`);
+ if (views.size >= LIMITS.views) fail(name, 'At most 24 views are allowed');
+ views.add(name.value);
+ } else if (++handlerCount > LIMITS.handlers) fail(token, 'At most 64 handlers are allowed');
+ }
+ let stack = 0, maxStack = 0;
+ const emit = (op, token, arg = 0, value = 0, delta = 0) => {
+ if (program.instructions.length >= LIMITS.instructions) fail(token, 'At most 512 instructions are allowed');
+ stack += delta;
+ if (stack < 0 || stack > LIMITS.stack) fail(token, 'Expression stack exceeds 64');
+ maxStack = Math.max(maxStack, stack);
+ program.instructions.push({ op, arg, value, line: token.line });
+ };
+ const expression = (node, binding = false) => {
+ const { token } = node;
+ if (node.kind === 'constant') emit(0, token, 0, node.value, 1);
+ else if (node.kind === 'reference') {
+ if (node.name === 'dt') {
+ if (binding) fail(token, 'dt is only available in handlers');
+ emit(2, token, 0, 0, 1);
+ } else {
+ if (!states.has(node.name)) fail(token, `Unknown state ${node.name}`);
+ emit(1, token, states.get(node.name), 0, 1);
+ }
+ } else if (node.kind === 'unary') { expression(node.child, binding); emit(node.op, token); }
+ else if (node.kind === 'binary') {
+ expression(node.left, binding); expression(node.right, binding); emit(node.op, token, 0, 0, -1);
+ } else {
+ for (const arg of node.args) expression(arg, binding);
+ emit(node.op, token, 0, 0, 1 - node.args.length);
+ }
+ };
+ const statements = body => {
+ for (const statement of body) {
+ if (statement.kind === 'assign') {
+ if (!states.has(statement.name)) fail(statement.token, `Unknown state ${statement.name}`);
+ expression(statement.expression);
+ emit(20, statement.token, states.get(statement.name), 0, -1);
+ } else {
+ expression(statement.condition);
+ const branch = program.instructions.length;
+ emit(21, statement.token, 0, 0, -1);
+ statements(statement.body);
+ program.instructions[branch].arg = program.instructions.length;
+ }
+ }
+ };
+ for (const declaration of declarations) {
+ if (declaration.kind === 'handler') {
+ let event = 0;
+ if (declaration.event.value !== 'frame') {
+ if (!inputs.has(declaration.event.value)) fail(declaration.event, `Unknown input ${declaration.event.value}`);
+ event = 1 + inputs.get(declaration.event.value) * 2 + (declaration.edge.value === 'up' ? 1 : 0);
+ }
+ const start = program.instructions.length;
+ stack = 0; statements(declaration.body);
+ program.handlers.push({ event, start, length: program.instructions.length - start, line: declaration.token.line });
+ } else if (declaration.kind === 'view') {
+ const view = { id: declaration.name.value, type: declaration.type, line: declaration.token.line, bindings: {} };
+ if (view.type === 'text') view.prefix = declaration.prefix;
+ else view.color = declaration.color;
+ declaration.expressions.forEach((node, index) => {
+ const start = program.instructions.length;
+ stack = 0; expression(node, true);
+ view.bindings[VIEW_FIELDS[view.type][index]] = { start, length: program.instructions.length - start };
+ });
+ program.views.push(view);
+ }
+ }
+ program.metrics = { sourceBytes, sourceLines: source === '' ? 0 : source.split('\n').length,
+ states: states.size, inputs: inputs.size, views: views.size,
+ handlers: program.handlers.length, instructions: program.instructions.length, maxStack };
+ return program;
+}
+
+export { LIMITS };
diff --git a/prototypes/workbench/results/2026-09-23-pointer/before/web/core.wasm b/prototypes/workbench/results/2026-09-23-pointer/before/web/core.wasm
new file mode 100644
index 0000000..0eb3fe5
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/before/web/core.wasm
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-23-pointer/before/web/index.html b/prototypes/workbench/results/2026-09-23-pointer/before/web/index.html
new file mode 100644
index 0000000..d93589e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/before/web/index.html
@@ -0,0 +1,93 @@
+<!doctype html>
+<html lang="ko">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="color-scheme" content="dark">
+ <title>Pure · 빈 작업물에서 시작하기</title>
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23151918'/%3E%3Ctext x='9' y='23' fill='%23c9f08b' font-size='24' font-family='sans-serif'%3Ep%3C/text%3E%3C/svg%3E">
+ <link rel="stylesheet" href="./style.css">
+</head>
+<body>
+ <header class="masthead">
+ <a class="wordmark" href="./" aria-label="Pure 작업 공간">pure<span> / workbench</span></a>
+ <span class="experiment">제작 경험 실험 004</span>
+ <div class="connection"><span id="ready-dot" class="dot"></span><span id="ready-status" role="status">실행기 준비 중</span></div>
+ </header>
+
+ <main>
+ <section class="intro" aria-labelledby="title">
+ <div><p class="eyebrow">빈 코드 · 직접 만든 동작</p><h1 id="title">무엇을 움직이게 할까요?</h1></div>
+ <p>상태를 만들고, 입력에 동작을 연결하세요.<br>코드를 <strong>적용</strong>하면 장면이 바뀝니다. 같은 이름의 상태는 이어집니다.</p>
+ </section>
+
+ <div class="workspace">
+ <section class="editor-panel panel" aria-labelledby="editor-title">
+ <div class="panel-heading"><h2 id="editor-title"><span class="section-index">01</span> 코드</h2><span id="draft-status" class="badge">빈 작업물</span></div>
+ <div class="file-tools" aria-label="파일 관리">
+ <button id="new" class="quiet" type="button">편집기 비우기</button>
+ <button id="upload" class="quiet" type="button">파일 열기</button>
+ <button id="download" class="quiet" type="button">.pure 저장</button>
+ <button id="download-project" class="quiet" type="button">JSON 저장</button>
+ <input id="file-input" type="file" accept=".pure,.txt,.json,text/plain,application/json" hidden>
+ </div>
+ <label class="sr-only" for="source">Pure 코드 편집기</label>
+ <div class="code-editor">
+ <div id="line-numbers" class="line-numbers" aria-hidden="true">1</div>
+ <textarea id="source" spellcheck="false" autocapitalize="off" autocomplete="off" autocorrect="off" aria-describedby="editor-hint diagnostic" placeholder="여기서 시작하세요.&#10;&#10;아래 ‘언어 안내’에서 작은 조각을 가져올 수 있습니다."></textarea>
+ </div>
+ <div class="editor-footer"><span id="source-size">0 / 32,768 bytes</span><span id="cursor-position">1행 · 1열</span></div>
+ <div class="apply-row"><button id="apply" class="primary" type="button" disabled>코드 적용 <span aria-hidden="true">↗</span></button><p id="editor-hint">편집만으로 실행 중인 코드는 바뀌지 않습니다.</p></div>
+ <div id="diagnostic" class="diagnostic" role="status" aria-live="polite"><span id="diagnostic-text">아직 작성한 코드가 없습니다.</span><button id="error-location" type="button" class="quiet" hidden>문제 위치로</button></div>
+ </section>
+
+ <div class="right-column">
+ <section class="scene-panel panel" aria-labelledby="scene-title">
+ <div class="panel-heading"><h2 id="scene-title"><span class="section-index">02</span> 실행 장면</h2><span id="scene-status" class="badge">적용 전</span></div>
+ <div class="stage-wrap">
+ <svg id="scene" viewBox="0 0 640 360" role="img" tabindex="0" aria-labelledby="scene-description"><title id="scene-description">사용자가 코드로 만든 장면. 현재 비어 있습니다.</title><g id="scene-content"></g></svg>
+ <div id="empty-scene" class="empty-scene"><span class="empty-cross" aria-hidden="true">+</span><strong>아직 아무것도 없습니다</strong><span>도형이나 글자를 선언하고 코드를 적용하세요.</span></div>
+ <span class="stage-dimensions" aria-hidden="true">640 × 360</span>
+ </div>
+ <div class="transport"><button id="play" class="secondary" type="button" disabled>▶ 실행</button><button id="step" type="button" disabled>한 프레임</button><button id="reset" type="button" disabled>초깃값으로</button><span id="clock-status">0 frame · 정지</span></div>
+ <p class="control-note">정지하면 시간만 멈춥니다. 아래 입력은 정지 중에도 작동합니다.</p>
+ <div class="input-heading"><h3>내가 선언한 입력</h3><span>눌렀을 때 .down / 놓았을 때 .up</span></div>
+ <div id="input-controls" class="input-controls"><p class="empty-note">입력을 선언하면 여기에 버튼이 생깁니다.</p></div>
+ <p class="keyboard-note">장면을 클릭하면 키보드로 입력할 수 있습니다. 코드나 폼을 편집하는 동안에는 전달되지 않습니다.</p>
+ </section>
+
+ <section class="inspect-panel panel" aria-labelledby="inspect-title">
+ <div class="panel-heading"><h2 id="inspect-title"><span class="section-index">03</span> 현재 상태</h2><span id="revision" class="mono subdued">revision 0</span></div>
+ <div id="state-values" class="state-values"><p class="empty-note">선언한 상태의 값이 표시됩니다.</p></div>
+ <div class="event-line"><span class="subdued">마지막 입력</span><span id="last-event" class="mono">아직 없음</span></div>
+ <div id="runtime-error" class="runtime-error" role="status" hidden></div>
+ <details class="cost-details"><summary>실행 비용과 범위</summary><dl id="cost-values" class="cost-values"></dl><p>코어가 보고한 WebAssembly 메모리입니다. 브라우저·편집기·렌더러의 메모리와 프로세스 전체 사용량은 포함하지 않습니다. 예약 공간 안에 사용 중인 코드·상태·스테이징 공간이 포함되므로 합산하지 않습니다.</p><p id="dropped-ticks">시간 지연으로 건너뛴 프레임: 0</p></details>
+ </section>
+ </div>
+ </div>
+
+ <section class="reference panel" aria-labelledby="reference-title">
+ <div class="panel-heading"><h2 id="reference-title">언어 안내</h2><span class="subdued">조각을 조합해 자신의 동작을 만드세요.</span></div>
+ <div class="reference-grid">
+ <article><h3>1. 기억할 값</h3><p>이름은 영문으로, 처음 값은 숫자로 정합니다.</p><pre><code>state count = 0
+state x = 80</code></pre><button class="insert-snippet quiet" data-snippet="state" type="button">상태 조각 삽입</button></article>
+ <article><h3>2. 입력과 반응</h3><p>버튼 이름과 키를 선언하고 .down / .up에 연결합니다.</p><pre><code>input tap = Space
+on tap.down {
+ count = count + 1
+}</code></pre><button class="insert-snippet quiet" data-snippet="input" type="button">입력 조각 삽입</button></article>
+ <article><h3>3. 보일 것</h3><p>좌표와 크기를 상태에 연결할 수 있습니다.</p><pre><code>box tile(x, 180, 40, 40, "#7ed9ae")
+text label(40, 60, count, "Count: ")</code></pre><button class="insert-snippet quiet" data-snippet="view" type="button">장면 조각 삽입</button></article>
+ <article><h3>4. 시간에 따른 변화</h3><p>실행하면 매 프레임 반응합니다. dt는 1/60초입니다.</p><pre><code>on frame {
+ x = x + 30 * dt
+ if x > 600 { x = 0 }
+}</code></pre><button class="insert-snippet quiet" data-snippet="frame" type="button">시간 조각 삽입</button></article>
+ </div>
+ <details class="syntax-details"><summary>표현식 · 입력 키 · 제한 보기</summary><div class="syntax-columns"><div><p><strong>표현식</strong><br><code>+ - * /</code>, <code>&lt; &lt;= &gt; &gt;= == !=</code>, <code>&amp;&amp; || !</code>, 괄호, <code>min(a,b)</code>, <code>max(a,b)</code>, <code>clamp(x,lo,hi)</code>를 사용합니다. 논리 연산은 양쪽을 모두 계산합니다.</p><p><strong>도형</strong><br><code>box id(x,y,width,height,"#rrggbb")</code><br><code>circle id(x,y,radius,"#rrggbb")</code><br><code>text id(x,y,value,"접두사")</code><br>글자의 value는 숫자 표현식입니다. 문자 접두사는 생략할 수 있습니다.</p></div><div><p><strong>입력</strong><br><code>Space</code>, <code>Enter</code>, <code>ArrowLeft/Right/Up/Down</code>, <code>KeyA</code>~<code>KeyZ</code>, <code>Digit0</code>~<code>Digit9</code>. 같은 키는 하나의 입력에 연결합니다.</p><p><strong>이 실험의 범위</strong><br>상태 32개 · 입력 8개 · 도형/글자 24개 · 반응 64개 · 컴파일된 명령 512개 · 코드 32 KiB. 반복문, 함수 선언, 자동 충돌·물리는 없습니다. 줄바꿈 또는 세미콜론으로 구분하고 <code>#</code>으로 주석을 씁니다.</p><p>0으로 나누기 등 계산 실패 시 해당 이벤트의 상태 변경 전체를 취소합니다. 입력을 누르고 있을 때는 새 코드를 적용할 수 없습니다.</p></div></div></details>
+ <div class="example-row"><div><label for="example">완성된 예제 살펴보기</label><p>선택한 코드를 편집기에 불러옵니다. 자동으로 적용되지 않습니다.</p></div><select id="example"><option value="counter">카운터</option><option value="charge">누르는 동안 충전</option><option value="toggle">입력으로 전환</option></select><button id="load-example" type="button">예제 불러오기</button></div>
+ <div class="baseline-row"><a href="./baseline.html" target="_blank" rel="noopener">같은 동작을 일반 JavaScript로 만든 비교 앱 ↗</a><span>충전 예제와 비교할 수 있습니다. 새 탭에서 열립니다.</span></div>
+ </section>
+ <footer class="page-footer"><span>Pure · authoring experiment</span><p>원하는 동작을 만들어보고, <strong>하려던 일 / 실제로 일어난 일 / 기대했던 일</strong>을 남겨주세요.</p></footer>
+ </main>
+ <script type="module" src="./app.js"></script>
+</body>
+</html>
diff --git a/prototypes/workbench/results/2026-09-23-pointer/before/web/output.mjs b/prototypes/workbench/results/2026-09-23-pointer/before/web/output.mjs
new file mode 100644
index 0000000..53cae48
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/before/web/output.mjs
@@ -0,0 +1,92 @@
+// Bind authored declarations to concrete DOM destinations once per apply.
+// The DOM holds the published values; no parallel scene/value model is kept.
+const NS = 'http://www.w3.org/2000/svg';
+const number = value => Number.isInteger(value) ? String(value) : Number(value.toPrecision(7)).toString();
+const attributes = { box: { x: 'x', y: 'y', width: 'width', height: 'height' },
+ circle: { x: 'cx', y: 'cy', radius: 'r' }, text: { x: 'x', y: 'y' } };
+
+function text(node, value) {
+ if (node.textContent !== value) node.textContent = value;
+}
+function attribute(node, name, value) {
+ const next = String(value);
+ if (node.getAttribute(name) !== next) node.setAttribute(name, next);
+}
+
+export function createOutput(document) {
+ const get = id => document.getElementById(id);
+ const scene = get('scene-content'), states = get('state-values');
+ const empty = get('empty-scene'), description = get('scene-description');
+ let active = undefined, nodes = [], amounts = [];
+ const costNames = ['Wasm 선형 메모리', '코어 예약 공간', '사용 중인 명령·핸들러',
+ '현재 상태 값', '새 코드 준비 공간 (예약에 포함)', '평가 스택 용량',
+ '마지막 이벤트 실행 명령', '적용된 소스 크기'];
+ const costs = costNames.map(label => {
+ const dt = document.createElement('dt'), dd = document.createElement('dd');
+ dt.textContent = label;
+ return { dt, dd };
+ });
+ get('cost-values').replaceChildren(...costs.flatMap(({dt, dd}) => [dt, dd]));
+
+ return {
+ begin(program) {
+ if (program === active) return;
+ active = program;
+ nodes = (program?.views ?? []).map(view => {
+ const node = document.createElementNS(NS, {box:'rect', circle:'circle', text:'text'}[view.type]);
+ node.setAttribute('data-view-id', view.id);
+ node.setAttribute('fill', view.color ?? '#e7ece6');
+ if (view.type === 'text') {
+ node.setAttribute('font-family', 'ui-monospace, monospace');
+ node.setAttribute('font-size', '20');
+ }
+ return node;
+ });
+ scene.replaceChildren(...nodes);
+ const holders = [];
+ amounts = (program?.states ?? []).map(state => {
+ const dl = document.createElement('dl'), dt = document.createElement('dt'), dd = document.createElement('dd');
+ dl.className = 'state-value'; dt.textContent = state.name; dd.dataset.state = state.name;
+ dl.append(dt, dd); holders.push(dl);
+ return dd;
+ });
+ if (!holders.length) {
+ const note = document.createElement('p');
+ note.className = 'empty-note'; note.textContent = '선언한 상태의 값이 표시됩니다.';
+ holders.push(note);
+ }
+ states.replaceChildren(...holders);
+ },
+ state(index, value) { text(amounts[index], number(value)); },
+ view(index, valid) {
+ const node = nodes[index];
+ if (valid) { if (node.hasAttribute('display')) node.removeAttribute('display'); }
+ else attribute(node, 'display', 'none');
+ },
+ value(index, name, value) {
+ const view = active.views[index], node = nodes[index];
+ if (name === 'value') text(node, `${view.prefix || ''}${number(value)}`);
+ else attribute(node, attributes[view.type][name], value);
+ },
+ end(visible) {
+ const hidden = visible !== 0;
+ if (empty.hidden !== hidden) empty.hidden = hidden;
+ const failed = !visible && nodes.length > 0;
+ text(empty.querySelector('strong'), failed ? '장면을 표시할 수 없습니다' : '아직 아무것도 없습니다');
+ text(empty.querySelector('span:last-child'), failed ? '현재 상태 아래의 표시 오류를 확인하세요.' : '도형이나 글자를 선언하고 코드를 적용하세요.');
+ text(description, visible ? `사용자가 만든 장면. 도형과 글자 ${visible}개.` : failed ? '표시 오류로 장면을 표시할 수 없습니다.' : '사용자가 코드로 만든 장면. 현재 비어 있습니다.');
+ },
+ costs(status, metrics, droppedTicks) {
+ const m = status.memory;
+ const values = [m.linearBytes, m.reservedEngineBytes, m.activeIRBytes, m.activeStateBytes,
+ m.stagingReservedBytes, m.evalStackBytes, status.lastOps, metrics?.sourceBytes];
+ for (let i = 0; i < values.length; i++) {
+ const value = values[i], {dt, dd} = costs[i], hidden = !Number.isFinite(value);
+ if (dt.hidden !== hidden) dt.hidden = hidden;
+ if (dd.hidden !== hidden) dd.hidden = hidden;
+ if (!hidden) text(dd, i === 6 ? `${value}개` : `${value.toLocaleString('en-US')} B`);
+ }
+ text(get('dropped-ticks'), `시간 지연으로 건너뛴 프레임: ${droppedTicks}`);
+ },
+ };
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer/before/web/runtime.mjs b/prototypes/workbench/results/2026-09-23-pointer/before/web/runtime.mjs
new file mode 100644
index 0000000..96634b8
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/before/web/runtime.mjs
@@ -0,0 +1,267 @@
+import { compile } from './compiler.mjs';
+
+const messages = [
+ '', '실행기의 용량 한도를 넘었습니다.', '실행 정의가 유효하지 않습니다.',
+ '계산 스택의 범위를 벗어났습니다.', '0으로 나눌 수 없습니다.',
+ '계산 결과가 유한한 수의 범위를 벗어났습니다.', '조건 분기 범위가 유효하지 않습니다.',
+ '존재하지 않는 상태를 참조했습니다.', '시간 간격이 유효하지 않습니다.',
+ '정의되지 않은 입력 사건입니다.',
+];
+const sourceLimit = 32768;
+
+function problem(message, line = null, column = null) {
+ return Object.assign(new Error(message), { line, column });
+}
+
+function freezeProgram(value) {
+ if (value && typeof value === 'object') {
+ for (const child of Object.values(value)) freezeProgram(child);
+ Object.freeze(value);
+ }
+ return value;
+}
+
+export async function createRuntime(wasmBytes, evaluationOptions = {}) {
+ if (wasmBytes === undefined) {
+ const response = await fetch(new URL('./core.wasm', import.meta.url), { cache: 'no-store' });
+ if (!response.ok) throw new Error(`실행기를 불러오지 못했습니다 (HTTP ${response.status}).`);
+ wasmBytes = await response.arrayBuffer();
+ }
+ const module = await WebAssembly.compile(wasmBytes);
+ if (WebAssembly.Module.imports(module).length) throw new Error('이 실행기는 호스트 함수 없이 동작해야 합니다.');
+ const { exports: core } = await WebAssembly.instantiate(module, {});
+ if (typeof core.eval_bounded !== 'function' || typeof core.abort_program !== 'function')
+ throw new Error('실행기 버전이 맞지 않습니다. 새로고침하거나 core.wasm을 다시 빌드하세요.');
+ core.reset_all();
+ let program = null;
+ let appliedSource = '';
+ let lastEvent = null;
+ // Keep a failed event visible through its release/focus-loss cleanup.
+ // The active program can clear this diagnostic by apply or explicit reset.
+ let lastFailure = null;
+ const held = new Map();
+ let presenting = false;
+ let bindings = [];
+ // One bounded publication buffer, never exposed to callers. No state mirror.
+ let displayValues = null, displayValid = null;
+ function available() {
+ if (presenting) throw new Error('출력 전달 중에는 실행기를 다시 호출할 수 없습니다.');
+ }
+ let evaluationPolicy = { cacheEntries: 0, historyEntries: 8, workLimit: 2048 };
+
+ function checked(code, line = null) {
+ if (code !== 0) throw problem(messages[code] || `실행기 오류 ${code}`, line);
+ }
+ function inputIndex(name) {
+ const index = program?.inputs.findIndex((input) => input.name === name) ?? -1;
+ if (index < 0) throw new Error(`정의되지 않은 입력: ${name}`);
+ return index;
+ }
+ function dispatch(event, name, phase, source) {
+ const status = core.dispatch(event, event === 0 ? 1 / 60 : 0);
+ const pc = core.info(2) >>> 0;
+ lastEvent = { name, phase, source, status,
+ line: program?.instructions[pc]?.line ?? null, ops: core.info(3) >>> 0 };
+ if (status) lastFailure = { ...lastEvent };
+ return status;
+ }
+
+ function evaluateDisplay() {
+ displayValues ??= new Float64Array(96);
+ displayValid ??= new Uint8Array(24);
+ core.begin_evaluation(evaluationPolicy.workLimit);
+ let error = null, slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ displayValid[i] = 1;
+ for (const [name, binding] of bindings[i]) {
+ const value = core.eval_bounded(binding.start, binding.length);
+ if (core.evaluation_info(0) === 10) {
+ displayValid.fill(0);
+ return { code: 101, line: view.line,
+ message: '표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.' };
+ }
+ if (!Number.isFinite(value) || ((name === 'width' || name === 'height' || name === 'radius') && value < 0)) {
+ error ??= { line: view.line, message: `${view.id}.${name}: 표시할 수 없는 값입니다. 0 나눗셈이나 음수 크기를 확인하세요.` };
+ displayValid[i] = 0;
+ }
+ displayValues[slot++] = value;
+ }
+ }
+ return error;
+ }
+
+ function status(viewError) {
+ const executionError = lastFailure?.status || 0;
+ return {
+ tick: core.info(0) >>> 0, revision: core.info(4) >>> 0,
+ displayError: viewError ? { code: viewError.code || 100, ...viewError } : null,
+ lastError: executionError || (viewError ? viewError.code || 100 : 0),
+ errorMessage: executionError ? messages[executionError] || `실행 오류 ${executionError}` : viewError?.message || '',
+ errorLine: executionError ? lastFailure.line : viewError?.line ?? null,
+ lastOps: core.info(3) >>> 0, successfulEvents: core.info(9) >>> 0,
+ held: [...held.keys()], lastEvent: lastEvent ? { ...lastEvent } : null,
+ lastFailure: lastFailure ? { ...lastFailure } : null,
+ memory: {
+ linearBytes: core.memory.buffer.byteLength,
+ reservedEngineBytes: core.layout(0), programReservedBytes: core.layout(1),
+ instructionBytes: core.layout(2), handlerBytes: core.layout(3),
+ activeIRBytes: core.layout(4), activeStateBytes: core.layout(5),
+ stagingReservedBytes: core.layout(6), evalStackBytes: core.layout(7),
+ },
+ };
+ }
+
+ const api = {
+ get currentProgram() { return program; },
+ configureEvaluation({ cacheEntries = evaluationPolicy.cacheEntries,
+ historyEntries = evaluationPolicy.historyEntries, workLimit = evaluationPolicy.workLimit } = {}) {
+ available();
+ for (const [name, value, maximum] of [['cacheEntries', cacheEntries, 32],
+ ['historyEntries', historyEntries, 8], ['workLimit', workLimit, 0xffffffff]]) {
+ if (!Number.isInteger(value) || value < 0 || value > maximum)
+ throw new Error(`${name}: 0~${maximum} 사이의 정수여야 합니다.`);
+ }
+ checked(core.configure_evaluation(cacheEntries, historyEntries));
+ evaluationPolicy = { cacheEntries, historyEntries, workLimit };
+ return { ...evaluationPolicy };
+ },
+ forgetEvaluation(part = 'all') {
+ available();
+ const flags = part === 'all' ? 3 : part === 'values' ? 1 : part === 'history' ? 2 : undefined;
+ if (flags === undefined) throw new Error('삭제할 기록은 values, history, all 중 하나여야 합니다.');
+ core.forget_evaluation(flags);
+ },
+ evaluationStats() {
+ return { ...evaluationPolicy, work: core.evaluation_info(1),
+ instructions: core.evaluation_info(2), cacheHits: core.evaluation_info(3),
+ reservedBytes: core.evaluation_info(6), dedicatedBytes: core.evaluation_info(7),
+ sharedBytes: core.evaluation_info(8), reuseAvailable: Boolean(core.evaluation_info(9)),
+ workspaceTagBytes: core.evaluation_info(10) };
+ },
+ apply(source) {
+ available();
+ if (typeof source !== 'string' || new TextEncoder().encode(source).length > sourceLimit) {
+ throw problem('정의는 UTF-8 기준 32,768바이트 이하의 텍스트여야 합니다.');
+ }
+ if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 적용하세요. 현재 작업은 그대로 유지합니다.');
+ const next = freezeProgram(compile(source));
+ const oldIds = new Map(program?.states.map((state, index) => [state.name, index]) ?? []);
+ checked(core.begin_program(next.states.length, next.inputs.length));
+ try {
+ for (let index = 0; index < next.states.length; index++) {
+ const state = next.states[index];
+ checked(core.put_state(index, state.initial, oldIds.get(state.name) ?? -1), state.line);
+ }
+ for (const op of next.instructions) checked(core.put_instruction(op.op, op.arg, op.value), op.line);
+ for (const handler of next.handlers) checked(core.put_handler(handler.event, handler.start, handler.length), handler.line);
+ checked(core.commit_program());
+ } catch (error) {
+ core.abort_program();
+ throw error;
+ }
+ program = next;
+ bindings = next.views.map(view => Object.entries(view.bindings));
+ appliedSource = source;
+ lastEvent = null;
+ lastFailure = null;
+ return next;
+ },
+ reset() {
+ available();
+ held.clear();
+ core.reset_state();
+ lastEvent = null;
+ lastFailure = null;
+ },
+ frame(count = 1) {
+ available();
+ if (!Number.isInteger(count) || count < 0 || count > 600) throw new Error('프레임 수는 0~600 사이의 정수여야 합니다.');
+ if (!program) return 0;
+ for (let i = 0; i < count; i++) {
+ const code = dispatch(0, 'frame', 'frame', 'clock');
+ if (code !== 0) return code;
+ }
+ return 0;
+ },
+ down(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ const sources = held.get(name) || new Set();
+ if (sources.has(source)) return 0;
+ const wasDown = sources.size > 0;
+ sources.add(source);
+ held.set(name, sources);
+ return wasDown ? 0 : dispatch(1 + index * 2, name, 'down', source);
+ },
+ up(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ const sources = held.get(name);
+ if (!sources?.delete(source)) return 0;
+ if (sources.size) return 0;
+ held.delete(name);
+ return dispatch(2 + index * 2, name, 'up', source);
+ },
+ releaseAll() {
+ available();
+ let firstError = 0;
+ for (const [name, sources] of [...held]) {
+ for (const source of [...sources]) {
+ const status = api.up(name, source);
+ if (!firstError) firstError = status;
+ }
+ }
+ return firstError;
+ },
+ exportProject() {
+ return { format: 'pure-workbench', version: 1, source: appliedSource };
+ },
+ // Synchronous trusted output adapter. Callbacks run only after the whole
+ // display has been evaluated. Runtime mutation/reentrant reads are refused.
+ // Exceptions from an adapter propagate; the next call republishes all values.
+ present(target) {
+ available();
+ presenting = true;
+ try {
+ const { begin, state, view, value, end } = target;
+ for (const callback of [begin, state, view, value, end])
+ if (typeof callback !== 'function') throw new TypeError('출력 어댑터 함수가 필요합니다.');
+ const error = evaluateDisplay();
+ begin.call(target, program);
+ for (let i = 0; i < (program?.states.length ?? 0); i++) state.call(target, i, core.get_state(i));
+ let slot = 0, visible = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const valid = Boolean(displayValid[i]);
+ view.call(target, i, valid);
+ for (const [name] of bindings[i]) {
+ if (valid) value.call(target, i, name, displayValues[slot]);
+ slot++;
+ }
+ if (valid) visible++;
+ }
+ end.call(target, visible);
+ return status(error);
+ } finally { presenting = false; }
+ },
+ snapshot() {
+ available();
+ const error = evaluateDisplay();
+ const states = Object.fromEntries((program?.states ?? []).map((state, index) => [state.name, core.get_state(index)]));
+ const views = [];
+ let slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ const rendered = displayValid[i] ? { id: view.id, type: view.type, color: view.color, prefix: view.prefix } : null;
+ for (const [name] of bindings[i]) {
+ if (rendered) rendered[name] = displayValues[slot];
+ slot++;
+ }
+ if (rendered) views.push(rendered);
+ }
+ return { states, views, ...status(error) };
+ },
+ };
+ api.configureEvaluation(evaluationOptions);
+ return Object.freeze(api);
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer/before/web/style.css b/prototypes/workbench/results/2026-09-23-pointer/before/web/style.css
new file mode 100644
index 0000000..819c84c
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/before/web/style.css
@@ -0,0 +1,8 @@
+:root{color-scheme:dark;--bg:#151918;--panel:#1d2220;--field:#151a18;--line:#353e38;--text:#e7ece6;--muted:#a9b6aa;--faint:#7b8e7e;--accent:#c9f08b;--accent-dark:#222e18;--danger:#f6afa0;--radius:14px;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-synthesis:none}
+*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text)}button,input,select,textarea{font:inherit}button,select{border:1px solid var(--line);border-radius:7px;background:#282f2a;color:var(--text);padding:9px 12px;cursor:pointer;font-size:12px;font-weight:600;min-height:36px}button:hover:not(:disabled){background:#344035;border-color:#70836d}button:disabled{opacity:.4;cursor:not-allowed}button:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px}button.primary{background:var(--accent);color:var(--accent-dark);border-color:var(--accent);padding:11px 19px;font-size:14px;display:flex;gap:28px;justify-content:space-between;align-items:center}button.primary:hover:not(:disabled){background:#d8f7ad}button.secondary{background:#c9f08b16;border-color:#637545;color:#d7f0b5}button.quiet{background:transparent;border-color:transparent;color:var(--muted);padding:6px 8px;min-height:30px}button.quiet:hover{color:var(--text)}.masthead{height:76px;padding:0 4.5%;display:flex;align-items:center;gap:28px;border-bottom:1px solid var(--line)}.wordmark{font-size:23px;font-weight:750;letter-spacing:-1px;color:var(--accent);text-decoration:none}.wordmark span{font-size:13px;color:var(--muted);letter-spacing:0;font-weight:400}.experiment{font-size:11px;color:var(--faint);border-left:1px solid var(--line);padding-left:24px}.connection{margin-left:auto;display:flex;gap:9px;align-items:center;font-size:11px;color:var(--muted)}.dot{width:6px;height:6px;border-radius:100%;background:#aa8e5c}.dot.ready{background:var(--accent)}.dot.failed{background:var(--danger)}main{max-width:1540px;margin:auto;padding:38px 4.5% 0}.intro{display:flex;justify-content:space-between;align-items:flex-end;gap:24px;margin:0 0 27px}.eyebrow{font-size:10px;letter-spacing:1.4px;font-weight:600;color:var(--accent);margin:0 0 10px}h1{font-size:clamp(23px,2.5vw,35px);letter-spacing:-1px;margin:0;font-weight:600}.intro>p{font-size:12px;line-height:1.9;color:var(--muted);margin:0 0 2px}.intro strong{color:var(--text);font-weight:500}.workspace{display:grid;grid-template-columns:minmax(340px,1fr) minmax(380px,1.05fr);gap:22px;align-items:start}.panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}.panel-heading{height:56px;padding:0 20px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-bottom:1px solid var(--line)}h2{margin:0;font-size:13px;font-weight:650;display:flex;gap:10px;align-items:center}.section-index{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px;font-weight:400;color:var(--faint)}.badge{font-size:10px;border:1px solid var(--line);border-radius:4px;padding:4px 7px;white-space:nowrap;color:var(--muted)}.badge.dirty{color:var(--accent);border-color:#5f7247}.file-tools{display:flex;align-items:center;gap:3px;padding:8px 10px;border-bottom:1px solid var(--line);flex-wrap:wrap}.code-editor{height:514px;display:flex;overflow:hidden;background:var(--field);position:relative}.line-numbers{padding:20px 10px 20px 16px;min-width:45px;text-align:right;color:#667567;user-select:none;white-space:pre;font:12px/1.85 ui-monospace,SFMono-Regular,Consolas,monospace;overflow:hidden;flex-shrink:0;border-right:1px solid #2a322b}textarea#source{border:0;border-radius:0;outline:0;resize:none;padding:20px 16px;display:block;width:100%;height:100%;color:#d4e3ca;background:transparent;white-space:pre;overflow:auto;tab-size:2;font:12px/1.85 ui-monospace,SFMono-Regular,Consolas,monospace;caret-color:var(--accent)}textarea#source:focus{background:#171e19}textarea#source::placeholder{color:#7c8b7d;font:12px/1.9 ui-sans-serif,system-ui,sans-serif;white-space:pre-wrap}textarea#source[aria-invalid=true]{box-shadow:inset 3px 0 0 var(--danger)}.editor-footer{padding:8px 15px;font-size:10px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--faint);display:flex;justify-content:space-between;border-top:1px solid var(--line)}.apply-row{display:flex;gap:18px;align-items:center;padding:16px 18px 12px}.apply-row p{font-size:10px;color:var(--muted);line-height:1.7;margin:0;max-width:185px}.diagnostic{margin:0 18px 17px;font-size:11px;line-height:1.8;min-height:22px;color:var(--muted);display:flex;align-items:baseline;gap:6px;flex-wrap:wrap}.diagnostic.error{color:var(--danger)}.diagnostic.success{color:#c7dea9}.right-column{display:grid;gap:18px}.stage-wrap{position:relative;background-color:#121613;background-image:linear-gradient(#a6c39907 1px,transparent 1px),linear-gradient(90deg,#a6c39907 1px,transparent 1px);background-size:24px 24px}#scene{width:100%;height:auto;aspect-ratio:640/360;display:block;overflow:hidden}.empty-scene{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none;gap:9px;color:var(--faint);padding:24px;text-align:center}.empty-scene[hidden]{display:none}.empty-scene strong{font-size:13px;color:var(--muted);font-weight:400}.empty-scene>span:not(.empty-cross){font-size:11px}.empty-cross{font-size:31px;color:#52614d;font-weight:200;margin-bottom:7px}.stage-dimensions{position:absolute;right:14px;bottom:10px;font-size:9px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;color:#6d7f6d;pointer-events:none}.transport{display:flex;gap:7px;align-items:center;padding:14px 16px 0;flex-wrap:wrap}.transport button{font-size:11px;padding:7px 10px}.transport #clock-status{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px;color:var(--muted);margin-left:auto}.control-note,.keyboard-note{font-size:10px;line-height:1.7;color:var(--faint);margin:10px 18px 15px}.input-heading{display:flex;justify-content:space-between;align-items:center;gap:10px;margin:0 18px;padding-top:13px;border-top:1px solid var(--line)}h3{font-size:11px;font-weight:550;margin:0}.input-heading>span{font-size:9px;color:var(--faint)}.input-controls{display:flex;flex-wrap:wrap;gap:7px;padding:11px 17px 0;min-height:39px}.input-button{display:flex;align-items:center;gap:14px;touch-action:none;user-select:none;-webkit-user-select:none}.input-button kbd{font:9px ui-monospace,SFMono-Regular,Consolas,monospace;border:1px solid #5a6957;border-radius:3px;padding:3px 4px;color:var(--muted);min-width:22px;text-align:center}.input-button.held{background:var(--accent);border-color:var(--accent);color:var(--accent-dark)}.input-button.held kbd{color:var(--accent-dark);border-color:#829a62}.empty-note{font-size:11px;line-height:1.7;color:var(--faint);margin:0}.keyboard-note{margin-bottom:13px;font-size:9px}.state-values{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));padding:15px 19px;gap:13px;max-height:210px;overflow:auto}.state-value{display:flex;flex-direction:column;gap:6px;min-width:0}.state-value dt{font:10px ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--muted);overflow-wrap:anywhere}.state-value dd{font:17px ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--accent);margin:0;overflow-wrap:anywhere}.state-value dl{margin:0}.mono{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px}.subdued{color:var(--muted);font-size:10px}.event-line{padding:0 19px 13px;display:flex;gap:16px;align-items:baseline;flex-wrap:wrap}.event-line #last-event{color:var(--muted);font-size:10px;overflow-wrap:anywhere}.runtime-error{margin:0 18px 14px;padding:10px;border:1px solid #705049;border-radius:5px;background:#432c272e;color:var(--danger);font-size:11px;line-height:1.6}.cost-details{border-top:1px solid var(--line);padding:12px 19px;font-size:10px;color:var(--muted)}summary{cursor:pointer;font-size:11px;color:var(--muted)}.cost-details p{font-size:10px;line-height:1.8;color:var(--faint)}.cost-values{display:grid;grid-template-columns:1fr auto;gap:8px 20px;margin:16px 0}.cost-values dt,.cost-values dd{margin:0}.cost-values dd{text-align:right;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--text)}.reference{margin-top:25px}.reference-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));padding:22px 20px;gap:24px}.reference-grid article{min-width:0;display:flex;flex-direction:column;align-items:flex-start}.reference-grid h3{font-size:12px;color:#d2ddc8}.reference-grid p{font-size:10px;line-height:1.8;color:var(--muted);min-height:36px;margin:8px 0 10px}.reference-grid pre{font:10px/1.85 ui-monospace,SFMono-Regular,Consolas,monospace;background:#151b16;color:#bacead;width:100%;padding:10px;overflow:auto;border-radius:6px;min-height:115px;margin:0 0 8px}.reference-grid .quiet{font-size:10px;padding:5px 0}.syntax-details{padding:16px 20px;border-top:1px solid var(--line)}.syntax-columns{display:grid;grid-template-columns:1fr 1fr;gap:35px;margin-top:12px}.syntax-columns p{font-size:11px;line-height:1.95;color:var(--muted)}.syntax-columns strong{color:var(--text);font-weight:500}.syntax-columns code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px;color:#d0dcbe;overflow-wrap:anywhere}.example-row{padding:18px 20px;border-top:1px solid var(--line);display:flex;align-items:center;gap:10px}.example-row>div{margin-right:auto}.example-row label{font-size:11px;color:var(--text)}.example-row p{font-size:10px;color:var(--faint);margin:5px 0 0}.example-row button,.example-row select{font-size:10px}.page-footer{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:26px 0 30px;color:var(--faint);font-size:10px}.page-footer>span{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:9px}.page-footer p{margin:0;line-height:1.8}.page-footer strong{font-weight:400;color:var(--muted)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}[hidden]{display:none!important}
+.baseline-row{border-top:1px solid var(--line);padding:15px 20px;display:flex;flex-wrap:wrap;gap:8px 16px;align-items:center}.baseline-row a{font-size:11px;color:var(--accent);text-underline-offset:3px}.baseline-row span{font-size:10px;color:var(--faint)}#scene:focus-visible{outline:2px solid var(--accent);outline-offset:-3px}
+@media(min-width:1450px){.code-editor{height:558px}.reference-grid pre{font-size:11px}}
+@media(max-width:1050px){.masthead{padding:0 25px}.experiment{display:none}main{padding:29px 25px 0}.workspace{grid-template-columns:minmax(300px,1fr) minmax(340px,1fr);gap:15px}.intro>p{font-size:11px;max-width:290px}.reference-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:25px}.reference-grid p{min-height:0}.reference-grid pre{min-height:110px;font-size:11px}.transport #clock-status{width:100%;margin:3px 0 0}.code-editor{height:533px}.panel-heading{padding:0 16px}.apply-row{gap:10px;padding-left:14px;padding-right:14px}.apply-row p{font-size:9px}.input-heading{align-items:flex-start;flex-direction:column;gap:5px}.intro{gap:20px}button.primary{gap:14px;padding:11px 13px}}
+@media(max-width:760px){.masthead{height:63px;padding:0 20px}.wordmark{font-size:21px}.wordmark span{font-size:11px}.connection{font-size:10px}main{padding:25px 18px 0}.intro{display:block;margin-bottom:22px}.intro h1{font-size:28px}.intro>p{margin-top:14px;max-width:none;font-size:11px}.workspace{grid-template-columns:minmax(0,1fr);gap:17px}.code-editor{height:340px}.editor-panel{width:100%}.transport #clock-status{width:auto;margin-left:auto}.right-column{gap:17px}.input-heading{flex-direction:row;align-items:center}.reference{margin-top:20px}.reference-grid{padding:20px 16px;gap:24px 17px}.reference-grid pre{font-size:10px}.reference>.panel-heading{height:auto;min-height:57px;padding-top:12px;padding-bottom:12px;align-items:flex-start;flex-direction:column;gap:6px}.syntax-columns{grid-template-columns:1fr;gap:0}.example-row{flex-wrap:wrap;padding:17px 16px}.example-row>div{width:100%;margin-bottom:4px}.example-row select{flex:1}.page-footer{align-items:flex-start;flex-direction:column;gap:10px;padding:23px 0}.apply-row{padding:14px 16px 10px;gap:17px}.apply-row p{font-size:10px;max-width:none}.diagnostic{margin-left:16px}.reference-grid article{overflow:hidden}}
+@media(max-width:420px){main{padding-left:13px;padding-right:13px}.masthead{padding:0 15px}.wordmark span{font-size:10px}.connection{font-size:9px;gap:6px}.intro h1{font-size:25px}.transport{gap:5px;padding-left:12px;padding-right:12px}.transport button{font-size:10px;padding:7px 8px}.transport #clock-status{font-size:9px}.input-heading>span{font-size:8px}.reference-grid{grid-template-columns:minmax(0,1fr)}.reference-grid pre{min-height:0;font-size:11px}.reference-grid p{margin-bottom:8px}.file-tools{gap:0;padding:7px 5px}.file-tools .quiet{font-size:10px;padding:6px 7px}.apply-row p{font-size:9px}.line-numbers{padding-left:10px;min-width:36px}textarea#source{padding-left:12px;font-size:11px}.line-numbers{font-size:11px}}
+@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
diff --git a/prototypes/workbench/results/2026-09-23-pointer/build.json b/prototypes/workbench/results/2026-09-23-pointer/build.json
new file mode 100644
index 0000000..5cddc11
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/build.json
@@ -0,0 +1,23 @@
+{
+ "builtUtc": "2026-09-23T06:14:20.758373+00:00",
+ "zigVersion": "0.15.2",
+ "sourceSha256": "e253ccf62c10386f70edb8c84e299eb0b5eb59f8d35772150c37b3035e9047d4",
+ "wasmSha256": "5e733dbbb87bfc7f5b0c7714f176d5655cca4f617d11b92b07e638ae320474a6",
+ "evaluationSourceSha256": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "wasmFileBytes": 54511,
+ "flags": [
+ "-target",
+ "wasm32-freestanding",
+ "-O",
+ "ReleaseSafe",
+ "-fstrip",
+ "-fno-entry",
+ "-rdynamic",
+ "--export-memory",
+ "--stack",
+ "16384",
+ "--initial-memory=65536",
+ "--max-memory=65536"
+ ],
+ "nativeTests": "1/26 core.test.blank program, no hidden application behavior, and reset...OK\n2/26 core.test.arbitrary input counter and unused events...OK\n3/26 core.test.charge and release behavior exists only in the authored instruction program...OK\n4/26 core.test.forward condition executes once and assignments and handlers are ordered...OK\n5/26 core.test.branch to program end is valid...OK\n6/26 core.test.one event rolls back writes from all handlers on arithmetic failure...OK\n7/26 core.test.nonfinite arithmetic and invalid clamp fail atomically...OK\n8/26 core.test.stack underflow overflow and leftover values reject the event...OK\n9/26 core.test.failed candidates leave active values program revision and diagnostics unchanged...OK\n10/26 core.test.state preservation mapping reads active values at commit and reset uses new initials...OK\n11/26 core.test.capacity boundaries and sticky load errors...OK\n12/26 core.test.invalid declaration values state mappings event ids and handler ranges...OK\n13/26 core.test.backward self out-of-handler branches and stores without handlers are rejected...OK\n14/26 core.test.event inputs and dt enforce deterministic frame contract...OK\n15/26 core.test.binding evaluation is pure on both success and failure...OK\n16/26 core.test.arithmetic comparison logical min max and clamp expression semantics...OK\n17/26 core.test.diagnostic counters wrap without affecting scalar values...OK\n18/26 core.test.memory accounting separates reserved storage used IR and stack capacity...OK\n19/26 core.test.point coordinates are ephemeral event operands and unavailable to pure evaluation...OK\n20/26 core.test.point event ranges reject holes and invalid payloads without committing...OK\n21/26 core.test.point event failure rolls back writes from earlier matching handlers...OK\n22/26 core.test.loading shares storage with records while old events and bindings remain executable...OK\n23/26 core.test.failed and abandoned candidates restore empty reuse storage without changing active work...OK\n24/26 core.test.full pending program survives repeated evaluation configuration record loss and reset...OK\n25/26 evaluation.test.record loss cannot authorize an old memo; unrelated changes can...OK\n26/26 evaluation.test.budget, collisions, invalid settings, and disabled history are conservative...OK\nAll 26 tests passed.\n"
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer/manifest.json b/prototypes/workbench/results/2026-09-23-pointer/manifest.json
new file mode 100644
index 0000000..645a8f3
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/manifest.json
@@ -0,0 +1,23 @@
+{
+ "recordedUtc": "2026-09-23T06:20:26.666681+00:00",
+ "host": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41",
+ "purpose": "Functional input/output correctness, not a performance comparison",
+ "sha256": {
+ "core.zig": "e253ccf62c10386f70edb8c84e299eb0b5eb59f8d35772150c37b3035e9047d4",
+ "evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "cli.mjs": "a64681e25259c4b639f6f0dfd0c39e771096619bcae8639706633a01b3aa0ba9",
+ "pointer.test.mjs": "dcbea73a1d9506698c1559f141f0aea671e98c420eaa11ccf499fd40a7049cff",
+ "cli.test.mjs": "e10724ccbe76ec8930546ac58e4e7148c062eae44162ecb65fd6a8d8a1f68270",
+ "qa-pointer.js": "4e684038e149e8f71bf1b4d7189eecab79c16f7d06db6b3f3273a5ef441e5e87",
+ "CONTRACT.md": "bdc013e85a8c0b304f444e107d208309c8e9ae5bbc8d54f01349da911f0843f0",
+ "examples/direct.pure": "ba555e4591e92ec524f9d785333a3b8201e335abcdd2c08d9bbcc11aa84ef6f4",
+ "web/app.js": "96739c52c67398b250958a42509f1ad883d321f042d5b842e92f17995df5b3f9",
+ "web/compiler.mjs": "676f67135c2f659a6840a4bfc2eb46d808a08b026e0401022a1a10ff48f59733",
+ "web/runtime.mjs": "db026b48cd976a4b05304f56df9386b76a68a9174f5edf66f6f69b6ac7c28bf7",
+ "web/output.mjs": "98bfa97dcf5a2481fcf22dba749541dc63cac961cf1800f24c8679caab1d2353",
+ "web/scene-input.mjs": "4f65c523c31c1bc2a07dc08d7d5b384f4dd7a53b5e8298a729cbc2e39ea66a2e",
+ "web/index.html": "6fa0ff41f847fd1ab07c6c2efc777081672eaa53c434e51d7991beded5fe4765",
+ "web/style.css": "c9cf8e7983b0b11e17afa539de69b654b8e9d27962d8b16142e91ec303692cc6",
+ "web/core.wasm": "5e733dbbb87bfc7f5b0c7714f176d5655cca4f617d11b92b07e638ae320474a6"
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer/pointer-browser.json b/prototypes/workbench/results/2026-09-23-pointer/pointer-browser.json
new file mode 100644
index 0000000..7f74840
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/pointer-browser.json
@@ -0,0 +1,32 @@
+{
+ "checks": [
+ "loading the pointer example leaves the running program unchanged until Apply",
+ "authored pointer declaration binds the visible target",
+ "keyboard alternatives remain available without a fake pointer button",
+ "real mouse down reaches the authored rule",
+ "drag retains grab offset in scene coordinates",
+ "linked output updates when the authored boundary is crossed",
+ "drag keeps its bound DOM target",
+ "capture delivers movement outside the scene and authored clamp applies",
+ "release outside the scene ends the gesture once",
+ "focus loss invokes authored rollback and clears ownership",
+ "late mouse release after cancel does not dispatch a second terminal event",
+ "explicit capture loss cancels instead of committing a drop",
+ "browser cancellation routes to the authored cancel handler",
+ "editing the authored rule changes the drag limit",
+ "later handler failure rolls back all earlier movement writes",
+ "cleanup releases a failed gesture while preserving its error",
+ "touch contact starts the same authored interaction",
+ "mobile scaled SVG maps touch coordinates back to world coordinates",
+ "touch drag manipulates the target without scrolling the page",
+ "touch release clears capture ownership",
+ "two contacts on different authored targets retain separate owners",
+ "independent touch payloads reach their own rules",
+ "touch cancellation releases both targets",
+ "second contact on an already owned target does not steal it",
+ "ignored second contact cannot strand a logical input",
+ "no browser page errors"
+ ],
+ "errors": [],
+ "kind": "functional input/output checks; no performance benchmark"
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer/pure-pointer-desktop.png b/prototypes/workbench/results/2026-09-23-pointer/pure-pointer-desktop.png
new file mode 100644
index 0000000..c3d9ab7
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/pure-pointer-desktop.png
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-23-pointer/pure-pointer-mobile.png b/prototypes/workbench/results/2026-09-23-pointer/pure-pointer-mobile.png
new file mode 100644
index 0000000..83b65f3
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/pure-pointer-mobile.png
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-23-pointer/qa-browser.js b/prototypes/workbench/results/2026-09-23-pointer/qa-browser.js
new file mode 100644
index 0000000..9c2514a
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/qa-browser.js
@@ -0,0 +1,228 @@
+// Run this expression with Playwright CLI run-code after opening the local host.
+// All behavior is authored through the editor; snapshot() only observes results.
+async (page) => {
+ const checks = [];
+ const errors = [];
+ const onError = error => errors.push(error.message);
+ const acceptDialog = dialog => dialog.accept();
+ const check = (condition, label) => {
+ if (!condition) throw new Error(`Check ${checks.length + 1} failed: ${label}`);
+ checks.push(label);
+ };
+ const state = () => page.evaluate(() => window.pureWorkbench.snapshot());
+ const apply = async text => { await page.locator('#source').fill(text); await page.locator('#apply').click(); };
+ const authorSource = [
+ '# A new authored interaction, not a bundled example.',
+ 'state x = 90', 'state moving = 0', 'state count = 0',
+ 'input advance = ArrowRight', 'input pulse = Space',
+ 'box marker(x, 170, 48, 48, "#7ed9ae")',
+ 'text reading(40, 70, count, "Signals: ")',
+ 'on advance.down { moving = 1 }', 'on advance.up { moving = 0 }',
+ 'on pulse.down { count = count + 1 }',
+ 'on frame { x = clamp(x + moving * 120 * dt, 0, 580) }', '',
+ ].join('\n');
+ let lastAuthored = null;
+ let mobile = null;
+ let downloaded = null;
+ const shots = ['output/playwright/pure-pointer-workbench-blank.png', 'output/playwright/pure-pointer-workbench-authored.png', 'output/playwright/pure-pointer-workbench-mobile.png'];
+ page.on('pageerror', onError);
+ page.on('dialog', acceptDialog);
+ try {
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.reload();
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ let s = await state();
+ check(s.source === '' && s.appliedSource === '' && !s.dirty, 'initial editor has literally empty source');
+ check(s.views.length === 0 && Object.keys(s.states).length === 0 && s.held.length === 0 && !s.running, 'initial scene has no built-in entities, states, inputs, or running clock');
+ check(await page.locator('#input-controls button').count() === 0 && await page.locator('#play').isDisabled(), 'controls require an authored program');
+ await page.screenshot({ path: shots[0], fullPage: true });
+
+ await page.locator('#source').fill(authorSource);
+ s = await state();
+ check(s.dirty && s.views.length === 0 && s.appliedSource === '', 'authoring a new program is a draft until Apply');
+ await page.locator('#apply').click();
+ s = await state();
+ check(!s.dirty && s.appliedSource === authorSource && s.views.length === 2 && s.states.x === 90 && s.states.count === 0, 'new custom program constructs states and multiple views from source');
+ check(await page.locator('#input-controls button').count() === 2 && await page.locator('[data-input="advance"]').count() === 1 && await page.locator('[data-input="pulse"]').count() === 1, 'input buttons are generated from the authored declarations');
+ check(await page.locator('#scene-content [data-view-id="marker"]').getAttribute('x') === '90', 'view bindings render the authored state value');
+
+ await page.locator('#source').focus();
+ await page.keyboard.press('End');
+ await page.keyboard.press('Space');
+ await page.keyboard.press('ArrowRight');
+ check((await state()).states.count === 0 && (await state()).states.moving === 0, 'Space and arrows in the editor never reach authored inputs');
+ await page.locator('#source').fill(authorSource);
+
+ const pulse = page.locator('[data-input="pulse"]');
+ let box = await pulse.boundingBox();
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ s = await state();
+ check(s.states.count === 1 && s.held.includes('pulse') && !s.running && s.tick === 0, 'pointer down dispatches once while the clock is paused');
+ await page.mouse.up();
+ check((await state()).held.length === 0 && (await state()).states.count === 1, 'pointer up releases without double dispatch from click');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('Space');
+ await page.keyboard.down('Space');
+ await page.keyboard.up('Space');
+ check((await state()).states.count === 2, 'keyboard repeat is ignored');
+ await pulse.focus();
+ await page.keyboard.press('Enter');
+ check((await state()).states.count === 3 && (await state()).held.length === 0, 'keyboard activation on a generated button is one complete tap');
+ await pulse.dispatchEvent('click', { detail: 0 });
+ check((await state()).states.count === 4 && (await state()).held.length === 0, 'assistive click activation is one complete tap');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('Space');
+ box = await pulse.boundingBox();
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ check((await state()).states.count === 5 && (await state()).held.includes('pulse'), 'keyboard and pointer sources share one logical input');
+ await page.mouse.up();
+ check((await state()).held.includes('pulse'), 'releasing one source does not release the other source');
+ await page.keyboard.up('Space');
+ check((await state()).held.length === 0, 'last source releases the logical input');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ await page.locator('#step').click();
+ await page.locator('#step').click();
+ s = await state();
+ check(s.states.x === 94 && s.states.moving === 1 && s.tick === 2, 'authored down/frame behavior uses the declared dt without built-in motion');
+ await page.locator('#source').focus();
+ await page.keyboard.up('ArrowRight');
+ check((await state()).states.moving === 0 && (await state()).held.length === 0, 'keyup releases even after focus moves into the editor');
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ await page.evaluate(() => window.dispatchEvent(new Event('blur')));
+ await page.keyboard.up('ArrowRight');
+ check((await state()).states.moving === 0 && !(await state()).running && (await state()).held.length === 0, 'focus loss sends release and pauses the clock');
+
+ await page.locator('#play').click();
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().tick >= 5);
+ await page.locator('#play').click();
+ const paused = await state();
+ await page.waitForTimeout(100);
+ check(!paused.running && paused.tick === (await state()).tick && paused.tick >= 5, 'Play advances and Pause freezes the clock');
+
+ const beforeFailure = await state();
+ await apply(authorSource + 'state broken =\n');
+ s = await state();
+ check(s.appliedSource === authorSource && s.revision === beforeFailure.revision && JSON.stringify(s.states) === JSON.stringify(beforeFailure.states), 'invalid code preserves the previous executable program and states');
+ check(await page.locator('#error-location').isVisible() && /행/.test(s.diagnostic) && await page.locator('#source').getAttribute('aria-invalid') === 'true', 'compile error exposes a source-line diagnostic');
+ await page.locator('#error-location').click();
+ check(await page.locator('#source').evaluate(el => el.selectionStart >= el.value.indexOf('state broken')), 'diagnostic location moves the editor caret to the problem');
+
+ const expanded = authorSource.replace('state x = 90', 'state x = 500').replace('count = count + 1', 'count = count + 5') + [
+ 'state bonus = 7', 'input reverse = KeyA',
+ 'circle beacon(300, 190, 15 + bonus, "#c9f08b")',
+ 'on reverse.down { bonus = bonus + 2 }', '',
+ ].join('\n');
+ await apply(expanded);
+ s = await state();
+ check(s.states.x === beforeFailure.states.x && s.states.count === 5 && s.states.bonus === 7 && s.views.length === 3, 'apply preserves matching state names and initializes newly authored states');
+ await page.locator('[data-input="reverse"]').click();
+ check((await state()).states.bonus === 9 && await page.locator('#scene-content [data-view-id="beacon"]').getAttribute('r') === '24', 'new input, entity, binding, and behavior work with source changes alone');
+ await page.locator('[data-input="pulse"]').click();
+ check((await state()).states.count === 10, 'changed behavior takes effect without resetting existing values');
+
+ const withDraft = expanded + '# This un-applied draft must survive Reset.\n';
+ await page.locator('#source').fill(withDraft);
+ await page.locator('#reset').click();
+ s = await state();
+ check(s.states.x === 500 && s.states.count === 0 && s.states.bonus === 7 && s.tick === 0 && s.source === withDraft && s.dirty, 'explicit Reset restores active initializers and keeps the draft');
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ const heldRevision = (await state()).revision;
+ await page.locator('#apply').click();
+ check((await state()).revision === heldRevision && /놓은/.test((await state()).diagnostic), 'Apply is refused while an input is held');
+ await page.keyboard.up('ArrowRight');
+
+ const downloadPromise = page.waitForEvent('download');
+ await page.locator('#download-project').click();
+ const projectDownload = await downloadPromise;
+ const downloadPath = 'output/playwright/pure-pointer-workbench-draft.json';
+ await projectDownload.saveAs(downloadPath);
+ downloaded = { filename: projectDownload.suggestedFilename(), path: downloadPath, expectedSource: withDraft };
+ await page.locator('#new').click();
+ s = await state();
+ check(s.source === '' && s.views.length === 3 && s.appliedSource === expanded, 'clear editor is explicit and does not clear the running scene before Apply');
+ await page.locator('#file-input').setInputFiles(downloadPath);
+ await page.waitForFunction(expected => window.pureWorkbench.snapshot().source === expected, withDraft);
+ check((await state()).source === withDraft && (await state()).appliedSource === expanded && (await state()).dirty, 'downloaded project restores the exact un-applied draft without execution');
+ await page.locator('#file-input').evaluate(input => {
+ const transfer = new DataTransfer();
+ transfer.items.add(new File(['{"format":"other","version":1,"source":"state no = 1"}'], 'invalid.json', { type: 'application/json' }));
+ input.files = transfer.files;
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ });
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().diagnostic.includes('형식'));
+ check((await state()).source === withDraft && (await state()).appliedSource === expanded, 'invalid JSON project schema cannot replace the draft');
+
+ await apply('state n = 1\ninput fail = Space\ntext label(40, 60, n)\non fail.down { n = n / 0 }\n');
+ await page.locator('[data-input="fail"]').dispatchEvent('click', { detail: 0 });
+ s = await state();
+ check(s.states.n === 1 && s.lastError === 4 && s.held.length === 0 && s.lastEvent.phase === 'up', 'failed assistive down event rolls back and its error survives the following successful up');
+ check(await page.locator('#runtime-error').isVisible() && (await page.locator('#runtime-error').textContent()).includes('마지막 실패'), 'latched runtime failure is visible and labeled separately from the latest input');
+ await page.locator('#reset').click();
+ check((await state()).lastError === 0 && await page.locator('#runtime-error').isHidden(), 'explicit Reset clears the failure record');
+
+ await page.locator('#new').click();
+ await page.locator('#apply').click();
+ s = await state();
+ check(s.source === '' && s.appliedSource === '' && s.views.length === 0 && Object.keys(s.states).length === 0 && await page.locator('#input-controls button').count() === 0, 'applying empty source removes all authored states, inputs, and scene contents');
+ await page.locator('#example').selectOption('counter');
+ await page.locator('#load-example').click();
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().source.includes('state count'));
+ check((await state()).views.length === 0 && (await state()).appliedSource === '' && (await state()).dirty, 'explicit example loading only fills the editor and never auto-applies');
+
+ await apply(authorSource);
+ await page.locator('[data-input="pulse"]').click();
+ await page.locator('[data-input="pulse"]').click();
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ for (let i = 0; i < 12; i++) await page.locator('#step').click();
+ await page.keyboard.up('ArrowRight');
+ lastAuthored = await state();
+ check(lastAuthored.states.x === 114 && lastAuthored.states.count === 2 && lastAuthored.memory.linearBytes === 65536, 'authored scene executes in the actual fixed 64 KiB Wasm memory');
+ await page.locator('.cost-details summary').click();
+ check((await page.locator('#cost-values').textContent()).includes('65,536 B'), 'cost inspector shows measured Wasm memory without claiming browser heap totals');
+ await page.locator('.cost-details summary').click();
+ await page.screenshot({ path: shots[1], fullPage: true });
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ mobile = await page.evaluate(() => ({ viewport: innerWidth, content: document.documentElement.scrollWidth }));
+ check(mobile.content <= mobile.viewport, '390px mobile viewport has no horizontal overflow');
+ await page.locator('[data-input="pulse"]').click();
+ check((await state()).states.count === 3, 'generated mobile input buttons remain usable');
+ await page.screenshot({ path: shots[2], fullPage: true });
+ const comparison = await page.request.get('http://127.0.0.1:8766/baseline.html');
+ const equivalent = await page.request.get('http://127.0.0.1:8766/examples/charge.pure');
+ check(comparison.ok() && equivalent.ok() && await page.locator('a[href="./baseline.html"]').count() === 1, 'ordinary-JavaScript comparison and equivalent Pure example are linked and served');
+
+ await page.route('**/core.wasm', route => route.abort());
+ await page.reload();
+ await page.waitForFunction(() => document.getElementById('ready-status').textContent.includes('실패'));
+ check(await page.locator('#apply').isDisabled() && await page.locator('#play').isDisabled() && await page.locator('#step').isDisabled() && /실행기를 열지/.test(await page.locator('#diagnostic').textContent()), 'missing Wasm shows an actionable load failure and disables execution controls');
+ await page.unroute('**/core.wasm');
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.reload();
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ check((await state()).source === '' && (await state()).views.length === 0, 'fresh load always returns to a blank authoring environment');
+ check(errors.length === 0, 'no uncaught page errors during the browser scenarios');
+ const evidence = { recordedUtc: new Date().toISOString(), browser: await page.evaluate(() => navigator.userAgent),
+ checks, authoredSource: authorSource, authoredSnapshot: lastAuthored, mobile, downloaded, errors, screenshots: shots };
+ // CLI can yield early when native dialogs/download events occur. Retain the
+ // observation report so a follow-up CLI call can retrieve the complete run.
+ await page.evaluate(value => { window.__pureWorkbenchQAEvidence = value; }, evidence);
+ return evidence;
+ } catch (error) {
+ const evidence = { failed: true, error: error.message, stack: error.stack, checks, errors };
+ await page.evaluate(value => { window.__pureWorkbenchQAEvidence = value; }, evidence);
+ return evidence;
+ } finally {
+ page.off('pageerror', onError);
+ page.off('dialog', acceptDialog);
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer/qa-pointer.js b/prototypes/workbench/results/2026-09-23-pointer/qa-pointer.js
new file mode 100644
index 0000000..9a12cc4
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/qa-pointer.js
@@ -0,0 +1,120 @@
+// Playwright CLI run-code: real mouse / CDP touch input; no timing benchmark.
+async (page) => {
+ const checks = [], errors = [];
+ const check = (condition, label) => { if (!condition) throw new Error(label); checks.push(label); };
+ const close = (a, b) => Math.abs(a - b) < 0.1;
+ const state = () => page.evaluate(() => window.pureWorkbench.snapshot());
+ const client = (x, y) => page.evaluate(({x,y}) => {
+ const p = new DOMPoint(x,y).matrixTransform(document.querySelector('#scene').getScreenCTM());
+ return {x:p.x,y:p.y};
+ }, {x,y});
+ const move = async (x,y) => { const p = await client(x,y); await page.mouse.move(p.x,p.y); };
+ const start = async (x,y) => { await move(x,y); await page.mouse.down(); };
+ const reset = async () => { await page.locator('#reset').click(); await page.locator('#scene').scrollIntoViewIfNeeded(); };
+ const apply = async source => { await page.locator('#source').fill(source); await page.locator('#apply').click(); await page.locator('#scene').scrollIntoViewIfNeeded(); };
+ const onError = error => errors.push(error.message);
+ const onDialog = dialog => dialog.accept();
+ page.on('pageerror', onError); page.on('dialog', onDialog);
+ let session;
+ try {
+ await page.setViewportSize({width:1440,height:1000});
+ await page.reload();
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ await page.locator('#example').selectOption('direct');
+ await page.locator('#load-example').click();
+ const source = await page.locator('#source').inputValue();
+ check((await state()).views.length === 0, 'loading the pointer example leaves the running program unchanged until Apply');
+ await page.locator('#apply').click(); await page.locator('#scene').scrollIntoViewIfNeeded();
+ check(await page.locator('[data-point-input="drag"]').count() === 1, 'authored pointer declaration binds the visible target');
+ check(await page.locator('#input-controls button').count() === 2, 'keyboard alternatives remain available without a fake pointer button');
+ await page.evaluate(() => {
+ window.__pointerTile = document.querySelector('[data-point-input="drag"]');
+ document.querySelector('#scene').addEventListener('pointerdown', e => { window.__pointerID = e.pointerId; });
+ });
+ await start(90,170);
+ let s = await state();
+ check(s.states.dragging === 1 && s.held.includes('drag'), 'real mouse down reaches the authored rule');
+ await move(370,230); s = await state();
+ check(close(s.states.x,360) && close(s.states.y,220), 'drag retains grab offset in scene coordinates');
+ check(s.views.find(v => v.id === 'region').value === 1 && await page.locator('[data-view-id="region"]').textContent() === '오른쪽 영역: 1', 'linked output updates when the authored boundary is crossed');
+ check(await page.evaluate(() => window.__pointerTile === document.querySelector('[data-point-input="drag"]')), 'drag keeps its bound DOM target');
+ // Physical mouse outside the SVG still reaches the stable captured scene.
+ await move(750,400); s = await state();
+ check(close(s.states.x,564) && close(s.states.y,276), 'capture delivers movement outside the scene and authored clamp applies');
+ await page.mouse.up(); s = await state();
+ check(s.held.length === 0 && s.states.dragging === 0 && s.lastEvent.phase === 'up', 'release outside the scene ends the gesture once');
+ await reset();
+ await start(90,170); await move(380,240);
+ await page.evaluate(() => window.dispatchEvent(new Event('blur')));
+ s = await state();
+ check(close(s.states.x,80) && close(s.states.y,160) && s.states.dragging === 0 && s.held.length === 0 && s.lastEvent.phase === 'cancel', 'focus loss invokes authored rollback and clears ownership');
+ const ended = s.successfulEvents;
+ await page.mouse.up();
+ check((await state()).successfulEvents === ended, 'late mouse release after cancel does not dispatch a second terminal event');
+ await start(90,170); await move(350,220);
+ await page.evaluate(() => document.querySelector('#scene').releasePointerCapture(window.__pointerID));
+ await move(360,230); s = await state();
+ check(close(s.states.x,80) && s.held.length === 0 && s.lastEvent.phase === 'cancel', 'explicit capture loss cancels instead of committing a drop');
+ await page.mouse.up();
+ await start(90,170); await move(300,220);
+ await page.evaluate(() => document.querySelector('#scene').dispatchEvent(new PointerEvent('pointercancel', {pointerId:window.__pointerID,bubbles:true})));
+ check((await state()).held.length === 0 && close((await state()).states.x,80), 'browser cancellation routes to the authored cancel handler');
+ await page.mouse.up();
+ // A source edit, not a renderer option, changes the movement constraint.
+ await apply(source.replaceAll('20, 564','20, 200'));
+ await start(90,170); await move(500,220); await page.mouse.up(); s = await state();
+ check(close(s.states.x,200), 'editing the authored rule changes the drag limit');
+ await apply(source + '\non drag.move { x = 1 / 0 }\n'); await reset();
+ await start(90,170); await move(300,220); s = await state();
+ check(s.lastError === 4 && close(s.states.x,80) && close(s.states.y,160), 'later handler failure rolls back all earlier movement writes');
+ await page.evaluate(() => window.dispatchEvent(new Event('blur'))); await page.mouse.up();
+ s = await state();
+ check(s.lastError === 4 && s.held.length === 0 && s.states.dragging === 0, 'cleanup releases a failed gesture while preserving its error');
+ await apply(source); await reset();
+ await page.screenshot({path:'output/playwright/pure-pointer-desktop.png',fullPage:true});
+
+ await page.setViewportSize({width:390,height:844});
+ await page.locator('#scene').scrollIntoViewIfNeeded();
+ session = await page.context().newCDPSession(page);
+ await session.send('Emulation.setTouchEmulationEnabled',{enabled:true,maxTouchPoints:2});
+ const touch = async (type, points) => session.send('Input.dispatchTouchEvent',{type,touchPoints:points});
+ const a = await client(90,170), b = await client(270,210);
+ const scroll = await page.evaluate(() => window.scrollY);
+ await touch('touchStart',[{id:1,...a}]);
+ check((await state()).states.dragging === 1, 'touch contact starts the same authored interaction');
+ await touch('touchMove',[{id:1,...b}]); s = await state();
+ check(close(s.states.x,260) && close(s.states.y,200), 'mobile scaled SVG maps touch coordinates back to world coordinates');
+ check(await page.evaluate(() => window.scrollY) === scroll, 'touch drag manipulates the target without scrolling the page');
+ await touch('touchEnd',[]); s = await state();
+ check(s.held.length === 0 && s.states.dragging === 0 && s.lastEvent.phase === 'up', 'touch release clears capture ownership');
+ await page.screenshot({path:'output/playwright/pure-pointer-mobile.png',fullPage:true});
+ // Independent contacts are not collapsed into one global drag state.
+ const two = 'state x=0\nstate y=0\ninput a=pointer(one)\ninput b=pointer(two)\nbox one(20,100,100,80,"#c9f08b")\nbox two(300,100,100,80,"#c9f08b")\non a.move { x=event_x }\non b.move { y=event_y }';
+ await apply(two);
+ const p1 = {id:1,...await client(50,140)}, p2 = {id:2,...await client(330,140)};
+ await touch('touchStart',[p1]); await touch('touchStart',[p1,p2]);
+ check((await state()).held.length === 2, 'two contacts on different authored targets retain separate owners');
+ await touch('touchMove',[{id:1,...await client(180,160)},{id:2,...await client(430,200)}]); s = await state();
+ check(close(s.states.x,180) && close(s.states.y,200), 'independent touch payloads reach their own rules');
+ await touch('touchCancel',[]);
+ check((await state()).held.length === 0, 'touch cancellation releases both targets');
+ // A second contact on the same target cannot steal the first one.
+ await touch('touchStart',[p1]);
+ await touch('touchStart',[p1,{id:2,...await client(70,150)}]);
+ check((await state()).held.length === 1, 'second contact on an already owned target does not steal it');
+ await touch('touchCancel',[]);
+ check((await state()).held.length === 0, 'ignored second contact cannot strand a logical input');
+ check(errors.length === 0,'no browser page errors');
+ await session.send('Emulation.setTouchEmulationEnabled',{enabled:false});
+ await session.detach(); session = null;
+ await page.setViewportSize({width:1440,height:1000}); await apply(source); await reset();
+ await page.evaluate(result => { window.__purePointerEvidence = result; }, {checks,errors,kind:'functional input/output checks; no performance benchmark'});
+ return {checks:checks.length,errors};
+ } catch (error) {
+ await page.evaluate(result => { window.__purePointerEvidence = result; }, {failed:String(error),checks,errors});
+ throw error;
+ } finally {
+ if (session) { await session.send('Emulation.setTouchEmulationEnabled',{enabled:false}); await session.detach(); }
+ page.off('pageerror',onError); page.off('dialog',onDialog);
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-23-pointer/verification.json b/prototypes/workbench/results/2026-09-23-pointer/verification.json
new file mode 100644
index 0000000..c4f3ab6
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-pointer/verification.json
@@ -0,0 +1,50 @@
+[
+ {
+ "file": "prototypes/workbench/baseline.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 reference begins with the authored states and matching scene (2.305482ms)\n\u2714 press, hold, release, and frame order produce exact expected values (0.527815ms)\n\u2714 charge clamps at one and explicit floor restores rest (1.250815ms)\n\u2714 repeat and concurrent physical sources produce only logical edges (0.412074ms)\n\u2714 airborne presses cannot charge or cause another jump (0.291222ms)\n\u2714 releaseAll emits release and reset clears latches without a jump (0.422408ms)\n\u2714 unknown inputs and invalid frame counts reject without mutation (0.72439ms)\n\u2139 tests 7\n\u2139 suites 0\n\u2139 pass 7\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 81.548794\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/cli.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 terminal starts empty and produces the same charge trace as the shared runtime (112.05504ms)\n\u2714 invalid terminal commands do not alter state and fail piped execution (49.240343ms)\n\u2714 terminal replays authored pointer movement and cancellation through the shared runtime (54.859307ms)\n\u2714 file load preserves by name, rejected load preserves active work, and save never overwrites (55.085715ms)\n\u2714 terminal exposes arithmetic rollback and retains failure through matching release (56.135751ms)\n\u2714 terminal configures and discards evaluation records and recovers from a display limit (51.887603ms)\n\u2139 tests 6\n\u2139 suites 0\n\u2139 pass 6\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 450.784885\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/compiler.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 a blank document is an empty program with no implied behavior (4.256704ms)\n\u2714 forward references and separate state/input namespaces preserve declaration IDs (1.963593ms)\n\u2714 ordered assignments, repeated handlers, and condition evaluation once (0.883148ms)\n\u2714 nested branches use absolute forward targets and skip correct bodies (0.576167ms)\n\u2714 operator precedence, associativity, unary and builtin function lowering (2.148353ms)\n\u2714 multiline parentheses, scientific literals, comments, and string escapes (0.726093ms)\n\u2714 all instruction fields are numeric and carry original source line (1.483741ms)\n\u2714 contextual declaration words remain valid names outside reserved builtins (0.465ms)\n\u2714 unknown and reserved symbols have useful one-based locations (1.551241ms)\n\u2714 duplicates, unsupported keys, initializers, and invalid syntax are rejected (1.354833ms)\n\u2714 document, declaration, instruction, and nesting limits reject overflow (5.992483ms)\n\u2714 every supported input maps to a bounded ordered event ID (0.696482ms)\n\u2714 optional examples compile and charge behavior is entirely authored (17.239448ms)\n\u2139 tests 13\n\u2139 suites 0\n\u2139 pass 13\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 109.878985\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/evaluation.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 cache reuse, unrelated changes and discarded records preserve authored values (26.550024ms)\n\u2714 committed changes only: failed events and failed edits preserve reusable output (3.965408ms)\n\u2714 program edits, state reordering and reset cannot reuse old definitions (6.722354ms)\n\u2714 finite float semantics include signed zero, high state bit and arithmetic errors (4.888334ms)\n\u2714 one budget covers the whole scene; failures publish no partial scene or state rollback (2.441686ms)\n\u2714 small caches, no history and every small work limit give exact results or explicit failure (34.833599ms)\n\u2714 invalid settings and forget names preserve policy and state (2.774686ms)\n\u2139 tests 7\n\u2139 suites 0\n\u2139 pass 7\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 176.339238\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/pointer.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 pointer declarations resolve forward targets and reject ambiguous or contextless inputs (13.505164ms)\n\u2714 authored drag keeps grab offset, uses final up coordinates, and updates linked output (28.893605ms)\n\u2714 ownership, duplicate downs, stale moves, cancellation and focus cleanup cannot leave held input (14.600108ms)\n\u2714 invalid coordinates and modes do not mutate a held gesture; arithmetic failure rolls back one event (10.681516ms)\n\u2714 independent targets share the event pipeline and publication remains non-reentrant (10.878331ms)\n\u2714 failing down, up and cancel never prevent terminal ownership cleanup (10.156146ms)\n\u2139 tests 6\n\u2139 suites 0\n\u2139 pass 6\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 230.99603\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/presentation.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 direct publication matches archived snapshots and work counts through authored changes (52.238325ms)\n\u2714 whole-scene budget failure never publishes partial bindings and recovers exactly (6.839798ms)\n\u2714 latched event rollback, nonfinite bindings, reset and signed zero keep their semantics (3.393075ms)\n\u2714 publication rejects reentrant runtime changes and reads before they affect the scene (5.349038ms)\n\u2714 invalid or throwing output adapters do not strand the runtime or alter authored state (2.23126ms)\n\u2139 tests 5\n\u2139 suites 0\n\u2139 pass 5\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 145.248954\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/runtime.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 blank startup and applied empty source contain no application objects or behavior (10.791113ms)\n\u2714 author can add unrelated states inputs views and behavior entirely through source (6.443242ms)\n\u2714 keyboard and pointer sources aggregate first down and last up; repeats are ignored (2.079686ms)\n\u2714 held input rejects apply without changing applied source, metadata, or values (2.284056ms)\n\u2714 invalid syntax, unknown references and capacity overflow preserve the previous program (3.138815ms)\n\u2714 state preservation follows names across reorder/add/remove; reset uses new initializers (1.600112ms)\n\u2714 callers cannot mutate active metadata and corrupt name-based state preservation (1.649259ms)\n\u2714 conditions execute once, and assignments and multiple handlers follow source order (2.004259ms)\n\u2714 a failed event rolls back all handlers and keeps its source diagnostic through release (1.672019ms)\n\u2714 reset clears held inputs and latched errors; invalid frame counts have no effects (1.742611ms)\n\u2714 render evaluation is pure and invalid binding is distinct from an event rollback (4.822723ms)\n\u2714 independent adapters replay the same author program without sharing live state (2.826889ms)\n\u2714 Wasm has no imports and reports fixed memory and exact active IR/state storage (7.826798ms)\n\u2139 tests 13\n\u2139 suites 0\n\u2139 pass 13\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 126.815099\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/workspace.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 Wasm executes old events and output during staged replacement without corrupting either phase (8.926576ms)\n\u2714 workspace accounting reports shared payload and safety tag without double-counting (3.193686ms)\n\u2139 tests 2\n\u2139 suites 0\n\u2139 pass 2\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 82.501368\n",
+ "stderr": ""
+ }
+]
diff --git a/prototypes/workbench/results/2026-09-23-presentation/before/core.zig b/prototypes/workbench/results/2026-09-23-presentation/before/core.zig
new file mode 100644
index 0000000..84812a3
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-presentation/before/core.zig
@@ -0,0 +1,942 @@
+//! Bounded scalar interaction VM experiment. No application behavior is built in.
+//! Same source for native tests and freestanding Wasm; no allocation or imports.
+//! Error ABI: 0 ok, 1 capacity, 2 invalid, 3 stack, 4 division by zero,
+//! 5 non-finite, 6 bad branch, 7 bad state, 8 bad frame, 9 bad event.
+//! Counters wrap modulo 2^32. Failed loads preserve the active program, authored
+//! state and event diagnostics; beginning a load discards optional reuse records.
+//! Failed events preserve values and counters but
+//! report their error and attempted instruction count. Binding evaluation is pure.
+
+const std = @import("std");
+const Evaluation = @import("evaluation.zig").Evaluation;
+const Tables = @import("evaluation.zig").Tables;
+
+const max_states = 32;
+const max_inputs = 8;
+const max_instructions = 512;
+const max_handlers = 64;
+const max_stack = 64;
+const no_pc = std.math.maxInt(u32);
+const frame_dt: f64 = 1.0 / 60.0;
+
+const Code = enum(u32) {
+ ok = 0,
+ capacity = 1,
+ invalid = 2,
+ stack = 3,
+ division_by_zero = 4,
+ non_finite = 5,
+ bad_branch = 6,
+ bad_state = 7,
+ bad_frame = 8,
+ bad_event = 9,
+ work_limit = 10,
+};
+
+const Instruction = struct { value: f64 = 0, op: u32 = 0, arg: u32 = 0 };
+const Handler = struct { event: u32 = 0, start: u32 = 0, length: u32 = 0 };
+const Program = struct {
+ instructions: [max_instructions]Instruction = @splat(.{}),
+ handlers: [max_handlers]Handler = @splat(.{}),
+ initials: [max_states]f64 = @splat(0),
+ old_ids: [max_states]i32 = @splat(-1),
+ declared: [max_states]bool = @splat(false),
+ state_count: u32 = 0,
+ input_count: u32 = 0,
+ instruction_count: u32 = 0,
+ handler_count: u32 = 0,
+};
+// Loading and result reuse are mutually exclusive phases. Current execution
+// always reads active, and can recompute bindings while this workspace loads.
+const Workspace = union { staging: Program, reuse: Tables };
+const Engine = struct {
+ active: Program = .{},
+ workspace: Workspace = .{ .reuse = .{} },
+ values: [max_states]f64 = @splat(0),
+ staging_open: bool = false,
+ staging_error: Code = .ok,
+ tick: u32 = 0,
+ last_error: Code = .ok,
+ error_pc: u32 = no_pc,
+ last_ops: u32 = 0,
+ revision: u32 = 0,
+ successful_events: u32 = 0,
+ evaluation: Evaluation = .{},
+};
+var engine: Engine = .{};
+
+fn reuseTables() ?*Tables {
+ return if (engine.staging_open) null else &engine.workspace.reuse;
+}
+
+fn finishStaging() void {
+ engine.workspace = .{ .reuse = .{} };
+ engine.staging_open = false;
+ engine.evaluation.forget(null, 3);
+}
+
+fn public(code: Code) u32 {
+ return @intFromEnum(code);
+}
+
+fn stagingFail(code: Code) u32 {
+ if (engine.staging_error == .ok) engine.staging_error = code;
+ return public(code);
+}
+
+export fn reset_all() void {
+ engine = .{};
+}
+
+export fn begin_program(state_count: u32, input_count: u32) u32 {
+ engine.staging_error = .ok;
+ if (state_count > max_states or input_count > max_inputs) {
+ if (engine.staging_open) finishStaging();
+ return stagingFail(.capacity);
+ }
+ engine.workspace = .{ .staging = .{ .state_count = state_count, .input_count = input_count } };
+ engine.staging_open = true;
+ engine.evaluation.forget(null, 3);
+ return 0;
+}
+
+export fn abort_program() void {
+ if (engine.staging_open) finishStaging();
+}
+
+export fn put_state(id: u32, initial: f64, old_id: i32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ if (id >= engine.workspace.staging.state_count or old_id < -1) return stagingFail(.bad_state);
+ if (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count) return stagingFail(.bad_state);
+ if (engine.workspace.staging.declared[id]) return stagingFail(.bad_state);
+ if (!std.math.isFinite(initial)) return stagingFail(.non_finite);
+ engine.workspace.staging.initials[id] = initial;
+ engine.workspace.staging.old_ids[id] = old_id;
+ engine.workspace.staging.declared[id] = true;
+ return 0;
+}
+
+export fn put_instruction(op: u32, arg: u32, value: f64) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging;
+ if (p.instruction_count == max_instructions) return stagingFail(.capacity);
+ if (op > 21) return stagingFail(.invalid);
+ if (!std.math.isFinite(value)) return stagingFail(.non_finite);
+ p.instructions[p.instruction_count] = .{ .op = op, .arg = arg, .value = value };
+ p.instruction_count += 1;
+ return 0;
+}
+
+export fn put_handler(event: u32, start: u32, length: u32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging;
+ if (p.handler_count == max_handlers) return stagingFail(.capacity);
+ if (event > p.input_count * 2) return stagingFail(.bad_event);
+ // Handlers may be appended before their instructions, so range validation
+ // is deferred until commit. Arithmetic remains guarded there.
+ p.handlers[p.handler_count] = .{ .event = event, .start = start, .length = length };
+ p.handler_count += 1;
+ return 0;
+}
+
+fn validate(p: *const Program) Code {
+ for (0..p.state_count) |id| {
+ if (!p.declared[id]) return .bad_state;
+ if (!std.math.isFinite(p.initials[id])) return .non_finite;
+ const old_id = p.old_ids[id];
+ if (old_id < -1 or (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count)) return .bad_state;
+ }
+ for (p.handlers[0..p.handler_count], 0..) |h, i| {
+ if (h.event > 2 * p.input_count) return .bad_event;
+ if (h.start > p.instruction_count or h.length > p.instruction_count - h.start) return .invalid;
+ if (h.length == 0) continue;
+ for (p.handlers[0..i]) |other| {
+ if (other.length != 0 and h.start < other.start + other.length and other.start < h.start + h.length) return .invalid;
+ }
+ }
+ for (p.instructions[0..p.instruction_count], 0..) |inst, pc| {
+ if (inst.op > 21) return .invalid;
+ if (!std.math.isFinite(inst.value)) return .non_finite;
+ if ((inst.op == 1 or inst.op == 20) and inst.arg >= p.state_count) return .bad_state;
+ if (inst.op == 20 or inst.op == 21) {
+ var owner: ?Handler = null;
+ for (p.handlers[0..p.handler_count]) |h| {
+ if (pc >= h.start and pc < h.start + h.length) {
+ owner = h;
+ break;
+ }
+ }
+ const h = owner orelse return .invalid;
+ if (inst.op == 21 and (inst.arg <= pc or inst.arg > h.start + h.length)) return .bad_branch;
+ }
+ }
+ return .ok;
+}
+
+export fn commit_program() u32 {
+ if (!engine.staging_open) return public(.invalid);
+ if (engine.staging_error != .ok) return public(engine.staging_error);
+ const code = validate(&engine.workspace.staging);
+ if (code != .ok) return stagingFail(code);
+ var next: [max_states]f64 = @splat(0);
+ for (0..engine.workspace.staging.state_count) |i| {
+ const old_id = engine.workspace.staging.old_ids[i];
+ next[i] = if (old_id < 0) engine.workspace.staging.initials[i] else engine.values[@intCast(old_id)];
+ }
+ engine.active = engine.workspace.staging;
+ engine.values = next;
+ finishStaging();
+ engine.revision +%= 1;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ return 0;
+}
+
+export fn reset_state() void {
+ engine.values = engine.active.initials;
+ engine.evaluation.forget(reuseTables(), 3);
+ engine.tick = 0;
+ engine.successful_events = 0;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+}
+
+const Execution = struct {
+ code: Code = .ok,
+ pc: u32 = no_pc,
+ ops: u32 = 0,
+ value: f64 = 0,
+ dependencies: u32 = 0,
+};
+
+fn executionFail(result: Execution, code: Code, pc: u32) Execution {
+ var failed = result;
+ failed.code = code;
+ failed.pc = pc;
+ return failed;
+}
+
+fn execute(values: *[max_states]f64, start: u32, length: u32, dt: f64, pure: bool, budget: ?*Evaluation) Execution {
+ const p = &engine.active;
+ var result: Execution = .{};
+ if (start > p.instruction_count or length > p.instruction_count - start) return executionFail(result, .invalid, no_pc);
+ const end = start + length;
+ var stack: [max_stack]f64 = undefined;
+ var sp: usize = 0;
+ var pc = start;
+ while (pc < end) {
+ if (budget) |b| {
+ if (!b.spend()) return executionFail(result, .work_limit, pc);
+ b.instructions += 1;
+ }
+ const inst = p.instructions[pc];
+ result.ops += 1;
+ if (pure and (inst.op == 2 or inst.op == 20 or inst.op == 21)) return executionFail(result, .invalid, pc);
+ const need: usize = switch (inst.op) {
+ 0...2 => 0,
+ 7, 16, 20, 21 => 1,
+ 3...6, 8...15, 17, 18 => 2,
+ 19 => 3,
+ else => return executionFail(result, .invalid, pc),
+ };
+ if (sp < need) return executionFail(result, .stack, pc);
+ if (inst.op <= 2) {
+ if (sp == max_stack) return executionFail(result, .stack, pc);
+ const value = switch (inst.op) {
+ 0 => inst.value,
+ 1 => blk: {
+ if (inst.arg >= p.state_count) return executionFail(result, .bad_state, pc);
+ if (budget != null and budget.?.cache_count != 0)
+ result.dependencies |= @as(u32, 1) << @as(u5, @intCast(inst.arg));
+ break :blk values[inst.arg];
+ },
+ 2 => dt,
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ stack[sp] = value;
+ sp += 1;
+ } else if (inst.op == 20) {
+ if (inst.arg >= p.state_count) return executionFail(result, .bad_state, pc);
+ sp -= 1;
+ values[inst.arg] = stack[sp];
+ } else if (inst.op == 21) {
+ if (inst.arg <= pc or inst.arg > end) return executionFail(result, .bad_branch, pc);
+ sp -= 1;
+ if (stack[sp] == 0) {
+ pc = inst.arg;
+ continue;
+ }
+ } else {
+ const a = stack[sp - need];
+ const b = if (need >= 2) stack[sp - need + 1] else 0;
+ const value: f64 = switch (inst.op) {
+ 3 => a + b,
+ 4 => a - b,
+ 5 => a * b,
+ 6 => blk: {
+ if (b == 0) return executionFail(result, .division_by_zero, pc);
+ break :blk a / b;
+ },
+ 7 => -a,
+ 8 => @floatFromInt(@intFromBool(a < b)),
+ 9 => @floatFromInt(@intFromBool(a <= b)),
+ 10 => @floatFromInt(@intFromBool(a > b)),
+ 11 => @floatFromInt(@intFromBool(a >= b)),
+ 12 => @floatFromInt(@intFromBool(a == b)),
+ 13 => @floatFromInt(@intFromBool(a != b)),
+ 14 => @floatFromInt(@intFromBool(a != 0 and b != 0)),
+ 15 => @floatFromInt(@intFromBool(a != 0 or b != 0)),
+ 16 => @floatFromInt(@intFromBool(a == 0)),
+ 17 => @min(a, b),
+ 18 => @max(a, b),
+ 19 => blk: {
+ const hi = stack[sp - 1];
+ if (b > hi) return executionFail(result, .invalid, pc);
+ break :blk @max(b, @min(a, hi));
+ },
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ sp = sp - need + 1;
+ stack[sp - 1] = value;
+ }
+ pc += 1;
+ }
+ if (sp != @as(usize, if (pure) 1 else 0)) return executionFail(result, .stack, end);
+ if (pure) result.value = stack[0];
+ return result;
+}
+
+export fn dispatch(event: u32, dt: f64) u32 {
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ if (event > engine.active.input_count * 2) {
+ engine.last_error = .bad_event;
+ return public(.bad_event);
+ }
+ if (!std.math.isFinite(dt)) {
+ engine.last_error = .non_finite;
+ return public(.non_finite);
+ }
+ if ((event == 0 and dt != frame_dt) or (event != 0 and dt != 0)) {
+ engine.last_error = .bad_frame;
+ return public(.bad_frame);
+ }
+ var pending = engine.values;
+ for (engine.active.handlers[0..engine.active.handler_count]) |h| {
+ if (h.event != event) continue;
+ const result = execute(&pending, h.start, h.length, dt, false, null);
+ engine.last_ops += result.ops;
+ if (result.code != .ok) {
+ engine.last_error = result.code;
+ engine.error_pc = result.pc;
+ return public(result.code);
+ }
+ }
+ if (engine.evaluation.cache_count != 0 and !engine.staging_open) {
+ var changed: u32 = 0;
+ for (0..engine.active.state_count) |i| {
+ // Signed zero is observable, so compare representation, not ==.
+ if (@as(u64, @bitCast(pending[i])) != @as(u64, @bitCast(engine.values[i])))
+ changed |= @as(u32, 1) << @as(u5, @intCast(i));
+ }
+ engine.evaluation.changed(reuseTables(), changed);
+ }
+ engine.values = pending;
+ if (event == 0) engine.tick +%= 1;
+ engine.successful_events +%= 1;
+ return 0;
+}
+
+export fn eval_at(start: u32, length: u32) f64 {
+ // A local copy makes the no-side-effects boundary structural as well as
+ // checked by the pure instruction filter. Diagnostics are never touched.
+ var values = engine.values;
+ const result = execute(&values, start, length, 0, true, null);
+ return if (result.code == .ok) result.value else std.math.nan(f64);
+}
+
+export fn configure_evaluation(cache: u32, history: u32) u32 {
+ return if (engine.evaluation.configure(reuseTables(), cache, history)) 0 else public(.capacity);
+}
+
+export fn forget_evaluation(flags: u32) void {
+ engine.evaluation.forget(reuseTables(), flags);
+}
+
+export fn begin_evaluation(limit: u32) void {
+ engine.evaluation.begin(limit);
+}
+
+export fn eval_bounded(start: u32, length: u32) f64 {
+ const e = &engine.evaluation;
+ e.status = 0;
+ // Charge per request as well as per instruction and validation probe.
+ if (!e.spend()) return std.math.nan(f64);
+ if (e.lookup(reuseTables(), start, length)) |value| return value;
+ if (e.status != 0) return std.math.nan(f64);
+ var values = engine.values;
+ const result = execute(&values, start, length, 0, true, e);
+ e.status = public(result.code);
+ if (result.code != .ok) return std.math.nan(f64);
+ e.remember(reuseTables(), start, length, result.value, result.dependencies);
+ return result.value;
+}
+
+export fn evaluation_info(id: u32) u32 {
+ const e = &engine.evaluation;
+ return switch (id) {
+ 0 => e.status,
+ 1 => e.work,
+ 2 => e.instructions,
+ 3 => e.hits,
+ 4 => e.cache_count,
+ 5 => e.history_count,
+ 6 => @sizeOf(Evaluation) + @sizeOf(Tables),
+ 7 => @sizeOf(Evaluation),
+ 8 => @sizeOf(Tables),
+ 9 => @intFromBool(!engine.staging_open),
+ 10 => @sizeOf(Workspace) - @sizeOf(Program),
+ else => 0,
+ };
+}
+
+export fn get_state(id: u32) f64 {
+ return if (id < engine.active.state_count) engine.values[id] else std.math.nan(f64);
+}
+
+export fn info(id: u32) u32 {
+ return switch (id) {
+ 0 => engine.tick,
+ 1 => public(engine.last_error),
+ 2 => engine.error_pc,
+ 3 => engine.last_ops,
+ 4 => engine.revision,
+ 5 => engine.active.state_count,
+ 6 => engine.active.instruction_count,
+ 7 => engine.active.handler_count,
+ 8 => engine.active.input_count,
+ 9 => engine.successful_events,
+ else => 0,
+ };
+}
+
+export fn layout(id: u32) u32 {
+ return switch (id) {
+ 0 => @sizeOf(Engine),
+ 1 => @sizeOf(Program),
+ 2 => @sizeOf(Instruction),
+ 3 => @sizeOf(Handler),
+ 4 => engine.active.instruction_count * @sizeOf(Instruction) + engine.active.handler_count * @sizeOf(Handler),
+ 5 => engine.active.state_count * @sizeOf(f64),
+ 6 => @sizeOf(Program),
+ 7 => max_stack * @sizeOf(f64),
+ else => 0,
+ };
+}
+
+// Test helpers describe programs directly; the product compiler is separately
+// tested against this ABI. These tests exercise semantics and failure boundaries.
+fn ok(code: u32) !void {
+ try std.testing.expectEqual(@as(u32, 0), code);
+}
+
+fn emit(op: u32, arg: u32, value: f64) !void {
+ try ok(put_instruction(op, arg, value));
+}
+
+fn counterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try ok(commit_program());
+}
+
+test "blank program, no hidden application behavior, and reset" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 1), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(3));
+ try std.testing.expectEqual(@as(u32, 0), info(5));
+ try std.testing.expect(std.math.isNan(get_state(0)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 0)));
+ reset_state();
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 1), info(4));
+}
+
+test "arbitrary input counter and unused events" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(dispatch(2, 0));
+ try ok(dispatch(0, frame_dt));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(3));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(6));
+}
+
+test "charge and release behavior exists only in the authored instruction program" {
+ reset_all();
+ try ok(begin_program(3, 1)); // held, charge, released_value
+ for (0..3) |i| try ok(put_state(@intCast(i), 0, -1));
+ try emit(0, 0, 1);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 2));
+ try emit(1, 1, 0);
+ try emit(1, 0, 0);
+ try emit(2, 0, 0);
+ try emit(5, 0, 0);
+ try emit(3, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(0, 2, 6));
+ try emit(1, 1, 0);
+ try emit(20, 2, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(2, 8, 6));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ try ok(dispatch(1, 0));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try ok(dispatch(2, 0));
+ try std.testing.expectApproxEqAbs(@as(f64, 0.5), get_state(2), 1e-12);
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+}
+
+test "forward condition executes once and assignments and handlers are ordered" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 1, -1));
+ try ok(put_state(1, 0, -1));
+ try emit(1, 0, 0);
+ try emit(21, 6, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 7);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 0, 6));
+ try emit(1, 1, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 6, 4));
+ try ok(commit_program());
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 14), get_state(1));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 28), get_state(1));
+ try std.testing.expectEqual(@as(u32, 6), info(3));
+}
+
+test "branch to program end is valid" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, 2, 0);
+ try ok(put_handler(0, 0, 2));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+}
+
+test "one event rolls back writes from all handlers on arithmetic failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 8, -1));
+ try emit(0, 0, 99);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 2));
+ try emit(0, 0, 1);
+ try emit(0, 0, 0);
+ try emit(6, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 2, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 8), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(2));
+ try std.testing.expectEqual(@as(u32, 5), info(3));
+}
+
+test "nonfinite arithmetic and invalid clamp fail atomically" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, -1));
+ try emit(0, 0, 1e308);
+ try emit(0, 0, 1e308);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, 0));
+ try emit(0, 0, 1);
+ try emit(0, 0, 10);
+ try emit(0, 0, 2);
+ try emit(19, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 5));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.invalid), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+}
+
+test "stack underflow overflow and leftover values reject the event" {
+ for (0..3) |mode| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ if (mode == 0) {
+ try emit(3, 0, 0);
+ } else {
+ for (0..@as(usize, if (mode == 1) 65 else 1)) |_| try emit(0, 0, 7);
+ }
+ try ok(put_handler(0, 0, engine.workspace.staging.instruction_count));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.stack), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ }
+}
+
+test "failed candidates leave active values program revision and diagnostics unchanged" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ const old_revision = info(4);
+ const old_ops = info(3);
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(1, 5, 0);
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try std.testing.expectEqual(old_revision, info(4));
+ try std.testing.expectEqual(old_ops, info(3));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.invalid), put_instruction(100, 0, 0));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+}
+
+test "state preservation mapping reads active values at commit and reset uses new initials" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(2, 0));
+ try ok(put_state(0, 42, -1)); // new symbol first
+ try ok(put_state(1, 100, 0)); // old symbol moves from id 0 to 1
+ try ok(dispatch(1, 0)); // changed after staging began
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 42), get_state(0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 100), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(4));
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try std.testing.expect(std.math.isNan(get_state(0)));
+}
+
+test "capacity boundaries and sticky load errors" {
+ reset_all();
+ try std.testing.expectEqual(public(.capacity), begin_program(33, 0));
+ try std.testing.expectEqual(public(.capacity), begin_program(0, 9));
+ try ok(begin_program(32, 8));
+ for (0..32) |i| try ok(put_state(@intCast(i), @floatFromInt(i), -1));
+ for (0..512) |_| try emit(0, 0, 0);
+ for (0..64) |_| try ok(put_handler(16, 0, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 512), info(6));
+ try std.testing.expectEqual(@as(u32, 64), info(7));
+ try std.testing.expectEqual(@as(f64, 31), get_state(31));
+ try ok(begin_program(0, 0));
+ for (0..512) |_| try emit(0, 0, 0);
+ try std.testing.expectEqual(public(.capacity), put_instruction(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+ try ok(begin_program(0, 0));
+ for (0..64) |_| try ok(put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+}
+
+test "invalid declaration values state mappings event ids and handler ranges" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 0, 0));
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.non_finite), put_state(0, std.math.inf(f64), -1));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 1, -1));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.non_finite), put_instruction(0, 0, std.math.nan(f64)));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.bad_event), put_handler(1, 0, 0));
+ try ok(begin_program(0, 0));
+ try ok(put_handler(0, no_pc, 5));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 1);
+ try ok(put_handler(0, 0, 1));
+ try ok(put_handler(0, 0, 1));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "backward self out-of-handler branches and stores without handlers are rejected" {
+ for ([_]u32{ 0, 1, 3 }) |target| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, target, 0);
+ try emit(0, 0, 7);
+ try ok(put_handler(0, 0, 2));
+ try std.testing.expectEqual(public(.bad_branch), commit_program());
+ }
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try emit(0, 0, 5);
+ try emit(20, 0, 0);
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "event inputs and dt enforce deterministic frame contract" {
+ reset_all();
+ try counterProgram();
+ try std.testing.expectEqual(public(.bad_event), dispatch(3, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch(no_pc, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(1, frame_dt));
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, std.math.nan(f64)));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(u32, 0), info(1));
+ try std.testing.expectEqual(no_pc, info(2));
+}
+
+test "binding evaluation is pure on both success and failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 4, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try emit(2, 0, 0);
+ try emit(0, 0, 0);
+ try emit(21, 7, 0);
+ try ok(put_handler(0, 3, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ const before = engine;
+ try std.testing.expectEqual(@as(f64, 12), eval_at(0, 3));
+ try std.testing.expect(std.math.isNan(eval_at(0, 4))); // attempted store
+ try std.testing.expect(std.math.isNan(eval_at(4, 1))); // dt forbidden in views
+ try std.testing.expect(std.math.isNan(eval_at(5, 2))); // branch forbidden
+ try std.testing.expect(std.math.isNan(eval_at(no_pc, 7)));
+ try std.testing.expect(std.math.isNan(eval_at(0, no_pc)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 2))); // two results
+ // Compare only the live member of the untagged workspace, never padding
+ // or the inactive member's indeterminate storage.
+ try std.testing.expectEqual(before.staging_open, engine.staging_open);
+ inline for (std.meta.fields(Engine)) |field| {
+ if (comptime std.mem.eql(u8, field.name, "workspace")) {
+ if (before.staging_open) {
+ try std.testing.expectEqualDeep(before.workspace.staging, engine.workspace.staging);
+ } else {
+ try std.testing.expectEqualDeep(before.workspace.reuse, engine.workspace.reuse);
+ }
+ } else try std.testing.expectEqualDeep(@field(before, field.name), @field(engine, field.name));
+ }
+}
+
+test "arithmetic comparison logical min max and clamp expression semantics" {
+ const cases = [_]struct { op: u32, a: f64, b: f64, want: f64 }{
+ .{ .op = 3, .a = 7, .b = 2, .want = 9 },
+ .{ .op = 4, .a = 7, .b = 2, .want = 5 },
+ .{ .op = 5, .a = 7, .b = 2, .want = 14 },
+ .{ .op = 6, .a = 7, .b = 2, .want = 3.5 },
+ .{ .op = 8, .a = 2, .b = 7, .want = 1 },
+ .{ .op = 9, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 10, .a = 2, .b = 7, .want = 0 },
+ .{ .op = 11, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 12, .a = 7, .b = 2, .want = 0 },
+ .{ .op = 13, .a = 7, .b = 2, .want = 1 },
+ .{ .op = 14, .a = -7, .b = 2, .want = 1 },
+ .{ .op = 15, .a = 0, .b = 2, .want = 1 },
+ .{ .op = 17, .a = 7, .b = 2, .want = 2 },
+ .{ .op = 18, .a = 7, .b = 2, .want = 7 },
+ };
+ for (cases) |case| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, case.a);
+ try emit(0, 0, case.b);
+ try emit(case.op, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(case.want, eval_at(0, 3));
+ }
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, -8);
+ try emit(7, 0, 0);
+ try emit(0, 0, 0);
+ try emit(0, 0, 5);
+ try emit(19, 0, 0);
+ try emit(16, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 5), eval_at(0, 5));
+ try std.testing.expectEqual(@as(f64, 0), eval_at(0, 6));
+}
+
+test "diagnostic counters wrap without affecting scalar values" {
+ reset_all();
+ try counterProgram();
+ engine.tick = no_pc;
+ engine.successful_events = no_pc;
+ engine.revision = no_pc;
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 99, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 0), info(4));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+}
+
+test "memory accounting separates reserved storage used IR and stack capacity" {
+ reset_all();
+ try std.testing.expectEqual(@as(u32, 0), layout(4));
+ try std.testing.expectEqual(@as(u32, 0), layout(5));
+ try counterProgram();
+ try std.testing.expectEqual(@as(u32, @sizeOf(Engine)), layout(0));
+ try std.testing.expectEqual(@as(u32, 16), layout(2));
+ try std.testing.expectEqual(@as(u32, 12), layout(3));
+ try std.testing.expectEqual(@as(u32, 76), layout(4));
+ try std.testing.expectEqual(@as(u32, 8), layout(5));
+ try std.testing.expectEqual(layout(1), layout(6));
+ try std.testing.expect(layout(0) >= layout(1) * 2);
+ try std.testing.expectEqual(@as(u32, 512), layout(7));
+ try std.testing.expect(layout(0) + 16 * 1024 < 64 * 1024);
+}
+
+fn displayCounterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try emit(1, 0, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try ok(configure_evaluation(32, 8));
+}
+
+fn boundedEquals(want: f64, start: u32, length: u32) !void {
+ begin_evaluation(100);
+ try std.testing.expectEqual(want, eval_bounded(start, length));
+ try ok(evaluation_info(0));
+}
+
+test "loading shares storage with records while old events and bindings remain executable" {
+ reset_all();
+ try displayCounterProgram();
+ try boundedEquals(20, 4, 3);
+ try boundedEquals(20, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(9));
+ try emit(1, 0, 0);
+ try ok(dispatch(1, 0)); // Active state advances after staging begins.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(3));
+ try ok(configure_evaluation(32, 1));
+ forget_evaluation(3); // Must not overwrite the pending program.
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(33, 0, 3);
+ try boundedEquals(33, 0, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+}
+
+test "failed and abandoned candidates restore empty reuse storage without changing active work" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(20, 0, 0); // Store without a handler fails validation.
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try boundedEquals(20, 4, 3);
+ try ok(dispatch(1, 0));
+ abort_program();
+ try boundedEquals(22, 4, 3);
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ abort_program(); // Inactive abort is a no-op, including for warmed records.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try std.testing.expectEqual(public(.capacity), begin_program(max_states + 1, 0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+}
+
+test "full pending program survives repeated evaluation configuration record loss and reset" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ for (0..max_instructions) |i| {
+ try emit(0, 0, @floatFromInt(i));
+ try ok(configure_evaluation(32, @intCast(i % 9)));
+ forget_evaluation(3);
+ try boundedEquals(22, 4, 3);
+ }
+ reset_state(); // The pending definition and old-id mapping survive reset.
+ try boundedEquals(20, 4, 3);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, max_instructions), info(6));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try boundedEquals(511, 511, 1);
+ // Safe Zig builds may reserve a hidden union tag in addition to the payload.
+ try std.testing.expect(@sizeOf(Workspace) >= @sizeOf(Program));
+ try std.testing.expect(@sizeOf(Workspace) <= @sizeOf(Program) + @alignOf(Program));
+ try std.testing.expectEqual(@as(u32, @sizeOf(Tables)), evaluation_info(8));
+}
diff --git a/prototypes/workbench/results/2026-09-23-presentation/before/evaluation.zig b/prototypes/workbench/results/2026-09-23-presentation/before/evaluation.zig
new file mode 100644
index 0000000..56081d9
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-presentation/before/evaluation.zig
@@ -0,0 +1,164 @@
+//! Disposable binding results and change history. No event state lives here.
+const std = @import("std");
+
+const Memo = struct {
+ value: f64 = 0,
+ revision: u64 = 0,
+ dependencies: u32 = 0,
+ start: u32 = 0,
+ length: u32 = 0,
+};
+const Change = struct { revision: u64 = 0, mask: u32 = 0 };
+
+pub const Tables = struct {
+ memo: [32]Memo = @splat(.{}),
+ history: [8]Change = @splat(.{}),
+};
+
+pub const Evaluation = struct {
+ pub const max_cache = 32;
+ pub const max_history = 8;
+ revision: u64 = 1,
+ floor: u64 = 1,
+ cache_count: u32 = 0,
+ history_count: u32 = 8,
+ next_change: u32 = 0,
+ limit: u32 = 0,
+ work: u32 = 0,
+ instructions: u32 = 0,
+ hits: u32 = 0,
+ status: u32 = 0,
+
+ pub fn configure(self: *Evaluation, tables: ?*Tables, cache: u32, history: u32) bool {
+ if (cache > max_cache or history > max_history) return false;
+ self.cache_count = cache;
+ self.history_count = history;
+ self.forget(tables, 3);
+ return true;
+ }
+
+ pub fn forget(self: *Evaluation, tables: ?*Tables, flags: u32) void {
+ if (flags & 1 != 0) if (tables) |t| { t.memo = @splat(.{}); };
+ if (flags & 2 != 0) {
+ if (tables) |t| t.history = @splat(.{});
+ self.floor = self.revision;
+ self.next_change = 0;
+ }
+ }
+
+ pub fn changed(self: *Evaluation, tables: ?*Tables, mask: u32) void {
+ const t = tables orelse return;
+ if (mask == 0 or self.cache_count == 0) return;
+ if (self.revision == std.math.maxInt(u64)) {
+ self.revision = 1;
+ self.forget(t, 3);
+ }
+ self.revision += 1;
+ if (self.history_count == 0) {
+ self.floor = self.revision;
+ return;
+ }
+ const change = &t.history[self.next_change];
+ self.floor = @max(self.floor, change.revision);
+ change.* = .{ .revision = self.revision, .mask = mask };
+ self.next_change = (self.next_change + 1) % self.history_count;
+ }
+
+ pub fn begin(self: *Evaluation, limit: u32) void {
+ self.limit = limit;
+ self.work = 0;
+ self.instructions = 0;
+ self.hits = 0;
+ self.status = 0;
+ }
+
+ pub fn spend(self: *Evaluation) bool {
+ if (self.work == self.limit) {
+ self.status = 10;
+ return false;
+ }
+ self.work += 1;
+ return true;
+ }
+
+ fn slot(self: *const Evaluation, start: u32, length: u32) u32 {
+ // One candidate, including on misses; no scan proportional to cache size.
+ return (start ^ (start >> 5) ^ (length *% 0x9e3779b9)) % self.cache_count;
+ }
+
+ pub fn lookup(self: *Evaluation, tables: ?*Tables, start: u32, length: u32) ?f64 {
+ const t = tables orelse return null;
+ if (self.cache_count == 0) return null;
+ if (!self.spend()) return null;
+ const memo = &t.memo[self.slot(start, length)];
+ if (memo.revision == 0 or memo.start != start or memo.length != length) return null;
+ // Successful pure constants need no input-change evidence. A program
+ // replacement still clears them, just like every other binding memo.
+ if (memo.dependencies != 0 and memo.revision < self.floor) return null;
+ if (memo.dependencies != 0 and memo.revision != self.revision) {
+ for (t.history[0..self.history_count]) |change| {
+ if (!self.spend()) return null;
+ if (change.revision > memo.revision and change.mask & memo.dependencies != 0) return null;
+ }
+ }
+ memo.revision = self.revision;
+ self.hits += 1;
+ return memo.value;
+ }
+
+ pub fn remember(self: *Evaluation, tables: ?*Tables, start: u32, length: u32, value: f64, dependencies: u32) void {
+ const t = tables orelse return;
+ if (self.cache_count == 0) return;
+ t.memo[self.slot(start, length)] = .{ .start = start, .length = length,
+ .value = value, .dependencies = dependencies, .revision = self.revision };
+ }
+};
+
+test "record loss cannot authorize an old memo; unrelated changes can" {
+ var tables: Tables = .{};
+ var e: Evaluation = .{};
+ try std.testing.expect(e.configure(&tables, 1, 1));
+ e.begin(100);
+ e.remember(&tables, 0, 3, 12, 1);
+ e.changed(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, 12), e.lookup(&tables, 0, 3));
+ e.changed(&tables, 2);
+ e.changed(&tables, 2); // The certificate now predates the surviving history.
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 12, 1);
+ e.changed(&tables, 1);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 20, 1);
+ e.changed(&tables, 2);
+ e.forget(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 20, 1);
+ e.revision = std.math.maxInt(u64);
+ e.changed(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+}
+
+test "budget, collisions, invalid settings, and disabled history are conservative" {
+ var tables: Tables = .{};
+ var e: Evaluation = .{};
+ try std.testing.expect(e.configure(&tables, 1, 0));
+ e.begin(100);
+ e.remember(&tables, 0, 3, 12, 1);
+ e.remember(&tables, 1, 3, 15, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ try std.testing.expectEqual(@as(?f64, 15), e.lookup(&tables, 1, 3));
+ e.changed(&tables, 0x80000000);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 1, 3));
+ try std.testing.expect(!e.configure(&tables, 33, 0));
+ try std.testing.expectEqual(@as(u32, 1), e.cache_count);
+ e.begin(0);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 1, 3));
+ try std.testing.expectEqual(@as(u32, 10), e.status);
+ try std.testing.expectEqual(@as(u32, 0), e.work);
+ e.begin(100);
+ e.remember(&tables, 2, 1, 99, 0);
+ e.changed(&tables, 1);
+ e.forget(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, 99), e.lookup(&tables, 2, 1));
+ try std.testing.expectEqual(@as(u32, 1), e.work);
+}
diff --git a/prototypes/workbench/results/2026-09-23-presentation/before/manifest.json b/prototypes/workbench/results/2026-09-23-presentation/before/manifest.json
new file mode 100644
index 0000000..5a8a5bb
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-presentation/before/manifest.json
@@ -0,0 +1,10 @@
+{
+ "web/runtime.mjs": "8516b0d7d89f5fd483b8c48cfc33bbb2bcbe8c27db3aa35b5f6cf892e89faa6e",
+ "web/app.js": "6d63f3bfdfe212146894dbb1772509eb4ecc2f3be3b106675f0fc10cdeb5bfd3",
+ "web/core.wasm": "76823b024fa5f4a79cbf1ed722ab757614a81a9f1c81d1d6268781a38bed7cc9",
+ "web/compiler.mjs": "a34f3beb069c811a9bd5be73dde9341907ba77756066030b8b95fc6d66a5cc02",
+ "web/index.html": "4fff6eb67d8cff098302465449251d17f0efb39d9144e87d15fefcc152e5d97e",
+ "web/style.css": "43301298028c5bc431eecd5ff30d7362fae83cce1fd0fdd7e8bea858100c22b9",
+ "core.zig": "dc84bcb75e4417fb1a804529c78ffa8cd1ddd079c00df4ca97a35da0b8fdbb8a",
+ "evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165"
+}
diff --git a/prototypes/workbench/results/2026-09-23-presentation/before/web/app.js b/prototypes/workbench/results/2026-09-23-presentation/before/web/app.js
new file mode 100644
index 0000000..c931b1a
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-presentation/before/web/app.js
@@ -0,0 +1,427 @@
+import { createRuntime } from './runtime.mjs';
+
+const $ = id => document.getElementById(id);
+const source = $('source');
+const MAX_SOURCE_BYTES = 32768;
+const encoder = new TextEncoder();
+const NS = 'http://www.w3.org/2000/svg';
+const snippets = {
+ state: 'state count = 0\nstate x = 80\n',
+ input: 'input tap = Space\non tap.down {\n count = count + 1\n}\n',
+ view: 'box tile(x, 180, 40, 40, "#7ed9ae")\ntext label(40, 60, count, "Count: ")\n',
+ frame: 'on frame {\n x = x + 30 * dt\n if x > 600 { x = 0 }\n}\n',
+};
+let runtime = null;
+let appliedSource = '';
+let running = false;
+let previousTime = null;
+let accumulator = 0;
+let droppedTicks = 0;
+let diagnosticLocation = null;
+const keyboardSources = new Map();
+const pointerSources = new Map();
+const inputButtons = new Map();
+
+const bytes = text => encoder.encode(text).byteLength;
+const dirty = () => source.value !== appliedSource;
+const number = value => Number.isInteger(value) ? String(value) : Number(value.toPrecision(7)).toString();
+const formatBytes = value => `${Number(value).toLocaleString('en-US')} B`;
+
+function setDiagnostic(message, kind = '', location = null) {
+ $('diagnostic-text').textContent = message;
+ $('diagnostic').className = `diagnostic ${kind}`;
+ diagnosticLocation = location;
+ $('error-location').hidden = !location;
+ source.setAttribute('aria-invalid', kind === 'error' && location ? 'true' : 'false');
+}
+
+function cursorPosition() {
+ const before = source.value.slice(0, source.selectionStart);
+ const lines = before.split('\n');
+ $('cursor-position').textContent = `${lines.length}행 · ${lines.at(-1).length + 1}열`;
+}
+
+function draftChanged() {
+ const count = source.value.split('\n').length;
+ $('line-numbers').textContent = Array.from({ length: count }, (_, i) => i + 1).join('\n');
+ $('line-numbers').scrollTop = source.scrollTop;
+ $('source-size').textContent = `${bytes(source.value).toLocaleString('en-US')} / 32,768 bytes`;
+ $('source-size').style.color = bytes(source.value) > MAX_SOURCE_BYTES ? 'var(--danger)' : '';
+ $('draft-status').textContent = dirty() ? '미적용 변경' : runtime?.currentProgram ? '적용됨' : '빈 작업물';
+ $('draft-status').classList.toggle('dirty', dirty());
+ cursorPosition();
+}
+
+function pause() {
+ running = false;
+ previousTime = null;
+ accumulator = 0;
+ $('play').textContent = '▶ 실행';
+ $('play').setAttribute('aria-pressed', 'false');
+}
+
+function safeAction(action) {
+ if (!runtime) return;
+ try { action(); }
+ catch (error) { pause(); setDiagnostic(error.message, 'error', error.line ? { line: error.line, column: error.column || 1 } : null); }
+ refresh();
+}
+
+function releaseEverything() {
+ keyboardSources.clear();
+ pointerSources.clear();
+ if (runtime) safeAction(() => runtime.releaseAll());
+}
+
+function loseFocus() {
+ pause();
+ releaseEverything();
+ refresh();
+}
+
+function svgElement(type, attrs, text) {
+ const node = document.createElementNS(NS, type);
+ for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, String(value));
+ if (text !== undefined) node.textContent = text;
+ return node;
+}
+
+function renderScene(views) {
+ const elements = [];
+ for (const view of views) {
+ let element;
+ const attrs = { 'data-view-id': view.id };
+ if (view.type === 'box') element = svgElement('rect', { ...attrs, x: view.x, y: view.y, width: Math.max(0, view.width), height: Math.max(0, view.height), fill: view.color });
+ else if (view.type === 'circle') element = svgElement('circle', { ...attrs, cx: view.x, cy: view.y, r: Math.max(0, view.radius), fill: view.color });
+ else if (view.type === 'text') element = svgElement('text', { ...attrs, x: view.x, y: view.y, fill: '#e7ece6', 'font-family': 'ui-monospace, monospace', 'font-size': 20 }, `${view.prefix || ''}${number(view.value)}`);
+ if (element) elements.push(element);
+ }
+ $('scene-content').replaceChildren(...elements);
+ $('empty-scene').hidden = elements.length !== 0;
+ $('scene-description').textContent = elements.length ? `사용자가 만든 장면. 도형과 글자 ${elements.length}개.` : '사용자가 코드로 만든 장면. 현재 비어 있습니다.';
+}
+
+function renderStates(states) {
+ const children = [];
+ for (const [name, value] of Object.entries(states)) {
+ const holder = document.createElement('dl');
+ holder.className = 'state-value';
+ const label = document.createElement('dt');
+ label.textContent = name;
+ const amount = document.createElement('dd');
+ amount.textContent = number(value);
+ amount.dataset.state = name;
+ holder.append(label, amount);
+ children.push(holder);
+ }
+ if (!children.length) {
+ const message = document.createElement('p');
+ message.className = 'empty-note';
+ message.textContent = '선언한 상태의 값이 표시됩니다.';
+ children.push(message);
+ }
+ $('state-values').replaceChildren(...children);
+}
+
+function renderCosts(snapshot) {
+ const memory = snapshot.memory || {};
+ const metrics = runtime.currentProgram?.metrics || {};
+ const rows = [
+ ['Wasm 선형 메모리', memory.linearBytes],
+ ['코어 예약 공간', memory.reservedEngineBytes],
+ ['사용 중인 명령·핸들러', memory.activeIRBytes],
+ ['현재 상태 값', memory.activeStateBytes],
+ ['새 코드 준비 공간 (예약에 포함)', memory.stagingReservedBytes],
+ ['평가 스택 용량', memory.evalStackBytes],
+ ];
+ const children = [];
+ for (const [label, amount] of rows) {
+ if (!Number.isFinite(amount)) continue;
+ const dt = document.createElement('dt'); dt.textContent = label;
+ const dd = document.createElement('dd'); dd.textContent = formatBytes(amount);
+ children.push(dt, dd);
+ }
+ const dt = document.createElement('dt'); dt.textContent = '마지막 이벤트 실행 명령';
+ const dd = document.createElement('dd'); dd.textContent = `${snapshot.lastOps || 0}개`;
+ children.push(dt, dd);
+ const sourceBytes = metrics.sourceBytes;
+ if (Number.isFinite(sourceBytes)) {
+ const a = document.createElement('dt'); a.textContent = '적용된 소스 크기';
+ const b = document.createElement('dd'); b.textContent = formatBytes(sourceBytes);
+ children.push(a, b);
+ }
+ $('cost-values').replaceChildren(...children);
+ $('dropped-ticks').textContent = `시간 지연으로 건너뛴 프레임: ${droppedTicks}`;
+}
+
+function refresh() {
+ if (!runtime) return;
+ const snapshot = runtime.snapshot();
+ if (snapshot.lastError) pause();
+ renderScene(snapshot.views || []);
+ renderStates(snapshot.states || {});
+ const held = new Set((snapshot.held || []).map(input => typeof input === 'string' ? input : input.name));
+ for (const [name, button] of inputButtons) {
+ button.classList.toggle('held', held.has(name));
+ button.setAttribute('aria-pressed', String(held.has(name)));
+ }
+ $('scene-status').textContent = runtime.currentProgram ? (running ? '실행 중' : '정지') : '적용 전';
+ $('clock-status').textContent = `${snapshot.tick || 0} frame · ${running ? '60 Hz' : '정지'}`;
+ $('revision').textContent = `revision ${snapshot.revision || 0}`;
+ for (const id of ['play', 'step', 'reset']) $(id).disabled = !runtime.currentProgram;
+ const event = snapshot.lastEvent;
+ $('last-event').textContent = event ? `${event.name}${event.phase && event.phase !== 'frame' ? `.${event.phase}` : ''} · ${event.status ? '실패' : '완료'}${event.line ? ` · ${event.line}행` : ''}` : '아직 없음';
+ $('runtime-error').hidden = !snapshot.lastError;
+ const displayError = snapshot.lastError === 100 || snapshot.lastError === 101;
+ $('runtime-error').textContent = snapshot.lastError ? `${displayError ? '표시 오류' : '마지막 실패'} · ${snapshot.errorLine ? `${snapshot.errorLine}행 · ` : ''}${snapshot.errorMessage || `실행 오류 ${snapshot.lastError}`}${snapshot.lastError === 101 ? ' — 이번 장면을 표시하지 않았습니다. 상태 변경은 유지됩니다.' : displayError ? ' — 해당 도형을 표시할 수 없습니다.' : ' — 실패한 이벤트의 상태 변경은 취소됐습니다. 코드를 적용하거나 초깃값으로 되돌리면 이 기록을 지웁니다.'}` : '';
+ if (!displayError && snapshot.displayError?.code === 101)
+ $('runtime-error').textContent += ` · ${snapshot.displayError.message}`;
+ renderCosts(snapshot);
+ draftChanged();
+}
+
+function endPointer(event) {
+ const entry = pointerSources.get(event.pointerId);
+ if (!entry) return;
+ event.preventDefault();
+ pointerSources.delete(event.pointerId);
+ safeAction(() => runtime.up(entry.name, entry.source));
+}
+
+function rebuildInputControls() {
+ inputButtons.clear();
+ const children = [];
+ for (const input of runtime.currentProgram?.inputs || []) {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'input-button';
+ button.dataset.input = input.name;
+ button.setAttribute('aria-pressed', 'false');
+ const name = document.createElement('span'); name.textContent = input.name;
+ const key = document.createElement('kbd'); key.textContent = input.key;
+ button.append(name, key);
+ button.addEventListener('pointerdown', event => {
+ if (event.button !== 0) return;
+ event.preventDefault();
+ const identity = `pointer:${event.pointerId}`;
+ pointerSources.set(event.pointerId, { name: input.name, source: identity });
+ button.setPointerCapture(event.pointerId);
+ safeAction(() => runtime.down(input.name, identity));
+ });
+ button.addEventListener('pointerup', endPointer);
+ button.addEventListener('pointercancel', endPointer);
+ button.addEventListener('lostpointercapture', endPointer);
+ // Keyboard activation and assistive technology clicks are complete taps.
+ // Pointer clicks already delivered down/up via the handlers above.
+ button.addEventListener('click', event => {
+ if (event.detail !== 0) return;
+ safeAction(() => { runtime.down(input.name, 'activation'); runtime.up(input.name, 'activation'); });
+ });
+ inputButtons.set(input.name, button);
+ children.push(button);
+ }
+ if (!children.length) {
+ const message = document.createElement('p');
+ message.className = 'empty-note';
+ message.textContent = '입력을 선언하면 여기에 버튼이 생깁니다.';
+ children.push(message);
+ }
+ $('input-controls').replaceChildren(...children);
+}
+
+function isEditing(target) {
+ return target instanceof Element && Boolean(target.closest('textarea,input,select,button,[contenteditable="true"],[role="textbox"]'));
+}
+
+$('scene').addEventListener('pointerdown', () => $('scene').focus());
+
+document.addEventListener('keydown', event => {
+ if (!runtime || event.repeat || event.isComposing || event.ctrlKey || event.metaKey || event.altKey || isEditing(event.target)) return;
+ const input = runtime.currentProgram?.inputs.find(item => item.key === event.code);
+ if (!input) return;
+ event.preventDefault();
+ if (keyboardSources.has(event.code)) return;
+ keyboardSources.set(event.code, input.name);
+ safeAction(() => runtime.down(input.name, `keyboard:${event.code}`));
+});
+
+document.addEventListener('keyup', event => {
+ const name = keyboardSources.get(event.code);
+ if (!name) return;
+ event.preventDefault();
+ keyboardSources.delete(event.code);
+ safeAction(() => runtime.up(name, `keyboard:${event.code}`));
+});
+
+source.addEventListener('input', () => {
+ draftChanged();
+ if (diagnosticLocation) setDiagnostic('코드를 수정했습니다. 적용해서 다시 확인하세요.');
+});
+source.addEventListener('scroll', () => { $('line-numbers').scrollTop = source.scrollTop; });
+for (const name of ['click', 'keyup', 'select']) source.addEventListener(name, cursorPosition);
+source.addEventListener('keydown', event => {
+ if (event.key === 'Tab' && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
+ event.preventDefault();
+ source.setRangeText(' ', source.selectionStart, source.selectionEnd, 'end');
+ draftChanged();
+ }
+});
+
+$('apply').addEventListener('click', () => {
+ if (!runtime) return;
+ try {
+ runtime.apply(source.value);
+ appliedSource = source.value;
+ rebuildInputControls();
+ setDiagnostic('코드를 적용했습니다. 같은 이름의 상태는 유지됩니다.', 'success');
+ } catch (error) {
+ const location = error.line ? { line: error.line, column: error.column || 1 } : null;
+ setDiagnostic(`${location ? `${location.line}행 ${location.column}열 · ` : ''}${error.message} · 기존 장면은 그대로 유지됩니다.`, 'error', location);
+ }
+ refresh();
+});
+
+$('error-location').addEventListener('click', () => {
+ if (!diagnosticLocation) return;
+ const lines = source.value.split('\n');
+ const lineIndex = Math.min(lines.length - 1, diagnosticLocation.line - 1);
+ const start = lines.slice(0, lineIndex).reduce((sum, line) => sum + line.length + 1, 0);
+ const at = start + Math.min(lines[lineIndex].length, diagnosticLocation.column - 1);
+ source.focus();
+ source.setSelectionRange(at, Math.min(at + 1, source.value.length));
+ const lineHeight = parseFloat(getComputedStyle(source).lineHeight);
+ source.scrollTop = Math.max(0, lineIndex * lineHeight - source.clientHeight / 3);
+ $('line-numbers').scrollTop = source.scrollTop;
+ cursorPosition();
+});
+
+$('play').addEventListener('click', () => {
+ if (running) pause();
+ else { running = true; previousTime = null; accumulator = 0; $('play').textContent = 'Ⅱ 정지'; $('play').setAttribute('aria-pressed', 'true'); }
+ refresh();
+});
+$('step').addEventListener('click', () => { pause(); safeAction(() => runtime.frame()); });
+$('reset').addEventListener('click', () => {
+ pause(); releaseEverything(); droppedTicks = 0;
+ safeAction(() => runtime.reset());
+ setDiagnostic('적용된 코드의 초깃값으로 되돌렸습니다. 편집 중인 코드는 유지됩니다.');
+});
+
+function replaceDraft(text, message) {
+ source.value = text;
+ source.scrollTop = 0;
+ source.setSelectionRange(0, 0);
+ setDiagnostic(message);
+ draftChanged();
+}
+
+function mayReplaceDraft() {
+ return !source.value || window.confirm('현재 편집 중인 코드를 바꿀까요? 저장하지 않은 내용은 사라집니다. 실행 장면은 적용 전까지 유지됩니다.');
+}
+
+$('new').addEventListener('click', () => {
+ if (mayReplaceDraft()) replaceDraft('', '편집기만 비웠습니다. 빈 코드를 적용하면 장면도 비워집니다.');
+});
+
+for (const button of document.querySelectorAll('.insert-snippet')) {
+ button.addEventListener('click', () => {
+ const at = source.selectionStart;
+ const prefix = at > 0 && source.value[at - 1] !== '\n' ? '\n' : '';
+ source.setRangeText(prefix + snippets[button.dataset.snippet], source.selectionStart, source.selectionEnd, 'end');
+ source.focus();
+ draftChanged();
+ setDiagnostic('조각을 삽입했습니다. 이름과 동작을 원하는 대로 바꾸고 적용하세요.');
+ });
+}
+
+$('load-example').addEventListener('click', async () => {
+ if (!mayReplaceDraft()) return;
+ const selected = $('example').value;
+ const before = source.value;
+ const button = $('load-example');
+ button.disabled = true;
+ try {
+ const response = await fetch(`./examples/${selected}.pure`);
+ if (!response.ok) throw new Error(`예제를 불러오지 못했습니다 (${response.status}).`);
+ const text = await response.text();
+ if (bytes(text) > MAX_SOURCE_BYTES) throw new Error('예제가 코드 크기 제한을 넘었습니다.');
+ if (source.value !== before) throw new Error('불러오는 동안 코드가 바뀌었습니다. 변경 내용을 지키기 위해 불러오기를 취소했습니다.');
+ replaceDraft(text, '예제를 편집기에 불러왔습니다. 아직 적용하지 않았습니다.');
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+ finally { button.disabled = false; }
+});
+
+function download(filename, content, mime) {
+ const url = URL.createObjectURL(new Blob([content], { type: mime }));
+ const link = document.createElement('a');
+ link.href = url; link.download = filename;
+ document.body.append(link); link.click(); link.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ setDiagnostic('편집 중인 코드를 파일로 저장했습니다. 적용되지 않은 변경도 포함합니다.');
+}
+$('download').addEventListener('click', () => download('my-work.pure', source.value, 'text/plain;charset=utf-8'));
+$('download-project').addEventListener('click', () => download('my-work.pure.json', JSON.stringify({ format: 'pure-workbench', version: 1, source: source.value }, null, 2) + '\n', 'application/json'));
+$('upload').addEventListener('click', () => $('file-input').click());
+$('file-input').addEventListener('change', async event => {
+ const file = event.target.files[0];
+ event.target.value = '';
+ if (!file) return;
+ try {
+ if (file.size > MAX_SOURCE_BYTES * 7 + 1024) throw new Error('파일이 너무 큽니다. 코드 제한은 32 KiB입니다.');
+ const before = source.value;
+ let content = await file.text();
+ if (file.name.toLowerCase().endsWith('.json')) {
+ let project;
+ try { project = JSON.parse(content); } catch { throw new Error('올바른 JSON 파일이 아닙니다.'); }
+ if (!project || typeof project !== 'object' || Array.isArray(project) || project.format !== 'pure-workbench' || project.version !== 1 || typeof project.source !== 'string') throw new Error('Pure 작업물 형식이 아닙니다. format, version, source를 확인하세요.');
+ content = project.source;
+ }
+ if (bytes(content) > MAX_SOURCE_BYTES) throw new Error('코드가 32 KiB 제한을 넘었습니다.');
+ if (source.value !== before) throw new Error('파일을 읽는 동안 코드가 바뀌었습니다. 불러오기를 다시 시도하세요.');
+ if (mayReplaceDraft()) replaceDraft(content, `${file.name}을 편집기에 불러왔습니다. 아직 적용하지 않았습니다.`);
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+});
+
+window.addEventListener('blur', loseFocus);
+document.addEventListener('visibilitychange', () => { if (document.hidden) loseFocus(); });
+window.addEventListener('beforeunload', event => {
+ // Applying is not persistence: even applied code can be lost when leaving.
+ if (source.value || appliedSource) { event.preventDefault(); event.returnValue = ''; }
+});
+
+function animate(time) {
+ if (running && runtime) {
+ if (previousTime !== null) {
+ accumulator += Math.max(0, time - previousTime);
+ const wanted = Math.floor(accumulator / (1000 / 60));
+ if (wanted > 0) {
+ const steps = Math.min(6, wanted);
+ accumulator -= wanted * (1000 / 60);
+ droppedTicks += wanted - steps;
+ safeAction(() => runtime.frame(steps));
+ }
+ }
+ previousTime = running ? time : null;
+ }
+ requestAnimationFrame(animate);
+}
+
+Object.defineProperty(window, 'pureWorkbench', {
+ configurable: false,
+ writable: false,
+ value: Object.freeze({ snapshot: () => structuredClone({ ...(runtime?.snapshot() || {}), ready: Boolean(runtime), source: source.value, appliedSource, dirty: dirty(), running, droppedTicks, diagnostic: $('diagnostic-text').textContent }) }),
+});
+
+draftChanged();
+requestAnimationFrame(animate);
+try {
+ runtime = await createRuntime();
+ $('ready-status').textContent = '실행기 준비됨';
+ $('ready-dot').classList.add('ready');
+ $('apply').disabled = false;
+ refresh();
+} catch (error) {
+ $('ready-status').textContent = '실행기 준비 실패';
+ $('ready-dot').classList.add('failed');
+ setDiagnostic(`실행기를 열지 못했습니다. ${error.message}`, 'error');
+}
diff --git a/prototypes/workbench/results/2026-09-23-presentation/before/web/compiler.mjs b/prototypes/workbench/results/2026-09-23-presentation/before/web/compiler.mjs
new file mode 100644
index 0000000..016ea9e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-presentation/before/web/compiler.mjs
@@ -0,0 +1,303 @@
+// Pure's experimental scalar interaction language. No host code is evaluated.
+const LIMITS = Object.freeze({ sourceBytes: 32768, states: 32, inputs: 8,
+ views: 24, handlers: 64, instructions: 512, depth: 64, stack: 64 });
+const RESERVED = new Set(['frame', 'dt', 'min', 'max', 'clamp']);
+const BINARY = new Map([
+ ['||', [1, 15]], ['&&', [2, 14]], ['==', [3, 12]], ['!=', [3, 13]],
+ ['<', [4, 8]], ['<=', [4, 9]], ['>', [4, 10]], ['>=', [4, 11]],
+ ['+', [5, 3]], ['-', [5, 4]], ['*', [6, 5]], ['/', [6, 6]],
+]);
+const FUNCTIONS = { min: [2, 17], max: [2, 18], clamp: [3, 19] };
+const VIEW_FIELDS = { box: ['x', 'y', 'width', 'height'],
+ circle: ['x', 'y', 'radius'], text: ['x', 'y', 'value'] };
+
+function fail(token, message) {
+ const error = new Error(`${message} (${token.line}:${token.column})`);
+ error.line = token.line;
+ error.column = token.column;
+ throw error;
+}
+
+function lex(source) {
+ const tokens = [];
+ let offset = 0, line = 1, column = 1;
+ const advance = () => {
+ const char = source[offset++];
+ if (char === '\n') { line++; column = 1; } else column++;
+ return char;
+ };
+ while (offset < source.length) {
+ const char = source[offset];
+ if (char === ' ' || char === '\t' || char === '\r') { advance(); continue; }
+ if (char === '#') { while (offset < source.length && source[offset] !== '\n') advance(); continue; }
+ const token = { line, column };
+ if (char === '\n') { advance(); tokens.push({ ...token, kind: 'nl' }); continue; }
+ if (char === '"') {
+ advance();
+ let value = '', closed = false;
+ while (offset < source.length) {
+ const next = advance();
+ if (next === '"') { closed = true; break; }
+ if (next === '\n' || next === '\r') fail(token, 'Strings must fit on one line');
+ if (next === '\\') {
+ const escaped = advance();
+ const escapes = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' };
+ if (!Object.hasOwn(escapes, escaped)) fail(token, 'Unsupported string escape');
+ value += escapes[escaped];
+ } else value += next;
+ }
+ if (!closed) fail(token, 'Unterminated string');
+ tokens.push({ ...token, kind: 'string', value }); continue;
+ }
+ const remaining = source.slice(offset);
+ const number = remaining.match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/);
+ if (number) {
+ const value = Number(number[0]);
+ if (!Number.isFinite(value)) fail(token, 'Number must be finite');
+ for (let i = 0; i < number[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'number', value }); continue;
+ }
+ const name = remaining.match(/^[A-Za-z_][A-Za-z_0-9]*/);
+ if (name) {
+ for (let i = 0; i < name[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'name', value: name[0] }); continue;
+ }
+ const pair = source.slice(offset, offset + 2);
+ if (['<=', '>=', '==', '!=', '&&', '||'].includes(pair)) {
+ advance(); advance(); tokens.push({ ...token, kind: pair }); continue;
+ }
+ if ('=+-*/!<>(){},.;'.includes(char)) {
+ advance(); tokens.push({ ...token, kind: char }); continue;
+ }
+ fail(token, `Unexpected character ${JSON.stringify(char)}`);
+ }
+ tokens.push({ kind: 'eof', line, column });
+ return tokens;
+}
+
+class Parser {
+ constructor(tokens) { this.tokens = tokens; this.position = 0; this.depth = 0; }
+ peek() { return this.tokens[this.position]; }
+ take() { return this.tokens[this.position++]; }
+ is(kind, value) { return this.peek().kind === kind && (value === undefined || this.peek().value === value); }
+ expect(kind, description = kind) {
+ if (!this.is(kind)) fail(this.peek(), `Expected ${description}`);
+ return this.take();
+ }
+ newlines() { while (this.is('nl')) this.take(); }
+ separators() { while (this.is('nl') || this.is(';')) this.take(); }
+ end() {
+ if (!['nl', ';', '}', 'eof'].includes(this.peek().kind)) fail(this.peek(), 'Expected newline or semicolon');
+ this.separators();
+ }
+ node(token, properties, children = []) {
+ const depth = 1 + Math.max(0, ...children.map(child => child.depth));
+ if (depth > LIMITS.depth) fail(token, 'Expression nesting exceeds 64');
+ return { token, depth, ...properties };
+ }
+ expression(minimum = 0, multiline = false) {
+ if (++this.depth > LIMITS.depth) fail(this.peek(), 'Expression nesting exceeds 64');
+ if (multiline) this.newlines();
+ const token = this.take();
+ let left;
+ if (token.kind === 'number') left = this.node(token, { kind: 'constant', value: token.value });
+ else if (token.kind === '-' || token.kind === '!') {
+ const child = this.expression(7, multiline);
+ left = this.node(token, { kind: 'unary', op: token.kind === '-' ? 7 : 16, child }, [child]);
+ } else if (token.kind === '(') {
+ left = this.expression(0, true); this.newlines(); this.expect(')');
+ } else if (token.kind === 'name') {
+ if (Object.hasOwn(FUNCTIONS, token.value) && this.is('(')) {
+ this.take();
+ const args = [];
+ this.newlines();
+ if (!this.is(')')) {
+ for (;;) {
+ args.push(this.expression(0, true)); this.newlines();
+ if (!this.is(',')) break;
+ this.take();
+ }
+ }
+ this.expect(')');
+ if (args.length !== FUNCTIONS[token.value][0]) fail(token, `${token.value} expects ${FUNCTIONS[token.value][0]} arguments`);
+ left = this.node(token, { kind: 'call', op: FUNCTIONS[token.value][1], args }, args);
+ } else left = this.node(token, { kind: 'reference', name: token.value });
+ } else fail(token, 'Expected a number, state, or expression');
+ for (;;) {
+ if (multiline) this.newlines();
+ const entry = BINARY.get(this.peek().kind);
+ if (!entry || entry[0] < minimum) break;
+ const operator = this.take();
+ const right = this.expression(entry[0] + 1, multiline);
+ left = this.node(operator, { kind: 'binary', op: entry[1], left, right }, [left, right]);
+ }
+ this.depth--;
+ return left;
+ }
+ block(depth = 0) {
+ if (depth >= LIMITS.depth) fail(this.peek(), 'Handler nesting exceeds 64');
+ this.newlines(); this.expect('{'); this.separators();
+ const statements = [];
+ while (!this.is('}')) {
+ const token = this.expect('name', 'assignment, if, or closing brace');
+ if (token.value === 'if' && !this.is('=')) {
+ const condition = this.expression();
+ const body = this.block(depth + 1);
+ statements.push({ kind: 'if', token, condition, body });
+ } else {
+ this.expect('=');
+ statements.push({ kind: 'assign', token, name: token.value, expression: this.expression() });
+ }
+ this.end();
+ }
+ this.take();
+ return statements;
+ }
+ parse() {
+ const declarations = [];
+ this.separators();
+ while (!this.is('eof')) {
+ const token = this.expect('name', 'declaration');
+ const kind = token.value;
+ if (kind === 'state') {
+ const name = this.expect('name', 'state name'); this.expect('=');
+ let sign = 1;
+ if (this.is('-') || this.is('+')) sign = this.take().kind === '-' ? -1 : 1;
+ const value = this.expect('number', 'numeric state initializer');
+ declarations.push({ kind, token, name, initial: sign * value.value });
+ } else if (kind === 'input') {
+ const name = this.expect('name', 'input name'); this.expect('=');
+ const key = this.expect('name', 'keyboard code');
+ if (!/^(?:Space|Enter|Arrow(?:Left|Right|Up|Down)|Key[A-Z]|Digit[0-9])$/.test(key.value)) fail(key, 'Unsupported keyboard code');
+ declarations.push({ kind, token, name, key });
+ } else if (Object.hasOwn(VIEW_FIELDS, kind)) {
+ const name = this.expect('name', 'view ID'); this.expect('(');
+ const expressions = [];
+ for (let i = 0; i < VIEW_FIELDS[kind].length; i++) {
+ if (i) { this.newlines(); this.expect(','); }
+ expressions.push(this.expression(0, true));
+ }
+ this.newlines();
+ let color, prefix = '';
+ if (kind !== 'text') {
+ this.expect(','); this.newlines();
+ const value = this.expect('string', 'literal hex color');
+ if (!/^#[0-9a-fA-F]{6}$/.test(value.value)) fail(value, 'Color must use six hexadecimal digits, such as "#356548"');
+ color = value.value;
+ } else if (this.is(',')) { this.take(); this.newlines(); prefix = this.expect('string', 'literal text prefix').value; }
+ this.newlines(); this.expect(')');
+ declarations.push({ kind: 'view', type: kind, token, name, expressions, color, prefix });
+ } else if (kind === 'on') {
+ const event = this.expect('name', 'input name or frame');
+ let edge;
+ if (event.value !== 'frame') {
+ this.expect('.'); edge = this.expect('name', 'down or up');
+ if (edge.value !== 'down' && edge.value !== 'up') fail(edge, 'Expected down or up');
+ }
+ declarations.push({ kind: 'handler', token, event, edge, body: this.block() });
+ } else fail(token, `Unknown declaration ${kind}`);
+ this.end();
+ }
+ return declarations;
+ }
+}
+
+export function compile(source) {
+ if (typeof source !== 'string') fail({ line: 1, column: 1 }, 'Source must be a string');
+ const sourceBytes = new TextEncoder().encode(source).length;
+ if (sourceBytes > LIMITS.sourceBytes) fail({ line: 1, column: 1 }, 'Source exceeds 32768 UTF-8 bytes');
+ const declarations = new Parser(lex(source)).parse();
+ const program = { states: [], inputs: [], instructions: [], handlers: [], views: [], metrics: {} };
+ const states = new Map(), inputs = new Map(), views = new Set(), keys = new Set();
+ let handlerCount = 0;
+ for (const declaration of declarations) {
+ const { kind, name, token } = declaration;
+ if (name && RESERVED.has(name.value)) fail(name, `Reserved name ${name.value}`);
+ if (kind === 'state' || kind === 'input') {
+ const symbols = kind === 'state' ? states : inputs;
+ if (symbols.has(name.value)) fail(name, `Duplicate ${kind} ${name.value}`);
+ if (symbols.size >= LIMITS[`${kind}s`]) fail(name, `At most ${LIMITS[`${kind}s`]} ${kind}s are allowed`);
+ symbols.set(name.value, symbols.size);
+ if (kind === 'state') program.states.push({ name: name.value, initial: declaration.initial, line: token.line });
+ else {
+ if (keys.has(declaration.key.value)) fail(declaration.key, `Duplicate keyboard code ${declaration.key.value}`);
+ keys.add(declaration.key.value);
+ program.inputs.push({ name: name.value, key: declaration.key.value, line: token.line });
+ }
+ } else if (kind === 'view') {
+ if (views.has(name.value)) fail(name, `Duplicate view ID ${name.value}`);
+ if (views.size >= LIMITS.views) fail(name, 'At most 24 views are allowed');
+ views.add(name.value);
+ } else if (++handlerCount > LIMITS.handlers) fail(token, 'At most 64 handlers are allowed');
+ }
+ let stack = 0, maxStack = 0;
+ const emit = (op, token, arg = 0, value = 0, delta = 0) => {
+ if (program.instructions.length >= LIMITS.instructions) fail(token, 'At most 512 instructions are allowed');
+ stack += delta;
+ if (stack < 0 || stack > LIMITS.stack) fail(token, 'Expression stack exceeds 64');
+ maxStack = Math.max(maxStack, stack);
+ program.instructions.push({ op, arg, value, line: token.line });
+ };
+ const expression = (node, binding = false) => {
+ const { token } = node;
+ if (node.kind === 'constant') emit(0, token, 0, node.value, 1);
+ else if (node.kind === 'reference') {
+ if (node.name === 'dt') {
+ if (binding) fail(token, 'dt is only available in handlers');
+ emit(2, token, 0, 0, 1);
+ } else {
+ if (!states.has(node.name)) fail(token, `Unknown state ${node.name}`);
+ emit(1, token, states.get(node.name), 0, 1);
+ }
+ } else if (node.kind === 'unary') { expression(node.child, binding); emit(node.op, token); }
+ else if (node.kind === 'binary') {
+ expression(node.left, binding); expression(node.right, binding); emit(node.op, token, 0, 0, -1);
+ } else {
+ for (const arg of node.args) expression(arg, binding);
+ emit(node.op, token, 0, 0, 1 - node.args.length);
+ }
+ };
+ const statements = body => {
+ for (const statement of body) {
+ if (statement.kind === 'assign') {
+ if (!states.has(statement.name)) fail(statement.token, `Unknown state ${statement.name}`);
+ expression(statement.expression);
+ emit(20, statement.token, states.get(statement.name), 0, -1);
+ } else {
+ expression(statement.condition);
+ const branch = program.instructions.length;
+ emit(21, statement.token, 0, 0, -1);
+ statements(statement.body);
+ program.instructions[branch].arg = program.instructions.length;
+ }
+ }
+ };
+ for (const declaration of declarations) {
+ if (declaration.kind === 'handler') {
+ let event = 0;
+ if (declaration.event.value !== 'frame') {
+ if (!inputs.has(declaration.event.value)) fail(declaration.event, `Unknown input ${declaration.event.value}`);
+ event = 1 + inputs.get(declaration.event.value) * 2 + (declaration.edge.value === 'up' ? 1 : 0);
+ }
+ const start = program.instructions.length;
+ stack = 0; statements(declaration.body);
+ program.handlers.push({ event, start, length: program.instructions.length - start, line: declaration.token.line });
+ } else if (declaration.kind === 'view') {
+ const view = { id: declaration.name.value, type: declaration.type, line: declaration.token.line, bindings: {} };
+ if (view.type === 'text') view.prefix = declaration.prefix;
+ else view.color = declaration.color;
+ declaration.expressions.forEach((node, index) => {
+ const start = program.instructions.length;
+ stack = 0; expression(node, true);
+ view.bindings[VIEW_FIELDS[view.type][index]] = { start, length: program.instructions.length - start };
+ });
+ program.views.push(view);
+ }
+ }
+ program.metrics = { sourceBytes, sourceLines: source === '' ? 0 : source.split('\n').length,
+ states: states.size, inputs: inputs.size, views: views.size,
+ handlers: program.handlers.length, instructions: program.instructions.length, maxStack };
+ return program;
+}
+
+export { LIMITS };
diff --git a/prototypes/workbench/results/2026-09-23-presentation/before/web/core.wasm b/prototypes/workbench/results/2026-09-23-presentation/before/web/core.wasm
new file mode 100644
index 0000000..0eb3fe5
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-presentation/before/web/core.wasm
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-23-presentation/before/web/index.html b/prototypes/workbench/results/2026-09-23-presentation/before/web/index.html
new file mode 100644
index 0000000..d93589e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-presentation/before/web/index.html
@@ -0,0 +1,93 @@
+<!doctype html>
+<html lang="ko">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="color-scheme" content="dark">
+ <title>Pure · 빈 작업물에서 시작하기</title>
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23151918'/%3E%3Ctext x='9' y='23' fill='%23c9f08b' font-size='24' font-family='sans-serif'%3Ep%3C/text%3E%3C/svg%3E">
+ <link rel="stylesheet" href="./style.css">
+</head>
+<body>
+ <header class="masthead">
+ <a class="wordmark" href="./" aria-label="Pure 작업 공간">pure<span> / workbench</span></a>
+ <span class="experiment">제작 경험 실험 004</span>
+ <div class="connection"><span id="ready-dot" class="dot"></span><span id="ready-status" role="status">실행기 준비 중</span></div>
+ </header>
+
+ <main>
+ <section class="intro" aria-labelledby="title">
+ <div><p class="eyebrow">빈 코드 · 직접 만든 동작</p><h1 id="title">무엇을 움직이게 할까요?</h1></div>
+ <p>상태를 만들고, 입력에 동작을 연결하세요.<br>코드를 <strong>적용</strong>하면 장면이 바뀝니다. 같은 이름의 상태는 이어집니다.</p>
+ </section>
+
+ <div class="workspace">
+ <section class="editor-panel panel" aria-labelledby="editor-title">
+ <div class="panel-heading"><h2 id="editor-title"><span class="section-index">01</span> 코드</h2><span id="draft-status" class="badge">빈 작업물</span></div>
+ <div class="file-tools" aria-label="파일 관리">
+ <button id="new" class="quiet" type="button">편집기 비우기</button>
+ <button id="upload" class="quiet" type="button">파일 열기</button>
+ <button id="download" class="quiet" type="button">.pure 저장</button>
+ <button id="download-project" class="quiet" type="button">JSON 저장</button>
+ <input id="file-input" type="file" accept=".pure,.txt,.json,text/plain,application/json" hidden>
+ </div>
+ <label class="sr-only" for="source">Pure 코드 편집기</label>
+ <div class="code-editor">
+ <div id="line-numbers" class="line-numbers" aria-hidden="true">1</div>
+ <textarea id="source" spellcheck="false" autocapitalize="off" autocomplete="off" autocorrect="off" aria-describedby="editor-hint diagnostic" placeholder="여기서 시작하세요.&#10;&#10;아래 ‘언어 안내’에서 작은 조각을 가져올 수 있습니다."></textarea>
+ </div>
+ <div class="editor-footer"><span id="source-size">0 / 32,768 bytes</span><span id="cursor-position">1행 · 1열</span></div>
+ <div class="apply-row"><button id="apply" class="primary" type="button" disabled>코드 적용 <span aria-hidden="true">↗</span></button><p id="editor-hint">편집만으로 실행 중인 코드는 바뀌지 않습니다.</p></div>
+ <div id="diagnostic" class="diagnostic" role="status" aria-live="polite"><span id="diagnostic-text">아직 작성한 코드가 없습니다.</span><button id="error-location" type="button" class="quiet" hidden>문제 위치로</button></div>
+ </section>
+
+ <div class="right-column">
+ <section class="scene-panel panel" aria-labelledby="scene-title">
+ <div class="panel-heading"><h2 id="scene-title"><span class="section-index">02</span> 실행 장면</h2><span id="scene-status" class="badge">적용 전</span></div>
+ <div class="stage-wrap">
+ <svg id="scene" viewBox="0 0 640 360" role="img" tabindex="0" aria-labelledby="scene-description"><title id="scene-description">사용자가 코드로 만든 장면. 현재 비어 있습니다.</title><g id="scene-content"></g></svg>
+ <div id="empty-scene" class="empty-scene"><span class="empty-cross" aria-hidden="true">+</span><strong>아직 아무것도 없습니다</strong><span>도형이나 글자를 선언하고 코드를 적용하세요.</span></div>
+ <span class="stage-dimensions" aria-hidden="true">640 × 360</span>
+ </div>
+ <div class="transport"><button id="play" class="secondary" type="button" disabled>▶ 실행</button><button id="step" type="button" disabled>한 프레임</button><button id="reset" type="button" disabled>초깃값으로</button><span id="clock-status">0 frame · 정지</span></div>
+ <p class="control-note">정지하면 시간만 멈춥니다. 아래 입력은 정지 중에도 작동합니다.</p>
+ <div class="input-heading"><h3>내가 선언한 입력</h3><span>눌렀을 때 .down / 놓았을 때 .up</span></div>
+ <div id="input-controls" class="input-controls"><p class="empty-note">입력을 선언하면 여기에 버튼이 생깁니다.</p></div>
+ <p class="keyboard-note">장면을 클릭하면 키보드로 입력할 수 있습니다. 코드나 폼을 편집하는 동안에는 전달되지 않습니다.</p>
+ </section>
+
+ <section class="inspect-panel panel" aria-labelledby="inspect-title">
+ <div class="panel-heading"><h2 id="inspect-title"><span class="section-index">03</span> 현재 상태</h2><span id="revision" class="mono subdued">revision 0</span></div>
+ <div id="state-values" class="state-values"><p class="empty-note">선언한 상태의 값이 표시됩니다.</p></div>
+ <div class="event-line"><span class="subdued">마지막 입력</span><span id="last-event" class="mono">아직 없음</span></div>
+ <div id="runtime-error" class="runtime-error" role="status" hidden></div>
+ <details class="cost-details"><summary>실행 비용과 범위</summary><dl id="cost-values" class="cost-values"></dl><p>코어가 보고한 WebAssembly 메모리입니다. 브라우저·편집기·렌더러의 메모리와 프로세스 전체 사용량은 포함하지 않습니다. 예약 공간 안에 사용 중인 코드·상태·스테이징 공간이 포함되므로 합산하지 않습니다.</p><p id="dropped-ticks">시간 지연으로 건너뛴 프레임: 0</p></details>
+ </section>
+ </div>
+ </div>
+
+ <section class="reference panel" aria-labelledby="reference-title">
+ <div class="panel-heading"><h2 id="reference-title">언어 안내</h2><span class="subdued">조각을 조합해 자신의 동작을 만드세요.</span></div>
+ <div class="reference-grid">
+ <article><h3>1. 기억할 값</h3><p>이름은 영문으로, 처음 값은 숫자로 정합니다.</p><pre><code>state count = 0
+state x = 80</code></pre><button class="insert-snippet quiet" data-snippet="state" type="button">상태 조각 삽입</button></article>
+ <article><h3>2. 입력과 반응</h3><p>버튼 이름과 키를 선언하고 .down / .up에 연결합니다.</p><pre><code>input tap = Space
+on tap.down {
+ count = count + 1
+}</code></pre><button class="insert-snippet quiet" data-snippet="input" type="button">입력 조각 삽입</button></article>
+ <article><h3>3. 보일 것</h3><p>좌표와 크기를 상태에 연결할 수 있습니다.</p><pre><code>box tile(x, 180, 40, 40, "#7ed9ae")
+text label(40, 60, count, "Count: ")</code></pre><button class="insert-snippet quiet" data-snippet="view" type="button">장면 조각 삽입</button></article>
+ <article><h3>4. 시간에 따른 변화</h3><p>실행하면 매 프레임 반응합니다. dt는 1/60초입니다.</p><pre><code>on frame {
+ x = x + 30 * dt
+ if x > 600 { x = 0 }
+}</code></pre><button class="insert-snippet quiet" data-snippet="frame" type="button">시간 조각 삽입</button></article>
+ </div>
+ <details class="syntax-details"><summary>표현식 · 입력 키 · 제한 보기</summary><div class="syntax-columns"><div><p><strong>표현식</strong><br><code>+ - * /</code>, <code>&lt; &lt;= &gt; &gt;= == !=</code>, <code>&amp;&amp; || !</code>, 괄호, <code>min(a,b)</code>, <code>max(a,b)</code>, <code>clamp(x,lo,hi)</code>를 사용합니다. 논리 연산은 양쪽을 모두 계산합니다.</p><p><strong>도형</strong><br><code>box id(x,y,width,height,"#rrggbb")</code><br><code>circle id(x,y,radius,"#rrggbb")</code><br><code>text id(x,y,value,"접두사")</code><br>글자의 value는 숫자 표현식입니다. 문자 접두사는 생략할 수 있습니다.</p></div><div><p><strong>입력</strong><br><code>Space</code>, <code>Enter</code>, <code>ArrowLeft/Right/Up/Down</code>, <code>KeyA</code>~<code>KeyZ</code>, <code>Digit0</code>~<code>Digit9</code>. 같은 키는 하나의 입력에 연결합니다.</p><p><strong>이 실험의 범위</strong><br>상태 32개 · 입력 8개 · 도형/글자 24개 · 반응 64개 · 컴파일된 명령 512개 · 코드 32 KiB. 반복문, 함수 선언, 자동 충돌·물리는 없습니다. 줄바꿈 또는 세미콜론으로 구분하고 <code>#</code>으로 주석을 씁니다.</p><p>0으로 나누기 등 계산 실패 시 해당 이벤트의 상태 변경 전체를 취소합니다. 입력을 누르고 있을 때는 새 코드를 적용할 수 없습니다.</p></div></div></details>
+ <div class="example-row"><div><label for="example">완성된 예제 살펴보기</label><p>선택한 코드를 편집기에 불러옵니다. 자동으로 적용되지 않습니다.</p></div><select id="example"><option value="counter">카운터</option><option value="charge">누르는 동안 충전</option><option value="toggle">입력으로 전환</option></select><button id="load-example" type="button">예제 불러오기</button></div>
+ <div class="baseline-row"><a href="./baseline.html" target="_blank" rel="noopener">같은 동작을 일반 JavaScript로 만든 비교 앱 ↗</a><span>충전 예제와 비교할 수 있습니다. 새 탭에서 열립니다.</span></div>
+ </section>
+ <footer class="page-footer"><span>Pure · authoring experiment</span><p>원하는 동작을 만들어보고, <strong>하려던 일 / 실제로 일어난 일 / 기대했던 일</strong>을 남겨주세요.</p></footer>
+ </main>
+ <script type="module" src="./app.js"></script>
+</body>
+</html>
diff --git a/prototypes/workbench/results/2026-09-23-presentation/before/web/runtime.mjs b/prototypes/workbench/results/2026-09-23-presentation/before/web/runtime.mjs
new file mode 100644
index 0000000..02b4e7c
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-presentation/before/web/runtime.mjs
@@ -0,0 +1,205 @@
+import { compile } from './compiler.mjs';
+
+const messages = [
+ '', '실행기의 용량 한도를 넘었습니다.', '실행 정의가 유효하지 않습니다.',
+ '계산 스택의 범위를 벗어났습니다.', '0으로 나눌 수 없습니다.',
+ '계산 결과가 유한한 수의 범위를 벗어났습니다.', '조건 분기 범위가 유효하지 않습니다.',
+ '존재하지 않는 상태를 참조했습니다.', '시간 간격이 유효하지 않습니다.',
+ '정의되지 않은 입력 사건입니다.',
+];
+const sourceLimit = 32768;
+
+function problem(message, line = null, column = null) {
+ return Object.assign(new Error(message), { line, column });
+}
+
+function freezeProgram(value) {
+ if (value && typeof value === 'object') {
+ for (const child of Object.values(value)) freezeProgram(child);
+ Object.freeze(value);
+ }
+ return value;
+}
+
+export async function createRuntime(wasmBytes, evaluationOptions = {}) {
+ if (wasmBytes === undefined) {
+ const response = await fetch(new URL('./core.wasm', import.meta.url), { cache: 'no-store' });
+ if (!response.ok) throw new Error(`실행기를 불러오지 못했습니다 (HTTP ${response.status}).`);
+ wasmBytes = await response.arrayBuffer();
+ }
+ const module = await WebAssembly.compile(wasmBytes);
+ if (WebAssembly.Module.imports(module).length) throw new Error('이 실행기는 호스트 함수 없이 동작해야 합니다.');
+ const { exports: core } = await WebAssembly.instantiate(module, {});
+ if (typeof core.eval_bounded !== 'function' || typeof core.abort_program !== 'function')
+ throw new Error('실행기 버전이 맞지 않습니다. 새로고침하거나 core.wasm을 다시 빌드하세요.');
+ core.reset_all();
+ let program = null;
+ let appliedSource = '';
+ let lastEvent = null;
+ // Keep a failed event visible through its release/focus-loss cleanup.
+ // The active program can clear this diagnostic by apply or explicit reset.
+ let lastFailure = null;
+ const held = new Map();
+ let evaluationPolicy = { cacheEntries: 0, historyEntries: 8, workLimit: 2048 };
+
+ function checked(code, line = null) {
+ if (code !== 0) throw problem(messages[code] || `실행기 오류 ${code}`, line);
+ }
+ function inputIndex(name) {
+ const index = program?.inputs.findIndex((input) => input.name === name) ?? -1;
+ if (index < 0) throw new Error(`정의되지 않은 입력: ${name}`);
+ return index;
+ }
+ function dispatch(event, name, phase, source) {
+ const status = core.dispatch(event, event === 0 ? 1 / 60 : 0);
+ const pc = core.info(2) >>> 0;
+ lastEvent = { name, phase, source, status,
+ line: program?.instructions[pc]?.line ?? null, ops: core.info(3) >>> 0 };
+ if (status) lastFailure = { ...lastEvent };
+ return status;
+ }
+
+ const api = {
+ get currentProgram() { return program; },
+ configureEvaluation({ cacheEntries = evaluationPolicy.cacheEntries,
+ historyEntries = evaluationPolicy.historyEntries, workLimit = evaluationPolicy.workLimit } = {}) {
+ for (const [name, value, maximum] of [['cacheEntries', cacheEntries, 32],
+ ['historyEntries', historyEntries, 8], ['workLimit', workLimit, 0xffffffff]]) {
+ if (!Number.isInteger(value) || value < 0 || value > maximum)
+ throw new Error(`${name}: 0~${maximum} 사이의 정수여야 합니다.`);
+ }
+ checked(core.configure_evaluation(cacheEntries, historyEntries));
+ evaluationPolicy = { cacheEntries, historyEntries, workLimit };
+ return { ...evaluationPolicy };
+ },
+ forgetEvaluation(part = 'all') {
+ const flags = part === 'all' ? 3 : part === 'values' ? 1 : part === 'history' ? 2 : undefined;
+ if (flags === undefined) throw new Error('삭제할 기록은 values, history, all 중 하나여야 합니다.');
+ core.forget_evaluation(flags);
+ },
+ evaluationStats() {
+ return { ...evaluationPolicy, work: core.evaluation_info(1),
+ instructions: core.evaluation_info(2), cacheHits: core.evaluation_info(3),
+ reservedBytes: core.evaluation_info(6), dedicatedBytes: core.evaluation_info(7),
+ sharedBytes: core.evaluation_info(8), reuseAvailable: Boolean(core.evaluation_info(9)),
+ workspaceTagBytes: core.evaluation_info(10) };
+ },
+ apply(source) {
+ if (typeof source !== 'string' || new TextEncoder().encode(source).length > sourceLimit) {
+ throw problem('정의는 UTF-8 기준 32,768바이트 이하의 텍스트여야 합니다.');
+ }
+ if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 적용하세요. 현재 작업은 그대로 유지합니다.');
+ const next = freezeProgram(compile(source));
+ const oldIds = new Map(program?.states.map((state, index) => [state.name, index]) ?? []);
+ checked(core.begin_program(next.states.length, next.inputs.length));
+ try {
+ for (let index = 0; index < next.states.length; index++) {
+ const state = next.states[index];
+ checked(core.put_state(index, state.initial, oldIds.get(state.name) ?? -1), state.line);
+ }
+ for (const op of next.instructions) checked(core.put_instruction(op.op, op.arg, op.value), op.line);
+ for (const handler of next.handlers) checked(core.put_handler(handler.event, handler.start, handler.length), handler.line);
+ checked(core.commit_program());
+ } catch (error) {
+ core.abort_program();
+ throw error;
+ }
+ program = next;
+ appliedSource = source;
+ lastEvent = null;
+ lastFailure = null;
+ return next;
+ },
+ reset() {
+ held.clear();
+ core.reset_state();
+ lastEvent = null;
+ lastFailure = null;
+ },
+ frame(count = 1) {
+ if (!Number.isInteger(count) || count < 0 || count > 600) throw new Error('프레임 수는 0~600 사이의 정수여야 합니다.');
+ if (!program) return 0;
+ for (let i = 0; i < count; i++) {
+ const code = dispatch(0, 'frame', 'frame', 'clock');
+ if (code !== 0) return code;
+ }
+ return 0;
+ },
+ down(name, source = 'external') {
+ const index = inputIndex(name);
+ const sources = held.get(name) || new Set();
+ if (sources.has(source)) return 0;
+ const wasDown = sources.size > 0;
+ sources.add(source);
+ held.set(name, sources);
+ return wasDown ? 0 : dispatch(1 + index * 2, name, 'down', source);
+ },
+ up(name, source = 'external') {
+ const index = inputIndex(name);
+ const sources = held.get(name);
+ if (!sources?.delete(source)) return 0;
+ if (sources.size) return 0;
+ held.delete(name);
+ return dispatch(2 + index * 2, name, 'up', source);
+ },
+ releaseAll() {
+ let firstError = 0;
+ for (const [name, sources] of [...held]) {
+ for (const source of [...sources]) {
+ const status = api.up(name, source);
+ if (!firstError) firstError = status;
+ }
+ }
+ return firstError;
+ },
+ exportProject() {
+ return { format: 'pure-workbench', version: 1, source: appliedSource };
+ },
+ snapshot() {
+ core.begin_evaluation(evaluationPolicy.workLimit);
+ const states = Object.fromEntries((program?.states ?? []).map((state, index) => [state.name, core.get_state(index)]));
+ const views = [];
+ let viewError = null;
+ viewLoop: for (const view of program?.views ?? []) {
+ const rendered = { id: view.id, type: view.type, color: view.color, prefix: view.prefix };
+ let valid = true;
+ for (const [name, binding] of Object.entries(view.bindings)) {
+ const value = core.eval_bounded(binding.start, binding.length);
+ if (core.evaluation_info(0) === 10) {
+ // A snapshot must not mix complete views with budget-truncated ones.
+ views.length = 0;
+ viewError = { code: 101, line: view.line,
+ message: '표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.' };
+ break viewLoop;
+ }
+ if (!Number.isFinite(value) || (['width', 'height', 'radius'].includes(name) && value < 0)) {
+ viewError ??= { line: view.line, message: `${view.id}.${name}: 표시할 수 없는 값입니다. 0 나눗셈이나 음수 크기를 확인하세요.` };
+ valid = false;
+ }
+ rendered[name] = value;
+ }
+ if (valid) views.push(rendered);
+ }
+ const executionError = lastFailure?.status || 0;
+ return {
+ states, views, tick: core.info(0) >>> 0, revision: core.info(4) >>> 0,
+ displayError: viewError ? { code: viewError.code || 100, ...viewError } : null,
+ lastError: executionError || (viewError ? viewError.code || 100 : 0),
+ errorMessage: executionError ? messages[executionError] || `실행 오류 ${executionError}` : viewError?.message || '',
+ errorLine: executionError ? lastFailure.line : viewError?.line ?? null,
+ lastOps: core.info(3) >>> 0, successfulEvents: core.info(9) >>> 0,
+ held: [...held.keys()], lastEvent: lastEvent ? { ...lastEvent } : null,
+ lastFailure: lastFailure ? { ...lastFailure } : null,
+ memory: {
+ linearBytes: core.memory.buffer.byteLength,
+ reservedEngineBytes: core.layout(0), programReservedBytes: core.layout(1),
+ instructionBytes: core.layout(2), handlerBytes: core.layout(3),
+ activeIRBytes: core.layout(4), activeStateBytes: core.layout(5),
+ stagingReservedBytes: core.layout(6), evalStackBytes: core.layout(7),
+ },
+ };
+ },
+ };
+ api.configureEvaluation(evaluationOptions);
+ return Object.freeze(api);
+}
diff --git a/prototypes/workbench/results/2026-09-23-presentation/before/web/style.css b/prototypes/workbench/results/2026-09-23-presentation/before/web/style.css
new file mode 100644
index 0000000..819c84c
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-presentation/before/web/style.css
@@ -0,0 +1,8 @@
+:root{color-scheme:dark;--bg:#151918;--panel:#1d2220;--field:#151a18;--line:#353e38;--text:#e7ece6;--muted:#a9b6aa;--faint:#7b8e7e;--accent:#c9f08b;--accent-dark:#222e18;--danger:#f6afa0;--radius:14px;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-synthesis:none}
+*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text)}button,input,select,textarea{font:inherit}button,select{border:1px solid var(--line);border-radius:7px;background:#282f2a;color:var(--text);padding:9px 12px;cursor:pointer;font-size:12px;font-weight:600;min-height:36px}button:hover:not(:disabled){background:#344035;border-color:#70836d}button:disabled{opacity:.4;cursor:not-allowed}button:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px}button.primary{background:var(--accent);color:var(--accent-dark);border-color:var(--accent);padding:11px 19px;font-size:14px;display:flex;gap:28px;justify-content:space-between;align-items:center}button.primary:hover:not(:disabled){background:#d8f7ad}button.secondary{background:#c9f08b16;border-color:#637545;color:#d7f0b5}button.quiet{background:transparent;border-color:transparent;color:var(--muted);padding:6px 8px;min-height:30px}button.quiet:hover{color:var(--text)}.masthead{height:76px;padding:0 4.5%;display:flex;align-items:center;gap:28px;border-bottom:1px solid var(--line)}.wordmark{font-size:23px;font-weight:750;letter-spacing:-1px;color:var(--accent);text-decoration:none}.wordmark span{font-size:13px;color:var(--muted);letter-spacing:0;font-weight:400}.experiment{font-size:11px;color:var(--faint);border-left:1px solid var(--line);padding-left:24px}.connection{margin-left:auto;display:flex;gap:9px;align-items:center;font-size:11px;color:var(--muted)}.dot{width:6px;height:6px;border-radius:100%;background:#aa8e5c}.dot.ready{background:var(--accent)}.dot.failed{background:var(--danger)}main{max-width:1540px;margin:auto;padding:38px 4.5% 0}.intro{display:flex;justify-content:space-between;align-items:flex-end;gap:24px;margin:0 0 27px}.eyebrow{font-size:10px;letter-spacing:1.4px;font-weight:600;color:var(--accent);margin:0 0 10px}h1{font-size:clamp(23px,2.5vw,35px);letter-spacing:-1px;margin:0;font-weight:600}.intro>p{font-size:12px;line-height:1.9;color:var(--muted);margin:0 0 2px}.intro strong{color:var(--text);font-weight:500}.workspace{display:grid;grid-template-columns:minmax(340px,1fr) minmax(380px,1.05fr);gap:22px;align-items:start}.panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}.panel-heading{height:56px;padding:0 20px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-bottom:1px solid var(--line)}h2{margin:0;font-size:13px;font-weight:650;display:flex;gap:10px;align-items:center}.section-index{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px;font-weight:400;color:var(--faint)}.badge{font-size:10px;border:1px solid var(--line);border-radius:4px;padding:4px 7px;white-space:nowrap;color:var(--muted)}.badge.dirty{color:var(--accent);border-color:#5f7247}.file-tools{display:flex;align-items:center;gap:3px;padding:8px 10px;border-bottom:1px solid var(--line);flex-wrap:wrap}.code-editor{height:514px;display:flex;overflow:hidden;background:var(--field);position:relative}.line-numbers{padding:20px 10px 20px 16px;min-width:45px;text-align:right;color:#667567;user-select:none;white-space:pre;font:12px/1.85 ui-monospace,SFMono-Regular,Consolas,monospace;overflow:hidden;flex-shrink:0;border-right:1px solid #2a322b}textarea#source{border:0;border-radius:0;outline:0;resize:none;padding:20px 16px;display:block;width:100%;height:100%;color:#d4e3ca;background:transparent;white-space:pre;overflow:auto;tab-size:2;font:12px/1.85 ui-monospace,SFMono-Regular,Consolas,monospace;caret-color:var(--accent)}textarea#source:focus{background:#171e19}textarea#source::placeholder{color:#7c8b7d;font:12px/1.9 ui-sans-serif,system-ui,sans-serif;white-space:pre-wrap}textarea#source[aria-invalid=true]{box-shadow:inset 3px 0 0 var(--danger)}.editor-footer{padding:8px 15px;font-size:10px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--faint);display:flex;justify-content:space-between;border-top:1px solid var(--line)}.apply-row{display:flex;gap:18px;align-items:center;padding:16px 18px 12px}.apply-row p{font-size:10px;color:var(--muted);line-height:1.7;margin:0;max-width:185px}.diagnostic{margin:0 18px 17px;font-size:11px;line-height:1.8;min-height:22px;color:var(--muted);display:flex;align-items:baseline;gap:6px;flex-wrap:wrap}.diagnostic.error{color:var(--danger)}.diagnostic.success{color:#c7dea9}.right-column{display:grid;gap:18px}.stage-wrap{position:relative;background-color:#121613;background-image:linear-gradient(#a6c39907 1px,transparent 1px),linear-gradient(90deg,#a6c39907 1px,transparent 1px);background-size:24px 24px}#scene{width:100%;height:auto;aspect-ratio:640/360;display:block;overflow:hidden}.empty-scene{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none;gap:9px;color:var(--faint);padding:24px;text-align:center}.empty-scene[hidden]{display:none}.empty-scene strong{font-size:13px;color:var(--muted);font-weight:400}.empty-scene>span:not(.empty-cross){font-size:11px}.empty-cross{font-size:31px;color:#52614d;font-weight:200;margin-bottom:7px}.stage-dimensions{position:absolute;right:14px;bottom:10px;font-size:9px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;color:#6d7f6d;pointer-events:none}.transport{display:flex;gap:7px;align-items:center;padding:14px 16px 0;flex-wrap:wrap}.transport button{font-size:11px;padding:7px 10px}.transport #clock-status{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px;color:var(--muted);margin-left:auto}.control-note,.keyboard-note{font-size:10px;line-height:1.7;color:var(--faint);margin:10px 18px 15px}.input-heading{display:flex;justify-content:space-between;align-items:center;gap:10px;margin:0 18px;padding-top:13px;border-top:1px solid var(--line)}h3{font-size:11px;font-weight:550;margin:0}.input-heading>span{font-size:9px;color:var(--faint)}.input-controls{display:flex;flex-wrap:wrap;gap:7px;padding:11px 17px 0;min-height:39px}.input-button{display:flex;align-items:center;gap:14px;touch-action:none;user-select:none;-webkit-user-select:none}.input-button kbd{font:9px ui-monospace,SFMono-Regular,Consolas,monospace;border:1px solid #5a6957;border-radius:3px;padding:3px 4px;color:var(--muted);min-width:22px;text-align:center}.input-button.held{background:var(--accent);border-color:var(--accent);color:var(--accent-dark)}.input-button.held kbd{color:var(--accent-dark);border-color:#829a62}.empty-note{font-size:11px;line-height:1.7;color:var(--faint);margin:0}.keyboard-note{margin-bottom:13px;font-size:9px}.state-values{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));padding:15px 19px;gap:13px;max-height:210px;overflow:auto}.state-value{display:flex;flex-direction:column;gap:6px;min-width:0}.state-value dt{font:10px ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--muted);overflow-wrap:anywhere}.state-value dd{font:17px ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--accent);margin:0;overflow-wrap:anywhere}.state-value dl{margin:0}.mono{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px}.subdued{color:var(--muted);font-size:10px}.event-line{padding:0 19px 13px;display:flex;gap:16px;align-items:baseline;flex-wrap:wrap}.event-line #last-event{color:var(--muted);font-size:10px;overflow-wrap:anywhere}.runtime-error{margin:0 18px 14px;padding:10px;border:1px solid #705049;border-radius:5px;background:#432c272e;color:var(--danger);font-size:11px;line-height:1.6}.cost-details{border-top:1px solid var(--line);padding:12px 19px;font-size:10px;color:var(--muted)}summary{cursor:pointer;font-size:11px;color:var(--muted)}.cost-details p{font-size:10px;line-height:1.8;color:var(--faint)}.cost-values{display:grid;grid-template-columns:1fr auto;gap:8px 20px;margin:16px 0}.cost-values dt,.cost-values dd{margin:0}.cost-values dd{text-align:right;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--text)}.reference{margin-top:25px}.reference-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));padding:22px 20px;gap:24px}.reference-grid article{min-width:0;display:flex;flex-direction:column;align-items:flex-start}.reference-grid h3{font-size:12px;color:#d2ddc8}.reference-grid p{font-size:10px;line-height:1.8;color:var(--muted);min-height:36px;margin:8px 0 10px}.reference-grid pre{font:10px/1.85 ui-monospace,SFMono-Regular,Consolas,monospace;background:#151b16;color:#bacead;width:100%;padding:10px;overflow:auto;border-radius:6px;min-height:115px;margin:0 0 8px}.reference-grid .quiet{font-size:10px;padding:5px 0}.syntax-details{padding:16px 20px;border-top:1px solid var(--line)}.syntax-columns{display:grid;grid-template-columns:1fr 1fr;gap:35px;margin-top:12px}.syntax-columns p{font-size:11px;line-height:1.95;color:var(--muted)}.syntax-columns strong{color:var(--text);font-weight:500}.syntax-columns code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px;color:#d0dcbe;overflow-wrap:anywhere}.example-row{padding:18px 20px;border-top:1px solid var(--line);display:flex;align-items:center;gap:10px}.example-row>div{margin-right:auto}.example-row label{font-size:11px;color:var(--text)}.example-row p{font-size:10px;color:var(--faint);margin:5px 0 0}.example-row button,.example-row select{font-size:10px}.page-footer{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:26px 0 30px;color:var(--faint);font-size:10px}.page-footer>span{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:9px}.page-footer p{margin:0;line-height:1.8}.page-footer strong{font-weight:400;color:var(--muted)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}[hidden]{display:none!important}
+.baseline-row{border-top:1px solid var(--line);padding:15px 20px;display:flex;flex-wrap:wrap;gap:8px 16px;align-items:center}.baseline-row a{font-size:11px;color:var(--accent);text-underline-offset:3px}.baseline-row span{font-size:10px;color:var(--faint)}#scene:focus-visible{outline:2px solid var(--accent);outline-offset:-3px}
+@media(min-width:1450px){.code-editor{height:558px}.reference-grid pre{font-size:11px}}
+@media(max-width:1050px){.masthead{padding:0 25px}.experiment{display:none}main{padding:29px 25px 0}.workspace{grid-template-columns:minmax(300px,1fr) minmax(340px,1fr);gap:15px}.intro>p{font-size:11px;max-width:290px}.reference-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:25px}.reference-grid p{min-height:0}.reference-grid pre{min-height:110px;font-size:11px}.transport #clock-status{width:100%;margin:3px 0 0}.code-editor{height:533px}.panel-heading{padding:0 16px}.apply-row{gap:10px;padding-left:14px;padding-right:14px}.apply-row p{font-size:9px}.input-heading{align-items:flex-start;flex-direction:column;gap:5px}.intro{gap:20px}button.primary{gap:14px;padding:11px 13px}}
+@media(max-width:760px){.masthead{height:63px;padding:0 20px}.wordmark{font-size:21px}.wordmark span{font-size:11px}.connection{font-size:10px}main{padding:25px 18px 0}.intro{display:block;margin-bottom:22px}.intro h1{font-size:28px}.intro>p{margin-top:14px;max-width:none;font-size:11px}.workspace{grid-template-columns:minmax(0,1fr);gap:17px}.code-editor{height:340px}.editor-panel{width:100%}.transport #clock-status{width:auto;margin-left:auto}.right-column{gap:17px}.input-heading{flex-direction:row;align-items:center}.reference{margin-top:20px}.reference-grid{padding:20px 16px;gap:24px 17px}.reference-grid pre{font-size:10px}.reference>.panel-heading{height:auto;min-height:57px;padding-top:12px;padding-bottom:12px;align-items:flex-start;flex-direction:column;gap:6px}.syntax-columns{grid-template-columns:1fr;gap:0}.example-row{flex-wrap:wrap;padding:17px 16px}.example-row>div{width:100%;margin-bottom:4px}.example-row select{flex:1}.page-footer{align-items:flex-start;flex-direction:column;gap:10px;padding:23px 0}.apply-row{padding:14px 16px 10px;gap:17px}.apply-row p{font-size:10px;max-width:none}.diagnostic{margin-left:16px}.reference-grid article{overflow:hidden}}
+@media(max-width:420px){main{padding-left:13px;padding-right:13px}.masthead{padding:0 15px}.wordmark span{font-size:10px}.connection{font-size:9px;gap:6px}.intro h1{font-size:25px}.transport{gap:5px;padding-left:12px;padding-right:12px}.transport button{font-size:10px;padding:7px 8px}.transport #clock-status{font-size:9px}.input-heading>span{font-size:8px}.reference-grid{grid-template-columns:minmax(0,1fr)}.reference-grid pre{min-height:0;font-size:11px}.reference-grid p{margin-bottom:8px}.file-tools{gap:0;padding:7px 5px}.file-tools .quiet{font-size:10px;padding:6px 7px}.apply-row p{font-size:9px}.line-numbers{padding-left:10px;min-width:36px}textarea#source{padding-left:12px;font-size:11px}.line-numbers{font-size:11px}}
+@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
diff --git a/prototypes/workbench/results/2026-09-23-presentation/browser-authoring.json b/prototypes/workbench/results/2026-09-23-presentation/browser-authoring.json
new file mode 100644
index 0000000..996591e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-presentation/browser-authoring.json
@@ -0,0 +1,131 @@
+{
+ "recordedUtc": "2026-09-23T05:42:05.485Z",
+ "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36",
+ "checks": [
+ "initial editor has literally empty source",
+ "initial scene has no built-in entities, states, inputs, or running clock",
+ "controls require an authored program",
+ "authoring a new program is a draft until Apply",
+ "new custom program constructs states and multiple views from source",
+ "input buttons are generated from the authored declarations",
+ "view bindings render the authored state value",
+ "Space and arrows in the editor never reach authored inputs",
+ "pointer down dispatches once while the clock is paused",
+ "pointer up releases without double dispatch from click",
+ "keyboard repeat is ignored",
+ "keyboard activation on a generated button is one complete tap",
+ "assistive click activation is one complete tap",
+ "keyboard and pointer sources share one logical input",
+ "releasing one source does not release the other source",
+ "last source releases the logical input",
+ "authored down/frame behavior uses the declared dt without built-in motion",
+ "keyup releases even after focus moves into the editor",
+ "focus loss sends release and pauses the clock",
+ "Play advances and Pause freezes the clock",
+ "invalid code preserves the previous executable program and states",
+ "compile error exposes a source-line diagnostic",
+ "diagnostic location moves the editor caret to the problem",
+ "apply preserves matching state names and initializes newly authored states",
+ "new input, entity, binding, and behavior work with source changes alone",
+ "changed behavior takes effect without resetting existing values",
+ "explicit Reset restores active initializers and keeps the draft",
+ "Apply is refused while an input is held",
+ "clear editor is explicit and does not clear the running scene before Apply",
+ "downloaded project restores the exact un-applied draft without execution",
+ "invalid JSON project schema cannot replace the draft",
+ "failed assistive down event rolls back and its error survives the following successful up",
+ "latched runtime failure is visible and labeled separately from the latest input",
+ "explicit Reset clears the failure record",
+ "applying empty source removes all authored states, inputs, and scene contents",
+ "explicit example loading only fills the editor and never auto-applies",
+ "authored scene executes in the actual fixed 64 KiB Wasm memory",
+ "cost inspector shows measured Wasm memory without claiming browser heap totals",
+ "390px mobile viewport has no horizontal overflow",
+ "generated mobile input buttons remain usable",
+ "ordinary-JavaScript comparison and equivalent Pure example are linked and served",
+ "missing Wasm shows an actionable load failure and disables execution controls",
+ "fresh load always returns to a blank authoring environment",
+ "no uncaught page errors during the browser scenarios"
+ ],
+ "authoredSource": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "authoredSnapshot": {
+ "states": {
+ "x": 114,
+ "moving": 0,
+ "count": 2
+ },
+ "views": [
+ {
+ "id": "marker",
+ "type": "box",
+ "color": "#7ed9ae",
+ "x": 114,
+ "y": 170,
+ "width": 48,
+ "height": 48
+ },
+ {
+ "id": "reading",
+ "type": "text",
+ "prefix": "Signals: ",
+ "x": 40,
+ "y": 70,
+ "value": 2
+ }
+ ],
+ "tick": 12,
+ "revision": 5,
+ "displayError": null,
+ "lastError": 0,
+ "errorMessage": "",
+ "errorLine": null,
+ "lastOps": 2,
+ "successfulEvents": 18,
+ "held": [],
+ "lastEvent": {
+ "name": "advance",
+ "phase": "up",
+ "source": "keyboard:ArrowRight",
+ "status": 0,
+ "line": null,
+ "ops": 2
+ },
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19128,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 464,
+ "activeStateBytes": 24,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ },
+ "ready": true,
+ "source": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "appliedSource": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "dirty": false,
+ "running": false,
+ "droppedTicks": 0,
+ "diagnostic": "코드를 적용했습니다. 같은 이름의 상태는 유지됩니다."
+ },
+ "mobile": {
+ "viewport": 390,
+ "content": 390
+ },
+ "downloaded": {
+ "filename": "my-work.pure.json",
+ "path": "output/playwright/pure-presentation-workbench-draft.json",
+ "expectedSource": "# A new authored interaction, not a bundled example.\nstate x = 500\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 5 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\nstate bonus = 7\ninput reverse = KeyA\ncircle beacon(300, 190, 15 + bonus, \"#c9f08b\")\non reverse.down { bonus = bonus + 2 }\n# This un-applied draft must survive Reset.\n"
+ },
+ "errors": [],
+ "screenshots": [
+ "output/playwright/pure-presentation-workbench-blank.png",
+ "output/playwright/pure-presentation-workbench-authored.png",
+ "output/playwright/pure-presentation-workbench-mobile.png"
+ ],
+ "qaSourceSha256": "0d4c155b3575481c60f175b71acdc332f1c0d8489297794deca6f55999ead1b8",
+ "executedScriptSha256": "b70a26bdc9d84e85b459da697670e8c122d5447a5bca04cecba6a54a317a7fec",
+ "scriptChange": "Artifact filename prefix (screenshots and downloaded draft) changed from pure-workbench- to pure-presentation-workbench- only."
+}
diff --git a/prototypes/workbench/results/2026-09-23-presentation/browser-limit.json b/prototypes/workbench/results/2026-09-23-presentation/browser-limit.json
new file mode 100644
index 0000000..a85692e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-presentation/browser-limit.json
@@ -0,0 +1,242 @@
+{
+ "utc": "2026-09-23T05:43:00.130Z",
+ "checks": [
+ "authored browser input commits fractional state",
+ "bounded evaluation renders the exact authored expression",
+ "code replacement preserves state and changes binding result",
+ "browser Wasm reuses completed bindings",
+ "browser runs the shared-workspace build with only 56 additional dedicated bytes",
+ "discarding records preserves the browser snapshot",
+ "browser API publishes no partial scene after budget exhaustion",
+ "raising the budget recovers the browser snapshot",
+ "configured UI keeps committed state while withholding incomplete display",
+ "UI distinguishes display limit from event rollback",
+ "normal configuration reloads to a clean blank work",
+ "no uncaught browser errors"
+ ],
+ "api": {
+ "before": {
+ "states": {
+ "x": 3
+ },
+ "views": [
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "",
+ "x": 40,
+ "y": 60,
+ "value": 10
+ }
+ ],
+ "tick": 0,
+ "revision": 1,
+ "displayError": null,
+ "lastError": 0,
+ "errorMessage": "",
+ "errorLine": null,
+ "lastOps": 0,
+ "successfulEvents": 0,
+ "held": [],
+ "lastEvent": null,
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19128,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 252,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ }
+ },
+ "warm": {
+ "cacheEntries": 32,
+ "historyEntries": 8,
+ "workLimit": 2048,
+ "work": 6,
+ "instructions": 0,
+ "cacheHits": 3,
+ "reservedBytes": 1200,
+ "dedicatedBytes": 48,
+ "sharedBytes": 1152,
+ "reuseAvailable": true,
+ "workspaceTagBytes": 8
+ },
+ "forgotten": {
+ "states": {
+ "x": 3
+ },
+ "views": [
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "",
+ "x": 40,
+ "y": 60,
+ "value": 10
+ }
+ ],
+ "tick": 0,
+ "revision": 1,
+ "displayError": null,
+ "lastError": 0,
+ "errorMessage": "",
+ "errorLine": null,
+ "lastOps": 0,
+ "successfulEvents": 0,
+ "held": [],
+ "lastEvent": null,
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19128,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 252,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ }
+ },
+ "limited": {
+ "states": {
+ "x": 3
+ },
+ "views": [],
+ "tick": 0,
+ "revision": 1,
+ "displayError": {
+ "code": 101,
+ "line": 3,
+ "message": "표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요."
+ },
+ "lastError": 101,
+ "errorMessage": "표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.",
+ "errorLine": 3,
+ "lastOps": 0,
+ "successfulEvents": 0,
+ "held": [],
+ "lastEvent": null,
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19128,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 252,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ }
+ },
+ "limitStats": {
+ "cacheEntries": 32,
+ "historyEntries": 8,
+ "workLimit": 1,
+ "work": 1,
+ "instructions": 0,
+ "cacheHits": 0,
+ "reservedBytes": 1200,
+ "dedicatedBytes": 48,
+ "sharedBytes": 1152,
+ "reuseAvailable": true,
+ "workspaceTagBytes": 8
+ },
+ "recovered": {
+ "states": {
+ "x": 3
+ },
+ "views": [
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "",
+ "x": 40,
+ "y": 60,
+ "value": 10
+ }
+ ],
+ "tick": 0,
+ "revision": 1,
+ "displayError": null,
+ "lastError": 0,
+ "errorMessage": "",
+ "errorLine": null,
+ "lastOps": 0,
+ "successfulEvents": 0,
+ "held": [],
+ "lastEvent": null,
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19128,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 252,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ }
+ }
+ },
+ "limitedUi": {
+ "states": {
+ "x": 3.25
+ },
+ "views": [],
+ "tick": 0,
+ "revision": 1,
+ "displayError": {
+ "code": 101,
+ "line": 3,
+ "message": "표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요."
+ },
+ "lastError": 101,
+ "errorMessage": "표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.",
+ "errorLine": 3,
+ "lastOps": 0,
+ "successfulEvents": 2,
+ "held": [],
+ "lastEvent": {
+ "name": "next",
+ "phase": "up",
+ "source": "pointer:1",
+ "status": 0,
+ "line": null,
+ "ops": 0
+ },
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19128,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 252,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ },
+ "ready": true,
+ "source": "state x = 3\ninput next = KeyN\ntext label(40, 60, (x + 1) * (x + 2) / 2)\non next.down { x = x + 0.25 }\n",
+ "appliedSource": "state x = 3\ninput next = KeyN\ntext label(40, 60, (x + 1) * (x + 2) / 2)\non next.down { x = x + 0.25 }\n",
+ "dirty": false,
+ "running": false,
+ "droppedTicks": 0,
+ "diagnostic": "코드를 적용했습니다. 같은 이름의 상태는 유지됩니다."
+ },
+ "message": "표시 오류 · 3행 · 표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요. — 이번 장면을 표시하지 않았습니다. 상태 변경은 유지됩니다.",
+ "errors": [],
+ "uiLimitSetup": "Test-only module response overrides default workLimit to 1; production files are unchanged.",
+ "screenshot": "output/playwright/pure-presentation-limit.png",
+ "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36",
+ "qaSourceSha256": "d83577c0851b465c3bd43c256b53659aef9df96f3c3dffe7e59f5d7c98db80c6",
+ "executedScriptSha256": "b1e1a18f540720252e3d2e6bb00907b343aee6f23ab339e704e96bf2e81229a9",
+ "scriptChange": "Screenshot path changed from pure-workspace-limit.png to pure-presentation-limit.png only."
+}
diff --git a/prototypes/workbench/results/2026-09-23-presentation/browser.json b/prototypes/workbench/results/2026-09-23-presentation/browser.json
new file mode 100644
index 0000000..d42f835
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-presentation/browser.json
@@ -0,0 +1,1536 @@
+{
+ "utc": "2026-09-23T05:40:52.800Z",
+ "checks": [
+ "real keyboard event reaches the authored output",
+ "real pointer input and keyboard input use the same authored behavior",
+ "the displayed element survives an input update",
+ "failed editing preserves active values and output destinations",
+ "invalid view is hidden while valid views remain published",
+ "reset recovers a hidden destination without recreating the scene",
+ "successful editing replaces changed output structure",
+ "applying empty work removes all old destinations",
+ "all six archived/current runs have identical final authored state and views",
+ "current hot path creates no DOM elements and no full snapshots",
+ "archived hot path reproduces full snapshots and element replacement",
+ "each tap changes only the moving x attribute and label text in the scene",
+ "no uncaught browser errors"
+ ],
+ "errors": [],
+ "samples": [
+ {
+ "variant": "before",
+ "taps": 100,
+ "events": 200,
+ "elapsedMs": 941.1999999955297,
+ "createdElements": 9200,
+ "snapshotCalls": 200,
+ "mutations": {
+ "scene-content": {
+ "records": 200,
+ "childList": 200,
+ "attributes": 0
+ },
+ "state-values": {
+ "records": 200,
+ "childList": 200,
+ "attributes": 0
+ },
+ "cost-values": {
+ "records": 200,
+ "childList": 200,
+ "attributes": 0
+ }
+ },
+ "identityPreserved": false,
+ "final": {
+ "states": {
+ "x": 70,
+ "radius": 12
+ },
+ "views": [
+ {
+ "id": "moving",
+ "type": "box",
+ "color": "#7ed9ae",
+ "x": 70,
+ "y": 100,
+ "width": 30,
+ "height": 30
+ },
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "x: ",
+ "x": 20,
+ "y": 40,
+ "value": 70
+ },
+ {
+ "id": "bubble",
+ "type": "circle",
+ "color": "#c9f08b",
+ "x": 500,
+ "y": 150,
+ "radius": 12
+ },
+ {
+ "id": "static0",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static1",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static2",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static3",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static4",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static5",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static6",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static7",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static8",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static9",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static10",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static11",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static12",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static13",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static14",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static15",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static16",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static17",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static18",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static19",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static20",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ }
+ ],
+ "successfulEvents": 240
+ },
+ "scene": "<g id=\"scene-content\"><rect data-view-id=\"moving\" x=\"70\" y=\"100\" width=\"30\" height=\"30\" fill=\"#7ed9ae\"></rect><text data-view-id=\"label\" x=\"20\" y=\"40\" fill=\"#e7ece6\" font-family=\"ui-monospace, monospace\" font-size=\"20\">x: 70</text><circle data-view-id=\"bubble\" cx=\"500\" cy=\"150\" r=\"12\" fill=\"#c9f08b\"></circle><rect data-view-id=\"static0\" x=\"20\" y=\"200\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static1\" x=\"90\" y=\"200\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static2\" x=\"160\" y=\"200\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static3\" x=\"230\" y=\"200\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static4\" x=\"300\" y=\"200\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static5\" x=\"370\" y=\"200\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static6\" x=\"440\" y=\"200\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static7\" x=\"20\" y=\"240\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static8\" x=\"90\" y=\"240\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static9\" x=\"160\" y=\"240\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static10\" x=\"230\" y=\"240\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static11\" x=\"300\" y=\"240\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static12\" x=\"370\" y=\"240\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static13\" x=\"440\" y=\"240\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static14\" x=\"20\" y=\"280\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static15\" x=\"90\" y=\"280\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static16\" x=\"160\" y=\"280\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static17\" x=\"230\" y=\"280\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static18\" x=\"300\" y=\"280\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static19\" x=\"370\" y=\"280\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static20\" x=\"440\" y=\"280\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect></g>"
+ },
+ {
+ "variant": "after",
+ "taps": 100,
+ "events": 200,
+ "elapsedMs": 751.3999999985099,
+ "createdElements": 0,
+ "snapshotCalls": 0,
+ "mutations": {
+ "scene-content": {
+ "records": 200,
+ "childList": 100,
+ "attributes": 100
+ },
+ "state-values": {
+ "records": 100,
+ "childList": 100,
+ "attributes": 0
+ },
+ "cost-values": {
+ "records": 200,
+ "childList": 200,
+ "attributes": 0
+ }
+ },
+ "identityPreserved": true,
+ "final": {
+ "states": {
+ "x": 70,
+ "radius": 12
+ },
+ "views": [
+ {
+ "id": "moving",
+ "type": "box",
+ "color": "#7ed9ae",
+ "x": 70,
+ "y": 100,
+ "width": 30,
+ "height": 30
+ },
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "x: ",
+ "x": 20,
+ "y": 40,
+ "value": 70
+ },
+ {
+ "id": "bubble",
+ "type": "circle",
+ "color": "#c9f08b",
+ "x": 500,
+ "y": 150,
+ "radius": 12
+ },
+ {
+ "id": "static0",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static1",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static2",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static3",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static4",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static5",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static6",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static7",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static8",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static9",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static10",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static11",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static12",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static13",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static14",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static15",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static16",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static17",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static18",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static19",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static20",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ }
+ ],
+ "successfulEvents": 240
+ },
+ "scene": "<g id=\"scene-content\"><rect data-view-id=\"moving\" fill=\"#7ed9ae\" x=\"70\" y=\"100\" width=\"30\" height=\"30\"></rect><text data-view-id=\"label\" fill=\"#e7ece6\" font-family=\"ui-monospace, monospace\" font-size=\"20\" x=\"20\" y=\"40\">x: 70</text><circle data-view-id=\"bubble\" fill=\"#c9f08b\" cx=\"500\" cy=\"150\" r=\"12\"></circle><rect data-view-id=\"static0\" fill=\"#8da5bb\" x=\"20\" y=\"200\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static1\" fill=\"#8da5bb\" x=\"90\" y=\"200\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static2\" fill=\"#8da5bb\" x=\"160\" y=\"200\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static3\" fill=\"#8da5bb\" x=\"230\" y=\"200\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static4\" fill=\"#8da5bb\" x=\"300\" y=\"200\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static5\" fill=\"#8da5bb\" x=\"370\" y=\"200\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static6\" fill=\"#8da5bb\" x=\"440\" y=\"200\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static7\" fill=\"#8da5bb\" x=\"20\" y=\"240\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static8\" fill=\"#8da5bb\" x=\"90\" y=\"240\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static9\" fill=\"#8da5bb\" x=\"160\" y=\"240\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static10\" fill=\"#8da5bb\" x=\"230\" y=\"240\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static11\" fill=\"#8da5bb\" x=\"300\" y=\"240\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static12\" fill=\"#8da5bb\" x=\"370\" y=\"240\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static13\" fill=\"#8da5bb\" x=\"440\" y=\"240\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static14\" fill=\"#8da5bb\" x=\"20\" y=\"280\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static15\" fill=\"#8da5bb\" x=\"90\" y=\"280\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static16\" fill=\"#8da5bb\" x=\"160\" y=\"280\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static17\" fill=\"#8da5bb\" x=\"230\" y=\"280\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static18\" fill=\"#8da5bb\" x=\"300\" y=\"280\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static19\" fill=\"#8da5bb\" x=\"370\" y=\"280\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static20\" fill=\"#8da5bb\" x=\"440\" y=\"280\" width=\"20\" height=\"20\"></rect></g>"
+ },
+ {
+ "variant": "after",
+ "taps": 100,
+ "events": 200,
+ "elapsedMs": 579.1000000014901,
+ "createdElements": 0,
+ "snapshotCalls": 0,
+ "mutations": {
+ "scene-content": {
+ "records": 200,
+ "childList": 100,
+ "attributes": 100
+ },
+ "state-values": {
+ "records": 100,
+ "childList": 100,
+ "attributes": 0
+ },
+ "cost-values": {
+ "records": 200,
+ "childList": 200,
+ "attributes": 0
+ }
+ },
+ "identityPreserved": true,
+ "final": {
+ "states": {
+ "x": 70,
+ "radius": 12
+ },
+ "views": [
+ {
+ "id": "moving",
+ "type": "box",
+ "color": "#7ed9ae",
+ "x": 70,
+ "y": 100,
+ "width": 30,
+ "height": 30
+ },
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "x: ",
+ "x": 20,
+ "y": 40,
+ "value": 70
+ },
+ {
+ "id": "bubble",
+ "type": "circle",
+ "color": "#c9f08b",
+ "x": 500,
+ "y": 150,
+ "radius": 12
+ },
+ {
+ "id": "static0",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static1",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static2",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static3",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static4",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static5",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static6",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static7",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static8",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static9",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static10",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static11",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static12",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static13",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static14",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static15",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static16",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static17",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static18",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static19",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static20",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ }
+ ],
+ "successfulEvents": 240
+ },
+ "scene": "<g id=\"scene-content\"><rect data-view-id=\"moving\" fill=\"#7ed9ae\" x=\"70\" y=\"100\" width=\"30\" height=\"30\"></rect><text data-view-id=\"label\" fill=\"#e7ece6\" font-family=\"ui-monospace, monospace\" font-size=\"20\" x=\"20\" y=\"40\">x: 70</text><circle data-view-id=\"bubble\" fill=\"#c9f08b\" cx=\"500\" cy=\"150\" r=\"12\"></circle><rect data-view-id=\"static0\" fill=\"#8da5bb\" x=\"20\" y=\"200\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static1\" fill=\"#8da5bb\" x=\"90\" y=\"200\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static2\" fill=\"#8da5bb\" x=\"160\" y=\"200\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static3\" fill=\"#8da5bb\" x=\"230\" y=\"200\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static4\" fill=\"#8da5bb\" x=\"300\" y=\"200\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static5\" fill=\"#8da5bb\" x=\"370\" y=\"200\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static6\" fill=\"#8da5bb\" x=\"440\" y=\"200\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static7\" fill=\"#8da5bb\" x=\"20\" y=\"240\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static8\" fill=\"#8da5bb\" x=\"90\" y=\"240\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static9\" fill=\"#8da5bb\" x=\"160\" y=\"240\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static10\" fill=\"#8da5bb\" x=\"230\" y=\"240\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static11\" fill=\"#8da5bb\" x=\"300\" y=\"240\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static12\" fill=\"#8da5bb\" x=\"370\" y=\"240\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static13\" fill=\"#8da5bb\" x=\"440\" y=\"240\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static14\" fill=\"#8da5bb\" x=\"20\" y=\"280\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static15\" fill=\"#8da5bb\" x=\"90\" y=\"280\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static16\" fill=\"#8da5bb\" x=\"160\" y=\"280\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static17\" fill=\"#8da5bb\" x=\"230\" y=\"280\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static18\" fill=\"#8da5bb\" x=\"300\" y=\"280\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static19\" fill=\"#8da5bb\" x=\"370\" y=\"280\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static20\" fill=\"#8da5bb\" x=\"440\" y=\"280\" width=\"20\" height=\"20\"></rect></g>"
+ },
+ {
+ "variant": "before",
+ "taps": 100,
+ "events": 200,
+ "elapsedMs": 648.6999999955297,
+ "createdElements": 9200,
+ "snapshotCalls": 200,
+ "mutations": {
+ "scene-content": {
+ "records": 200,
+ "childList": 200,
+ "attributes": 0
+ },
+ "state-values": {
+ "records": 200,
+ "childList": 200,
+ "attributes": 0
+ },
+ "cost-values": {
+ "records": 200,
+ "childList": 200,
+ "attributes": 0
+ }
+ },
+ "identityPreserved": false,
+ "final": {
+ "states": {
+ "x": 70,
+ "radius": 12
+ },
+ "views": [
+ {
+ "id": "moving",
+ "type": "box",
+ "color": "#7ed9ae",
+ "x": 70,
+ "y": 100,
+ "width": 30,
+ "height": 30
+ },
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "x: ",
+ "x": 20,
+ "y": 40,
+ "value": 70
+ },
+ {
+ "id": "bubble",
+ "type": "circle",
+ "color": "#c9f08b",
+ "x": 500,
+ "y": 150,
+ "radius": 12
+ },
+ {
+ "id": "static0",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static1",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static2",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static3",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static4",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static5",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static6",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static7",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static8",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static9",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static10",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static11",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static12",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static13",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static14",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static15",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static16",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static17",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static18",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static19",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static20",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ }
+ ],
+ "successfulEvents": 240
+ },
+ "scene": "<g id=\"scene-content\"><rect data-view-id=\"moving\" x=\"70\" y=\"100\" width=\"30\" height=\"30\" fill=\"#7ed9ae\"></rect><text data-view-id=\"label\" x=\"20\" y=\"40\" fill=\"#e7ece6\" font-family=\"ui-monospace, monospace\" font-size=\"20\">x: 70</text><circle data-view-id=\"bubble\" cx=\"500\" cy=\"150\" r=\"12\" fill=\"#c9f08b\"></circle><rect data-view-id=\"static0\" x=\"20\" y=\"200\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static1\" x=\"90\" y=\"200\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static2\" x=\"160\" y=\"200\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static3\" x=\"230\" y=\"200\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static4\" x=\"300\" y=\"200\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static5\" x=\"370\" y=\"200\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static6\" x=\"440\" y=\"200\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static7\" x=\"20\" y=\"240\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static8\" x=\"90\" y=\"240\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static9\" x=\"160\" y=\"240\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static10\" x=\"230\" y=\"240\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static11\" x=\"300\" y=\"240\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static12\" x=\"370\" y=\"240\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static13\" x=\"440\" y=\"240\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static14\" x=\"20\" y=\"280\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static15\" x=\"90\" y=\"280\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static16\" x=\"160\" y=\"280\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static17\" x=\"230\" y=\"280\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static18\" x=\"300\" y=\"280\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static19\" x=\"370\" y=\"280\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static20\" x=\"440\" y=\"280\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect></g>"
+ },
+ {
+ "variant": "before",
+ "taps": 100,
+ "events": 200,
+ "elapsedMs": 633.6000000014901,
+ "createdElements": 9200,
+ "snapshotCalls": 200,
+ "mutations": {
+ "scene-content": {
+ "records": 200,
+ "childList": 200,
+ "attributes": 0
+ },
+ "state-values": {
+ "records": 200,
+ "childList": 200,
+ "attributes": 0
+ },
+ "cost-values": {
+ "records": 200,
+ "childList": 200,
+ "attributes": 0
+ }
+ },
+ "identityPreserved": false,
+ "final": {
+ "states": {
+ "x": 70,
+ "radius": 12
+ },
+ "views": [
+ {
+ "id": "moving",
+ "type": "box",
+ "color": "#7ed9ae",
+ "x": 70,
+ "y": 100,
+ "width": 30,
+ "height": 30
+ },
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "x: ",
+ "x": 20,
+ "y": 40,
+ "value": 70
+ },
+ {
+ "id": "bubble",
+ "type": "circle",
+ "color": "#c9f08b",
+ "x": 500,
+ "y": 150,
+ "radius": 12
+ },
+ {
+ "id": "static0",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static1",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static2",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static3",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static4",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static5",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static6",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static7",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static8",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static9",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static10",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static11",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static12",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static13",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static14",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static15",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static16",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static17",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static18",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static19",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static20",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ }
+ ],
+ "successfulEvents": 240
+ },
+ "scene": "<g id=\"scene-content\"><rect data-view-id=\"moving\" x=\"70\" y=\"100\" width=\"30\" height=\"30\" fill=\"#7ed9ae\"></rect><text data-view-id=\"label\" x=\"20\" y=\"40\" fill=\"#e7ece6\" font-family=\"ui-monospace, monospace\" font-size=\"20\">x: 70</text><circle data-view-id=\"bubble\" cx=\"500\" cy=\"150\" r=\"12\" fill=\"#c9f08b\"></circle><rect data-view-id=\"static0\" x=\"20\" y=\"200\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static1\" x=\"90\" y=\"200\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static2\" x=\"160\" y=\"200\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static3\" x=\"230\" y=\"200\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static4\" x=\"300\" y=\"200\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static5\" x=\"370\" y=\"200\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static6\" x=\"440\" y=\"200\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static7\" x=\"20\" y=\"240\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static8\" x=\"90\" y=\"240\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static9\" x=\"160\" y=\"240\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static10\" x=\"230\" y=\"240\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static11\" x=\"300\" y=\"240\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static12\" x=\"370\" y=\"240\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static13\" x=\"440\" y=\"240\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static14\" x=\"20\" y=\"280\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static15\" x=\"90\" y=\"280\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static16\" x=\"160\" y=\"280\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static17\" x=\"230\" y=\"280\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static18\" x=\"300\" y=\"280\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static19\" x=\"370\" y=\"280\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect><rect data-view-id=\"static20\" x=\"440\" y=\"280\" width=\"20\" height=\"20\" fill=\"#8da5bb\"></rect></g>"
+ },
+ {
+ "variant": "after",
+ "taps": 100,
+ "events": 200,
+ "elapsedMs": 733.0999999940395,
+ "createdElements": 0,
+ "snapshotCalls": 0,
+ "mutations": {
+ "scene-content": {
+ "records": 200,
+ "childList": 100,
+ "attributes": 100
+ },
+ "state-values": {
+ "records": 100,
+ "childList": 100,
+ "attributes": 0
+ },
+ "cost-values": {
+ "records": 200,
+ "childList": 200,
+ "attributes": 0
+ }
+ },
+ "identityPreserved": true,
+ "final": {
+ "states": {
+ "x": 70,
+ "radius": 12
+ },
+ "views": [
+ {
+ "id": "moving",
+ "type": "box",
+ "color": "#7ed9ae",
+ "x": 70,
+ "y": 100,
+ "width": 30,
+ "height": 30
+ },
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "x: ",
+ "x": 20,
+ "y": 40,
+ "value": 70
+ },
+ {
+ "id": "bubble",
+ "type": "circle",
+ "color": "#c9f08b",
+ "x": 500,
+ "y": 150,
+ "radius": 12
+ },
+ {
+ "id": "static0",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static1",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static2",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static3",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static4",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static5",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static6",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static7",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static8",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static9",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static10",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static11",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static12",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static13",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 240,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static14",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static15",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static16",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static17",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static18",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static19",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static20",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 280,
+ "width": 20,
+ "height": 20
+ }
+ ],
+ "successfulEvents": 240
+ },
+ "scene": "<g id=\"scene-content\"><rect data-view-id=\"moving\" fill=\"#7ed9ae\" x=\"70\" y=\"100\" width=\"30\" height=\"30\"></rect><text data-view-id=\"label\" fill=\"#e7ece6\" font-family=\"ui-monospace, monospace\" font-size=\"20\" x=\"20\" y=\"40\">x: 70</text><circle data-view-id=\"bubble\" fill=\"#c9f08b\" cx=\"500\" cy=\"150\" r=\"12\"></circle><rect data-view-id=\"static0\" fill=\"#8da5bb\" x=\"20\" y=\"200\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static1\" fill=\"#8da5bb\" x=\"90\" y=\"200\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static2\" fill=\"#8da5bb\" x=\"160\" y=\"200\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static3\" fill=\"#8da5bb\" x=\"230\" y=\"200\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static4\" fill=\"#8da5bb\" x=\"300\" y=\"200\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static5\" fill=\"#8da5bb\" x=\"370\" y=\"200\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static6\" fill=\"#8da5bb\" x=\"440\" y=\"200\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static7\" fill=\"#8da5bb\" x=\"20\" y=\"240\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static8\" fill=\"#8da5bb\" x=\"90\" y=\"240\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static9\" fill=\"#8da5bb\" x=\"160\" y=\"240\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static10\" fill=\"#8da5bb\" x=\"230\" y=\"240\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static11\" fill=\"#8da5bb\" x=\"300\" y=\"240\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static12\" fill=\"#8da5bb\" x=\"370\" y=\"240\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static13\" fill=\"#8da5bb\" x=\"440\" y=\"240\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static14\" fill=\"#8da5bb\" x=\"20\" y=\"280\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static15\" fill=\"#8da5bb\" x=\"90\" y=\"280\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static16\" fill=\"#8da5bb\" x=\"160\" y=\"280\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static17\" fill=\"#8da5bb\" x=\"230\" y=\"280\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static18\" fill=\"#8da5bb\" x=\"300\" y=\"280\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static19\" fill=\"#8da5bb\" x=\"370\" y=\"280\" width=\"20\" height=\"20\"></rect><rect data-view-id=\"static20\" fill=\"#8da5bb\" x=\"440\" y=\"280\" width=\"20\" height=\"20\"></rect></g>"
+ }
+ ],
+ "source": "state x = 40\nstate radius = 12\ninput move = KeyM\ninput shrink = KeyS\nbox moving(x, 100, 30, 30, \"#7ed9ae\")\ntext label(20, 40, x, \"x: \")\ncircle bubble(500, 150, radius, \"#c9f08b\")\nbox static0(20, 200, 20, 20, \"#8da5bb\")\nbox static1(90, 200, 20, 20, \"#8da5bb\")\nbox static2(160, 200, 20, 20, \"#8da5bb\")\nbox static3(230, 200, 20, 20, \"#8da5bb\")\nbox static4(300, 200, 20, 20, \"#8da5bb\")\nbox static5(370, 200, 20, 20, \"#8da5bb\")\nbox static6(440, 200, 20, 20, \"#8da5bb\")\nbox static7(20, 240, 20, 20, \"#8da5bb\")\nbox static8(90, 240, 20, 20, \"#8da5bb\")\nbox static9(160, 240, 20, 20, \"#8da5bb\")\nbox static10(230, 240, 20, 20, \"#8da5bb\")\nbox static11(300, 240, 20, 20, \"#8da5bb\")\nbox static12(370, 240, 20, 20, \"#8da5bb\")\nbox static13(440, 240, 20, 20, \"#8da5bb\")\nbox static14(20, 280, 20, 20, \"#8da5bb\")\nbox static15(90, 280, 20, 20, \"#8da5bb\")\nbox static16(160, 280, 20, 20, \"#8da5bb\")\nbox static17(230, 280, 20, 20, \"#8da5bb\")\nbox static18(300, 280, 20, 20, \"#8da5bb\")\nbox static19(370, 280, 20, 20, \"#8da5bb\")\nbox static20(440, 280, 20, 20, \"#8da5bb\")\non move.down { x = x + 0.25 }\non shrink.down { radius = radius - 24 }",
+ "timingScope": "100 synchronous synthetic keyboard taps after 20 warm-up taps; JS dispatch to DOM submission only, including instrumentation; not paint/input latency.",
+ "setup": "Archived app/runtime are supplied by test-only routing; snapshot calls and element creation are instrumented. Same Wasm/compiler and production configuration.",
+ "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36",
+ "screenshot": "output/playwright/pure-presentation.png",
+ "sourceSha256": {
+ "web/runtime.mjs": "d490c54fb2b422dfe89b90a594a8da9ebc95bb007910211cb4686ccb83ed75c7",
+ "web/app.js": "a50e6d99306461f690530eedf5797b2c86c3367a704d24dc5cc86a1c1d2035ea",
+ "web/output.mjs": "1d22c44955e812d671f1a3c3ae4b2f0642a05ebe614017bb041bdddc8c363be3",
+ "web/core.wasm": "76823b024fa5f4a79cbf1ed722ab757614a81a9f1c81d1d6268781a38bed7cc9",
+ "web/compiler.mjs": "a34f3beb069c811a9bd5be73dde9341907ba77756066030b8b95fc6d66a5cc02",
+ "qa-presentation.js": "04b0135983ea718d3744b56b706c9bf3266972a5180c744ac791ef787a29aa05",
+ "qa-presentation.py": "bec45426ec297896ed5c87b152148956bc6c5730ff188aea0e041a0ec69ae1c3"
+ },
+ "baselineManifestSha256": "6068e6ca3630e240484abfa2cddbb9d3fb877b3df1886ae500dc4caa485d4f6a"
+}
diff --git a/prototypes/workbench/results/2026-09-23-presentation/verification.json b/prototypes/workbench/results/2026-09-23-presentation/verification.json
new file mode 100644
index 0000000..eb007bb
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-presentation/verification.json
@@ -0,0 +1,58 @@
+{
+ "utc": "2026-09-23T05:41:43.833092+00:00",
+ "javascript": [
+ {
+ "file": "compiler.test.mjs",
+ "tests": 13,
+ "sha256": "6aa442d9d1eeb69453082f1780ff4597e46b1fc09d8ba3cf955545bc94d81431",
+ "output": "✔ a blank document is an empty program with no implied behavior (4.119065ms)\n✔ forward references and separate state/input namespaces preserve declaration IDs (2.599791ms)\n✔ ordered assignments, repeated handlers, and condition evaluation once (0.981294ms)\n✔ nested branches use absolute forward targets and skip correct bodies (0.595425ms)\n✔ operator precedence, associativity, unary and builtin function lowering (1.513886ms)\n✔ multiline parentheses, scientific literals, comments, and string escapes (0.671276ms)\n✔ all instruction fields are numeric and carry original source line (0.349295ms)\n✔ contextual declaration words remain valid names outside reserved builtins (0.541406ms)\n✔ unknown and reserved symbols have useful one-based locations (1.579459ms)\n✔ duplicates, unsupported keys, initializers, and invalid syntax are rejected (1.378719ms)\n✔ document, declaration, instruction, and nesting limits reject overflow (6.561671ms)\n✔ every supported input maps to a bounded ordered event ID (0.715887ms)\n✔ optional examples compile and charge behavior is entirely authored (10.511884ms)\nℹ tests 13\nℹ suites 0\nℹ pass 13\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 41.185946\n"
+ },
+ {
+ "file": "runtime.test.mjs",
+ "tests": 13,
+ "sha256": "5fe66115aab0c178a283dcb592376a2e7209308368004865e6c0a1cc58fa7f62",
+ "output": "✔ blank startup and applied empty source contain no application objects or behavior (7.223873ms)\n✔ author can add unrelated states inputs views and behavior entirely through source (4.612638ms)\n✔ keyboard and pointer sources aggregate first down and last up; repeats are ignored (2.630605ms)\n✔ held input rejects apply without changing applied source, metadata, or values (1.535145ms)\n✔ invalid syntax, unknown references and capacity overflow preserve the previous program (2.099848ms)\n✔ state preservation follows names across reorder/add/remove; reset uses new initializers (1.048794ms)\n✔ callers cannot mutate active metadata and corrupt name-based state preservation (0.86472ms)\n✔ conditions execute once, and assignments and multiple handlers follow source order (0.828498ms)\n✔ a failed event rolls back all handlers and keeps its source diagnostic through release (2.229514ms)\n✔ reset clears held inputs and latched errors; invalid frame counts have no effects (1.298812ms)\n✔ render evaluation is pure and invalid binding is distinct from an event rollback (4.6701ms)\n✔ independent adapters replay the same author program without sharing live state (2.174384ms)\n✔ Wasm has no imports and reports fixed memory and exact active IR/state storage (9.03348ms)\nℹ tests 13\nℹ suites 0\nℹ pass 13\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 51.721812\n"
+ },
+ {
+ "file": "baseline.test.mjs",
+ "tests": 7,
+ "sha256": "3ea469a29d301b95701bc6f6ffafa2471ac0f334c5bd462c0cbc7c320bdc0764",
+ "output": "✔ reference begins with the authored states and matching scene (2.419773ms)\n✔ press, hold, release, and frame order produce exact expected values (0.596573ms)\n✔ charge clamps at one and explicit floor restores rest (1.552126ms)\n✔ repeat and concurrent physical sources produce only logical edges (0.600425ms)\n✔ airborne presses cannot charge or cause another jump (0.332166ms)\n✔ releaseAll emits release and reset clears latches without a jump (0.530518ms)\n✔ unknown inputs and invalid frame counts reject without mutation (0.836813ms)\nℹ tests 7\nℹ suites 0\nℹ pass 7\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 16.923982\n"
+ },
+ {
+ "file": "cli.test.mjs",
+ "tests": 5,
+ "sha256": "655c6d80b51af818fa7cdeb27cb5462962a0e1aef70f609ef5e2ddc85f4a6bc7",
+ "output": "✔ terminal starts empty and produces the same charge trace as the shared runtime (112.599177ms)\n✔ invalid terminal commands do not alter state and fail piped execution (50.710481ms)\n✔ file load preserves by name, rejected load preserves active work, and save never overwrites (58.74187ms)\n✔ terminal exposes arithmetic rollback and retains failure through matching release (54.053511ms)\n✔ terminal configures and discards evaluation records and recovers from a display limit (49.682483ms)\nℹ tests 5\nℹ suites 0\nℹ pass 5\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 333.929744\n"
+ },
+ {
+ "file": "evaluation.test.mjs",
+ "tests": 7,
+ "sha256": "d3026ab5c6bffce56c0bde3155d7dddd6fa442a5688656ee710626c762258ff1",
+ "output": "✔ cache reuse, unrelated changes and discarded records preserve authored values (17.225666ms)\n✔ committed changes only: failed events and failed edits preserve reusable output (2.575309ms)\n✔ program edits, state reordering and reset cannot reuse old definitions (2.738642ms)\n✔ finite float semantics include signed zero, high state bit and arithmetic errors (2.509513ms)\n✔ one budget covers the whole scene; failures publish no partial scene or state rollback (1.276608ms)\n✔ small caches, no history and every small work limit give exact results or explicit failure (30.326359ms)\n✔ invalid settings and forget names preserve policy and state (2.301254ms)\nℹ tests 7\nℹ suites 0\nℹ pass 7\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 69.267384\n"
+ },
+ {
+ "file": "workspace.test.mjs",
+ "tests": 2,
+ "sha256": "7ea664736e4c9d791d72cf64de0fbc230b8d849910cc2a456d4d0e959acb9857",
+ "output": "✔ Wasm executes old events and output during staged replacement without corrupting either phase (6.30019ms)\n✔ workspace accounting reports shared payload and safety tag without double-counting (2.077181ms)\nℹ tests 2\nℹ suites 0\nℹ pass 2\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 14.960097\n"
+ },
+ {
+ "file": "presentation.test.mjs",
+ "tests": 5,
+ "sha256": "604858a3f601d2a67c601ff477415025dcb766cdfd092bafed2d9dcfda39abd5",
+ "output": "✔ direct publication matches archived snapshots and work counts through authored changes (48.77004ms)\n✔ whole-scene budget failure never publishes partial bindings and recovers exactly (5.804043ms)\n✔ latched event rollback, nonfinite bindings, reset and signed zero keep their semantics (3.292808ms)\n✔ publication rejects reentrant runtime changes and reads before they affect the scene (3.694584ms)\n✔ invalid or throwing output adapters do not strand the runtime or alter authored state (7.631298ms)\nℹ tests 5\nℹ suites 0\nℹ pass 5\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 82.836799\n"
+ }
+ ],
+ "javascriptTestCount": 52,
+ "sourceSha256": {
+ "web/runtime.mjs": "d490c54fb2b422dfe89b90a594a8da9ebc95bb007910211cb4686ccb83ed75c7",
+ "web/output.mjs": "1d22c44955e812d671f1a3c3ae4b2f0642a05ebe614017bb041bdddc8c363be3",
+ "web/app.js": "a50e6d99306461f690530eedf5797b2c86c3367a704d24dc5cc86a1c1d2035ea",
+ "web/core.wasm": "76823b024fa5f4a79cbf1ed722ab757614a81a9f1c81d1d6268781a38bed7cc9",
+ "web/compiler.mjs": "a34f3beb069c811a9bd5be73dde9341907ba77756066030b8b95fc6d66a5cc02",
+ "core.zig": "dc84bcb75e4417fb1a804529c78ffa8cd1ddd079c00df4ca97a35da0b8fdbb8a",
+ "evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165"
+ },
+ "nativeNote": "Zig and Wasm are unchanged; native tests are not rerun. Prior build record remains in 2026-09-23-workspace/verification.json."
+}
diff --git a/prototypes/workbench/results/2026-09-23-refresh/before/core.zig b/prototypes/workbench/results/2026-09-23-refresh/before/core.zig
new file mode 100644
index 0000000..84812a3
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-refresh/before/core.zig
@@ -0,0 +1,942 @@
+//! Bounded scalar interaction VM experiment. No application behavior is built in.
+//! Same source for native tests and freestanding Wasm; no allocation or imports.
+//! Error ABI: 0 ok, 1 capacity, 2 invalid, 3 stack, 4 division by zero,
+//! 5 non-finite, 6 bad branch, 7 bad state, 8 bad frame, 9 bad event.
+//! Counters wrap modulo 2^32. Failed loads preserve the active program, authored
+//! state and event diagnostics; beginning a load discards optional reuse records.
+//! Failed events preserve values and counters but
+//! report their error and attempted instruction count. Binding evaluation is pure.
+
+const std = @import("std");
+const Evaluation = @import("evaluation.zig").Evaluation;
+const Tables = @import("evaluation.zig").Tables;
+
+const max_states = 32;
+const max_inputs = 8;
+const max_instructions = 512;
+const max_handlers = 64;
+const max_stack = 64;
+const no_pc = std.math.maxInt(u32);
+const frame_dt: f64 = 1.0 / 60.0;
+
+const Code = enum(u32) {
+ ok = 0,
+ capacity = 1,
+ invalid = 2,
+ stack = 3,
+ division_by_zero = 4,
+ non_finite = 5,
+ bad_branch = 6,
+ bad_state = 7,
+ bad_frame = 8,
+ bad_event = 9,
+ work_limit = 10,
+};
+
+const Instruction = struct { value: f64 = 0, op: u32 = 0, arg: u32 = 0 };
+const Handler = struct { event: u32 = 0, start: u32 = 0, length: u32 = 0 };
+const Program = struct {
+ instructions: [max_instructions]Instruction = @splat(.{}),
+ handlers: [max_handlers]Handler = @splat(.{}),
+ initials: [max_states]f64 = @splat(0),
+ old_ids: [max_states]i32 = @splat(-1),
+ declared: [max_states]bool = @splat(false),
+ state_count: u32 = 0,
+ input_count: u32 = 0,
+ instruction_count: u32 = 0,
+ handler_count: u32 = 0,
+};
+// Loading and result reuse are mutually exclusive phases. Current execution
+// always reads active, and can recompute bindings while this workspace loads.
+const Workspace = union { staging: Program, reuse: Tables };
+const Engine = struct {
+ active: Program = .{},
+ workspace: Workspace = .{ .reuse = .{} },
+ values: [max_states]f64 = @splat(0),
+ staging_open: bool = false,
+ staging_error: Code = .ok,
+ tick: u32 = 0,
+ last_error: Code = .ok,
+ error_pc: u32 = no_pc,
+ last_ops: u32 = 0,
+ revision: u32 = 0,
+ successful_events: u32 = 0,
+ evaluation: Evaluation = .{},
+};
+var engine: Engine = .{};
+
+fn reuseTables() ?*Tables {
+ return if (engine.staging_open) null else &engine.workspace.reuse;
+}
+
+fn finishStaging() void {
+ engine.workspace = .{ .reuse = .{} };
+ engine.staging_open = false;
+ engine.evaluation.forget(null, 3);
+}
+
+fn public(code: Code) u32 {
+ return @intFromEnum(code);
+}
+
+fn stagingFail(code: Code) u32 {
+ if (engine.staging_error == .ok) engine.staging_error = code;
+ return public(code);
+}
+
+export fn reset_all() void {
+ engine = .{};
+}
+
+export fn begin_program(state_count: u32, input_count: u32) u32 {
+ engine.staging_error = .ok;
+ if (state_count > max_states or input_count > max_inputs) {
+ if (engine.staging_open) finishStaging();
+ return stagingFail(.capacity);
+ }
+ engine.workspace = .{ .staging = .{ .state_count = state_count, .input_count = input_count } };
+ engine.staging_open = true;
+ engine.evaluation.forget(null, 3);
+ return 0;
+}
+
+export fn abort_program() void {
+ if (engine.staging_open) finishStaging();
+}
+
+export fn put_state(id: u32, initial: f64, old_id: i32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ if (id >= engine.workspace.staging.state_count or old_id < -1) return stagingFail(.bad_state);
+ if (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count) return stagingFail(.bad_state);
+ if (engine.workspace.staging.declared[id]) return stagingFail(.bad_state);
+ if (!std.math.isFinite(initial)) return stagingFail(.non_finite);
+ engine.workspace.staging.initials[id] = initial;
+ engine.workspace.staging.old_ids[id] = old_id;
+ engine.workspace.staging.declared[id] = true;
+ return 0;
+}
+
+export fn put_instruction(op: u32, arg: u32, value: f64) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging;
+ if (p.instruction_count == max_instructions) return stagingFail(.capacity);
+ if (op > 21) return stagingFail(.invalid);
+ if (!std.math.isFinite(value)) return stagingFail(.non_finite);
+ p.instructions[p.instruction_count] = .{ .op = op, .arg = arg, .value = value };
+ p.instruction_count += 1;
+ return 0;
+}
+
+export fn put_handler(event: u32, start: u32, length: u32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging;
+ if (p.handler_count == max_handlers) return stagingFail(.capacity);
+ if (event > p.input_count * 2) return stagingFail(.bad_event);
+ // Handlers may be appended before their instructions, so range validation
+ // is deferred until commit. Arithmetic remains guarded there.
+ p.handlers[p.handler_count] = .{ .event = event, .start = start, .length = length };
+ p.handler_count += 1;
+ return 0;
+}
+
+fn validate(p: *const Program) Code {
+ for (0..p.state_count) |id| {
+ if (!p.declared[id]) return .bad_state;
+ if (!std.math.isFinite(p.initials[id])) return .non_finite;
+ const old_id = p.old_ids[id];
+ if (old_id < -1 or (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count)) return .bad_state;
+ }
+ for (p.handlers[0..p.handler_count], 0..) |h, i| {
+ if (h.event > 2 * p.input_count) return .bad_event;
+ if (h.start > p.instruction_count or h.length > p.instruction_count - h.start) return .invalid;
+ if (h.length == 0) continue;
+ for (p.handlers[0..i]) |other| {
+ if (other.length != 0 and h.start < other.start + other.length and other.start < h.start + h.length) return .invalid;
+ }
+ }
+ for (p.instructions[0..p.instruction_count], 0..) |inst, pc| {
+ if (inst.op > 21) return .invalid;
+ if (!std.math.isFinite(inst.value)) return .non_finite;
+ if ((inst.op == 1 or inst.op == 20) and inst.arg >= p.state_count) return .bad_state;
+ if (inst.op == 20 or inst.op == 21) {
+ var owner: ?Handler = null;
+ for (p.handlers[0..p.handler_count]) |h| {
+ if (pc >= h.start and pc < h.start + h.length) {
+ owner = h;
+ break;
+ }
+ }
+ const h = owner orelse return .invalid;
+ if (inst.op == 21 and (inst.arg <= pc or inst.arg > h.start + h.length)) return .bad_branch;
+ }
+ }
+ return .ok;
+}
+
+export fn commit_program() u32 {
+ if (!engine.staging_open) return public(.invalid);
+ if (engine.staging_error != .ok) return public(engine.staging_error);
+ const code = validate(&engine.workspace.staging);
+ if (code != .ok) return stagingFail(code);
+ var next: [max_states]f64 = @splat(0);
+ for (0..engine.workspace.staging.state_count) |i| {
+ const old_id = engine.workspace.staging.old_ids[i];
+ next[i] = if (old_id < 0) engine.workspace.staging.initials[i] else engine.values[@intCast(old_id)];
+ }
+ engine.active = engine.workspace.staging;
+ engine.values = next;
+ finishStaging();
+ engine.revision +%= 1;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ return 0;
+}
+
+export fn reset_state() void {
+ engine.values = engine.active.initials;
+ engine.evaluation.forget(reuseTables(), 3);
+ engine.tick = 0;
+ engine.successful_events = 0;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+}
+
+const Execution = struct {
+ code: Code = .ok,
+ pc: u32 = no_pc,
+ ops: u32 = 0,
+ value: f64 = 0,
+ dependencies: u32 = 0,
+};
+
+fn executionFail(result: Execution, code: Code, pc: u32) Execution {
+ var failed = result;
+ failed.code = code;
+ failed.pc = pc;
+ return failed;
+}
+
+fn execute(values: *[max_states]f64, start: u32, length: u32, dt: f64, pure: bool, budget: ?*Evaluation) Execution {
+ const p = &engine.active;
+ var result: Execution = .{};
+ if (start > p.instruction_count or length > p.instruction_count - start) return executionFail(result, .invalid, no_pc);
+ const end = start + length;
+ var stack: [max_stack]f64 = undefined;
+ var sp: usize = 0;
+ var pc = start;
+ while (pc < end) {
+ if (budget) |b| {
+ if (!b.spend()) return executionFail(result, .work_limit, pc);
+ b.instructions += 1;
+ }
+ const inst = p.instructions[pc];
+ result.ops += 1;
+ if (pure and (inst.op == 2 or inst.op == 20 or inst.op == 21)) return executionFail(result, .invalid, pc);
+ const need: usize = switch (inst.op) {
+ 0...2 => 0,
+ 7, 16, 20, 21 => 1,
+ 3...6, 8...15, 17, 18 => 2,
+ 19 => 3,
+ else => return executionFail(result, .invalid, pc),
+ };
+ if (sp < need) return executionFail(result, .stack, pc);
+ if (inst.op <= 2) {
+ if (sp == max_stack) return executionFail(result, .stack, pc);
+ const value = switch (inst.op) {
+ 0 => inst.value,
+ 1 => blk: {
+ if (inst.arg >= p.state_count) return executionFail(result, .bad_state, pc);
+ if (budget != null and budget.?.cache_count != 0)
+ result.dependencies |= @as(u32, 1) << @as(u5, @intCast(inst.arg));
+ break :blk values[inst.arg];
+ },
+ 2 => dt,
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ stack[sp] = value;
+ sp += 1;
+ } else if (inst.op == 20) {
+ if (inst.arg >= p.state_count) return executionFail(result, .bad_state, pc);
+ sp -= 1;
+ values[inst.arg] = stack[sp];
+ } else if (inst.op == 21) {
+ if (inst.arg <= pc or inst.arg > end) return executionFail(result, .bad_branch, pc);
+ sp -= 1;
+ if (stack[sp] == 0) {
+ pc = inst.arg;
+ continue;
+ }
+ } else {
+ const a = stack[sp - need];
+ const b = if (need >= 2) stack[sp - need + 1] else 0;
+ const value: f64 = switch (inst.op) {
+ 3 => a + b,
+ 4 => a - b,
+ 5 => a * b,
+ 6 => blk: {
+ if (b == 0) return executionFail(result, .division_by_zero, pc);
+ break :blk a / b;
+ },
+ 7 => -a,
+ 8 => @floatFromInt(@intFromBool(a < b)),
+ 9 => @floatFromInt(@intFromBool(a <= b)),
+ 10 => @floatFromInt(@intFromBool(a > b)),
+ 11 => @floatFromInt(@intFromBool(a >= b)),
+ 12 => @floatFromInt(@intFromBool(a == b)),
+ 13 => @floatFromInt(@intFromBool(a != b)),
+ 14 => @floatFromInt(@intFromBool(a != 0 and b != 0)),
+ 15 => @floatFromInt(@intFromBool(a != 0 or b != 0)),
+ 16 => @floatFromInt(@intFromBool(a == 0)),
+ 17 => @min(a, b),
+ 18 => @max(a, b),
+ 19 => blk: {
+ const hi = stack[sp - 1];
+ if (b > hi) return executionFail(result, .invalid, pc);
+ break :blk @max(b, @min(a, hi));
+ },
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ sp = sp - need + 1;
+ stack[sp - 1] = value;
+ }
+ pc += 1;
+ }
+ if (sp != @as(usize, if (pure) 1 else 0)) return executionFail(result, .stack, end);
+ if (pure) result.value = stack[0];
+ return result;
+}
+
+export fn dispatch(event: u32, dt: f64) u32 {
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ if (event > engine.active.input_count * 2) {
+ engine.last_error = .bad_event;
+ return public(.bad_event);
+ }
+ if (!std.math.isFinite(dt)) {
+ engine.last_error = .non_finite;
+ return public(.non_finite);
+ }
+ if ((event == 0 and dt != frame_dt) or (event != 0 and dt != 0)) {
+ engine.last_error = .bad_frame;
+ return public(.bad_frame);
+ }
+ var pending = engine.values;
+ for (engine.active.handlers[0..engine.active.handler_count]) |h| {
+ if (h.event != event) continue;
+ const result = execute(&pending, h.start, h.length, dt, false, null);
+ engine.last_ops += result.ops;
+ if (result.code != .ok) {
+ engine.last_error = result.code;
+ engine.error_pc = result.pc;
+ return public(result.code);
+ }
+ }
+ if (engine.evaluation.cache_count != 0 and !engine.staging_open) {
+ var changed: u32 = 0;
+ for (0..engine.active.state_count) |i| {
+ // Signed zero is observable, so compare representation, not ==.
+ if (@as(u64, @bitCast(pending[i])) != @as(u64, @bitCast(engine.values[i])))
+ changed |= @as(u32, 1) << @as(u5, @intCast(i));
+ }
+ engine.evaluation.changed(reuseTables(), changed);
+ }
+ engine.values = pending;
+ if (event == 0) engine.tick +%= 1;
+ engine.successful_events +%= 1;
+ return 0;
+}
+
+export fn eval_at(start: u32, length: u32) f64 {
+ // A local copy makes the no-side-effects boundary structural as well as
+ // checked by the pure instruction filter. Diagnostics are never touched.
+ var values = engine.values;
+ const result = execute(&values, start, length, 0, true, null);
+ return if (result.code == .ok) result.value else std.math.nan(f64);
+}
+
+export fn configure_evaluation(cache: u32, history: u32) u32 {
+ return if (engine.evaluation.configure(reuseTables(), cache, history)) 0 else public(.capacity);
+}
+
+export fn forget_evaluation(flags: u32) void {
+ engine.evaluation.forget(reuseTables(), flags);
+}
+
+export fn begin_evaluation(limit: u32) void {
+ engine.evaluation.begin(limit);
+}
+
+export fn eval_bounded(start: u32, length: u32) f64 {
+ const e = &engine.evaluation;
+ e.status = 0;
+ // Charge per request as well as per instruction and validation probe.
+ if (!e.spend()) return std.math.nan(f64);
+ if (e.lookup(reuseTables(), start, length)) |value| return value;
+ if (e.status != 0) return std.math.nan(f64);
+ var values = engine.values;
+ const result = execute(&values, start, length, 0, true, e);
+ e.status = public(result.code);
+ if (result.code != .ok) return std.math.nan(f64);
+ e.remember(reuseTables(), start, length, result.value, result.dependencies);
+ return result.value;
+}
+
+export fn evaluation_info(id: u32) u32 {
+ const e = &engine.evaluation;
+ return switch (id) {
+ 0 => e.status,
+ 1 => e.work,
+ 2 => e.instructions,
+ 3 => e.hits,
+ 4 => e.cache_count,
+ 5 => e.history_count,
+ 6 => @sizeOf(Evaluation) + @sizeOf(Tables),
+ 7 => @sizeOf(Evaluation),
+ 8 => @sizeOf(Tables),
+ 9 => @intFromBool(!engine.staging_open),
+ 10 => @sizeOf(Workspace) - @sizeOf(Program),
+ else => 0,
+ };
+}
+
+export fn get_state(id: u32) f64 {
+ return if (id < engine.active.state_count) engine.values[id] else std.math.nan(f64);
+}
+
+export fn info(id: u32) u32 {
+ return switch (id) {
+ 0 => engine.tick,
+ 1 => public(engine.last_error),
+ 2 => engine.error_pc,
+ 3 => engine.last_ops,
+ 4 => engine.revision,
+ 5 => engine.active.state_count,
+ 6 => engine.active.instruction_count,
+ 7 => engine.active.handler_count,
+ 8 => engine.active.input_count,
+ 9 => engine.successful_events,
+ else => 0,
+ };
+}
+
+export fn layout(id: u32) u32 {
+ return switch (id) {
+ 0 => @sizeOf(Engine),
+ 1 => @sizeOf(Program),
+ 2 => @sizeOf(Instruction),
+ 3 => @sizeOf(Handler),
+ 4 => engine.active.instruction_count * @sizeOf(Instruction) + engine.active.handler_count * @sizeOf(Handler),
+ 5 => engine.active.state_count * @sizeOf(f64),
+ 6 => @sizeOf(Program),
+ 7 => max_stack * @sizeOf(f64),
+ else => 0,
+ };
+}
+
+// Test helpers describe programs directly; the product compiler is separately
+// tested against this ABI. These tests exercise semantics and failure boundaries.
+fn ok(code: u32) !void {
+ try std.testing.expectEqual(@as(u32, 0), code);
+}
+
+fn emit(op: u32, arg: u32, value: f64) !void {
+ try ok(put_instruction(op, arg, value));
+}
+
+fn counterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try ok(commit_program());
+}
+
+test "blank program, no hidden application behavior, and reset" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 1), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(3));
+ try std.testing.expectEqual(@as(u32, 0), info(5));
+ try std.testing.expect(std.math.isNan(get_state(0)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 0)));
+ reset_state();
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 1), info(4));
+}
+
+test "arbitrary input counter and unused events" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(dispatch(2, 0));
+ try ok(dispatch(0, frame_dt));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(3));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(6));
+}
+
+test "charge and release behavior exists only in the authored instruction program" {
+ reset_all();
+ try ok(begin_program(3, 1)); // held, charge, released_value
+ for (0..3) |i| try ok(put_state(@intCast(i), 0, -1));
+ try emit(0, 0, 1);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 2));
+ try emit(1, 1, 0);
+ try emit(1, 0, 0);
+ try emit(2, 0, 0);
+ try emit(5, 0, 0);
+ try emit(3, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(0, 2, 6));
+ try emit(1, 1, 0);
+ try emit(20, 2, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(2, 8, 6));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ try ok(dispatch(1, 0));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try ok(dispatch(2, 0));
+ try std.testing.expectApproxEqAbs(@as(f64, 0.5), get_state(2), 1e-12);
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+}
+
+test "forward condition executes once and assignments and handlers are ordered" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 1, -1));
+ try ok(put_state(1, 0, -1));
+ try emit(1, 0, 0);
+ try emit(21, 6, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 7);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 0, 6));
+ try emit(1, 1, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 6, 4));
+ try ok(commit_program());
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 14), get_state(1));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 28), get_state(1));
+ try std.testing.expectEqual(@as(u32, 6), info(3));
+}
+
+test "branch to program end is valid" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, 2, 0);
+ try ok(put_handler(0, 0, 2));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+}
+
+test "one event rolls back writes from all handlers on arithmetic failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 8, -1));
+ try emit(0, 0, 99);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 2));
+ try emit(0, 0, 1);
+ try emit(0, 0, 0);
+ try emit(6, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 2, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 8), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(2));
+ try std.testing.expectEqual(@as(u32, 5), info(3));
+}
+
+test "nonfinite arithmetic and invalid clamp fail atomically" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, -1));
+ try emit(0, 0, 1e308);
+ try emit(0, 0, 1e308);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, 0));
+ try emit(0, 0, 1);
+ try emit(0, 0, 10);
+ try emit(0, 0, 2);
+ try emit(19, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 5));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.invalid), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+}
+
+test "stack underflow overflow and leftover values reject the event" {
+ for (0..3) |mode| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ if (mode == 0) {
+ try emit(3, 0, 0);
+ } else {
+ for (0..@as(usize, if (mode == 1) 65 else 1)) |_| try emit(0, 0, 7);
+ }
+ try ok(put_handler(0, 0, engine.workspace.staging.instruction_count));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.stack), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ }
+}
+
+test "failed candidates leave active values program revision and diagnostics unchanged" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ const old_revision = info(4);
+ const old_ops = info(3);
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(1, 5, 0);
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try std.testing.expectEqual(old_revision, info(4));
+ try std.testing.expectEqual(old_ops, info(3));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.invalid), put_instruction(100, 0, 0));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+}
+
+test "state preservation mapping reads active values at commit and reset uses new initials" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(2, 0));
+ try ok(put_state(0, 42, -1)); // new symbol first
+ try ok(put_state(1, 100, 0)); // old symbol moves from id 0 to 1
+ try ok(dispatch(1, 0)); // changed after staging began
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 42), get_state(0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 100), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(4));
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try std.testing.expect(std.math.isNan(get_state(0)));
+}
+
+test "capacity boundaries and sticky load errors" {
+ reset_all();
+ try std.testing.expectEqual(public(.capacity), begin_program(33, 0));
+ try std.testing.expectEqual(public(.capacity), begin_program(0, 9));
+ try ok(begin_program(32, 8));
+ for (0..32) |i| try ok(put_state(@intCast(i), @floatFromInt(i), -1));
+ for (0..512) |_| try emit(0, 0, 0);
+ for (0..64) |_| try ok(put_handler(16, 0, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 512), info(6));
+ try std.testing.expectEqual(@as(u32, 64), info(7));
+ try std.testing.expectEqual(@as(f64, 31), get_state(31));
+ try ok(begin_program(0, 0));
+ for (0..512) |_| try emit(0, 0, 0);
+ try std.testing.expectEqual(public(.capacity), put_instruction(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+ try ok(begin_program(0, 0));
+ for (0..64) |_| try ok(put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+}
+
+test "invalid declaration values state mappings event ids and handler ranges" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 0, 0));
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.non_finite), put_state(0, std.math.inf(f64), -1));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 1, -1));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.non_finite), put_instruction(0, 0, std.math.nan(f64)));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.bad_event), put_handler(1, 0, 0));
+ try ok(begin_program(0, 0));
+ try ok(put_handler(0, no_pc, 5));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 1);
+ try ok(put_handler(0, 0, 1));
+ try ok(put_handler(0, 0, 1));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "backward self out-of-handler branches and stores without handlers are rejected" {
+ for ([_]u32{ 0, 1, 3 }) |target| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, target, 0);
+ try emit(0, 0, 7);
+ try ok(put_handler(0, 0, 2));
+ try std.testing.expectEqual(public(.bad_branch), commit_program());
+ }
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try emit(0, 0, 5);
+ try emit(20, 0, 0);
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "event inputs and dt enforce deterministic frame contract" {
+ reset_all();
+ try counterProgram();
+ try std.testing.expectEqual(public(.bad_event), dispatch(3, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch(no_pc, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(1, frame_dt));
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, std.math.nan(f64)));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(u32, 0), info(1));
+ try std.testing.expectEqual(no_pc, info(2));
+}
+
+test "binding evaluation is pure on both success and failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 4, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try emit(2, 0, 0);
+ try emit(0, 0, 0);
+ try emit(21, 7, 0);
+ try ok(put_handler(0, 3, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ const before = engine;
+ try std.testing.expectEqual(@as(f64, 12), eval_at(0, 3));
+ try std.testing.expect(std.math.isNan(eval_at(0, 4))); // attempted store
+ try std.testing.expect(std.math.isNan(eval_at(4, 1))); // dt forbidden in views
+ try std.testing.expect(std.math.isNan(eval_at(5, 2))); // branch forbidden
+ try std.testing.expect(std.math.isNan(eval_at(no_pc, 7)));
+ try std.testing.expect(std.math.isNan(eval_at(0, no_pc)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 2))); // two results
+ // Compare only the live member of the untagged workspace, never padding
+ // or the inactive member's indeterminate storage.
+ try std.testing.expectEqual(before.staging_open, engine.staging_open);
+ inline for (std.meta.fields(Engine)) |field| {
+ if (comptime std.mem.eql(u8, field.name, "workspace")) {
+ if (before.staging_open) {
+ try std.testing.expectEqualDeep(before.workspace.staging, engine.workspace.staging);
+ } else {
+ try std.testing.expectEqualDeep(before.workspace.reuse, engine.workspace.reuse);
+ }
+ } else try std.testing.expectEqualDeep(@field(before, field.name), @field(engine, field.name));
+ }
+}
+
+test "arithmetic comparison logical min max and clamp expression semantics" {
+ const cases = [_]struct { op: u32, a: f64, b: f64, want: f64 }{
+ .{ .op = 3, .a = 7, .b = 2, .want = 9 },
+ .{ .op = 4, .a = 7, .b = 2, .want = 5 },
+ .{ .op = 5, .a = 7, .b = 2, .want = 14 },
+ .{ .op = 6, .a = 7, .b = 2, .want = 3.5 },
+ .{ .op = 8, .a = 2, .b = 7, .want = 1 },
+ .{ .op = 9, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 10, .a = 2, .b = 7, .want = 0 },
+ .{ .op = 11, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 12, .a = 7, .b = 2, .want = 0 },
+ .{ .op = 13, .a = 7, .b = 2, .want = 1 },
+ .{ .op = 14, .a = -7, .b = 2, .want = 1 },
+ .{ .op = 15, .a = 0, .b = 2, .want = 1 },
+ .{ .op = 17, .a = 7, .b = 2, .want = 2 },
+ .{ .op = 18, .a = 7, .b = 2, .want = 7 },
+ };
+ for (cases) |case| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, case.a);
+ try emit(0, 0, case.b);
+ try emit(case.op, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(case.want, eval_at(0, 3));
+ }
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, -8);
+ try emit(7, 0, 0);
+ try emit(0, 0, 0);
+ try emit(0, 0, 5);
+ try emit(19, 0, 0);
+ try emit(16, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 5), eval_at(0, 5));
+ try std.testing.expectEqual(@as(f64, 0), eval_at(0, 6));
+}
+
+test "diagnostic counters wrap without affecting scalar values" {
+ reset_all();
+ try counterProgram();
+ engine.tick = no_pc;
+ engine.successful_events = no_pc;
+ engine.revision = no_pc;
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 99, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 0), info(4));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+}
+
+test "memory accounting separates reserved storage used IR and stack capacity" {
+ reset_all();
+ try std.testing.expectEqual(@as(u32, 0), layout(4));
+ try std.testing.expectEqual(@as(u32, 0), layout(5));
+ try counterProgram();
+ try std.testing.expectEqual(@as(u32, @sizeOf(Engine)), layout(0));
+ try std.testing.expectEqual(@as(u32, 16), layout(2));
+ try std.testing.expectEqual(@as(u32, 12), layout(3));
+ try std.testing.expectEqual(@as(u32, 76), layout(4));
+ try std.testing.expectEqual(@as(u32, 8), layout(5));
+ try std.testing.expectEqual(layout(1), layout(6));
+ try std.testing.expect(layout(0) >= layout(1) * 2);
+ try std.testing.expectEqual(@as(u32, 512), layout(7));
+ try std.testing.expect(layout(0) + 16 * 1024 < 64 * 1024);
+}
+
+fn displayCounterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try emit(1, 0, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try ok(configure_evaluation(32, 8));
+}
+
+fn boundedEquals(want: f64, start: u32, length: u32) !void {
+ begin_evaluation(100);
+ try std.testing.expectEqual(want, eval_bounded(start, length));
+ try ok(evaluation_info(0));
+}
+
+test "loading shares storage with records while old events and bindings remain executable" {
+ reset_all();
+ try displayCounterProgram();
+ try boundedEquals(20, 4, 3);
+ try boundedEquals(20, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(9));
+ try emit(1, 0, 0);
+ try ok(dispatch(1, 0)); // Active state advances after staging begins.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(3));
+ try ok(configure_evaluation(32, 1));
+ forget_evaluation(3); // Must not overwrite the pending program.
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(33, 0, 3);
+ try boundedEquals(33, 0, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+}
+
+test "failed and abandoned candidates restore empty reuse storage without changing active work" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(20, 0, 0); // Store without a handler fails validation.
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try boundedEquals(20, 4, 3);
+ try ok(dispatch(1, 0));
+ abort_program();
+ try boundedEquals(22, 4, 3);
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ abort_program(); // Inactive abort is a no-op, including for warmed records.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try std.testing.expectEqual(public(.capacity), begin_program(max_states + 1, 0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+}
+
+test "full pending program survives repeated evaluation configuration record loss and reset" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ for (0..max_instructions) |i| {
+ try emit(0, 0, @floatFromInt(i));
+ try ok(configure_evaluation(32, @intCast(i % 9)));
+ forget_evaluation(3);
+ try boundedEquals(22, 4, 3);
+ }
+ reset_state(); // The pending definition and old-id mapping survive reset.
+ try boundedEquals(20, 4, 3);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, max_instructions), info(6));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try boundedEquals(511, 511, 1);
+ // Safe Zig builds may reserve a hidden union tag in addition to the payload.
+ try std.testing.expect(@sizeOf(Workspace) >= @sizeOf(Program));
+ try std.testing.expect(@sizeOf(Workspace) <= @sizeOf(Program) + @alignOf(Program));
+ try std.testing.expectEqual(@as(u32, @sizeOf(Tables)), evaluation_info(8));
+}
diff --git a/prototypes/workbench/results/2026-09-23-refresh/before/evaluation.zig b/prototypes/workbench/results/2026-09-23-refresh/before/evaluation.zig
new file mode 100644
index 0000000..56081d9
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-refresh/before/evaluation.zig
@@ -0,0 +1,164 @@
+//! Disposable binding results and change history. No event state lives here.
+const std = @import("std");
+
+const Memo = struct {
+ value: f64 = 0,
+ revision: u64 = 0,
+ dependencies: u32 = 0,
+ start: u32 = 0,
+ length: u32 = 0,
+};
+const Change = struct { revision: u64 = 0, mask: u32 = 0 };
+
+pub const Tables = struct {
+ memo: [32]Memo = @splat(.{}),
+ history: [8]Change = @splat(.{}),
+};
+
+pub const Evaluation = struct {
+ pub const max_cache = 32;
+ pub const max_history = 8;
+ revision: u64 = 1,
+ floor: u64 = 1,
+ cache_count: u32 = 0,
+ history_count: u32 = 8,
+ next_change: u32 = 0,
+ limit: u32 = 0,
+ work: u32 = 0,
+ instructions: u32 = 0,
+ hits: u32 = 0,
+ status: u32 = 0,
+
+ pub fn configure(self: *Evaluation, tables: ?*Tables, cache: u32, history: u32) bool {
+ if (cache > max_cache or history > max_history) return false;
+ self.cache_count = cache;
+ self.history_count = history;
+ self.forget(tables, 3);
+ return true;
+ }
+
+ pub fn forget(self: *Evaluation, tables: ?*Tables, flags: u32) void {
+ if (flags & 1 != 0) if (tables) |t| { t.memo = @splat(.{}); };
+ if (flags & 2 != 0) {
+ if (tables) |t| t.history = @splat(.{});
+ self.floor = self.revision;
+ self.next_change = 0;
+ }
+ }
+
+ pub fn changed(self: *Evaluation, tables: ?*Tables, mask: u32) void {
+ const t = tables orelse return;
+ if (mask == 0 or self.cache_count == 0) return;
+ if (self.revision == std.math.maxInt(u64)) {
+ self.revision = 1;
+ self.forget(t, 3);
+ }
+ self.revision += 1;
+ if (self.history_count == 0) {
+ self.floor = self.revision;
+ return;
+ }
+ const change = &t.history[self.next_change];
+ self.floor = @max(self.floor, change.revision);
+ change.* = .{ .revision = self.revision, .mask = mask };
+ self.next_change = (self.next_change + 1) % self.history_count;
+ }
+
+ pub fn begin(self: *Evaluation, limit: u32) void {
+ self.limit = limit;
+ self.work = 0;
+ self.instructions = 0;
+ self.hits = 0;
+ self.status = 0;
+ }
+
+ pub fn spend(self: *Evaluation) bool {
+ if (self.work == self.limit) {
+ self.status = 10;
+ return false;
+ }
+ self.work += 1;
+ return true;
+ }
+
+ fn slot(self: *const Evaluation, start: u32, length: u32) u32 {
+ // One candidate, including on misses; no scan proportional to cache size.
+ return (start ^ (start >> 5) ^ (length *% 0x9e3779b9)) % self.cache_count;
+ }
+
+ pub fn lookup(self: *Evaluation, tables: ?*Tables, start: u32, length: u32) ?f64 {
+ const t = tables orelse return null;
+ if (self.cache_count == 0) return null;
+ if (!self.spend()) return null;
+ const memo = &t.memo[self.slot(start, length)];
+ if (memo.revision == 0 or memo.start != start or memo.length != length) return null;
+ // Successful pure constants need no input-change evidence. A program
+ // replacement still clears them, just like every other binding memo.
+ if (memo.dependencies != 0 and memo.revision < self.floor) return null;
+ if (memo.dependencies != 0 and memo.revision != self.revision) {
+ for (t.history[0..self.history_count]) |change| {
+ if (!self.spend()) return null;
+ if (change.revision > memo.revision and change.mask & memo.dependencies != 0) return null;
+ }
+ }
+ memo.revision = self.revision;
+ self.hits += 1;
+ return memo.value;
+ }
+
+ pub fn remember(self: *Evaluation, tables: ?*Tables, start: u32, length: u32, value: f64, dependencies: u32) void {
+ const t = tables orelse return;
+ if (self.cache_count == 0) return;
+ t.memo[self.slot(start, length)] = .{ .start = start, .length = length,
+ .value = value, .dependencies = dependencies, .revision = self.revision };
+ }
+};
+
+test "record loss cannot authorize an old memo; unrelated changes can" {
+ var tables: Tables = .{};
+ var e: Evaluation = .{};
+ try std.testing.expect(e.configure(&tables, 1, 1));
+ e.begin(100);
+ e.remember(&tables, 0, 3, 12, 1);
+ e.changed(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, 12), e.lookup(&tables, 0, 3));
+ e.changed(&tables, 2);
+ e.changed(&tables, 2); // The certificate now predates the surviving history.
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 12, 1);
+ e.changed(&tables, 1);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 20, 1);
+ e.changed(&tables, 2);
+ e.forget(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 20, 1);
+ e.revision = std.math.maxInt(u64);
+ e.changed(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+}
+
+test "budget, collisions, invalid settings, and disabled history are conservative" {
+ var tables: Tables = .{};
+ var e: Evaluation = .{};
+ try std.testing.expect(e.configure(&tables, 1, 0));
+ e.begin(100);
+ e.remember(&tables, 0, 3, 12, 1);
+ e.remember(&tables, 1, 3, 15, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ try std.testing.expectEqual(@as(?f64, 15), e.lookup(&tables, 1, 3));
+ e.changed(&tables, 0x80000000);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 1, 3));
+ try std.testing.expect(!e.configure(&tables, 33, 0));
+ try std.testing.expectEqual(@as(u32, 1), e.cache_count);
+ e.begin(0);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 1, 3));
+ try std.testing.expectEqual(@as(u32, 10), e.status);
+ try std.testing.expectEqual(@as(u32, 0), e.work);
+ e.begin(100);
+ e.remember(&tables, 2, 1, 99, 0);
+ e.changed(&tables, 1);
+ e.forget(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, 99), e.lookup(&tables, 2, 1));
+ try std.testing.expectEqual(@as(u32, 1), e.work);
+}
diff --git a/prototypes/workbench/results/2026-09-23-refresh/before/manifest.json b/prototypes/workbench/results/2026-09-23-refresh/before/manifest.json
new file mode 100644
index 0000000..6284e49
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-refresh/before/manifest.json
@@ -0,0 +1,14 @@
+{
+ "web/app.js": "a50e6d99306461f690530eedf5797b2c86c3367a704d24dc5cc86a1c1d2035ea",
+ "web/output.mjs": "1d22c44955e812d671f1a3c3ae4b2f0642a05ebe614017bb041bdddc8c363be3",
+ "web/runtime.mjs": "d490c54fb2b422dfe89b90a594a8da9ebc95bb007910211cb4686ccb83ed75c7",
+ "web/compiler.mjs": "a34f3beb069c811a9bd5be73dde9341907ba77756066030b8b95fc6d66a5cc02",
+ "web/core.wasm": "76823b024fa5f4a79cbf1ed722ab757614a81a9f1c81d1d6268781a38bed7cc9",
+ "web/index.html": "4fff6eb67d8cff098302465449251d17f0efb39d9144e87d15fefcc152e5d97e",
+ "web/style.css": "43301298028c5bc431eecd5ff30d7362fae83cce1fd0fdd7e8bea858100c22b9",
+ "core.zig": "dc84bcb75e4417fb1a804529c78ffa8cd1ddd079c00df4ca97a35da0b8fdbb8a",
+ "evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "qa-presentation.js": "04b0135983ea718d3744b56b706c9bf3266972a5180c744ac791ef787a29aa05",
+ "qa-presentation.py": "bec45426ec297896ed5c87b152148956bc6c5730ff188aea0e041a0ec69ae1c3",
+ "presentation.test.mjs": "604858a3f601d2a67c601ff477415025dcb766cdfd092bafed2d9dcfda39abd5"
+}
diff --git a/prototypes/workbench/results/2026-09-23-refresh/before/presentation.test.mjs b/prototypes/workbench/results/2026-09-23-refresh/before/presentation.test.mjs
new file mode 100644
index 0000000..207905d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-refresh/before/presentation.test.mjs
@@ -0,0 +1,123 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { createRuntime } from './web/runtime.mjs';
+import { createRuntime as before } from './results/2026-09-23-presentation/before/web/runtime.mjs';
+
+const wasm = await readFile(new URL('./web/core.wasm', import.meta.url));
+const source = `state x = 3
+state radius = 5
+input move = KeyM
+input shrink = KeyS
+box tile(x, 10, x * 2, 20, "#123456")
+circle dot(50, 50, radius, "#abcdef")
+text label(0, 0, 10 / x, "Value: ")
+on move.down { x = x + 0.25 }
+on shrink.down { radius = radius - 10 }
+on frame { x = x + dt }
+`;
+
+// Reconstruct only in tests, for comparison with the archived snapshot path.
+function collect(runtime, hooks = {}) {
+ let states, views, declared;
+ const result = runtime.present({
+ begin(program) {
+ declared = program; states = {}; views = [];
+ hooks.begin?.();
+ },
+ state(i, value) { states[declared.states[i].name] = value; },
+ view(i, valid) {
+ if (!valid) return;
+ const {id, type, color, prefix} = declared.views[i];
+ views.push({id, type, color, prefix});
+ },
+ value(i, name, value) { views.at(-1)[name] = value; hooks.value?.(); },
+ end(count) { assert.equal(views.length, count); },
+ });
+ return {states, views, ...result};
+}
+
+test('direct publication matches archived snapshots and work counts through authored changes', async () => {
+ for (const cacheEntries of [0, 1, 32]) {
+ const left = await createRuntime(wasm, {cacheEntries}), right = await before(wasm, {cacheEntries});
+ const compare = () => {
+ assert.deepEqual(collect(left), right.snapshot());
+ assert.deepEqual(left.evaluationStats(), right.evaluationStats());
+ };
+ compare();
+ for (const r of [left, right]) r.apply(source);
+ compare();
+ for (let i = 0; i < 120; i++) {
+ for (const r of [left, right]) {
+ r.down('move', 'keyboard'); r.down('move', 'pointer');
+ r.up('move', 'keyboard'); r.up('move', 'pointer');
+ if (i % 11 === 0) r.frame(2);
+ if (i % 17 === 0) r.forgetEvaluation('history');
+ if (i % 23 === 0) r.reset();
+ }
+ compare();
+ }
+ for (const r of [left, right]) { r.down('shrink'); r.up('shrink'); }
+ compare(); // invalid circle only; other views still publish
+ for (const r of [left, right]) assert.throws(() => r.apply('state broken ='));
+ compare();
+ for (const r of [left, right]) r.apply('state radius = 1\nstate x = 0\ntext label(1, 2, x)');
+ compare();
+ for (const r of [left, right]) r.apply('');
+ compare();
+ }
+});
+
+test('whole-scene budget failure never publishes partial bindings and recovers exactly', async () => {
+ const left = await createRuntime(wasm, {cacheEntries:32}), right = await before(wasm, {cacheEntries:32});
+ for (const r of [left, right]) r.apply(source);
+ for (let workLimit = 0; workLimit <= 80; workLimit++) {
+ for (const r of [left, right]) r.configureEvaluation({workLimit});
+ let calls = 0;
+ const result = collect(left, {value: () => calls++});
+ assert.deepEqual(result, right.snapshot());
+ assert.deepEqual(left.evaluationStats(), right.evaluationStats());
+ if (result.displayError?.code === 101) assert.equal(calls, 0);
+ else assert.equal(calls, 10);
+ }
+});
+
+test('latched event rollback, nonfinite bindings, reset and signed zero keep their semantics', async () => {
+ const left = await createRuntime(wasm), right = await before(wasm);
+ const definition = 'state x = 0\ninput fail = Space\ntext value(0, 0, 1 / x)\non fail.down { x = 2\nx = 1 / 0 }';
+ for (const r of [left, right]) { r.apply(definition); r.down('fail'); r.up('fail'); }
+ assert.deepEqual(collect(left), right.snapshot());
+ for (const r of [left, right]) r.configureEvaluation({workLimit:0});
+ assert.deepEqual(collect(left), right.snapshot());
+ for (const r of [left, right]) {
+ r.apply('state x = -0\ntext value(x, 0, x)'); r.reset(); r.configureEvaluation({workLimit:2048});
+ }
+ const result = collect(left);
+ assert.deepEqual(result, right.snapshot());
+ assert(Object.is(result.states.x, -0));
+});
+
+test('publication rejects reentrant runtime changes and reads before they affect the scene', async () => {
+ const runtime = await createRuntime(wasm);
+ runtime.apply(source);
+ const calls = [() => runtime.apply(''), () => runtime.reset(), () => runtime.frame(),
+ () => runtime.down('move'), () => runtime.up('move'), () => runtime.releaseAll(),
+ () => runtime.configureEvaluation({workLimit:0}), () => runtime.forgetEvaluation(),
+ () => runtime.snapshot(), () => runtime.present({})];
+ const expected = runtime.snapshot();
+ assert.deepEqual(collect(runtime, {begin() {
+ for (const call of calls) assert.throws(call, /출력 전달 중/);
+ }}), expected);
+ assert.deepEqual(runtime.snapshot(), expected);
+});
+
+test('invalid or throwing output adapters do not strand the runtime or alter authored state', async () => {
+ const runtime = await createRuntime(wasm);
+ runtime.apply(source);
+ const expected = runtime.snapshot();
+ assert.throws(() => runtime.present({}), TypeError);
+ assert.throws(() => collect(runtime, {value() { throw new Error('destination failed'); }}), /destination failed/);
+ assert.deepEqual(collect(runtime), expected);
+ runtime.down('move'); runtime.up('move');
+ assert.equal(collect(runtime).states.x, 3.25);
+});
diff --git a/prototypes/workbench/results/2026-09-23-refresh/before/qa-presentation.js b/prototypes/workbench/results/2026-09-23-refresh/before/qa-presentation.js
new file mode 100644
index 0000000..90595e3
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-refresh/before/qa-presentation.js
@@ -0,0 +1,141 @@
+// Playwright CLI run-code function. The Python runner injects the archived
+// app/runtime source at BASELINE_SOURCES; production files are never replaced.
+async (page) => {
+ const baseline = /* BASELINE_SOURCES */ null;
+ const checks = [], errors = [], samples = [];
+ const check = (ok, name) => { if (!ok) throw new Error(name); checks.push(name); };
+ const onError = error => errors.push(error.message);
+ const onDialog = dialog => dialog.accept();
+ page.on('pageerror', onError); page.on('dialog', onDialog);
+ let variant = 'after';
+ const source = [
+ 'state x = 40', 'state radius = 12', 'input move = KeyM', 'input shrink = KeyS',
+ 'box moving(x, 100, 30, 30, "#7ed9ae")', 'text label(20, 40, x, "x: ")',
+ 'circle bubble(500, 150, radius, "#c9f08b")',
+ ...Array.from({length:21}, (_, i) => `box static${i}(${20 + (i % 7) * 70}, ${200 + Math.floor(i / 7) * 40}, 20, 20, "#8da5bb")`),
+ 'on move.down { x = x + 0.25 }', 'on shrink.down { radius = radius - 24 }',
+ ].join('\n');
+ const apply = async value => { await page.locator('#source').fill(value); await page.locator('#apply').click(); };
+ const state = () => page.evaluate(() => window.pureWorkbench.snapshot());
+ const reload = async () => {
+ await page.reload();
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ await apply(source);
+ };
+ const routeApp = async route => {
+ const response = await route.fetch();
+ await route.fulfill({response, body: variant === 'before' ? baseline.app : await response.text()});
+ };
+ const routeRuntime = async route => {
+ const response = await route.fetch();
+ const body = variant === 'before' ? baseline.runtime : await response.text();
+ if (!body.includes('snapshot() {')) throw new Error('snapshot instrumentation does not match');
+ await route.fulfill({response, body:body.replace('snapshot() {', 'snapshot() { globalThis.__snapshotCalls = (globalThis.__snapshotCalls || 0) + 1;')});
+ };
+ try {
+ if (!baseline) throw new Error('Run through qa-presentation.py to supply the archived baseline.');
+ await page.route('**/app.js', routeApp);
+ await page.route('**/runtime.mjs', routeRuntime);
+ await reload();
+ await page.locator('#scene').focus();
+ await page.keyboard.press('KeyM');
+ check((await state()).states.x === 40.25, 'real keyboard event reaches the authored output');
+ await page.evaluate(() => { window.__retained = document.querySelector('[data-view-id="moving"]'); });
+ await page.locator('[data-input="move"]').click();
+ check((await state()).states.x === 40.5 && await page.locator('[data-view-id="moving"]').getAttribute('x') === '40.5',
+ 'real pointer input and keyboard input use the same authored behavior');
+ check(await page.evaluate(() => window.__retained === document.querySelector('[data-view-id="moving"]')),
+ 'the displayed element survives an input update');
+ await apply(source + '\nstate broken =');
+ check((await state()).states.x === 40.5 && await page.evaluate(() => window.__retained === document.querySelector('[data-view-id="moving"]')),
+ 'failed editing preserves active values and output destinations');
+ await page.locator('[data-input="shrink"]').click();
+ check((await state()).lastError === 100 && await page.locator('[data-view-id="bubble"]').getAttribute('display') === 'none' &&
+ await page.locator('[data-view-id="moving"]').getAttribute('display') === null,
+ 'invalid view is hidden while valid views remain published');
+ await page.locator('#reset').click();
+ check((await state()).lastError === 0 && await page.locator('[data-view-id="bubble"]').getAttribute('display') === null,
+ 'reset recovers a hidden destination without recreating the scene');
+ await apply(source.replace('box moving(x, 100, 30, 30, "#7ed9ae")', 'circle moving(x, 100, 15, "#7ed9ae")'));
+ check(await page.locator('[data-view-id="moving"]').evaluate(node => node.localName) === 'circle',
+ 'successful editing replaces changed output structure');
+ await apply('');
+ check(await page.locator('#scene-content > *').count() === 0 && (await state()).views.length === 0,
+ 'applying empty work removes all old destinations');
+
+ // Alternating whole-page runs avoid sharing a runtime or retained DOM across
+ // variants. Timings cover synchronous dispatch through DOM submission, not
+ // OS event arrival, layout, paint, or actual display scanout.
+ for (const mode of ['before', 'after', 'after', 'before', 'before', 'after']) {
+ variant = mode;
+ await reload();
+ await page.locator('#scene').focus();
+ const result = await page.evaluate(() => {
+ const scene = document.getElementById('scene');
+ const tap = () => {
+ scene.dispatchEvent(new KeyboardEvent('keydown', {code:'KeyM', bubbles:true}));
+ scene.dispatchEvent(new KeyboardEvent('keyup', {code:'KeyM', bubbles:true}));
+ };
+ for (let i = 0; i < 20; i++) tap();
+ const retained = [...document.querySelectorAll('#scene-content > *')];
+ const scopes = ['scene-content', 'state-values', 'cost-values'];
+ const observers = scopes.map(id => {
+ const observer = new MutationObserver(() => {});
+ observer.observe(document.getElementById(id), {subtree:true, childList:true, attributes:true, characterData:true});
+ return observer;
+ });
+ const createElement = document.createElement, createElementNS = document.createElementNS;
+ let createdElements = 0;
+ document.createElement = function(...args) { createdElements++; return createElement.apply(this, args); };
+ document.createElementNS = function(...args) { createdElements++; return createElementNS.apply(this, args); };
+ window.__snapshotCalls = 0;
+ let elapsedMs;
+ try {
+ const start = performance.now();
+ for (let i = 0; i < 100; i++) tap();
+ elapsedMs = performance.now() - start;
+ } finally {
+ document.createElement = createElement; document.createElementNS = createElementNS;
+ }
+ const snapshotCalls = window.__snapshotCalls;
+ const mutations = Object.fromEntries(observers.map((observer, i) => {
+ const records = observer.takeRecords(); observer.disconnect();
+ return [scopes[i], {records:records.length,
+ childList:records.filter(record => record.type === 'childList').length,
+ attributes:records.filter(record => record.type === 'attributes').length}];
+ }));
+ const nodes = [...document.querySelectorAll('#scene-content > *')];
+ const identityPreserved = nodes.every((node, i) => node === retained[i]);
+ const final = window.pureWorkbench.snapshot(); // outside timed/count interval
+ return {elapsedMs, createdElements, snapshotCalls, mutations, identityPreserved,
+ final: {states:final.states, views:final.views, successfulEvents:final.successfulEvents},
+ scene:document.getElementById('scene-content').outerHTML};
+ });
+ samples.push({variant:mode, taps:100, events:200, ...result});
+ }
+ check(samples.every(sample => JSON.stringify(sample.final) === JSON.stringify(samples[0].final)),
+ 'all six archived/current runs have identical final authored state and views');
+ check(samples.filter(s => s.variant === 'after').every(s => s.createdElements === 0 && s.snapshotCalls === 0 && s.identityPreserved),
+ 'current hot path creates no DOM elements and no full snapshots');
+ check(samples.filter(s => s.variant === 'before').every(s => s.createdElements > 0 && s.snapshotCalls === 200 && !s.identityPreserved),
+ 'archived hot path reproduces full snapshots and element replacement');
+ check(samples.filter(s => s.variant === 'after').every(s => s.mutations['scene-content'].attributes === 100 && s.mutations['scene-content'].childList === 100),
+ 'each tap changes only the moving x attribute and label text in the scene');
+ variant = 'after'; await reload();
+ await page.screenshot({path:'output/playwright/pure-presentation.png', fullPage:true});
+ check(errors.length === 0, 'no uncaught browser errors');
+ const evidence = {utc:new Date().toISOString(), checks, errors, samples, source,
+ timingScope:'100 synchronous synthetic keyboard taps after 20 warm-up taps; JS dispatch to DOM submission only, including instrumentation; not paint/input latency.',
+ setup:'Archived app/runtime are supplied by test-only routing; snapshot calls and element creation are instrumented. Same Wasm/compiler and production configuration.',
+ browser:await page.evaluate(() => navigator.userAgent), screenshot:'output/playwright/pure-presentation.png'};
+ await page.evaluate(value => { window.__purePresentationEvidence = value; }, evidence);
+ return evidence;
+ } catch (error) {
+ const evidence = {failed:true, message:error.stack || error.message, checks, errors, samples};
+ await page.evaluate(value => { window.__purePresentationEvidence = value; }, evidence);
+ return evidence;
+ } finally {
+ await page.unroute('**/app.js', routeApp); await page.unroute('**/runtime.mjs', routeRuntime);
+ page.off('pageerror', onError); page.off('dialog', onDialog);
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-23-refresh/before/qa-presentation.py b/prototypes/workbench/results/2026-09-23-refresh/before/qa-presentation.py
new file mode 100644
index 0000000..b3adb02
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-refresh/before/qa-presentation.py
@@ -0,0 +1,54 @@
+#!/usr/bin/env python3
+"""Run the local browser comparison with the archived implementation injected."""
+import argparse
+import hashlib
+import json
+from pathlib import Path
+import subprocess
+import time
+
+ROOT = Path(__file__).resolve().parent
+parser = argparse.ArgumentParser()
+parser.add_argument('--cli', required=True, help='Path to Playwright CLI 0.1.21 JS entry point')
+parser.add_argument('--session', default='pure-presentation')
+parser.add_argument('--output', required=True, type=Path)
+args = parser.parse_args()
+if args.output.exists():
+ raise SystemExit('Choose a new output file; evidence is never overwritten.')
+archive = ROOT / 'results/2026-09-23-presentation/before'
+sha = lambda p: hashlib.sha256(p.read_bytes()).hexdigest()
+manifest = json.loads((archive / 'manifest.json').read_text())
+for name, digest in manifest.items():
+ if sha(archive / name) != digest:
+ raise SystemExit(f'Archived source mismatch: {name}')
+baseline = {name: (archive / f'web/{file}').read_text()
+ for name, file in [('app', 'app.js'), ('runtime', 'runtime.mjs')]}
+script = (ROOT / 'qa-presentation.js').read_text().replace('/* BASELINE_SOURCES */ null', json.dumps(baseline))
+command = ['node', args.cli, f'--session={args.session}']
+run = subprocess.run([*command, 'run-code', script], text=True, capture_output=True, check=True)
+# Navigation can return early while the run-code function is accepting a
+# beforeunload dialog. Collect the published result instead of rerunning it.
+for attempt in range(30):
+ result = subprocess.run([*command, 'eval', 'window.__purePresentationEvidence'],
+ text=True, capture_output=True, check=True)
+ if '### Result\n' not in result.stdout:
+ raise SystemExit(result.stdout + result.stderr)
+ payload = result.stdout.split('### Result\n', 1)[1].split('\n### ', 1)[0].strip()
+ if payload != 'undefined':
+ value = json.loads(payload)
+ if value.get('failed'):
+ raise SystemExit(json.dumps(value, ensure_ascii=False, indent=2))
+ value['sourceSha256'] = {name: sha(ROOT / name) for name in
+ ['web/runtime.mjs', 'web/app.js', 'web/output.mjs', 'web/core.wasm',
+ 'web/compiler.mjs', 'qa-presentation.js', 'qa-presentation.py']}
+ value['baselineManifestSha256'] = sha(archive / 'manifest.json')
+ with args.output.open('x') as output:
+ json.dump(value, output, ensure_ascii=False, indent=2)
+ output.write('\n')
+ print(json.dumps({'checks':len(value['checks']), 'samples':[
+ {k:s[k] for k in ['variant', 'elapsedMs', 'createdElements', 'snapshotCalls']}
+ for s in value['samples']]}, indent=2))
+ break
+ time.sleep(0.2)
+else:
+ raise SystemExit('No finished evidence; inspect the browser session before retrying.')
diff --git a/prototypes/workbench/results/2026-09-23-refresh/before/web/app.js b/prototypes/workbench/results/2026-09-23-refresh/before/web/app.js
new file mode 100644
index 0000000..5241754
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-refresh/before/web/app.js
@@ -0,0 +1,349 @@
+import { createRuntime } from './runtime.mjs';
+import { createOutput } from './output.mjs';
+
+const $ = id => document.getElementById(id);
+const source = $('source');
+const MAX_SOURCE_BYTES = 32768;
+const encoder = new TextEncoder();
+const output = createOutput(document);
+const snippets = {
+ state: 'state count = 0\nstate x = 80\n',
+ input: 'input tap = Space\non tap.down {\n count = count + 1\n}\n',
+ view: 'box tile(x, 180, 40, 40, "#7ed9ae")\ntext label(40, 60, count, "Count: ")\n',
+ frame: 'on frame {\n x = x + 30 * dt\n if x > 600 { x = 0 }\n}\n',
+};
+let runtime = null;
+let appliedSource = '';
+let running = false;
+let previousTime = null;
+let accumulator = 0;
+let droppedTicks = 0;
+let diagnosticLocation = null;
+const keyboardSources = new Map();
+const pointerSources = new Map();
+const inputButtons = new Map();
+
+const bytes = text => encoder.encode(text).byteLength;
+const dirty = () => source.value !== appliedSource;
+
+function setDiagnostic(message, kind = '', location = null) {
+ $('diagnostic-text').textContent = message;
+ $('diagnostic').className = `diagnostic ${kind}`;
+ diagnosticLocation = location;
+ $('error-location').hidden = !location;
+ source.setAttribute('aria-invalid', kind === 'error' && location ? 'true' : 'false');
+}
+
+function cursorPosition() {
+ const before = source.value.slice(0, source.selectionStart);
+ const lines = before.split('\n');
+ $('cursor-position').textContent = `${lines.length}행 · ${lines.at(-1).length + 1}열`;
+}
+
+function draftChanged() {
+ const count = source.value.split('\n').length;
+ $('line-numbers').textContent = Array.from({ length: count }, (_, i) => i + 1).join('\n');
+ $('line-numbers').scrollTop = source.scrollTop;
+ $('source-size').textContent = `${bytes(source.value).toLocaleString('en-US')} / 32,768 bytes`;
+ $('source-size').style.color = bytes(source.value) > MAX_SOURCE_BYTES ? 'var(--danger)' : '';
+ $('draft-status').textContent = dirty() ? '미적용 변경' : runtime?.currentProgram ? '적용됨' : '빈 작업물';
+ $('draft-status').classList.toggle('dirty', dirty());
+ cursorPosition();
+}
+
+function pause() {
+ running = false;
+ previousTime = null;
+ accumulator = 0;
+ $('play').textContent = '▶ 실행';
+ $('play').setAttribute('aria-pressed', 'false');
+}
+
+function safeAction(action) {
+ if (!runtime) return;
+ try { action(); }
+ catch (error) { pause(); setDiagnostic(error.message, 'error', error.line ? { line: error.line, column: error.column || 1 } : null); }
+ refresh();
+}
+
+function releaseEverything() {
+ keyboardSources.clear();
+ pointerSources.clear();
+ if (runtime) safeAction(() => runtime.releaseAll());
+}
+
+function loseFocus() {
+ pause();
+ releaseEverything();
+ refresh();
+}
+
+function refresh() {
+ if (!runtime) return;
+ const snapshot = runtime.present(output);
+ if (snapshot.lastError) pause();
+ const held = new Set((snapshot.held || []).map(input => typeof input === 'string' ? input : input.name));
+ for (const [name, button] of inputButtons) {
+ button.classList.toggle('held', held.has(name));
+ button.setAttribute('aria-pressed', String(held.has(name)));
+ }
+ $('scene-status').textContent = runtime.currentProgram ? (running ? '실행 중' : '정지') : '적용 전';
+ $('clock-status').textContent = `${snapshot.tick || 0} frame · ${running ? '60 Hz' : '정지'}`;
+ $('revision').textContent = `revision ${snapshot.revision || 0}`;
+ for (const id of ['play', 'step', 'reset']) $(id).disabled = !runtime.currentProgram;
+ const event = snapshot.lastEvent;
+ $('last-event').textContent = event ? `${event.name}${event.phase && event.phase !== 'frame' ? `.${event.phase}` : ''} · ${event.status ? '실패' : '완료'}${event.line ? ` · ${event.line}행` : ''}` : '아직 없음';
+ $('runtime-error').hidden = !snapshot.lastError;
+ const displayError = snapshot.lastError === 100 || snapshot.lastError === 101;
+ $('runtime-error').textContent = snapshot.lastError ? `${displayError ? '표시 오류' : '마지막 실패'} · ${snapshot.errorLine ? `${snapshot.errorLine}행 · ` : ''}${snapshot.errorMessage || `실행 오류 ${snapshot.lastError}`}${snapshot.lastError === 101 ? ' — 이번 장면을 표시하지 않았습니다. 상태 변경은 유지됩니다.' : displayError ? ' — 해당 도형을 표시할 수 없습니다.' : ' — 실패한 이벤트의 상태 변경은 취소됐습니다. 코드를 적용하거나 초깃값으로 되돌리면 이 기록을 지웁니다.'}` : '';
+ if (!displayError && snapshot.displayError?.code === 101)
+ $('runtime-error').textContent += ` · ${snapshot.displayError.message}`;
+ output.costs(snapshot, runtime.currentProgram?.metrics, droppedTicks);
+ draftChanged();
+}
+
+function endPointer(event) {
+ const entry = pointerSources.get(event.pointerId);
+ if (!entry) return;
+ event.preventDefault();
+ pointerSources.delete(event.pointerId);
+ safeAction(() => runtime.up(entry.name, entry.source));
+}
+
+function rebuildInputControls() {
+ inputButtons.clear();
+ const children = [];
+ for (const input of runtime.currentProgram?.inputs || []) {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'input-button';
+ button.dataset.input = input.name;
+ button.setAttribute('aria-pressed', 'false');
+ const name = document.createElement('span'); name.textContent = input.name;
+ const key = document.createElement('kbd'); key.textContent = input.key;
+ button.append(name, key);
+ button.addEventListener('pointerdown', event => {
+ if (event.button !== 0) return;
+ event.preventDefault();
+ const identity = `pointer:${event.pointerId}`;
+ pointerSources.set(event.pointerId, { name: input.name, source: identity });
+ button.setPointerCapture(event.pointerId);
+ safeAction(() => runtime.down(input.name, identity));
+ });
+ button.addEventListener('pointerup', endPointer);
+ button.addEventListener('pointercancel', endPointer);
+ button.addEventListener('lostpointercapture', endPointer);
+ // Keyboard activation and assistive technology clicks are complete taps.
+ // Pointer clicks already delivered down/up via the handlers above.
+ button.addEventListener('click', event => {
+ if (event.detail !== 0) return;
+ safeAction(() => { runtime.down(input.name, 'activation'); runtime.up(input.name, 'activation'); });
+ });
+ inputButtons.set(input.name, button);
+ children.push(button);
+ }
+ if (!children.length) {
+ const message = document.createElement('p');
+ message.className = 'empty-note';
+ message.textContent = '입력을 선언하면 여기에 버튼이 생깁니다.';
+ children.push(message);
+ }
+ $('input-controls').replaceChildren(...children);
+}
+
+function isEditing(target) {
+ return target instanceof Element && Boolean(target.closest('textarea,input,select,button,[contenteditable="true"],[role="textbox"]'));
+}
+
+$('scene').addEventListener('pointerdown', () => $('scene').focus());
+
+document.addEventListener('keydown', event => {
+ if (!runtime || event.repeat || event.isComposing || event.ctrlKey || event.metaKey || event.altKey || isEditing(event.target)) return;
+ const input = runtime.currentProgram?.inputs.find(item => item.key === event.code);
+ if (!input) return;
+ event.preventDefault();
+ if (keyboardSources.has(event.code)) return;
+ keyboardSources.set(event.code, input.name);
+ safeAction(() => runtime.down(input.name, `keyboard:${event.code}`));
+});
+
+document.addEventListener('keyup', event => {
+ const name = keyboardSources.get(event.code);
+ if (!name) return;
+ event.preventDefault();
+ keyboardSources.delete(event.code);
+ safeAction(() => runtime.up(name, `keyboard:${event.code}`));
+});
+
+source.addEventListener('input', () => {
+ draftChanged();
+ if (diagnosticLocation) setDiagnostic('코드를 수정했습니다. 적용해서 다시 확인하세요.');
+});
+source.addEventListener('scroll', () => { $('line-numbers').scrollTop = source.scrollTop; });
+for (const name of ['click', 'keyup', 'select']) source.addEventListener(name, cursorPosition);
+source.addEventListener('keydown', event => {
+ if (event.key === 'Tab' && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
+ event.preventDefault();
+ source.setRangeText(' ', source.selectionStart, source.selectionEnd, 'end');
+ draftChanged();
+ }
+});
+
+$('apply').addEventListener('click', () => {
+ if (!runtime) return;
+ try {
+ runtime.apply(source.value);
+ appliedSource = source.value;
+ rebuildInputControls();
+ setDiagnostic('코드를 적용했습니다. 같은 이름의 상태는 유지됩니다.', 'success');
+ } catch (error) {
+ const location = error.line ? { line: error.line, column: error.column || 1 } : null;
+ setDiagnostic(`${location ? `${location.line}행 ${location.column}열 · ` : ''}${error.message} · 기존 장면은 그대로 유지됩니다.`, 'error', location);
+ }
+ refresh();
+});
+
+$('error-location').addEventListener('click', () => {
+ if (!diagnosticLocation) return;
+ const lines = source.value.split('\n');
+ const lineIndex = Math.min(lines.length - 1, diagnosticLocation.line - 1);
+ const start = lines.slice(0, lineIndex).reduce((sum, line) => sum + line.length + 1, 0);
+ const at = start + Math.min(lines[lineIndex].length, diagnosticLocation.column - 1);
+ source.focus();
+ source.setSelectionRange(at, Math.min(at + 1, source.value.length));
+ const lineHeight = parseFloat(getComputedStyle(source).lineHeight);
+ source.scrollTop = Math.max(0, lineIndex * lineHeight - source.clientHeight / 3);
+ $('line-numbers').scrollTop = source.scrollTop;
+ cursorPosition();
+});
+
+$('play').addEventListener('click', () => {
+ if (running) pause();
+ else { running = true; previousTime = null; accumulator = 0; $('play').textContent = 'Ⅱ 정지'; $('play').setAttribute('aria-pressed', 'true'); }
+ refresh();
+});
+$('step').addEventListener('click', () => { pause(); safeAction(() => runtime.frame()); });
+$('reset').addEventListener('click', () => {
+ pause(); releaseEverything(); droppedTicks = 0;
+ safeAction(() => runtime.reset());
+ setDiagnostic('적용된 코드의 초깃값으로 되돌렸습니다. 편집 중인 코드는 유지됩니다.');
+});
+
+function replaceDraft(text, message) {
+ source.value = text;
+ source.scrollTop = 0;
+ source.setSelectionRange(0, 0);
+ setDiagnostic(message);
+ draftChanged();
+}
+
+function mayReplaceDraft() {
+ return !source.value || window.confirm('현재 편집 중인 코드를 바꿀까요? 저장하지 않은 내용은 사라집니다. 실행 장면은 적용 전까지 유지됩니다.');
+}
+
+$('new').addEventListener('click', () => {
+ if (mayReplaceDraft()) replaceDraft('', '편집기만 비웠습니다. 빈 코드를 적용하면 장면도 비워집니다.');
+});
+
+for (const button of document.querySelectorAll('.insert-snippet')) {
+ button.addEventListener('click', () => {
+ const at = source.selectionStart;
+ const prefix = at > 0 && source.value[at - 1] !== '\n' ? '\n' : '';
+ source.setRangeText(prefix + snippets[button.dataset.snippet], source.selectionStart, source.selectionEnd, 'end');
+ source.focus();
+ draftChanged();
+ setDiagnostic('조각을 삽입했습니다. 이름과 동작을 원하는 대로 바꾸고 적용하세요.');
+ });
+}
+
+$('load-example').addEventListener('click', async () => {
+ if (!mayReplaceDraft()) return;
+ const selected = $('example').value;
+ const before = source.value;
+ const button = $('load-example');
+ button.disabled = true;
+ try {
+ const response = await fetch(`./examples/${selected}.pure`);
+ if (!response.ok) throw new Error(`예제를 불러오지 못했습니다 (${response.status}).`);
+ const text = await response.text();
+ if (bytes(text) > MAX_SOURCE_BYTES) throw new Error('예제가 코드 크기 제한을 넘었습니다.');
+ if (source.value !== before) throw new Error('불러오는 동안 코드가 바뀌었습니다. 변경 내용을 지키기 위해 불러오기를 취소했습니다.');
+ replaceDraft(text, '예제를 편집기에 불러왔습니다. 아직 적용하지 않았습니다.');
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+ finally { button.disabled = false; }
+});
+
+function download(filename, content, mime) {
+ const url = URL.createObjectURL(new Blob([content], { type: mime }));
+ const link = document.createElement('a');
+ link.href = url; link.download = filename;
+ document.body.append(link); link.click(); link.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ setDiagnostic('편집 중인 코드를 파일로 저장했습니다. 적용되지 않은 변경도 포함합니다.');
+}
+$('download').addEventListener('click', () => download('my-work.pure', source.value, 'text/plain;charset=utf-8'));
+$('download-project').addEventListener('click', () => download('my-work.pure.json', JSON.stringify({ format: 'pure-workbench', version: 1, source: source.value }, null, 2) + '\n', 'application/json'));
+$('upload').addEventListener('click', () => $('file-input').click());
+$('file-input').addEventListener('change', async event => {
+ const file = event.target.files[0];
+ event.target.value = '';
+ if (!file) return;
+ try {
+ if (file.size > MAX_SOURCE_BYTES * 7 + 1024) throw new Error('파일이 너무 큽니다. 코드 제한은 32 KiB입니다.');
+ const before = source.value;
+ let content = await file.text();
+ if (file.name.toLowerCase().endsWith('.json')) {
+ let project;
+ try { project = JSON.parse(content); } catch { throw new Error('올바른 JSON 파일이 아닙니다.'); }
+ if (!project || typeof project !== 'object' || Array.isArray(project) || project.format !== 'pure-workbench' || project.version !== 1 || typeof project.source !== 'string') throw new Error('Pure 작업물 형식이 아닙니다. format, version, source를 확인하세요.');
+ content = project.source;
+ }
+ if (bytes(content) > MAX_SOURCE_BYTES) throw new Error('코드가 32 KiB 제한을 넘었습니다.');
+ if (source.value !== before) throw new Error('파일을 읽는 동안 코드가 바뀌었습니다. 불러오기를 다시 시도하세요.');
+ if (mayReplaceDraft()) replaceDraft(content, `${file.name}을 편집기에 불러왔습니다. 아직 적용하지 않았습니다.`);
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+});
+
+window.addEventListener('blur', loseFocus);
+document.addEventListener('visibilitychange', () => { if (document.hidden) loseFocus(); });
+window.addEventListener('beforeunload', event => {
+ // Applying is not persistence: even applied code can be lost when leaving.
+ if (source.value || appliedSource) { event.preventDefault(); event.returnValue = ''; }
+});
+
+function animate(time) {
+ if (running && runtime) {
+ if (previousTime !== null) {
+ accumulator += Math.max(0, time - previousTime);
+ const wanted = Math.floor(accumulator / (1000 / 60));
+ if (wanted > 0) {
+ const steps = Math.min(6, wanted);
+ accumulator -= wanted * (1000 / 60);
+ droppedTicks += wanted - steps;
+ safeAction(() => runtime.frame(steps));
+ }
+ }
+ previousTime = running ? time : null;
+ }
+ requestAnimationFrame(animate);
+}
+
+Object.defineProperty(window, 'pureWorkbench', {
+ configurable: false,
+ writable: false,
+ value: Object.freeze({ snapshot: () => structuredClone({ ...(runtime?.snapshot() || {}), ready: Boolean(runtime), source: source.value, appliedSource, dirty: dirty(), running, droppedTicks, diagnostic: $('diagnostic-text').textContent }) }),
+});
+
+draftChanged();
+requestAnimationFrame(animate);
+try {
+ runtime = await createRuntime();
+ $('ready-status').textContent = '실행기 준비됨';
+ $('ready-dot').classList.add('ready');
+ $('apply').disabled = false;
+ refresh();
+} catch (error) {
+ $('ready-status').textContent = '실행기 준비 실패';
+ $('ready-dot').classList.add('failed');
+ setDiagnostic(`실행기를 열지 못했습니다. ${error.message}`, 'error');
+}
diff --git a/prototypes/workbench/results/2026-09-23-refresh/before/web/compiler.mjs b/prototypes/workbench/results/2026-09-23-refresh/before/web/compiler.mjs
new file mode 100644
index 0000000..016ea9e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-refresh/before/web/compiler.mjs
@@ -0,0 +1,303 @@
+// Pure's experimental scalar interaction language. No host code is evaluated.
+const LIMITS = Object.freeze({ sourceBytes: 32768, states: 32, inputs: 8,
+ views: 24, handlers: 64, instructions: 512, depth: 64, stack: 64 });
+const RESERVED = new Set(['frame', 'dt', 'min', 'max', 'clamp']);
+const BINARY = new Map([
+ ['||', [1, 15]], ['&&', [2, 14]], ['==', [3, 12]], ['!=', [3, 13]],
+ ['<', [4, 8]], ['<=', [4, 9]], ['>', [4, 10]], ['>=', [4, 11]],
+ ['+', [5, 3]], ['-', [5, 4]], ['*', [6, 5]], ['/', [6, 6]],
+]);
+const FUNCTIONS = { min: [2, 17], max: [2, 18], clamp: [3, 19] };
+const VIEW_FIELDS = { box: ['x', 'y', 'width', 'height'],
+ circle: ['x', 'y', 'radius'], text: ['x', 'y', 'value'] };
+
+function fail(token, message) {
+ const error = new Error(`${message} (${token.line}:${token.column})`);
+ error.line = token.line;
+ error.column = token.column;
+ throw error;
+}
+
+function lex(source) {
+ const tokens = [];
+ let offset = 0, line = 1, column = 1;
+ const advance = () => {
+ const char = source[offset++];
+ if (char === '\n') { line++; column = 1; } else column++;
+ return char;
+ };
+ while (offset < source.length) {
+ const char = source[offset];
+ if (char === ' ' || char === '\t' || char === '\r') { advance(); continue; }
+ if (char === '#') { while (offset < source.length && source[offset] !== '\n') advance(); continue; }
+ const token = { line, column };
+ if (char === '\n') { advance(); tokens.push({ ...token, kind: 'nl' }); continue; }
+ if (char === '"') {
+ advance();
+ let value = '', closed = false;
+ while (offset < source.length) {
+ const next = advance();
+ if (next === '"') { closed = true; break; }
+ if (next === '\n' || next === '\r') fail(token, 'Strings must fit on one line');
+ if (next === '\\') {
+ const escaped = advance();
+ const escapes = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' };
+ if (!Object.hasOwn(escapes, escaped)) fail(token, 'Unsupported string escape');
+ value += escapes[escaped];
+ } else value += next;
+ }
+ if (!closed) fail(token, 'Unterminated string');
+ tokens.push({ ...token, kind: 'string', value }); continue;
+ }
+ const remaining = source.slice(offset);
+ const number = remaining.match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/);
+ if (number) {
+ const value = Number(number[0]);
+ if (!Number.isFinite(value)) fail(token, 'Number must be finite');
+ for (let i = 0; i < number[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'number', value }); continue;
+ }
+ const name = remaining.match(/^[A-Za-z_][A-Za-z_0-9]*/);
+ if (name) {
+ for (let i = 0; i < name[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'name', value: name[0] }); continue;
+ }
+ const pair = source.slice(offset, offset + 2);
+ if (['<=', '>=', '==', '!=', '&&', '||'].includes(pair)) {
+ advance(); advance(); tokens.push({ ...token, kind: pair }); continue;
+ }
+ if ('=+-*/!<>(){},.;'.includes(char)) {
+ advance(); tokens.push({ ...token, kind: char }); continue;
+ }
+ fail(token, `Unexpected character ${JSON.stringify(char)}`);
+ }
+ tokens.push({ kind: 'eof', line, column });
+ return tokens;
+}
+
+class Parser {
+ constructor(tokens) { this.tokens = tokens; this.position = 0; this.depth = 0; }
+ peek() { return this.tokens[this.position]; }
+ take() { return this.tokens[this.position++]; }
+ is(kind, value) { return this.peek().kind === kind && (value === undefined || this.peek().value === value); }
+ expect(kind, description = kind) {
+ if (!this.is(kind)) fail(this.peek(), `Expected ${description}`);
+ return this.take();
+ }
+ newlines() { while (this.is('nl')) this.take(); }
+ separators() { while (this.is('nl') || this.is(';')) this.take(); }
+ end() {
+ if (!['nl', ';', '}', 'eof'].includes(this.peek().kind)) fail(this.peek(), 'Expected newline or semicolon');
+ this.separators();
+ }
+ node(token, properties, children = []) {
+ const depth = 1 + Math.max(0, ...children.map(child => child.depth));
+ if (depth > LIMITS.depth) fail(token, 'Expression nesting exceeds 64');
+ return { token, depth, ...properties };
+ }
+ expression(minimum = 0, multiline = false) {
+ if (++this.depth > LIMITS.depth) fail(this.peek(), 'Expression nesting exceeds 64');
+ if (multiline) this.newlines();
+ const token = this.take();
+ let left;
+ if (token.kind === 'number') left = this.node(token, { kind: 'constant', value: token.value });
+ else if (token.kind === '-' || token.kind === '!') {
+ const child = this.expression(7, multiline);
+ left = this.node(token, { kind: 'unary', op: token.kind === '-' ? 7 : 16, child }, [child]);
+ } else if (token.kind === '(') {
+ left = this.expression(0, true); this.newlines(); this.expect(')');
+ } else if (token.kind === 'name') {
+ if (Object.hasOwn(FUNCTIONS, token.value) && this.is('(')) {
+ this.take();
+ const args = [];
+ this.newlines();
+ if (!this.is(')')) {
+ for (;;) {
+ args.push(this.expression(0, true)); this.newlines();
+ if (!this.is(',')) break;
+ this.take();
+ }
+ }
+ this.expect(')');
+ if (args.length !== FUNCTIONS[token.value][0]) fail(token, `${token.value} expects ${FUNCTIONS[token.value][0]} arguments`);
+ left = this.node(token, { kind: 'call', op: FUNCTIONS[token.value][1], args }, args);
+ } else left = this.node(token, { kind: 'reference', name: token.value });
+ } else fail(token, 'Expected a number, state, or expression');
+ for (;;) {
+ if (multiline) this.newlines();
+ const entry = BINARY.get(this.peek().kind);
+ if (!entry || entry[0] < minimum) break;
+ const operator = this.take();
+ const right = this.expression(entry[0] + 1, multiline);
+ left = this.node(operator, { kind: 'binary', op: entry[1], left, right }, [left, right]);
+ }
+ this.depth--;
+ return left;
+ }
+ block(depth = 0) {
+ if (depth >= LIMITS.depth) fail(this.peek(), 'Handler nesting exceeds 64');
+ this.newlines(); this.expect('{'); this.separators();
+ const statements = [];
+ while (!this.is('}')) {
+ const token = this.expect('name', 'assignment, if, or closing brace');
+ if (token.value === 'if' && !this.is('=')) {
+ const condition = this.expression();
+ const body = this.block(depth + 1);
+ statements.push({ kind: 'if', token, condition, body });
+ } else {
+ this.expect('=');
+ statements.push({ kind: 'assign', token, name: token.value, expression: this.expression() });
+ }
+ this.end();
+ }
+ this.take();
+ return statements;
+ }
+ parse() {
+ const declarations = [];
+ this.separators();
+ while (!this.is('eof')) {
+ const token = this.expect('name', 'declaration');
+ const kind = token.value;
+ if (kind === 'state') {
+ const name = this.expect('name', 'state name'); this.expect('=');
+ let sign = 1;
+ if (this.is('-') || this.is('+')) sign = this.take().kind === '-' ? -1 : 1;
+ const value = this.expect('number', 'numeric state initializer');
+ declarations.push({ kind, token, name, initial: sign * value.value });
+ } else if (kind === 'input') {
+ const name = this.expect('name', 'input name'); this.expect('=');
+ const key = this.expect('name', 'keyboard code');
+ if (!/^(?:Space|Enter|Arrow(?:Left|Right|Up|Down)|Key[A-Z]|Digit[0-9])$/.test(key.value)) fail(key, 'Unsupported keyboard code');
+ declarations.push({ kind, token, name, key });
+ } else if (Object.hasOwn(VIEW_FIELDS, kind)) {
+ const name = this.expect('name', 'view ID'); this.expect('(');
+ const expressions = [];
+ for (let i = 0; i < VIEW_FIELDS[kind].length; i++) {
+ if (i) { this.newlines(); this.expect(','); }
+ expressions.push(this.expression(0, true));
+ }
+ this.newlines();
+ let color, prefix = '';
+ if (kind !== 'text') {
+ this.expect(','); this.newlines();
+ const value = this.expect('string', 'literal hex color');
+ if (!/^#[0-9a-fA-F]{6}$/.test(value.value)) fail(value, 'Color must use six hexadecimal digits, such as "#356548"');
+ color = value.value;
+ } else if (this.is(',')) { this.take(); this.newlines(); prefix = this.expect('string', 'literal text prefix').value; }
+ this.newlines(); this.expect(')');
+ declarations.push({ kind: 'view', type: kind, token, name, expressions, color, prefix });
+ } else if (kind === 'on') {
+ const event = this.expect('name', 'input name or frame');
+ let edge;
+ if (event.value !== 'frame') {
+ this.expect('.'); edge = this.expect('name', 'down or up');
+ if (edge.value !== 'down' && edge.value !== 'up') fail(edge, 'Expected down or up');
+ }
+ declarations.push({ kind: 'handler', token, event, edge, body: this.block() });
+ } else fail(token, `Unknown declaration ${kind}`);
+ this.end();
+ }
+ return declarations;
+ }
+}
+
+export function compile(source) {
+ if (typeof source !== 'string') fail({ line: 1, column: 1 }, 'Source must be a string');
+ const sourceBytes = new TextEncoder().encode(source).length;
+ if (sourceBytes > LIMITS.sourceBytes) fail({ line: 1, column: 1 }, 'Source exceeds 32768 UTF-8 bytes');
+ const declarations = new Parser(lex(source)).parse();
+ const program = { states: [], inputs: [], instructions: [], handlers: [], views: [], metrics: {} };
+ const states = new Map(), inputs = new Map(), views = new Set(), keys = new Set();
+ let handlerCount = 0;
+ for (const declaration of declarations) {
+ const { kind, name, token } = declaration;
+ if (name && RESERVED.has(name.value)) fail(name, `Reserved name ${name.value}`);
+ if (kind === 'state' || kind === 'input') {
+ const symbols = kind === 'state' ? states : inputs;
+ if (symbols.has(name.value)) fail(name, `Duplicate ${kind} ${name.value}`);
+ if (symbols.size >= LIMITS[`${kind}s`]) fail(name, `At most ${LIMITS[`${kind}s`]} ${kind}s are allowed`);
+ symbols.set(name.value, symbols.size);
+ if (kind === 'state') program.states.push({ name: name.value, initial: declaration.initial, line: token.line });
+ else {
+ if (keys.has(declaration.key.value)) fail(declaration.key, `Duplicate keyboard code ${declaration.key.value}`);
+ keys.add(declaration.key.value);
+ program.inputs.push({ name: name.value, key: declaration.key.value, line: token.line });
+ }
+ } else if (kind === 'view') {
+ if (views.has(name.value)) fail(name, `Duplicate view ID ${name.value}`);
+ if (views.size >= LIMITS.views) fail(name, 'At most 24 views are allowed');
+ views.add(name.value);
+ } else if (++handlerCount > LIMITS.handlers) fail(token, 'At most 64 handlers are allowed');
+ }
+ let stack = 0, maxStack = 0;
+ const emit = (op, token, arg = 0, value = 0, delta = 0) => {
+ if (program.instructions.length >= LIMITS.instructions) fail(token, 'At most 512 instructions are allowed');
+ stack += delta;
+ if (stack < 0 || stack > LIMITS.stack) fail(token, 'Expression stack exceeds 64');
+ maxStack = Math.max(maxStack, stack);
+ program.instructions.push({ op, arg, value, line: token.line });
+ };
+ const expression = (node, binding = false) => {
+ const { token } = node;
+ if (node.kind === 'constant') emit(0, token, 0, node.value, 1);
+ else if (node.kind === 'reference') {
+ if (node.name === 'dt') {
+ if (binding) fail(token, 'dt is only available in handlers');
+ emit(2, token, 0, 0, 1);
+ } else {
+ if (!states.has(node.name)) fail(token, `Unknown state ${node.name}`);
+ emit(1, token, states.get(node.name), 0, 1);
+ }
+ } else if (node.kind === 'unary') { expression(node.child, binding); emit(node.op, token); }
+ else if (node.kind === 'binary') {
+ expression(node.left, binding); expression(node.right, binding); emit(node.op, token, 0, 0, -1);
+ } else {
+ for (const arg of node.args) expression(arg, binding);
+ emit(node.op, token, 0, 0, 1 - node.args.length);
+ }
+ };
+ const statements = body => {
+ for (const statement of body) {
+ if (statement.kind === 'assign') {
+ if (!states.has(statement.name)) fail(statement.token, `Unknown state ${statement.name}`);
+ expression(statement.expression);
+ emit(20, statement.token, states.get(statement.name), 0, -1);
+ } else {
+ expression(statement.condition);
+ const branch = program.instructions.length;
+ emit(21, statement.token, 0, 0, -1);
+ statements(statement.body);
+ program.instructions[branch].arg = program.instructions.length;
+ }
+ }
+ };
+ for (const declaration of declarations) {
+ if (declaration.kind === 'handler') {
+ let event = 0;
+ if (declaration.event.value !== 'frame') {
+ if (!inputs.has(declaration.event.value)) fail(declaration.event, `Unknown input ${declaration.event.value}`);
+ event = 1 + inputs.get(declaration.event.value) * 2 + (declaration.edge.value === 'up' ? 1 : 0);
+ }
+ const start = program.instructions.length;
+ stack = 0; statements(declaration.body);
+ program.handlers.push({ event, start, length: program.instructions.length - start, line: declaration.token.line });
+ } else if (declaration.kind === 'view') {
+ const view = { id: declaration.name.value, type: declaration.type, line: declaration.token.line, bindings: {} };
+ if (view.type === 'text') view.prefix = declaration.prefix;
+ else view.color = declaration.color;
+ declaration.expressions.forEach((node, index) => {
+ const start = program.instructions.length;
+ stack = 0; expression(node, true);
+ view.bindings[VIEW_FIELDS[view.type][index]] = { start, length: program.instructions.length - start };
+ });
+ program.views.push(view);
+ }
+ }
+ program.metrics = { sourceBytes, sourceLines: source === '' ? 0 : source.split('\n').length,
+ states: states.size, inputs: inputs.size, views: views.size,
+ handlers: program.handlers.length, instructions: program.instructions.length, maxStack };
+ return program;
+}
+
+export { LIMITS };
diff --git a/prototypes/workbench/results/2026-09-23-refresh/before/web/core.wasm b/prototypes/workbench/results/2026-09-23-refresh/before/web/core.wasm
new file mode 100644
index 0000000..0eb3fe5
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-refresh/before/web/core.wasm
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-23-refresh/before/web/index.html b/prototypes/workbench/results/2026-09-23-refresh/before/web/index.html
new file mode 100644
index 0000000..d93589e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-refresh/before/web/index.html
@@ -0,0 +1,93 @@
+<!doctype html>
+<html lang="ko">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="color-scheme" content="dark">
+ <title>Pure · 빈 작업물에서 시작하기</title>
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23151918'/%3E%3Ctext x='9' y='23' fill='%23c9f08b' font-size='24' font-family='sans-serif'%3Ep%3C/text%3E%3C/svg%3E">
+ <link rel="stylesheet" href="./style.css">
+</head>
+<body>
+ <header class="masthead">
+ <a class="wordmark" href="./" aria-label="Pure 작업 공간">pure<span> / workbench</span></a>
+ <span class="experiment">제작 경험 실험 004</span>
+ <div class="connection"><span id="ready-dot" class="dot"></span><span id="ready-status" role="status">실행기 준비 중</span></div>
+ </header>
+
+ <main>
+ <section class="intro" aria-labelledby="title">
+ <div><p class="eyebrow">빈 코드 · 직접 만든 동작</p><h1 id="title">무엇을 움직이게 할까요?</h1></div>
+ <p>상태를 만들고, 입력에 동작을 연결하세요.<br>코드를 <strong>적용</strong>하면 장면이 바뀝니다. 같은 이름의 상태는 이어집니다.</p>
+ </section>
+
+ <div class="workspace">
+ <section class="editor-panel panel" aria-labelledby="editor-title">
+ <div class="panel-heading"><h2 id="editor-title"><span class="section-index">01</span> 코드</h2><span id="draft-status" class="badge">빈 작업물</span></div>
+ <div class="file-tools" aria-label="파일 관리">
+ <button id="new" class="quiet" type="button">편집기 비우기</button>
+ <button id="upload" class="quiet" type="button">파일 열기</button>
+ <button id="download" class="quiet" type="button">.pure 저장</button>
+ <button id="download-project" class="quiet" type="button">JSON 저장</button>
+ <input id="file-input" type="file" accept=".pure,.txt,.json,text/plain,application/json" hidden>
+ </div>
+ <label class="sr-only" for="source">Pure 코드 편집기</label>
+ <div class="code-editor">
+ <div id="line-numbers" class="line-numbers" aria-hidden="true">1</div>
+ <textarea id="source" spellcheck="false" autocapitalize="off" autocomplete="off" autocorrect="off" aria-describedby="editor-hint diagnostic" placeholder="여기서 시작하세요.&#10;&#10;아래 ‘언어 안내’에서 작은 조각을 가져올 수 있습니다."></textarea>
+ </div>
+ <div class="editor-footer"><span id="source-size">0 / 32,768 bytes</span><span id="cursor-position">1행 · 1열</span></div>
+ <div class="apply-row"><button id="apply" class="primary" type="button" disabled>코드 적용 <span aria-hidden="true">↗</span></button><p id="editor-hint">편집만으로 실행 중인 코드는 바뀌지 않습니다.</p></div>
+ <div id="diagnostic" class="diagnostic" role="status" aria-live="polite"><span id="diagnostic-text">아직 작성한 코드가 없습니다.</span><button id="error-location" type="button" class="quiet" hidden>문제 위치로</button></div>
+ </section>
+
+ <div class="right-column">
+ <section class="scene-panel panel" aria-labelledby="scene-title">
+ <div class="panel-heading"><h2 id="scene-title"><span class="section-index">02</span> 실행 장면</h2><span id="scene-status" class="badge">적용 전</span></div>
+ <div class="stage-wrap">
+ <svg id="scene" viewBox="0 0 640 360" role="img" tabindex="0" aria-labelledby="scene-description"><title id="scene-description">사용자가 코드로 만든 장면. 현재 비어 있습니다.</title><g id="scene-content"></g></svg>
+ <div id="empty-scene" class="empty-scene"><span class="empty-cross" aria-hidden="true">+</span><strong>아직 아무것도 없습니다</strong><span>도형이나 글자를 선언하고 코드를 적용하세요.</span></div>
+ <span class="stage-dimensions" aria-hidden="true">640 × 360</span>
+ </div>
+ <div class="transport"><button id="play" class="secondary" type="button" disabled>▶ 실행</button><button id="step" type="button" disabled>한 프레임</button><button id="reset" type="button" disabled>초깃값으로</button><span id="clock-status">0 frame · 정지</span></div>
+ <p class="control-note">정지하면 시간만 멈춥니다. 아래 입력은 정지 중에도 작동합니다.</p>
+ <div class="input-heading"><h3>내가 선언한 입력</h3><span>눌렀을 때 .down / 놓았을 때 .up</span></div>
+ <div id="input-controls" class="input-controls"><p class="empty-note">입력을 선언하면 여기에 버튼이 생깁니다.</p></div>
+ <p class="keyboard-note">장면을 클릭하면 키보드로 입력할 수 있습니다. 코드나 폼을 편집하는 동안에는 전달되지 않습니다.</p>
+ </section>
+
+ <section class="inspect-panel panel" aria-labelledby="inspect-title">
+ <div class="panel-heading"><h2 id="inspect-title"><span class="section-index">03</span> 현재 상태</h2><span id="revision" class="mono subdued">revision 0</span></div>
+ <div id="state-values" class="state-values"><p class="empty-note">선언한 상태의 값이 표시됩니다.</p></div>
+ <div class="event-line"><span class="subdued">마지막 입력</span><span id="last-event" class="mono">아직 없음</span></div>
+ <div id="runtime-error" class="runtime-error" role="status" hidden></div>
+ <details class="cost-details"><summary>실행 비용과 범위</summary><dl id="cost-values" class="cost-values"></dl><p>코어가 보고한 WebAssembly 메모리입니다. 브라우저·편집기·렌더러의 메모리와 프로세스 전체 사용량은 포함하지 않습니다. 예약 공간 안에 사용 중인 코드·상태·스테이징 공간이 포함되므로 합산하지 않습니다.</p><p id="dropped-ticks">시간 지연으로 건너뛴 프레임: 0</p></details>
+ </section>
+ </div>
+ </div>
+
+ <section class="reference panel" aria-labelledby="reference-title">
+ <div class="panel-heading"><h2 id="reference-title">언어 안내</h2><span class="subdued">조각을 조합해 자신의 동작을 만드세요.</span></div>
+ <div class="reference-grid">
+ <article><h3>1. 기억할 값</h3><p>이름은 영문으로, 처음 값은 숫자로 정합니다.</p><pre><code>state count = 0
+state x = 80</code></pre><button class="insert-snippet quiet" data-snippet="state" type="button">상태 조각 삽입</button></article>
+ <article><h3>2. 입력과 반응</h3><p>버튼 이름과 키를 선언하고 .down / .up에 연결합니다.</p><pre><code>input tap = Space
+on tap.down {
+ count = count + 1
+}</code></pre><button class="insert-snippet quiet" data-snippet="input" type="button">입력 조각 삽입</button></article>
+ <article><h3>3. 보일 것</h3><p>좌표와 크기를 상태에 연결할 수 있습니다.</p><pre><code>box tile(x, 180, 40, 40, "#7ed9ae")
+text label(40, 60, count, "Count: ")</code></pre><button class="insert-snippet quiet" data-snippet="view" type="button">장면 조각 삽입</button></article>
+ <article><h3>4. 시간에 따른 변화</h3><p>실행하면 매 프레임 반응합니다. dt는 1/60초입니다.</p><pre><code>on frame {
+ x = x + 30 * dt
+ if x > 600 { x = 0 }
+}</code></pre><button class="insert-snippet quiet" data-snippet="frame" type="button">시간 조각 삽입</button></article>
+ </div>
+ <details class="syntax-details"><summary>표현식 · 입력 키 · 제한 보기</summary><div class="syntax-columns"><div><p><strong>표현식</strong><br><code>+ - * /</code>, <code>&lt; &lt;= &gt; &gt;= == !=</code>, <code>&amp;&amp; || !</code>, 괄호, <code>min(a,b)</code>, <code>max(a,b)</code>, <code>clamp(x,lo,hi)</code>를 사용합니다. 논리 연산은 양쪽을 모두 계산합니다.</p><p><strong>도형</strong><br><code>box id(x,y,width,height,"#rrggbb")</code><br><code>circle id(x,y,radius,"#rrggbb")</code><br><code>text id(x,y,value,"접두사")</code><br>글자의 value는 숫자 표현식입니다. 문자 접두사는 생략할 수 있습니다.</p></div><div><p><strong>입력</strong><br><code>Space</code>, <code>Enter</code>, <code>ArrowLeft/Right/Up/Down</code>, <code>KeyA</code>~<code>KeyZ</code>, <code>Digit0</code>~<code>Digit9</code>. 같은 키는 하나의 입력에 연결합니다.</p><p><strong>이 실험의 범위</strong><br>상태 32개 · 입력 8개 · 도형/글자 24개 · 반응 64개 · 컴파일된 명령 512개 · 코드 32 KiB. 반복문, 함수 선언, 자동 충돌·물리는 없습니다. 줄바꿈 또는 세미콜론으로 구분하고 <code>#</code>으로 주석을 씁니다.</p><p>0으로 나누기 등 계산 실패 시 해당 이벤트의 상태 변경 전체를 취소합니다. 입력을 누르고 있을 때는 새 코드를 적용할 수 없습니다.</p></div></div></details>
+ <div class="example-row"><div><label for="example">완성된 예제 살펴보기</label><p>선택한 코드를 편집기에 불러옵니다. 자동으로 적용되지 않습니다.</p></div><select id="example"><option value="counter">카운터</option><option value="charge">누르는 동안 충전</option><option value="toggle">입력으로 전환</option></select><button id="load-example" type="button">예제 불러오기</button></div>
+ <div class="baseline-row"><a href="./baseline.html" target="_blank" rel="noopener">같은 동작을 일반 JavaScript로 만든 비교 앱 ↗</a><span>충전 예제와 비교할 수 있습니다. 새 탭에서 열립니다.</span></div>
+ </section>
+ <footer class="page-footer"><span>Pure · authoring experiment</span><p>원하는 동작을 만들어보고, <strong>하려던 일 / 실제로 일어난 일 / 기대했던 일</strong>을 남겨주세요.</p></footer>
+ </main>
+ <script type="module" src="./app.js"></script>
+</body>
+</html>
diff --git a/prototypes/workbench/results/2026-09-23-refresh/before/web/output.mjs b/prototypes/workbench/results/2026-09-23-refresh/before/web/output.mjs
new file mode 100644
index 0000000..53cae48
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-refresh/before/web/output.mjs
@@ -0,0 +1,92 @@
+// Bind authored declarations to concrete DOM destinations once per apply.
+// The DOM holds the published values; no parallel scene/value model is kept.
+const NS = 'http://www.w3.org/2000/svg';
+const number = value => Number.isInteger(value) ? String(value) : Number(value.toPrecision(7)).toString();
+const attributes = { box: { x: 'x', y: 'y', width: 'width', height: 'height' },
+ circle: { x: 'cx', y: 'cy', radius: 'r' }, text: { x: 'x', y: 'y' } };
+
+function text(node, value) {
+ if (node.textContent !== value) node.textContent = value;
+}
+function attribute(node, name, value) {
+ const next = String(value);
+ if (node.getAttribute(name) !== next) node.setAttribute(name, next);
+}
+
+export function createOutput(document) {
+ const get = id => document.getElementById(id);
+ const scene = get('scene-content'), states = get('state-values');
+ const empty = get('empty-scene'), description = get('scene-description');
+ let active = undefined, nodes = [], amounts = [];
+ const costNames = ['Wasm 선형 메모리', '코어 예약 공간', '사용 중인 명령·핸들러',
+ '현재 상태 값', '새 코드 준비 공간 (예약에 포함)', '평가 스택 용량',
+ '마지막 이벤트 실행 명령', '적용된 소스 크기'];
+ const costs = costNames.map(label => {
+ const dt = document.createElement('dt'), dd = document.createElement('dd');
+ dt.textContent = label;
+ return { dt, dd };
+ });
+ get('cost-values').replaceChildren(...costs.flatMap(({dt, dd}) => [dt, dd]));
+
+ return {
+ begin(program) {
+ if (program === active) return;
+ active = program;
+ nodes = (program?.views ?? []).map(view => {
+ const node = document.createElementNS(NS, {box:'rect', circle:'circle', text:'text'}[view.type]);
+ node.setAttribute('data-view-id', view.id);
+ node.setAttribute('fill', view.color ?? '#e7ece6');
+ if (view.type === 'text') {
+ node.setAttribute('font-family', 'ui-monospace, monospace');
+ node.setAttribute('font-size', '20');
+ }
+ return node;
+ });
+ scene.replaceChildren(...nodes);
+ const holders = [];
+ amounts = (program?.states ?? []).map(state => {
+ const dl = document.createElement('dl'), dt = document.createElement('dt'), dd = document.createElement('dd');
+ dl.className = 'state-value'; dt.textContent = state.name; dd.dataset.state = state.name;
+ dl.append(dt, dd); holders.push(dl);
+ return dd;
+ });
+ if (!holders.length) {
+ const note = document.createElement('p');
+ note.className = 'empty-note'; note.textContent = '선언한 상태의 값이 표시됩니다.';
+ holders.push(note);
+ }
+ states.replaceChildren(...holders);
+ },
+ state(index, value) { text(amounts[index], number(value)); },
+ view(index, valid) {
+ const node = nodes[index];
+ if (valid) { if (node.hasAttribute('display')) node.removeAttribute('display'); }
+ else attribute(node, 'display', 'none');
+ },
+ value(index, name, value) {
+ const view = active.views[index], node = nodes[index];
+ if (name === 'value') text(node, `${view.prefix || ''}${number(value)}`);
+ else attribute(node, attributes[view.type][name], value);
+ },
+ end(visible) {
+ const hidden = visible !== 0;
+ if (empty.hidden !== hidden) empty.hidden = hidden;
+ const failed = !visible && nodes.length > 0;
+ text(empty.querySelector('strong'), failed ? '장면을 표시할 수 없습니다' : '아직 아무것도 없습니다');
+ text(empty.querySelector('span:last-child'), failed ? '현재 상태 아래의 표시 오류를 확인하세요.' : '도형이나 글자를 선언하고 코드를 적용하세요.');
+ text(description, visible ? `사용자가 만든 장면. 도형과 글자 ${visible}개.` : failed ? '표시 오류로 장면을 표시할 수 없습니다.' : '사용자가 코드로 만든 장면. 현재 비어 있습니다.');
+ },
+ costs(status, metrics, droppedTicks) {
+ const m = status.memory;
+ const values = [m.linearBytes, m.reservedEngineBytes, m.activeIRBytes, m.activeStateBytes,
+ m.stagingReservedBytes, m.evalStackBytes, status.lastOps, metrics?.sourceBytes];
+ for (let i = 0; i < values.length; i++) {
+ const value = values[i], {dt, dd} = costs[i], hidden = !Number.isFinite(value);
+ if (dt.hidden !== hidden) dt.hidden = hidden;
+ if (dd.hidden !== hidden) dd.hidden = hidden;
+ if (!hidden) text(dd, i === 6 ? `${value}개` : `${value.toLocaleString('en-US')} B`);
+ }
+ text(get('dropped-ticks'), `시간 지연으로 건너뛴 프레임: ${droppedTicks}`);
+ },
+ };
+}
diff --git a/prototypes/workbench/results/2026-09-23-refresh/before/web/runtime.mjs b/prototypes/workbench/results/2026-09-23-refresh/before/web/runtime.mjs
new file mode 100644
index 0000000..96634b8
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-refresh/before/web/runtime.mjs
@@ -0,0 +1,267 @@
+import { compile } from './compiler.mjs';
+
+const messages = [
+ '', '실행기의 용량 한도를 넘었습니다.', '실행 정의가 유효하지 않습니다.',
+ '계산 스택의 범위를 벗어났습니다.', '0으로 나눌 수 없습니다.',
+ '계산 결과가 유한한 수의 범위를 벗어났습니다.', '조건 분기 범위가 유효하지 않습니다.',
+ '존재하지 않는 상태를 참조했습니다.', '시간 간격이 유효하지 않습니다.',
+ '정의되지 않은 입력 사건입니다.',
+];
+const sourceLimit = 32768;
+
+function problem(message, line = null, column = null) {
+ return Object.assign(new Error(message), { line, column });
+}
+
+function freezeProgram(value) {
+ if (value && typeof value === 'object') {
+ for (const child of Object.values(value)) freezeProgram(child);
+ Object.freeze(value);
+ }
+ return value;
+}
+
+export async function createRuntime(wasmBytes, evaluationOptions = {}) {
+ if (wasmBytes === undefined) {
+ const response = await fetch(new URL('./core.wasm', import.meta.url), { cache: 'no-store' });
+ if (!response.ok) throw new Error(`실행기를 불러오지 못했습니다 (HTTP ${response.status}).`);
+ wasmBytes = await response.arrayBuffer();
+ }
+ const module = await WebAssembly.compile(wasmBytes);
+ if (WebAssembly.Module.imports(module).length) throw new Error('이 실행기는 호스트 함수 없이 동작해야 합니다.');
+ const { exports: core } = await WebAssembly.instantiate(module, {});
+ if (typeof core.eval_bounded !== 'function' || typeof core.abort_program !== 'function')
+ throw new Error('실행기 버전이 맞지 않습니다. 새로고침하거나 core.wasm을 다시 빌드하세요.');
+ core.reset_all();
+ let program = null;
+ let appliedSource = '';
+ let lastEvent = null;
+ // Keep a failed event visible through its release/focus-loss cleanup.
+ // The active program can clear this diagnostic by apply or explicit reset.
+ let lastFailure = null;
+ const held = new Map();
+ let presenting = false;
+ let bindings = [];
+ // One bounded publication buffer, never exposed to callers. No state mirror.
+ let displayValues = null, displayValid = null;
+ function available() {
+ if (presenting) throw new Error('출력 전달 중에는 실행기를 다시 호출할 수 없습니다.');
+ }
+ let evaluationPolicy = { cacheEntries: 0, historyEntries: 8, workLimit: 2048 };
+
+ function checked(code, line = null) {
+ if (code !== 0) throw problem(messages[code] || `실행기 오류 ${code}`, line);
+ }
+ function inputIndex(name) {
+ const index = program?.inputs.findIndex((input) => input.name === name) ?? -1;
+ if (index < 0) throw new Error(`정의되지 않은 입력: ${name}`);
+ return index;
+ }
+ function dispatch(event, name, phase, source) {
+ const status = core.dispatch(event, event === 0 ? 1 / 60 : 0);
+ const pc = core.info(2) >>> 0;
+ lastEvent = { name, phase, source, status,
+ line: program?.instructions[pc]?.line ?? null, ops: core.info(3) >>> 0 };
+ if (status) lastFailure = { ...lastEvent };
+ return status;
+ }
+
+ function evaluateDisplay() {
+ displayValues ??= new Float64Array(96);
+ displayValid ??= new Uint8Array(24);
+ core.begin_evaluation(evaluationPolicy.workLimit);
+ let error = null, slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ displayValid[i] = 1;
+ for (const [name, binding] of bindings[i]) {
+ const value = core.eval_bounded(binding.start, binding.length);
+ if (core.evaluation_info(0) === 10) {
+ displayValid.fill(0);
+ return { code: 101, line: view.line,
+ message: '표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.' };
+ }
+ if (!Number.isFinite(value) || ((name === 'width' || name === 'height' || name === 'radius') && value < 0)) {
+ error ??= { line: view.line, message: `${view.id}.${name}: 표시할 수 없는 값입니다. 0 나눗셈이나 음수 크기를 확인하세요.` };
+ displayValid[i] = 0;
+ }
+ displayValues[slot++] = value;
+ }
+ }
+ return error;
+ }
+
+ function status(viewError) {
+ const executionError = lastFailure?.status || 0;
+ return {
+ tick: core.info(0) >>> 0, revision: core.info(4) >>> 0,
+ displayError: viewError ? { code: viewError.code || 100, ...viewError } : null,
+ lastError: executionError || (viewError ? viewError.code || 100 : 0),
+ errorMessage: executionError ? messages[executionError] || `실행 오류 ${executionError}` : viewError?.message || '',
+ errorLine: executionError ? lastFailure.line : viewError?.line ?? null,
+ lastOps: core.info(3) >>> 0, successfulEvents: core.info(9) >>> 0,
+ held: [...held.keys()], lastEvent: lastEvent ? { ...lastEvent } : null,
+ lastFailure: lastFailure ? { ...lastFailure } : null,
+ memory: {
+ linearBytes: core.memory.buffer.byteLength,
+ reservedEngineBytes: core.layout(0), programReservedBytes: core.layout(1),
+ instructionBytes: core.layout(2), handlerBytes: core.layout(3),
+ activeIRBytes: core.layout(4), activeStateBytes: core.layout(5),
+ stagingReservedBytes: core.layout(6), evalStackBytes: core.layout(7),
+ },
+ };
+ }
+
+ const api = {
+ get currentProgram() { return program; },
+ configureEvaluation({ cacheEntries = evaluationPolicy.cacheEntries,
+ historyEntries = evaluationPolicy.historyEntries, workLimit = evaluationPolicy.workLimit } = {}) {
+ available();
+ for (const [name, value, maximum] of [['cacheEntries', cacheEntries, 32],
+ ['historyEntries', historyEntries, 8], ['workLimit', workLimit, 0xffffffff]]) {
+ if (!Number.isInteger(value) || value < 0 || value > maximum)
+ throw new Error(`${name}: 0~${maximum} 사이의 정수여야 합니다.`);
+ }
+ checked(core.configure_evaluation(cacheEntries, historyEntries));
+ evaluationPolicy = { cacheEntries, historyEntries, workLimit };
+ return { ...evaluationPolicy };
+ },
+ forgetEvaluation(part = 'all') {
+ available();
+ const flags = part === 'all' ? 3 : part === 'values' ? 1 : part === 'history' ? 2 : undefined;
+ if (flags === undefined) throw new Error('삭제할 기록은 values, history, all 중 하나여야 합니다.');
+ core.forget_evaluation(flags);
+ },
+ evaluationStats() {
+ return { ...evaluationPolicy, work: core.evaluation_info(1),
+ instructions: core.evaluation_info(2), cacheHits: core.evaluation_info(3),
+ reservedBytes: core.evaluation_info(6), dedicatedBytes: core.evaluation_info(7),
+ sharedBytes: core.evaluation_info(8), reuseAvailable: Boolean(core.evaluation_info(9)),
+ workspaceTagBytes: core.evaluation_info(10) };
+ },
+ apply(source) {
+ available();
+ if (typeof source !== 'string' || new TextEncoder().encode(source).length > sourceLimit) {
+ throw problem('정의는 UTF-8 기준 32,768바이트 이하의 텍스트여야 합니다.');
+ }
+ if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 적용하세요. 현재 작업은 그대로 유지합니다.');
+ const next = freezeProgram(compile(source));
+ const oldIds = new Map(program?.states.map((state, index) => [state.name, index]) ?? []);
+ checked(core.begin_program(next.states.length, next.inputs.length));
+ try {
+ for (let index = 0; index < next.states.length; index++) {
+ const state = next.states[index];
+ checked(core.put_state(index, state.initial, oldIds.get(state.name) ?? -1), state.line);
+ }
+ for (const op of next.instructions) checked(core.put_instruction(op.op, op.arg, op.value), op.line);
+ for (const handler of next.handlers) checked(core.put_handler(handler.event, handler.start, handler.length), handler.line);
+ checked(core.commit_program());
+ } catch (error) {
+ core.abort_program();
+ throw error;
+ }
+ program = next;
+ bindings = next.views.map(view => Object.entries(view.bindings));
+ appliedSource = source;
+ lastEvent = null;
+ lastFailure = null;
+ return next;
+ },
+ reset() {
+ available();
+ held.clear();
+ core.reset_state();
+ lastEvent = null;
+ lastFailure = null;
+ },
+ frame(count = 1) {
+ available();
+ if (!Number.isInteger(count) || count < 0 || count > 600) throw new Error('프레임 수는 0~600 사이의 정수여야 합니다.');
+ if (!program) return 0;
+ for (let i = 0; i < count; i++) {
+ const code = dispatch(0, 'frame', 'frame', 'clock');
+ if (code !== 0) return code;
+ }
+ return 0;
+ },
+ down(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ const sources = held.get(name) || new Set();
+ if (sources.has(source)) return 0;
+ const wasDown = sources.size > 0;
+ sources.add(source);
+ held.set(name, sources);
+ return wasDown ? 0 : dispatch(1 + index * 2, name, 'down', source);
+ },
+ up(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ const sources = held.get(name);
+ if (!sources?.delete(source)) return 0;
+ if (sources.size) return 0;
+ held.delete(name);
+ return dispatch(2 + index * 2, name, 'up', source);
+ },
+ releaseAll() {
+ available();
+ let firstError = 0;
+ for (const [name, sources] of [...held]) {
+ for (const source of [...sources]) {
+ const status = api.up(name, source);
+ if (!firstError) firstError = status;
+ }
+ }
+ return firstError;
+ },
+ exportProject() {
+ return { format: 'pure-workbench', version: 1, source: appliedSource };
+ },
+ // Synchronous trusted output adapter. Callbacks run only after the whole
+ // display has been evaluated. Runtime mutation/reentrant reads are refused.
+ // Exceptions from an adapter propagate; the next call republishes all values.
+ present(target) {
+ available();
+ presenting = true;
+ try {
+ const { begin, state, view, value, end } = target;
+ for (const callback of [begin, state, view, value, end])
+ if (typeof callback !== 'function') throw new TypeError('출력 어댑터 함수가 필요합니다.');
+ const error = evaluateDisplay();
+ begin.call(target, program);
+ for (let i = 0; i < (program?.states.length ?? 0); i++) state.call(target, i, core.get_state(i));
+ let slot = 0, visible = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const valid = Boolean(displayValid[i]);
+ view.call(target, i, valid);
+ for (const [name] of bindings[i]) {
+ if (valid) value.call(target, i, name, displayValues[slot]);
+ slot++;
+ }
+ if (valid) visible++;
+ }
+ end.call(target, visible);
+ return status(error);
+ } finally { presenting = false; }
+ },
+ snapshot() {
+ available();
+ const error = evaluateDisplay();
+ const states = Object.fromEntries((program?.states ?? []).map((state, index) => [state.name, core.get_state(index)]));
+ const views = [];
+ let slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ const rendered = displayValid[i] ? { id: view.id, type: view.type, color: view.color, prefix: view.prefix } : null;
+ for (const [name] of bindings[i]) {
+ if (rendered) rendered[name] = displayValues[slot];
+ slot++;
+ }
+ if (rendered) views.push(rendered);
+ }
+ return { states, views, ...status(error) };
+ },
+ };
+ api.configureEvaluation(evaluationOptions);
+ return Object.freeze(api);
+}
diff --git a/prototypes/workbench/results/2026-09-23-refresh/before/web/style.css b/prototypes/workbench/results/2026-09-23-refresh/before/web/style.css
new file mode 100644
index 0000000..819c84c
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-refresh/before/web/style.css
@@ -0,0 +1,8 @@
+:root{color-scheme:dark;--bg:#151918;--panel:#1d2220;--field:#151a18;--line:#353e38;--text:#e7ece6;--muted:#a9b6aa;--faint:#7b8e7e;--accent:#c9f08b;--accent-dark:#222e18;--danger:#f6afa0;--radius:14px;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-synthesis:none}
+*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text)}button,input,select,textarea{font:inherit}button,select{border:1px solid var(--line);border-radius:7px;background:#282f2a;color:var(--text);padding:9px 12px;cursor:pointer;font-size:12px;font-weight:600;min-height:36px}button:hover:not(:disabled){background:#344035;border-color:#70836d}button:disabled{opacity:.4;cursor:not-allowed}button:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px}button.primary{background:var(--accent);color:var(--accent-dark);border-color:var(--accent);padding:11px 19px;font-size:14px;display:flex;gap:28px;justify-content:space-between;align-items:center}button.primary:hover:not(:disabled){background:#d8f7ad}button.secondary{background:#c9f08b16;border-color:#637545;color:#d7f0b5}button.quiet{background:transparent;border-color:transparent;color:var(--muted);padding:6px 8px;min-height:30px}button.quiet:hover{color:var(--text)}.masthead{height:76px;padding:0 4.5%;display:flex;align-items:center;gap:28px;border-bottom:1px solid var(--line)}.wordmark{font-size:23px;font-weight:750;letter-spacing:-1px;color:var(--accent);text-decoration:none}.wordmark span{font-size:13px;color:var(--muted);letter-spacing:0;font-weight:400}.experiment{font-size:11px;color:var(--faint);border-left:1px solid var(--line);padding-left:24px}.connection{margin-left:auto;display:flex;gap:9px;align-items:center;font-size:11px;color:var(--muted)}.dot{width:6px;height:6px;border-radius:100%;background:#aa8e5c}.dot.ready{background:var(--accent)}.dot.failed{background:var(--danger)}main{max-width:1540px;margin:auto;padding:38px 4.5% 0}.intro{display:flex;justify-content:space-between;align-items:flex-end;gap:24px;margin:0 0 27px}.eyebrow{font-size:10px;letter-spacing:1.4px;font-weight:600;color:var(--accent);margin:0 0 10px}h1{font-size:clamp(23px,2.5vw,35px);letter-spacing:-1px;margin:0;font-weight:600}.intro>p{font-size:12px;line-height:1.9;color:var(--muted);margin:0 0 2px}.intro strong{color:var(--text);font-weight:500}.workspace{display:grid;grid-template-columns:minmax(340px,1fr) minmax(380px,1.05fr);gap:22px;align-items:start}.panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}.panel-heading{height:56px;padding:0 20px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-bottom:1px solid var(--line)}h2{margin:0;font-size:13px;font-weight:650;display:flex;gap:10px;align-items:center}.section-index{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px;font-weight:400;color:var(--faint)}.badge{font-size:10px;border:1px solid var(--line);border-radius:4px;padding:4px 7px;white-space:nowrap;color:var(--muted)}.badge.dirty{color:var(--accent);border-color:#5f7247}.file-tools{display:flex;align-items:center;gap:3px;padding:8px 10px;border-bottom:1px solid var(--line);flex-wrap:wrap}.code-editor{height:514px;display:flex;overflow:hidden;background:var(--field);position:relative}.line-numbers{padding:20px 10px 20px 16px;min-width:45px;text-align:right;color:#667567;user-select:none;white-space:pre;font:12px/1.85 ui-monospace,SFMono-Regular,Consolas,monospace;overflow:hidden;flex-shrink:0;border-right:1px solid #2a322b}textarea#source{border:0;border-radius:0;outline:0;resize:none;padding:20px 16px;display:block;width:100%;height:100%;color:#d4e3ca;background:transparent;white-space:pre;overflow:auto;tab-size:2;font:12px/1.85 ui-monospace,SFMono-Regular,Consolas,monospace;caret-color:var(--accent)}textarea#source:focus{background:#171e19}textarea#source::placeholder{color:#7c8b7d;font:12px/1.9 ui-sans-serif,system-ui,sans-serif;white-space:pre-wrap}textarea#source[aria-invalid=true]{box-shadow:inset 3px 0 0 var(--danger)}.editor-footer{padding:8px 15px;font-size:10px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--faint);display:flex;justify-content:space-between;border-top:1px solid var(--line)}.apply-row{display:flex;gap:18px;align-items:center;padding:16px 18px 12px}.apply-row p{font-size:10px;color:var(--muted);line-height:1.7;margin:0;max-width:185px}.diagnostic{margin:0 18px 17px;font-size:11px;line-height:1.8;min-height:22px;color:var(--muted);display:flex;align-items:baseline;gap:6px;flex-wrap:wrap}.diagnostic.error{color:var(--danger)}.diagnostic.success{color:#c7dea9}.right-column{display:grid;gap:18px}.stage-wrap{position:relative;background-color:#121613;background-image:linear-gradient(#a6c39907 1px,transparent 1px),linear-gradient(90deg,#a6c39907 1px,transparent 1px);background-size:24px 24px}#scene{width:100%;height:auto;aspect-ratio:640/360;display:block;overflow:hidden}.empty-scene{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none;gap:9px;color:var(--faint);padding:24px;text-align:center}.empty-scene[hidden]{display:none}.empty-scene strong{font-size:13px;color:var(--muted);font-weight:400}.empty-scene>span:not(.empty-cross){font-size:11px}.empty-cross{font-size:31px;color:#52614d;font-weight:200;margin-bottom:7px}.stage-dimensions{position:absolute;right:14px;bottom:10px;font-size:9px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;color:#6d7f6d;pointer-events:none}.transport{display:flex;gap:7px;align-items:center;padding:14px 16px 0;flex-wrap:wrap}.transport button{font-size:11px;padding:7px 10px}.transport #clock-status{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px;color:var(--muted);margin-left:auto}.control-note,.keyboard-note{font-size:10px;line-height:1.7;color:var(--faint);margin:10px 18px 15px}.input-heading{display:flex;justify-content:space-between;align-items:center;gap:10px;margin:0 18px;padding-top:13px;border-top:1px solid var(--line)}h3{font-size:11px;font-weight:550;margin:0}.input-heading>span{font-size:9px;color:var(--faint)}.input-controls{display:flex;flex-wrap:wrap;gap:7px;padding:11px 17px 0;min-height:39px}.input-button{display:flex;align-items:center;gap:14px;touch-action:none;user-select:none;-webkit-user-select:none}.input-button kbd{font:9px ui-monospace,SFMono-Regular,Consolas,monospace;border:1px solid #5a6957;border-radius:3px;padding:3px 4px;color:var(--muted);min-width:22px;text-align:center}.input-button.held{background:var(--accent);border-color:var(--accent);color:var(--accent-dark)}.input-button.held kbd{color:var(--accent-dark);border-color:#829a62}.empty-note{font-size:11px;line-height:1.7;color:var(--faint);margin:0}.keyboard-note{margin-bottom:13px;font-size:9px}.state-values{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));padding:15px 19px;gap:13px;max-height:210px;overflow:auto}.state-value{display:flex;flex-direction:column;gap:6px;min-width:0}.state-value dt{font:10px ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--muted);overflow-wrap:anywhere}.state-value dd{font:17px ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--accent);margin:0;overflow-wrap:anywhere}.state-value dl{margin:0}.mono{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px}.subdued{color:var(--muted);font-size:10px}.event-line{padding:0 19px 13px;display:flex;gap:16px;align-items:baseline;flex-wrap:wrap}.event-line #last-event{color:var(--muted);font-size:10px;overflow-wrap:anywhere}.runtime-error{margin:0 18px 14px;padding:10px;border:1px solid #705049;border-radius:5px;background:#432c272e;color:var(--danger);font-size:11px;line-height:1.6}.cost-details{border-top:1px solid var(--line);padding:12px 19px;font-size:10px;color:var(--muted)}summary{cursor:pointer;font-size:11px;color:var(--muted)}.cost-details p{font-size:10px;line-height:1.8;color:var(--faint)}.cost-values{display:grid;grid-template-columns:1fr auto;gap:8px 20px;margin:16px 0}.cost-values dt,.cost-values dd{margin:0}.cost-values dd{text-align:right;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--text)}.reference{margin-top:25px}.reference-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));padding:22px 20px;gap:24px}.reference-grid article{min-width:0;display:flex;flex-direction:column;align-items:flex-start}.reference-grid h3{font-size:12px;color:#d2ddc8}.reference-grid p{font-size:10px;line-height:1.8;color:var(--muted);min-height:36px;margin:8px 0 10px}.reference-grid pre{font:10px/1.85 ui-monospace,SFMono-Regular,Consolas,monospace;background:#151b16;color:#bacead;width:100%;padding:10px;overflow:auto;border-radius:6px;min-height:115px;margin:0 0 8px}.reference-grid .quiet{font-size:10px;padding:5px 0}.syntax-details{padding:16px 20px;border-top:1px solid var(--line)}.syntax-columns{display:grid;grid-template-columns:1fr 1fr;gap:35px;margin-top:12px}.syntax-columns p{font-size:11px;line-height:1.95;color:var(--muted)}.syntax-columns strong{color:var(--text);font-weight:500}.syntax-columns code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px;color:#d0dcbe;overflow-wrap:anywhere}.example-row{padding:18px 20px;border-top:1px solid var(--line);display:flex;align-items:center;gap:10px}.example-row>div{margin-right:auto}.example-row label{font-size:11px;color:var(--text)}.example-row p{font-size:10px;color:var(--faint);margin:5px 0 0}.example-row button,.example-row select{font-size:10px}.page-footer{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:26px 0 30px;color:var(--faint);font-size:10px}.page-footer>span{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:9px}.page-footer p{margin:0;line-height:1.8}.page-footer strong{font-weight:400;color:var(--muted)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}[hidden]{display:none!important}
+.baseline-row{border-top:1px solid var(--line);padding:15px 20px;display:flex;flex-wrap:wrap;gap:8px 16px;align-items:center}.baseline-row a{font-size:11px;color:var(--accent);text-underline-offset:3px}.baseline-row span{font-size:10px;color:var(--faint)}#scene:focus-visible{outline:2px solid var(--accent);outline-offset:-3px}
+@media(min-width:1450px){.code-editor{height:558px}.reference-grid pre{font-size:11px}}
+@media(max-width:1050px){.masthead{padding:0 25px}.experiment{display:none}main{padding:29px 25px 0}.workspace{grid-template-columns:minmax(300px,1fr) minmax(340px,1fr);gap:15px}.intro>p{font-size:11px;max-width:290px}.reference-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:25px}.reference-grid p{min-height:0}.reference-grid pre{min-height:110px;font-size:11px}.transport #clock-status{width:100%;margin:3px 0 0}.code-editor{height:533px}.panel-heading{padding:0 16px}.apply-row{gap:10px;padding-left:14px;padding-right:14px}.apply-row p{font-size:9px}.input-heading{align-items:flex-start;flex-direction:column;gap:5px}.intro{gap:20px}button.primary{gap:14px;padding:11px 13px}}
+@media(max-width:760px){.masthead{height:63px;padding:0 20px}.wordmark{font-size:21px}.wordmark span{font-size:11px}.connection{font-size:10px}main{padding:25px 18px 0}.intro{display:block;margin-bottom:22px}.intro h1{font-size:28px}.intro>p{margin-top:14px;max-width:none;font-size:11px}.workspace{grid-template-columns:minmax(0,1fr);gap:17px}.code-editor{height:340px}.editor-panel{width:100%}.transport #clock-status{width:auto;margin-left:auto}.right-column{gap:17px}.input-heading{flex-direction:row;align-items:center}.reference{margin-top:20px}.reference-grid{padding:20px 16px;gap:24px 17px}.reference-grid pre{font-size:10px}.reference>.panel-heading{height:auto;min-height:57px;padding-top:12px;padding-bottom:12px;align-items:flex-start;flex-direction:column;gap:6px}.syntax-columns{grid-template-columns:1fr;gap:0}.example-row{flex-wrap:wrap;padding:17px 16px}.example-row>div{width:100%;margin-bottom:4px}.example-row select{flex:1}.page-footer{align-items:flex-start;flex-direction:column;gap:10px;padding:23px 0}.apply-row{padding:14px 16px 10px;gap:17px}.apply-row p{font-size:10px;max-width:none}.diagnostic{margin-left:16px}.reference-grid article{overflow:hidden}}
+@media(max-width:420px){main{padding-left:13px;padding-right:13px}.masthead{padding:0 15px}.wordmark span{font-size:10px}.connection{font-size:9px;gap:6px}.intro h1{font-size:25px}.transport{gap:5px;padding-left:12px;padding-right:12px}.transport button{font-size:10px;padding:7px 8px}.transport #clock-status{font-size:9px}.input-heading>span{font-size:8px}.reference-grid{grid-template-columns:minmax(0,1fr)}.reference-grid pre{min-height:0;font-size:11px}.reference-grid p{margin-bottom:8px}.file-tools{gap:0;padding:7px 5px}.file-tools .quiet{font-size:10px;padding:6px 7px}.apply-row p{font-size:9px}.line-numbers{padding-left:10px;min-width:36px}textarea#source{padding-left:12px;font-size:11px}.line-numbers{font-size:11px}}
+@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
diff --git a/prototypes/workbench/results/2026-09-23-refresh/browser-authoring.json b/prototypes/workbench/results/2026-09-23-refresh/browser-authoring.json
new file mode 100644
index 0000000..893db32
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-refresh/browser-authoring.json
@@ -0,0 +1,131 @@
+{
+ "recordedUtc": "2026-09-23T05:54:43.141Z",
+ "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36",
+ "checks": [
+ "initial editor has literally empty source",
+ "initial scene has no built-in entities, states, inputs, or running clock",
+ "controls require an authored program",
+ "authoring a new program is a draft until Apply",
+ "new custom program constructs states and multiple views from source",
+ "input buttons are generated from the authored declarations",
+ "view bindings render the authored state value",
+ "Space and arrows in the editor never reach authored inputs",
+ "pointer down dispatches once while the clock is paused",
+ "pointer up releases without double dispatch from click",
+ "keyboard repeat is ignored",
+ "keyboard activation on a generated button is one complete tap",
+ "assistive click activation is one complete tap",
+ "keyboard and pointer sources share one logical input",
+ "releasing one source does not release the other source",
+ "last source releases the logical input",
+ "authored down/frame behavior uses the declared dt without built-in motion",
+ "keyup releases even after focus moves into the editor",
+ "focus loss sends release and pauses the clock",
+ "Play advances and Pause freezes the clock",
+ "invalid code preserves the previous executable program and states",
+ "compile error exposes a source-line diagnostic",
+ "diagnostic location moves the editor caret to the problem",
+ "apply preserves matching state names and initializes newly authored states",
+ "new input, entity, binding, and behavior work with source changes alone",
+ "changed behavior takes effect without resetting existing values",
+ "explicit Reset restores active initializers and keeps the draft",
+ "Apply is refused while an input is held",
+ "clear editor is explicit and does not clear the running scene before Apply",
+ "downloaded project restores the exact un-applied draft without execution",
+ "invalid JSON project schema cannot replace the draft",
+ "failed assistive down event rolls back and its error survives the following successful up",
+ "latched runtime failure is visible and labeled separately from the latest input",
+ "explicit Reset clears the failure record",
+ "applying empty source removes all authored states, inputs, and scene contents",
+ "explicit example loading only fills the editor and never auto-applies",
+ "authored scene executes in the actual fixed 64 KiB Wasm memory",
+ "cost inspector shows measured Wasm memory without claiming browser heap totals",
+ "390px mobile viewport has no horizontal overflow",
+ "generated mobile input buttons remain usable",
+ "ordinary-JavaScript comparison and equivalent Pure example are linked and served",
+ "missing Wasm shows an actionable load failure and disables execution controls",
+ "fresh load always returns to a blank authoring environment",
+ "no uncaught page errors during the browser scenarios"
+ ],
+ "authoredSource": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "authoredSnapshot": {
+ "states": {
+ "x": 114,
+ "moving": 0,
+ "count": 2
+ },
+ "views": [
+ {
+ "id": "marker",
+ "type": "box",
+ "color": "#7ed9ae",
+ "x": 114,
+ "y": 170,
+ "width": 48,
+ "height": 48
+ },
+ {
+ "id": "reading",
+ "type": "text",
+ "prefix": "Signals: ",
+ "x": 40,
+ "y": 70,
+ "value": 2
+ }
+ ],
+ "tick": 12,
+ "revision": 5,
+ "displayError": null,
+ "lastError": 0,
+ "errorMessage": "",
+ "errorLine": null,
+ "lastOps": 2,
+ "successfulEvents": 18,
+ "held": [],
+ "lastEvent": {
+ "name": "advance",
+ "phase": "up",
+ "source": "keyboard:ArrowRight",
+ "status": 0,
+ "line": null,
+ "ops": 2
+ },
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19128,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 464,
+ "activeStateBytes": 24,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ },
+ "ready": true,
+ "source": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "appliedSource": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "dirty": false,
+ "running": false,
+ "droppedTicks": 0,
+ "diagnostic": "코드를 적용했습니다. 같은 이름의 상태는 유지됩니다."
+ },
+ "mobile": {
+ "viewport": 390,
+ "content": 390
+ },
+ "downloaded": {
+ "filename": "my-work.pure.json",
+ "path": "output/playwright/pure-refresh-workbench-draft.json",
+ "expectedSource": "# A new authored interaction, not a bundled example.\nstate x = 500\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 5 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\nstate bonus = 7\ninput reverse = KeyA\ncircle beacon(300, 190, 15 + bonus, \"#c9f08b\")\non reverse.down { bonus = bonus + 2 }\n# This un-applied draft must survive Reset.\n"
+ },
+ "errors": [],
+ "screenshots": [
+ "output/playwright/pure-refresh-workbench-blank.png",
+ "output/playwright/pure-refresh-workbench-authored.png",
+ "output/playwright/pure-refresh-workbench-mobile.png"
+ ],
+ "qaSourceSha256": "0d4c155b3575481c60f175b71acdc332f1c0d8489297794deca6f55999ead1b8",
+ "executedScriptSha256": "30103b714560a01bc82e3c1698efd77e2e5424d60973ba0a7096de2ff3b2c821",
+ "scriptChange": "Artifact filename prefix changed from pure-workbench- to pure-refresh-workbench- only."
+}
diff --git a/prototypes/workbench/results/2026-09-23-refresh/browser-limit.json b/prototypes/workbench/results/2026-09-23-refresh/browser-limit.json
new file mode 100644
index 0000000..97ce00b
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-refresh/browser-limit.json
@@ -0,0 +1,242 @@
+{
+ "utc": "2026-09-23T05:56:55.778Z",
+ "checks": [
+ "authored browser input commits fractional state",
+ "bounded evaluation renders the exact authored expression",
+ "code replacement preserves state and changes binding result",
+ "browser Wasm reuses completed bindings",
+ "browser runs the shared-workspace build with only 56 additional dedicated bytes",
+ "discarding records preserves the browser snapshot",
+ "browser API publishes no partial scene after budget exhaustion",
+ "raising the budget recovers the browser snapshot",
+ "configured UI keeps committed state while withholding incomplete display",
+ "UI distinguishes display limit from event rollback",
+ "normal configuration reloads to a clean blank work",
+ "no uncaught browser errors"
+ ],
+ "api": {
+ "before": {
+ "states": {
+ "x": 3
+ },
+ "views": [
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "",
+ "x": 40,
+ "y": 60,
+ "value": 10
+ }
+ ],
+ "tick": 0,
+ "revision": 1,
+ "displayError": null,
+ "lastError": 0,
+ "errorMessage": "",
+ "errorLine": null,
+ "lastOps": 0,
+ "successfulEvents": 0,
+ "held": [],
+ "lastEvent": null,
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19128,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 252,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ }
+ },
+ "warm": {
+ "cacheEntries": 32,
+ "historyEntries": 8,
+ "workLimit": 2048,
+ "work": 6,
+ "instructions": 0,
+ "cacheHits": 3,
+ "reservedBytes": 1200,
+ "dedicatedBytes": 48,
+ "sharedBytes": 1152,
+ "reuseAvailable": true,
+ "workspaceTagBytes": 8
+ },
+ "forgotten": {
+ "states": {
+ "x": 3
+ },
+ "views": [
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "",
+ "x": 40,
+ "y": 60,
+ "value": 10
+ }
+ ],
+ "tick": 0,
+ "revision": 1,
+ "displayError": null,
+ "lastError": 0,
+ "errorMessage": "",
+ "errorLine": null,
+ "lastOps": 0,
+ "successfulEvents": 0,
+ "held": [],
+ "lastEvent": null,
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19128,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 252,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ }
+ },
+ "limited": {
+ "states": {
+ "x": 3
+ },
+ "views": [],
+ "tick": 0,
+ "revision": 1,
+ "displayError": {
+ "code": 101,
+ "line": 3,
+ "message": "표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요."
+ },
+ "lastError": 101,
+ "errorMessage": "표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.",
+ "errorLine": 3,
+ "lastOps": 0,
+ "successfulEvents": 0,
+ "held": [],
+ "lastEvent": null,
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19128,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 252,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ }
+ },
+ "limitStats": {
+ "cacheEntries": 32,
+ "historyEntries": 8,
+ "workLimit": 1,
+ "work": 1,
+ "instructions": 0,
+ "cacheHits": 0,
+ "reservedBytes": 1200,
+ "dedicatedBytes": 48,
+ "sharedBytes": 1152,
+ "reuseAvailable": true,
+ "workspaceTagBytes": 8
+ },
+ "recovered": {
+ "states": {
+ "x": 3
+ },
+ "views": [
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "",
+ "x": 40,
+ "y": 60,
+ "value": 10
+ }
+ ],
+ "tick": 0,
+ "revision": 1,
+ "displayError": null,
+ "lastError": 0,
+ "errorMessage": "",
+ "errorLine": null,
+ "lastOps": 0,
+ "successfulEvents": 0,
+ "held": [],
+ "lastEvent": null,
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19128,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 252,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ }
+ }
+ },
+ "limitedUi": {
+ "states": {
+ "x": 3.25
+ },
+ "views": [],
+ "tick": 0,
+ "revision": 1,
+ "displayError": {
+ "code": 101,
+ "line": 3,
+ "message": "표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요."
+ },
+ "lastError": 101,
+ "errorMessage": "표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.",
+ "errorLine": 3,
+ "lastOps": 0,
+ "successfulEvents": 2,
+ "held": [],
+ "lastEvent": {
+ "name": "next",
+ "phase": "up",
+ "source": "pointer:1",
+ "status": 0,
+ "line": null,
+ "ops": 0
+ },
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19128,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 252,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ },
+ "ready": true,
+ "source": "state x = 3\ninput next = KeyN\ntext label(40, 60, (x + 1) * (x + 2) / 2)\non next.down { x = x + 0.25 }\n",
+ "appliedSource": "state x = 3\ninput next = KeyN\ntext label(40, 60, (x + 1) * (x + 2) / 2)\non next.down { x = x + 0.25 }\n",
+ "dirty": false,
+ "running": false,
+ "droppedTicks": 0,
+ "diagnostic": "코드를 적용했습니다. 같은 이름의 상태는 유지됩니다."
+ },
+ "message": "표시 오류 · 3행 · 표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요. — 이번 장면을 표시하지 않았습니다. 상태 변경은 유지됩니다.",
+ "errors": [],
+ "uiLimitSetup": "Test-only module response overrides default workLimit to 1; production files are unchanged.",
+ "screenshot": "output/playwright/pure-refresh-limit.png",
+ "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36",
+ "qaSourceSha256": "d83577c0851b465c3bd43c256b53659aef9df96f3c3dffe7e59f5d7c98db80c6",
+ "executedScriptSha256": "58addd30823934f2e10aab96abcc1d8291897a8f92401fd606b908c76fe6cb76",
+ "scriptChange": "Screenshot changed from pure-workspace-limit.png to pure-refresh-limit.png only."
+}
diff --git a/prototypes/workbench/results/2026-09-23-refresh/browser.json b/prototypes/workbench/results/2026-09-23-refresh/browser.json
new file mode 100644
index 0000000..fe41194
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-refresh/browser.json
@@ -0,0 +1,1487 @@
+{
+ "utc": "2026-09-23T05:53:50.616Z",
+ "checks": [
+ "six runs preserve exact authored states, outputs, event counts and rendered position",
+ "archived path reproduces unrelated editor work on every event",
+ "runtime input performs no source encoding or editor metadata mutations",
+ "Unicode source editing updates byte count, line numbers and dirty status",
+ "keyboard input and frame stepping preserve unapplied editor metadata",
+ "reset preserves draft and its metadata while restoring active state",
+ "successful apply updates draft status without requiring a source edit",
+ "failed apply preserves dirty status and active work with a source diagnostic",
+ "diagnostic navigation still updates the cursor position",
+ "programmatic Tab insertion updates source metadata",
+ "applying an empty program has a correct applied status",
+ "snippet insertion updates source metadata",
+ "no uncaught browser errors"
+ ],
+ "errors": [],
+ "samples": [
+ {
+ "variant": "before",
+ "taps": 100,
+ "events": 200,
+ "elapsedMs": 1151.8000000044703,
+ "encodingCalls": 400,
+ "encodedBytes": 410800,
+ "editorMutations": 800,
+ "metrics": {
+ "LayoutCount": 201,
+ "LayoutDuration": 0.547966,
+ "RecalcStyleCount": 201,
+ "RecalcStyleDuration": 0.116558,
+ "ScriptDuration": 0.462013
+ },
+ "final": {
+ "states": {
+ "x": 70
+ },
+ "views": [
+ {
+ "id": "moving",
+ "type": "box",
+ "color": "#7ed9ae",
+ "x": 70,
+ "y": 100,
+ "width": 30,
+ "height": 30
+ },
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "",
+ "x": 20,
+ "y": 40,
+ "value": 70
+ },
+ {
+ "id": "static0",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static1",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static2",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static3",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static4",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static5",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static6",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static7",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static8",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static9",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static10",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static11",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static12",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static13",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static14",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static15",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static16",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static17",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static18",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static19",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static20",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static21",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 290,
+ "width": 20,
+ "height": 20
+ }
+ ],
+ "successfulEvents": 240,
+ "tick": 0,
+ "lastError": 0
+ },
+ "renderedX": "70"
+ },
+ {
+ "variant": "after",
+ "taps": 100,
+ "events": 200,
+ "elapsedMs": 327.40000000596046,
+ "encodingCalls": 0,
+ "encodedBytes": 0,
+ "editorMutations": 0,
+ "metrics": {
+ "LayoutCount": 1,
+ "LayoutDuration": 0.0006349999999999967,
+ "RecalcStyleCount": 1,
+ "RecalcStyleDuration": 0.002024999999999999,
+ "ScriptDuration": 0.311722
+ },
+ "final": {
+ "states": {
+ "x": 70
+ },
+ "views": [
+ {
+ "id": "moving",
+ "type": "box",
+ "color": "#7ed9ae",
+ "x": 70,
+ "y": 100,
+ "width": 30,
+ "height": 30
+ },
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "",
+ "x": 20,
+ "y": 40,
+ "value": 70
+ },
+ {
+ "id": "static0",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static1",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static2",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static3",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static4",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static5",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static6",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static7",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static8",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static9",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static10",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static11",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static12",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static13",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static14",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static15",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static16",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static17",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static18",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static19",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static20",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static21",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 290,
+ "width": 20,
+ "height": 20
+ }
+ ],
+ "successfulEvents": 240,
+ "tick": 0,
+ "lastError": 0
+ },
+ "renderedX": "70"
+ },
+ {
+ "variant": "after",
+ "taps": 100,
+ "events": 200,
+ "elapsedMs": 137.80000000447035,
+ "encodingCalls": 0,
+ "encodedBytes": 0,
+ "editorMutations": 0,
+ "metrics": {
+ "LayoutCount": 1,
+ "LayoutDuration": 0.0016250000000000014,
+ "RecalcStyleCount": 1,
+ "RecalcStyleDuration": 0.0002489999999999992,
+ "ScriptDuration": 0.129971
+ },
+ "final": {
+ "states": {
+ "x": 70
+ },
+ "views": [
+ {
+ "id": "moving",
+ "type": "box",
+ "color": "#7ed9ae",
+ "x": 70,
+ "y": 100,
+ "width": 30,
+ "height": 30
+ },
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "",
+ "x": 20,
+ "y": 40,
+ "value": 70
+ },
+ {
+ "id": "static0",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static1",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static2",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static3",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static4",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static5",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static6",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static7",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static8",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static9",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static10",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static11",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static12",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static13",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static14",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static15",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static16",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static17",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static18",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static19",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static20",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static21",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 290,
+ "width": 20,
+ "height": 20
+ }
+ ],
+ "successfulEvents": 240,
+ "tick": 0,
+ "lastError": 0
+ },
+ "renderedX": "70"
+ },
+ {
+ "variant": "before",
+ "taps": 100,
+ "events": 200,
+ "elapsedMs": 709.6999999955297,
+ "encodingCalls": 400,
+ "encodedBytes": 410800,
+ "editorMutations": 800,
+ "metrics": {
+ "LayoutCount": 201,
+ "LayoutDuration": 0.343894,
+ "RecalcStyleCount": 201,
+ "RecalcStyleDuration": 0.07382699999999999,
+ "ScriptDuration": 0.27855
+ },
+ "final": {
+ "states": {
+ "x": 70
+ },
+ "views": [
+ {
+ "id": "moving",
+ "type": "box",
+ "color": "#7ed9ae",
+ "x": 70,
+ "y": 100,
+ "width": 30,
+ "height": 30
+ },
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "",
+ "x": 20,
+ "y": 40,
+ "value": 70
+ },
+ {
+ "id": "static0",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static1",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static2",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static3",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static4",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static5",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static6",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static7",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static8",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static9",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static10",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static11",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static12",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static13",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static14",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static15",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static16",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static17",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static18",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static19",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static20",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static21",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 290,
+ "width": 20,
+ "height": 20
+ }
+ ],
+ "successfulEvents": 240,
+ "tick": 0,
+ "lastError": 0
+ },
+ "renderedX": "70"
+ },
+ {
+ "variant": "before",
+ "taps": 100,
+ "events": 200,
+ "elapsedMs": 837.8999999985099,
+ "encodingCalls": 400,
+ "encodedBytes": 410800,
+ "editorMutations": 800,
+ "metrics": {
+ "LayoutCount": 201,
+ "LayoutDuration": 0.387684,
+ "RecalcStyleCount": 201,
+ "RecalcStyleDuration": 0.08588600000000002,
+ "ScriptDuration": 0.351059
+ },
+ "final": {
+ "states": {
+ "x": 70
+ },
+ "views": [
+ {
+ "id": "moving",
+ "type": "box",
+ "color": "#7ed9ae",
+ "x": 70,
+ "y": 100,
+ "width": 30,
+ "height": 30
+ },
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "",
+ "x": 20,
+ "y": 40,
+ "value": 70
+ },
+ {
+ "id": "static0",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static1",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static2",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static3",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static4",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static5",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static6",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static7",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static8",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static9",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static10",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static11",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static12",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static13",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static14",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static15",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static16",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static17",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static18",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static19",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static20",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static21",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 290,
+ "width": 20,
+ "height": 20
+ }
+ ],
+ "successfulEvents": 240,
+ "tick": 0,
+ "lastError": 0
+ },
+ "renderedX": "70"
+ },
+ {
+ "variant": "after",
+ "taps": 100,
+ "events": 200,
+ "elapsedMs": 152.80000000447035,
+ "encodingCalls": 0,
+ "encodedBytes": 0,
+ "editorMutations": 0,
+ "metrics": {
+ "LayoutCount": 1,
+ "LayoutDuration": 0.0011510000000000062,
+ "RecalcStyleCount": 1,
+ "RecalcStyleDuration": 0.00024500000000000216,
+ "ScriptDuration": 0.142424
+ },
+ "final": {
+ "states": {
+ "x": 70
+ },
+ "views": [
+ {
+ "id": "moving",
+ "type": "box",
+ "color": "#7ed9ae",
+ "x": 70,
+ "y": 100,
+ "width": 30,
+ "height": 30
+ },
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "",
+ "x": 20,
+ "y": 40,
+ "value": 70
+ },
+ {
+ "id": "static0",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static1",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static2",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static3",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static4",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static5",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static6",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 200,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static7",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static8",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static9",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static10",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static11",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static12",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static13",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 230,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static14",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static15",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 90,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static16",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 160,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static17",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 230,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static18",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 300,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static19",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 370,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static20",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 440,
+ "y": 260,
+ "width": 20,
+ "height": 20
+ },
+ {
+ "id": "static21",
+ "type": "box",
+ "color": "#8da5bb",
+ "x": 20,
+ "y": 290,
+ "width": 20,
+ "height": 20
+ }
+ ],
+ "successfulEvents": 240,
+ "tick": 0,
+ "lastError": 0
+ },
+ "renderedX": "70"
+ }
+ ],
+ "source": "state x = 40\ninput move = KeyM\nbox moving(x, 100, 30, 30, \"#7ed9ae\")\ntext label(20, 40, x)\nbox static0(20, 200, 20, 20, \"#8da5bb\")\nbox static1(90, 200, 20, 20, \"#8da5bb\")\nbox static2(160, 200, 20, 20, \"#8da5bb\")\nbox static3(230, 200, 20, 20, \"#8da5bb\")\nbox static4(300, 200, 20, 20, \"#8da5bb\")\nbox static5(370, 200, 20, 20, \"#8da5bb\")\nbox static6(440, 200, 20, 20, \"#8da5bb\")\nbox static7(20, 230, 20, 20, \"#8da5bb\")\nbox static8(90, 230, 20, 20, \"#8da5bb\")\nbox static9(160, 230, 20, 20, \"#8da5bb\")\nbox static10(230, 230, 20, 20, \"#8da5bb\")\nbox static11(300, 230, 20, 20, \"#8da5bb\")\nbox static12(370, 230, 20, 20, \"#8da5bb\")\nbox static13(440, 230, 20, 20, \"#8da5bb\")\nbox static14(20, 260, 20, 20, \"#8da5bb\")\nbox static15(90, 260, 20, 20, \"#8da5bb\")\nbox static16(160, 260, 20, 20, \"#8da5bb\")\nbox static17(230, 260, 20, 20, \"#8da5bb\")\nbox static18(300, 260, 20, 20, \"#8da5bb\")\nbox static19(370, 260, 20, 20, \"#8da5bb\")\nbox static20(440, 260, 20, 20, \"#8da5bb\")\nbox static21(20, 290, 20, 20, \"#8da5bb\")\non move.down { x = x + 0.25 }",
+ "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36",
+ "scope": "100 synchronous synthetic keyboard taps after 20 warm-up taps. Timings include encoding/mutation instrumentation, exclude actual input arrival and presentation latency. CDP metrics may include browser work around the batch.",
+ "setup": "Only app.js is substituted from the verified archive for before runs. Other production modules are identical. No phase-timing wrappers in this comparison.",
+ "sourceSha256": {
+ "web/runtime.mjs": "d490c54fb2b422dfe89b90a594a8da9ebc95bb007910211cb4686ccb83ed75c7",
+ "web/app.js": "f91c076ff5242b10b390b57b4213d09b1ae74e61ea221845603428ecec5ea3f9",
+ "web/output.mjs": "1d22c44955e812d671f1a3c3ae4b2f0642a05ebe614017bb041bdddc8c363be3",
+ "web/core.wasm": "76823b024fa5f4a79cbf1ed722ab757614a81a9f1c81d1d6268781a38bed7cc9",
+ "web/compiler.mjs": "a34f3beb069c811a9bd5be73dde9341907ba77756066030b8b95fc6d66a5cc02",
+ "qa-refresh.js": "afa6ac62f45a1d8e25a8667ecb23607c457964c410331587bbd2a2fe3bf5baaa",
+ "qa-refresh.py": "01da7aaba6148d0ad96d6e9686300674e6ba8779113aaf7928aa3e7ad1348960"
+ },
+ "baselineManifestSha256": "bbf999305e08d48e94419491c20bd0efe95b36b337b91b2f2eba0f36065d0f91"
+}
diff --git a/prototypes/workbench/results/2026-09-23-refresh/profile-before.json b/prototypes/workbench/results/2026-09-23-refresh/profile-before.json
new file mode 100644
index 0000000..d36bfbe
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-refresh/profile-before.json
@@ -0,0 +1,34 @@
+{
+ "utc": "2026-09-23T05:49:37.571Z",
+ "taps": 100,
+ "elapsedMs": 375.8999999985099,
+ "phase": {
+ "present": {
+ "calls": 200,
+ "ms": 26.600000098347664
+ },
+ "costs": {
+ "calls": 200,
+ "ms": 56.59999991953373
+ },
+ "draft": {
+ "calls": 200,
+ "ms": 259.1000000089407
+ },
+ "refresh": {
+ "calls": 200,
+ "ms": 359.6000000536442
+ }
+ },
+ "metrics": {
+ "LayoutCount": 201,
+ "LayoutDuration": 0.177734,
+ "RecalcStyleCount": 201,
+ "RecalcStyleDuration": 0.03897,
+ "ScriptDuration": 0.15203
+ },
+ "source": "state x = 40\ninput move = KeyM\nbox moving(x, 100, 30, 30, \"#7ed9ae\")\ntext label(20, 40, x)\nbox static0(20, 200, 20, 20, \"#8da5bb\")\nbox static1(90, 200, 20, 20, \"#8da5bb\")\nbox static2(160, 200, 20, 20, \"#8da5bb\")\nbox static3(230, 200, 20, 20, \"#8da5bb\")\nbox static4(300, 200, 20, 20, \"#8da5bb\")\nbox static5(370, 200, 20, 20, \"#8da5bb\")\nbox static6(440, 200, 20, 20, \"#8da5bb\")\nbox static7(20, 230, 20, 20, \"#8da5bb\")\nbox static8(90, 230, 20, 20, \"#8da5bb\")\nbox static9(160, 230, 20, 20, \"#8da5bb\")\nbox static10(230, 230, 20, 20, \"#8da5bb\")\nbox static11(300, 230, 20, 20, \"#8da5bb\")\nbox static12(370, 230, 20, 20, \"#8da5bb\")\nbox static13(440, 230, 20, 20, \"#8da5bb\")\nbox static14(20, 260, 20, 20, \"#8da5bb\")\nbox static15(90, 260, 20, 20, \"#8da5bb\")\nbox static16(160, 260, 20, 20, \"#8da5bb\")\nbox static17(230, 260, 20, 20, \"#8da5bb\")\nbox static18(300, 260, 20, 20, \"#8da5bb\")\nbox static19(370, 260, 20, 20, \"#8da5bb\")\nbox static20(440, 260, 20, 20, \"#8da5bb\")\nbox static21(20, 290, 20, 20, \"#8da5bb\")\non move.down { x = x + 0.25 }",
+ "scope": "Phase timings overlap: draft/present/costs are inside refresh. CDP counters bracket the synchronous batch and may include surrounding browser work.",
+ "profileSourceSha256": "16773f1983261571a32c1e149e557c0fcb2436486437e950dd86fd3239c35d80",
+ "appSourceSha256": "a50e6d99306461f690530eedf5797b2c86c3367a704d24dc5cc86a1c1d2035ea"
+}
diff --git a/prototypes/workbench/results/2026-09-23-refresh/verification.json b/prototypes/workbench/results/2026-09-23-refresh/verification.json
new file mode 100644
index 0000000..5b65f0d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-refresh/verification.json
@@ -0,0 +1,41 @@
+{
+ "utc": "2026-09-23T05:58:28.389318+00:00",
+ "host": {
+ "system": "Linux",
+ "machine": "aarch64",
+ "release": "6.18.39+rpt-rpi-2712"
+ },
+ "browserChecks": 69,
+ "browserEvidenceSha256": {
+ "browser.json": "1ab4ee47a63fca29bf77507053bb672ac708b2dc0df55c1bbae5ac902917655d",
+ "browser-authoring.json": "8f2a7c13334c71b213d06fbfe90f524fb8bbf1857af9fa84c36b7566f995c1d3",
+ "browser-limit.json": "bfa2fb1bd385cdc2aa964ed54d465d69260d9b0a0966f9d0eb12d97cd1256a0b",
+ "profile-before.json": "716b7f1ad32c9f42c5ce3cda2f359eaccb3c81463b942372d20daa83b7616b3b"
+ },
+ "sourceSha256": {
+ "web/app.js": "f91c076ff5242b10b390b57b4213d09b1ae74e61ea221845603428ecec5ea3f9",
+ "web/runtime.mjs": "d490c54fb2b422dfe89b90a594a8da9ebc95bb007910211cb4686ccb83ed75c7",
+ "web/output.mjs": "1d22c44955e812d671f1a3c3ae4b2f0642a05ebe614017bb041bdddc8c363be3",
+ "web/compiler.mjs": "a34f3beb069c811a9bd5be73dde9341907ba77756066030b8b95fc6d66a5cc02",
+ "web/core.wasm": "76823b024fa5f4a79cbf1ed722ab757614a81a9f1c81d1d6268781a38bed7cc9",
+ "core.zig": "dc84bcb75e4417fb1a804529c78ffa8cd1ddd079c00df4ca97a35da0b8fdbb8a",
+ "evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "profile-refresh.js": "16773f1983261571a32c1e149e557c0fcb2436486437e950dd86fd3239c35d80",
+ "qa-refresh.js": "afa6ac62f45a1d8e25a8667ecb23607c457964c410331587bbd2a2fe3bf5baaa",
+ "qa-refresh.py": "01da7aaba6148d0ad96d6e9686300674e6ba8779113aaf7928aa3e7ad1348960"
+ },
+ "unchangedFromBaseline": [
+ "web/runtime.mjs",
+ "web/output.mjs",
+ "web/compiler.mjs",
+ "web/core.wasm",
+ "core.zig",
+ "evaluation.zig"
+ ],
+ "unitTestNote": "No fresh JS/CLI/native unit test run: runtime/compiler/Zig/Wasm unchanged. Browser behavior is covered by 69 checks. Previous 52 JS/CLI/Wasm tests recorded in 2026-09-23-presentation/verification.json.",
+ "syntaxChecks": [
+ "node --check web/app.js",
+ "node --check qa-refresh.js"
+ ],
+ "localLinksChecked": 46
+}
diff --git a/prototypes/workbench/results/2026-09-23-release/README.md b/prototypes/workbench/results/2026-09-23-release/README.md
new file mode 100644
index 0000000..2c04bc6
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-release/README.md
@@ -0,0 +1,19 @@
+# Drag release regression
+
+User reported rollback after mouse release in the Codex browser, with either up
+or cancel displayed. The embedded browser event sequence was not directly observed.
+
+`before-browser.json` reproduces rollback by delivering pointerup on document,
+then releasing SVG capture. This event injection tests a delivery-path hypothesis;
+it is not a recording from the user's browser.
+`after-browser.json` contains 14 release regression checks using real mouse drags
+and explicit release/cancel event injections. The example now commits the last
+rendered position on up; authors still receive the original up coordinates.
+Actual root capture loss, pointercancel and window blur still cancel.
+
+Use `qa-release.js` with Playwright CLI run-code on the local workbench.
+Implementation and test sources are under `after/`. Prior pointer results remain
+unchanged in the sibling 2026-09-23-pointer directory.
+
+`pointer-browser.json`: 27 passing mouse/touch checks, including ignoring mouseup
+while a touch owns a target. Pointer and CLI Node suites: 12 tests passed.
diff --git a/prototypes/workbench/results/2026-09-23-release/after-browser.json b/prototypes/workbench/results/2026-09-23-release/after-browser.json
new file mode 100644
index 0000000..0c7fd1d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-release/after-browser.json
@@ -0,0 +1,62 @@
+{
+ "checks": [
+ {
+ "label": "ordinary in-scene release retains the displayed position",
+ "passed": true
+ },
+ {
+ "label": "ordinary release outside the scene retains the clamped position",
+ "passed": true
+ },
+ {
+ "label": "document-pointerup: release commits once without a coordinate jump or later cancel",
+ "passed": true
+ },
+ {
+ "label": "document-pointerup: duplicate release/capture loss produces no extra authored event",
+ "passed": true
+ },
+ {
+ "label": "child-nonbubbling-up: release commits once without a coordinate jump or later cancel",
+ "passed": true
+ },
+ {
+ "label": "child-nonbubbling-up: duplicate release/capture loss produces no extra authored event",
+ "passed": true
+ },
+ {
+ "label": "mouse-only-up: release commits once without a coordinate jump or later cancel",
+ "passed": true
+ },
+ {
+ "label": "mouse-only-up: duplicate release/capture loss produces no extra authored event",
+ "passed": true
+ },
+ {
+ "label": "stale-scene-up: release commits once without a coordinate jump or later cancel",
+ "passed": true
+ },
+ {
+ "label": "stale-scene-up: duplicate release/capture loss produces no extra authored event",
+ "passed": true
+ },
+ {
+ "label": "descendant capture-loss notification cannot cancel the root owner",
+ "passed": true
+ },
+ {
+ "label": "actual root capture loss while held still cancels",
+ "passed": true
+ },
+ {
+ "label": "cancellation outside the scene still rolls back and ignores the late up",
+ "passed": true
+ },
+ {
+ "label": "focus loss still cancels rather than committing",
+ "passed": true
+ }
+ ],
+ "passed": 14,
+ "failed": []
+}
diff --git a/prototypes/workbench/results/2026-09-23-release/after/cli.test.mjs b/prototypes/workbench/results/2026-09-23-release/after/cli.test.mjs
new file mode 100644
index 0000000..5a51536
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-release/after/cli.test.mjs
@@ -0,0 +1,97 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createRuntime } from './web/runtime.mjs';
+
+const root = fileURLToPath(new URL('.', import.meta.url));
+function run(input, file) {
+ const result = spawnSync(process.execPath, [join(root, 'cli.mjs'), ...(file ? [file] : [])], {
+ input, encoding: 'utf8', cwd: root,
+ });
+ if (result.error) throw result.error;
+ return { ...result, snapshots: result.stdout.split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line)) };
+}
+
+test('terminal starts empty and produces the same charge trace as the shared runtime', async () => {
+ assert.deepEqual(run('show\nquit\n').snapshots[0].states, {});
+ const file = join(root, 'examples/charge.pure');
+ const result = run('down jump\nframe 30\nup jump\nframe 1\nshow\nquit\n', file);
+ assert.equal(result.status, 0, result.stderr);
+ const runtime = await createRuntime(await readFile(join(root, 'web/core.wasm')));
+ runtime.apply(await readFile(file, 'utf8'));
+ runtime.down('jump', 'terminal'); runtime.frame(30); runtime.up('jump', 'terminal'); runtime.frame();
+ // JSON has no undefined-valued optional fields; compare the same wire form.
+ assert.deepEqual(result.snapshots.at(-1), JSON.parse(JSON.stringify(runtime.snapshot())));
+ assert(result.snapshots.at(-1).states.y < 240);
+});
+
+test('invalid terminal commands do not alter state and fail piped execution', () => {
+ const result = run('show\nframe 0\nframe -1\nframe 1.5\nframe 1e2\nframe 601\nshow extra\ndown unknown\nshow\nquit\n', join(root, 'examples/counter.pure'));
+ assert.equal(result.status, 1);
+ assert.deepEqual(result.snapshots[0], result.snapshots[1]);
+ assert.equal(result.stderr.trim().split('\n').length, 7);
+});
+
+test('terminal replays authored pointer movement and cancellation through the shared runtime', async () => {
+ const file = join(root, 'examples/direct.pure');
+ const result = run('point drag down 90 170\npoint drag move 360 220\npoint drag up 410 240\npoint drag down 410 240\npoint drag move 500 260\npoint drag cancel 500 260\nshow\nquit\n', file);
+ assert.equal(result.status, 0, result.stderr);
+ const runtime = await createRuntime(await readFile(join(root, 'web/core.wasm')));
+ runtime.apply(await readFile(file, 'utf8'));
+ for (const [phase, x, y] of [['down',90,170],['move',360,220],['up',410,240],['down',410,240],['move',500,260],['cancel',500,260]])
+ runtime.point('drag', phase, x, y, 'terminal');
+ assert.deepEqual(result.snapshots.at(-1), JSON.parse(JSON.stringify(runtime.snapshot())));
+ assert.equal(result.snapshots.at(-1).states.x, 350);
+ assert.equal(result.snapshots.at(-1).states.y, 210);
+ assert.deepEqual(result.snapshots.at(-1).held, []);
+});
+
+test('file load preserves by name, rejected load preserves active work, and save never overwrites', async t => {
+ const directory = await mkdtemp(join(tmpdir(), 'pure-author-cli-'));
+ t.after(() => rm(directory, { recursive: true, force: true }));
+ const first = join(directory, 'first.pure'), second = join(directory, 'second with space.pure');
+ const invalid = join(directory, 'invalid.pure'), saved = join(directory, 'saved.pure');
+ await writeFile(first, 'state n = 7');
+ await writeFile(second, 'state n = 100\nstate added = 9');
+ await writeFile(invalid, 'state n = broken');
+ const result = run(`load ${second}\nload ${invalid}\nshow\nsave ${saved}\nreset\nsave ${saved}\nquit\n`, first);
+ assert.equal(result.status, 1);
+ assert.deepEqual(result.snapshots[0].states, { n: 7, added: 9 });
+ assert.deepEqual(result.snapshots[1], result.snapshots[0]);
+ assert.deepEqual(result.snapshots.at(-1).states, { n: 100, added: 9 });
+ assert.equal(await readFile(saved, 'utf8'), await readFile(second, 'utf8'));
+ assert.match(result.stderr, /EEXIST/);
+});
+
+test('terminal exposes arithmetic rollback and retains failure through matching release', async t => {
+ const directory = await mkdtemp(join(tmpdir(), 'pure-author-error-'));
+ t.after(() => rm(directory, { recursive: true, force: true }));
+ const file = join(directory, 'error.pure');
+ await writeFile(file, 'state n = 7\ninput tap = Space\non tap.down { n = 1 / 0 }');
+ const result = run('down tap\nup tap\nshow\nreset\nquit\n', file);
+ assert.equal(result.status, 1);
+ for (const snapshot of result.snapshots.slice(0, 3)) {
+ assert.equal(snapshot.states.n, 7); assert.equal(snapshot.lastError, 4);
+ assert.equal(snapshot.errorLine, 3);
+ }
+ assert.equal(result.snapshots[3].lastError, 0);
+});
+
+test('terminal configures and discards evaluation records and recovers from a display limit', () => {
+ const result = run('limits 32 8 2048\nshow\ncosts\nforget all\nlimits 32 8 0\nlimits 32 8 2048\nshow\nquit\n', join(root, 'examples/counter.pure'));
+ assert.equal(result.status, 1); // The explicit zero-budget display failed.
+ assert.equal(result.stderr, '');
+ const [configured, warm, costs, forgotten, limited, recovered, final] = result.snapshots;
+ assert.deepEqual(warm.views, configured.views);
+ assert(costs.evaluation.cacheHits > 0);
+ assert.deepEqual(forgotten.states, configured.states);
+ assert.deepEqual(limited.views, []);
+ assert.equal(limited.lastError, 101);
+ assert.deepEqual(recovered, final);
+ assert.deepEqual(recovered.views, configured.views);
+ assert.deepEqual(recovered.states, configured.states);
+});
diff --git a/prototypes/workbench/results/2026-09-23-release/after/examples/direct.pure b/prototypes/workbench/results/2026-09-23-release/after/examples/direct.pure
new file mode 100644
index 0000000..b29721c
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-release/after/examples/direct.pure
@@ -0,0 +1,47 @@
+# 도형을 잡은 위치를 유지해 이동합니다. 취소하면 잡기 전 위치로 돌아갑니다.
+state x = 80
+state y = 160
+state grab_x = 0
+state grab_y = 0
+state start_x = 80
+state start_y = 160
+state dragging = 0
+
+input drag = pointer(tile)
+input left = ArrowLeft
+input right = ArrowRight
+
+box left_area(20, 100, 280, 220, "#233a31")
+box right_area(340, 100, 280, 220, "#343b25")
+box tile(x, y, 56, 44, "#c9f08b")
+text horizontal(30, 45, x, "x: ")
+text vertical(180, 45, y, "y: ")
+text region(350, 70, x >= 320, "오른쪽 영역: ")
+text active(30, 75, dragging, "잡고 있음: ")
+
+on drag.down {
+ start_x = x
+ start_y = y
+ grab_x = event_x - x
+ grab_y = event_y - y
+ dragging = 1
+}
+on drag.move {
+ x = clamp(event_x - grab_x, 20, 564)
+ y = clamp(event_y - grab_y, 100, 276)
+}
+on drag.up {
+ # 놓기는 마지막으로 표시한 위치를 확정합니다.
+ dragging = 0
+}
+on drag.cancel {
+ x = start_x
+ y = start_y
+ dragging = 0
+}
+on left.down {
+ if dragging == 0 { x = max(20, x - 10) }
+}
+on right.down {
+ if dragging == 0 { x = min(564, x + 10) }
+}
diff --git a/prototypes/workbench/results/2026-09-23-release/after/pointer.test.mjs b/prototypes/workbench/results/2026-09-23-release/after/pointer.test.mjs
new file mode 100644
index 0000000..8bf530d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-release/after/pointer.test.mjs
@@ -0,0 +1,118 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { compile } from './web/compiler.mjs';
+import { createRuntime } from './web/runtime.mjs';
+
+const wasm = await readFile(new URL('./web/core.wasm', import.meta.url));
+const source = await readFile(new URL('./examples/direct.pure', import.meta.url), 'utf8');
+const fresh = async () => { const r = await createRuntime(wasm, {cacheEntries:32}); r.apply(source); return r; };
+
+test('pointer declarations resolve forward targets and reject ambiguous or contextless inputs', () => {
+ const p = compile(source);
+ assert.equal(p.inputs[0].target, 'tile');
+ assert(p.handlers.some(h => h.event === 17)); assert(p.handlers.some(h => h.event === 25));
+ assert(p.instructions.some(i => i.op === 22)); assert(p.instructions.some(i => i.op === 23));
+ for (const [code, message] of [
+ ['input p = pointer(missing)', /Unknown pointer target/],
+ ['input p=pointer(tile)\ninput q=pointer(tile)\ntext tile(0,0,0)', /Duplicate pointer/],
+ ['input p=Space\non p.move {}', /require a pointer/],
+ ['input p=Space\non p.cancel {}', /require a pointer/],
+ ['state x=0\non frame { x=event_x }', /only available/],
+ ['text v(event_y,0,0)', /only available/],
+ ['state event_x=0', /Reserved name/],
+ ]) assert.throws(() => compile(code), message);
+ const full = compile(Array.from({length:8}, (_, i) => `input p${i}=pointer(v${i})\ntext v${i}(0,0,0)\non p${i}.move {}\non p${i}.cancel {}`).join('\n'));
+ assert.deepEqual(full.handlers.map(h => h.event), Array.from({length:8}, (_, i) => [17+i,25+i]).flat());
+});
+
+test('authored drag keeps grab offset and commits the displayed position even if release coordinates differ', async () => {
+ const r = await fresh();
+ r.point('drag', 'down', 90, 170, 'pen');
+ assert.equal(r.snapshot().states.dragging, 1);
+ assert.throws(() => r.apply(''), /놓은 뒤/);
+ r.point('drag', 'move', 360, 220, 'pen');
+ let s = r.snapshot();
+ assert.equal(s.states.x, 350); assert.equal(s.states.y, 210);
+ assert.equal(s.views.find(v => v.id === 'region').value, 1);
+ r.point('drag', 'up', 410, 240, 'pen');
+ s = r.snapshot();
+ assert.equal(s.states.x, 350); assert.equal(s.states.y, 210);
+ assert.equal(s.states.dragging, 0); assert.deepEqual(s.held, []);
+ assert.equal(s.successfulEvents, 3);
+ assert.equal(s.lastEvent.x, 410);
+ r.down('left'); r.up('left');
+ assert.equal(r.snapshot().states.x, 340);
+ r.point('drag', 'down', 350, 220, 'pen');
+ r.point('drag', 'move', 450, 250, 'pen');
+ r.point('drag', 'up', 350, 220, 'pen'); // A host may repeat the press coordinates.
+ assert.equal(r.snapshot().states.x, 440);
+ assert.equal(r.snapshot().states.y, 240);
+ r.apply('state x=0\nstate y=0\ninput p=pointer(tile)\ntext tile(0,0,x)\non p.up { x=event_x\ny=event_y }');
+ r.point('p','down',1,2); r.point('p','up',30,40);
+ assert.deepEqual(r.snapshot().states, {x:30,y:40}); // Payload remains available to authors.
+});
+
+test('ownership, duplicate downs, stale moves, cancellation and focus cleanup cannot leave held input', async () => {
+ const r = await fresh();
+ r.point('drag', 'down', 90, 170, 'a');
+ r.point('drag', 'down', 500, 500, 'a');
+ assert.throws(() => r.point('drag', 'down', 90, 170, 'b'), /다른 포인터/);
+ r.point('drag', 'move', 400, 200, 'b');
+ assert.equal(r.snapshot().successfulEvents, 1);
+ r.point('drag', 'move', 400, 200, 'a');
+ r.releaseAll();
+ const canceled = r.snapshot();
+ assert.equal(canceled.states.x, 80); assert.equal(canceled.states.y, 160);
+ assert.equal(canceled.states.dragging, 0); assert.equal(canceled.lastEvent.phase, 'cancel');
+ assert.deepEqual(canceled.held, []);
+ r.point('drag', 'cancel', 1, 1, 'a'); r.point('drag', 'move', 500, 500, 'a'); r.releaseAll();
+ assert.deepEqual(r.snapshot(), canceled);
+ r.point('drag', 'down', 90, 170, 'new'); r.reset();
+ r.point('drag', 'up', 600, 500, 'new');
+ assert.equal(r.snapshot().states.x, 80); assert.deepEqual(r.snapshot().held, []);
+});
+
+test('invalid coordinates and modes do not mutate a held gesture; arithmetic failure rolls back one event', async () => {
+ const r = await fresh(); r.point('drag', 'down', 90, 170);
+ const before = r.snapshot();
+ for (const invoke of [() => r.point('drag','move',NaN,1), () => r.point('drag','up',1,Infinity),
+ () => r.point('drag','unknown',1,2), () => r.point('left','down',1,2),
+ () => r.down('drag'), () => r.up('drag'), () => r.point('drag','move',1,2,'')]) assert.throws(invoke);
+ assert.deepEqual(r.snapshot(), before);
+ r.releaseAll();
+ r.apply(source.replace('x = clamp(event_x - grab_x, 20, 564)', 'x = event_x\n y = 1 / 0'));
+ r.point('drag', 'down', 90, 170);
+ assert.equal(r.point('drag', 'move', 500, 220), 4);
+ let s = r.snapshot(); assert.equal(s.states.x, 80); assert.equal(s.states.y, 160);
+ assert.equal(s.lastFailure.phase, 'move');
+ r.releaseAll(); s = r.snapshot(); assert.deepEqual(s.held, []); assert.equal(s.lastError, 4);
+ assert.equal(s.states.dragging, 0);
+});
+
+test('independent targets share the event pipeline and publication remains non-reentrant', async () => {
+ const r = await createRuntime(wasm);
+ r.apply('state x=0\nstate y=0\ninput a=pointer(one)\ninput b=pointer(two)\ntext one(0,0,x)\ntext two(0,0,y)\non a.move { x=event_x }\non b.move { y=event_y }');
+ r.point('a','down',1,2,'first'); r.point('b','down',3,4,'second');
+ r.point('a','move',10,20,'first'); r.point('b','move',30,40,'second');
+ assert.deepEqual(r.snapshot().states,{x:10,y:40});
+ r.present({begin(){assert.throws(() => r.point('a','move',0,0,'first'), /출력 전달 중/);},state(){},view(){},value(){},end(){}});
+ r.releaseAll(); assert.deepEqual(r.snapshot().held, []);
+});
+
+test('failing down, up and cancel never prevent terminal ownership cleanup', async () => {
+ for (const phase of ['down', 'up', 'cancel']) {
+ const r = await createRuntime(wasm);
+ r.apply(`state x=7\ninput p=pointer(tile)\ntext tile(0,0,x)\non p.${phase} { x=event_x\nx=1/0 }`);
+ const code = r.point('p','down',10,20,'owner');
+ assert.equal(code, phase === 'down' ? 4 : 0);
+ assert.deepEqual(r.snapshot().held, ['p']);
+ if (phase === 'up') assert.equal(r.point('p','up',30,40,'owner'), 4);
+ else assert.equal(r.releaseAll(), phase === 'cancel' ? 4 : 0);
+ const s = r.snapshot();
+ assert.equal(s.states.x, 7); assert.equal(s.lastError, 4);
+ assert.equal(s.lastFailure.phase, phase); assert.deepEqual(s.held, []);
+ r.apply('state x=100'); // Cleanup also unblocks definition changes.
+ assert.equal(r.snapshot().states.x, 7);
+ }
+});
diff --git a/prototypes/workbench/results/2026-09-23-release/after/qa-pointer.js b/prototypes/workbench/results/2026-09-23-release/after/qa-pointer.js
new file mode 100644
index 0000000..0b0c591
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-release/after/qa-pointer.js
@@ -0,0 +1,122 @@
+// Playwright CLI run-code: real mouse / CDP touch input; no timing benchmark.
+async (page) => {
+ const checks = [], errors = [];
+ const check = (condition, label) => { if (!condition) throw new Error(label); checks.push(label); };
+ const close = (a, b) => Math.abs(a - b) < 0.1;
+ const state = () => page.evaluate(() => window.pureWorkbench.snapshot());
+ const client = (x, y) => page.evaluate(({x,y}) => {
+ const p = new DOMPoint(x,y).matrixTransform(document.querySelector('#scene').getScreenCTM());
+ return {x:p.x,y:p.y};
+ }, {x,y});
+ const move = async (x,y) => { const p = await client(x,y); await page.mouse.move(p.x,p.y); };
+ const start = async (x,y) => { await move(x,y); await page.mouse.down(); };
+ const reset = async () => { await page.locator('#reset').click(); await page.locator('#scene').scrollIntoViewIfNeeded(); };
+ const apply = async source => { await page.locator('#source').fill(source); await page.locator('#apply').click(); await page.locator('#scene').scrollIntoViewIfNeeded(); };
+ const onError = error => errors.push(error.message);
+ const onDialog = dialog => dialog.accept();
+ page.on('pageerror', onError); page.on('dialog', onDialog);
+ let session;
+ try {
+ await page.setViewportSize({width:1440,height:1000});
+ await page.reload();
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ await page.locator('#example').selectOption('direct');
+ await page.locator('#load-example').click();
+ const source = await page.locator('#source').inputValue();
+ check((await state()).views.length === 0, 'loading the pointer example leaves the running program unchanged until Apply');
+ await page.locator('#apply').click(); await page.locator('#scene').scrollIntoViewIfNeeded();
+ check(await page.locator('[data-point-input="drag"]').count() === 1, 'authored pointer declaration binds the visible target');
+ check(await page.locator('#input-controls button').count() === 2, 'keyboard alternatives remain available without a fake pointer button');
+ await page.evaluate(() => {
+ window.__pointerTile = document.querySelector('[data-point-input="drag"]');
+ document.querySelector('#scene').addEventListener('pointerdown', e => { window.__pointerID = e.pointerId; });
+ });
+ await start(90,170);
+ let s = await state();
+ check(s.states.dragging === 1 && s.held.includes('drag'), 'real mouse down reaches the authored rule');
+ await move(370,230); s = await state();
+ check(close(s.states.x,360) && close(s.states.y,220), 'drag retains grab offset in scene coordinates');
+ check(s.views.find(v => v.id === 'region').value === 1 && await page.locator('[data-view-id="region"]').textContent() === '오른쪽 영역: 1', 'linked output updates when the authored boundary is crossed');
+ check(await page.evaluate(() => window.__pointerTile === document.querySelector('[data-point-input="drag"]')), 'drag keeps its bound DOM target');
+ // Physical mouse outside the SVG still reaches the stable captured scene.
+ await move(750,400); s = await state();
+ check(close(s.states.x,564) && close(s.states.y,276), 'capture delivers movement outside the scene and authored clamp applies');
+ await page.mouse.up(); s = await state();
+ check(s.held.length === 0 && s.states.dragging === 0 && s.lastEvent.phase === 'up', 'release outside the scene ends the gesture once');
+ await reset();
+ await start(90,170); await move(380,240);
+ await page.evaluate(() => window.dispatchEvent(new Event('blur')));
+ s = await state();
+ check(close(s.states.x,80) && close(s.states.y,160) && s.states.dragging === 0 && s.held.length === 0 && s.lastEvent.phase === 'cancel', 'focus loss invokes authored rollback and clears ownership');
+ const ended = s.successfulEvents;
+ await page.mouse.up();
+ check((await state()).successfulEvents === ended, 'late mouse release after cancel does not dispatch a second terminal event');
+ await start(90,170); await move(350,220);
+ await page.evaluate(() => document.querySelector('#scene').releasePointerCapture(window.__pointerID));
+ await move(360,230); s = await state();
+ check(close(s.states.x,80) && s.held.length === 0 && s.lastEvent.phase === 'cancel', 'explicit capture loss cancels instead of committing a drop');
+ await page.mouse.up();
+ await start(90,170); await move(300,220);
+ await page.evaluate(() => document.querySelector('#scene').dispatchEvent(new PointerEvent('pointercancel', {pointerId:window.__pointerID,bubbles:true})));
+ check((await state()).held.length === 0 && close((await state()).states.x,80), 'browser cancellation routes to the authored cancel handler');
+ await page.mouse.up();
+ // A source edit, not a renderer option, changes the movement constraint.
+ await apply(source.replaceAll('20, 564','20, 200'));
+ await start(90,170); await move(500,220); await page.mouse.up(); s = await state();
+ check(close(s.states.x,200), 'editing the authored rule changes the drag limit');
+ await apply(source + '\non drag.move { x = 1 / 0 }\n'); await reset();
+ await start(90,170); await move(300,220); s = await state();
+ check(s.lastError === 4 && close(s.states.x,80) && close(s.states.y,160), 'later handler failure rolls back all earlier movement writes');
+ await page.evaluate(() => window.dispatchEvent(new Event('blur'))); await page.mouse.up();
+ s = await state();
+ check(s.lastError === 4 && s.held.length === 0 && s.states.dragging === 0, 'cleanup releases a failed gesture while preserving its error');
+ await apply(source); await reset();
+ await page.screenshot({path:'output/playwright/pure-pointer-desktop.png',fullPage:true});
+
+ await page.setViewportSize({width:390,height:844});
+ await page.locator('#scene').scrollIntoViewIfNeeded();
+ session = await page.context().newCDPSession(page);
+ await session.send('Emulation.setTouchEmulationEnabled',{enabled:true,maxTouchPoints:2});
+ const touch = async (type, points) => session.send('Input.dispatchTouchEvent',{type,touchPoints:points});
+ const a = await client(90,170), b = await client(270,210);
+ const scroll = await page.evaluate(() => window.scrollY);
+ await touch('touchStart',[{id:1,...a}]);
+ check((await state()).states.dragging === 1, 'touch contact starts the same authored interaction');
+ await page.evaluate(() => window.dispatchEvent(new MouseEvent('mouseup', {button:0,buttons:0,bubbles:true})));
+ check((await state()).held.includes('drag'), 'mouse release fallback cannot terminate an active touch contact');
+ await touch('touchMove',[{id:1,...b}]); s = await state();
+ check(close(s.states.x,260) && close(s.states.y,200), 'mobile scaled SVG maps touch coordinates back to world coordinates');
+ check(await page.evaluate(() => window.scrollY) === scroll, 'touch drag manipulates the target without scrolling the page');
+ await touch('touchEnd',[]); s = await state();
+ check(s.held.length === 0 && s.states.dragging === 0 && s.lastEvent.phase === 'up', 'touch release clears capture ownership');
+ await page.screenshot({path:'output/playwright/pure-pointer-mobile.png',fullPage:true});
+ // Independent contacts are not collapsed into one global drag state.
+ const two = 'state x=0\nstate y=0\ninput a=pointer(one)\ninput b=pointer(two)\nbox one(20,100,100,80,"#c9f08b")\nbox two(300,100,100,80,"#c9f08b")\non a.move { x=event_x }\non b.move { y=event_y }';
+ await apply(two);
+ const p1 = {id:1,...await client(50,140)}, p2 = {id:2,...await client(330,140)};
+ await touch('touchStart',[p1]); await touch('touchStart',[p1,p2]);
+ check((await state()).held.length === 2, 'two contacts on different authored targets retain separate owners');
+ await touch('touchMove',[{id:1,...await client(180,160)},{id:2,...await client(430,200)}]); s = await state();
+ check(close(s.states.x,180) && close(s.states.y,200), 'independent touch payloads reach their own rules');
+ await touch('touchCancel',[]);
+ check((await state()).held.length === 0, 'touch cancellation releases both targets');
+ // A second contact on the same target cannot steal the first one.
+ await touch('touchStart',[p1]);
+ await touch('touchStart',[p1,{id:2,...await client(70,150)}]);
+ check((await state()).held.length === 1, 'second contact on an already owned target does not steal it');
+ await touch('touchCancel',[]);
+ check((await state()).held.length === 0, 'ignored second contact cannot strand a logical input');
+ check(errors.length === 0,'no browser page errors');
+ await session.send('Emulation.setTouchEmulationEnabled',{enabled:false});
+ await session.detach(); session = null;
+ await page.setViewportSize({width:1440,height:1000}); await apply(source); await reset();
+ await page.evaluate(result => { window.__purePointerEvidence = result; }, {checks,errors,kind:'functional input/output checks; no performance benchmark'});
+ return {checks:checks.length,errors};
+ } catch (error) {
+ await page.evaluate(result => { window.__purePointerEvidence = result; }, {failed:String(error),checks,errors});
+ throw error;
+ } finally {
+ if (session) { await session.send('Emulation.setTouchEmulationEnabled',{enabled:false}); await session.detach(); }
+ page.off('pageerror',onError); page.off('dialog',onDialog);
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-23-release/after/qa-release.js b/prototypes/workbench/results/2026-09-23-release/after/qa-release.js
new file mode 100644
index 0000000..1674c5b
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-release/after/qa-release.js
@@ -0,0 +1,59 @@
+// Playwright CLI run-code. Covers release routing and stale release coordinates.
+async page => {
+ const checks = [];
+ const state = () => page.evaluate(() => window.pureWorkbench.snapshot());
+ const check = (ok, label) => checks.push({label, passed:Boolean(ok)});
+ const near = (a,b) => Math.abs(a-b)<0.1;
+ const client = (x,y) => page.evaluate(({x,y}) => {
+ const p = new DOMPoint(x,y).matrixTransform(document.querySelector('#scene').getScreenCTM());
+ return {x:p.x,y:p.y};
+ },{x,y});
+ const move = async(x,y) => { const p=await client(x,y); await page.mouse.move(p.x,p.y); };
+ const start = async() => {
+ await page.locator('#reset').click(); await page.locator('#scene').scrollIntoViewIfNeeded();
+ await move(90,170); await page.mouse.down(); await move(370,230);
+ };
+ await page.setViewportSize({width:1440,height:1000});
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ await page.locator('#example').selectOption('direct');
+ await page.locator('#load-example').click(); await page.locator('#apply').click();
+ await page.evaluate(() => document.querySelector('#scene').addEventListener('pointerdown',e=>{ window.__releaseID=e.pointerId; }));
+ await start(); await page.mouse.up(); let s=await state();
+ check(near(s.states.x,360)&&near(s.states.y,220)&&!s.held.length&&s.lastEvent.phase==='up','ordinary in-scene release retains the displayed position');
+ await start(); await move(750,400); await page.mouse.up(); s=await state();
+ check(near(s.states.x,564)&&near(s.states.y,276)&&!s.held.length&&s.lastEvent.phase==='up','ordinary release outside the scene retains the clamped position');
+ for (const scenario of ['document-pointerup','child-nonbubbling-up','mouse-only-up','stale-scene-up']) {
+ await start();
+ const position=await client(90,170); // Deliberately stale press coordinates.
+ await page.evaluate(({scenario,position})=>{
+ const scene=document.querySelector('#scene'), tile=document.querySelector('[data-point-input="drag"]');
+ const fields={pointerId:window.__releaseID,pointerType:'mouse',button:0,buttons:0,clientX:position.x,clientY:position.y,bubbles:scenario!=='child-nonbubbling-up'};
+ if(scenario==='mouse-only-up') window.dispatchEvent(new MouseEvent('mouseup',fields));
+ else (scenario==='document-pointerup'?document:scenario==='child-nonbubbling-up'?tile:scene).dispatchEvent(new PointerEvent('pointerup',fields));
+ if(scene.hasPointerCapture(window.__releaseID)) scene.releasePointerCapture(window.__releaseID);
+ },{scenario,position});
+ await page.mouse.up(); s=await state();
+ check(near(s.states.x,360)&&near(s.states.y,220)&&!s.held.length&&s.lastEvent.phase==='up',`${scenario}: release commits once without a coordinate jump or later cancel`);
+ check(s.successfulEvents===3,`${scenario}: duplicate release/capture loss produces no extra authored event`);
+ }
+ await start();
+ await page.evaluate(()=>document.querySelector('[data-point-input="drag"]').dispatchEvent(new PointerEvent('lostpointercapture',{pointerId:window.__releaseID,bubbles:true})));
+ s=await state(); check(s.held.includes('drag')&&near(s.states.x,360),'descendant capture-loss notification cannot cancel the root owner');
+ await page.mouse.up();
+ await start();
+ await page.evaluate(()=>document.querySelector('#scene').releasePointerCapture(window.__releaseID));
+ await move(380,240); s=await state();
+ check(!s.held.length&&s.states.x===80&&s.states.y===160&&s.lastEvent.phase==='cancel','actual root capture loss while held still cancels');
+ await page.mouse.up();
+ await start();
+ await page.evaluate(()=>document.dispatchEvent(new PointerEvent('pointercancel',{pointerId:window.__releaseID,bubbles:true})));
+ await page.mouse.up(); s=await state();
+ check(!s.held.length&&s.states.x===80&&s.states.y===160&&s.lastEvent.phase==='cancel','cancellation outside the scene still rolls back and ignores the late up');
+ await start();
+ await page.evaluate(()=>window.dispatchEvent(new Event('blur')));
+ await page.mouse.up(); s=await state();
+ check(!s.held.length&&s.states.x===80&&s.states.y===160&&s.lastEvent.phase==='cancel','focus loss still cancels rather than committing');
+ const result={checks,passed:checks.filter(c=>c.passed).length,failed:checks.filter(c=>!c.passed)};
+ await page.evaluate(result=>{window.__pureReleaseEvidence=result;},result);
+ return result;
+}
diff --git a/prototypes/workbench/results/2026-09-23-release/after/web/examples/direct.pure b/prototypes/workbench/results/2026-09-23-release/after/web/examples/direct.pure
new file mode 100644
index 0000000..b29721c
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-release/after/web/examples/direct.pure
@@ -0,0 +1,47 @@
+# 도형을 잡은 위치를 유지해 이동합니다. 취소하면 잡기 전 위치로 돌아갑니다.
+state x = 80
+state y = 160
+state grab_x = 0
+state grab_y = 0
+state start_x = 80
+state start_y = 160
+state dragging = 0
+
+input drag = pointer(tile)
+input left = ArrowLeft
+input right = ArrowRight
+
+box left_area(20, 100, 280, 220, "#233a31")
+box right_area(340, 100, 280, 220, "#343b25")
+box tile(x, y, 56, 44, "#c9f08b")
+text horizontal(30, 45, x, "x: ")
+text vertical(180, 45, y, "y: ")
+text region(350, 70, x >= 320, "오른쪽 영역: ")
+text active(30, 75, dragging, "잡고 있음: ")
+
+on drag.down {
+ start_x = x
+ start_y = y
+ grab_x = event_x - x
+ grab_y = event_y - y
+ dragging = 1
+}
+on drag.move {
+ x = clamp(event_x - grab_x, 20, 564)
+ y = clamp(event_y - grab_y, 100, 276)
+}
+on drag.up {
+ # 놓기는 마지막으로 표시한 위치를 확정합니다.
+ dragging = 0
+}
+on drag.cancel {
+ x = start_x
+ y = start_y
+ dragging = 0
+}
+on left.down {
+ if dragging == 0 { x = max(20, x - 10) }
+}
+on right.down {
+ if dragging == 0 { x = min(564, x + 10) }
+}
diff --git a/prototypes/workbench/results/2026-09-23-release/after/web/scene-input.mjs b/prototypes/workbench/results/2026-09-23-release/after/web/scene-input.mjs
new file mode 100644
index 0000000..0f7cbef
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-release/after/web/scene-input.mjs
@@ -0,0 +1,83 @@
+// This adapter owns browser capture only. Authored state changes stay in the VM.
+export function bindSceneInput(scene, runtime, act) {
+ const captures = new Map();
+ const events = scene.ownerDocument.defaultView;
+ const coordinates = event => {
+ const matrix = scene.getScreenCTM();
+ if (!matrix) return null;
+ try {
+ const p = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse());
+ return Number.isFinite(p.x) && Number.isFinite(p.y) ? p : null;
+ } catch { return null; }
+ };
+ const release = id => {
+ if (scene.hasPointerCapture(id)) scene.releasePointerCapture(id);
+ };
+ function finish(event, phase, pointerId = event.pointerId) {
+ const entry = captures.get(pointerId);
+ if (!entry) return;
+ event.preventDefault();
+ // Capture loss/cancel may not carry a meaningful position. Use the last
+ // delivered coordinates. Up must use its own final position.
+ const p = phase === 'up' ? coordinates(event) : null;
+ if (p) { entry.x = p.x; entry.y = p.y; }
+ if (phase === 'up' && !p) phase = 'cancel';
+ captures.delete(pointerId);
+ release(pointerId);
+ act(() => runtime().point(entry.name, phase, entry.x, entry.y, entry.source));
+ }
+ scene.addEventListener('pointerdown', event => {
+ if (event.button !== 0 || !runtime() || captures.has(event.pointerId)) return;
+ const target = event.target.closest?.('[data-point-input]');
+ if (!target || !scene.contains(target)) return;
+ const name = target.getAttribute('data-point-input');
+ // A logical input has one coordinate owner. A second contact on it must not
+ // steal the gesture; other authored targets can be manipulated independently.
+ if ([...captures.values()].some(entry => entry.name === name)) return;
+ const p = coordinates(event);
+ if (!p) return;
+ event.preventDefault(); scene.focus();
+ act(() => {
+ scene.setPointerCapture(event.pointerId);
+ const entry = {name, source:`scene:${event.pointerId}`, pointerType:event.pointerType, x:p.x, y:p.y};
+ captures.set(event.pointerId, entry);
+ try { runtime().point(name, 'down', p.x, p.y, entry.source); }
+ catch (error) { captures.delete(event.pointerId); release(event.pointerId); throw error; }
+ });
+ });
+ scene.addEventListener('pointermove', event => {
+ const entry = captures.get(event.pointerId);
+ if (!entry) return;
+ const p = coordinates(event);
+ if (!p) { finish(event, 'cancel'); return; }
+ event.preventDefault();
+ entry.x = p.x; entry.y = p.y;
+ act(() => runtime().point(entry.name, 'move', p.x, p.y, entry.source));
+ });
+ // A host may deliver release outside the SVG even while it owns capture.
+ // Observe termination before target handlers or capture cleanup can cancel it.
+ events.addEventListener('pointerup', event => finish(event, 'up'), true);
+ events.addEventListener('pointercancel', event => finish(event, 'cancel'), true);
+ events.addEventListener('mouseup', event => {
+ if (event.button !== 0) return;
+ // Mouse-only fallback when the host delivers mouseup without pointerup.
+ // A normal pointerup already removed the owner; touch compatibility events
+ // must never finish a different contact.
+ for (const [id, entry] of captures) {
+ if (entry.pointerType === 'mouse') finish(event, 'up', id);
+ }
+ }, true);
+ scene.addEventListener('lostpointercapture', event => {
+ // Descendant capture transitions bubble here but do not release our owner.
+ if (event.target === scene) finish(event, 'cancel');
+ });
+ return Object.freeze({
+ releaseCaptures() {
+ const ids = [...captures.keys()];
+ captures.clear();
+ for (const id of ids) release(id);
+ // The caller subsequently invokes runtime.releaseAll(), which dispatches
+ // each pending cancel once. Clearing first makes lostcapture harmless.
+ },
+ });
+}
diff --git a/prototypes/workbench/results/2026-09-23-release/before-browser.json b/prototypes/workbench/results/2026-09-23-release/before-browser.json
new file mode 100644
index 0000000..c019713
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-release/before-browser.json
@@ -0,0 +1,21 @@
+{
+ "states": {
+ "x": 80,
+ "y": 160,
+ "grab_x": 9.999986805438311,
+ "grab_y": 10.00000329864045,
+ "start_x": 80,
+ "start_y": 160,
+ "dragging": 0
+ },
+ "lastEvent": {
+ "name": "drag",
+ "phase": "cancel",
+ "source": "scene:1",
+ "status": 0,
+ "line": null,
+ "ops": 6,
+ "x": 370.0000131945618,
+ "y": 229.99999835067982
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-23-release/manifest.json b/prototypes/workbench/results/2026-09-23-release/manifest.json
new file mode 100644
index 0000000..e6ab837
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-release/manifest.json
@@ -0,0 +1,6 @@
+{
+ "web/scene-input.mjs": "a7de0d0c26328fa6885b1d1633783f1550acfa4a2233db604b0ce4646e633a25",
+ "examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a",
+ "qa-release.js": "09c4b5f9d4a13bd9b89ead072476b9019e906d921ae4ca7e6a10225750051d43",
+ "qa-pointer.js": "b51588231600d60677c89e8861984826d52fa2e0d6eb2f6bfffa826055033cf4"
+}
diff --git a/prototypes/workbench/results/2026-09-23-release/pointer-browser.json b/prototypes/workbench/results/2026-09-23-release/pointer-browser.json
new file mode 100644
index 0000000..c7ba8ca
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-release/pointer-browser.json
@@ -0,0 +1,33 @@
+{
+ "checks": [
+ "loading the pointer example leaves the running program unchanged until Apply",
+ "authored pointer declaration binds the visible target",
+ "keyboard alternatives remain available without a fake pointer button",
+ "real mouse down reaches the authored rule",
+ "drag retains grab offset in scene coordinates",
+ "linked output updates when the authored boundary is crossed",
+ "drag keeps its bound DOM target",
+ "capture delivers movement outside the scene and authored clamp applies",
+ "release outside the scene ends the gesture once",
+ "focus loss invokes authored rollback and clears ownership",
+ "late mouse release after cancel does not dispatch a second terminal event",
+ "explicit capture loss cancels instead of committing a drop",
+ "browser cancellation routes to the authored cancel handler",
+ "editing the authored rule changes the drag limit",
+ "later handler failure rolls back all earlier movement writes",
+ "cleanup releases a failed gesture while preserving its error",
+ "touch contact starts the same authored interaction",
+ "mouse release fallback cannot terminate an active touch contact",
+ "mobile scaled SVG maps touch coordinates back to world coordinates",
+ "touch drag manipulates the target without scrolling the page",
+ "touch release clears capture ownership",
+ "two contacts on different authored targets retain separate owners",
+ "independent touch payloads reach their own rules",
+ "touch cancellation releases both targets",
+ "second contact on an already owned target does not steal it",
+ "ignored second contact cannot strand a logical input",
+ "no browser page errors"
+ ],
+ "errors": [],
+ "kind": "functional input/output checks; no performance benchmark"
+}
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/README.md b/prototypes/workbench/results/2026-09-23-shared-rules/README.md
new file mode 100644
index 0000000..4d8b6d0
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/README.md
@@ -0,0 +1,24 @@
+# Shared rule bodies — 2026-09-23
+
+- build.json: final contiguous-range binding ABI, matching source/Wasm hashes,
+ 32 native tests passed.
+- verification.json: 75 Node tests passed. Compiler/rules suites include the final
+ parameter-prefix shadowing guard and the generated-name document size boundary.
+- shared-browser.json: 23 checks on the final contiguous-binding build; mouse,
+ two-touch ownership/cancel, editing one shared rule, reordered declarations,
+ file download and fresh-page restoration, async read conflicts.
+- authoring-browser.json: 44 existing authoring checks passed.
+- representation.json: compile/layout accounting on the same final core for
+ explicit and shared versions. Not a throughput, latency or process RAM claim.
+- before/: previous implementation references. sources/ and manifest.json:
+ final implementation and identities. Screenshots and actual downloaded layout
+ are retained beside the JSON results.
+
+The browser scenarios precede the final compiler-only prefix-shadow rejection
+check; that diagnostic change is covered by the final Node compiler/rules suites.
+No browser performance benchmark was run. Browser is Linux Chromium; touches use
+CDP emulation, not physical hardware. Codex embedded browser is not directly driven.
+
+Build with Zig 0.15.2 via manage.py build. Run *.test.mjs with Node child-process
+support. Serve on port 8766 and run qa-shared-rules.js through Playwright CLI on a
+fresh blank workbench page. The ordinary qa-browser.js changes only artifact names.
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/authoring-browser.json b/prototypes/workbench/results/2026-09-23-shared-rules/authoring-browser.json
new file mode 100644
index 0000000..7e0927a
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/authoring-browser.json
@@ -0,0 +1,128 @@
+{
+ "recordedUtc": "2026-09-23T07:08:12.678Z",
+ "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36",
+ "checks": [
+ "initial editor has literally empty source",
+ "initial scene has no built-in entities, states, inputs, or running clock",
+ "controls require an authored program",
+ "authoring a new program is a draft until Apply",
+ "new custom program constructs states and multiple views from source",
+ "input buttons are generated from the authored declarations",
+ "view bindings render the authored state value",
+ "Space and arrows in the editor never reach authored inputs",
+ "pointer down dispatches once while the clock is paused",
+ "pointer up releases without double dispatch from click",
+ "keyboard repeat is ignored",
+ "keyboard activation on a generated button is one complete tap",
+ "assistive click activation is one complete tap",
+ "keyboard and pointer sources share one logical input",
+ "releasing one source does not release the other source",
+ "last source releases the logical input",
+ "authored down/frame behavior uses the declared dt without built-in motion",
+ "keyup releases even after focus moves into the editor",
+ "focus loss sends release and pauses the clock",
+ "Play advances and Pause freezes the clock",
+ "invalid code preserves the previous executable program and states",
+ "compile error exposes a source-line diagnostic",
+ "diagnostic location moves the editor caret to the problem",
+ "apply preserves matching state names and initializes newly authored states",
+ "new input, entity, binding, and behavior work with source changes alone",
+ "changed behavior takes effect without resetting existing values",
+ "explicit Reset restores active initializers and keeps the draft",
+ "Apply is refused while an input is held",
+ "clear editor is explicit and does not clear the running scene before Apply",
+ "downloaded project restores the exact un-applied draft without execution",
+ "invalid JSON project schema cannot replace the draft",
+ "failed assistive down event rolls back and its error survives the following successful up",
+ "latched runtime failure is visible and labeled separately from the latest input",
+ "explicit Reset clears the failure record",
+ "applying empty source removes all authored states, inputs, and scene contents",
+ "explicit example loading only fills the editor and never auto-applies",
+ "authored scene executes in the actual fixed 64 KiB Wasm memory",
+ "cost inspector shows measured Wasm memory without claiming browser heap totals",
+ "390px mobile viewport has no horizontal overflow",
+ "generated mobile input buttons remain usable",
+ "ordinary-JavaScript comparison and equivalent Pure example are linked and served",
+ "missing Wasm shows an actionable load failure and disables execution controls",
+ "fresh load always returns to a blank authoring environment",
+ "no uncaught page errors during the browser scenarios"
+ ],
+ "authoredSource": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "authoredSnapshot": {
+ "states": {
+ "x": 114,
+ "moving": 0,
+ "count": 2
+ },
+ "views": [
+ {
+ "id": "marker",
+ "type": "box",
+ "color": "#7ed9ae",
+ "x": 114,
+ "y": 170,
+ "width": 48,
+ "height": 48
+ },
+ {
+ "id": "reading",
+ "type": "text",
+ "prefix": "Signals: ",
+ "x": 40,
+ "y": 70,
+ "value": 2
+ }
+ ],
+ "tick": 12,
+ "revision": 5,
+ "displayError": null,
+ "lastError": 0,
+ "errorMessage": "",
+ "errorLine": null,
+ "lastOps": 2,
+ "successfulEvents": 18,
+ "held": [],
+ "lastEvent": {
+ "name": "advance",
+ "phase": "up",
+ "source": "keyboard:ArrowRight",
+ "status": 0,
+ "line": null,
+ "ops": 2
+ },
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19904,
+ "programReservedBytes": 9648,
+ "instructionBytes": 16,
+ "handlerBytes": 16,
+ "activeIRBytes": 480,
+ "activeStateBytes": 24,
+ "stagingReservedBytes": 9912,
+ "evalStackBytes": 512
+ },
+ "ready": true,
+ "source": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "appliedSource": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "dirty": false,
+ "running": false,
+ "droppedTicks": 0,
+ "diagnostic": "코드를 적용했습니다. 같은 이름의 상태는 유지됩니다."
+ },
+ "mobile": {
+ "viewport": 390,
+ "content": 390
+ },
+ "downloaded": {
+ "filename": "my-work.pure.json",
+ "path": "output/playwright/pure-shared-workbench-draft.json",
+ "expectedSource": "# A new authored interaction, not a bundled example.\nstate x = 500\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 5 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\nstate bonus = 7\ninput reverse = KeyA\ncircle beacon(300, 190, 15 + bonus, \"#c9f08b\")\non reverse.down { bonus = bonus + 2 }\n# This un-applied draft must survive Reset.\n"
+ },
+ "errors": [],
+ "screenshots": [
+ "output/playwright/pure-shared-workbench-blank.png",
+ "output/playwright/pure-shared-workbench-authored.png",
+ "output/playwright/pure-shared-workbench-mobile.png"
+ ]
+}
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/before/core.zig b/prototypes/workbench/results/2026-09-23-shared-rules/before/core.zig
new file mode 100644
index 0000000..1154297
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/before/core.zig
@@ -0,0 +1,1136 @@
+//! Bounded scalar interaction VM experiment. No application behavior is built in.
+//! Same source for native tests and freestanding Wasm; no allocation or imports.
+//! Error ABI: 0 ok, 1 capacity, 2 invalid, 3 stack, 4 division by zero,
+//! 5 non-finite, 6 bad branch, 7 bad state, 8 bad frame, 9 bad event.
+//! Counters wrap modulo 2^32. Failed loads preserve the active program, authored
+//! state and event diagnostics; beginning a load discards optional reuse records.
+//! Failed events preserve values and counters but
+//! report their error and attempted instruction count. Binding evaluation is pure.
+
+const std = @import("std");
+const Evaluation = @import("evaluation.zig").Evaluation;
+const Tables = @import("evaluation.zig").Tables;
+
+const max_states = 32;
+const max_inputs = 8;
+const max_instructions = 512;
+const max_handlers = 64;
+const max_stack = 64;
+const no_pc = std.math.maxInt(u32);
+const frame_dt: f64 = 1.0 / 60.0;
+
+const Code = enum(u32) {
+ ok = 0,
+ capacity = 1,
+ invalid = 2,
+ stack = 3,
+ division_by_zero = 4,
+ non_finite = 5,
+ bad_branch = 6,
+ bad_state = 7,
+ bad_frame = 8,
+ bad_event = 9,
+ work_limit = 10,
+};
+
+const Instruction = struct { value: f64 = 0, op: u32 = 0, arg: u32 = 0 };
+const Handler = struct { event: u32 = 0, start: u32 = 0, length: u32 = 0 };
+const Program = struct {
+ instructions: [max_instructions]Instruction = @splat(.{}),
+ handlers: [max_handlers]Handler = @splat(.{}),
+ initials: [max_states]f64 = @splat(0),
+ old_ids: [max_states]i32 = @splat(-1),
+ declared: [max_states]bool = @splat(false),
+ state_count: u32 = 0,
+ input_count: u32 = 0,
+ instruction_count: u32 = 0,
+ handler_count: u32 = 0,
+};
+// Loading a definition/optional restored values and result reuse are mutually exclusive phases. Current execution
+// always reads active, and can recompute bindings while this workspace loads.
+const Pending = struct {
+ program: Program = .{},
+ values: [max_states]f64 = @splat(0),
+ value_mask: u32 = 0,
+};
+const Workspace = union { staging: Pending, reuse: Tables };
+const Engine = struct {
+ active: Program = .{},
+ workspace: Workspace = .{ .reuse = .{} },
+ values: [max_states]f64 = @splat(0),
+ staging_open: bool = false,
+ staging_error: Code = .ok,
+ tick: u32 = 0,
+ last_error: Code = .ok,
+ error_pc: u32 = no_pc,
+ last_ops: u32 = 0,
+ revision: u32 = 0,
+ successful_events: u32 = 0,
+ evaluation: Evaluation = .{},
+};
+var engine: Engine = .{};
+
+fn reuseTables() ?*Tables {
+ return if (engine.staging_open) null else &engine.workspace.reuse;
+}
+
+fn finishStaging() void {
+ engine.workspace = .{ .reuse = .{} };
+ engine.staging_open = false;
+ engine.evaluation.forget(null, 3);
+}
+
+fn public(code: Code) u32 {
+ return @intFromEnum(code);
+}
+
+fn stagingFail(code: Code) u32 {
+ if (engine.staging_error == .ok) engine.staging_error = code;
+ return public(code);
+}
+
+export fn reset_all() void {
+ engine = .{};
+}
+
+export fn begin_program(state_count: u32, input_count: u32) u32 {
+ engine.staging_error = .ok;
+ if (state_count > max_states or input_count > max_inputs) {
+ if (engine.staging_open) finishStaging();
+ return stagingFail(.capacity);
+ }
+ engine.workspace = .{ .staging = .{ .program = .{ .state_count = state_count, .input_count = input_count } } };
+ engine.staging_open = true;
+ engine.evaluation.forget(null, 3);
+ return 0;
+}
+
+export fn abort_program() void {
+ if (engine.staging_open) finishStaging();
+}
+
+export fn put_state(id: u32, initial: f64, old_id: i32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ if (id >= engine.workspace.staging.program.state_count or old_id < -1) return stagingFail(.bad_state);
+ if (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count) return stagingFail(.bad_state);
+ if (engine.workspace.staging.program.declared[id]) return stagingFail(.bad_state);
+ if (!std.math.isFinite(initial)) return stagingFail(.non_finite);
+ engine.workspace.staging.program.initials[id] = initial;
+ engine.workspace.staging.program.old_ids[id] = old_id;
+ engine.workspace.staging.program.declared[id] = true;
+ return 0;
+}
+
+export fn put_instruction(op: u32, arg: u32, value: f64) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (p.instruction_count == max_instructions) return stagingFail(.capacity);
+ if (op > 23) return stagingFail(.invalid);
+ if (!std.math.isFinite(value)) return stagingFail(.non_finite);
+ p.instructions[p.instruction_count] = .{ .op = op, .arg = arg, .value = value };
+ p.instruction_count += 1;
+ return 0;
+}
+
+fn validEvent(event: u32, inputs: u32) bool {
+ return event <= inputs * 2 or
+ (event >= 17 and event - 17 < inputs) or
+ (event >= 25 and event - 25 < inputs);
+}
+
+export fn put_handler(event: u32, start: u32, length: u32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (p.handler_count == max_handlers) return stagingFail(.capacity);
+ if (!validEvent(event, p.input_count)) return stagingFail(.bad_event);
+ // Handlers may be appended before their instructions, so range validation
+ // is deferred until commit. Arithmetic remains guarded there.
+ p.handlers[p.handler_count] = .{ .event = event, .start = start, .length = length };
+ p.handler_count += 1;
+ return 0;
+}
+
+fn validate(p: *const Program) Code {
+ for (0..p.state_count) |id| {
+ if (!p.declared[id]) return .bad_state;
+ if (!std.math.isFinite(p.initials[id])) return .non_finite;
+ const old_id = p.old_ids[id];
+ if (old_id < -1 or (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count)) return .bad_state;
+ }
+ for (p.handlers[0..p.handler_count], 0..) |h, i| {
+ if (!validEvent(h.event, p.input_count)) return .bad_event;
+ if (h.start > p.instruction_count or h.length > p.instruction_count - h.start) return .invalid;
+ if (h.length == 0) continue;
+ for (p.handlers[0..i]) |other| {
+ if (other.length != 0 and h.start < other.start + other.length and other.start < h.start + h.length) return .invalid;
+ }
+ }
+ for (p.instructions[0..p.instruction_count], 0..) |inst, pc| {
+ if (inst.op > 23) return .invalid;
+ if (!std.math.isFinite(inst.value)) return .non_finite;
+ if ((inst.op == 1 or inst.op == 20) and inst.arg >= p.state_count) return .bad_state;
+ if (inst.op >= 20) {
+ var owner: ?Handler = null;
+ for (p.handlers[0..p.handler_count]) |h| {
+ if (pc >= h.start and pc < h.start + h.length) {
+ owner = h;
+ break;
+ }
+ }
+ const h = owner orelse return .invalid;
+ if (inst.op == 21 and (inst.arg <= pc or inst.arg > h.start + h.length)) return .bad_branch;
+ }
+ }
+ return .ok;
+}
+
+export fn put_restored_value(id: u32, value: f64) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const pending = &engine.workspace.staging;
+ if (id >= pending.program.state_count) return stagingFail(.bad_state);
+ const bit = @as(u32, 1) << @as(u5, @intCast(id));
+ if (pending.value_mask & bit != 0) return stagingFail(.bad_state);
+ if (!std.math.isFinite(value)) return stagingFail(.non_finite);
+ pending.values[id] = value;
+ pending.value_mask |= bit;
+ return 0;
+}
+
+export fn commit_program() u32 {
+ return commit(false);
+}
+
+export fn commit_restored_program() u32 {
+ return commit(true);
+}
+
+fn commit(restoring: bool) u32 {
+ if (!engine.staging_open) return public(.invalid);
+ if (engine.staging_error != .ok) return public(engine.staging_error);
+ const count = engine.workspace.staging.program.state_count;
+ const expected: u32 = if (count == 32) 0xffffffff else (@as(u32, 1) << @as(u5, @intCast(count))) - 1;
+ if (engine.workspace.staging.value_mask != (if (restoring) expected else @as(u32, 0)))
+ return stagingFail(.bad_state);
+ const code = validate(&engine.workspace.staging.program);
+ if (code != .ok) return stagingFail(code);
+ var next: [max_states]f64 = @splat(0);
+ for (0..engine.workspace.staging.program.state_count) |i| {
+ const old_id = engine.workspace.staging.program.old_ids[i];
+ next[i] = if (restoring) engine.workspace.staging.values[i] else if (old_id < 0) engine.workspace.staging.program.initials[i] else engine.values[@intCast(old_id)];
+ }
+ engine.active = engine.workspace.staging.program;
+ engine.values = next;
+ finishStaging();
+ if (restoring) {
+ engine.tick = 0;
+ engine.successful_events = 0;
+ }
+ engine.revision +%= 1;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ return 0;
+}
+
+export fn reset_state() void {
+ engine.values = engine.active.initials;
+ engine.evaluation.forget(reuseTables(), 3);
+ engine.tick = 0;
+ engine.successful_events = 0;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+}
+
+const Execution = struct {
+ code: Code = .ok,
+ pc: u32 = no_pc,
+ ops: u32 = 0,
+ value: f64 = 0,
+ dependencies: u32 = 0,
+};
+
+fn executionFail(result: Execution, code: Code, pc: u32) Execution {
+ var failed = result;
+ failed.code = code;
+ failed.pc = pc;
+ return failed;
+}
+
+const EventContext = struct { dt: f64 = 0, x: f64 = 0, y: f64 = 0, point: bool = false };
+
+fn execute(values: *[max_states]f64, start: u32, length: u32, context: EventContext, pure: bool, budget: ?*Evaluation) Execution {
+ const p = &engine.active;
+ var result: Execution = .{};
+ if (start > p.instruction_count or length > p.instruction_count - start) return executionFail(result, .invalid, no_pc);
+ const end = start + length;
+ var stack: [max_stack]f64 = undefined;
+ var sp: usize = 0;
+ var pc = start;
+ while (pc < end) {
+ if (budget) |b| {
+ if (!b.spend()) return executionFail(result, .work_limit, pc);
+ b.instructions += 1;
+ }
+ const inst = p.instructions[pc];
+ result.ops += 1;
+ if (pure and (inst.op == 2 or inst.op >= 20)) return executionFail(result, .invalid, pc);
+ const need: usize = switch (inst.op) {
+ 0...2, 22, 23 => 0,
+ 7, 16, 20, 21 => 1,
+ 3...6, 8...15, 17, 18 => 2,
+ 19 => 3,
+ else => return executionFail(result, .invalid, pc),
+ };
+ if (sp < need) return executionFail(result, .stack, pc);
+ if (inst.op <= 2 or inst.op == 22 or inst.op == 23) {
+ if (sp == max_stack) return executionFail(result, .stack, pc);
+ const value = switch (inst.op) {
+ 0 => inst.value,
+ 1 => blk: {
+ if (inst.arg >= p.state_count) return executionFail(result, .bad_state, pc);
+ if (budget != null and budget.?.cache_count != 0)
+ result.dependencies |= @as(u32, 1) << @as(u5, @intCast(inst.arg));
+ break :blk values[inst.arg];
+ },
+ 2 => context.dt,
+ 22, 23 => if (!context.point) return executionFail(result, .bad_event, pc) else if (inst.op == 22) context.x else context.y,
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ stack[sp] = value;
+ sp += 1;
+ } else if (inst.op == 20) {
+ if (inst.arg >= p.state_count) return executionFail(result, .bad_state, pc);
+ sp -= 1;
+ values[inst.arg] = stack[sp];
+ } else if (inst.op == 21) {
+ if (inst.arg <= pc or inst.arg > end) return executionFail(result, .bad_branch, pc);
+ sp -= 1;
+ if (stack[sp] == 0) {
+ pc = inst.arg;
+ continue;
+ }
+ } else {
+ const a = stack[sp - need];
+ const b = if (need >= 2) stack[sp - need + 1] else 0;
+ const value: f64 = switch (inst.op) {
+ 3 => a + b,
+ 4 => a - b,
+ 5 => a * b,
+ 6 => blk: {
+ if (b == 0) return executionFail(result, .division_by_zero, pc);
+ break :blk a / b;
+ },
+ 7 => -a,
+ 8 => @floatFromInt(@intFromBool(a < b)),
+ 9 => @floatFromInt(@intFromBool(a <= b)),
+ 10 => @floatFromInt(@intFromBool(a > b)),
+ 11 => @floatFromInt(@intFromBool(a >= b)),
+ 12 => @floatFromInt(@intFromBool(a == b)),
+ 13 => @floatFromInt(@intFromBool(a != b)),
+ 14 => @floatFromInt(@intFromBool(a != 0 and b != 0)),
+ 15 => @floatFromInt(@intFromBool(a != 0 or b != 0)),
+ 16 => @floatFromInt(@intFromBool(a == 0)),
+ 17 => @min(a, b),
+ 18 => @max(a, b),
+ 19 => blk: {
+ const hi = stack[sp - 1];
+ if (b > hi) return executionFail(result, .invalid, pc);
+ break :blk @max(b, @min(a, hi));
+ },
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ sp = sp - need + 1;
+ stack[sp - 1] = value;
+ }
+ pc += 1;
+ }
+ if (sp != @as(usize, if (pure) 1 else 0)) return executionFail(result, .stack, end);
+ if (pure) result.value = stack[0];
+ return result;
+}
+
+export fn dispatch(event: u32, dt: f64) u32 {
+ return dispatchContext(event, .{ .dt = dt });
+}
+
+export fn dispatch_point(event: u32, x: f64, y: f64) u32 {
+ return dispatchContext(event, .{ .x = x, .y = y, .point = true });
+}
+
+fn dispatchContext(event: u32, context: EventContext) u32 {
+ const dt = context.dt;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ if (!validEvent(event, engine.active.input_count) or
+ (context.point and event == 0) or (!context.point and event > engine.active.input_count * 2)) {
+ engine.last_error = .bad_event;
+ return public(.bad_event);
+ }
+ if (!std.math.isFinite(dt) or !std.math.isFinite(context.x) or !std.math.isFinite(context.y)) {
+ engine.last_error = .non_finite;
+ return public(.non_finite);
+ }
+ if ((event == 0 and dt != frame_dt) or (event != 0 and dt != 0)) {
+ engine.last_error = .bad_frame;
+ return public(.bad_frame);
+ }
+ var pending = engine.values;
+ for (engine.active.handlers[0..engine.active.handler_count]) |h| {
+ if (h.event != event) continue;
+ const result = execute(&pending, h.start, h.length, context, false, null);
+ engine.last_ops += result.ops;
+ if (result.code != .ok) {
+ engine.last_error = result.code;
+ engine.error_pc = result.pc;
+ return public(result.code);
+ }
+ }
+ if (engine.evaluation.cache_count != 0 and !engine.staging_open) {
+ var changed: u32 = 0;
+ for (0..engine.active.state_count) |i| {
+ // Signed zero is observable, so compare representation, not ==.
+ if (@as(u64, @bitCast(pending[i])) != @as(u64, @bitCast(engine.values[i])))
+ changed |= @as(u32, 1) << @as(u5, @intCast(i));
+ }
+ engine.evaluation.changed(reuseTables(), changed);
+ }
+ engine.values = pending;
+ if (event == 0) engine.tick +%= 1;
+ engine.successful_events +%= 1;
+ return 0;
+}
+
+export fn eval_at(start: u32, length: u32) f64 {
+ // A local copy makes the no-side-effects boundary structural as well as
+ // checked by the pure instruction filter. Diagnostics are never touched.
+ var values = engine.values;
+ const result = execute(&values, start, length, .{}, true, null);
+ return if (result.code == .ok) result.value else std.math.nan(f64);
+}
+
+export fn configure_evaluation(cache: u32, history: u32) u32 {
+ return if (engine.evaluation.configure(reuseTables(), cache, history)) 0 else public(.capacity);
+}
+
+export fn forget_evaluation(flags: u32) void {
+ engine.evaluation.forget(reuseTables(), flags);
+}
+
+export fn begin_evaluation(limit: u32) void {
+ engine.evaluation.begin(limit);
+}
+
+export fn eval_bounded(start: u32, length: u32) f64 {
+ const e = &engine.evaluation;
+ e.status = 0;
+ // Charge per request as well as per instruction and validation probe.
+ if (!e.spend()) return std.math.nan(f64);
+ if (e.lookup(reuseTables(), start, length)) |value| return value;
+ if (e.status != 0) return std.math.nan(f64);
+ var values = engine.values;
+ const result = execute(&values, start, length, .{}, true, e);
+ e.status = public(result.code);
+ if (result.code != .ok) return std.math.nan(f64);
+ e.remember(reuseTables(), start, length, result.value, result.dependencies);
+ return result.value;
+}
+
+export fn evaluation_info(id: u32) u32 {
+ const e = &engine.evaluation;
+ return switch (id) {
+ 0 => e.status,
+ 1 => e.work,
+ 2 => e.instructions,
+ 3 => e.hits,
+ 4 => e.cache_count,
+ 5 => e.history_count,
+ 6 => @sizeOf(Evaluation) + @sizeOf(Tables),
+ 7 => @sizeOf(Evaluation),
+ 8 => @sizeOf(Tables),
+ 9 => @intFromBool(!engine.staging_open),
+ 10 => @sizeOf(Workspace) - @sizeOf(Pending),
+ else => 0,
+ };
+}
+
+export fn get_state(id: u32) f64 {
+ return if (id < engine.active.state_count) engine.values[id] else std.math.nan(f64);
+}
+
+export fn info(id: u32) u32 {
+ return switch (id) {
+ 0 => engine.tick,
+ 1 => public(engine.last_error),
+ 2 => engine.error_pc,
+ 3 => engine.last_ops,
+ 4 => engine.revision,
+ 5 => engine.active.state_count,
+ 6 => engine.active.instruction_count,
+ 7 => engine.active.handler_count,
+ 8 => engine.active.input_count,
+ 9 => engine.successful_events,
+ else => 0,
+ };
+}
+
+export fn layout(id: u32) u32 {
+ return switch (id) {
+ 0 => @sizeOf(Engine),
+ 1 => @sizeOf(Program),
+ 2 => @sizeOf(Instruction),
+ 3 => @sizeOf(Handler),
+ 4 => engine.active.instruction_count * @sizeOf(Instruction) + engine.active.handler_count * @sizeOf(Handler),
+ 5 => engine.active.state_count * @sizeOf(f64),
+ 6 => @sizeOf(Pending),
+ 7 => max_stack * @sizeOf(f64),
+ else => 0,
+ };
+}
+
+// Test helpers describe programs directly; the product compiler is separately
+// tested against this ABI. These tests exercise semantics and failure boundaries.
+fn ok(code: u32) !void {
+ try std.testing.expectEqual(@as(u32, 0), code);
+}
+
+fn emit(op: u32, arg: u32, value: f64) !void {
+ try ok(put_instruction(op, arg, value));
+}
+
+fn counterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try ok(commit_program());
+}
+
+test "blank program, no hidden application behavior, and reset" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 1), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(3));
+ try std.testing.expectEqual(@as(u32, 0), info(5));
+ try std.testing.expect(std.math.isNan(get_state(0)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 0)));
+ reset_state();
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 1), info(4));
+}
+
+test "arbitrary input counter and unused events" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(dispatch(2, 0));
+ try ok(dispatch(0, frame_dt));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(3));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(6));
+}
+
+test "charge and release behavior exists only in the authored instruction program" {
+ reset_all();
+ try ok(begin_program(3, 1)); // held, charge, released_value
+ for (0..3) |i| try ok(put_state(@intCast(i), 0, -1));
+ try emit(0, 0, 1);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 2));
+ try emit(1, 1, 0);
+ try emit(1, 0, 0);
+ try emit(2, 0, 0);
+ try emit(5, 0, 0);
+ try emit(3, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(0, 2, 6));
+ try emit(1, 1, 0);
+ try emit(20, 2, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(2, 8, 6));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ try ok(dispatch(1, 0));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try ok(dispatch(2, 0));
+ try std.testing.expectApproxEqAbs(@as(f64, 0.5), get_state(2), 1e-12);
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+}
+
+test "forward condition executes once and assignments and handlers are ordered" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 1, -1));
+ try ok(put_state(1, 0, -1));
+ try emit(1, 0, 0);
+ try emit(21, 6, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 7);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 0, 6));
+ try emit(1, 1, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 6, 4));
+ try ok(commit_program());
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 14), get_state(1));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 28), get_state(1));
+ try std.testing.expectEqual(@as(u32, 6), info(3));
+}
+
+test "branch to program end is valid" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, 2, 0);
+ try ok(put_handler(0, 0, 2));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+}
+
+test "one event rolls back writes from all handlers on arithmetic failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 8, -1));
+ try emit(0, 0, 99);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 2));
+ try emit(0, 0, 1);
+ try emit(0, 0, 0);
+ try emit(6, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 2, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 8), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(2));
+ try std.testing.expectEqual(@as(u32, 5), info(3));
+}
+
+test "nonfinite arithmetic and invalid clamp fail atomically" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, -1));
+ try emit(0, 0, 1e308);
+ try emit(0, 0, 1e308);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, 0));
+ try emit(0, 0, 1);
+ try emit(0, 0, 10);
+ try emit(0, 0, 2);
+ try emit(19, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 5));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.invalid), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+}
+
+test "stack underflow overflow and leftover values reject the event" {
+ for (0..3) |mode| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ if (mode == 0) {
+ try emit(3, 0, 0);
+ } else {
+ for (0..@as(usize, if (mode == 1) 65 else 1)) |_| try emit(0, 0, 7);
+ }
+ try ok(put_handler(0, 0, engine.workspace.staging.program.instruction_count));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.stack), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ }
+}
+
+test "failed candidates leave active values program revision and diagnostics unchanged" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ const old_revision = info(4);
+ const old_ops = info(3);
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(1, 5, 0);
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try std.testing.expectEqual(old_revision, info(4));
+ try std.testing.expectEqual(old_ops, info(3));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.invalid), put_instruction(100, 0, 0));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+}
+
+test "state preservation mapping reads active values at commit and reset uses new initials" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(2, 0));
+ try ok(put_state(0, 42, -1)); // new symbol first
+ try ok(put_state(1, 100, 0)); // old symbol moves from id 0 to 1
+ try ok(dispatch(1, 0)); // changed after staging began
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 42), get_state(0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 100), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(4));
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try std.testing.expect(std.math.isNan(get_state(0)));
+}
+
+test "capacity boundaries and sticky load errors" {
+ reset_all();
+ try std.testing.expectEqual(public(.capacity), begin_program(33, 0));
+ try std.testing.expectEqual(public(.capacity), begin_program(0, 9));
+ try ok(begin_program(32, 8));
+ for (0..32) |i| try ok(put_state(@intCast(i), @floatFromInt(i), -1));
+ for (0..512) |_| try emit(0, 0, 0);
+ for (0..64) |_| try ok(put_handler(16, 0, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 512), info(6));
+ try std.testing.expectEqual(@as(u32, 64), info(7));
+ try std.testing.expectEqual(@as(f64, 31), get_state(31));
+ try ok(begin_program(0, 0));
+ for (0..512) |_| try emit(0, 0, 0);
+ try std.testing.expectEqual(public(.capacity), put_instruction(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+ try ok(begin_program(0, 0));
+ for (0..64) |_| try ok(put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+}
+
+test "invalid declaration values state mappings event ids and handler ranges" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 0, 0));
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.non_finite), put_state(0, std.math.inf(f64), -1));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 1, -1));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.non_finite), put_instruction(0, 0, std.math.nan(f64)));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.bad_event), put_handler(1, 0, 0));
+ try ok(begin_program(0, 0));
+ try ok(put_handler(0, no_pc, 5));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 1);
+ try ok(put_handler(0, 0, 1));
+ try ok(put_handler(0, 0, 1));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "backward self out-of-handler branches and stores without handlers are rejected" {
+ for ([_]u32{ 0, 1, 3 }) |target| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, target, 0);
+ try emit(0, 0, 7);
+ try ok(put_handler(0, 0, 2));
+ try std.testing.expectEqual(public(.bad_branch), commit_program());
+ }
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try emit(0, 0, 5);
+ try emit(20, 0, 0);
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "event inputs and dt enforce deterministic frame contract" {
+ reset_all();
+ try counterProgram();
+ try std.testing.expectEqual(public(.bad_event), dispatch(3, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch(no_pc, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(1, frame_dt));
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, std.math.nan(f64)));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(u32, 0), info(1));
+ try std.testing.expectEqual(no_pc, info(2));
+}
+
+test "binding evaluation is pure on both success and failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 4, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try emit(2, 0, 0);
+ try emit(0, 0, 0);
+ try emit(21, 7, 0);
+ try ok(put_handler(0, 3, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ const before = engine;
+ try std.testing.expectEqual(@as(f64, 12), eval_at(0, 3));
+ try std.testing.expect(std.math.isNan(eval_at(0, 4))); // attempted store
+ try std.testing.expect(std.math.isNan(eval_at(4, 1))); // dt forbidden in views
+ try std.testing.expect(std.math.isNan(eval_at(5, 2))); // branch forbidden
+ try std.testing.expect(std.math.isNan(eval_at(no_pc, 7)));
+ try std.testing.expect(std.math.isNan(eval_at(0, no_pc)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 2))); // two results
+ // Compare only the live member of the untagged workspace, never padding
+ // or the inactive member's indeterminate storage.
+ try std.testing.expectEqual(before.staging_open, engine.staging_open);
+ inline for (std.meta.fields(Engine)) |field| {
+ if (comptime std.mem.eql(u8, field.name, "workspace")) {
+ if (before.staging_open) {
+ try std.testing.expectEqualDeep(before.workspace.staging, engine.workspace.staging);
+ } else {
+ try std.testing.expectEqualDeep(before.workspace.reuse, engine.workspace.reuse);
+ }
+ } else try std.testing.expectEqualDeep(@field(before, field.name), @field(engine, field.name));
+ }
+}
+
+test "arithmetic comparison logical min max and clamp expression semantics" {
+ const cases = [_]struct { op: u32, a: f64, b: f64, want: f64 }{
+ .{ .op = 3, .a = 7, .b = 2, .want = 9 },
+ .{ .op = 4, .a = 7, .b = 2, .want = 5 },
+ .{ .op = 5, .a = 7, .b = 2, .want = 14 },
+ .{ .op = 6, .a = 7, .b = 2, .want = 3.5 },
+ .{ .op = 8, .a = 2, .b = 7, .want = 1 },
+ .{ .op = 9, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 10, .a = 2, .b = 7, .want = 0 },
+ .{ .op = 11, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 12, .a = 7, .b = 2, .want = 0 },
+ .{ .op = 13, .a = 7, .b = 2, .want = 1 },
+ .{ .op = 14, .a = -7, .b = 2, .want = 1 },
+ .{ .op = 15, .a = 0, .b = 2, .want = 1 },
+ .{ .op = 17, .a = 7, .b = 2, .want = 2 },
+ .{ .op = 18, .a = 7, .b = 2, .want = 7 },
+ };
+ for (cases) |case| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, case.a);
+ try emit(0, 0, case.b);
+ try emit(case.op, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(case.want, eval_at(0, 3));
+ }
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, -8);
+ try emit(7, 0, 0);
+ try emit(0, 0, 0);
+ try emit(0, 0, 5);
+ try emit(19, 0, 0);
+ try emit(16, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 5), eval_at(0, 5));
+ try std.testing.expectEqual(@as(f64, 0), eval_at(0, 6));
+}
+
+test "diagnostic counters wrap without affecting scalar values" {
+ reset_all();
+ try counterProgram();
+ engine.tick = no_pc;
+ engine.successful_events = no_pc;
+ engine.revision = no_pc;
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 99, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 0), info(4));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+}
+
+test "memory accounting separates reserved storage used IR and stack capacity" {
+ reset_all();
+ try std.testing.expectEqual(@as(u32, 0), layout(4));
+ try std.testing.expectEqual(@as(u32, 0), layout(5));
+ try counterProgram();
+ try std.testing.expectEqual(@as(u32, @sizeOf(Engine)), layout(0));
+ try std.testing.expectEqual(@as(u32, 16), layout(2));
+ try std.testing.expectEqual(@as(u32, 12), layout(3));
+ try std.testing.expectEqual(@as(u32, 76), layout(4));
+ try std.testing.expectEqual(@as(u32, 8), layout(5));
+ try std.testing.expectEqual(@as(u32, @sizeOf(Pending)), layout(6));
+ try std.testing.expect(layout(6) >= layout(1) + max_states * @sizeOf(f64));
+ try std.testing.expect(layout(0) >= layout(1) * 2);
+ try std.testing.expectEqual(@as(u32, 512), layout(7));
+ try std.testing.expect(layout(0) + 16 * 1024 < 64 * 1024);
+}
+
+fn displayCounterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try emit(1, 0, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try ok(configure_evaluation(32, 8));
+}
+
+fn boundedEquals(want: f64, start: u32, length: u32) !void {
+ begin_evaluation(100);
+ try std.testing.expectEqual(want, eval_bounded(start, length));
+ try ok(evaluation_info(0));
+}
+
+test "point coordinates are ephemeral event operands and unavailable to pure evaluation" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 0, -1));
+ try ok(put_state(1, 0, -1));
+ const events = [_]u32{ 1, 17, 2, 25 };
+ for (events, 0..) |event, i| {
+ try emit(22, 0, 0);
+ try emit(20, 0, 0);
+ try emit(23, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(event, @intCast(i * 4), 4));
+ }
+ try ok(commit_program());
+ for (events, 0..) |event, i| {
+ const x: f64 = @floatFromInt(i + 10);
+ try ok(dispatch_point(event, x, -x));
+ try std.testing.expectEqual(x, get_state(0));
+ try std.testing.expectEqual(-x, get_state(1));
+ }
+ try std.testing.expectEqual(public(.bad_event), dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 13), get_state(0));
+ const diagnostics = [_]u32{ info(1), info(2), info(3), info(9) };
+ try std.testing.expect(std.math.isNan(eval_at(0, 1)));
+ try std.testing.expectEqualSlices(u32, &diagnostics, &.{ info(1), info(2), info(3), info(9) });
+ try std.testing.expectEqual(@as(u32, 4), info(9));
+}
+
+test "point event ranges reject holes and invalid payloads without committing" {
+ reset_all();
+ try counterProgram();
+ for ([_]u32{ 0, 3, 16, 18, 24, 26, 32, 33, no_pc }) |event|
+ try std.testing.expectEqual(public(.bad_event), dispatch_point(event, 1, 2));
+ try std.testing.expectEqual(public(.non_finite), dispatch_point(1, std.math.nan(f64), 0));
+ try std.testing.expectEqual(public(.non_finite), dispatch_point(1, 0, std.math.inf(f64)));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try ok(begin_program(0, 8));
+ try ok(put_handler(24, 0, 0));
+ try ok(put_handler(32, 0, 0));
+ try ok(commit_program());
+ try ok(dispatch_point(24, 1, 2));
+ try ok(dispatch_point(32, 1, 2));
+ try std.testing.expectEqual(public(.bad_event), dispatch(24, 0));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+}
+
+test "point event failure rolls back writes from earlier matching handlers" {
+ reset_all();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 7, -1));
+ try emit(22, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(17, 0, 2));
+ try emit(23, 0, 0);
+ try emit(0, 0, 0);
+ try emit(6, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(17, 2, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch_point(17, 400, 200));
+ try std.testing.expectEqual(@as(f64, 7), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(2));
+}
+
+test "loading shares storage with records while old events and bindings remain executable" {
+ reset_all();
+ try displayCounterProgram();
+ try boundedEquals(20, 4, 3);
+ try boundedEquals(20, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(9));
+ try emit(1, 0, 0);
+ try ok(dispatch(1, 0)); // Active state advances after staging begins.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(3));
+ try ok(configure_evaluation(32, 1));
+ forget_evaluation(3); // Must not overwrite the pending program.
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(33, 0, 3);
+ try boundedEquals(33, 0, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+}
+
+test "failed and abandoned candidates restore empty reuse storage without changing active work" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(20, 0, 0); // Store without a handler fails validation.
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try boundedEquals(20, 4, 3);
+ try ok(dispatch(1, 0));
+ abort_program();
+ try boundedEquals(22, 4, 3);
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ abort_program(); // Inactive abort is a no-op, including for warmed records.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try std.testing.expectEqual(public(.capacity), begin_program(max_states + 1, 0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+}
+
+test "full pending program survives repeated evaluation configuration record loss and reset" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ for (0..max_instructions) |i| {
+ try emit(0, 0, @floatFromInt(i));
+ try ok(configure_evaluation(32, @intCast(i % 9)));
+ forget_evaluation(3);
+ try boundedEquals(22, 4, 3);
+ }
+ reset_state(); // The pending definition and old-id mapping survive reset.
+ try boundedEquals(20, 4, 3);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, max_instructions), info(6));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try boundedEquals(511, 511, 1);
+ // Safe Zig builds may reserve a hidden union tag in addition to the payload.
+ try std.testing.expect(@sizeOf(Workspace) >= @sizeOf(Pending));
+ try std.testing.expect(@sizeOf(Workspace) <= @sizeOf(Pending) + @alignOf(Pending));
+ try std.testing.expectEqual(@as(u32, @sizeOf(Tables)), evaluation_info(8));
+}
+
+test "restored definition and values commit together without changing reset initializers" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(dispatch(0, frame_dt));
+ const revision = info(4);
+ try ok(begin_program(2, 0));
+ try ok(put_state(0, 100, -1));
+ try ok(put_state(1, 200, -1));
+ try ok(put_restored_value(1, -0.0));
+ try ok(put_restored_value(0, 37));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try ok(dispatch(1, 0)); // Old execution remains independent of pending values.
+ try ok(commit_restored_program());
+ try std.testing.expectEqual(@as(f64, 37), get_state(0));
+ try std.testing.expectEqual(@as(u64, @bitCast(@as(f64, -0.0))), @as(u64, @bitCast(get_state(1))));
+ try std.testing.expectEqual(revision + 1, info(4));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 100), get_state(0));
+ try std.testing.expectEqual(@as(f64, 200), get_state(1));
+}
+
+test "missing duplicate nonfinite or invalid restored candidates leave active work untouched" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ const revision = info(4);
+ for (0..4) |mode| {
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 999, -1));
+ if (mode == 1) {
+ try ok(put_restored_value(0, 30));
+ try std.testing.expectEqual(public(.bad_state), put_restored_value(0, 40));
+ } else if (mode == 2) {
+ try std.testing.expectEqual(public(.non_finite), put_restored_value(0, std.math.inf(f64)));
+ } else if (mode == 3) {
+ try ok(put_restored_value(0, 30));
+ try emit(20, 0, 0); // Invalid store outside a handler.
+ }
+ try std.testing.expect(commit_restored_program() != 0);
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try std.testing.expectEqual(revision, info(4));
+ try std.testing.expectEqual(@as(u32, 1), info(9));
+ abort_program();
+ }
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 100, -1));
+ try ok(put_restored_value(0, 42));
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ abort_program();
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+}
+
+test "empty and full restored programs validate all values and discard old display records" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(configure_evaluation(32, 8));
+ try boundedEquals(20, 4, 3);
+ try ok(begin_program(32, 0));
+ for (0..32) |i| {
+ try ok(put_state(@intCast(i), 0, -1));
+ try ok(put_restored_value(@intCast(i), @floatFromInt(i + 1)));
+ }
+ try emit(1, 31, 0);
+ try ok(commit_restored_program());
+ try boundedEquals(32, 0, 1);
+ try ok(begin_program(0, 0));
+ try ok(commit_restored_program());
+ try std.testing.expectEqual(@as(u32, 0), info(5));
+ try std.testing.expect(std.math.isNan(get_state(0)));
+}
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/before/examples/layout.pure b/prototypes/workbench/results/2026-09-23-shared-rules/before/examples/layout.pure
new file mode 100644
index 0000000..41cf149
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/before/examples/layout.pure
@@ -0,0 +1,92 @@
+# 세 도형을 배치하고 배치 저장으로 결과를 보관하세요.
+# 마지막으로 잡은 도형은 방향키로 10씩 이동합니다.
+state selected = 0
+input left = ArrowLeft
+input right = ArrowRight
+input up = ArrowUp
+input down = ArrowDown
+state x1 = 80
+state y1 = 120
+state ox1 = 0
+state oy1 = 0
+state sx1 = 80
+state sy1 = 120
+state held1 = 0
+input drag1 = pointer(tile1)
+state x2 = 280
+state y2 = 160
+state ox2 = 0
+state oy2 = 0
+state sx2 = 280
+state sy2 = 160
+state held2 = 0
+input drag2 = pointer(tile2)
+state x3 = 450
+state y3 = 220
+state ox3 = 0
+state oy3 = 0
+state sx3 = 450
+state sy3 = 220
+state held3 = 0
+input drag3 = pointer(tile3)
+box board(20,90,600,240,"#233a31")
+text help(25,40,selected,"선택한 도형: ")
+box tile1(x1,y1,56,44,"#c9f08b")
+box tile2(x2,y2,56,44,"#74c4d4")
+box tile3(x3,y3,56,44,"#dfb184")
+on drag1.down {
+ selected = 1
+ sx1 = x1; sy1 = y1
+ ox1 = event_x - x1; oy1 = event_y - y1
+ held1 = 1
+}
+on drag1.move {
+ x1 = clamp(event_x - ox1, 20, 564)
+ y1 = clamp(event_y - oy1, 90, 286)
+}
+on drag1.up { held1 = 0 }
+on drag1.cancel { x1 = sx1; y1 = sy1; held1 = 0 }
+on drag2.down {
+ selected = 2
+ sx2 = x2; sy2 = y2
+ ox2 = event_x - x2; oy2 = event_y - y2
+ held2 = 1
+}
+on drag2.move {
+ x2 = clamp(event_x - ox2, 20, 564)
+ y2 = clamp(event_y - oy2, 90, 286)
+}
+on drag2.up { held2 = 0 }
+on drag2.cancel { x2 = sx2; y2 = sy2; held2 = 0 }
+on drag3.down {
+ selected = 3
+ sx3 = x3; sy3 = y3
+ ox3 = event_x - x3; oy3 = event_y - y3
+ held3 = 1
+}
+on drag3.move {
+ x3 = clamp(event_x - ox3, 20, 564)
+ y3 = clamp(event_y - oy3, 90, 286)
+}
+on drag3.up { held3 = 0 }
+on drag3.cancel { x3 = sx3; y3 = sy3; held3 = 0 }
+on left.down {
+ if selected == 1 && held1 == 0 { x1 = clamp(x1 + -10, 20, 564) }
+ if selected == 2 && held2 == 0 { x2 = clamp(x2 + -10, 20, 564) }
+ if selected == 3 && held3 == 0 { x3 = clamp(x3 + -10, 20, 564) }
+}
+on right.down {
+ if selected == 1 && held1 == 0 { x1 = clamp(x1 + 10, 20, 564) }
+ if selected == 2 && held2 == 0 { x2 = clamp(x2 + 10, 20, 564) }
+ if selected == 3 && held3 == 0 { x3 = clamp(x3 + 10, 20, 564) }
+}
+on up.down {
+ if selected == 1 && held1 == 0 { y1 = clamp(y1 + -10, 90, 286) }
+ if selected == 2 && held2 == 0 { y2 = clamp(y2 + -10, 90, 286) }
+ if selected == 3 && held3 == 0 { y3 = clamp(y3 + -10, 90, 286) }
+}
+on down.down {
+ if selected == 1 && held1 == 0 { y1 = clamp(y1 + 10, 90, 286) }
+ if selected == 2 && held2 == 0 { y2 = clamp(y2 + 10, 90, 286) }
+ if selected == 3 && held3 == 0 { y3 = clamp(y3 + 10, 90, 286) }
+}
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/before/web/compiler.mjs b/prototypes/workbench/results/2026-09-23-shared-rules/before/web/compiler.mjs
new file mode 100644
index 0000000..1ec0c7c
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/before/web/compiler.mjs
@@ -0,0 +1,326 @@
+// Pure's experimental scalar interaction language. No host code is evaluated.
+const LIMITS = Object.freeze({ sourceBytes: 32768, states: 32, inputs: 8,
+ views: 24, handlers: 64, instructions: 512, depth: 64, stack: 64 });
+const RESERVED = new Set(['frame', 'dt', 'event_x', 'event_y', 'min', 'max', 'clamp']);
+const BINARY = new Map([
+ ['||', [1, 15]], ['&&', [2, 14]], ['==', [3, 12]], ['!=', [3, 13]],
+ ['<', [4, 8]], ['<=', [4, 9]], ['>', [4, 10]], ['>=', [4, 11]],
+ ['+', [5, 3]], ['-', [5, 4]], ['*', [6, 5]], ['/', [6, 6]],
+]);
+const FUNCTIONS = { min: [2, 17], max: [2, 18], clamp: [3, 19] };
+const VIEW_FIELDS = { box: ['x', 'y', 'width', 'height'],
+ circle: ['x', 'y', 'radius'], text: ['x', 'y', 'value'] };
+
+function fail(token, message) {
+ const error = new Error(`${message} (${token.line}:${token.column})`);
+ error.line = token.line;
+ error.column = token.column;
+ throw error;
+}
+
+function lex(source) {
+ const tokens = [];
+ let offset = 0, line = 1, column = 1;
+ const advance = () => {
+ const char = source[offset++];
+ if (char === '\n') { line++; column = 1; } else column++;
+ return char;
+ };
+ while (offset < source.length) {
+ const char = source[offset];
+ if (char === ' ' || char === '\t' || char === '\r') { advance(); continue; }
+ if (char === '#') { while (offset < source.length && source[offset] !== '\n') advance(); continue; }
+ const token = { line, column };
+ if (char === '\n') { advance(); tokens.push({ ...token, kind: 'nl' }); continue; }
+ if (char === '"') {
+ advance();
+ let value = '', closed = false;
+ while (offset < source.length) {
+ const next = advance();
+ if (next === '"') { closed = true; break; }
+ if (next === '\n' || next === '\r') fail(token, 'Strings must fit on one line');
+ if (next === '\\') {
+ const escaped = advance();
+ const escapes = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' };
+ if (!Object.hasOwn(escapes, escaped)) fail(token, 'Unsupported string escape');
+ value += escapes[escaped];
+ } else value += next;
+ }
+ if (!closed) fail(token, 'Unterminated string');
+ tokens.push({ ...token, kind: 'string', value }); continue;
+ }
+ const remaining = source.slice(offset);
+ const number = remaining.match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/);
+ if (number) {
+ const value = Number(number[0]);
+ if (!Number.isFinite(value)) fail(token, 'Number must be finite');
+ for (let i = 0; i < number[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'number', value }); continue;
+ }
+ const name = remaining.match(/^[A-Za-z_][A-Za-z_0-9]*/);
+ if (name) {
+ for (let i = 0; i < name[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'name', value: name[0] }); continue;
+ }
+ const pair = source.slice(offset, offset + 2);
+ if (['<=', '>=', '==', '!=', '&&', '||'].includes(pair)) {
+ advance(); advance(); tokens.push({ ...token, kind: pair }); continue;
+ }
+ if ('=+-*/!<>(){},.;'.includes(char)) {
+ advance(); tokens.push({ ...token, kind: char }); continue;
+ }
+ fail(token, `Unexpected character ${JSON.stringify(char)}`);
+ }
+ tokens.push({ kind: 'eof', line, column });
+ return tokens;
+}
+
+class Parser {
+ constructor(tokens) { this.tokens = tokens; this.position = 0; this.depth = 0; }
+ peek() { return this.tokens[this.position]; }
+ take() { return this.tokens[this.position++]; }
+ is(kind, value) { return this.peek().kind === kind && (value === undefined || this.peek().value === value); }
+ expect(kind, description = kind) {
+ if (!this.is(kind)) fail(this.peek(), `Expected ${description}`);
+ return this.take();
+ }
+ newlines() { while (this.is('nl')) this.take(); }
+ separators() { while (this.is('nl') || this.is(';')) this.take(); }
+ end() {
+ if (!['nl', ';', '}', 'eof'].includes(this.peek().kind)) fail(this.peek(), 'Expected newline or semicolon');
+ this.separators();
+ }
+ node(token, properties, children = []) {
+ const depth = 1 + Math.max(0, ...children.map(child => child.depth));
+ if (depth > LIMITS.depth) fail(token, 'Expression nesting exceeds 64');
+ return { token, depth, ...properties };
+ }
+ expression(minimum = 0, multiline = false) {
+ if (++this.depth > LIMITS.depth) fail(this.peek(), 'Expression nesting exceeds 64');
+ if (multiline) this.newlines();
+ const token = this.take();
+ let left;
+ if (token.kind === 'number') left = this.node(token, { kind: 'constant', value: token.value });
+ else if (token.kind === '-' || token.kind === '!') {
+ const child = this.expression(7, multiline);
+ left = this.node(token, { kind: 'unary', op: token.kind === '-' ? 7 : 16, child }, [child]);
+ } else if (token.kind === '(') {
+ left = this.expression(0, true); this.newlines(); this.expect(')');
+ } else if (token.kind === 'name') {
+ if (Object.hasOwn(FUNCTIONS, token.value) && this.is('(')) {
+ this.take();
+ const args = [];
+ this.newlines();
+ if (!this.is(')')) {
+ for (;;) {
+ args.push(this.expression(0, true)); this.newlines();
+ if (!this.is(',')) break;
+ this.take();
+ }
+ }
+ this.expect(')');
+ if (args.length !== FUNCTIONS[token.value][0]) fail(token, `${token.value} expects ${FUNCTIONS[token.value][0]} arguments`);
+ left = this.node(token, { kind: 'call', op: FUNCTIONS[token.value][1], args }, args);
+ } else left = this.node(token, { kind: 'reference', name: token.value });
+ } else fail(token, 'Expected a number, state, or expression');
+ for (;;) {
+ if (multiline) this.newlines();
+ const entry = BINARY.get(this.peek().kind);
+ if (!entry || entry[0] < minimum) break;
+ const operator = this.take();
+ const right = this.expression(entry[0] + 1, multiline);
+ left = this.node(operator, { kind: 'binary', op: entry[1], left, right }, [left, right]);
+ }
+ this.depth--;
+ return left;
+ }
+ block(depth = 0) {
+ if (depth >= LIMITS.depth) fail(this.peek(), 'Handler nesting exceeds 64');
+ this.newlines(); this.expect('{'); this.separators();
+ const statements = [];
+ while (!this.is('}')) {
+ const token = this.expect('name', 'assignment, if, or closing brace');
+ if (token.value === 'if' && !this.is('=')) {
+ const condition = this.expression();
+ const body = this.block(depth + 1);
+ statements.push({ kind: 'if', token, condition, body });
+ } else {
+ this.expect('=');
+ statements.push({ kind: 'assign', token, name: token.value, expression: this.expression() });
+ }
+ this.end();
+ }
+ this.take();
+ return statements;
+ }
+ parse() {
+ const declarations = [];
+ this.separators();
+ while (!this.is('eof')) {
+ const token = this.expect('name', 'declaration');
+ const kind = token.value;
+ if (kind === 'state') {
+ const name = this.expect('name', 'state name'); this.expect('=');
+ let sign = 1;
+ if (this.is('-') || this.is('+')) sign = this.take().kind === '-' ? -1 : 1;
+ const value = this.expect('number', 'numeric state initializer');
+ declarations.push({ kind, token, name, initial: sign * value.value });
+ } else if (kind === 'input') {
+ const name = this.expect('name', 'input name'); this.expect('=');
+ const key = this.expect('name', 'keyboard code or pointer(view)');
+ if (key.value === 'pointer') {
+ this.expect('(');
+ const target = this.expect('name', 'view ID'); this.expect(')');
+ declarations.push({ kind, token, name, target });
+ this.end(); continue;
+ }
+ if (!/^(?:Space|Enter|Arrow(?:Left|Right|Up|Down)|Key[A-Z]|Digit[0-9])$/.test(key.value)) fail(key, 'Unsupported keyboard code');
+ declarations.push({ kind, token, name, key });
+ } else if (Object.hasOwn(VIEW_FIELDS, kind)) {
+ const name = this.expect('name', 'view ID'); this.expect('(');
+ const expressions = [];
+ for (let i = 0; i < VIEW_FIELDS[kind].length; i++) {
+ if (i) { this.newlines(); this.expect(','); }
+ expressions.push(this.expression(0, true));
+ }
+ this.newlines();
+ let color, prefix = '';
+ if (kind !== 'text') {
+ this.expect(','); this.newlines();
+ const value = this.expect('string', 'literal hex color');
+ if (!/^#[0-9a-fA-F]{6}$/.test(value.value)) fail(value, 'Color must use six hexadecimal digits, such as "#356548"');
+ color = value.value;
+ } else if (this.is(',')) { this.take(); this.newlines(); prefix = this.expect('string', 'literal text prefix').value; }
+ this.newlines(); this.expect(')');
+ declarations.push({ kind: 'view', type: kind, token, name, expressions, color, prefix });
+ } else if (kind === 'on') {
+ const event = this.expect('name', 'input name or frame');
+ let edge;
+ if (event.value !== 'frame') {
+ this.expect('.'); edge = this.expect('name', 'down, up, move or cancel');
+ if (!['down', 'up', 'move', 'cancel'].includes(edge.value)) fail(edge, 'Expected down or up, move or cancel');
+ }
+ declarations.push({ kind: 'handler', token, event, edge, body: this.block() });
+ } else fail(token, `Unknown declaration ${kind}`);
+ this.end();
+ }
+ return declarations;
+ }
+}
+
+export function compile(source) {
+ if (typeof source !== 'string') fail({ line: 1, column: 1 }, 'Source must be a string');
+ const sourceBytes = new TextEncoder().encode(source).length;
+ if (sourceBytes > LIMITS.sourceBytes) fail({ line: 1, column: 1 }, 'Source exceeds 32768 UTF-8 bytes');
+ const declarations = new Parser(lex(source)).parse();
+ const program = { states: [], inputs: [], instructions: [], handlers: [], views: [], metrics: {} };
+ const states = new Map(), inputs = new Map(), views = new Set(), keys = new Set(), targets = new Set();
+ let handlerCount = 0;
+ for (const declaration of declarations) {
+ const { kind, name, token } = declaration;
+ if (name && RESERVED.has(name.value)) fail(name, `Reserved name ${name.value}`);
+ if (kind === 'state' || kind === 'input') {
+ const symbols = kind === 'state' ? states : inputs;
+ if (symbols.has(name.value)) fail(name, `Duplicate ${kind} ${name.value}`);
+ if (symbols.size >= LIMITS[`${kind}s`]) fail(name, `At most ${LIMITS[`${kind}s`]} ${kind}s are allowed`);
+ symbols.set(name.value, symbols.size);
+ if (kind === 'state') program.states.push({ name: name.value, initial: declaration.initial, line: token.line });
+ else if (declaration.target) {
+ const target = declaration.target;
+ if (targets.has(target.value)) fail(target, `Duplicate pointer target ${target.value}`);
+ targets.add(target.value);
+ program.inputs.push({ name: name.value, target: target.value, line: token.line });
+ } else {
+ if (keys.has(declaration.key.value)) fail(declaration.key, `Duplicate keyboard code ${declaration.key.value}`);
+ keys.add(declaration.key.value);
+ program.inputs.push({ name: name.value, key: declaration.key.value, line: token.line });
+ }
+ } else if (kind === 'view') {
+ if (views.has(name.value)) fail(name, `Duplicate view ID ${name.value}`);
+ if (views.size >= LIMITS.views) fail(name, 'At most 24 views are allowed');
+ views.add(name.value);
+ } else if (++handlerCount > LIMITS.handlers) fail(token, 'At most 64 handlers are allowed');
+ }
+ for (const declaration of declarations) {
+ if (declaration.target && !views.has(declaration.target.value))
+ fail(declaration.target, `Unknown pointer target ${declaration.target.value}`);
+ }
+ let pointHandler = false;
+ let stack = 0, maxStack = 0;
+ const emit = (op, token, arg = 0, value = 0, delta = 0) => {
+ if (program.instructions.length >= LIMITS.instructions) fail(token, 'At most 512 instructions are allowed');
+ stack += delta;
+ if (stack < 0 || stack > LIMITS.stack) fail(token, 'Expression stack exceeds 64');
+ maxStack = Math.max(maxStack, stack);
+ program.instructions.push({ op, arg, value, line: token.line });
+ };
+ const expression = (node, binding = false) => {
+ const { token } = node;
+ if (node.kind === 'constant') emit(0, token, 0, node.value, 1);
+ else if (node.kind === 'reference') {
+ if (node.name === 'event_x' || node.name === 'event_y') {
+ if (binding || !pointHandler) fail(token, 'event_x and event_y are only available in pointer handlers');
+ emit(node.name === 'event_x' ? 22 : 23, token, 0, 0, 1);
+ } else if (node.name === 'dt') {
+ if (binding) fail(token, 'dt is only available in handlers');
+ emit(2, token, 0, 0, 1);
+ } else {
+ if (!states.has(node.name)) fail(token, `Unknown state ${node.name}`);
+ emit(1, token, states.get(node.name), 0, 1);
+ }
+ } else if (node.kind === 'unary') { expression(node.child, binding); emit(node.op, token); }
+ else if (node.kind === 'binary') {
+ expression(node.left, binding); expression(node.right, binding); emit(node.op, token, 0, 0, -1);
+ } else {
+ for (const arg of node.args) expression(arg, binding);
+ emit(node.op, token, 0, 0, 1 - node.args.length);
+ }
+ };
+ const statements = body => {
+ for (const statement of body) {
+ if (statement.kind === 'assign') {
+ if (!states.has(statement.name)) fail(statement.token, `Unknown state ${statement.name}`);
+ expression(statement.expression);
+ emit(20, statement.token, states.get(statement.name), 0, -1);
+ } else {
+ expression(statement.condition);
+ const branch = program.instructions.length;
+ emit(21, statement.token, 0, 0, -1);
+ statements(statement.body);
+ program.instructions[branch].arg = program.instructions.length;
+ }
+ }
+ };
+ for (const declaration of declarations) {
+ if (declaration.kind === 'handler') {
+ let event = 0;
+ pointHandler = false;
+ if (declaration.event.value !== 'frame') {
+ if (!inputs.has(declaration.event.value)) fail(declaration.event, `Unknown input ${declaration.event.value}`);
+ const index = inputs.get(declaration.event.value), edge = declaration.edge.value;
+ pointHandler = Boolean(program.inputs[index].target);
+ if (!pointHandler && (edge === 'move' || edge === 'cancel')) fail(declaration.edge, 'move and cancel require a pointer input');
+ event = edge === 'move' ? 17 + index : edge === 'cancel' ? 25 + index : 1 + index * 2 + (edge === 'up' ? 1 : 0);
+ }
+ const start = program.instructions.length;
+ stack = 0; statements(declaration.body);
+ program.handlers.push({ event, start, length: program.instructions.length - start, line: declaration.token.line });
+ } else if (declaration.kind === 'view') {
+ const view = { id: declaration.name.value, type: declaration.type, line: declaration.token.line, bindings: {} };
+ if (view.type === 'text') view.prefix = declaration.prefix;
+ else view.color = declaration.color;
+ declaration.expressions.forEach((node, index) => {
+ const start = program.instructions.length;
+ stack = 0; expression(node, true);
+ view.bindings[VIEW_FIELDS[view.type][index]] = { start, length: program.instructions.length - start };
+ });
+ program.views.push(view);
+ }
+ }
+ program.metrics = { sourceBytes, sourceLines: source === '' ? 0 : source.split('\n').length,
+ states: states.size, inputs: inputs.size, views: views.size,
+ handlers: program.handlers.length, instructions: program.instructions.length, maxStack };
+ return program;
+}
+
+export { LIMITS };
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/before/web/core.wasm b/prototypes/workbench/results/2026-09-23-shared-rules/before/web/core.wasm
new file mode 100644
index 0000000..f657ed7
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/before/web/core.wasm
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/before/web/runtime.mjs b/prototypes/workbench/results/2026-09-23-shared-rules/before/web/runtime.mjs
new file mode 100644
index 0000000..8a9fdce
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/before/web/runtime.mjs
@@ -0,0 +1,324 @@
+import { compile } from './compiler.mjs';
+import { validateDocument } from './project.mjs';
+
+const messages = [
+ '', '실행기의 용량 한도를 넘었습니다.', '실행 정의가 유효하지 않습니다.',
+ '계산 스택의 범위를 벗어났습니다.', '0으로 나눌 수 없습니다.',
+ '계산 결과가 유한한 수의 범위를 벗어났습니다.', '조건 분기 범위가 유효하지 않습니다.',
+ '존재하지 않는 상태를 참조했습니다.', '시간 간격이 유효하지 않습니다.',
+ '정의되지 않은 입력 사건입니다.',
+];
+const sourceLimit = 32768;
+
+function problem(message, line = null, column = null) {
+ return Object.assign(new Error(message), { line, column });
+}
+
+function freezeProgram(value) {
+ if (value && typeof value === 'object') {
+ for (const child of Object.values(value)) freezeProgram(child);
+ Object.freeze(value);
+ }
+ return value;
+}
+
+export async function createRuntime(wasmBytes, evaluationOptions = {}) {
+ if (wasmBytes === undefined) {
+ const response = await fetch(new URL('./core.wasm', import.meta.url), { cache: 'no-store' });
+ if (!response.ok) throw new Error(`실행기를 불러오지 못했습니다 (HTTP ${response.status}).`);
+ wasmBytes = await response.arrayBuffer();
+ }
+ const module = await WebAssembly.compile(wasmBytes);
+ if (WebAssembly.Module.imports(module).length) throw new Error('이 실행기는 호스트 함수 없이 동작해야 합니다.');
+ const { exports: core } = await WebAssembly.instantiate(module, {});
+ if (typeof core.eval_bounded !== 'function' || typeof core.abort_program !== 'function' || typeof core.dispatch_point !== 'function' || typeof core.put_restored_value !== 'function' || typeof core.commit_restored_program !== 'function')
+ throw new Error('실행기 버전이 맞지 않습니다. 새로고침하거나 core.wasm을 다시 빌드하세요.');
+ core.reset_all();
+ let program = null;
+ let appliedSource = '';
+ let lastEvent = null;
+ // Keep a failed event visible through its release/focus-loss cleanup.
+ // The active program can clear this diagnostic by apply or explicit reset.
+ let lastFailure = null;
+ const held = new Map();
+ const points = new Map();
+ let presenting = false;
+ let bindings = [];
+ // One bounded publication buffer, never exposed to callers. No state mirror.
+ let displayValues = null, displayValid = null;
+ function available() {
+ if (presenting) throw new Error('출력 전달 중에는 실행기를 다시 호출할 수 없습니다.');
+ }
+ let evaluationPolicy = { cacheEntries: 0, historyEntries: 8, workLimit: 2048 };
+
+ function checked(code, line = null) {
+ if (code !== 0) throw problem(messages[code] || `실행기 오류 ${code}`, line);
+ }
+ function inputIndex(name) {
+ const index = program?.inputs.findIndex((input) => input.name === name) ?? -1;
+ if (index < 0) throw new Error(`정의되지 않은 입력: ${name}`);
+ return index;
+ }
+ function dispatch(event, name, phase, source, point = null) {
+ const status = point ? core.dispatch_point(event, point.x, point.y) : core.dispatch(event, event === 0 ? 1 / 60 : 0);
+ const pc = core.info(2) >>> 0;
+ lastEvent = { name, phase, source, status,
+ line: program?.instructions[pc]?.line ?? null, ops: core.info(3) >>> 0 };
+ if (point) { lastEvent.x = point.x; lastEvent.y = point.y; }
+ if (status) lastFailure = { ...lastEvent };
+ return status;
+ }
+
+ function evaluateDisplay() {
+ displayValues ??= new Float64Array(96);
+ displayValid ??= new Uint8Array(24);
+ core.begin_evaluation(evaluationPolicy.workLimit);
+ let error = null, slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ displayValid[i] = 1;
+ for (const [name, binding] of bindings[i]) {
+ const value = core.eval_bounded(binding.start, binding.length);
+ if (core.evaluation_info(0) === 10) {
+ displayValid.fill(0);
+ return { code: 101, line: view.line,
+ message: '표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.' };
+ }
+ if (!Number.isFinite(value) || ((name === 'width' || name === 'height' || name === 'radius') && value < 0)) {
+ error ??= { line: view.line, message: `${view.id}.${name}: 표시할 수 없는 값입니다. 0 나눗셈이나 음수 크기를 확인하세요.` };
+ displayValid[i] = 0;
+ }
+ displayValues[slot++] = value;
+ }
+ }
+ return error;
+ }
+
+ function status(viewError) {
+ const executionError = lastFailure?.status || 0;
+ return {
+ tick: core.info(0) >>> 0, revision: core.info(4) >>> 0,
+ displayError: viewError ? { code: viewError.code || 100, ...viewError } : null,
+ lastError: executionError || (viewError ? viewError.code || 100 : 0),
+ errorMessage: executionError ? messages[executionError] || `실행 오류 ${executionError}` : viewError?.message || '',
+ errorLine: executionError ? lastFailure.line : viewError?.line ?? null,
+ lastOps: core.info(3) >>> 0, successfulEvents: core.info(9) >>> 0,
+ held: [...held.keys()], lastEvent: lastEvent ? { ...lastEvent } : null,
+ lastFailure: lastFailure ? { ...lastFailure } : null,
+ memory: {
+ linearBytes: core.memory.buffer.byteLength,
+ reservedEngineBytes: core.layout(0), programReservedBytes: core.layout(1),
+ instructionBytes: core.layout(2), handlerBytes: core.layout(3),
+ activeIRBytes: core.layout(4), activeStateBytes: core.layout(5),
+ stagingReservedBytes: core.layout(6), evalStackBytes: core.layout(7),
+ },
+ };
+ }
+
+ function load(next, source, restored = null) {
+ const oldIds = new Map(restored ? [] : program?.states.map((state, index) => [state.name, index]) ?? []);
+ checked(core.begin_program(next.states.length, next.inputs.length));
+ try {
+ for (let index = 0; index < next.states.length; index++) {
+ const state = next.states[index];
+ checked(core.put_state(index, state.initial, oldIds.get(state.name) ?? -1), state.line);
+ if (restored) checked(core.put_restored_value(index, restored[index]), state.line);
+ }
+ for (const op of next.instructions) checked(core.put_instruction(op.op, op.arg, op.value), op.line);
+ for (const handler of next.handlers) checked(core.put_handler(handler.event, handler.start, handler.length), handler.line);
+ checked(restored ? core.commit_restored_program() : core.commit_program());
+ } catch (error) {
+ core.abort_program();
+ throw error;
+ }
+ program = next;
+ bindings = next.views.map(view => Object.entries(view.bindings));
+ appliedSource = source;
+ lastEvent = null;
+ lastFailure = null;
+ }
+
+ const api = {
+ get currentProgram() { return program; },
+ configureEvaluation({ cacheEntries = evaluationPolicy.cacheEntries,
+ historyEntries = evaluationPolicy.historyEntries, workLimit = evaluationPolicy.workLimit } = {}) {
+ available();
+ for (const [name, value, maximum] of [['cacheEntries', cacheEntries, 32],
+ ['historyEntries', historyEntries, 8], ['workLimit', workLimit, 0xffffffff]]) {
+ if (!Number.isInteger(value) || value < 0 || value > maximum)
+ throw new Error(`${name}: 0~${maximum} 사이의 정수여야 합니다.`);
+ }
+ checked(core.configure_evaluation(cacheEntries, historyEntries));
+ evaluationPolicy = { cacheEntries, historyEntries, workLimit };
+ return { ...evaluationPolicy };
+ },
+ forgetEvaluation(part = 'all') {
+ available();
+ const flags = part === 'all' ? 3 : part === 'values' ? 1 : part === 'history' ? 2 : undefined;
+ if (flags === undefined) throw new Error('삭제할 기록은 values, history, all 중 하나여야 합니다.');
+ core.forget_evaluation(flags);
+ },
+ evaluationStats() {
+ return { ...evaluationPolicy, work: core.evaluation_info(1),
+ instructions: core.evaluation_info(2), cacheHits: core.evaluation_info(3),
+ reservedBytes: core.evaluation_info(6), dedicatedBytes: core.evaluation_info(7),
+ sharedBytes: core.evaluation_info(8), reuseAvailable: Boolean(core.evaluation_info(9)),
+ workspaceTagBytes: core.evaluation_info(10) };
+ },
+ apply(source) {
+ available();
+ if (typeof source !== 'string' || new TextEncoder().encode(source).length > sourceLimit) {
+ throw problem('정의는 UTF-8 기준 32,768바이트 이하의 텍스트여야 합니다.');
+ }
+ if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 적용하세요. 현재 작업은 그대로 유지합니다.');
+ const next = freezeProgram(compile(source));
+ load(next, source);
+ return next;
+ },
+ restoreDocument(document) {
+ available();
+ if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 복원하세요.');
+ const next = validateDocument(document);
+ load(freezeProgram(next.program), next.source, next.values);
+ return { source: next.source, draft: next.draft };
+ },
+ exportDocument(draft = appliedSource) {
+ available();
+ if (!program) throw problem('먼저 코드를 적용하세요.');
+ if (held.size) throw problem('조작을 끝낸 뒤 배치를 저장하세요.');
+ if (lastFailure) throw problem('실행 오류를 해결한 뒤 배치를 저장하세요.');
+ if (typeof draft !== 'string' || new TextEncoder().encode(draft).length > sourceLimit)
+ throw problem('편집 중인 코드는 UTF-8 32 KiB 이하여야 합니다.');
+ return { format: 'pure-workbench', version: 2, source: appliedSource, draft,
+ states: program.states.map((state, i) => {
+ const value = core.get_state(i);
+ return { name: state.name, value: Object.is(value, -0) ? '-0' : value };
+ }) };
+ },
+ reset() {
+ available();
+ held.clear();
+ points.clear();
+ core.reset_state();
+ lastEvent = null;
+ lastFailure = null;
+ },
+ frame(count = 1) {
+ available();
+ if (!Number.isInteger(count) || count < 0 || count > 600) throw new Error('프레임 수는 0~600 사이의 정수여야 합니다.');
+ if (!program) return 0;
+ for (let i = 0; i < count; i++) {
+ const code = dispatch(0, 'frame', 'frame', 'clock');
+ if (code !== 0) return code;
+ }
+ return 0;
+ },
+ down(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (program.inputs[index].target) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.');
+ const sources = held.get(name) || new Set();
+ if (sources.has(source)) return 0;
+ const wasDown = sources.size > 0;
+ sources.add(source);
+ held.set(name, sources);
+ return wasDown ? 0 : dispatch(1 + index * 2, name, 'down', source);
+ },
+ up(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (program.inputs[index].target) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.');
+ const sources = held.get(name);
+ if (!sources?.delete(source)) return 0;
+ if (sources.size) return 0;
+ held.delete(name);
+ return dispatch(2 + index * 2, name, 'up', source);
+ },
+ point(name, phase, x, y, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (!program.inputs[index].target) throw new Error('포인터에 연결된 입력이 아닙니다.');
+ if (!['down', 'move', 'up', 'cancel'].includes(phase)) throw new Error('포인터 단계는 down, move, up, cancel 중 하나여야 합니다.');
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error('포인터 좌표는 유한한 숫자여야 합니다.');
+ if (typeof source !== 'string' || source.length === 0) throw new Error('포인터 출처 이름이 필요합니다.');
+ let point = points.get(name);
+ if (phase === 'down') {
+ if (point?.source === source) return 0;
+ if (point) throw new Error('이 입력은 다른 포인터가 사용 중입니다.');
+ point = {source, x, y};
+ points.set(name, point);
+ held.set(name, new Set([source]));
+ } else {
+ if (!point || point.source !== source) return 0;
+ point.x = x; point.y = y;
+ if (phase === 'up' || phase === 'cancel') { points.delete(name); held.delete(name); }
+ }
+ const event = phase === 'move' ? 17 + index : phase === 'cancel' ? 25 + index : 1 + index * 2 + (phase === 'up' ? 1 : 0);
+ return dispatch(event, name, phase, source, point);
+ },
+ releaseAll() {
+ available();
+ let firstError = 0;
+ for (const [name, point] of [...points]) {
+ const status = api.point(name, 'cancel', point.x, point.y, point.source);
+ if (!firstError) firstError = status;
+ }
+ for (const [name, sources] of [...held]) {
+ for (const source of [...sources]) {
+ const status = api.up(name, source);
+ if (!firstError) firstError = status;
+ }
+ }
+ return firstError;
+ },
+ exportProject() {
+ return { format: 'pure-workbench', version: 1, source: appliedSource };
+ },
+ // Synchronous trusted output adapter. Callbacks run only after the whole
+ // display has been evaluated. Runtime mutation/reentrant reads are refused.
+ // Exceptions from an adapter propagate; the next call republishes all values.
+ present(target) {
+ available();
+ presenting = true;
+ try {
+ const { begin, state, view, value, end } = target;
+ for (const callback of [begin, state, view, value, end])
+ if (typeof callback !== 'function') throw new TypeError('출력 어댑터 함수가 필요합니다.');
+ const error = evaluateDisplay();
+ begin.call(target, program);
+ for (let i = 0; i < (program?.states.length ?? 0); i++) state.call(target, i, core.get_state(i));
+ let slot = 0, visible = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const valid = Boolean(displayValid[i]);
+ view.call(target, i, valid);
+ for (const [name] of bindings[i]) {
+ if (valid) value.call(target, i, name, displayValues[slot]);
+ slot++;
+ }
+ if (valid) visible++;
+ }
+ end.call(target, visible);
+ return status(error);
+ } finally { presenting = false; }
+ },
+ snapshot() {
+ available();
+ const error = evaluateDisplay();
+ const states = Object.fromEntries((program?.states ?? []).map((state, index) => [state.name, core.get_state(index)]));
+ const views = [];
+ let slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ const rendered = displayValid[i] ? { id: view.id, type: view.type, color: view.color, prefix: view.prefix } : null;
+ for (const [name] of bindings[i]) {
+ if (rendered) rendered[name] = displayValues[slot];
+ slot++;
+ }
+ if (rendered) views.push(rendered);
+ }
+ return { states, views, ...status(error) };
+ },
+ };
+ api.configureEvaluation(evaluationOptions);
+ return Object.freeze(api);
+}
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/build.json b/prototypes/workbench/results/2026-09-23-shared-rules/build.json
new file mode 100644
index 0000000..b294d5d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/build.json
@@ -0,0 +1,23 @@
+{
+ "builtUtc": "2026-09-23T07:05:16.331700+00:00",
+ "zigVersion": "0.15.2",
+ "sourceSha256": "630aee1cc20b5803657f75e541cb6b706e2b99dd00218ac71afd78b82259621b",
+ "wasmSha256": "2b1b6d2bbf9005f5c13275d6013cc4c21c155c7474d334c470fcb11094818b52",
+ "evaluationSourceSha256": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "wasmFileBytes": 58440,
+ "flags": [
+ "-target",
+ "wasm32-freestanding",
+ "-O",
+ "ReleaseSafe",
+ "-fstrip",
+ "-fno-entry",
+ "-rdynamic",
+ "--export-memory",
+ "--stack",
+ "16384",
+ "--initial-memory=65536",
+ "--max-memory=65536"
+ ],
+ "nativeTests": "1/32 core.test.blank program, no hidden application behavior, and reset...OK\n2/32 core.test.arbitrary input counter and unused events...OK\n3/32 core.test.charge and release behavior exists only in the authored instruction program...OK\n4/32 core.test.forward condition executes once and assignments and handlers are ordered...OK\n5/32 core.test.branch to program end is valid...OK\n6/32 core.test.one event rolls back writes from all handlers on arithmetic failure...OK\n7/32 core.test.nonfinite arithmetic and invalid clamp fail atomically...OK\n8/32 core.test.stack underflow overflow and leftover values reject the event...OK\n9/32 core.test.failed candidates leave active values program revision and diagnostics unchanged...OK\n10/32 core.test.state preservation mapping reads active values at commit and reset uses new initials...OK\n11/32 core.test.capacity boundaries and sticky load errors...OK\n12/32 core.test.invalid declaration values state mappings event ids and handler ranges...OK\n13/32 core.test.backward self out-of-handler branches and stores without handlers are rejected...OK\n14/32 core.test.event inputs and dt enforce deterministic frame contract...OK\n15/32 core.test.binding evaluation is pure on both success and failure...OK\n16/32 core.test.arithmetic comparison logical min max and clamp expression semantics...OK\n17/32 core.test.diagnostic counters wrap without affecting scalar values...OK\n18/32 core.test.memory accounting separates reserved storage used IR and stack capacity...OK\n19/32 core.test.point coordinates are ephemeral event operands and unavailable to pure evaluation...OK\n20/32 core.test.point event ranges reject holes and invalid payloads without committing...OK\n21/32 core.test.point event failure rolls back writes from earlier matching handlers...OK\n22/32 core.test.loading shares storage with records while old events and bindings remain executable...OK\n23/32 core.test.failed and abandoned candidates restore empty reuse storage without changing active work...OK\n24/32 core.test.full pending program survives repeated evaluation configuration record loss and reset...OK\n25/32 core.test.restored definition and values commit together without changing reset initializers...OK\n26/32 core.test.missing duplicate nonfinite or invalid restored candidates leave active work untouched...OK\n27/32 core.test.empty and full restored programs validate all values and discard old display records...OK\n28/32 core.test.shared handler bodies access their own bound state slots in source order...OK\n29/32 core.test.failure in a later shared binding rolls back all earlier bindings...OK\n30/32 core.test.shared bindings reject missing arguments partial overlaps and invalid slot mappings...OK\n31/32 evaluation.test.record loss cannot authorize an old memo; unrelated changes can...OK\n32/32 evaluation.test.budget, collisions, invalid settings, and disabled history are conservative...OK\nAll 32 tests passed.\n"
+}
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/manifest.json b/prototypes/workbench/results/2026-09-23-shared-rules/manifest.json
new file mode 100644
index 0000000..9e8f06e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/manifest.json
@@ -0,0 +1,21 @@
+{
+ "recordedUtc": "2026-09-23T07:12:25.660651+00:00",
+ "purpose": "Shared-body representation and behavior validation; no performance benchmark",
+ "sha256": {
+ "core.zig": "630aee1cc20b5803657f75e541cb6b706e2b99dd00218ac71afd78b82259621b",
+ "evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "web/compiler.mjs": "033227dcee1ecfe79aded2a3b5dd5f28b896377e27671347767ef99e2eb0cf56",
+ "web/runtime.mjs": "d4d8fa382f501e1cdb293a1079e5dcf3cf4e65ccfc860f800b51e32cc098cf51",
+ "web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35",
+ "web/app.js": "d5e062f9eab74f7e2bd6ab9f44e1217b12f2c17537054f5f7e4ec55b96801a09",
+ "web/index.html": "729945ec20857df6fcb827d8b7ef247d21d427f22debf9018fbb99401e9c7e33",
+ "web/scene-input.mjs": "a7de0d0c26328fa6885b1d1633783f1550acfa4a2233db604b0ce4646e633a25",
+ "web/core.wasm": "2b1b6d2bbf9005f5c13275d6013cc4c21c155c7474d334c470fcb11094818b52",
+ "examples/shared-layout.pure": "be8296244b768f9adda99e7afc89249f34853fa27124a277aecb0841eb8176eb",
+ "rules.test.mjs": "caab0cb2618a39c7dcd547b402561d8f9edb11acc7cf389ae16761b70464db7b",
+ "runtime.test.mjs": "1b9bdbae7379fcdd106cebf1acc6454839fd0e989d7ce31bfd4924fb8501cb8a",
+ "workspace.test.mjs": "7289caf39283041271de39c6ac58424ba811402f1a12ae13f2c49a019e3ac2af",
+ "qa-shared-rules.js": "700ca81147fae8264bbdaf8aee83fc1070a75ab7bccc4ae54ad228a1dc06242e",
+ "CONTRACT.md": "8ab46e0e420822375ba01509fecc662c457cb2405e6b35b034f78167d6dc26b3"
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/pure-shared-layout-desktop.png b/prototypes/workbench/results/2026-09-23-shared-rules/pure-shared-layout-desktop.png
new file mode 100644
index 0000000..227a270
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/pure-shared-layout-desktop.png
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/pure-shared-layout-mobile.png b/prototypes/workbench/results/2026-09-23-shared-rules/pure-shared-layout-mobile.png
new file mode 100644
index 0000000..ad8ff54
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/pure-shared-layout-mobile.png
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/pure-shared-layout-saved.json b/prototypes/workbench/results/2026-09-23-shared-rules/pure-shared-layout-saved.json
new file mode 100644
index 0000000..c9d066f
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/pure-shared-layout-saved.json
@@ -0,0 +1,108 @@
+{
+ "format": "pure-workbench",
+ "version": 2,
+ "source": "# 대상별 값은 item에, 공통 반응은 rule에 한 번만 작성합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag_green = pointer(green_box)\ninput drag_blue = pointer(blue_box)\ninput drag_amber = pointer(amber_box)\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag_green.down use grab(green)\non drag_green.move use move(green)\non drag_green.up use drop(green)\non drag_green.cancel use cancel(green)\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non drag_blue.down use grab(blue)\non drag_blue.move use move(blue)\non drag_blue.up use drop(blue)\non drag_blue.cancel use cancel(blue)\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non drag_amber.down use grab(amber)\non drag_amber.move use move(amber)\non drag_amber.up use drop(amber)\non drag_amber.cancel use cancel(amber)\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n",
+ "draft": "# 대상별 값은 item에, 공통 반응은 rule에 한 번만 작성합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag_green = pointer(green_box)\ninput drag_blue = pointer(blue_box)\ninput drag_amber = pointer(amber_box)\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag_green.down use grab(green)\non drag_green.move use move(green)\non drag_green.up use drop(green)\non drag_green.cancel use cancel(green)\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non drag_blue.down use grab(blue)\non drag_blue.move use move(blue)\non drag_blue.up use drop(blue)\non drag_blue.cancel use cancel(blue)\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non drag_amber.down use grab(amber)\non drag_amber.move use move(amber)\non drag_amber.up use drop(amber)\non drag_amber.cancel use cancel(amber)\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n\nstate unfinished =",
+ "states": [
+ {
+ "name": "selected",
+ "value": 3
+ },
+ {
+ "name": "green.x",
+ "value": 150.00000659728084
+ },
+ {
+ "name": "green.y",
+ "value": 169.9999983506798
+ },
+ {
+ "name": "green.ox",
+ "value": 9.999986805438311
+ },
+ {
+ "name": "green.oy",
+ "value": 10.00000164932024
+ },
+ {
+ "name": "green.sx",
+ "value": 80
+ },
+ {
+ "name": "green.sy",
+ "value": 120
+ },
+ {
+ "name": "green.held",
+ "value": 0
+ },
+ {
+ "name": "green.id",
+ "value": 1
+ },
+ {
+ "name": "blue.x",
+ "value": 350.00000659728073
+ },
+ {
+ "name": "blue.y",
+ "value": 209.9999983506798
+ },
+ {
+ "name": "blue.ox",
+ "value": 9.999980208157467
+ },
+ {
+ "name": "blue.oy",
+ "value": 10.00000329864045
+ },
+ {
+ "name": "blue.sx",
+ "value": 280
+ },
+ {
+ "name": "blue.sy",
+ "value": 160
+ },
+ {
+ "name": "blue.held",
+ "value": 0
+ },
+ {
+ "name": "blue.id",
+ "value": 2
+ },
+ {
+ "name": "amber.x",
+ "value": 509.999947221753
+ },
+ {
+ "name": "amber.y",
+ "value": 260.0000016493202
+ },
+ {
+ "name": "amber.ox",
+ "value": 10.000013194561802
+ },
+ {
+ "name": "amber.oy",
+ "value": 9.999998350679817
+ },
+ {
+ "name": "amber.sx",
+ "value": 450
+ },
+ {
+ "name": "amber.sy",
+ "value": 220
+ },
+ {
+ "name": "amber.held",
+ "value": 0
+ },
+ {
+ "name": "amber.id",
+ "value": 3
+ }
+ ]
+}
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/qa-browser.js b/prototypes/workbench/results/2026-09-23-shared-rules/qa-browser.js
new file mode 100644
index 0000000..dea4a6e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/qa-browser.js
@@ -0,0 +1,228 @@
+// Run this expression with Playwright CLI run-code after opening the local host.
+// All behavior is authored through the editor; snapshot() only observes results.
+async (page) => {
+ const checks = [];
+ const errors = [];
+ const onError = error => errors.push(error.message);
+ const acceptDialog = dialog => dialog.accept();
+ const check = (condition, label) => {
+ if (!condition) throw new Error(`Check ${checks.length + 1} failed: ${label}`);
+ checks.push(label);
+ };
+ const state = () => page.evaluate(() => window.pureWorkbench.snapshot());
+ const apply = async text => { await page.locator('#source').fill(text); await page.locator('#apply').click(); };
+ const authorSource = [
+ '# A new authored interaction, not a bundled example.',
+ 'state x = 90', 'state moving = 0', 'state count = 0',
+ 'input advance = ArrowRight', 'input pulse = Space',
+ 'box marker(x, 170, 48, 48, "#7ed9ae")',
+ 'text reading(40, 70, count, "Signals: ")',
+ 'on advance.down { moving = 1 }', 'on advance.up { moving = 0 }',
+ 'on pulse.down { count = count + 1 }',
+ 'on frame { x = clamp(x + moving * 120 * dt, 0, 580) }', '',
+ ].join('\n');
+ let lastAuthored = null;
+ let mobile = null;
+ let downloaded = null;
+ const shots = ['output/playwright/pure-shared-workbench-blank.png', 'output/playwright/pure-shared-workbench-authored.png', 'output/playwright/pure-shared-workbench-mobile.png'];
+ page.on('pageerror', onError);
+ page.on('dialog', acceptDialog);
+ try {
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.reload();
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ let s = await state();
+ check(s.source === '' && s.appliedSource === '' && !s.dirty, 'initial editor has literally empty source');
+ check(s.views.length === 0 && Object.keys(s.states).length === 0 && s.held.length === 0 && !s.running, 'initial scene has no built-in entities, states, inputs, or running clock');
+ check(await page.locator('#input-controls button').count() === 0 && await page.locator('#play').isDisabled(), 'controls require an authored program');
+ await page.screenshot({ path: shots[0], fullPage: true });
+
+ await page.locator('#source').fill(authorSource);
+ s = await state();
+ check(s.dirty && s.views.length === 0 && s.appliedSource === '', 'authoring a new program is a draft until Apply');
+ await page.locator('#apply').click();
+ s = await state();
+ check(!s.dirty && s.appliedSource === authorSource && s.views.length === 2 && s.states.x === 90 && s.states.count === 0, 'new custom program constructs states and multiple views from source');
+ check(await page.locator('#input-controls button').count() === 2 && await page.locator('[data-input="advance"]').count() === 1 && await page.locator('[data-input="pulse"]').count() === 1, 'input buttons are generated from the authored declarations');
+ check(await page.locator('#scene-content [data-view-id="marker"]').getAttribute('x') === '90', 'view bindings render the authored state value');
+
+ await page.locator('#source').focus();
+ await page.keyboard.press('End');
+ await page.keyboard.press('Space');
+ await page.keyboard.press('ArrowRight');
+ check((await state()).states.count === 0 && (await state()).states.moving === 0, 'Space and arrows in the editor never reach authored inputs');
+ await page.locator('#source').fill(authorSource);
+
+ const pulse = page.locator('[data-input="pulse"]');
+ let box = await pulse.boundingBox();
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ s = await state();
+ check(s.states.count === 1 && s.held.includes('pulse') && !s.running && s.tick === 0, 'pointer down dispatches once while the clock is paused');
+ await page.mouse.up();
+ check((await state()).held.length === 0 && (await state()).states.count === 1, 'pointer up releases without double dispatch from click');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('Space');
+ await page.keyboard.down('Space');
+ await page.keyboard.up('Space');
+ check((await state()).states.count === 2, 'keyboard repeat is ignored');
+ await pulse.focus();
+ await page.keyboard.press('Enter');
+ check((await state()).states.count === 3 && (await state()).held.length === 0, 'keyboard activation on a generated button is one complete tap');
+ await pulse.dispatchEvent('click', { detail: 0 });
+ check((await state()).states.count === 4 && (await state()).held.length === 0, 'assistive click activation is one complete tap');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('Space');
+ box = await pulse.boundingBox();
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ check((await state()).states.count === 5 && (await state()).held.includes('pulse'), 'keyboard and pointer sources share one logical input');
+ await page.mouse.up();
+ check((await state()).held.includes('pulse'), 'releasing one source does not release the other source');
+ await page.keyboard.up('Space');
+ check((await state()).held.length === 0, 'last source releases the logical input');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ await page.locator('#step').click();
+ await page.locator('#step').click();
+ s = await state();
+ check(s.states.x === 94 && s.states.moving === 1 && s.tick === 2, 'authored down/frame behavior uses the declared dt without built-in motion');
+ await page.locator('#source').focus();
+ await page.keyboard.up('ArrowRight');
+ check((await state()).states.moving === 0 && (await state()).held.length === 0, 'keyup releases even after focus moves into the editor');
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ await page.evaluate(() => window.dispatchEvent(new Event('blur')));
+ await page.keyboard.up('ArrowRight');
+ check((await state()).states.moving === 0 && !(await state()).running && (await state()).held.length === 0, 'focus loss sends release and pauses the clock');
+
+ await page.locator('#play').click();
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().tick >= 5);
+ await page.locator('#play').click();
+ const paused = await state();
+ await page.waitForTimeout(100);
+ check(!paused.running && paused.tick === (await state()).tick && paused.tick >= 5, 'Play advances and Pause freezes the clock');
+
+ const beforeFailure = await state();
+ await apply(authorSource + 'state broken =\n');
+ s = await state();
+ check(s.appliedSource === authorSource && s.revision === beforeFailure.revision && JSON.stringify(s.states) === JSON.stringify(beforeFailure.states), 'invalid code preserves the previous executable program and states');
+ check(await page.locator('#error-location').isVisible() && /행/.test(s.diagnostic) && await page.locator('#source').getAttribute('aria-invalid') === 'true', 'compile error exposes a source-line diagnostic');
+ await page.locator('#error-location').click();
+ check(await page.locator('#source').evaluate(el => el.selectionStart >= el.value.indexOf('state broken')), 'diagnostic location moves the editor caret to the problem');
+
+ const expanded = authorSource.replace('state x = 90', 'state x = 500').replace('count = count + 1', 'count = count + 5') + [
+ 'state bonus = 7', 'input reverse = KeyA',
+ 'circle beacon(300, 190, 15 + bonus, "#c9f08b")',
+ 'on reverse.down { bonus = bonus + 2 }', '',
+ ].join('\n');
+ await apply(expanded);
+ s = await state();
+ check(s.states.x === beforeFailure.states.x && s.states.count === 5 && s.states.bonus === 7 && s.views.length === 3, 'apply preserves matching state names and initializes newly authored states');
+ await page.locator('[data-input="reverse"]').click();
+ check((await state()).states.bonus === 9 && await page.locator('#scene-content [data-view-id="beacon"]').getAttribute('r') === '24', 'new input, entity, binding, and behavior work with source changes alone');
+ await page.locator('[data-input="pulse"]').click();
+ check((await state()).states.count === 10, 'changed behavior takes effect without resetting existing values');
+
+ const withDraft = expanded + '# This un-applied draft must survive Reset.\n';
+ await page.locator('#source').fill(withDraft);
+ await page.locator('#reset').click();
+ s = await state();
+ check(s.states.x === 500 && s.states.count === 0 && s.states.bonus === 7 && s.tick === 0 && s.source === withDraft && s.dirty, 'explicit Reset restores active initializers and keeps the draft');
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ const heldRevision = (await state()).revision;
+ await page.locator('#apply').click();
+ check((await state()).revision === heldRevision && /놓은/.test((await state()).diagnostic), 'Apply is refused while an input is held');
+ await page.keyboard.up('ArrowRight');
+
+ const downloadPromise = page.waitForEvent('download');
+ await page.locator('#download-project').click();
+ const projectDownload = await downloadPromise;
+ const downloadPath = 'output/playwright/pure-shared-workbench-draft.json';
+ await projectDownload.saveAs(downloadPath);
+ downloaded = { filename: projectDownload.suggestedFilename(), path: downloadPath, expectedSource: withDraft };
+ await page.locator('#new').click();
+ s = await state();
+ check(s.source === '' && s.views.length === 3 && s.appliedSource === expanded, 'clear editor is explicit and does not clear the running scene before Apply');
+ await page.locator('#file-input').setInputFiles(downloadPath);
+ await page.waitForFunction(expected => window.pureWorkbench.snapshot().source === expected, withDraft);
+ check((await state()).source === withDraft && (await state()).appliedSource === expanded && (await state()).dirty, 'downloaded project restores the exact un-applied draft without execution');
+ await page.locator('#file-input').evaluate(input => {
+ const transfer = new DataTransfer();
+ transfer.items.add(new File(['{"format":"other","version":1,"source":"state no = 1"}'], 'invalid.json', { type: 'application/json' }));
+ input.files = transfer.files;
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ });
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().diagnostic.includes('형식'));
+ check((await state()).source === withDraft && (await state()).appliedSource === expanded, 'invalid JSON project schema cannot replace the draft');
+
+ await apply('state n = 1\ninput fail = Space\ntext label(40, 60, n)\non fail.down { n = n / 0 }\n');
+ await page.locator('[data-input="fail"]').dispatchEvent('click', { detail: 0 });
+ s = await state();
+ check(s.states.n === 1 && s.lastError === 4 && s.held.length === 0 && s.lastEvent.phase === 'up', 'failed assistive down event rolls back and its error survives the following successful up');
+ check(await page.locator('#runtime-error').isVisible() && (await page.locator('#runtime-error').textContent()).includes('마지막 실패'), 'latched runtime failure is visible and labeled separately from the latest input');
+ await page.locator('#reset').click();
+ check((await state()).lastError === 0 && await page.locator('#runtime-error').isHidden(), 'explicit Reset clears the failure record');
+
+ await page.locator('#new').click();
+ await page.locator('#apply').click();
+ s = await state();
+ check(s.source === '' && s.appliedSource === '' && s.views.length === 0 && Object.keys(s.states).length === 0 && await page.locator('#input-controls button').count() === 0, 'applying empty source removes all authored states, inputs, and scene contents');
+ await page.locator('#example').selectOption('counter');
+ await page.locator('#load-example').click();
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().source.includes('state count'));
+ check((await state()).views.length === 0 && (await state()).appliedSource === '' && (await state()).dirty, 'explicit example loading only fills the editor and never auto-applies');
+
+ await apply(authorSource);
+ await page.locator('[data-input="pulse"]').click();
+ await page.locator('[data-input="pulse"]').click();
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ for (let i = 0; i < 12; i++) await page.locator('#step').click();
+ await page.keyboard.up('ArrowRight');
+ lastAuthored = await state();
+ check(lastAuthored.states.x === 114 && lastAuthored.states.count === 2 && lastAuthored.memory.linearBytes === 65536, 'authored scene executes in the actual fixed 64 KiB Wasm memory');
+ await page.locator('.cost-details summary').click();
+ check((await page.locator('#cost-values').textContent()).includes('65,536 B'), 'cost inspector shows measured Wasm memory without claiming browser heap totals');
+ await page.locator('.cost-details summary').click();
+ await page.screenshot({ path: shots[1], fullPage: true });
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ mobile = await page.evaluate(() => ({ viewport: innerWidth, content: document.documentElement.scrollWidth }));
+ check(mobile.content <= mobile.viewport, '390px mobile viewport has no horizontal overflow');
+ await page.locator('[data-input="pulse"]').click();
+ check((await state()).states.count === 3, 'generated mobile input buttons remain usable');
+ await page.screenshot({ path: shots[2], fullPage: true });
+ const comparison = await page.request.get('http://127.0.0.1:8766/baseline.html');
+ const equivalent = await page.request.get('http://127.0.0.1:8766/examples/charge.pure');
+ check(comparison.ok() && equivalent.ok() && await page.locator('a[href="./baseline.html"]').count() === 1, 'ordinary-JavaScript comparison and equivalent Pure example are linked and served');
+
+ await page.route('**/core.wasm', route => route.abort());
+ await page.reload();
+ await page.waitForFunction(() => document.getElementById('ready-status').textContent.includes('실패'));
+ check(await page.locator('#apply').isDisabled() && await page.locator('#play').isDisabled() && await page.locator('#step').isDisabled() && /실행기를 열지/.test(await page.locator('#diagnostic').textContent()), 'missing Wasm shows an actionable load failure and disables execution controls');
+ await page.unroute('**/core.wasm');
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.reload();
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ check((await state()).source === '' && (await state()).views.length === 0, 'fresh load always returns to a blank authoring environment');
+ check(errors.length === 0, 'no uncaught page errors during the browser scenarios');
+ const evidence = { recordedUtc: new Date().toISOString(), browser: await page.evaluate(() => navigator.userAgent),
+ checks, authoredSource: authorSource, authoredSnapshot: lastAuthored, mobile, downloaded, errors, screenshots: shots };
+ // CLI can yield early when native dialogs/download events occur. Retain the
+ // observation report so a follow-up CLI call can retrieve the complete run.
+ await page.evaluate(value => { window.__pureWorkbenchQAEvidence = value; }, evidence);
+ return evidence;
+ } catch (error) {
+ const evidence = { failed: true, error: error.message, stack: error.stack, checks, errors };
+ await page.evaluate(value => { window.__pureWorkbenchQAEvidence = value; }, evidence);
+ return evidence;
+ } finally {
+ page.off('pageerror', onError);
+ page.off('dialog', acceptDialog);
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/representation.json b/prototypes/workbench/results/2026-09-23-shared-rules/representation.json
new file mode 100644
index 0000000..4719545
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/representation.json
@@ -0,0 +1,403 @@
+{
+ "purpose": "Compiled representation accounting, not a throughput/latency benchmark",
+ "examples": {
+ "layout": {
+ "metrics": {
+ "sourceBytes": 2530,
+ "sourceLines": 93,
+ "states": 22,
+ "inputs": 7,
+ "views": 5,
+ "handlers": 16,
+ "instructions": 319,
+ "maxStack": 3
+ },
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19904,
+ "programReservedBytes": 9648,
+ "instructionBytes": 16,
+ "handlerBytes": 16,
+ "activeIRBytes": 5360,
+ "activeStateBytes": 176,
+ "stagingReservedBytes": 9912,
+ "evalStackBytes": 512
+ },
+ "bodies": [
+ {
+ "start": 19,
+ "length": 16
+ },
+ {
+ "start": 35,
+ "length": 14
+ },
+ {
+ "start": 49,
+ "length": 2
+ },
+ {
+ "start": 51,
+ "length": 6
+ },
+ {
+ "start": 57,
+ "length": 16
+ },
+ {
+ "start": 73,
+ "length": 14
+ },
+ {
+ "start": 87,
+ "length": 2
+ },
+ {
+ "start": 89,
+ "length": 6
+ },
+ {
+ "start": 95,
+ "length": 16
+ },
+ {
+ "start": 111,
+ "length": 14
+ },
+ {
+ "start": 125,
+ "length": 2
+ },
+ {
+ "start": 127,
+ "length": 6
+ },
+ {
+ "start": 133,
+ "length": 48
+ },
+ {
+ "start": 181,
+ "length": 45
+ },
+ {
+ "start": 226,
+ "length": 48
+ },
+ {
+ "start": 274,
+ "length": 45
+ }
+ ],
+ "bindings": [
+ {
+ "event": 9,
+ "start": 19,
+ "length": 16
+ },
+ {
+ "event": 21,
+ "start": 35,
+ "length": 14
+ },
+ {
+ "event": 10,
+ "start": 49,
+ "length": 2
+ },
+ {
+ "event": 29,
+ "start": 51,
+ "length": 6
+ },
+ {
+ "event": 11,
+ "start": 57,
+ "length": 16
+ },
+ {
+ "event": 22,
+ "start": 73,
+ "length": 14
+ },
+ {
+ "event": 12,
+ "start": 87,
+ "length": 2
+ },
+ {
+ "event": 30,
+ "start": 89,
+ "length": 6
+ },
+ {
+ "event": 13,
+ "start": 95,
+ "length": 16
+ },
+ {
+ "event": 23,
+ "start": 111,
+ "length": 14
+ },
+ {
+ "event": 14,
+ "start": 125,
+ "length": 2
+ },
+ {
+ "event": 31,
+ "start": 127,
+ "length": 6
+ },
+ {
+ "event": 1,
+ "start": 133,
+ "length": 48
+ },
+ {
+ "event": 3,
+ "start": 181,
+ "length": 45
+ },
+ {
+ "event": 5,
+ "start": 226,
+ "length": 48
+ },
+ {
+ "event": 7,
+ "start": 274,
+ "length": 45
+ }
+ ]
+ },
+ "shared-layout": {
+ "metrics": {
+ "sourceBytes": 2271,
+ "sourceLines": 76,
+ "states": 25,
+ "inputs": 7,
+ "views": 5,
+ "handlers": 24,
+ "instructions": 117,
+ "maxStack": 3
+ },
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19904,
+ "programReservedBytes": 9648,
+ "instructionBytes": 16,
+ "handlerBytes": 16,
+ "activeIRBytes": 2256,
+ "activeStateBytes": 200,
+ "stagingReservedBytes": 9912,
+ "evalStackBytes": 512
+ },
+ "bodies": [
+ {
+ "start": 19,
+ "length": 16
+ },
+ {
+ "start": 35,
+ "length": 14
+ },
+ {
+ "start": 49,
+ "length": 2
+ },
+ {
+ "start": 51,
+ "length": 6
+ },
+ {
+ "start": 57,
+ "length": 15
+ },
+ {
+ "start": 72,
+ "length": 15
+ },
+ {
+ "start": 87,
+ "length": 15
+ },
+ {
+ "start": 102,
+ "length": 15
+ }
+ ],
+ "bindings": [
+ {
+ "event": 9,
+ "start": 19,
+ "length": 16,
+ "base": 1,
+ "count": 8
+ },
+ {
+ "event": 21,
+ "start": 35,
+ "length": 14,
+ "base": 1,
+ "count": 8
+ },
+ {
+ "event": 10,
+ "start": 49,
+ "length": 2,
+ "base": 1,
+ "count": 8
+ },
+ {
+ "event": 29,
+ "start": 51,
+ "length": 6,
+ "base": 1,
+ "count": 8
+ },
+ {
+ "event": 1,
+ "start": 57,
+ "length": 15,
+ "base": 1,
+ "count": 8
+ },
+ {
+ "event": 3,
+ "start": 72,
+ "length": 15,
+ "base": 1,
+ "count": 8
+ },
+ {
+ "event": 5,
+ "start": 87,
+ "length": 15,
+ "base": 1,
+ "count": 8
+ },
+ {
+ "event": 7,
+ "start": 102,
+ "length": 15,
+ "base": 1,
+ "count": 8
+ },
+ {
+ "event": 11,
+ "start": 19,
+ "length": 16,
+ "base": 9,
+ "count": 8
+ },
+ {
+ "event": 22,
+ "start": 35,
+ "length": 14,
+ "base": 9,
+ "count": 8
+ },
+ {
+ "event": 12,
+ "start": 49,
+ "length": 2,
+ "base": 9,
+ "count": 8
+ },
+ {
+ "event": 30,
+ "start": 51,
+ "length": 6,
+ "base": 9,
+ "count": 8
+ },
+ {
+ "event": 1,
+ "start": 57,
+ "length": 15,
+ "base": 9,
+ "count": 8
+ },
+ {
+ "event": 3,
+ "start": 72,
+ "length": 15,
+ "base": 9,
+ "count": 8
+ },
+ {
+ "event": 5,
+ "start": 87,
+ "length": 15,
+ "base": 9,
+ "count": 8
+ },
+ {
+ "event": 7,
+ "start": 102,
+ "length": 15,
+ "base": 9,
+ "count": 8
+ },
+ {
+ "event": 13,
+ "start": 19,
+ "length": 16,
+ "base": 17,
+ "count": 8
+ },
+ {
+ "event": 23,
+ "start": 35,
+ "length": 14,
+ "base": 17,
+ "count": 8
+ },
+ {
+ "event": 14,
+ "start": 49,
+ "length": 2,
+ "base": 17,
+ "count": 8
+ },
+ {
+ "event": 31,
+ "start": 51,
+ "length": 6,
+ "base": 17,
+ "count": 8
+ },
+ {
+ "event": 1,
+ "start": 57,
+ "length": 15,
+ "base": 17,
+ "count": 8
+ },
+ {
+ "event": 3,
+ "start": 72,
+ "length": 15,
+ "base": 17,
+ "count": 8
+ },
+ {
+ "event": 5,
+ "start": 87,
+ "length": 15,
+ "base": 17,
+ "count": 8
+ },
+ {
+ "event": 7,
+ "start": 102,
+ "length": 15,
+ "base": 17,
+ "count": 8
+ }
+ ]
+ }
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/shared-browser.json b/prototypes/workbench/results/2026-09-23-shared-rules/shared-browser.json
new file mode 100644
index 0000000..49c7b15
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/shared-browser.json
@@ -0,0 +1,106 @@
+{
+ "checks": [
+ "placement save requires an applied definition",
+ "three independently authored targets move and selection directs keyboard adjustment",
+ "placement download is a separate document file",
+ "saving neither applies nor discards the unfinished draft",
+ "reset returns to authored initial positions",
+ "downloaded file restores all states and visible positions",
+ "the draft remains separate from the restored executable definition",
+ "restored work starts stopped with no replayed inputs or clock",
+ "restored selection and bindings remain editable",
+ "a fresh browser page reconstructs the saved work without old runtime state",
+ "invalid saved state cannot partially replace code, draft or scene",
+ "a delayed file read cannot overwrite a newer draft",
+ "a delayed restore cannot overwrite placement changes made while reading",
+ "saving while time advances requests a stopped document",
+ "saving with a held input is refused",
+ "editing the shared rule changes the selected amber target",
+ "the same single rule edit also changes the blue target",
+ "reordering instance declarations preserves every field by qualified name",
+ "rebuilt parameter maps still send selection input to blue after reorder",
+ "simultaneous touches share code but retain independent field bindings",
+ "shared cancel restores each contact to its own start position",
+ "extra persistence control fits a mobile viewport",
+ "no uncaught page errors"
+ ],
+ "errors": [],
+ "saved": "output/playwright/pure-shared-layout-saved.json",
+ "expected": {
+ "states": {
+ "selected": 3,
+ "green.x": 150.00000659728084,
+ "green.y": 169.9999983506798,
+ "green.ox": 9.999986805438311,
+ "green.oy": 10.00000164932024,
+ "green.sx": 80,
+ "green.sy": 120,
+ "green.held": 0,
+ "green.id": 1,
+ "blue.x": 350.00000659728073,
+ "blue.y": 209.9999983506798,
+ "blue.ox": 9.999980208157467,
+ "blue.oy": 10.00000329864045,
+ "blue.sx": 280,
+ "blue.sy": 160,
+ "blue.held": 0,
+ "blue.id": 2,
+ "amber.x": 509.999947221753,
+ "amber.y": 260.0000016493202,
+ "amber.ox": 10.000013194561802,
+ "amber.oy": 9.999998350679817,
+ "amber.sx": 450,
+ "amber.sy": 220,
+ "amber.held": 0,
+ "amber.id": 3
+ },
+ "views": [
+ {
+ "id": "board",
+ "type": "box",
+ "color": "#233a31",
+ "x": 20,
+ "y": 90,
+ "width": 600,
+ "height": 240
+ },
+ {
+ "id": "help",
+ "type": "text",
+ "prefix": "선택한 도형: ",
+ "x": 25,
+ "y": 40,
+ "value": 3
+ },
+ {
+ "id": "green_box",
+ "type": "box",
+ "color": "#c9f08b",
+ "x": 150.00000659728084,
+ "y": 169.9999983506798,
+ "width": 56,
+ "height": 44
+ },
+ {
+ "id": "blue_box",
+ "type": "box",
+ "color": "#74c4d4",
+ "x": 350.00000659728073,
+ "y": 209.9999983506798,
+ "width": 56,
+ "height": 44
+ },
+ {
+ "id": "amber_box",
+ "type": "box",
+ "color": "#dfb184",
+ "x": 509.999947221753,
+ "y": 260.0000016493202,
+ "width": 56,
+ "height": 44
+ }
+ ]
+ },
+ "source": "# 대상별 값은 item에, 공통 반응은 rule에 한 번만 작성합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag_green = pointer(green_box)\ninput drag_blue = pointer(blue_box)\ninput drag_amber = pointer(amber_box)\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag_green.down use grab(green)\non drag_green.move use move(green)\non drag_green.up use drop(green)\non drag_green.cancel use cancel(green)\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non drag_blue.down use grab(blue)\non drag_blue.move use move(blue)\non drag_blue.up use drop(blue)\non drag_blue.cancel use cancel(blue)\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non drag_amber.down use grab(amber)\non drag_amber.move use move(amber)\non drag_amber.up use drop(amber)\non drag_amber.cancel use cancel(amber)\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n",
+ "draft": "# 대상별 값은 item에, 공통 반응은 rule에 한 번만 작성합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag_green = pointer(green_box)\ninput drag_blue = pointer(blue_box)\ninput drag_amber = pointer(amber_box)\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag_green.down use grab(green)\non drag_green.move use move(green)\non drag_green.up use drop(green)\non drag_green.cancel use cancel(green)\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non drag_blue.down use grab(blue)\non drag_blue.move use move(blue)\non drag_blue.up use drop(blue)\non drag_blue.cancel use cancel(blue)\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non drag_amber.down use grab(amber)\non drag_amber.move use move(amber)\non drag_amber.up use drop(amber)\non drag_amber.cancel use cancel(amber)\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n\nstate unfinished ="
+}
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/sources/CONTRACT.md b/prototypes/workbench/results/2026-09-23-shared-rules/sources/CONTRACT.md
new file mode 100644
index 0000000..cfbe597
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/sources/CONTRACT.md
@@ -0,0 +1,437 @@
+# Workbench experiment contract
+
+An author starts with empty source. There is no built-in player, physics, jump,
+score, or timer behavior. All behavior is declared by the author. This is a
+bounded scalar interaction language experiment, not a general language claim.
+
+## Source language
+
+```text
+state count = 0
+input tap = Space
+box tile(80, 150, 60, 60, "#356548")
+circle dot(200, 180, 20, "#c78638")
+text label(80, 100, count, "Count: ")
+on tap.down {
+ count = count + 1
+}
+on frame {
+ if count > 20 {
+ count = 0
+ }
+}
+```
+
+Declarations and statements end at a newline or semicolon; `#` comments outside
+strings. ASCII identifiers, finite numeric literals, state initializers must be
+numeric constants (optional unary sign). Input key codes accepted:
+Space, Enter, ArrowLeft/Right/Up/Down, KeyA..KeyZ, Digit0..Digit9. Input names
+must be unique, key codes unique, names cannot shadow `frame`, `dt`, `min`, `max`,
+`clamp`, `event_x`, `event_y`. State and input names use separate namespaces, entity IDs unique.
+
+Expressions: parentheses, state references, dt (seconds on frame, zero on input),
+unary - and !, + - * /, < <= > >= == !=, && ||, min(a,b), max(a,b), clamp(x,lo,hi).
+Logical operators evaluate both sides. Division by zero/non-finite results fail
+the whole event without committing state changes. No loops, recursion or eval.
+An if without else executes its condition once; assignment statements are ordered.
+Multiple handlers for the same event execute in source order within one atomic event.
+`dt` is allowed in handlers, not view bindings. A frame always uses 1/60 seconds.
+
+box: x,y,width,height expressions + literal hex color; circle: x,y,radius + color;
+text: x,y,value expression + optional literal prefix. Values are world units in a
+640x360 view. No implicit clipping/collision/physics. Render expressions are pure.
+Unknown identifiers and invalid syntax include 1-based line+column diagnostics.
+
+Initial scene is empty. Optional examples are explicit insertions, never auto-run.
+Successful apply preserves scalar states by exact name; new names use initializers,
+removed names disappear. Apply/reset are separate. Failed parse/load changes nothing.
+Apply is refused while any input is held; release first. This avoids inventing
+release events across changed mappings. Focus loss sends .up to ordinary buttons
+and .cancel to pointer inputs.
+
+## Compiler interface (web/compiler.mjs, browser and Node)
+
+`compile(source)` returns:
+```
+{
+ states:[{name,initial,line}], inputs:[{name,key,line} | {name,target,line}],
+ instructions:[{op,arg,value,line}], handlers:[{event,start,length,line}],
+ views:[{id,type,line,color?,prefix?,bindings:{x:{start,length},...}}],
+ metrics:{...}
+}
+```
+Bindings: box x/y/width/height; circle x/y/radius; text x/y/value.
+Compiler throws an Error with .line and .column. Empty source is a valid empty program.
+Handlers and binding expressions all occupy instructions (maximum 512 total), but
+only handlers execute on events; bindings are evaluated on demand by VM eval_at.
+Limits: 32 states, 8 inputs, 24 views, 64 handlers, 512 instructions, stack 64,
+source 32768 UTF-8 bytes. State IDs follow declaration order. Input event IDs:
+frame=0; input i down=1+2*i, up=2+2*i, move=17+i, cancel=25+i.
+Move/cancel handlers require a pointer input in source. States may be referenced before declaration;
+symbols must be resolved after gathering top-level declarations. `metrics` can
+record source bytes/lines and instruction/handler/view/state counts.
+
+## VM opcodes
+
+| op | operation |
+|---:|---|
+|0| push constant `value` |
+|1| push state `arg` |
+|2| push dt |
+|3..6| add, subtract, multiply, divide |
+|7| negate |
+|8..13| less, less-equal, greater, greater-equal, equal, not-equal |
+|14..16| AND, OR, NOT (nonzero true) |
+|17..19| min, max, clamp (push x,lo,hi; invalid lo>hi fails) |
+|20| store state `arg` (pop) |
+|21| jump-if-zero to absolute instruction index `arg` (pop), forward only |
+|22,23| push current pointer event x,y; fail without point context |
+|24,25| load/store field at handler parameter_base + arg; handler-bound only |
+
+Rules start with an empty stack and must end with empty stack. Bindings must finish
+with exactly one value and cannot STORE, branch, or read dt/event coordinates. Branch targets may be handler end.
+
+## Zig exported ABI
+
+`reset_all()` clears programs and state. `begin_program(state_count,input_count)`
+starts staging, returns0 success; `put_state(id,initial,old_id)` old_id=-1 means init;
+`put_instruction(op,arg,value)` append; `put_handler(event,start,length)` append;
+`commit_program()` all0 success otherwise error code, atomic; candidate is separate
+from the active program, but shares its workspace with optional evaluation records.
+Preserved IDs read active values only at successful commit. Validate limits,
+indices, finite values and forward branch ranges; runtime stack errors are checked.
+`reset_state()` restores active initializers, resets tick/event diagnostics.
+`dispatch(event,dt)` accepts0..16 and finite dt; event0 dt must equal1/60, input dt0;
+returns0 success, other code failure with no state mutation. Tick increments for
+successful frame; one event runs all matching handlers in source order.
+`dispatch_point(event,x,y)` accepts existing input down/up IDs plus move/cancel IDs
+within input_count, never frame0. Coordinates must be finite; dt is implicitly0.
+The core only knows input count, not host device kinds. The compiler and host enforce
+pointer ownership and device mapping. Calling legacy dispatch on a coordinate-reading
+handler fails with bad_event9. Point operands are ephemeral execution context, not
+extra persistent state slots. Both dispatch paths use the same atomic commit/history.
+The host requires this new export; older Wasm must be rebuilt.
+`eval_at(start,length)` returns f64 or NaN on invalid/pure-expression failure;
+must not mutate state or event diagnostics. `get_state(id)` returns f64 orNaN.
+`info(id)` u32: 0 tick,1 last_error,2 error_pc (0xffffffff none),3 last_executed_ops,
+4 revision,5 state_count,6 instruction_count,7 handler_count,8 input_count,
+9 successful_event_count. `layout(id)` u32: 0 Engine reserved @sizeOf,
+1 Program reserved @sizeOf,2 Instruction @sizeOf,3 Handler @sizeOf,
+4 active used IR bytes (instructions+handlers),5 active state-value bytes,
+6 staging reserved bytes (subset of Engine),7 evaluation stack capacity bytes.
+`error_message` belongs to host map; core error codes documented in core.zig.
+No allocation or imported runtime in Wasm. Fixed64KiB linear memory target with
+16KiB stack; increase only if actual linker evidence requires and document it.
+
+## Host interface (web/runtime.mjs)
+
+`await createRuntime(wasmBytes?, evaluationOptions?)` fetches core.wasm in browser if omitted.
+Returns object with apply(source), reset(), frame(count=1), down(name,source),
+up(name,source), point(name,phase,x,y,source), releaseAll(), snapshot(), exportProject(), currentProgram.
+Multiple sources per input share one logical held state; down emits only first
+source, up only last. Source strings identify keyboard vs onscreen pointer.
+`apply` returns compiled program or throws with diagnostic. It must fully validate
+in host and stage+commit in core. It preserves by name. `reset` keeps active program.
+`snapshot` returns {states:{name:value}, views:[{id,type,x,y,width?,height?,radius?,
+value?,prefix?,color?}], tick,revision,lastError,errorLine,lastOps,memory:{...},held:[]}
+`currentProgram` is the latest successfully applied compiled program or null.
+The program and all nested metadata are frozen so readers cannot corrupt state
+name mappings or binding ranges. `lastFailure` records the last failed event;
+its `lastError`/line remain visible through successful release or focus cleanup
+until successful apply or explicit reset. `lastEvent` still describes the latest
+event. A view evaluation failure uses host error100 and does not roll back events.
+UI draft and applied code are distinct. Browser/CLI share compiler/runtime/wasm;
+instances are separate, not a live collaborative session. No arbitrary JS evaluation.
+
+## Bounded display evaluation (2026-09-22)
+
+The existing event and float semantics above are unchanged. The forgetful C
+prototype's contract is adapted into `evaluation.zig`; its uint64 DAG is not
+used as a substitute for this VM's float instructions.
+
+Each snapshot starts one display budget, shared by all bindings. Default options
+are `{cacheEntries:0, historyEntries:8, workLimit:2048}`. A unit is a binding
+request, a candidate-cache probe, a change-record probe, or an executed VM
+instruction. State extraction, host object creation, event dispatch and DOM work
+are outside this budget. It is not a time limit or a bound on whole-app work.
+For the current compiler limits, 96 bindings * 10 units plus 512 instructions
+is at most 1,472 units; the default admits even an uncached valid scene with
+cache lookup/validation enabled. Malformed bindings may fail sooner.
+
+`configureEvaluation({cacheEntries?,historyEntries?,workLimit?})` accepts integers
+0..32, 0..8, and 0..4294967295 respectively. Missing fields retain prior settings.
+Valid configuration clears reuse records; invalid configuration changes nothing.
+`forgetEvaluation('values'|'history'|'all')` drops optional records without
+changing authored state, event diagnostics or program identity.
+`evaluationStats()` returns the options plus work, instructions, cacheHits for
+the last snapshot, and reservedBytes for the complete fixed evaluation structure.
+Statistics are separate from snapshots so repeated observations remain equivalent.
+
+Memo entries contain the value, instruction range, input dependency bit mask
+and validated revision. Direct mapping uses one candidate probe, with collisions
+causing recomputation. Only successful computations are cached. Constant results
+need no input-change history. All other reuse requires a complete retained
+history since validation and no intersection with changed states. Dependencies
+are collected from actual VM LOADs, not trusted compiler-supplied annotations.
+History loss causes recomputation. All history and memo storage is fixed size;
+setting capacities to zero does not return reserved linear memory.
+
+Only bitwise-different committed states advance change history (including signed
+zero). Failed events and net-zero updates do not. Successful apply and reset
+discard all reuse records. Parse/host validation failure before staging preserves
+records. Beginning core staging discards them, including if that load is later
+rejected or aborted. Epoch wrap also clears records. No inputs or effectful event
+handlers are replayed to rebuild a binding.
+
+Budget exhaustion returns an empty views array for the entire snapshot, with
+displayError `{code:101,line,message}`. Successfully committed event state remains.
+There is no automatic retry or silent return of an old scene. Raising the budget
+allows a fresh attempt. Arithmetic/size errors retain the earlier per-view error100
+behavior. `displayError` is null on display success; a latched execution error
+still takes precedence in lastError/errorMessage, while displayError independently
+reports display failure. Partial successful binding memos may survive a failed
+snapshot but no partial scene is published.
+
+New core exports: `configure_evaluation(cache,history)` returns 0 or capacity1;
+`forget_evaluation(flags)` bits 1=values, 2=history; `begin_evaluation(limit)`;
+`eval_bounded(start,length)` returns f64 or NaN and consumes that shared budget.
+`evaluation_info(id)` returns 0 status (10=work limit), 1 work, 2 instructions,
+3 cache hits, 4 cache capacity, 5 history capacity, 6 reserved bytes. These
+diagnostics do not replace event info. The unbounded pure `eval_at` remains for
+reference checks and is not the host snapshot path.
+
+## Shared load/reuse workspace (2026-09-23)
+
+The pending definition/restore values and evaluation Tables occupy one untagged union. The active
+Program, current state and evaluation control fields remain separate. All core
+calls are single-threaded and synchronous; no callback can reenter a phase change.
+No pointer to the reused tables is retained between calls.
+
+`begin_program` switches the workspace to staging and drops optional records.
+Until commit/abort, old events and bindings still run against active. Binding
+evaluation bypasses reuse and recomputes within its budget; cache configuration,
+forget and reset must not write the staging payload. Old-id state preservation
+reads the current values at successful commit, including events/reset during load.
+
+Successful commit publishes the new Program and then initializes empty Tables in
+the shared workspace. `abort_program()` discards a pending definition and restores
+empty Tables without changing active program/state/event diagnostics. Inactive
+abort is a no-op. A failed commit leaves staging open for explicit abort or the
+next begin. An invalid begin closes any previous pending load. Host apply aborts
+after a core loading failure. Lost reuse may make a previously warm tiny budget
+insufficient; neither a preserved cache nor completion at that budget is promised.
+
+`evaluation_info` additionally reports 7 dedicated control bytes, 8 shared Tables
+bytes, 9 reuse available (false during load), and 10 union safety-tag overhead.
+The host exposes these as dedicatedBytes, sharedBytes, reuseAvailable and
+workspaceTagBytes. reservedBytes is control + Tables capacity, not incremental
+Engine storage. The Tables capacity overlaps stagingReservedBytes and must not
+be added to it. Before document restoration was added, the Wasm control was 48 B, shared Tables
+1,152 B, union overhead 8 B. That Engine was 19,128 B, only 56 B larger than the
+19,072 B pre-evaluation core; linear memory remains 65,536 B. No OS pages are
+returned and this is not a measurement of browser/process memory.
+
+## Direct output publication (2026-09-23)
+
+`present(target)` evaluates the same bindings, in the same order and shared
+budget as `snapshot()`, then calls a synchronous trusted output adapter:
+
+- `begin(program)`: the frozen current definition, or null at startup.
+- `state(index, value)`: each current authored state, without a host state map.
+- `view(index, valid)`: every declared view's publication status.
+- `value(viewIndex, bindingName, value)`: each binding of a valid view only.
+- `end(visibleCount)`: number of valid views.
+
+It returns the snapshot's diagnostics, held inputs, event information and memory
+report, without `states` or `views`. Evaluation finishes before the first adapter
+callback. On budget error101 every view is invalid and no binding values are
+published; committed state values are still published. Error100 invalidates only
+the affected views, as before. No stale value is silently substituted.
+
+Runtime mutations, `snapshot()` and nested `present()` are rejected during
+callbacks. Read-only program/export/statistics access remains available. Adapters
+must be synchronous and must not throw. If one does throw, the exception
+propagates and the runtime unlocks in a finally block; arbitrary external adapter
+effects cannot be rolled back. A later call republishes every value. This is not
+a transaction across arbitrary output devices or an untrusted plugin boundary.
+
+Evaluation uses a private reusable Float64Array(96) and Uint8Array(24), allocated
+on first snapshot/presentation. Their payload totals 792 host bytes, outside the
+64 KiB Wasm memory. Typed-array object overhead, binding-entry arrays built at
+apply, and DOM destination references are additional host memory. The numeric
+buffer is a publication barrier, not another authoritative state or public
+mutable scene. `snapshot()` remains an independent object representation for
+CLI/export/debugging, built only when explicitly requested.
+
+The browser calls `present()` for normal refreshes. Its output adapter binds DOM
+destinations once per successful definition change and compares values with the
+existing DOM before writing. Input refreshes reuse scene, state and cost elements.
+Invalid views remain allocated but are hidden using SVG `display="none"`; recovery
+reveals them. A definition change can replace all scene/state destinations. This
+does not skip unchanged binding evaluation, eliminate all host allocations, or
+provide measured process-memory/paint-latency savings.
+
+## Authored direct manipulation (2026-09-23)
+
+`input drag = pointer(tile)` binds a logical input to a declared view, including a
+forward declaration. One input per target; it shares the existing 8-input limit
+with keyboard inputs. `on drag.down/move/up/cancel` handlers may read `event_x`
+and `event_y` in the same 640x360 world coordinates as output. They are unavailable
+in keyboard/frame handlers and display bindings; copy to authored state to retain
+them. There is no built-in drag constraint, snap, grab offset, or gesture rollback.
+The direct.pure example authors these using ordinary assignments and expressions.
+
+Host `point` requires a pointer input, valid phase, finite coordinates and nonempty
+source string. Each logical input has one owning source; different targets may
+have independent active contacts. Same-owner repeated down is a no-op; another
+owner's down throws. Move/up/cancel without that owner are no-ops. Down establishes
+ownership before dispatch; up/cancel release it before dispatch, even if authored
+arithmetic fails. An event failure rolls back that event's state writes, not the
+whole gesture. `releaseAll` cancels owned pointers at their last delivered position,
+then releases ordinary buttons. `reset` directly clears ownership and resets state.
+Last point events include x,y. A failed cancel can leave authored state unchanged,
+but cannot strand host ownership; the failure remains visible until reset/apply.
+
+Browser hit testing selects the declared target on primary-button pointerdown.
+The stable SVG root captures that pointer, including movement/release outside the
+scene. Client coordinates are transformed through the inverse SVG screen matrix.
+Pointerup exposes its own final coordinates to authored handlers. The direct.pure
+example updates position only on move and ends dragging on up, preserving the
+last displayed position even if a host repeats stale coordinates on release. Termination is observed in the window capture phase, including releases outside
+the SVG. A primary mouseup can finish an owned mouse gesture if pointerup was
+not delivered; it cannot finish a touch gesture. Duplicate termination is ignored.
+Only capture loss of the SVG root ends its owner, not a bubbled descendant event.
+Pointercancel, lostpointercapture and
+focus loss use the last delivered position and dispatch cancel, never fake up.
+Missing/non-invertible coordinate transforms cancel an active gesture. A second
+contact on the same target is ignored; contacts on different targets are independent.
+Touch scenes disable native panning on the SVG. Mouse/touch share this adapter;
+pen-specific pressure, tilt and button meanings are not implemented or validated.
+
+Every delivered pointermove executes synchronously; this layer adds no event queue,
+coalescing, frame gating or newest-only drop policy. This is not a guarantee that
+browsers expose every hardware sample. Keyboard commands and pointer coordinates
+enter the same authored state/commit/output path. CLI `point NAME PHASE X Y` uses
+that host API and core with source `terminal`; it does not replay browser hit testing.
+
+
+## Placement documents (2026-09-23)
+
+Code-only version1 JSON and .pure files keep their earlier behavior. Version2
+stores a restorable authored document, not a suspended execution image:
+
+```json
+{"format":"pure-workbench","version":2,"source":"state x=80","draft":"state x=80",
+ "states":[{"name":"x","value":350}]}
+```
+
+`source` is the applied definition; optional `draft` is separate unapplied editor
+text (default source). Each is at most 32768 UTF-8 bytes. Draft syntax may be
+unfinished. State records must contain exactly one finite value for every declared
+state name, with no extra, missing or duplicate names. Record order is irrelevant.
+JSON numbers preserve finite f64 values, except negative zero which is encoded as
+the exact string `"-0"`. No other numeric string is accepted. Input latches, running
+clock, diagnostics, caches, DOM, compiled IR and external resources are not saved.
+The serialized input file limit is 1449984 bytes, including escaped source/draft
+and up to 32 generated item.field names (which can repeat a long schema name).
+
+`exportDocument(draft?)` returns an independent version2 object. It refuses an
+unapplied runtime, held input or latched execution failure. It reads core state,
+without constructing an output snapshot. `restoreDocument(document)` validates
+and compiles before staging, refuses held input, then commits the definition and
+all saved values together. It returns `{source,draft}`. Failed validation leaves
+active program/values/diagnostics intact. A failed core load is aborted; optional
+reuse records may be lost as with ordinary apply. Successful restore discards old
+reuse, clears diagnostics, resets tick/event count and starts with no held inputs.
+The original source initializers remain intact: explicit reset restores those,
+not saved values. Existing `apply` continues to preserve current values by name.
+Both new document APIs reject reentry during output publication.
+
+Core exports `put_restored_value(id,value)` and `commit_restored_program()` extend
+the existing begin/put/commit loader. Every declared state needs exactly one finite
+restored value; duplicate/out-of-range/nonfinite input poisons staging. Restored
+commit checks completeness and program validity before touching active state.
+Ordinary commit rejects staged restored values. Pending values live alongside the
+pending Program in the same union as optional evaluation Tables; active Program
+layout does not grow. `layout(6)` now includes Pending, rather than Program only.
+Before shared rule bindings, the Wasm Pending was 9656 B (264 B added for 32 values, mask and alignment),
+Engine was 19392 B; linear memory remains fixed at 65536 B. These are build-layout
+facts, not a browser RAM or performance improvement claim. `evaluation_info(10)`
+continues to report only union tag overhead, excluding restoration payload.
+
+Browser **배치 저장** stores applied source, all current values and editor draft.
+It requires a stopped clock. **파일 열기** restores version2 after replacement
+confirmation when existing work is present; it starts stopped. Version1/plain
+source still only replaces the editor draft. Async reading checks draft generation,
+program identity and successful event count so intervening edits/inputs cannot be
+overwritten by a stale read. Invalid files do not alter the working document.
+**.pure 저장 / 코드 JSON 저장** remain draft-only exports.
+
+CLI `save-project PATH` writes version2 with exclusive creation; `restore PATH`
+restores it. Loading a .json path (including at startup) distinguishes versions.
+A browser draft is retained through CLI save-project, but never applied implicitly.
+Existing `save PATH` remains applied-source-only. No filesystem overwrite is added.
+
+
+## Shared rules and named records (2026-09-23)
+
+```text
+record Position { x=0; y=0 }
+item green = Position(x=80,y=120)
+item blue = Position(x=280,y=160)
+rule move(t: Position) { t.x=event_x; t.y=event_y }
+input drag_green = pointer(green_box)
+input drag_blue = pointer(blue_box)
+box green_box(green.x,green.y,56,44,"#c9f08b")
+box blue_box(blue.x,blue.y,56,44,"#74c4d4")
+on drag_green.move use move(green)
+on drag_blue.move use move(blue)
+```
+
+A record defines 1..8 named finite numeric initializers. An item has its own values;
+optional literal field overrides replace defaults. Its fields occupy contiguous
+slots in the existing scalar state array, with public names `item.field`. They
+count toward the same 32-state limit. Schemas and items may be forward-referenced.
+Unknown/duplicate fields, types or items fail compilation. Scalar and item names
+cannot collide; type and rule names have separate namespaces. There are no runtime
+record objects, nested records, record copying, dynamic item creation or deletion.
+
+A rule takes exactly one named record parameter with an exact schema name match.
+Its body uses the existing expressions, ordered assignments and conditional blocks.
+Parameter fields read/write the connected item's current slots; globals and other
+qualified state names remain accessible. The parameter shadows the same qualified
+prefix only inside its body. No rule calls from expressions or rule bodies, recursion,
+return values, stack frames, or implicit event loop are introduced. Frame handlers
+may use rules; coordinate-reading rules may only bind to pointer events.
+Unused rules are name/type checked without emitting a body into the VM program.
+
+`on input.edge use rule(item)` is one event handler. Each used rule's instructions
+are emitted once at its first use. Every use adds handler metadata referencing that
+same exact instruction range, with parameterBase/parameterCount. Bindings run in
+source order, including repeated uses of one item. All matching bindings and
+ordinary handlers participate in the same atomic event: a failure in any rolls
+back every state write in that event. Error PCs refer to the shared source body.
+
+Core `bind_handler(handler_index,base,count)` sets a previously unbound staged
+handler's contiguous state range. Counts are 1..8; the entire range must fit current
+staged state_count. A duplicate binding poisons staging. All owners of shared code
+must have the same nonzero field count and exactly the same range. Partial overlap
+remains invalid. Parameter opcodes must belong to a bound handler and use an index
+below every owner's field count. Pure display evaluation rejects parameter ops;
+view bindings use ordinary resolved state loads and their normal cache dependencies.
+
+Handler metadata grows from 12 to 16 B (base/count plus alignment). There is no
+per-field indirection list or per-event parameter-value copy. Current Wasm Program
+is 9648 B, Pending 9912 B, Engine 19904 B: 512 B more fixed reservation across active
+and pending handler arrays. The linear-memory limit stays 64 KiB. The required
+bind_handler export makes the host reject older incompatible Wasm.
+
+Apply preserves each qualified field by name. Reordering schema fields or item
+instances rebuilds physical bindings without changing their named values. Removing
+an item removes its fields; adding it again initializes new fields. An active
+input still blocks definition changes. Version2 documents save qualified field
+names, not physical offsets; restoration recompiles all bindings and validates all
+values before commit. This is static named-field identity, not a general lifetime
+handle for dynamically deleted/reused objects. Example `id` and `selected` are
+ordinary author-defined numbers; uniqueness/selection policy is not enforced by
+an implicit object system.
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/sources/core.zig b/prototypes/workbench/results/2026-09-23-shared-rules/sources/core.zig
new file mode 100644
index 0000000..eedeeb5
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/sources/core.zig
@@ -0,0 +1,1230 @@
+//! Bounded scalar interaction VM experiment. No application behavior is built in.
+//! Same source for native tests and freestanding Wasm; no allocation or imports.
+//! Error ABI: 0 ok, 1 capacity, 2 invalid, 3 stack, 4 division by zero,
+//! 5 non-finite, 6 bad branch, 7 bad state, 8 bad frame, 9 bad event.
+//! Counters wrap modulo 2^32. Failed loads preserve the active program, authored
+//! state and event diagnostics; beginning a load discards optional reuse records.
+//! Failed events preserve values and counters but
+//! report their error and attempted instruction count. Binding evaluation is pure.
+
+const std = @import("std");
+const Evaluation = @import("evaluation.zig").Evaluation;
+const Tables = @import("evaluation.zig").Tables;
+
+const max_states = 32;
+const max_inputs = 8;
+const max_instructions = 512;
+const max_handlers = 64;
+const max_stack = 64;
+const no_pc = std.math.maxInt(u32);
+const frame_dt: f64 = 1.0 / 60.0;
+
+const Code = enum(u32) {
+ ok = 0,
+ capacity = 1,
+ invalid = 2,
+ stack = 3,
+ division_by_zero = 4,
+ non_finite = 5,
+ bad_branch = 6,
+ bad_state = 7,
+ bad_frame = 8,
+ bad_event = 9,
+ work_limit = 10,
+};
+
+const Instruction = struct { value: f64 = 0, op: u32 = 0, arg: u32 = 0 };
+const Handler = struct {
+ event: u32 = 0, start: u32 = 0, length: u32 = 0,
+ parameter_base: u8 = 0, parameter_count: u8 = 0,
+};
+const Program = struct {
+ instructions: [max_instructions]Instruction = @splat(.{}),
+ handlers: [max_handlers]Handler = @splat(.{}),
+ initials: [max_states]f64 = @splat(0),
+ old_ids: [max_states]i32 = @splat(-1),
+ declared: [max_states]bool = @splat(false),
+ state_count: u32 = 0,
+ input_count: u32 = 0,
+ instruction_count: u32 = 0,
+ handler_count: u32 = 0,
+};
+// Loading a definition/optional restored values and result reuse are mutually exclusive phases. Current execution
+// always reads active, and can recompute bindings while this workspace loads.
+const Pending = struct {
+ program: Program = .{},
+ values: [max_states]f64 = @splat(0),
+ value_mask: u32 = 0,
+};
+const Workspace = union { staging: Pending, reuse: Tables };
+const Engine = struct {
+ active: Program = .{},
+ workspace: Workspace = .{ .reuse = .{} },
+ values: [max_states]f64 = @splat(0),
+ staging_open: bool = false,
+ staging_error: Code = .ok,
+ tick: u32 = 0,
+ last_error: Code = .ok,
+ error_pc: u32 = no_pc,
+ last_ops: u32 = 0,
+ revision: u32 = 0,
+ successful_events: u32 = 0,
+ evaluation: Evaluation = .{},
+};
+var engine: Engine = .{};
+
+fn reuseTables() ?*Tables {
+ return if (engine.staging_open) null else &engine.workspace.reuse;
+}
+
+fn finishStaging() void {
+ engine.workspace = .{ .reuse = .{} };
+ engine.staging_open = false;
+ engine.evaluation.forget(null, 3);
+}
+
+fn public(code: Code) u32 {
+ return @intFromEnum(code);
+}
+
+fn stagingFail(code: Code) u32 {
+ if (engine.staging_error == .ok) engine.staging_error = code;
+ return public(code);
+}
+
+export fn reset_all() void {
+ engine = .{};
+}
+
+export fn begin_program(state_count: u32, input_count: u32) u32 {
+ engine.staging_error = .ok;
+ if (state_count > max_states or input_count > max_inputs) {
+ if (engine.staging_open) finishStaging();
+ return stagingFail(.capacity);
+ }
+ engine.workspace = .{ .staging = .{ .program = .{ .state_count = state_count, .input_count = input_count } } };
+ engine.staging_open = true;
+ engine.evaluation.forget(null, 3);
+ return 0;
+}
+
+export fn abort_program() void {
+ if (engine.staging_open) finishStaging();
+}
+
+export fn put_state(id: u32, initial: f64, old_id: i32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ if (id >= engine.workspace.staging.program.state_count or old_id < -1) return stagingFail(.bad_state);
+ if (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count) return stagingFail(.bad_state);
+ if (engine.workspace.staging.program.declared[id]) return stagingFail(.bad_state);
+ if (!std.math.isFinite(initial)) return stagingFail(.non_finite);
+ engine.workspace.staging.program.initials[id] = initial;
+ engine.workspace.staging.program.old_ids[id] = old_id;
+ engine.workspace.staging.program.declared[id] = true;
+ return 0;
+}
+
+export fn put_instruction(op: u32, arg: u32, value: f64) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (p.instruction_count == max_instructions) return stagingFail(.capacity);
+ if (op > 25) return stagingFail(.invalid);
+ if (!std.math.isFinite(value)) return stagingFail(.non_finite);
+ p.instructions[p.instruction_count] = .{ .op = op, .arg = arg, .value = value };
+ p.instruction_count += 1;
+ return 0;
+}
+
+fn validEvent(event: u32, inputs: u32) bool {
+ return event <= inputs * 2 or
+ (event >= 17 and event - 17 < inputs) or
+ (event >= 25 and event - 25 < inputs);
+}
+
+export fn put_handler(event: u32, start: u32, length: u32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (p.handler_count == max_handlers) return stagingFail(.capacity);
+ if (!validEvent(event, p.input_count)) return stagingFail(.bad_event);
+ // Handlers may be appended before their instructions, so range validation
+ // is deferred until commit. Arithmetic remains guarded there.
+ p.handlers[p.handler_count] = .{ .event = event, .start = start, .length = length };
+ p.handler_count += 1;
+ return 0;
+}
+
+export fn bind_handler(handler: u32, base: u32, count: u32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (handler >= p.handler_count or base >= p.state_count) return stagingFail(.bad_state);
+ if (count == 0 or count > 8 or count > p.state_count - base) return stagingFail(.bad_state);
+ const h = &p.handlers[handler];
+ if (h.parameter_count != 0) return stagingFail(.invalid);
+ h.parameter_base = @intCast(base);
+ h.parameter_count = @intCast(count);
+ return 0;
+}
+
+fn validate(p: *const Program) Code {
+ for (0..p.state_count) |id| {
+ if (!p.declared[id]) return .bad_state;
+ if (!std.math.isFinite(p.initials[id])) return .non_finite;
+ const old_id = p.old_ids[id];
+ if (old_id < -1 or (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count)) return .bad_state;
+ }
+ for (p.handlers[0..p.handler_count], 0..) |h, i| {
+ if (!validEvent(h.event, p.input_count)) return .bad_event;
+ if (h.start > p.instruction_count or h.length > p.instruction_count - h.start) return .invalid;
+ if (h.parameter_count > 8 or @as(u32, h.parameter_base) + h.parameter_count > p.state_count) return .bad_state;
+ if (h.length == 0) continue;
+ for (p.handlers[0..i]) |other| {
+ if (other.length != 0 and h.start < other.start + other.length and other.start < h.start + h.length) {
+ // Bound rules may share exactly one body, never a partial range.
+ if (h.parameter_count == 0 or h.parameter_count != other.parameter_count or
+ h.start != other.start or h.length != other.length) return .invalid;
+ }
+ }
+ }
+ for (p.instructions[0..p.instruction_count], 0..) |inst, pc| {
+ if (inst.op > 25) return .invalid;
+ if (!std.math.isFinite(inst.value)) return .non_finite;
+ if ((inst.op == 1 or inst.op == 20) and inst.arg >= p.state_count) return .bad_state;
+ if (inst.op >= 20) {
+ var owner: ?Handler = null;
+ for (p.handlers[0..p.handler_count]) |h| {
+ if (pc >= h.start and pc < h.start + h.length) {
+ owner = h;
+ if (inst.op == 21 and (inst.arg <= pc or inst.arg > h.start + h.length)) return .bad_branch;
+ if ((inst.op == 24 or inst.op == 25) and inst.arg >= h.parameter_count) return .bad_state;
+ }
+ }
+ if (owner == null) return .invalid;
+ }
+ }
+ return .ok;
+}
+
+export fn put_restored_value(id: u32, value: f64) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const pending = &engine.workspace.staging;
+ if (id >= pending.program.state_count) return stagingFail(.bad_state);
+ const bit = @as(u32, 1) << @as(u5, @intCast(id));
+ if (pending.value_mask & bit != 0) return stagingFail(.bad_state);
+ if (!std.math.isFinite(value)) return stagingFail(.non_finite);
+ pending.values[id] = value;
+ pending.value_mask |= bit;
+ return 0;
+}
+
+export fn commit_program() u32 {
+ return commit(false);
+}
+
+export fn commit_restored_program() u32 {
+ return commit(true);
+}
+
+fn commit(restoring: bool) u32 {
+ if (!engine.staging_open) return public(.invalid);
+ if (engine.staging_error != .ok) return public(engine.staging_error);
+ const count = engine.workspace.staging.program.state_count;
+ const expected: u32 = if (count == 32) 0xffffffff else (@as(u32, 1) << @as(u5, @intCast(count))) - 1;
+ if (engine.workspace.staging.value_mask != (if (restoring) expected else @as(u32, 0)))
+ return stagingFail(.bad_state);
+ const code = validate(&engine.workspace.staging.program);
+ if (code != .ok) return stagingFail(code);
+ var next: [max_states]f64 = @splat(0);
+ for (0..engine.workspace.staging.program.state_count) |i| {
+ const old_id = engine.workspace.staging.program.old_ids[i];
+ next[i] = if (restoring) engine.workspace.staging.values[i] else if (old_id < 0) engine.workspace.staging.program.initials[i] else engine.values[@intCast(old_id)];
+ }
+ engine.active = engine.workspace.staging.program;
+ engine.values = next;
+ finishStaging();
+ if (restoring) {
+ engine.tick = 0;
+ engine.successful_events = 0;
+ }
+ engine.revision +%= 1;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ return 0;
+}
+
+export fn reset_state() void {
+ engine.values = engine.active.initials;
+ engine.evaluation.forget(reuseTables(), 3);
+ engine.tick = 0;
+ engine.successful_events = 0;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+}
+
+const Execution = struct {
+ code: Code = .ok,
+ pc: u32 = no_pc,
+ ops: u32 = 0,
+ value: f64 = 0,
+ dependencies: u32 = 0,
+};
+
+fn executionFail(result: Execution, code: Code, pc: u32) Execution {
+ var failed = result;
+ failed.code = code;
+ failed.pc = pc;
+ return failed;
+}
+
+const EventContext = struct { dt: f64 = 0, x: f64 = 0, y: f64 = 0, parameter_base: u32 = 0, parameter_count: u32 = 0, point: bool = false };
+
+fn execute(values: *[max_states]f64, start: u32, length: u32, context: EventContext, pure: bool, budget: ?*Evaluation) Execution {
+ const p = &engine.active;
+ var result: Execution = .{};
+ if (start > p.instruction_count or length > p.instruction_count - start) return executionFail(result, .invalid, no_pc);
+ const end = start + length;
+ var stack: [max_stack]f64 = undefined;
+ var sp: usize = 0;
+ var pc = start;
+ while (pc < end) {
+ if (budget) |b| {
+ if (!b.spend()) return executionFail(result, .work_limit, pc);
+ b.instructions += 1;
+ }
+ const inst = p.instructions[pc];
+ result.ops += 1;
+ if (pure and (inst.op == 2 or inst.op >= 20)) return executionFail(result, .invalid, pc);
+ const need: usize = switch (inst.op) {
+ 0...2, 22, 23, 24 => 0,
+ 7, 16, 20, 21, 25 => 1,
+ 3...6, 8...15, 17, 18 => 2,
+ 19 => 3,
+ else => return executionFail(result, .invalid, pc),
+ };
+ if (sp < need) return executionFail(result, .stack, pc);
+ if (inst.op <= 2 or inst.op == 22 or inst.op == 23 or inst.op == 24) {
+ if (sp == max_stack) return executionFail(result, .stack, pc);
+ const value = switch (inst.op) {
+ 0 => inst.value,
+ 1 => blk: {
+ if (inst.arg >= p.state_count) return executionFail(result, .bad_state, pc);
+ if (budget != null and budget.?.cache_count != 0)
+ result.dependencies |= @as(u32, 1) << @as(u5, @intCast(inst.arg));
+ break :blk values[inst.arg];
+ },
+ 24 => blk: {
+ if (inst.arg >= context.parameter_count) return executionFail(result, .bad_state, pc);
+ const state = context.parameter_base + inst.arg;
+ if (state >= p.state_count) return executionFail(result, .bad_state, pc);
+ break :blk values[state];
+ },
+ 2 => context.dt,
+ 22, 23 => if (!context.point) return executionFail(result, .bad_event, pc) else if (inst.op == 22) context.x else context.y,
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ stack[sp] = value;
+ sp += 1;
+ } else if (inst.op == 20 or inst.op == 25) {
+ if (inst.op == 25 and inst.arg >= context.parameter_count) return executionFail(result, .bad_state, pc);
+ const state = if (inst.op == 25) context.parameter_base + inst.arg else inst.arg;
+ if (state >= p.state_count) return executionFail(result, .bad_state, pc);
+ sp -= 1;
+ values[state] = stack[sp];
+ } else if (inst.op == 21) {
+ if (inst.arg <= pc or inst.arg > end) return executionFail(result, .bad_branch, pc);
+ sp -= 1;
+ if (stack[sp] == 0) {
+ pc = inst.arg;
+ continue;
+ }
+ } else {
+ const a = stack[sp - need];
+ const b = if (need >= 2) stack[sp - need + 1] else 0;
+ const value: f64 = switch (inst.op) {
+ 3 => a + b,
+ 4 => a - b,
+ 5 => a * b,
+ 6 => blk: {
+ if (b == 0) return executionFail(result, .division_by_zero, pc);
+ break :blk a / b;
+ },
+ 7 => -a,
+ 8 => @floatFromInt(@intFromBool(a < b)),
+ 9 => @floatFromInt(@intFromBool(a <= b)),
+ 10 => @floatFromInt(@intFromBool(a > b)),
+ 11 => @floatFromInt(@intFromBool(a >= b)),
+ 12 => @floatFromInt(@intFromBool(a == b)),
+ 13 => @floatFromInt(@intFromBool(a != b)),
+ 14 => @floatFromInt(@intFromBool(a != 0 and b != 0)),
+ 15 => @floatFromInt(@intFromBool(a != 0 or b != 0)),
+ 16 => @floatFromInt(@intFromBool(a == 0)),
+ 17 => @min(a, b),
+ 18 => @max(a, b),
+ 19 => blk: {
+ const hi = stack[sp - 1];
+ if (b > hi) return executionFail(result, .invalid, pc);
+ break :blk @max(b, @min(a, hi));
+ },
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ sp = sp - need + 1;
+ stack[sp - 1] = value;
+ }
+ pc += 1;
+ }
+ if (sp != @as(usize, if (pure) 1 else 0)) return executionFail(result, .stack, end);
+ if (pure) result.value = stack[0];
+ return result;
+}
+
+export fn dispatch(event: u32, dt: f64) u32 {
+ return dispatchContext(event, .{ .dt = dt });
+}
+
+export fn dispatch_point(event: u32, x: f64, y: f64) u32 {
+ return dispatchContext(event, .{ .x = x, .y = y, .point = true });
+}
+
+fn dispatchContext(event: u32, context: EventContext) u32 {
+ const dt = context.dt;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ if (!validEvent(event, engine.active.input_count) or
+ (context.point and event == 0) or (!context.point and event > engine.active.input_count * 2)) {
+ engine.last_error = .bad_event;
+ return public(.bad_event);
+ }
+ if (!std.math.isFinite(dt) or !std.math.isFinite(context.x) or !std.math.isFinite(context.y)) {
+ engine.last_error = .non_finite;
+ return public(.non_finite);
+ }
+ if ((event == 0 and dt != frame_dt) or (event != 0 and dt != 0)) {
+ engine.last_error = .bad_frame;
+ return public(.bad_frame);
+ }
+ var pending = engine.values;
+ for (engine.active.handlers[0..engine.active.handler_count]) |h| {
+ if (h.event != event) continue;
+ var bound = context;
+ bound.parameter_base = h.parameter_base;
+ bound.parameter_count = h.parameter_count;
+ const result = execute(&pending, h.start, h.length, bound, false, null);
+ engine.last_ops += result.ops;
+ if (result.code != .ok) {
+ engine.last_error = result.code;
+ engine.error_pc = result.pc;
+ return public(result.code);
+ }
+ }
+ if (engine.evaluation.cache_count != 0 and !engine.staging_open) {
+ var changed: u32 = 0;
+ for (0..engine.active.state_count) |i| {
+ // Signed zero is observable, so compare representation, not ==.
+ if (@as(u64, @bitCast(pending[i])) != @as(u64, @bitCast(engine.values[i])))
+ changed |= @as(u32, 1) << @as(u5, @intCast(i));
+ }
+ engine.evaluation.changed(reuseTables(), changed);
+ }
+ engine.values = pending;
+ if (event == 0) engine.tick +%= 1;
+ engine.successful_events +%= 1;
+ return 0;
+}
+
+export fn eval_at(start: u32, length: u32) f64 {
+ // A local copy makes the no-side-effects boundary structural as well as
+ // checked by the pure instruction filter. Diagnostics are never touched.
+ var values = engine.values;
+ const result = execute(&values, start, length, .{}, true, null);
+ return if (result.code == .ok) result.value else std.math.nan(f64);
+}
+
+export fn configure_evaluation(cache: u32, history: u32) u32 {
+ return if (engine.evaluation.configure(reuseTables(), cache, history)) 0 else public(.capacity);
+}
+
+export fn forget_evaluation(flags: u32) void {
+ engine.evaluation.forget(reuseTables(), flags);
+}
+
+export fn begin_evaluation(limit: u32) void {
+ engine.evaluation.begin(limit);
+}
+
+export fn eval_bounded(start: u32, length: u32) f64 {
+ const e = &engine.evaluation;
+ e.status = 0;
+ // Charge per request as well as per instruction and validation probe.
+ if (!e.spend()) return std.math.nan(f64);
+ if (e.lookup(reuseTables(), start, length)) |value| return value;
+ if (e.status != 0) return std.math.nan(f64);
+ var values = engine.values;
+ const result = execute(&values, start, length, .{}, true, e);
+ e.status = public(result.code);
+ if (result.code != .ok) return std.math.nan(f64);
+ e.remember(reuseTables(), start, length, result.value, result.dependencies);
+ return result.value;
+}
+
+export fn evaluation_info(id: u32) u32 {
+ const e = &engine.evaluation;
+ return switch (id) {
+ 0 => e.status,
+ 1 => e.work,
+ 2 => e.instructions,
+ 3 => e.hits,
+ 4 => e.cache_count,
+ 5 => e.history_count,
+ 6 => @sizeOf(Evaluation) + @sizeOf(Tables),
+ 7 => @sizeOf(Evaluation),
+ 8 => @sizeOf(Tables),
+ 9 => @intFromBool(!engine.staging_open),
+ 10 => @sizeOf(Workspace) - @sizeOf(Pending),
+ else => 0,
+ };
+}
+
+export fn get_state(id: u32) f64 {
+ return if (id < engine.active.state_count) engine.values[id] else std.math.nan(f64);
+}
+
+export fn info(id: u32) u32 {
+ return switch (id) {
+ 0 => engine.tick,
+ 1 => public(engine.last_error),
+ 2 => engine.error_pc,
+ 3 => engine.last_ops,
+ 4 => engine.revision,
+ 5 => engine.active.state_count,
+ 6 => engine.active.instruction_count,
+ 7 => engine.active.handler_count,
+ 8 => engine.active.input_count,
+ 9 => engine.successful_events,
+ else => 0,
+ };
+}
+
+export fn layout(id: u32) u32 {
+ return switch (id) {
+ 0 => @sizeOf(Engine),
+ 1 => @sizeOf(Program),
+ 2 => @sizeOf(Instruction),
+ 3 => @sizeOf(Handler),
+ 4 => engine.active.instruction_count * @sizeOf(Instruction) + engine.active.handler_count * @sizeOf(Handler),
+ 5 => engine.active.state_count * @sizeOf(f64),
+ 6 => @sizeOf(Pending),
+ 7 => max_stack * @sizeOf(f64),
+ else => 0,
+ };
+}
+
+// Test helpers describe programs directly; the product compiler is separately
+// tested against this ABI. These tests exercise semantics and failure boundaries.
+fn ok(code: u32) !void {
+ try std.testing.expectEqual(@as(u32, 0), code);
+}
+
+fn emit(op: u32, arg: u32, value: f64) !void {
+ try ok(put_instruction(op, arg, value));
+}
+
+fn counterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try ok(commit_program());
+}
+
+test "blank program, no hidden application behavior, and reset" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 1), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(3));
+ try std.testing.expectEqual(@as(u32, 0), info(5));
+ try std.testing.expect(std.math.isNan(get_state(0)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 0)));
+ reset_state();
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 1), info(4));
+}
+
+test "arbitrary input counter and unused events" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(dispatch(2, 0));
+ try ok(dispatch(0, frame_dt));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(3));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(6));
+}
+
+test "charge and release behavior exists only in the authored instruction program" {
+ reset_all();
+ try ok(begin_program(3, 1)); // held, charge, released_value
+ for (0..3) |i| try ok(put_state(@intCast(i), 0, -1));
+ try emit(0, 0, 1);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 2));
+ try emit(1, 1, 0);
+ try emit(1, 0, 0);
+ try emit(2, 0, 0);
+ try emit(5, 0, 0);
+ try emit(3, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(0, 2, 6));
+ try emit(1, 1, 0);
+ try emit(20, 2, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(2, 8, 6));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ try ok(dispatch(1, 0));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try ok(dispatch(2, 0));
+ try std.testing.expectApproxEqAbs(@as(f64, 0.5), get_state(2), 1e-12);
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+}
+
+test "forward condition executes once and assignments and handlers are ordered" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 1, -1));
+ try ok(put_state(1, 0, -1));
+ try emit(1, 0, 0);
+ try emit(21, 6, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 7);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 0, 6));
+ try emit(1, 1, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 6, 4));
+ try ok(commit_program());
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 14), get_state(1));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 28), get_state(1));
+ try std.testing.expectEqual(@as(u32, 6), info(3));
+}
+
+test "branch to program end is valid" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, 2, 0);
+ try ok(put_handler(0, 0, 2));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+}
+
+test "one event rolls back writes from all handlers on arithmetic failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 8, -1));
+ try emit(0, 0, 99);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 2));
+ try emit(0, 0, 1);
+ try emit(0, 0, 0);
+ try emit(6, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 2, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 8), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(2));
+ try std.testing.expectEqual(@as(u32, 5), info(3));
+}
+
+test "nonfinite arithmetic and invalid clamp fail atomically" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, -1));
+ try emit(0, 0, 1e308);
+ try emit(0, 0, 1e308);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, 0));
+ try emit(0, 0, 1);
+ try emit(0, 0, 10);
+ try emit(0, 0, 2);
+ try emit(19, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 5));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.invalid), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+}
+
+test "stack underflow overflow and leftover values reject the event" {
+ for (0..3) |mode| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ if (mode == 0) {
+ try emit(3, 0, 0);
+ } else {
+ for (0..@as(usize, if (mode == 1) 65 else 1)) |_| try emit(0, 0, 7);
+ }
+ try ok(put_handler(0, 0, engine.workspace.staging.program.instruction_count));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.stack), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ }
+}
+
+test "failed candidates leave active values program revision and diagnostics unchanged" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ const old_revision = info(4);
+ const old_ops = info(3);
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(1, 5, 0);
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try std.testing.expectEqual(old_revision, info(4));
+ try std.testing.expectEqual(old_ops, info(3));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.invalid), put_instruction(100, 0, 0));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+}
+
+test "state preservation mapping reads active values at commit and reset uses new initials" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(2, 0));
+ try ok(put_state(0, 42, -1)); // new symbol first
+ try ok(put_state(1, 100, 0)); // old symbol moves from id 0 to 1
+ try ok(dispatch(1, 0)); // changed after staging began
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 42), get_state(0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 100), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(4));
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try std.testing.expect(std.math.isNan(get_state(0)));
+}
+
+test "capacity boundaries and sticky load errors" {
+ reset_all();
+ try std.testing.expectEqual(public(.capacity), begin_program(33, 0));
+ try std.testing.expectEqual(public(.capacity), begin_program(0, 9));
+ try ok(begin_program(32, 8));
+ for (0..32) |i| try ok(put_state(@intCast(i), @floatFromInt(i), -1));
+ for (0..512) |_| try emit(0, 0, 0);
+ for (0..64) |_| try ok(put_handler(16, 0, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 512), info(6));
+ try std.testing.expectEqual(@as(u32, 64), info(7));
+ try std.testing.expectEqual(@as(f64, 31), get_state(31));
+ try ok(begin_program(0, 0));
+ for (0..512) |_| try emit(0, 0, 0);
+ try std.testing.expectEqual(public(.capacity), put_instruction(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+ try ok(begin_program(0, 0));
+ for (0..64) |_| try ok(put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+}
+
+test "invalid declaration values state mappings event ids and handler ranges" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 0, 0));
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.non_finite), put_state(0, std.math.inf(f64), -1));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 1, -1));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.non_finite), put_instruction(0, 0, std.math.nan(f64)));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.bad_event), put_handler(1, 0, 0));
+ try ok(begin_program(0, 0));
+ try ok(put_handler(0, no_pc, 5));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 1);
+ try ok(put_handler(0, 0, 1));
+ try ok(put_handler(0, 0, 1));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "backward self out-of-handler branches and stores without handlers are rejected" {
+ for ([_]u32{ 0, 1, 3 }) |target| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, target, 0);
+ try emit(0, 0, 7);
+ try ok(put_handler(0, 0, 2));
+ try std.testing.expectEqual(public(.bad_branch), commit_program());
+ }
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try emit(0, 0, 5);
+ try emit(20, 0, 0);
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "event inputs and dt enforce deterministic frame contract" {
+ reset_all();
+ try counterProgram();
+ try std.testing.expectEqual(public(.bad_event), dispatch(3, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch(no_pc, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(1, frame_dt));
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, std.math.nan(f64)));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(u32, 0), info(1));
+ try std.testing.expectEqual(no_pc, info(2));
+}
+
+test "binding evaluation is pure on both success and failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 4, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try emit(2, 0, 0);
+ try emit(0, 0, 0);
+ try emit(21, 7, 0);
+ try ok(put_handler(0, 3, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ const before = engine;
+ try std.testing.expectEqual(@as(f64, 12), eval_at(0, 3));
+ try std.testing.expect(std.math.isNan(eval_at(0, 4))); // attempted store
+ try std.testing.expect(std.math.isNan(eval_at(4, 1))); // dt forbidden in views
+ try std.testing.expect(std.math.isNan(eval_at(5, 2))); // branch forbidden
+ try std.testing.expect(std.math.isNan(eval_at(no_pc, 7)));
+ try std.testing.expect(std.math.isNan(eval_at(0, no_pc)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 2))); // two results
+ // Compare only the live member of the untagged workspace, never padding
+ // or the inactive member's indeterminate storage.
+ try std.testing.expectEqual(before.staging_open, engine.staging_open);
+ inline for (std.meta.fields(Engine)) |field| {
+ if (comptime std.mem.eql(u8, field.name, "workspace")) {
+ if (before.staging_open) {
+ try std.testing.expectEqualDeep(before.workspace.staging, engine.workspace.staging);
+ } else {
+ try std.testing.expectEqualDeep(before.workspace.reuse, engine.workspace.reuse);
+ }
+ } else try std.testing.expectEqualDeep(@field(before, field.name), @field(engine, field.name));
+ }
+}
+
+test "arithmetic comparison logical min max and clamp expression semantics" {
+ const cases = [_]struct { op: u32, a: f64, b: f64, want: f64 }{
+ .{ .op = 3, .a = 7, .b = 2, .want = 9 },
+ .{ .op = 4, .a = 7, .b = 2, .want = 5 },
+ .{ .op = 5, .a = 7, .b = 2, .want = 14 },
+ .{ .op = 6, .a = 7, .b = 2, .want = 3.5 },
+ .{ .op = 8, .a = 2, .b = 7, .want = 1 },
+ .{ .op = 9, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 10, .a = 2, .b = 7, .want = 0 },
+ .{ .op = 11, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 12, .a = 7, .b = 2, .want = 0 },
+ .{ .op = 13, .a = 7, .b = 2, .want = 1 },
+ .{ .op = 14, .a = -7, .b = 2, .want = 1 },
+ .{ .op = 15, .a = 0, .b = 2, .want = 1 },
+ .{ .op = 17, .a = 7, .b = 2, .want = 2 },
+ .{ .op = 18, .a = 7, .b = 2, .want = 7 },
+ };
+ for (cases) |case| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, case.a);
+ try emit(0, 0, case.b);
+ try emit(case.op, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(case.want, eval_at(0, 3));
+ }
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, -8);
+ try emit(7, 0, 0);
+ try emit(0, 0, 0);
+ try emit(0, 0, 5);
+ try emit(19, 0, 0);
+ try emit(16, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 5), eval_at(0, 5));
+ try std.testing.expectEqual(@as(f64, 0), eval_at(0, 6));
+}
+
+test "diagnostic counters wrap without affecting scalar values" {
+ reset_all();
+ try counterProgram();
+ engine.tick = no_pc;
+ engine.successful_events = no_pc;
+ engine.revision = no_pc;
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 99, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 0), info(4));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+}
+
+test "memory accounting separates reserved storage used IR and stack capacity" {
+ reset_all();
+ try std.testing.expectEqual(@as(u32, 0), layout(4));
+ try std.testing.expectEqual(@as(u32, 0), layout(5));
+ try counterProgram();
+ try std.testing.expectEqual(@as(u32, @sizeOf(Engine)), layout(0));
+ try std.testing.expectEqual(@as(u32, 16), layout(2));
+ try std.testing.expectEqual(@as(u32, 16), layout(3));
+ try std.testing.expectEqual(@as(u32, 80), layout(4));
+ try std.testing.expectEqual(@as(u32, 8), layout(5));
+ try std.testing.expectEqual(@as(u32, @sizeOf(Pending)), layout(6));
+ try std.testing.expect(layout(6) >= layout(1) + max_states * @sizeOf(f64));
+ try std.testing.expect(layout(0) >= layout(1) * 2);
+ try std.testing.expectEqual(@as(u32, 512), layout(7));
+ try std.testing.expect(layout(0) + 16 * 1024 < 64 * 1024);
+}
+
+fn displayCounterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try emit(1, 0, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try ok(configure_evaluation(32, 8));
+}
+
+fn boundedEquals(want: f64, start: u32, length: u32) !void {
+ begin_evaluation(100);
+ try std.testing.expectEqual(want, eval_bounded(start, length));
+ try ok(evaluation_info(0));
+}
+
+test "point coordinates are ephemeral event operands and unavailable to pure evaluation" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 0, -1));
+ try ok(put_state(1, 0, -1));
+ const events = [_]u32{ 1, 17, 2, 25 };
+ for (events, 0..) |event, i| {
+ try emit(22, 0, 0);
+ try emit(20, 0, 0);
+ try emit(23, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(event, @intCast(i * 4), 4));
+ }
+ try ok(commit_program());
+ for (events, 0..) |event, i| {
+ const x: f64 = @floatFromInt(i + 10);
+ try ok(dispatch_point(event, x, -x));
+ try std.testing.expectEqual(x, get_state(0));
+ try std.testing.expectEqual(-x, get_state(1));
+ }
+ try std.testing.expectEqual(public(.bad_event), dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 13), get_state(0));
+ const diagnostics = [_]u32{ info(1), info(2), info(3), info(9) };
+ try std.testing.expect(std.math.isNan(eval_at(0, 1)));
+ try std.testing.expectEqualSlices(u32, &diagnostics, &.{ info(1), info(2), info(3), info(9) });
+ try std.testing.expectEqual(@as(u32, 4), info(9));
+}
+
+test "point event ranges reject holes and invalid payloads without committing" {
+ reset_all();
+ try counterProgram();
+ for ([_]u32{ 0, 3, 16, 18, 24, 26, 32, 33, no_pc }) |event|
+ try std.testing.expectEqual(public(.bad_event), dispatch_point(event, 1, 2));
+ try std.testing.expectEqual(public(.non_finite), dispatch_point(1, std.math.nan(f64), 0));
+ try std.testing.expectEqual(public(.non_finite), dispatch_point(1, 0, std.math.inf(f64)));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try ok(begin_program(0, 8));
+ try ok(put_handler(24, 0, 0));
+ try ok(put_handler(32, 0, 0));
+ try ok(commit_program());
+ try ok(dispatch_point(24, 1, 2));
+ try ok(dispatch_point(32, 1, 2));
+ try std.testing.expectEqual(public(.bad_event), dispatch(24, 0));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+}
+
+test "point event failure rolls back writes from earlier matching handlers" {
+ reset_all();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 7, -1));
+ try emit(22, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(17, 0, 2));
+ try emit(23, 0, 0);
+ try emit(0, 0, 0);
+ try emit(6, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(17, 2, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch_point(17, 400, 200));
+ try std.testing.expectEqual(@as(f64, 7), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(2));
+}
+
+test "loading shares storage with records while old events and bindings remain executable" {
+ reset_all();
+ try displayCounterProgram();
+ try boundedEquals(20, 4, 3);
+ try boundedEquals(20, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(9));
+ try emit(1, 0, 0);
+ try ok(dispatch(1, 0)); // Active state advances after staging begins.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(3));
+ try ok(configure_evaluation(32, 1));
+ forget_evaluation(3); // Must not overwrite the pending program.
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(33, 0, 3);
+ try boundedEquals(33, 0, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+}
+
+test "failed and abandoned candidates restore empty reuse storage without changing active work" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(20, 0, 0); // Store without a handler fails validation.
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try boundedEquals(20, 4, 3);
+ try ok(dispatch(1, 0));
+ abort_program();
+ try boundedEquals(22, 4, 3);
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ abort_program(); // Inactive abort is a no-op, including for warmed records.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try std.testing.expectEqual(public(.capacity), begin_program(max_states + 1, 0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+}
+
+test "full pending program survives repeated evaluation configuration record loss and reset" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ for (0..max_instructions) |i| {
+ try emit(0, 0, @floatFromInt(i));
+ try ok(configure_evaluation(32, @intCast(i % 9)));
+ forget_evaluation(3);
+ try boundedEquals(22, 4, 3);
+ }
+ reset_state(); // The pending definition and old-id mapping survive reset.
+ try boundedEquals(20, 4, 3);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, max_instructions), info(6));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try boundedEquals(511, 511, 1);
+ // Safe Zig builds may reserve a hidden union tag in addition to the payload.
+ try std.testing.expect(@sizeOf(Workspace) >= @sizeOf(Pending));
+ try std.testing.expect(@sizeOf(Workspace) <= @sizeOf(Pending) + @alignOf(Pending));
+ try std.testing.expectEqual(@as(u32, @sizeOf(Tables)), evaluation_info(8));
+}
+
+test "restored definition and values commit together without changing reset initializers" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(dispatch(0, frame_dt));
+ const revision = info(4);
+ try ok(begin_program(2, 0));
+ try ok(put_state(0, 100, -1));
+ try ok(put_state(1, 200, -1));
+ try ok(put_restored_value(1, -0.0));
+ try ok(put_restored_value(0, 37));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try ok(dispatch(1, 0)); // Old execution remains independent of pending values.
+ try ok(commit_restored_program());
+ try std.testing.expectEqual(@as(f64, 37), get_state(0));
+ try std.testing.expectEqual(@as(u64, @bitCast(@as(f64, -0.0))), @as(u64, @bitCast(get_state(1))));
+ try std.testing.expectEqual(revision + 1, info(4));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 100), get_state(0));
+ try std.testing.expectEqual(@as(f64, 200), get_state(1));
+}
+
+test "missing duplicate nonfinite or invalid restored candidates leave active work untouched" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ const revision = info(4);
+ for (0..4) |mode| {
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 999, -1));
+ if (mode == 1) {
+ try ok(put_restored_value(0, 30));
+ try std.testing.expectEqual(public(.bad_state), put_restored_value(0, 40));
+ } else if (mode == 2) {
+ try std.testing.expectEqual(public(.non_finite), put_restored_value(0, std.math.inf(f64)));
+ } else if (mode == 3) {
+ try ok(put_restored_value(0, 30));
+ try emit(20, 0, 0); // Invalid store outside a handler.
+ }
+ try std.testing.expect(commit_restored_program() != 0);
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try std.testing.expectEqual(revision, info(4));
+ try std.testing.expectEqual(@as(u32, 1), info(9));
+ abort_program();
+ }
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 100, -1));
+ try ok(put_restored_value(0, 42));
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ abort_program();
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+}
+
+test "empty and full restored programs validate all values and discard old display records" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(configure_evaluation(32, 8));
+ try boundedEquals(20, 4, 3);
+ try ok(begin_program(32, 0));
+ for (0..32) |i| {
+ try ok(put_state(@intCast(i), 0, -1));
+ try ok(put_restored_value(@intCast(i), @floatFromInt(i + 1)));
+ }
+ try emit(1, 31, 0);
+ try ok(commit_restored_program());
+ try boundedEquals(32, 0, 1);
+ try ok(begin_program(0, 0));
+ try ok(commit_restored_program());
+ try std.testing.expectEqual(@as(u32, 0), info(5));
+ try std.testing.expect(std.math.isNan(get_state(0)));
+}
+
+test "shared handler bodies access their own bound state slots in source order" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 10, -1));
+ try ok(put_state(1, 20, -1));
+ try emit(24, 0, 0); try emit(0, 0, 1); try emit(3, 0, 0); try emit(25, 0, 0);
+ try ok(put_handler(1, 0, 4)); try ok(bind_handler(0, 0, 1));
+ try ok(put_handler(1, 0, 4)); try ok(bind_handler(1, 1, 1));
+ try ok(put_handler(1, 0, 4)); try ok(bind_handler(2, 0, 1));
+ try ok(commit_program());
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try std.testing.expectEqual(@as(f64, 21), get_state(1));
+ try std.testing.expectEqual(@as(u32, 4), info(6)); // Three bindings, one body.
+ try std.testing.expectEqual(@as(u32, 12), info(3));
+ const diagnostics = [_]u32{ info(1), info(2), info(3), info(9) };
+ try std.testing.expect(std.math.isNan(eval_at(0, 1)));
+ try std.testing.expectEqualSlices(u32, &diagnostics, &.{ info(1), info(2), info(3), info(9) });
+}
+
+test "failure in a later shared binding rolls back all earlier bindings" {
+ reset_all();
+ try ok(begin_program(4, 1));
+ for ([_]f64{ 8, 2, 12, 0 }, 0..) |value, i| try ok(put_state(@intCast(i), value, -1));
+ try emit(24, 0, 0); try emit(24, 1, 0); try emit(6, 0, 0); try emit(25, 0, 0);
+ for (0..2) |i| {
+ try ok(put_handler(1, 0, 4));
+ try ok(bind_handler(@intCast(i), @intCast(i * 2), 2));
+ }
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 8), get_state(0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(2));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+}
+
+test "shared bindings reject missing arguments partial overlaps and invalid slot mappings" {
+ reset_all();
+ try counterProgram();
+ const revision = info(4);
+ for (0..5) |mode| {
+ try ok(begin_program(1, 1)); try ok(put_state(0, 9, -1));
+ try emit(24, 0, 0); try emit(25, 0, 0);
+ try ok(put_handler(1, 0, 2));
+ switch (mode) {
+ 0 => {}, // Parameter op without a binding.
+ 1 => { try ok(bind_handler(0, 0, 1)); try std.testing.expectEqual(public(.invalid), bind_handler(0, 0, 1)); },
+ 2 => { try std.testing.expectEqual(public(.bad_state), bind_handler(0, 0, 2)); },
+ 3 => {
+ try ok(bind_handler(0, 0, 1));
+ try ok(put_handler(1, 1, 1)); try ok(bind_handler(1, 0, 1));
+ },
+ else => {
+ try ok(bind_handler(0, 0, 1));
+ try ok(put_handler(1, 0, 2)); // Another owner missing its mapping.
+ },
+ }
+ try std.testing.expect(commit_program() != 0); abort_program();
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(revision, info(4));
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/sources/evaluation.zig b/prototypes/workbench/results/2026-09-23-shared-rules/sources/evaluation.zig
new file mode 100644
index 0000000..56081d9
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/sources/evaluation.zig
@@ -0,0 +1,164 @@
+//! Disposable binding results and change history. No event state lives here.
+const std = @import("std");
+
+const Memo = struct {
+ value: f64 = 0,
+ revision: u64 = 0,
+ dependencies: u32 = 0,
+ start: u32 = 0,
+ length: u32 = 0,
+};
+const Change = struct { revision: u64 = 0, mask: u32 = 0 };
+
+pub const Tables = struct {
+ memo: [32]Memo = @splat(.{}),
+ history: [8]Change = @splat(.{}),
+};
+
+pub const Evaluation = struct {
+ pub const max_cache = 32;
+ pub const max_history = 8;
+ revision: u64 = 1,
+ floor: u64 = 1,
+ cache_count: u32 = 0,
+ history_count: u32 = 8,
+ next_change: u32 = 0,
+ limit: u32 = 0,
+ work: u32 = 0,
+ instructions: u32 = 0,
+ hits: u32 = 0,
+ status: u32 = 0,
+
+ pub fn configure(self: *Evaluation, tables: ?*Tables, cache: u32, history: u32) bool {
+ if (cache > max_cache or history > max_history) return false;
+ self.cache_count = cache;
+ self.history_count = history;
+ self.forget(tables, 3);
+ return true;
+ }
+
+ pub fn forget(self: *Evaluation, tables: ?*Tables, flags: u32) void {
+ if (flags & 1 != 0) if (tables) |t| { t.memo = @splat(.{}); };
+ if (flags & 2 != 0) {
+ if (tables) |t| t.history = @splat(.{});
+ self.floor = self.revision;
+ self.next_change = 0;
+ }
+ }
+
+ pub fn changed(self: *Evaluation, tables: ?*Tables, mask: u32) void {
+ const t = tables orelse return;
+ if (mask == 0 or self.cache_count == 0) return;
+ if (self.revision == std.math.maxInt(u64)) {
+ self.revision = 1;
+ self.forget(t, 3);
+ }
+ self.revision += 1;
+ if (self.history_count == 0) {
+ self.floor = self.revision;
+ return;
+ }
+ const change = &t.history[self.next_change];
+ self.floor = @max(self.floor, change.revision);
+ change.* = .{ .revision = self.revision, .mask = mask };
+ self.next_change = (self.next_change + 1) % self.history_count;
+ }
+
+ pub fn begin(self: *Evaluation, limit: u32) void {
+ self.limit = limit;
+ self.work = 0;
+ self.instructions = 0;
+ self.hits = 0;
+ self.status = 0;
+ }
+
+ pub fn spend(self: *Evaluation) bool {
+ if (self.work == self.limit) {
+ self.status = 10;
+ return false;
+ }
+ self.work += 1;
+ return true;
+ }
+
+ fn slot(self: *const Evaluation, start: u32, length: u32) u32 {
+ // One candidate, including on misses; no scan proportional to cache size.
+ return (start ^ (start >> 5) ^ (length *% 0x9e3779b9)) % self.cache_count;
+ }
+
+ pub fn lookup(self: *Evaluation, tables: ?*Tables, start: u32, length: u32) ?f64 {
+ const t = tables orelse return null;
+ if (self.cache_count == 0) return null;
+ if (!self.spend()) return null;
+ const memo = &t.memo[self.slot(start, length)];
+ if (memo.revision == 0 or memo.start != start or memo.length != length) return null;
+ // Successful pure constants need no input-change evidence. A program
+ // replacement still clears them, just like every other binding memo.
+ if (memo.dependencies != 0 and memo.revision < self.floor) return null;
+ if (memo.dependencies != 0 and memo.revision != self.revision) {
+ for (t.history[0..self.history_count]) |change| {
+ if (!self.spend()) return null;
+ if (change.revision > memo.revision and change.mask & memo.dependencies != 0) return null;
+ }
+ }
+ memo.revision = self.revision;
+ self.hits += 1;
+ return memo.value;
+ }
+
+ pub fn remember(self: *Evaluation, tables: ?*Tables, start: u32, length: u32, value: f64, dependencies: u32) void {
+ const t = tables orelse return;
+ if (self.cache_count == 0) return;
+ t.memo[self.slot(start, length)] = .{ .start = start, .length = length,
+ .value = value, .dependencies = dependencies, .revision = self.revision };
+ }
+};
+
+test "record loss cannot authorize an old memo; unrelated changes can" {
+ var tables: Tables = .{};
+ var e: Evaluation = .{};
+ try std.testing.expect(e.configure(&tables, 1, 1));
+ e.begin(100);
+ e.remember(&tables, 0, 3, 12, 1);
+ e.changed(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, 12), e.lookup(&tables, 0, 3));
+ e.changed(&tables, 2);
+ e.changed(&tables, 2); // The certificate now predates the surviving history.
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 12, 1);
+ e.changed(&tables, 1);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 20, 1);
+ e.changed(&tables, 2);
+ e.forget(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 20, 1);
+ e.revision = std.math.maxInt(u64);
+ e.changed(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+}
+
+test "budget, collisions, invalid settings, and disabled history are conservative" {
+ var tables: Tables = .{};
+ var e: Evaluation = .{};
+ try std.testing.expect(e.configure(&tables, 1, 0));
+ e.begin(100);
+ e.remember(&tables, 0, 3, 12, 1);
+ e.remember(&tables, 1, 3, 15, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ try std.testing.expectEqual(@as(?f64, 15), e.lookup(&tables, 1, 3));
+ e.changed(&tables, 0x80000000);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 1, 3));
+ try std.testing.expect(!e.configure(&tables, 33, 0));
+ try std.testing.expectEqual(@as(u32, 1), e.cache_count);
+ e.begin(0);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 1, 3));
+ try std.testing.expectEqual(@as(u32, 10), e.status);
+ try std.testing.expectEqual(@as(u32, 0), e.work);
+ e.begin(100);
+ e.remember(&tables, 2, 1, 99, 0);
+ e.changed(&tables, 1);
+ e.forget(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, 99), e.lookup(&tables, 2, 1));
+ try std.testing.expectEqual(@as(u32, 1), e.work);
+}
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/sources/examples/shared-layout.pure b/prototypes/workbench/results/2026-09-23-shared-rules/sources/examples/shared-layout.pure
new file mode 100644
index 0000000..38c001d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/sources/examples/shared-layout.pure
@@ -0,0 +1,75 @@
+# 대상별 값은 item에, 공통 반응은 rule에 한 번만 작성합니다.
+state selected = 0
+record Tile {
+ x = 0; y = 0
+ ox = 0; oy = 0
+ sx = 0; sy = 0
+ held = 0; id = 0
+}
+item green = Tile(x=80, y=120, sx=80, sy=120, id=1)
+item blue = Tile(x=280, y=160, sx=280, sy=160, id=2)
+item amber = Tile(x=450, y=220, sx=450, sy=220, id=3)
+input left = ArrowLeft
+input right = ArrowRight
+input up = ArrowUp
+input down = ArrowDown
+input drag_green = pointer(green_box)
+input drag_blue = pointer(blue_box)
+input drag_amber = pointer(amber_box)
+box board(20,90,600,240,"#233a31")
+text help(25,40,selected,"선택한 도형: ")
+box green_box(green.x,green.y,56,44,"#c9f08b")
+box blue_box(blue.x,blue.y,56,44,"#74c4d4")
+box amber_box(amber.x,amber.y,56,44,"#dfb184")
+
+rule grab(t: Tile) {
+ selected = t.id
+ t.sx = t.x; t.sy = t.y
+ t.ox = event_x - t.x; t.oy = event_y - t.y
+ t.held = 1
+}
+rule move(t: Tile) {
+ t.x = clamp(event_x - t.ox, 20, 564)
+ t.y = clamp(event_y - t.oy, 90, 286)
+}
+rule drop(t: Tile) { t.held = 0 }
+rule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }
+rule go_left(t: Tile) {
+ if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }
+}
+rule go_right(t: Tile) {
+ if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }
+}
+rule go_up(t: Tile) {
+ if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }
+}
+rule go_down(t: Tile) {
+ if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }
+}
+
+on drag_green.down use grab(green)
+on drag_green.move use move(green)
+on drag_green.up use drop(green)
+on drag_green.cancel use cancel(green)
+on left.down use go_left(green)
+on right.down use go_right(green)
+on up.down use go_up(green)
+on down.down use go_down(green)
+
+on drag_blue.down use grab(blue)
+on drag_blue.move use move(blue)
+on drag_blue.up use drop(blue)
+on drag_blue.cancel use cancel(blue)
+on left.down use go_left(blue)
+on right.down use go_right(blue)
+on up.down use go_up(blue)
+on down.down use go_down(blue)
+
+on drag_amber.down use grab(amber)
+on drag_amber.move use move(amber)
+on drag_amber.up use drop(amber)
+on drag_amber.cancel use cancel(amber)
+on left.down use go_left(amber)
+on right.down use go_right(amber)
+on up.down use go_up(amber)
+on down.down use go_down(amber)
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/sources/qa-shared-rules.js b/prototypes/workbench/results/2026-09-23-shared-rules/sources/qa-shared-rules.js
new file mode 100644
index 0000000..6dad3dc
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/sources/qa-shared-rules.js
@@ -0,0 +1,137 @@
+// Functional end-to-end persistence checks. Run with Playwright CLI run-code.
+async page => {
+ const checks=[],errors=[];
+ const check=(ok,label)=>{if(!ok)throw new Error(label);checks.push(label);};
+ const state=()=>page.evaluate(()=>window.pureWorkbench.snapshot());
+ const onDialog=dialog=>dialog.accept(), onError=error=>errors.push(error.message);
+ page.on('dialog',onDialog);page.on('pageerror',onError);
+ const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
+ const client=(x,y)=>page.evaluate(({x,y})=>{const p=new DOMPoint(x,y).matrixTransform(document.querySelector('#scene').getScreenCTM());return{x:p.x,y:p.y};},{x,y});
+ const move=async(x,y)=>{const p=await client(x,y);await page.mouse.move(p.x,p.y);};
+ const drag=async(x,y,nx,ny)=>{await page.locator('#scene').scrollIntoViewIfNeeded();await move(x,y);await page.mouse.down();await move(nx,ny);await page.mouse.up();};
+ const upload=async(text,name='layout.json')=>{
+ await page.locator('#file-input').evaluate((input,{text,name})=>{
+ const transfer=new DataTransfer();transfer.items.add(new File([text],name,{type:'application/json'}));
+ input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
+ },{text,name});
+ };
+ try {
+ await page.setViewportSize({width:1440,height:1000});
+ await page.waitForFunction(()=>window.pureWorkbench?.snapshot().ready);
+ check(await page.locator('#download-layout').isDisabled(),'placement save requires an applied definition');
+ await page.locator('#example').selectOption('shared-layout');await page.locator('#load-example').click();await page.locator('#apply').click();
+ const source=await page.locator('#source').inputValue();
+ await drag(90,130,160,180);await drag(290,170,360,220);await drag(460,230,530,270);
+ await page.keyboard.press('ArrowLeft');
+ let s=await state();
+ check(Math.abs(s.states['green.x']-150)<0.1&&Math.abs(s.states['blue.x']-350)<0.1&&Math.abs(s.states['amber.x']-510)<0.1,'three independently authored targets move and selection directs keyboard adjustment');
+ const expected={states:s.states,views:s.views};
+ const draft=source+'\nstate unfinished =';
+ await page.locator('#source').fill(draft);
+ const pending=page.waitForEvent('download');await page.locator('#download-layout').click();
+ const download=await pending;const saved='output/playwright/pure-shared-layout-saved.json';await download.saveAs(saved);
+ check(download.suggestedFilename()==='my-layout.pure.json','placement download is a separate document file');
+ check((await state()).source===draft&&(await state()).appliedSource===source,'saving neither applies nor discards the unfinished draft');
+ await page.locator('#reset').click();
+ check((await state()).states['green.x']===80,'reset returns to authored initial positions');
+ await page.locator('#file-input').setInputFiles(saved);
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
+ s=await state();
+ check(same(s.states,expected.states)&&same(s.views,expected.views),'downloaded file restores all states and visible positions');
+ check(s.source===draft&&s.appliedSource===source&&s.dirty,'the draft remains separate from the restored executable definition');
+ check(!s.running&&!s.held.length&&s.tick===0&&s.successfulEvents===0,'restored work starts stopped with no replayed inputs or clock');
+ await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');
+ check(Math.abs((await state()).states['amber.x']-(expected.states['amber.x']+10))<0.1,'restored selection and bindings remain editable');
+ // Use a fresh page/runtime, not just preservation inside the original instance.
+ const fresh=await page.context().newPage();await fresh.goto(page.url());
+ await fresh.waitForFunction(()=>window.pureWorkbench?.snapshot().ready);
+ await fresh.locator('#file-input').setInputFiles(saved);
+ await fresh.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
+ const reopened=await fresh.evaluate(()=>window.pureWorkbench.snapshot());
+ check(same(reopened.states,expected.states)&&same(reopened.views,expected.views)&&reopened.source===draft,'a fresh browser page reconstructs the saved work without old runtime state');
+ await fresh.close();await page.bringToFront();
+
+ const before=await state();
+ await upload(JSON.stringify({format:'pure-workbench',version:2,source:'state changed=99',draft:'replacement',states:[]}));
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('상태 목록'));
+ s=await state();
+ check(same(s.states,before.states)&&s.source===before.source&&s.appliedSource===before.appliedSource&&s.revision===before.revision,'invalid saved state cannot partially replace code, draft or scene');
+ // Delay File.text, then edit before it completes. Restoring must not overwrite the edit.
+ await page.locator('#file-input').evaluate(input=>{
+ const document={format:'pure-workbench',version:2,source:'state other=4',draft:'state other=4',states:[{name:'other',value:55}]};
+ const transfer=new DataTransfer(),file=new File([JSON.stringify(document)],'delayed.json',{type:'application/json'});
+ const text=JSON.stringify(document);file.text=()=>new Promise(resolve=>{window.__finishLayoutRead=()=>resolve(text);});
+ transfer.items.add(file);input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
+ });
+ await page.locator('#source').fill(draft+'\n# edit during file read');
+ await page.evaluate(()=>window.__finishLayoutRead());
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('작업이 바뀌'));
+ s=await state();check(s.source.endsWith('# edit during file read')&&same(s.states,before.states),'a delayed file read cannot overwrite a newer draft');
+ // Repeat with an input event while file content is pending.
+ await page.locator('#file-input').evaluate(input=>{
+ const text=JSON.stringify({format:'pure-workbench',version:2,source:'state other=4',states:[{name:'other',value:55}]});
+ const file=new File([text],'delayed.json',{type:'application/json'}),transfer=new DataTransfer();
+ file.text=()=>new Promise(resolve=>{window.__finishLayoutRead=()=>resolve(text);});
+ transfer.items.add(file);input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
+ });
+ await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');const changed=await state();
+ await page.evaluate(()=>window.__finishLayoutRead());
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('작업이 바뀌'));
+ check(same((await state()).states,changed.states),'a delayed restore cannot overwrite placement changes made while reading');
+ // UI-only run state is deliberately not persisted.
+ await page.locator('#play').click();await page.locator('#download-layout').click();
+ check((await state()).diagnostic.includes('정지한 뒤'),'saving while time advances requests a stopped document');
+ await page.locator('#play').click();
+ // Refuse while a logical input remains held, with no lost source or partial save.
+ await page.locator('#scene').focus();await page.keyboard.down('ArrowRight');
+ await page.locator('#download-layout').click();
+ check((await state()).diagnostic.includes('조작을 끝낸 뒤'),'saving with a held input is refused');
+ await page.keyboard.up('ArrowRight');
+ await page.locator('#file-input').setInputFiles(saved);
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
+ // One source edit affects every bound target, without editing input adapters.
+ const edited=source.replace('t.x = clamp(t.x + 10,','t.x = clamp(t.x + 25,');
+ await page.locator('#source').fill(edited);await page.locator('#apply').click();
+ const beforeEdit=await state();
+ await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');
+ check(Math.abs((await state()).states['amber.x']-(beforeEdit.states['amber.x']+25))<0.1,'editing the shared rule changes the selected amber target');
+ await drag(beforeEdit.states['blue.x']+10,beforeEdit.states['blue.y']+10,beforeEdit.states['blue.x']+10,beforeEdit.states['blue.y']+10);
+ const blueBefore=(await state()).states['blue.x'];await page.keyboard.press('ArrowRight');
+ check(Math.abs((await state()).states['blue.x']-(blueBefore+25))<0.1,'the same single rule edit also changes the blue target');
+ const lines=edited.split('\n'),items=lines.filter(line=>line.startsWith('item ')).reverse();
+ const reordered=lines.map(line=>line.startsWith('item ')?items.shift():line).join('\n');
+ const beforeReorder=await state();await page.locator('#source').fill(reordered);await page.locator('#apply').click();
+ const afterReorder=await state();
+ check(Object.entries(beforeReorder.states).every(([name,value])=>afterReorder.states[name]===value),'reordering instance declarations preserves every field by qualified name');
+ await page.locator('#scene').focus();await page.keyboard.press('ArrowRight');
+ check(Math.abs((await state()).states['blue.x']-(afterReorder.states['blue.x']+25))<0.1,'rebuilt parameter maps still send selection input to blue after reorder');
+ // Two contacts execute the same body with different bound fields.
+ await page.locator('#source').fill(source);await page.locator('#apply').click();await page.locator('#reset').click();
+ await page.locator('#scene').scrollIntoViewIfNeeded();
+ const session=await page.context().newCDPSession(page);
+ try {
+ await session.send('Emulation.setTouchEmulationEnabled',{enabled:true,maxTouchPoints:2});
+ const p1={id:1,...await client(90,130)},p2={id:2,...await client(290,170)};
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1]});
+ await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[p1,p2]});
+ await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{id:1,...await client(160,180)},{id:2,...await client(360,220)}]});
+ const touch=await state();
+ check(Math.abs(touch.states['green.x']-150)<0.1&&Math.abs(touch.states['blue.x']-350)<0.1&&touch.held.length===2,'simultaneous touches share code but retain independent field bindings');
+ await session.send('Input.dispatchTouchEvent',{type:'touchCancel',touchPoints:[]});
+ const canceled=await state();
+ check(canceled.states['green.x']===80&&canceled.states['blue.x']===280&&!canceled.held.length,'shared cancel restores each contact to its own start position');
+ } finally {await session.send('Emulation.setTouchEmulationEnabled',{enabled:false});await session.detach();}
+ await page.locator('#file-input').setInputFiles(saved);
+ await page.waitForFunction(()=>window.pureWorkbench.snapshot().diagnostic.includes('배치를 복원'));
+ await page.screenshot({path:'output/playwright/pure-shared-layout-desktop.png',fullPage:true});
+ await page.setViewportSize({width:390,height:844});
+ check(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'extra persistence control fits a mobile viewport');
+ await page.screenshot({path:'output/playwright/pure-shared-layout-mobile.png',fullPage:true});
+ check(errors.length===0,'no uncaught page errors');
+ const result={checks,errors,saved,expected,source,draft};
+ await page.evaluate(result=>{window.__pureSharedLayoutEvidence=result;},result);
+ return {checks:checks.length,errors};
+ } catch(error) {
+ await page.evaluate(result=>{window.__pureSharedLayoutEvidence=result;},{failed:String(error),checks,errors});throw error;
+ } finally {page.off('dialog',onDialog);page.off('pageerror',onError);}
+}
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/sources/rules.test.mjs b/prototypes/workbench/results/2026-09-23-shared-rules/sources/rules.test.mjs
new file mode 100644
index 0000000..b4c4969
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/sources/rules.test.mjs
@@ -0,0 +1,94 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {readFile} from 'node:fs/promises';
+import {compile} from './web/compiler.mjs';
+import {createRuntime} from './web/runtime.mjs';
+import {parseProject} from './web/project.mjs';
+const wasm=await readFile(new URL('./web/core.wasm',import.meta.url));
+const shared=await readFile(new URL('./examples/shared-layout.pure',import.meta.url),'utf8');
+const explicit=await readFile(new URL('./examples/layout.pure',import.meta.url),'utf8');
+const fresh=()=>createRuntime(wasm,{cacheEntries:32});
+const named=['green','blue','amber'];
+
+function simple(n){return 'record Cell { value=1 }\ninput tap=Space\nrule inc(t: Cell) { t.value=t.value+1 }\n'+Array.from({length:n},(_,i)=>`item cell${i}=Cell()\non tap.down use inc(cell${i})`).join('\n');}
+
+test('binding more objects shares one VM body instead of expanding its instructions',()=>{
+ for(const n of [1,2,4,8]){
+ const p=compile(simple(n));
+ assert.equal(p.instructions.length,4);assert.equal(p.handlers.length,n);
+ assert.deepEqual(p.handlers.map(h=>h.start),Array(n).fill(0));
+ assert.deepEqual(p.handlers.map(h=>[h.parameterBase,h.parameterCount]),Array.from({length:n},(_,i)=>[i,1]));
+ }
+ assert.equal(compile('record R { value=0 }\nrule unused(t:R) { t.value=t.value+1 }').instructions.length,0);
+});
+
+test('records and rules resolve forward names and reject invalid types fields and bindings',()=>{
+ const p=compile('on tap.down use inc(a)\nitem a=R(v=4)\ninput tap=Space\nrule inc(t:R) { t.v=t.v+1 }\nrecord R { v=0 }');
+ assert.equal(p.states[0].name,'a.v');assert.deepEqual([p.handlers[0].parameterBase,p.handlers[0].parameterCount],[0,1]);
+ for(const code of [
+ 'record R {}','record R { x=0; x=1 }','record R { event_x=0 }',
+ 'item a=Missing()','record R {x=0}\nitem a=R(y=1)',
+ 'record R {x=0}\nitem a=R(x=1,x=2)','record R {x=0}\nstate a=0\nitem a=R()',
+ 'record R {x=0}\nrule bad(t:R) {t.missing=0}',
+ 'record R {x=0}\nrecord S {y=0}\nitem t=S()\nrule bad(t:R) {t.y=1}',
+ 'state value=0\nrecord R {x=0}\nrecord S {y=0}\nitem t=S()\nrule bad(t:R) {value=t.y}',
+ 'rule bad(t:Missing) {}','record R {x=0}\nitem a=R()\ninput k=Space\non k.down use missing(a)',
+ 'record R {x=0}\nrule r(t:R) {}\ninput k=Space\non k.down use r(missing)',
+ 'record R {x=0}\nrecord S {x=0}\nitem a=S()\nrule r(t:R) {}\ninput k=Space\non k.down use r(a)',
+ 'record R {x=0}\nitem a=R()\nrule r(t:R) {t.x=event_x}\ninput k=Space\non k.down use r(a)',
+ 'record R {x=0}\nitem a=R()\nrule r(t:R) {t.x=event_y}\non frame use r(a)',
+ 'record R {x=0}\nitem a=R()\ntext label(0,0,t.x)',
+ `record R { ${Array.from({length:9},(_,i)=>`f${i}=0`).join(';')} }`,
+ ]) assert.throws(()=>compile(code),code);
+ assert.throws(()=>compile(simple(33)),/32 states/);
+});
+
+test('shared layout matches the existing authored layout across independent and simultaneous gestures',async()=>{
+ const a=await fresh(),b=await fresh();a.apply(explicit);b.apply(shared);
+ const compare=()=>{
+ const old=a.snapshot(),next=b.snapshot();assert.equal(next.lastError,old.lastError);
+ assert.equal(next.states.selected,old.states.selected);
+ for(let i=1;i<=3;i++)for(const field of ['x','y','ox','oy','sx','sy','held'])assert.equal(next.states[`${named[i-1]}.${field}`],old.states[`${field}${i}`]);
+ for(let i=0;i<old.views.length;i++)for(const field of ['x','y','width','height','value'])assert.equal(next.views[i][field],old.views[i][field]);
+ };
+ const point=(i,phase,x,y)=>{a.point(`drag${i}`,phase,x,y,`p${i}`);b.point(`drag_${named[i-1]}`,phase,x,y,`p${i}`);compare();};
+ compare();point(1,'down',90,130);point(2,'down',290,170);point(1,'move',350,200);point(2,'move',500,300);point(2,'up',290,170);point(1,'cancel',350,200);
+ for(const key of ['left','right','up','down']){a.down(key);b.down(key);a.up(key);b.up(key);compare();}
+ point(3,'down',460,230);point(3,'move',900,500);point(3,'up',460,230);
+ const saved=JSON.parse(JSON.stringify(b.exportDocument()));const c=await fresh();c.restoreDocument(saved);
+ assert.deepEqual(c.snapshot().states,b.snapshot().states);assert.deepEqual(c.snapshot().views,b.snapshot().views);
+ c.down('left');c.up('left');assert.equal(c.snapshot().states['amber.x'],b.snapshot().states['amber.x']-10);
+});
+
+test('field names retain identity across schema and instance reorder, removal and restoration',async()=>{
+ const r=await fresh();
+ const source='record R { x=1;y=2 }\nitem a=R(x=10)\nitem b=R(x=20)\ninput k=Space\nrule step(t:R) {t.x=t.x+1}\non k.down use step(b)';
+ r.apply(source);r.down('k');r.up('k');
+ r.apply(source.replace('x=1;y=2','y=2;x=1').replace('item a=R(x=10)\nitem b=R(x=20)','item b=R(x=20)\nitem a=R(x=10)'));
+ r.down('k');r.up('k');assert.equal(r.snapshot().states['b.x'],22);assert.equal(r.snapshot().states['a.x'],10);
+ r.apply(source.replace('item a=R(x=10)\n',''));r.down('k');r.up('k');assert.equal(r.snapshot().states['b.x'],23);assert(!Object.hasOwn(r.snapshot().states,'a.x'));
+ const saved=r.exportDocument();r.apply(source);assert.equal(r.snapshot().states['a.x'],10);
+ r.restoreDocument(saved);assert.equal(r.snapshot().states['b.x'],23);assert(!Object.hasOwn(r.snapshot().states,'a.x'));
+});
+
+test('bindings share one event transaction including global effects and multiple uses of one item',async()=>{
+ const r=await fresh();r.apply('state total=0\nrecord R {x=8;divisor=2}\nitem a=R()\nitem b=R(divisor=0)\ninput k=Space\nrule divide(t:R) {total=total+1\nt.x=t.x/t.divisor}\non k.down use divide(a)\non k.down use divide(b)');
+ r.down('k');assert.equal(r.snapshot().lastError,4);assert.equal(r.snapshot().states.total,0);assert.equal(r.snapshot().states['a.x'],8);
+ r.up('k');r.apply(simple(1)+'\non tap.down use inc(cell0)');r.down('tap');r.up('tap');assert.equal(r.snapshot().states['cell0.value'],3);
+});
+
+test('bound writes invalidate affected display caches and failed edits preserve active bindings',async()=>{
+ const r=await fresh();const source=simple(2)+'\ntext first(0,0,cell0.value)\ntext second(0,0,cell1.value)';r.apply(source);r.snapshot();r.snapshot();
+ r.down('tap');r.up('tap');assert.deepEqual(r.snapshot().views.map(v=>v.value),[2,2]);
+ const before=r.snapshot();assert.throws(()=>r.apply(source.replace('inc(cell1)','inc(missing)')));assert.deepEqual(r.snapshot(),before);
+ r.down('tap');r.up('tap');assert.deepEqual(r.snapshot().views.map(v=>v.value),[3,3]);
+});
+
+test('generated qualified names cannot make a valid exported document unreadable by its own loader',async()=>{
+ const field='f'.repeat(18000);
+ const source=`record R { ${field}=1 }\n`+Array.from({length:32},(_,i)=>`item i${i}=R()`).join('\n');
+ const r=await fresh();r.apply(source);
+ const saved=JSON.stringify(r.exportDocument('\u0001'.repeat(32768)));
+ r.restoreDocument(parseProject(saved));
+ assert.equal(r.snapshot().states[`i31.${field}`],1);
+});
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/sources/runtime.test.mjs b/prototypes/workbench/results/2026-09-23-shared-rules/sources/runtime.test.mjs
new file mode 100644
index 0000000..86bc9ba
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/sources/runtime.test.mjs
@@ -0,0 +1,197 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { createRuntime } from './web/runtime.mjs';
+
+const wasm = await readFile(new URL('./web/core.wasm', import.meta.url));
+const fresh = () => createRuntime(wasm);
+const countSource = `state total = 3
+input add = KeyA
+box bar(20, 80, total * 10, 30, "#abcdef")
+text label(20, 60, total, "Total: ")
+on add.down { total = total + 2 }
+on add.up { total = total + 1 }
+`;
+
+test('blank startup and applied empty source contain no application objects or behavior', async () => {
+ const runtime = await fresh();
+ assert.equal(runtime.currentProgram, null);
+ assert.deepEqual(runtime.snapshot().states, {});
+ assert.deepEqual(runtime.snapshot().views, []);
+ runtime.apply('');
+ runtime.frame(60);
+ assert.deepEqual(runtime.snapshot().states, {});
+ assert.deepEqual(runtime.snapshot().views, []);
+ assert.equal(runtime.snapshot().tick, 60);
+ assert.throws(() => runtime.down('jump'), /jump/);
+});
+
+test('author can add unrelated states inputs views and behavior entirely through source', async () => {
+ const runtime = await fresh();
+ runtime.apply(countSource);
+ runtime.down('add'); runtime.up('add');
+ assert.equal(runtime.snapshot().states.total, 6);
+ const added = `${countSource}\nstate radius = 12
+input inflate = KeyI
+circle bubble(240, 160, radius, "#123456")
+on inflate.down { radius = radius + total }
+on frame { radius = radius + 60 * dt }
+`;
+ runtime.apply(added);
+ runtime.down('inflate'); runtime.up('inflate'); runtime.frame(2);
+ assert.deepEqual(runtime.snapshot().states, { total: 6, radius: 20 });
+ assert.equal(runtime.snapshot().views.find(view => view.id === 'bubble').radius, 20);
+ assert.equal(runtime.snapshot().views.length, 3);
+});
+
+test('keyboard and pointer sources aggregate first down and last up; repeats are ignored', async () => {
+ const runtime = await fresh(); runtime.apply(countSource);
+ runtime.down('add', 'keyboard'); runtime.down('add', 'keyboard'); runtime.down('add', 'pointer');
+ assert.equal(runtime.snapshot().states.total, 5);
+ runtime.up('add', 'unknown'); runtime.up('add', 'keyboard');
+ assert.equal(runtime.snapshot().states.total, 5);
+ assert.deepEqual(runtime.snapshot().held, ['add']);
+ runtime.releaseAll(); runtime.up('add', 'pointer');
+ assert.equal(runtime.snapshot().states.total, 6);
+ assert.deepEqual(runtime.snapshot().held, []);
+ assert.equal(runtime.snapshot().successfulEvents, 2);
+});
+
+test('held input rejects apply without changing applied source, metadata, or values', async () => {
+ const runtime = await fresh(); runtime.apply(countSource); runtime.down('add');
+ const before = runtime.snapshot();
+ assert.throws(() => runtime.apply('state changed = 9'), /놓은 뒤/);
+ assert.deepEqual(runtime.snapshot(), before);
+ assert.equal(runtime.exportProject().source, countSource);
+ runtime.up('add'); runtime.apply('state changed = 9');
+ assert.deepEqual(runtime.snapshot().states, { changed: 9 });
+});
+
+test('invalid syntax, unknown references and capacity overflow preserve the previous program', async () => {
+ const runtime = await fresh(); runtime.apply(countSource); runtime.down('add'); runtime.up('add');
+ const before = runtime.snapshot();
+ for (const source of ['state broken =', 'state n = 0\non frame { n = missing }',
+ Array.from({ length: 33 }, (_, i) => `state v${i} = ${i}`).join('\n'), '#'.repeat(32769)]) {
+ assert.throws(() => runtime.apply(source));
+ assert.deepEqual(runtime.snapshot(), before);
+ assert.equal(runtime.exportProject().source, countSource);
+ }
+});
+
+test('state preservation follows names across reorder/add/remove; reset uses new initializers', async () => {
+ const runtime = await fresh();
+ runtime.apply('state x = 11\nstate y = 22');
+ runtime.apply('state z = 30\nstate y = 2\nstate x = 1');
+ assert.deepEqual(runtime.snapshot().states, { z: 30, y: 22, x: 11 });
+ runtime.apply('state y = 200\nstate renamed = 9');
+ assert.deepEqual(runtime.snapshot().states, { y: 22, renamed: 9 });
+ runtime.reset();
+ assert.deepEqual(runtime.snapshot().states, { y: 200, renamed: 9 });
+ assert.equal(runtime.snapshot().revision, 3);
+});
+
+test('callers cannot mutate active metadata and corrupt name-based state preservation', async () => {
+ const runtime = await fresh(); const program = runtime.apply('state x = 11\nstate y = 22');
+ assert.throws(() => program.states.reverse(), TypeError);
+ assert.throws(() => { runtime.currentProgram.states[0].name = 'y'; }, TypeError);
+ runtime.apply('state x = 0\nstate y = 0');
+ assert.deepEqual(runtime.snapshot().states, { x: 11, y: 22 });
+});
+
+test('conditions execute once, and assignments and multiple handlers follow source order', async () => {
+ const runtime = await fresh();
+ runtime.apply(`state x = 0
+state seen = 0
+on frame {
+ if x == 0 {
+ x = 1
+ seen = x + 1
+ }
+ x = x + 3
+}
+on frame { seen = seen + x }
+`);
+ runtime.frame();
+ assert.deepEqual(runtime.snapshot().states, { x: 4, seen: 6 });
+ runtime.frame();
+ assert.deepEqual(runtime.snapshot().states, { x: 7, seen: 13 });
+});
+
+test('a failed event rolls back all handlers and keeps its source diagnostic through release', async () => {
+ const runtime = await fresh();
+ const source = `state x = 7
+state y = 9
+input breakit = Space
+on breakit.down { x = 99 }
+on breakit.down {
+ y = 2
+ x = 1 / 0
+}
+`;
+ runtime.apply(source);
+ assert.equal(runtime.down('breakit'), 4);
+ assert.deepEqual(runtime.snapshot().states, { x: 7, y: 9 });
+ assert.equal(runtime.snapshot().successfulEvents, 0);
+ assert.equal(runtime.snapshot().errorLine, 7);
+ runtime.up('breakit'); runtime.frame();
+ assert.equal(runtime.snapshot().lastEvent.status, 0);
+ assert.equal(runtime.snapshot().lastFailure.phase, 'down');
+ assert.equal(runtime.snapshot().lastError, 4);
+ assert.equal(runtime.snapshot().errorLine, 7);
+ assert.throws(() => runtime.apply('bad'));
+ assert.equal(runtime.snapshot().lastError, 4);
+ runtime.apply(source.replace('1 / 0', '10'));
+ assert.equal(runtime.snapshot().lastError, 0);
+ runtime.down('breakit'); runtime.up('breakit');
+ assert.deepEqual(runtime.snapshot().states, { x: 10, y: 2 });
+});
+
+test('reset clears held inputs and latched errors; invalid frame counts have no effects', async () => {
+ const runtime = await fresh();
+ runtime.apply('state x = 5\ninput tap = Space\non tap.down { x = 1 / 0 }');
+ runtime.down('tap'); runtime.reset();
+ const before = runtime.snapshot();
+ assert.equal(before.lastError, 0); assert.equal(before.lastFailure, null);
+ assert.deepEqual(before.held, []);
+ for (const count of [-1, 601, 0.5, NaN, Infinity]) assert.throws(() => runtime.frame(count));
+ assert.deepEqual(runtime.snapshot(), before);
+});
+
+test('render evaluation is pure and invalid binding is distinct from an event rollback', async () => {
+ const runtime = await fresh(); runtime.apply(countSource); runtime.down('add'); runtime.up('add');
+ const before = runtime.snapshot();
+ for (let i = 0; i < 100; i++) assert.deepEqual(runtime.snapshot(), before);
+ runtime.apply('state n = 2\ncircle bad(20, 20, -n, "#123456")\ntext fine(0, 0, n)');
+ const result = runtime.snapshot();
+ assert.equal(result.lastError, 100); assert.equal(result.errorLine, 2);
+ assert.deepEqual(result.states, { n: 2 }); assert.equal(result.views.length, 1);
+ assert.equal(result.lastFailure, null);
+ assert.deepEqual(runtime.snapshot(), result);
+});
+
+test('independent adapters replay the same author program without sharing live state', async () => {
+ const left = await fresh(), right = await fresh();
+ left.apply(countSource); right.apply(countSource);
+ for (const runtime of [left, right]) {
+ runtime.down('add', 'keyboard'); runtime.frame(3); runtime.up('add', 'keyboard');
+ }
+ assert.deepEqual(left.snapshot(), right.snapshot());
+ left.down('add');
+ assert.equal(right.snapshot().states.total, 6);
+ assert.equal(left.snapshot().states.total, 8);
+});
+
+test('Wasm has no imports and reports fixed memory and exact active IR/state storage', async () => {
+ const module = await WebAssembly.compile(wasm);
+ assert.deepEqual(WebAssembly.Module.imports(module), []);
+ const runtime = await fresh(); const program = runtime.apply(countSource);
+ const before = runtime.snapshot().memory;
+ assert.equal(before.linearBytes, 65536);
+ assert.equal(before.instructionBytes, 16); assert.equal(before.handlerBytes, 16);
+ assert.equal(before.activeIRBytes, program.instructions.length * 16 + program.handlers.length * 16);
+ assert.equal(before.activeStateBytes, program.states.length * 8);
+ assert(before.stagingReservedBytes < before.reservedEngineBytes);
+ assert(before.reservedEngineBytes < before.linearBytes);
+ for (let i = 0; i < 20; i++) runtime.frame(600);
+ assert.deepEqual(runtime.snapshot().memory, before);
+});
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/sources/web/app.js b/prototypes/workbench/results/2026-09-23-shared-rules/sources/web/app.js
new file mode 100644
index 0000000..e163ebc
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/sources/web/app.js
@@ -0,0 +1,403 @@
+import { createRuntime } from './runtime.mjs';
+import { createOutput } from './output.mjs';
+import { bindSceneInput } from './scene-input.mjs';
+import { parseProject, MAX_PROJECT_BYTES } from './project.mjs';
+
+const $ = id => document.getElementById(id);
+const source = $('source');
+const MAX_SOURCE_BYTES = 32768;
+const encoder = new TextEncoder();
+const output = createOutput(document);
+const snippets = {
+ state: 'state count = 0\nstate x = 80\n',
+ input: 'input tap = Space\non tap.down {\n count = count + 1\n}\n',
+ view: 'box tile(x, 180, 40, 40, "#7ed9ae")\ntext label(40, 60, count, "Count: ")\n',
+ frame: 'on frame {\n x = x + 30 * dt\n if x > 600 { x = 0 }\n}\n',
+};
+let runtime = null;
+let appliedSource = '';
+let running = false;
+let previousTime = null;
+let accumulator = 0;
+let droppedTicks = 0;
+let diagnosticLocation = null;
+let draftGeneration = 0;
+const keyboardSources = new Map();
+const pointerSources = new Map();
+const inputButtons = new Map();
+const sceneInput = bindSceneInput($('scene'), () => runtime, safeAction);
+
+const bytes = text => encoder.encode(text).byteLength;
+const dirty = () => source.value !== appliedSource;
+// DOM is the published value. Avoid rewriting unchanged diagnostics/controls.
+function text(id, value) {
+ const node = $(id);
+ if (node.textContent !== value) node.textContent = value;
+}
+function pressed(node, value) {
+ const next = String(value);
+ if (node.getAttribute('aria-pressed') !== next) node.setAttribute('aria-pressed', next);
+}
+function draftStatus() {
+ const changed = dirty();
+ text('draft-status', changed ? '미적용 변경' : runtime?.currentProgram ? '적용됨' : '빈 작업물');
+ $('draft-status').classList.toggle('dirty', changed);
+}
+
+function setDiagnostic(message, kind = '', location = null) {
+ $('diagnostic-text').textContent = message;
+ $('diagnostic').className = `diagnostic ${kind}`;
+ diagnosticLocation = location;
+ $('error-location').hidden = !location;
+ source.setAttribute('aria-invalid', kind === 'error' && location ? 'true' : 'false');
+}
+
+function cursorPosition() {
+ const before = source.value.slice(0, source.selectionStart);
+ const lines = before.split('\n');
+ text('cursor-position', `${lines.length}행 · ${lines.at(-1).length + 1}열`);
+}
+
+function draftChanged() {
+ draftGeneration++;
+ // Read scroll position before writes; this path runs only for source edits.
+ const scrollTop = source.scrollTop;
+ const count = source.value.split('\n').length;
+ const size = bytes(source.value);
+ text('line-numbers', Array.from({ length: count }, (_, i) => i + 1).join('\n'));
+ $('line-numbers').scrollTop = scrollTop;
+ text('source-size', `${size.toLocaleString('en-US')} / 32,768 bytes`);
+ $('source-size').style.color = size > MAX_SOURCE_BYTES ? 'var(--danger)' : '';
+ draftStatus();
+ cursorPosition();
+}
+
+function pause() {
+ running = false;
+ previousTime = null;
+ accumulator = 0;
+ text('play', '▶ 실행');
+ pressed($('play'), false);
+}
+
+function safeAction(action) {
+ if (!runtime) return;
+ try { action(); }
+ catch (error) { pause(); setDiagnostic(error.message, 'error', error.line ? { line: error.line, column: error.column || 1 } : null); }
+ refresh();
+}
+
+function releaseEverything() {
+ keyboardSources.clear();
+ pointerSources.clear();
+ sceneInput.releaseCaptures();
+ if (runtime) safeAction(() => runtime.releaseAll());
+}
+
+function loseFocus() {
+ pause();
+ releaseEverything();
+ refresh();
+}
+
+function refresh() {
+ if (!runtime) return;
+ const snapshot = runtime.present(output);
+ if (snapshot.lastError) pause();
+ const held = new Set((snapshot.held || []).map(input => typeof input === 'string' ? input : input.name));
+ for (const [name, button] of inputButtons) {
+ button.classList.toggle('held', held.has(name));
+ pressed(button, held.has(name));
+ }
+ text('scene-status', runtime.currentProgram ? (running ? '실행 중' : '정지') : '적용 전');
+ text('clock-status', `${snapshot.tick || 0} frame · ${running ? '60 Hz' : '정지'}`);
+ text('revision', `revision ${snapshot.revision || 0}`);
+ for (const id of ['play', 'step', 'reset', 'download-layout']) {
+ if ($(id).disabled !== !runtime.currentProgram) $(id).disabled = !runtime.currentProgram;
+ }
+ const event = snapshot.lastEvent;
+ text('last-event', event ? `${event.name}${event.phase && event.phase !== 'frame' ? `.${event.phase}` : ''} · ${event.status ? '실패' : '완료'}${event.line ? ` · ${event.line}행` : ''}` : '아직 없음');
+ if ($('runtime-error').hidden !== !snapshot.lastError) $('runtime-error').hidden = !snapshot.lastError;
+ const displayError = snapshot.lastError === 100 || snapshot.lastError === 101;
+ let message = snapshot.lastError ? `${displayError ? '표시 오류' : '마지막 실패'} · ${snapshot.errorLine ? `${snapshot.errorLine}행 · ` : ''}${snapshot.errorMessage || `실행 오류 ${snapshot.lastError}`}${snapshot.lastError === 101 ? ' — 이번 장면을 표시하지 않았습니다. 상태 변경은 유지됩니다.' : displayError ? ' — 해당 도형을 표시할 수 없습니다.' : ' — 실패한 이벤트의 상태 변경은 취소됐습니다. 코드를 적용하거나 초깃값으로 되돌리면 이 기록을 지웁니다.'}` : '';
+ if (!displayError && snapshot.displayError?.code === 101)
+ message += ` · ${snapshot.displayError.message}`;
+ text('runtime-error', message);
+ output.costs(snapshot, runtime.currentProgram?.metrics, droppedTicks);
+}
+
+function endPointer(event) {
+ const entry = pointerSources.get(event.pointerId);
+ if (!entry) return;
+ event.preventDefault();
+ pointerSources.delete(event.pointerId);
+ safeAction(() => runtime.up(entry.name, entry.source));
+}
+
+function rebuildInputControls() {
+ inputButtons.clear();
+ const children = [];
+ for (const input of runtime.currentProgram?.inputs || []) {
+ if (input.target) {
+ const note = document.createElement('p');
+ note.className = 'empty-note';
+ note.textContent = `${input.name} · 장면의 ${input.target}을 잡아 조작하세요.`;
+ children.push(note);
+ continue;
+ }
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'input-button';
+ button.dataset.input = input.name;
+ button.setAttribute('aria-pressed', 'false');
+ const name = document.createElement('span'); name.textContent = input.name;
+ const key = document.createElement('kbd'); key.textContent = input.key;
+ button.append(name, key);
+ button.addEventListener('pointerdown', event => {
+ if (event.button !== 0) return;
+ event.preventDefault();
+ const identity = `pointer:${event.pointerId}`;
+ pointerSources.set(event.pointerId, { name: input.name, source: identity });
+ button.setPointerCapture(event.pointerId);
+ safeAction(() => runtime.down(input.name, identity));
+ });
+ button.addEventListener('pointerup', endPointer);
+ button.addEventListener('pointercancel', endPointer);
+ button.addEventListener('lostpointercapture', endPointer);
+ // Keyboard activation and assistive technology clicks are complete taps.
+ // Pointer clicks already delivered down/up via the handlers above.
+ button.addEventListener('click', event => {
+ if (event.detail !== 0) return;
+ safeAction(() => { runtime.down(input.name, 'activation'); runtime.up(input.name, 'activation'); });
+ });
+ inputButtons.set(input.name, button);
+ children.push(button);
+ }
+ if (!children.length) {
+ const message = document.createElement('p');
+ message.className = 'empty-note';
+ message.textContent = '입력을 선언하면 여기에 버튼이 생깁니다.';
+ children.push(message);
+ }
+ $('input-controls').replaceChildren(...children);
+}
+
+function isEditing(target) {
+ return target instanceof Element && Boolean(target.closest('textarea,input,select,button,[contenteditable="true"],[role="textbox"]'));
+}
+
+$('scene').addEventListener('pointerdown', () => $('scene').focus());
+
+document.addEventListener('keydown', event => {
+ if (!runtime || event.repeat || event.isComposing || event.ctrlKey || event.metaKey || event.altKey || isEditing(event.target)) return;
+ const input = runtime.currentProgram?.inputs.find(item => item.key === event.code);
+ if (!input) return;
+ event.preventDefault();
+ if (keyboardSources.has(event.code)) return;
+ keyboardSources.set(event.code, input.name);
+ safeAction(() => runtime.down(input.name, `keyboard:${event.code}`));
+});
+
+document.addEventListener('keyup', event => {
+ const name = keyboardSources.get(event.code);
+ if (!name) return;
+ event.preventDefault();
+ keyboardSources.delete(event.code);
+ safeAction(() => runtime.up(name, `keyboard:${event.code}`));
+});
+
+source.addEventListener('input', () => {
+ draftChanged();
+ if (diagnosticLocation) setDiagnostic('코드를 수정했습니다. 적용해서 다시 확인하세요.');
+});
+source.addEventListener('scroll', () => { $('line-numbers').scrollTop = source.scrollTop; });
+for (const name of ['click', 'keyup', 'select']) source.addEventListener(name, cursorPosition);
+source.addEventListener('keydown', event => {
+ if (event.key === 'Tab' && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
+ event.preventDefault();
+ source.setRangeText(' ', source.selectionStart, source.selectionEnd, 'end');
+ draftChanged();
+ }
+});
+
+$('apply').addEventListener('click', () => {
+ if (!runtime) return;
+ try {
+ runtime.apply(source.value);
+ appliedSource = source.value;
+ rebuildInputControls();
+ setDiagnostic('코드를 적용했습니다. 같은 이름의 상태는 유지됩니다.', 'success');
+ } catch (error) {
+ const location = error.line ? { line: error.line, column: error.column || 1 } : null;
+ setDiagnostic(`${location ? `${location.line}행 ${location.column}열 · ` : ''}${error.message} · 기존 장면은 그대로 유지됩니다.`, 'error', location);
+ }
+ draftStatus();
+ refresh();
+});
+
+$('error-location').addEventListener('click', () => {
+ if (!diagnosticLocation) return;
+ const lines = source.value.split('\n');
+ const lineIndex = Math.min(lines.length - 1, diagnosticLocation.line - 1);
+ const start = lines.slice(0, lineIndex).reduce((sum, line) => sum + line.length + 1, 0);
+ const at = start + Math.min(lines[lineIndex].length, diagnosticLocation.column - 1);
+ source.focus();
+ source.setSelectionRange(at, Math.min(at + 1, source.value.length));
+ const lineHeight = parseFloat(getComputedStyle(source).lineHeight);
+ source.scrollTop = Math.max(0, lineIndex * lineHeight - source.clientHeight / 3);
+ $('line-numbers').scrollTop = source.scrollTop;
+ cursorPosition();
+});
+
+$('play').addEventListener('click', () => {
+ if (running) pause();
+ else { running = true; previousTime = null; accumulator = 0; $('play').textContent = 'Ⅱ 정지'; $('play').setAttribute('aria-pressed', 'true'); }
+ refresh();
+});
+$('step').addEventListener('click', () => { pause(); safeAction(() => runtime.frame()); });
+$('reset').addEventListener('click', () => {
+ pause(); releaseEverything(); droppedTicks = 0;
+ safeAction(() => runtime.reset());
+ setDiagnostic('적용된 코드의 초깃값으로 되돌렸습니다. 편집 중인 코드는 유지됩니다.');
+});
+
+function replaceDraft(text, message) {
+ source.value = text;
+ source.scrollTop = 0;
+ source.setSelectionRange(0, 0);
+ setDiagnostic(message);
+ draftChanged();
+}
+
+function mayReplaceDraft() {
+ return !source.value || window.confirm('현재 편집 중인 코드를 바꿀까요? 저장하지 않은 내용은 사라집니다. 실행 장면은 적용 전까지 유지됩니다.');
+}
+
+$('new').addEventListener('click', () => {
+ if (mayReplaceDraft()) replaceDraft('', '편집기만 비웠습니다. 빈 코드를 적용하면 장면도 비워집니다.');
+});
+
+for (const button of document.querySelectorAll('.insert-snippet')) {
+ button.addEventListener('click', () => {
+ const at = source.selectionStart;
+ const prefix = at > 0 && source.value[at - 1] !== '\n' ? '\n' : '';
+ source.setRangeText(prefix + snippets[button.dataset.snippet], source.selectionStart, source.selectionEnd, 'end');
+ source.focus();
+ draftChanged();
+ setDiagnostic('조각을 삽입했습니다. 이름과 동작을 원하는 대로 바꾸고 적용하세요.');
+ });
+}
+
+$('load-example').addEventListener('click', async () => {
+ if (!mayReplaceDraft()) return;
+ const selected = $('example').value;
+ const before = source.value;
+ const button = $('load-example');
+ button.disabled = true;
+ try {
+ const response = await fetch(`./examples/${selected}.pure`);
+ if (!response.ok) throw new Error(`예제를 불러오지 못했습니다 (${response.status}).`);
+ const text = await response.text();
+ if (bytes(text) > MAX_SOURCE_BYTES) throw new Error('예제가 코드 크기 제한을 넘었습니다.');
+ if (source.value !== before) throw new Error('불러오는 동안 코드가 바뀌었습니다. 변경 내용을 지키기 위해 불러오기를 취소했습니다.');
+ replaceDraft(text, '예제를 편집기에 불러왔습니다. 아직 적용하지 않았습니다.');
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+ finally { button.disabled = false; }
+});
+
+function download(filename, content, mime, message = '편집 중인 코드를 파일로 저장했습니다. 적용되지 않은 변경도 포함합니다.') {
+ const url = URL.createObjectURL(new Blob([content], { type: mime }));
+ const link = document.createElement('a');
+ link.href = url; link.download = filename;
+ document.body.append(link); link.click(); link.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ setDiagnostic(message);
+}
+$('download').addEventListener('click', () => download('my-work.pure', source.value, 'text/plain;charset=utf-8'));
+$('download-project').addEventListener('click', () => download('my-work.pure.json', JSON.stringify({ format: 'pure-workbench', version: 1, source: source.value }, null, 2) + '\n', 'application/json'));
+$('download-layout').addEventListener('click', () => {
+ try {
+ if (running) throw new Error('시간 진행을 정지한 뒤 배치를 저장하세요.');
+ const project = runtime.exportDocument(source.value);
+ download('my-layout.pure.json', JSON.stringify(project, null, 2) + '\n', 'application/json',
+ '적용된 코드·현재 배치·미적용 초안을 함께 저장했습니다.');
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+});
+$('upload').addEventListener('click', () => $('file-input').click());
+$('file-input').addEventListener('change', async event => {
+ const file = event.target.files[0];
+ event.target.value = '';
+ if (!file) return;
+ try {
+ if (file.size > MAX_PROJECT_BYTES) throw new Error('파일이 너무 큽니다. 코드 제한은 32 KiB입니다.');
+ const before = draftGeneration;
+ const active = runtime?.currentProgram;
+ const events = runtime?.snapshot().successfulEvents;
+ let content = await file.text();
+ if (file.name.toLowerCase().endsWith('.json')) {
+ const project = parseProject(content);
+ if (project.version === 2) {
+ if (!runtime) throw new Error('실행기가 준비되지 않았습니다.');
+ if (draftGeneration !== before || runtime.currentProgram !== active || runtime.snapshot().successfulEvents !== events)
+ throw new Error('파일을 읽는 동안 작업이 바뀌었습니다. 정지한 뒤 다시 불러오세요.');
+ if (running) throw new Error('시간 진행을 정지한 뒤 배치를 복원하세요.');
+ if ((source.value || appliedSource) && !window.confirm('현재 코드와 배치를 파일의 내용으로 복원할까요? 저장하지 않은 작업은 사라집니다.')) return;
+ const restored = runtime.restoreDocument(project);
+ pause(); droppedTicks = 0;
+ appliedSource = restored.source;
+ rebuildInputControls();
+ replaceDraft(restored.draft, '배치를 복원했습니다. 미적용 초안도 보존했고, 시간은 정지 상태입니다.');
+ refresh();
+ return;
+ }
+ content = project.source;
+ }
+ if (bytes(content) > MAX_SOURCE_BYTES) throw new Error('코드가 32 KiB 제한을 넘었습니다.');
+ if (draftGeneration !== before) throw new Error('파일을 읽는 동안 코드가 바뀌었습니다. 불러오기를 다시 시도하세요.');
+ if (mayReplaceDraft()) replaceDraft(content, `${file.name}을 편집기에 불러왔습니다. 아직 적용하지 않았습니다.`);
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+});
+
+window.addEventListener('blur', loseFocus);
+document.addEventListener('visibilitychange', () => { if (document.hidden) loseFocus(); });
+window.addEventListener('beforeunload', event => {
+ // Applying is not persistence: even applied code can be lost when leaving.
+ if (source.value || appliedSource) { event.preventDefault(); event.returnValue = ''; }
+});
+
+function animate(time) {
+ if (running && runtime) {
+ if (previousTime !== null) {
+ accumulator += Math.max(0, time - previousTime);
+ const wanted = Math.floor(accumulator / (1000 / 60));
+ if (wanted > 0) {
+ const steps = Math.min(6, wanted);
+ accumulator -= wanted * (1000 / 60);
+ droppedTicks += wanted - steps;
+ safeAction(() => runtime.frame(steps));
+ }
+ }
+ previousTime = running ? time : null;
+ }
+ requestAnimationFrame(animate);
+}
+
+Object.defineProperty(window, 'pureWorkbench', {
+ configurable: false,
+ writable: false,
+ value: Object.freeze({ snapshot: () => structuredClone({ ...(runtime?.snapshot() || {}), ready: Boolean(runtime), source: source.value, appliedSource, dirty: dirty(), running, droppedTicks, diagnostic: $('diagnostic-text').textContent }) }),
+});
+
+draftChanged();
+requestAnimationFrame(animate);
+try {
+ runtime = await createRuntime();
+ $('ready-status').textContent = '실행기 준비됨';
+ $('ready-dot').classList.add('ready');
+ $('apply').disabled = false;
+ refresh();
+} catch (error) {
+ $('ready-status').textContent = '실행기 준비 실패';
+ $('ready-dot').classList.add('failed');
+ setDiagnostic(`실행기를 열지 못했습니다. ${error.message}`, 'error');
+}
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/sources/web/compiler.mjs b/prototypes/workbench/results/2026-09-23-shared-rules/sources/web/compiler.mjs
new file mode 100644
index 0000000..7e8bff3
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/sources/web/compiler.mjs
@@ -0,0 +1,447 @@
+// Pure's experimental scalar interaction language. No host code is evaluated.
+const LIMITS = Object.freeze({ sourceBytes: 32768, states: 32, inputs: 8,
+ views: 24, handlers: 64, instructions: 512, depth: 64, stack: 64 });
+const RESERVED = new Set(['frame', 'dt', 'event_x', 'event_y', 'min', 'max', 'clamp']);
+const BINARY = new Map([
+ ['||', [1, 15]], ['&&', [2, 14]], ['==', [3, 12]], ['!=', [3, 13]],
+ ['<', [4, 8]], ['<=', [4, 9]], ['>', [4, 10]], ['>=', [4, 11]],
+ ['+', [5, 3]], ['-', [5, 4]], ['*', [6, 5]], ['/', [6, 6]],
+]);
+const FUNCTIONS = { min: [2, 17], max: [2, 18], clamp: [3, 19] };
+const VIEW_FIELDS = { box: ['x', 'y', 'width', 'height'],
+ circle: ['x', 'y', 'radius'], text: ['x', 'y', 'value'] };
+
+function fail(token, message) {
+ const error = new Error(`${message} (${token.line}:${token.column})`);
+ error.line = token.line;
+ error.column = token.column;
+ throw error;
+}
+
+function lex(source) {
+ const tokens = [];
+ let offset = 0, line = 1, column = 1;
+ const advance = () => {
+ const char = source[offset++];
+ if (char === '\n') { line++; column = 1; } else column++;
+ return char;
+ };
+ while (offset < source.length) {
+ const char = source[offset];
+ if (char === ' ' || char === '\t' || char === '\r') { advance(); continue; }
+ if (char === '#') { while (offset < source.length && source[offset] !== '\n') advance(); continue; }
+ const token = { line, column };
+ if (char === '\n') { advance(); tokens.push({ ...token, kind: 'nl' }); continue; }
+ if (char === '"') {
+ advance();
+ let value = '', closed = false;
+ while (offset < source.length) {
+ const next = advance();
+ if (next === '"') { closed = true; break; }
+ if (next === '\n' || next === '\r') fail(token, 'Strings must fit on one line');
+ if (next === '\\') {
+ const escaped = advance();
+ const escapes = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' };
+ if (!Object.hasOwn(escapes, escaped)) fail(token, 'Unsupported string escape');
+ value += escapes[escaped];
+ } else value += next;
+ }
+ if (!closed) fail(token, 'Unterminated string');
+ tokens.push({ ...token, kind: 'string', value }); continue;
+ }
+ const remaining = source.slice(offset);
+ const number = remaining.match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/);
+ if (number) {
+ const value = Number(number[0]);
+ if (!Number.isFinite(value)) fail(token, 'Number must be finite');
+ for (let i = 0; i < number[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'number', value }); continue;
+ }
+ const name = remaining.match(/^[A-Za-z_][A-Za-z_0-9]*/);
+ if (name) {
+ for (let i = 0; i < name[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'name', value: name[0] }); continue;
+ }
+ const pair = source.slice(offset, offset + 2);
+ if (['<=', '>=', '==', '!=', '&&', '||'].includes(pair)) {
+ advance(); advance(); tokens.push({ ...token, kind: pair }); continue;
+ }
+ if ('=+-*/!<>(){},.;:'.includes(char)) {
+ advance(); tokens.push({ ...token, kind: char }); continue;
+ }
+ fail(token, `Unexpected character ${JSON.stringify(char)}`);
+ }
+ tokens.push({ kind: 'eof', line, column });
+ return tokens;
+}
+
+class Parser {
+ constructor(tokens) { this.tokens = tokens; this.position = 0; this.depth = 0; }
+ peek() { return this.tokens[this.position]; }
+ take() { return this.tokens[this.position++]; }
+ is(kind, value) { return this.peek().kind === kind && (value === undefined || this.peek().value === value); }
+ expect(kind, description = kind) {
+ if (!this.is(kind)) fail(this.peek(), `Expected ${description}`);
+ return this.take();
+ }
+ newlines() { while (this.is('nl')) this.take(); }
+ separators() { while (this.is('nl') || this.is(';')) this.take(); }
+ end() {
+ if (!['nl', ';', '}', 'eof'].includes(this.peek().kind)) fail(this.peek(), 'Expected newline or semicolon');
+ this.separators();
+ }
+ reference(token) {
+ if (!this.is('.')) return token.value;
+ this.take();
+ return `${token.value}.${this.expect('name', 'field name').value}`;
+ }
+ initial() {
+ let sign = 1;
+ if (this.is('-') || this.is('+')) sign = this.take().kind === '-' ? -1 : 1;
+ return sign * this.expect('number', 'numeric state initializer').value;
+ }
+ node(token, properties, children = []) {
+ const depth = 1 + Math.max(0, ...children.map(child => child.depth));
+ if (depth > LIMITS.depth) fail(token, 'Expression nesting exceeds 64');
+ return { token, depth, ...properties };
+ }
+ expression(minimum = 0, multiline = false) {
+ if (++this.depth > LIMITS.depth) fail(this.peek(), 'Expression nesting exceeds 64');
+ if (multiline) this.newlines();
+ const token = this.take();
+ let left;
+ if (token.kind === 'number') left = this.node(token, { kind: 'constant', value: token.value });
+ else if (token.kind === '-' || token.kind === '!') {
+ const child = this.expression(7, multiline);
+ left = this.node(token, { kind: 'unary', op: token.kind === '-' ? 7 : 16, child }, [child]);
+ } else if (token.kind === '(') {
+ left = this.expression(0, true); this.newlines(); this.expect(')');
+ } else if (token.kind === 'name') {
+ if (Object.hasOwn(FUNCTIONS, token.value) && this.is('(')) {
+ this.take();
+ const args = [];
+ this.newlines();
+ if (!this.is(')')) {
+ for (;;) {
+ args.push(this.expression(0, true)); this.newlines();
+ if (!this.is(',')) break;
+ this.take();
+ }
+ }
+ this.expect(')');
+ if (args.length !== FUNCTIONS[token.value][0]) fail(token, `${token.value} expects ${FUNCTIONS[token.value][0]} arguments`);
+ left = this.node(token, { kind: 'call', op: FUNCTIONS[token.value][1], args }, args);
+ } else left = this.node(token, { kind: 'reference', name: this.reference(token) });
+ } else fail(token, 'Expected a number, state, or expression');
+ for (;;) {
+ if (multiline) this.newlines();
+ const entry = BINARY.get(this.peek().kind);
+ if (!entry || entry[0] < minimum) break;
+ const operator = this.take();
+ const right = this.expression(entry[0] + 1, multiline);
+ left = this.node(operator, { kind: 'binary', op: entry[1], left, right }, [left, right]);
+ }
+ this.depth--;
+ return left;
+ }
+ block(depth = 0) {
+ if (depth >= LIMITS.depth) fail(this.peek(), 'Handler nesting exceeds 64');
+ this.newlines(); this.expect('{'); this.separators();
+ const statements = [];
+ while (!this.is('}')) {
+ const token = this.expect('name', 'assignment, if, or closing brace');
+ if (token.value === 'if' && !this.is('=')) {
+ const condition = this.expression();
+ const body = this.block(depth + 1);
+ statements.push({ kind: 'if', token, condition, body });
+ } else {
+ const name = this.reference(token);
+ this.expect('=');
+ statements.push({ kind: 'assign', token, name, expression: this.expression() });
+ }
+ this.end();
+ }
+ this.take();
+ return statements;
+ }
+ parse() {
+ const declarations = [];
+ this.separators();
+ while (!this.is('eof')) {
+ const token = this.expect('name', 'declaration');
+ const kind = token.value;
+ if (kind === 'state') {
+ const name = this.expect('name', 'state name'); this.expect('=');
+ declarations.push({ kind, token, name, initial: this.initial() });
+ } else if (kind === 'record') {
+ const name = this.expect('name', 'record type'); this.newlines(); this.expect('{'); this.separators();
+ const fields = [];
+ while (!this.is('}')) {
+ const field = this.expect('name', 'field name'); this.expect('=');
+ fields.push({name:field, initial:this.initial()}); this.end();
+ }
+ this.take(); declarations.push({kind,token,name,fields});
+ } else if (kind === 'item') {
+ const name = this.expect('name', 'item name'); this.expect('=');
+ const type = this.expect('name', 'record type'); this.expect('('); this.newlines();
+ const fields = [];
+ while (!this.is(')')) {
+ const field = this.expect('name', 'field name'); this.expect('=');
+ fields.push({name:field, initial:this.initial()}); this.newlines();
+ if (!this.is(',')) break;
+ this.take(); this.newlines();
+ }
+ this.expect(')'); declarations.push({kind,token,name,type,fields});
+ } else if (kind === 'rule') {
+ const name = this.expect('name', 'rule name'); this.expect('(');
+ const parameter = this.expect('name', 'record parameter'); this.expect(':');
+ const type = this.expect('name', 'record type'); this.expect(')');
+ declarations.push({kind,token,name,parameter,type,body:this.block()});
+ } else if (kind === 'input') {
+ const name = this.expect('name', 'input name'); this.expect('=');
+ const key = this.expect('name', 'keyboard code or pointer(view)');
+ if (key.value === 'pointer') {
+ this.expect('(');
+ const target = this.expect('name', 'view ID'); this.expect(')');
+ declarations.push({ kind, token, name, target });
+ this.end(); continue;
+ }
+ if (!/^(?:Space|Enter|Arrow(?:Left|Right|Up|Down)|Key[A-Z]|Digit[0-9])$/.test(key.value)) fail(key, 'Unsupported keyboard code');
+ declarations.push({ kind, token, name, key });
+ } else if (Object.hasOwn(VIEW_FIELDS, kind)) {
+ const name = this.expect('name', 'view ID'); this.expect('(');
+ const expressions = [];
+ for (let i = 0; i < VIEW_FIELDS[kind].length; i++) {
+ if (i) { this.newlines(); this.expect(','); }
+ expressions.push(this.expression(0, true));
+ }
+ this.newlines();
+ let color, prefix = '';
+ if (kind !== 'text') {
+ this.expect(','); this.newlines();
+ const value = this.expect('string', 'literal hex color');
+ if (!/^#[0-9a-fA-F]{6}$/.test(value.value)) fail(value, 'Color must use six hexadecimal digits, such as "#356548"');
+ color = value.value;
+ } else if (this.is(',')) { this.take(); this.newlines(); prefix = this.expect('string', 'literal text prefix').value; }
+ this.newlines(); this.expect(')');
+ declarations.push({ kind: 'view', type: kind, token, name, expressions, color, prefix });
+ } else if (kind === 'on') {
+ const event = this.expect('name', 'input name or frame');
+ let edge;
+ if (event.value !== 'frame') {
+ this.expect('.'); edge = this.expect('name', 'down, up, move or cancel');
+ if (!['down', 'up', 'move', 'cancel'].includes(edge.value)) fail(edge, 'Expected down or up, move or cancel');
+ }
+ if (this.is('name', 'use')) {
+ this.take(); const rule = this.expect('name', 'rule name'); this.expect('(');
+ const item = this.expect('name', 'item name'); this.expect(')');
+ declarations.push({kind:'handler',token,event,edge,rule,item});
+ } else declarations.push({ kind: 'handler', token, event, edge, body: this.block() });
+ } else fail(token, `Unknown declaration ${kind}`);
+ this.end();
+ }
+ return declarations;
+ }
+}
+
+export function compile(source) {
+ if (typeof source !== 'string') fail({ line: 1, column: 1 }, 'Source must be a string');
+ const sourceBytes = new TextEncoder().encode(source).length;
+ if (sourceBytes > LIMITS.sourceBytes) fail({ line: 1, column: 1 }, 'Source exceeds 32768 UTF-8 bytes');
+ const declarations = new Parser(lex(source)).parse();
+ const program = { states: [], inputs: [], instructions: [], handlers: [], views: [], metrics: {} };
+ const states = new Map(), inputs = new Map(), views = new Set(), keys = new Set(), targets = new Set();
+ const records = new Map(), items = new Map(), rules = new Map();
+ const scalarNames = new Set(declarations.filter(d => d.kind === 'state').map(d => d.name.value));
+ for (const declaration of declarations) {
+ if (!['record','rule'].includes(declaration.kind)) continue;
+ const {name,fields,kind} = declaration, symbols = kind === 'record' ? records : rules;
+ if (RESERVED.has(name.value)) fail(name, `Reserved name ${name.value}`);
+ if (symbols.has(name.value)) fail(name, `Duplicate ${kind} ${name.value}`);
+ if (kind === 'record') {
+ if (!fields.length || fields.length > 8) fail(name, 'A record needs 1..8 fields');
+ const used = new Set();
+ for (const field of fields) {
+ if (used.has(field.name.value)) fail(field.name, `Duplicate field ${field.name.value}`);
+ if (RESERVED.has(field.name.value)) fail(field.name, `Reserved name ${field.name.value}`);
+ used.add(field.name.value);
+ }
+ }
+ symbols.set(name.value, declaration);
+ }
+ let handlerCount = 0;
+ for (const declaration of declarations) {
+ const { kind, name, token } = declaration;
+ if (name && RESERVED.has(name.value)) fail(name, `Reserved name ${name.value}`);
+ if (kind === 'record' || kind === 'rule') continue;
+ if (kind === 'item') {
+ if (items.has(name.value) || scalarNames.has(name.value)) fail(name, `Duplicate state/item ${name.value}`);
+ const record = records.get(declaration.type.value);
+ if (!record) fail(declaration.type, `Unknown record ${declaration.type.value}`);
+ const overrides = new Map();
+ for (const field of declaration.fields) {
+ if (!record.fields.some(f => f.name.value === field.name.value)) fail(field.name, `Unknown field ${field.name.value}`);
+ if (overrides.has(field.name.value)) fail(field.name, `Duplicate field ${field.name.value}`);
+ overrides.set(field.name.value, field.initial);
+ }
+ const base = states.size;
+ for (const field of record.fields) {
+ if (states.size >= LIMITS.states) fail(name, 'At most 32 states are allowed, including item fields');
+ const qualified = `${name.value}.${field.name.value}`, index = states.size;
+ states.set(qualified,index);
+ program.states.push({name:qualified,initial:overrides.has(field.name.value) ? overrides.get(field.name.value) : field.initial,line:token.line});
+ }
+ items.set(name.value,{type:declaration.type.value,base,count:record.fields.length});
+ } else if (kind === 'state' || kind === 'input') {
+ const symbols = kind === 'state' ? states : inputs;
+ if (symbols.has(name.value)) fail(name, `Duplicate ${kind} ${name.value}`);
+ if (symbols.size >= LIMITS[`${kind}s`]) fail(name, `At most ${LIMITS[`${kind}s`]} ${kind}s are allowed`);
+ symbols.set(name.value, symbols.size);
+ if (kind === 'state') program.states.push({ name: name.value, initial: declaration.initial, line: token.line });
+ else if (declaration.target) {
+ const target = declaration.target;
+ if (targets.has(target.value)) fail(target, `Duplicate pointer target ${target.value}`);
+ targets.add(target.value);
+ program.inputs.push({ name: name.value, target: target.value, line: token.line });
+ } else {
+ if (keys.has(declaration.key.value)) fail(declaration.key, `Duplicate keyboard code ${declaration.key.value}`);
+ keys.add(declaration.key.value);
+ program.inputs.push({ name: name.value, key: declaration.key.value, line: token.line });
+ }
+ } else if (kind === 'view') {
+ if (views.has(name.value)) fail(name, `Duplicate view ID ${name.value}`);
+ if (views.size >= LIMITS.views) fail(name, 'At most 24 views are allowed');
+ views.add(name.value);
+ } else if (++handlerCount > LIMITS.handlers) fail(token, 'At most 64 handlers are allowed');
+ }
+ for (const declaration of declarations) {
+ if (declaration.target && !views.has(declaration.target.value))
+ fail(declaration.target, `Unknown pointer target ${declaration.target.value}`);
+ }
+ for (const rule of rules.values()) {
+ if (!records.has(rule.type.value)) fail(rule.type, `Unknown record ${rule.type.value}`);
+ if (RESERVED.has(rule.parameter.value)) fail(rule.parameter, `Reserved name ${rule.parameter.value}`);
+ }
+ let pointHandler = false;
+ let parameters = null;
+ let parameterPrefix = null;
+ let stack = 0, maxStack = 0;
+ const emit = (op, token, arg = 0, value = 0, delta = 0) => {
+ if (program.instructions.length >= LIMITS.instructions) fail(token, 'At most 512 instructions are allowed');
+ stack += delta;
+ if (stack < 0 || stack > LIMITS.stack) fail(token, 'Expression stack exceeds 64');
+ maxStack = Math.max(maxStack, stack);
+ program.instructions.push({ op, arg, value, line: token.line });
+ };
+ const expression = (node, binding = false) => {
+ const { token } = node;
+ if (node.kind === 'constant') emit(0, token, 0, node.value, 1);
+ else if (node.kind === 'reference') {
+ if (node.name === 'event_x' || node.name === 'event_y') {
+ if (binding || !pointHandler) fail(token, 'event_x and event_y are only available in pointer handlers');
+ emit(node.name === 'event_x' ? 22 : 23, token, 0, 0, 1);
+ } else if (node.name === 'dt') {
+ if (binding) fail(token, 'dt is only available in handlers');
+ emit(2, token, 0, 0, 1);
+ } else {
+ if (parameterPrefix && node.name.startsWith(parameterPrefix) && !parameters.has(node.name))
+ fail(token, `Unknown parameter field ${node.name}`);
+ if (parameters?.has(node.name)) {
+ emit(24, token, parameters.get(node.name), 0, 1);
+ return;
+ }
+ if (!states.has(node.name)) fail(token, `Unknown state ${node.name}`);
+ emit(1, token, states.get(node.name), 0, 1);
+ }
+ } else if (node.kind === 'unary') { expression(node.child, binding); emit(node.op, token); }
+ else if (node.kind === 'binary') {
+ expression(node.left, binding); expression(node.right, binding); emit(node.op, token, 0, 0, -1);
+ } else {
+ for (const arg of node.args) expression(arg, binding);
+ emit(node.op, token, 0, 0, 1 - node.args.length);
+ }
+ };
+ const statements = body => {
+ for (const statement of body) {
+ if (statement.kind === 'assign') {
+ if (parameterPrefix && statement.name.startsWith(parameterPrefix) && !parameters.has(statement.name))
+ fail(statement.token, `Unknown parameter field ${statement.name}`);
+ const parameter = parameters?.get(statement.name);
+ if (parameter === undefined && !states.has(statement.name)) fail(statement.token, `Unknown state ${statement.name}`);
+ expression(statement.expression);
+ emit(parameter === undefined ? 20 : 25, statement.token, parameter === undefined ? states.get(statement.name) : parameter, 0, -1);
+ } else {
+ expression(statement.condition);
+ const branch = program.instructions.length;
+ emit(21, statement.token, 0, 0, -1);
+ statements(statement.body);
+ program.instructions[branch].arg = program.instructions.length;
+ }
+ }
+ };
+ const bodies = new Map();
+ function ruleBody(rule) {
+ if (bodies.has(rule.name.value)) return bodies.get(rule.name.value);
+ parameterPrefix = `${rule.parameter.value}.`;
+ parameters = new Map(records.get(rule.type.value).fields.map((field,index) => [`${rule.parameter.value}.${field.name.value}`,index]));
+ // A shared body may read coordinates; every binding is checked below.
+ pointHandler = true; stack = 0;
+ const start = program.instructions.length;
+ statements(rule.body);
+ const length = program.instructions.length - start;
+ const point = program.instructions.slice(start).some(op => op.op === 22 || op.op === 23);
+ parameters = null; parameterPrefix = null;
+ const body = {start,length,point}; bodies.set(rule.name.value,body); return body;
+ }
+ for (const declaration of declarations) {
+ if (declaration.kind === 'handler') {
+ let event = 0;
+ pointHandler = false;
+ if (declaration.event.value !== 'frame') {
+ if (!inputs.has(declaration.event.value)) fail(declaration.event, `Unknown input ${declaration.event.value}`);
+ const index = inputs.get(declaration.event.value), edge = declaration.edge.value;
+ pointHandler = Boolean(program.inputs[index].target);
+ if (!pointHandler && (edge === 'move' || edge === 'cancel')) fail(declaration.edge, 'move and cancel require a pointer input');
+ event = edge === 'move' ? 17 + index : edge === 'cancel' ? 25 + index : 1 + index * 2 + (edge === 'up' ? 1 : 0);
+ }
+ if (declaration.rule) {
+ const rule = rules.get(declaration.rule.value), item = items.get(declaration.item.value);
+ if (!rule) fail(declaration.rule, `Unknown rule ${declaration.rule.value}`);
+ if (!item) fail(declaration.item, `Unknown item ${declaration.item.value}`);
+ if (item.type !== rule.type.value) fail(declaration.item, `Rule ${rule.name.value} requires ${rule.type.value}`);
+ const pointer = pointHandler;
+ const body = ruleBody(rule);
+ if (body.point && !pointer) fail(declaration.rule, 'Coordinate-reading rules require a pointer input');
+ program.handlers.push({event,start:body.start,length:body.length,line:declaration.token.line,parameterBase:item.base,parameterCount:item.count});
+ } else {
+ const start = program.instructions.length;
+ stack = 0; statements(declaration.body);
+ program.handlers.push({ event, start, length: program.instructions.length - start, line: declaration.token.line });
+ }
+ } else if (declaration.kind === 'view') {
+ const view = { id: declaration.name.value, type: declaration.type, line: declaration.token.line, bindings: {} };
+ if (view.type === 'text') view.prefix = declaration.prefix;
+ else view.color = declaration.color;
+ declaration.expressions.forEach((node, index) => {
+ const start = program.instructions.length;
+ stack = 0; expression(node, true);
+ view.bindings[VIEW_FIELDS[view.type][index]] = { start, length: program.instructions.length - start };
+ });
+ program.views.push(view);
+ }
+ }
+ // Unused definitions still receive name/type checking, but reserve no VM body.
+ for (const rule of rules.values()) {
+ if (bodies.has(rule.name.value)) continue;
+ const instructions = program.instructions, previousMax = maxStack;
+ program.instructions = [];
+ ruleBody(rule);
+ program.instructions = instructions; maxStack = previousMax;
+ }
+ program.metrics = { sourceBytes, sourceLines: source === '' ? 0 : source.split('\n').length,
+ states: states.size, inputs: inputs.size, views: views.size,
+ handlers: program.handlers.length, instructions: program.instructions.length, maxStack };
+ return program;
+}
+
+export { LIMITS };
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/sources/web/core.wasm b/prototypes/workbench/results/2026-09-23-shared-rules/sources/web/core.wasm
new file mode 100644
index 0000000..8c7b1c5
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/sources/web/core.wasm
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/sources/web/index.html b/prototypes/workbench/results/2026-09-23-shared-rules/sources/web/index.html
new file mode 100644
index 0000000..5520163
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/sources/web/index.html
@@ -0,0 +1,94 @@
+<!doctype html>
+<html lang="ko">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="color-scheme" content="dark">
+ <title>Pure · 빈 작업물에서 시작하기</title>
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23151918'/%3E%3Ctext x='9' y='23' fill='%23c9f08b' font-size='24' font-family='sans-serif'%3Ep%3C/text%3E%3C/svg%3E">
+ <link rel="stylesheet" href="./style.css">
+</head>
+<body>
+ <header class="masthead">
+ <a class="wordmark" href="./" aria-label="Pure 작업 공간">pure<span> / workbench</span></a>
+ <span class="experiment">제작 경험 실험 004</span>
+ <div class="connection"><span id="ready-dot" class="dot"></span><span id="ready-status" role="status">실행기 준비 중</span></div>
+ </header>
+
+ <main>
+ <section class="intro" aria-labelledby="title">
+ <div><p class="eyebrow">빈 코드 · 직접 만든 동작</p><h1 id="title">무엇을 움직이게 할까요?</h1></div>
+ <p>상태를 만들고, 입력에 동작을 연결하세요.<br>코드를 <strong>적용</strong>하면 장면이 바뀝니다. 같은 이름의 상태는 이어집니다.</p>
+ </section>
+
+ <div class="workspace">
+ <section class="editor-panel panel" aria-labelledby="editor-title">
+ <div class="panel-heading"><h2 id="editor-title"><span class="section-index">01</span> 코드</h2><span id="draft-status" class="badge">빈 작업물</span></div>
+ <div class="file-tools" aria-label="파일 관리">
+ <button id="new" class="quiet" type="button">편집기 비우기</button>
+ <button id="upload" class="quiet" type="button">파일 열기</button>
+ <button id="download" class="quiet" type="button">.pure 저장</button>
+ <button id="download-project" class="quiet" type="button">코드 JSON 저장</button>
+ <button id="download-layout" class="quiet" type="button" disabled>배치 저장</button>
+ <input id="file-input" type="file" accept=".pure,.txt,.json,text/plain,application/json" hidden>
+ </div>
+ <label class="sr-only" for="source">Pure 코드 편집기</label>
+ <div class="code-editor">
+ <div id="line-numbers" class="line-numbers" aria-hidden="true">1</div>
+ <textarea id="source" spellcheck="false" autocapitalize="off" autocomplete="off" autocorrect="off" aria-describedby="editor-hint diagnostic" placeholder="여기서 시작하세요.&#10;&#10;아래 ‘언어 안내’에서 작은 조각을 가져올 수 있습니다."></textarea>
+ </div>
+ <div class="editor-footer"><span id="source-size">0 / 32,768 bytes</span><span id="cursor-position">1행 · 1열</span></div>
+ <div class="apply-row"><button id="apply" class="primary" type="button" disabled>코드 적용 <span aria-hidden="true">↗</span></button><p id="editor-hint">편집만으로 실행 중인 코드는 바뀌지 않습니다.</p></div>
+ <div id="diagnostic" class="diagnostic" role="status" aria-live="polite"><span id="diagnostic-text">아직 작성한 코드가 없습니다.</span><button id="error-location" type="button" class="quiet" hidden>문제 위치로</button></div>
+ </section>
+
+ <div class="right-column">
+ <section class="scene-panel panel" aria-labelledby="scene-title">
+ <div class="panel-heading"><h2 id="scene-title"><span class="section-index">02</span> 실행 장면</h2><span id="scene-status" class="badge">적용 전</span></div>
+ <div class="stage-wrap">
+ <svg id="scene" viewBox="0 0 640 360" role="img" tabindex="0" aria-labelledby="scene-description"><title id="scene-description">사용자가 코드로 만든 장면. 현재 비어 있습니다.</title><g id="scene-content"></g></svg>
+ <div id="empty-scene" class="empty-scene"><span class="empty-cross" aria-hidden="true">+</span><strong>아직 아무것도 없습니다</strong><span>도형이나 글자를 선언하고 코드를 적용하세요.</span></div>
+ <span class="stage-dimensions" aria-hidden="true">640 × 360</span>
+ </div>
+ <div class="transport"><button id="play" class="secondary" type="button" disabled>▶ 실행</button><button id="step" type="button" disabled>한 프레임</button><button id="reset" type="button" disabled>초깃값으로</button><span id="clock-status">0 frame · 정지</span></div>
+ <p class="control-note">정지하면 시간만 멈춥니다. 아래 입력은 정지 중에도 작동합니다.</p>
+ <div class="input-heading"><h3>내가 선언한 입력</h3><span>눌렀을 때 .down / 놓았을 때 .up</span></div>
+ <div id="input-controls" class="input-controls"><p class="empty-note">입력을 선언하면 여기에 버튼이 생깁니다.</p></div>
+ <p class="keyboard-note">장면을 클릭하면 키보드로 입력할 수 있습니다. 코드나 폼을 편집하는 동안에는 전달되지 않습니다.</p>
+ </section>
+
+ <section class="inspect-panel panel" aria-labelledby="inspect-title">
+ <div class="panel-heading"><h2 id="inspect-title"><span class="section-index">03</span> 현재 상태</h2><span id="revision" class="mono subdued">revision 0</span></div>
+ <div id="state-values" class="state-values"><p class="empty-note">선언한 상태의 값이 표시됩니다.</p></div>
+ <div class="event-line"><span class="subdued">마지막 입력</span><span id="last-event" class="mono">아직 없음</span></div>
+ <div id="runtime-error" class="runtime-error" role="status" hidden></div>
+ <details class="cost-details"><summary>실행 비용과 범위</summary><dl id="cost-values" class="cost-values"></dl><p>코어가 보고한 WebAssembly 메모리입니다. 브라우저·편집기·렌더러의 메모리와 프로세스 전체 사용량은 포함하지 않습니다. 예약 공간 안에 사용 중인 코드·상태·스테이징 공간이 포함되므로 합산하지 않습니다.</p><p id="dropped-ticks">시간 지연으로 건너뛴 프레임: 0</p></details>
+ </section>
+ </div>
+ </div>
+
+ <section class="reference panel" aria-labelledby="reference-title">
+ <div class="panel-heading"><h2 id="reference-title">언어 안내</h2><span class="subdued">조각을 조합해 자신의 동작을 만드세요.</span></div>
+ <div class="reference-grid">
+ <article><h3>1. 기억할 값</h3><p>이름은 영문으로, 처음 값은 숫자로 정합니다.</p><pre><code>state count = 0
+state x = 80</code></pre><button class="insert-snippet quiet" data-snippet="state" type="button">상태 조각 삽입</button></article>
+ <article><h3>2. 입력과 반응</h3><p>버튼 이름과 키를 선언하고 .down / .up에 연결합니다.</p><pre><code>input tap = Space
+on tap.down {
+ count = count + 1
+}</code></pre><button class="insert-snippet quiet" data-snippet="input" type="button">입력 조각 삽입</button></article>
+ <article><h3>3. 보일 것</h3><p>좌표와 크기를 상태에 연결할 수 있습니다.</p><pre><code>box tile(x, 180, 40, 40, "#7ed9ae")
+text label(40, 60, count, "Count: ")</code></pre><button class="insert-snippet quiet" data-snippet="view" type="button">장면 조각 삽입</button></article>
+ <article><h3>4. 시간에 따른 변화</h3><p>실행하면 매 프레임 반응합니다. dt는 1/60초입니다.</p><pre><code>on frame {
+ x = x + 30 * dt
+ if x > 600 { x = 0 }
+}</code></pre><button class="insert-snippet quiet" data-snippet="frame" type="button">시간 조각 삽입</button></article>
+ </div>
+ <details class="syntax-details"><summary>표현식 · 입력 키 · 제한 보기</summary><div class="syntax-columns"><div><p><strong>표현식</strong><br><code>+ - * /</code>, <code>&lt; &lt;= &gt; &gt;= == !=</code>, <code>&amp;&amp; || !</code>, 괄호, <code>min(a,b)</code>, <code>max(a,b)</code>, <code>clamp(x,lo,hi)</code>를 사용합니다. 논리 연산은 양쪽을 모두 계산합니다.</p><p><strong>도형</strong><br><code>box id(x,y,width,height,"#rrggbb")</code><br><code>circle id(x,y,radius,"#rrggbb")</code><br><code>text id(x,y,value,"접두사")</code><br>글자의 value는 숫자 표현식입니다. 문자 접두사는 생략할 수 있습니다.</p></div><div><p><strong>입력</strong><br><code>Space</code>, <code>Enter</code>, <code>ArrowLeft/Right/Up/Down</code>, <code>KeyA</code>~<code>KeyZ</code>, <code>Digit0</code>~<code>Digit9</code>. 같은 키는 하나의 입력에 연결합니다.</p><p><strong>직접 조작</strong><br><code>input drag = pointer(tile)</code>로 도형에 입력을 연결합니다. <code>on drag.down / .move / .up / .cancel</code>에서 <code>event_x</code>, <code>event_y</code>는 장면 좌표입니다. 이동 방식과 취소 시 동작도 코드로 정합니다. 초점이나 포인터 캡처를 잃으면 <code>.cancel</code>이 전달됩니다.</p><p><strong>값과 규칙 재사용</strong><br><code>record Tile { x=0; y=0 }</code>로 필드를 정하고 <code>item green = Tile(x=80)</code>으로 대상을 만듭니다. <code>rule move(t: Tile) { t.x=event_x }</code>를 <code>on drag.move use move(green)</code>으로 연결합니다. 화면에서는 <code>green.x</code>를 읽습니다. 규칙은 연결한 대상의 값을 직접 바꿉니다.</p><p><strong>이 실험의 범위</strong><br>상태 32개 · 입력 8개 · 도형/글자 24개 · 반응 64개 · 컴파일된 명령 512개 · 코드 32 KiB. 반복문, 함수 선언, 자동 충돌·물리는 없습니다. 줄바꿈 또는 세미콜론으로 구분하고 <code>#</code>으로 주석을 씁니다.</p><p>0으로 나누기 등 계산 실패 시 해당 이벤트의 상태 변경 전체를 취소합니다. 입력을 누르고 있을 때는 새 코드를 적용할 수 없습니다.</p></div></div></details>
+ <div class="example-row"><div><label for="example">완성된 예제 살펴보기</label><p>선택한 코드를 편집기에 불러옵니다. 자동으로 적용되지 않습니다.</p></div><select id="example"><option value="counter">카운터</option><option value="charge">누르는 동안 충전</option><option value="toggle">입력으로 전환</option><option value="direct">직접 잡아 이동</option><option value="layout">세 도형 배치</option><option value="shared-layout">공유 규칙으로 배치</option></select><button id="load-example" type="button">예제 불러오기</button></div>
+ <div class="baseline-row"><a href="./baseline.html" target="_blank" rel="noopener">같은 동작을 일반 JavaScript로 만든 비교 앱 ↗</a><span>충전 예제와 비교할 수 있습니다. 새 탭에서 열립니다.</span></div>
+ </section>
+ <footer class="page-footer"><span>Pure · authoring experiment</span><p>원하는 동작을 만들어보고, <strong>하려던 일 / 실제로 일어난 일 / 기대했던 일</strong>을 남겨주세요.</p></footer>
+ </main>
+ <script type="module" src="./app.js"></script>
+</body>
+</html>
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/sources/web/project.mjs b/prototypes/workbench/results/2026-09-23-shared-rules/sources/web/project.mjs
new file mode 100644
index 0000000..861e0fc
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/sources/web/project.mjs
@@ -0,0 +1,47 @@
+import { compile } from './compiler.mjs';
+
+// Two escaped source strings plus up to 32 generated item.field names. A long
+// schema field can appear in every item without repeating in the source text.
+export const MAX_PROJECT_BYTES = (2 * 6 + 32) * 32768 + 8192;
+const encoder = new TextEncoder();
+function sourceText(value, label) {
+ if (typeof value !== 'string' || encoder.encode(value).length > 32768)
+ throw new Error(`${label}: UTF-8 32 KiB 이하의 코드가 필요합니다.`);
+ return value;
+}
+
+// No runtime mutation. Validate every saved value before opening core staging.
+export function validateDocument(document) {
+ if (!document || typeof document !== 'object' || Array.isArray(document) ||
+ document.format !== 'pure-workbench' || document.version !== 2)
+ throw new Error('배치 파일 형식이 맞지 않습니다. pure-workbench version 2가 필요합니다.');
+ const source = sourceText(document.source, '적용된 코드');
+ const draft = sourceText(Object.hasOwn(document, 'draft') ? document.draft : source, '편집 중인 코드');
+ const program = compile(source);
+ if (!Array.isArray(document.states) || document.states.length !== program.states.length)
+ throw new Error('저장된 상태 목록이 적용된 코드와 일치하지 않습니다.');
+ const values = new Map();
+ for (const entry of document.states) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry) || typeof entry.name !== 'string' || values.has(entry.name))
+ throw new Error('상태 이름이 잘못됐거나 중복됐습니다.');
+ const value = entry.value === '-0' ? -0 : entry.value;
+ if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error('상태 값은 유한한 숫자여야 합니다.');
+ values.set(entry.name, value);
+ }
+ for (const state of program.states)
+ if (!values.has(state.name)) throw new Error(`저장된 상태가 없습니다: ${state.name}`);
+ return { source, draft, program, values: program.states.map(state => values.get(state.name)) };
+}
+
+export function parseProject(text) {
+ if (typeof text !== 'string' || encoder.encode(text).length > MAX_PROJECT_BYTES)
+ throw new Error('작업 파일이 너무 큽니다.');
+ let project;
+ try { project = JSON.parse(text); } catch { throw new Error('올바른 JSON 파일이 아닙니다.'); }
+ if (!project || typeof project !== 'object' || Array.isArray(project) || project.format !== 'pure-workbench')
+ throw new Error('Pure 작업물 형식이 아닙니다.');
+ if (project.version === 1) sourceText(project.source, '코드');
+ else if (project.version === 2) validateDocument(project);
+ else throw new Error('지원하지 않는 작업 파일 버전입니다.');
+ return project;
+}
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/sources/web/runtime.mjs b/prototypes/workbench/results/2026-09-23-shared-rules/sources/web/runtime.mjs
new file mode 100644
index 0000000..4a9cd14
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/sources/web/runtime.mjs
@@ -0,0 +1,328 @@
+import { compile } from './compiler.mjs';
+import { validateDocument } from './project.mjs';
+
+const messages = [
+ '', '실행기의 용량 한도를 넘었습니다.', '실행 정의가 유효하지 않습니다.',
+ '계산 스택의 범위를 벗어났습니다.', '0으로 나눌 수 없습니다.',
+ '계산 결과가 유한한 수의 범위를 벗어났습니다.', '조건 분기 범위가 유효하지 않습니다.',
+ '존재하지 않는 상태를 참조했습니다.', '시간 간격이 유효하지 않습니다.',
+ '정의되지 않은 입력 사건입니다.',
+];
+const sourceLimit = 32768;
+
+function problem(message, line = null, column = null) {
+ return Object.assign(new Error(message), { line, column });
+}
+
+function freezeProgram(value) {
+ if (value && typeof value === 'object') {
+ for (const child of Object.values(value)) freezeProgram(child);
+ Object.freeze(value);
+ }
+ return value;
+}
+
+export async function createRuntime(wasmBytes, evaluationOptions = {}) {
+ if (wasmBytes === undefined) {
+ const response = await fetch(new URL('./core.wasm', import.meta.url), { cache: 'no-store' });
+ if (!response.ok) throw new Error(`실행기를 불러오지 못했습니다 (HTTP ${response.status}).`);
+ wasmBytes = await response.arrayBuffer();
+ }
+ const module = await WebAssembly.compile(wasmBytes);
+ if (WebAssembly.Module.imports(module).length) throw new Error('이 실행기는 호스트 함수 없이 동작해야 합니다.');
+ const { exports: core } = await WebAssembly.instantiate(module, {});
+ if (typeof core.eval_bounded !== 'function' || typeof core.abort_program !== 'function' || typeof core.dispatch_point !== 'function' || typeof core.put_restored_value !== 'function' || typeof core.commit_restored_program !== 'function' || typeof core.bind_handler !== 'function')
+ throw new Error('실행기 버전이 맞지 않습니다. 새로고침하거나 core.wasm을 다시 빌드하세요.');
+ core.reset_all();
+ let program = null;
+ let appliedSource = '';
+ let lastEvent = null;
+ // Keep a failed event visible through its release/focus-loss cleanup.
+ // The active program can clear this diagnostic by apply or explicit reset.
+ let lastFailure = null;
+ const held = new Map();
+ const points = new Map();
+ let presenting = false;
+ let bindings = [];
+ // One bounded publication buffer, never exposed to callers. No state mirror.
+ let displayValues = null, displayValid = null;
+ function available() {
+ if (presenting) throw new Error('출력 전달 중에는 실행기를 다시 호출할 수 없습니다.');
+ }
+ let evaluationPolicy = { cacheEntries: 0, historyEntries: 8, workLimit: 2048 };
+
+ function checked(code, line = null) {
+ if (code !== 0) throw problem(messages[code] || `실행기 오류 ${code}`, line);
+ }
+ function inputIndex(name) {
+ const index = program?.inputs.findIndex((input) => input.name === name) ?? -1;
+ if (index < 0) throw new Error(`정의되지 않은 입력: ${name}`);
+ return index;
+ }
+ function dispatch(event, name, phase, source, point = null) {
+ const status = point ? core.dispatch_point(event, point.x, point.y) : core.dispatch(event, event === 0 ? 1 / 60 : 0);
+ const pc = core.info(2) >>> 0;
+ lastEvent = { name, phase, source, status,
+ line: program?.instructions[pc]?.line ?? null, ops: core.info(3) >>> 0 };
+ if (point) { lastEvent.x = point.x; lastEvent.y = point.y; }
+ if (status) lastFailure = { ...lastEvent };
+ return status;
+ }
+
+ function evaluateDisplay() {
+ displayValues ??= new Float64Array(96);
+ displayValid ??= new Uint8Array(24);
+ core.begin_evaluation(evaluationPolicy.workLimit);
+ let error = null, slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ displayValid[i] = 1;
+ for (const [name, binding] of bindings[i]) {
+ const value = core.eval_bounded(binding.start, binding.length);
+ if (core.evaluation_info(0) === 10) {
+ displayValid.fill(0);
+ return { code: 101, line: view.line,
+ message: '표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.' };
+ }
+ if (!Number.isFinite(value) || ((name === 'width' || name === 'height' || name === 'radius') && value < 0)) {
+ error ??= { line: view.line, message: `${view.id}.${name}: 표시할 수 없는 값입니다. 0 나눗셈이나 음수 크기를 확인하세요.` };
+ displayValid[i] = 0;
+ }
+ displayValues[slot++] = value;
+ }
+ }
+ return error;
+ }
+
+ function status(viewError) {
+ const executionError = lastFailure?.status || 0;
+ return {
+ tick: core.info(0) >>> 0, revision: core.info(4) >>> 0,
+ displayError: viewError ? { code: viewError.code || 100, ...viewError } : null,
+ lastError: executionError || (viewError ? viewError.code || 100 : 0),
+ errorMessage: executionError ? messages[executionError] || `실행 오류 ${executionError}` : viewError?.message || '',
+ errorLine: executionError ? lastFailure.line : viewError?.line ?? null,
+ lastOps: core.info(3) >>> 0, successfulEvents: core.info(9) >>> 0,
+ held: [...held.keys()], lastEvent: lastEvent ? { ...lastEvent } : null,
+ lastFailure: lastFailure ? { ...lastFailure } : null,
+ memory: {
+ linearBytes: core.memory.buffer.byteLength,
+ reservedEngineBytes: core.layout(0), programReservedBytes: core.layout(1),
+ instructionBytes: core.layout(2), handlerBytes: core.layout(3),
+ activeIRBytes: core.layout(4), activeStateBytes: core.layout(5),
+ stagingReservedBytes: core.layout(6), evalStackBytes: core.layout(7),
+ },
+ };
+ }
+
+ function load(next, source, restored = null) {
+ const oldIds = new Map(restored ? [] : program?.states.map((state, index) => [state.name, index]) ?? []);
+ checked(core.begin_program(next.states.length, next.inputs.length));
+ try {
+ for (let index = 0; index < next.states.length; index++) {
+ const state = next.states[index];
+ checked(core.put_state(index, state.initial, oldIds.get(state.name) ?? -1), state.line);
+ if (restored) checked(core.put_restored_value(index, restored[index]), state.line);
+ }
+ for (const op of next.instructions) checked(core.put_instruction(op.op, op.arg, op.value), op.line);
+ for (const [index, handler] of next.handlers.entries()) {
+ checked(core.put_handler(handler.event, handler.start, handler.length), handler.line);
+ if (handler.parameterCount)
+ checked(core.bind_handler(index, handler.parameterBase, handler.parameterCount), handler.line);
+ }
+ checked(restored ? core.commit_restored_program() : core.commit_program());
+ } catch (error) {
+ core.abort_program();
+ throw error;
+ }
+ program = next;
+ bindings = next.views.map(view => Object.entries(view.bindings));
+ appliedSource = source;
+ lastEvent = null;
+ lastFailure = null;
+ }
+
+ const api = {
+ get currentProgram() { return program; },
+ configureEvaluation({ cacheEntries = evaluationPolicy.cacheEntries,
+ historyEntries = evaluationPolicy.historyEntries, workLimit = evaluationPolicy.workLimit } = {}) {
+ available();
+ for (const [name, value, maximum] of [['cacheEntries', cacheEntries, 32],
+ ['historyEntries', historyEntries, 8], ['workLimit', workLimit, 0xffffffff]]) {
+ if (!Number.isInteger(value) || value < 0 || value > maximum)
+ throw new Error(`${name}: 0~${maximum} 사이의 정수여야 합니다.`);
+ }
+ checked(core.configure_evaluation(cacheEntries, historyEntries));
+ evaluationPolicy = { cacheEntries, historyEntries, workLimit };
+ return { ...evaluationPolicy };
+ },
+ forgetEvaluation(part = 'all') {
+ available();
+ const flags = part === 'all' ? 3 : part === 'values' ? 1 : part === 'history' ? 2 : undefined;
+ if (flags === undefined) throw new Error('삭제할 기록은 values, history, all 중 하나여야 합니다.');
+ core.forget_evaluation(flags);
+ },
+ evaluationStats() {
+ return { ...evaluationPolicy, work: core.evaluation_info(1),
+ instructions: core.evaluation_info(2), cacheHits: core.evaluation_info(3),
+ reservedBytes: core.evaluation_info(6), dedicatedBytes: core.evaluation_info(7),
+ sharedBytes: core.evaluation_info(8), reuseAvailable: Boolean(core.evaluation_info(9)),
+ workspaceTagBytes: core.evaluation_info(10) };
+ },
+ apply(source) {
+ available();
+ if (typeof source !== 'string' || new TextEncoder().encode(source).length > sourceLimit) {
+ throw problem('정의는 UTF-8 기준 32,768바이트 이하의 텍스트여야 합니다.');
+ }
+ if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 적용하세요. 현재 작업은 그대로 유지합니다.');
+ const next = freezeProgram(compile(source));
+ load(next, source);
+ return next;
+ },
+ restoreDocument(document) {
+ available();
+ if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 복원하세요.');
+ const next = validateDocument(document);
+ load(freezeProgram(next.program), next.source, next.values);
+ return { source: next.source, draft: next.draft };
+ },
+ exportDocument(draft = appliedSource) {
+ available();
+ if (!program) throw problem('먼저 코드를 적용하세요.');
+ if (held.size) throw problem('조작을 끝낸 뒤 배치를 저장하세요.');
+ if (lastFailure) throw problem('실행 오류를 해결한 뒤 배치를 저장하세요.');
+ if (typeof draft !== 'string' || new TextEncoder().encode(draft).length > sourceLimit)
+ throw problem('편집 중인 코드는 UTF-8 32 KiB 이하여야 합니다.');
+ return { format: 'pure-workbench', version: 2, source: appliedSource, draft,
+ states: program.states.map((state, i) => {
+ const value = core.get_state(i);
+ return { name: state.name, value: Object.is(value, -0) ? '-0' : value };
+ }) };
+ },
+ reset() {
+ available();
+ held.clear();
+ points.clear();
+ core.reset_state();
+ lastEvent = null;
+ lastFailure = null;
+ },
+ frame(count = 1) {
+ available();
+ if (!Number.isInteger(count) || count < 0 || count > 600) throw new Error('프레임 수는 0~600 사이의 정수여야 합니다.');
+ if (!program) return 0;
+ for (let i = 0; i < count; i++) {
+ const code = dispatch(0, 'frame', 'frame', 'clock');
+ if (code !== 0) return code;
+ }
+ return 0;
+ },
+ down(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (program.inputs[index].target) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.');
+ const sources = held.get(name) || new Set();
+ if (sources.has(source)) return 0;
+ const wasDown = sources.size > 0;
+ sources.add(source);
+ held.set(name, sources);
+ return wasDown ? 0 : dispatch(1 + index * 2, name, 'down', source);
+ },
+ up(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (program.inputs[index].target) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.');
+ const sources = held.get(name);
+ if (!sources?.delete(source)) return 0;
+ if (sources.size) return 0;
+ held.delete(name);
+ return dispatch(2 + index * 2, name, 'up', source);
+ },
+ point(name, phase, x, y, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (!program.inputs[index].target) throw new Error('포인터에 연결된 입력이 아닙니다.');
+ if (!['down', 'move', 'up', 'cancel'].includes(phase)) throw new Error('포인터 단계는 down, move, up, cancel 중 하나여야 합니다.');
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error('포인터 좌표는 유한한 숫자여야 합니다.');
+ if (typeof source !== 'string' || source.length === 0) throw new Error('포인터 출처 이름이 필요합니다.');
+ let point = points.get(name);
+ if (phase === 'down') {
+ if (point?.source === source) return 0;
+ if (point) throw new Error('이 입력은 다른 포인터가 사용 중입니다.');
+ point = {source, x, y};
+ points.set(name, point);
+ held.set(name, new Set([source]));
+ } else {
+ if (!point || point.source !== source) return 0;
+ point.x = x; point.y = y;
+ if (phase === 'up' || phase === 'cancel') { points.delete(name); held.delete(name); }
+ }
+ const event = phase === 'move' ? 17 + index : phase === 'cancel' ? 25 + index : 1 + index * 2 + (phase === 'up' ? 1 : 0);
+ return dispatch(event, name, phase, source, point);
+ },
+ releaseAll() {
+ available();
+ let firstError = 0;
+ for (const [name, point] of [...points]) {
+ const status = api.point(name, 'cancel', point.x, point.y, point.source);
+ if (!firstError) firstError = status;
+ }
+ for (const [name, sources] of [...held]) {
+ for (const source of [...sources]) {
+ const status = api.up(name, source);
+ if (!firstError) firstError = status;
+ }
+ }
+ return firstError;
+ },
+ exportProject() {
+ return { format: 'pure-workbench', version: 1, source: appliedSource };
+ },
+ // Synchronous trusted output adapter. Callbacks run only after the whole
+ // display has been evaluated. Runtime mutation/reentrant reads are refused.
+ // Exceptions from an adapter propagate; the next call republishes all values.
+ present(target) {
+ available();
+ presenting = true;
+ try {
+ const { begin, state, view, value, end } = target;
+ for (const callback of [begin, state, view, value, end])
+ if (typeof callback !== 'function') throw new TypeError('출력 어댑터 함수가 필요합니다.');
+ const error = evaluateDisplay();
+ begin.call(target, program);
+ for (let i = 0; i < (program?.states.length ?? 0); i++) state.call(target, i, core.get_state(i));
+ let slot = 0, visible = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const valid = Boolean(displayValid[i]);
+ view.call(target, i, valid);
+ for (const [name] of bindings[i]) {
+ if (valid) value.call(target, i, name, displayValues[slot]);
+ slot++;
+ }
+ if (valid) visible++;
+ }
+ end.call(target, visible);
+ return status(error);
+ } finally { presenting = false; }
+ },
+ snapshot() {
+ available();
+ const error = evaluateDisplay();
+ const states = Object.fromEntries((program?.states ?? []).map((state, index) => [state.name, core.get_state(index)]));
+ const views = [];
+ let slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ const rendered = displayValid[i] ? { id: view.id, type: view.type, color: view.color, prefix: view.prefix } : null;
+ for (const [name] of bindings[i]) {
+ if (rendered) rendered[name] = displayValues[slot];
+ slot++;
+ }
+ if (rendered) views.push(rendered);
+ }
+ return { states, views, ...status(error) };
+ },
+ };
+ api.configureEvaluation(evaluationOptions);
+ return Object.freeze(api);
+}
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/sources/web/scene-input.mjs b/prototypes/workbench/results/2026-09-23-shared-rules/sources/web/scene-input.mjs
new file mode 100644
index 0000000..0f7cbef
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/sources/web/scene-input.mjs
@@ -0,0 +1,83 @@
+// This adapter owns browser capture only. Authored state changes stay in the VM.
+export function bindSceneInput(scene, runtime, act) {
+ const captures = new Map();
+ const events = scene.ownerDocument.defaultView;
+ const coordinates = event => {
+ const matrix = scene.getScreenCTM();
+ if (!matrix) return null;
+ try {
+ const p = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse());
+ return Number.isFinite(p.x) && Number.isFinite(p.y) ? p : null;
+ } catch { return null; }
+ };
+ const release = id => {
+ if (scene.hasPointerCapture(id)) scene.releasePointerCapture(id);
+ };
+ function finish(event, phase, pointerId = event.pointerId) {
+ const entry = captures.get(pointerId);
+ if (!entry) return;
+ event.preventDefault();
+ // Capture loss/cancel may not carry a meaningful position. Use the last
+ // delivered coordinates. Up must use its own final position.
+ const p = phase === 'up' ? coordinates(event) : null;
+ if (p) { entry.x = p.x; entry.y = p.y; }
+ if (phase === 'up' && !p) phase = 'cancel';
+ captures.delete(pointerId);
+ release(pointerId);
+ act(() => runtime().point(entry.name, phase, entry.x, entry.y, entry.source));
+ }
+ scene.addEventListener('pointerdown', event => {
+ if (event.button !== 0 || !runtime() || captures.has(event.pointerId)) return;
+ const target = event.target.closest?.('[data-point-input]');
+ if (!target || !scene.contains(target)) return;
+ const name = target.getAttribute('data-point-input');
+ // A logical input has one coordinate owner. A second contact on it must not
+ // steal the gesture; other authored targets can be manipulated independently.
+ if ([...captures.values()].some(entry => entry.name === name)) return;
+ const p = coordinates(event);
+ if (!p) return;
+ event.preventDefault(); scene.focus();
+ act(() => {
+ scene.setPointerCapture(event.pointerId);
+ const entry = {name, source:`scene:${event.pointerId}`, pointerType:event.pointerType, x:p.x, y:p.y};
+ captures.set(event.pointerId, entry);
+ try { runtime().point(name, 'down', p.x, p.y, entry.source); }
+ catch (error) { captures.delete(event.pointerId); release(event.pointerId); throw error; }
+ });
+ });
+ scene.addEventListener('pointermove', event => {
+ const entry = captures.get(event.pointerId);
+ if (!entry) return;
+ const p = coordinates(event);
+ if (!p) { finish(event, 'cancel'); return; }
+ event.preventDefault();
+ entry.x = p.x; entry.y = p.y;
+ act(() => runtime().point(entry.name, 'move', p.x, p.y, entry.source));
+ });
+ // A host may deliver release outside the SVG even while it owns capture.
+ // Observe termination before target handlers or capture cleanup can cancel it.
+ events.addEventListener('pointerup', event => finish(event, 'up'), true);
+ events.addEventListener('pointercancel', event => finish(event, 'cancel'), true);
+ events.addEventListener('mouseup', event => {
+ if (event.button !== 0) return;
+ // Mouse-only fallback when the host delivers mouseup without pointerup.
+ // A normal pointerup already removed the owner; touch compatibility events
+ // must never finish a different contact.
+ for (const [id, entry] of captures) {
+ if (entry.pointerType === 'mouse') finish(event, 'up', id);
+ }
+ }, true);
+ scene.addEventListener('lostpointercapture', event => {
+ // Descendant capture transitions bubble here but do not release our owner.
+ if (event.target === scene) finish(event, 'cancel');
+ });
+ return Object.freeze({
+ releaseCaptures() {
+ const ids = [...captures.keys()];
+ captures.clear();
+ for (const id of ids) release(id);
+ // The caller subsequently invokes runtime.releaseAll(), which dispatches
+ // each pending cancel once. Clearing first makes lostcapture harmless.
+ },
+ });
+}
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/sources/workspace.test.mjs b/prototypes/workbench/results/2026-09-23-shared-rules/sources/workspace.test.mjs
new file mode 100644
index 0000000..0fcbeae
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/sources/workspace.test.mjs
@@ -0,0 +1,89 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { compile } from './web/compiler.mjs';
+import { createRuntime } from './web/runtime.mjs';
+
+const wasm = await readFile(new URL('./web/core.wasm', import.meta.url));
+const source = factor => `state x = 10
+input next = KeyN
+text label(0, 0, x * ${factor})
+on next.down { x = x + 0.25 }
+`;
+
+function begin(core, program, preserve) {
+ assert.equal(core.begin_program(program.states.length, program.inputs.length), 0);
+ program.states.forEach((state, i) => assert.equal(core.put_state(i, state.initial, preserve ? i : -1), 0));
+}
+
+function finish(core, program, during = () => {}) {
+ for (const op of program.instructions) {
+ assert.equal(core.put_instruction(op.op, op.arg, op.value), 0);
+ during();
+ }
+ for (const handler of program.handlers)
+ assert.equal(core.put_handler(handler.event, handler.start, handler.length), 0);
+ assert.equal(core.commit_program(), 0);
+}
+
+function observe(core, binding) {
+ core.begin_evaluation(100);
+ const value = core.eval_bounded(binding.start, binding.length);
+ assert.equal(core.evaluation_info(0), 0);
+ return value;
+}
+
+test('Wasm executes old events and output during staged replacement without corrupting either phase', async () => {
+ const {instance:{exports:core}} = await WebAssembly.instantiate(wasm);
+ core.reset_all();
+ const old = compile(source(2)), next = compile(source(3));
+ begin(core, old, false); finish(core, old);
+ assert.equal(core.configure_evaluation(32, 8), 0);
+ const binding = old.views[0].bindings.value;
+ assert.equal(observe(core, binding), 20);
+ assert.equal(observe(core, binding), 20);
+ assert.equal(core.evaluation_info(3), 1);
+ begin(core, next, true);
+ let value = 10;
+ finish(core, next, () => {
+ assert.equal(core.evaluation_info(9), 0);
+ assert.equal(core.dispatch(1, 0), 0); value += 0.25;
+ assert.equal(core.configure_evaluation(32, 8), 0);
+ core.forget_evaluation(3);
+ assert.equal(observe(core, binding), value * 2);
+ assert.equal(core.evaluation_info(3), 0);
+ });
+ assert.equal(core.evaluation_info(9), 1);
+ assert.equal(core.get_state(0), value);
+ const replaced = next.views[0].bindings.value;
+ assert.equal(observe(core, replaced), value * 3);
+ assert.equal(observe(core, replaced), value * 3);
+ assert.equal(core.evaluation_info(3), 1);
+
+ const revision = core.info(4);
+ begin(core, old, true);
+ assert.equal(core.put_instruction(0, 0, Infinity), 5);
+ assert.equal(core.commit_program(), 5);
+ assert.equal(observe(core, replaced), value * 3);
+ core.abort_program();
+ assert.equal(core.info(4), revision);
+ assert.equal(core.get_state(0), value);
+ assert.equal(core.evaluation_info(9), 1);
+ assert.equal(observe(core, replaced), value * 3);
+ assert.equal(core.memory.buffer.byteLength, 65536);
+});
+
+test('workspace accounting reports shared payload and safety tag without double-counting', async () => {
+ const runtime = await createRuntime(wasm, {cacheEntries:32});
+ runtime.apply(source(2)); runtime.snapshot();
+ const stats = runtime.evaluationStats(), memory = runtime.snapshot().memory;
+ assert.equal(stats.dedicatedBytes, 48);
+ assert.equal(stats.sharedBytes, 1152);
+ assert.equal(stats.workspaceTagBytes, 8);
+ assert.equal(stats.reservedBytes, stats.dedicatedBytes + stats.sharedBytes);
+ const restoreStorage = memory.stagingReservedBytes - memory.programReservedBytes;
+ assert.equal(restoreStorage, 264); // 32 values + mask/alignment, only in pending storage.
+ assert.equal(memory.reservedEngineBytes, 19072 + stats.dedicatedBytes + stats.workspaceTagBytes + restoreStorage + 2 * 64 * (memory.handlerBytes - 12));
+ assert(stats.sharedBytes < memory.stagingReservedBytes);
+ assert.equal(stats.reuseAvailable, true);
+});
diff --git a/prototypes/workbench/results/2026-09-23-shared-rules/verification.json b/prototypes/workbench/results/2026-09-23-shared-rules/verification.json
new file mode 100644
index 0000000..0768cc3
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-shared-rules/verification.json
@@ -0,0 +1,62 @@
+[
+ {
+ "file": "prototypes/workbench/baseline.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 reference begins with the authored states and matching scene (8.045605ms)\n\u2714 press, hold, release, and frame order produce exact expected values (0.643315ms)\n\u2714 charge clamps at one and explicit floor restores rest (1.415832ms)\n\u2714 repeat and concurrent physical sources produce only logical edges (0.440981ms)\n\u2714 airborne presses cannot charge or cause another jump (0.296037ms)\n\u2714 releaseAll emits release and reset clears latches without a jump (0.460666ms)\n\u2714 unknown inputs and invalid frame counts reject without mutation (0.833592ms)\n\u2139 tests 7\n\u2139 suites 0\n\u2139 pass 7\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 104.998545\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/cli.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 terminal starts empty and produces the same charge trace as the shared runtime (132.23208ms)\n\u2714 invalid terminal commands do not alter state and fail piped execution (55.4384ms)\n\u2714 terminal replays authored pointer movement and cancellation through the shared runtime (72.766738ms)\n\u2714 file load preserves by name, rejected load preserves active work, and save never overwrites (60.705193ms)\n\u2714 terminal exposes arithmetic rollback and retains failure through matching release (57.574139ms)\n\u2714 terminal configures and discards evaluation records and recovers from a display limit (51.583977ms)\n\u2714 terminal saves and restores placement independently of reset and preserves a browser draft (214.094236ms)\n\u2714 invalid restoration leaves terminal code and state unchanged (64.081708ms)\n\u2139 tests 8\n\u2139 suites 0\n\u2139 pass 8\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 791.095627\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/compiler.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 a blank document is an empty program with no implied behavior (4.437219ms)\n\u2714 forward references and separate state/input namespaces preserve declaration IDs (2.043572ms)\n\u2714 ordered assignments, repeated handlers, and condition evaluation once (0.933296ms)\n\u2714 nested branches use absolute forward targets and skip correct bodies (1.152407ms)\n\u2714 operator precedence, associativity, unary and builtin function lowering (2.408572ms)\n\u2714 multiline parentheses, scientific literals, comments, and string escapes (0.938277ms)\n\u2714 all instruction fields are numeric and carry original source line (0.394222ms)\n\u2714 contextual declaration words remain valid names outside reserved builtins (0.313389ms)\n\u2714 unknown and reserved symbols have useful one-based locations (1.743147ms)\n\u2714 duplicates, unsupported keys, initializers, and invalid syntax are rejected (1.464017ms)\n\u2714 document, declaration, instruction, and nesting limits reject overflow (6.69505ms)\n\u2714 every supported input maps to a bounded ordered event ID (1.028536ms)\n\u2714 optional examples compile and charge behavior is entirely authored (20.773687ms)\n\u2139 tests 13\n\u2139 suites 0\n\u2139 pass 13\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 120.580868\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/evaluation.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 cache reuse, unrelated changes and discarded records preserve authored values (20.981335ms)\n\u2714 committed changes only: failed events and failed edits preserve reusable output (4.493144ms)\n\u2714 program edits, state reordering and reset cannot reuse old definitions (3.96033ms)\n\u2714 finite float semantics include signed zero, high state bit and arithmetic errors (4.212293ms)\n\u2714 one budget covers the whole scene; failures publish no partial scene or state rollback (2.07635ms)\n\u2714 small caches, no history and every small work limit give exact results or explicit failure (28.417737ms)\n\u2714 invalid settings and forget names preserve policy and state (2.441665ms)\n\u2139 tests 7\n\u2139 suites 0\n\u2139 pass 7\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 151.059416\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/pointer.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 pointer declarations resolve forward targets and reject ambiguous or contextless inputs (9.304659ms)\n\u2714 authored drag keeps grab offset and commits the displayed position even if release coordinates differ (11.38525ms)\n\u2714 ownership, duplicate downs, stale moves, cancellation and focus cleanup cannot leave held input (3.902664ms)\n\u2714 invalid coordinates and modes do not mutate a held gesture; arithmetic failure rolls back one event (3.549423ms)\n\u2714 independent targets share the event pipeline and publication remains non-reentrant (3.036349ms)\n\u2714 failing down, up and cancel never prevent terminal ownership cleanup (3.723978ms)\n\u2139 tests 6\n\u2139 suites 0\n\u2139 pass 6\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 118.292424\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/presentation.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 direct publication matches archived snapshots and work counts through authored changes (58.271083ms)\n\u2714 whole-scene budget failure never publishes partial bindings and recovers exactly (8.306901ms)\n\u2714 latched event rollback, nonfinite bindings, reset and signed zero keep their semantics (3.417886ms)\n\u2714 publication rejects reentrant runtime changes and reads before they affect the scene (4.134738ms)\n\u2714 invalid or throwing output adapters do not strand the runtime or alter authored state (2.927572ms)\n\u2139 tests 5\n\u2139 suites 0\n\u2139 pass 5\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 170.584957\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/project.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 multiple authored placements and a separate invalid draft survive a fresh runtime restore (35.665731ms)\n\u2714 malformed files reject before changing active code, values, diagnostics or warm display records (12.678175ms)\n\u2714 restoration matches names rather than ordering and never reuses stale cached values (4.795756ms)\n\u2714 finite float values including negative zero round trip without changing authored reset values (3.274775ms)\n\u2714 held and failed interactions cannot be saved and restoration cannot steal an active input (12.313916ms)\n\u2714 empty and full documents restore without imposing a new source size or state count (4.861367ms)\n\u2714 document APIs respect the output callback boundary and export independent values (9.588011ms)\n\u2139 tests 7\n\u2139 suites 0\n\u2139 pass 7\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 197.019695\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/rules.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 binding more objects shares one VM body instead of expanding its instructions (8.572475ms)\n\u2714 records and rules resolve forward names and reject invalid types fields and bindings (8.254919ms)\n\u2714 shared layout matches the existing authored layout across independent and simultaneous gestures (32.486364ms)\n\u2714 field names retain identity across schema and instance reorder, removal and restoration (6.508144ms)\n\u2714 bindings share one event transaction including global effects and multiple uses of one item (2.478183ms)\n\u2714 bound writes invalidate affected display caches and failed edits preserve active bindings (2.754313ms)\n\u2714 generated qualified names cannot make a valid exported document unreadable by its own loader (10.14301ms)\n\u2139 tests 7\n\u2139 suites 0\n\u2139 pass 7\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 159.341189\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/runtime.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 blank startup and applied empty source contain no application objects or behavior (12.167268ms)\n\u2714 author can add unrelated states inputs views and behavior entirely through source (5.777903ms)\n\u2714 keyboard and pointer sources aggregate first down and last up; repeats are ignored (2.04235ms)\n\u2714 held input rejects apply without changing applied source, metadata, or values (2.22159ms)\n\u2714 invalid syntax, unknown references and capacity overflow preserve the previous program (2.604239ms)\n\u2714 state preservation follows names across reorder/add/remove; reset uses new initializers (2.888331ms)\n\u2714 callers cannot mutate active metadata and corrupt name-based state preservation (1.489535ms)\n\u2714 conditions execute once, and assignments and multiple handlers follow source order (1.677591ms)\n\u2714 a failed event rolls back all handlers and keeps its source diagnostic through release (1.809258ms)\n\u2714 reset clears held inputs and latched errors; invalid frame counts have no effects (2.023887ms)\n\u2714 render evaluation is pure and invalid binding is distinct from an event rollback (5.592051ms)\n\u2714 independent adapters replay the same author program without sharing live state (3.838849ms)\n\u2714 Wasm has no imports and reports fixed memory and exact active IR/state storage (7.566272ms)\n\u2139 tests 13\n\u2139 suites 0\n\u2139 pass 13\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 129.686452\n",
+ "stderr": ""
+ },
+ {
+ "file": "prototypes/workbench/workspace.test.mjs",
+ "exitCode": 0,
+ "stdout": "\u2714 Wasm executes old events and output during staged replacement without corrupting either phase (10.164529ms)\n\u2714 workspace accounting reports shared payload and safety tag without double-counting (3.272442ms)\n\u2139 tests 2\n\u2139 suites 0\n\u2139 pass 2\n\u2139 fail 0\n\u2139 cancelled 0\n\u2139 skipped 0\n\u2139 todo 0\n\u2139 duration_ms 92.159223\n",
+ "stderr": ""
+ }
+]
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/README.md b/prototypes/workbench/results/2026-09-23-web-layout/README.md
new file mode 100644
index 0000000..a64f2f1
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/README.md
@@ -0,0 +1,47 @@
+# Native web layout boundary — 2026-09-23
+
+Functional and ownership evidence, not a performance or total-memory benchmark.
+
+- `build.json` / `native-build.log`: pinned Zig 0.15.2, 37 native tests, source/Wasm hashes.
+- `node-tests.log`: final 87 tests (compiler, runtime, numeric/pointer routes, CLI,
+ persistence, display evaluation/publication and previous examples).
+- `browser.json`: 32 document-workspace checks, with source/preview text and counters.
+- `authoring.json`: 44 legacy authoring-flow checks.
+- `value-control.json`: 3 numeric-control checks in the original workbench UI.
+- `guards.json`: 6 focused checks including fixture validation, rejected out-of-range
+ output, arithmetic rollback/control reconciliation, active-gesture apply refusal,
+ and oversized-file rejection.
+- `pure-document-saved.txt`: actual browser download, byte/text checked against the
+ UTF-8 LF fixture in `browser.json`, also reopened in a fresh page.
+- `pure-document-{desktop,mobile}.png`: final screenshots, visually inspected.
+- `sources/`: implementation and test snapshots; `before/`: previous core/host files.
+- `verification.json`, `manifest.json`: bounded result counts and artifact hashes.
+
+The 32-case document run precedes the final range-control reconciliation on rejected
+host output. The focused guard run tests that final correction and records the final
+screenshots (also incorporating a small heading-wrap adjustment). The core/runtime
+used by the final snapshots have matching build hashes. No timing comparison with
+before/ is made.
+
+Reproduce from repository root:
+
+```sh
+python3 prototypes/workbench/manage.py build --zig /path/to/zig-0.15.2
+node --test prototypes/workbench/*.test.mjs
+python3 prototypes/workbench/manage.py serve --port 8766
+```
+
+Open `/document.html` with Playwright CLI, and pass `qa-document.js` to `run-code`.
+After completion read `window.__pureDocumentEvidence`. `qa-document-guards.js`
+exposes `__pureDocumentGuards`. The original workbench uses `qa-authoring.js` and
+`qa-value-control.js` here, with `__pureWorkbenchQAEvidence` / `__pureValueEvidence`.
+Dialogs/downloads can make the CLI return before the async function finishes;
+read completion evidence rather than treating a CLI return as success.
+
+Counters prove which application callbacks run, not whether a browser internally
+performs layout or allocates memory. Native editor and preview both retain text;
+there is no additional persistent JS/VM document model. Per-edit whole-preview text
+publication remains. No physical mobile, OS IME, screen-reader, macOS/Metal or
+large-document performance result is claimed. Composition checks use synthetic
+composition events, touch uses Chromium CDP, other mouse/keyboard flows use actual
+browser input. File exchange is UTF-8 text with native textarea newline normalization.
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/authoring-result.log b/prototypes/workbench/results/2026-09-23-web-layout/authoring-result.log
new file mode 100644
index 0000000..efc3322
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/authoring-result.log
@@ -0,0 +1,13 @@
+### Result
+{"recordedUtc":"2026-09-23T09:00:17.827Z","browser":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36","checks":["initial editor has literally empty source","initial scene has no built-in entities, states, inputs, or running clock","controls require an authored program","authoring a new program is a draft until Apply","new custom program constructs states and multiple views from source","input buttons are generated from the authored declarations","view bindings render the authored state value","Space and arrows in the editor never reach authored inputs","pointer down dispatches once while the clock is paused","pointer up releases without double dispatch from click","keyboard repeat is ignored","keyboard activation on a generated button is one complete tap","assistive click activation is one complete tap","keyboard and pointer sources share one logical input","releasing one source does not release the other source","last source releases the logical input","authored down/frame behavior uses the declared dt without built-in motion","keyup releases even after focus moves into the editor","focus loss sends release and pauses the clock","Play advances and Pause freezes the clock","invalid code preserves the previous executable program and states","compile error exposes a source-line diagnostic","diagnostic location moves the editor caret to the problem","apply preserves matching state names and initializes newly authored states","new input, entity, binding, and behavior work with source changes alone","changed behavior takes effect without resetting existing values","explicit Reset restores active initializers and keeps the draft","Apply is refused while an input is held","clear editor is explicit and does not clear the running scene before Apply","downloaded project restores the exact un-applied draft without execution","invalid JSON project schema cannot replace the draft","failed assistive down event rolls back and its error survives the following successful up","latched runtime failure is visible and labeled separately from the latest input","explicit Reset clears the failure record","applying empty source removes all authored states, inputs, and scene contents","explicit example loading only fills the editor and never auto-applies","authored scene executes in the actual fixed 64 KiB Wasm memory","cost inspector shows measured Wasm memory without claiming browser heap totals","390px mobile viewport has no horizontal overflow","generated mobile input buttons remain usable","ordinary-JavaScript comparison and equivalent Pure example are linked and served","missing Wasm shows an actionable load failure and disables execution controls","fresh load always returns to a blank authoring environment","no uncaught page errors during the browser scenarios"],"authoredSource":"# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n","authoredSnapshot":{"states":{"x":114,"moving":0,"count":2},"views":[{"id":"marker","type":"box","color":"#7ed9ae","x":114,"y":170,"width":48,"height":48},{"id":"reading","type":"text","prefix":"Signals: ","x":40,"y":70,"value":2}],"tick":12,"revision":5,"displayError":null,"lastError":0,"errorMessage":"","errorLine":null,"lastOps":2,"successfulEvents":18,"held":[],"lastEvent":{"name":"advance","phase":"up","source":"keyboard:ArrowRight","status":0,"line":null,"ops":2},"lastFailure":null,"memory":{"linearBytes":65536,"reservedEngineBytes":20064,"programReservedBytes":9728,"instructionBytes":16,"handlerBytes":16,"activeIRBytes":480,"activeStateBytes":24,"stagingReservedBytes":9992,"evalStackBytes":512,"pointerTargetBytes":3,"pointerTargets":0},"ready":true,"source":"# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n","appliedSource":"# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n","dirty":false,"running":false,"droppedTicks":0,"diagnostic":"코드를 적용했습니다. 같은 이름의 상태는 유지됩니다."},"mobile":{"viewport":390,"content":390},"downloaded":{"filename":"my-work.pure.json","path":"output/playwright/pure-document-authoring-draft.json","expectedSource":"# A new authored interaction, not a bundled example.\nstate x = 500\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 5 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\nstate bonus = 7\ninput reverse = KeyA\ncircle beacon(300, 190, 15 + bonus, \"#c9f08b\")\non reverse.down { bonus = bonus + 2 }\n# This un-applied draft must survive Reset.\n"},"errors":[],"screenshots":["output/playwright/pure-document-authoring-blank.png","output/playwright/pure-document-authoring-authored.png","output/playwright/pure-document-authoring-mobile.png"]}
+### Ran Playwright code
+```js
+await (async page => await page.evaluate(() => window.__pureWorkbenchQAEvidence ?? {pending:true}))(page);
+```
+### Page
+- Page URL: http://127.0.0.1:8766/
+- Page Title: Pure · 빈 작업물에서 시작하기
+### Events
+- New console entries: .playwright-cli/console-2026-09-23T08-54-35-115Z.log#L2
+- Downloading file my-work.pure.json ...
+- Downloaded file my-work.pure.json to ".playwright-cli/my-work-pure.json"
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/authoring-run.log b/prototypes/workbench/results/2026-09-23-web-layout/authoring-run.log
new file mode 100644
index 0000000..cca9455
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/authoring-run.log
@@ -0,0 +1,237 @@
+### Ran Playwright code
+```js
+await (// Run this expression with Playwright CLI run-code after opening the local host.
+// All behavior is authored through the editor; snapshot() only observes results.
+async (page) => {
+ const checks = [];
+ const errors = [];
+ const onError = error => errors.push(error.message);
+ const acceptDialog = dialog => dialog.accept();
+ const check = (condition, label) => {
+ if (!condition) throw new Error(`Check ${checks.length + 1} failed: ${label}`);
+ checks.push(label);
+ };
+ const state = () => page.evaluate(() => window.pureWorkbench.snapshot());
+ const apply = async text => { await page.locator('#source').fill(text); await page.locator('#apply').click(); };
+ const authorSource = [
+ '# A new authored interaction, not a bundled example.',
+ 'state x = 90', 'state moving = 0', 'state count = 0',
+ 'input advance = ArrowRight', 'input pulse = Space',
+ 'box marker(x, 170, 48, 48, "#7ed9ae")',
+ 'text reading(40, 70, count, "Signals: ")',
+ 'on advance.down { moving = 1 }', 'on advance.up { moving = 0 }',
+ 'on pulse.down { count = count + 1 }',
+ 'on frame { x = clamp(x + moving * 120 * dt, 0, 580) }', '',
+ ].join('\n');
+ let lastAuthored = null;
+ let mobile = null;
+ let downloaded = null;
+ const shots = ['output/playwright/pure-document-authoring-blank.png', 'output/playwright/pure-document-authoring-authored.png', 'output/playwright/pure-document-authoring-mobile.png'];
+ page.on('pageerror', onError);
+ page.on('dialog', acceptDialog);
+ try {
+ await page.goto('http://127.0.0.1:8766/');
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.reload();
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ let s = await state();
+ check(s.source === '' && s.appliedSource === '' && !s.dirty, 'initial editor has literally empty source');
+ check(s.views.length === 0 && Object.keys(s.states).length === 0 && s.held.length === 0 && !s.running, 'initial scene has no built-in entities, states, inputs, or running clock');
+ check(await page.locator('#input-controls button').count() === 0 && await page.locator('#play').isDisabled(), 'controls require an authored program');
+ await page.screenshot({ path: shots[0], fullPage: true });
+
+ await page.locator('#source').fill(authorSource);
+ s = await state();
+ check(s.dirty && s.views.length === 0 && s.appliedSource === '', 'authoring a new program is a draft until Apply');
+ await page.locator('#apply').click();
+ s = await state();
+ check(!s.dirty && s.appliedSource === authorSource && s.views.length === 2 && s.states.x === 90 && s.states.count === 0, 'new custom program constructs states and multiple views from source');
+ check(await page.locator('#input-controls button').count() === 2 && await page.locator('[data-input="advance"]').count() === 1 && await page.locator('[data-input="pulse"]').count() === 1, 'input buttons are generated from the authored declarations');
+ check(await page.locator('#scene-content [data-view-id="marker"]').getAttribute('x') === '90', 'view bindings render the authored state value');
+
+ await page.locator('#source').focus();
+ await page.keyboard.press('End');
+ await page.keyboard.press('Space');
+ await page.keyboard.press('ArrowRight');
+ check((await state()).states.count === 0 && (await state()).states.moving === 0, 'Space and arrows in the editor never reach authored inputs');
+ await page.locator('#source').fill(authorSource);
+
+ const pulse = page.locator('[data-input="pulse"]');
+ let box = await pulse.boundingBox();
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ s = await state();
+ check(s.states.count === 1 && s.held.includes('pulse') && !s.running && s.tick === 0, 'pointer down dispatches once while the clock is paused');
+ await page.mouse.up();
+ check((await state()).held.length === 0 && (await state()).states.count === 1, 'pointer up releases without double dispatch from click');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('Space');
+ await page.keyboard.down('Space');
+ await page.keyboard.up('Space');
+ check((await state()).states.count === 2, 'keyboard repeat is ignored');
+ await pulse.focus();
+ await page.keyboard.press('Enter');
+ check((await state()).states.count === 3 && (await state()).held.length === 0, 'keyboard activation on a generated button is one complete tap');
+ await pulse.dispatchEvent('click', { detail: 0 });
+ check((await state()).states.count === 4 && (await state()).held.length === 0, 'assistive click activation is one complete tap');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('Space');
+ box = await pulse.boundingBox();
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ check((await state()).states.count === 5 && (await state()).held.includes('pulse'), 'keyboard and pointer sources share one logical input');
+ await page.mouse.up();
+ check((await state()).held.includes('pulse'), 'releasing one source does not release the other source');
+ await page.keyboard.up('Space');
+ check((await state()).held.length === 0, 'last source releases the logical input');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ await page.locator('#step').click();
+ await page.locator('#step').click();
+ s = await state();
+ check(s.states.x === 94 && s.states.moving === 1 && s.tick === 2, 'authored down/frame behavior uses the declared dt without built-in motion');
+ await page.locator('#source').focus();
+ await page.keyboard.up('ArrowRight');
+ check((await state()).states.moving === 0 && (await state()).held.length === 0, 'keyup releases even after focus moves into the editor');
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ await page.evaluate(() => window.dispatchEvent(new Event('blur')));
+ await page.keyboard.up('ArrowRight');
+ check((await state()).states.moving === 0 && !(await state()).running && (await state()).held.length === 0, 'focus loss sends release and pauses the clock');
+
+ await page.locator('#play').click();
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().tick >= 5);
+ await page.locator('#play').click();
+ const paused = await state();
+ await page.waitForTimeout(100);
+ check(!paused.running && paused.tick === (await state()).tick && paused.tick >= 5, 'Play advances and Pause freezes the clock');
+
+ const beforeFailure = await state();
+ await apply(authorSource + 'state broken =\n');
+ s = await state();
+ check(s.appliedSource === authorSource && s.revision === beforeFailure.revision && JSON.stringify(s.states) === JSON.stringify(beforeFailure.states), 'invalid code preserves the previous executable program and states');
+ check(await page.locator('#error-location').isVisible() && /행/.test(s.diagnostic) && await page.locator('#source').getAttribute('aria-invalid') === 'true', 'compile error exposes a source-line diagnostic');
+ await page.locator('#error-location').click();
+ check(await page.locator('#source').evaluate(el => el.selectionStart >= el.value.indexOf('state broken')), 'diagnostic location moves the editor caret to the problem');
+
+ const expanded = authorSource.replace('state x = 90', 'state x = 500').replace('count = count + 1', 'count = count + 5') + [
+ 'state bonus = 7', 'input reverse = KeyA',
+ 'circle beacon(300, 190, 15 + bonus, "#c9f08b")',
+ 'on reverse.down { bonus = bonus + 2 }', '',
+ ].join('\n');
+ await apply(expanded);
+ s = await state();
+ check(s.states.x === beforeFailure.states.x && s.states.count === 5 && s.states.bonus === 7 && s.views.length === 3, 'apply preserves matching state names and initializes newly authored states');
+ await page.locator('[data-input="reverse"]').click();
+ check((await state()).states.bonus === 9 && await page.locator('#scene-content [data-view-id="beacon"]').getAttribute('r') === '24', 'new input, entity, binding, and behavior work with source changes alone');
+ await page.locator('[data-input="pulse"]').click();
+ check((await state()).states.count === 10, 'changed behavior takes effect without resetting existing values');
+
+ const withDraft = expanded + '# This un-applied draft must survive Reset.\n';
+ await page.locator('#source').fill(withDraft);
+ await page.locator('#reset').click();
+ s = await state();
+ check(s.states.x === 500 && s.states.count === 0 && s.states.bonus === 7 && s.tick === 0 && s.source === withDraft && s.dirty, 'explicit Reset restores active initializers and keeps the draft');
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ const heldRevision = (await state()).revision;
+ await page.locator('#apply').click();
+ check((await state()).revision === heldRevision && /놓은/.test((await state()).diagnostic), 'Apply is refused while an input is held');
+ await page.keyboard.up('ArrowRight');
+
+ const downloadPromise = page.waitForEvent('download');
+ await page.locator('#download-project').click();
+ const projectDownload = await downloadPromise;
+ const downloadPath = 'output/playwright/pure-document-authoring-draft.json';
+ await projectDownload.saveAs(downloadPath);
+ downloaded = { filename: projectDownload.suggestedFilename(), path: downloadPath, expectedSource: withDraft };
+ await page.locator('#new').click();
+ s = await state();
+ check(s.source === '' && s.views.length === 3 && s.appliedSource === expanded, 'clear editor is explicit and does not clear the running scene before Apply');
+ await page.locator('#file-input').setInputFiles(downloadPath);
+ await page.waitForFunction(expected => window.pureWorkbench.snapshot().source === expected, withDraft);
+ check((await state()).source === withDraft && (await state()).appliedSource === expanded && (await state()).dirty, 'downloaded project restores the exact un-applied draft without execution');
+ await page.locator('#file-input').evaluate(input => {
+ const transfer = new DataTransfer();
+ transfer.items.add(new File(['{"format":"other","version":1,"source":"state no = 1"}'], 'invalid.json', { type: 'application/json' }));
+ input.files = transfer.files;
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ });
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().diagnostic.includes('형식'));
+ check((await state()).source === withDraft && (await state()).appliedSource === expanded, 'invalid JSON project schema cannot replace the draft');
+
+ await apply('state n = 1\ninput fail = Space\ntext label(40, 60, n)\non fail.down { n = n / 0 }\n');
+ await page.locator('[data-input="fail"]').dispatchEvent('click', { detail: 0 });
+ s = await state();
+ check(s.states.n === 1 && s.lastError === 4 && s.held.length === 0 && s.lastEvent.phase === 'up', 'failed assistive down event rolls back and its error survives the following successful up');
+ check(await page.locator('#runtime-error').isVisible() && (await page.locator('#runtime-error').textContent()).includes('마지막 실패'), 'latched runtime failure is visible and labeled separately from the latest input');
+ await page.locator('#reset').click();
+ check((await state()).lastError === 0 && await page.locator('#runtime-error').isHidden(), 'explicit Reset clears the failure record');
+
+ await page.locator('#new').click();
+ await page.locator('#apply').click();
+ s = await state();
+ check(s.source === '' && s.appliedSource === '' && s.views.length === 0 && Object.keys(s.states).length === 0 && await page.locator('#input-controls button').count() === 0, 'applying empty source removes all authored states, inputs, and scene contents');
+ await page.locator('#example').selectOption('counter');
+ await page.locator('#load-example').click();
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().source.includes('state count'));
+ check((await state()).views.length === 0 && (await state()).appliedSource === '' && (await state()).dirty, 'explicit example loading only fills the editor and never auto-applies');
+
+ await apply(authorSource);
+ await page.locator('[data-input="pulse"]').click();
+ await page.locator('[data-input="pulse"]').click();
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ for (let i = 0; i < 12; i++) await page.locator('#step').click();
+ await page.keyboard.up('ArrowRight');
+ lastAuthored = await state();
+ check(lastAuthored.states.x === 114 && lastAuthored.states.count === 2 && lastAuthored.memory.linearBytes === 65536, 'authored scene executes in the actual fixed 64 KiB Wasm memory');
+ await page.locator('.cost-details summary').click();
+ check((await page.locator('#cost-values').textContent()).includes('65,536 B'), 'cost inspector shows measured Wasm memory without claiming browser heap totals');
+ await page.locator('.cost-details summary').click();
+ await page.screenshot({ path: shots[1], fullPage: true });
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ mobile = await page.evaluate(() => ({ viewport: innerWidth, content: document.documentElement.scrollWidth }));
+ check(mobile.content <= mobile.viewport, '390px mobile viewport has no horizontal overflow');
+ await page.locator('[data-input="pulse"]').click();
+ check((await state()).states.count === 3, 'generated mobile input buttons remain usable');
+ await page.screenshot({ path: shots[2], fullPage: true });
+ const comparison = await page.request.get('http://127.0.0.1:8766/baseline.html');
+ const equivalent = await page.request.get('http://127.0.0.1:8766/examples/charge.pure');
+ check(comparison.ok() && equivalent.ok() && await page.locator('a[href="./baseline.html"]').count() === 1, 'ordinary-JavaScript comparison and equivalent Pure example are linked and served');
+
+ await page.route('**/core.wasm', route => route.abort());
+ await page.reload();
+ await page.waitForFunction(() => document.getElementById('ready-status').textContent.includes('실패'));
+ check(await page.locator('#apply').isDisabled() && await page.locator('#play').isDisabled() && await page.locator('#step').isDisabled() && /실행기를 열지/.test(await page.locator('#diagnostic').textContent()), 'missing Wasm shows an actionable load failure and disables execution controls');
+ await page.unroute('**/core.wasm');
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.reload();
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ check((await state()).source === '' && (await state()).views.length === 0, 'fresh load always returns to a blank authoring environment');
+ check(errors.length === 0, 'no uncaught page errors during the browser scenarios');
+ const evidence = { recordedUtc: new Date().toISOString(), browser: await page.evaluate(() => navigator.userAgent),
+ checks, authoredSource: authorSource, authoredSnapshot: lastAuthored, mobile, downloaded, errors, screenshots: shots };
+ // CLI can yield early when native dialogs/download events occur. Retain the
+ // observation report so a follow-up CLI call can retrieve the complete run.
+ await page.evaluate(value => { window.__pureWorkbenchQAEvidence = value; }, evidence);
+ return evidence;
+ } catch (error) {
+ const evidence = { failed: true, error: error.message, stack: error.stack, checks, errors };
+ await page.evaluate(value => { window.__pureWorkbenchQAEvidence = value; }, evidence);
+ return evidence;
+ } finally {
+ page.off('pageerror', onError);
+ page.off('dialog', acceptDialog);
+ }
+}
+)(page);
+```
+### Page
+- Page URL: http://127.0.0.1:8766/document.html
+### Modal state
+- ["beforeunload" dialog with message ""]: can be handled by dialog-accept or dialog-dismiss
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/authoring.json b/prototypes/workbench/results/2026-09-23-web-layout/authoring.json
new file mode 100644
index 0000000..96d5c0a
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/authoring.json
@@ -0,0 +1,130 @@
+{
+ "recordedUtc": "2026-09-23T09:00:17.827Z",
+ "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36",
+ "checks": [
+ "initial editor has literally empty source",
+ "initial scene has no built-in entities, states, inputs, or running clock",
+ "controls require an authored program",
+ "authoring a new program is a draft until Apply",
+ "new custom program constructs states and multiple views from source",
+ "input buttons are generated from the authored declarations",
+ "view bindings render the authored state value",
+ "Space and arrows in the editor never reach authored inputs",
+ "pointer down dispatches once while the clock is paused",
+ "pointer up releases without double dispatch from click",
+ "keyboard repeat is ignored",
+ "keyboard activation on a generated button is one complete tap",
+ "assistive click activation is one complete tap",
+ "keyboard and pointer sources share one logical input",
+ "releasing one source does not release the other source",
+ "last source releases the logical input",
+ "authored down/frame behavior uses the declared dt without built-in motion",
+ "keyup releases even after focus moves into the editor",
+ "focus loss sends release and pauses the clock",
+ "Play advances and Pause freezes the clock",
+ "invalid code preserves the previous executable program and states",
+ "compile error exposes a source-line diagnostic",
+ "diagnostic location moves the editor caret to the problem",
+ "apply preserves matching state names and initializes newly authored states",
+ "new input, entity, binding, and behavior work with source changes alone",
+ "changed behavior takes effect without resetting existing values",
+ "explicit Reset restores active initializers and keeps the draft",
+ "Apply is refused while an input is held",
+ "clear editor is explicit and does not clear the running scene before Apply",
+ "downloaded project restores the exact un-applied draft without execution",
+ "invalid JSON project schema cannot replace the draft",
+ "failed assistive down event rolls back and its error survives the following successful up",
+ "latched runtime failure is visible and labeled separately from the latest input",
+ "explicit Reset clears the failure record",
+ "applying empty source removes all authored states, inputs, and scene contents",
+ "explicit example loading only fills the editor and never auto-applies",
+ "authored scene executes in the actual fixed 64 KiB Wasm memory",
+ "cost inspector shows measured Wasm memory without claiming browser heap totals",
+ "390px mobile viewport has no horizontal overflow",
+ "generated mobile input buttons remain usable",
+ "ordinary-JavaScript comparison and equivalent Pure example are linked and served",
+ "missing Wasm shows an actionable load failure and disables execution controls",
+ "fresh load always returns to a blank authoring environment",
+ "no uncaught page errors during the browser scenarios"
+ ],
+ "authoredSource": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "authoredSnapshot": {
+ "states": {
+ "x": 114,
+ "moving": 0,
+ "count": 2
+ },
+ "views": [
+ {
+ "id": "marker",
+ "type": "box",
+ "color": "#7ed9ae",
+ "x": 114,
+ "y": 170,
+ "width": 48,
+ "height": 48
+ },
+ {
+ "id": "reading",
+ "type": "text",
+ "prefix": "Signals: ",
+ "x": 40,
+ "y": 70,
+ "value": 2
+ }
+ ],
+ "tick": 12,
+ "revision": 5,
+ "displayError": null,
+ "lastError": 0,
+ "errorMessage": "",
+ "errorLine": null,
+ "lastOps": 2,
+ "successfulEvents": 18,
+ "held": [],
+ "lastEvent": {
+ "name": "advance",
+ "phase": "up",
+ "source": "keyboard:ArrowRight",
+ "status": 0,
+ "line": null,
+ "ops": 2
+ },
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 20064,
+ "programReservedBytes": 9728,
+ "instructionBytes": 16,
+ "handlerBytes": 16,
+ "activeIRBytes": 480,
+ "activeStateBytes": 24,
+ "stagingReservedBytes": 9992,
+ "evalStackBytes": 512,
+ "pointerTargetBytes": 3,
+ "pointerTargets": 0
+ },
+ "ready": true,
+ "source": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "appliedSource": "# A new authored interaction, not a bundled example.\nstate x = 90\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 1 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\n",
+ "dirty": false,
+ "running": false,
+ "droppedTicks": 0,
+ "diagnostic": "코드를 적용했습니다. 같은 이름의 상태는 유지됩니다."
+ },
+ "mobile": {
+ "viewport": 390,
+ "content": 390
+ },
+ "downloaded": {
+ "filename": "my-work.pure.json",
+ "path": "output/playwright/pure-document-authoring-draft.json",
+ "expectedSource": "# A new authored interaction, not a bundled example.\nstate x = 500\nstate moving = 0\nstate count = 0\ninput advance = ArrowRight\ninput pulse = Space\nbox marker(x, 170, 48, 48, \"#7ed9ae\")\ntext reading(40, 70, count, \"Signals: \")\non advance.down { moving = 1 }\non advance.up { moving = 0 }\non pulse.down { count = count + 5 }\non frame { x = clamp(x + moving * 120 * dt, 0, 580) }\nstate bonus = 7\ninput reverse = KeyA\ncircle beacon(300, 190, 15 + bonus, \"#c9f08b\")\non reverse.down { bonus = bonus + 2 }\n# This un-applied draft must survive Reset.\n"
+ },
+ "errors": [],
+ "screenshots": [
+ "output/playwright/pure-document-authoring-blank.png",
+ "output/playwright/pure-document-authoring-authored.png",
+ "output/playwright/pure-document-authoring-mobile.png"
+ ]
+}
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/before/compiler.mjs b/prototypes/workbench/results/2026-09-23-web-layout/before/compiler.mjs
new file mode 100644
index 0000000..bfe671b
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/before/compiler.mjs
@@ -0,0 +1,484 @@
+// Pure's experimental scalar interaction language. No host code is evaluated.
+const LIMITS = Object.freeze({ sourceBytes: 32768, states: 32, inputs: 8,
+ views: 24, handlers: 64, instructions: 512, depth: 64, stack: 64 });
+const RESERVED = new Set(['frame', 'dt', 'event_x', 'event_y', 'min', 'max', 'clamp']);
+const BINARY = new Map([
+ ['||', [1, 15]], ['&&', [2, 14]], ['==', [3, 12]], ['!=', [3, 13]],
+ ['<', [4, 8]], ['<=', [4, 9]], ['>', [4, 10]], ['>=', [4, 11]],
+ ['+', [5, 3]], ['-', [5, 4]], ['*', [6, 5]], ['/', [6, 6]],
+]);
+const FUNCTIONS = { min: [2, 17], max: [2, 18], clamp: [3, 19] };
+const VIEW_FIELDS = { box: ['x', 'y', 'width', 'height'],
+ circle: ['x', 'y', 'radius'], text: ['x', 'y', 'value'] };
+
+function fail(token, message) {
+ const error = new Error(`${message} (${token.line}:${token.column})`);
+ error.line = token.line;
+ error.column = token.column;
+ throw error;
+}
+
+function lex(source) {
+ const tokens = [];
+ let offset = 0, line = 1, column = 1;
+ const advance = () => {
+ const char = source[offset++];
+ if (char === '\n') { line++; column = 1; } else column++;
+ return char;
+ };
+ while (offset < source.length) {
+ const char = source[offset];
+ if (char === ' ' || char === '\t' || char === '\r') { advance(); continue; }
+ if (char === '#') { while (offset < source.length && source[offset] !== '\n') advance(); continue; }
+ const token = { line, column };
+ if (char === '\n') { advance(); tokens.push({ ...token, kind: 'nl' }); continue; }
+ if (char === '"') {
+ advance();
+ let value = '', closed = false;
+ while (offset < source.length) {
+ const next = advance();
+ if (next === '"') { closed = true; break; }
+ if (next === '\n' || next === '\r') fail(token, 'Strings must fit on one line');
+ if (next === '\\') {
+ const escaped = advance();
+ const escapes = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' };
+ if (!Object.hasOwn(escapes, escaped)) fail(token, 'Unsupported string escape');
+ value += escapes[escaped];
+ } else value += next;
+ }
+ if (!closed) fail(token, 'Unterminated string');
+ tokens.push({ ...token, kind: 'string', value }); continue;
+ }
+ const remaining = source.slice(offset);
+ const number = remaining.match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/);
+ if (number) {
+ const value = Number(number[0]);
+ if (!Number.isFinite(value)) fail(token, 'Number must be finite');
+ for (let i = 0; i < number[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'number', value }); continue;
+ }
+ const name = remaining.match(/^[A-Za-z_][A-Za-z_0-9]*/);
+ if (name) {
+ for (let i = 0; i < name[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'name', value: name[0] }); continue;
+ }
+ const pair = source.slice(offset, offset + 2);
+ if (['<=', '>=', '==', '!=', '&&', '||'].includes(pair)) {
+ advance(); advance(); tokens.push({ ...token, kind: pair }); continue;
+ }
+ if ('=+-*/!<>(){},.;:'.includes(char)) {
+ advance(); tokens.push({ ...token, kind: char }); continue;
+ }
+ fail(token, `Unexpected character ${JSON.stringify(char)}`);
+ }
+ tokens.push({ kind: 'eof', line, column });
+ return tokens;
+}
+
+class Parser {
+ constructor(tokens) { this.tokens = tokens; this.position = 0; this.depth = 0; }
+ peek() { return this.tokens[this.position]; }
+ take() { return this.tokens[this.position++]; }
+ is(kind, value) { return this.peek().kind === kind && (value === undefined || this.peek().value === value); }
+ expect(kind, description = kind) {
+ if (!this.is(kind)) fail(this.peek(), `Expected ${description}`);
+ return this.take();
+ }
+ newlines() { while (this.is('nl')) this.take(); }
+ separators() { while (this.is('nl') || this.is(';')) this.take(); }
+ end() {
+ if (!['nl', ';', '}', 'eof'].includes(this.peek().kind)) fail(this.peek(), 'Expected newline or semicolon');
+ this.separators();
+ }
+ reference(token) {
+ if (!this.is('.')) return token.value;
+ this.take();
+ return `${token.value}.${this.expect('name', 'field name').value}`;
+ }
+ initial() {
+ let sign = 1;
+ if (this.is('-') || this.is('+')) sign = this.take().kind === '-' ? -1 : 1;
+ return sign * this.expect('number', 'numeric state initializer').value;
+ }
+ node(token, properties, children = []) {
+ const depth = 1 + Math.max(0, ...children.map(child => child.depth));
+ if (depth > LIMITS.depth) fail(token, 'Expression nesting exceeds 64');
+ return { token, depth, ...properties };
+ }
+ expression(minimum = 0, multiline = false) {
+ if (++this.depth > LIMITS.depth) fail(this.peek(), 'Expression nesting exceeds 64');
+ if (multiline) this.newlines();
+ const token = this.take();
+ let left;
+ if (token.kind === 'number') left = this.node(token, { kind: 'constant', value: token.value });
+ else if (token.kind === '-' || token.kind === '!') {
+ const child = this.expression(7, multiline);
+ left = this.node(token, { kind: 'unary', op: token.kind === '-' ? 7 : 16, child }, [child]);
+ } else if (token.kind === '(') {
+ left = this.expression(0, true); this.newlines(); this.expect(')');
+ } else if (token.kind === 'name') {
+ if (Object.hasOwn(FUNCTIONS, token.value) && this.is('(')) {
+ this.take();
+ const args = [];
+ this.newlines();
+ if (!this.is(')')) {
+ for (;;) {
+ args.push(this.expression(0, true)); this.newlines();
+ if (!this.is(',')) break;
+ this.take();
+ }
+ }
+ this.expect(')');
+ if (args.length !== FUNCTIONS[token.value][0]) fail(token, `${token.value} expects ${FUNCTIONS[token.value][0]} arguments`);
+ left = this.node(token, { kind: 'call', op: FUNCTIONS[token.value][1], args }, args);
+ } else left = this.node(token, { kind: 'reference', name: this.reference(token) });
+ } else fail(token, 'Expected a number, state, or expression');
+ for (;;) {
+ if (multiline) this.newlines();
+ const entry = BINARY.get(this.peek().kind);
+ if (!entry || entry[0] < minimum) break;
+ const operator = this.take();
+ const right = this.expression(entry[0] + 1, multiline);
+ left = this.node(operator, { kind: 'binary', op: entry[1], left, right }, [left, right]);
+ }
+ this.depth--;
+ return left;
+ }
+ block(depth = 0) {
+ if (depth >= LIMITS.depth) fail(this.peek(), 'Handler nesting exceeds 64');
+ this.newlines(); this.expect('{'); this.separators();
+ const statements = [];
+ while (!this.is('}')) {
+ const token = this.expect('name', 'assignment, if, or closing brace');
+ if (token.value === 'if' && !this.is('=')) {
+ const condition = this.expression();
+ const body = this.block(depth + 1);
+ statements.push({ kind: 'if', token, condition, body });
+ } else {
+ const name = this.reference(token);
+ this.expect('=');
+ statements.push({ kind: 'assign', token, name, expression: this.expression() });
+ }
+ this.end();
+ }
+ this.take();
+ return statements;
+ }
+ parse() {
+ const declarations = [];
+ this.separators();
+ while (!this.is('eof')) {
+ const token = this.expect('name', 'declaration');
+ const kind = token.value;
+ if (kind === 'state') {
+ const name = this.expect('name', 'state name'); this.expect('=');
+ declarations.push({ kind, token, name, initial: this.initial() });
+ } else if (kind === 'record') {
+ const name = this.expect('name', 'record type'); this.newlines(); this.expect('{'); this.separators();
+ const fields = [];
+ while (!this.is('}')) {
+ const field = this.expect('name', 'field name'); this.expect('=');
+ fields.push({name:field, initial:this.initial()}); this.end();
+ }
+ this.take(); declarations.push({kind,token,name,fields});
+ } else if (kind === 'item') {
+ const name = this.expect('name', 'item name'); this.expect('=');
+ const type = this.expect('name', 'record type'); this.expect('('); this.newlines();
+ const fields = [];
+ while (!this.is(')')) {
+ const field = this.expect('name', 'field name'); this.expect('=');
+ fields.push({name:field, initial:this.initial()}); this.newlines();
+ if (!this.is(',')) break;
+ this.take(); this.newlines();
+ }
+ this.expect(')'); declarations.push({kind,token,name,type,fields});
+ } else if (kind === 'rule') {
+ const name = this.expect('name', 'rule name'); this.expect('(');
+ const parameter = this.expect('name', 'record parameter'); this.expect(':');
+ const type = this.expect('name', 'record type'); this.expect(')');
+ declarations.push({kind,token,name,parameter,type,body:this.block()});
+ } else if (kind === 'input') {
+ const name = this.expect('name', 'input name'); this.expect('=');
+ const key = this.expect('name', 'keyboard code or pointer(view)');
+ if (key.value === 'pointer') {
+ if (this.is('{')) {
+ this.take(); this.separators(); const routes = [];
+ while (!this.is('}')) {
+ const target = this.expect('name', 'view ID'); this.expect(':');
+ const item = this.expect('name', 'item name'); routes.push({target,item}); this.end();
+ }
+ this.take(); declarations.push({kind,token,name,routes}); this.end(); continue;
+ }
+ this.expect('(');
+ const target = this.expect('name', 'view ID'); this.expect(')');
+ declarations.push({ kind, token, name, target });
+ this.end(); continue;
+ }
+ if (!/^(?:Space|Enter|Arrow(?:Left|Right|Up|Down)|Key[A-Z]|Digit[0-9])$/.test(key.value)) fail(key, 'Unsupported keyboard code');
+ declarations.push({ kind, token, name, key });
+ } else if (Object.hasOwn(VIEW_FIELDS, kind)) {
+ const name = this.expect('name', 'view ID'); this.expect('(');
+ const expressions = [];
+ for (let i = 0; i < VIEW_FIELDS[kind].length; i++) {
+ if (i) { this.newlines(); this.expect(','); }
+ expressions.push(this.expression(0, true));
+ }
+ this.newlines();
+ let color, prefix = '';
+ if (kind !== 'text') {
+ this.expect(','); this.newlines();
+ const value = this.expect('string', 'literal hex color');
+ if (!/^#[0-9a-fA-F]{6}$/.test(value.value)) fail(value, 'Color must use six hexadecimal digits, such as "#356548"');
+ color = value.value;
+ } else if (this.is(',')) { this.take(); this.newlines(); prefix = this.expect('string', 'literal text prefix').value; }
+ this.newlines(); this.expect(')');
+ declarations.push({ kind: 'view', type: kind, token, name, expressions, color, prefix });
+ } else if (kind === 'on') {
+ const event = this.expect('name', 'input name or frame');
+ let edge;
+ if (event.value !== 'frame') {
+ this.expect('.'); edge = this.expect('name', 'down, up, move or cancel');
+ if (!['down', 'up', 'move', 'cancel'].includes(edge.value)) fail(edge, 'Expected down or up, move or cancel');
+ }
+ if (this.is('name', 'use')) {
+ this.take(); const rule = this.expect('name', 'rule name'); this.expect('(');
+ const item = this.expect('name', 'item name or input.target');
+ const argument = this.reference(item); this.expect(')');
+ declarations.push({kind:'handler',token,event,edge,rule,item,argument});
+ } else declarations.push({ kind: 'handler', token, event, edge, body: this.block() });
+ } else fail(token, `Unknown declaration ${kind}`);
+ this.end();
+ }
+ return declarations;
+ }
+}
+
+export function compile(source) {
+ if (typeof source !== 'string') fail({ line: 1, column: 1 }, 'Source must be a string');
+ const sourceBytes = new TextEncoder().encode(source).length;
+ if (sourceBytes > LIMITS.sourceBytes) fail({ line: 1, column: 1 }, 'Source exceeds 32768 UTF-8 bytes');
+ const declarations = new Parser(lex(source)).parse();
+ const program = { states: [], inputs: [], instructions: [], handlers: [], views: [], pointerTargets: [], metrics: {} };
+ const states = new Map(), inputs = new Map(), views = new Set(), keys = new Set(), targets = new Set();
+ const records = new Map(), items = new Map(), rules = new Map();
+ const scalarNames = new Set(declarations.filter(d => d.kind === 'state').map(d => d.name.value));
+ for (const declaration of declarations) {
+ if (!['record','rule'].includes(declaration.kind)) continue;
+ const {name,fields,kind} = declaration, symbols = kind === 'record' ? records : rules;
+ if (RESERVED.has(name.value)) fail(name, `Reserved name ${name.value}`);
+ if (symbols.has(name.value)) fail(name, `Duplicate ${kind} ${name.value}`);
+ if (kind === 'record') {
+ if (!fields.length || fields.length > 8) fail(name, 'A record needs 1..8 fields');
+ const used = new Set();
+ for (const field of fields) {
+ if (used.has(field.name.value)) fail(field.name, `Duplicate field ${field.name.value}`);
+ if (RESERVED.has(field.name.value)) fail(field.name, `Reserved name ${field.name.value}`);
+ used.add(field.name.value);
+ }
+ }
+ symbols.set(name.value, declaration);
+ }
+ let handlerCount = 0;
+ for (const declaration of declarations) {
+ const { kind, name, token } = declaration;
+ if (name && RESERVED.has(name.value)) fail(name, `Reserved name ${name.value}`);
+ if (kind === 'record' || kind === 'rule') continue;
+ if (kind === 'item') {
+ if (items.has(name.value) || scalarNames.has(name.value)) fail(name, `Duplicate state/item ${name.value}`);
+ const record = records.get(declaration.type.value);
+ if (!record) fail(declaration.type, `Unknown record ${declaration.type.value}`);
+ const overrides = new Map();
+ for (const field of declaration.fields) {
+ if (!record.fields.some(f => f.name.value === field.name.value)) fail(field.name, `Unknown field ${field.name.value}`);
+ if (overrides.has(field.name.value)) fail(field.name, `Duplicate field ${field.name.value}`);
+ overrides.set(field.name.value, field.initial);
+ }
+ const base = states.size;
+ for (const field of record.fields) {
+ if (states.size >= LIMITS.states) fail(name, 'At most 32 states are allowed, including item fields');
+ const qualified = `${name.value}.${field.name.value}`, index = states.size;
+ states.set(qualified,index);
+ program.states.push({name:qualified,initial:overrides.has(field.name.value) ? overrides.get(field.name.value) : field.initial,line:token.line});
+ }
+ items.set(name.value,{type:declaration.type.value,base,count:record.fields.length});
+ } else if (kind === 'state' || kind === 'input') {
+ const symbols = kind === 'state' ? states : inputs;
+ if (symbols.has(name.value)) fail(name, `Duplicate ${kind} ${name.value}`);
+ if (symbols.size >= LIMITS[`${kind}s`]) fail(name, `At most ${LIMITS[`${kind}s`]} ${kind}s are allowed`);
+ symbols.set(name.value, symbols.size);
+ if (kind === 'state') program.states.push({ name: name.value, initial: declaration.initial, line: token.line });
+ else if (declaration.routes) {
+ if (!declaration.routes.length) fail(name, 'A pointer group needs at least one target');
+ program.inputs.push({name:name.value, routes:[], line:token.line});
+ } else if (declaration.target) {
+ const target = declaration.target;
+ if (targets.has(target.value)) fail(target, `Duplicate pointer target ${target.value}`);
+ targets.add(target.value);
+ program.inputs.push({ name: name.value, target: target.value, line: token.line });
+ } else {
+ if (keys.has(declaration.key.value)) fail(declaration.key, `Duplicate keyboard code ${declaration.key.value}`);
+ keys.add(declaration.key.value);
+ program.inputs.push({ name: name.value, key: declaration.key.value, line: token.line });
+ }
+ } else if (kind === 'view') {
+ if (views.has(name.value)) fail(name, `Duplicate view ID ${name.value}`);
+ if (views.size >= LIMITS.views) fail(name, 'At most 24 views are allowed');
+ views.add(name.value);
+ } else if (++handlerCount > LIMITS.handlers) fail(token, 'At most 64 handlers are allowed');
+ }
+ for (const declaration of declarations) {
+ if (declaration.target && !views.has(declaration.target.value))
+ fail(declaration.target, `Unknown pointer target ${declaration.target.value}`);
+ }
+ const routedItems = new Set();
+ for (const declaration of declarations) {
+ if (!declaration.routes) continue;
+ const input = inputs.get(declaration.name.value), resolved = program.inputs[input];
+ let type;
+ for (const {target,item:token} of declaration.routes) {
+ if (!views.has(target.value)) fail(target, `Unknown pointer target ${target.value}`);
+ if (targets.has(target.value)) fail(target, `Duplicate pointer target ${target.value}`);
+ const item = items.get(token.value);
+ if (!item) fail(token, `Unknown item ${token.value}`);
+ if (type && type !== item.type) fail(token, 'Pointer group targets must share one record type');
+ if (routedItems.has(token.value)) fail(token, `Item ${token.value} already has a pointer route`);
+ targets.add(target.value); routedItems.add(token.value); type = item.type;
+ const route = program.pointerTargets.length;
+ if (route >= 24) fail(target, 'At most 24 pointer targets are allowed');
+ program.pointerTargets.push({input,base:item.base,count:item.count});
+ resolved.routes.push({target:target.value, item:token.value, route});
+ }
+ resolved.type = type;
+ }
+ for (const rule of rules.values()) {
+ if (!records.has(rule.type.value)) fail(rule.type, `Unknown record ${rule.type.value}`);
+ if (RESERVED.has(rule.parameter.value)) fail(rule.parameter, `Reserved name ${rule.parameter.value}`);
+ }
+ let pointHandler = false;
+ let parameters = null;
+ let parameterPrefix = null;
+ let stack = 0, maxStack = 0;
+ const emit = (op, token, arg = 0, value = 0, delta = 0) => {
+ if (program.instructions.length >= LIMITS.instructions) fail(token, 'At most 512 instructions are allowed');
+ stack += delta;
+ if (stack < 0 || stack > LIMITS.stack) fail(token, 'Expression stack exceeds 64');
+ maxStack = Math.max(maxStack, stack);
+ program.instructions.push({ op, arg, value, line: token.line });
+ };
+ const expression = (node, binding = false) => {
+ const { token } = node;
+ if (node.kind === 'constant') emit(0, token, 0, node.value, 1);
+ else if (node.kind === 'reference') {
+ if (node.name === 'event_x' || node.name === 'event_y') {
+ if (binding || !pointHandler) fail(token, 'event_x and event_y are only available in pointer handlers');
+ emit(node.name === 'event_x' ? 22 : 23, token, 0, 0, 1);
+ } else if (node.name === 'dt') {
+ if (binding) fail(token, 'dt is only available in handlers');
+ emit(2, token, 0, 0, 1);
+ } else {
+ if (parameterPrefix && node.name.startsWith(parameterPrefix) && !parameters.has(node.name))
+ fail(token, `Unknown parameter field ${node.name}`);
+ if (parameters?.has(node.name)) {
+ emit(24, token, parameters.get(node.name), 0, 1);
+ return;
+ }
+ if (!states.has(node.name)) fail(token, `Unknown state ${node.name}`);
+ emit(1, token, states.get(node.name), 0, 1);
+ }
+ } else if (node.kind === 'unary') { expression(node.child, binding); emit(node.op, token); }
+ else if (node.kind === 'binary') {
+ expression(node.left, binding); expression(node.right, binding); emit(node.op, token, 0, 0, -1);
+ } else {
+ for (const arg of node.args) expression(arg, binding);
+ emit(node.op, token, 0, 0, 1 - node.args.length);
+ }
+ };
+ const statements = body => {
+ for (const statement of body) {
+ if (statement.kind === 'assign') {
+ if (parameterPrefix && statement.name.startsWith(parameterPrefix) && !parameters.has(statement.name))
+ fail(statement.token, `Unknown parameter field ${statement.name}`);
+ const parameter = parameters?.get(statement.name);
+ if (parameter === undefined && !states.has(statement.name)) fail(statement.token, `Unknown state ${statement.name}`);
+ expression(statement.expression);
+ emit(parameter === undefined ? 20 : 25, statement.token, parameter === undefined ? states.get(statement.name) : parameter, 0, -1);
+ } else {
+ expression(statement.condition);
+ const branch = program.instructions.length;
+ emit(21, statement.token, 0, 0, -1);
+ statements(statement.body);
+ program.instructions[branch].arg = program.instructions.length;
+ }
+ }
+ };
+ const bodies = new Map();
+ function ruleBody(rule) {
+ if (bodies.has(rule.name.value)) return bodies.get(rule.name.value);
+ parameterPrefix = `${rule.parameter.value}.`;
+ parameters = new Map(records.get(rule.type.value).fields.map((field,index) => [`${rule.parameter.value}.${field.name.value}`,index]));
+ // A shared body may read coordinates; every binding is checked below.
+ pointHandler = true; stack = 0;
+ const start = program.instructions.length;
+ statements(rule.body);
+ const length = program.instructions.length - start;
+ const point = program.instructions.slice(start).some(op => op.op === 22 || op.op === 23);
+ parameters = null; parameterPrefix = null;
+ const body = {start,length,point}; bodies.set(rule.name.value,body); return body;
+ }
+ for (const declaration of declarations) {
+ if (declaration.kind === 'handler') {
+ let event = 0;
+ pointHandler = false;
+ if (declaration.event.value !== 'frame') {
+ if (!inputs.has(declaration.event.value)) fail(declaration.event, `Unknown input ${declaration.event.value}`);
+ const index = inputs.get(declaration.event.value), edge = declaration.edge.value;
+ pointHandler = Boolean(program.inputs[index].target || program.inputs[index].routes);
+ if (!pointHandler && (edge === 'move' || edge === 'cancel')) fail(declaration.edge, 'move and cancel require a pointer input');
+ event = edge === 'move' ? 17 + index : edge === 'cancel' ? 25 + index : 1 + index * 2 + (edge === 'up' ? 1 : 0);
+ }
+ if (declaration.rule) {
+ const rule = rules.get(declaration.rule.value);
+ const routed = declaration.argument.includes('.');
+ const input = program.inputs[inputs.get(declaration.event.value)];
+ if (routed && (!input?.routes || declaration.argument !== `${declaration.event.value}.target`))
+ fail(declaration.item, 'Use the current pointer group target');
+ const item = routed ? {type:input.type, count:records.get(input.type).fields.length} : items.get(declaration.item.value);
+ if (!rule) fail(declaration.rule, `Unknown rule ${declaration.rule.value}`);
+ if (!item) fail(declaration.item, `Unknown item ${declaration.item.value}`);
+ if (item.type !== rule.type.value) fail(declaration.item, `Rule ${rule.name.value} requires ${rule.type.value}`);
+ const pointer = pointHandler;
+ const body = ruleBody(rule);
+ if (body.point && !pointer) fail(declaration.rule, 'Coordinate-reading rules require a pointer input');
+ program.handlers.push({event,start:body.start,length:body.length,line:declaration.token.line,parameterBase:routed ? 255 : item.base,parameterCount:item.count});
+ } else {
+ const start = program.instructions.length;
+ stack = 0; statements(declaration.body);
+ program.handlers.push({ event, start, length: program.instructions.length - start, line: declaration.token.line });
+ }
+ } else if (declaration.kind === 'view') {
+ const view = { id: declaration.name.value, type: declaration.type, line: declaration.token.line, bindings: {} };
+ if (view.type === 'text') view.prefix = declaration.prefix;
+ else view.color = declaration.color;
+ declaration.expressions.forEach((node, index) => {
+ const start = program.instructions.length;
+ stack = 0; expression(node, true);
+ view.bindings[VIEW_FIELDS[view.type][index]] = { start, length: program.instructions.length - start };
+ });
+ program.views.push(view);
+ }
+ }
+ // Unused definitions still receive name/type checking, but reserve no VM body.
+ for (const rule of rules.values()) {
+ if (bodies.has(rule.name.value)) continue;
+ const instructions = program.instructions, previousMax = maxStack;
+ program.instructions = [];
+ ruleBody(rule);
+ program.instructions = instructions; maxStack = previousMax;
+ }
+ program.metrics = { sourceBytes, sourceLines: source === '' ? 0 : source.split('\n').length,
+ states: states.size, inputs: inputs.size, views: views.size,
+ handlers: program.handlers.length, instructions: program.instructions.length, maxStack };
+ return program;
+}
+
+export { LIMITS };
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/before/core.wasm b/prototypes/workbench/results/2026-09-23-web-layout/before/core.wasm
new file mode 100755
index 0000000..117f25d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/before/core.wasm
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/before/core.zig b/prototypes/workbench/results/2026-09-23-web-layout/before/core.zig
new file mode 100644
index 0000000..a68f483
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/before/core.zig
@@ -0,0 +1,1398 @@
+//! Bounded scalar interaction VM experiment. No application behavior is built in.
+//! Same source for native tests and freestanding Wasm; no allocation or imports.
+//! Error ABI: 0 ok, 1 capacity, 2 invalid, 3 stack, 4 division by zero,
+//! 5 non-finite, 6 bad branch, 7 bad state, 8 bad frame, 9 bad event.
+//! Counters wrap modulo 2^32. Failed loads preserve the active program, authored
+//! state and event diagnostics; beginning a load discards optional reuse records.
+//! Failed events preserve values and counters but
+//! report their error and attempted instruction count. Binding evaluation is pure.
+
+const std = @import("std");
+const Evaluation = @import("evaluation.zig").Evaluation;
+const Tables = @import("evaluation.zig").Tables;
+
+const max_states = 32;
+const max_inputs = 8;
+const max_instructions = 512;
+const max_handlers = 64;
+const max_stack = 64;
+const max_pointer_targets = 24;
+const event_target = 255;
+const no_pc = std.math.maxInt(u32);
+const frame_dt: f64 = 1.0 / 60.0;
+
+const Code = enum(u32) {
+ ok = 0,
+ capacity = 1,
+ invalid = 2,
+ stack = 3,
+ division_by_zero = 4,
+ non_finite = 5,
+ bad_branch = 6,
+ bad_state = 7,
+ bad_frame = 8,
+ bad_event = 9,
+ work_limit = 10,
+};
+
+const Instruction = struct { value: f64 = 0, op: u32 = 0, arg: u32 = 0 };
+const Handler = struct {
+ event: u32 = 0, start: u32 = 0, length: u32 = 0,
+ parameter_base: u8 = 0, parameter_count: u8 = 0,
+};
+const PointerTarget = struct { input: u8, base: u8, count: u8 };
+const Program = struct {
+ pointer_targets: [max_pointer_targets]PointerTarget = undefined,
+ pointer_target_count: u8 = 0,
+ routed_inputs: u8 = 0,
+ instructions: [max_instructions]Instruction = @splat(.{}),
+ handlers: [max_handlers]Handler = @splat(.{}),
+ initials: [max_states]f64 = @splat(0),
+ old_ids: [max_states]i32 = @splat(-1),
+ declared: [max_states]bool = @splat(false),
+ state_count: u32 = 0,
+ input_count: u32 = 0,
+ instruction_count: u32 = 0,
+ handler_count: u32 = 0,
+};
+// Loading a definition/optional restored values and result reuse are mutually exclusive phases. Current execution
+// always reads active, and can recompute bindings while this workspace loads.
+const Pending = struct {
+ program: Program = .{},
+ values: [max_states]f64 = @splat(0),
+ value_mask: u32 = 0,
+};
+const Workspace = union { staging: Pending, reuse: Tables };
+const Engine = struct {
+ active: Program = .{},
+ workspace: Workspace = .{ .reuse = .{} },
+ values: [max_states]f64 = @splat(0),
+ staging_open: bool = false,
+ staging_error: Code = .ok,
+ tick: u32 = 0,
+ last_error: Code = .ok,
+ error_pc: u32 = no_pc,
+ last_ops: u32 = 0,
+ revision: u32 = 0,
+ successful_events: u32 = 0,
+ evaluation: Evaluation = .{},
+};
+var engine: Engine = .{};
+
+fn reuseTables() ?*Tables {
+ return if (engine.staging_open) null else &engine.workspace.reuse;
+}
+
+fn finishStaging() void {
+ engine.workspace = .{ .reuse = .{} };
+ engine.staging_open = false;
+ engine.evaluation.forget(null, 3);
+}
+
+fn public(code: Code) u32 {
+ return @intFromEnum(code);
+}
+
+fn stagingFail(code: Code) u32 {
+ if (engine.staging_error == .ok) engine.staging_error = code;
+ return public(code);
+}
+
+export fn reset_all() void {
+ engine = .{};
+}
+
+export fn begin_program(state_count: u32, input_count: u32) u32 {
+ engine.staging_error = .ok;
+ if (state_count > max_states or input_count > max_inputs) {
+ if (engine.staging_open) finishStaging();
+ return stagingFail(.capacity);
+ }
+ engine.workspace = .{ .staging = .{ .program = .{ .state_count = state_count, .input_count = input_count } } };
+ engine.staging_open = true;
+ engine.evaluation.forget(null, 3);
+ return 0;
+}
+
+export fn abort_program() void {
+ if (engine.staging_open) finishStaging();
+}
+
+export fn put_state(id: u32, initial: f64, old_id: i32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ if (id >= engine.workspace.staging.program.state_count or old_id < -1) return stagingFail(.bad_state);
+ if (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count) return stagingFail(.bad_state);
+ if (engine.workspace.staging.program.declared[id]) return stagingFail(.bad_state);
+ if (!std.math.isFinite(initial)) return stagingFail(.non_finite);
+ engine.workspace.staging.program.initials[id] = initial;
+ engine.workspace.staging.program.old_ids[id] = old_id;
+ engine.workspace.staging.program.declared[id] = true;
+ return 0;
+}
+
+export fn put_instruction(op: u32, arg: u32, value: f64) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (p.instruction_count == max_instructions) return stagingFail(.capacity);
+ if (op > 25) return stagingFail(.invalid);
+ if (!std.math.isFinite(value)) return stagingFail(.non_finite);
+ p.instructions[p.instruction_count] = .{ .op = op, .arg = arg, .value = value };
+ p.instruction_count += 1;
+ return 0;
+}
+
+fn validEvent(event: u32, inputs: u32) bool {
+ return event <= inputs * 2 or
+ (event >= 17 and event - 17 < inputs) or
+ (event >= 25 and event - 25 < inputs);
+}
+
+export fn put_handler(event: u32, start: u32, length: u32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (p.handler_count == max_handlers) return stagingFail(.capacity);
+ if (!validEvent(event, p.input_count)) return stagingFail(.bad_event);
+ // Handlers may be appended before their instructions, so range validation
+ // is deferred until commit. Arithmetic remains guarded there.
+ p.handlers[p.handler_count] = .{ .event = event, .start = start, .length = length };
+ p.handler_count += 1;
+ return 0;
+}
+
+export fn bind_handler(handler: u32, base: u32, count: u32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (handler >= p.handler_count or base >= p.state_count) return stagingFail(.bad_state);
+ if (count == 0 or count > 8 or count > p.state_count - base) return stagingFail(.bad_state);
+ const h = &p.handlers[handler];
+ if (h.parameter_count != 0) return stagingFail(.invalid);
+ h.parameter_base = @intCast(base);
+ h.parameter_count = @intCast(count);
+ return 0;
+}
+
+// A route is a static program-local index, never a persistent object identity.
+export fn put_pointer_target(input: u32, base: u32, count: u32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (p.pointer_target_count == max_pointer_targets) return stagingFail(.capacity);
+ if (input >= p.input_count) return stagingFail(.bad_event);
+ if (base >= p.state_count or count == 0 or count > 8 or count > p.state_count - base) return stagingFail(.bad_state);
+ for (p.pointer_targets[0..p.pointer_target_count]) |t| {
+ if (base < @as(u32, t.base) + t.count and t.base < base + count) return stagingFail(.invalid);
+ if (t.input == input and t.count != count) return stagingFail(.bad_state);
+ }
+ p.pointer_targets[p.pointer_target_count] = .{ .input = @intCast(input), .base = @intCast(base), .count = @intCast(count) };
+ p.pointer_target_count += 1;
+ p.routed_inputs |= @as(u8, 1) << @as(u3, @intCast(input));
+ return 0;
+}
+
+export fn bind_target_handler(handler: u32, count: u32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (handler >= p.handler_count or count == 0 or count > 8) return stagingFail(.bad_state);
+ const h = &p.handlers[handler];
+ if (h.parameter_count != 0) return stagingFail(.invalid);
+ h.parameter_base = event_target;
+ h.parameter_count = @intCast(count);
+ return 0;
+}
+
+fn eventInput(event: u32) u32 {
+ return if (event >= 25) event - 25 else if (event >= 17) event - 17 else (event - 1) / 2;
+}
+
+fn validate(p: *const Program) Code {
+ for (0..p.state_count) |id| {
+ if (!p.declared[id]) return .bad_state;
+ if (!std.math.isFinite(p.initials[id])) return .non_finite;
+ const old_id = p.old_ids[id];
+ if (old_id < -1 or (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count)) return .bad_state;
+ }
+ for (p.handlers[0..p.handler_count], 0..) |h, i| {
+ if (!validEvent(h.event, p.input_count)) return .bad_event;
+ if (h.start > p.instruction_count or h.length > p.instruction_count - h.start) return .invalid;
+ if (h.parameter_base == event_target) {
+ if (h.event == 0 or h.parameter_count == 0 or h.parameter_count > 8) return .bad_event;
+ var found = false;
+ for (p.pointer_targets[0..p.pointer_target_count]) |t| {
+ if (t.input != eventInput(h.event)) continue;
+ if (t.count != h.parameter_count) return .bad_state;
+ found = true;
+ }
+ if (!found) return .bad_event;
+ } else if (h.parameter_count > 8 or @as(u32, h.parameter_base) + h.parameter_count > p.state_count) return .bad_state;
+ if (h.length == 0) continue;
+ for (p.handlers[0..i]) |other| {
+ if (other.length != 0 and h.start < other.start + other.length and other.start < h.start + h.length) {
+ // Bound rules may share exactly one body, never a partial range.
+ if (h.parameter_count == 0 or h.parameter_count != other.parameter_count or
+ h.start != other.start or h.length != other.length) return .invalid;
+ }
+ }
+ }
+ for (p.instructions[0..p.instruction_count], 0..) |inst, pc| {
+ if (inst.op > 25) return .invalid;
+ if (!std.math.isFinite(inst.value)) return .non_finite;
+ if ((inst.op == 1 or inst.op == 20) and inst.arg >= p.state_count) return .bad_state;
+ if (inst.op >= 20) {
+ var owner: ?Handler = null;
+ for (p.handlers[0..p.handler_count]) |h| {
+ if (pc >= h.start and pc < h.start + h.length) {
+ owner = h;
+ if (inst.op == 21 and (inst.arg <= pc or inst.arg > h.start + h.length)) return .bad_branch;
+ if ((inst.op == 24 or inst.op == 25) and inst.arg >= h.parameter_count) return .bad_state;
+ }
+ }
+ if (owner == null) return .invalid;
+ }
+ }
+ return .ok;
+}
+
+export fn put_restored_value(id: u32, value: f64) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const pending = &engine.workspace.staging;
+ if (id >= pending.program.state_count) return stagingFail(.bad_state);
+ const bit = @as(u32, 1) << @as(u5, @intCast(id));
+ if (pending.value_mask & bit != 0) return stagingFail(.bad_state);
+ if (!std.math.isFinite(value)) return stagingFail(.non_finite);
+ pending.values[id] = value;
+ pending.value_mask |= bit;
+ return 0;
+}
+
+export fn commit_program() u32 {
+ return commit(false);
+}
+
+export fn commit_restored_program() u32 {
+ return commit(true);
+}
+
+fn commit(restoring: bool) u32 {
+ if (!engine.staging_open) return public(.invalid);
+ if (engine.staging_error != .ok) return public(engine.staging_error);
+ const count = engine.workspace.staging.program.state_count;
+ const expected: u32 = if (count == 32) 0xffffffff else (@as(u32, 1) << @as(u5, @intCast(count))) - 1;
+ if (engine.workspace.staging.value_mask != (if (restoring) expected else @as(u32, 0)))
+ return stagingFail(.bad_state);
+ const code = validate(&engine.workspace.staging.program);
+ if (code != .ok) return stagingFail(code);
+ var next: [max_states]f64 = @splat(0);
+ for (0..engine.workspace.staging.program.state_count) |i| {
+ const old_id = engine.workspace.staging.program.old_ids[i];
+ next[i] = if (restoring) engine.workspace.staging.values[i] else if (old_id < 0) engine.workspace.staging.program.initials[i] else engine.values[@intCast(old_id)];
+ }
+ engine.active = engine.workspace.staging.program;
+ engine.values = next;
+ finishStaging();
+ if (restoring) {
+ engine.tick = 0;
+ engine.successful_events = 0;
+ }
+ engine.revision +%= 1;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ return 0;
+}
+
+export fn reset_state() void {
+ engine.values = engine.active.initials;
+ engine.evaluation.forget(reuseTables(), 3);
+ engine.tick = 0;
+ engine.successful_events = 0;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+}
+
+const Execution = struct {
+ code: Code = .ok,
+ pc: u32 = no_pc,
+ ops: u32 = 0,
+ value: f64 = 0,
+ dependencies: u32 = 0,
+};
+
+fn executionFail(result: Execution, code: Code, pc: u32) Execution {
+ var failed = result;
+ failed.code = code;
+ failed.pc = pc;
+ return failed;
+}
+
+const EventContext = struct { dt: f64 = 0, x: f64 = 0, y: f64 = 0, parameter_base: u32 = 0, parameter_count: u32 = 0, point: bool = false, target: ?u32 = null };
+
+fn execute(values: *[max_states]f64, start: u32, length: u32, context: EventContext, pure: bool, budget: ?*Evaluation) Execution {
+ const p = &engine.active;
+ var result: Execution = .{};
+ if (start > p.instruction_count or length > p.instruction_count - start) return executionFail(result, .invalid, no_pc);
+ const end = start + length;
+ var stack: [max_stack]f64 = undefined;
+ var sp: usize = 0;
+ var pc = start;
+ while (pc < end) {
+ if (budget) |b| {
+ if (!b.spend()) return executionFail(result, .work_limit, pc);
+ b.instructions += 1;
+ }
+ const inst = p.instructions[pc];
+ result.ops += 1;
+ if (pure and (inst.op == 2 or inst.op >= 20)) return executionFail(result, .invalid, pc);
+ const need: usize = switch (inst.op) {
+ 0...2, 22, 23, 24 => 0,
+ 7, 16, 20, 21, 25 => 1,
+ 3...6, 8...15, 17, 18 => 2,
+ 19 => 3,
+ else => return executionFail(result, .invalid, pc),
+ };
+ if (sp < need) return executionFail(result, .stack, pc);
+ if (inst.op <= 2 or inst.op == 22 or inst.op == 23 or inst.op == 24) {
+ if (sp == max_stack) return executionFail(result, .stack, pc);
+ const value = switch (inst.op) {
+ 0 => inst.value,
+ 1 => blk: {
+ if (inst.arg >= p.state_count) return executionFail(result, .bad_state, pc);
+ if (budget != null and budget.?.cache_count != 0)
+ result.dependencies |= @as(u32, 1) << @as(u5, @intCast(inst.arg));
+ break :blk values[inst.arg];
+ },
+ 24 => blk: {
+ if (inst.arg >= context.parameter_count) return executionFail(result, .bad_state, pc);
+ const state = context.parameter_base + inst.arg;
+ if (state >= p.state_count) return executionFail(result, .bad_state, pc);
+ break :blk values[state];
+ },
+ 2 => context.dt,
+ 22, 23 => if (!context.point) return executionFail(result, .bad_event, pc) else if (inst.op == 22) context.x else context.y,
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ stack[sp] = value;
+ sp += 1;
+ } else if (inst.op == 20 or inst.op == 25) {
+ if (inst.op == 25 and inst.arg >= context.parameter_count) return executionFail(result, .bad_state, pc);
+ const state = if (inst.op == 25) context.parameter_base + inst.arg else inst.arg;
+ if (state >= p.state_count) return executionFail(result, .bad_state, pc);
+ sp -= 1;
+ values[state] = stack[sp];
+ } else if (inst.op == 21) {
+ if (inst.arg <= pc or inst.arg > end) return executionFail(result, .bad_branch, pc);
+ sp -= 1;
+ if (stack[sp] == 0) {
+ pc = inst.arg;
+ continue;
+ }
+ } else {
+ const a = stack[sp - need];
+ const b = if (need >= 2) stack[sp - need + 1] else 0;
+ const value: f64 = switch (inst.op) {
+ 3 => a + b,
+ 4 => a - b,
+ 5 => a * b,
+ 6 => blk: {
+ if (b == 0) return executionFail(result, .division_by_zero, pc);
+ break :blk a / b;
+ },
+ 7 => -a,
+ 8 => @floatFromInt(@intFromBool(a < b)),
+ 9 => @floatFromInt(@intFromBool(a <= b)),
+ 10 => @floatFromInt(@intFromBool(a > b)),
+ 11 => @floatFromInt(@intFromBool(a >= b)),
+ 12 => @floatFromInt(@intFromBool(a == b)),
+ 13 => @floatFromInt(@intFromBool(a != b)),
+ 14 => @floatFromInt(@intFromBool(a != 0 and b != 0)),
+ 15 => @floatFromInt(@intFromBool(a != 0 or b != 0)),
+ 16 => @floatFromInt(@intFromBool(a == 0)),
+ 17 => @min(a, b),
+ 18 => @max(a, b),
+ 19 => blk: {
+ const hi = stack[sp - 1];
+ if (b > hi) return executionFail(result, .invalid, pc);
+ break :blk @max(b, @min(a, hi));
+ },
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ sp = sp - need + 1;
+ stack[sp - 1] = value;
+ }
+ pc += 1;
+ }
+ if (sp != @as(usize, if (pure) 1 else 0)) return executionFail(result, .stack, end);
+ if (pure) result.value = stack[0];
+ return result;
+}
+
+export fn dispatch(event: u32, dt: f64) u32 {
+ return dispatchContext(event, .{ .dt = dt });
+}
+
+export fn dispatch_point(event: u32, x: f64, y: f64) u32 {
+ return dispatchContext(event, .{ .x = x, .y = y, .point = true });
+}
+
+export fn dispatch_target(event: u32, target: u32, x: f64, y: f64) u32 {
+ return dispatchContext(event, .{ .x = x, .y = y, .point = true, .target = target });
+}
+
+fn dispatchContext(event: u32, context: EventContext) u32 {
+ const dt = context.dt;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ if (!validEvent(event, engine.active.input_count) or
+ (context.point and event == 0) or (!context.point and event > engine.active.input_count * 2)) {
+ engine.last_error = .bad_event;
+ return public(.bad_event);
+ }
+ if (!std.math.isFinite(dt) or !std.math.isFinite(context.x) or !std.math.isFinite(context.y)) {
+ engine.last_error = .non_finite;
+ return public(.non_finite);
+ }
+ if ((event == 0 and dt != frame_dt) or (event != 0 and dt != 0)) {
+ engine.last_error = .bad_frame;
+ return public(.bad_frame);
+ }
+ var route: ?PointerTarget = null;
+ const routed = event != 0 and engine.active.routed_inputs & (@as(u8, 1) << @as(u3, @intCast(eventInput(event)))) != 0;
+ if (context.target) |target| {
+ if (!routed or target >= engine.active.pointer_target_count or
+ engine.active.pointer_targets[target].input != eventInput(event)) {
+ engine.last_error = .bad_event;
+ return public(.bad_event);
+ }
+ route = engine.active.pointer_targets[target];
+ } else if (routed) {
+ engine.last_error = .bad_event;
+ return public(.bad_event);
+ }
+ var pending = engine.values;
+ for (engine.active.handlers[0..engine.active.handler_count]) |h| {
+ if (h.event != event) continue;
+ var bound = context;
+ bound.parameter_base = if (h.parameter_base == event_target) route.?.base else h.parameter_base;
+ bound.parameter_count = h.parameter_count;
+ const result = execute(&pending, h.start, h.length, bound, false, null);
+ engine.last_ops += result.ops;
+ if (result.code != .ok) {
+ engine.last_error = result.code;
+ engine.error_pc = result.pc;
+ return public(result.code);
+ }
+ }
+ if (engine.evaluation.cache_count != 0 and !engine.staging_open) {
+ var changed: u32 = 0;
+ for (0..engine.active.state_count) |i| {
+ // Signed zero is observable, so compare representation, not ==.
+ if (@as(u64, @bitCast(pending[i])) != @as(u64, @bitCast(engine.values[i])))
+ changed |= @as(u32, 1) << @as(u5, @intCast(i));
+ }
+ engine.evaluation.changed(reuseTables(), changed);
+ }
+ engine.values = pending;
+ if (event == 0) engine.tick +%= 1;
+ engine.successful_events +%= 1;
+ return 0;
+}
+
+export fn eval_at(start: u32, length: u32) f64 {
+ // A local copy makes the no-side-effects boundary structural as well as
+ // checked by the pure instruction filter. Diagnostics are never touched.
+ var values = engine.values;
+ const result = execute(&values, start, length, .{}, true, null);
+ return if (result.code == .ok) result.value else std.math.nan(f64);
+}
+
+export fn configure_evaluation(cache: u32, history: u32) u32 {
+ return if (engine.evaluation.configure(reuseTables(), cache, history)) 0 else public(.capacity);
+}
+
+export fn forget_evaluation(flags: u32) void {
+ engine.evaluation.forget(reuseTables(), flags);
+}
+
+export fn begin_evaluation(limit: u32) void {
+ engine.evaluation.begin(limit);
+}
+
+export fn eval_bounded(start: u32, length: u32) f64 {
+ const e = &engine.evaluation;
+ e.status = 0;
+ // Charge per request as well as per instruction and validation probe.
+ if (!e.spend()) return std.math.nan(f64);
+ if (e.lookup(reuseTables(), start, length)) |value| return value;
+ if (e.status != 0) return std.math.nan(f64);
+ var values = engine.values;
+ const result = execute(&values, start, length, .{}, true, e);
+ e.status = public(result.code);
+ if (result.code != .ok) return std.math.nan(f64);
+ e.remember(reuseTables(), start, length, result.value, result.dependencies);
+ return result.value;
+}
+
+export fn evaluation_info(id: u32) u32 {
+ const e = &engine.evaluation;
+ return switch (id) {
+ 0 => e.status,
+ 1 => e.work,
+ 2 => e.instructions,
+ 3 => e.hits,
+ 4 => e.cache_count,
+ 5 => e.history_count,
+ 6 => @sizeOf(Evaluation) + @sizeOf(Tables),
+ 7 => @sizeOf(Evaluation),
+ 8 => @sizeOf(Tables),
+ 9 => @intFromBool(!engine.staging_open),
+ 10 => @sizeOf(Workspace) - @sizeOf(Pending),
+ else => 0,
+ };
+}
+
+export fn get_state(id: u32) f64 {
+ return if (id < engine.active.state_count) engine.values[id] else std.math.nan(f64);
+}
+
+export fn info(id: u32) u32 {
+ return switch (id) {
+ 0 => engine.tick,
+ 1 => public(engine.last_error),
+ 2 => engine.error_pc,
+ 3 => engine.last_ops,
+ 4 => engine.revision,
+ 5 => engine.active.state_count,
+ 6 => engine.active.instruction_count,
+ 7 => engine.active.handler_count,
+ 8 => engine.active.input_count,
+ 9 => engine.successful_events,
+ else => 0,
+ };
+}
+
+export fn layout(id: u32) u32 {
+ return switch (id) {
+ 0 => @sizeOf(Engine),
+ 1 => @sizeOf(Program),
+ 2 => @sizeOf(Instruction),
+ 3 => @sizeOf(Handler),
+ 4 => engine.active.instruction_count * @sizeOf(Instruction) + engine.active.handler_count * @sizeOf(Handler) + @as(u32, engine.active.pointer_target_count) * @sizeOf(PointerTarget),
+ 5 => engine.active.state_count * @sizeOf(f64),
+ 6 => @sizeOf(Pending),
+ 7 => max_stack * @sizeOf(f64),
+ 8 => @sizeOf(PointerTarget),
+ 9 => engine.active.pointer_target_count,
+ else => 0,
+ };
+}
+
+// Test helpers describe programs directly; the product compiler is separately
+// tested against this ABI. These tests exercise semantics and failure boundaries.
+fn ok(code: u32) !void {
+ try std.testing.expectEqual(@as(u32, 0), code);
+}
+
+fn emit(op: u32, arg: u32, value: f64) !void {
+ try ok(put_instruction(op, arg, value));
+}
+
+fn counterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try ok(commit_program());
+}
+
+test "blank program, no hidden application behavior, and reset" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 1), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(3));
+ try std.testing.expectEqual(@as(u32, 0), info(5));
+ try std.testing.expect(std.math.isNan(get_state(0)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 0)));
+ reset_state();
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 1), info(4));
+}
+
+test "arbitrary input counter and unused events" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(dispatch(2, 0));
+ try ok(dispatch(0, frame_dt));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(3));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(6));
+}
+
+test "charge and release behavior exists only in the authored instruction program" {
+ reset_all();
+ try ok(begin_program(3, 1)); // held, charge, released_value
+ for (0..3) |i| try ok(put_state(@intCast(i), 0, -1));
+ try emit(0, 0, 1);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 2));
+ try emit(1, 1, 0);
+ try emit(1, 0, 0);
+ try emit(2, 0, 0);
+ try emit(5, 0, 0);
+ try emit(3, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(0, 2, 6));
+ try emit(1, 1, 0);
+ try emit(20, 2, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(2, 8, 6));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ try ok(dispatch(1, 0));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try ok(dispatch(2, 0));
+ try std.testing.expectApproxEqAbs(@as(f64, 0.5), get_state(2), 1e-12);
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+}
+
+test "forward condition executes once and assignments and handlers are ordered" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 1, -1));
+ try ok(put_state(1, 0, -1));
+ try emit(1, 0, 0);
+ try emit(21, 6, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 7);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 0, 6));
+ try emit(1, 1, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 6, 4));
+ try ok(commit_program());
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 14), get_state(1));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 28), get_state(1));
+ try std.testing.expectEqual(@as(u32, 6), info(3));
+}
+
+test "branch to program end is valid" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, 2, 0);
+ try ok(put_handler(0, 0, 2));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+}
+
+test "one event rolls back writes from all handlers on arithmetic failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 8, -1));
+ try emit(0, 0, 99);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 2));
+ try emit(0, 0, 1);
+ try emit(0, 0, 0);
+ try emit(6, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 2, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 8), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(2));
+ try std.testing.expectEqual(@as(u32, 5), info(3));
+}
+
+test "nonfinite arithmetic and invalid clamp fail atomically" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, -1));
+ try emit(0, 0, 1e308);
+ try emit(0, 0, 1e308);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, 0));
+ try emit(0, 0, 1);
+ try emit(0, 0, 10);
+ try emit(0, 0, 2);
+ try emit(19, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 5));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.invalid), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+}
+
+test "stack underflow overflow and leftover values reject the event" {
+ for (0..3) |mode| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ if (mode == 0) {
+ try emit(3, 0, 0);
+ } else {
+ for (0..@as(usize, if (mode == 1) 65 else 1)) |_| try emit(0, 0, 7);
+ }
+ try ok(put_handler(0, 0, engine.workspace.staging.program.instruction_count));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.stack), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ }
+}
+
+test "failed candidates leave active values program revision and diagnostics unchanged" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ const old_revision = info(4);
+ const old_ops = info(3);
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(1, 5, 0);
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try std.testing.expectEqual(old_revision, info(4));
+ try std.testing.expectEqual(old_ops, info(3));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.invalid), put_instruction(100, 0, 0));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+}
+
+test "state preservation mapping reads active values at commit and reset uses new initials" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(2, 0));
+ try ok(put_state(0, 42, -1)); // new symbol first
+ try ok(put_state(1, 100, 0)); // old symbol moves from id 0 to 1
+ try ok(dispatch(1, 0)); // changed after staging began
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 42), get_state(0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 100), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(4));
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try std.testing.expect(std.math.isNan(get_state(0)));
+}
+
+test "capacity boundaries and sticky load errors" {
+ reset_all();
+ try std.testing.expectEqual(public(.capacity), begin_program(33, 0));
+ try std.testing.expectEqual(public(.capacity), begin_program(0, 9));
+ try ok(begin_program(32, 8));
+ for (0..32) |i| try ok(put_state(@intCast(i), @floatFromInt(i), -1));
+ for (0..512) |_| try emit(0, 0, 0);
+ for (0..64) |_| try ok(put_handler(16, 0, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 512), info(6));
+ try std.testing.expectEqual(@as(u32, 64), info(7));
+ try std.testing.expectEqual(@as(f64, 31), get_state(31));
+ try ok(begin_program(0, 0));
+ for (0..512) |_| try emit(0, 0, 0);
+ try std.testing.expectEqual(public(.capacity), put_instruction(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+ try ok(begin_program(0, 0));
+ for (0..64) |_| try ok(put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+}
+
+test "invalid declaration values state mappings event ids and handler ranges" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 0, 0));
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.non_finite), put_state(0, std.math.inf(f64), -1));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 1, -1));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.non_finite), put_instruction(0, 0, std.math.nan(f64)));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.bad_event), put_handler(1, 0, 0));
+ try ok(begin_program(0, 0));
+ try ok(put_handler(0, no_pc, 5));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 1);
+ try ok(put_handler(0, 0, 1));
+ try ok(put_handler(0, 0, 1));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "backward self out-of-handler branches and stores without handlers are rejected" {
+ for ([_]u32{ 0, 1, 3 }) |target| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, target, 0);
+ try emit(0, 0, 7);
+ try ok(put_handler(0, 0, 2));
+ try std.testing.expectEqual(public(.bad_branch), commit_program());
+ }
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try emit(0, 0, 5);
+ try emit(20, 0, 0);
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "event inputs and dt enforce deterministic frame contract" {
+ reset_all();
+ try counterProgram();
+ try std.testing.expectEqual(public(.bad_event), dispatch(3, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch(no_pc, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(1, frame_dt));
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, std.math.nan(f64)));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(u32, 0), info(1));
+ try std.testing.expectEqual(no_pc, info(2));
+}
+
+test "binding evaluation is pure on both success and failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 4, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try emit(2, 0, 0);
+ try emit(0, 0, 0);
+ try emit(21, 7, 0);
+ try ok(put_handler(0, 3, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ const before = engine;
+ try std.testing.expectEqual(@as(f64, 12), eval_at(0, 3));
+ try std.testing.expect(std.math.isNan(eval_at(0, 4))); // attempted store
+ try std.testing.expect(std.math.isNan(eval_at(4, 1))); // dt forbidden in views
+ try std.testing.expect(std.math.isNan(eval_at(5, 2))); // branch forbidden
+ try std.testing.expect(std.math.isNan(eval_at(no_pc, 7)));
+ try std.testing.expect(std.math.isNan(eval_at(0, no_pc)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 2))); // two results
+ // Compare only the live member of the untagged workspace, never padding
+ // or the inactive member's indeterminate storage.
+ try std.testing.expectEqual(before.staging_open, engine.staging_open);
+ inline for (std.meta.fields(Engine)) |field| {
+ if (comptime std.mem.eql(u8, field.name, "workspace")) {
+ if (before.staging_open) {
+ try std.testing.expectEqualDeep(before.workspace.staging, engine.workspace.staging);
+ } else {
+ try std.testing.expectEqualDeep(before.workspace.reuse, engine.workspace.reuse);
+ }
+ } else try std.testing.expectEqualDeep(@field(before, field.name), @field(engine, field.name));
+ }
+}
+
+test "arithmetic comparison logical min max and clamp expression semantics" {
+ const cases = [_]struct { op: u32, a: f64, b: f64, want: f64 }{
+ .{ .op = 3, .a = 7, .b = 2, .want = 9 },
+ .{ .op = 4, .a = 7, .b = 2, .want = 5 },
+ .{ .op = 5, .a = 7, .b = 2, .want = 14 },
+ .{ .op = 6, .a = 7, .b = 2, .want = 3.5 },
+ .{ .op = 8, .a = 2, .b = 7, .want = 1 },
+ .{ .op = 9, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 10, .a = 2, .b = 7, .want = 0 },
+ .{ .op = 11, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 12, .a = 7, .b = 2, .want = 0 },
+ .{ .op = 13, .a = 7, .b = 2, .want = 1 },
+ .{ .op = 14, .a = -7, .b = 2, .want = 1 },
+ .{ .op = 15, .a = 0, .b = 2, .want = 1 },
+ .{ .op = 17, .a = 7, .b = 2, .want = 2 },
+ .{ .op = 18, .a = 7, .b = 2, .want = 7 },
+ };
+ for (cases) |case| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, case.a);
+ try emit(0, 0, case.b);
+ try emit(case.op, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(case.want, eval_at(0, 3));
+ }
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, -8);
+ try emit(7, 0, 0);
+ try emit(0, 0, 0);
+ try emit(0, 0, 5);
+ try emit(19, 0, 0);
+ try emit(16, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 5), eval_at(0, 5));
+ try std.testing.expectEqual(@as(f64, 0), eval_at(0, 6));
+}
+
+test "diagnostic counters wrap without affecting scalar values" {
+ reset_all();
+ try counterProgram();
+ engine.tick = no_pc;
+ engine.successful_events = no_pc;
+ engine.revision = no_pc;
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 99, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 0), info(4));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+}
+
+test "memory accounting separates reserved storage used IR and stack capacity" {
+ reset_all();
+ try std.testing.expectEqual(@as(u32, 0), layout(4));
+ try std.testing.expectEqual(@as(u32, 0), layout(5));
+ try counterProgram();
+ try std.testing.expectEqual(@as(u32, @sizeOf(Engine)), layout(0));
+ try std.testing.expectEqual(@as(u32, 16), layout(2));
+ try std.testing.expectEqual(@as(u32, 16), layout(3));
+ try std.testing.expectEqual(@as(u32, 80), layout(4));
+ try std.testing.expectEqual(@as(u32, 8), layout(5));
+ try std.testing.expectEqual(@as(u32, @sizeOf(Pending)), layout(6));
+ try std.testing.expect(layout(6) >= layout(1) + max_states * @sizeOf(f64));
+ try std.testing.expect(layout(0) >= layout(1) * 2);
+ try std.testing.expectEqual(@as(u32, 512), layout(7));
+ try std.testing.expect(layout(0) + 16 * 1024 < 64 * 1024);
+}
+
+fn displayCounterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try emit(1, 0, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try ok(configure_evaluation(32, 8));
+}
+
+fn boundedEquals(want: f64, start: u32, length: u32) !void {
+ begin_evaluation(100);
+ try std.testing.expectEqual(want, eval_bounded(start, length));
+ try ok(evaluation_info(0));
+}
+
+test "point coordinates are ephemeral event operands and unavailable to pure evaluation" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 0, -1));
+ try ok(put_state(1, 0, -1));
+ const events = [_]u32{ 1, 17, 2, 25 };
+ for (events, 0..) |event, i| {
+ try emit(22, 0, 0);
+ try emit(20, 0, 0);
+ try emit(23, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(event, @intCast(i * 4), 4));
+ }
+ try ok(commit_program());
+ for (events, 0..) |event, i| {
+ const x: f64 = @floatFromInt(i + 10);
+ try ok(dispatch_point(event, x, -x));
+ try std.testing.expectEqual(x, get_state(0));
+ try std.testing.expectEqual(-x, get_state(1));
+ }
+ try std.testing.expectEqual(public(.bad_event), dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 13), get_state(0));
+ const diagnostics = [_]u32{ info(1), info(2), info(3), info(9) };
+ try std.testing.expect(std.math.isNan(eval_at(0, 1)));
+ try std.testing.expectEqualSlices(u32, &diagnostics, &.{ info(1), info(2), info(3), info(9) });
+ try std.testing.expectEqual(@as(u32, 4), info(9));
+}
+
+test "point event ranges reject holes and invalid payloads without committing" {
+ reset_all();
+ try counterProgram();
+ for ([_]u32{ 0, 3, 16, 18, 24, 26, 32, 33, no_pc }) |event|
+ try std.testing.expectEqual(public(.bad_event), dispatch_point(event, 1, 2));
+ try std.testing.expectEqual(public(.non_finite), dispatch_point(1, std.math.nan(f64), 0));
+ try std.testing.expectEqual(public(.non_finite), dispatch_point(1, 0, std.math.inf(f64)));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try ok(begin_program(0, 8));
+ try ok(put_handler(24, 0, 0));
+ try ok(put_handler(32, 0, 0));
+ try ok(commit_program());
+ try ok(dispatch_point(24, 1, 2));
+ try ok(dispatch_point(32, 1, 2));
+ try std.testing.expectEqual(public(.bad_event), dispatch(24, 0));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+}
+
+test "point event failure rolls back writes from earlier matching handlers" {
+ reset_all();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 7, -1));
+ try emit(22, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(17, 0, 2));
+ try emit(23, 0, 0);
+ try emit(0, 0, 0);
+ try emit(6, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(17, 2, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch_point(17, 400, 200));
+ try std.testing.expectEqual(@as(f64, 7), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(2));
+}
+
+test "loading shares storage with records while old events and bindings remain executable" {
+ reset_all();
+ try displayCounterProgram();
+ try boundedEquals(20, 4, 3);
+ try boundedEquals(20, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(9));
+ try emit(1, 0, 0);
+ try ok(dispatch(1, 0)); // Active state advances after staging begins.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(3));
+ try ok(configure_evaluation(32, 1));
+ forget_evaluation(3); // Must not overwrite the pending program.
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(33, 0, 3);
+ try boundedEquals(33, 0, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+}
+
+test "failed and abandoned candidates restore empty reuse storage without changing active work" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(20, 0, 0); // Store without a handler fails validation.
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try boundedEquals(20, 4, 3);
+ try ok(dispatch(1, 0));
+ abort_program();
+ try boundedEquals(22, 4, 3);
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ abort_program(); // Inactive abort is a no-op, including for warmed records.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try std.testing.expectEqual(public(.capacity), begin_program(max_states + 1, 0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+}
+
+test "full pending program survives repeated evaluation configuration record loss and reset" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ for (0..max_instructions) |i| {
+ try emit(0, 0, @floatFromInt(i));
+ try ok(configure_evaluation(32, @intCast(i % 9)));
+ forget_evaluation(3);
+ try boundedEquals(22, 4, 3);
+ }
+ reset_state(); // The pending definition and old-id mapping survive reset.
+ try boundedEquals(20, 4, 3);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, max_instructions), info(6));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try boundedEquals(511, 511, 1);
+ // Safe Zig builds may reserve a hidden union tag in addition to the payload.
+ try std.testing.expect(@sizeOf(Workspace) >= @sizeOf(Pending));
+ try std.testing.expect(@sizeOf(Workspace) <= @sizeOf(Pending) + @alignOf(Pending));
+ try std.testing.expectEqual(@as(u32, @sizeOf(Tables)), evaluation_info(8));
+}
+
+test "restored definition and values commit together without changing reset initializers" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(dispatch(0, frame_dt));
+ const revision = info(4);
+ try ok(begin_program(2, 0));
+ try ok(put_state(0, 100, -1));
+ try ok(put_state(1, 200, -1));
+ try ok(put_restored_value(1, -0.0));
+ try ok(put_restored_value(0, 37));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try ok(dispatch(1, 0)); // Old execution remains independent of pending values.
+ try ok(commit_restored_program());
+ try std.testing.expectEqual(@as(f64, 37), get_state(0));
+ try std.testing.expectEqual(@as(u64, @bitCast(@as(f64, -0.0))), @as(u64, @bitCast(get_state(1))));
+ try std.testing.expectEqual(revision + 1, info(4));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 100), get_state(0));
+ try std.testing.expectEqual(@as(f64, 200), get_state(1));
+}
+
+test "missing duplicate nonfinite or invalid restored candidates leave active work untouched" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ const revision = info(4);
+ for (0..4) |mode| {
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 999, -1));
+ if (mode == 1) {
+ try ok(put_restored_value(0, 30));
+ try std.testing.expectEqual(public(.bad_state), put_restored_value(0, 40));
+ } else if (mode == 2) {
+ try std.testing.expectEqual(public(.non_finite), put_restored_value(0, std.math.inf(f64)));
+ } else if (mode == 3) {
+ try ok(put_restored_value(0, 30));
+ try emit(20, 0, 0); // Invalid store outside a handler.
+ }
+ try std.testing.expect(commit_restored_program() != 0);
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try std.testing.expectEqual(revision, info(4));
+ try std.testing.expectEqual(@as(u32, 1), info(9));
+ abort_program();
+ }
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 100, -1));
+ try ok(put_restored_value(0, 42));
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ abort_program();
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+}
+
+test "empty and full restored programs validate all values and discard old display records" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(configure_evaluation(32, 8));
+ try boundedEquals(20, 4, 3);
+ try ok(begin_program(32, 0));
+ for (0..32) |i| {
+ try ok(put_state(@intCast(i), 0, -1));
+ try ok(put_restored_value(@intCast(i), @floatFromInt(i + 1)));
+ }
+ try emit(1, 31, 0);
+ try ok(commit_restored_program());
+ try boundedEquals(32, 0, 1);
+ try ok(begin_program(0, 0));
+ try ok(commit_restored_program());
+ try std.testing.expectEqual(@as(u32, 0), info(5));
+ try std.testing.expect(std.math.isNan(get_state(0)));
+}
+
+test "shared handler bodies access their own bound state slots in source order" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 10, -1));
+ try ok(put_state(1, 20, -1));
+ try emit(24, 0, 0); try emit(0, 0, 1); try emit(3, 0, 0); try emit(25, 0, 0);
+ try ok(put_handler(1, 0, 4)); try ok(bind_handler(0, 0, 1));
+ try ok(put_handler(1, 0, 4)); try ok(bind_handler(1, 1, 1));
+ try ok(put_handler(1, 0, 4)); try ok(bind_handler(2, 0, 1));
+ try ok(commit_program());
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try std.testing.expectEqual(@as(f64, 21), get_state(1));
+ try std.testing.expectEqual(@as(u32, 4), info(6)); // Three bindings, one body.
+ try std.testing.expectEqual(@as(u32, 12), info(3));
+ const diagnostics = [_]u32{ info(1), info(2), info(3), info(9) };
+ try std.testing.expect(std.math.isNan(eval_at(0, 1)));
+ try std.testing.expectEqualSlices(u32, &diagnostics, &.{ info(1), info(2), info(3), info(9) });
+}
+
+test "failure in a later shared binding rolls back all earlier bindings" {
+ reset_all();
+ try ok(begin_program(4, 1));
+ for ([_]f64{ 8, 2, 12, 0 }, 0..) |value, i| try ok(put_state(@intCast(i), value, -1));
+ try emit(24, 0, 0); try emit(24, 1, 0); try emit(6, 0, 0); try emit(25, 0, 0);
+ for (0..2) |i| {
+ try ok(put_handler(1, 0, 4));
+ try ok(bind_handler(@intCast(i), @intCast(i * 2), 2));
+ }
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 8), get_state(0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(2));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+}
+
+test "shared bindings reject missing arguments partial overlaps and invalid slot mappings" {
+ reset_all();
+ try counterProgram();
+ const revision = info(4);
+ for (0..5) |mode| {
+ try ok(begin_program(1, 1)); try ok(put_state(0, 9, -1));
+ try emit(24, 0, 0); try emit(25, 0, 0);
+ try ok(put_handler(1, 0, 2));
+ switch (mode) {
+ 0 => {}, // Parameter op without a binding.
+ 1 => { try ok(bind_handler(0, 0, 1)); try std.testing.expectEqual(public(.invalid), bind_handler(0, 0, 1)); },
+ 2 => { try std.testing.expectEqual(public(.bad_state), bind_handler(0, 0, 2)); },
+ 3 => {
+ try ok(bind_handler(0, 0, 1));
+ try ok(put_handler(1, 1, 1)); try ok(bind_handler(1, 0, 1));
+ },
+ else => {
+ try ok(bind_handler(0, 0, 1));
+ try ok(put_handler(1, 0, 2)); // Another owner missing its mapping.
+ },
+ }
+ try std.testing.expect(commit_program() != 0); abort_program();
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(revision, info(4));
+ }
+}
+
+test "one routed handler changes only its event target and rejects missing or foreign routes" {
+ reset_all();
+ try ok(begin_program(2, 2));
+ try ok(put_state(0, 10, -1));
+ try ok(put_state(1, 20, -1));
+ try ok(put_pointer_target(0, 0, 1));
+ try ok(put_pointer_target(0, 1, 1));
+ try emit(22, 0, 0);
+ try emit(25, 0, 0);
+ try ok(put_handler(17, 0, 2));
+ try ok(bind_target_handler(0, 1));
+ try ok(commit_program());
+ try ok(dispatch_target(17, 1, 77, 0));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(f64, 77), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(3));
+ try std.testing.expectEqual(public(.bad_event), dispatch_point(17, 99, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch(1, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch_target(17, 2, 99, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch_target(17, no_pc, 99, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch_target(18, 0, 99, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch_target(0, 0, 99, 0));
+ try std.testing.expectEqual(public(.non_finite), dispatch_target(17, 0, std.math.inf(f64), 0));
+ try std.testing.expectEqual(@as(u32, 1), info(9));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(f64, 77), get_state(1));
+ try std.testing.expectEqual(@as(u32, 3), layout(8));
+ try std.testing.expectEqual(@as(u32, 2), layout(9));
+ try std.testing.expectEqual(@as(u32, 54), layout(4));
+}
+
+test "routes reject invalid mappings and bindings without replacing active work" {
+ for (0..7) |mode| {
+ reset_all();
+ try counterProgram();
+ try ok(begin_program(2, 2));
+ try ok(put_state(0, 0, -1));
+ try ok(put_state(1, 0, -1));
+ switch (mode) {
+ 0 => try std.testing.expectEqual(public(.bad_state), put_pointer_target(0, 1, 2)),
+ 1 => try std.testing.expectEqual(public(.bad_event), put_pointer_target(2, 0, 1)),
+ 2 => {
+ try ok(put_pointer_target(0, 0, 2));
+ try std.testing.expectEqual(public(.invalid), put_pointer_target(1, 1, 1));
+ },
+ else => {
+ if (mode != 3) try ok(put_pointer_target(0, 0, 1));
+ try emit(0, 0, 2);
+ try emit(25, 0, 0);
+ try ok(put_handler(if (mode == 4) 0 else 1, 0, 2));
+ try ok(bind_target_handler(0, if (mode == 5) 2 else 1));
+ if (mode == 6) try std.testing.expectEqual(public(.invalid), bind_target_handler(0, 1));
+ },
+ }
+ try std.testing.expect(commit_program() != 0);
+ // Active program still executes while the failed candidate occupies staging.
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ abort_program();
+ try std.testing.expectEqual(@as(u32, 0), layout(9));
+ }
+ reset_all();
+ try ok(begin_program(32, 1));
+ for (0..32) |i| try ok(put_state(@intCast(i), 0, -1));
+ for (0..24) |i| try ok(put_pointer_target(0, @intCast(i), 1));
+ try std.testing.expectEqual(public(.capacity), put_pointer_target(0, 24, 1));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+}
+
+test "routed event rollback includes globals and earlier handlers and survives staged route replacement" {
+ reset_all();
+ try ok(begin_program(3, 1));
+ for (0..3) |i| try ok(put_state(@intCast(i), @floatFromInt(i), -1));
+ try ok(put_pointer_target(0, 1, 1));
+ try ok(put_pointer_target(0, 2, 1));
+ try emit(22, 0, 0);
+ try emit(25, 0, 0);
+ try ok(put_handler(17, 0, 2));
+ try ok(bind_target_handler(0, 1));
+ try emit(0, 0, 99);
+ try emit(20, 0, 0);
+ try emit(0, 0, 1);
+ try emit(23, 0, 0);
+ try emit(6, 0, 0);
+ try emit(25, 0, 0);
+ try ok(put_handler(17, 2, 6));
+ try ok(bind_target_handler(1, 1));
+ try ok(commit_program());
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 0, -1));
+ try ok(put_pointer_target(0, 0, 1));
+ try std.testing.expectEqual(public(.division_by_zero), dispatch_target(17, 1, 88, 0));
+ try std.testing.expectEqual(@as(f64, 0), get_state(0));
+ try std.testing.expectEqual(@as(f64, 1), get_state(1));
+ try std.testing.expectEqual(@as(f64, 2), get_state(2));
+ abort_program();
+ try ok(dispatch_target(17, 1, 88, 2));
+ try std.testing.expectEqual(@as(f64, 99), get_state(0));
+ try std.testing.expectEqual(@as(f64, 1), get_state(1));
+ try std.testing.expectEqual(@as(f64, 0.5), get_state(2));
+}
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/before/runtime.mjs b/prototypes/workbench/results/2026-09-23-web-layout/before/runtime.mjs
new file mode 100644
index 0000000..eb36839
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/before/runtime.mjs
@@ -0,0 +1,339 @@
+import { compile } from './compiler.mjs';
+import { validateDocument } from './project.mjs';
+
+const messages = [
+ '', '실행기의 용량 한도를 넘었습니다.', '실행 정의가 유효하지 않습니다.',
+ '계산 스택의 범위를 벗어났습니다.', '0으로 나눌 수 없습니다.',
+ '계산 결과가 유한한 수의 범위를 벗어났습니다.', '조건 분기 범위가 유효하지 않습니다.',
+ '존재하지 않는 상태를 참조했습니다.', '시간 간격이 유효하지 않습니다.',
+ '정의되지 않은 입력 사건입니다.',
+];
+const sourceLimit = 32768;
+
+function problem(message, line = null, column = null) {
+ return Object.assign(new Error(message), { line, column });
+}
+
+function freezeProgram(value) {
+ if (value && typeof value === 'object') {
+ for (const child of Object.values(value)) freezeProgram(child);
+ Object.freeze(value);
+ }
+ return value;
+}
+
+export async function createRuntime(wasmBytes, evaluationOptions = {}) {
+ if (wasmBytes === undefined) {
+ const response = await fetch(new URL('./core.wasm', import.meta.url), { cache: 'no-store' });
+ if (!response.ok) throw new Error(`실행기를 불러오지 못했습니다 (HTTP ${response.status}).`);
+ wasmBytes = await response.arrayBuffer();
+ }
+ const module = await WebAssembly.compile(wasmBytes);
+ if (WebAssembly.Module.imports(module).length) throw new Error('이 실행기는 호스트 함수 없이 동작해야 합니다.');
+ const { exports: core } = await WebAssembly.instantiate(module, {});
+ if (typeof core.eval_bounded !== 'function' || typeof core.abort_program !== 'function' || typeof core.dispatch_point !== 'function' || typeof core.put_restored_value !== 'function' || typeof core.commit_restored_program !== 'function' || typeof core.bind_handler !== 'function' || typeof core.put_pointer_target !== 'function' || typeof core.bind_target_handler !== 'function' || typeof core.dispatch_target !== 'function')
+ throw new Error('실행기 버전이 맞지 않습니다. 새로고침하거나 core.wasm을 다시 빌드하세요.');
+ core.reset_all();
+ let program = null;
+ let appliedSource = '';
+ let lastEvent = null;
+ // Keep a failed event visible through its release/focus-loss cleanup.
+ // The active program can clear this diagnostic by apply or explicit reset.
+ let lastFailure = null;
+ const held = new Map();
+ const points = new Map();
+ let presenting = false;
+ let bindings = [];
+ let pointRoutes = new Map();
+ // One bounded publication buffer, never exposed to callers. No state mirror.
+ let displayValues = null, displayValid = null;
+ function available() {
+ if (presenting) throw new Error('출력 전달 중에는 실행기를 다시 호출할 수 없습니다.');
+ }
+ let evaluationPolicy = { cacheEntries: 0, historyEntries: 8, workLimit: 2048 };
+
+ function checked(code, line = null) {
+ if (code !== 0) throw problem(messages[code] || `실행기 오류 ${code}`, line);
+ }
+ function inputIndex(name) {
+ const index = program?.inputs.findIndex((input) => input.name === name) ?? -1;
+ if (index < 0) throw new Error(`정의되지 않은 입력: ${name}`);
+ return index;
+ }
+ function dispatch(event, name, phase, source, point = null) {
+ const status = point ? (point.route === undefined ? core.dispatch_point(event, point.x, point.y)
+ : core.dispatch_target(event, point.route, point.x, point.y)) : core.dispatch(event, event === 0 ? 1 / 60 : 0);
+ const pc = core.info(2) >>> 0;
+ lastEvent = { name, phase, source, status,
+ line: program?.instructions[pc]?.line ?? null, ops: core.info(3) >>> 0 };
+ if (point) { lastEvent.x = point.x; lastEvent.y = point.y; if (point.target) lastEvent.target = point.target; }
+ if (status) lastFailure = { ...lastEvent };
+ return status;
+ }
+
+ function evaluateDisplay() {
+ displayValues ??= new Float64Array(96);
+ displayValid ??= new Uint8Array(24);
+ core.begin_evaluation(evaluationPolicy.workLimit);
+ let error = null, slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ displayValid[i] = 1;
+ for (const [name, binding] of bindings[i]) {
+ const value = core.eval_bounded(binding.start, binding.length);
+ if (core.evaluation_info(0) === 10) {
+ displayValid.fill(0);
+ return { code: 101, line: view.line,
+ message: '표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.' };
+ }
+ if (!Number.isFinite(value) || ((name === 'width' || name === 'height' || name === 'radius') && value < 0)) {
+ error ??= { line: view.line, message: `${view.id}.${name}: 표시할 수 없는 값입니다. 0 나눗셈이나 음수 크기를 확인하세요.` };
+ displayValid[i] = 0;
+ }
+ displayValues[slot++] = value;
+ }
+ }
+ return error;
+ }
+
+ function status(viewError) {
+ const executionError = lastFailure?.status || 0;
+ return {
+ tick: core.info(0) >>> 0, revision: core.info(4) >>> 0,
+ displayError: viewError ? { code: viewError.code || 100, ...viewError } : null,
+ lastError: executionError || (viewError ? viewError.code || 100 : 0),
+ errorMessage: executionError ? messages[executionError] || `실행 오류 ${executionError}` : viewError?.message || '',
+ errorLine: executionError ? lastFailure.line : viewError?.line ?? null,
+ lastOps: core.info(3) >>> 0, successfulEvents: core.info(9) >>> 0,
+ held: [...held.keys()], lastEvent: lastEvent ? { ...lastEvent } : null,
+ lastFailure: lastFailure ? { ...lastFailure } : null,
+ memory: {
+ linearBytes: core.memory.buffer.byteLength,
+ reservedEngineBytes: core.layout(0), programReservedBytes: core.layout(1),
+ instructionBytes: core.layout(2), handlerBytes: core.layout(3),
+ activeIRBytes: core.layout(4), activeStateBytes: core.layout(5),
+ stagingReservedBytes: core.layout(6), evalStackBytes: core.layout(7),
+ pointerTargetBytes: core.layout(8), pointerTargets: core.layout(9),
+ },
+ };
+ }
+
+ function load(next, source, restored = null) {
+ const oldIds = new Map(restored ? [] : program?.states.map((state, index) => [state.name, index]) ?? []);
+ checked(core.begin_program(next.states.length, next.inputs.length));
+ try {
+ for (let index = 0; index < next.states.length; index++) {
+ const state = next.states[index];
+ checked(core.put_state(index, state.initial, oldIds.get(state.name) ?? -1), state.line);
+ if (restored) checked(core.put_restored_value(index, restored[index]), state.line);
+ }
+ for (const op of next.instructions) checked(core.put_instruction(op.op, op.arg, op.value), op.line);
+ for (const target of next.pointerTargets) checked(core.put_pointer_target(target.input, target.base, target.count));
+ for (const [index, handler] of next.handlers.entries()) {
+ checked(core.put_handler(handler.event, handler.start, handler.length), handler.line);
+ if (handler.parameterCount)
+ checked(handler.parameterBase === 255 ? core.bind_target_handler(index, handler.parameterCount)
+ : core.bind_handler(index, handler.parameterBase, handler.parameterCount), handler.line);
+ }
+ checked(restored ? core.commit_restored_program() : core.commit_program());
+ } catch (error) {
+ core.abort_program();
+ throw error;
+ }
+ pointRoutes = new Map(next.inputs.flatMap(input => (input.routes ?? []).map(route => [route.target, route])));
+ program = next;
+ bindings = next.views.map(view => Object.entries(view.bindings));
+ appliedSource = source;
+ lastEvent = null;
+ lastFailure = null;
+ }
+
+ const api = {
+ get currentProgram() { return program; },
+ configureEvaluation({ cacheEntries = evaluationPolicy.cacheEntries,
+ historyEntries = evaluationPolicy.historyEntries, workLimit = evaluationPolicy.workLimit } = {}) {
+ available();
+ for (const [name, value, maximum] of [['cacheEntries', cacheEntries, 32],
+ ['historyEntries', historyEntries, 8], ['workLimit', workLimit, 0xffffffff]]) {
+ if (!Number.isInteger(value) || value < 0 || value > maximum)
+ throw new Error(`${name}: 0~${maximum} 사이의 정수여야 합니다.`);
+ }
+ checked(core.configure_evaluation(cacheEntries, historyEntries));
+ evaluationPolicy = { cacheEntries, historyEntries, workLimit };
+ return { ...evaluationPolicy };
+ },
+ forgetEvaluation(part = 'all') {
+ available();
+ const flags = part === 'all' ? 3 : part === 'values' ? 1 : part === 'history' ? 2 : undefined;
+ if (flags === undefined) throw new Error('삭제할 기록은 values, history, all 중 하나여야 합니다.');
+ core.forget_evaluation(flags);
+ },
+ evaluationStats() {
+ return { ...evaluationPolicy, work: core.evaluation_info(1),
+ instructions: core.evaluation_info(2), cacheHits: core.evaluation_info(3),
+ reservedBytes: core.evaluation_info(6), dedicatedBytes: core.evaluation_info(7),
+ sharedBytes: core.evaluation_info(8), reuseAvailable: Boolean(core.evaluation_info(9)),
+ workspaceTagBytes: core.evaluation_info(10) };
+ },
+ apply(source) {
+ available();
+ if (typeof source !== 'string' || new TextEncoder().encode(source).length > sourceLimit) {
+ throw problem('정의는 UTF-8 기준 32,768바이트 이하의 텍스트여야 합니다.');
+ }
+ if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 적용하세요. 현재 작업은 그대로 유지합니다.');
+ const next = freezeProgram(compile(source));
+ load(next, source);
+ return next;
+ },
+ restoreDocument(document) {
+ available();
+ if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 복원하세요.');
+ const next = validateDocument(document);
+ load(freezeProgram(next.program), next.source, next.values);
+ return { source: next.source, draft: next.draft };
+ },
+ exportDocument(draft = appliedSource) {
+ available();
+ if (!program) throw problem('먼저 코드를 적용하세요.');
+ if (held.size) throw problem('조작을 끝낸 뒤 배치를 저장하세요.');
+ if (lastFailure) throw problem('실행 오류를 해결한 뒤 배치를 저장하세요.');
+ if (typeof draft !== 'string' || new TextEncoder().encode(draft).length > sourceLimit)
+ throw problem('편집 중인 코드는 UTF-8 32 KiB 이하여야 합니다.');
+ return { format: 'pure-workbench', version: 2, source: appliedSource, draft,
+ states: program.states.map((state, i) => {
+ const value = core.get_state(i);
+ return { name: state.name, value: Object.is(value, -0) ? '-0' : value };
+ }) };
+ },
+ reset() {
+ available();
+ held.clear();
+ points.clear();
+ core.reset_state();
+ lastEvent = null;
+ lastFailure = null;
+ },
+ frame(count = 1) {
+ available();
+ if (!Number.isInteger(count) || count < 0 || count > 600) throw new Error('프레임 수는 0~600 사이의 정수여야 합니다.');
+ if (!program) return 0;
+ for (let i = 0; i < count; i++) {
+ const code = dispatch(0, 'frame', 'frame', 'clock');
+ if (code !== 0) return code;
+ }
+ return 0;
+ },
+ down(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (program.inputs[index].target || program.inputs[index].routes) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.');
+ const sources = held.get(name) || new Set();
+ if (sources.has(source)) return 0;
+ const wasDown = sources.size > 0;
+ sources.add(source);
+ held.set(name, sources);
+ return wasDown ? 0 : dispatch(1 + index * 2, name, 'down', source);
+ },
+ up(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (program.inputs[index].target || program.inputs[index].routes) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.');
+ const sources = held.get(name);
+ if (!sources?.delete(source)) return 0;
+ if (sources.size) return 0;
+ held.delete(name);
+ return dispatch(2 + index * 2, name, 'up', source);
+ },
+ point(name, phase, x, y, source = 'external', target = undefined) {
+ available();
+ const index = inputIndex(name);
+ const input = program.inputs[index];
+ if (!input.target && !input.routes) throw new Error('포인터에 연결된 입력이 아닙니다.');
+ const route = input.routes ? pointRoutes.get(target) : undefined;
+ if (input.routes && (!route || program.pointerTargets[route.route].input !== index)) throw new Error('포인터 그룹에 연결된 도형 이름이 필요합니다.');
+ if (!input.routes && target !== undefined) throw new Error('단일 포인터 입력에는 대상 인자를 지정하지 않습니다.');
+ const owner = route ? `${name}:${target}` : name;
+ if (!['down', 'move', 'up', 'cancel'].includes(phase)) throw new Error('포인터 단계는 down, move, up, cancel 중 하나여야 합니다.');
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error('포인터 좌표는 유한한 숫자여야 합니다.');
+ if (typeof source !== 'string' || source.length === 0) throw new Error('포인터 출처 이름이 필요합니다.');
+ let point = points.get(owner);
+ if (phase === 'down') {
+ if (point?.source === source) return 0;
+ if (point) throw new Error('이 입력은 다른 포인터가 사용 중입니다.');
+ point = {name, source, x, y, target, route:route?.route};
+ points.set(owner, point);
+ held.set(owner, new Set([source]));
+ } else {
+ if (!point || point.source !== source) return 0;
+ point.x = x; point.y = y;
+ if (phase === 'up' || phase === 'cancel') { points.delete(owner); held.delete(owner); }
+ }
+ const event = phase === 'move' ? 17 + index : phase === 'cancel' ? 25 + index : 1 + index * 2 + (phase === 'up' ? 1 : 0);
+ return dispatch(event, name, phase, source, point);
+ },
+ releaseAll() {
+ available();
+ let firstError = 0;
+ for (const point of [...points.values()]) {
+ const status = api.point(point.name, 'cancel', point.x, point.y, point.source, point.target);
+ if (!firstError) firstError = status;
+ }
+ for (const [name, sources] of [...held]) {
+ for (const source of [...sources]) {
+ const status = api.up(name, source);
+ if (!firstError) firstError = status;
+ }
+ }
+ return firstError;
+ },
+ exportProject() {
+ return { format: 'pure-workbench', version: 1, source: appliedSource };
+ },
+ // Synchronous trusted output adapter. Callbacks run only after the whole
+ // display has been evaluated. Runtime mutation/reentrant reads are refused.
+ // Exceptions from an adapter propagate; the next call republishes all values.
+ present(target) {
+ available();
+ presenting = true;
+ try {
+ const { begin, state, view, value, end } = target;
+ for (const callback of [begin, state, view, value, end])
+ if (typeof callback !== 'function') throw new TypeError('출력 어댑터 함수가 필요합니다.');
+ const error = evaluateDisplay();
+ begin.call(target, program);
+ for (let i = 0; i < (program?.states.length ?? 0); i++) state.call(target, i, core.get_state(i));
+ let slot = 0, visible = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const valid = Boolean(displayValid[i]);
+ view.call(target, i, valid);
+ for (const [name] of bindings[i]) {
+ if (valid) value.call(target, i, name, displayValues[slot]);
+ slot++;
+ }
+ if (valid) visible++;
+ }
+ end.call(target, visible);
+ return status(error);
+ } finally { presenting = false; }
+ },
+ snapshot() {
+ available();
+ const error = evaluateDisplay();
+ const states = Object.fromEntries((program?.states ?? []).map((state, index) => [state.name, core.get_state(index)]));
+ const views = [];
+ let slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ const rendered = displayValid[i] ? { id: view.id, type: view.type, color: view.color, prefix: view.prefix } : null;
+ for (const [name] of bindings[i]) {
+ if (rendered) rendered[name] = displayValues[slot];
+ slot++;
+ }
+ if (rendered) views.push(rendered);
+ }
+ return { states, views, ...status(error) };
+ },
+ };
+ api.configureEvaluation(evaluationOptions);
+ return Object.freeze(api);
+}
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/browser-result.log b/prototypes/workbench/results/2026-09-23-web-layout/browser-result.log
new file mode 100644
index 0000000..fbb03ff
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/browser-result.log
@@ -0,0 +1,12 @@
+### Result
+{"checks":["only the authored ratio occupies VM state; no document or geometry mirror","native editor and reading pane show the same Unicode document","document markup is displayed as text","resize reflows through CSS without VM events, document publication or application geometry reads","reflow preserves the native editing selection","long wrapped content uses the native reading scroll area","reading scroll does not move the editing pane","native scrolling does not dispatch layout data to the VM","a native range input uses the authored numeric-input policy","keyboard separator control publishes the same value and accessibility state","mouse release commits the last delivered split position","resizing panes never republishes unchanged document text","Escape restores the ratio captured at gesture start","a resize during capture invalidates geometry before the next move","a compatibility mouseup cannot release a touch owner","touch cancellation restores the original panel ratio","narrow layout cancels the active split gesture and stacks the panes","the narrow document workspace has no page-level horizontal overflow","editing Unicode updates the text projection","text editing neither sends document copies to the VM nor reads geometry","native editing undo survives the Pure integration","in-progress composition does not replace the reading projection","composition completion publishes the final text (synthetic event sequence)","a standard UTF-8 text file opens without a custom container","export keeps the standard text filename","the exported file reopens in a fresh page with exact text","a delayed file read cannot overwrite newer editing","invalid UTF-8 leaves the current document intact","changing the authored rule changes native control behavior without touching the HTML adapter","invalid policy edits preserve the executable policy and document","missing Wasm disables split controls while standard text editing and saving remain available","no uncaught page errors"],"errors":[],"fileText":"표준 UTF-8 파일\n외부 편집기에서 만든 내용\n😀 <article>문자</article>\n","savedPath":"output/playwright/pure-document-saved.txt","final":{"ready":true,"stats":{"geometryReads":12,"previewWrites":8,"ratioWrites":14},"runtime":{"states":{"ratio":50},"views":[],"tick":0,"revision":3,"displayError":null,"lastError":0,"errorMessage":"","errorLine":null,"lastOps":0,"successfulEvents":16,"held":[],"lastEvent":{"name":"reset","phase":"up","source":"control","status":0,"line":null,"ops":0},"lastFailure":null,"memory":{"linearBytes":65536,"reservedEngineBytes":20064,"programReservedBytes":9728,"instructionBytes":16,"handlerBytes":16,"activeIRBytes":144,"activeStateBytes":8,"stagingReservedBytes":9992,"evalStackBytes":512,"pointerTargetBytes":3,"pointerTargets":0}},"dragging":false,"composing":false,"dirty":true,"generation":10},"browser":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36"}
+### Ran Playwright code
+```js
+await (async page => await page.evaluate(() => window.__pureDocumentEvidence ?? {pending:true}))(page);
+```
+### Page
+- Page URL: http://127.0.0.1:8766/document.html
+- Page Title: Pure · 문서 작업
+### Events
+- Downloading file external.txt ...
+- Downloaded file external.txt to ".playwright-cli/external.txt"
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/browser-run.log b/prototypes/workbench/results/2026-09-23-web-layout/browser-run.log
new file mode 100644
index 0000000..24e6494
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/browser-run.log
@@ -0,0 +1,111 @@
+### Ran Playwright code
+```js
+await (// Playwright CLI run-code. Functional and ownership checks, not a timing benchmark.
+async page => {
+ const checks=[],errors=[],check=(ok,label)=>{if(!ok)throw new Error(label);checks.push(label);};
+ const onDialog=d=>d.accept(),onError=e=>errors.push(e.message);page.on('dialog',onDialog);page.on('pageerror',onError);
+ const state=()=>page.evaluate(()=>window.pureDocument.snapshot());
+ const settled=()=>page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
+ const ratio=async expected=>{await page.waitForFunction(n=>Math.abs(window.pureDocument.snapshot().runtime.states.ratio-n)<0.2,expected);};
+ const geometry=()=>page.evaluate(()=>{const p=document.querySelector('#panes').getBoundingClientRect(),h=document.querySelector('#divider').getBoundingClientRect();return{left:p.left,width:p.width,handle:h.width,x:h.left+h.width/2,y:h.top+h.height/2};});
+ const target=(g,r)=>g.left+g.handle/2+(g.width-g.handle)*r/100;
+ const dragStart=async()=>{await page.locator('#divider').scrollIntoViewIfNeeded();const g=await geometry();await page.mouse.move(g.x,g.y);await page.mouse.down();return g;};
+ const upload=async(content,name='notes.txt')=>page.locator('#file-input').evaluate((input,{content,name})=>{const d=new DataTransfer();d.items.add(new File([content],name,{type:'text/plain'}));input.files=d.files;input.dispatchEvent(new Event('change',{bubbles:true}));},{content,name});
+ try {
+ await page.setViewportSize({width:1440,height:1000});await page.reload();
+ await page.waitForFunction(()=>window.pureDocument?.snapshot().ready);
+ check((await state()).runtime.memory.activeStateBytes===8,'only the authored ratio occupies VM state; no document or geometry mirror');
+ await page.locator('#sample').click();await settled();
+ const original=await page.locator('#document-text').inputValue();
+ check(await page.locator('#reading-text').textContent()===original,'native editor and reading pane show the same Unicode document');
+ check(await page.locator('#reading-text div').count()===0,'document markup is displayed as text');
+ const beforeResize=await state();
+ await page.locator('#document-text').evaluate(t=>{t.focus();t.setSelectionRange(20,25);t.scrollTop=80;});
+ await page.setViewportSize({width:1040,height:900});await settled();
+ const afterResize=await state();
+ check(afterResize.runtime.successfulEvents===beforeResize.runtime.successfulEvents&&afterResize.stats.previewWrites===beforeResize.stats.previewWrites&&afterResize.stats.geometryReads===beforeResize.stats.geometryReads,'resize reflows through CSS without VM events, document publication or application geometry reads');
+ check(await page.locator('#document-text').evaluate(t=>t.selectionStart===20&&t.selectionEnd===25),'reflow preserves the native editing selection');
+ const scrolling=await page.evaluate(()=>{const t=document.querySelector('#document-text'),r=document.querySelector('#reading-scroll');r.scrollTop=120;return{editor:t.scrollTop,reading:r.scrollTop,scrollable:r.scrollHeight>r.clientHeight};});
+ check(scrolling.scrollable&&scrolling.reading>0,'long wrapped content uses the native reading scroll area');
+ await page.locator('#reading-scroll').evaluate(r=>r.scrollTop+=80);await settled();
+ check(await page.locator('#document-text').evaluate((t,y)=>t.scrollTop===y,scrolling.editor),'reading scroll does not move the editing pane');
+ check((await state()).runtime.successfulEvents===afterResize.runtime.successfulEvents,'native scrolling does not dispatch layout data to the VM');
+ await page.locator('#split-range').focus();await page.keyboard.press('End');await ratio(80);
+ check((await state()).runtime.states.ratio===80,'a native range input uses the authored numeric-input policy');
+ await page.locator('#divider').focus();await page.keyboard.press('Home');await ratio(20);await page.keyboard.press('Shift+ArrowRight');await ratio(30);
+ check(await page.locator('#divider').getAttribute('aria-valuenow')==='30','keyboard separator control publishes the same value and accessibility state');
+ await page.locator('#reset-split').click();await ratio(50);
+ const textWrites=(await state()).stats.previewWrites;
+ let g=await dragStart();await page.mouse.move(target(g,65),g.y);await ratio(65);await page.mouse.up();
+ check(Math.abs((await state()).runtime.states.ratio-65)<0.2&&!(await state()).dragging,'mouse release commits the last delivered split position');
+ check((await state()).stats.previewWrites===textWrites,'resizing panes never republishes unchanged document text');
+ g=await dragStart();await page.mouse.move(target(g,35),g.y);await ratio(35);await page.keyboard.press('Escape');await page.mouse.up();await ratio(65);
+ check(!(await state()).dragging,'Escape restores the ratio captured at gesture start');
+ g=await dragStart();await page.setViewportSize({width:1240,height:1000});await settled();g=await geometry();
+ await page.mouse.move(target(g,45),g.y);await ratio(45);await page.mouse.up();
+ check(Math.abs((await state()).runtime.states.ratio-45)<0.2,'a resize during capture invalidates geometry before the next move');
+ // Capture a real touch; mouse compatibility termination must not release it.
+ const cdp=await page.context().newCDPSession(page);
+ try {
+ await cdp.send('Emulation.setTouchEmulationEnabled',{enabled:true,maxTouchPoints:2});g=await geometry();
+ await cdp.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[{id:1,x:g.x,y:g.y}]});
+ await cdp.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{id:1,x:target(g,70),y:g.y}]});await ratio(70);
+ await page.evaluate(()=>window.dispatchEvent(new MouseEvent('mouseup',{button:0,bubbles:true})));
+ check((await state()).dragging,'a compatibility mouseup cannot release a touch owner');
+ await cdp.send('Input.dispatchTouchEvent',{type:'touchCancel',touchPoints:[]});await ratio(45);
+ check(!(await state()).dragging,'touch cancellation restores the original panel ratio');
+ } finally {await cdp.send('Emulation.setTouchEmulationEnabled',{enabled:false});await cdp.detach();}
+ g=await dragStart();await page.mouse.move(target(g,60),g.y);await ratio(60);
+ await page.setViewportSize({width:390,height:844});await settled();await page.mouse.up();await ratio(45);
+ check(await page.locator('#divider').isHidden()&&!(await state()).dragging,'narrow layout cancels the active split gesture and stacks the panes');
+ check(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'the narrow document workspace has no page-level horizontal overflow');
+ const oldStats=await state();
+ await page.locator('#document-text').fill('한글 문서 📝\n새 줄과 <태그>도 그대로.');await settled();
+ check(await page.locator('#reading-text').textContent()==='한글 문서 📝\n새 줄과 <태그>도 그대로.','editing Unicode updates the text projection');
+ check((await state()).runtime.successfulEvents===oldStats.runtime.successfulEvents&& (await state()).stats.geometryReads===oldStats.stats.geometryReads,'text editing neither sends document copies to the VM nor reads geometry');
+ await page.locator('#document-text').focus();await page.keyboard.press('Control+End');await page.keyboard.insertText(' 추가');await settled();await page.keyboard.press('Control+z');await settled();
+ check(await page.locator('#document-text').inputValue()==='한글 문서 📝\n새 줄과 <태그>도 그대로.','native editing undo survives the Pure integration');
+ const compositionBefore=(await state()).stats.previewWrites;
+ await page.locator('#document-text').evaluate(t=>{t.dispatchEvent(new CompositionEvent('compositionstart'));t.value+=' 조합';t.dispatchEvent(new InputEvent('input',{bubbles:true,isComposing:true}));});await settled();
+ check((await state()).stats.previewWrites===compositionBefore,'in-progress composition does not replace the reading projection');
+ await page.locator('#document-text').evaluate(t=>t.dispatchEvent(new CompositionEvent('compositionend')));await settled();
+ check((await page.locator('#reading-text').textContent()).endsWith(' 조합'),'composition completion publishes the final text (synthetic event sequence)');
+ const fileText='표준 UTF-8 파일\n외부 편집기에서 만든 내용\n😀 <article>문자</article>\n';
+ await upload(fileText,'external.txt');await page.waitForFunction(()=>document.querySelector('#document-status').textContent.includes('파일을 열었습니다'));await settled();
+ check(await page.locator('#document-text').inputValue()===fileText,'a standard UTF-8 text file opens without a custom container');
+ const download=page.waitForEvent('download');await page.locator('#save').click();const saved=await download;const savedPath='output/playwright/pure-document-saved.txt';await saved.saveAs(savedPath);
+ check(saved.suggestedFilename()==='external.txt','export keeps the standard text filename');
+ const fresh=await page.context().newPage();await fresh.goto(page.url());await fresh.waitForFunction(()=>window.pureDocument?.snapshot().ready);
+ await fresh.locator('#file-input').setInputFiles(savedPath);await fresh.waitForFunction(()=>document.querySelector('#document-status').textContent.includes('파일을 열었습니다'));
+ check(await fresh.locator('#document-text').inputValue()===fileText,'the exported file reopens in a fresh page with exact text');await fresh.close();await page.bringToFront();
+ await page.locator('#file-input').evaluate(input=>{const f=new File(['late'],'late.txt');f.arrayBuffer=()=>new Promise(resolve=>window.finishDocumentRead=()=>resolve(new TextEncoder().encode('late').buffer));const d=new DataTransfer();d.items.add(f);Object.defineProperty(input,'files',{configurable:true,value:d.files});input.dispatchEvent(new Event('change',{bubbles:true}));});
+ await page.locator('#document-text').fill('읽는 동안 새로 편집한 문서');await page.evaluate(()=>window.finishDocumentRead());await settled();
+ check(await page.locator('#document-text').inputValue()==='읽는 동안 새로 편집한 문서'&&(await page.locator('#document-status').textContent()).includes('읽는 동안'),'a delayed file read cannot overwrite newer editing');
+ await page.locator('#file-input').evaluate(input=>{delete input.files;const d=new DataTransfer();d.items.add(new File([new Uint8Array([255])],'invalid.txt'));input.files=d.files;input.dispatchEvent(new Event('change',{bubbles:true}));});
+ await page.waitForFunction(()=>document.querySelector('#document-status').textContent.includes('UTF-8 텍스트로 읽을 수'));check(await page.locator('#document-text').inputValue()==='읽는 동안 새로 편집한 문서','invalid UTF-8 leaves the current document intact');
+ await page.setViewportSize({width:1440,height:1000});await settled();
+ await page.locator('.policy summary').click();const source=await page.locator('#policy-source').inputValue();
+ await page.locator('#policy-source').fill(source.replace('20, 80','30, 70'));await page.locator('#apply-policy').click();
+ await page.locator('#split-range').focus();await page.keyboard.press('End');await ratio(70);
+ check((await state()).runtime.states.ratio===70,'changing the authored rule changes native control behavior without touching the HTML adapter');
+ const revision=(await state()).runtime.revision;await page.locator('#policy-source').fill('state broken =');await page.locator('#apply-policy').click();
+ check((await state()).runtime.revision===revision&&await page.locator('#document-text').inputValue()==='읽는 동안 새로 편집한 문서','invalid policy edits preserve the executable policy and document');
+ await page.locator('#policy-source').fill(source);await page.locator('#apply-policy').click();await page.locator('.policy summary').click();
+ await page.locator('#sample').click();await settled();await page.locator('#reset-split').click();
+ await page.screenshot({path:'output/playwright/pure-document-desktop.png',fullPage:true});
+ await page.setViewportSize({width:390,height:844});await page.screenshot({path:'output/playwright/pure-document-mobile.png',fullPage:true});
+ const fallback=await page.context().newPage();await fallback.route('**/core.wasm',r=>r.abort());await fallback.goto(page.url());await fallback.waitForFunction(()=>window.pureDocument);
+ await fallback.locator('#document-text').fill('실행기 없이도 보존되는 텍스트');await fallback.waitForFunction(()=>document.querySelector('#reading-text').textContent.includes('실행기 없이도'));
+ check(!await fallback.evaluate(()=>window.pureDocument.snapshot().ready)&&await fallback.locator('#split-range').isDisabled()&&await fallback.locator('#save').isEnabled(),'missing Wasm disables split controls while standard text editing and saving remain available');await fallback.close();
+ check(errors.length===0,'no uncaught page errors');
+ const evidence={checks,errors,fileText,savedPath,final:await state(),browser:await page.evaluate(()=>navigator.userAgent)};
+ await page.evaluate(e=>window.__pureDocumentEvidence=e,evidence);return{checks:checks.length,errors};
+ } catch(error){await page.evaluate(e=>window.__pureDocumentEvidence=e,{failed:String(error),checks,errors,state:await state()});throw error;}
+ finally{page.off('dialog',onDialog);page.off('pageerror',onError);}
+}
+)(page);
+```
+### Page
+- Page URL: http://127.0.0.1:8766/document.html
+### Modal state
+- ["confirm" dialog with message "저장하지 않은 내용을 파일의 내용으로 바꿀까요?"]: can be handled by dialog-accept or dialog-dismiss
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/browser.json b/prototypes/workbench/results/2026-09-23-web-layout/browser.json
new file mode 100644
index 0000000..bb99277
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/browser.json
@@ -0,0 +1,89 @@
+{
+ "checks": [
+ "only the authored ratio occupies VM state; no document or geometry mirror",
+ "native editor and reading pane show the same Unicode document",
+ "document markup is displayed as text",
+ "resize reflows through CSS without VM events, document publication or application geometry reads",
+ "reflow preserves the native editing selection",
+ "long wrapped content uses the native reading scroll area",
+ "reading scroll does not move the editing pane",
+ "native scrolling does not dispatch layout data to the VM",
+ "a native range input uses the authored numeric-input policy",
+ "keyboard separator control publishes the same value and accessibility state",
+ "mouse release commits the last delivered split position",
+ "resizing panes never republishes unchanged document text",
+ "Escape restores the ratio captured at gesture start",
+ "a resize during capture invalidates geometry before the next move",
+ "a compatibility mouseup cannot release a touch owner",
+ "touch cancellation restores the original panel ratio",
+ "narrow layout cancels the active split gesture and stacks the panes",
+ "the narrow document workspace has no page-level horizontal overflow",
+ "editing Unicode updates the text projection",
+ "text editing neither sends document copies to the VM nor reads geometry",
+ "native editing undo survives the Pure integration",
+ "in-progress composition does not replace the reading projection",
+ "composition completion publishes the final text (synthetic event sequence)",
+ "a standard UTF-8 text file opens without a custom container",
+ "export keeps the standard text filename",
+ "the exported file reopens in a fresh page with exact text",
+ "a delayed file read cannot overwrite newer editing",
+ "invalid UTF-8 leaves the current document intact",
+ "changing the authored rule changes native control behavior without touching the HTML adapter",
+ "invalid policy edits preserve the executable policy and document",
+ "missing Wasm disables split controls while standard text editing and saving remain available",
+ "no uncaught page errors"
+ ],
+ "errors": [],
+ "fileText": "표준 UTF-8 파일\n외부 편집기에서 만든 내용\n😀 <article>문자</article>\n",
+ "savedPath": "output/playwright/pure-document-saved.txt",
+ "final": {
+ "ready": true,
+ "stats": {
+ "geometryReads": 12,
+ "previewWrites": 8,
+ "ratioWrites": 14
+ },
+ "runtime": {
+ "states": {
+ "ratio": 50
+ },
+ "views": [],
+ "tick": 0,
+ "revision": 3,
+ "displayError": null,
+ "lastError": 0,
+ "errorMessage": "",
+ "errorLine": null,
+ "lastOps": 0,
+ "successfulEvents": 16,
+ "held": [],
+ "lastEvent": {
+ "name": "reset",
+ "phase": "up",
+ "source": "control",
+ "status": 0,
+ "line": null,
+ "ops": 0
+ },
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 20064,
+ "programReservedBytes": 9728,
+ "instructionBytes": 16,
+ "handlerBytes": 16,
+ "activeIRBytes": 144,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9992,
+ "evalStackBytes": 512,
+ "pointerTargetBytes": 3,
+ "pointerTargets": 0
+ }
+ },
+ "dragging": false,
+ "composing": false,
+ "dirty": true,
+ "generation": 10
+ },
+ "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36"
+}
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/build.json b/prototypes/workbench/results/2026-09-23-web-layout/build.json
new file mode 100644
index 0000000..4167481
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/build.json
@@ -0,0 +1,23 @@
+{
+ "builtUtc": "2026-09-23T08:54:16.810753+00:00",
+ "zigVersion": "0.15.2",
+ "sourceSha256": "60307704dbb5e11a75d14108fb7d87a5a4480359fdd771ec73beebf5f5c48b81",
+ "wasmSha256": "9f760e4a2ce6ea11f2be07693fceca74088884885cc545a70ceef5003d0b2479",
+ "evaluationSourceSha256": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "wasmFileBytes": 61072,
+ "flags": [
+ "-target",
+ "wasm32-freestanding",
+ "-O",
+ "ReleaseSafe",
+ "-fstrip",
+ "-fno-entry",
+ "-rdynamic",
+ "--export-memory",
+ "--stack",
+ "16384",
+ "--initial-memory=65536",
+ "--max-memory=65536"
+ ],
+ "nativeTests": "1/37 core.test.blank program, no hidden application behavior, and reset...OK\n2/37 core.test.arbitrary input counter and unused events...OK\n3/37 core.test.charge and release behavior exists only in the authored instruction program...OK\n4/37 core.test.forward condition executes once and assignments and handlers are ordered...OK\n5/37 core.test.branch to program end is valid...OK\n6/37 core.test.one event rolls back writes from all handlers on arithmetic failure...OK\n7/37 core.test.nonfinite arithmetic and invalid clamp fail atomically...OK\n8/37 core.test.stack underflow overflow and leftover values reject the event...OK\n9/37 core.test.failed candidates leave active values program revision and diagnostics unchanged...OK\n10/37 core.test.state preservation mapping reads active values at commit and reset uses new initials...OK\n11/37 core.test.capacity boundaries and sticky load errors...OK\n12/37 core.test.invalid declaration values state mappings event ids and handler ranges...OK\n13/37 core.test.backward self out-of-handler branches and stores without handlers are rejected...OK\n14/37 core.test.event inputs and dt enforce deterministic frame contract...OK\n15/37 core.test.binding evaluation is pure on both success and failure...OK\n16/37 core.test.arithmetic comparison logical min max and clamp expression semantics...OK\n17/37 core.test.diagnostic counters wrap without affecting scalar values...OK\n18/37 core.test.memory accounting separates reserved storage used IR and stack capacity...OK\n19/37 core.test.point coordinates are ephemeral event operands and unavailable to pure evaluation...OK\n20/37 core.test.point event ranges reject holes and invalid payloads without committing...OK\n21/37 core.test.point event failure rolls back writes from earlier matching handlers...OK\n22/37 core.test.loading shares storage with records while old events and bindings remain executable...OK\n23/37 core.test.failed and abandoned candidates restore empty reuse storage without changing active work...OK\n24/37 core.test.full pending program survives repeated evaluation configuration record loss and reset...OK\n25/37 core.test.restored definition and values commit together without changing reset initializers...OK\n26/37 core.test.missing duplicate nonfinite or invalid restored candidates leave active work untouched...OK\n27/37 core.test.empty and full restored programs validate all values and discard old display records...OK\n28/37 core.test.shared handler bodies access their own bound state slots in source order...OK\n29/37 core.test.failure in a later shared binding rolls back all earlier bindings...OK\n30/37 core.test.shared bindings reject missing arguments partial overlaps and invalid slot mappings...OK\n31/37 core.test.one routed handler changes only its event target and rejects missing or foreign routes...OK\n32/37 core.test.routes reject invalid mappings and bindings without replacing active work...OK\n33/37 core.test.routed event rollback includes globals and earlier handlers and survives staged route replacement...OK\n34/37 core.test.scalar inputs carry a finite ephemeral value without adding state or pointer ownership...OK\n35/37 core.test.scalar input failure rolls back earlier writes and old scalar events survive staging...OK\n36/37 evaluation.test.record loss cannot authorize an old memo; unrelated changes can...OK\n37/37 evaluation.test.budget, collisions, invalid settings, and disabled history are conservative...OK\nAll 37 tests passed.\n"
+}
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/compiler-check.log b/prototypes/workbench/results/2026-09-23-web-layout/compiler-check.log
new file mode 100644
index 0000000..22d5929
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/compiler-check.log
@@ -0,0 +1,21 @@
+✔ a blank document is an empty program with no implied behavior (4.977136ms)
+✔ forward references and separate state/input namespaces preserve declaration IDs (2.107257ms)
+✔ ordered assignments, repeated handlers, and condition evaluation once (0.987638ms)
+✔ nested branches use absolute forward targets and skip correct bodies (0.87564ms)
+✔ operator precedence, associativity, unary and builtin function lowering (2.617456ms)
+✔ multiline parentheses, scientific literals, comments, and string escapes (0.840566ms)
+✔ all instruction fields are numeric and carry original source line (0.411107ms)
+✔ contextual declaration words remain valid names outside reserved builtins (0.322923ms)
+✔ unknown and reserved symbols have useful one-based locations (2.02148ms)
+✔ duplicates, unsupported keys, initializers, and invalid syntax are rejected (1.536577ms)
+✔ document, declaration, instruction, and nesting limits reject overflow (6.955487ms)
+✔ every supported input maps to a bounded ordered event ID (0.7934ms)
+✔ optional examples compile and charge behavior is entirely authored (17.558808ms)
+ℹ tests 13
+ℹ suites 0
+ℹ pass 13
+ℹ fail 0
+ℹ cancelled 0
+ℹ skipped 0
+ℹ todo 0
+ℹ duration_ms 113.13035
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/guards-result.log b/prototypes/workbench/results/2026-09-23-web-layout/guards-result.log
new file mode 100644
index 0000000..909606d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/guards-result.log
@@ -0,0 +1,9 @@
+### Result
+{"checks":["invalid-output fixture changes the numeric rule","out-of-range authored output preserves both the layout and its native control projection","a corrected policy and valid request recover from invalid host output","arithmetic rollback also restores the native range projection","policy replacement is refused during an active native gesture","oversize files cannot replace the editable text"]}
+### Ran Playwright code
+```js
+await (async page => await page.evaluate(() => window.__pureDocumentGuards ?? {pending:true}))(page);
+```
+### Page
+- Page URL: http://127.0.0.1:8766/document.html
+- Page Title: Pure · 문서 작업
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/guards-run.log b/prototypes/workbench/results/2026-09-23-web-layout/guards-run.log
new file mode 100644
index 0000000..75ca6b1
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/guards-run.log
@@ -0,0 +1,38 @@
+### Ran Playwright code
+```js
+await (async page => {
+ const checks=[],check=(ok,label)=>{if(!ok)throw new Error(label);checks.push(label);};
+ const onDialog=d=>d.accept();page.on('dialog',onDialog);
+ try {
+ await page.goto('http://127.0.0.1:8766/document.html');await page.waitForFunction(()=>window.pureDocument?.snapshot().ready);
+ await page.setViewportSize({width:1440,height:1000});await page.locator('#document-text').fill('보존할 문서');
+ await page.locator('.policy summary').click();const source=await page.locator('#policy-source').inputValue();
+ const bad=source.replace('clamp(event_value, 20, 80)','999');check(bad!==source,'invalid-output fixture changes the numeric rule');
+ await page.locator('#policy-source').fill(bad);await page.locator('#apply-policy').click();await page.locator('#split-range').focus();await page.keyboard.press('End');
+ check(await page.locator('#divider').getAttribute('aria-valuenow')==='50'&&await page.locator('#split-range').inputValue()==='50'&&(await page.locator('#policy-status').textContent()).includes('20~80'),'out-of-range authored output preserves both the layout and its native control projection');
+ await page.locator('#policy-source').fill(source);await page.locator('#apply-policy').click();await page.locator('#split-range').focus();await page.keyboard.press('Home');
+ check(await page.locator('#divider').getAttribute('aria-valuenow')==='20','a corrected policy and valid request recover from invalid host output');
+ await page.locator('#policy-source').fill(source.replace('clamp(event_value, 20, 80)','1 / 0'));await page.locator('#apply-policy').click();await page.locator('#split-range').focus();await page.keyboard.press('End');
+ const s=await page.evaluate(()=>window.pureDocument.snapshot());check(s.runtime.lastError===4&&s.runtime.states.ratio===20&&await page.locator('#split-range').inputValue()==='20','arithmetic rollback also restores the native range projection');
+ await page.locator('#policy-source').fill(source);await page.locator('#apply-policy').click();
+ await page.locator('#divider').scrollIntoViewIfNeeded();const box=await page.locator('#divider').boundingBox();await page.mouse.move(box.x+box.width/2,box.y+box.height/2);await page.mouse.down();
+ const revision=await page.evaluate(()=>window.pureDocument.snapshot().runtime.revision);
+ await page.evaluate(()=>document.querySelector('#apply-policy').click());
+ check(await page.evaluate(r=>window.pureDocument.snapshot().dragging&&window.pureDocument.snapshot().runtime.revision===r,revision)&&(await page.locator('#policy-status').textContent()).includes('경계를 놓거나'),'policy replacement is refused during an active native gesture');await page.mouse.up();
+ await page.locator('#file-input').evaluate(input=>{const d=new DataTransfer();d.items.add(new File([new Uint8Array(2*1024*1024+1)],'oversize.txt'));input.files=d.files;input.dispatchEvent(new Event('change',{bubbles:true}));});
+ await page.waitForFunction(()=>document.querySelector('#document-status').textContent.includes('2 MiB'));
+ check(await page.locator('#document-text').inputValue()==='보존할 문서','oversize files cannot replace the editable text');
+ await page.locator('.policy summary').click();await page.locator('#sample').click();await page.locator('#reset-split').click();
+ await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
+ await page.screenshot({path:'output/playwright/pure-document-desktop.png',fullPage:true});
+ await page.setViewportSize({width:390,height:844});await page.screenshot({path:'output/playwright/pure-document-mobile.png',fullPage:true});
+ await page.evaluate(e=>window.__pureDocumentGuards=e,{checks});return{checks};
+ }catch(error){await page.evaluate(e=>window.__pureDocumentGuards=e,{failed:String(error),checks});throw error;}
+ finally{page.off('dialog',onDialog);}
+}
+)(page);
+```
+### Page
+- Page URL: http://127.0.0.1:8766/
+### Modal state
+- ["beforeunload" dialog with message ""]: can be handled by dialog-accept or dialog-dismiss
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/guards.json b/prototypes/workbench/results/2026-09-23-web-layout/guards.json
new file mode 100644
index 0000000..2082a78
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/guards.json
@@ -0,0 +1,10 @@
+{
+ "checks": [
+ "invalid-output fixture changes the numeric rule",
+ "out-of-range authored output preserves both the layout and its native control projection",
+ "a corrected policy and valid request recover from invalid host output",
+ "arithmetic rollback also restores the native range projection",
+ "policy replacement is refused during an active native gesture",
+ "oversize files cannot replace the editable text"
+ ]
+}
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/manifest.json b/prototypes/workbench/results/2026-09-23-web-layout/manifest.json
new file mode 100644
index 0000000..e2952a3
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/manifest.json
@@ -0,0 +1,45 @@
+{
+ "README.md": "f3c303c8e824ff1dd1692f6bc55493efbf36337325f9a4a0b960123de94cfbe3",
+ "authoring-result.log": "c788e72ebcafef9e46a41c2d2fb554347e60f2059f5296305c2385366387e897",
+ "authoring-run.log": "42a7655af1bf01316f5cbc2785cffe3ede11c6e2fd2e9d17724bba74aaa04a2c",
+ "authoring.json": "7c748a1f6624a9a82804bdafd4c4ba59685cc3b28411452ecf0d15243ec3a71c",
+ "before/compiler.mjs": "a9d5d1a863519e86c37df24385ae9cf605384bea5e6b4298f6e67177e6a2a811",
+ "before/core.wasm": "b494269c1332f71de9725a4c7f0b58c074b669403d5d9dc95ddfeb7b16967c0d",
+ "before/core.zig": "56b8db87bcf7e49ab2b2e8d4166ade6ed04c77571fbb69c60633e76376b46976",
+ "before/runtime.mjs": "cb22adac57452453fe3add6cd885b5636d49fc946b1cd54e9e8ee502d167e7e2",
+ "browser-result.log": "67a99bb012002df426c481aef11f6449f667fd28a09987ae95b76d3f0d76cfa8",
+ "browser-run.log": "4e4cc8d0a889f0dc866337fbe9beca1eff8740512bbeef4e1dfae9054a0ef391",
+ "browser.json": "cc1b01fa1ff37636a41820b809e885aee4954f5e72d5e6e605931a3e2a8f6b9b",
+ "build.json": "cbaa131d63fb64a03f080bcd97d797a66754628192ac264df8f7228ffc6d9ed4",
+ "compiler-check.log": "9beca80a174458581337435093b818670bd60376b77fc5bebe0ea0c38a3da7df",
+ "guards-result.log": "b8ed60c6502111159045c95dafe9821620a102ede5a774ae944b8fb1ec1a1f0d",
+ "guards-run.log": "5030edd96dbf77c43efedaefc491fb25ffa6d1a168317c9df7a01f7e192c918d",
+ "guards.json": "552a8943d0fda2f4a417eecab7dce67825ae354580687b571e2c8d972413947b",
+ "native-build.log": "1d94c8ee4d8661903235bddc387bfae7c24509c63cb0b1dc6abe5849eb3f0124",
+ "node-tests.log": "67458943179cdb726afe9d8368ed96624e9eb94ff8bb7767e62c75a448aa8de2",
+ "pure-document-desktop.png": "81be2cf443fbf4f3b2fd32699b5c90d7d09ee1977ae3b99f0fc829143acdd362",
+ "pure-document-mobile.png": "21dc43b6f9b838acaf139d0fa14be4b262f5d6d654d432721280c58fab6bc5c9",
+ "pure-document-saved.txt": "377ee4ce8ed66005074d8b7e6a683a04fbe24f556218ec7d49cd6613d500704d",
+ "qa-authoring.js": "b6e2998652a020a65f3210b1d7790a95fee021cbd7016bb0e1cdb258fd06b295",
+ "qa-value-control.js": "f7fc9f93fb4873e22645fc6e8199544d0786aeb4b6458c7979ce56505922943d",
+ "sources/CONTRACT.md": "70cf2bb14f2e12bdc7f72ea1ca13fea70dbf8b91362cd6d2b818aeb962f3e930",
+ "sources/cli.mjs": "c02bb3775bc193f2505c413cd97c39a29fdd4a7447635972a6d335a047eb8f60",
+ "sources/core.zig": "60307704dbb5e11a75d14108fb7d87a5a4480359fdd771ec73beebf5f5c48b81",
+ "sources/evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "sources/examples/document.pure": "057d2e665d72dd3fce7ac59d232a65e9e0e1a6ca1f8c1e1b36ae766e480367d1",
+ "sources/qa-document-guards.js": "a8617e51d8a8de044a880771aa16a09994f930b9c0cc914bcea5db60ab793fff",
+ "sources/qa-document.js": "d9aa61a376167e31f10aebccf91f016a41bfa419e602c4bac14971a53854b3c2",
+ "sources/value-input.test.mjs": "8b08813ed64949cb0d26b9b4e02a5fbf28e84dcccf8641c0ec7c8b6e686c1267",
+ "sources/web/app.js": "82006b868cd6325b8cbb6392e766fddc4805499d0abbeb33c56293cd49d73b5b",
+ "sources/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1",
+ "sources/web/core.wasm": "9f760e4a2ce6ea11f2be07693fceca74088884885cc545a70ceef5003d0b2479",
+ "sources/web/document.css": "7f549cc2cf85019a220f5bd11c4477afae066c8d7d993a8706cf4a0515fb5d4a",
+ "sources/web/document.html": "77116a14e0e85c95ded98c8e3df76970221968ae06278ada37350eb3f88d18e1",
+ "sources/web/document.mjs": "3115841429e3c78add3ec04f2a5f197901a11302e449b29acefffcf9e617fb9d",
+ "sources/web/index.html": "4241bd1a2d45f6ac1e8166abe0daac3230d9f080c4ba3b9653565530dc4e8cc7",
+ "sources/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54",
+ "sources/web/split-input.mjs": "cfc76b56dbc7386ff1d024c13807afe1ae4c260bb0bbee51fe1b14549143381e",
+ "value-control.json": "e9d69bbcd4cb1136239cf43f59620a080097b2100a542bbd531d2b1937a6d1e7",
+ "value-control.log": "57353ddce606d43574558712ff46ef80daac5794220f187d8ce09eb5594310b1",
+ "verification.json": "c81b2adceb496e733bb51244d1c79258727f9f093894e4fdf3b395233dc54f7f"
+}
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/native-build.log b/prototypes/workbench/results/2026-09-23-web-layout/native-build.log
new file mode 100644
index 0000000..5f13011
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/native-build.log
@@ -0,0 +1,39 @@
+1/37 core.test.blank program, no hidden application behavior, and reset...OK
+2/37 core.test.arbitrary input counter and unused events...OK
+3/37 core.test.charge and release behavior exists only in the authored instruction program...OK
+4/37 core.test.forward condition executes once and assignments and handlers are ordered...OK
+5/37 core.test.branch to program end is valid...OK
+6/37 core.test.one event rolls back writes from all handlers on arithmetic failure...OK
+7/37 core.test.nonfinite arithmetic and invalid clamp fail atomically...OK
+8/37 core.test.stack underflow overflow and leftover values reject the event...OK
+9/37 core.test.failed candidates leave active values program revision and diagnostics unchanged...OK
+10/37 core.test.state preservation mapping reads active values at commit and reset uses new initials...OK
+11/37 core.test.capacity boundaries and sticky load errors...OK
+12/37 core.test.invalid declaration values state mappings event ids and handler ranges...OK
+13/37 core.test.backward self out-of-handler branches and stores without handlers are rejected...OK
+14/37 core.test.event inputs and dt enforce deterministic frame contract...OK
+15/37 core.test.binding evaluation is pure on both success and failure...OK
+16/37 core.test.arithmetic comparison logical min max and clamp expression semantics...OK
+17/37 core.test.diagnostic counters wrap without affecting scalar values...OK
+18/37 core.test.memory accounting separates reserved storage used IR and stack capacity...OK
+19/37 core.test.point coordinates are ephemeral event operands and unavailable to pure evaluation...OK
+20/37 core.test.point event ranges reject holes and invalid payloads without committing...OK
+21/37 core.test.point event failure rolls back writes from earlier matching handlers...OK
+22/37 core.test.loading shares storage with records while old events and bindings remain executable...OK
+23/37 core.test.failed and abandoned candidates restore empty reuse storage without changing active work...OK
+24/37 core.test.full pending program survives repeated evaluation configuration record loss and reset...OK
+25/37 core.test.restored definition and values commit together without changing reset initializers...OK
+26/37 core.test.missing duplicate nonfinite or invalid restored candidates leave active work untouched...OK
+27/37 core.test.empty and full restored programs validate all values and discard old display records...OK
+28/37 core.test.shared handler bodies access their own bound state slots in source order...OK
+29/37 core.test.failure in a later shared binding rolls back all earlier bindings...OK
+30/37 core.test.shared bindings reject missing arguments partial overlaps and invalid slot mappings...OK
+31/37 core.test.one routed handler changes only its event target and rejects missing or foreign routes...OK
+32/37 core.test.routes reject invalid mappings and bindings without replacing active work...OK
+33/37 core.test.routed event rollback includes globals and earlier handlers and survives staged route replacement...OK
+34/37 core.test.scalar inputs carry a finite ephemeral value without adding state or pointer ownership...OK
+35/37 core.test.scalar input failure rolls back earlier writes and old scalar events survive staging...OK
+36/37 evaluation.test.record loss cannot authorize an old memo; unrelated changes can...OK
+37/37 evaluation.test.budget, collisions, invalid settings, and disabled history are conservative...OK
+All 37 tests passed.
+Built /home/eunhhu/work/pure/prototypes/workbench/web/core.wasm (61,072 bytes)
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/node-tests.log b/prototypes/workbench/results/2026-09-23-web-layout/node-tests.log
new file mode 100644
index 0000000..5a5df79
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/node-tests.log
@@ -0,0 +1,95 @@
+✔ reference begins with the authored states and matching scene (12.66232ms)
+✔ press, hold, release, and frame order produce exact expected values (1.077864ms)
+✔ charge clamps at one and explicit floor restores rest (4.575512ms)
+✔ repeat and concurrent physical sources produce only logical edges (4.137847ms)
+✔ airborne presses cannot charge or cause another jump (0.449738ms)
+✔ releaseAll emits release and reset clears latches without a jump (1.114142ms)
+✔ unknown inputs and invalid frame counts reject without mutation (1.183864ms)
+✔ terminal starts empty and produces the same charge trace as the shared runtime (390.652434ms)
+✔ invalid terminal commands do not alter state and fail piped execution (233.630018ms)
+✔ terminal replays authored pointer movement and cancellation through the shared runtime (162.430096ms)
+✔ file load preserves by name, rejected load preserves active work, and save never overwrites (245.025646ms)
+✔ terminal exposes arithmetic rollback and retains failure through matching release (155.756539ms)
+✔ terminal configures and discards evaluation records and recovers from a display limit (113.106013ms)
+✔ terminal saves and restores placement independently of reset and preserves a browser draft (525.765513ms)
+✔ invalid restoration leaves terminal code and state unchanged (127.509012ms)
+✔ a blank document is an empty program with no implied behavior (8.64436ms)
+✔ forward references and separate state/input namespaces preserve declaration IDs (3.976626ms)
+✔ ordered assignments, repeated handlers, and condition evaluation once (1.714565ms)
+✔ nested branches use absolute forward targets and skip correct bodies (1.537677ms)
+✔ operator precedence, associativity, unary and builtin function lowering (4.025015ms)
+✔ multiline parentheses, scientific literals, comments, and string escapes (3.961885ms)
+✔ all instruction fields are numeric and carry original source line (1.301363ms)
+✔ contextual declaration words remain valid names outside reserved builtins (0.999902ms)
+✔ unknown and reserved symbols have useful one-based locations (3.873349ms)
+✔ duplicates, unsupported keys, initializers, and invalid syntax are rejected (3.797849ms)
+✔ document, declaration, instruction, and nesting limits reject overflow (21.576327ms)
+✔ every supported input maps to a bounded ordered event ID (4.487642ms)
+✔ optional examples compile and charge behavior is entirely authored (63.171839ms)
+✔ cache reuse, unrelated changes and discarded records preserve authored values (145.592017ms)
+✔ committed changes only: failed events and failed edits preserve reusable output (16.185689ms)
+✔ program edits, state reordering and reset cannot reuse old definitions (13.364686ms)
+✔ finite float semantics include signed zero, high state bit and arithmetic errors (11.375975ms)
+✔ one budget covers the whole scene; failures publish no partial scene or state rollback (6.433502ms)
+✔ small caches, no history and every small work limit give exact results or explicit failure (142.930942ms)
+✔ invalid settings and forget names preserve policy and state (18.528843ms)
+✔ pointer declarations resolve forward targets and reject ambiguous or contextless inputs (63.575855ms)
+✔ authored drag keeps grab offset and commits the displayed position even if release coordinates differ (47.043964ms)
+✔ ownership, duplicate downs, stale moves, cancellation and focus cleanup cannot leave held input (20.144926ms)
+✔ invalid coordinates and modes do not mutate a held gesture; arithmetic failure rolls back one event (24.907531ms)
+✔ independent targets share the event pipeline and publication remains non-reentrant (20.336407ms)
+✔ failing down, up and cancel never prevent terminal ownership cleanup (17.199536ms)
+✔ direct publication matches archived snapshots and work counts through authored changes (123.09694ms)
+✔ whole-scene budget failure never publishes partial bindings and recovers exactly (14.010757ms)
+✔ latched event rollback, nonfinite bindings, reset and signed zero keep their semantics (10.119575ms)
+✔ publication rejects reentrant runtime changes and reads before they affect the scene (11.072124ms)
+✔ invalid or throwing output adapters do not strand the runtime or alter authored state (5.967394ms)
+✔ multiple authored placements and a separate invalid draft survive a fresh runtime restore (83.580766ms)
+✔ malformed files reject before changing active code, values, diagnostics or warm display records (29.663338ms)
+✔ restoration matches names rather than ordering and never reuses stale cached values (7.112109ms)
+✔ finite float values including negative zero round trip without changing authored reset values (6.838574ms)
+✔ held and failed interactions cannot be saved and restoration cannot steal an active input (12.077971ms)
+✔ empty and full documents restore without imposing a new source size or state count (7.015221ms)
+✔ document APIs respect the output callback boundary and export independent values (5.109731ms)
+✔ one input and one handler serve up to 24 static targets without instruction or handler expansion (63.170859ms)
+✔ routes validate forward references, exact types, unique views/items and current input target (5.637618ms)
+✔ grouped layout matches explicit bindings through concurrent capture, partial cancel, keyboard and restore (62.651066ms)
+✔ capture remains bound to its target and releaseAll cancels each target exactly once (13.395149ms)
+✔ reorder and remove/readd rebuild routes by names without persisting physical indices (16.455874ms)
+✔ routed writes invalidate cached displays and failed handlers rollback without losing another target owner (7.482478ms)
+✔ CLI sends grouped pointer events and rejects an unknown target (355.285569ms)
+✔ a route from a different input group cannot be delivered through the named group (12.613062ms)
+✔ binding more objects shares one VM body instead of expanding its instructions (15.696248ms)
+✔ records and rules resolve forward names and reject invalid types fields and bindings (12.425414ms)
+✔ shared layout matches the existing authored layout across independent and simultaneous gestures (63.591654ms)
+✔ field names retain identity across schema and instance reorder, removal and restoration (15.784988ms)
+✔ bindings share one event transaction including global effects and multiple uses of one item (12.381266ms)
+✔ bound writes invalidate affected display caches and failed edits preserve active bindings (13.678055ms)
+✔ generated qualified names cannot make a valid exported document unreadable by its own loader (32.819469ms)
+✔ blank startup and applied empty source contain no application objects or behavior (40.046003ms)
+✔ author can add unrelated states inputs views and behavior entirely through source (21.605345ms)
+✔ keyboard and pointer sources aggregate first down and last up; repeats are ignored (9.64304ms)
+✔ held input rejects apply without changing applied source, metadata, or values (9.352079ms)
+✔ invalid syntax, unknown references and capacity overflow preserve the previous program (16.480058ms)
+✔ state preservation follows names across reorder/add/remove; reset uses new initializers (4.060756ms)
+✔ callers cannot mutate active metadata and corrupt name-based state preservation (17.230926ms)
+✔ conditions execute once, and assignments and multiple handlers follow source order (19.697116ms)
+✔ a failed event rolls back all handlers and keeps its source diagnostic through release (4.942696ms)
+✔ reset clears held inputs and latched errors; invalid frame counts have no effects (8.38588ms)
+✔ render evaluation is pure and invalid binding is distinct from an event rollback (26.878928ms)
+✔ independent adapters replay the same author program without sharing live state (17.047112ms)
+✔ Wasm has no imports and reports fixed memory and exact active IR/state storage (37.971164ms)
+✔ numeric controls use explicit change context and cannot read pointer or frame payloads (14.06335ms)
+✔ numeric input handles scalar rules, rollback, cache invalidation and signed zero (32.805007ms)
+✔ the document policy receives range, keyboard and pointer requests without layouts or text in VM state (12.342451ms)
+✔ CLI supplies the same numeric control value and rejects nonfinite input (206.351585ms)
+✔ Wasm executes old events and output during staged replacement without corrupting either phase (17.789348ms)
+✔ workspace accounting reports shared payload and safety tag without double-counting (9.700743ms)
+ℹ tests 87
+ℹ suites 0
+ℹ pass 87
+ℹ fail 0
+ℹ cancelled 0
+ℹ skipped 0
+ℹ todo 0
+ℹ duration_ms 2402.223363
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/pure-document-desktop.png b/prototypes/workbench/results/2026-09-23-web-layout/pure-document-desktop.png
new file mode 100644
index 0000000..a2e1092
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/pure-document-desktop.png
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/pure-document-mobile.png b/prototypes/workbench/results/2026-09-23-web-layout/pure-document-mobile.png
new file mode 100644
index 0000000..1565cd2
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/pure-document-mobile.png
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/pure-document-saved.txt b/prototypes/workbench/results/2026-09-23-web-layout/pure-document-saved.txt
new file mode 100644
index 0000000..8681227
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/pure-document-saved.txt
@@ -0,0 +1,3 @@
+표준 UTF-8 파일
+외부 편집기에서 만든 내용
+😀 <article>문자</article>
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/qa-authoring.js b/prototypes/workbench/results/2026-09-23-web-layout/qa-authoring.js
new file mode 100644
index 0000000..37f56a0
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/qa-authoring.js
@@ -0,0 +1,229 @@
+// Run this expression with Playwright CLI run-code after opening the local host.
+// All behavior is authored through the editor; snapshot() only observes results.
+async (page) => {
+ const checks = [];
+ const errors = [];
+ const onError = error => errors.push(error.message);
+ const acceptDialog = dialog => dialog.accept();
+ const check = (condition, label) => {
+ if (!condition) throw new Error(`Check ${checks.length + 1} failed: ${label}`);
+ checks.push(label);
+ };
+ const state = () => page.evaluate(() => window.pureWorkbench.snapshot());
+ const apply = async text => { await page.locator('#source').fill(text); await page.locator('#apply').click(); };
+ const authorSource = [
+ '# A new authored interaction, not a bundled example.',
+ 'state x = 90', 'state moving = 0', 'state count = 0',
+ 'input advance = ArrowRight', 'input pulse = Space',
+ 'box marker(x, 170, 48, 48, "#7ed9ae")',
+ 'text reading(40, 70, count, "Signals: ")',
+ 'on advance.down { moving = 1 }', 'on advance.up { moving = 0 }',
+ 'on pulse.down { count = count + 1 }',
+ 'on frame { x = clamp(x + moving * 120 * dt, 0, 580) }', '',
+ ].join('\n');
+ let lastAuthored = null;
+ let mobile = null;
+ let downloaded = null;
+ const shots = ['output/playwright/pure-document-authoring-blank.png', 'output/playwright/pure-document-authoring-authored.png', 'output/playwright/pure-document-authoring-mobile.png'];
+ page.on('pageerror', onError);
+ page.on('dialog', acceptDialog);
+ try {
+ await page.goto('http://127.0.0.1:8766/');
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.reload();
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ let s = await state();
+ check(s.source === '' && s.appliedSource === '' && !s.dirty, 'initial editor has literally empty source');
+ check(s.views.length === 0 && Object.keys(s.states).length === 0 && s.held.length === 0 && !s.running, 'initial scene has no built-in entities, states, inputs, or running clock');
+ check(await page.locator('#input-controls button').count() === 0 && await page.locator('#play').isDisabled(), 'controls require an authored program');
+ await page.screenshot({ path: shots[0], fullPage: true });
+
+ await page.locator('#source').fill(authorSource);
+ s = await state();
+ check(s.dirty && s.views.length === 0 && s.appliedSource === '', 'authoring a new program is a draft until Apply');
+ await page.locator('#apply').click();
+ s = await state();
+ check(!s.dirty && s.appliedSource === authorSource && s.views.length === 2 && s.states.x === 90 && s.states.count === 0, 'new custom program constructs states and multiple views from source');
+ check(await page.locator('#input-controls button').count() === 2 && await page.locator('[data-input="advance"]').count() === 1 && await page.locator('[data-input="pulse"]').count() === 1, 'input buttons are generated from the authored declarations');
+ check(await page.locator('#scene-content [data-view-id="marker"]').getAttribute('x') === '90', 'view bindings render the authored state value');
+
+ await page.locator('#source').focus();
+ await page.keyboard.press('End');
+ await page.keyboard.press('Space');
+ await page.keyboard.press('ArrowRight');
+ check((await state()).states.count === 0 && (await state()).states.moving === 0, 'Space and arrows in the editor never reach authored inputs');
+ await page.locator('#source').fill(authorSource);
+
+ const pulse = page.locator('[data-input="pulse"]');
+ let box = await pulse.boundingBox();
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ s = await state();
+ check(s.states.count === 1 && s.held.includes('pulse') && !s.running && s.tick === 0, 'pointer down dispatches once while the clock is paused');
+ await page.mouse.up();
+ check((await state()).held.length === 0 && (await state()).states.count === 1, 'pointer up releases without double dispatch from click');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('Space');
+ await page.keyboard.down('Space');
+ await page.keyboard.up('Space');
+ check((await state()).states.count === 2, 'keyboard repeat is ignored');
+ await pulse.focus();
+ await page.keyboard.press('Enter');
+ check((await state()).states.count === 3 && (await state()).held.length === 0, 'keyboard activation on a generated button is one complete tap');
+ await pulse.dispatchEvent('click', { detail: 0 });
+ check((await state()).states.count === 4 && (await state()).held.length === 0, 'assistive click activation is one complete tap');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('Space');
+ box = await pulse.boundingBox();
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ check((await state()).states.count === 5 && (await state()).held.includes('pulse'), 'keyboard and pointer sources share one logical input');
+ await page.mouse.up();
+ check((await state()).held.includes('pulse'), 'releasing one source does not release the other source');
+ await page.keyboard.up('Space');
+ check((await state()).held.length === 0, 'last source releases the logical input');
+
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ await page.locator('#step').click();
+ await page.locator('#step').click();
+ s = await state();
+ check(s.states.x === 94 && s.states.moving === 1 && s.tick === 2, 'authored down/frame behavior uses the declared dt without built-in motion');
+ await page.locator('#source').focus();
+ await page.keyboard.up('ArrowRight');
+ check((await state()).states.moving === 0 && (await state()).held.length === 0, 'keyup releases even after focus moves into the editor');
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ await page.evaluate(() => window.dispatchEvent(new Event('blur')));
+ await page.keyboard.up('ArrowRight');
+ check((await state()).states.moving === 0 && !(await state()).running && (await state()).held.length === 0, 'focus loss sends release and pauses the clock');
+
+ await page.locator('#play').click();
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().tick >= 5);
+ await page.locator('#play').click();
+ const paused = await state();
+ await page.waitForTimeout(100);
+ check(!paused.running && paused.tick === (await state()).tick && paused.tick >= 5, 'Play advances and Pause freezes the clock');
+
+ const beforeFailure = await state();
+ await apply(authorSource + 'state broken =\n');
+ s = await state();
+ check(s.appliedSource === authorSource && s.revision === beforeFailure.revision && JSON.stringify(s.states) === JSON.stringify(beforeFailure.states), 'invalid code preserves the previous executable program and states');
+ check(await page.locator('#error-location').isVisible() && /행/.test(s.diagnostic) && await page.locator('#source').getAttribute('aria-invalid') === 'true', 'compile error exposes a source-line diagnostic');
+ await page.locator('#error-location').click();
+ check(await page.locator('#source').evaluate(el => el.selectionStart >= el.value.indexOf('state broken')), 'diagnostic location moves the editor caret to the problem');
+
+ const expanded = authorSource.replace('state x = 90', 'state x = 500').replace('count = count + 1', 'count = count + 5') + [
+ 'state bonus = 7', 'input reverse = KeyA',
+ 'circle beacon(300, 190, 15 + bonus, "#c9f08b")',
+ 'on reverse.down { bonus = bonus + 2 }', '',
+ ].join('\n');
+ await apply(expanded);
+ s = await state();
+ check(s.states.x === beforeFailure.states.x && s.states.count === 5 && s.states.bonus === 7 && s.views.length === 3, 'apply preserves matching state names and initializes newly authored states');
+ await page.locator('[data-input="reverse"]').click();
+ check((await state()).states.bonus === 9 && await page.locator('#scene-content [data-view-id="beacon"]').getAttribute('r') === '24', 'new input, entity, binding, and behavior work with source changes alone');
+ await page.locator('[data-input="pulse"]').click();
+ check((await state()).states.count === 10, 'changed behavior takes effect without resetting existing values');
+
+ const withDraft = expanded + '# This un-applied draft must survive Reset.\n';
+ await page.locator('#source').fill(withDraft);
+ await page.locator('#reset').click();
+ s = await state();
+ check(s.states.x === 500 && s.states.count === 0 && s.states.bonus === 7 && s.tick === 0 && s.source === withDraft && s.dirty, 'explicit Reset restores active initializers and keeps the draft');
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ const heldRevision = (await state()).revision;
+ await page.locator('#apply').click();
+ check((await state()).revision === heldRevision && /놓은/.test((await state()).diagnostic), 'Apply is refused while an input is held');
+ await page.keyboard.up('ArrowRight');
+
+ const downloadPromise = page.waitForEvent('download');
+ await page.locator('#download-project').click();
+ const projectDownload = await downloadPromise;
+ const downloadPath = 'output/playwright/pure-document-authoring-draft.json';
+ await projectDownload.saveAs(downloadPath);
+ downloaded = { filename: projectDownload.suggestedFilename(), path: downloadPath, expectedSource: withDraft };
+ await page.locator('#new').click();
+ s = await state();
+ check(s.source === '' && s.views.length === 3 && s.appliedSource === expanded, 'clear editor is explicit and does not clear the running scene before Apply');
+ await page.locator('#file-input').setInputFiles(downloadPath);
+ await page.waitForFunction(expected => window.pureWorkbench.snapshot().source === expected, withDraft);
+ check((await state()).source === withDraft && (await state()).appliedSource === expanded && (await state()).dirty, 'downloaded project restores the exact un-applied draft without execution');
+ await page.locator('#file-input').evaluate(input => {
+ const transfer = new DataTransfer();
+ transfer.items.add(new File(['{"format":"other","version":1,"source":"state no = 1"}'], 'invalid.json', { type: 'application/json' }));
+ input.files = transfer.files;
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ });
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().diagnostic.includes('형식'));
+ check((await state()).source === withDraft && (await state()).appliedSource === expanded, 'invalid JSON project schema cannot replace the draft');
+
+ await apply('state n = 1\ninput fail = Space\ntext label(40, 60, n)\non fail.down { n = n / 0 }\n');
+ await page.locator('[data-input="fail"]').dispatchEvent('click', { detail: 0 });
+ s = await state();
+ check(s.states.n === 1 && s.lastError === 4 && s.held.length === 0 && s.lastEvent.phase === 'up', 'failed assistive down event rolls back and its error survives the following successful up');
+ check(await page.locator('#runtime-error').isVisible() && (await page.locator('#runtime-error').textContent()).includes('마지막 실패'), 'latched runtime failure is visible and labeled separately from the latest input');
+ await page.locator('#reset').click();
+ check((await state()).lastError === 0 && await page.locator('#runtime-error').isHidden(), 'explicit Reset clears the failure record');
+
+ await page.locator('#new').click();
+ await page.locator('#apply').click();
+ s = await state();
+ check(s.source === '' && s.appliedSource === '' && s.views.length === 0 && Object.keys(s.states).length === 0 && await page.locator('#input-controls button').count() === 0, 'applying empty source removes all authored states, inputs, and scene contents');
+ await page.locator('#example').selectOption('counter');
+ await page.locator('#load-example').click();
+ await page.waitForFunction(() => window.pureWorkbench.snapshot().source.includes('state count'));
+ check((await state()).views.length === 0 && (await state()).appliedSource === '' && (await state()).dirty, 'explicit example loading only fills the editor and never auto-applies');
+
+ await apply(authorSource);
+ await page.locator('[data-input="pulse"]').click();
+ await page.locator('[data-input="pulse"]').click();
+ await page.locator('#scene').focus();
+ await page.keyboard.down('ArrowRight');
+ for (let i = 0; i < 12; i++) await page.locator('#step').click();
+ await page.keyboard.up('ArrowRight');
+ lastAuthored = await state();
+ check(lastAuthored.states.x === 114 && lastAuthored.states.count === 2 && lastAuthored.memory.linearBytes === 65536, 'authored scene executes in the actual fixed 64 KiB Wasm memory');
+ await page.locator('.cost-details summary').click();
+ check((await page.locator('#cost-values').textContent()).includes('65,536 B'), 'cost inspector shows measured Wasm memory without claiming browser heap totals');
+ await page.locator('.cost-details summary').click();
+ await page.screenshot({ path: shots[1], fullPage: true });
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ mobile = await page.evaluate(() => ({ viewport: innerWidth, content: document.documentElement.scrollWidth }));
+ check(mobile.content <= mobile.viewport, '390px mobile viewport has no horizontal overflow');
+ await page.locator('[data-input="pulse"]').click();
+ check((await state()).states.count === 3, 'generated mobile input buttons remain usable');
+ await page.screenshot({ path: shots[2], fullPage: true });
+ const comparison = await page.request.get('http://127.0.0.1:8766/baseline.html');
+ const equivalent = await page.request.get('http://127.0.0.1:8766/examples/charge.pure');
+ check(comparison.ok() && equivalent.ok() && await page.locator('a[href="./baseline.html"]').count() === 1, 'ordinary-JavaScript comparison and equivalent Pure example are linked and served');
+
+ await page.route('**/core.wasm', route => route.abort());
+ await page.reload();
+ await page.waitForFunction(() => document.getElementById('ready-status').textContent.includes('실패'));
+ check(await page.locator('#apply').isDisabled() && await page.locator('#play').isDisabled() && await page.locator('#step').isDisabled() && /실행기를 열지/.test(await page.locator('#diagnostic').textContent()), 'missing Wasm shows an actionable load failure and disables execution controls');
+ await page.unroute('**/core.wasm');
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.reload();
+ await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready);
+ check((await state()).source === '' && (await state()).views.length === 0, 'fresh load always returns to a blank authoring environment');
+ check(errors.length === 0, 'no uncaught page errors during the browser scenarios');
+ const evidence = { recordedUtc: new Date().toISOString(), browser: await page.evaluate(() => navigator.userAgent),
+ checks, authoredSource: authorSource, authoredSnapshot: lastAuthored, mobile, downloaded, errors, screenshots: shots };
+ // CLI can yield early when native dialogs/download events occur. Retain the
+ // observation report so a follow-up CLI call can retrieve the complete run.
+ await page.evaluate(value => { window.__pureWorkbenchQAEvidence = value; }, evidence);
+ return evidence;
+ } catch (error) {
+ const evidence = { failed: true, error: error.message, stack: error.stack, checks, errors };
+ await page.evaluate(value => { window.__pureWorkbenchQAEvidence = value; }, evidence);
+ return evidence;
+ } finally {
+ page.off('pageerror', onError);
+ page.off('dialog', acceptDialog);
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/qa-value-control.js b/prototypes/workbench/results/2026-09-23-web-layout/qa-value-control.js
new file mode 100644
index 0000000..a5a3eb1
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/qa-value-control.js
@@ -0,0 +1,12 @@
+async page => {
+ const checks=[],check=(ok,label)=>{if(!ok)throw new Error(label);checks.push(label);};
+ await page.waitForFunction(()=>window.pureWorkbench?.snapshot().ready);
+ await page.locator('#source').fill('state amount=0\ninput level=value\ntext label(20,40,amount)\non level.change {amount=clamp(event_value,0,100)}');
+ await page.locator('#apply').click();
+ await page.getByLabel('level',{exact:true}).fill('123');await page.getByRole('button',{name:'값 전달',exact:true}).click();
+ let s=await page.evaluate(()=>window.pureWorkbench.snapshot());check(s.states.amount===100&&s.lastEvent.phase==='change','numeric control dispatches change and publishes the authored result');
+ check(s.held.length===0,'numeric controls do not create a held pointer owner');
+ await page.getByLabel('level',{exact:true}).fill('-5');await page.getByRole('button',{name:'값 전달',exact:true}).click();
+ s=await page.evaluate(()=>window.pureWorkbench.snapshot());check(s.states.amount===0,'the same authored clamp handles negative requests');
+ await page.evaluate(e=>window.__pureValueEvidence=e,{checks});return{checks};
+}
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/sources/CONTRACT.md b/prototypes/workbench/results/2026-09-23-web-layout/sources/CONTRACT.md
new file mode 100644
index 0000000..e4873be
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/sources/CONTRACT.md
@@ -0,0 +1,568 @@
+# Workbench experiment contract
+
+An author starts with empty source. There is no built-in player, physics, jump,
+score, or timer behavior. All behavior is declared by the author. This is a
+bounded scalar interaction language experiment, not a general language claim.
+
+## Source language
+
+```text
+state count = 0
+input tap = Space
+box tile(80, 150, 60, 60, "#356548")
+circle dot(200, 180, 20, "#c78638")
+text label(80, 100, count, "Count: ")
+on tap.down {
+ count = count + 1
+}
+on frame {
+ if count > 20 {
+ count = 0
+ }
+}
+```
+
+Declarations and statements end at a newline or semicolon; `#` comments outside
+strings. ASCII identifiers, finite numeric literals, state initializers must be
+numeric constants (optional unary sign). Input key codes accepted:
+Space, Enter, ArrowLeft/Right/Up/Down, KeyA..KeyZ, Digit0..Digit9. Input names
+must be unique, key codes unique, names cannot shadow `frame`, `dt`, `min`, `max`,
+`clamp`, `event_x`, `event_y`. State and input names use separate namespaces, entity IDs unique.
+
+Expressions: parentheses, state references, dt (seconds on frame, zero on input),
+unary - and !, + - * /, < <= > >= == !=, && ||, min(a,b), max(a,b), clamp(x,lo,hi).
+Logical operators evaluate both sides. Division by zero/non-finite results fail
+the whole event without committing state changes. No loops, recursion or eval.
+An if without else executes its condition once; assignment statements are ordered.
+Multiple handlers for the same event execute in source order within one atomic event.
+`dt` is allowed in handlers, not view bindings. A frame always uses 1/60 seconds.
+
+box: x,y,width,height expressions + literal hex color; circle: x,y,radius + color;
+text: x,y,value expression + optional literal prefix. Values are world units in a
+640x360 view. No implicit clipping/collision/physics. Render expressions are pure.
+Unknown identifiers and invalid syntax include 1-based line+column diagnostics.
+
+Initial scene is empty. Optional examples are explicit insertions, never auto-run.
+Successful apply preserves scalar states by exact name; new names use initializers,
+removed names disappear. Apply/reset are separate. Failed parse/load changes nothing.
+Apply is refused while any input is held; release first. This avoids inventing
+release events across changed mappings. Focus loss sends .up to ordinary buttons
+and .cancel to pointer inputs.
+
+## Compiler interface (web/compiler.mjs, browser and Node)
+
+`compile(source)` returns:
+```
+{
+ states:[{name,initial,line}], inputs:[{name,key,line} | {name,target,line} | {name,type,routes,line}],
+ instructions:[{op,arg,value,line}], handlers:[{event,start,length,line}],
+ views:[{id,type,line,color?,prefix?,bindings:{x:{start,length},...}}],
+ pointerTargets:[{input,base,count}], metrics:{...}
+}
+```
+Bindings: box x/y/width/height; circle x/y/radius; text x/y/value.
+Compiler throws an Error with .line and .column. Empty source is a valid empty program.
+Handlers and binding expressions all occupy instructions (maximum 512 total), but
+only handlers execute on events; bindings are evaluated on demand by VM eval_at.
+Limits: 32 states, 8 inputs, 24 views, 64 handlers, 512 instructions, stack 64,
+source 32768 UTF-8 bytes. State IDs follow declaration order. Input event IDs:
+frame=0; input i down=1+2*i, up=2+2*i, move=17+i, cancel=25+i.
+Move/cancel handlers require a pointer input in source. States may be referenced before declaration;
+symbols must be resolved after gathering top-level declarations. `metrics` can
+record source bytes/lines and instruction/handler/view/state counts.
+
+## VM opcodes
+
+| op | operation |
+|---:|---|
+|0| push constant `value` |
+|1| push state `arg` |
+|2| push dt |
+|3..6| add, subtract, multiply, divide |
+|7| negate |
+|8..13| less, less-equal, greater, greater-equal, equal, not-equal |
+|14..16| AND, OR, NOT (nonzero true) |
+|17..19| min, max, clamp (push x,lo,hi; invalid lo>hi fails) |
+|20| store state `arg` (pop) |
+|21| jump-if-zero to absolute instruction index `arg` (pop), forward only |
+|22,23| push current pointer event x,y; fail without point context |
+|24,25| load/store field at handler parameter_base + arg; handler-bound only |
+
+Rules start with an empty stack and must end with empty stack. Bindings must finish
+with exactly one value and cannot STORE, branch, or read dt/event coordinates. Branch targets may be handler end.
+
+## Zig exported ABI
+
+`reset_all()` clears programs and state. `begin_program(state_count,input_count)`
+starts staging, returns0 success; `put_state(id,initial,old_id)` old_id=-1 means init;
+`put_instruction(op,arg,value)` append; `put_handler(event,start,length)` append;
+`commit_program()` all0 success otherwise error code, atomic; candidate is separate
+from the active program, but shares its workspace with optional evaluation records.
+Preserved IDs read active values only at successful commit. Validate limits,
+indices, finite values and forward branch ranges; runtime stack errors are checked.
+`reset_state()` restores active initializers, resets tick/event diagnostics.
+`dispatch(event,dt)` accepts0..16 and finite dt; event0 dt must equal1/60, input dt0;
+returns0 success, other code failure with no state mutation. Tick increments for
+successful frame; one event runs all matching handlers in source order.
+`dispatch_point(event,x,y)` accepts existing input down/up IDs plus move/cancel IDs
+within input_count, never frame0. Coordinates must be finite; dt is implicitly0.
+The core only knows input count, not host device kinds. The compiler and host enforce
+pointer ownership and device mapping. Calling legacy dispatch on a coordinate-reading
+handler fails with bad_event9. Point operands are ephemeral execution context, not
+extra persistent state slots. Both dispatch paths use the same atomic commit/history.
+The host requires this new export; older Wasm must be rebuilt.
+`eval_at(start,length)` returns f64 or NaN on invalid/pure-expression failure;
+must not mutate state or event diagnostics. `get_state(id)` returns f64 orNaN.
+`info(id)` u32: 0 tick,1 last_error,2 error_pc (0xffffffff none),3 last_executed_ops,
+4 revision,5 state_count,6 instruction_count,7 handler_count,8 input_count,
+9 successful_event_count. `layout(id)` u32: 0 Engine reserved @sizeOf,
+1 Program reserved @sizeOf,2 Instruction @sizeOf,3 Handler @sizeOf,
+4 active used IR bytes (instructions+handlers+pointer targets),5 active state-value bytes,
+6 staging reserved bytes (subset of Engine),7 evaluation stack capacity bytes,8 pointer target entry bytes,9 active pointer target count.
+`error_message` belongs to host map; core error codes documented in core.zig.
+No allocation or imported runtime in Wasm. Fixed64KiB linear memory target with
+16KiB stack; increase only if actual linker evidence requires and document it.
+
+## Host interface (web/runtime.mjs)
+
+`await createRuntime(wasmBytes?, evaluationOptions?)` fetches core.wasm in browser if omitted.
+Returns object with apply(source), reset(), frame(count=1), down(name,source),
+up(name,source), point(name,phase,x,y,source,target?), releaseAll(), snapshot(), exportProject(), currentProgram.
+Multiple sources per input share one logical held state; down emits only first
+source, up only last. Source strings identify keyboard vs onscreen pointer.
+`apply` returns compiled program or throws with diagnostic. It must fully validate
+in host and stage+commit in core. It preserves by name. `reset` keeps active program.
+`snapshot` returns {states:{name:value}, views:[{id,type,x,y,width?,height?,radius?,
+value?,prefix?,color?}], tick,revision,lastError,errorLine,lastOps,memory:{...},held:[]}
+`currentProgram` is the latest successfully applied compiled program or null.
+The program and all nested metadata are frozen so readers cannot corrupt state
+name mappings or binding ranges. `lastFailure` records the last failed event;
+its `lastError`/line remain visible through successful release or focus cleanup
+until successful apply or explicit reset. `lastEvent` still describes the latest
+event. A view evaluation failure uses host error100 and does not roll back events.
+UI draft and applied code are distinct. Browser/CLI share compiler/runtime/wasm;
+instances are separate, not a live collaborative session. No arbitrary JS evaluation.
+
+## Bounded display evaluation (2026-09-22)
+
+The existing event and float semantics above are unchanged. The forgetful C
+prototype's contract is adapted into `evaluation.zig`; its uint64 DAG is not
+used as a substitute for this VM's float instructions.
+
+Each snapshot starts one display budget, shared by all bindings. Default options
+are `{cacheEntries:0, historyEntries:8, workLimit:2048}`. A unit is a binding
+request, a candidate-cache probe, a change-record probe, or an executed VM
+instruction. State extraction, host object creation, event dispatch and DOM work
+are outside this budget. It is not a time limit or a bound on whole-app work.
+For the current compiler limits, 96 bindings * 10 units plus 512 instructions
+is at most 1,472 units; the default admits even an uncached valid scene with
+cache lookup/validation enabled. Malformed bindings may fail sooner.
+
+`configureEvaluation({cacheEntries?,historyEntries?,workLimit?})` accepts integers
+0..32, 0..8, and 0..4294967295 respectively. Missing fields retain prior settings.
+Valid configuration clears reuse records; invalid configuration changes nothing.
+`forgetEvaluation('values'|'history'|'all')` drops optional records without
+changing authored state, event diagnostics or program identity.
+`evaluationStats()` returns the options plus work, instructions, cacheHits for
+the last snapshot, and reservedBytes for the complete fixed evaluation structure.
+Statistics are separate from snapshots so repeated observations remain equivalent.
+
+Memo entries contain the value, instruction range, input dependency bit mask
+and validated revision. Direct mapping uses one candidate probe, with collisions
+causing recomputation. Only successful computations are cached. Constant results
+need no input-change history. All other reuse requires a complete retained
+history since validation and no intersection with changed states. Dependencies
+are collected from actual VM LOADs, not trusted compiler-supplied annotations.
+History loss causes recomputation. All history and memo storage is fixed size;
+setting capacities to zero does not return reserved linear memory.
+
+Only bitwise-different committed states advance change history (including signed
+zero). Failed events and net-zero updates do not. Successful apply and reset
+discard all reuse records. Parse/host validation failure before staging preserves
+records. Beginning core staging discards them, including if that load is later
+rejected or aborted. Epoch wrap also clears records. No inputs or effectful event
+handlers are replayed to rebuild a binding.
+
+Budget exhaustion returns an empty views array for the entire snapshot, with
+displayError `{code:101,line,message}`. Successfully committed event state remains.
+There is no automatic retry or silent return of an old scene. Raising the budget
+allows a fresh attempt. Arithmetic/size errors retain the earlier per-view error100
+behavior. `displayError` is null on display success; a latched execution error
+still takes precedence in lastError/errorMessage, while displayError independently
+reports display failure. Partial successful binding memos may survive a failed
+snapshot but no partial scene is published.
+
+New core exports: `configure_evaluation(cache,history)` returns 0 or capacity1;
+`forget_evaluation(flags)` bits 1=values, 2=history; `begin_evaluation(limit)`;
+`eval_bounded(start,length)` returns f64 or NaN and consumes that shared budget.
+`evaluation_info(id)` returns 0 status (10=work limit), 1 work, 2 instructions,
+3 cache hits, 4 cache capacity, 5 history capacity, 6 reserved bytes. These
+diagnostics do not replace event info. The unbounded pure `eval_at` remains for
+reference checks and is not the host snapshot path.
+
+## Shared load/reuse workspace (2026-09-23)
+
+The pending definition/restore values and evaluation Tables occupy one untagged union. The active
+Program, current state and evaluation control fields remain separate. All core
+calls are single-threaded and synchronous; no callback can reenter a phase change.
+No pointer to the reused tables is retained between calls.
+
+`begin_program` switches the workspace to staging and drops optional records.
+Until commit/abort, old events and bindings still run against active. Binding
+evaluation bypasses reuse and recomputes within its budget; cache configuration,
+forget and reset must not write the staging payload. Old-id state preservation
+reads the current values at successful commit, including events/reset during load.
+
+Successful commit publishes the new Program and then initializes empty Tables in
+the shared workspace. `abort_program()` discards a pending definition and restores
+empty Tables without changing active program/state/event diagnostics. Inactive
+abort is a no-op. A failed commit leaves staging open for explicit abort or the
+next begin. An invalid begin closes any previous pending load. Host apply aborts
+after a core loading failure. Lost reuse may make a previously warm tiny budget
+insufficient; neither a preserved cache nor completion at that budget is promised.
+
+`evaluation_info` additionally reports 7 dedicated control bytes, 8 shared Tables
+bytes, 9 reuse available (false during load), and 10 union safety-tag overhead.
+The host exposes these as dedicatedBytes, sharedBytes, reuseAvailable and
+workspaceTagBytes. reservedBytes is control + Tables capacity, not incremental
+Engine storage. The Tables capacity overlaps stagingReservedBytes and must not
+be added to it. Before document restoration was added, the Wasm control was 48 B, shared Tables
+1,152 B, union overhead 8 B. That Engine was 19,128 B, only 56 B larger than the
+19,072 B pre-evaluation core; linear memory remains 65,536 B. No OS pages are
+returned and this is not a measurement of browser/process memory.
+
+## Direct output publication (2026-09-23)
+
+`present(target)` evaluates the same bindings, in the same order and shared
+budget as `snapshot()`, then calls a synchronous trusted output adapter:
+
+- `begin(program)`: the frozen current definition, or null at startup.
+- `state(index, value)`: each current authored state, without a host state map.
+- `view(index, valid)`: every declared view's publication status.
+- `value(viewIndex, bindingName, value)`: each binding of a valid view only.
+- `end(visibleCount)`: number of valid views.
+
+It returns the snapshot's diagnostics, held inputs, event information and memory
+report, without `states` or `views`. Evaluation finishes before the first adapter
+callback. On budget error101 every view is invalid and no binding values are
+published; committed state values are still published. Error100 invalidates only
+the affected views, as before. No stale value is silently substituted.
+
+Runtime mutations, `snapshot()` and nested `present()` are rejected during
+callbacks. Read-only program/export/statistics access remains available. Adapters
+must be synchronous and must not throw. If one does throw, the exception
+propagates and the runtime unlocks in a finally block; arbitrary external adapter
+effects cannot be rolled back. A later call republishes every value. This is not
+a transaction across arbitrary output devices or an untrusted plugin boundary.
+
+Evaluation uses a private reusable Float64Array(96) and Uint8Array(24), allocated
+on first snapshot/presentation. Their payload totals 792 host bytes, outside the
+64 KiB Wasm memory. Typed-array object overhead, binding-entry arrays built at
+apply, and DOM destination references are additional host memory. The numeric
+buffer is a publication barrier, not another authoritative state or public
+mutable scene. `snapshot()` remains an independent object representation for
+CLI/export/debugging, built only when explicitly requested.
+
+The browser calls `present()` for normal refreshes. Its output adapter binds DOM
+destinations once per successful definition change and compares values with the
+existing DOM before writing. Input refreshes reuse scene, state and cost elements.
+Invalid views remain allocated but are hidden using SVG `display="none"`; recovery
+reveals them. A definition change can replace all scene/state destinations. This
+does not skip unchanged binding evaluation, eliminate all host allocations, or
+provide measured process-memory/paint-latency savings.
+
+## Authored direct manipulation (2026-09-23)
+
+`input drag = pointer(tile)` binds a logical input to a declared view, including a
+forward declaration. One input per target; it shares the existing 8-input limit
+with keyboard inputs. `on drag.down/move/up/cancel` handlers may read `event_x`
+and `event_y` in the same 640x360 world coordinates as output. They are unavailable
+in keyboard/frame handlers and display bindings; copy to authored state to retain
+them. There is no built-in drag constraint, snap, grab offset, or gesture rollback.
+The direct.pure example authors these using ordinary assignments and expressions.
+
+Host `point` requires a pointer input, valid phase, finite coordinates and nonempty
+source string. Each logical input has one owning source; different targets may
+have independent active contacts. Same-owner repeated down is a no-op; another
+owner's down throws. Move/up/cancel without that owner are no-ops. Down establishes
+ownership before dispatch; up/cancel release it before dispatch, even if authored
+arithmetic fails. An event failure rolls back that event's state writes, not the
+whole gesture. `releaseAll` cancels owned pointers at their last delivered position,
+then releases ordinary buttons. `reset` directly clears ownership and resets state.
+Last point events include x,y. A failed cancel can leave authored state unchanged,
+but cannot strand host ownership; the failure remains visible until reset/apply.
+
+Browser hit testing selects the declared target on primary-button pointerdown.
+The stable SVG root captures that pointer, including movement/release outside the
+scene. Client coordinates are transformed through the inverse SVG screen matrix.
+Pointerup exposes its own final coordinates to authored handlers. The direct.pure
+example updates position only on move and ends dragging on up, preserving the
+last displayed position even if a host repeats stale coordinates on release. Termination is observed in the window capture phase, including releases outside
+the SVG. A primary mouseup can finish an owned mouse gesture if pointerup was
+not delivered; it cannot finish a touch gesture. Duplicate termination is ignored.
+Only capture loss of the SVG root ends its owner, not a bubbled descendant event.
+Pointercancel, lostpointercapture and
+focus loss use the last delivered position and dispatch cancel, never fake up.
+Missing/non-invertible coordinate transforms cancel an active gesture. A second
+contact on the same target is ignored; contacts on different targets are independent.
+Touch scenes disable native panning on the SVG. Mouse/touch share this adapter;
+pen-specific pressure, tilt and button meanings are not implemented or validated.
+
+Every delivered pointermove executes synchronously; this layer adds no event queue,
+coalescing, frame gating or newest-only drop policy. This is not a guarantee that
+browsers expose every hardware sample. Keyboard commands and pointer coordinates
+enter the same authored state/commit/output path. CLI `point NAME PHASE X Y` uses
+that host API and core with source `terminal`; it does not replay browser hit testing.
+
+
+## Placement documents (2026-09-23)
+
+Code-only version1 JSON and .pure files keep their earlier behavior. Version2
+stores a restorable authored document, not a suspended execution image:
+
+```json
+{"format":"pure-workbench","version":2,"source":"state x=80","draft":"state x=80",
+ "states":[{"name":"x","value":350}]}
+```
+
+`source` is the applied definition; optional `draft` is separate unapplied editor
+text (default source). Each is at most 32768 UTF-8 bytes. Draft syntax may be
+unfinished. State records must contain exactly one finite value for every declared
+state name, with no extra, missing or duplicate names. Record order is irrelevant.
+JSON numbers preserve finite f64 values, except negative zero which is encoded as
+the exact string `"-0"`. No other numeric string is accepted. Input latches, running
+clock, diagnostics, caches, DOM, compiled IR and external resources are not saved.
+The serialized input file limit is 1449984 bytes, including escaped source/draft
+and up to 32 generated item.field names (which can repeat a long schema name).
+
+`exportDocument(draft?)` returns an independent version2 object. It refuses an
+unapplied runtime, held input or latched execution failure. It reads core state,
+without constructing an output snapshot. `restoreDocument(document)` validates
+and compiles before staging, refuses held input, then commits the definition and
+all saved values together. It returns `{source,draft}`. Failed validation leaves
+active program/values/diagnostics intact. A failed core load is aborted; optional
+reuse records may be lost as with ordinary apply. Successful restore discards old
+reuse, clears diagnostics, resets tick/event count and starts with no held inputs.
+The original source initializers remain intact: explicit reset restores those,
+not saved values. Existing `apply` continues to preserve current values by name.
+Both new document APIs reject reentry during output publication.
+
+Core exports `put_restored_value(id,value)` and `commit_restored_program()` extend
+the existing begin/put/commit loader. Every declared state needs exactly one finite
+restored value; duplicate/out-of-range/nonfinite input poisons staging. Restored
+commit checks completeness and program validity before touching active state.
+Ordinary commit rejects staged restored values. Pending values live alongside the
+pending Program in the same union as optional evaluation Tables; active Program
+layout does not grow. `layout(6)` now includes Pending, rather than Program only.
+Before shared rule bindings, the Wasm Pending was 9656 B (264 B added for 32 values, mask and alignment),
+Engine was 19392 B; linear memory remains fixed at 65536 B. These are build-layout
+facts, not a browser RAM or performance improvement claim. `evaluation_info(10)`
+continues to report only union tag overhead, excluding restoration payload.
+
+Browser **배치 저장** stores applied source, all current values and editor draft.
+It requires a stopped clock. **파일 열기** restores version2 after replacement
+confirmation when existing work is present; it starts stopped. Version1/plain
+source still only replaces the editor draft. Async reading checks draft generation,
+program identity and successful event count so intervening edits/inputs cannot be
+overwritten by a stale read. Invalid files do not alter the working document.
+**.pure 저장 / 코드 JSON 저장** remain draft-only exports.
+
+CLI `save-project PATH` writes version2 with exclusive creation; `restore PATH`
+restores it. Loading a .json path (including at startup) distinguishes versions.
+A browser draft is retained through CLI save-project, but never applied implicitly.
+Existing `save PATH` remains applied-source-only. No filesystem overwrite is added.
+
+
+## Shared rules and named records (2026-09-23)
+
+```text
+record Position { x=0; y=0 }
+item green = Position(x=80,y=120)
+item blue = Position(x=280,y=160)
+rule move(t: Position) { t.x=event_x; t.y=event_y }
+input drag_green = pointer(green_box)
+input drag_blue = pointer(blue_box)
+box green_box(green.x,green.y,56,44,"#c9f08b")
+box blue_box(blue.x,blue.y,56,44,"#74c4d4")
+on drag_green.move use move(green)
+on drag_blue.move use move(blue)
+```
+
+A record defines 1..8 named finite numeric initializers. An item has its own values;
+optional literal field overrides replace defaults. Its fields occupy contiguous
+slots in the existing scalar state array, with public names `item.field`. They
+count toward the same 32-state limit. Schemas and items may be forward-referenced.
+Unknown/duplicate fields, types or items fail compilation. Scalar and item names
+cannot collide; type and rule names have separate namespaces. There are no runtime
+record objects, nested records, record copying, dynamic item creation or deletion.
+
+A rule takes exactly one named record parameter with an exact schema name match.
+Its body uses the existing expressions, ordered assignments and conditional blocks.
+Parameter fields read/write the connected item's current slots; globals and other
+qualified state names remain accessible. The parameter shadows the same qualified
+prefix only inside its body. No rule calls from expressions or rule bodies, recursion,
+return values, stack frames, or implicit event loop are introduced. Frame handlers
+may use rules; coordinate-reading rules may only bind to pointer events.
+Unused rules are name/type checked without emitting a body into the VM program.
+
+`on input.edge use rule(item)` is one event handler. Each used rule's instructions
+are emitted once at its first use. Every use adds handler metadata referencing that
+same exact instruction range, with parameterBase/parameterCount. Bindings run in
+source order, including repeated uses of one item. All matching bindings and
+ordinary handlers participate in the same atomic event: a failure in any rolls
+back every state write in that event. Error PCs refer to the shared source body.
+
+Core `bind_handler(handler_index,base,count)` sets a previously unbound staged
+handler's contiguous state range. Counts are 1..8; the entire range must fit current
+staged state_count. A duplicate binding poisons staging. All owners of shared code
+must have the same nonzero field count and exactly the same range. Partial overlap
+remains invalid. Parameter opcodes must belong to a bound handler and use an index
+below every owner's field count. Pure display evaluation rejects parameter ops;
+view bindings use ordinary resolved state loads and their normal cache dependencies.
+
+Handler metadata grows from 12 to 16 B (base/count plus alignment). There is no
+per-field indirection list or per-event parameter-value copy. At the shared-rules step, Wasm Program
+was 9648 B, Pending 9912 B, Engine 19904 B: 512 B more fixed reservation across active
+and pending handler arrays. The linear-memory limit stays 64 KiB. The required
+bind_handler export makes the host reject older incompatible Wasm.
+
+Apply preserves each qualified field by name. Reordering schema fields or item
+instances rebuilds physical bindings without changing their named values. Removing
+an item removes its fields; adding it again initializes new fields. An active
+input still blocks definition changes. Version2 documents save qualified field
+names, not physical offsets; restoration recompiles all bindings and validates all
+values before commit. This is static named-field identity, not a general lifetime
+handle for dynamically deleted/reused objects. Example `id` and `selected` are
+ordinary author-defined numbers; uniqueness/selection policy is not enforced by
+an implicit object system.
+
+
+## Target-routed pointer groups (2026-09-23)
+
+```text
+input drag = pointer { green_box: green; blue_box: blue }
+on drag.move use move(drag.target)
+```
+
+A group resolves named views to named items of exactly one record type. Forward
+references work. Every view may be connected only once across single-target inputs
+and groups. Every item may occur in only one group route, even across groups;
+this prevents two group captures from writing aliased field ranges. Explicit
+ordinary handlers and statically bound rules may still write other states as authored.
+Groups must be nonempty. There are at most 24 routes in the program, independently
+of the existing eight logical inputs. State/view/instruction limits are unchanged.
+`input.target` is a contextual rule argument for the current group's event only,
+not a scalar expression or persistent object handle. Wrong groups/types are rejected.
+
+A group handler is emitted once per authored `on`, independent of target count.
+It can share its exact body with statically bound handlers. One route consists of
+three bytes: logical input index, field base, field count. The compiler's routes
+also retain source view/item names for the host. The host builds one bounded view
+lookup Map per successful load; target-name lookup does not scan all group targets
+on each event. JS Map/metadata allocations are outside the core memory accounting.
+
+`put_pointer_target(input,base,count)` appends a staged route, with count 1..8 and
+an in-bounds, nonoverlapping state range. Same-input routes must have equal counts.
+`bind_target_handler(handler,count)` binds a previously unbound staged handler to
+the event target, encoded as parameter_base=255. Commit requires a non-frame event
+with matching routes/counts. Existing `bind_handler` accepts only concrete bases.
+`dispatch_target(event,route,x,y)` validates the active route's membership in the
+event's logical input, then supplies its base to the matching handlers. Missing,
+foreign, and out-of-range targets fail before any authored writes. Ordinary dispatch
+and dispatch_point reject inputs that require a route. All matching static, routed,
+and inline handlers still run in source order within one atomic event.
+The VM indexes the route directly; it still scans the bounded handler list to match
+event IDs. No claim of constant-time complete dispatch is made.
+
+Host `point(name,phase,x,y,source,target)` requires a group view name in the final
+argument. Legacy single-target calls keep the old form and reject an extra target.
+Capture/held ownership is `(input,view)`, so two views in a group can be manipulated
+concurrently; a second source cannot steal the same view. Continuations from a
+non-owner are ignored. Browser capture records retain the initial view across
+movement, release outside the view, and cancellation. `releaseAll` cancels every
+owned view once. `held` uses `input:view` for groups and the old input names for
+legacy inputs; lastEvent/lastFailure add `target` for group events.
+
+Route indices live only in the active definition. Apply/restore remain blocked
+while inputs are held. Reset clears owners; load rebuilds routes by source names.
+Documents still store source and named scalar fields, never physical route indices.
+Deletion/recreation at runtime and generation-checked dynamic identity are not added.
+CLI uses `point-target NAME VIEW PHASE X Y`; existing `point` is unchanged.
+
+Each Program adds 80 B (24 three-byte routes, mask/count and alignment). Current
+Program=9728 B, Pending=9992 B, Engine=20064 B, an additional 160 B of fixed engine
+reservation over the shared-rules step. Handler remains 16 B, Instruction 16 B.
+The active IR byte count now includes used routes. Linear memory remains 65536 B.
+The host requires put_pointer_target, bind_target_handler, and dispatch_target.
+
+## Numeric host inputs and native document layout (2026-09-23)
+
+```text
+state ratio = 50
+input split = value
+on split.change { ratio = clamp(event_value,20,80) }
+```
+
+`value` is a numeric input kind, not a state initializer. Its only edge is
+`.change`, event ID `33 + input_index` (33..40). `event_value` is reserved and
+available only in these handlers; view expressions and pointer/keyboard/frame
+bindings cannot read it. Shared rules may use it only when bound to a value input.
+A rule requiring both pointer coordinates and a scalar value has no compatible
+binding. Values are ephemeral event operands, not hidden state slots.
+
+Opcode26 pushes the event's numeric value. Core `dispatch_value(event,value)`
+requires the scalar event range and a finite value. Ordinary dispatch/dispatch_point
+reject scalar event IDs; scalar dispatch rejects other event IDs. Pure evaluation
+rejects opcode26. The same ordered, atomic state commit and display-cache invalidation
+apply. A later handler failure rolls back all earlier writes in this event.
+No new persistent core tables are added: Engine remains 20064 B; linear memory is
+65536 B. The existing event context's x payload holds the scalar under an explicit
+scalar tag, separate from pointer context. Host requires dispatch_value export.
+
+Host `change(name,value,source='external')` validates the input kind and value,
+returns the dispatch status, and records `.change` and `value` in the last event.
+It does not acquire a held-input owner. Repeated equal requests still dispatch;
+no authored side effects are dropped. Reentrant output callbacks cannot invoke it.
+`down/up/point` reject numeric inputs. Source forms can submit finite numbers through
+native number controls. CLI command is `change NAME VALUE`.
+
+`document.html` is a concrete host, not a generic UI declaration language. It uses
+CSS Grid/media queries and native textarea editing, scrolling and text rendering.
+The authored policy contains one scalar ratio and no output views. With no declared
+views, display evaluation resets its budget then returns without allocating the
+96-number/24-flag display publication buffers. No browser layout tree, text buffer,
+scroll offset or viewport dimensions are copied into VM state.
+
+The document host projects ratio to CSS and accessible controls. Values outside
+20..80 are rejected at publication, leaving the last valid visual ratio in place;
+this host-output error is separate from VM arithmetic rollback. A later valid value
+or corrected rule can restore valid output. Policy apply during its own splitter
+capture is refused. The generic numeric port itself has no capture semantics.
+
+The splitter owns one pointer and an original ratio for cancel. Mouse, touch and
+keyboard feed the same policy. Moves are coalesced to the latest coordinate once
+per animation frame; release flushes a pending move and does not replace it with a
+possibly stale release coordinate. Cancel/Escape/blur/capture loss restore the
+original ratio. Crossing into narrow stacked layout cancels. Geometry is read at
+gesture start and invalidated by resize observation, window resize and scroll;
+observer callbacks do not write layout. No geometry reads occur in inactive resize,
+scroll or text-edit handlers. A single geometry cache remains in the host between
+gestures and is remeasured on every new gesture; it is not a layout-tree mirror.
+
+The textarea is the editable document authority. The reading DOM contains a second
+text projection. There is no additional persistent JS string model, AST or document
+copy in Wasm. This does NOT mean a zero-copy editor: native undo/layout storage,
+DOM text and transient reads/decoding/encoding are outside core memory accounting.
+Text edits schedule one reading publication per frame, deferred through composition.
+Publication currently replaces the entire reading text; no incremental text format
+or delta editor is claimed. Resize/split/scroll do not trigger text publication.
+
+Open/save use plain UTF-8 .txt, up to 2 MiB per imported/exported file. Invalid UTF-8
+and oversize files are refused without changing the current document. File reads
+use generation/request guards against stale replacement. Native textarea normalizes
+line endings; saved text uses LF and no BOM, not byte-identical preservation of
+all original files. Files contain text only, not panel ratios or Pure rules. An
+unavailable Wasm disables split policy controls while text editing/preview/save stay
+available. Native undo is retained for ordinary editing; file/sample replacement
+starts a new document value. No complete accessibility certification or physical
+mobile/IME test is claimed by the browser automation.
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/sources/cli.mjs b/prototypes/workbench/results/2026-09-23-web-layout/sources/cli.mjs
new file mode 100644
index 0000000..4282b52
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/sources/cli.mjs
@@ -0,0 +1,103 @@
+#!/usr/bin/env node
+import { readFile, writeFile } from 'node:fs/promises';
+import { createInterface } from 'node:readline';
+import { createRuntime } from './web/runtime.mjs';
+import { parseProject } from './web/project.mjs';
+
+const help = `Pure authoring workbench · 같은 정의와 Zig 실행기
+ load PATH .pure 파일을 적용 (같은 이름의 상태 보존)
+ down NAME 정의한 입력 누르기
+ up NAME 정의한 입력 놓기
+ point NAME PHASE X Y 좌표 입력 (down / move / up / cancel)
+ point-target NAME VIEW PHASE X Y 그룹의 대상에 좌표 입력
+ change NAME VALUE 숫자 입력 전달
+ frame [1..600] 시간 진행, 1프레임 = 1/60초
+ show 상태와 도형 출력
+ limits C H W 표시 캐시(0..32)·변경 기록(0..8)·작업량(0..4294967295) 설정
+ forget [PART] 표시 기록 삭제: all, values, history (상태는 유지)
+ costs 마지막 표시 계산의 작업량·명령 수·캐시 적중 출력
+ reset 현재 정의의 초기 상태로
+ save PATH 적용한 정의를 저장
+ save-project PATH 적용 코드와 현재 배치를 JSON으로 저장
+ restore PATH 저장한 배치를 복원 (초안이 있으면 파일에 보존)
+ help / quit
+브라우저와 별도 인스턴스입니다. 임의 JavaScript는 실행하지 않습니다.`;
+
+async function main() {
+ const args = process.argv.slice(2);
+ if (args.includes('--help')) { console.log(help); return; }
+ if (args.length > 1) throw new Error('사용법: node cli.mjs [project.pure]');
+ const runtime = await createRuntime(await readFile(new URL('./web/core.wasm', import.meta.url)));
+ let savedDraft = null;
+ async function loadFile(path, restoreOnly = false) {
+ const content = await readFile(path, 'utf8');
+ if (restoreOnly || path.toLowerCase().endsWith('.json')) {
+ const project = parseProject(content);
+ if (project.version === 2) {
+ savedDraft = runtime.restoreDocument(project).draft;
+ return;
+ }
+ if (restoreOnly) throw new Error('현재 배치가 포함된 version 2 파일이 필요합니다.');
+ runtime.apply(project.source);
+ } else runtime.apply(content);
+ savedDraft = null;
+ }
+ if (args[0]) await loadFile(args[0]); else runtime.apply('');
+ const terminal = createInterface({ input: process.stdin, output: process.stdout, terminal: Boolean(process.stdin.isTTY) });
+ if (process.stdin.isTTY) console.log(help);
+ terminal.setPrompt('pure> ');
+ if (process.stdin.isTTY) terminal.prompt();
+ for await (const line of terminal) {
+ try {
+ const match = /^(\S+)(?:\s+(.+))?$/.exec(line.trim());
+ const name = match?.[1] || 'show';
+ const arg = match?.[2];
+ if (name === 'quit') { terminal.close(); break; }
+ if (name === 'help') console.log(help);
+ else if (name === 'costs' && !arg) console.log(JSON.stringify({ evaluation: runtime.evaluationStats() }));
+ else {
+ if (name === 'load' && arg) await loadFile(arg);
+ else if (name === 'restore' && arg) await loadFile(arg, true);
+ else if (name === 'save-project' && arg) await writeFile(arg, JSON.stringify(runtime.exportDocument(savedDraft ?? undefined), null, 2) + '\n', {flag:'wx'});
+ else if (name === 'save' && arg) await writeFile(arg, runtime.exportProject().source, { flag: 'wx' });
+ else if (name === 'down' && arg) runtime.down(arg, 'terminal');
+ else if (name === 'up' && arg) runtime.up(arg, 'terminal');
+ else if (name === 'point' && arg) {
+ const parts = arg.split(/\s+/);
+ if (parts.length !== 4) throw new Error('point NAME PHASE X Y 형식을 사용하세요.');
+ runtime.point(parts[0], parts[1], Number(parts[2]), Number(parts[3]), 'terminal');
+ } else if (name === 'point-target' && arg) {
+ const parts = arg.split(/\s+/);
+ if (parts.length !== 5) throw new Error('point-target NAME VIEW PHASE X Y 형식을 사용하세요.');
+ runtime.point(parts[0], parts[2], Number(parts[3]), Number(parts[4]), 'terminal', parts[1]);
+ } else if (name === 'change' && arg) {
+ const parts = arg.split(/\s+/);
+ if (parts.length !== 2) throw new Error('change NAME VALUE 형식을 사용하세요.');
+ runtime.change(parts[0], Number(parts[1]), 'terminal');
+ } else if (name === 'frame') {
+ if (arg !== undefined && !/^\d+$/.test(arg)) throw new Error('frame 1..600 정수를 입력하세요.');
+ const count = arg === undefined ? 1 : Number(arg);
+ if (count < 1 || count > 600) throw new Error('frame 1..600 정수를 입력하세요.');
+ runtime.frame(count);
+ } else if (name === 'reset' && !arg) runtime.reset();
+ else if (name === 'limits' && arg) {
+ const parts = arg.split(/\s+/);
+ if (parts.length !== 3 || parts.some(part => !/^\d+$/.test(part)))
+ throw new Error('limits C H W 정수 세 개를 입력하세요.');
+ const [cacheEntries, historyEntries, workLimit] = parts.map(Number);
+ runtime.configureEvaluation({ cacheEntries, historyEntries, workLimit });
+ } else if (name === 'forget') runtime.forgetEvaluation(arg || 'all');
+ else if (name !== 'show' || arg) throw new Error('명령 형식을 확인하세요. help로 안내를 볼 수 있습니다.');
+ const result = runtime.snapshot();
+ console.log(JSON.stringify(result));
+ if (result.lastError && !process.stdin.isTTY) process.exitCode = 1;
+ }
+ } catch (error) {
+ console.error(`${error.line ? `${error.line}:${error.column || 1} ` : ''}${error.message}`);
+ if (!process.stdin.isTTY) process.exitCode = 1;
+ }
+ if (process.stdin.isTTY) terminal.prompt();
+ }
+}
+
+main().catch((error) => { console.error(error.message); process.exitCode = 1; });
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/sources/core.zig b/prototypes/workbench/results/2026-09-23-web-layout/sources/core.zig
new file mode 100644
index 0000000..b9a0177
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/sources/core.zig
@@ -0,0 +1,1443 @@
+//! Bounded scalar interaction VM experiment. No application behavior is built in.
+//! Same source for native tests and freestanding Wasm; no allocation or imports.
+//! Error ABI: 0 ok, 1 capacity, 2 invalid, 3 stack, 4 division by zero,
+//! 5 non-finite, 6 bad branch, 7 bad state, 8 bad frame, 9 bad event.
+//! Counters wrap modulo 2^32. Failed loads preserve the active program, authored
+//! state and event diagnostics; beginning a load discards optional reuse records.
+//! Failed events preserve values and counters but
+//! report their error and attempted instruction count. Binding evaluation is pure.
+
+const std = @import("std");
+const Evaluation = @import("evaluation.zig").Evaluation;
+const Tables = @import("evaluation.zig").Tables;
+
+const max_states = 32;
+const max_inputs = 8;
+const max_instructions = 512;
+const max_handlers = 64;
+const max_stack = 64;
+const max_pointer_targets = 24;
+const event_target = 255;
+const no_pc = std.math.maxInt(u32);
+const frame_dt: f64 = 1.0 / 60.0;
+
+const Code = enum(u32) {
+ ok = 0,
+ capacity = 1,
+ invalid = 2,
+ stack = 3,
+ division_by_zero = 4,
+ non_finite = 5,
+ bad_branch = 6,
+ bad_state = 7,
+ bad_frame = 8,
+ bad_event = 9,
+ work_limit = 10,
+};
+
+const Instruction = struct { value: f64 = 0, op: u32 = 0, arg: u32 = 0 };
+const Handler = struct {
+ event: u32 = 0, start: u32 = 0, length: u32 = 0,
+ parameter_base: u8 = 0, parameter_count: u8 = 0,
+};
+const PointerTarget = struct { input: u8, base: u8, count: u8 };
+const Program = struct {
+ pointer_targets: [max_pointer_targets]PointerTarget = undefined,
+ pointer_target_count: u8 = 0,
+ routed_inputs: u8 = 0,
+ instructions: [max_instructions]Instruction = @splat(.{}),
+ handlers: [max_handlers]Handler = @splat(.{}),
+ initials: [max_states]f64 = @splat(0),
+ old_ids: [max_states]i32 = @splat(-1),
+ declared: [max_states]bool = @splat(false),
+ state_count: u32 = 0,
+ input_count: u32 = 0,
+ instruction_count: u32 = 0,
+ handler_count: u32 = 0,
+};
+// Loading a definition/optional restored values and result reuse are mutually exclusive phases. Current execution
+// always reads active, and can recompute bindings while this workspace loads.
+const Pending = struct {
+ program: Program = .{},
+ values: [max_states]f64 = @splat(0),
+ value_mask: u32 = 0,
+};
+const Workspace = union { staging: Pending, reuse: Tables };
+const Engine = struct {
+ active: Program = .{},
+ workspace: Workspace = .{ .reuse = .{} },
+ values: [max_states]f64 = @splat(0),
+ staging_open: bool = false,
+ staging_error: Code = .ok,
+ tick: u32 = 0,
+ last_error: Code = .ok,
+ error_pc: u32 = no_pc,
+ last_ops: u32 = 0,
+ revision: u32 = 0,
+ successful_events: u32 = 0,
+ evaluation: Evaluation = .{},
+};
+var engine: Engine = .{};
+
+fn reuseTables() ?*Tables {
+ return if (engine.staging_open) null else &engine.workspace.reuse;
+}
+
+fn finishStaging() void {
+ engine.workspace = .{ .reuse = .{} };
+ engine.staging_open = false;
+ engine.evaluation.forget(null, 3);
+}
+
+fn public(code: Code) u32 {
+ return @intFromEnum(code);
+}
+
+fn stagingFail(code: Code) u32 {
+ if (engine.staging_error == .ok) engine.staging_error = code;
+ return public(code);
+}
+
+export fn reset_all() void {
+ engine = .{};
+}
+
+export fn begin_program(state_count: u32, input_count: u32) u32 {
+ engine.staging_error = .ok;
+ if (state_count > max_states or input_count > max_inputs) {
+ if (engine.staging_open) finishStaging();
+ return stagingFail(.capacity);
+ }
+ engine.workspace = .{ .staging = .{ .program = .{ .state_count = state_count, .input_count = input_count } } };
+ engine.staging_open = true;
+ engine.evaluation.forget(null, 3);
+ return 0;
+}
+
+export fn abort_program() void {
+ if (engine.staging_open) finishStaging();
+}
+
+export fn put_state(id: u32, initial: f64, old_id: i32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ if (id >= engine.workspace.staging.program.state_count or old_id < -1) return stagingFail(.bad_state);
+ if (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count) return stagingFail(.bad_state);
+ if (engine.workspace.staging.program.declared[id]) return stagingFail(.bad_state);
+ if (!std.math.isFinite(initial)) return stagingFail(.non_finite);
+ engine.workspace.staging.program.initials[id] = initial;
+ engine.workspace.staging.program.old_ids[id] = old_id;
+ engine.workspace.staging.program.declared[id] = true;
+ return 0;
+}
+
+export fn put_instruction(op: u32, arg: u32, value: f64) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (p.instruction_count == max_instructions) return stagingFail(.capacity);
+ if (op > 26) return stagingFail(.invalid);
+ if (!std.math.isFinite(value)) return stagingFail(.non_finite);
+ p.instructions[p.instruction_count] = .{ .op = op, .arg = arg, .value = value };
+ p.instruction_count += 1;
+ return 0;
+}
+
+fn validEvent(event: u32, inputs: u32) bool {
+ return event <= inputs * 2 or
+ (event >= 17 and event - 17 < inputs) or
+ (event >= 25 and event - 25 < inputs) or
+ (event >= 33 and event - 33 < inputs);
+}
+
+export fn put_handler(event: u32, start: u32, length: u32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (p.handler_count == max_handlers) return stagingFail(.capacity);
+ if (!validEvent(event, p.input_count)) return stagingFail(.bad_event);
+ // Handlers may be appended before their instructions, so range validation
+ // is deferred until commit. Arithmetic remains guarded there.
+ p.handlers[p.handler_count] = .{ .event = event, .start = start, .length = length };
+ p.handler_count += 1;
+ return 0;
+}
+
+export fn bind_handler(handler: u32, base: u32, count: u32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (handler >= p.handler_count or base >= p.state_count) return stagingFail(.bad_state);
+ if (count == 0 or count > 8 or count > p.state_count - base) return stagingFail(.bad_state);
+ const h = &p.handlers[handler];
+ if (h.parameter_count != 0) return stagingFail(.invalid);
+ h.parameter_base = @intCast(base);
+ h.parameter_count = @intCast(count);
+ return 0;
+}
+
+// A route is a static program-local index, never a persistent object identity.
+export fn put_pointer_target(input: u32, base: u32, count: u32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (p.pointer_target_count == max_pointer_targets) return stagingFail(.capacity);
+ if (input >= p.input_count) return stagingFail(.bad_event);
+ if (base >= p.state_count or count == 0 or count > 8 or count > p.state_count - base) return stagingFail(.bad_state);
+ for (p.pointer_targets[0..p.pointer_target_count]) |t| {
+ if (base < @as(u32, t.base) + t.count and t.base < base + count) return stagingFail(.invalid);
+ if (t.input == input and t.count != count) return stagingFail(.bad_state);
+ }
+ p.pointer_targets[p.pointer_target_count] = .{ .input = @intCast(input), .base = @intCast(base), .count = @intCast(count) };
+ p.pointer_target_count += 1;
+ p.routed_inputs |= @as(u8, 1) << @as(u3, @intCast(input));
+ return 0;
+}
+
+export fn bind_target_handler(handler: u32, count: u32) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const p = &engine.workspace.staging.program;
+ if (handler >= p.handler_count or count == 0 or count > 8) return stagingFail(.bad_state);
+ const h = &p.handlers[handler];
+ if (h.parameter_count != 0) return stagingFail(.invalid);
+ h.parameter_base = event_target;
+ h.parameter_count = @intCast(count);
+ return 0;
+}
+
+fn eventInput(event: u32) u32 {
+ return if (event >= 33) event - 33 else if (event >= 25) event - 25 else if (event >= 17) event - 17 else (event - 1) / 2;
+}
+
+fn validate(p: *const Program) Code {
+ for (0..p.state_count) |id| {
+ if (!p.declared[id]) return .bad_state;
+ if (!std.math.isFinite(p.initials[id])) return .non_finite;
+ const old_id = p.old_ids[id];
+ if (old_id < -1 or (old_id >= 0 and @as(u32, @intCast(old_id)) >= engine.active.state_count)) return .bad_state;
+ }
+ for (p.handlers[0..p.handler_count], 0..) |h, i| {
+ if (!validEvent(h.event, p.input_count)) return .bad_event;
+ if (h.start > p.instruction_count or h.length > p.instruction_count - h.start) return .invalid;
+ if (h.parameter_base == event_target) {
+ if (h.event == 0 or h.event >= 33 or h.parameter_count == 0 or h.parameter_count > 8) return .bad_event;
+ var found = false;
+ for (p.pointer_targets[0..p.pointer_target_count]) |t| {
+ if (t.input != eventInput(h.event)) continue;
+ if (t.count != h.parameter_count) return .bad_state;
+ found = true;
+ }
+ if (!found) return .bad_event;
+ } else if (h.parameter_count > 8 or @as(u32, h.parameter_base) + h.parameter_count > p.state_count) return .bad_state;
+ if (h.length == 0) continue;
+ for (p.handlers[0..i]) |other| {
+ if (other.length != 0 and h.start < other.start + other.length and other.start < h.start + h.length) {
+ // Bound rules may share exactly one body, never a partial range.
+ if (h.parameter_count == 0 or h.parameter_count != other.parameter_count or
+ h.start != other.start or h.length != other.length) return .invalid;
+ }
+ }
+ }
+ for (p.instructions[0..p.instruction_count], 0..) |inst, pc| {
+ if (inst.op > 26) return .invalid;
+ if (!std.math.isFinite(inst.value)) return .non_finite;
+ if ((inst.op == 1 or inst.op == 20) and inst.arg >= p.state_count) return .bad_state;
+ if (inst.op >= 20) {
+ var owner: ?Handler = null;
+ for (p.handlers[0..p.handler_count]) |h| {
+ if (pc >= h.start and pc < h.start + h.length) {
+ owner = h;
+ if (inst.op == 21 and (inst.arg <= pc or inst.arg > h.start + h.length)) return .bad_branch;
+ if ((inst.op == 24 or inst.op == 25) and inst.arg >= h.parameter_count) return .bad_state;
+ }
+ }
+ if (owner == null) return .invalid;
+ }
+ }
+ return .ok;
+}
+
+export fn put_restored_value(id: u32, value: f64) u32 {
+ if (!engine.staging_open) return stagingFail(.invalid);
+ const pending = &engine.workspace.staging;
+ if (id >= pending.program.state_count) return stagingFail(.bad_state);
+ const bit = @as(u32, 1) << @as(u5, @intCast(id));
+ if (pending.value_mask & bit != 0) return stagingFail(.bad_state);
+ if (!std.math.isFinite(value)) return stagingFail(.non_finite);
+ pending.values[id] = value;
+ pending.value_mask |= bit;
+ return 0;
+}
+
+export fn commit_program() u32 {
+ return commit(false);
+}
+
+export fn commit_restored_program() u32 {
+ return commit(true);
+}
+
+fn commit(restoring: bool) u32 {
+ if (!engine.staging_open) return public(.invalid);
+ if (engine.staging_error != .ok) return public(engine.staging_error);
+ const count = engine.workspace.staging.program.state_count;
+ const expected: u32 = if (count == 32) 0xffffffff else (@as(u32, 1) << @as(u5, @intCast(count))) - 1;
+ if (engine.workspace.staging.value_mask != (if (restoring) expected else @as(u32, 0)))
+ return stagingFail(.bad_state);
+ const code = validate(&engine.workspace.staging.program);
+ if (code != .ok) return stagingFail(code);
+ var next: [max_states]f64 = @splat(0);
+ for (0..engine.workspace.staging.program.state_count) |i| {
+ const old_id = engine.workspace.staging.program.old_ids[i];
+ next[i] = if (restoring) engine.workspace.staging.values[i] else if (old_id < 0) engine.workspace.staging.program.initials[i] else engine.values[@intCast(old_id)];
+ }
+ engine.active = engine.workspace.staging.program;
+ engine.values = next;
+ finishStaging();
+ if (restoring) {
+ engine.tick = 0;
+ engine.successful_events = 0;
+ }
+ engine.revision +%= 1;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ return 0;
+}
+
+export fn reset_state() void {
+ engine.values = engine.active.initials;
+ engine.evaluation.forget(reuseTables(), 3);
+ engine.tick = 0;
+ engine.successful_events = 0;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+}
+
+const Execution = struct {
+ code: Code = .ok,
+ pc: u32 = no_pc,
+ ops: u32 = 0,
+ value: f64 = 0,
+ dependencies: u32 = 0,
+};
+
+fn executionFail(result: Execution, code: Code, pc: u32) Execution {
+ var failed = result;
+ failed.code = code;
+ failed.pc = pc;
+ return failed;
+}
+
+const EventContext = struct { dt: f64 = 0, x: f64 = 0, y: f64 = 0, parameter_base: u32 = 0, parameter_count: u32 = 0, point: bool = false, scalar: bool = false, target: ?u32 = null };
+
+fn execute(values: *[max_states]f64, start: u32, length: u32, context: EventContext, pure: bool, budget: ?*Evaluation) Execution {
+ const p = &engine.active;
+ var result: Execution = .{};
+ if (start > p.instruction_count or length > p.instruction_count - start) return executionFail(result, .invalid, no_pc);
+ const end = start + length;
+ var stack: [max_stack]f64 = undefined;
+ var sp: usize = 0;
+ var pc = start;
+ while (pc < end) {
+ if (budget) |b| {
+ if (!b.spend()) return executionFail(result, .work_limit, pc);
+ b.instructions += 1;
+ }
+ const inst = p.instructions[pc];
+ result.ops += 1;
+ if (pure and (inst.op == 2 or inst.op >= 20)) return executionFail(result, .invalid, pc);
+ const need: usize = switch (inst.op) {
+ 0...2, 22, 23, 24, 26 => 0,
+ 7, 16, 20, 21, 25 => 1,
+ 3...6, 8...15, 17, 18 => 2,
+ 19 => 3,
+ else => return executionFail(result, .invalid, pc),
+ };
+ if (sp < need) return executionFail(result, .stack, pc);
+ if (inst.op <= 2 or inst.op == 22 or inst.op == 23 or inst.op == 24 or inst.op == 26) {
+ if (sp == max_stack) return executionFail(result, .stack, pc);
+ const value = switch (inst.op) {
+ 0 => inst.value,
+ 1 => blk: {
+ if (inst.arg >= p.state_count) return executionFail(result, .bad_state, pc);
+ if (budget != null and budget.?.cache_count != 0)
+ result.dependencies |= @as(u32, 1) << @as(u5, @intCast(inst.arg));
+ break :blk values[inst.arg];
+ },
+ 24 => blk: {
+ if (inst.arg >= context.parameter_count) return executionFail(result, .bad_state, pc);
+ const state = context.parameter_base + inst.arg;
+ if (state >= p.state_count) return executionFail(result, .bad_state, pc);
+ break :blk values[state];
+ },
+ 26 => if (context.scalar) context.x else return executionFail(result, .bad_event, pc),
+ 2 => context.dt,
+ 22, 23 => if (!context.point) return executionFail(result, .bad_event, pc) else if (inst.op == 22) context.x else context.y,
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ stack[sp] = value;
+ sp += 1;
+ } else if (inst.op == 20 or inst.op == 25) {
+ if (inst.op == 25 and inst.arg >= context.parameter_count) return executionFail(result, .bad_state, pc);
+ const state = if (inst.op == 25) context.parameter_base + inst.arg else inst.arg;
+ if (state >= p.state_count) return executionFail(result, .bad_state, pc);
+ sp -= 1;
+ values[state] = stack[sp];
+ } else if (inst.op == 21) {
+ if (inst.arg <= pc or inst.arg > end) return executionFail(result, .bad_branch, pc);
+ sp -= 1;
+ if (stack[sp] == 0) {
+ pc = inst.arg;
+ continue;
+ }
+ } else {
+ const a = stack[sp - need];
+ const b = if (need >= 2) stack[sp - need + 1] else 0;
+ const value: f64 = switch (inst.op) {
+ 3 => a + b,
+ 4 => a - b,
+ 5 => a * b,
+ 6 => blk: {
+ if (b == 0) return executionFail(result, .division_by_zero, pc);
+ break :blk a / b;
+ },
+ 7 => -a,
+ 8 => @floatFromInt(@intFromBool(a < b)),
+ 9 => @floatFromInt(@intFromBool(a <= b)),
+ 10 => @floatFromInt(@intFromBool(a > b)),
+ 11 => @floatFromInt(@intFromBool(a >= b)),
+ 12 => @floatFromInt(@intFromBool(a == b)),
+ 13 => @floatFromInt(@intFromBool(a != b)),
+ 14 => @floatFromInt(@intFromBool(a != 0 and b != 0)),
+ 15 => @floatFromInt(@intFromBool(a != 0 or b != 0)),
+ 16 => @floatFromInt(@intFromBool(a == 0)),
+ 17 => @min(a, b),
+ 18 => @max(a, b),
+ 19 => blk: {
+ const hi = stack[sp - 1];
+ if (b > hi) return executionFail(result, .invalid, pc);
+ break :blk @max(b, @min(a, hi));
+ },
+ else => unreachable,
+ };
+ if (!std.math.isFinite(value)) return executionFail(result, .non_finite, pc);
+ sp = sp - need + 1;
+ stack[sp - 1] = value;
+ }
+ pc += 1;
+ }
+ if (sp != @as(usize, if (pure) 1 else 0)) return executionFail(result, .stack, end);
+ if (pure) result.value = stack[0];
+ return result;
+}
+
+export fn dispatch(event: u32, dt: f64) u32 {
+ return dispatchContext(event, .{ .dt = dt });
+}
+
+export fn dispatch_point(event: u32, x: f64, y: f64) u32 {
+ return dispatchContext(event, .{ .x = x, .y = y, .point = true });
+}
+
+export fn dispatch_target(event: u32, target: u32, x: f64, y: f64) u32 {
+ return dispatchContext(event, .{ .x = x, .y = y, .point = true, .target = target });
+}
+
+export fn dispatch_value(event: u32, value: f64) u32 {
+ return dispatchContext(event, .{ .x = value, .scalar = true });
+}
+
+fn dispatchContext(event: u32, context: EventContext) u32 {
+ const dt = context.dt;
+ engine.last_error = .ok;
+ engine.error_pc = no_pc;
+ engine.last_ops = 0;
+ if (!validEvent(event, engine.active.input_count) or
+ ((context.point or context.scalar) and event == 0) or
+ (context.scalar != (event >= 33)) or
+ (!context.point and !context.scalar and event > engine.active.input_count * 2)) {
+ engine.last_error = .bad_event;
+ return public(.bad_event);
+ }
+ if (!std.math.isFinite(dt) or !std.math.isFinite(context.x) or !std.math.isFinite(context.y)) {
+ engine.last_error = .non_finite;
+ return public(.non_finite);
+ }
+ if ((event == 0 and dt != frame_dt) or (event != 0 and dt != 0)) {
+ engine.last_error = .bad_frame;
+ return public(.bad_frame);
+ }
+ var route: ?PointerTarget = null;
+ const routed = event != 0 and engine.active.routed_inputs & (@as(u8, 1) << @as(u3, @intCast(eventInput(event)))) != 0;
+ if (context.target) |target| {
+ if (!routed or target >= engine.active.pointer_target_count or
+ engine.active.pointer_targets[target].input != eventInput(event)) {
+ engine.last_error = .bad_event;
+ return public(.bad_event);
+ }
+ route = engine.active.pointer_targets[target];
+ } else if (routed) {
+ engine.last_error = .bad_event;
+ return public(.bad_event);
+ }
+ var pending = engine.values;
+ for (engine.active.handlers[0..engine.active.handler_count]) |h| {
+ if (h.event != event) continue;
+ var bound = context;
+ bound.parameter_base = if (h.parameter_base == event_target) route.?.base else h.parameter_base;
+ bound.parameter_count = h.parameter_count;
+ const result = execute(&pending, h.start, h.length, bound, false, null);
+ engine.last_ops += result.ops;
+ if (result.code != .ok) {
+ engine.last_error = result.code;
+ engine.error_pc = result.pc;
+ return public(result.code);
+ }
+ }
+ if (engine.evaluation.cache_count != 0 and !engine.staging_open) {
+ var changed: u32 = 0;
+ for (0..engine.active.state_count) |i| {
+ // Signed zero is observable, so compare representation, not ==.
+ if (@as(u64, @bitCast(pending[i])) != @as(u64, @bitCast(engine.values[i])))
+ changed |= @as(u32, 1) << @as(u5, @intCast(i));
+ }
+ engine.evaluation.changed(reuseTables(), changed);
+ }
+ engine.values = pending;
+ if (event == 0) engine.tick +%= 1;
+ engine.successful_events +%= 1;
+ return 0;
+}
+
+export fn eval_at(start: u32, length: u32) f64 {
+ // A local copy makes the no-side-effects boundary structural as well as
+ // checked by the pure instruction filter. Diagnostics are never touched.
+ var values = engine.values;
+ const result = execute(&values, start, length, .{}, true, null);
+ return if (result.code == .ok) result.value else std.math.nan(f64);
+}
+
+export fn configure_evaluation(cache: u32, history: u32) u32 {
+ return if (engine.evaluation.configure(reuseTables(), cache, history)) 0 else public(.capacity);
+}
+
+export fn forget_evaluation(flags: u32) void {
+ engine.evaluation.forget(reuseTables(), flags);
+}
+
+export fn begin_evaluation(limit: u32) void {
+ engine.evaluation.begin(limit);
+}
+
+export fn eval_bounded(start: u32, length: u32) f64 {
+ const e = &engine.evaluation;
+ e.status = 0;
+ // Charge per request as well as per instruction and validation probe.
+ if (!e.spend()) return std.math.nan(f64);
+ if (e.lookup(reuseTables(), start, length)) |value| return value;
+ if (e.status != 0) return std.math.nan(f64);
+ var values = engine.values;
+ const result = execute(&values, start, length, .{}, true, e);
+ e.status = public(result.code);
+ if (result.code != .ok) return std.math.nan(f64);
+ e.remember(reuseTables(), start, length, result.value, result.dependencies);
+ return result.value;
+}
+
+export fn evaluation_info(id: u32) u32 {
+ const e = &engine.evaluation;
+ return switch (id) {
+ 0 => e.status,
+ 1 => e.work,
+ 2 => e.instructions,
+ 3 => e.hits,
+ 4 => e.cache_count,
+ 5 => e.history_count,
+ 6 => @sizeOf(Evaluation) + @sizeOf(Tables),
+ 7 => @sizeOf(Evaluation),
+ 8 => @sizeOf(Tables),
+ 9 => @intFromBool(!engine.staging_open),
+ 10 => @sizeOf(Workspace) - @sizeOf(Pending),
+ else => 0,
+ };
+}
+
+export fn get_state(id: u32) f64 {
+ return if (id < engine.active.state_count) engine.values[id] else std.math.nan(f64);
+}
+
+export fn info(id: u32) u32 {
+ return switch (id) {
+ 0 => engine.tick,
+ 1 => public(engine.last_error),
+ 2 => engine.error_pc,
+ 3 => engine.last_ops,
+ 4 => engine.revision,
+ 5 => engine.active.state_count,
+ 6 => engine.active.instruction_count,
+ 7 => engine.active.handler_count,
+ 8 => engine.active.input_count,
+ 9 => engine.successful_events,
+ else => 0,
+ };
+}
+
+export fn layout(id: u32) u32 {
+ return switch (id) {
+ 0 => @sizeOf(Engine),
+ 1 => @sizeOf(Program),
+ 2 => @sizeOf(Instruction),
+ 3 => @sizeOf(Handler),
+ 4 => engine.active.instruction_count * @sizeOf(Instruction) + engine.active.handler_count * @sizeOf(Handler) + @as(u32, engine.active.pointer_target_count) * @sizeOf(PointerTarget),
+ 5 => engine.active.state_count * @sizeOf(f64),
+ 6 => @sizeOf(Pending),
+ 7 => max_stack * @sizeOf(f64),
+ 8 => @sizeOf(PointerTarget),
+ 9 => engine.active.pointer_target_count,
+ else => 0,
+ };
+}
+
+// Test helpers describe programs directly; the product compiler is separately
+// tested against this ABI. These tests exercise semantics and failure boundaries.
+fn ok(code: u32) !void {
+ try std.testing.expectEqual(@as(u32, 0), code);
+}
+
+fn emit(op: u32, arg: u32, value: f64) !void {
+ try ok(put_instruction(op, arg, value));
+}
+
+fn counterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try ok(commit_program());
+}
+
+test "blank program, no hidden application behavior, and reset" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 1), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(3));
+ try std.testing.expectEqual(@as(u32, 0), info(5));
+ try std.testing.expect(std.math.isNan(get_state(0)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 0)));
+ reset_state();
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 1), info(4));
+}
+
+test "arbitrary input counter and unused events" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(dispatch(2, 0));
+ try ok(dispatch(0, frame_dt));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(3));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(u32, 4), info(6));
+}
+
+test "charge and release behavior exists only in the authored instruction program" {
+ reset_all();
+ try ok(begin_program(3, 1)); // held, charge, released_value
+ for (0..3) |i| try ok(put_state(@intCast(i), 0, -1));
+ try emit(0, 0, 1);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 2));
+ try emit(1, 1, 0);
+ try emit(1, 0, 0);
+ try emit(2, 0, 0);
+ try emit(5, 0, 0);
+ try emit(3, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(0, 2, 6));
+ try emit(1, 1, 0);
+ try emit(20, 2, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(2, 8, 6));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ try ok(dispatch(1, 0));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try ok(dispatch(2, 0));
+ try std.testing.expectApproxEqAbs(@as(f64, 0.5), get_state(2), 1e-12);
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+ for (0..30) |_| try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 0), get_state(1));
+}
+
+test "forward condition executes once and assignments and handlers are ordered" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 1, -1));
+ try ok(put_state(1, 0, -1));
+ try emit(1, 0, 0);
+ try emit(21, 6, 0);
+ try emit(0, 0, 0);
+ try emit(20, 0, 0);
+ try emit(0, 0, 7);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 0, 6));
+ try emit(1, 1, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(1, 6, 4));
+ try ok(commit_program());
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 14), get_state(1));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 28), get_state(1));
+ try std.testing.expectEqual(@as(u32, 6), info(3));
+}
+
+test "branch to program end is valid" {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, 2, 0);
+ try ok(put_handler(0, 0, 2));
+ try ok(commit_program());
+ try ok(dispatch(0, frame_dt));
+}
+
+test "one event rolls back writes from all handlers on arithmetic failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 8, -1));
+ try emit(0, 0, 99);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 2));
+ try emit(0, 0, 1);
+ try emit(0, 0, 0);
+ try emit(6, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 2, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 8), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(2));
+ try std.testing.expectEqual(@as(u32, 5), info(3));
+}
+
+test "nonfinite arithmetic and invalid clamp fail atomically" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, -1));
+ try emit(0, 0, 1e308);
+ try emit(0, 0, 1e308);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 5, 0));
+ try emit(0, 0, 1);
+ try emit(0, 0, 10);
+ try emit(0, 0, 2);
+ try emit(19, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(0, 0, 5));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.invalid), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(f64, 5), get_state(0));
+}
+
+test "stack underflow overflow and leftover values reject the event" {
+ for (0..3) |mode| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ if (mode == 0) {
+ try emit(3, 0, 0);
+ } else {
+ for (0..@as(usize, if (mode == 1) 65 else 1)) |_| try emit(0, 0, 7);
+ }
+ try ok(put_handler(0, 0, engine.workspace.staging.program.instruction_count));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.stack), dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ }
+}
+
+test "failed candidates leave active values program revision and diagnostics unchanged" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ const old_revision = info(4);
+ const old_ops = info(3);
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(1, 5, 0);
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try std.testing.expectEqual(old_revision, info(4));
+ try std.testing.expectEqual(old_ops, info(3));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.invalid), put_instruction(100, 0, 0));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+}
+
+test "state preservation mapping reads active values at commit and reset uses new initials" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(2, 0));
+ try ok(put_state(0, 42, -1)); // new symbol first
+ try ok(put_state(1, 100, 0)); // old symbol moves from id 0 to 1
+ try ok(dispatch(1, 0)); // changed after staging began
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 42), get_state(0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 100), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(4));
+ try ok(begin_program(0, 0));
+ try ok(commit_program());
+ try std.testing.expect(std.math.isNan(get_state(0)));
+}
+
+test "capacity boundaries and sticky load errors" {
+ reset_all();
+ try std.testing.expectEqual(public(.capacity), begin_program(33, 0));
+ try std.testing.expectEqual(public(.capacity), begin_program(0, 9));
+ try ok(begin_program(32, 8));
+ for (0..32) |i| try ok(put_state(@intCast(i), @floatFromInt(i), -1));
+ for (0..512) |_| try emit(0, 0, 0);
+ for (0..64) |_| try ok(put_handler(16, 0, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 512), info(6));
+ try std.testing.expectEqual(@as(u32, 64), info(7));
+ try std.testing.expectEqual(@as(f64, 31), get_state(31));
+ try ok(begin_program(0, 0));
+ for (0..512) |_| try emit(0, 0, 0);
+ try std.testing.expectEqual(public(.capacity), put_instruction(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+ try ok(begin_program(0, 0));
+ for (0..64) |_| try ok(put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), put_handler(0, 0, 0));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+}
+
+test "invalid declaration values state mappings event ids and handler ranges" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 0, 0));
+ try ok(begin_program(1, 0));
+ try std.testing.expectEqual(public(.non_finite), put_state(0, std.math.inf(f64), -1));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try std.testing.expectEqual(public(.bad_state), put_state(0, 1, -1));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.non_finite), put_instruction(0, 0, std.math.nan(f64)));
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.bad_event), put_handler(1, 0, 0));
+ try ok(begin_program(0, 0));
+ try ok(put_handler(0, no_pc, 5));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 1);
+ try ok(put_handler(0, 0, 1));
+ try ok(put_handler(0, 0, 1));
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "backward self out-of-handler branches and stores without handlers are rejected" {
+ for ([_]u32{ 0, 1, 3 }) |target| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, 0);
+ try emit(21, target, 0);
+ try emit(0, 0, 7);
+ try ok(put_handler(0, 0, 2));
+ try std.testing.expectEqual(public(.bad_branch), commit_program());
+ }
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 0, -1));
+ try emit(0, 0, 5);
+ try emit(20, 0, 0);
+ try std.testing.expectEqual(public(.invalid), commit_program());
+}
+
+test "event inputs and dt enforce deterministic frame contract" {
+ reset_all();
+ try counterProgram();
+ try std.testing.expectEqual(public(.bad_event), dispatch(3, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch(no_pc, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ try std.testing.expectEqual(public(.bad_frame), dispatch(1, frame_dt));
+ try std.testing.expectEqual(public(.non_finite), dispatch(0, std.math.nan(f64)));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(u32, 0), info(1));
+ try std.testing.expectEqual(no_pc, info(2));
+}
+
+test "binding evaluation is pure on both success and failure" {
+ reset_all();
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 4, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try emit(20, 0, 0);
+ try emit(2, 0, 0);
+ try emit(0, 0, 0);
+ try emit(21, 7, 0);
+ try ok(put_handler(0, 3, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.bad_frame), dispatch(0, 0));
+ const before = engine;
+ try std.testing.expectEqual(@as(f64, 12), eval_at(0, 3));
+ try std.testing.expect(std.math.isNan(eval_at(0, 4))); // attempted store
+ try std.testing.expect(std.math.isNan(eval_at(4, 1))); // dt forbidden in views
+ try std.testing.expect(std.math.isNan(eval_at(5, 2))); // branch forbidden
+ try std.testing.expect(std.math.isNan(eval_at(no_pc, 7)));
+ try std.testing.expect(std.math.isNan(eval_at(0, no_pc)));
+ try std.testing.expect(std.math.isNan(eval_at(0, 2))); // two results
+ // Compare only the live member of the untagged workspace, never padding
+ // or the inactive member's indeterminate storage.
+ try std.testing.expectEqual(before.staging_open, engine.staging_open);
+ inline for (std.meta.fields(Engine)) |field| {
+ if (comptime std.mem.eql(u8, field.name, "workspace")) {
+ if (before.staging_open) {
+ try std.testing.expectEqualDeep(before.workspace.staging, engine.workspace.staging);
+ } else {
+ try std.testing.expectEqualDeep(before.workspace.reuse, engine.workspace.reuse);
+ }
+ } else try std.testing.expectEqualDeep(@field(before, field.name), @field(engine, field.name));
+ }
+}
+
+test "arithmetic comparison logical min max and clamp expression semantics" {
+ const cases = [_]struct { op: u32, a: f64, b: f64, want: f64 }{
+ .{ .op = 3, .a = 7, .b = 2, .want = 9 },
+ .{ .op = 4, .a = 7, .b = 2, .want = 5 },
+ .{ .op = 5, .a = 7, .b = 2, .want = 14 },
+ .{ .op = 6, .a = 7, .b = 2, .want = 3.5 },
+ .{ .op = 8, .a = 2, .b = 7, .want = 1 },
+ .{ .op = 9, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 10, .a = 2, .b = 7, .want = 0 },
+ .{ .op = 11, .a = 7, .b = 7, .want = 1 },
+ .{ .op = 12, .a = 7, .b = 2, .want = 0 },
+ .{ .op = 13, .a = 7, .b = 2, .want = 1 },
+ .{ .op = 14, .a = -7, .b = 2, .want = 1 },
+ .{ .op = 15, .a = 0, .b = 2, .want = 1 },
+ .{ .op = 17, .a = 7, .b = 2, .want = 2 },
+ .{ .op = 18, .a = 7, .b = 2, .want = 7 },
+ };
+ for (cases) |case| {
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, case.a);
+ try emit(0, 0, case.b);
+ try emit(case.op, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(case.want, eval_at(0, 3));
+ }
+ reset_all();
+ try ok(begin_program(0, 0));
+ try emit(0, 0, -8);
+ try emit(7, 0, 0);
+ try emit(0, 0, 0);
+ try emit(0, 0, 5);
+ try emit(19, 0, 0);
+ try emit(16, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 5), eval_at(0, 5));
+ try std.testing.expectEqual(@as(f64, 0), eval_at(0, 6));
+}
+
+test "diagnostic counters wrap without affecting scalar values" {
+ reset_all();
+ try counterProgram();
+ engine.tick = no_pc;
+ engine.successful_events = no_pc;
+ engine.revision = no_pc;
+ try ok(dispatch(0, frame_dt));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 99, 0));
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, 0), info(4));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+}
+
+test "memory accounting separates reserved storage used IR and stack capacity" {
+ reset_all();
+ try std.testing.expectEqual(@as(u32, 0), layout(4));
+ try std.testing.expectEqual(@as(u32, 0), layout(5));
+ try counterProgram();
+ try std.testing.expectEqual(@as(u32, @sizeOf(Engine)), layout(0));
+ try std.testing.expectEqual(@as(u32, 16), layout(2));
+ try std.testing.expectEqual(@as(u32, 16), layout(3));
+ try std.testing.expectEqual(@as(u32, 80), layout(4));
+ try std.testing.expectEqual(@as(u32, 8), layout(5));
+ try std.testing.expectEqual(@as(u32, @sizeOf(Pending)), layout(6));
+ try std.testing.expect(layout(6) >= layout(1) + max_states * @sizeOf(f64));
+ try std.testing.expect(layout(0) >= layout(1) * 2);
+ try std.testing.expectEqual(@as(u32, 512), layout(7));
+ try std.testing.expect(layout(0) + 16 * 1024 < 64 * 1024);
+}
+
+fn displayCounterProgram() !void {
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 10, -1));
+ try emit(1, 0, 0);
+ try emit(0, 0, 1);
+ try emit(3, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(1, 0, 4));
+ try emit(1, 0, 0);
+ try emit(0, 0, 2);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try ok(configure_evaluation(32, 8));
+}
+
+fn boundedEquals(want: f64, start: u32, length: u32) !void {
+ begin_evaluation(100);
+ try std.testing.expectEqual(want, eval_bounded(start, length));
+ try ok(evaluation_info(0));
+}
+
+test "point coordinates are ephemeral event operands and unavailable to pure evaluation" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 0, -1));
+ try ok(put_state(1, 0, -1));
+ const events = [_]u32{ 1, 17, 2, 25 };
+ for (events, 0..) |event, i| {
+ try emit(22, 0, 0);
+ try emit(20, 0, 0);
+ try emit(23, 0, 0);
+ try emit(20, 1, 0);
+ try ok(put_handler(event, @intCast(i * 4), 4));
+ }
+ try ok(commit_program());
+ for (events, 0..) |event, i| {
+ const x: f64 = @floatFromInt(i + 10);
+ try ok(dispatch_point(event, x, -x));
+ try std.testing.expectEqual(x, get_state(0));
+ try std.testing.expectEqual(-x, get_state(1));
+ }
+ try std.testing.expectEqual(public(.bad_event), dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 13), get_state(0));
+ const diagnostics = [_]u32{ info(1), info(2), info(3), info(9) };
+ try std.testing.expect(std.math.isNan(eval_at(0, 1)));
+ try std.testing.expectEqualSlices(u32, &diagnostics, &.{ info(1), info(2), info(3), info(9) });
+ try std.testing.expectEqual(@as(u32, 4), info(9));
+}
+
+test "point event ranges reject holes and invalid payloads without committing" {
+ reset_all();
+ try counterProgram();
+ for ([_]u32{ 0, 3, 16, 18, 24, 26, 32, 33, no_pc }) |event|
+ try std.testing.expectEqual(public(.bad_event), dispatch_point(event, 1, 2));
+ try std.testing.expectEqual(public(.non_finite), dispatch_point(1, std.math.nan(f64), 0));
+ try std.testing.expectEqual(public(.non_finite), dispatch_point(1, 0, std.math.inf(f64)));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try ok(begin_program(0, 8));
+ try ok(put_handler(24, 0, 0));
+ try ok(put_handler(32, 0, 0));
+ try ok(commit_program());
+ try ok(dispatch_point(24, 1, 2));
+ try ok(dispatch_point(32, 1, 2));
+ try std.testing.expectEqual(public(.bad_event), dispatch(24, 0));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+}
+
+test "point event failure rolls back writes from earlier matching handlers" {
+ reset_all();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 7, -1));
+ try emit(22, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(17, 0, 2));
+ try emit(23, 0, 0);
+ try emit(0, 0, 0);
+ try emit(6, 0, 0);
+ try emit(20, 0, 0);
+ try ok(put_handler(17, 2, 4));
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch_point(17, 400, 200));
+ try std.testing.expectEqual(@as(f64, 7), get_state(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ try std.testing.expectEqual(@as(u32, 4), info(2));
+}
+
+test "loading shares storage with records while old events and bindings remain executable" {
+ reset_all();
+ try displayCounterProgram();
+ try boundedEquals(20, 4, 3);
+ try boundedEquals(20, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(9));
+ try emit(1, 0, 0);
+ try ok(dispatch(1, 0)); // Active state advances after staging begins.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 0), evaluation_info(3));
+ try ok(configure_evaluation(32, 1));
+ forget_evaluation(3); // Must not overwrite the pending program.
+ try emit(0, 0, 3);
+ try emit(5, 0, 0);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(33, 0, 3);
+ try boundedEquals(33, 0, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+}
+
+test "failed and abandoned candidates restore empty reuse storage without changing active work" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ try emit(20, 0, 0); // Store without a handler fails validation.
+ try std.testing.expectEqual(public(.invalid), commit_program());
+ try boundedEquals(20, 4, 3);
+ try ok(dispatch(1, 0));
+ abort_program();
+ try boundedEquals(22, 4, 3);
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ abort_program(); // Inactive abort is a no-op, including for warmed records.
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(3));
+ try ok(begin_program(1, 1));
+ try std.testing.expectEqual(public(.capacity), begin_program(max_states + 1, 0));
+ try std.testing.expectEqual(@as(u32, 1), evaluation_info(9));
+ try boundedEquals(22, 4, 3);
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+}
+
+test "full pending program survives repeated evaluation configuration record loss and reset" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(dispatch(1, 0));
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 900, 0));
+ for (0..max_instructions) |i| {
+ try emit(0, 0, @floatFromInt(i));
+ try ok(configure_evaluation(32, @intCast(i % 9)));
+ forget_evaluation(3);
+ try boundedEquals(22, 4, 3);
+ }
+ reset_state(); // The pending definition and old-id mapping survive reset.
+ try boundedEquals(20, 4, 3);
+ try ok(commit_program());
+ try std.testing.expectEqual(@as(u32, max_instructions), info(6));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try boundedEquals(511, 511, 1);
+ // Safe Zig builds may reserve a hidden union tag in addition to the payload.
+ try std.testing.expect(@sizeOf(Workspace) >= @sizeOf(Pending));
+ try std.testing.expect(@sizeOf(Workspace) <= @sizeOf(Pending) + @alignOf(Pending));
+ try std.testing.expectEqual(@as(u32, @sizeOf(Tables)), evaluation_info(8));
+}
+
+test "restored definition and values commit together without changing reset initializers" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ try ok(dispatch(0, frame_dt));
+ const revision = info(4);
+ try ok(begin_program(2, 0));
+ try ok(put_state(0, 100, -1));
+ try ok(put_state(1, 200, -1));
+ try ok(put_restored_value(1, -0.0));
+ try ok(put_restored_value(0, 37));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try ok(dispatch(1, 0)); // Old execution remains independent of pending values.
+ try ok(commit_restored_program());
+ try std.testing.expectEqual(@as(f64, 37), get_state(0));
+ try std.testing.expectEqual(@as(u64, @bitCast(@as(f64, -0.0))), @as(u64, @bitCast(get_state(1))));
+ try std.testing.expectEqual(revision + 1, info(4));
+ try std.testing.expectEqual(@as(u32, 0), info(0));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+ reset_state();
+ try std.testing.expectEqual(@as(f64, 100), get_state(0));
+ try std.testing.expectEqual(@as(f64, 200), get_state(1));
+}
+
+test "missing duplicate nonfinite or invalid restored candidates leave active work untouched" {
+ reset_all();
+ try counterProgram();
+ try ok(dispatch(1, 0));
+ const revision = info(4);
+ for (0..4) |mode| {
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 999, -1));
+ if (mode == 1) {
+ try ok(put_restored_value(0, 30));
+ try std.testing.expectEqual(public(.bad_state), put_restored_value(0, 40));
+ } else if (mode == 2) {
+ try std.testing.expectEqual(public(.non_finite), put_restored_value(0, std.math.inf(f64)));
+ } else if (mode == 3) {
+ try ok(put_restored_value(0, 30));
+ try emit(20, 0, 0); // Invalid store outside a handler.
+ }
+ try std.testing.expect(commit_restored_program() != 0);
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ try std.testing.expectEqual(revision, info(4));
+ try std.testing.expectEqual(@as(u32, 1), info(9));
+ abort_program();
+ }
+ try ok(begin_program(1, 0));
+ try ok(put_state(0, 100, -1));
+ try ok(put_restored_value(0, 42));
+ try std.testing.expectEqual(public(.bad_state), commit_program());
+ abort_program();
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+}
+
+test "empty and full restored programs validate all values and discard old display records" {
+ reset_all();
+ try displayCounterProgram();
+ try ok(configure_evaluation(32, 8));
+ try boundedEquals(20, 4, 3);
+ try ok(begin_program(32, 0));
+ for (0..32) |i| {
+ try ok(put_state(@intCast(i), 0, -1));
+ try ok(put_restored_value(@intCast(i), @floatFromInt(i + 1)));
+ }
+ try emit(1, 31, 0);
+ try ok(commit_restored_program());
+ try boundedEquals(32, 0, 1);
+ try ok(begin_program(0, 0));
+ try ok(commit_restored_program());
+ try std.testing.expectEqual(@as(u32, 0), info(5));
+ try std.testing.expect(std.math.isNan(get_state(0)));
+}
+
+test "shared handler bodies access their own bound state slots in source order" {
+ reset_all();
+ try ok(begin_program(2, 1));
+ try ok(put_state(0, 10, -1));
+ try ok(put_state(1, 20, -1));
+ try emit(24, 0, 0); try emit(0, 0, 1); try emit(3, 0, 0); try emit(25, 0, 0);
+ try ok(put_handler(1, 0, 4)); try ok(bind_handler(0, 0, 1));
+ try ok(put_handler(1, 0, 4)); try ok(bind_handler(1, 1, 1));
+ try ok(put_handler(1, 0, 4)); try ok(bind_handler(2, 0, 1));
+ try ok(commit_program());
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(0));
+ try std.testing.expectEqual(@as(f64, 21), get_state(1));
+ try std.testing.expectEqual(@as(u32, 4), info(6)); // Three bindings, one body.
+ try std.testing.expectEqual(@as(u32, 12), info(3));
+ const diagnostics = [_]u32{ info(1), info(2), info(3), info(9) };
+ try std.testing.expect(std.math.isNan(eval_at(0, 1)));
+ try std.testing.expectEqualSlices(u32, &diagnostics, &.{ info(1), info(2), info(3), info(9) });
+}
+
+test "failure in a later shared binding rolls back all earlier bindings" {
+ reset_all();
+ try ok(begin_program(4, 1));
+ for ([_]f64{ 8, 2, 12, 0 }, 0..) |value, i| try ok(put_state(@intCast(i), value, -1));
+ try emit(24, 0, 0); try emit(24, 1, 0); try emit(6, 0, 0); try emit(25, 0, 0);
+ for (0..2) |i| {
+ try ok(put_handler(1, 0, 4));
+ try ok(bind_handler(@intCast(i), @intCast(i * 2), 2));
+ }
+ try ok(commit_program());
+ try std.testing.expectEqual(public(.division_by_zero), dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 8), get_state(0));
+ try std.testing.expectEqual(@as(f64, 12), get_state(2));
+ try std.testing.expectEqual(@as(u32, 0), info(9));
+}
+
+test "shared bindings reject missing arguments partial overlaps and invalid slot mappings" {
+ reset_all();
+ try counterProgram();
+ const revision = info(4);
+ for (0..5) |mode| {
+ try ok(begin_program(1, 1)); try ok(put_state(0, 9, -1));
+ try emit(24, 0, 0); try emit(25, 0, 0);
+ try ok(put_handler(1, 0, 2));
+ switch (mode) {
+ 0 => {}, // Parameter op without a binding.
+ 1 => { try ok(bind_handler(0, 0, 1)); try std.testing.expectEqual(public(.invalid), bind_handler(0, 0, 1)); },
+ 2 => { try std.testing.expectEqual(public(.bad_state), bind_handler(0, 0, 2)); },
+ 3 => {
+ try ok(bind_handler(0, 0, 1));
+ try ok(put_handler(1, 1, 1)); try ok(bind_handler(1, 0, 1));
+ },
+ else => {
+ try ok(bind_handler(0, 0, 1));
+ try ok(put_handler(1, 0, 2)); // Another owner missing its mapping.
+ },
+ }
+ try std.testing.expect(commit_program() != 0); abort_program();
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(revision, info(4));
+ }
+}
+
+test "one routed handler changes only its event target and rejects missing or foreign routes" {
+ reset_all();
+ try ok(begin_program(2, 2));
+ try ok(put_state(0, 10, -1));
+ try ok(put_state(1, 20, -1));
+ try ok(put_pointer_target(0, 0, 1));
+ try ok(put_pointer_target(0, 1, 1));
+ try emit(22, 0, 0);
+ try emit(25, 0, 0);
+ try ok(put_handler(17, 0, 2));
+ try ok(bind_target_handler(0, 1));
+ try ok(commit_program());
+ try ok(dispatch_target(17, 1, 77, 0));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(f64, 77), get_state(1));
+ try std.testing.expectEqual(@as(u32, 2), info(3));
+ try std.testing.expectEqual(public(.bad_event), dispatch_point(17, 99, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch(1, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch_target(17, 2, 99, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch_target(17, no_pc, 99, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch_target(18, 0, 99, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch_target(0, 0, 99, 0));
+ try std.testing.expectEqual(public(.non_finite), dispatch_target(17, 0, std.math.inf(f64), 0));
+ try std.testing.expectEqual(@as(u32, 1), info(9));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try std.testing.expectEqual(@as(f64, 77), get_state(1));
+ try std.testing.expectEqual(@as(u32, 3), layout(8));
+ try std.testing.expectEqual(@as(u32, 2), layout(9));
+ try std.testing.expectEqual(@as(u32, 54), layout(4));
+}
+
+test "routes reject invalid mappings and bindings without replacing active work" {
+ for (0..7) |mode| {
+ reset_all();
+ try counterProgram();
+ try ok(begin_program(2, 2));
+ try ok(put_state(0, 0, -1));
+ try ok(put_state(1, 0, -1));
+ switch (mode) {
+ 0 => try std.testing.expectEqual(public(.bad_state), put_pointer_target(0, 1, 2)),
+ 1 => try std.testing.expectEqual(public(.bad_event), put_pointer_target(2, 0, 1)),
+ 2 => {
+ try ok(put_pointer_target(0, 0, 2));
+ try std.testing.expectEqual(public(.invalid), put_pointer_target(1, 1, 1));
+ },
+ else => {
+ if (mode != 3) try ok(put_pointer_target(0, 0, 1));
+ try emit(0, 0, 2);
+ try emit(25, 0, 0);
+ try ok(put_handler(if (mode == 4) 0 else 1, 0, 2));
+ try ok(bind_target_handler(0, if (mode == 5) 2 else 1));
+ if (mode == 6) try std.testing.expectEqual(public(.invalid), bind_target_handler(0, 1));
+ },
+ }
+ try std.testing.expect(commit_program() != 0);
+ // Active program still executes while the failed candidate occupies staging.
+ try ok(dispatch(1, 0));
+ try std.testing.expectEqual(@as(f64, 11), get_state(0));
+ abort_program();
+ try std.testing.expectEqual(@as(u32, 0), layout(9));
+ }
+ reset_all();
+ try ok(begin_program(32, 1));
+ for (0..32) |i| try ok(put_state(@intCast(i), 0, -1));
+ for (0..24) |i| try ok(put_pointer_target(0, @intCast(i), 1));
+ try std.testing.expectEqual(public(.capacity), put_pointer_target(0, 24, 1));
+ try std.testing.expectEqual(public(.capacity), commit_program());
+}
+
+test "routed event rollback includes globals and earlier handlers and survives staged route replacement" {
+ reset_all();
+ try ok(begin_program(3, 1));
+ for (0..3) |i| try ok(put_state(@intCast(i), @floatFromInt(i), -1));
+ try ok(put_pointer_target(0, 1, 1));
+ try ok(put_pointer_target(0, 2, 1));
+ try emit(22, 0, 0);
+ try emit(25, 0, 0);
+ try ok(put_handler(17, 0, 2));
+ try ok(bind_target_handler(0, 1));
+ try emit(0, 0, 99);
+ try emit(20, 0, 0);
+ try emit(0, 0, 1);
+ try emit(23, 0, 0);
+ try emit(6, 0, 0);
+ try emit(25, 0, 0);
+ try ok(put_handler(17, 2, 6));
+ try ok(bind_target_handler(1, 1));
+ try ok(commit_program());
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 0, -1));
+ try ok(put_pointer_target(0, 0, 1));
+ try std.testing.expectEqual(public(.division_by_zero), dispatch_target(17, 1, 88, 0));
+ try std.testing.expectEqual(@as(f64, 0), get_state(0));
+ try std.testing.expectEqual(@as(f64, 1), get_state(1));
+ try std.testing.expectEqual(@as(f64, 2), get_state(2));
+ abort_program();
+ try ok(dispatch_target(17, 1, 88, 2));
+ try std.testing.expectEqual(@as(f64, 99), get_state(0));
+ try std.testing.expectEqual(@as(f64, 1), get_state(1));
+ try std.testing.expectEqual(@as(f64, 0.5), get_state(2));
+}
+
+test "scalar inputs carry a finite ephemeral value without adding state or pointer ownership" {
+ reset_all();
+ try ok(begin_program(1, 1));
+ try ok(put_state(0, 50, -1));
+ try emit(26, 0, 0); try emit(0, 0, 20); try emit(0, 0, 80); try emit(19, 0, 0); try emit(20, 0, 0);
+ try ok(put_handler(33, 0, 5)); try ok(commit_program());
+ try ok(dispatch_value(33, 200));
+ try std.testing.expectEqual(@as(f64, 80), get_state(0));
+ try ok(dispatch_value(33, -200));
+ try std.testing.expectEqual(@as(f64, 20), get_state(0));
+ try std.testing.expectEqual(@as(u32, 1), info(5));
+ try std.testing.expect(std.math.isNan(eval_at(0, 1)));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+ for ([_]u32{0, 1, 17, 25, 34, no_pc}) |event|
+ try std.testing.expectEqual(public(.bad_event), dispatch_value(event, 60));
+ try std.testing.expectEqual(public(.bad_event), dispatch(33, 0));
+ try std.testing.expectEqual(public(.bad_event), dispatch_point(33, 60, 0));
+ try std.testing.expectEqual(public(.non_finite), dispatch_value(33, std.math.nan(f64)));
+ try std.testing.expectEqual(@as(f64, 20), get_state(0));
+ try std.testing.expectEqual(@as(u32, 2), info(9));
+}
+
+test "scalar input failure rolls back earlier writes and old scalar events survive staging" {
+ reset_all();
+ try ok(begin_program(1, 1)); try ok(put_state(0, 10, -1));
+ try emit(26, 0, 0); try emit(20, 0, 0); try ok(put_handler(33, 0, 2));
+ try emit(0, 0, 1); try emit(26, 0, 0); try emit(6, 0, 0); try emit(20, 0, 0);
+ try ok(put_handler(33, 2, 4)); try ok(commit_program());
+ try ok(begin_program(0, 0));
+ try std.testing.expectEqual(public(.division_by_zero), dispatch_value(33, 0));
+ try std.testing.expectEqual(@as(f64, 10), get_state(0));
+ try ok(dispatch_value(33, 2));
+ try std.testing.expectEqual(@as(f64, 0.5), get_state(0));
+ abort_program();
+ try std.testing.expectEqual(@as(u32, 1), info(9));
+}
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/sources/evaluation.zig b/prototypes/workbench/results/2026-09-23-web-layout/sources/evaluation.zig
new file mode 100644
index 0000000..56081d9
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/sources/evaluation.zig
@@ -0,0 +1,164 @@
+//! Disposable binding results and change history. No event state lives here.
+const std = @import("std");
+
+const Memo = struct {
+ value: f64 = 0,
+ revision: u64 = 0,
+ dependencies: u32 = 0,
+ start: u32 = 0,
+ length: u32 = 0,
+};
+const Change = struct { revision: u64 = 0, mask: u32 = 0 };
+
+pub const Tables = struct {
+ memo: [32]Memo = @splat(.{}),
+ history: [8]Change = @splat(.{}),
+};
+
+pub const Evaluation = struct {
+ pub const max_cache = 32;
+ pub const max_history = 8;
+ revision: u64 = 1,
+ floor: u64 = 1,
+ cache_count: u32 = 0,
+ history_count: u32 = 8,
+ next_change: u32 = 0,
+ limit: u32 = 0,
+ work: u32 = 0,
+ instructions: u32 = 0,
+ hits: u32 = 0,
+ status: u32 = 0,
+
+ pub fn configure(self: *Evaluation, tables: ?*Tables, cache: u32, history: u32) bool {
+ if (cache > max_cache or history > max_history) return false;
+ self.cache_count = cache;
+ self.history_count = history;
+ self.forget(tables, 3);
+ return true;
+ }
+
+ pub fn forget(self: *Evaluation, tables: ?*Tables, flags: u32) void {
+ if (flags & 1 != 0) if (tables) |t| { t.memo = @splat(.{}); };
+ if (flags & 2 != 0) {
+ if (tables) |t| t.history = @splat(.{});
+ self.floor = self.revision;
+ self.next_change = 0;
+ }
+ }
+
+ pub fn changed(self: *Evaluation, tables: ?*Tables, mask: u32) void {
+ const t = tables orelse return;
+ if (mask == 0 or self.cache_count == 0) return;
+ if (self.revision == std.math.maxInt(u64)) {
+ self.revision = 1;
+ self.forget(t, 3);
+ }
+ self.revision += 1;
+ if (self.history_count == 0) {
+ self.floor = self.revision;
+ return;
+ }
+ const change = &t.history[self.next_change];
+ self.floor = @max(self.floor, change.revision);
+ change.* = .{ .revision = self.revision, .mask = mask };
+ self.next_change = (self.next_change + 1) % self.history_count;
+ }
+
+ pub fn begin(self: *Evaluation, limit: u32) void {
+ self.limit = limit;
+ self.work = 0;
+ self.instructions = 0;
+ self.hits = 0;
+ self.status = 0;
+ }
+
+ pub fn spend(self: *Evaluation) bool {
+ if (self.work == self.limit) {
+ self.status = 10;
+ return false;
+ }
+ self.work += 1;
+ return true;
+ }
+
+ fn slot(self: *const Evaluation, start: u32, length: u32) u32 {
+ // One candidate, including on misses; no scan proportional to cache size.
+ return (start ^ (start >> 5) ^ (length *% 0x9e3779b9)) % self.cache_count;
+ }
+
+ pub fn lookup(self: *Evaluation, tables: ?*Tables, start: u32, length: u32) ?f64 {
+ const t = tables orelse return null;
+ if (self.cache_count == 0) return null;
+ if (!self.spend()) return null;
+ const memo = &t.memo[self.slot(start, length)];
+ if (memo.revision == 0 or memo.start != start or memo.length != length) return null;
+ // Successful pure constants need no input-change evidence. A program
+ // replacement still clears them, just like every other binding memo.
+ if (memo.dependencies != 0 and memo.revision < self.floor) return null;
+ if (memo.dependencies != 0 and memo.revision != self.revision) {
+ for (t.history[0..self.history_count]) |change| {
+ if (!self.spend()) return null;
+ if (change.revision > memo.revision and change.mask & memo.dependencies != 0) return null;
+ }
+ }
+ memo.revision = self.revision;
+ self.hits += 1;
+ return memo.value;
+ }
+
+ pub fn remember(self: *Evaluation, tables: ?*Tables, start: u32, length: u32, value: f64, dependencies: u32) void {
+ const t = tables orelse return;
+ if (self.cache_count == 0) return;
+ t.memo[self.slot(start, length)] = .{ .start = start, .length = length,
+ .value = value, .dependencies = dependencies, .revision = self.revision };
+ }
+};
+
+test "record loss cannot authorize an old memo; unrelated changes can" {
+ var tables: Tables = .{};
+ var e: Evaluation = .{};
+ try std.testing.expect(e.configure(&tables, 1, 1));
+ e.begin(100);
+ e.remember(&tables, 0, 3, 12, 1);
+ e.changed(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, 12), e.lookup(&tables, 0, 3));
+ e.changed(&tables, 2);
+ e.changed(&tables, 2); // The certificate now predates the surviving history.
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 12, 1);
+ e.changed(&tables, 1);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 20, 1);
+ e.changed(&tables, 2);
+ e.forget(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ e.remember(&tables, 0, 3, 20, 1);
+ e.revision = std.math.maxInt(u64);
+ e.changed(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+}
+
+test "budget, collisions, invalid settings, and disabled history are conservative" {
+ var tables: Tables = .{};
+ var e: Evaluation = .{};
+ try std.testing.expect(e.configure(&tables, 1, 0));
+ e.begin(100);
+ e.remember(&tables, 0, 3, 12, 1);
+ e.remember(&tables, 1, 3, 15, 2);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 0, 3));
+ try std.testing.expectEqual(@as(?f64, 15), e.lookup(&tables, 1, 3));
+ e.changed(&tables, 0x80000000);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 1, 3));
+ try std.testing.expect(!e.configure(&tables, 33, 0));
+ try std.testing.expectEqual(@as(u32, 1), e.cache_count);
+ e.begin(0);
+ try std.testing.expectEqual(@as(?f64, null), e.lookup(&tables, 1, 3));
+ try std.testing.expectEqual(@as(u32, 10), e.status);
+ try std.testing.expectEqual(@as(u32, 0), e.work);
+ e.begin(100);
+ e.remember(&tables, 2, 1, 99, 0);
+ e.changed(&tables, 1);
+ e.forget(&tables, 2);
+ try std.testing.expectEqual(@as(?f64, 99), e.lookup(&tables, 2, 1));
+ try std.testing.expectEqual(@as(u32, 1), e.work);
+}
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/sources/examples/document.pure b/prototypes/workbench/results/2026-09-23-web-layout/sources/examples/document.pure
new file mode 100644
index 0000000..6d78f8c
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/sources/examples/document.pure
@@ -0,0 +1,6 @@
+# 웹 컨트롤이 요청한 분할 비율을 받습니다. 레이아웃과 문서는 브라우저가 소유합니다.
+state ratio = 50
+input split = value
+input reset = KeyR
+on split.change { ratio = clamp(event_value, 20, 80) }
+on reset.down { ratio = 50 }
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/sources/qa-document-guards.js b/prototypes/workbench/results/2026-09-23-web-layout/sources/qa-document-guards.js
new file mode 100644
index 0000000..c938ec3
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/sources/qa-document-guards.js
@@ -0,0 +1,30 @@
+async page => {
+ const checks=[],check=(ok,label)=>{if(!ok)throw new Error(label);checks.push(label);};
+ const onDialog=d=>d.accept();page.on('dialog',onDialog);
+ try {
+ await page.goto('http://127.0.0.1:8766/document.html');await page.waitForFunction(()=>window.pureDocument?.snapshot().ready);
+ await page.setViewportSize({width:1440,height:1000});await page.locator('#document-text').fill('보존할 문서');
+ await page.locator('.policy summary').click();const source=await page.locator('#policy-source').inputValue();
+ const bad=source.replace('clamp(event_value, 20, 80)','999');check(bad!==source,'invalid-output fixture changes the numeric rule');
+ await page.locator('#policy-source').fill(bad);await page.locator('#apply-policy').click();await page.locator('#split-range').focus();await page.keyboard.press('End');
+ check(await page.locator('#divider').getAttribute('aria-valuenow')==='50'&&await page.locator('#split-range').inputValue()==='50'&&(await page.locator('#policy-status').textContent()).includes('20~80'),'out-of-range authored output preserves both the layout and its native control projection');
+ await page.locator('#policy-source').fill(source);await page.locator('#apply-policy').click();await page.locator('#split-range').focus();await page.keyboard.press('Home');
+ check(await page.locator('#divider').getAttribute('aria-valuenow')==='20','a corrected policy and valid request recover from invalid host output');
+ await page.locator('#policy-source').fill(source.replace('clamp(event_value, 20, 80)','1 / 0'));await page.locator('#apply-policy').click();await page.locator('#split-range').focus();await page.keyboard.press('End');
+ const s=await page.evaluate(()=>window.pureDocument.snapshot());check(s.runtime.lastError===4&&s.runtime.states.ratio===20&&await page.locator('#split-range').inputValue()==='20','arithmetic rollback also restores the native range projection');
+ await page.locator('#policy-source').fill(source);await page.locator('#apply-policy').click();
+ await page.locator('#divider').scrollIntoViewIfNeeded();const box=await page.locator('#divider').boundingBox();await page.mouse.move(box.x+box.width/2,box.y+box.height/2);await page.mouse.down();
+ const revision=await page.evaluate(()=>window.pureDocument.snapshot().runtime.revision);
+ await page.evaluate(()=>document.querySelector('#apply-policy').click());
+ check(await page.evaluate(r=>window.pureDocument.snapshot().dragging&&window.pureDocument.snapshot().runtime.revision===r,revision)&&(await page.locator('#policy-status').textContent()).includes('경계를 놓거나'),'policy replacement is refused during an active native gesture');await page.mouse.up();
+ await page.locator('#file-input').evaluate(input=>{const d=new DataTransfer();d.items.add(new File([new Uint8Array(2*1024*1024+1)],'oversize.txt'));input.files=d.files;input.dispatchEvent(new Event('change',{bubbles:true}));});
+ await page.waitForFunction(()=>document.querySelector('#document-status').textContent.includes('2 MiB'));
+ check(await page.locator('#document-text').inputValue()==='보존할 문서','oversize files cannot replace the editable text');
+ await page.locator('.policy summary').click();await page.locator('#sample').click();await page.locator('#reset-split').click();
+ await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
+ await page.screenshot({path:'output/playwright/pure-document-desktop.png',fullPage:true});
+ await page.setViewportSize({width:390,height:844});await page.screenshot({path:'output/playwright/pure-document-mobile.png',fullPage:true});
+ await page.evaluate(e=>window.__pureDocumentGuards=e,{checks});return{checks};
+ }catch(error){await page.evaluate(e=>window.__pureDocumentGuards=e,{failed:String(error),checks});throw error;}
+ finally{page.off('dialog',onDialog);}
+}
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/sources/qa-document.js b/prototypes/workbench/results/2026-09-23-web-layout/sources/qa-document.js
new file mode 100644
index 0000000..46b9b8c
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/sources/qa-document.js
@@ -0,0 +1,103 @@
+// Playwright CLI run-code. Functional and ownership checks, not a timing benchmark.
+async page => {
+ const checks=[],errors=[],check=(ok,label)=>{if(!ok)throw new Error(label);checks.push(label);};
+ const onDialog=d=>d.accept(),onError=e=>errors.push(e.message);page.on('dialog',onDialog);page.on('pageerror',onError);
+ const state=()=>page.evaluate(()=>window.pureDocument.snapshot());
+ const settled=()=>page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
+ const ratio=async expected=>{await page.waitForFunction(n=>Math.abs(window.pureDocument.snapshot().runtime.states.ratio-n)<0.2,expected);};
+ const geometry=()=>page.evaluate(()=>{const p=document.querySelector('#panes').getBoundingClientRect(),h=document.querySelector('#divider').getBoundingClientRect();return{left:p.left,width:p.width,handle:h.width,x:h.left+h.width/2,y:h.top+h.height/2};});
+ const target=(g,r)=>g.left+g.handle/2+(g.width-g.handle)*r/100;
+ const dragStart=async()=>{await page.locator('#divider').scrollIntoViewIfNeeded();const g=await geometry();await page.mouse.move(g.x,g.y);await page.mouse.down();return g;};
+ const upload=async(content,name='notes.txt')=>page.locator('#file-input').evaluate((input,{content,name})=>{const d=new DataTransfer();d.items.add(new File([content],name,{type:'text/plain'}));input.files=d.files;input.dispatchEvent(new Event('change',{bubbles:true}));},{content,name});
+ try {
+ await page.setViewportSize({width:1440,height:1000});await page.reload();
+ await page.waitForFunction(()=>window.pureDocument?.snapshot().ready);
+ check((await state()).runtime.memory.activeStateBytes===8,'only the authored ratio occupies VM state; no document or geometry mirror');
+ await page.locator('#sample').click();await settled();
+ const original=await page.locator('#document-text').inputValue();
+ check(await page.locator('#reading-text').textContent()===original,'native editor and reading pane show the same Unicode document');
+ check(await page.locator('#reading-text div').count()===0,'document markup is displayed as text');
+ const beforeResize=await state();
+ await page.locator('#document-text').evaluate(t=>{t.focus();t.setSelectionRange(20,25);t.scrollTop=80;});
+ await page.setViewportSize({width:1040,height:900});await settled();
+ const afterResize=await state();
+ check(afterResize.runtime.successfulEvents===beforeResize.runtime.successfulEvents&&afterResize.stats.previewWrites===beforeResize.stats.previewWrites&&afterResize.stats.geometryReads===beforeResize.stats.geometryReads,'resize reflows through CSS without VM events, document publication or application geometry reads');
+ check(await page.locator('#document-text').evaluate(t=>t.selectionStart===20&&t.selectionEnd===25),'reflow preserves the native editing selection');
+ const scrolling=await page.evaluate(()=>{const t=document.querySelector('#document-text'),r=document.querySelector('#reading-scroll');r.scrollTop=120;return{editor:t.scrollTop,reading:r.scrollTop,scrollable:r.scrollHeight>r.clientHeight};});
+ check(scrolling.scrollable&&scrolling.reading>0,'long wrapped content uses the native reading scroll area');
+ await page.locator('#reading-scroll').evaluate(r=>r.scrollTop+=80);await settled();
+ check(await page.locator('#document-text').evaluate((t,y)=>t.scrollTop===y,scrolling.editor),'reading scroll does not move the editing pane');
+ check((await state()).runtime.successfulEvents===afterResize.runtime.successfulEvents,'native scrolling does not dispatch layout data to the VM');
+ await page.locator('#split-range').focus();await page.keyboard.press('End');await ratio(80);
+ check((await state()).runtime.states.ratio===80,'a native range input uses the authored numeric-input policy');
+ await page.locator('#divider').focus();await page.keyboard.press('Home');await ratio(20);await page.keyboard.press('Shift+ArrowRight');await ratio(30);
+ check(await page.locator('#divider').getAttribute('aria-valuenow')==='30','keyboard separator control publishes the same value and accessibility state');
+ await page.locator('#reset-split').click();await ratio(50);
+ const textWrites=(await state()).stats.previewWrites;
+ let g=await dragStart();await page.mouse.move(target(g,65),g.y);await ratio(65);await page.mouse.up();
+ check(Math.abs((await state()).runtime.states.ratio-65)<0.2&&!(await state()).dragging,'mouse release commits the last delivered split position');
+ check((await state()).stats.previewWrites===textWrites,'resizing panes never republishes unchanged document text');
+ g=await dragStart();await page.mouse.move(target(g,35),g.y);await ratio(35);await page.keyboard.press('Escape');await page.mouse.up();await ratio(65);
+ check(!(await state()).dragging,'Escape restores the ratio captured at gesture start');
+ g=await dragStart();await page.setViewportSize({width:1240,height:1000});await settled();g=await geometry();
+ await page.mouse.move(target(g,45),g.y);await ratio(45);await page.mouse.up();
+ check(Math.abs((await state()).runtime.states.ratio-45)<0.2,'a resize during capture invalidates geometry before the next move');
+ // Capture a real touch; mouse compatibility termination must not release it.
+ const cdp=await page.context().newCDPSession(page);
+ try {
+ await cdp.send('Emulation.setTouchEmulationEnabled',{enabled:true,maxTouchPoints:2});g=await geometry();
+ await cdp.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[{id:1,x:g.x,y:g.y}]});
+ await cdp.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{id:1,x:target(g,70),y:g.y}]});await ratio(70);
+ await page.evaluate(()=>window.dispatchEvent(new MouseEvent('mouseup',{button:0,bubbles:true})));
+ check((await state()).dragging,'a compatibility mouseup cannot release a touch owner');
+ await cdp.send('Input.dispatchTouchEvent',{type:'touchCancel',touchPoints:[]});await ratio(45);
+ check(!(await state()).dragging,'touch cancellation restores the original panel ratio');
+ } finally {await cdp.send('Emulation.setTouchEmulationEnabled',{enabled:false});await cdp.detach();}
+ g=await dragStart();await page.mouse.move(target(g,60),g.y);await ratio(60);
+ await page.setViewportSize({width:390,height:844});await settled();await page.mouse.up();await ratio(45);
+ check(await page.locator('#divider').isHidden()&&!(await state()).dragging,'narrow layout cancels the active split gesture and stacks the panes');
+ check(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'the narrow document workspace has no page-level horizontal overflow');
+ const oldStats=await state();
+ await page.locator('#document-text').fill('한글 문서 📝\n새 줄과 <태그>도 그대로.');await settled();
+ check(await page.locator('#reading-text').textContent()==='한글 문서 📝\n새 줄과 <태그>도 그대로.','editing Unicode updates the text projection');
+ check((await state()).runtime.successfulEvents===oldStats.runtime.successfulEvents&& (await state()).stats.geometryReads===oldStats.stats.geometryReads,'text editing neither sends document copies to the VM nor reads geometry');
+ await page.locator('#document-text').focus();await page.keyboard.press('Control+End');await page.keyboard.insertText(' 추가');await settled();await page.keyboard.press('Control+z');await settled();
+ check(await page.locator('#document-text').inputValue()==='한글 문서 📝\n새 줄과 <태그>도 그대로.','native editing undo survives the Pure integration');
+ const compositionBefore=(await state()).stats.previewWrites;
+ await page.locator('#document-text').evaluate(t=>{t.dispatchEvent(new CompositionEvent('compositionstart'));t.value+=' 조합';t.dispatchEvent(new InputEvent('input',{bubbles:true,isComposing:true}));});await settled();
+ check((await state()).stats.previewWrites===compositionBefore,'in-progress composition does not replace the reading projection');
+ await page.locator('#document-text').evaluate(t=>t.dispatchEvent(new CompositionEvent('compositionend')));await settled();
+ check((await page.locator('#reading-text').textContent()).endsWith(' 조합'),'composition completion publishes the final text (synthetic event sequence)');
+ const fileText='표준 UTF-8 파일\n외부 편집기에서 만든 내용\n😀 <article>문자</article>\n';
+ await upload(fileText,'external.txt');await page.waitForFunction(()=>document.querySelector('#document-status').textContent.includes('파일을 열었습니다'));await settled();
+ check(await page.locator('#document-text').inputValue()===fileText,'a standard UTF-8 text file opens without a custom container');
+ const download=page.waitForEvent('download');await page.locator('#save').click();const saved=await download;const savedPath='output/playwright/pure-document-saved.txt';await saved.saveAs(savedPath);
+ check(saved.suggestedFilename()==='external.txt','export keeps the standard text filename');
+ const fresh=await page.context().newPage();await fresh.goto(page.url());await fresh.waitForFunction(()=>window.pureDocument?.snapshot().ready);
+ await fresh.locator('#file-input').setInputFiles(savedPath);await fresh.waitForFunction(()=>document.querySelector('#document-status').textContent.includes('파일을 열었습니다'));
+ check(await fresh.locator('#document-text').inputValue()===fileText,'the exported file reopens in a fresh page with exact text');await fresh.close();await page.bringToFront();
+ await page.locator('#file-input').evaluate(input=>{const f=new File(['late'],'late.txt');f.arrayBuffer=()=>new Promise(resolve=>window.finishDocumentRead=()=>resolve(new TextEncoder().encode('late').buffer));const d=new DataTransfer();d.items.add(f);Object.defineProperty(input,'files',{configurable:true,value:d.files});input.dispatchEvent(new Event('change',{bubbles:true}));});
+ await page.locator('#document-text').fill('읽는 동안 새로 편집한 문서');await page.evaluate(()=>window.finishDocumentRead());await settled();
+ check(await page.locator('#document-text').inputValue()==='읽는 동안 새로 편집한 문서'&&(await page.locator('#document-status').textContent()).includes('읽는 동안'),'a delayed file read cannot overwrite newer editing');
+ await page.locator('#file-input').evaluate(input=>{delete input.files;const d=new DataTransfer();d.items.add(new File([new Uint8Array([255])],'invalid.txt'));input.files=d.files;input.dispatchEvent(new Event('change',{bubbles:true}));});
+ await page.waitForFunction(()=>document.querySelector('#document-status').textContent.includes('UTF-8 텍스트로 읽을 수'));check(await page.locator('#document-text').inputValue()==='읽는 동안 새로 편집한 문서','invalid UTF-8 leaves the current document intact');
+ await page.setViewportSize({width:1440,height:1000});await settled();
+ await page.locator('.policy summary').click();const source=await page.locator('#policy-source').inputValue();
+ await page.locator('#policy-source').fill(source.replace('20, 80','30, 70'));await page.locator('#apply-policy').click();
+ await page.locator('#split-range').focus();await page.keyboard.press('End');await ratio(70);
+ check((await state()).runtime.states.ratio===70,'changing the authored rule changes native control behavior without touching the HTML adapter');
+ const revision=(await state()).runtime.revision;await page.locator('#policy-source').fill('state broken =');await page.locator('#apply-policy').click();
+ check((await state()).runtime.revision===revision&&await page.locator('#document-text').inputValue()==='읽는 동안 새로 편집한 문서','invalid policy edits preserve the executable policy and document');
+ await page.locator('#policy-source').fill(source);await page.locator('#apply-policy').click();await page.locator('.policy summary').click();
+ await page.locator('#sample').click();await settled();await page.locator('#reset-split').click();
+ await page.screenshot({path:'output/playwright/pure-document-desktop.png',fullPage:true});
+ await page.setViewportSize({width:390,height:844});await page.screenshot({path:'output/playwright/pure-document-mobile.png',fullPage:true});
+ const fallback=await page.context().newPage();await fallback.route('**/core.wasm',r=>r.abort());await fallback.goto(page.url());await fallback.waitForFunction(()=>window.pureDocument);
+ await fallback.locator('#document-text').fill('실행기 없이도 보존되는 텍스트');await fallback.waitForFunction(()=>document.querySelector('#reading-text').textContent.includes('실행기 없이도'));
+ check(!await fallback.evaluate(()=>window.pureDocument.snapshot().ready)&&await fallback.locator('#split-range').isDisabled()&&await fallback.locator('#save').isEnabled(),'missing Wasm disables split controls while standard text editing and saving remain available');await fallback.close();
+ check(errors.length===0,'no uncaught page errors');
+ const evidence={checks,errors,fileText,savedPath,final:await state(),browser:await page.evaluate(()=>navigator.userAgent)};
+ await page.evaluate(e=>window.__pureDocumentEvidence=e,evidence);return{checks:checks.length,errors};
+ } catch(error){await page.evaluate(e=>window.__pureDocumentEvidence=e,{failed:String(error),checks,errors,state:await state()});throw error;}
+ finally{page.off('dialog',onDialog);page.off('pageerror',onError);}
+}
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/sources/value-input.test.mjs b/prototypes/workbench/results/2026-09-23-web-layout/sources/value-input.test.mjs
new file mode 100644
index 0000000..3044048
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/sources/value-input.test.mjs
@@ -0,0 +1,50 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {readFile} from 'node:fs/promises';
+import {spawnSync} from 'node:child_process';
+import {compile} from './web/compiler.mjs';
+import {createRuntime} from './web/runtime.mjs';
+const wasm=await readFile(new URL('./web/core.wasm',import.meta.url));
+const source=await readFile(new URL('./examples/document.pure',import.meta.url),'utf8');
+
+test('numeric controls use explicit change context and cannot read pointer or frame payloads',()=>{
+ const p=compile(source);assert.equal(p.states.length,1);assert.equal(p.views.length,0);assert.equal(p.handlers[0].event,33);
+ for(const s of [
+ 'state x=0\ninput n=value\non n.down {x=1}',
+ 'state x=0\ninput k=Space\non k.change {x=1}',
+ 'state x=0\ninput n=value\non n.change {x=event_x}',
+ 'state x=0\non frame {x=event_value}',
+ 'input n=value\ntext v(0,0,event_value)',
+ 'record R {x=0}\nitem a=R()\ninput k=Space\nrule r(t:R){t.x=event_value}\non k.down use r(a)',
+ 'state event_value=0',
+ ])assert.throws(()=>compile(s),s);
+});
+
+test('numeric input handles scalar rules, rollback, cache invalidation and signed zero',async()=>{
+ const r=await createRuntime(wasm,{cacheEntries:32});
+ const code='record R {x=10}\nitem a=R()\ninput n=value\nrule r(t:R){t.x=event_value}\non n.change use r(a)\ntext v(0,0,a.x)';
+ r.apply(code);r.snapshot();r.snapshot();r.change('n',25);assert.equal(r.snapshot().views[0].value,25);
+ r.change('n',-0);assert(Object.is(r.snapshot().states['a.x'],-0));assert.deepEqual(r.snapshot().held,[]);
+ const before=r.snapshot();assert.throws(()=>r.change('n',Infinity));assert.throws(()=>r.down('n'));assert.throws(()=>r.point('n','down',1,2));
+ assert.equal(r.snapshot().successfulEvents,before.successfulEvents);
+ r.apply(code+'\non n.change {a.x=1/0}');assert.equal(r.change('n',80),4);assert(Object.is(r.snapshot().states['a.x'],-0));
+ assert.equal(r.snapshot().lastFailure.value,80);assert.throws(()=>r.exportDocument(),/오류/);
+ r.apply(code);const saved=r.exportDocument();const next=await createRuntime(wasm);next.restoreDocument(saved);next.change('n',32);assert.equal(next.snapshot().states['a.x'],32);
+});
+
+test('the document policy receives range, keyboard and pointer requests without layouts or text in VM state',async()=>{
+ const r=await createRuntime(wasm);r.apply(source);
+ for(const [value,expected] of [[60,60],[1000,80],[-1000,20],[45.5,45.5]]){assert.equal(r.change('split',value,'control'),0);assert.deepEqual(r.snapshot().states,{ratio:expected});}
+ r.down('reset');r.up('reset');assert.deepEqual(r.snapshot().states,{ratio:50});
+ assert.equal(r.snapshot().views.length,0);assert.equal(r.snapshot().memory.activeStateBytes,8);
+ const published=[];const target={begin(){},view(){assert.fail('no declared views');},value(){assert.fail('no view values');},state(i,v){published.push(v);},end(n){assert.equal(n,0);}};
+ r.present(target);assert.deepEqual(published,[50]);
+ r.apply(source.replace('20, 80','30, 70'));r.change('split',10);assert.equal(r.snapshot().states.ratio,30);
+ const current=r.currentProgram;assert.throws(()=>r.apply(source+'\non split.down {ratio=0}'));assert.equal(r.currentProgram,current);
+});
+
+test('CLI supplies the same numeric control value and rejects nonfinite input',()=>{
+ const run=input=>spawnSync(process.execPath,[new URL('./cli.mjs',import.meta.url).pathname,new URL('./examples/document.pure',import.meta.url).pathname],{input,encoding:'utf8'});
+ const ok=run('change split 95\nquit\n');assert.equal(ok.status,0,ok.stderr);assert.equal(JSON.parse(ok.stdout.trim()).states.ratio,80);
+ const bad=run('change split NaN\nquit\n');assert.equal(bad.status,1);assert.match(bad.stderr,/유한/);
+});
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/sources/web/app.js b/prototypes/workbench/results/2026-09-23-web-layout/sources/web/app.js
new file mode 100644
index 0000000..9e53ee3
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/sources/web/app.js
@@ -0,0 +1,411 @@
+import { createRuntime } from './runtime.mjs';
+import { createOutput } from './output.mjs';
+import { bindSceneInput } from './scene-input.mjs';
+import { parseProject, MAX_PROJECT_BYTES } from './project.mjs';
+
+const $ = id => document.getElementById(id);
+const source = $('source');
+const MAX_SOURCE_BYTES = 32768;
+const encoder = new TextEncoder();
+const output = createOutput(document);
+const snippets = {
+ state: 'state count = 0\nstate x = 80\n',
+ input: 'input tap = Space\non tap.down {\n count = count + 1\n}\n',
+ view: 'box tile(x, 180, 40, 40, "#7ed9ae")\ntext label(40, 60, count, "Count: ")\n',
+ frame: 'on frame {\n x = x + 30 * dt\n if x > 600 { x = 0 }\n}\n',
+};
+let runtime = null;
+let appliedSource = '';
+let running = false;
+let previousTime = null;
+let accumulator = 0;
+let droppedTicks = 0;
+let diagnosticLocation = null;
+let draftGeneration = 0;
+const keyboardSources = new Map();
+const pointerSources = new Map();
+const inputButtons = new Map();
+const sceneInput = bindSceneInput($('scene'), () => runtime, safeAction);
+
+const bytes = text => encoder.encode(text).byteLength;
+const dirty = () => source.value !== appliedSource;
+// DOM is the published value. Avoid rewriting unchanged diagnostics/controls.
+function text(id, value) {
+ const node = $(id);
+ if (node.textContent !== value) node.textContent = value;
+}
+function pressed(node, value) {
+ const next = String(value);
+ if (node.getAttribute('aria-pressed') !== next) node.setAttribute('aria-pressed', next);
+}
+function draftStatus() {
+ const changed = dirty();
+ text('draft-status', changed ? '미적용 변경' : runtime?.currentProgram ? '적용됨' : '빈 작업물');
+ $('draft-status').classList.toggle('dirty', changed);
+}
+
+function setDiagnostic(message, kind = '', location = null) {
+ $('diagnostic-text').textContent = message;
+ $('diagnostic').className = `diagnostic ${kind}`;
+ diagnosticLocation = location;
+ $('error-location').hidden = !location;
+ source.setAttribute('aria-invalid', kind === 'error' && location ? 'true' : 'false');
+}
+
+function cursorPosition() {
+ const before = source.value.slice(0, source.selectionStart);
+ const lines = before.split('\n');
+ text('cursor-position', `${lines.length}행 · ${lines.at(-1).length + 1}열`);
+}
+
+function draftChanged() {
+ draftGeneration++;
+ // Read scroll position before writes; this path runs only for source edits.
+ const scrollTop = source.scrollTop;
+ const count = source.value.split('\n').length;
+ const size = bytes(source.value);
+ text('line-numbers', Array.from({ length: count }, (_, i) => i + 1).join('\n'));
+ $('line-numbers').scrollTop = scrollTop;
+ text('source-size', `${size.toLocaleString('en-US')} / 32,768 bytes`);
+ $('source-size').style.color = size > MAX_SOURCE_BYTES ? 'var(--danger)' : '';
+ draftStatus();
+ cursorPosition();
+}
+
+function pause() {
+ running = false;
+ previousTime = null;
+ accumulator = 0;
+ text('play', '▶ 실행');
+ pressed($('play'), false);
+}
+
+function safeAction(action) {
+ if (!runtime) return;
+ try { action(); }
+ catch (error) { pause(); setDiagnostic(error.message, 'error', error.line ? { line: error.line, column: error.column || 1 } : null); }
+ refresh();
+}
+
+function releaseEverything() {
+ keyboardSources.clear();
+ pointerSources.clear();
+ sceneInput.releaseCaptures();
+ if (runtime) safeAction(() => runtime.releaseAll());
+}
+
+function loseFocus() {
+ pause();
+ releaseEverything();
+ refresh();
+}
+
+function refresh() {
+ if (!runtime) return;
+ const snapshot = runtime.present(output);
+ if (snapshot.lastError) pause();
+ const held = new Set((snapshot.held || []).map(input => typeof input === 'string' ? input : input.name));
+ for (const [name, button] of inputButtons) {
+ button.classList.toggle('held', held.has(name));
+ pressed(button, held.has(name));
+ }
+ text('scene-status', runtime.currentProgram ? (running ? '실행 중' : '정지') : '적용 전');
+ text('clock-status', `${snapshot.tick || 0} frame · ${running ? '60 Hz' : '정지'}`);
+ text('revision', `revision ${snapshot.revision || 0}`);
+ for (const id of ['play', 'step', 'reset', 'download-layout']) {
+ if ($(id).disabled !== !runtime.currentProgram) $(id).disabled = !runtime.currentProgram;
+ }
+ const event = snapshot.lastEvent;
+ text('last-event', event ? `${event.name}${event.phase && event.phase !== 'frame' ? `.${event.phase}` : ''}${event.target ? ` → ${event.target}` : ''} · ${event.status ? '실패' : '완료'}${event.line ? ` · ${event.line}행` : ''}` : '아직 없음');
+ if ($('runtime-error').hidden !== !snapshot.lastError) $('runtime-error').hidden = !snapshot.lastError;
+ const displayError = snapshot.lastError === 100 || snapshot.lastError === 101;
+ let message = snapshot.lastError ? `${displayError ? '표시 오류' : '마지막 실패'} · ${snapshot.errorLine ? `${snapshot.errorLine}행 · ` : ''}${snapshot.errorMessage || `실행 오류 ${snapshot.lastError}`}${snapshot.lastError === 101 ? ' — 이번 장면을 표시하지 않았습니다. 상태 변경은 유지됩니다.' : displayError ? ' — 해당 도형을 표시할 수 없습니다.' : ' — 실패한 이벤트의 상태 변경은 취소됐습니다. 코드를 적용하거나 초깃값으로 되돌리면 이 기록을 지웁니다.'}` : '';
+ if (!displayError && snapshot.displayError?.code === 101)
+ message += ` · ${snapshot.displayError.message}`;
+ text('runtime-error', message);
+ output.costs(snapshot, runtime.currentProgram?.metrics, droppedTicks);
+}
+
+function endPointer(event) {
+ const entry = pointerSources.get(event.pointerId);
+ if (!entry) return;
+ event.preventDefault();
+ pointerSources.delete(event.pointerId);
+ safeAction(() => runtime.up(entry.name, entry.source));
+}
+
+function rebuildInputControls() {
+ inputButtons.clear();
+ const children = [];
+ for (const input of runtime.currentProgram?.inputs || []) {
+ if (input.kind === 'value') {
+ const form = document.createElement('form'), label = document.createElement('label');
+ const field = document.createElement('input'), button = document.createElement('button');
+ label.textContent = input.name + ' '; field.type = 'number'; field.step = 'any'; field.value = '0'; field.required = true;
+ button.type = 'submit'; button.textContent = '값 전달'; label.append(field); form.append(label, button);
+ form.addEventListener('submit', event => { event.preventDefault(); safeAction(() => runtime.change(input.name, field.valueAsNumber, 'control')); });
+ children.push(form); continue;
+ }
+ if (input.target || input.routes) {
+ const note = document.createElement('p');
+ note.className = 'empty-note';
+ note.textContent = `${input.name} · 장면의 ${input.target || input.routes.map(route => route.target).join(', ')}을 잡아 조작하세요.`;
+ children.push(note);
+ continue;
+ }
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'input-button';
+ button.dataset.input = input.name;
+ button.setAttribute('aria-pressed', 'false');
+ const name = document.createElement('span'); name.textContent = input.name;
+ const key = document.createElement('kbd'); key.textContent = input.key;
+ button.append(name, key);
+ button.addEventListener('pointerdown', event => {
+ if (event.button !== 0) return;
+ event.preventDefault();
+ const identity = `pointer:${event.pointerId}`;
+ pointerSources.set(event.pointerId, { name: input.name, source: identity });
+ button.setPointerCapture(event.pointerId);
+ safeAction(() => runtime.down(input.name, identity));
+ });
+ button.addEventListener('pointerup', endPointer);
+ button.addEventListener('pointercancel', endPointer);
+ button.addEventListener('lostpointercapture', endPointer);
+ // Keyboard activation and assistive technology clicks are complete taps.
+ // Pointer clicks already delivered down/up via the handlers above.
+ button.addEventListener('click', event => {
+ if (event.detail !== 0) return;
+ safeAction(() => { runtime.down(input.name, 'activation'); runtime.up(input.name, 'activation'); });
+ });
+ inputButtons.set(input.name, button);
+ children.push(button);
+ }
+ if (!children.length) {
+ const message = document.createElement('p');
+ message.className = 'empty-note';
+ message.textContent = '입력을 선언하면 여기에 버튼이 생깁니다.';
+ children.push(message);
+ }
+ $('input-controls').replaceChildren(...children);
+}
+
+function isEditing(target) {
+ return target instanceof Element && Boolean(target.closest('textarea,input,select,button,[contenteditable="true"],[role="textbox"]'));
+}
+
+$('scene').addEventListener('pointerdown', () => $('scene').focus());
+
+document.addEventListener('keydown', event => {
+ if (!runtime || event.repeat || event.isComposing || event.ctrlKey || event.metaKey || event.altKey || isEditing(event.target)) return;
+ const input = runtime.currentProgram?.inputs.find(item => item.key === event.code);
+ if (!input) return;
+ event.preventDefault();
+ if (keyboardSources.has(event.code)) return;
+ keyboardSources.set(event.code, input.name);
+ safeAction(() => runtime.down(input.name, `keyboard:${event.code}`));
+});
+
+document.addEventListener('keyup', event => {
+ const name = keyboardSources.get(event.code);
+ if (!name) return;
+ event.preventDefault();
+ keyboardSources.delete(event.code);
+ safeAction(() => runtime.up(name, `keyboard:${event.code}`));
+});
+
+source.addEventListener('input', () => {
+ draftChanged();
+ if (diagnosticLocation) setDiagnostic('코드를 수정했습니다. 적용해서 다시 확인하세요.');
+});
+source.addEventListener('scroll', () => { $('line-numbers').scrollTop = source.scrollTop; });
+for (const name of ['click', 'keyup', 'select']) source.addEventListener(name, cursorPosition);
+source.addEventListener('keydown', event => {
+ if (event.key === 'Tab' && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
+ event.preventDefault();
+ source.setRangeText(' ', source.selectionStart, source.selectionEnd, 'end');
+ draftChanged();
+ }
+});
+
+$('apply').addEventListener('click', () => {
+ if (!runtime) return;
+ try {
+ runtime.apply(source.value);
+ appliedSource = source.value;
+ rebuildInputControls();
+ setDiagnostic('코드를 적용했습니다. 같은 이름의 상태는 유지됩니다.', 'success');
+ } catch (error) {
+ const location = error.line ? { line: error.line, column: error.column || 1 } : null;
+ setDiagnostic(`${location ? `${location.line}행 ${location.column}열 · ` : ''}${error.message} · 기존 장면은 그대로 유지됩니다.`, 'error', location);
+ }
+ draftStatus();
+ refresh();
+});
+
+$('error-location').addEventListener('click', () => {
+ if (!diagnosticLocation) return;
+ const lines = source.value.split('\n');
+ const lineIndex = Math.min(lines.length - 1, diagnosticLocation.line - 1);
+ const start = lines.slice(0, lineIndex).reduce((sum, line) => sum + line.length + 1, 0);
+ const at = start + Math.min(lines[lineIndex].length, diagnosticLocation.column - 1);
+ source.focus();
+ source.setSelectionRange(at, Math.min(at + 1, source.value.length));
+ const lineHeight = parseFloat(getComputedStyle(source).lineHeight);
+ source.scrollTop = Math.max(0, lineIndex * lineHeight - source.clientHeight / 3);
+ $('line-numbers').scrollTop = source.scrollTop;
+ cursorPosition();
+});
+
+$('play').addEventListener('click', () => {
+ if (running) pause();
+ else { running = true; previousTime = null; accumulator = 0; $('play').textContent = 'Ⅱ 정지'; $('play').setAttribute('aria-pressed', 'true'); }
+ refresh();
+});
+$('step').addEventListener('click', () => { pause(); safeAction(() => runtime.frame()); });
+$('reset').addEventListener('click', () => {
+ pause(); releaseEverything(); droppedTicks = 0;
+ safeAction(() => runtime.reset());
+ setDiagnostic('적용된 코드의 초깃값으로 되돌렸습니다. 편집 중인 코드는 유지됩니다.');
+});
+
+function replaceDraft(text, message) {
+ source.value = text;
+ source.scrollTop = 0;
+ source.setSelectionRange(0, 0);
+ setDiagnostic(message);
+ draftChanged();
+}
+
+function mayReplaceDraft() {
+ return !source.value || window.confirm('현재 편집 중인 코드를 바꿀까요? 저장하지 않은 내용은 사라집니다. 실행 장면은 적용 전까지 유지됩니다.');
+}
+
+$('new').addEventListener('click', () => {
+ if (mayReplaceDraft()) replaceDraft('', '편집기만 비웠습니다. 빈 코드를 적용하면 장면도 비워집니다.');
+});
+
+for (const button of document.querySelectorAll('.insert-snippet')) {
+ button.addEventListener('click', () => {
+ const at = source.selectionStart;
+ const prefix = at > 0 && source.value[at - 1] !== '\n' ? '\n' : '';
+ source.setRangeText(prefix + snippets[button.dataset.snippet], source.selectionStart, source.selectionEnd, 'end');
+ source.focus();
+ draftChanged();
+ setDiagnostic('조각을 삽입했습니다. 이름과 동작을 원하는 대로 바꾸고 적용하세요.');
+ });
+}
+
+$('load-example').addEventListener('click', async () => {
+ if (!mayReplaceDraft()) return;
+ const selected = $('example').value;
+ const before = source.value;
+ const button = $('load-example');
+ button.disabled = true;
+ try {
+ const response = await fetch(`./examples/${selected}.pure`);
+ if (!response.ok) throw new Error(`예제를 불러오지 못했습니다 (${response.status}).`);
+ const text = await response.text();
+ if (bytes(text) > MAX_SOURCE_BYTES) throw new Error('예제가 코드 크기 제한을 넘었습니다.');
+ if (source.value !== before) throw new Error('불러오는 동안 코드가 바뀌었습니다. 변경 내용을 지키기 위해 불러오기를 취소했습니다.');
+ replaceDraft(text, '예제를 편집기에 불러왔습니다. 아직 적용하지 않았습니다.');
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+ finally { button.disabled = false; }
+});
+
+function download(filename, content, mime, message = '편집 중인 코드를 파일로 저장했습니다. 적용되지 않은 변경도 포함합니다.') {
+ const url = URL.createObjectURL(new Blob([content], { type: mime }));
+ const link = document.createElement('a');
+ link.href = url; link.download = filename;
+ document.body.append(link); link.click(); link.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ setDiagnostic(message);
+}
+$('download').addEventListener('click', () => download('my-work.pure', source.value, 'text/plain;charset=utf-8'));
+$('download-project').addEventListener('click', () => download('my-work.pure.json', JSON.stringify({ format: 'pure-workbench', version: 1, source: source.value }, null, 2) + '\n', 'application/json'));
+$('download-layout').addEventListener('click', () => {
+ try {
+ if (running) throw new Error('시간 진행을 정지한 뒤 배치를 저장하세요.');
+ const project = runtime.exportDocument(source.value);
+ download('my-layout.pure.json', JSON.stringify(project, null, 2) + '\n', 'application/json',
+ '적용된 코드·현재 배치·미적용 초안을 함께 저장했습니다.');
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+});
+$('upload').addEventListener('click', () => $('file-input').click());
+$('file-input').addEventListener('change', async event => {
+ const file = event.target.files[0];
+ event.target.value = '';
+ if (!file) return;
+ try {
+ if (file.size > MAX_PROJECT_BYTES) throw new Error('파일이 너무 큽니다. 코드 제한은 32 KiB입니다.');
+ const before = draftGeneration;
+ const active = runtime?.currentProgram;
+ const events = runtime?.snapshot().successfulEvents;
+ let content = await file.text();
+ if (file.name.toLowerCase().endsWith('.json')) {
+ const project = parseProject(content);
+ if (project.version === 2) {
+ if (!runtime) throw new Error('실행기가 준비되지 않았습니다.');
+ if (draftGeneration !== before || runtime.currentProgram !== active || runtime.snapshot().successfulEvents !== events)
+ throw new Error('파일을 읽는 동안 작업이 바뀌었습니다. 정지한 뒤 다시 불러오세요.');
+ if (running) throw new Error('시간 진행을 정지한 뒤 배치를 복원하세요.');
+ if ((source.value || appliedSource) && !window.confirm('현재 코드와 배치를 파일의 내용으로 복원할까요? 저장하지 않은 작업은 사라집니다.')) return;
+ const restored = runtime.restoreDocument(project);
+ pause(); droppedTicks = 0;
+ appliedSource = restored.source;
+ rebuildInputControls();
+ replaceDraft(restored.draft, '배치를 복원했습니다. 미적용 초안도 보존했고, 시간은 정지 상태입니다.');
+ refresh();
+ return;
+ }
+ content = project.source;
+ }
+ if (bytes(content) > MAX_SOURCE_BYTES) throw new Error('코드가 32 KiB 제한을 넘었습니다.');
+ if (draftGeneration !== before) throw new Error('파일을 읽는 동안 코드가 바뀌었습니다. 불러오기를 다시 시도하세요.');
+ if (mayReplaceDraft()) replaceDraft(content, `${file.name}을 편집기에 불러왔습니다. 아직 적용하지 않았습니다.`);
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+});
+
+window.addEventListener('blur', loseFocus);
+document.addEventListener('visibilitychange', () => { if (document.hidden) loseFocus(); });
+window.addEventListener('beforeunload', event => {
+ // Applying is not persistence: even applied code can be lost when leaving.
+ if (source.value || appliedSource) { event.preventDefault(); event.returnValue = ''; }
+});
+
+function animate(time) {
+ if (running && runtime) {
+ if (previousTime !== null) {
+ accumulator += Math.max(0, time - previousTime);
+ const wanted = Math.floor(accumulator / (1000 / 60));
+ if (wanted > 0) {
+ const steps = Math.min(6, wanted);
+ accumulator -= wanted * (1000 / 60);
+ droppedTicks += wanted - steps;
+ safeAction(() => runtime.frame(steps));
+ }
+ }
+ previousTime = running ? time : null;
+ }
+ requestAnimationFrame(animate);
+}
+
+Object.defineProperty(window, 'pureWorkbench', {
+ configurable: false,
+ writable: false,
+ value: Object.freeze({ snapshot: () => structuredClone({ ...(runtime?.snapshot() || {}), ready: Boolean(runtime), source: source.value, appliedSource, dirty: dirty(), running, droppedTicks, diagnostic: $('diagnostic-text').textContent }) }),
+});
+
+draftChanged();
+requestAnimationFrame(animate);
+try {
+ runtime = await createRuntime();
+ $('ready-status').textContent = '실행기 준비됨';
+ $('ready-dot').classList.add('ready');
+ $('apply').disabled = false;
+ refresh();
+} catch (error) {
+ $('ready-status').textContent = '실행기 준비 실패';
+ $('ready-dot').classList.add('failed');
+ setDiagnostic(`실행기를 열지 못했습니다. ${error.message}`, 'error');
+}
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/sources/web/compiler.mjs b/prototypes/workbench/results/2026-09-23-web-layout/sources/web/compiler.mjs
new file mode 100644
index 0000000..6aec335
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/sources/web/compiler.mjs
@@ -0,0 +1,496 @@
+// Pure's experimental scalar interaction language. No host code is evaluated.
+const LIMITS = Object.freeze({ sourceBytes: 32768, states: 32, inputs: 8,
+ views: 24, handlers: 64, instructions: 512, depth: 64, stack: 64 });
+const RESERVED = new Set(['frame', 'dt', 'event_x', 'event_y', 'event_value', 'min', 'max', 'clamp']);
+const BINARY = new Map([
+ ['||', [1, 15]], ['&&', [2, 14]], ['==', [3, 12]], ['!=', [3, 13]],
+ ['<', [4, 8]], ['<=', [4, 9]], ['>', [4, 10]], ['>=', [4, 11]],
+ ['+', [5, 3]], ['-', [5, 4]], ['*', [6, 5]], ['/', [6, 6]],
+]);
+const FUNCTIONS = { min: [2, 17], max: [2, 18], clamp: [3, 19] };
+const VIEW_FIELDS = { box: ['x', 'y', 'width', 'height'],
+ circle: ['x', 'y', 'radius'], text: ['x', 'y', 'value'] };
+
+function fail(token, message) {
+ const error = new Error(`${message} (${token.line}:${token.column})`);
+ error.line = token.line;
+ error.column = token.column;
+ throw error;
+}
+
+function lex(source) {
+ const tokens = [];
+ let offset = 0, line = 1, column = 1;
+ const advance = () => {
+ const char = source[offset++];
+ if (char === '\n') { line++; column = 1; } else column++;
+ return char;
+ };
+ while (offset < source.length) {
+ const char = source[offset];
+ if (char === ' ' || char === '\t' || char === '\r') { advance(); continue; }
+ if (char === '#') { while (offset < source.length && source[offset] !== '\n') advance(); continue; }
+ const token = { line, column };
+ if (char === '\n') { advance(); tokens.push({ ...token, kind: 'nl' }); continue; }
+ if (char === '"') {
+ advance();
+ let value = '', closed = false;
+ while (offset < source.length) {
+ const next = advance();
+ if (next === '"') { closed = true; break; }
+ if (next === '\n' || next === '\r') fail(token, 'Strings must fit on one line');
+ if (next === '\\') {
+ const escaped = advance();
+ const escapes = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' };
+ if (!Object.hasOwn(escapes, escaped)) fail(token, 'Unsupported string escape');
+ value += escapes[escaped];
+ } else value += next;
+ }
+ if (!closed) fail(token, 'Unterminated string');
+ tokens.push({ ...token, kind: 'string', value }); continue;
+ }
+ const remaining = source.slice(offset);
+ const number = remaining.match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/);
+ if (number) {
+ const value = Number(number[0]);
+ if (!Number.isFinite(value)) fail(token, 'Number must be finite');
+ for (let i = 0; i < number[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'number', value }); continue;
+ }
+ const name = remaining.match(/^[A-Za-z_][A-Za-z_0-9]*/);
+ if (name) {
+ for (let i = 0; i < name[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'name', value: name[0] }); continue;
+ }
+ const pair = source.slice(offset, offset + 2);
+ if (['<=', '>=', '==', '!=', '&&', '||'].includes(pair)) {
+ advance(); advance(); tokens.push({ ...token, kind: pair }); continue;
+ }
+ if ('=+-*/!<>(){},.;:'.includes(char)) {
+ advance(); tokens.push({ ...token, kind: char }); continue;
+ }
+ fail(token, `Unexpected character ${JSON.stringify(char)}`);
+ }
+ tokens.push({ kind: 'eof', line, column });
+ return tokens;
+}
+
+class Parser {
+ constructor(tokens) { this.tokens = tokens; this.position = 0; this.depth = 0; }
+ peek() { return this.tokens[this.position]; }
+ take() { return this.tokens[this.position++]; }
+ is(kind, value) { return this.peek().kind === kind && (value === undefined || this.peek().value === value); }
+ expect(kind, description = kind) {
+ if (!this.is(kind)) fail(this.peek(), `Expected ${description}`);
+ return this.take();
+ }
+ newlines() { while (this.is('nl')) this.take(); }
+ separators() { while (this.is('nl') || this.is(';')) this.take(); }
+ end() {
+ if (!['nl', ';', '}', 'eof'].includes(this.peek().kind)) fail(this.peek(), 'Expected newline or semicolon');
+ this.separators();
+ }
+ reference(token) {
+ if (!this.is('.')) return token.value;
+ this.take();
+ return `${token.value}.${this.expect('name', 'field name').value}`;
+ }
+ initial() {
+ let sign = 1;
+ if (this.is('-') || this.is('+')) sign = this.take().kind === '-' ? -1 : 1;
+ return sign * this.expect('number', 'numeric state initializer').value;
+ }
+ node(token, properties, children = []) {
+ const depth = 1 + Math.max(0, ...children.map(child => child.depth));
+ if (depth > LIMITS.depth) fail(token, 'Expression nesting exceeds 64');
+ return { token, depth, ...properties };
+ }
+ expression(minimum = 0, multiline = false) {
+ if (++this.depth > LIMITS.depth) fail(this.peek(), 'Expression nesting exceeds 64');
+ if (multiline) this.newlines();
+ const token = this.take();
+ let left;
+ if (token.kind === 'number') left = this.node(token, { kind: 'constant', value: token.value });
+ else if (token.kind === '-' || token.kind === '!') {
+ const child = this.expression(7, multiline);
+ left = this.node(token, { kind: 'unary', op: token.kind === '-' ? 7 : 16, child }, [child]);
+ } else if (token.kind === '(') {
+ left = this.expression(0, true); this.newlines(); this.expect(')');
+ } else if (token.kind === 'name') {
+ if (Object.hasOwn(FUNCTIONS, token.value) && this.is('(')) {
+ this.take();
+ const args = [];
+ this.newlines();
+ if (!this.is(')')) {
+ for (;;) {
+ args.push(this.expression(0, true)); this.newlines();
+ if (!this.is(',')) break;
+ this.take();
+ }
+ }
+ this.expect(')');
+ if (args.length !== FUNCTIONS[token.value][0]) fail(token, `${token.value} expects ${FUNCTIONS[token.value][0]} arguments`);
+ left = this.node(token, { kind: 'call', op: FUNCTIONS[token.value][1], args }, args);
+ } else left = this.node(token, { kind: 'reference', name: this.reference(token) });
+ } else fail(token, 'Expected a number, state, or expression');
+ for (;;) {
+ if (multiline) this.newlines();
+ const entry = BINARY.get(this.peek().kind);
+ if (!entry || entry[0] < minimum) break;
+ const operator = this.take();
+ const right = this.expression(entry[0] + 1, multiline);
+ left = this.node(operator, { kind: 'binary', op: entry[1], left, right }, [left, right]);
+ }
+ this.depth--;
+ return left;
+ }
+ block(depth = 0) {
+ if (depth >= LIMITS.depth) fail(this.peek(), 'Handler nesting exceeds 64');
+ this.newlines(); this.expect('{'); this.separators();
+ const statements = [];
+ while (!this.is('}')) {
+ const token = this.expect('name', 'assignment, if, or closing brace');
+ if (token.value === 'if' && !this.is('=')) {
+ const condition = this.expression();
+ const body = this.block(depth + 1);
+ statements.push({ kind: 'if', token, condition, body });
+ } else {
+ const name = this.reference(token);
+ this.expect('=');
+ statements.push({ kind: 'assign', token, name, expression: this.expression() });
+ }
+ this.end();
+ }
+ this.take();
+ return statements;
+ }
+ parse() {
+ const declarations = [];
+ this.separators();
+ while (!this.is('eof')) {
+ const token = this.expect('name', 'declaration');
+ const kind = token.value;
+ if (kind === 'state') {
+ const name = this.expect('name', 'state name'); this.expect('=');
+ declarations.push({ kind, token, name, initial: this.initial() });
+ } else if (kind === 'record') {
+ const name = this.expect('name', 'record type'); this.newlines(); this.expect('{'); this.separators();
+ const fields = [];
+ while (!this.is('}')) {
+ const field = this.expect('name', 'field name'); this.expect('=');
+ fields.push({name:field, initial:this.initial()}); this.end();
+ }
+ this.take(); declarations.push({kind,token,name,fields});
+ } else if (kind === 'item') {
+ const name = this.expect('name', 'item name'); this.expect('=');
+ const type = this.expect('name', 'record type'); this.expect('('); this.newlines();
+ const fields = [];
+ while (!this.is(')')) {
+ const field = this.expect('name', 'field name'); this.expect('=');
+ fields.push({name:field, initial:this.initial()}); this.newlines();
+ if (!this.is(',')) break;
+ this.take(); this.newlines();
+ }
+ this.expect(')'); declarations.push({kind,token,name,type,fields});
+ } else if (kind === 'rule') {
+ const name = this.expect('name', 'rule name'); this.expect('(');
+ const parameter = this.expect('name', 'record parameter'); this.expect(':');
+ const type = this.expect('name', 'record type'); this.expect(')');
+ declarations.push({kind,token,name,parameter,type,body:this.block()});
+ } else if (kind === 'input') {
+ const name = this.expect('name', 'input name'); this.expect('=');
+ const key = this.expect('name', 'keyboard code or pointer(view)');
+ if (key.value === 'value') {
+ declarations.push({kind,token,name,valueInput:true}); this.end(); continue;
+ }
+ if (key.value === 'pointer') {
+ if (this.is('{')) {
+ this.take(); this.separators(); const routes = [];
+ while (!this.is('}')) {
+ const target = this.expect('name', 'view ID'); this.expect(':');
+ const item = this.expect('name', 'item name'); routes.push({target,item}); this.end();
+ }
+ this.take(); declarations.push({kind,token,name,routes}); this.end(); continue;
+ }
+ this.expect('(');
+ const target = this.expect('name', 'view ID'); this.expect(')');
+ declarations.push({ kind, token, name, target });
+ this.end(); continue;
+ }
+ if (!/^(?:Space|Enter|Arrow(?:Left|Right|Up|Down)|Key[A-Z]|Digit[0-9])$/.test(key.value)) fail(key, 'Unsupported keyboard code');
+ declarations.push({ kind, token, name, key });
+ } else if (Object.hasOwn(VIEW_FIELDS, kind)) {
+ const name = this.expect('name', 'view ID'); this.expect('(');
+ const expressions = [];
+ for (let i = 0; i < VIEW_FIELDS[kind].length; i++) {
+ if (i) { this.newlines(); this.expect(','); }
+ expressions.push(this.expression(0, true));
+ }
+ this.newlines();
+ let color, prefix = '';
+ if (kind !== 'text') {
+ this.expect(','); this.newlines();
+ const value = this.expect('string', 'literal hex color');
+ if (!/^#[0-9a-fA-F]{6}$/.test(value.value)) fail(value, 'Color must use six hexadecimal digits, such as "#356548"');
+ color = value.value;
+ } else if (this.is(',')) { this.take(); this.newlines(); prefix = this.expect('string', 'literal text prefix').value; }
+ this.newlines(); this.expect(')');
+ declarations.push({ kind: 'view', type: kind, token, name, expressions, color, prefix });
+ } else if (kind === 'on') {
+ const event = this.expect('name', 'input name or frame');
+ let edge;
+ if (event.value !== 'frame') {
+ this.expect('.'); edge = this.expect('name', 'down, up, move or cancel');
+ if (!['down', 'up', 'move', 'cancel', 'change'].includes(edge.value)) fail(edge, 'Expected down or up, move, cancel or change');
+ }
+ if (this.is('name', 'use')) {
+ this.take(); const rule = this.expect('name', 'rule name'); this.expect('(');
+ const item = this.expect('name', 'item name or input.target');
+ const argument = this.reference(item); this.expect(')');
+ declarations.push({kind:'handler',token,event,edge,rule,item,argument});
+ } else declarations.push({ kind: 'handler', token, event, edge, body: this.block() });
+ } else fail(token, `Unknown declaration ${kind}`);
+ this.end();
+ }
+ return declarations;
+ }
+}
+
+export function compile(source) {
+ if (typeof source !== 'string') fail({ line: 1, column: 1 }, 'Source must be a string');
+ const sourceBytes = new TextEncoder().encode(source).length;
+ if (sourceBytes > LIMITS.sourceBytes) fail({ line: 1, column: 1 }, 'Source exceeds 32768 UTF-8 bytes');
+ const declarations = new Parser(lex(source)).parse();
+ const program = { states: [], inputs: [], instructions: [], handlers: [], views: [], pointerTargets: [], metrics: {} };
+ const states = new Map(), inputs = new Map(), views = new Set(), keys = new Set(), targets = new Set();
+ const records = new Map(), items = new Map(), rules = new Map();
+ const scalarNames = new Set(declarations.filter(d => d.kind === 'state').map(d => d.name.value));
+ for (const declaration of declarations) {
+ if (!['record','rule'].includes(declaration.kind)) continue;
+ const {name,fields,kind} = declaration, symbols = kind === 'record' ? records : rules;
+ if (RESERVED.has(name.value)) fail(name, `Reserved name ${name.value}`);
+ if (symbols.has(name.value)) fail(name, `Duplicate ${kind} ${name.value}`);
+ if (kind === 'record') {
+ if (!fields.length || fields.length > 8) fail(name, 'A record needs 1..8 fields');
+ const used = new Set();
+ for (const field of fields) {
+ if (used.has(field.name.value)) fail(field.name, `Duplicate field ${field.name.value}`);
+ if (RESERVED.has(field.name.value)) fail(field.name, `Reserved name ${field.name.value}`);
+ used.add(field.name.value);
+ }
+ }
+ symbols.set(name.value, declaration);
+ }
+ let handlerCount = 0;
+ for (const declaration of declarations) {
+ const { kind, name, token } = declaration;
+ if (name && RESERVED.has(name.value)) fail(name, `Reserved name ${name.value}`);
+ if (kind === 'record' || kind === 'rule') continue;
+ if (kind === 'item') {
+ if (items.has(name.value) || scalarNames.has(name.value)) fail(name, `Duplicate state/item ${name.value}`);
+ const record = records.get(declaration.type.value);
+ if (!record) fail(declaration.type, `Unknown record ${declaration.type.value}`);
+ const overrides = new Map();
+ for (const field of declaration.fields) {
+ if (!record.fields.some(f => f.name.value === field.name.value)) fail(field.name, `Unknown field ${field.name.value}`);
+ if (overrides.has(field.name.value)) fail(field.name, `Duplicate field ${field.name.value}`);
+ overrides.set(field.name.value, field.initial);
+ }
+ const base = states.size;
+ for (const field of record.fields) {
+ if (states.size >= LIMITS.states) fail(name, 'At most 32 states are allowed, including item fields');
+ const qualified = `${name.value}.${field.name.value}`, index = states.size;
+ states.set(qualified,index);
+ program.states.push({name:qualified,initial:overrides.has(field.name.value) ? overrides.get(field.name.value) : field.initial,line:token.line});
+ }
+ items.set(name.value,{type:declaration.type.value,base,count:record.fields.length});
+ } else if (kind === 'state' || kind === 'input') {
+ const symbols = kind === 'state' ? states : inputs;
+ if (symbols.has(name.value)) fail(name, `Duplicate ${kind} ${name.value}`);
+ if (symbols.size >= LIMITS[`${kind}s`]) fail(name, `At most ${LIMITS[`${kind}s`]} ${kind}s are allowed`);
+ symbols.set(name.value, symbols.size);
+ if (kind === 'state') program.states.push({ name: name.value, initial: declaration.initial, line: token.line });
+ else if (declaration.valueInput) {
+ program.inputs.push({name:name.value,kind:'value',line:token.line});
+ } else if (declaration.routes) {
+ if (!declaration.routes.length) fail(name, 'A pointer group needs at least one target');
+ program.inputs.push({name:name.value, routes:[], line:token.line});
+ } else if (declaration.target) {
+ const target = declaration.target;
+ if (targets.has(target.value)) fail(target, `Duplicate pointer target ${target.value}`);
+ targets.add(target.value);
+ program.inputs.push({ name: name.value, target: target.value, line: token.line });
+ } else {
+ if (keys.has(declaration.key.value)) fail(declaration.key, `Duplicate keyboard code ${declaration.key.value}`);
+ keys.add(declaration.key.value);
+ program.inputs.push({ name: name.value, key: declaration.key.value, line: token.line });
+ }
+ } else if (kind === 'view') {
+ if (views.has(name.value)) fail(name, `Duplicate view ID ${name.value}`);
+ if (views.size >= LIMITS.views) fail(name, 'At most 24 views are allowed');
+ views.add(name.value);
+ } else if (++handlerCount > LIMITS.handlers) fail(token, 'At most 64 handlers are allowed');
+ }
+ for (const declaration of declarations) {
+ if (declaration.target && !views.has(declaration.target.value))
+ fail(declaration.target, `Unknown pointer target ${declaration.target.value}`);
+ }
+ const routedItems = new Set();
+ for (const declaration of declarations) {
+ if (!declaration.routes) continue;
+ const input = inputs.get(declaration.name.value), resolved = program.inputs[input];
+ let type;
+ for (const {target,item:token} of declaration.routes) {
+ if (!views.has(target.value)) fail(target, `Unknown pointer target ${target.value}`);
+ if (targets.has(target.value)) fail(target, `Duplicate pointer target ${target.value}`);
+ const item = items.get(token.value);
+ if (!item) fail(token, `Unknown item ${token.value}`);
+ if (type && type !== item.type) fail(token, 'Pointer group targets must share one record type');
+ if (routedItems.has(token.value)) fail(token, `Item ${token.value} already has a pointer route`);
+ targets.add(target.value); routedItems.add(token.value); type = item.type;
+ const route = program.pointerTargets.length;
+ if (route >= 24) fail(target, 'At most 24 pointer targets are allowed');
+ program.pointerTargets.push({input,base:item.base,count:item.count});
+ resolved.routes.push({target:target.value, item:token.value, route});
+ }
+ resolved.type = type;
+ }
+ for (const rule of rules.values()) {
+ if (!records.has(rule.type.value)) fail(rule.type, `Unknown record ${rule.type.value}`);
+ if (RESERVED.has(rule.parameter.value)) fail(rule.parameter, `Reserved name ${rule.parameter.value}`);
+ }
+ let pointHandler = false, valueHandler = false;
+ let parameters = null;
+ let parameterPrefix = null;
+ let stack = 0, maxStack = 0;
+ const emit = (op, token, arg = 0, value = 0, delta = 0) => {
+ if (program.instructions.length >= LIMITS.instructions) fail(token, 'At most 512 instructions are allowed');
+ stack += delta;
+ if (stack < 0 || stack > LIMITS.stack) fail(token, 'Expression stack exceeds 64');
+ maxStack = Math.max(maxStack, stack);
+ program.instructions.push({ op, arg, value, line: token.line });
+ };
+ const expression = (node, binding = false) => {
+ const { token } = node;
+ if (node.kind === 'constant') emit(0, token, 0, node.value, 1);
+ else if (node.kind === 'reference') {
+ if (node.name === 'event_value') {
+ if (binding || !valueHandler) fail(token, 'event_value is only available in value change handlers');
+ emit(26, token, 0, 0, 1);
+ } else if (node.name === 'event_x' || node.name === 'event_y') {
+ if (binding || !pointHandler) fail(token, 'event_x and event_y are only available in pointer handlers');
+ emit(node.name === 'event_x' ? 22 : 23, token, 0, 0, 1);
+ } else if (node.name === 'dt') {
+ if (binding) fail(token, 'dt is only available in handlers');
+ emit(2, token, 0, 0, 1);
+ } else {
+ if (parameterPrefix && node.name.startsWith(parameterPrefix) && !parameters.has(node.name))
+ fail(token, `Unknown parameter field ${node.name}`);
+ if (parameters?.has(node.name)) {
+ emit(24, token, parameters.get(node.name), 0, 1);
+ return;
+ }
+ if (!states.has(node.name)) fail(token, `Unknown state ${node.name}`);
+ emit(1, token, states.get(node.name), 0, 1);
+ }
+ } else if (node.kind === 'unary') { expression(node.child, binding); emit(node.op, token); }
+ else if (node.kind === 'binary') {
+ expression(node.left, binding); expression(node.right, binding); emit(node.op, token, 0, 0, -1);
+ } else {
+ for (const arg of node.args) expression(arg, binding);
+ emit(node.op, token, 0, 0, 1 - node.args.length);
+ }
+ };
+ const statements = body => {
+ for (const statement of body) {
+ if (statement.kind === 'assign') {
+ if (parameterPrefix && statement.name.startsWith(parameterPrefix) && !parameters.has(statement.name))
+ fail(statement.token, `Unknown parameter field ${statement.name}`);
+ const parameter = parameters?.get(statement.name);
+ if (parameter === undefined && !states.has(statement.name)) fail(statement.token, `Unknown state ${statement.name}`);
+ expression(statement.expression);
+ emit(parameter === undefined ? 20 : 25, statement.token, parameter === undefined ? states.get(statement.name) : parameter, 0, -1);
+ } else {
+ expression(statement.condition);
+ const branch = program.instructions.length;
+ emit(21, statement.token, 0, 0, -1);
+ statements(statement.body);
+ program.instructions[branch].arg = program.instructions.length;
+ }
+ }
+ };
+ const bodies = new Map();
+ function ruleBody(rule) {
+ if (bodies.has(rule.name.value)) return bodies.get(rule.name.value);
+ parameterPrefix = `${rule.parameter.value}.`;
+ parameters = new Map(records.get(rule.type.value).fields.map((field,index) => [`${rule.parameter.value}.${field.name.value}`,index]));
+ // A shared body may read coordinates; every binding is checked below.
+ pointHandler = true; valueHandler = true; stack = 0;
+ const start = program.instructions.length;
+ statements(rule.body);
+ const length = program.instructions.length - start;
+ const point = program.instructions.slice(start).some(op => op.op === 22 || op.op === 23);
+ parameters = null; parameterPrefix = null;
+ const value = program.instructions.slice(start).some(op => op.op === 26);
+ const body = {start,length,point,value}; bodies.set(rule.name.value,body); return body;
+ }
+ for (const declaration of declarations) {
+ if (declaration.kind === 'handler') {
+ let event = 0;
+ pointHandler = false; valueHandler = false;
+ if (declaration.event.value !== 'frame') {
+ if (!inputs.has(declaration.event.value)) fail(declaration.event, `Unknown input ${declaration.event.value}`);
+ const index = inputs.get(declaration.event.value), edge = declaration.edge.value;
+ pointHandler = Boolean(program.inputs[index].target || program.inputs[index].routes);
+ valueHandler = program.inputs[index].kind === 'value';
+ if (valueHandler !== (edge === 'change')) fail(declaration.edge, 'Value inputs require change; change requires a value input');
+ if (!pointHandler && (edge === 'move' || edge === 'cancel')) fail(declaration.edge, 'move and cancel require a pointer input');
+ event = edge === 'change' ? 33 + index : edge === 'move' ? 17 + index : edge === 'cancel' ? 25 + index : 1 + index * 2 + (edge === 'up' ? 1 : 0);
+ }
+ if (declaration.rule) {
+ const rule = rules.get(declaration.rule.value);
+ const routed = declaration.argument.includes('.');
+ const input = program.inputs[inputs.get(declaration.event.value)];
+ if (routed && (!input?.routes || declaration.argument !== `${declaration.event.value}.target`))
+ fail(declaration.item, 'Use the current pointer group target');
+ const item = routed ? {type:input.type, count:records.get(input.type).fields.length} : items.get(declaration.item.value);
+ if (!rule) fail(declaration.rule, `Unknown rule ${declaration.rule.value}`);
+ if (!item) fail(declaration.item, `Unknown item ${declaration.item.value}`);
+ if (item.type !== rule.type.value) fail(declaration.item, `Rule ${rule.name.value} requires ${rule.type.value}`);
+ const pointer = pointHandler, value = valueHandler;
+ const body = ruleBody(rule);
+ if (body.value && !value) fail(declaration.rule, 'Value-reading rules require a value input');
+ if (body.point && !pointer) fail(declaration.rule, 'Coordinate-reading rules require a pointer input');
+ program.handlers.push({event,start:body.start,length:body.length,line:declaration.token.line,parameterBase:routed ? 255 : item.base,parameterCount:item.count});
+ } else {
+ const start = program.instructions.length;
+ stack = 0; statements(declaration.body);
+ program.handlers.push({ event, start, length: program.instructions.length - start, line: declaration.token.line });
+ }
+ } else if (declaration.kind === 'view') {
+ const view = { id: declaration.name.value, type: declaration.type, line: declaration.token.line, bindings: {} };
+ if (view.type === 'text') view.prefix = declaration.prefix;
+ else view.color = declaration.color;
+ declaration.expressions.forEach((node, index) => {
+ const start = program.instructions.length;
+ stack = 0; expression(node, true);
+ view.bindings[VIEW_FIELDS[view.type][index]] = { start, length: program.instructions.length - start };
+ });
+ program.views.push(view);
+ }
+ }
+ // Unused definitions still receive name/type checking, but reserve no VM body.
+ for (const rule of rules.values()) {
+ if (bodies.has(rule.name.value)) continue;
+ const instructions = program.instructions, previousMax = maxStack;
+ program.instructions = [];
+ ruleBody(rule);
+ program.instructions = instructions; maxStack = previousMax;
+ }
+ program.metrics = { sourceBytes, sourceLines: source === '' ? 0 : source.split('\n').length,
+ states: states.size, inputs: inputs.size, views: views.size,
+ handlers: program.handlers.length, instructions: program.instructions.length, maxStack };
+ return program;
+}
+
+export { LIMITS };
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/sources/web/core.wasm b/prototypes/workbench/results/2026-09-23-web-layout/sources/web/core.wasm
new file mode 100644
index 0000000..79c50a8
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/sources/web/core.wasm
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/sources/web/document.css b/prototypes/workbench/results/2026-09-23-web-layout/sources/web/document.css
new file mode 100644
index 0000000..7e7ed97
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/sources/web/document.css
@@ -0,0 +1,3 @@
+:root{color-scheme:dark;--bg:#101815;--panel:#19241e;--line:#35483c;--text:#e5eade;--muted:#a6b3a7;--accent:#c9f08b;font-family:system-ui,-apple-system,sans-serif;color:var(--text);background:var(--bg)}
+*{box-sizing:border-box}body{margin:0}header{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--line);padding:22px max(24px,calc((100vw - 1500px)/2))}.brand{font-size:22px;font-weight:700;letter-spacing:-1px;text-decoration:none;color:var(--accent)}.brand span{font-size:14px;color:var(--muted);font-weight:400;letter-spacing:0}.badge,.eyebrow{color:var(--muted);font-size:12px;letter-spacing:.1em}main{max-width:1550px;margin:auto;padding:28px 24px}.intro{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:25px}h1{font-size:clamp(23px,3vw,34px);font-weight:550;letter-spacing:-.04em;word-break:keep-all;margin:8px 0}.intro p:not(.eyebrow){color:var(--muted);font-size:14px;line-height:1.65;margin-bottom:0}button,input,textarea{font:inherit;color:inherit}button{background:#273a2e;border:1px solid #456049;padding:9px 14px;border-radius:7px;cursor:pointer;white-space:nowrap}button:hover{background:#334d3d}button:disabled{opacity:.45;cursor:default}input:not([type=range]){background:var(--bg);border:1px solid var(--line);border-radius:5px;padding:8px;max-width:180px}button:focus-visible,input:focus-visible,textarea:focus-visible,[tabindex]:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px}.toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;background:var(--panel);padding:14px;border:1px solid var(--line);border-radius:9px 9px 0 0}.toolbar label{font-size:13px;color:var(--muted)}#document-status{font-size:12px;color:var(--muted);margin-left:auto;max-width:100%;overflow-wrap:anywhere}.layout-tools{display:flex;align-items:center;gap:12px;padding:15px 0;font-size:13px}.layout-tools input{width:145px;accent-color:var(--accent)}.layout-tools output{min-width:42px;font-variant-numeric:tabular-nums}.layout-tools button{padding:5px 10px}.hint{color:var(--muted);font-size:12px;margin-left:auto}.panes{display:grid;grid-template-columns:minmax(0,calc((100% - 18px)*var(--split))) 18px minmax(0,1fr);height:clamp(400px,65vh,850px)}.pane{min-width:0;min-height:0;display:flex;flex-direction:column;background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}.pane-heading{display:flex;align-items:center;justify-content:space-between;padding:13px 18px;border-bottom:1px solid var(--line);flex:0 0 auto}.pane-heading h2{font-size:14px;font-weight:550;margin:0}.pane-heading span{font-size:10px;letter-spacing:.12em;color:var(--muted)}#document-text{width:100%;flex:1;min-height:0;resize:none;background:transparent;border:0;padding:22px;font:15px/1.8 ui-monospace,monospace;outline-offset:-3px;tab-size:4}#document-text::placeholder{color:#84998b}#reading-scroll{flex:1;min-height:0;overflow:auto;overscroll-behavior:contain;outline-offset:-3px;padding:26px clamp(18px,3vw,42px);font-size:17px;line-height:1.95;background:#e5e8dc;color:#26352d}#reading-text{white-space:pre-wrap;overflow-wrap:anywhere}#reading-empty{color:#637566;margin:0}#divider{cursor:col-resize;touch-action:none;display:flex;align-items:center;justify-content:center;outline-offset:-2px}#divider span{height:44px;width:4px;background:#617768;border-radius:4px}#divider:hover span,#divider.dragging span,#divider:focus-visible span{background:var(--accent)}#divider[aria-disabled=true]{pointer-events:none}.policy{margin-top:22px;border:1px solid var(--line);padding:14px 18px;border-radius:8px}.policy summary{cursor:pointer;color:var(--muted);font-size:13px}.policy p{font-size:13px;color:var(--muted);line-height:1.7}.policy textarea{display:block;width:100%;height:160px;resize:vertical;background:#121b16;border:1px solid var(--line);border-radius:5px;font:13px/1.65 ui-monospace,monospace;padding:12px;margin:12px 0}.policy span{font-size:12px;margin-left:12px;color:var(--muted)}footer{font-size:12px;color:var(--muted);line-height:1.7;padding:22px 0}a{color:var(--accent)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}[hidden]{display:none!important}
+@media(max-width:720px){header{padding:18px}.badge{display:none}main{padding:20px 16px}.intro{align-items:flex-start}.intro button{font-size:12px;padding:8px}.toolbar input:not([type=range]){max-width:155px}.toolbar label{width:100%}#document-status{width:100%;margin:2px 0}.layout-tools{display:none}.panes{display:flex;flex-direction:column;height:auto;gap:18px}.pane{height:430px}.read-pane{height:500px}#divider{display:none}.policy span{display:block;margin:10px 0 0}.hint{display:none}#reading-scroll{padding:22px}}
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/sources/web/document.html b/prototypes/workbench/results/2026-09-23-web-layout/sources/web/document.html
new file mode 100644
index 0000000..7e96674
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/sources/web/document.html
@@ -0,0 +1,32 @@
+<!doctype html>
+<html lang="ko">
+<head>
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+ <link rel="icon" href="data:,">
+ <title>Pure · 문서 작업</title><link rel="stylesheet" href="./document.css">
+</head>
+<body>
+ <header><a class="brand" href="./">pure <span>/ 문서 작업</span></a><span class="badge">텍스트 워크스페이스</span></header>
+ <main>
+ <div class="intro"><div><p class="eyebrow">WRITE / READ</p><h1>쓰고, 읽고, 공간을 조절하세요.</h1><p>가운데 경계를 움직여 편한 폭을 만드세요. 좁은 화면에서는 위아래로 이어집니다.</p></div><button id="sample" type="button">예제 문서</button></div>
+ <div class="toolbar" aria-label="문서 도구">
+ <label>파일 이름 <input id="filename" value="notes.txt" maxlength="120" aria-label="파일 이름"></label>
+ <button id="open" type="button">텍스트 열기</button><button id="save" type="button">.txt 저장</button>
+ <input id="file-input" type="file" accept=".txt,text/plain" hidden>
+ <span id="document-status" role="status">UTF-8 텍스트 파일을 열거나 바로 작성하세요.</span>
+ </div>
+ <div class="layout-tools">
+ <label for="split-range">편집 영역</label><input id="split-range" type="range" min="20" max="80" value="50" step="1" disabled><output id="split-output" for="split-range">50%</output>
+ <button id="reset-split" type="button" disabled>반반으로</button><span class="hint">경계에서 ← → · Shift로 크게 · Esc로 취소</span>
+ </div>
+ <div id="panes" class="panes" style="--split:0.5">
+ <section id="editor-pane" class="pane edit-pane" aria-labelledby="editor-title"><div class="pane-heading"><h2 id="editor-title">작성</h2><span>TEXT</span></div><label for="document-text" class="sr-only">문서 내용</label><textarea id="document-text" spellcheck="true" placeholder="생각을 적어보세요.&#10;&#10;내용은 오른쪽 읽기 화면에 표시됩니다."></textarea></section>
+ <div id="divider" role="separator" tabindex="0" aria-orientation="vertical" aria-labelledby="editor-title" aria-controls="editor-pane" aria-valuemin="20" aria-valuemax="80" aria-valuenow="50" aria-disabled="true"><span></span></div>
+ <section class="pane read-pane" aria-labelledby="reading-title"><div class="pane-heading"><h2 id="reading-title">읽기</h2><span>PREVIEW</span></div><div id="reading-scroll" tabindex="0" aria-label="문서 읽기"><div id="reading-text"></div><p id="reading-empty">작성한 내용이 여기에 나타납니다.</p></div></section>
+ </div>
+ <details class="policy"><summary>분할 규칙 수정</summary><p>비율을 받는 규칙을 수정할 수 있습니다. 문서 내용은 유지됩니다. 숫자 입력 이름은 split, 결과 상태는 ratio입니다.</p><label for="policy-source" class="sr-only">Pure 분할 규칙</label><textarea id="policy-source" spellcheck="false"></textarea><button id="apply-policy" type="button" disabled>규칙 적용</button><span id="policy-status" role="status">실행기를 준비하고 있습니다.</span></details>
+ <footer>텍스트는 일반 .txt 파일로 저장됩니다. 줄바꿈과 글자 배치는 브라우저가 처리합니다. <a href="./">기존 작업 공간 ↗</a></footer>
+ </main>
+ <script type="module" src="./document.mjs"></script>
+</body>
+</html>
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/sources/web/document.mjs b/prototypes/workbench/results/2026-09-23-web-layout/sources/web/document.mjs
new file mode 100644
index 0000000..1f19322
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/sources/web/document.mjs
@@ -0,0 +1,132 @@
+import {createRuntime} from './runtime.mjs';
+import {compile} from './compiler.mjs';
+import {bindSplit} from './split-input.mjs';
+const $=id=>document.getElementById(id), text=$('document-text'), reading=$('reading-text');
+const MAX_BYTES=2*1024*1024, stats={geometryReads:0,previewWrites:0,ratioWrites:0};
+let runtime=null, splitter=null, previewFrame=0, composing=false, generation=0, request=0, dirty=false;
+const message=(id,value)=>{$(id).textContent=value;};
+const narrow=matchMedia('(max-width:720px)');
+const sample=`작업 노트 — 인터페이스의 경계
+
+1. 이 문서에서 해볼 일
+
+창의 폭을 줄여보세요. 읽기 화면의 글자는 새 폭에 맞춰 줄바꿈됩니다. 화면이 좁아지면 작성과 읽기가 위아래로 놓입니다. 문서 자체를 다른 형식으로 바꿀 필요는 없습니다.
+
+넓은 화면에서는 두 영역 사이의 경계를 잡아 움직일 수 있습니다. 경계에 키보드 초점을 두고 방향키를 눌러도 됩니다. 손을 놓으면 현재 비율을 유지하고, 움직이는 도중 Esc를 누르면 시작한 비율로 돌아갑니다.
+
+2. 작성과 읽기
+
+왼쪽은 브라우저의 텍스트 입력입니다. 이 문장을 고치고, 몇 줄을 더 적어보세요. 한글을 조합하고 복사하거나 붙여넣고, 실행 취소를 해볼 수 있습니다. 읽기 화면에서는 같은 글을 다른 폭으로 살펴봅니다.
+
+양쪽의 스크롤은 독립적입니다. 읽던 곳을 옮긴다고 작성 커서가 움직이지 않습니다. 분할 비율을 바꿀 때도 글자나 커서, 문서를 새로 만들지 않습니다.
+
+3. 연결을 유지하기
+
+일반 UTF-8 텍스트 파일을 열 수 있습니다. 작성한 결과도 .txt로 내려받습니다. 다른 편집기에서 그대로 읽을 수 있는 경로를 유지하는 것이 이 작업의 조건입니다.
+
+아래 문자열은 코드가 아니라 문서의 일부입니다.
+<div>이 글은 화면의 HTML 요소로 해석되지 않습니다.</div>
+
+4. 작업 메모
+
+여기에 실제 프로젝트의 메모를 적어도 됩니다.
+
+하려던 일:
+
+실제로 일어난 일:
+
+기대했던 일:
+
+다음 수정:
+
+5. 남겨둘 질문
+
+입력이 바뀌었을 때 꼭 다시 계산해야 하는 것은 무엇일까요? 문서가 바뀌지 않았는데 문서를 다시 전달할 이유가 있을까요? 화면의 폭과 문서의 의미를 같은 상태로 보관할 필요가 있을까요?
+
+이 화면이 답하는 범위는 작습니다. 실제 브라우저의 편집과 배치를 사용하면서, 조작 규칙을 어디에 둘 수 있는지를 확인합니다.
+`;
+function publishText() {
+ previewFrame=0;
+ if(composing)return;
+ reading.textContent=text.value;stats.previewWrites++;
+ $('reading-empty').hidden=text.value.length!==0;
+}
+function scheduleText(){if(!previewFrame)previewFrame=requestAnimationFrame(publishText);}
+text.addEventListener('compositionstart',()=>{composing=true;});
+text.addEventListener('compositionend',()=>{composing=false;scheduleText();});
+text.addEventListener('input',()=>{generation++;dirty=true;scheduleText();message('document-status','편집 중 · 저장하지 않은 변경이 있습니다.');});
+function replaceText(value,name) {
+ text.value=value;text.scrollTop=0;$('reading-scroll').scrollTop=0;
+ if(name)$('filename').value=name;
+ generation++;dirty=false;scheduleText();
+}
+$('sample').addEventListener('click',()=>{
+ if(dirty&&!confirm('편집한 내용을 예제 문서로 바꿀까요?'))return;
+ request++;replaceText(sample,'interface-notes.txt');dirty=true;message('document-status','예제 문서를 불러왔습니다. 자유롭게 고쳐보세요.');
+});
+$('open').addEventListener('click',()=>$('file-input').click());
+$('file-input').addEventListener('change',async event=>{
+ const file=event.target.files[0];event.target.value='';if(!file)return;
+ if(dirty&&!confirm('저장하지 않은 내용을 파일의 내용으로 바꿀까요?'))return;
+ const ticket=++request, version=generation;
+ try {
+ if(file.size>MAX_BYTES)throw new Error('2 MiB 이하의 텍스트 파일을 선택하세요.');
+ const bytes=await file.arrayBuffer();
+ if(ticket!==request||version!==generation)throw new Error('파일을 읽는 동안 문서가 바뀌어 열기를 취소했습니다.');
+ if(bytes.byteLength>MAX_BYTES)throw new Error('파일 크기 한도를 넘었습니다.');
+ const value=new TextDecoder('utf-8',{fatal:true}).decode(bytes);
+ replaceText(value,file.name);message('document-status',`${file.name} 파일을 열었습니다.`);
+ } catch(error){if(ticket===request)message('document-status',error instanceof TypeError?'UTF-8 텍스트로 읽을 수 없습니다. 현재 문서는 유지됩니다.':error.message);}
+});
+$('save').addEventListener('click',()=>{
+ const bytes=new TextEncoder().encode(text.value);
+ if(bytes.byteLength>MAX_BYTES){message('document-status','저장은 UTF-8 기준 2 MiB까지 지원합니다. 문서는 그대로 유지됩니다.');return;}
+ const name=$('filename').value.trim()||'notes.txt';
+ const url=URL.createObjectURL(new Blob([bytes],{type:'text/plain;charset=utf-8'}));
+ const link=document.createElement('a');link.href=url;link.download=name.endsWith('.txt')?name:`${name}.txt`;
+ link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);dirty=false;message('document-status','텍스트 파일을 내려받았습니다.');
+});
+window.addEventListener('beforeunload',event=>{if(dirty){event.preventDefault();event.returnValue='';}});
+const output={
+ begin(){},view(){},value(){},end(){},
+ state(index,value){
+ if(runtime.currentProgram.states[index].name!=='ratio')return;
+ // Invalid authored output never produces a broken grid or invalid ARIA value.
+ if(value<20||value>80)throw new Error('이 화면의 분할 비율은 20~80% 범위여야 합니다. 규칙을 수정하세요.');
+ const ratio=String(value);
+ if($('divider').getAttribute('aria-valuenow')===ratio)return;
+ $('divider').setAttribute('aria-valuenow',ratio);
+ $('panes').style.setProperty('--split',String(value/100));
+ $('split-range').value=ratio;$('split-output').value=`${Math.round(value)}%`;stats.ratioWrites++;
+ },
+};
+function publish(){const result=runtime.present(output);if(result.lastError)throw new Error(result.errorMessage);}
+function writeRatio(value,source) {
+ if(!runtime)return;
+ try {runtime.change('split',value,source);publish();message('policy-status','적용된 규칙으로 분할 비율을 조절합니다.');}
+ catch(error){$('split-range').value=$('divider').getAttribute('aria-valuenow');message('policy-status',error.message);}
+}
+function applyPolicy() {
+ if(splitter?.isDragging())throw new Error('경계를 놓거나 취소한 뒤 규칙을 적용하세요.');
+ const source=$('policy-source').value, p=compile(source), ratio=p.states.find(state=>state.name==='ratio');
+ if(!ratio||ratio.initial<20||ratio.initial>80||!p.inputs.some(input=>input.name==='split'&&input.kind==='value')||!p.inputs.some(input=>input.name==='reset'&&input.key==='KeyR')||p.views.length)
+ throw new Error('ratio 상태(초깃값 20~80), split 숫자 입력, reset = KeyR 입력을 유지하세요. 이 화면은 HTML로 표시합니다.');
+ runtime.apply(source);publish();message('policy-status','규칙을 적용했습니다. 문서와 편집 커서는 유지됩니다.');
+}
+$('apply-policy').addEventListener('click',()=>{try{applyPolicy();}catch(error){message('policy-status',error.message);}});
+$('split-range').addEventListener('input',event=>{splitter?.cancel();writeRatio(event.target.valueAsNumber,'range');});
+$('reset-split').addEventListener('click',()=>{
+ splitter?.cancel();if(!runtime)return;
+ try{runtime.down('reset','control');runtime.up('reset','control');publish();message('policy-status','분할 비율을 초기화했습니다.');}catch(error){message('policy-status',error.message);}
+});
+try {
+ const [instance,response]=await Promise.all([createRuntime(),fetch('./examples/document.pure',{cache:'no-store'})]);
+ if(!response.ok)throw new Error('분할 규칙을 불러올 수 없습니다.');
+ runtime=instance;$('policy-source').value=await response.text();applyPolicy();
+ splitter=bindSplit({container:$('panes'),handle:$('divider'),narrow,stats,
+ readRatio:()=>Number($('divider').getAttribute('aria-valuenow')),writeRatio});
+ for(const id of ['split-range','reset-split','apply-policy'])$(id).disabled=false;
+ $('divider').setAttribute('aria-disabled','false');
+} catch(error){message('policy-status',`분할 실행기를 열지 못했습니다: ${error.message} 문서 작성과 파일 저장은 계속 사용할 수 있습니다.`);}
+window.pureDocument=Object.freeze({snapshot:()=>({ready:Boolean(splitter),stats:{...stats},
+ runtime:runtime?.snapshot()??null,dragging:splitter?.isDragging()??false,composing,dirty,generation})});
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/sources/web/index.html b/prototypes/workbench/results/2026-09-23-web-layout/sources/web/index.html
new file mode 100644
index 0000000..089302c
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/sources/web/index.html
@@ -0,0 +1,95 @@
+<!doctype html>
+<html lang="ko">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="color-scheme" content="dark">
+ <title>Pure · 빈 작업물에서 시작하기</title>
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23151918'/%3E%3Ctext x='9' y='23' fill='%23c9f08b' font-size='24' font-family='sans-serif'%3Ep%3C/text%3E%3C/svg%3E">
+ <link rel="stylesheet" href="./style.css">
+</head>
+<body>
+ <header class="masthead">
+ <a class="wordmark" href="./" aria-label="Pure 작업 공간">pure<span> / workbench</span></a>
+ <span class="experiment">제작 경험 실험 004</span>
+ <div class="connection"><span id="ready-dot" class="dot"></span><span id="ready-status" role="status">실행기 준비 중</span></div>
+ </header>
+
+ <main>
+ <section class="intro" aria-labelledby="title">
+ <div><p class="eyebrow">빈 코드 · 직접 만든 동작</p><h1 id="title">무엇을 움직이게 할까요?</h1></div>
+ <p>상태를 만들고, 입력에 동작을 연결하세요.<br>코드를 <strong>적용</strong>하면 장면이 바뀝니다. 같은 이름의 상태는 이어집니다.</p>
+ </section>
+
+ <div class="workspace">
+ <section class="editor-panel panel" aria-labelledby="editor-title">
+ <div class="panel-heading"><h2 id="editor-title"><span class="section-index">01</span> 코드</h2><span id="draft-status" class="badge">빈 작업물</span></div>
+ <div class="file-tools" aria-label="파일 관리">
+ <button id="new" class="quiet" type="button">편집기 비우기</button>
+ <button id="upload" class="quiet" type="button">파일 열기</button>
+ <button id="download" class="quiet" type="button">.pure 저장</button>
+ <button id="download-project" class="quiet" type="button">코드 JSON 저장</button>
+ <button id="download-layout" class="quiet" type="button" disabled>배치 저장</button>
+ <input id="file-input" type="file" accept=".pure,.txt,.json,text/plain,application/json" hidden>
+ </div>
+ <label class="sr-only" for="source">Pure 코드 편집기</label>
+ <div class="code-editor">
+ <div id="line-numbers" class="line-numbers" aria-hidden="true">1</div>
+ <textarea id="source" spellcheck="false" autocapitalize="off" autocomplete="off" autocorrect="off" aria-describedby="editor-hint diagnostic" placeholder="여기서 시작하세요.&#10;&#10;아래 ‘언어 안내’에서 작은 조각을 가져올 수 있습니다."></textarea>
+ </div>
+ <div class="editor-footer"><span id="source-size">0 / 32,768 bytes</span><span id="cursor-position">1행 · 1열</span></div>
+ <div class="apply-row"><button id="apply" class="primary" type="button" disabled>코드 적용 <span aria-hidden="true">↗</span></button><p id="editor-hint">편집만으로 실행 중인 코드는 바뀌지 않습니다.</p></div>
+ <div id="diagnostic" class="diagnostic" role="status" aria-live="polite"><span id="diagnostic-text">아직 작성한 코드가 없습니다.</span><button id="error-location" type="button" class="quiet" hidden>문제 위치로</button></div>
+ </section>
+
+ <div class="right-column">
+ <section class="scene-panel panel" aria-labelledby="scene-title">
+ <div class="panel-heading"><h2 id="scene-title"><span class="section-index">02</span> 실행 장면</h2><span id="scene-status" class="badge">적용 전</span></div>
+ <div class="stage-wrap">
+ <svg id="scene" viewBox="0 0 640 360" role="img" tabindex="0" aria-labelledby="scene-description"><title id="scene-description">사용자가 코드로 만든 장면. 현재 비어 있습니다.</title><g id="scene-content"></g></svg>
+ <div id="empty-scene" class="empty-scene"><span class="empty-cross" aria-hidden="true">+</span><strong>아직 아무것도 없습니다</strong><span>도형이나 글자를 선언하고 코드를 적용하세요.</span></div>
+ <span class="stage-dimensions" aria-hidden="true">640 × 360</span>
+ </div>
+ <div class="transport"><button id="play" class="secondary" type="button" disabled>▶ 실행</button><button id="step" type="button" disabled>한 프레임</button><button id="reset" type="button" disabled>초깃값으로</button><span id="clock-status">0 frame · 정지</span></div>
+ <p class="control-note">정지하면 시간만 멈춥니다. 아래 입력은 정지 중에도 작동합니다.</p>
+ <div class="input-heading"><h3>내가 선언한 입력</h3><span>눌렀을 때 .down / 놓았을 때 .up</span></div>
+ <div id="input-controls" class="input-controls"><p class="empty-note">입력을 선언하면 여기에 버튼이 생깁니다.</p></div>
+ <p class="keyboard-note">장면을 클릭하면 키보드로 입력할 수 있습니다. 코드나 폼을 편집하는 동안에는 전달되지 않습니다.</p>
+ </section>
+
+ <section class="inspect-panel panel" aria-labelledby="inspect-title">
+ <div class="panel-heading"><h2 id="inspect-title"><span class="section-index">03</span> 현재 상태</h2><span id="revision" class="mono subdued">revision 0</span></div>
+ <div id="state-values" class="state-values"><p class="empty-note">선언한 상태의 값이 표시됩니다.</p></div>
+ <div class="event-line"><span class="subdued">마지막 입력</span><span id="last-event" class="mono">아직 없음</span></div>
+ <div id="runtime-error" class="runtime-error" role="status" hidden></div>
+ <details class="cost-details"><summary>실행 비용과 범위</summary><dl id="cost-values" class="cost-values"></dl><p>코어가 보고한 WebAssembly 메모리입니다. 브라우저·편집기·렌더러의 메모리와 프로세스 전체 사용량은 포함하지 않습니다. 예약 공간 안에 사용 중인 코드·상태·스테이징 공간이 포함되므로 합산하지 않습니다.</p><p id="dropped-ticks">시간 지연으로 건너뛴 프레임: 0</p></details>
+ </section>
+ </div>
+ </div>
+
+ <section class="reference panel" aria-labelledby="reference-title">
+ <div class="panel-heading"><h2 id="reference-title">언어 안내</h2><span class="subdued">조각을 조합해 자신의 동작을 만드세요.</span></div>
+ <div class="reference-grid">
+ <article><h3>1. 기억할 값</h3><p>이름은 영문으로, 처음 값은 숫자로 정합니다.</p><pre><code>state count = 0
+state x = 80</code></pre><button class="insert-snippet quiet" data-snippet="state" type="button">상태 조각 삽입</button></article>
+ <article><h3>2. 입력과 반응</h3><p>버튼 이름과 키를 선언하고 .down / .up에 연결합니다.</p><pre><code>input tap = Space
+on tap.down {
+ count = count + 1
+}</code></pre><button class="insert-snippet quiet" data-snippet="input" type="button">입력 조각 삽입</button></article>
+ <article><h3>3. 보일 것</h3><p>좌표와 크기를 상태에 연결할 수 있습니다.</p><pre><code>box tile(x, 180, 40, 40, "#7ed9ae")
+text label(40, 60, count, "Count: ")</code></pre><button class="insert-snippet quiet" data-snippet="view" type="button">장면 조각 삽입</button></article>
+ <article><h3>4. 시간에 따른 변화</h3><p>실행하면 매 프레임 반응합니다. dt는 1/60초입니다.</p><pre><code>on frame {
+ x = x + 30 * dt
+ if x > 600 { x = 0 }
+}</code></pre><button class="insert-snippet quiet" data-snippet="frame" type="button">시간 조각 삽입</button></article>
+ </div>
+ <details class="syntax-details"><summary>표현식 · 입력 키 · 제한 보기</summary><div class="syntax-columns"><div><p><strong>표현식</strong><br><code>+ - * /</code>, <code>&lt; &lt;= &gt; &gt;= == !=</code>, <code>&amp;&amp; || !</code>, 괄호, <code>min(a,b)</code>, <code>max(a,b)</code>, <code>clamp(x,lo,hi)</code>를 사용합니다. 논리 연산은 양쪽을 모두 계산합니다.</p><p><strong>도형</strong><br><code>box id(x,y,width,height,"#rrggbb")</code><br><code>circle id(x,y,radius,"#rrggbb")</code><br><code>text id(x,y,value,"접두사")</code><br>글자의 value는 숫자 표현식입니다. 문자 접두사는 생략할 수 있습니다.</p></div><div><p><strong>입력</strong><br><code>Space</code>, <code>Enter</code>, <code>ArrowLeft/Right/Up/Down</code>, <code>KeyA</code>~<code>KeyZ</code>, <code>Digit0</code>~<code>Digit9</code>. 같은 키는 하나의 입력에 연결합니다.</p><p><strong>숫자 입력</strong><br><code>input level = value</code>와 <code>on level.change { amount = event_value }</code>로 숫자 컨트롤을 연결합니다. 웹 호스트와 CLI에서도 같은 값을 전달할 수 있습니다.</p><p><strong>직접 조작</strong><br><code>input drag = pointer(tile)</code>로 도형에 입력을 연결합니다. <code>on drag.down / .move / .up / .cancel</code>에서 <code>event_x</code>, <code>event_y</code>는 장면 좌표입니다. 이동 방식과 취소 시 동작도 코드로 정합니다. 초점이나 포인터 캡처를 잃으면 <code>.cancel</code>이 전달됩니다.</p><p><strong>값과 규칙 재사용</strong><br><code>record Tile { x=0; y=0 }</code>로 필드를 정하고 <code>item green = Tile(x=80)</code>으로 대상을 만듭니다. <code>rule move(t: Tile) { t.x=event_x }</code>를 <code>on drag.move use move(green)</code>으로 연결합니다. 화면에서는 <code>green.x</code>를 읽습니다. 규칙은 연결한 대상의 값을 직접 바꿉니다.</p><p><strong>여러 도형의 입력 묶기</strong><br><code>input drag = pointer { green_box: green; blue_box: blue }</code>로 연결하고 <code>on drag.move use move(drag.target)</code>으로 현재 조작 대상을 전달합니다. 같은 레코드 타입의 대상끼리 묶으며, 각 도형은 따로 잡고 놓을 수 있습니다.</p><p><strong>이 실험의 범위</strong><br>상태 32개 · 입력 8개 · 도형/글자 24개 · 반응 64개 · 컴파일된 명령 512개 · 코드 32 KiB. 반복문, 함수 선언, 자동 충돌·물리는 없습니다. 줄바꿈 또는 세미콜론으로 구분하고 <code>#</code>으로 주석을 씁니다.</p><p>0으로 나누기 등 계산 실패 시 해당 이벤트의 상태 변경 전체를 취소합니다. 입력을 누르고 있을 때는 새 코드를 적용할 수 없습니다.</p></div></div></details>
+ <p><a href="./document.html">문서 편집 · 반응형 레이아웃 작업 화면 ↗</a></p>
+ <div class="example-row"><div><label for="example">완성된 예제 살펴보기</label><p>선택한 코드를 편집기에 불러옵니다. 자동으로 적용되지 않습니다.</p></div><select id="example"><option value="counter">카운터</option><option value="charge">누르는 동안 충전</option><option value="toggle">입력으로 전환</option><option value="direct">직접 잡아 이동</option><option value="layout">세 도형 배치</option><option value="shared-layout">공유 규칙으로 배치</option><option value="routed-layout">입력 묶어서 배치</option></select><button id="load-example" type="button">예제 불러오기</button></div>
+ <div class="baseline-row"><a href="./baseline.html" target="_blank" rel="noopener">같은 동작을 일반 JavaScript로 만든 비교 앱 ↗</a><span>충전 예제와 비교할 수 있습니다. 새 탭에서 열립니다.</span></div>
+ </section>
+ <footer class="page-footer"><span>Pure · authoring experiment</span><p>원하는 동작을 만들어보고, <strong>하려던 일 / 실제로 일어난 일 / 기대했던 일</strong>을 남겨주세요.</p></footer>
+ </main>
+ <script type="module" src="./app.js"></script>
+</body>
+</html>
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/sources/web/runtime.mjs b/prototypes/workbench/results/2026-09-23-web-layout/sources/web/runtime.mjs
new file mode 100644
index 0000000..d8d6618
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/sources/web/runtime.mjs
@@ -0,0 +1,350 @@
+import { compile } from './compiler.mjs';
+import { validateDocument } from './project.mjs';
+
+const messages = [
+ '', '실행기의 용량 한도를 넘었습니다.', '실행 정의가 유효하지 않습니다.',
+ '계산 스택의 범위를 벗어났습니다.', '0으로 나눌 수 없습니다.',
+ '계산 결과가 유한한 수의 범위를 벗어났습니다.', '조건 분기 범위가 유효하지 않습니다.',
+ '존재하지 않는 상태를 참조했습니다.', '시간 간격이 유효하지 않습니다.',
+ '정의되지 않은 입력 사건입니다.',
+];
+const sourceLimit = 32768;
+
+function problem(message, line = null, column = null) {
+ return Object.assign(new Error(message), { line, column });
+}
+
+function freezeProgram(value) {
+ if (value && typeof value === 'object') {
+ for (const child of Object.values(value)) freezeProgram(child);
+ Object.freeze(value);
+ }
+ return value;
+}
+
+export async function createRuntime(wasmBytes, evaluationOptions = {}) {
+ if (wasmBytes === undefined) {
+ const response = await fetch(new URL('./core.wasm', import.meta.url), { cache: 'no-store' });
+ if (!response.ok) throw new Error(`실행기를 불러오지 못했습니다 (HTTP ${response.status}).`);
+ wasmBytes = await response.arrayBuffer();
+ }
+ const module = await WebAssembly.compile(wasmBytes);
+ if (WebAssembly.Module.imports(module).length) throw new Error('이 실행기는 호스트 함수 없이 동작해야 합니다.');
+ const { exports: core } = await WebAssembly.instantiate(module, {});
+ if (typeof core.eval_bounded !== 'function' || typeof core.abort_program !== 'function' || typeof core.dispatch_point !== 'function' || typeof core.put_restored_value !== 'function' || typeof core.commit_restored_program !== 'function' || typeof core.bind_handler !== 'function' || typeof core.put_pointer_target !== 'function' || typeof core.bind_target_handler !== 'function' || typeof core.dispatch_target !== 'function' || typeof core.dispatch_value !== 'function')
+ throw new Error('실행기 버전이 맞지 않습니다. 새로고침하거나 core.wasm을 다시 빌드하세요.');
+ core.reset_all();
+ let program = null;
+ let appliedSource = '';
+ let lastEvent = null;
+ // Keep a failed event visible through its release/focus-loss cleanup.
+ // The active program can clear this diagnostic by apply or explicit reset.
+ let lastFailure = null;
+ const held = new Map();
+ const points = new Map();
+ let presenting = false;
+ let bindings = [];
+ let pointRoutes = new Map();
+ // One bounded publication buffer, never exposed to callers. No state mirror.
+ let displayValues = null, displayValid = null;
+ function available() {
+ if (presenting) throw new Error('출력 전달 중에는 실행기를 다시 호출할 수 없습니다.');
+ }
+ let evaluationPolicy = { cacheEntries: 0, historyEntries: 8, workLimit: 2048 };
+
+ function checked(code, line = null) {
+ if (code !== 0) throw problem(messages[code] || `실행기 오류 ${code}`, line);
+ }
+ function inputIndex(name) {
+ const index = program?.inputs.findIndex((input) => input.name === name) ?? -1;
+ if (index < 0) throw new Error(`정의되지 않은 입력: ${name}`);
+ return index;
+ }
+ function dispatch(event, name, phase, source, point = null, value = undefined) {
+ const status = value !== undefined ? core.dispatch_value(event, value) : point ? (point.route === undefined ? core.dispatch_point(event, point.x, point.y)
+ : core.dispatch_target(event, point.route, point.x, point.y)) : core.dispatch(event, event === 0 ? 1 / 60 : 0);
+ const pc = core.info(2) >>> 0;
+ lastEvent = { name, phase, source, status,
+ line: program?.instructions[pc]?.line ?? null, ops: core.info(3) >>> 0 };
+ if (point) { lastEvent.x = point.x; lastEvent.y = point.y; if (point.target) lastEvent.target = point.target; }
+ if (value !== undefined) lastEvent.value = value;
+ if (status) lastFailure = { ...lastEvent };
+ return status;
+ }
+
+ function evaluateDisplay() {
+ core.begin_evaluation(evaluationPolicy.workLimit);
+ if (!bindings.length) return null;
+ displayValues ??= new Float64Array(96);
+ displayValid ??= new Uint8Array(24);
+ let error = null, slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ displayValid[i] = 1;
+ for (const [name, binding] of bindings[i]) {
+ const value = core.eval_bounded(binding.start, binding.length);
+ if (core.evaluation_info(0) === 10) {
+ displayValid.fill(0);
+ return { code: 101, line: view.line,
+ message: '표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.' };
+ }
+ if (!Number.isFinite(value) || ((name === 'width' || name === 'height' || name === 'radius') && value < 0)) {
+ error ??= { line: view.line, message: `${view.id}.${name}: 표시할 수 없는 값입니다. 0 나눗셈이나 음수 크기를 확인하세요.` };
+ displayValid[i] = 0;
+ }
+ displayValues[slot++] = value;
+ }
+ }
+ return error;
+ }
+
+ function status(viewError) {
+ const executionError = lastFailure?.status || 0;
+ return {
+ tick: core.info(0) >>> 0, revision: core.info(4) >>> 0,
+ displayError: viewError ? { code: viewError.code || 100, ...viewError } : null,
+ lastError: executionError || (viewError ? viewError.code || 100 : 0),
+ errorMessage: executionError ? messages[executionError] || `실행 오류 ${executionError}` : viewError?.message || '',
+ errorLine: executionError ? lastFailure.line : viewError?.line ?? null,
+ lastOps: core.info(3) >>> 0, successfulEvents: core.info(9) >>> 0,
+ held: [...held.keys()], lastEvent: lastEvent ? { ...lastEvent } : null,
+ lastFailure: lastFailure ? { ...lastFailure } : null,
+ memory: {
+ linearBytes: core.memory.buffer.byteLength,
+ reservedEngineBytes: core.layout(0), programReservedBytes: core.layout(1),
+ instructionBytes: core.layout(2), handlerBytes: core.layout(3),
+ activeIRBytes: core.layout(4), activeStateBytes: core.layout(5),
+ stagingReservedBytes: core.layout(6), evalStackBytes: core.layout(7),
+ pointerTargetBytes: core.layout(8), pointerTargets: core.layout(9),
+ },
+ };
+ }
+
+ function load(next, source, restored = null) {
+ const oldIds = new Map(restored ? [] : program?.states.map((state, index) => [state.name, index]) ?? []);
+ checked(core.begin_program(next.states.length, next.inputs.length));
+ try {
+ for (let index = 0; index < next.states.length; index++) {
+ const state = next.states[index];
+ checked(core.put_state(index, state.initial, oldIds.get(state.name) ?? -1), state.line);
+ if (restored) checked(core.put_restored_value(index, restored[index]), state.line);
+ }
+ for (const op of next.instructions) checked(core.put_instruction(op.op, op.arg, op.value), op.line);
+ for (const target of next.pointerTargets) checked(core.put_pointer_target(target.input, target.base, target.count));
+ for (const [index, handler] of next.handlers.entries()) {
+ checked(core.put_handler(handler.event, handler.start, handler.length), handler.line);
+ if (handler.parameterCount)
+ checked(handler.parameterBase === 255 ? core.bind_target_handler(index, handler.parameterCount)
+ : core.bind_handler(index, handler.parameterBase, handler.parameterCount), handler.line);
+ }
+ checked(restored ? core.commit_restored_program() : core.commit_program());
+ } catch (error) {
+ core.abort_program();
+ throw error;
+ }
+ pointRoutes = new Map(next.inputs.flatMap(input => (input.routes ?? []).map(route => [route.target, route])));
+ program = next;
+ bindings = next.views.map(view => Object.entries(view.bindings));
+ appliedSource = source;
+ lastEvent = null;
+ lastFailure = null;
+ }
+
+ const api = {
+ get currentProgram() { return program; },
+ configureEvaluation({ cacheEntries = evaluationPolicy.cacheEntries,
+ historyEntries = evaluationPolicy.historyEntries, workLimit = evaluationPolicy.workLimit } = {}) {
+ available();
+ for (const [name, value, maximum] of [['cacheEntries', cacheEntries, 32],
+ ['historyEntries', historyEntries, 8], ['workLimit', workLimit, 0xffffffff]]) {
+ if (!Number.isInteger(value) || value < 0 || value > maximum)
+ throw new Error(`${name}: 0~${maximum} 사이의 정수여야 합니다.`);
+ }
+ checked(core.configure_evaluation(cacheEntries, historyEntries));
+ evaluationPolicy = { cacheEntries, historyEntries, workLimit };
+ return { ...evaluationPolicy };
+ },
+ forgetEvaluation(part = 'all') {
+ available();
+ const flags = part === 'all' ? 3 : part === 'values' ? 1 : part === 'history' ? 2 : undefined;
+ if (flags === undefined) throw new Error('삭제할 기록은 values, history, all 중 하나여야 합니다.');
+ core.forget_evaluation(flags);
+ },
+ evaluationStats() {
+ return { ...evaluationPolicy, work: core.evaluation_info(1),
+ instructions: core.evaluation_info(2), cacheHits: core.evaluation_info(3),
+ reservedBytes: core.evaluation_info(6), dedicatedBytes: core.evaluation_info(7),
+ sharedBytes: core.evaluation_info(8), reuseAvailable: Boolean(core.evaluation_info(9)),
+ workspaceTagBytes: core.evaluation_info(10) };
+ },
+ apply(source) {
+ available();
+ if (typeof source !== 'string' || new TextEncoder().encode(source).length > sourceLimit) {
+ throw problem('정의는 UTF-8 기준 32,768바이트 이하의 텍스트여야 합니다.');
+ }
+ if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 적용하세요. 현재 작업은 그대로 유지합니다.');
+ const next = freezeProgram(compile(source));
+ load(next, source);
+ return next;
+ },
+ restoreDocument(document) {
+ available();
+ if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 복원하세요.');
+ const next = validateDocument(document);
+ load(freezeProgram(next.program), next.source, next.values);
+ return { source: next.source, draft: next.draft };
+ },
+ exportDocument(draft = appliedSource) {
+ available();
+ if (!program) throw problem('먼저 코드를 적용하세요.');
+ if (held.size) throw problem('조작을 끝낸 뒤 배치를 저장하세요.');
+ if (lastFailure) throw problem('실행 오류를 해결한 뒤 배치를 저장하세요.');
+ if (typeof draft !== 'string' || new TextEncoder().encode(draft).length > sourceLimit)
+ throw problem('편집 중인 코드는 UTF-8 32 KiB 이하여야 합니다.');
+ return { format: 'pure-workbench', version: 2, source: appliedSource, draft,
+ states: program.states.map((state, i) => {
+ const value = core.get_state(i);
+ return { name: state.name, value: Object.is(value, -0) ? '-0' : value };
+ }) };
+ },
+ reset() {
+ available();
+ held.clear();
+ points.clear();
+ core.reset_state();
+ lastEvent = null;
+ lastFailure = null;
+ },
+ frame(count = 1) {
+ available();
+ if (!Number.isInteger(count) || count < 0 || count > 600) throw new Error('프레임 수는 0~600 사이의 정수여야 합니다.');
+ if (!program) return 0;
+ for (let i = 0; i < count; i++) {
+ const code = dispatch(0, 'frame', 'frame', 'clock');
+ if (code !== 0) return code;
+ }
+ return 0;
+ },
+ down(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (program.inputs[index].kind === 'value') throw new Error('숫자 입력은 change(name, value)를 사용하세요.');
+ if (program.inputs[index].target || program.inputs[index].routes) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.');
+ const sources = held.get(name) || new Set();
+ if (sources.has(source)) return 0;
+ const wasDown = sources.size > 0;
+ sources.add(source);
+ held.set(name, sources);
+ return wasDown ? 0 : dispatch(1 + index * 2, name, 'down', source);
+ },
+ up(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (program.inputs[index].kind === 'value') throw new Error('숫자 입력은 change(name, value)를 사용하세요.');
+ if (program.inputs[index].target || program.inputs[index].routes) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.');
+ const sources = held.get(name);
+ if (!sources?.delete(source)) return 0;
+ if (sources.size) return 0;
+ held.delete(name);
+ return dispatch(2 + index * 2, name, 'up', source);
+ },
+ change(name, value, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (program.inputs[index].kind !== 'value') throw new Error('숫자 값에 연결된 입력이 아닙니다.');
+ if (!Number.isFinite(value)) throw new Error('입력 값은 유한한 숫자여야 합니다.');
+ return dispatch(33 + index, name, 'change', source, null, value);
+ },
+ point(name, phase, x, y, source = 'external', target = undefined) {
+ available();
+ const index = inputIndex(name);
+ const input = program.inputs[index];
+ if (!input.target && !input.routes) throw new Error('포인터에 연결된 입력이 아닙니다.');
+ const route = input.routes ? pointRoutes.get(target) : undefined;
+ if (input.routes && (!route || program.pointerTargets[route.route].input !== index)) throw new Error('포인터 그룹에 연결된 도형 이름이 필요합니다.');
+ if (!input.routes && target !== undefined) throw new Error('단일 포인터 입력에는 대상 인자를 지정하지 않습니다.');
+ const owner = route ? `${name}:${target}` : name;
+ if (!['down', 'move', 'up', 'cancel'].includes(phase)) throw new Error('포인터 단계는 down, move, up, cancel 중 하나여야 합니다.');
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error('포인터 좌표는 유한한 숫자여야 합니다.');
+ if (typeof source !== 'string' || source.length === 0) throw new Error('포인터 출처 이름이 필요합니다.');
+ let point = points.get(owner);
+ if (phase === 'down') {
+ if (point?.source === source) return 0;
+ if (point) throw new Error('이 입력은 다른 포인터가 사용 중입니다.');
+ point = {name, source, x, y, target, route:route?.route};
+ points.set(owner, point);
+ held.set(owner, new Set([source]));
+ } else {
+ if (!point || point.source !== source) return 0;
+ point.x = x; point.y = y;
+ if (phase === 'up' || phase === 'cancel') { points.delete(owner); held.delete(owner); }
+ }
+ const event = phase === 'move' ? 17 + index : phase === 'cancel' ? 25 + index : 1 + index * 2 + (phase === 'up' ? 1 : 0);
+ return dispatch(event, name, phase, source, point);
+ },
+ releaseAll() {
+ available();
+ let firstError = 0;
+ for (const point of [...points.values()]) {
+ const status = api.point(point.name, 'cancel', point.x, point.y, point.source, point.target);
+ if (!firstError) firstError = status;
+ }
+ for (const [name, sources] of [...held]) {
+ for (const source of [...sources]) {
+ const status = api.up(name, source);
+ if (!firstError) firstError = status;
+ }
+ }
+ return firstError;
+ },
+ exportProject() {
+ return { format: 'pure-workbench', version: 1, source: appliedSource };
+ },
+ // Synchronous trusted output adapter. Callbacks run only after the whole
+ // display has been evaluated. Runtime mutation/reentrant reads are refused.
+ // Exceptions from an adapter propagate; the next call republishes all values.
+ present(target) {
+ available();
+ presenting = true;
+ try {
+ const { begin, state, view, value, end } = target;
+ for (const callback of [begin, state, view, value, end])
+ if (typeof callback !== 'function') throw new TypeError('출력 어댑터 함수가 필요합니다.');
+ const error = evaluateDisplay();
+ begin.call(target, program);
+ for (let i = 0; i < (program?.states.length ?? 0); i++) state.call(target, i, core.get_state(i));
+ let slot = 0, visible = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const valid = Boolean(displayValid[i]);
+ view.call(target, i, valid);
+ for (const [name] of bindings[i]) {
+ if (valid) value.call(target, i, name, displayValues[slot]);
+ slot++;
+ }
+ if (valid) visible++;
+ }
+ end.call(target, visible);
+ return status(error);
+ } finally { presenting = false; }
+ },
+ snapshot() {
+ available();
+ const error = evaluateDisplay();
+ const states = Object.fromEntries((program?.states ?? []).map((state, index) => [state.name, core.get_state(index)]));
+ const views = [];
+ let slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ const rendered = displayValid[i] ? { id: view.id, type: view.type, color: view.color, prefix: view.prefix } : null;
+ for (const [name] of bindings[i]) {
+ if (rendered) rendered[name] = displayValues[slot];
+ slot++;
+ }
+ if (rendered) views.push(rendered);
+ }
+ return { states, views, ...status(error) };
+ },
+ };
+ api.configureEvaluation(evaluationOptions);
+ return Object.freeze(api);
+}
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/sources/web/split-input.mjs b/prototypes/workbench/results/2026-09-23-web-layout/sources/web/split-input.mjs
new file mode 100644
index 0000000..294e9a3
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/sources/web/split-input.mjs
@@ -0,0 +1,57 @@
+// Only gesture ownership and transient geometry live here. Layout belongs to CSS.
+export function bindSplit({container, handle, readRatio, writeRatio, narrow, stats}) {
+ const events=container.ownerDocument.defaultView, abort=new AbortController();
+ const listen=(node,type,callback,options={})=>node.addEventListener(type,callback,{...options,signal:abort.signal});
+ let drag=null, frame=0, dirty=true, rect=null, handleWidth=0;
+ const invalidate=()=>{dirty=true;};
+ const observer=new ResizeObserver(invalidate);observer.observe(container);observer.observe(handle);
+ function measure() {
+ rect=container.getBoundingClientRect();const h=handle.getBoundingClientRect();
+ handleWidth=h.width;stats.geometryReads+=2;dirty=false;return h;
+ }
+ function flush() {
+ if(frame){cancelAnimationFrame(frame);frame=0;}
+ if(!drag || drag.x===null)return;
+ if(dirty)measure();
+ const width=rect.width-handleWidth;
+ if(width>0)writeRatio(100*(drag.x-rect.left-handleWidth/2-drag.offset)/width,'pointer');
+ drag.x=null;
+ }
+ function finish(cancel) {
+ if(!drag)return;
+ if(cancel){if(frame)cancelAnimationFrame(frame);frame=0;}else flush();
+ const previous=drag;drag=null;
+ if(handle.hasPointerCapture(previous.id))handle.releasePointerCapture(previous.id);
+ handle.classList.remove('dragging');
+ if(cancel)writeRatio(previous.before,'cancel');
+ }
+ listen(handle,'pointerdown',event=>{
+ if(event.button!==0 || drag || narrow.matches)return;
+ const h=measure();if(rect.width<=handleWidth)return;
+ event.preventDefault();handle.focus();
+ handle.setPointerCapture(event.pointerId);
+ drag={id:event.pointerId,type:event.pointerType,before:readRatio(),offset:event.clientX-(h.left+h.width/2),x:null};
+ handle.classList.add('dragging');
+ });
+ listen(handle,'pointermove',event=>{
+ if(!drag || drag.id!==event.pointerId)return;
+ event.preventDefault();drag.x=event.clientX;
+ if(!frame)frame=requestAnimationFrame(()=>{frame=0;flush();});
+ });
+ listen(events,'pointerup',event=>{if(drag?.id===event.pointerId)finish(false);},{capture:true});
+ listen(events,'pointercancel',event=>{if(drag?.id===event.pointerId)finish(true);},{capture:true});
+ listen(events,'mouseup',event=>{if(event.button===0&&drag?.type==='mouse')finish(false);},{capture:true});
+ listen(handle,'lostpointercapture',event=>{if(event.target===handle&&drag?.id===event.pointerId)finish(true);});
+ listen(events,'blur',()=>finish(true));
+ listen(container.ownerDocument,'visibilitychange',()=>{if(container.ownerDocument.hidden)finish(true);});
+ listen(events,'resize',invalidate);listen(events,'scroll',invalidate,{capture:true,passive:true});
+ listen(narrow,'change',()=>{invalidate();if(narrow.matches)finish(true);});
+ listen(handle,'keydown',event=>{
+ if(event.key==='Escape'){event.preventDefault();finish(true);return;}
+ if(!['ArrowLeft','ArrowRight','Home','End'].includes(event.key))return;
+ event.preventDefault();finish(true);
+ writeRatio(event.key==='Home'?20:event.key==='End'?80:readRatio()+(event.key==='ArrowLeft'?-1:1)*(event.shiftKey?10:1),'keyboard');
+ });
+ return Object.freeze({cancel:()=>finish(true),isDragging:()=>drag!==null,
+ dispose(){finish(true);observer.disconnect();abort.abort();}});
+}
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/value-control.json b/prototypes/workbench/results/2026-09-23-web-layout/value-control.json
new file mode 100644
index 0000000..36ac5dc
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/value-control.json
@@ -0,0 +1,7 @@
+{
+ "checks": [
+ "numeric control dispatches change and publishes the authored result",
+ "numeric controls do not create a held pointer owner",
+ "the same authored clamp handles negative requests"
+ ]
+}
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/value-control.log b/prototypes/workbench/results/2026-09-23-web-layout/value-control.log
new file mode 100644
index 0000000..346a5a6
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/value-control.log
@@ -0,0 +1,18 @@
+### Result
+{"checks":["numeric control dispatches change and publishes the authored result","numeric controls do not create a held pointer owner","the same authored clamp handles negative requests"]}
+### Ran Playwright code
+```js
+await (async page => {
+ const checks=[],check=(ok,label)=>{if(!ok)throw new Error(label);checks.push(label);};
+ await page.waitForFunction(()=>window.pureWorkbench?.snapshot().ready);
+ await page.locator('#source').fill('state amount=0\ninput level=value\ntext label(20,40,amount)\non level.change {amount=clamp(event_value,0,100)}');
+ await page.locator('#apply').click();
+ await page.getByLabel('level',{exact:true}).fill('123');await page.getByRole('button',{name:'값 전달',exact:true}).click();
+ let s=await page.evaluate(()=>window.pureWorkbench.snapshot());check(s.states.amount===100&&s.lastEvent.phase==='change','numeric control dispatches change and publishes the authored result');
+ check(s.held.length===0,'numeric controls do not create a held pointer owner');
+ await page.getByLabel('level',{exact:true}).fill('-5');await page.getByRole('button',{name:'값 전달',exact:true}).click();
+ s=await page.evaluate(()=>window.pureWorkbench.snapshot());check(s.states.amount===0,'the same authored clamp handles negative requests');
+ await page.evaluate(e=>window.__pureValueEvidence=e,{checks});return{checks};
+}
+)(page);
+```
diff --git a/prototypes/workbench/results/2026-09-23-web-layout/verification.json b/prototypes/workbench/results/2026-09-23-web-layout/verification.json
new file mode 100644
index 0000000..a29556b
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-web-layout/verification.json
@@ -0,0 +1,14 @@
+{
+ "nativeTests": 37,
+ "nodeTests": 87,
+ "browserChecks": {
+ "document": 32,
+ "authoring": 44,
+ "numericControl": 3,
+ "guards": 6
+ },
+ "sourceSha256": "60307704dbb5e11a75d14108fb7d87a5a4480359fdd771ec73beebf5f5c48b81",
+ "wasmSha256": "9f760e4a2ce6ea11f2be07693fceca74088884885cc545a70ceef5003d0b2479",
+ "wasmFileBytes": 61072,
+ "scope": "Functional and ownership checks, not a throughput/latency/RAM benchmark"
+}
diff --git a/prototypes/workbench/results/2026-09-23-workspace/browser.json b/prototypes/workbench/results/2026-09-23-workspace/browser.json
new file mode 100644
index 0000000..5c40c3d
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-workspace/browser.json
@@ -0,0 +1,240 @@
+{
+ "utc": "2026-09-23T02:06:08.428Z",
+ "checks": [
+ "authored browser input commits fractional state",
+ "bounded evaluation renders the exact authored expression",
+ "code replacement preserves state and changes binding result",
+ "browser Wasm reuses completed bindings",
+ "browser runs the shared-workspace build with only 56 additional dedicated bytes",
+ "discarding records preserves the browser snapshot",
+ "browser API publishes no partial scene after budget exhaustion",
+ "raising the budget recovers the browser snapshot",
+ "configured UI keeps committed state while withholding incomplete display",
+ "UI distinguishes display limit from event rollback",
+ "normal configuration reloads to a clean blank work",
+ "no uncaught browser errors"
+ ],
+ "api": {
+ "before": {
+ "states": {
+ "x": 3
+ },
+ "views": [
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "",
+ "x": 40,
+ "y": 60,
+ "value": 10
+ }
+ ],
+ "tick": 0,
+ "revision": 1,
+ "displayError": null,
+ "lastError": 0,
+ "errorMessage": "",
+ "errorLine": null,
+ "lastOps": 0,
+ "successfulEvents": 0,
+ "held": [],
+ "lastEvent": null,
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19128,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 252,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ }
+ },
+ "warm": {
+ "cacheEntries": 32,
+ "historyEntries": 8,
+ "workLimit": 2048,
+ "work": 6,
+ "instructions": 0,
+ "cacheHits": 3,
+ "reservedBytes": 1200,
+ "dedicatedBytes": 48,
+ "sharedBytes": 1152,
+ "reuseAvailable": true,
+ "workspaceTagBytes": 8
+ },
+ "forgotten": {
+ "states": {
+ "x": 3
+ },
+ "views": [
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "",
+ "x": 40,
+ "y": 60,
+ "value": 10
+ }
+ ],
+ "tick": 0,
+ "revision": 1,
+ "displayError": null,
+ "lastError": 0,
+ "errorMessage": "",
+ "errorLine": null,
+ "lastOps": 0,
+ "successfulEvents": 0,
+ "held": [],
+ "lastEvent": null,
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19128,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 252,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ }
+ },
+ "limited": {
+ "states": {
+ "x": 3
+ },
+ "views": [],
+ "tick": 0,
+ "revision": 1,
+ "displayError": {
+ "code": 101,
+ "line": 3,
+ "message": "표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요."
+ },
+ "lastError": 101,
+ "errorMessage": "표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.",
+ "errorLine": 3,
+ "lastOps": 0,
+ "successfulEvents": 0,
+ "held": [],
+ "lastEvent": null,
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19128,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 252,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ }
+ },
+ "limitStats": {
+ "cacheEntries": 32,
+ "historyEntries": 8,
+ "workLimit": 1,
+ "work": 1,
+ "instructions": 0,
+ "cacheHits": 0,
+ "reservedBytes": 1200,
+ "dedicatedBytes": 48,
+ "sharedBytes": 1152,
+ "reuseAvailable": true,
+ "workspaceTagBytes": 8
+ },
+ "recovered": {
+ "states": {
+ "x": 3
+ },
+ "views": [
+ {
+ "id": "label",
+ "type": "text",
+ "prefix": "",
+ "x": 40,
+ "y": 60,
+ "value": 10
+ }
+ ],
+ "tick": 0,
+ "revision": 1,
+ "displayError": null,
+ "lastError": 0,
+ "errorMessage": "",
+ "errorLine": null,
+ "lastOps": 0,
+ "successfulEvents": 0,
+ "held": [],
+ "lastEvent": null,
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19128,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 252,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ }
+ }
+ },
+ "limitedUi": {
+ "states": {
+ "x": 3.25
+ },
+ "views": [],
+ "tick": 0,
+ "revision": 1,
+ "displayError": {
+ "code": 101,
+ "line": 3,
+ "message": "표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요."
+ },
+ "lastError": 101,
+ "errorMessage": "표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.",
+ "errorLine": 3,
+ "lastOps": 0,
+ "successfulEvents": 2,
+ "held": [],
+ "lastEvent": {
+ "name": "next",
+ "phase": "up",
+ "source": "pointer:1",
+ "status": 0,
+ "line": null,
+ "ops": 0
+ },
+ "lastFailure": null,
+ "memory": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19128,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 252,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ },
+ "ready": true,
+ "source": "state x = 3\ninput next = KeyN\ntext label(40, 60, (x + 1) * (x + 2) / 2)\non next.down { x = x + 0.25 }\n",
+ "appliedSource": "state x = 3\ninput next = KeyN\ntext label(40, 60, (x + 1) * (x + 2) / 2)\non next.down { x = x + 0.25 }\n",
+ "dirty": false,
+ "running": false,
+ "droppedTicks": 0,
+ "diagnostic": "코드를 적용했습니다. 같은 이름의 상태는 유지됩니다."
+ },
+ "message": "표시 오류 · 3행 · 표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요. — 이번 장면을 표시하지 않았습니다. 상태 변경은 유지됩니다.",
+ "errors": [],
+ "uiLimitSetup": "Test-only module response overrides default workLimit to 1; production files are unchanged.",
+ "screenshot": "output/playwright/pure-workspace-limit.png",
+ "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36",
+ "qaSourceSha256": "d83577c0851b465c3bd43c256b53659aef9df96f3c3dffe7e59f5d7c98db80c6"
+}
diff --git a/prototypes/workbench/results/2026-09-23-workspace/comparison.json b/prototypes/workbench/results/2026-09-23-workspace/comparison.json
new file mode 100644
index 0000000..c53cc60
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-workspace/comparison.json
@@ -0,0 +1,142 @@
+{
+ "utc": "2026-09-23T01:58:27.939Z",
+ "kind": "workspace-reuse-correctness-and-storage",
+ "baselineManifestSha256": "f3e4eabf8a5a2a085a6cce62336b254e960d40892902653aba387dcbe4e775a8",
+ "sourceSha256": {
+ "core.zig": "dc84bcb75e4417fb1a804529c78ffa8cd1ddd079c00df4ca97a35da0b8fdbb8a",
+ "evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "web/runtime.mjs": "8516b0d7d89f5fd483b8c48cfc33bbb2bcbe8c27db3aa35b5f6cf892e89faa6e",
+ "web/compiler.mjs": "a34f3beb069c811a9bd5be73dde9341907ba77756066030b8b95fc6d66a5cc02",
+ "web/core.wasm": "76823b024fa5f4a79cbf1ed722ab757614a81a9f1c81d1d6268781a38bed7cc9",
+ "workspace-check.mjs": "a841167a2be597a56c1adc1cf31e4b1e8841579a42d853e260059531009015f0",
+ "examples/counter.pure": "53cb3a93ff0f26a3d208a5876bfff9ecb1c39e9a01acc101566727733ba166a8",
+ "examples/toggle.pure": "3de97fab9d316c17ba6a0c82a16cccd3baafc6816b879bf9b0a174210c8a1915",
+ "examples/charge.pure": "8d69d9fd51d44ab754d560eb7ff5102ea8a34540753cd7170b3bd800e4294f56"
+ },
+ "cases": [
+ {
+ "name": "counter",
+ "snapshots": 257,
+ "before": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 20272,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 284,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ },
+ "after": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19128,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 284,
+ "activeStateBytes": 8,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ },
+ "evaluation": {
+ "cacheEntries": 32,
+ "historyEntries": 8,
+ "workLimit": 2048,
+ "work": 14,
+ "instructions": 0,
+ "cacheHits": 7,
+ "reservedBytes": 1200,
+ "dedicatedBytes": 48,
+ "sharedBytes": 1152,
+ "reuseAvailable": true,
+ "workspaceTagBytes": 8
+ }
+ },
+ {
+ "name": "toggle",
+ "snapshots": 257,
+ "before": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 20272,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 456,
+ "activeStateBytes": 16,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ },
+ "after": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19128,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 456,
+ "activeStateBytes": 16,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ },
+ "evaluation": {
+ "cacheEntries": 32,
+ "historyEntries": 8,
+ "workLimit": 2048,
+ "work": 20,
+ "instructions": 0,
+ "cacheHits": 10,
+ "reservedBytes": 1200,
+ "dedicatedBytes": 48,
+ "sharedBytes": 1152,
+ "reuseAvailable": true,
+ "workspaceTagBytes": 8
+ }
+ },
+ {
+ "name": "charge",
+ "snapshots": 257,
+ "before": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 20272,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 1092,
+ "activeStateBytes": 32,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ },
+ "after": {
+ "linearBytes": 65536,
+ "reservedEngineBytes": 19128,
+ "programReservedBytes": 9392,
+ "instructionBytes": 16,
+ "handlerBytes": 12,
+ "activeIRBytes": 1092,
+ "activeStateBytes": 32,
+ "stagingReservedBytes": 9392,
+ "evalStackBytes": 512
+ },
+ "evaluation": {
+ "cacheEntries": 32,
+ "historyEntries": 8,
+ "workLimit": 2048,
+ "work": 28,
+ "instructions": 0,
+ "cacheHits": 14,
+ "reservedBytes": 1200,
+ "dedicatedBytes": 48,
+ "sharedBytes": 1152,
+ "reuseAvailable": true,
+ "workspaceTagBytes": 8
+ }
+ }
+ ],
+ "node": "v24.20.0",
+ "platform": "linux",
+ "arch": "arm64",
+ "wasmFileBytes": {
+ "before": 53125,
+ "after": 53952
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-23-workspace/verification.json b/prototypes/workbench/results/2026-09-23-workspace/verification.json
new file mode 100644
index 0000000..204e308
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-23-workspace/verification.json
@@ -0,0 +1,75 @@
+{
+ "utc": "2026-09-23T01:59:46.692314+00:00",
+ "build": {
+ "builtUtc": "2026-09-23T01:55:46.672638+00:00",
+ "zigVersion": "0.15.2",
+ "sourceSha256": "dc84bcb75e4417fb1a804529c78ffa8cd1ddd079c00df4ca97a35da0b8fdbb8a",
+ "wasmSha256": "76823b024fa5f4a79cbf1ed722ab757614a81a9f1c81d1d6268781a38bed7cc9",
+ "evaluationSourceSha256": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "wasmFileBytes": 53952,
+ "flags": [
+ "-target",
+ "wasm32-freestanding",
+ "-O",
+ "ReleaseSafe",
+ "-fstrip",
+ "-fno-entry",
+ "-rdynamic",
+ "--export-memory",
+ "--stack",
+ "16384",
+ "--initial-memory=65536",
+ "--max-memory=65536"
+ ],
+ "nativeTests": "1/23 core.test.blank program, no hidden application behavior, and reset...OK\n2/23 core.test.arbitrary input counter and unused events...OK\n3/23 core.test.charge and release behavior exists only in the authored instruction program...OK\n4/23 core.test.forward condition executes once and assignments and handlers are ordered...OK\n5/23 core.test.branch to program end is valid...OK\n6/23 core.test.one event rolls back writes from all handlers on arithmetic failure...OK\n7/23 core.test.nonfinite arithmetic and invalid clamp fail atomically...OK\n8/23 core.test.stack underflow overflow and leftover values reject the event...OK\n9/23 core.test.failed candidates leave active values program revision and diagnostics unchanged...OK\n10/23 core.test.state preservation mapping reads active values at commit and reset uses new initials...OK\n11/23 core.test.capacity boundaries and sticky load errors...OK\n12/23 core.test.invalid declaration values state mappings event ids and handler ranges...OK\n13/23 core.test.backward self out-of-handler branches and stores without handlers are rejected...OK\n14/23 core.test.event inputs and dt enforce deterministic frame contract...OK\n15/23 core.test.binding evaluation is pure on both success and failure...OK\n16/23 core.test.arithmetic comparison logical min max and clamp expression semantics...OK\n17/23 core.test.diagnostic counters wrap without affecting scalar values...OK\n18/23 core.test.memory accounting separates reserved storage used IR and stack capacity...OK\n19/23 core.test.loading shares storage with records while old events and bindings remain executable...OK\n20/23 core.test.failed and abandoned candidates restore empty reuse storage without changing active work...OK\n21/23 core.test.full pending program survives repeated evaluation configuration record loss and reset...OK\n22/23 evaluation.test.record loss cannot authorize an old memo; unrelated changes can...OK\n23/23 evaluation.test.budget, collisions, invalid settings, and disabled history are conservative...OK\nAll 23 tests passed.\n"
+ },
+ "javascript": [
+ {
+ "file": "compiler.test.mjs",
+ "tests": 13,
+ "output": "✔ a blank document is an empty program with no implied behavior (4.084845ms)\n✔ forward references and separate state/input namespaces preserve declaration IDs (1.916867ms)\n✔ ordered assignments, repeated handlers, and condition evaluation once (1.004795ms)\n✔ nested branches use absolute forward targets and skip correct bodies (0.627499ms)\n✔ operator precedence, associativity, unary and builtin function lowering (1.465349ms)\n✔ multiline parentheses, scientific literals, comments, and string escapes (0.665517ms)\n✔ all instruction fields are numeric and carry original source line (1.468127ms)\n✔ contextual declaration words remain valid names outside reserved builtins (0.597314ms)\n✔ unknown and reserved symbols have useful one-based locations (1.610405ms)\n✔ duplicates, unsupported keys, initializers, and invalid syntax are rejected (1.42372ms)\n✔ document, declaration, instruction, and nesting limits reject overflow (6.016712ms)\n✔ every supported input maps to a bounded ordered event ID (0.619481ms)\n✔ optional examples compile and charge behavior is entirely authored (9.912298ms)\nℹ tests 13\nℹ suites 0\nℹ pass 13\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 38.96273\n",
+ "sha256": "6aa442d9d1eeb69453082f1780ff4597e46b1fc09d8ba3cf955545bc94d81431"
+ },
+ {
+ "file": "runtime.test.mjs",
+ "tests": 13,
+ "output": "✔ blank startup and applied empty source contain no application objects or behavior (6.367748ms)\n✔ author can add unrelated states inputs views and behavior entirely through source (4.274419ms)\n✔ keyboard and pointer sources aggregate first down and last up; repeats are ignored (2.158996ms)\n✔ held input rejects apply without changing applied source, metadata, or values (1.463775ms)\n✔ invalid syntax, unknown references and capacity overflow preserve the previous program (1.888256ms)\n✔ state preservation follows names across reorder/add/remove; reset uses new initializers (1.034961ms)\n✔ callers cannot mutate active metadata and corrupt name-based state preservation (0.856887ms)\n✔ conditions execute once, and assignments and multiple handlers follow source order (0.784572ms)\n✔ a failed event rolls back all handlers and keeps its source diagnostic through release (2.938199ms)\n✔ reset clears held inputs and latched errors; invalid frame counts have no effects (1.303664ms)\n✔ render evaluation is pure and invalid binding is distinct from an event rollback (3.887456ms)\n✔ independent adapters replay the same author program without sharing live state (1.536738ms)\n✔ Wasm has no imports and reports fixed memory and exact active IR/state storage (6.509674ms)\nℹ tests 13\nℹ suites 0\nℹ pass 13\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 45.605312\n",
+ "sha256": "5fe66115aab0c178a283dcb592376a2e7209308368004865e6c0a1cc58fa7f62"
+ },
+ {
+ "file": "baseline.test.mjs",
+ "tests": 7,
+ "output": "✔ reference begins with the authored states and matching scene (2.33507ms)\n✔ press, hold, release, and frame order produce exact expected values (0.559814ms)\n✔ charge clamps at one and explicit floor restores rest (1.323109ms)\n✔ repeat and concurrent physical sources produce only logical edges (0.477092ms)\n✔ airborne presses cannot charge or cause another jump (0.26063ms)\n✔ releaseAll emits release and reset clears latches without a jump (0.432721ms)\n✔ unknown inputs and invalid frame counts reject without mutation (0.798832ms)\nℹ tests 7\nℹ suites 0\nℹ pass 7\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 14.77855\n",
+ "sha256": "3ea469a29d301b95701bc6f6ffafa2471ac0f334c5bd462c0cbc7c320bdc0764"
+ },
+ {
+ "file": "cli.test.mjs",
+ "tests": 5,
+ "output": "✔ terminal starts empty and produces the same charge trace as the shared runtime (103.338011ms)\n✔ invalid terminal commands do not alter state and fail piped execution (46.394366ms)\n✔ file load preserves by name, rejected load preserves active work, and save never overwrites (51.618413ms)\n✔ terminal exposes arithmetic rollback and retains failure through matching release (51.527709ms)\n✔ terminal configures and discards evaluation records and recovers from a display limit (44.876573ms)\nℹ tests 5\nℹ suites 0\nℹ pass 5\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 305.313987\n",
+ "sha256": "655c6d80b51af818fa7cdeb27cb5462962a0e1aef70f609ef5e2ddc85f4a6bc7"
+ },
+ {
+ "file": "evaluation.test.mjs",
+ "tests": 7,
+ "output": "✔ cache reuse, unrelated changes and discarded records preserve authored values (14.743642ms)\n✔ committed changes only: failed events and failed edits preserve reusable output (2.431237ms)\n✔ program edits, state reordering and reset cannot reuse old definitions (2.475107ms)\n✔ finite float semantics include signed zero, high state bit and arithmetic errors (2.389774ms)\n✔ one budget covers the whole scene; failures publish no partial scene or state rollback (1.167016ms)\n✔ small caches, no history and every small work limit give exact results or explicit failure (23.256183ms)\n✔ invalid settings and forget names preserve policy and state (1.602997ms)\nℹ tests 7\nℹ suites 0\nℹ pass 7\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 56.583738\n",
+ "sha256": "d3026ab5c6bffce56c0bde3155d7dddd6fa442a5688656ee710626c762258ff1"
+ },
+ {
+ "file": "workspace.test.mjs",
+ "tests": 2,
+ "output": "✔ Wasm executes old events and output during staged replacement without corrupting either phase (6.216471ms)\n✔ workspace accounting reports shared payload and safety tag without double-counting (1.978441ms)\nℹ tests 2\nℹ suites 0\nℹ pass 2\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 14.652234\n",
+ "sha256": "7ea664736e4c9d791d72cf64de0fbc230b8d849910cc2a456d4d0e959acb9857"
+ }
+ ],
+ "javascriptTestCount": 47,
+ "sourceSha256": {
+ "core.zig": "dc84bcb75e4417fb1a804529c78ffa8cd1ddd079c00df4ca97a35da0b8fdbb8a",
+ "evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "web/core.wasm": "76823b024fa5f4a79cbf1ed722ab757614a81a9f1c81d1d6268781a38bed7cc9",
+ "web/runtime.mjs": "8516b0d7d89f5fd483b8c48cfc33bbb2bcbe8c27db3aa35b5f6cf892e89faa6e",
+ "web/app.js": "6d63f3bfdfe212146894dbb1772509eb4ecc2f3be3b106675f0fc10cdeb5bfd3",
+ "cli.mjs": "2e0627ef84a45c8a9e91fb28c0010673212d459d0b0d09a5342856a049060030",
+ "manage.py": "e04199a0b5addc73bea7748420eee21ccf0aaa1a5b8c83082e5588187d6ed669",
+ "qa-evaluation.js": "d83577c0851b465c3bd43c256b53659aef9df96f3c3dffe7e59f5d7c98db80c6"
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-24-compact-runtime/app-memory-three-way-final.json b/prototypes/workbench/results/2026-09-24-compact-runtime/app-memory-three-way-final.json
new file mode 100644
index 0000000..3367cec
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-compact-runtime/app-memory-three-way-final.json
@@ -0,0 +1,160 @@
+{
+ "profile": "whole-native-app-idle-work-storage",
+ "prepared_sha256": "b7524e53a5d90d1ce16d2e2fbaac67ceb242f1cd27f280afc3a80857ad8c21ba",
+ "scalar_archive_sha256": "30a015c55d385b337639b54b2f31aea34b40bc37d1fe6c9e3c884660d26e4cbc",
+ "harness_sha256": "f0e3eedda1bb38bf4d2be3d1b43ca8c30bdfe536fe4890d943f25bd29bebf344",
+ "checker_sha256": "eab934985f3748c9c840d74975071389c9b7a4e8491a05bf4ef2a15e2a719111",
+ "baseline": {
+ "binary_sha256": "b9dea2faf85f096e93f9b93db4b431ec6e5617d5528be87db27e2bae0e614b89",
+ "app_source_sha256": "96ab7faf4848b5eaba5a1fb672eec2ea7882b3e554a4cba1adfac9c202a2b4ca",
+ "app_header_sha256": "32c738e06f007894457b6b4369ff75c9c2286737885a04159e257c1dcdfa3729",
+ "text_bytes": 35546,
+ "data_bytes": 1968,
+ "app_controller_bytes": 856,
+ "scalar_bytes": 280,
+ "samples": [
+ {
+ "apps": 1000,
+ "app_controller_bytes": 856,
+ "scalar_bytes": 280,
+ "rss_before_kib": 1376,
+ "rss_initialized_kib": 2576,
+ "rss_saved_kib": 2640,
+ "rss_initialized_delta_kib": 1200,
+ "rss_saved_delta_kib": 1264,
+ "completed_actions": 1000,
+ "completed_transfers": 1005
+ },
+ {
+ "apps": 1000,
+ "app_controller_bytes": 856,
+ "scalar_bytes": 280,
+ "rss_before_kib": 1392,
+ "rss_initialized_kib": 2592,
+ "rss_saved_kib": 2656,
+ "rss_initialized_delta_kib": 1200,
+ "rss_saved_delta_kib": 1264,
+ "completed_actions": 1000,
+ "completed_transfers": 1005
+ },
+ {
+ "apps": 1000,
+ "app_controller_bytes": 856,
+ "scalar_bytes": 280,
+ "rss_before_kib": 1360,
+ "rss_initialized_kib": 2560,
+ "rss_saved_kib": 2624,
+ "rss_initialized_delta_kib": 1200,
+ "rss_saved_delta_kib": 1264,
+ "completed_actions": 1000,
+ "completed_transfers": 1005
+ }
+ ],
+ "median_initialized_delta_kib": 1200,
+ "median_saved_delta_kib": 1264
+ },
+ "elastic": {
+ "binary_sha256": "738fecb38f2306264ef5356318a3e23b4e131355d88c2457ef541734b9c70a37",
+ "app_source_sha256": "cf19cbf7d7c60020b13198e9335e519f650633f1eb3fb25b61953ff56c3e4dd8",
+ "app_header_sha256": "622ec62cfb5f755fe0fb2582f5e1ce264172e8a45663078429afb910061e21a4",
+ "text_bytes": 35730,
+ "data_bytes": 1968,
+ "app_controller_bytes": 408,
+ "scalar_bytes": 280,
+ "samples": [
+ {
+ "apps": 1000,
+ "app_controller_bytes": 408,
+ "scalar_bytes": 280,
+ "rss_before_kib": 1376,
+ "rss_initialized_kib": 2128,
+ "rss_saved_kib": 2192,
+ "rss_initialized_delta_kib": 752,
+ "rss_saved_delta_kib": 816,
+ "completed_actions": 1000,
+ "completed_transfers": 1005
+ },
+ {
+ "apps": 1000,
+ "app_controller_bytes": 408,
+ "scalar_bytes": 280,
+ "rss_before_kib": 1392,
+ "rss_initialized_kib": 2144,
+ "rss_saved_kib": 2208,
+ "rss_initialized_delta_kib": 752,
+ "rss_saved_delta_kib": 816,
+ "completed_actions": 1000,
+ "completed_transfers": 1005
+ },
+ {
+ "apps": 1000,
+ "app_controller_bytes": 408,
+ "scalar_bytes": 280,
+ "rss_before_kib": 1376,
+ "rss_initialized_kib": 2128,
+ "rss_saved_kib": 2192,
+ "rss_initialized_delta_kib": 752,
+ "rss_saved_delta_kib": 816,
+ "completed_actions": 1000,
+ "completed_transfers": 1005
+ }
+ ],
+ "median_initialized_delta_kib": 752,
+ "median_saved_delta_kib": 816
+ },
+ "elastic_metrics": {
+ "binary_sha256": "bdf8be3979cdb5b76916e71771cf6304ea50c2184f9c490d63ab996d7852a5db",
+ "app_source_sha256": "cf19cbf7d7c60020b13198e9335e519f650633f1eb3fb25b61953ff56c3e4dd8",
+ "app_header_sha256": "622ec62cfb5f755fe0fb2582f5e1ce264172e8a45663078429afb910061e21a4",
+ "text_bytes": 35898,
+ "data_bytes": 1968,
+ "app_controller_bytes": 480,
+ "scalar_bytes": 280,
+ "samples": [
+ {
+ "apps": 1000,
+ "app_controller_bytes": 480,
+ "scalar_bytes": 280,
+ "rss_before_kib": 1376,
+ "rss_initialized_kib": 2208,
+ "rss_saved_kib": 2272,
+ "rss_initialized_delta_kib": 832,
+ "rss_saved_delta_kib": 896,
+ "completed_actions": 1000,
+ "completed_transfers": 1005
+ },
+ {
+ "apps": 1000,
+ "app_controller_bytes": 480,
+ "scalar_bytes": 280,
+ "rss_before_kib": 1392,
+ "rss_initialized_kib": 2224,
+ "rss_saved_kib": 2288,
+ "rss_initialized_delta_kib": 832,
+ "rss_saved_delta_kib": 896,
+ "completed_actions": 1000,
+ "completed_transfers": 1005
+ },
+ {
+ "apps": 1000,
+ "app_controller_bytes": 480,
+ "scalar_bytes": 280,
+ "rss_before_kib": 1376,
+ "rss_initialized_kib": 2208,
+ "rss_saved_kib": 2272,
+ "rss_initialized_delta_kib": 832,
+ "rss_saved_delta_kib": 896,
+ "completed_actions": 1000,
+ "completed_transfers": 1005
+ }
+ ],
+ "median_initialized_delta_kib": 832,
+ "median_saved_delta_kib": 896
+ },
+ "controller_saved_per_app_bytes": 448,
+ "metrics_storage_saved_per_app_bytes": 72,
+ "metrics_initialized_rss_median_saved_kib": 80,
+ "metrics_saved_rss_median_saved_kib": 80,
+ "initialized_rss_median_saved_kib": 448,
+ "saved_rss_median_saved_kib": 448
+}
diff --git a/prototypes/workbench/results/2026-09-24-fixed-capacity/fixed-cli-build.json b/prototypes/workbench/results/2026-09-24-fixed-capacity/fixed-cli-build.json
new file mode 100644
index 0000000..25fdd72
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-fixed-capacity/fixed-cli-build.json
@@ -0,0 +1,48 @@
+{
+ "source": "/home/eunhhu/work/pure/prototypes/workbench/examples/routed-layout.pure",
+ "sourceSha256": "656e81577c250df704ba744b1ea6ec9c987ab3b8ee90c758062ac4beaf195650",
+ "zigVersion": "0.15.2",
+ "zigFlags": [
+ "-O",
+ "ReleaseSafe",
+ "-fno-compiler-rt"
+ ],
+ "scalarProfile": "fixed-definition",
+ "zigRoot": "/home/eunhhu/work/pure/prototypes/workbench/native-fixed.zig",
+ "cFlags": [
+ "-std=c17",
+ "-O2",
+ "-g",
+ "-Wall",
+ "-Wextra",
+ "-Wpedantic",
+ "-Werror"
+ ],
+ "binary": "/home/eunhhu/work/pure/prototypes/workbench/build/native/pure-native",
+ "binarySha256": "ca33aa955ac192db31f5684ff8535f05bded6adef2f7f982b4afd681b3ef11d0",
+ "librarySha256": "bcdfc29c7aaa42f3c50baa0f46c0b0cab341f7b8d5d7c75b1f10ddc9f94660f2",
+ "fixedProfileSha256": "adc229822531f3c62f3e4f16821ea21d74047808125e699f1ce03c5bfa7f9199",
+ "preparedSha256": "710503e7ffadf02b309c24a761caade4c5a093750d6801956cc698bfc263fc09",
+ "desktop": false,
+ "desktopFlags": [],
+ "peer": false,
+ "peerFlags": [],
+ "implementationSha256": {
+ "prototypes/workbench/engine.zig": "1a58aa081fcfa0876c5499bbfa2768334641e4bd5a357ae4136003fd1e315b98",
+ "prototypes/workbench/evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "prototypes/workbench/native.zig": "fe6c445cdfb5a4bc27ea9f540f3880c45b65c33098eec761d4398d9a7fc5c9a6",
+ "prototypes/workbench/native-fixed.zig": "29b4cc1c8777e83c0d167ecabd66b6bb8c84dba9273f0c1d9e24e33f872f1125",
+ "prototypes/workbench/native/prepare.mjs": "2d2679ae71088ba8691f29029c85c849a5351cf5e49c9b691c60b56b15ceca0f",
+ "prototypes/workbench/native/scalar.h": "b84a796bde2f45d59505cc0addb5f5afede0a9c4dc58d40a38dd37a8ac68d1fd",
+ "prototypes/workbench/native/plan.h": "2e9bd7e3c2ecc52f79c8938d09d2a696174c44a245e4bf52b70be2ed02cbbb57",
+ "prototypes/workbench/native/app.h": "32c738e06f007894457b6b4369ff75c9c2286737885a04159e257c1dcdfa3729",
+ "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1",
+ "prototypes/workbench/native/main.c": "dd28168741e79ffaa73cf74e60b974a1ca84b0473a43aafe089019f61e732aab",
+ "prototypes/workbench/native/app.c": "96ab7faf4848b5eaba5a1fb672eec2ea7882b3e554a4cba1adfac9c202a2b4ca",
+ "prototypes/workbench/native/plan.c": "f0bdf8466097cfad54c197120aace8acd5f430170931d575e26807b760ce2173",
+ "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2",
+ "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04",
+ "prototypes/connections/store.c": "44702e9414c26d1ee336b2e36e6713560013c2c8d0091a8900042e2468f88015",
+ "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f"
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-24-fixed-capacity/fixed-parity-final.json b/prototypes/workbench/results/2026-09-24-fixed-capacity/fixed-parity-final.json
new file mode 100644
index 0000000..69a2446
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-fixed-capacity/fixed-parity-final.json
@@ -0,0 +1,209 @@
+{
+ "kind": "native-instance-and-authored-behavior-correctness-not-performance",
+ "sanitized_c_host": true,
+ "profile": "fixed-definition",
+ "general_instance_bytes": 20064,
+ "zig_mode": "ReleaseSafe; Zig object is not ASan-instrumented",
+ "compiler": "cc (Debian 14.2.0-19) 14.2.0",
+ "cases": [
+ {
+ "name": "counter",
+ "commands": 5,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 1576,
+ "fixed_profile_sha256": "f894c6e3ed9ab2aff461e4bf1e31a19d7eedc8e0d1618b433c1a733b08539e72",
+ "fresh_process_restore": true,
+ "source": "# Space or the tap control increments a state that is displayed and drawn.\nstate count = 0\ninput tap = Space\ntext label(80, 90, count, \"Count: \")\nbox tile(80, 150, 40 + min(count, 20) * 8, 60, \"#356548\")\non tap.down {\n count = count + 1\n}\n",
+ "source_sha256": "53cb3a93ff0f26a3d208a5876bfff9ecb1c39e9a01acc101566727733ba166a8",
+ "prepared_sha256": "eada952dee9afd7071370215364b2a6717a28f03e0513cdd0cae4695939ec52b",
+ "native_binary_sha256": "4dde0c70ea02eaf3def61e4d82c1707ffb825a60e81cea89487b83576afd6705",
+ "native_binary_bytes": 589832
+ },
+ {
+ "name": "toggle",
+ "commands": 28,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 1768,
+ "fixed_profile_sha256": "f6afa6aab5a41d915a68af4414372b1e3fd36b1486de31a97690dd1872f45ffc",
+ "fresh_process_restore": true,
+ "source": "# Space toggles the circle. Right arrow moves the tile.\nstate visible = 1\nstate position = 120\ninput toggle = Space\ninput move = ArrowRight\ncircle marker(position, 140, visible * 24, \"#c78638\")\nbox tile(position - 24, 190, 48, 48, \"#356548\")\ntext value(80, 80, visible, \"Visible: \")\non toggle.down {\n visible = !visible\n}\non move.down {\n position = position + 40\n if position > 560 {\n position = 80\n }\n}\n",
+ "source_sha256": "3de97fab9d316c17ba6a0c82a16cccd3baafc6816b879bf9b0a174210c8a1915",
+ "prepared_sha256": "fc96ee38b256117a7bfe2859b7126e22262c19e96047a8d01fbe30d4d1a28764",
+ "native_binary_sha256": "0de137366b074a34090268a0e5198a1ed4fd39c8613594f1212c447df1a47b48",
+ "native_binary_bytes": 593464
+ },
+ {
+ "name": "charge",
+ "commands": 104,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 2528,
+ "fixed_profile_sha256": "4b15bba6c5aec3cc97caa76a93c77398be13f54ccd536936fc41c0d6387ffde3",
+ "fresh_process_restore": true,
+ "source": "# All holding, charging, gravity, jumping, and floor behavior is authored here.\nstate holding = 0\nstate charge = 0\nstate y = 240\nstate velocity = 0\ninput jump = Space\nbox floor(40, 261, 560, 4, \"#356548\")\ncircle actor(180, y, 20, \"#c78638\")\nbox meter(80, 80, charge * 200, 12, \"#c78638\")\ntext amount(80, 60, charge, \"Charge: \")\non jump.down {\n if y >= 240 {\n holding = 1\n charge = 0\n }\n}\non jump.up {\n if holding {\n velocity = -(160 + charge * 300)\n holding = 0\n charge = 0\n }\n}\non frame {\n if holding {\n charge = clamp(charge + dt, 0, 1)\n }\n velocity = velocity + 600 * dt\n y = y + velocity * dt\n if y >= 240 {\n y = 240\n velocity = 0\n }\n}\n",
+ "source_sha256": "8d69d9fd51d44ab754d560eb7ff5102ea8a34540753cd7170b3bd800e4294f56",
+ "prepared_sha256": "7ba5fa094b0bc772202bb44a62b3c8377c4f3924db246ec429c2faee4948b09f",
+ "native_binary_sha256": "1fe2b0dfbf1bce6955b21d9a48b7751db0529db14402d47ce3b230838836e9ee",
+ "native_binary_bytes": 598024
+ },
+ {
+ "name": "direct",
+ "commands": 3,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 3248,
+ "fixed_profile_sha256": "9dfd71e76ae0d2b42cf85b02d4169fc26f4f15d0ac0a9796962f892cb05efdb3",
+ "fresh_process_restore": true,
+ "source": "# 도형을 잡은 위치를 유지해 이동합니다. 취소하면 잡기 전 위치로 돌아갑니다.\nstate x = 80\nstate y = 160\nstate grab_x = 0\nstate grab_y = 0\nstate start_x = 80\nstate start_y = 160\nstate dragging = 0\n\ninput drag = pointer(tile)\ninput left = ArrowLeft\ninput right = ArrowRight\n\nbox left_area(20, 100, 280, 220, \"#233a31\")\nbox right_area(340, 100, 280, 220, \"#343b25\")\nbox tile(x, y, 56, 44, \"#c9f08b\")\ntext horizontal(30, 45, x, \"x: \")\ntext vertical(180, 45, y, \"y: \")\ntext region(350, 70, x >= 320, \"오른쪽 영역: \")\ntext active(30, 75, dragging, \"잡고 있음: \")\n\non drag.down {\n start_x = x\n start_y = y\n grab_x = event_x - x\n grab_y = event_y - y\n dragging = 1\n}\non drag.move {\n x = clamp(event_x - grab_x, 20, 564)\n y = clamp(event_y - grab_y, 100, 276)\n}\non drag.up {\n # 놓기는 마지막으로 표시한 위치를 확정합니다.\n dragging = 0\n}\non drag.cancel {\n x = start_x\n y = start_y\n dragging = 0\n}\non left.down {\n if dragging == 0 { x = max(20, x - 10) }\n}\non right.down {\n if dragging == 0 { x = min(564, x + 10) }\n}\n",
+ "source_sha256": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a",
+ "prepared_sha256": "27f0aa76eb3e12e289bcd6edf6c2ae72892a2dfe1ff094b619671f0583e2c8d3",
+ "native_binary_sha256": "cad1d5261438b6b643d728c015d138bb268f5e804a6f0c56a69a4f401b0084ab",
+ "native_binary_bytes": 600624
+ },
+ {
+ "name": "routed",
+ "commands": 8,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 5456,
+ "fixed_profile_sha256": "adc229822531f3c62f3e4f16821ea21d74047808125e699f1ce03c5bfa7f9199",
+ "fresh_process_restore": true,
+ "source": "# 하나의 포인터 입력과 네 반응을 모든 도형이 공유합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag = pointer {\n green_box: green\n blue_box: blue\n amber_box: amber\n}\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag.down use grab(drag.target)\non drag.move use move(drag.target)\non drag.up use drop(drag.target)\non drag.cancel use cancel(drag.target)\n\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n",
+ "source_sha256": "656e81577c250df704ba744b1ea6ec9c987ab3b8ee90c758062ac4beaf195650",
+ "prepared_sha256": "710503e7ffadf02b309c24a761caade4c5a093750d6801956cc698bfc263fc09",
+ "native_binary_sha256": "12647b3270ca7022b82542cc10837b0bb4527e1f1892c8635c2d11a11df4c7f7",
+ "native_binary_bytes": 595000
+ },
+ {
+ "name": "layout",
+ "commands": 7,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 11792,
+ "fixed_profile_sha256": "5ede5580fe1f780269a6288fbfb8bc54487699f1e73aabbd3fc4d6200bba8602",
+ "fresh_process_restore": true,
+ "source": "# 세 도형을 배치하고 배치 저장으로 결과를 보관하세요.\n# 마지막으로 잡은 도형은 방향키로 10씩 이동합니다.\nstate selected = 0\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\nstate x1 = 80\nstate y1 = 120\nstate ox1 = 0\nstate oy1 = 0\nstate sx1 = 80\nstate sy1 = 120\nstate held1 = 0\ninput drag1 = pointer(tile1)\nstate x2 = 280\nstate y2 = 160\nstate ox2 = 0\nstate oy2 = 0\nstate sx2 = 280\nstate sy2 = 160\nstate held2 = 0\ninput drag2 = pointer(tile2)\nstate x3 = 450\nstate y3 = 220\nstate ox3 = 0\nstate oy3 = 0\nstate sx3 = 450\nstate sy3 = 220\nstate held3 = 0\ninput drag3 = pointer(tile3)\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox tile1(x1,y1,56,44,\"#c9f08b\")\nbox tile2(x2,y2,56,44,\"#74c4d4\")\nbox tile3(x3,y3,56,44,\"#dfb184\")\non drag1.down {\n selected = 1\n sx1 = x1; sy1 = y1\n ox1 = event_x - x1; oy1 = event_y - y1\n held1 = 1\n}\non drag1.move {\n x1 = clamp(event_x - ox1, 20, 564)\n y1 = clamp(event_y - oy1, 90, 286)\n}\non drag1.up { held1 = 0 }\non drag1.cancel { x1 = sx1; y1 = sy1; held1 = 0 }\non drag2.down {\n selected = 2\n sx2 = x2; sy2 = y2\n ox2 = event_x - x2; oy2 = event_y - y2\n held2 = 1\n}\non drag2.move {\n x2 = clamp(event_x - ox2, 20, 564)\n y2 = clamp(event_y - oy2, 90, 286)\n}\non drag2.up { held2 = 0 }\non drag2.cancel { x2 = sx2; y2 = sy2; held2 = 0 }\non drag3.down {\n selected = 3\n sx3 = x3; sy3 = y3\n ox3 = event_x - x3; oy3 = event_y - y3\n held3 = 1\n}\non drag3.move {\n x3 = clamp(event_x - ox3, 20, 564)\n y3 = clamp(event_y - oy3, 90, 286)\n}\non drag3.up { held3 = 0 }\non drag3.cancel { x3 = sx3; y3 = sy3; held3 = 0 }\non left.down {\n if selected == 1 && held1 == 0 { x1 = clamp(x1 + -10, 20, 564) }\n if selected == 2 && held2 == 0 { x2 = clamp(x2 + -10, 20, 564) }\n if selected == 3 && held3 == 0 { x3 = clamp(x3 + -10, 20, 564) }\n}\non right.down {\n if selected == 1 && held1 == 0 { x1 = clamp(x1 + 10, 20, 564) }\n if selected == 2 && held2 == 0 { x2 = clamp(x2 + 10, 20, 564) }\n if selected == 3 && held3 == 0 { x3 = clamp(x3 + 10, 20, 564) }\n}\non up.down {\n if selected == 1 && held1 == 0 { y1 = clamp(y1 + -10, 90, 286) }\n if selected == 2 && held2 == 0 { y2 = clamp(y2 + -10, 90, 286) }\n if selected == 3 && held3 == 0 { y3 = clamp(y3 + -10, 90, 286) }\n}\non down.down {\n if selected == 1 && held1 == 0 { y1 = clamp(y1 + 10, 90, 286) }\n if selected == 2 && held2 == 0 { y2 = clamp(y2 + 10, 90, 286) }\n if selected == 3 && held3 == 0 { y3 = clamp(y3 + 10, 90, 286) }\n}\n",
+ "source_sha256": "cebd9c3485a2be4ec16ae665c1ed4ee200d51fc56ad0151306c8e9c31138f346",
+ "prepared_sha256": "ed2fb846200749b500e3349b324bd30778a6938e867abd715b35f661f1d06e81",
+ "native_binary_sha256": "8e28b4cbad0538b948dd207958d83907cb31fc8e50dcc20d47ead8bc97c9a952",
+ "native_binary_bytes": 606864
+ },
+ {
+ "name": "shared-layout",
+ "commands": 7,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 5712,
+ "fixed_profile_sha256": "6892a498a7289d9e969e745271615ad963b692bcc4012e4d20519881ffe40d94",
+ "fresh_process_restore": true,
+ "source": "# 대상별 값은 item에, 공통 반응은 rule에 한 번만 작성합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag_green = pointer(green_box)\ninput drag_blue = pointer(blue_box)\ninput drag_amber = pointer(amber_box)\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag_green.down use grab(green)\non drag_green.move use move(green)\non drag_green.up use drop(green)\non drag_green.cancel use cancel(green)\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non drag_blue.down use grab(blue)\non drag_blue.move use move(blue)\non drag_blue.up use drop(blue)\non drag_blue.cancel use cancel(blue)\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non drag_amber.down use grab(amber)\non drag_amber.move use move(amber)\non drag_amber.up use drop(amber)\non drag_amber.cancel use cancel(amber)\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n",
+ "source_sha256": "be8296244b768f9adda99e7afc89249f34853fa27124a277aecb0841eb8176eb",
+ "prepared_sha256": "aed8c20aff78e0322434b3b6f9893f39084444d209337fef9345cc182965790d",
+ "native_binary_sha256": "6663289f823122261fd48ee307f7319c67793ca80dbcc0240552df361e2aa3eb",
+ "native_binary_bytes": 595400
+ },
+ {
+ "name": "document",
+ "commands": 5,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 1432,
+ "fixed_profile_sha256": "017becbebd7be3f6bb66fc758c49ff11fdd919ecbd28300f362ab5cb06481484",
+ "fresh_process_restore": true,
+ "source": "# 웹 컨트롤이 요청한 분할 비율을 받습니다. 레이아웃과 문서는 브라우저가 소유합니다.\nstate ratio = 50\ninput split = value\ninput reset = KeyR\non split.change { ratio = clamp(event_value, 20, 80) }\non reset.down { ratio = 50 }\n",
+ "source_sha256": "057d2e665d72dd3fce7ac59d232a65e9e0e1a6ca1f8c1e1b36ae766e480367d1",
+ "prepared_sha256": "c74315c1b0067e167a4678b30505d1770b0753b601e8acee27f6d598ac7b9ffb",
+ "native_binary_sha256": "0bf6d2cfd68d26a6f2e382feea6ca78e99364c8fdf22006bebd07a4bba11cae7",
+ "native_binary_bytes": 591968
+ },
+ {
+ "name": "atomic-error",
+ "commands": 2,
+ "failed_events": 1,
+ "states_and_views_match": true,
+ "instance_bytes": 1464,
+ "fixed_profile_sha256": "daf614a069ecd881b4e0acf0b45a61f64bb7fc5098a03bf55282dce04084b0a7",
+ "fresh_process_restore": true,
+ "source": "state x=7\nstate y=2\ninput n=value\non n.change {x=event_value; y=1/event_value}\ntext v(1,2,y,\"value: \")",
+ "source_sha256": "61a5478e7147ef281d617017910af36ad1bae3dabc97c03c8569c5d8b0e5fe19",
+ "prepared_sha256": "6a415d33780227158ebeae2a75c7520bf13bad3c0a6fef7ee82f5c323be226f0",
+ "native_binary_sha256": "60fb0cc6dd2162ca3149096e23cc18a70a39044fe3cd5c972eb27b78e1b93541",
+ "native_binary_bytes": 591184
+ },
+ {
+ "name": "blank",
+ "commands": 0,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 1320,
+ "fixed_profile_sha256": "9cf762238448174b8fc058577912f3aad318b9e84d50e4061862ad3f7a884fcd",
+ "fresh_process_restore": true,
+ "source": "",
+ "source_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "prepared_sha256": "1c8f5ade56ee8ec931a62fd1f68ef3635b17297d3a54b20c954860c4a43b6155",
+ "native_binary_sha256": "63df7a3e4b2ac8cefc9074b97da9eda533660f86ce9ebe1f5f87d24d5d3351f9",
+ "native_binary_bytes": 588832
+ },
+ {
+ "name": "literal-boundary",
+ "commands": 1,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 1384,
+ "fixed_profile_sha256": "933c81c100e38fd977057d4444210c0715af372183e002b649497f8f8c72daf4",
+ "fresh_process_restore": true,
+ "source": "state x=-0\ninput n=value\non n.change {x=event_value}\ntext v(1,2,x,\"한글 \\\" \\\\ \\n\")",
+ "source_sha256": "7f480fa50bbddaf4e3a42bbd2132701e2091ef12b499e30c1bdda1dcd947dcc1",
+ "prepared_sha256": "e9a8b1542ecf6b008ad30188cb9e95d29638fab1c38d331c00bf706c40a06d3c",
+ "native_binary_sha256": "4ccc23a268a9cf6519380b1b692b4ee376f7e12aa094c35ac91fbb245adfa8b1",
+ "native_binary_bytes": 592032
+ }
+ ],
+ "units": {
+ "native_instances": 3,
+ "checks": "independent-state-staging-inputs-errors-budget-snapshot-conflict-restore-plan-reload",
+ "scalar_instance_bytes": 5456,
+ "scalar_alignment": 8,
+ "app_controller_bytes": 928,
+ "action_bytes": 32,
+ "snapshot_bytes": 244,
+ "all_work_drained": true
+ },
+ "source_sha256": {
+ "prototypes/workbench/core.zig": "ea237848df0dbd2ce620c2874e64bc5ecbe569bf77f3737e9a4fe1e64fea40cc",
+ "prototypes/workbench/engine.zig": "1a58aa081fcfa0876c5499bbfa2768334641e4bd5a357ae4136003fd1e315b98",
+ "prototypes/workbench/evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "prototypes/workbench/native.zig": "fe6c445cdfb5a4bc27ea9f540f3880c45b65c33098eec761d4398d9a7fc5c9a6",
+ "prototypes/workbench/native-fixed.zig": "29b4cc1c8777e83c0d167ecabd66b6bb8c84dba9273f0c1d9e24e33f872f1125",
+ "prototypes/workbench/manage.py": "e0cf9f9eb65bb8b8830797bf4f533ea2cd86045d401fef444e86fad654e4dd8f",
+ "prototypes/workbench/native/scalar.h": "b84a796bde2f45d59505cc0addb5f5afede0a9c4dc58d40a38dd37a8ac68d1fd",
+ "prototypes/workbench/native/plan.h": "2e9bd7e3c2ecc52f79c8938d09d2a696174c44a245e4bf52b70be2ed02cbbb57",
+ "prototypes/workbench/native/plan.c": "f0bdf8466097cfad54c197120aace8acd5f430170931d575e26807b760ce2173",
+ "prototypes/workbench/native/app.h": "32c738e06f007894457b6b4369ff75c9c2286737885a04159e257c1dcdfa3729",
+ "prototypes/workbench/native/app.c": "96ab7faf4848b5eaba5a1fb672eec2ea7882b3e554a4cba1adfac9c202a2b4ca",
+ "prototypes/workbench/native/main.c": "dd28168741e79ffaa73cf74e60b974a1ca84b0473a43aafe089019f61e732aab",
+ "prototypes/workbench/native/prepare.mjs": "2d2679ae71088ba8691f29029c85c849a5351cf5e49c9b691c60b56b15ceca0f",
+ "prototypes/workbench/native/test.c": "47488a2cfda2b378637f69b8f0d94f75e615d784e8418167b5b82c8ce141f7e8",
+ "prototypes/workbench/native/test-fixed.c": "725d109417ee6ca2b680b9b8ba82f604985083fe6173a6e7269f40d40eb7d18a",
+ "prototypes/workbench/native/check.mjs": "794ee0d44395c973d3ca2a7213dcd564733f291f0d22358e914815e41bdf3958",
+ "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1",
+ "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54",
+ "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35",
+ "prototypes/workbench/web/core.wasm": "8fe2f501cb9639fea0817aa57e0b7292ca6779cda4ac156b7c6b3df31d7921e9",
+ "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2",
+ "prototypes/connections/runtime.h": "ff03c0be92dcc482247a58a0be54d2dc5c270325b51ceeed37afc2ffa5480b1f",
+ "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04",
+ "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc",
+ "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421",
+ "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d",
+ "prototypes/connections/store.c": "44702e9414c26d1ee336b2e36e6713560013c2c8d0091a8900042e2468f88015",
+ "prototypes/connections/store.h": "e3cf5f169be02e40f1274105046f29a63a46cd153beaf204d33d6100b9c869b8",
+ "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f",
+ "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6"
+ },
+ "scalar_archive_sha256": "5ed7f2793d136aa79945c3115bac196ab2c97c1d5ef096bc2f3aba04bc9d13f4",
+ "scalar_archive_size": " text\t data\t bss\t dec\t hex\tfilename\n 33020\t 0\t 0\t 33020\t 80fc\tprototypes/workbench/build/cache/tmp/ad67c6128f027364/general_zcu.o (ex /tmp/pure-native-parity-r76i5q/general.a)\n",
+ "scalar_archive_undefined": "\nprototypes/workbench/build/cache/tmp/ad67c6128f027364/general_zcu.o:\n U memcpy\n U memset\n"
+}
diff --git a/prototypes/workbench/results/2026-09-24-fixed-capacity/fixed-peer-build.json b/prototypes/workbench/results/2026-09-24-fixed-capacity/fixed-peer-build.json
new file mode 100644
index 0000000..6b86d65
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-fixed-capacity/fixed-peer-build.json
@@ -0,0 +1,52 @@
+{
+ "source": "/home/eunhhu/work/pure/prototypes/workbench/examples/routed-layout.pure",
+ "sourceSha256": "656e81577c250df704ba744b1ea6ec9c987ab3b8ee90c758062ac4beaf195650",
+ "zigVersion": "0.15.2",
+ "zigFlags": [
+ "-O",
+ "ReleaseSafe",
+ "-fno-compiler-rt"
+ ],
+ "scalarProfile": "fixed-definition",
+ "zigRoot": "/home/eunhhu/work/pure/prototypes/workbench/native-fixed.zig",
+ "cFlags": [
+ "-std=c17",
+ "-O2",
+ "-g",
+ "-Wall",
+ "-Wextra",
+ "-Wpedantic",
+ "-Werror"
+ ],
+ "binary": "/home/eunhhu/work/pure/prototypes/workbench/build/native/pure-peer",
+ "binarySha256": "d62916cbaec4e84580127ef79134bd21f0298811be70b9cdd6e29e998bd4d5e0",
+ "librarySha256": "9d030a00ba5d3d24f9e7b4aaf967dc90a4fdad6afeac59b2959a2dac1564570a",
+ "fixedProfileSha256": "adc229822531f3c62f3e4f16821ea21d74047808125e699f1ce03c5bfa7f9199",
+ "preparedSha256": "710503e7ffadf02b309c24a761caade4c5a093750d6801956cc698bfc263fc09",
+ "desktop": false,
+ "desktopFlags": [],
+ "peer": true,
+ "peerFlags": [
+ "-lssl",
+ "-lcrypto"
+ ],
+ "implementationSha256": {
+ "prototypes/workbench/engine.zig": "1a58aa081fcfa0876c5499bbfa2768334641e4bd5a357ae4136003fd1e315b98",
+ "prototypes/workbench/evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "prototypes/workbench/native.zig": "fe6c445cdfb5a4bc27ea9f540f3880c45b65c33098eec761d4398d9a7fc5c9a6",
+ "prototypes/workbench/native-fixed.zig": "29b4cc1c8777e83c0d167ecabd66b6bb8c84dba9273f0c1d9e24e33f872f1125",
+ "prototypes/workbench/native/prepare.mjs": "2d2679ae71088ba8691f29029c85c849a5351cf5e49c9b691c60b56b15ceca0f",
+ "prototypes/workbench/native/scalar.h": "b84a796bde2f45d59505cc0addb5f5afede0a9c4dc58d40a38dd37a8ac68d1fd",
+ "prototypes/workbench/native/plan.h": "2e9bd7e3c2ecc52f79c8938d09d2a696174c44a245e4bf52b70be2ed02cbbb57",
+ "prototypes/workbench/native/app.h": "32c738e06f007894457b6b4369ff75c9c2286737885a04159e257c1dcdfa3729",
+ "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1",
+ "prototypes/workbench/native/peer.c": "7d5865d499a4ba51f1d14e0d286480f605be2476616d28e1150b35ca1f87bf56",
+ "prototypes/workbench/native/peer-wire.c": "8fc3694db7a00a4f4899ca5a42dc2c0d96e39cb7d927c759ee8881901241733a",
+ "prototypes/workbench/native/app.c": "96ab7faf4848b5eaba5a1fb672eec2ea7882b3e554a4cba1adfac9c202a2b4ca",
+ "prototypes/workbench/native/plan.c": "f0bdf8466097cfad54c197120aace8acd5f430170931d575e26807b760ce2173",
+ "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2",
+ "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04",
+ "prototypes/connections/store.c": "44702e9414c26d1ee336b2e36e6713560013c2c8d0091a8900042e2468f88015",
+ "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f"
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-24-fixed-capacity/general-parity-final.json b/prototypes/workbench/results/2026-09-24-fixed-capacity/general-parity-final.json
new file mode 100644
index 0000000..d8013a2
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-fixed-capacity/general-parity-final.json
@@ -0,0 +1,209 @@
+{
+ "kind": "native-instance-and-authored-behavior-correctness-not-performance",
+ "sanitized_c_host": true,
+ "profile": "general-authoring",
+ "general_instance_bytes": 20064,
+ "zig_mode": "ReleaseSafe; Zig object is not ASan-instrumented",
+ "compiler": "cc (Debian 14.2.0-19) 14.2.0",
+ "cases": [
+ {
+ "name": "counter",
+ "commands": 5,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 20064,
+ "fixed_profile_sha256": null,
+ "fresh_process_restore": true,
+ "source": "# Space or the tap control increments a state that is displayed and drawn.\nstate count = 0\ninput tap = Space\ntext label(80, 90, count, \"Count: \")\nbox tile(80, 150, 40 + min(count, 20) * 8, 60, \"#356548\")\non tap.down {\n count = count + 1\n}\n",
+ "source_sha256": "53cb3a93ff0f26a3d208a5876bfff9ecb1c39e9a01acc101566727733ba166a8",
+ "prepared_sha256": "eada952dee9afd7071370215364b2a6717a28f03e0513cdd0cae4695939ec52b",
+ "native_binary_sha256": "ed239ce3033b402d454bc11752dd1c9559216acdda8c45ccefad1660ea482b45",
+ "native_binary_bytes": 591904
+ },
+ {
+ "name": "toggle",
+ "commands": 28,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 20064,
+ "fixed_profile_sha256": null,
+ "fresh_process_restore": true,
+ "source": "# Space toggles the circle. Right arrow moves the tile.\nstate visible = 1\nstate position = 120\ninput toggle = Space\ninput move = ArrowRight\ncircle marker(position, 140, visible * 24, \"#c78638\")\nbox tile(position - 24, 190, 48, 48, \"#356548\")\ntext value(80, 80, visible, \"Visible: \")\non toggle.down {\n visible = !visible\n}\non move.down {\n position = position + 40\n if position > 560 {\n position = 80\n }\n}\n",
+ "source_sha256": "3de97fab9d316c17ba6a0c82a16cccd3baafc6816b879bf9b0a174210c8a1915",
+ "prepared_sha256": "fc96ee38b256117a7bfe2859b7126e22262c19e96047a8d01fbe30d4d1a28764",
+ "native_binary_sha256": "aee4b4660bcf74176ca4ef3e83139e6a8d62af14ba35f6ec12cfd63982c7e388",
+ "native_binary_bytes": 592288
+ },
+ {
+ "name": "charge",
+ "commands": 104,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 20064,
+ "fixed_profile_sha256": null,
+ "fresh_process_restore": true,
+ "source": "# All holding, charging, gravity, jumping, and floor behavior is authored here.\nstate holding = 0\nstate charge = 0\nstate y = 240\nstate velocity = 0\ninput jump = Space\nbox floor(40, 261, 560, 4, \"#356548\")\ncircle actor(180, y, 20, \"#c78638\")\nbox meter(80, 80, charge * 200, 12, \"#c78638\")\ntext amount(80, 60, charge, \"Charge: \")\non jump.down {\n if y >= 240 {\n holding = 1\n charge = 0\n }\n}\non jump.up {\n if holding {\n velocity = -(160 + charge * 300)\n holding = 0\n charge = 0\n }\n}\non frame {\n if holding {\n charge = clamp(charge + dt, 0, 1)\n }\n velocity = velocity + 600 * dt\n y = y + velocity * dt\n if y >= 240 {\n y = 240\n velocity = 0\n }\n}\n",
+ "source_sha256": "8d69d9fd51d44ab754d560eb7ff5102ea8a34540753cd7170b3bd800e4294f56",
+ "prepared_sha256": "7ba5fa094b0bc772202bb44a62b3c8377c4f3924db246ec429c2faee4948b09f",
+ "native_binary_sha256": "ed9d13c86fafa6ac9a1d80177fd2c702b38cc374796df698143ca3c7eea687d1",
+ "native_binary_bytes": 592352
+ },
+ {
+ "name": "direct",
+ "commands": 3,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 20064,
+ "fixed_profile_sha256": null,
+ "fresh_process_restore": true,
+ "source": "# 도형을 잡은 위치를 유지해 이동합니다. 취소하면 잡기 전 위치로 돌아갑니다.\nstate x = 80\nstate y = 160\nstate grab_x = 0\nstate grab_y = 0\nstate start_x = 80\nstate start_y = 160\nstate dragging = 0\n\ninput drag = pointer(tile)\ninput left = ArrowLeft\ninput right = ArrowRight\n\nbox left_area(20, 100, 280, 220, \"#233a31\")\nbox right_area(340, 100, 280, 220, \"#343b25\")\nbox tile(x, y, 56, 44, \"#c9f08b\")\ntext horizontal(30, 45, x, \"x: \")\ntext vertical(180, 45, y, \"y: \")\ntext region(350, 70, x >= 320, \"오른쪽 영역: \")\ntext active(30, 75, dragging, \"잡고 있음: \")\n\non drag.down {\n start_x = x\n start_y = y\n grab_x = event_x - x\n grab_y = event_y - y\n dragging = 1\n}\non drag.move {\n x = clamp(event_x - grab_x, 20, 564)\n y = clamp(event_y - grab_y, 100, 276)\n}\non drag.up {\n # 놓기는 마지막으로 표시한 위치를 확정합니다.\n dragging = 0\n}\non drag.cancel {\n x = start_x\n y = start_y\n dragging = 0\n}\non left.down {\n if dragging == 0 { x = max(20, x - 10) }\n}\non right.down {\n if dragging == 0 { x = min(564, x + 10) }\n}\n",
+ "source_sha256": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a",
+ "prepared_sha256": "27f0aa76eb3e12e289bcd6edf6c2ae72892a2dfe1ff094b619671f0583e2c8d3",
+ "native_binary_sha256": "d536134e2997232b19d7672862b9ca9016da9f0d3554daf899a930b4aeccf9ec",
+ "native_binary_bytes": 593120
+ },
+ {
+ "name": "routed",
+ "commands": 8,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 20064,
+ "fixed_profile_sha256": null,
+ "fresh_process_restore": true,
+ "source": "# 하나의 포인터 입력과 네 반응을 모든 도형이 공유합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag = pointer {\n green_box: green\n blue_box: blue\n amber_box: amber\n}\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag.down use grab(drag.target)\non drag.move use move(drag.target)\non drag.up use drop(drag.target)\non drag.cancel use cancel(drag.target)\n\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n",
+ "source_sha256": "656e81577c250df704ba744b1ea6ec9c987ab3b8ee90c758062ac4beaf195650",
+ "prepared_sha256": "710503e7ffadf02b309c24a761caade4c5a093750d6801956cc698bfc263fc09",
+ "native_binary_sha256": "cabd1b99761af718c687068c53bb44879bf7f2b7654c32d8fb265f2714e4c24d",
+ "native_binary_bytes": 594272
+ },
+ {
+ "name": "layout",
+ "commands": 7,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 20064,
+ "fixed_profile_sha256": null,
+ "fresh_process_restore": true,
+ "source": "# 세 도형을 배치하고 배치 저장으로 결과를 보관하세요.\n# 마지막으로 잡은 도형은 방향키로 10씩 이동합니다.\nstate selected = 0\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\nstate x1 = 80\nstate y1 = 120\nstate ox1 = 0\nstate oy1 = 0\nstate sx1 = 80\nstate sy1 = 120\nstate held1 = 0\ninput drag1 = pointer(tile1)\nstate x2 = 280\nstate y2 = 160\nstate ox2 = 0\nstate oy2 = 0\nstate sx2 = 280\nstate sy2 = 160\nstate held2 = 0\ninput drag2 = pointer(tile2)\nstate x3 = 450\nstate y3 = 220\nstate ox3 = 0\nstate oy3 = 0\nstate sx3 = 450\nstate sy3 = 220\nstate held3 = 0\ninput drag3 = pointer(tile3)\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox tile1(x1,y1,56,44,\"#c9f08b\")\nbox tile2(x2,y2,56,44,\"#74c4d4\")\nbox tile3(x3,y3,56,44,\"#dfb184\")\non drag1.down {\n selected = 1\n sx1 = x1; sy1 = y1\n ox1 = event_x - x1; oy1 = event_y - y1\n held1 = 1\n}\non drag1.move {\n x1 = clamp(event_x - ox1, 20, 564)\n y1 = clamp(event_y - oy1, 90, 286)\n}\non drag1.up { held1 = 0 }\non drag1.cancel { x1 = sx1; y1 = sy1; held1 = 0 }\non drag2.down {\n selected = 2\n sx2 = x2; sy2 = y2\n ox2 = event_x - x2; oy2 = event_y - y2\n held2 = 1\n}\non drag2.move {\n x2 = clamp(event_x - ox2, 20, 564)\n y2 = clamp(event_y - oy2, 90, 286)\n}\non drag2.up { held2 = 0 }\non drag2.cancel { x2 = sx2; y2 = sy2; held2 = 0 }\non drag3.down {\n selected = 3\n sx3 = x3; sy3 = y3\n ox3 = event_x - x3; oy3 = event_y - y3\n held3 = 1\n}\non drag3.move {\n x3 = clamp(event_x - ox3, 20, 564)\n y3 = clamp(event_y - oy3, 90, 286)\n}\non drag3.up { held3 = 0 }\non drag3.cancel { x3 = sx3; y3 = sy3; held3 = 0 }\non left.down {\n if selected == 1 && held1 == 0 { x1 = clamp(x1 + -10, 20, 564) }\n if selected == 2 && held2 == 0 { x2 = clamp(x2 + -10, 20, 564) }\n if selected == 3 && held3 == 0 { x3 = clamp(x3 + -10, 20, 564) }\n}\non right.down {\n if selected == 1 && held1 == 0 { x1 = clamp(x1 + 10, 20, 564) }\n if selected == 2 && held2 == 0 { x2 = clamp(x2 + 10, 20, 564) }\n if selected == 3 && held3 == 0 { x3 = clamp(x3 + 10, 20, 564) }\n}\non up.down {\n if selected == 1 && held1 == 0 { y1 = clamp(y1 + -10, 90, 286) }\n if selected == 2 && held2 == 0 { y2 = clamp(y2 + -10, 90, 286) }\n if selected == 3 && held3 == 0 { y3 = clamp(y3 + -10, 90, 286) }\n}\non down.down {\n if selected == 1 && held1 == 0 { y1 = clamp(y1 + 10, 90, 286) }\n if selected == 2 && held2 == 0 { y2 = clamp(y2 + 10, 90, 286) }\n if selected == 3 && held3 == 0 { y3 = clamp(y3 + 10, 90, 286) }\n}\n",
+ "source_sha256": "cebd9c3485a2be4ec16ae665c1ed4ee200d51fc56ad0151306c8e9c31138f346",
+ "prepared_sha256": "ed2fb846200749b500e3349b324bd30778a6938e867abd715b35f661f1d06e81",
+ "native_binary_sha256": "03c3f8a4d67da292dfe632758a687c22873bd852e2ef6e2986c110a0b26e26eb",
+ "native_binary_bytes": 594240
+ },
+ {
+ "name": "shared-layout",
+ "commands": 7,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 20064,
+ "fixed_profile_sha256": null,
+ "fresh_process_restore": true,
+ "source": "# 대상별 값은 item에, 공통 반응은 rule에 한 번만 작성합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag_green = pointer(green_box)\ninput drag_blue = pointer(blue_box)\ninput drag_amber = pointer(amber_box)\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag_green.down use grab(green)\non drag_green.move use move(green)\non drag_green.up use drop(green)\non drag_green.cancel use cancel(green)\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non drag_blue.down use grab(blue)\non drag_blue.move use move(blue)\non drag_blue.up use drop(blue)\non drag_blue.cancel use cancel(blue)\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non drag_amber.down use grab(amber)\non drag_amber.move use move(amber)\non drag_amber.up use drop(amber)\non drag_amber.cancel use cancel(amber)\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n",
+ "source_sha256": "be8296244b768f9adda99e7afc89249f34853fa27124a277aecb0841eb8176eb",
+ "prepared_sha256": "aed8c20aff78e0322434b3b6f9893f39084444d209337fef9345cc182965790d",
+ "native_binary_sha256": "c335d630de485cc699a26409280408b9f025dc55321b1ff2ba7ff4f5a6ec71c0",
+ "native_binary_bytes": 594416
+ },
+ {
+ "name": "document",
+ "commands": 5,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 20064,
+ "fixed_profile_sha256": null,
+ "fresh_process_restore": true,
+ "source": "# 웹 컨트롤이 요청한 분할 비율을 받습니다. 레이아웃과 문서는 브라우저가 소유합니다.\nstate ratio = 50\ninput split = value\ninput reset = KeyR\non split.change { ratio = clamp(event_value, 20, 80) }\non reset.down { ratio = 50 }\n",
+ "source_sha256": "057d2e665d72dd3fce7ac59d232a65e9e0e1a6ca1f8c1e1b36ae766e480367d1",
+ "prepared_sha256": "c74315c1b0067e167a4678b30505d1770b0753b601e8acee27f6d598ac7b9ffb",
+ "native_binary_sha256": "aca33b051685a745ee3d838c18dc581a15537f3aa9e5b46031b9410555d55b9c",
+ "native_binary_bytes": 591584
+ },
+ {
+ "name": "atomic-error",
+ "commands": 2,
+ "failed_events": 1,
+ "states_and_views_match": true,
+ "instance_bytes": 20064,
+ "fixed_profile_sha256": null,
+ "fresh_process_restore": true,
+ "source": "state x=7\nstate y=2\ninput n=value\non n.change {x=event_value; y=1/event_value}\ntext v(1,2,y,\"value: \")",
+ "source_sha256": "61a5478e7147ef281d617017910af36ad1bae3dabc97c03c8569c5d8b0e5fe19",
+ "prepared_sha256": "6a415d33780227158ebeae2a75c7520bf13bad3c0a6fef7ee82f5c323be226f0",
+ "native_binary_sha256": "e0d924e1d1fe5a6c7d4524443c97bb28199d8abf7d165f96a07b7235f97c14b6",
+ "native_binary_bytes": 591728
+ },
+ {
+ "name": "blank",
+ "commands": 0,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 20064,
+ "fixed_profile_sha256": null,
+ "fresh_process_restore": true,
+ "source": "",
+ "source_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "prepared_sha256": "1c8f5ade56ee8ec931a62fd1f68ef3635b17297d3a54b20c954860c4a43b6155",
+ "native_binary_sha256": "0702180c9dd146f07811f5a95c1ff880039f0445666afa0401125ed358c613a1",
+ "native_binary_bytes": 591264
+ },
+ {
+ "name": "literal-boundary",
+ "commands": 1,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 20064,
+ "fixed_profile_sha256": null,
+ "fresh_process_restore": true,
+ "source": "state x=-0\ninput n=value\non n.change {x=event_value}\ntext v(1,2,x,\"한글 \\\" \\\\ \\n\")",
+ "source_sha256": "7f480fa50bbddaf4e3a42bbd2132701e2091ef12b499e30c1bdda1dcd947dcc1",
+ "prepared_sha256": "e9a8b1542ecf6b008ad30188cb9e95d29638fab1c38d331c00bf706c40a06d3c",
+ "native_binary_sha256": "2bb2bcd328bf95ae473b6081a33c5bfd10f03398933d13b95cca429efd5afcd4",
+ "native_binary_bytes": 591664
+ }
+ ],
+ "units": {
+ "native_instances": 3,
+ "checks": "independent-state-staging-inputs-errors-budget-snapshot-conflict-restore-plan-reload",
+ "scalar_instance_bytes": 20064,
+ "scalar_alignment": 8,
+ "app_controller_bytes": 928,
+ "action_bytes": 32,
+ "snapshot_bytes": 244,
+ "all_work_drained": true
+ },
+ "source_sha256": {
+ "prototypes/workbench/core.zig": "ea237848df0dbd2ce620c2874e64bc5ecbe569bf77f3737e9a4fe1e64fea40cc",
+ "prototypes/workbench/engine.zig": "1a58aa081fcfa0876c5499bbfa2768334641e4bd5a357ae4136003fd1e315b98",
+ "prototypes/workbench/evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "prototypes/workbench/native.zig": "fe6c445cdfb5a4bc27ea9f540f3880c45b65c33098eec761d4398d9a7fc5c9a6",
+ "prototypes/workbench/native-fixed.zig": "29b4cc1c8777e83c0d167ecabd66b6bb8c84dba9273f0c1d9e24e33f872f1125",
+ "prototypes/workbench/manage.py": "e0cf9f9eb65bb8b8830797bf4f533ea2cd86045d401fef444e86fad654e4dd8f",
+ "prototypes/workbench/native/scalar.h": "b84a796bde2f45d59505cc0addb5f5afede0a9c4dc58d40a38dd37a8ac68d1fd",
+ "prototypes/workbench/native/plan.h": "2e9bd7e3c2ecc52f79c8938d09d2a696174c44a245e4bf52b70be2ed02cbbb57",
+ "prototypes/workbench/native/plan.c": "f0bdf8466097cfad54c197120aace8acd5f430170931d575e26807b760ce2173",
+ "prototypes/workbench/native/app.h": "32c738e06f007894457b6b4369ff75c9c2286737885a04159e257c1dcdfa3729",
+ "prototypes/workbench/native/app.c": "96ab7faf4848b5eaba5a1fb672eec2ea7882b3e554a4cba1adfac9c202a2b4ca",
+ "prototypes/workbench/native/main.c": "dd28168741e79ffaa73cf74e60b974a1ca84b0473a43aafe089019f61e732aab",
+ "prototypes/workbench/native/prepare.mjs": "2d2679ae71088ba8691f29029c85c849a5351cf5e49c9b691c60b56b15ceca0f",
+ "prototypes/workbench/native/test.c": "47488a2cfda2b378637f69b8f0d94f75e615d784e8418167b5b82c8ce141f7e8",
+ "prototypes/workbench/native/test-fixed.c": "725d109417ee6ca2b680b9b8ba82f604985083fe6173a6e7269f40d40eb7d18a",
+ "prototypes/workbench/native/check.mjs": "794ee0d44395c973d3ca2a7213dcd564733f291f0d22358e914815e41bdf3958",
+ "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1",
+ "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54",
+ "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35",
+ "prototypes/workbench/web/core.wasm": "8fe2f501cb9639fea0817aa57e0b7292ca6779cda4ac156b7c6b3df31d7921e9",
+ "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2",
+ "prototypes/connections/runtime.h": "ff03c0be92dcc482247a58a0be54d2dc5c270325b51ceeed37afc2ffa5480b1f",
+ "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04",
+ "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc",
+ "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421",
+ "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d",
+ "prototypes/connections/store.c": "44702e9414c26d1ee336b2e36e6713560013c2c8d0091a8900042e2468f88015",
+ "prototypes/connections/store.h": "e3cf5f169be02e40f1274105046f29a63a46cd153beaf204d33d6100b9c869b8",
+ "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f",
+ "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6"
+ },
+ "scalar_archive_sha256": "83834892d7b67b06aa30fb61dbca3c6bad89fa3064644269880d8c7c3e3286f7",
+ "scalar_archive_size": " text\t data\t bss\t dec\t hex\tfilename\n 33020\t 0\t 0\t 33020\t 80fc\tprototypes/workbench/build/cache/tmp/2d18d0109be25ab7/general_zcu.o (ex /tmp/pure-native-parity-5YxKyV/general.a)\n",
+ "scalar_archive_undefined": "\nprototypes/workbench/build/cache/tmp/2d18d0109be25ab7/general_zcu.o:\n U memcpy\n U memset\n"
+}
diff --git a/prototypes/workbench/results/2026-09-24-fixed-capacity/instance-memory.json b/prototypes/workbench/results/2026-09-24-fixed-capacity/instance-memory.json
new file mode 100644
index 0000000..33be16a
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-fixed-capacity/instance-memory.json
@@ -0,0 +1,65 @@
+{
+ "host": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41",
+ "samples": {
+ "general": [
+ {
+ "instances": 1000,
+ "instance_bytes": 20064,
+ "rss_before_kib": 1248,
+ "rss_after_kib": 21056,
+ "rss_delta_kib": 19808,
+ "checksum": 1000
+ },
+ {
+ "instances": 1000,
+ "instance_bytes": 20064,
+ "rss_before_kib": 1232,
+ "rss_after_kib": 21040,
+ "rss_delta_kib": 19808,
+ "checksum": 1000
+ },
+ {
+ "instances": 1000,
+ "instance_bytes": 20064,
+ "rss_before_kib": 1248,
+ "rss_after_kib": 21056,
+ "rss_delta_kib": 19808,
+ "checksum": 1000
+ }
+ ],
+ "fixed": [
+ {
+ "instances": 1000,
+ "instance_bytes": 5456,
+ "rss_before_kib": 1232,
+ "rss_after_kib": 6768,
+ "rss_delta_kib": 5536,
+ "checksum": 1000
+ },
+ {
+ "instances": 1000,
+ "instance_bytes": 5456,
+ "rss_before_kib": 1232,
+ "rss_after_kib": 6768,
+ "rss_delta_kib": 5536,
+ "checksum": 1000
+ },
+ {
+ "instances": 1000,
+ "instance_bytes": 5456,
+ "rss_before_kib": 1232,
+ "rss_after_kib": 6768,
+ "rss_delta_kib": 5536,
+ "checksum": 1000
+ }
+ ]
+ },
+ "binary_sha256": {
+ "general": "bb9b95ab306e0bb252c793055717409d3726729c408e73dd652d58cf4cb10b03",
+ "fixed": "faed0a646207e63d466f0afe78b655bfa875695aa3e45dc9e0d0d575eaa97768"
+ },
+ "median_rss_delta_kib": {
+ "general": 19808,
+ "fixed": 5536
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-24-fixed-capacity/peer-check.json b/prototypes/workbench/results/2026-09-24-fixed-capacity/peer-check.json
new file mode 100644
index 0000000..301ba16
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-fixed-capacity/peer-check.json
@@ -0,0 +1 @@
+{"alpn": "pass", "disconnect_release": "pass", "idle_deadline": "pass", "malformed_frames": "pass", "mutual_authentication": "pass", "persist_restart": "pass", "python_client": "pass", "revision_conflict": "pass", "server_connected_rss_kib": 8464, "server_hostname": "pass", "server_idle_rss_kib": 7888}
diff --git a/prototypes/workbench/results/2026-09-24-fixed-capacity/size.txt b/prototypes/workbench/results/2026-09-24-fixed-capacity/size.txt
new file mode 100644
index 0000000..42841eb
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-fixed-capacity/size.txt
@@ -0,0 +1,6 @@
+ text data bss dec hex filename
+ 33020 0 0 33020 80fc prototypes/workbench/build/cache/tmp/13dbb99515d73cd6/pure-general-scalar_zcu.o (ex /tmp/pure-general-scalar.a)
+ 18252 0 0 18252 474c prototypes/workbench/build/cache/tmp/5e96d4ff68ad7b63/libpure-scalar-fixed_zcu.o (ex prototypes/workbench/build/native/libpure-scalar-fixed.a)
+ 62827 2040 16 64883 fd73 /tmp/pure-native-general
+ 48059 2040 16 50115 c3c3 prototypes/workbench/build/native/pure-native
+ 61201 2472 16 63689 f8c9 prototypes/workbench/build/native/pure-peer
diff --git a/prototypes/workbench/results/2026-09-24-fixed-capacity/stripped.txt b/prototypes/workbench/results/2026-09-24-fixed-capacity/stripped.txt
new file mode 100644
index 0000000..1897476
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-fixed-capacity/stripped.txt
@@ -0,0 +1,3 @@
+/tmp/pure-native-general-stripped 68000
+/tmp/pure-native-fixed-stripped 68000
+/tmp/pure-peer-fixed-stripped 68400
diff --git a/prototypes/workbench/results/2026-09-24-idle-work-binding/app-memory.json b/prototypes/workbench/results/2026-09-24-idle-work-binding/app-memory.json
new file mode 100644
index 0000000..14875e6
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-idle-work-binding/app-memory.json
@@ -0,0 +1,108 @@
+{
+ "profile": "whole-native-app-idle-work-storage",
+ "prepared_sha256": "b7524e53a5d90d1ce16d2e2fbaac67ceb242f1cd27f280afc3a80857ad8c21ba",
+ "scalar_archive_sha256": "30a015c55d385b337639b54b2f31aea34b40bc37d1fe6c9e3c884660d26e4cbc",
+ "harness_sha256": "f6d6dde36c00847552e8a8f0c98fab700e33c7634cddd363202a8891424d789e",
+ "checker_sha256": "0360c8e60f769da76f2e6352ab11b753213c61a1f7b4d85660c6bb87d673eabe",
+ "baseline": {
+ "binary_sha256": "d76598743f43ebba0c11bbb70af7a2e24cc07172c794320bdeac0d35aac44f06",
+ "app_source_sha256": "96ab7faf4848b5eaba5a1fb672eec2ea7882b3e554a4cba1adfac9c202a2b4ca",
+ "app_header_sha256": "32c738e06f007894457b6b4369ff75c9c2286737885a04159e257c1dcdfa3729",
+ "text_bytes": 35682,
+ "data_bytes": 1968,
+ "app_controller_bytes": 928,
+ "scalar_bytes": 280,
+ "samples": [
+ {
+ "apps": 1000,
+ "app_controller_bytes": 928,
+ "scalar_bytes": 280,
+ "rss_before_kib": 1392,
+ "rss_initialized_kib": 2656,
+ "rss_saved_kib": 2720,
+ "rss_initialized_delta_kib": 1264,
+ "rss_saved_delta_kib": 1328,
+ "accepted": 3010,
+ "releases": 3010
+ },
+ {
+ "apps": 1000,
+ "app_controller_bytes": 928,
+ "scalar_bytes": 280,
+ "rss_before_kib": 1376,
+ "rss_initialized_kib": 2640,
+ "rss_saved_kib": 2704,
+ "rss_initialized_delta_kib": 1264,
+ "rss_saved_delta_kib": 1328,
+ "accepted": 3010,
+ "releases": 3010
+ },
+ {
+ "apps": 1000,
+ "app_controller_bytes": 928,
+ "scalar_bytes": 280,
+ "rss_before_kib": 1376,
+ "rss_initialized_kib": 2640,
+ "rss_saved_kib": 2704,
+ "rss_initialized_delta_kib": 1264,
+ "rss_saved_delta_kib": 1328,
+ "accepted": 3010,
+ "releases": 3010
+ }
+ ],
+ "median_initialized_delta_kib": 1264,
+ "median_saved_delta_kib": 1328
+ },
+ "elastic": {
+ "binary_sha256": "a5d5f4d3344dcaa9a206261f8a423f39836afa3b3302933873ae5981383c95ef",
+ "app_source_sha256": "cf19cbf7d7c60020b13198e9335e519f650633f1eb3fb25b61953ff56c3e4dd8",
+ "app_header_sha256": "622ec62cfb5f755fe0fb2582f5e1ce264172e8a45663078429afb910061e21a4",
+ "text_bytes": 35946,
+ "data_bytes": 1968,
+ "app_controller_bytes": 480,
+ "scalar_bytes": 280,
+ "samples": [
+ {
+ "apps": 1000,
+ "app_controller_bytes": 480,
+ "scalar_bytes": 280,
+ "rss_before_kib": 1392,
+ "rss_initialized_kib": 2224,
+ "rss_saved_kib": 2288,
+ "rss_initialized_delta_kib": 832,
+ "rss_saved_delta_kib": 896,
+ "accepted": 3010,
+ "releases": 3010
+ },
+ {
+ "apps": 1000,
+ "app_controller_bytes": 480,
+ "scalar_bytes": 280,
+ "rss_before_kib": 1376,
+ "rss_initialized_kib": 2208,
+ "rss_saved_kib": 2272,
+ "rss_initialized_delta_kib": 832,
+ "rss_saved_delta_kib": 896,
+ "accepted": 3010,
+ "releases": 3010
+ },
+ {
+ "apps": 1000,
+ "app_controller_bytes": 480,
+ "scalar_bytes": 280,
+ "rss_before_kib": 1392,
+ "rss_initialized_kib": 2224,
+ "rss_saved_kib": 2288,
+ "rss_initialized_delta_kib": 832,
+ "rss_saved_delta_kib": 896,
+ "accepted": 3010,
+ "releases": 3010
+ }
+ ],
+ "median_initialized_delta_kib": 832,
+ "median_saved_delta_kib": 896
+ },
+ "controller_saved_per_app_bytes": 448,
+ "initialized_rss_median_saved_kib": 432,
+ "saved_rss_median_saved_kib": 432
+}
diff --git a/prototypes/workbench/results/2026-09-24-idle-work-binding/native-sanitized.json b/prototypes/workbench/results/2026-09-24-idle-work-binding/native-sanitized.json
new file mode 100644
index 0000000..6a0da13
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-idle-work-binding/native-sanitized.json
@@ -0,0 +1 @@
+{"native_instances":3,"checks":"independent-state-staging-inputs-errors-budget-snapshot-conflict-restore-plan-reload","scalar_instance_bytes":20064,"scalar_alignment":8,"app_controller_bytes":480,"action_bytes":32,"snapshot_bytes":244,"all_work_drained":true}
diff --git a/prototypes/workbench/results/2026-09-24-idle-work-binding/native-unit.json b/prototypes/workbench/results/2026-09-24-idle-work-binding/native-unit.json
new file mode 100644
index 0000000..6a0da13
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-idle-work-binding/native-unit.json
@@ -0,0 +1 @@
+{"native_instances":3,"checks":"independent-state-staging-inputs-errors-budget-snapshot-conflict-restore-plan-reload","scalar_instance_bytes":20064,"scalar_alignment":8,"app_controller_bytes":480,"action_bytes":32,"snapshot_bytes":244,"all_work_drained":true}
diff --git a/prototypes/workbench/results/2026-09-24-idle-work-binding/scalar-peer.json b/prototypes/workbench/results/2026-09-24-idle-work-binding/scalar-peer.json
new file mode 100644
index 0000000..5f48783
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-idle-work-binding/scalar-peer.json
@@ -0,0 +1 @@
+{"alpn": "pass", "disconnect_release": "pass", "idle_deadline": "pass", "malformed_frames": "pass", "mutual_authentication": "pass", "persist_restart": "pass", "python_client": "pass", "revision_conflict": "pass", "server_connected_rss_kib": 8624, "server_hostname": "pass", "server_idle_rss_kib": 7952}
diff --git a/prototypes/workbench/results/2026-09-24-idle-work-binding/stack-baseline.su b/prototypes/workbench/results/2026-09-24-idle-work-binding/stack-baseline.su
new file mode 100644
index 0000000..60579da
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-idle-work-binding/stack-baseline.su
@@ -0,0 +1,9 @@
+prototypes/workbench/results/2026-09-24-native-desktop/sources/native/app.c:16:13:allocate 32 static
+prototypes/workbench/results/2026-09-24-native-desktop/sources/native/app.c:33:19:read_value 176 static
+prototypes/workbench/results/2026-09-24-native-desktop/sources/native/app.c:15:13:release 0 static
+prototypes/workbench/results/2026-09-24-native-desktop/sources/native/app.c:121:19:write_value 336 static
+prototypes/workbench/results/2026-09-24-native-desktop/sources/native/app.c:203:20:transfer 288 static
+prototypes/workbench/results/2026-09-24-native-desktop/sources/native/app.c:167:12:pure_native_app_init 176 static
+prototypes/workbench/results/2026-09-24-native-desktop/sources/native/app.c:192:13:pure_native_app_action 208 static
+prototypes/workbench/results/2026-09-24-native-desktop/sources/native/app.c:222:13:pure_native_app_save 16 static
+prototypes/workbench/results/2026-09-24-native-desktop/sources/native/app.c:223:13:pure_native_app_restore 16 static
diff --git a/prototypes/workbench/results/2026-09-24-idle-work-binding/stack-elastic.su b/prototypes/workbench/results/2026-09-24-idle-work-binding/stack-elastic.su
new file mode 100644
index 0000000..265d96f
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-idle-work-binding/stack-elastic.su
@@ -0,0 +1,9 @@
+prototypes/workbench/native/app.c:16:13:allocate 32 static
+prototypes/workbench/native/app.c:33:19:read_value 176 static
+prototypes/workbench/native/app.c:15:13:release 0 static
+prototypes/workbench/native/app.c:121:19:write_value 336 static
+prototypes/workbench/native/app.c:210:20:transfer 736 static
+prototypes/workbench/native/app.c:167:12:pure_native_app_init 176 static
+prototypes/workbench/native/app.c:192:13:pure_native_app_action 432 static
+prototypes/workbench/native/app.c:235:13:pure_native_app_save 16 static
+prototypes/workbench/native/app.c:236:13:pure_native_app_restore 16 static
diff --git a/prototypes/workbench/results/2026-09-24-native-desktop/README.md b/prototypes/workbench/results/2026-09-24-native-desktop/README.md
new file mode 100644
index 0000000..4ed851e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-native-desktop/README.md
@@ -0,0 +1,38 @@
+# Linux 네이티브 작성 창 검증 — 2026-09-24
+
+이 폴더는 성능 벤치마크가 아니라 실제 GTK 창의 동작, 실행 전용 비용과 선택형 편집기 비용의
+구분을 기록한다. 환경·바이너리 해시·섹션 크기·프로세스 메모리 관찰 값은 `manifest.json`에,
+재실행 가능한 네이티브/Wasm 동작 비교와 C 호스트 sanitizer 결과는 `native-parity*.json`에 있다.
+`core-budget.json`은 기존 6 KiB 코어 한도 재검사다. `sources/`는 검증 당시 변경 파일 사본이다.
+
+Xvfb 1400×800×24, `GTK_A11Y=none GDK_BACKEND=x11 GSK_RENDERER=cairo GDK_DISABLE=gl,vulkan`에서
+GTK 창을 열었다. `xdotool`로 다음을 실제 창에 보냈다: 도형 누름·이동·놓기, 저장, `GtkTextView`
+끝에 새 `text` 출력 추가와 적용, 잘못된 선언 적용 및 수정, 다시 저장, 900×600으로 크기 변경.
+새 CLI 프로세스에서 `load` 결과를 확인하고, 새 창에서도 수정된 소스와 저장 상태를 복원했다.
+수치 입력은 별도 `document.pure` 창에서 99를 넣어 작성 규칙의 저장 결과 80을 확인했다.
+
+화면 자료:
+
+- `drag-and-save.png`: 놓은 위치와 저장 성공.
+- `rejected-edit.png`: 잘못된 선언을 거절하고 직전 실행 화면을 유지.
+- `recovered-edit.png`: 소스 복구 후 새 정의의 상태를 저장.
+- `resized.png`, `resized-input.png`: 축소 후 화면 및 별도 드래그·키 입력.
+- `reopened-state.png`: 새 프로세스의 새 정의·상태 복원.
+- `numeric-input.png`: native 값 입력 및 상태 저장.
+
+화면은 `/tmp`에서 빌드한 `capture-x11.c`로 X 서버의 실제 픽셀을 PNG로 기록했다.
+최종 작성 소스는 `authored-after.pure`에 있다. 프로세스 RSS/PSS는 작업 완료 후
+`/proc/<pid>/status`와 `smaps_rollup`에서 관찰했다. 컴파일러 자식 프로세스·Xvfb·운영체제
+전체 메모리를 합친 값이 아니며, macOS의 비용도 아니다.
+
+선택 빌드 확인:
+
+```sh
+python3 prototypes/workbench/manage.py native prototypes/workbench/examples/routed-layout.pure --zig /path/to/zig
+python3 prototypes/workbench/manage.py native prototypes/workbench/examples/routed-layout.pure --desktop --zig /path/to/zig
+node prototypes/workbench/native/check.mjs
+node prototypes/workbench/native/check.mjs --sanitize
+```
+
+CLI의 직접 공유 라이브러리 의존성은 libc 하나였다. GTK 작성 창에는 GTK/Pango/Cairo/GLib가
+추가된다. 문서의 해석과 남은 범위는 [025 보고서](../../../../docs/research/025-native-authoring-shell.md)에 있다.
diff --git a/prototypes/workbench/results/2026-09-24-native-desktop/authored-after.pure b/prototypes/workbench/results/2026-09-24-native-desktop/authored-after.pure
new file mode 100644
index 0000000..8065362
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-native-desktop/authored-after.pure
@@ -0,0 +1,72 @@
+# 하나의 포인터 입력과 네 반응을 모든 도형이 공유합니다.
+state selected = 0
+record Tile {
+ x = 0; y = 0
+ ox = 0; oy = 0
+ sx = 0; sy = 0
+ held = 0; id = 0
+}
+item green = Tile(x=80, y=120, sx=80, sy=120, id=1)
+item blue = Tile(x=280, y=160, sx=280, sy=160, id=2)
+item amber = Tile(x=450, y=220, sx=450, sy=220, id=3)
+input left = ArrowLeft
+input right = ArrowRight
+input up = ArrowUp
+input down = ArrowDown
+input drag = pointer {
+ green_box: green
+ blue_box: blue
+ amber_box: amber
+}
+box board(20,90,600,240,"#233a31")
+text help(25,40,selected,"선택한 도형: ")
+box green_box(green.x,green.y,56,44,"#c9f08b")
+box blue_box(blue.x,blue.y,56,44,"#74c4d4")
+box amber_box(amber.x,amber.y,56,44,"#dfb184")
+
+rule grab(t: Tile) {
+ selected = t.id
+ t.sx = t.x; t.sy = t.y
+ t.ox = event_x - t.x; t.oy = event_y - t.y
+ t.held = 1
+}
+rule move(t: Tile) {
+ t.x = clamp(event_x - t.ox, 20, 564)
+ t.y = clamp(event_y - t.oy, 90, 286)
+}
+rule drop(t: Tile) { t.held = 0 }
+rule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }
+rule go_left(t: Tile) {
+ if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }
+}
+rule go_right(t: Tile) {
+ if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }
+}
+rule go_up(t: Tile) {
+ if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }
+}
+rule go_down(t: Tile) {
+ if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }
+}
+
+on drag.down use grab(drag.target)
+on drag.move use move(drag.target)
+on drag.up use drop(drag.target)
+on drag.cancel use cancel(drag.target)
+
+on left.down use go_left(green)
+on right.down use go_right(green)
+on up.down use go_up(green)
+on down.down use go_down(green)
+
+on left.down use go_left(blue)
+on right.down use go_right(blue)
+on up.down use go_up(blue)
+on down.down use go_down(blue)
+
+on left.down use go_left(amber)
+on right.down use go_right(amber)
+on up.down use go_up(amber)
+on down.down use go_down(amber)
+
+text live(20,20,selected,"LIVE: " )
diff --git a/prototypes/workbench/results/2026-09-24-native-desktop/capture-x11.c b/prototypes/workbench/results/2026-09-24-native-desktop/capture-x11.c
new file mode 100644
index 0000000..2d03544
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-native-desktop/capture-x11.c
@@ -0,0 +1,43 @@
+#include <X11/Xlib.h>
+#include <X11/Xutil.h>
+#include <png.h>
+#include <stdio.h>
+#include <stdlib.h>
+
+static unsigned char component(unsigned long pixel, unsigned long mask) {
+ if (!mask) return 0;
+ unsigned long shift = __builtin_ctzl(mask);
+ unsigned long bits = mask >> shift;
+ return (unsigned char)(((pixel & mask) >> shift) * 255 / bits);
+}
+int main(int argc, char **argv) {
+ if (argc != 2) return 2;
+ Display *d = XOpenDisplay(NULL);
+ if (!d) return 2;
+ int screen = DefaultScreen(d), width = DisplayWidth(d, screen), height = DisplayHeight(d, screen);
+ XImage *image = XGetImage(d, RootWindow(d, screen), 0, 0, width, height, AllPlanes, ZPixmap);
+ FILE *file = fopen(argv[1], "wb");
+ if (!image || !file) return 2;
+ png_structp png = png_create_write_struct(PNG_LIBPNG_VER_STRING, NULL, NULL, NULL);
+ png_infop info = png_create_info_struct(png);
+ if (setjmp(png_jmpbuf(png))) return 2;
+ png_init_io(png, file);
+ png_set_IHDR(png, info, width, height, 8, PNG_COLOR_TYPE_RGB, PNG_INTERLACE_NONE,
+ PNG_COMPRESSION_TYPE_DEFAULT, PNG_FILTER_TYPE_DEFAULT);
+ png_write_info(png, info);
+ unsigned char *row = malloc((size_t)width * 3);
+ if (!row) return 2;
+ for (int y = 0; y < height; ++y) {
+ for (int x = 0; x < width; ++x) {
+ unsigned long pixel = XGetPixel(image, x, y);
+ row[x * 3] = component(pixel, image->red_mask);
+ row[x * 3 + 1] = component(pixel, image->green_mask);
+ row[x * 3 + 2] = component(pixel, image->blue_mask);
+ }
+ png_write_row(png, row);
+ }
+ png_write_end(png, info);
+ free(row); png_destroy_write_struct(&png, &info);
+ XDestroyImage(image); fclose(file); XCloseDisplay(d);
+ return 0;
+}
diff --git a/prototypes/workbench/results/2026-09-24-native-desktop/core-budget.json b/prototypes/workbench/results/2026-09-24-native-desktop/core-budget.json
new file mode 100644
index 0000000..8c0caf7
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-native-desktop/core-budget.json
@@ -0,0 +1,53 @@
+{
+ "passed": true,
+ "failures": [],
+ "budget": {
+ "profile": "current-storage-execution-core",
+ "max_text_bytes": 6144,
+ "max_data_bytes": 0,
+ "max_bss_bytes": 0,
+ "layout_max_bytes": {
+ "domain_bytes": 200,
+ "work_slot_bytes": 224,
+ "ticket_bytes": 24,
+ "prepared_program_bytes": 680
+ },
+ "archive_members": [
+ "runtime.o",
+ "evaluate.o"
+ ],
+ "external_symbols_allowed": [
+ "memcpy",
+ "memmove",
+ "memset",
+ "strlen"
+ ],
+ "scope": "Release object sections and C bookkeeping, validated with GNU tools on Linux AArch64. Not total RAM, process RSS, stack, linked libraries, parser, storage, network or UI. 6 KiB is a regression ceiling for this profile, not a universal runtime size promise."
+ },
+ "layout": {
+ "domain_bytes": 200,
+ "work_slot_bytes": 224,
+ "ticket_bytes": 24,
+ "prepared_program_bytes": 680,
+ "pointer_bytes": 8
+ },
+ "sections": {
+ "text": 5932,
+ "data": 0,
+ "bss": 0
+ },
+ "members": [
+ "runtime.o",
+ "evaluate.o"
+ ],
+ "external_symbols": [
+ "memcpy",
+ "strlen"
+ ],
+ "size_output": " text\t data\t bss\t dec\t hex\tfilename\n 5012\t 0\t 0\t 5012\t 1394\truntime.o (ex /home/eunhhu/work/pure/prototypes/connections/build/libpure-execution.a)\n 920\t 0\t 0\t 920\t 398\tevaluate.o (ex /home/eunhhu/work/pure/prototypes/connections/build/libpure-execution.a)\n",
+ "archive_sha256": "d56c50e38c6c0949cfad069d34568da2ac45b1d60097cf9e7c4bf804f44c8128",
+ "layout_binary_sha256": "47cf1803007fc1ec85f119bd46195ad39d9f01ef0146bf5cc45e1af31eb18098",
+ "budget_sha256": "b4eceaffce900144470dd29076164039b8729bad4ba849789d212e7c952f5082",
+ "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41",
+ "size_tool": "GNU size (GNU Binutils for Debian) 2.44"
+}
diff --git a/prototypes/workbench/results/2026-09-24-native-desktop/drag-and-save.png b/prototypes/workbench/results/2026-09-24-native-desktop/drag-and-save.png
new file mode 100644
index 0000000..3c1d630
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-native-desktop/drag-and-save.png
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-24-native-desktop/manifest.json b/prototypes/workbench/results/2026-09-24-native-desktop/manifest.json
new file mode 100644
index 0000000..34482d8
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-native-desktop/manifest.json
@@ -0,0 +1,141 @@
+{
+ "kind": "correctness-and-cost-evidence-not-performance-benchmark",
+ "date": "2026-09-24",
+ "host": {
+ "machine": "aarch64",
+ "system": "Linux",
+ "cc": "cc (Debian 14.2.0-19) 14.2.0",
+ "gtk": "4.18.6",
+ "zig": "0.15.2"
+ },
+ "renderer_for_final_qa": "GDK_BACKEND=x11 GSK_RENDERER=cairo GDK_DISABLE=gl,vulkan GTK_A11Y=none, Xvfb 1400x800x24",
+ "sections": {
+ "cli": {
+ "text": 62827,
+ "data": 2040,
+ "bss": 16
+ },
+ "desktop": {
+ "text": 78964,
+ "data": 2824,
+ "bss": 16
+ }
+ },
+ "stripped_elf_bytes": {
+ "cli": 68000,
+ "desktop": 134224
+ },
+ "direct_needed": {
+ "cli": [
+ "libc.so.6"
+ ],
+ "desktop": [
+ "libgtk-4.so.1",
+ "libpangocairo-1.0.so.0",
+ "libpango-1.0.so.0",
+ "libcairo.so.2",
+ "libgio-2.0.so.0",
+ "libgobject-2.0.so.0",
+ "libglib-2.0.so.0",
+ "libc.so.6"
+ ]
+ },
+ "idle_kib_single_observation": {
+ "cli_vmrss": 1552,
+ "desktop_cairo_no_gpu_vmrss": 36512,
+ "desktop_cairo_no_gpu_pss": 23618,
+ "desktop_default_vmrss_other_run": 173808,
+ "desktop_default_pss_other_run": 139710
+ },
+ "observed_restored_state": {
+ "status": 0,
+ "stored_revision": 2,
+ "selected": 1,
+ "green_x": 167.07482993197277,
+ "green_y": 154.82993197278913,
+ "green_held": 0,
+ "new_view_live": 1
+ },
+ "files": {
+ "README.md": {
+ "bytes": 2596,
+ "sha256": "000e8d83d1809facacc32039618a7b6dce41d84bedf7c921ff11c37a4a7b2289"
+ },
+ "authored-after.pure": {
+ "bytes": 2000,
+ "sha256": "7e0861e8116da883dce578963cca40073f4efaf98265d1c5ca79aa0a7855001c"
+ },
+ "capture-x11.c": {
+ "bytes": 1768,
+ "sha256": "c3e21d4e79180ee60e9fa7845118fcc6ec57a4e1386471d78ae404831ebc83ae"
+ },
+ "core-budget.json": {
+ "bytes": 1851,
+ "sha256": "37e9ae8320daf7bbe05983e7f35b59707e64a83d09574e3b126e8d9e13f3336c"
+ },
+ "drag-and-save.png": {
+ "bytes": 134122,
+ "sha256": "5f8773c98f31736ba49419e1c0fa8b8e76c84b916f9fd2a92ff50accd9f35014"
+ },
+ "native-parity-sanitized.json": {
+ "bytes": 13740,
+ "sha256": "c34e50c4e634a1bc3324b190d3fd54a0502986c5cc42bf3b3d8b62c788447ba1"
+ },
+ "native-parity.json": {
+ "bytes": 13741,
+ "sha256": "24d7c37de7609fb1aa406f13cf92cbe6534c8f02ac1ab884cbd171e941fd48b4"
+ },
+ "numeric-input.png": {
+ "bytes": 45958,
+ "sha256": "284139508ecd8049083e4128325c27b4f65ec4e3104147736037fb879535cf95"
+ },
+ "recovered-edit.png": {
+ "bytes": 139858,
+ "sha256": "700895fbd4320617a3b428bed1a42eff3a5189a9647d6477a80b99a678114047"
+ },
+ "rejected-edit.png": {
+ "bytes": 142502,
+ "sha256": "5a9acabbb1ab60d569158ab403e5f51ffe0e12c655ed281f24c196f325548082"
+ },
+ "reopened-state.png": {
+ "bytes": 134717,
+ "sha256": "596bb7a9e6234143283db188605faaa94b65db9e17366a25bf0d80d0d578c046"
+ },
+ "resized-input.png": {
+ "bytes": 108314,
+ "sha256": "ca29815234a244916f1a3674963c400549343866fd9be00acc942525f631f7c4"
+ },
+ "resized.png": {
+ "bytes": 108735,
+ "sha256": "0685310a9b5c1e046a1133f54db81f29e035eefb9f1927e434a03e3b0f318f97"
+ },
+ "sources/manage.py": {
+ "bytes": 6992,
+ "sha256": "afde3c7129d89cea0a559df4aa18cc9c3bd997146375db0967730340b66c16fe"
+ },
+ "sources/native/app.c": {
+ "bytes": 11764,
+ "sha256": "96ab7faf4848b5eaba5a1fb672eec2ea7882b3e554a4cba1adfac9c202a2b4ca"
+ },
+ "sources/native/app.h": {
+ "bytes": 1492,
+ "sha256": "32c738e06f007894457b6b4369ff75c9c2286737885a04159e257c1dcdfa3729"
+ },
+ "sources/native/check.mjs": {
+ "bytes": 8492,
+ "sha256": "286e23f1ab164a956f53c6d89f229ccb0ec5fdbb35426737a6c28cd553b92953"
+ },
+ "sources/native/desktop.c": {
+ "bytes": 22779,
+ "sha256": "617d58ad2ec74de0f428ab76ca23e76bed4ca9c3234dd50213853843552d5c7c"
+ },
+ "sources/native/test.c": {
+ "bytes": 9837,
+ "sha256": "47488a2cfda2b378637f69b8f0d94f75e615d784e8418167b5b82c8ce141f7e8"
+ }
+ },
+ "binary_sha256": {
+ "cli": "b7add8a755a4959191417b3f0fd50805f71c72659eb4659905a689588351bda8",
+ "desktop": "eb4276076f4fb11268f976baef9cb7701399a8a82223bc11f93a0593e176174a"
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-24-native-desktop/native-parity-sanitized.json b/prototypes/workbench/results/2026-09-24-native-desktop/native-parity-sanitized.json
new file mode 100644
index 0000000..c1f5006
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-native-desktop/native-parity-sanitized.json
@@ -0,0 +1,159 @@
+{
+ "kind": "native-instance-and-authored-behavior-correctness-not-performance",
+ "sanitized_c_host": true,
+ "zig_mode": "ReleaseSafe; Zig object is not ASan-instrumented",
+ "compiler": "cc (Debian 14.2.0-19) 14.2.0",
+ "cases": [
+ {
+ "name": "counter",
+ "commands": 5,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# Space or the tap control increments a state that is displayed and drawn.\nstate count = 0\ninput tap = Space\ntext label(80, 90, count, \"Count: \")\nbox tile(80, 150, 40 + min(count, 20) * 8, 60, \"#356548\")\non tap.down {\n count = count + 1\n}\n",
+ "source_sha256": "53cb3a93ff0f26a3d208a5876bfff9ecb1c39e9a01acc101566727733ba166a8",
+ "prepared_sha256": "eada952dee9afd7071370215364b2a6717a28f03e0513cdd0cae4695939ec52b",
+ "native_binary_sha256": "b51112856288b20947d7287c135f78ee889f4a67e15fcf4410900ac43299c9ab",
+ "native_binary_bytes": 591896
+ },
+ {
+ "name": "toggle",
+ "commands": 28,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# Space toggles the circle. Right arrow moves the tile.\nstate visible = 1\nstate position = 120\ninput toggle = Space\ninput move = ArrowRight\ncircle marker(position, 140, visible * 24, \"#c78638\")\nbox tile(position - 24, 190, 48, 48, \"#356548\")\ntext value(80, 80, visible, \"Visible: \")\non toggle.down {\n visible = !visible\n}\non move.down {\n position = position + 40\n if position > 560 {\n position = 80\n }\n}\n",
+ "source_sha256": "3de97fab9d316c17ba6a0c82a16cccd3baafc6816b879bf9b0a174210c8a1915",
+ "prepared_sha256": "fc96ee38b256117a7bfe2859b7126e22262c19e96047a8d01fbe30d4d1a28764",
+ "native_binary_sha256": "1a309cdf3d564ce995f34c40bb14ad2989dfeee736ce59d93b67083873d6f566",
+ "native_binary_bytes": 592280
+ },
+ {
+ "name": "charge",
+ "commands": 104,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# All holding, charging, gravity, jumping, and floor behavior is authored here.\nstate holding = 0\nstate charge = 0\nstate y = 240\nstate velocity = 0\ninput jump = Space\nbox floor(40, 261, 560, 4, \"#356548\")\ncircle actor(180, y, 20, \"#c78638\")\nbox meter(80, 80, charge * 200, 12, \"#c78638\")\ntext amount(80, 60, charge, \"Charge: \")\non jump.down {\n if y >= 240 {\n holding = 1\n charge = 0\n }\n}\non jump.up {\n if holding {\n velocity = -(160 + charge * 300)\n holding = 0\n charge = 0\n }\n}\non frame {\n if holding {\n charge = clamp(charge + dt, 0, 1)\n }\n velocity = velocity + 600 * dt\n y = y + velocity * dt\n if y >= 240 {\n y = 240\n velocity = 0\n }\n}\n",
+ "source_sha256": "8d69d9fd51d44ab754d560eb7ff5102ea8a34540753cd7170b3bd800e4294f56",
+ "prepared_sha256": "7ba5fa094b0bc772202bb44a62b3c8377c4f3924db246ec429c2faee4948b09f",
+ "native_binary_sha256": "ececc7e0b1c01a36ade2d376807ea0aeb99191c8feea1b1f202a254a5e9a7d2e",
+ "native_binary_bytes": 592344
+ },
+ {
+ "name": "direct",
+ "commands": 3,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# 도형을 잡은 위치를 유지해 이동합니다. 취소하면 잡기 전 위치로 돌아갑니다.\nstate x = 80\nstate y = 160\nstate grab_x = 0\nstate grab_y = 0\nstate start_x = 80\nstate start_y = 160\nstate dragging = 0\n\ninput drag = pointer(tile)\ninput left = ArrowLeft\ninput right = ArrowRight\n\nbox left_area(20, 100, 280, 220, \"#233a31\")\nbox right_area(340, 100, 280, 220, \"#343b25\")\nbox tile(x, y, 56, 44, \"#c9f08b\")\ntext horizontal(30, 45, x, \"x: \")\ntext vertical(180, 45, y, \"y: \")\ntext region(350, 70, x >= 320, \"오른쪽 영역: \")\ntext active(30, 75, dragging, \"잡고 있음: \")\n\non drag.down {\n start_x = x\n start_y = y\n grab_x = event_x - x\n grab_y = event_y - y\n dragging = 1\n}\non drag.move {\n x = clamp(event_x - grab_x, 20, 564)\n y = clamp(event_y - grab_y, 100, 276)\n}\non drag.up {\n # 놓기는 마지막으로 표시한 위치를 확정합니다.\n dragging = 0\n}\non drag.cancel {\n x = start_x\n y = start_y\n dragging = 0\n}\non left.down {\n if dragging == 0 { x = max(20, x - 10) }\n}\non right.down {\n if dragging == 0 { x = min(564, x + 10) }\n}\n",
+ "source_sha256": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a",
+ "prepared_sha256": "27f0aa76eb3e12e289bcd6edf6c2ae72892a2dfe1ff094b619671f0583e2c8d3",
+ "native_binary_sha256": "60e9c0a7eeef1c3f74325bda42763096548e54969bd1c13dfe8a9d470c3eda46",
+ "native_binary_bytes": 593112
+ },
+ {
+ "name": "routed",
+ "commands": 8,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# 하나의 포인터 입력과 네 반응을 모든 도형이 공유합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag = pointer {\n green_box: green\n blue_box: blue\n amber_box: amber\n}\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag.down use grab(drag.target)\non drag.move use move(drag.target)\non drag.up use drop(drag.target)\non drag.cancel use cancel(drag.target)\n\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n",
+ "source_sha256": "656e81577c250df704ba744b1ea6ec9c987ab3b8ee90c758062ac4beaf195650",
+ "prepared_sha256": "710503e7ffadf02b309c24a761caade4c5a093750d6801956cc698bfc263fc09",
+ "native_binary_sha256": "c0b9fdf9008d5c9c92eb4ff24c9beb38cf12f95551ec998e9a9e6a2393949083",
+ "native_binary_bytes": 594264
+ },
+ {
+ "name": "document",
+ "commands": 5,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# 웹 컨트롤이 요청한 분할 비율을 받습니다. 레이아웃과 문서는 브라우저가 소유합니다.\nstate ratio = 50\ninput split = value\ninput reset = KeyR\non split.change { ratio = clamp(event_value, 20, 80) }\non reset.down { ratio = 50 }\n",
+ "source_sha256": "057d2e665d72dd3fce7ac59d232a65e9e0e1a6ca1f8c1e1b36ae766e480367d1",
+ "prepared_sha256": "c74315c1b0067e167a4678b30505d1770b0753b601e8acee27f6d598ac7b9ffb",
+ "native_binary_sha256": "838098d0ad1158f12fdcedd532c9e3f69220e346d7bf0e0c6bdcc9694e123098",
+ "native_binary_bytes": 591576
+ },
+ {
+ "name": "atomic-error",
+ "commands": 2,
+ "failed_events": 1,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "state x=7\nstate y=2\ninput n=value\non n.change {x=event_value; y=1/event_value}\ntext v(1,2,y,\"value: \")",
+ "source_sha256": "61a5478e7147ef281d617017910af36ad1bae3dabc97c03c8569c5d8b0e5fe19",
+ "prepared_sha256": "6a415d33780227158ebeae2a75c7520bf13bad3c0a6fef7ee82f5c323be226f0",
+ "native_binary_sha256": "9b39b9b8d0493d13c641a964083289b478be5b57897d5bfa1991a7d68c812f83",
+ "native_binary_bytes": 591712
+ },
+ {
+ "name": "blank",
+ "commands": 0,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "",
+ "source_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "prepared_sha256": "1c8f5ade56ee8ec931a62fd1f68ef3635b17297d3a54b20c954860c4a43b6155",
+ "native_binary_sha256": "6bc96dcfbe6a40f1ac2f21124eaecbe5a21ea5a61a28b4196328948b4441d382",
+ "native_binary_bytes": 591248
+ },
+ {
+ "name": "literal-boundary",
+ "commands": 1,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "state x=-0\ninput n=value\non n.change {x=event_value}\ntext v(1,2,x,\"한글 \\\" \\\\ \\n\")",
+ "source_sha256": "7f480fa50bbddaf4e3a42bbd2132701e2091ef12b499e30c1bdda1dcd947dcc1",
+ "prepared_sha256": "e9a8b1542ecf6b008ad30188cb9e95d29638fab1c38d331c00bf706c40a06d3c",
+ "native_binary_sha256": "720b98f4a5022f38560245b352dbf85a2d70e9e248a483db365ce06c92c50bde",
+ "native_binary_bytes": 591656
+ }
+ ],
+ "units": {
+ "native_instances": 3,
+ "checks": "independent-state-staging-inputs-errors-budget-snapshot-conflict-restore-plan-reload",
+ "scalar_instance_bytes": 20064,
+ "scalar_alignment": 8,
+ "app_controller_bytes": 928,
+ "action_bytes": 32,
+ "snapshot_bytes": 244,
+ "all_work_drained": true
+ },
+ "source_sha256": {
+ "prototypes/workbench/core.zig": "ea237848df0dbd2ce620c2874e64bc5ecbe569bf77f3737e9a4fe1e64fea40cc",
+ "prototypes/workbench/engine.zig": "555b9ff3143888921d90a475bcb7957c18dbf7101ee9e3d2853c01a562cbf3f5",
+ "prototypes/workbench/evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "prototypes/workbench/native.zig": "fe6c445cdfb5a4bc27ea9f540f3880c45b65c33098eec761d4398d9a7fc5c9a6",
+ "prototypes/workbench/manage.py": "afde3c7129d89cea0a559df4aa18cc9c3bd997146375db0967730340b66c16fe",
+ "prototypes/workbench/native/scalar.h": "b84a796bde2f45d59505cc0addb5f5afede0a9c4dc58d40a38dd37a8ac68d1fd",
+ "prototypes/workbench/native/plan.h": "2e9bd7e3c2ecc52f79c8938d09d2a696174c44a245e4bf52b70be2ed02cbbb57",
+ "prototypes/workbench/native/plan.c": "f0bdf8466097cfad54c197120aace8acd5f430170931d575e26807b760ce2173",
+ "prototypes/workbench/native/app.h": "32c738e06f007894457b6b4369ff75c9c2286737885a04159e257c1dcdfa3729",
+ "prototypes/workbench/native/app.c": "96ab7faf4848b5eaba5a1fb672eec2ea7882b3e554a4cba1adfac9c202a2b4ca",
+ "prototypes/workbench/native/main.c": "dd28168741e79ffaa73cf74e60b974a1ca84b0473a43aafe089019f61e732aab",
+ "prototypes/workbench/native/prepare.mjs": "3989d4e545255fccf9d846afb79246e874f4cefb03eecef9ee1e2b1ce8fd257e",
+ "prototypes/workbench/native/test.c": "47488a2cfda2b378637f69b8f0d94f75e615d784e8418167b5b82c8ce141f7e8",
+ "prototypes/workbench/native/check.mjs": "286e23f1ab164a956f53c6d89f229ccb0ec5fdbb35426737a6c28cd553b92953",
+ "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1",
+ "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54",
+ "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35",
+ "prototypes/workbench/web/core.wasm": "8fe2f501cb9639fea0817aa57e0b7292ca6779cda4ac156b7c6b3df31d7921e9",
+ "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2",
+ "prototypes/connections/runtime.h": "ff03c0be92dcc482247a58a0be54d2dc5c270325b51ceeed37afc2ffa5480b1f",
+ "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04",
+ "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc",
+ "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421",
+ "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d",
+ "prototypes/connections/store.c": "44702e9414c26d1ee336b2e36e6713560013c2c8d0091a8900042e2468f88015",
+ "prototypes/connections/store.h": "e3cf5f169be02e40f1274105046f29a63a46cd153beaf204d33d6100b9c869b8",
+ "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f",
+ "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6"
+ },
+ "scalar_archive_sha256": "4e4082a2252de7b23758e438a246e654012231105b12e2619f45d4e82701238d",
+ "scalar_archive_size": " text\t data\t bss\t dec\t hex\tfilename\n 33020\t 0\t 0\t 33020\t 80fc\tprototypes/workbench/build/cache/tmp/5d68cd21f0052e5a/libpure-scalar_zcu.o (ex /home/eunhhu/work/pure/prototypes/workbench/build/native/libpure-scalar.a)\n",
+ "scalar_archive_undefined": "\nprototypes/workbench/build/cache/tmp/5d68cd21f0052e5a/libpure-scalar_zcu.o:\n U memcpy\n U memset\n"
+}
diff --git a/prototypes/workbench/results/2026-09-24-native-desktop/native-parity.json b/prototypes/workbench/results/2026-09-24-native-desktop/native-parity.json
new file mode 100644
index 0000000..f84b22e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-native-desktop/native-parity.json
@@ -0,0 +1,159 @@
+{
+ "kind": "native-instance-and-authored-behavior-correctness-not-performance",
+ "sanitized_c_host": false,
+ "zig_mode": "ReleaseSafe; Zig object is not ASan-instrumented",
+ "compiler": "cc (Debian 14.2.0-19) 14.2.0",
+ "cases": [
+ {
+ "name": "counter",
+ "commands": 5,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# Space or the tap control increments a state that is displayed and drawn.\nstate count = 0\ninput tap = Space\ntext label(80, 90, count, \"Count: \")\nbox tile(80, 150, 40 + min(count, 20) * 8, 60, \"#356548\")\non tap.down {\n count = count + 1\n}\n",
+ "source_sha256": "53cb3a93ff0f26a3d208a5876bfff9ecb1c39e9a01acc101566727733ba166a8",
+ "prepared_sha256": "eada952dee9afd7071370215364b2a6717a28f03e0513cdd0cae4695939ec52b",
+ "native_binary_sha256": "a2e4c733de5edd2c1abcd3a9e186ee495bdbfd7df65cc6ba220d093ad80bfb19",
+ "native_binary_bytes": 213744
+ },
+ {
+ "name": "toggle",
+ "commands": 28,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# Space toggles the circle. Right arrow moves the tile.\nstate visible = 1\nstate position = 120\ninput toggle = Space\ninput move = ArrowRight\ncircle marker(position, 140, visible * 24, \"#c78638\")\nbox tile(position - 24, 190, 48, 48, \"#356548\")\ntext value(80, 80, visible, \"Visible: \")\non toggle.down {\n visible = !visible\n}\non move.down {\n position = position + 40\n if position > 560 {\n position = 80\n }\n}\n",
+ "source_sha256": "3de97fab9d316c17ba6a0c82a16cccd3baafc6816b879bf9b0a174210c8a1915",
+ "prepared_sha256": "fc96ee38b256117a7bfe2859b7126e22262c19e96047a8d01fbe30d4d1a28764",
+ "native_binary_sha256": "bcd626e79163df2cc3eb0afc87fa301ed33ca9622d2b48cd2eb7e4d508af0940",
+ "native_binary_bytes": 213744
+ },
+ {
+ "name": "charge",
+ "commands": 104,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# All holding, charging, gravity, jumping, and floor behavior is authored here.\nstate holding = 0\nstate charge = 0\nstate y = 240\nstate velocity = 0\ninput jump = Space\nbox floor(40, 261, 560, 4, \"#356548\")\ncircle actor(180, y, 20, \"#c78638\")\nbox meter(80, 80, charge * 200, 12, \"#c78638\")\ntext amount(80, 60, charge, \"Charge: \")\non jump.down {\n if y >= 240 {\n holding = 1\n charge = 0\n }\n}\non jump.up {\n if holding {\n velocity = -(160 + charge * 300)\n holding = 0\n charge = 0\n }\n}\non frame {\n if holding {\n charge = clamp(charge + dt, 0, 1)\n }\n velocity = velocity + 600 * dt\n y = y + velocity * dt\n if y >= 240 {\n y = 240\n velocity = 0\n }\n}\n",
+ "source_sha256": "8d69d9fd51d44ab754d560eb7ff5102ea8a34540753cd7170b3bd800e4294f56",
+ "prepared_sha256": "7ba5fa094b0bc772202bb44a62b3c8377c4f3924db246ec429c2faee4948b09f",
+ "native_binary_sha256": "eca35aa011c0a67eca954846be5241eb21ccc9cc979b491d519ead880acbf95e",
+ "native_binary_bytes": 213744
+ },
+ {
+ "name": "direct",
+ "commands": 3,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# 도형을 잡은 위치를 유지해 이동합니다. 취소하면 잡기 전 위치로 돌아갑니다.\nstate x = 80\nstate y = 160\nstate grab_x = 0\nstate grab_y = 0\nstate start_x = 80\nstate start_y = 160\nstate dragging = 0\n\ninput drag = pointer(tile)\ninput left = ArrowLeft\ninput right = ArrowRight\n\nbox left_area(20, 100, 280, 220, \"#233a31\")\nbox right_area(340, 100, 280, 220, \"#343b25\")\nbox tile(x, y, 56, 44, \"#c9f08b\")\ntext horizontal(30, 45, x, \"x: \")\ntext vertical(180, 45, y, \"y: \")\ntext region(350, 70, x >= 320, \"오른쪽 영역: \")\ntext active(30, 75, dragging, \"잡고 있음: \")\n\non drag.down {\n start_x = x\n start_y = y\n grab_x = event_x - x\n grab_y = event_y - y\n dragging = 1\n}\non drag.move {\n x = clamp(event_x - grab_x, 20, 564)\n y = clamp(event_y - grab_y, 100, 276)\n}\non drag.up {\n # 놓기는 마지막으로 표시한 위치를 확정합니다.\n dragging = 0\n}\non drag.cancel {\n x = start_x\n y = start_y\n dragging = 0\n}\non left.down {\n if dragging == 0 { x = max(20, x - 10) }\n}\non right.down {\n if dragging == 0 { x = min(564, x + 10) }\n}\n",
+ "source_sha256": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a",
+ "prepared_sha256": "27f0aa76eb3e12e289bcd6edf6c2ae72892a2dfe1ff094b619671f0583e2c8d3",
+ "native_binary_sha256": "6d67ee421bf83e1e1c8ff081e9fe7e36c22a75791049f7887183bb84f5a03590",
+ "native_binary_bytes": 213744
+ },
+ {
+ "name": "routed",
+ "commands": 8,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# 하나의 포인터 입력과 네 반응을 모든 도형이 공유합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag = pointer {\n green_box: green\n blue_box: blue\n amber_box: amber\n}\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag.down use grab(drag.target)\non drag.move use move(drag.target)\non drag.up use drop(drag.target)\non drag.cancel use cancel(drag.target)\n\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n",
+ "source_sha256": "656e81577c250df704ba744b1ea6ec9c987ab3b8ee90c758062ac4beaf195650",
+ "prepared_sha256": "710503e7ffadf02b309c24a761caade4c5a093750d6801956cc698bfc263fc09",
+ "native_binary_sha256": "768406f62dfe7ddcfe448e45dd3a4e0285363ffcce143f8b07850b02fcedf6e4",
+ "native_binary_bytes": 213744
+ },
+ {
+ "name": "document",
+ "commands": 5,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "# 웹 컨트롤이 요청한 분할 비율을 받습니다. 레이아웃과 문서는 브라우저가 소유합니다.\nstate ratio = 50\ninput split = value\ninput reset = KeyR\non split.change { ratio = clamp(event_value, 20, 80) }\non reset.down { ratio = 50 }\n",
+ "source_sha256": "057d2e665d72dd3fce7ac59d232a65e9e0e1a6ca1f8c1e1b36ae766e480367d1",
+ "prepared_sha256": "c74315c1b0067e167a4678b30505d1770b0753b601e8acee27f6d598ac7b9ffb",
+ "native_binary_sha256": "75c2b688cacd060010f3a2f416b9f14c44df6782da172690bdd3e3b709f97e8e",
+ "native_binary_bytes": 213744
+ },
+ {
+ "name": "atomic-error",
+ "commands": 2,
+ "failed_events": 1,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "state x=7\nstate y=2\ninput n=value\non n.change {x=event_value; y=1/event_value}\ntext v(1,2,y,\"value: \")",
+ "source_sha256": "61a5478e7147ef281d617017910af36ad1bae3dabc97c03c8569c5d8b0e5fe19",
+ "prepared_sha256": "6a415d33780227158ebeae2a75c7520bf13bad3c0a6fef7ee82f5c323be226f0",
+ "native_binary_sha256": "9b2346f153516af265003a60f155e48805b354f4afaa7845940acba5971c3eee",
+ "native_binary_bytes": 213760
+ },
+ {
+ "name": "blank",
+ "commands": 0,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "",
+ "source_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "prepared_sha256": "1c8f5ade56ee8ec931a62fd1f68ef3635b17297d3a54b20c954860c4a43b6155",
+ "native_binary_sha256": "74e48d74b3e3ff73a0b501aaad0f72b103e3001fa6f70373e49c056f740c417d",
+ "native_binary_bytes": 213720
+ },
+ {
+ "name": "literal-boundary",
+ "commands": 1,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "fresh_process_restore": true,
+ "source": "state x=-0\ninput n=value\non n.change {x=event_value}\ntext v(1,2,x,\"한글 \\\" \\\\ \\n\")",
+ "source_sha256": "7f480fa50bbddaf4e3a42bbd2132701e2091ef12b499e30c1bdda1dcd947dcc1",
+ "prepared_sha256": "e9a8b1542ecf6b008ad30188cb9e95d29638fab1c38d331c00bf706c40a06d3c",
+ "native_binary_sha256": "0f6c413948c5e3b942edc268206baa6bbed04f0288d8b6d49f0f293fe0b4ab5b",
+ "native_binary_bytes": 213760
+ }
+ ],
+ "units": {
+ "native_instances": 3,
+ "checks": "independent-state-staging-inputs-errors-budget-snapshot-conflict-restore-plan-reload",
+ "scalar_instance_bytes": 20064,
+ "scalar_alignment": 8,
+ "app_controller_bytes": 928,
+ "action_bytes": 32,
+ "snapshot_bytes": 244,
+ "all_work_drained": true
+ },
+ "source_sha256": {
+ "prototypes/workbench/core.zig": "ea237848df0dbd2ce620c2874e64bc5ecbe569bf77f3737e9a4fe1e64fea40cc",
+ "prototypes/workbench/engine.zig": "555b9ff3143888921d90a475bcb7957c18dbf7101ee9e3d2853c01a562cbf3f5",
+ "prototypes/workbench/evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "prototypes/workbench/native.zig": "fe6c445cdfb5a4bc27ea9f540f3880c45b65c33098eec761d4398d9a7fc5c9a6",
+ "prototypes/workbench/manage.py": "afde3c7129d89cea0a559df4aa18cc9c3bd997146375db0967730340b66c16fe",
+ "prototypes/workbench/native/scalar.h": "b84a796bde2f45d59505cc0addb5f5afede0a9c4dc58d40a38dd37a8ac68d1fd",
+ "prototypes/workbench/native/plan.h": "2e9bd7e3c2ecc52f79c8938d09d2a696174c44a245e4bf52b70be2ed02cbbb57",
+ "prototypes/workbench/native/plan.c": "f0bdf8466097cfad54c197120aace8acd5f430170931d575e26807b760ce2173",
+ "prototypes/workbench/native/app.h": "32c738e06f007894457b6b4369ff75c9c2286737885a04159e257c1dcdfa3729",
+ "prototypes/workbench/native/app.c": "96ab7faf4848b5eaba5a1fb672eec2ea7882b3e554a4cba1adfac9c202a2b4ca",
+ "prototypes/workbench/native/main.c": "dd28168741e79ffaa73cf74e60b974a1ca84b0473a43aafe089019f61e732aab",
+ "prototypes/workbench/native/prepare.mjs": "3989d4e545255fccf9d846afb79246e874f4cefb03eecef9ee1e2b1ce8fd257e",
+ "prototypes/workbench/native/test.c": "47488a2cfda2b378637f69b8f0d94f75e615d784e8418167b5b82c8ce141f7e8",
+ "prototypes/workbench/native/check.mjs": "286e23f1ab164a956f53c6d89f229ccb0ec5fdbb35426737a6c28cd553b92953",
+ "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1",
+ "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54",
+ "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35",
+ "prototypes/workbench/web/core.wasm": "8fe2f501cb9639fea0817aa57e0b7292ca6779cda4ac156b7c6b3df31d7921e9",
+ "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2",
+ "prototypes/connections/runtime.h": "ff03c0be92dcc482247a58a0be54d2dc5c270325b51ceeed37afc2ffa5480b1f",
+ "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04",
+ "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc",
+ "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421",
+ "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d",
+ "prototypes/connections/store.c": "44702e9414c26d1ee336b2e36e6713560013c2c8d0091a8900042e2468f88015",
+ "prototypes/connections/store.h": "e3cf5f169be02e40f1274105046f29a63a46cd153beaf204d33d6100b9c869b8",
+ "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f",
+ "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6"
+ },
+ "scalar_archive_sha256": "4e4082a2252de7b23758e438a246e654012231105b12e2619f45d4e82701238d",
+ "scalar_archive_size": " text\t data\t bss\t dec\t hex\tfilename\n 33020\t 0\t 0\t 33020\t 80fc\tprototypes/workbench/build/cache/tmp/5d68cd21f0052e5a/libpure-scalar_zcu.o (ex /home/eunhhu/work/pure/prototypes/workbench/build/native/libpure-scalar.a)\n",
+ "scalar_archive_undefined": "\nprototypes/workbench/build/cache/tmp/5d68cd21f0052e5a/libpure-scalar_zcu.o:\n U memcpy\n U memset\n"
+}
diff --git a/prototypes/workbench/results/2026-09-24-native-desktop/numeric-input.png b/prototypes/workbench/results/2026-09-24-native-desktop/numeric-input.png
new file mode 100644
index 0000000..3c58d07
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-native-desktop/numeric-input.png
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-24-native-desktop/recovered-edit.png b/prototypes/workbench/results/2026-09-24-native-desktop/recovered-edit.png
new file mode 100644
index 0000000..af11b13
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-native-desktop/recovered-edit.png
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-24-native-desktop/rejected-edit.png b/prototypes/workbench/results/2026-09-24-native-desktop/rejected-edit.png
new file mode 100644
index 0000000..c4a674a
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-native-desktop/rejected-edit.png
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-24-native-desktop/reopened-state.png b/prototypes/workbench/results/2026-09-24-native-desktop/reopened-state.png
new file mode 100644
index 0000000..d4ce69a
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-native-desktop/reopened-state.png
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-24-native-desktop/resized-input.png b/prototypes/workbench/results/2026-09-24-native-desktop/resized-input.png
new file mode 100644
index 0000000..4434902
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-native-desktop/resized-input.png
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-24-native-desktop/resized.png b/prototypes/workbench/results/2026-09-24-native-desktop/resized.png
new file mode 100644
index 0000000..3768cc1
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-native-desktop/resized.png
Binary files differ
diff --git a/prototypes/workbench/results/2026-09-24-native-desktop/sources/manage.py b/prototypes/workbench/results/2026-09-24-native-desktop/sources/manage.py
new file mode 100644
index 0000000..a95f6e2
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-native-desktop/sources/manage.py
@@ -0,0 +1,137 @@
+#!/usr/bin/env python3
+"""Build the authoring VM or serve its local browser adapter."""
+import argparse
+from datetime import datetime, timezone
+from functools import partial
+import hashlib
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+import json
+from pathlib import Path
+import shutil
+import shlex
+import subprocess
+
+ROOT = Path(__file__).resolve().parent
+
+
+def sync_examples():
+ target = ROOT / 'web/examples'
+ target.mkdir(exist_ok=True)
+ for source in (ROOT / 'examples').glob('*.pure'):
+ shutil.copyfile(source, target / source.name)
+
+
+def build(zig):
+ compiler = shutil.which(zig)
+ if not compiler:
+ raise SystemExit('Zig 0.15.2가 필요합니다. --zig /path/to/zig로 지정할 수 있습니다.')
+ version = subprocess.check_output([compiler, 'version'], text=True).strip()
+ if version != '0.15.2':
+ raise SystemExit(f'이 실험은 Zig 0.15.2로 고정했습니다. 현재 {version}')
+ folder = ROOT / 'build'
+ folder.mkdir(exist_ok=True)
+ cache = ['--cache-dir', str(folder / 'cache'), '--global-cache-dir', str(folder / 'global-cache')]
+ source = ROOT / 'core.zig'
+ result = subprocess.run([compiler, 'test', str(source), *cache], capture_output=True, text=True)
+ print(result.stdout + result.stderr, end='', flush=True)
+ result.check_returncode()
+ flags = ['-target', 'wasm32-freestanding', '-O', 'ReleaseSafe', '-fstrip', '-fno-entry',
+ '-rdynamic', '--export-memory', '--stack', '16384',
+ '--initial-memory=65536', '--max-memory=65536']
+ artifact = ROOT / 'web/core.wasm'
+ subprocess.run([compiler, 'build-exe', str(source), *flags, f'-femit-bin={artifact}', *cache], check=True)
+ sync_examples()
+ sha = lambda path: hashlib.sha256(path.read_bytes()).hexdigest()
+ (folder / 'build.json').write_text(json.dumps({
+ 'builtUtc': datetime.now(timezone.utc).isoformat(), 'zigVersion': version,
+ 'sourceSha256': sha(source), 'wasmSha256': sha(artifact),
+ 'evaluationSourceSha256': sha(ROOT / 'evaluation.zig'),
+ 'engineSourceSha256': sha(ROOT / 'engine.zig'),
+ 'wasmFileBytes': artifact.stat().st_size, 'flags': flags,
+ 'nativeTests': result.stdout + result.stderr,
+ }, indent=2) + '\n')
+ print(f'Built {artifact} ({artifact.stat().st_size:,} bytes)')
+
+
+def native(zig, source, output, cc, desktop=False):
+ compiler = shutil.which(zig)
+ if not compiler or subprocess.check_output([compiler, 'version'], text=True).strip() != '0.15.2':
+ raise SystemExit('Zig 0.15.2가 필요합니다. --zig /path/to/zig로 지정하세요.')
+ folder = ROOT / 'build/native'
+ folder.mkdir(parents=True, exist_ok=True)
+ cache = ['--cache-dir', str(ROOT / 'build/cache'), '--global-cache-dir', str(ROOT / 'build/global-cache')]
+ library = folder / 'libpure-scalar.a'
+ subprocess.run([compiler, 'build-lib', str(ROOT / 'native.zig'), '-O', 'ReleaseSafe',
+ '-fno-compiler-rt', f'-femit-bin={library}', *cache], check=True)
+ prepared = folder / 'application.c'
+ subprocess.run(['node', str(ROOT / 'native/prepare.mjs'), str(source.resolve()), str(prepared)], check=True)
+ result = output.resolve() if output else folder / ('pure-desktop' if desktop else 'pure-native')
+ result.parent.mkdir(parents=True, exist_ok=True)
+ connections = ROOT.parent / 'connections'
+ inputs = [prepared, ROOT / ('native/desktop.c' if desktop else 'native/main.c'),
+ ROOT / 'native/app.c', ROOT / 'native/plan.c',
+ *(connections / name for name in ('runtime.c', 'evaluate.c', 'store.c', 'store-port.c')), library]
+ flags = ['-std=c17', '-O2', '-g', '-Wall', '-Wextra', '-Wpedantic', '-Werror']
+ desktop_flags = []
+ if desktop:
+ desktop_flags = shlex.split(subprocess.check_output(['pkg-config', '--cflags', '--libs', 'gtk4'], text=True))
+ flags.extend(['-DPURE_NATIVE_AUTHORING=1', f'-DPURE_PREPARE_PATH="{ROOT / "native/prepare.mjs"}"',
+ f'-DPURE_NATIVE_HEADERS="{ROOT / "native"}"', f'-DPURE_CC="{cc}"'])
+ subprocess.run([cc, *flags, '-I' + str(ROOT / 'native'), *(str(p) for p in inputs),
+ '-lm', *(desktop_flags if desktop else []), *(['-ldl'] if desktop else []),
+ '-o', str(result)], check=True)
+ sha = lambda path: hashlib.sha256(path.read_bytes()).hexdigest()
+ (folder / 'build.json').write_text(json.dumps({'source': str(source.resolve()), 'sourceSha256': sha(source),
+ 'zigVersion': '0.15.2', 'zigFlags': ['-O', 'ReleaseSafe', '-fno-compiler-rt'], 'cFlags': flags,
+ 'binary': str(result), 'binarySha256': sha(result), 'librarySha256': sha(library),
+ 'preparedSha256': sha(prepared), 'desktop': desktop, 'desktopFlags': desktop_flags,
+ 'implementationSha256': {str(p.relative_to(ROOT.parent.parent)): sha(p) for p in [
+ ROOT / 'engine.zig', ROOT / 'evaluation.zig', ROOT / 'native.zig', ROOT / 'native/prepare.mjs',
+ ROOT / 'native/scalar.h', ROOT / 'native/plan.h', ROOT / 'native/app.h', ROOT / 'web/compiler.mjs',
+ *(p for p in inputs if p.suffix == '.c' and p != prepared)]}}, indent=2) + '\n')
+ print(f'Built native application: {result}')
+
+
+class Handler(SimpleHTTPRequestHandler):
+ extensions_map = {**SimpleHTTPRequestHandler.extensions_map, '.wasm': 'application/wasm', '.mjs': 'text/javascript', '.pure': 'text/plain'}
+
+ def end_headers(self):
+ self.send_header('Cache-Control', 'no-store')
+ super().end_headers()
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ sub = parser.add_subparsers(dest='action', required=True)
+ b = sub.add_parser('build')
+ b.add_argument('--zig', default='zig')
+ n = sub.add_parser('native', help='prepare an existing .pure definition as a native CLI or authoring desktop app')
+ n.add_argument('source', type=Path)
+ n.add_argument('--zig', default='zig')
+ n.add_argument('--cc', default='cc')
+ n.add_argument('--output', type=Path)
+ n.add_argument('--desktop', action='store_true', help='build the optional Linux GTK4 authoring shell')
+ s = sub.add_parser('serve')
+ s.add_argument('--port', type=int, default=8766)
+ args = parser.parse_args()
+ if args.action == 'build':
+ build(args.zig)
+ return
+ if args.action == 'native':
+ native(args.zig, args.source, args.output, args.cc, args.desktop)
+ return
+ if not 1 <= args.port <= 65535:
+ parser.error('port must be 1..65535')
+ if not (ROOT / 'web/core.wasm').is_file():
+ parser.error('먼저 manage.py build를 실행하세요.')
+ sync_examples()
+ with ThreadingHTTPServer(('127.0.0.1', args.port), partial(Handler, directory=str(ROOT / 'web'))) as server:
+ print(f'Pure workbench: http://127.0.0.1:{args.port}', flush=True)
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+
+
+if __name__ == '__main__':
+ main()
diff --git a/prototypes/workbench/results/2026-09-24-native-desktop/sources/native/app.c b/prototypes/workbench/results/2026-09-24-native-desktop/sources/native/app.c
new file mode 100644
index 0000000..ad79c2f
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-native-desktop/sources/native/app.c
@@ -0,0 +1,239 @@
+#include "app.h"
+#include "../../connections/store-port.h"
+#include "../../connections/store.h"
+#include <stdlib.h>
+#include <string.h>
+#include <float.h>
+
+_Static_assert(sizeof(double) == 8 && FLT_RADIX == 2 && DBL_MANT_DIG == 53 && DBL_MAX_EXP == 1024,
+ "the current snapshot boundary requires IEEE binary64");
+
+static const PureProgram connections = {.read = {.to_end = true}, .write = {.to_end = true}};
+static const unsigned char marker[8] = {'P','U','R','E','S','0','0','1'};
+enum { SNAPSHOT_HEADER = 44 };
+
+static void release(void *memory, size_t size) { (void)size; free(memory); }
+static bool allocate(void *context, size_t size, PureResource *out) {
+ (void)context;
+ void *memory = malloc(size);
+ if (!memory) return false;
+ *out = (PureResource){.body = {memory, size}, .allocation = memory,
+ .allocation_size = size, .release = release};
+ return true;
+}
+static uint64_t decode(const unsigned char *bytes, unsigned count) {
+ uint64_t value = 0;
+ for (unsigned i = 0; i < count; ++i) value |= (uint64_t)bytes[i] << (8 * i);
+ return value;
+}
+static void encode(unsigned char *bytes, uint64_t value, unsigned count) {
+ for (unsigned i = 0; i < count; ++i) bytes[i] = (unsigned char)(value >> (8 * i));
+}
+
+static PureResult read_value(void *context, const char *key, PureRegion region, size_t minimum,
+ size_t available, PureResource *out, int64_t *revision) {
+ PureNativeApp *app = context;
+ if (!strcmp(key, "load")) {
+ PureHost store = pure_store_host(app->directory);
+ return store.read(store.context, app->document, region, minimum, available, out, revision);
+ }
+ if (strcmp(key, "snapshot")) return PURE_INVALID;
+ if (app->held) return PURE_BUSY;
+ size_t size = SNAPSHOT_HEADER + 8u * app->plan->state_count;
+ /* This host contract currently exports the complete typed snapshot. */
+ if (minimum > size || !region.to_end || region.offset) return PURE_LIMIT;
+ if (size > available) return PURE_BUDGET;
+ if (!allocate(NULL, size, out)) return PURE_ALLOCATION;
+ unsigned char *bytes = out->allocation;
+ memcpy(bytes, marker, sizeof(marker));
+ memcpy(bytes + 8, app->plan->identity, 32);
+ encode(bytes + 40, app->plan->state_count, 4);
+ for (uint32_t i = 0; i < app->plan->state_count; ++i) {
+ double value = pure_scalar_get_state(app->scalar, i);
+ uint64_t bits;
+ memcpy(&bits, &value, sizeof(bits));
+ encode(bytes + SNAPSHOT_HEADER + i * 8, bits, 8);
+ }
+ *revision = app->revision;
+ return PURE_OK;
+}
+
+static PureResult action(PureNativeApp *app, PureAction a) {
+ if (a.phase == PURE_RESET) {
+ if (app->held) return PURE_BUSY;
+ pure_scalar_reset_state(app->scalar);
+ return PURE_OK;
+ }
+ if (a.phase == PURE_FRAME) {
+ app->last_scalar_error = pure_scalar_dispatch(app->scalar, 0, 1.0 / 60.0);
+ return app->last_scalar_error ? PURE_INVALID : PURE_OK;
+ }
+ if (a.input >= app->plan->input_count || a.input >= 8) return PURE_INVALID;
+ PureScalarInputKind kind = app->plan->inputs[a.input].kind;
+ uint32_t bit = 1u << a.input, event;
+ if (kind == PURE_VALUE) {
+ if (a.phase != PURE_CHANGE) return PURE_INVALID;
+ app->last_scalar_error = pure_scalar_dispatch_value(app->scalar, 33 + a.input, a.x);
+ return app->last_scalar_error ? PURE_INVALID : PURE_OK;
+ }
+ if (kind == PURE_KEY) {
+ if ((a.phase != PURE_DOWN && a.phase != PURE_UP) || a.source >= 32) return PURE_INVALID;
+ uint32_t source = 1u << a.source, before = app->key_sources[a.input];
+ if (a.phase == PURE_DOWN) {
+ app->key_sources[a.input] |= source;
+ app->held |= bit;
+ if (before) return PURE_OK;
+ } else {
+ if (!(before & source)) return PURE_OK;
+ app->key_sources[a.input] &= ~source;
+ if (app->key_sources[a.input]) return PURE_OK;
+ app->held &= ~bit;
+ }
+ event = a.input * 2 + (a.phase == PURE_DOWN ? 1u : 2u);
+ app->last_scalar_error = pure_scalar_dispatch(app->scalar, event, 0);
+ } else {
+ if (a.phase > PURE_CANCEL) return PURE_INVALID;
+ uint32_t slot = a.input;
+ if (kind == PURE_ROUTED) {
+ if (a.target >= app->plan->target_count || a.target >= 24 || app->plan->targets[a.target].input != a.input) return PURE_INVALID;
+ slot = 8 + a.target;
+ }
+ bit = 1u << slot;
+ if (a.phase == PURE_DOWN) {
+ if (app->held & bit) return app->pointer_owners[slot] == a.source ? PURE_OK : PURE_BUSY;
+ app->held |= bit;
+ app->pointer_owners[slot] = a.source;
+ } else {
+ if (!(app->held & bit) || app->pointer_owners[slot] != a.source) return PURE_INVALID;
+ if (a.phase == PURE_UP || a.phase == PURE_CANCEL) app->held &= ~bit;
+ }
+ event = a.phase == PURE_MOVE ? 17 + a.input : a.phase == PURE_CANCEL ? 25 + a.input
+ : a.input * 2 + (a.phase == PURE_DOWN ? 1u : 2u);
+ app->last_scalar_error = kind == PURE_ROUTED
+ ? pure_scalar_dispatch_target(app->scalar, event, a.target, a.x, a.y)
+ : pure_scalar_dispatch_point(app->scalar, event, a.x, a.y);
+ }
+ /* Input ownership follows the physical phases even if an authored handler
+ * fails; its scalar writes remain atomic. Each routed target has its owner. */
+ return app->last_scalar_error ? PURE_INVALID : PURE_OK;
+}
+
+static PureResult write_value(void *context, const char *key, int64_t expected,
+ PureSpan bytes, int64_t *revision) {
+ PureNativeApp *app = context;
+ if (!strcmp(key, "save")) return pure_store_put(app->directory, app->document, expected, bytes, revision);
+ *revision = app->revision;
+ if (expected != app->revision) return PURE_CONFLICT;
+ if (app->revision == INT64_MAX) return PURE_LIMIT;
+ app->last_scalar_error = 0;
+ if (!strcmp(key, "event")) {
+ if (bytes.size != sizeof(PureAction)) return PURE_LIMIT;
+ PureAction input;
+ memcpy(&input, bytes.data, sizeof(input));
+ PureResult status = action(app, input);
+ if (status != PURE_OK) return status;
+#ifdef PURE_NATIVE_AUTHORING
+ } else if (!strcmp(key, "plan")) {
+ if (bytes.size != sizeof(const PureScalarPlan *)) return PURE_LIMIT;
+ if (app->held) return PURE_BUSY;
+ const PureScalarPlan *candidate;
+ memcpy(&candidate, bytes.data, sizeof(candidate));
+ if (!candidate || !candidate->states || !candidate->inputs || !candidate->views ||
+ candidate->state_count > 32 || candidate->input_count > 8 ||
+ candidate->target_count > 24 || candidate->view_count > 24) return PURE_INVALID;
+ app->last_scalar_error = pure_scalar_load(app->scalar, candidate, app->plan, NULL);
+ if (app->last_scalar_error) return PURE_INVALID;
+ app->plan = candidate;
+ memset(app->pointer_owners, 0, sizeof(app->pointer_owners));
+ memset(app->key_sources, 0, sizeof(app->key_sources));
+#endif
+ } else if (!strcmp(key, "restore")) {
+ if (app->held) return PURE_BUSY;
+ if (bytes.size != SNAPSHOT_HEADER + 8u * app->plan->state_count ||
+ memcmp(bytes.data, marker, sizeof(marker)) || memcmp(bytes.data + 8, app->plan->identity, 32) ||
+ decode(bytes.data + 40, 4) != app->plan->state_count) return PURE_INVALID;
+ double values[32];
+ for (uint32_t i = 0; i < app->plan->state_count; ++i) {
+ uint64_t bits = decode(bytes.data + SNAPSHOT_HEADER + 8 * i, 8);
+ memcpy(values + i, &bits, sizeof(bits));
+ }
+ app->last_scalar_error = pure_scalar_load(app->scalar, app->plan, NULL, values);
+ if (app->last_scalar_error) return PURE_INVALID;
+ } else return PURE_INVALID;
+ *revision = ++app->revision;
+ return PURE_OK;
+}
+
+PureResult pure_native_app_init(PureNativeApp *app, const PureScalarPlan *plan, void *memory,
+ size_t size, const char *directory, const char *key, size_t budget) {
+ if (!app || !plan || !directory || !key || !*key || strlen(key) > 64 || plan->state_count > 32) return PURE_INVALID;
+ for (const char *p = key; *p; ++p)
+ if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') ||
+ (*p >= '0' && *p <= '9') || *p == '_' || *p == '-')) return PURE_INVALID;
+ PureScalar *scalar = pure_scalar_initialize(memory, size);
+ if (!scalar) return PURE_INVALID;
+ if (pure_scalar_load(scalar, plan, NULL, NULL)) return PURE_INVALID;
+ PureResult status = pure_store_init(directory);
+ if (status != PURE_OK) return status;
+ *app = (PureNativeApp){.scalar = scalar, .plan = plan, .directory = directory};
+ strcpy(app->document, key);
+ PureHost host = {.context = app, .allocate = allocate, .read = read_value, .write = write_value};
+ return pure_runtime_init(&app->execution, app->slots, 2, &connections, host, budget) ? PURE_OK : PURE_INVALID;
+}
+
+static PureOutcome finish(PureNativeApp *app, PureTicket ticket) {
+ PureOutcome result;
+ while (!pure_runtime_result(ticket, &result)) {
+ if (!pure_runtime_step(&app->execution)) return (PureOutcome){.status = PURE_INVALID};
+ }
+ return result;
+}
+
+PureOutcome pure_native_app_action(PureNativeApp *app, PureAction input) {
+ PureTicket ticket;
+ PureResult status = pure_runtime_accept(&app->execution, PURE_INPUT_WRITE, "event", app->revision, sizeof(input), &ticket);
+ if (status != PURE_OK) return (PureOutcome){.status = status};
+ memcpy(pure_runtime_ingress(ticket, NULL), &input, sizeof(input));
+ pure_runtime_ready(ticket);
+ PureOutcome result = finish(app, ticket);
+ pure_runtime_release(ticket);
+ return result;
+}
+
+static PureOutcome transfer(PureNativeApp *app, bool restore) {
+ PureTicket source, destination;
+ PureResult status = pure_runtime_accept(&app->execution, PURE_INPUT_READ, restore ? "load" : "snapshot", 0, 0, &source);
+ if (status != PURE_OK) return (PureOutcome){.status = status};
+ PureOutcome result = finish(app, source);
+ int64_t stored = result.revision;
+ if (result.status == PURE_OK) {
+ status = pure_runtime_forward(source, &app->execution, restore ? "restore" : "save",
+ restore ? app->revision : app->stored_revision, &destination);
+ pure_runtime_release(source);
+ if (status != PURE_OK) return (PureOutcome){.status = status};
+ result = finish(app, destination);
+ pure_runtime_release(destination);
+ if (result.status == PURE_OK) app->stored_revision = restore ? stored : result.revision;
+ } else pure_runtime_release(source);
+ /* Bodies are consumed internally before this API returns. */
+ result.body = (PureSpan){0};
+ return result;
+}
+PureOutcome pure_native_app_save(PureNativeApp *app) { return transfer(app, false); }
+PureOutcome pure_native_app_restore(PureNativeApp *app) { return transfer(app, true); }
+
+#ifdef PURE_NATIVE_AUTHORING
+PureResult pure_native_app_replace_plan(PureNativeApp *app, const PureScalarPlan *candidate) {
+ if (!app || !candidate) return PURE_INVALID;
+ if (app->held || app->execution.active || app->execution.retained) return PURE_BUSY;
+ PureTicket ticket;
+ PureResult status = pure_runtime_accept(&app->execution, PURE_INPUT_WRITE,
+ "plan", app->revision, sizeof(candidate), &ticket);
+ if (status != PURE_OK) return status;
+ memcpy(pure_runtime_ingress(ticket, NULL), &candidate, sizeof(candidate));
+ pure_runtime_ready(ticket);
+ PureOutcome result = finish(app, ticket);
+ pure_runtime_release(ticket);
+ return result.status;
+}
+#endif
diff --git a/prototypes/workbench/results/2026-09-24-native-desktop/sources/native/app.h b/prototypes/workbench/results/2026-09-24-native-desktop/sources/native/app.h
new file mode 100644
index 0000000..f5ce9b1
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-native-desktop/sources/native/app.h
@@ -0,0 +1,32 @@
+#ifndef PURE_NATIVE_APP_H
+#define PURE_NATIVE_APP_H
+#include "plan.h"
+#include "../../connections/runtime.h"
+
+typedef enum { PURE_DOWN, PURE_UP, PURE_MOVE, PURE_CANCEL, PURE_CHANGE, PURE_FRAME, PURE_RESET } PureActionPhase;
+/* In-process event value, never a persistent or network representation. */
+typedef struct { uint32_t input, phase, target, source; double x, y; } PureAction;
+typedef struct {
+ PureScalar *scalar;
+ const PureScalarPlan *plan;
+ const char *directory;
+ char document[65];
+ PureRuntime execution;
+ PureWork slots[2];
+ int64_t revision, stored_revision;
+ uint32_t last_scalar_error, held, pointer_owners[32], key_sources[8];
+} PureNativeApp;
+
+/* Caller supplies instance memory and keeps plan/directory alive. This selected
+ * host uses the existing SQL-free store. Single owner; effects are synchronous. */
+PureResult pure_native_app_init(PureNativeApp *, const PureScalarPlan *, void *memory,
+ size_t size, const char *directory, const char *key, size_t budget);
+PureOutcome pure_native_app_action(PureNativeApp *, PureAction);
+PureOutcome pure_native_app_save(PureNativeApp *);
+PureOutcome pure_native_app_restore(PureNativeApp *);
+/* Authoring host only. Candidate plan stays alive on success; previous plan
+ * stays alive until this call returns. A failed reload retains the old app. */
+#ifdef PURE_NATIVE_AUTHORING
+PureResult pure_native_app_replace_plan(PureNativeApp *, const PureScalarPlan *);
+#endif
+#endif
diff --git a/prototypes/workbench/results/2026-09-24-native-desktop/sources/native/check.mjs b/prototypes/workbench/results/2026-09-24-native-desktop/sources/native/check.mjs
new file mode 100644
index 0000000..02dcb5e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-native-desktop/sources/native/check.mjs
@@ -0,0 +1,132 @@
+#!/usr/bin/env node
+import assert from 'node:assert/strict';
+import { readFile, writeFile, mkdir, mkdtemp, rm } from 'node:fs/promises';
+import { spawnSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import { tmpdir } from 'node:os';
+import { resolve, join, relative } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { compile } from '../web/compiler.mjs';
+import { createRuntime } from '../web/runtime.mjs';
+
+const root = fileURLToPath(new URL('../', import.meta.url));
+const repo = resolve(root, '../..');
+const connections = resolve(root, '../connections');
+const args = process.argv.slice(2);
+const sanitized = args.includes('--sanitize');
+const index = args.indexOf('--output');
+const output = index >= 0 ? resolve(args[index + 1]) : null;
+const temporary = await mkdtemp(join(tmpdir(), 'pure-native-parity-'));
+const library = resolve(root, 'build/native/libpure-scalar.a');
+const wasm = await readFile(resolve(root, 'web/core.wasm'));
+const hash = data => createHash('sha256').update(data).digest('hex');
+const compiler = process.env.CC || 'cc';
+const flags = ['-std=c17', sanitized ? '-O1' : '-O2', '-g', '-Wall', '-Wextra', '-Wpedantic', '-Werror',
+ ...(sanitized ? ['-fsanitize=address,undefined', '-fno-omit-frame-pointer'] : []), '-I' + resolve(root, 'native')];
+function run(command, arguments_, options = {}) {
+ const p = spawnSync(command, arguments_, { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: 60000, ...options });
+ assert.ifError(p.error);
+ return p;
+}
+function successful(command, arguments_, options) {
+ const p = run(command, arguments_, options);
+ assert.equal(p.status, 0, p.stdout + p.stderr);
+ return p.stdout;
+}
+const units = [resolve(root, 'native/app.c'), resolve(root, 'native/plan.c'),
+ ...['runtime.c', 'evaluate.c', 'store.c', 'store-port.c'].map(name => resolve(connections, name))];
+const objects = [];
+const cases = [];
+let unitResult;
+try {
+ for (const [i, path] of units.entries()) {
+ const object = join(temporary, `unit-${i}.o`);
+ successful(compiler, [...flags, '-c', path, '-o', object]);
+ objects.push(object);
+ }
+ const main = join(temporary, 'main.o');
+ successful(compiler, [...flags, '-c', resolve(root, 'native/main.c'), '-o', main]);
+ const definition = name => readFile(resolve(root, 'examples', name + '.pure'), 'utf8');
+ const repeat = (line, n) => Array(n).fill(line);
+ const definitions = [
+ ['counter', await definition('counter'), ['key tap down', 'key tap down', 'key tap up', 'key tap down', 'key tap up']],
+ ['toggle', await definition('toggle'), ['key toggle down', 'key toggle up', ...Array.from({length: 13}, () => ['key move down', 'key move up']).flat()]],
+ ['charge', await definition('charge'), ['key jump down', ...repeat('frame', 12), 'key jump up', ...repeat('frame', 90)]],
+ ['direct', await definition('direct'), ['point drag down tile 90 180', 'point drag move tile 250 220', 'point drag up tile 250 220']],
+ ['routed', await definition('routed-layout'), ['point drag down green_box 90 130', 'point drag down blue_box 290 170',
+ 'point drag move green_box 200 210', 'point drag move blue_box 500 270', 'point drag cancel green_box 200 210',
+ 'point drag up blue_box 500 270', 'key left down', 'key left up']],
+ ['document', await definition('document'), ['value split 99', 'value split -10', 'value split 61.5', 'key reset down', 'key reset up']],
+ ['atomic-error', 'state x=7\nstate y=2\ninput n=value\non n.change {x=event_value; y=1/event_value}\ntext v(1,2,y,"value: ")',
+ ['value n 0', 'value n 4']],
+ ['blank', '', []],
+ ['literal-boundary', 'state x=-0\ninput n=value\non n.change {x=event_value}\ntext v(1,2,x,"한글 \\\" \\\\ \\n")', ['value n -0']],
+ ];
+ for (const [name, source, commands] of definitions) {
+ const sourcePath = join(temporary, name + '.pure'), prepared = join(temporary, name + '.c'), binary = join(temporary, name);
+ await writeFile(sourcePath, source);
+ successful(process.execPath, [resolve(root, 'native/prepare.mjs'), sourcePath, prepared]);
+ successful(compiler, [...flags, prepared, main, ...objects, library, '-lm', '-o', binary]);
+ const native = run(binary, [join(temporary, name + '-store'), 'project'], { input: commands.concat(['save', 'quit']).join('\n') + '\n' });
+ const rows = native.stdout.trim().split('\n').map(line => JSON.parse(line));
+ const runtime = await createRuntime(wasm);
+ const plan = compile(source);
+ runtime.apply(source);
+ function equivalent(row) {
+ const snapshot = runtime.snapshot();
+ assert.deepEqual(row.states, snapshot.states, name);
+ const views = Object.fromEntries(snapshot.views.map(view => [view.id, Object.fromEntries(Object.keys(plan.views.find(v => v.id === view.id).bindings).map(key => [key, view[key]]))]));
+ assert.deepEqual(row.views, views, name);
+ }
+ equivalent(rows[0]);
+ let failures = 0;
+ commands.forEach((command, i) => {
+ const [verb, input, phase, target, x, y] = command.split(' ');
+ let code = 0;
+ if (verb === 'key') code = phase === 'down' ? runtime.down(input) : runtime.up(input);
+ else if (verb === 'frame') code = runtime.frame(1);
+ else if (verb === 'value') code = runtime.change(input, Number(phase));
+ else if (verb === 'point') {
+ const routed = plan.inputs.find(p => p.name === input).routes;
+ code = runtime.point(input, phase, Number(x), Number(y), target, routed ? target : undefined);
+ }
+ if (code) ++failures;
+ assert.equal(rows[i + 1].status === 0, code === 0, command);
+ if (code) assert.equal(rows[i + 1].scalar_error, code, command);
+ equivalent(rows[i + 1]);
+ });
+ assert.equal(rows.length, commands.length + 2);
+ assert.equal(rows.at(-1).status, 0);
+ assert.equal(native.status, failures ? 1 : 0, native.stderr);
+ const restored = JSON.parse(successful(binary, [join(temporary, name + '-store'), 'project'], {input: 'load\nquit\n'}).trim().split('\n').at(-1));
+ equivalent(restored);
+ assert.equal(restored.stored_revision, '1');
+ if (name === 'routed') {
+ const unit = join(temporary, 'test-native');
+ const authoring = join(temporary, 'app-author.o');
+ successful(compiler, [...flags, '-DPURE_NATIVE_AUTHORING=1', '-c', units[0], '-o', authoring]);
+ successful(compiler, [...flags, '-DPURE_NATIVE_AUTHORING=1', prepared, resolve(root, 'native/test.c'),
+ authoring, ...objects.slice(1), library, '-lm', '-o', unit]);
+ unitResult = JSON.parse(successful(unit, []));
+ }
+ cases.push({name, commands: commands.length, failed_events: failures, states_and_views_match: true,
+ fresh_process_restore: true, source, source_sha256: hash(source), prepared_sha256: hash(await readFile(prepared)),
+ native_binary_sha256: hash(await readFile(binary)), native_binary_bytes: (await readFile(binary)).length});
+ }
+ const paths = [resolve(root, 'core.zig'), resolve(root, 'engine.zig'), resolve(root, 'evaluation.zig'), resolve(root, 'native.zig'),
+ resolve(root, 'manage.py'), ...['scalar.h','plan.h','plan.c','app.h','app.c','main.c','prepare.mjs','test.c','check.mjs'].map(name => resolve(root, 'native', name)),
+ ...['compiler.mjs','runtime.mjs','project.mjs','core.wasm'].map(name => resolve(root, 'web', name)),
+ ...['runtime.c','runtime.h','evaluate.c','program.h','flow.h','result.h','store.c','store.h','store-port.c','store-port.h'].map(name => resolve(connections, name))];
+ const sourceHashes = Object.fromEntries(await Promise.all(paths.map(async path => [relative(repo, path), hash(await readFile(path))])));
+ const result = {kind:'native-instance-and-authored-behavior-correctness-not-performance', sanitized_c_host: sanitized,
+ zig_mode:'ReleaseSafe; Zig object is not ASan-instrumented', compiler: successful(compiler,['--version']).split('\n')[0],
+ cases, units: unitResult, source_sha256: sourceHashes, scalar_archive_sha256: hash(await readFile(library)),
+ scalar_archive_size: successful('size',[library]), scalar_archive_undefined: successful('nm',['-u',library])};
+ if (output) {
+ await mkdir(resolve(output, '..'), {recursive: true});
+ await writeFile(output, JSON.stringify(result,null,2)+'\n', {flag:'wx'});
+ }
+ console.log(JSON.stringify({cases:cases.length, event_comparisons:cases.reduce((n,c)=>n+c.commands,0), units:unitResult, sanitized_c_host:sanitized}));
+} finally {
+ await rm(temporary, {recursive:true, force:true});
+}
diff --git a/prototypes/workbench/results/2026-09-24-native-desktop/sources/native/desktop.c b/prototypes/workbench/results/2026-09-24-native-desktop/sources/native/desktop.c
new file mode 100644
index 0000000..064b181
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-native-desktop/sources/native/desktop.c
@@ -0,0 +1,503 @@
+/* Linux authoring shell. GTK, Cairo, the compiler and module loader stay outside
+ * the application/execution core; the standalone CLI links none of them. */
+#include "app.h"
+#include <gtk/gtk.h>
+#include <glib/gstdio.h>
+#include <gdk/gdkkeysyms.h>
+#include <pango/pangocairo.h>
+#include <dlfcn.h>
+#include <math.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <inttypes.h>
+
+#ifndef PURE_PREPARE_PATH
+#error PURE_PREPARE_PATH must name the existing build-time compiler adapter
+#endif
+#ifndef PURE_NATIVE_HEADERS
+#error PURE_NATIVE_HEADERS must name the native plan headers
+#endif
+#ifndef PURE_CC
+#define PURE_CC "cc"
+#endif
+
+extern const PureScalarPlan pure_app_plan;
+
+typedef struct {
+ PureNativeApp app;
+ void *scalar_memory, *module;
+ const char *source_path;
+ char *initial_source;
+ gsize initial_source_bytes;
+ char *scratch;
+ unsigned generation;
+ GtkWidget *window, *canvas, *status, *values, *source;
+ bool pointer_active;
+ uint32_t pointer_input, pointer_target;
+ double pointer_x, pointer_y, pointer_px, pointer_py;
+ uint32_t keys_down;
+} Desktop;
+
+typedef struct { double field[4]; bool valid; } SceneView;
+typedef struct { Desktop *desktop; uint32_t input; } ValueControl;
+
+static void free_value_control(gpointer data, GClosure *closure) {
+ (void)closure;
+ g_free(data);
+}
+
+static void report(Desktop *d, const char *name, PureOutcome result) {
+ char message[200];
+ snprintf(message, sizeof(message), "%s · status %u · scalar %u · local %" PRId64 " · stored %" PRId64,
+ name, (unsigned)result.status, d->app.last_scalar_error, d->app.revision, d->app.stored_revision);
+ gtk_label_set_text(GTK_LABEL(d->status), message);
+ gtk_widget_queue_draw(d->canvas);
+}
+
+static bool scene(Desktop *d, SceneView result[24]) {
+ if (d->app.plan->view_count > 24) return false;
+ pure_scalar_begin_evaluation(d->app.scalar, 2048);
+ for (uint32_t i = 0; i < d->app.plan->view_count; ++i) {
+ const PureScalarView *view = &d->app.plan->views[i];
+ result[i].valid = view->count <= 4;
+ for (uint32_t j = 0; j < view->count && j < 4; ++j) {
+ result[i].field[j] = pure_scalar_eval_bounded(d->app.scalar, view->fields[j].start, view->fields[j].length);
+ if (pure_scalar_evaluation_info(d->app.scalar, 0) || !isfinite(result[i].field[j])) result[i].valid = false;
+ }
+ if (view->count >= 3 && strcmp(view->type, "text") && result[i].field[2] < 0) result[i].valid = false;
+ if (view->count == 4 && result[i].field[3] < 0) result[i].valid = false;
+ }
+ return true;
+}
+
+static void viewport(int width, int height, double *scale, double *left, double *top) {
+ *scale = fmin((double)width / 640.0, (double)height / 360.0);
+ if (*scale <= 0) *scale = 1;
+ *left = (width - 640 * *scale) / 2;
+ *top = (height - 360 * *scale) / 2;
+}
+
+static void set_color(cairo_t *cr, const char *text) {
+ GdkRGBA color;
+ if (!text || !gdk_rgba_parse(&color, text)) gdk_rgba_parse(&color, "#79a7a1");
+ cairo_set_source_rgba(cr, color.red, color.green, color.blue, color.alpha);
+}
+
+static void draw(GtkDrawingArea *area, cairo_t *cr, int width, int height, gpointer data) {
+ Desktop *d = data;
+ cairo_set_source_rgb(cr, .055, .075, .09);
+ cairo_paint(cr);
+ double scale, left, top;
+ viewport(width, height, &scale, &left, &top);
+ cairo_save(cr);
+ cairo_translate(cr, left, top);
+ cairo_scale(cr, scale, scale);
+ cairo_set_source_rgb(cr, .08, .115, .14);
+ cairo_rectangle(cr, 0, 0, 640, 360);
+ cairo_fill(cr);
+ SceneView values[24] = {0};
+ if (scene(d, values)) for (uint32_t i = 0; i < d->app.plan->view_count; ++i) {
+ const PureScalarView *v = &d->app.plan->views[i];
+ const double *f = values[i].field;
+ if (!values[i].valid) continue;
+ if (!strcmp(v->type, "box") && v->count == 4) {
+ set_color(cr, v->decoration);
+ cairo_rectangle(cr, f[0], f[1], f[2], f[3]);
+ cairo_fill(cr);
+ } else if (!strcmp(v->type, "circle") && v->count == 3) {
+ set_color(cr, v->decoration);
+ cairo_arc(cr, f[0], f[1], f[2], 0, 2 * G_PI);
+ cairo_fill(cr);
+ } else if (!strcmp(v->type, "text") && v->count == 3) {
+ char number[64];
+ snprintf(number, sizeof(number), "%.6g", f[2]);
+ char *label = g_strconcat(v->decoration ? v->decoration : "", number, NULL);
+ PangoLayout *layout = gtk_widget_create_pango_layout(GTK_WIDGET(area), label);
+ PangoFontDescription *font = pango_font_description_from_string("Sans 15");
+ pango_layout_set_font_description(layout, font);
+ cairo_set_source_rgb(cr, .88, .92, .92);
+ cairo_move_to(cr, f[0], f[1]);
+ pango_cairo_show_layout(cr, layout);
+ pango_font_description_free(font);
+ g_object_unref(layout);
+ g_free(label);
+ }
+ }
+ cairo_restore(cr);
+}
+
+static bool hit(Desktop *d, double px, double py, uint32_t *input, uint32_t *target) {
+ double scale, left, top;
+ viewport(gtk_widget_get_width(d->canvas), gtk_widget_get_height(d->canvas), &scale, &left, &top);
+ double x = (px - left) / scale, y = (py - top) / scale;
+ if (x < 0 || y < 0 || x >= 640 || y >= 360) return false;
+ SceneView views[24] = {0};
+ if (!scene(d, views)) return false;
+ for (uint32_t ri = d->app.plan->view_count; ri; --ri) {
+ uint32_t i = ri - 1;
+ const PureScalarView *v = &d->app.plan->views[i];
+ const double *f = views[i].field;
+ if (!views[i].valid) continue;
+ bool inside = (!strcmp(v->type, "box") && v->count == 4 &&
+ x >= f[0] && x <= f[0] + f[2] && y >= f[1] && y <= f[1] + f[3]) ||
+ (!strcmp(v->type, "circle") && v->count == 3 &&
+ (x - f[0]) * (x - f[0]) + (y - f[1]) * (y - f[1]) <= f[2] * f[2]);
+ if (!inside) continue;
+ for (uint32_t j = 0; j < d->app.plan->target_count; ++j)
+ if (!strcmp(v->id, d->app.plan->targets[j].view)) {
+ *input = d->app.plan->targets[j].input; *target = j; return true;
+ }
+ for (uint32_t j = 0; j < d->app.plan->input_count; ++j)
+ if (d->app.plan->inputs[j].kind == PURE_POINT &&
+ !strcmp(v->id, d->app.plan->inputs[j].target)) {
+ *input = j; *target = UINT32_MAX; return true;
+ }
+ }
+ return false;
+}
+
+static void pointer(Desktop *d, PureActionPhase phase, double px, double py) {
+ double scale, left, top;
+ viewport(gtk_widget_get_width(d->canvas), gtk_widget_get_height(d->canvas), &scale, &left, &top);
+ d->pointer_px = px;
+ d->pointer_py = py;
+ d->pointer_x = (px - left) / scale;
+ d->pointer_y = (py - top) / scale;
+ PureAction a = {.input = d->pointer_input, .phase = phase, .target = d->pointer_target,
+ .source = 0, .x = d->pointer_x, .y = d->pointer_y};
+ report(d, "pointer", pure_native_app_action(&d->app, a));
+}
+static void drag_begin(GtkGestureDrag *gesture, double x, double y, gpointer data) {
+ (void)gesture;
+ Desktop *d = data;
+ if (d->pointer_active || !hit(d, x, y, &d->pointer_input, &d->pointer_target)) return;
+ gtk_widget_grab_focus(d->canvas);
+ pointer(d, PURE_DOWN, x, y);
+ uint32_t slot = d->pointer_target == UINT32_MAX ? d->pointer_input : 8 + d->pointer_target;
+ d->pointer_active = (d->app.held & (1u << slot)) != 0;
+}
+static void drag_update(GtkGestureDrag *gesture, double dx, double dy, gpointer data) {
+ Desktop *d = data;
+ if (!d->pointer_active) return;
+ double x, y;
+ if (gtk_gesture_drag_get_start_point(gesture, &x, &y)) pointer(d, PURE_MOVE, x + dx, y + dy);
+}
+static void drag_end(GtkGestureDrag *gesture, double dx, double dy, gpointer data) {
+ Desktop *d = data;
+ if (!d->pointer_active) return;
+ double x, y;
+ if (gtk_gesture_drag_get_start_point(gesture, &x, &y)) pointer(d, PURE_UP, x + dx, y + dy);
+ else pointer(d, PURE_CANCEL, d->pointer_px, d->pointer_py);
+ d->pointer_active = false;
+}
+static void drag_cancel(GtkGesture *gesture, GdkEventSequence *sequence, gpointer data) {
+ (void)gesture; (void)sequence;
+ Desktop *d = data;
+ if (d->pointer_active) {
+ pointer(d, PURE_CANCEL, d->pointer_px, d->pointer_py);
+ d->pointer_active = false;
+ }
+}
+
+static bool key_matches(const char *key, guint value) {
+ if (!strcmp(key, "ArrowLeft")) return value == GDK_KEY_Left;
+ if (!strcmp(key, "ArrowRight")) return value == GDK_KEY_Right;
+ if (!strcmp(key, "ArrowUp")) return value == GDK_KEY_Up;
+ if (!strcmp(key, "ArrowDown")) return value == GDK_KEY_Down;
+ if (!strcmp(key, "Space")) return value == GDK_KEY_space;
+ if (strlen(key) == 4 && !memcmp(key, "Key", 3) && value < 128)
+ return g_ascii_toupper((char)value) == key[3];
+ return false;
+}
+static gboolean key_event(GtkEventControllerKey *controller, guint keyval, guint keycode,
+ GdkModifierType modifiers, gpointer data) {
+ (void)keycode; (void)modifiers;
+ Desktop *d = data;
+ bool pressed = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(controller), "pressed"));
+ bool handled = false;
+ for (uint32_t i = 0; i < d->app.plan->input_count; ++i) {
+ const PureScalarInput *input = &d->app.plan->inputs[i];
+ if (input->kind != PURE_KEY || !key_matches(input->key, keyval)) continue;
+ handled = true;
+ if (pressed) d->keys_down |= 1u << i; else d->keys_down &= ~(1u << i);
+ report(d, input->name, pure_native_app_action(&d->app, (PureAction){.input = i,
+ .phase = pressed ? PURE_DOWN : PURE_UP, .source = 0}));
+ }
+ return handled;
+}
+static gboolean key_down(GtkEventControllerKey *c, guint key, guint code, GdkModifierType mods, gpointer d) {
+ g_object_set_data(G_OBJECT(c), "pressed", GINT_TO_POINTER(1));
+ return key_event(c, key, code, mods, d);
+}
+static void key_up(GtkEventControllerKey *c, guint key, guint code, GdkModifierType mods, gpointer d) {
+ g_object_set_data(G_OBJECT(c), "pressed", NULL);
+ (void)key_event(c, key, code, mods, d);
+}
+static void focus_left(GtkEventControllerFocus *controller, gpointer data) {
+ (void)controller;
+ Desktop *d = data;
+ if (d->pointer_active) {
+ pointer(d, PURE_CANCEL, d->pointer_px, d->pointer_py);
+ d->pointer_active = false;
+ }
+ for (uint32_t i = 0; i < d->app.plan->input_count; ++i)
+ if (d->keys_down & (1u << i))
+ report(d, "key release", pure_native_app_action(&d->app, (PureAction){.input = i, .phase = PURE_UP}));
+ d->keys_down = 0;
+}
+
+static void value_enter(GtkEntry *entry, gpointer data) {
+ ValueControl *control = data;
+ const char *s = gtk_editable_get_text(GTK_EDITABLE(entry));
+ char *end;
+ double n = g_ascii_strtod(s, &end);
+ if (!*s || *end || !isfinite(n)) {
+ gtk_label_set_text(GTK_LABEL(control->desktop->status), "Value must be a finite number");
+ return;
+ }
+ report(control->desktop, "value", pure_native_app_action(&control->desktop->app,
+ (PureAction){.input = control->input, .phase = PURE_CHANGE, .x = n}));
+}
+static void rebuild_values(Desktop *d) {
+ GtkWidget *child;
+ while ((child = gtk_widget_get_first_child(d->values))) gtk_box_remove(GTK_BOX(d->values), child);
+ for (uint32_t i = 0; i < d->app.plan->input_count; ++i) {
+ if (d->app.plan->inputs[i].kind != PURE_VALUE) continue;
+ GtkWidget *row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
+ GtkWidget *label = gtk_label_new(d->app.plan->inputs[i].name);
+ GtkWidget *entry = gtk_entry_new();
+ gtk_entry_set_placeholder_text(GTK_ENTRY(entry), "number ↵");
+ gtk_widget_set_hexpand(entry, TRUE);
+ gtk_box_append(GTK_BOX(row), label);
+ gtk_box_append(GTK_BOX(row), entry);
+ gtk_box_append(GTK_BOX(d->values), row);
+ ValueControl *control = g_new(ValueControl, 1);
+ *control = (ValueControl){d, i};
+ g_signal_connect_data(entry, "activate", G_CALLBACK(value_enter), control, free_value_control, 0);
+ }
+}
+
+static bool run(char **arguments, char **failure) {
+ GError *error = NULL;
+ char *output = NULL, *err = NULL;
+ int status = 0;
+ bool launched = g_spawn_sync(NULL, arguments, NULL, G_SPAWN_SEARCH_PATH, NULL, NULL,
+ &output, &err, &status, &error);
+ bool ok = launched && g_spawn_check_wait_status(status, &error);
+ if (!ok) {
+ if (err && *err) {
+ const char *begin = strstr(err, "Error: ");
+ if (!begin) begin = err;
+ const char *end = strchr(begin, '\n');
+ *failure = g_strndup(begin, end ? (size_t)(end - begin) : 300);
+ } else *failure = g_strdup(error ? error->message : "tool failed");
+ }
+ g_clear_error(&error);
+ g_free(output); g_free(err);
+ return ok;
+}
+
+static void apply_source(Desktop *d, bool save_source) {
+ if (d->app.held) {
+ gtk_label_set_text(GTK_LABEL(d->status), "Release held inputs before Apply");
+ return;
+ }
+ GtkTextBuffer *buffer = gtk_text_view_get_buffer(GTK_TEXT_VIEW(d->source));
+ GtkTextIter start, end;
+ gtk_text_buffer_get_bounds(buffer, &start, &end);
+ char *source = gtk_text_buffer_get_text(buffer, &start, &end, FALSE);
+ size_t bytes = strlen(source);
+ if (bytes > 32768) {
+ gtk_label_set_text(GTK_LABEL(d->status), "Source exceeds the 32 KiB compiler limit");
+ g_free(source); return;
+ }
+ char *input = g_build_filename(d->scratch, "source.pure", NULL);
+ char *output = g_strdup_printf("%s/plan-%u.c", d->scratch, ++d->generation);
+ char *module = g_strdup_printf("%s/plan-%u.so", d->scratch, d->generation);
+ char *failure = NULL;
+ GError *error = NULL;
+ if (!g_file_set_contents(input, source, bytes, &error)) {
+ failure = g_strdup(error->message); g_clear_error(&error); goto done;
+ }
+ char *prepare_argv[] = {"node", PURE_PREPARE_PATH, input, output, NULL};
+ if (!run(prepare_argv, &failure)) goto done;
+ char *headers = g_strconcat("-I", PURE_NATIVE_HEADERS, NULL);
+ char *compile_argv[] = {PURE_CC, "-std=c17", "-O2", "-fPIC", "-shared", "-Wall", "-Wextra", "-Werror",
+ headers, output, "-o", module, NULL};
+ bool built = run(compile_argv, &failure);
+ g_free(headers);
+ if (!built) goto done;
+ void *handle = dlopen(module, RTLD_NOW | RTLD_LOCAL);
+ if (!handle) { failure = g_strdup(dlerror()); goto done; }
+ const PureScalarPlan *candidate = dlsym(handle, "pure_app_plan");
+ bool same = candidate && !memcmp(candidate->identity, d->app.plan->identity, sizeof(candidate->identity));
+ if (!same) {
+ PureResult status = pure_native_app_replace_plan(&d->app, candidate);
+ if (status != PURE_OK) {
+ failure = g_strdup_printf("plan rejected: status %u, scalar %u", status, d->app.last_scalar_error);
+ dlclose(handle); goto done;
+ }
+ if (d->module) dlclose(d->module);
+ d->module = handle;
+ rebuild_values(d);
+ } else {
+ dlclose(handle);
+ }
+ if (save_source && !g_file_set_contents(d->source_path, source, bytes, &error)) {
+ failure = g_strdup_printf("Applied in memory; source save failed: %s", error->message);
+ g_clear_error(&error);
+ } else {
+ gtk_text_buffer_set_modified(buffer, FALSE);
+ report(d, "Applied source", (PureOutcome){.status = PURE_OK});
+ }
+done:
+ if (failure) {
+ char message[350];
+ g_snprintf(message, sizeof(message), "Apply: %.335s", failure);
+ gtk_label_set_text(GTK_LABEL(d->status), message);
+ g_free(failure);
+ }
+ g_remove(input); g_remove(output); g_remove(module);
+ g_free(input); g_free(output); g_free(module); g_free(source);
+ gtk_widget_queue_draw(d->canvas);
+}
+static void apply(GtkButton *button, gpointer data) {
+ (void)button;
+ apply_source(data, true);
+}
+
+static void save_state(GtkButton *button, gpointer data) {
+ (void)button;
+ Desktop *d = data;
+ report(d, "Save state", pure_native_app_save(&d->app));
+}
+static void load_state(GtkButton *button, gpointer data) {
+ (void)button;
+ Desktop *d = data;
+ report(d, "Load state", pure_native_app_restore(&d->app));
+}
+static void reset_state(GtkButton *button, gpointer data) {
+ (void)button;
+ Desktop *d = data;
+ report(d, "Reset", pure_native_app_action(&d->app, (PureAction){.phase = PURE_RESET}));
+}
+
+static GtkWidget *button(GtkWidget *parent, const char *title, GCallback callback, Desktop *d) {
+ GtkWidget *item = gtk_button_new_with_label(title);
+ gtk_box_append(GTK_BOX(parent), item);
+ g_signal_connect(item, "clicked", callback, d);
+ return item;
+}
+static void activate(GtkApplication *application, gpointer data) {
+ Desktop *d = data;
+ d->window = gtk_application_window_new(application);
+ gtk_window_set_title(GTK_WINDOW(d->window), "Pure · native authoring");
+ gtk_window_set_default_size(GTK_WINDOW(d->window), 1320, 720);
+ GtkWidget *root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
+ gtk_widget_set_margin_start(root, 12); gtk_widget_set_margin_end(root, 12);
+ gtk_widget_set_margin_top(root, 12); gtk_widget_set_margin_bottom(root, 12);
+ gtk_window_set_child(GTK_WINDOW(d->window), root);
+ GtkWidget *bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
+ gtk_box_append(GTK_BOX(root), bar);
+ button(bar, "Apply source", G_CALLBACK(apply), d);
+ button(bar, "Save state", G_CALLBACK(save_state), d);
+ button(bar, "Load state", G_CALLBACK(load_state), d);
+ button(bar, "Reset", G_CALLBACK(reset_state), d);
+ d->status = gtk_label_new("Edit source and Apply; click the scene for keyboard input");
+ gtk_label_set_xalign(GTK_LABEL(d->status), 0);
+ gtk_label_set_single_line_mode(GTK_LABEL(d->status), TRUE);
+ gtk_label_set_ellipsize(GTK_LABEL(d->status), PANGO_ELLIPSIZE_END);
+ gtk_widget_set_hexpand(d->status, TRUE);
+ gtk_box_append(GTK_BOX(bar), d->status);
+ GtkWidget *panes = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL);
+ gtk_widget_set_vexpand(panes, TRUE);
+ gtk_box_append(GTK_BOX(root), panes);
+ GtkWidget *left = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
+ gtk_paned_set_start_child(GTK_PANED(panes), left);
+ GtkWidget *source_title = gtk_label_new(d->source_path);
+ gtk_label_set_xalign(GTK_LABEL(source_title), 0);
+ gtk_box_append(GTK_BOX(left), source_title);
+ GtkWidget *scroll = gtk_scrolled_window_new();
+ gtk_widget_set_hexpand(scroll, TRUE); gtk_widget_set_vexpand(scroll, TRUE);
+ gtk_box_append(GTK_BOX(left), scroll);
+ d->source = gtk_text_view_new();
+ gtk_text_view_set_monospace(GTK_TEXT_VIEW(d->source), TRUE);
+ gtk_text_view_set_wrap_mode(GTK_TEXT_VIEW(d->source), GTK_WRAP_NONE);
+ gtk_text_buffer_set_enable_undo(gtk_text_view_get_buffer(GTK_TEXT_VIEW(d->source)), TRUE);
+ gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), d->source);
+ gtk_text_buffer_set_text(gtk_text_view_get_buffer(GTK_TEXT_VIEW(d->source)),
+ d->initial_source, (int)d->initial_source_bytes);
+ GtkWidget *right = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
+ gtk_paned_set_end_child(GTK_PANED(panes), right);
+ d->canvas = gtk_drawing_area_new();
+ gtk_widget_set_hexpand(d->canvas, TRUE); gtk_widget_set_vexpand(d->canvas, TRUE);
+ gtk_widget_set_focusable(d->canvas, TRUE);
+ gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(d->canvas), draw, d, NULL);
+ gtk_box_append(GTK_BOX(right), d->canvas);
+ d->values = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
+ gtk_box_append(GTK_BOX(right), d->values);
+ rebuild_values(d);
+ GtkGesture *drag = gtk_gesture_drag_new();
+ gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(drag), GDK_BUTTON_PRIMARY);
+ g_signal_connect(drag, "drag-begin", G_CALLBACK(drag_begin), d);
+ g_signal_connect(drag, "drag-update", G_CALLBACK(drag_update), d);
+ g_signal_connect(drag, "drag-end", G_CALLBACK(drag_end), d);
+ g_signal_connect(drag, "cancel", G_CALLBACK(drag_cancel), d);
+ gtk_widget_add_controller(d->canvas, GTK_EVENT_CONTROLLER(drag));
+ GtkEventController *keys = gtk_event_controller_key_new();
+ g_signal_connect(keys, "key-pressed", G_CALLBACK(key_down), d);
+ g_signal_connect(keys, "key-released", G_CALLBACK(key_up), d);
+ gtk_widget_add_controller(d->canvas, keys);
+ GtkEventController *focus = gtk_event_controller_focus_new();
+ g_signal_connect(focus, "leave", G_CALLBACK(focus_left), d);
+ gtk_widget_add_controller(d->canvas, focus);
+ gtk_paned_set_position(GTK_PANED(panes), 560);
+ apply_source(d, false);
+ gtk_window_present(GTK_WINDOW(d->window));
+}
+
+int main(int argc, char **argv) {
+ if (argc != 4) {
+ fprintf(stderr, "pure-desktop STORE_DIRECTORY DOCUMENT_KEY SOURCE.pure\n");
+ return 2;
+ }
+ Desktop d = {.source_path = argv[3]};
+ GError *error = NULL;
+ if (!g_file_get_contents(d.source_path, &d.initial_source, &d.initial_source_bytes, &error)) {
+ fprintf(stderr, "source read failed: %s\n", error->message);
+ g_clear_error(&error);
+ return 2;
+ }
+ if (d.initial_source_bytes > 32768) {
+ fprintf(stderr, "source exceeds the 32 KiB compiler limit\n");
+ g_free(d.initial_source); return 2;
+ }
+ if (!g_utf8_validate(d.initial_source, (gssize)d.initial_source_bytes, NULL)) {
+ fprintf(stderr, "source must be valid UTF-8 text\n");
+ g_free(d.initial_source); return 2;
+ }
+ d.scalar_memory = malloc(pure_scalar_bytes());
+ if (!d.scalar_memory) { g_free(d.initial_source); return 2; }
+ PureResult status = pure_native_app_init(&d.app, &pure_app_plan, d.scalar_memory,
+ pure_scalar_bytes(), argv[1], argv[2], 4096);
+ if (status != PURE_OK) {
+ fprintf(stderr, "initialization failed: %u\n", status);
+ free(d.scalar_memory); g_free(d.initial_source); return 2;
+ }
+ d.scratch = g_dir_make_tmp("pure-author-XXXXXX", &error);
+ if (!d.scratch) {
+ fprintf(stderr, "authoring scratch: %s\n", error->message);
+ g_clear_error(&error); free(d.scalar_memory); g_free(d.initial_source); return 2;
+ }
+ GtkApplication *application = gtk_application_new("dev.pure.authoring", G_APPLICATION_NON_UNIQUE);
+ g_signal_connect(application, "activate", G_CALLBACK(activate), &d);
+ int result = g_application_run(G_APPLICATION(application), 0, NULL);
+ g_object_unref(application);
+ pure_runtime_stop(&d.app.execution);
+ if (d.module) dlclose(d.module);
+ g_rmdir(d.scratch); g_free(d.scratch);
+ free(d.scalar_memory);
+ g_free(d.initial_source);
+ return result;
+}
diff --git a/prototypes/workbench/results/2026-09-24-native-desktop/sources/native/test.c b/prototypes/workbench/results/2026-09-24-native-desktop/sources/native/test.c
new file mode 100644
index 0000000..697fb64
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-native-desktop/sources/native/test.c
@@ -0,0 +1,151 @@
+#define _POSIX_C_SOURCE 200809L
+#include "app.h"
+#include "../../connections/store.h"
+#include <assert.h>
+#include <inttypes.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+extern const PureScalarPlan pure_app_plan;
+static void move(PureNativeApp *app, uint32_t target, double x, double y) {
+ assert(pure_native_app_action(app, (PureAction){.input = 4, .phase = PURE_DOWN, .target = target, .x = x, .y = y}).status == PURE_OK);
+ assert(pure_native_app_action(app, (PureAction){.input = 4, .phase = PURE_MOVE, .target = target, .x = x + 70, .y = y + 40}).status == PURE_OK);
+ assert(pure_native_app_action(app, (PureAction){.input = 4, .phase = PURE_UP, .target = target, .x = x + 70, .y = y + 40}).status == PURE_OK);
+}
+static void drained(PureNativeApp *app) {
+ assert(app->execution.active == 0 && app->execution.retained == 0);
+ assert(app->execution.peak_active <= 2 && app->execution.peak_retained <= app->execution.budget);
+}
+static void values(PureNativeApp *app, double output[32]) {
+ for (uint32_t i = 0; i < app->plan->state_count; ++i) output[i] = pure_scalar_get_state(app->scalar, i);
+}
+static void same(PureNativeApp *app, const double *expected) {
+ for (uint32_t i = 0; i < app->plan->state_count; ++i) assert(pure_scalar_get_state(app->scalar, i) == expected[i]);
+}
+
+int main(void) {
+ size_t size = pure_scalar_bytes();
+ unsigned char *a = malloc(size), *b = malloc(size), *c = malloc(size);
+ assert(a && b && c);
+ assert(!pure_scalar_initialize(NULL, size));
+ assert(!pure_scalar_initialize(a, size - 1));
+ assert(pure_scalar_alignment() > 1 && !pure_scalar_initialize(a + 1, size));
+ char path[] = "/tmp/pure-native-instance-XXXXXX";
+ assert(mkdtemp(path));
+ PureNativeApp first, second, limited;
+ assert(pure_native_app_init(&first, &pure_app_plan, a, size, path, "scene", 4096) == PURE_OK);
+ assert(pure_native_app_init(&second, &pure_app_plan, b, size, path, "scene", 4096) == PURE_OK);
+ assert(pure_native_app_init(&limited, &pure_app_plan, c, size, path, "limited", sizeof(PureAction) - 1) == PURE_OK);
+ assert(pure_native_app_action(&limited, (PureAction){.phase = PURE_FRAME}).status == PURE_BUDGET);
+ assert(pure_scalar_info(limited.scalar, 9) == 0);
+
+ assert(pure_native_app_action(&first, (PureAction){.input = 4, .phase = PURE_DOWN, .target = 0, .x = 90, .y = 130}).status == PURE_OK);
+ assert(pure_native_app_save(&first).status == PURE_BUSY);
+ assert(pure_native_app_action(&first, (PureAction){.input = 4, .phase = PURE_MOVE, .target = 1, .x = 230, .y = 210}).status == PURE_INVALID);
+ assert(pure_native_app_action(&first, (PureAction){.input = 4, .phase = PURE_MOVE, .target = 0, .x = 230, .y = 210}).status == PURE_OK);
+ move(&second, 1, 290, 170);
+ assert(pure_scalar_get_state(second.scalar, 1) == 80 && pure_scalar_get_state(second.scalar, 9) == 350);
+ assert(pure_scalar_get_state(first.scalar, 1) == 220 && pure_scalar_get_state(first.scalar, 9) == 280);
+ assert(pure_native_app_action(&first, (PureAction){.input = 4, .phase = PURE_UP, .target = 0, .x = 230, .y = 210}).status == PURE_OK);
+ assert(first.held == 0 && second.held == 0);
+ assert(pure_native_app_save(&first).status == PURE_OK && first.stored_revision == 1);
+ double second_values[32] = {0};
+ values(&second, second_values);
+ PureOutcome conflict = pure_native_app_save(&second);
+ assert(conflict.status == PURE_CONFLICT && conflict.revision == 1 && second.stored_revision == 0);
+ same(&second, second_values);
+ assert(pure_native_app_restore(&second).status == PURE_OK && second.stored_revision == 1);
+ assert(pure_scalar_get_state(second.scalar, 1) == 220 && pure_scalar_get_state(second.scalar, 9) == 280);
+
+ /* Staging/error/evaluation state also belongs to the selected instance. */
+ assert(pure_scalar_begin_program(first.scalar, 1, 0) == 0);
+ assert(pure_scalar_put_state(first.scalar, 0, 999, -1) == 0);
+ assert(pure_scalar_configure_evaluation(first.scalar, 2, 2) == 0);
+ move(&second, 2, 460, 230);
+ assert(pure_scalar_put_instruction(first.scalar, 999, 0, 0) != 0);
+ assert(pure_scalar_commit_program(first.scalar) != 0);
+ assert(pure_scalar_get_state(first.scalar, 1) == 220);
+ assert(pure_scalar_get_state(first.scalar, 17) == 450 && pure_scalar_get_state(second.scalar, 17) == 520);
+ assert(pure_native_app_save(&second).status == PURE_OK && second.stored_revision == 2);
+ assert(pure_native_app_save(&first).status == PURE_CONFLICT && first.stored_revision == 1);
+
+ /* Mismatched definition and nonfinite restored values never replace state. */
+ PureStoredView saved;
+ assert(pure_store_get(path, "scene", 300, &saved) == PURE_OK);
+ unsigned char invalid[300];
+ assert(saved.body.size <= sizeof(invalid));
+ size_t length = saved.body.size;
+ memcpy(invalid, saved.body.data, length);
+ pure_store_release(&saved);
+ invalid[8] ^= 1;
+ int64_t revision;
+ assert(pure_store_put(path, "scene", 2, (PureSpan){invalid, length}, &revision) == PURE_OK);
+ double first_values[32] = {0};
+ values(&first, first_values);
+ assert(pure_native_app_restore(&first).status == PURE_INVALID && first.stored_revision == 1);
+ same(&first, first_values);
+ invalid[8] ^= 1;
+ const unsigned char nan[8] = {0,0,0,0,0,0,248,127};
+ memcpy(invalid + 44, nan, 8);
+ assert(pure_store_put(path, "scene", 3, (PureSpan){invalid, length}, &revision) == PURE_OK);
+ assert(pure_native_app_restore(&first).status == PURE_INVALID && first.last_scalar_error == 5);
+ same(&first, first_values);
+ assert(first.stored_revision == 1);
+ assert(pure_native_app_action(&first, (PureAction){.input = 4, .phase = PURE_DOWN, .target = 0, .source = 11, .x = 230, .y = 210}).status == PURE_OK);
+ assert(pure_native_app_action(&first, (PureAction){.input = 4, .phase = PURE_DOWN, .target = 1, .source = 22, .x = 290, .y = 170}).status == PURE_OK);
+ assert(pure_native_app_action(&first, (PureAction){.input = 4, .phase = PURE_MOVE, .target = 0, .source = 22}).status == PURE_INVALID);
+ assert(pure_native_app_action(&first, (PureAction){.input = 4, .phase = PURE_CANCEL, .target = 0, .source = 11}).status == PURE_OK);
+ assert(first.held && pure_native_app_save(&first).status == PURE_BUSY);
+ assert(pure_native_app_action(&first, (PureAction){.input = 4, .phase = PURE_UP, .target = 1, .source = 22}).status == PURE_OK);
+ assert(first.held == 0);
+ assert(pure_native_app_action(&first, (PureAction){.input = 1, .phase = PURE_DOWN, .source = 1}).status == PURE_OK);
+ double after_key = pure_scalar_get_state(first.scalar, 9);
+ assert(pure_native_app_action(&first, (PureAction){.input = 1, .phase = PURE_DOWN, .source = 2}).status == PURE_OK);
+ assert(pure_scalar_get_state(first.scalar, 9) == after_key);
+ assert(pure_native_app_action(&first, (PureAction){.input = 1, .phase = PURE_UP, .source = 1}).status == PURE_OK && first.held);
+ assert(pure_native_app_action(&first, (PureAction){.input = 1, .phase = PURE_UP, .source = 2}).status == PURE_OK && !first.held);
+
+ /* The native editor swaps only a fully accepted definition. Matching state
+ * names survive; a new name starts at its declared initial value. */
+ PureScalarState changed_states[32];
+ memcpy(changed_states, pure_app_plan.states, pure_app_plan.state_count * sizeof(changed_states[0]));
+ changed_states[1] = (PureScalarState){"new.green.x", 123};
+ PureScalarPlan changed = pure_app_plan;
+ changed.states = changed_states;
+ changed.identity[0] ^= 1;
+ assert(pure_native_app_action(&first, (PureAction){.input = 1, .phase = PURE_DOWN}).status == PURE_OK);
+ assert(pure_native_app_replace_plan(&first, &changed) == PURE_BUSY);
+ assert(pure_native_app_action(&first, (PureAction){.input = 1, .phase = PURE_UP}).status == PURE_OK);
+ double old_y = pure_scalar_get_state(first.scalar, 2);
+ int64_t before_replace = first.revision;
+ uint64_t before_accept = first.execution.accepted;
+ assert(pure_native_app_replace_plan(&first, &changed) == PURE_OK);
+ assert(first.execution.accepted == before_accept + 1);
+ assert(first.plan == &changed && first.revision == before_replace + 1);
+ assert(pure_scalar_get_state(first.scalar, 1) == 123 && pure_scalar_get_state(first.scalar, 2) == old_y);
+ PureScalarInstruction invalid_program[512];
+ assert(changed.instruction_count <= 512);
+ memcpy(invalid_program, changed.instructions, changed.instruction_count * sizeof(invalid_program[0]));
+ invalid_program[0].op = 999;
+ PureScalarPlan rejected = changed;
+ rejected.instructions = invalid_program;
+ before_replace = first.revision;
+ before_accept = first.execution.accepted;
+ assert(pure_native_app_replace_plan(&first, &rejected) == PURE_INVALID && first.last_scalar_error != 0);
+ assert(first.execution.accepted == before_accept + 1);
+ assert(first.plan == &changed && first.revision == before_replace);
+ assert(pure_scalar_get_state(first.scalar, 1) == 123 && pure_scalar_get_state(first.scalar, 2) == old_y);
+ drained(&first); drained(&second); drained(&limited);
+
+ char file[256];
+ snprintf(file, sizeof(file), "%s/obj-scene", path); assert(unlink(file) == 0);
+ snprintf(file, sizeof(file), "%s/.writer.lock", path); assert(unlink(file) == 0);
+ assert(rmdir(path) == 0);
+ free(a); free(b); free(c);
+ printf("{\"native_instances\":3,\"checks\":\"independent-state-staging-inputs-errors-budget-snapshot-conflict-restore-plan-reload\","
+ "\"scalar_instance_bytes\":%zu,\"scalar_alignment\":%zu,\"app_controller_bytes\":%zu,\"action_bytes\":%zu,"
+ "\"snapshot_bytes\":%zu,\"all_work_drained\":true}\n", size, pure_scalar_alignment(), sizeof(PureNativeApp), sizeof(PureAction), length);
+ return 0;
+}
diff --git a/prototypes/workbench/results/2026-09-24-secure-peer/build.json b/prototypes/workbench/results/2026-09-24-secure-peer/build.json
new file mode 100644
index 0000000..38c1906
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-secure-peer/build.json
@@ -0,0 +1,48 @@
+{
+ "source": "/home/eunhhu/work/pure/prototypes/workbench/examples/routed-layout.pure",
+ "sourceSha256": "656e81577c250df704ba744b1ea6ec9c987ab3b8ee90c758062ac4beaf195650",
+ "zigVersion": "0.15.2",
+ "zigFlags": [
+ "-O",
+ "ReleaseSafe",
+ "-fno-compiler-rt"
+ ],
+ "cFlags": [
+ "-std=c17",
+ "-O2",
+ "-g",
+ "-Wall",
+ "-Wextra",
+ "-Wpedantic",
+ "-Werror"
+ ],
+ "binary": "/home/eunhhu/work/pure/prototypes/workbench/build/native/pure-peer",
+ "binarySha256": "3389497f5c9091d0ac971b1647b762bc4f4de556aa83dece729365b97eca0410",
+ "librarySha256": "ce4835b457ccae5f7d21b76c5d86b4d6b74ca99f3932efe96d9d1281b17429be",
+ "preparedSha256": "710503e7ffadf02b309c24a761caade4c5a093750d6801956cc698bfc263fc09",
+ "desktop": false,
+ "desktopFlags": [],
+ "peer": true,
+ "peerFlags": [
+ "-lssl",
+ "-lcrypto"
+ ],
+ "implementationSha256": {
+ "prototypes/workbench/engine.zig": "555b9ff3143888921d90a475bcb7957c18dbf7101ee9e3d2853c01a562cbf3f5",
+ "prototypes/workbench/evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "prototypes/workbench/native.zig": "fe6c445cdfb5a4bc27ea9f540f3880c45b65c33098eec761d4398d9a7fc5c9a6",
+ "prototypes/workbench/native/prepare.mjs": "3989d4e545255fccf9d846afb79246e874f4cefb03eecef9ee1e2b1ce8fd257e",
+ "prototypes/workbench/native/scalar.h": "b84a796bde2f45d59505cc0addb5f5afede0a9c4dc58d40a38dd37a8ac68d1fd",
+ "prototypes/workbench/native/plan.h": "2e9bd7e3c2ecc52f79c8938d09d2a696174c44a245e4bf52b70be2ed02cbbb57",
+ "prototypes/workbench/native/app.h": "32c738e06f007894457b6b4369ff75c9c2286737885a04159e257c1dcdfa3729",
+ "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1",
+ "prototypes/workbench/native/peer.c": "7d5865d499a4ba51f1d14e0d286480f605be2476616d28e1150b35ca1f87bf56",
+ "prototypes/workbench/native/peer-wire.c": "8fc3694db7a00a4f4899ca5a42dc2c0d96e39cb7d927c759ee8881901241733a",
+ "prototypes/workbench/native/app.c": "96ab7faf4848b5eaba5a1fb672eec2ea7882b3e554a4cba1adfac9c202a2b4ca",
+ "prototypes/workbench/native/plan.c": "f0bdf8466097cfad54c197120aace8acd5f430170931d575e26807b760ce2173",
+ "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2",
+ "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04",
+ "prototypes/connections/store.c": "44702e9414c26d1ee336b2e36e6713560013c2c8d0091a8900042e2468f88015",
+ "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f"
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-24-secure-peer/peer-check.json b/prototypes/workbench/results/2026-09-24-secure-peer/peer-check.json
new file mode 100644
index 0000000..257ef9e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-secure-peer/peer-check.json
@@ -0,0 +1 @@
+{"alpn": "pass", "disconnect_release": "pass", "idle_deadline": "pass", "malformed_frames": "pass", "mutual_authentication": "pass", "persist_restart": "pass", "python_client": "pass", "revision_conflict": "pass", "server_connected_rss_kib": 8480, "server_hostname": "pass", "server_idle_rss_kib": 7904}
diff --git a/prototypes/workbench/results/2026-09-24-secure-peer/size.txt b/prototypes/workbench/results/2026-09-24-secure-peer/size.txt
new file mode 100644
index 0000000..2c2547a
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-secure-peer/size.txt
@@ -0,0 +1,4 @@
+ text data bss dec hex filename
+ 5012 0 0 5012 1394 /tmp/pure-peer-runtime.o
+ 62827 2040 16 64883 fd73 prototypes/workbench/build/native/pure-native
+ 75969 2472 16 78457 13279 prototypes/workbench/build/native/pure-peer
diff --git a/prototypes/workbench/results/2026-09-24-shared-program/cli-build.json b/prototypes/workbench/results/2026-09-24-shared-program/cli-build.json
new file mode 100644
index 0000000..0618cc5
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-shared-program/cli-build.json
@@ -0,0 +1,49 @@
+{
+ "source": "/home/eunhhu/work/pure/prototypes/workbench/examples/routed-layout.pure",
+ "sourceSha256": "656e81577c250df704ba744b1ea6ec9c987ab3b8ee90c758062ac4beaf195650",
+ "zigVersion": "0.15.2",
+ "zigFlags": [
+ "-O",
+ "ReleaseSafe",
+ "-fno-compiler-rt"
+ ],
+ "scalarProfile": "fixed-definition",
+ "zigRoot": "/home/eunhhu/work/pure/prototypes/workbench/native-fixed.zig",
+ "cFlags": [
+ "-std=c17",
+ "-O2",
+ "-g",
+ "-Wall",
+ "-Wextra",
+ "-Wpedantic",
+ "-Werror",
+ "-DPURE_FIXED_DEFINITION=1"
+ ],
+ "binary": "/home/eunhhu/work/pure/prototypes/workbench/build/native/pure-native",
+ "binarySha256": "75da17aa4518ac725a42fa0b96e2419d0c19f301af700fb6081bae0c9b562731",
+ "librarySha256": "30a015c55d385b337639b54b2f31aea34b40bc37d1fe6c9e3c884660d26e4cbc",
+ "fixedProfileSha256": "4854304aae01c28b5e6fd43a7d16ff3b08ddd711108b4fdde6848eb5f5beaa92",
+ "preparedSha256": "b7524e53a5d90d1ce16d2e2fbaac67ceb242f1cd27f280afc3a80857ad8c21ba",
+ "desktop": false,
+ "desktopFlags": [],
+ "peer": false,
+ "peerFlags": [],
+ "implementationSha256": {
+ "prototypes/workbench/engine.zig": "a2ea7923975eeeb11d9222b91887489b374efd91f3f50b312b8d96deec076339",
+ "prototypes/workbench/evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "prototypes/workbench/native.zig": "5656e5e402359b5f1b4bd14c1e73366ed5e32a253a7469a16f58dd8e88104d43",
+ "prototypes/workbench/native-fixed.zig": "15418d44f2f697b65770ea96d8e896de84905dec98af1074784323a51cef5a2b",
+ "prototypes/workbench/native/prepare.mjs": "94b732b7cd7a1f8beaf6759bf484fb794449f9d93ed4ea1c3a3186501f8a519a",
+ "prototypes/workbench/native/scalar.h": "4a95675e08c4a3bd30f0bef123aeb7327f0cae58297b9b32fc1e7d2a208f1d1a",
+ "prototypes/workbench/native/plan.h": "98825678cb4e9beba4699f4f9679bb6d6637968b513639cac4173c052de206d2",
+ "prototypes/workbench/native/app.h": "32c738e06f007894457b6b4369ff75c9c2286737885a04159e257c1dcdfa3729",
+ "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1",
+ "prototypes/workbench/native/main.c": "dd28168741e79ffaa73cf74e60b974a1ca84b0473a43aafe089019f61e732aab",
+ "prototypes/workbench/native/app.c": "96ab7faf4848b5eaba5a1fb672eec2ea7882b3e554a4cba1adfac9c202a2b4ca",
+ "prototypes/workbench/native/plan.c": "aa484bfe9072addb00e6eb87edcc11b290b97bd4e362fe9c12105aca8b807f69",
+ "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2",
+ "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04",
+ "prototypes/connections/store.c": "44702e9414c26d1ee336b2e36e6713560013c2c8d0091a8900042e2468f88015",
+ "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f"
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-24-shared-program/dependencies.txt b/prototypes/workbench/results/2026-09-24-shared-program/dependencies.txt
new file mode 100644
index 0000000..89d1dd9
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-shared-program/dependencies.txt
@@ -0,0 +1,6 @@
+pure-native TEXTREL=absent
+0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
+pure-peer TEXTREL=absent
+0x0000000000000001 (NEEDED) Shared library: [libssl.so.3]
+0x0000000000000001 (NEEDED) Shared library: [libcrypto.so.3]
+0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
diff --git a/prototypes/workbench/results/2026-09-24-shared-program/desktop-build.json b/prototypes/workbench/results/2026-09-24-shared-program/desktop-build.json
new file mode 100644
index 0000000..56604fd
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-shared-program/desktop-build.json
@@ -0,0 +1,84 @@
+{
+ "source": "/home/eunhhu/work/pure/prototypes/workbench/examples/routed-layout.pure",
+ "sourceSha256": "656e81577c250df704ba744b1ea6ec9c987ab3b8ee90c758062ac4beaf195650",
+ "zigVersion": "0.15.2",
+ "zigFlags": [
+ "-O",
+ "ReleaseSafe",
+ "-fno-compiler-rt"
+ ],
+ "scalarProfile": "general-authoring",
+ "zigRoot": "/home/eunhhu/work/pure/prototypes/workbench/native.zig",
+ "cFlags": [
+ "-std=c17",
+ "-O2",
+ "-g",
+ "-Wall",
+ "-Wextra",
+ "-Wpedantic",
+ "-Werror",
+ "-DPURE_NATIVE_AUTHORING=1",
+ "-DPURE_PREPARE_PATH=\"/home/eunhhu/work/pure/prototypes/workbench/native/prepare.mjs\"",
+ "-DPURE_NATIVE_HEADERS=\"/home/eunhhu/work/pure/prototypes/workbench/native\"",
+ "-DPURE_CC=\"cc\""
+ ],
+ "binary": "/home/eunhhu/work/pure/prototypes/workbench/build/native/pure-desktop",
+ "binarySha256": "c9dec132b2d4e980c75126ac51c8ea3bdd5417ee903b76962f5824feb5f346b2",
+ "librarySha256": "6916a374f8c6c9be93ca09b8d81eb60af889928b5a68376c3985bf5f87f6a2a7",
+ "fixedProfileSha256": null,
+ "preparedSha256": "b7524e53a5d90d1ce16d2e2fbaac67ceb242f1cd27f280afc3a80857ad8c21ba",
+ "desktop": true,
+ "desktopFlags": [
+ "-I/usr/include/gtk-4.0",
+ "-I/usr/include/pango-1.0",
+ "-I/usr/include/glib-2.0",
+ "-I/usr/lib/aarch64-linux-gnu/glib-2.0/include",
+ "-I/usr/include/sysprof-6",
+ "-I/usr/include/harfbuzz",
+ "-I/usr/include/freetype2",
+ "-I/usr/include/libpng16",
+ "-I/usr/include/libmount",
+ "-I/usr/include/blkid",
+ "-I/usr/include/fribidi",
+ "-I/usr/include/cairo",
+ "-I/usr/include/pixman-1",
+ "-I/usr/include/gdk-pixbuf-2.0",
+ "-I/usr/include/aarch64-linux-gnu",
+ "-I/usr/include/webp",
+ "-I/usr/include/graphene-1.0",
+ "-I/usr/lib/aarch64-linux-gnu/graphene-1.0/include",
+ "-pthread",
+ "-lgtk-4",
+ "-lpangocairo-1.0",
+ "-lpango-1.0",
+ "-lharfbuzz",
+ "-lgdk_pixbuf-2.0",
+ "-lcairo-gobject",
+ "-lcairo",
+ "-lvulkan",
+ "-lgraphene-1.0",
+ "-lgio-2.0",
+ "-lgobject-2.0",
+ "-lglib-2.0"
+ ],
+ "peer": false,
+ "peerFlags": [],
+ "implementationSha256": {
+ "prototypes/workbench/engine.zig": "a2ea7923975eeeb11d9222b91887489b374efd91f3f50b312b8d96deec076339",
+ "prototypes/workbench/evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "prototypes/workbench/native.zig": "5656e5e402359b5f1b4bd14c1e73366ed5e32a253a7469a16f58dd8e88104d43",
+ "prototypes/workbench/native-fixed.zig": "15418d44f2f697b65770ea96d8e896de84905dec98af1074784323a51cef5a2b",
+ "prototypes/workbench/native/prepare.mjs": "94b732b7cd7a1f8beaf6759bf484fb794449f9d93ed4ea1c3a3186501f8a519a",
+ "prototypes/workbench/native/scalar.h": "4a95675e08c4a3bd30f0bef123aeb7327f0cae58297b9b32fc1e7d2a208f1d1a",
+ "prototypes/workbench/native/plan.h": "98825678cb4e9beba4699f4f9679bb6d6637968b513639cac4173c052de206d2",
+ "prototypes/workbench/native/app.h": "32c738e06f007894457b6b4369ff75c9c2286737885a04159e257c1dcdfa3729",
+ "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1",
+ "prototypes/workbench/native/desktop.c": "617d58ad2ec74de0f428ab76ca23e76bed4ca9c3234dd50213853843552d5c7c",
+ "prototypes/workbench/native/app.c": "96ab7faf4848b5eaba5a1fb672eec2ea7882b3e554a4cba1adfac9c202a2b4ca",
+ "prototypes/workbench/native/plan.c": "aa484bfe9072addb00e6eb87edcc11b290b97bd4e362fe9c12105aca8b807f69",
+ "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2",
+ "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04",
+ "prototypes/connections/store.c": "44702e9414c26d1ee336b2e36e6713560013c2c8d0091a8900042e2468f88015",
+ "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f"
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-24-shared-program/fixed-parity.json b/prototypes/workbench/results/2026-09-24-shared-program/fixed-parity.json
new file mode 100644
index 0000000..7d56dd6
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-shared-program/fixed-parity.json
@@ -0,0 +1,205 @@
+{
+ "kind": "native-instance-and-authored-behavior-correctness-not-performance",
+ "sanitized_c_host": true,
+ "profile": "fixed-definition",
+ "general_instance_bytes": 20064,
+ "zig_mode": "ReleaseSafe; Zig object is not ASan-instrumented",
+ "compiler": "cc (Debian 14.2.0-19) 14.2.0",
+ "cases": [
+ {
+ "name": "counter",
+ "commands": 5,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 88,
+ "fixed_profile_sha256": "dfff2e26103b07d6ad79c664cca1a8b074784eade92e575c0cc760d3679b1b18",
+ "fresh_process_restore": true,
+ "source": "# Space or the tap control increments a state that is displayed and drawn.\nstate count = 0\ninput tap = Space\ntext label(80, 90, count, \"Count: \")\nbox tile(80, 150, 40 + min(count, 20) * 8, 60, \"#356548\")\non tap.down {\n count = count + 1\n}\n",
+ "source_sha256": "53cb3a93ff0f26a3d208a5876bfff9ecb1c39e9a01acc101566727733ba166a8",
+ "prepared_sha256": "b7a39d8499bd2e638ce614c24263160c7a16a379c33e2f405d10b04bd99dd780",
+ "native_binary_sha256": "3c85ede423237d68ade84bd27983e1cb2ba7a9a4eb86c384b091badfe4e51ed8",
+ "native_binary_bytes": 558928
+ },
+ {
+ "name": "toggle",
+ "commands": 28,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 96,
+ "fixed_profile_sha256": "aa3508055dece145608b3d98e7fdf6054560d6ef9d17de5a55e0f66e78f71631",
+ "fresh_process_restore": true,
+ "source": "# Space toggles the circle. Right arrow moves the tile.\nstate visible = 1\nstate position = 120\ninput toggle = Space\ninput move = ArrowRight\ncircle marker(position, 140, visible * 24, \"#c78638\")\nbox tile(position - 24, 190, 48, 48, \"#356548\")\ntext value(80, 80, visible, \"Visible: \")\non toggle.down {\n visible = !visible\n}\non move.down {\n position = position + 40\n if position > 560 {\n position = 80\n }\n}\n",
+ "source_sha256": "3de97fab9d316c17ba6a0c82a16cccd3baafc6816b879bf9b0a174210c8a1915",
+ "prepared_sha256": "d3c278f4eb8796e422ef03384373fab6ae517cb682c7432f057c2432c6f8e8e5",
+ "native_binary_sha256": "5c7d08ff87abcc022310f8ec99a1172710912ae0e964e6925cbbe62e51f89ced",
+ "native_binary_bytes": 559000
+ },
+ {
+ "name": "charge",
+ "commands": 104,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 112,
+ "fixed_profile_sha256": "19d850442b7a7043e3b6c11d4554095df296c1dfed07de35730f4fee36a725b8",
+ "fresh_process_restore": true,
+ "source": "# All holding, charging, gravity, jumping, and floor behavior is authored here.\nstate holding = 0\nstate charge = 0\nstate y = 240\nstate velocity = 0\ninput jump = Space\nbox floor(40, 261, 560, 4, \"#356548\")\ncircle actor(180, y, 20, \"#c78638\")\nbox meter(80, 80, charge * 200, 12, \"#c78638\")\ntext amount(80, 60, charge, \"Charge: \")\non jump.down {\n if y >= 240 {\n holding = 1\n charge = 0\n }\n}\non jump.up {\n if holding {\n velocity = -(160 + charge * 300)\n holding = 0\n charge = 0\n }\n}\non frame {\n if holding {\n charge = clamp(charge + dt, 0, 1)\n }\n velocity = velocity + 600 * dt\n y = y + velocity * dt\n if y >= 240 {\n y = 240\n velocity = 0\n }\n}\n",
+ "source_sha256": "8d69d9fd51d44ab754d560eb7ff5102ea8a34540753cd7170b3bd800e4294f56",
+ "prepared_sha256": "99846fb777cb7b1b7289d3fb0662b8138ea7891371798e4e451355cbc629ef10",
+ "native_binary_sha256": "e965e259831d65b6e5e6b2307a045e96e7b2133785b311d04c01df45842cb3d4",
+ "native_binary_bytes": 559912
+ },
+ {
+ "name": "direct",
+ "commands": 3,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 136,
+ "fixed_profile_sha256": "376ec5d1eda3865897afc4538d01685ece57b63ec8b0ba8b7d79f67ed55e146d",
+ "fresh_process_restore": true,
+ "source": "# 도형을 잡은 위치를 유지해 이동합니다. 취소하면 잡기 전 위치로 돌아갑니다.\nstate x = 80\nstate y = 160\nstate grab_x = 0\nstate grab_y = 0\nstate start_x = 80\nstate start_y = 160\nstate dragging = 0\n\ninput drag = pointer(tile)\ninput left = ArrowLeft\ninput right = ArrowRight\n\nbox left_area(20, 100, 280, 220, \"#233a31\")\nbox right_area(340, 100, 280, 220, \"#343b25\")\nbox tile(x, y, 56, 44, \"#c9f08b\")\ntext horizontal(30, 45, x, \"x: \")\ntext vertical(180, 45, y, \"y: \")\ntext region(350, 70, x >= 320, \"오른쪽 영역: \")\ntext active(30, 75, dragging, \"잡고 있음: \")\n\non drag.down {\n start_x = x\n start_y = y\n grab_x = event_x - x\n grab_y = event_y - y\n dragging = 1\n}\non drag.move {\n x = clamp(event_x - grab_x, 20, 564)\n y = clamp(event_y - grab_y, 100, 276)\n}\non drag.up {\n # 놓기는 마지막으로 표시한 위치를 확정합니다.\n dragging = 0\n}\non drag.cancel {\n x = start_x\n y = start_y\n dragging = 0\n}\non left.down {\n if dragging == 0 { x = max(20, x - 10) }\n}\non right.down {\n if dragging == 0 { x = min(564, x + 10) }\n}\n",
+ "source_sha256": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a",
+ "prepared_sha256": "307257b3be2ec979114f85d9b8d37bc6309b4f6fd236bfcdf799f784af0b7644",
+ "native_binary_sha256": "50af581c01391749fa65a957fbb35b00a29a0fb47e7af9e53749dfac0900429e",
+ "native_binary_bytes": 560848
+ },
+ {
+ "name": "routed",
+ "commands": 8,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 280,
+ "fixed_profile_sha256": "4854304aae01c28b5e6fd43a7d16ff3b08ddd711108b4fdde6848eb5f5beaa92",
+ "fresh_process_restore": true,
+ "source": "# 하나의 포인터 입력과 네 반응을 모든 도형이 공유합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag = pointer {\n green_box: green\n blue_box: blue\n amber_box: amber\n}\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag.down use grab(drag.target)\non drag.move use move(drag.target)\non drag.up use drop(drag.target)\non drag.cancel use cancel(drag.target)\n\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n",
+ "source_sha256": "656e81577c250df704ba744b1ea6ec9c987ab3b8ee90c758062ac4beaf195650",
+ "prepared_sha256": "b7524e53a5d90d1ce16d2e2fbaac67ceb242f1cd27f280afc3a80857ad8c21ba",
+ "native_binary_sha256": "bdd8277378c04ac6f336093d12f55747d046fec0f70cbe25286136687898de10",
+ "native_binary_bytes": 564736
+ },
+ {
+ "name": "layout",
+ "commands": 7,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 256,
+ "fixed_profile_sha256": "1c957ab0a71623233c0d122f209c3cc84071da93e44f0704e1b8d4410cbb3cba",
+ "fresh_process_restore": true,
+ "source": "# 세 도형을 배치하고 배치 저장으로 결과를 보관하세요.\n# 마지막으로 잡은 도형은 방향키로 10씩 이동합니다.\nstate selected = 0\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\nstate x1 = 80\nstate y1 = 120\nstate ox1 = 0\nstate oy1 = 0\nstate sx1 = 80\nstate sy1 = 120\nstate held1 = 0\ninput drag1 = pointer(tile1)\nstate x2 = 280\nstate y2 = 160\nstate ox2 = 0\nstate oy2 = 0\nstate sx2 = 280\nstate sy2 = 160\nstate held2 = 0\ninput drag2 = pointer(tile2)\nstate x3 = 450\nstate y3 = 220\nstate ox3 = 0\nstate oy3 = 0\nstate sx3 = 450\nstate sy3 = 220\nstate held3 = 0\ninput drag3 = pointer(tile3)\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox tile1(x1,y1,56,44,\"#c9f08b\")\nbox tile2(x2,y2,56,44,\"#74c4d4\")\nbox tile3(x3,y3,56,44,\"#dfb184\")\non drag1.down {\n selected = 1\n sx1 = x1; sy1 = y1\n ox1 = event_x - x1; oy1 = event_y - y1\n held1 = 1\n}\non drag1.move {\n x1 = clamp(event_x - ox1, 20, 564)\n y1 = clamp(event_y - oy1, 90, 286)\n}\non drag1.up { held1 = 0 }\non drag1.cancel { x1 = sx1; y1 = sy1; held1 = 0 }\non drag2.down {\n selected = 2\n sx2 = x2; sy2 = y2\n ox2 = event_x - x2; oy2 = event_y - y2\n held2 = 1\n}\non drag2.move {\n x2 = clamp(event_x - ox2, 20, 564)\n y2 = clamp(event_y - oy2, 90, 286)\n}\non drag2.up { held2 = 0 }\non drag2.cancel { x2 = sx2; y2 = sy2; held2 = 0 }\non drag3.down {\n selected = 3\n sx3 = x3; sy3 = y3\n ox3 = event_x - x3; oy3 = event_y - y3\n held3 = 1\n}\non drag3.move {\n x3 = clamp(event_x - ox3, 20, 564)\n y3 = clamp(event_y - oy3, 90, 286)\n}\non drag3.up { held3 = 0 }\non drag3.cancel { x3 = sx3; y3 = sy3; held3 = 0 }\non left.down {\n if selected == 1 && held1 == 0 { x1 = clamp(x1 + -10, 20, 564) }\n if selected == 2 && held2 == 0 { x2 = clamp(x2 + -10, 20, 564) }\n if selected == 3 && held3 == 0 { x3 = clamp(x3 + -10, 20, 564) }\n}\non right.down {\n if selected == 1 && held1 == 0 { x1 = clamp(x1 + 10, 20, 564) }\n if selected == 2 && held2 == 0 { x2 = clamp(x2 + 10, 20, 564) }\n if selected == 3 && held3 == 0 { x3 = clamp(x3 + 10, 20, 564) }\n}\non up.down {\n if selected == 1 && held1 == 0 { y1 = clamp(y1 + -10, 90, 286) }\n if selected == 2 && held2 == 0 { y2 = clamp(y2 + -10, 90, 286) }\n if selected == 3 && held3 == 0 { y3 = clamp(y3 + -10, 90, 286) }\n}\non down.down {\n if selected == 1 && held1 == 0 { y1 = clamp(y1 + 10, 90, 286) }\n if selected == 2 && held2 == 0 { y2 = clamp(y2 + 10, 90, 286) }\n if selected == 3 && held3 == 0 { y3 = clamp(y3 + 10, 90, 286) }\n}\n",
+ "source_sha256": "cebd9c3485a2be4ec16ae665c1ed4ee200d51fc56ad0151306c8e9c31138f346",
+ "prepared_sha256": "95a9cb69007033e06f62e8cf7c7f6ea63d8afa85fe90b856666e1facbba7f2f4",
+ "native_binary_sha256": "11d69b3a99c8a9d4d0a19effb28e3ec06fd543d4e9b1590cd4c1534a968424a7",
+ "native_binary_bytes": 563352
+ },
+ {
+ "name": "shared-layout",
+ "commands": 7,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 280,
+ "fixed_profile_sha256": "a2aa8b4aff56d2cff8994126b66777fc02350f6a896bbf6c648710757ec60cfb",
+ "fresh_process_restore": true,
+ "source": "# 대상별 값은 item에, 공통 반응은 rule에 한 번만 작성합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag_green = pointer(green_box)\ninput drag_blue = pointer(blue_box)\ninput drag_amber = pointer(amber_box)\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag_green.down use grab(green)\non drag_green.move use move(green)\non drag_green.up use drop(green)\non drag_green.cancel use cancel(green)\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non drag_blue.down use grab(blue)\non drag_blue.move use move(blue)\non drag_blue.up use drop(blue)\non drag_blue.cancel use cancel(blue)\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non drag_amber.down use grab(amber)\non drag_amber.move use move(amber)\non drag_amber.up use drop(amber)\non drag_amber.cancel use cancel(amber)\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n",
+ "source_sha256": "be8296244b768f9adda99e7afc89249f34853fa27124a277aecb0841eb8176eb",
+ "prepared_sha256": "a0a3a7618e74a615cb2de5d66388a073e15015a1e9a737b8384c70e96fc77fd4",
+ "native_binary_sha256": "521814d64a0c0fd766f3735e31e8ec020ddeb4229ffabe38bbe66461b2c0bf7b",
+ "native_binary_bytes": 563904
+ },
+ {
+ "name": "document",
+ "commands": 5,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 88,
+ "fixed_profile_sha256": "dfa3b8bc6975c3bb3a49e54880e55c8fdfff868eeebd448cae336ef18b9e3ee0",
+ "fresh_process_restore": true,
+ "source": "# 웹 컨트롤이 요청한 분할 비율을 받습니다. 레이아웃과 문서는 브라우저가 소유합니다.\nstate ratio = 50\ninput split = value\ninput reset = KeyR\non split.change { ratio = clamp(event_value, 20, 80) }\non reset.down { ratio = 50 }\n",
+ "source_sha256": "057d2e665d72dd3fce7ac59d232a65e9e0e1a6ca1f8c1e1b36ae766e480367d1",
+ "prepared_sha256": "905b28757ee3e93e69a1631cf88d9b2c63b70570f2484b9d8ca96367af15b5d6",
+ "native_binary_sha256": "2dd90e041e02f97454bacaff36c633577391f4d2ca1210984f4ce46d78660857",
+ "native_binary_bytes": 559000
+ },
+ {
+ "name": "atomic-error",
+ "commands": 2,
+ "failed_events": 1,
+ "states_and_views_match": true,
+ "instance_bytes": 96,
+ "fixed_profile_sha256": "d1da839b23ccfd107c3b5f9c84113d9c9832237e07975d094a4e61bbc085861e",
+ "fresh_process_restore": true,
+ "source": "state x=7\nstate y=2\ninput n=value\non n.change {x=event_value; y=1/event_value}\ntext v(1,2,y,\"value: \")",
+ "source_sha256": "61a5478e7147ef281d617017910af36ad1bae3dabc97c03c8569c5d8b0e5fe19",
+ "prepared_sha256": "8fc04a4b7a6b9d6480374e5ec71379b8afba085c1c18ef7fc31c09eec29cede3",
+ "native_binary_sha256": "b196d28fdeb4adc8f89c28c033bde88c48d922890e4f256ca94d552c7d387dcc",
+ "native_binary_bytes": 558472
+ },
+ {
+ "name": "blank",
+ "commands": 0,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 88,
+ "fixed_profile_sha256": "73d69c68a84728b0189fcd1d77366cd7e86261e966c4074ec16e3e2b9ca59f73",
+ "fresh_process_restore": true,
+ "source": "",
+ "source_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "prepared_sha256": "0b788b46d577755be3dcd0b6f8126fd58de579079f048d57a240b830ef42446c",
+ "native_binary_sha256": "0b1177946d226c9058daf4ac48f1838ae13a722743642143983eff23a31bcc8a",
+ "native_binary_bytes": 548696
+ },
+ {
+ "name": "literal-boundary",
+ "commands": 1,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 88,
+ "fixed_profile_sha256": "0cd50260d50ab3dc787ebea64cc6a973e7f43906244d59110e28ba610f579fd6",
+ "fresh_process_restore": true,
+ "source": "state x=-0\ninput n=value\non n.change {x=event_value}\ntext v(1,2,x,\"한글 \\\" \\\\ \\n\")",
+ "source_sha256": "7f480fa50bbddaf4e3a42bbd2132701e2091ef12b499e30c1bdda1dcd947dcc1",
+ "prepared_sha256": "e07c5dfc5050f6ab50371d342a239deccc2eb392b804630a4ea0e07f24b378c3",
+ "native_binary_sha256": "89833bfc5fe82a69bd75a2b9c70f2b78e138b2100cb9442fa3e1d699333e6714",
+ "native_binary_bytes": 558936
+ }
+ ],
+ "units": {
+ "native_instances": 2,
+ "scalar_instance_bytes": 280,
+ "shared_program_bytes": 2480,
+ "checks": "empty-state-independent-state-identity-capacity-atomic-restore-signed-zero-reset"
+ },
+ "source_sha256": {
+ "prototypes/workbench/core.zig": "ea237848df0dbd2ce620c2874e64bc5ecbe569bf77f3737e9a4fe1e64fea40cc",
+ "prototypes/workbench/engine.zig": "a2ea7923975eeeb11d9222b91887489b374efd91f3f50b312b8d96deec076339",
+ "prototypes/workbench/evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "prototypes/workbench/native.zig": "5656e5e402359b5f1b4bd14c1e73366ed5e32a253a7469a16f58dd8e88104d43",
+ "prototypes/workbench/native-fixed.zig": "15418d44f2f697b65770ea96d8e896de84905dec98af1074784323a51cef5a2b",
+ "prototypes/workbench/manage.py": "6c3aba1b7a6d2b42ce967a0de8a1a74ae19a9751464607e2a349c76347cb62f2",
+ "prototypes/workbench/native/scalar.h": "4a95675e08c4a3bd30f0bef123aeb7327f0cae58297b9b32fc1e7d2a208f1d1a",
+ "prototypes/workbench/native/plan.h": "98825678cb4e9beba4699f4f9679bb6d6637968b513639cac4173c052de206d2",
+ "prototypes/workbench/native/plan.c": "aa484bfe9072addb00e6eb87edcc11b290b97bd4e362fe9c12105aca8b807f69",
+ "prototypes/workbench/native/app.h": "32c738e06f007894457b6b4369ff75c9c2286737885a04159e257c1dcdfa3729",
+ "prototypes/workbench/native/app.c": "96ab7faf4848b5eaba5a1fb672eec2ea7882b3e554a4cba1adfac9c202a2b4ca",
+ "prototypes/workbench/native/main.c": "dd28168741e79ffaa73cf74e60b974a1ca84b0473a43aafe089019f61e732aab",
+ "prototypes/workbench/native/prepare.mjs": "94b732b7cd7a1f8beaf6759bf484fb794449f9d93ed4ea1c3a3186501f8a519a",
+ "prototypes/workbench/native/test.c": "47488a2cfda2b378637f69b8f0d94f75e615d784e8418167b5b82c8ce141f7e8",
+ "prototypes/workbench/native/test-fixed.c": "a6a967239c6510ebcba4ffbcf30a3a08644dea7117f4fe96d4f0e611bbbc17ef",
+ "prototypes/workbench/native/check.mjs": "4beac79764664bfa30ae5693a9063f60879221eafa2fb5b51329dc27bab7fec7",
+ "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1",
+ "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54",
+ "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35",
+ "prototypes/workbench/web/core.wasm": "e5187c44f8d2df952a85e4cf8102b71dbec6c43420893b3544a6f8514a1bff5c",
+ "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2",
+ "prototypes/connections/runtime.h": "ff03c0be92dcc482247a58a0be54d2dc5c270325b51ceeed37afc2ffa5480b1f",
+ "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04",
+ "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc",
+ "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421",
+ "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d",
+ "prototypes/connections/store.c": "44702e9414c26d1ee336b2e36e6713560013c2c8d0091a8900042e2468f88015",
+ "prototypes/connections/store.h": "e3cf5f169be02e40f1274105046f29a63a46cd153beaf204d33d6100b9c869b8",
+ "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f",
+ "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6"
+ },
+ "scalar_archive_sha256": "1dcdfeb56a8fbfb4b4da033c2af5d694699292253f18e0b7d71296f656e3bdda",
+ "scalar_archive_size": " text\t data\t bss\t dec\t hex\tfilename\n 33084\t 0\t 0\t 33084\t 813c\tprototypes/workbench/build/cache/tmp/40c7a059de114231/general_zcu.o (ex /tmp/pure-native-parity-dY4Kg7/general.a)\n",
+ "scalar_archive_undefined": "\nprototypes/workbench/build/cache/tmp/40c7a059de114231/general_zcu.o:\n U memcpy\n U memset\n"
+}
diff --git a/prototypes/workbench/results/2026-09-24-shared-program/general-parity.json b/prototypes/workbench/results/2026-09-24-shared-program/general-parity.json
new file mode 100644
index 0000000..eecb8bb
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-shared-program/general-parity.json
@@ -0,0 +1,209 @@
+{
+ "kind": "native-instance-and-authored-behavior-correctness-not-performance",
+ "sanitized_c_host": true,
+ "profile": "general-authoring",
+ "general_instance_bytes": 20064,
+ "zig_mode": "ReleaseSafe; Zig object is not ASan-instrumented",
+ "compiler": "cc (Debian 14.2.0-19) 14.2.0",
+ "cases": [
+ {
+ "name": "counter",
+ "commands": 5,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 20064,
+ "fixed_profile_sha256": null,
+ "fresh_process_restore": true,
+ "source": "# Space or the tap control increments a state that is displayed and drawn.\nstate count = 0\ninput tap = Space\ntext label(80, 90, count, \"Count: \")\nbox tile(80, 150, 40 + min(count, 20) * 8, 60, \"#356548\")\non tap.down {\n count = count + 1\n}\n",
+ "source_sha256": "53cb3a93ff0f26a3d208a5876bfff9ecb1c39e9a01acc101566727733ba166a8",
+ "prepared_sha256": "b7a39d8499bd2e638ce614c24263160c7a16a379c33e2f405d10b04bd99dd780",
+ "native_binary_sha256": "a171412f698f3dce575b7975b5c12c4e8f7842045cce2cb11c805539a496286f",
+ "native_binary_bytes": 592464
+ },
+ {
+ "name": "toggle",
+ "commands": 28,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 20064,
+ "fixed_profile_sha256": null,
+ "fresh_process_restore": true,
+ "source": "# Space toggles the circle. Right arrow moves the tile.\nstate visible = 1\nstate position = 120\ninput toggle = Space\ninput move = ArrowRight\ncircle marker(position, 140, visible * 24, \"#c78638\")\nbox tile(position - 24, 190, 48, 48, \"#356548\")\ntext value(80, 80, visible, \"Visible: \")\non toggle.down {\n visible = !visible\n}\non move.down {\n position = position + 40\n if position > 560 {\n position = 80\n }\n}\n",
+ "source_sha256": "3de97fab9d316c17ba6a0c82a16cccd3baafc6816b879bf9b0a174210c8a1915",
+ "prepared_sha256": "d3c278f4eb8796e422ef03384373fab6ae517cb682c7432f057c2432c6f8e8e5",
+ "native_binary_sha256": "e79bb3b4b319e249244512befc5ce074212d6b88c4daca3cf99cce1f194afda9",
+ "native_binary_bytes": 592848
+ },
+ {
+ "name": "charge",
+ "commands": 104,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 20064,
+ "fixed_profile_sha256": null,
+ "fresh_process_restore": true,
+ "source": "# All holding, charging, gravity, jumping, and floor behavior is authored here.\nstate holding = 0\nstate charge = 0\nstate y = 240\nstate velocity = 0\ninput jump = Space\nbox floor(40, 261, 560, 4, \"#356548\")\ncircle actor(180, y, 20, \"#c78638\")\nbox meter(80, 80, charge * 200, 12, \"#c78638\")\ntext amount(80, 60, charge, \"Charge: \")\non jump.down {\n if y >= 240 {\n holding = 1\n charge = 0\n }\n}\non jump.up {\n if holding {\n velocity = -(160 + charge * 300)\n holding = 0\n charge = 0\n }\n}\non frame {\n if holding {\n charge = clamp(charge + dt, 0, 1)\n }\n velocity = velocity + 600 * dt\n y = y + velocity * dt\n if y >= 240 {\n y = 240\n velocity = 0\n }\n}\n",
+ "source_sha256": "8d69d9fd51d44ab754d560eb7ff5102ea8a34540753cd7170b3bd800e4294f56",
+ "prepared_sha256": "99846fb777cb7b1b7289d3fb0662b8138ea7891371798e4e451355cbc629ef10",
+ "native_binary_sha256": "9036e33de79b1d16be4b97d8b41729054f54099f2d4445259274c4f048648d2c",
+ "native_binary_bytes": 592912
+ },
+ {
+ "name": "direct",
+ "commands": 3,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 20064,
+ "fixed_profile_sha256": null,
+ "fresh_process_restore": true,
+ "source": "# 도형을 잡은 위치를 유지해 이동합니다. 취소하면 잡기 전 위치로 돌아갑니다.\nstate x = 80\nstate y = 160\nstate grab_x = 0\nstate grab_y = 0\nstate start_x = 80\nstate start_y = 160\nstate dragging = 0\n\ninput drag = pointer(tile)\ninput left = ArrowLeft\ninput right = ArrowRight\n\nbox left_area(20, 100, 280, 220, \"#233a31\")\nbox right_area(340, 100, 280, 220, \"#343b25\")\nbox tile(x, y, 56, 44, \"#c9f08b\")\ntext horizontal(30, 45, x, \"x: \")\ntext vertical(180, 45, y, \"y: \")\ntext region(350, 70, x >= 320, \"오른쪽 영역: \")\ntext active(30, 75, dragging, \"잡고 있음: \")\n\non drag.down {\n start_x = x\n start_y = y\n grab_x = event_x - x\n grab_y = event_y - y\n dragging = 1\n}\non drag.move {\n x = clamp(event_x - grab_x, 20, 564)\n y = clamp(event_y - grab_y, 100, 276)\n}\non drag.up {\n # 놓기는 마지막으로 표시한 위치를 확정합니다.\n dragging = 0\n}\non drag.cancel {\n x = start_x\n y = start_y\n dragging = 0\n}\non left.down {\n if dragging == 0 { x = max(20, x - 10) }\n}\non right.down {\n if dragging == 0 { x = min(564, x + 10) }\n}\n",
+ "source_sha256": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a",
+ "prepared_sha256": "307257b3be2ec979114f85d9b8d37bc6309b4f6fd236bfcdf799f784af0b7644",
+ "native_binary_sha256": "2f95aa15b0d12ec4696e5bfb47f7675c32f02b864723b68e6f6f27d7ee8af869",
+ "native_binary_bytes": 593680
+ },
+ {
+ "name": "routed",
+ "commands": 8,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 20064,
+ "fixed_profile_sha256": null,
+ "fresh_process_restore": true,
+ "source": "# 하나의 포인터 입력과 네 반응을 모든 도형이 공유합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag = pointer {\n green_box: green\n blue_box: blue\n amber_box: amber\n}\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag.down use grab(drag.target)\non drag.move use move(drag.target)\non drag.up use drop(drag.target)\non drag.cancel use cancel(drag.target)\n\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n",
+ "source_sha256": "656e81577c250df704ba744b1ea6ec9c987ab3b8ee90c758062ac4beaf195650",
+ "prepared_sha256": "b7524e53a5d90d1ce16d2e2fbaac67ceb242f1cd27f280afc3a80857ad8c21ba",
+ "native_binary_sha256": "c072b4c631710945d1eaffe3ed5fd13a5ebe5463c98042294f93b6f6d4a8db7c",
+ "native_binary_bytes": 594832
+ },
+ {
+ "name": "layout",
+ "commands": 7,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 20064,
+ "fixed_profile_sha256": null,
+ "fresh_process_restore": true,
+ "source": "# 세 도형을 배치하고 배치 저장으로 결과를 보관하세요.\n# 마지막으로 잡은 도형은 방향키로 10씩 이동합니다.\nstate selected = 0\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\nstate x1 = 80\nstate y1 = 120\nstate ox1 = 0\nstate oy1 = 0\nstate sx1 = 80\nstate sy1 = 120\nstate held1 = 0\ninput drag1 = pointer(tile1)\nstate x2 = 280\nstate y2 = 160\nstate ox2 = 0\nstate oy2 = 0\nstate sx2 = 280\nstate sy2 = 160\nstate held2 = 0\ninput drag2 = pointer(tile2)\nstate x3 = 450\nstate y3 = 220\nstate ox3 = 0\nstate oy3 = 0\nstate sx3 = 450\nstate sy3 = 220\nstate held3 = 0\ninput drag3 = pointer(tile3)\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox tile1(x1,y1,56,44,\"#c9f08b\")\nbox tile2(x2,y2,56,44,\"#74c4d4\")\nbox tile3(x3,y3,56,44,\"#dfb184\")\non drag1.down {\n selected = 1\n sx1 = x1; sy1 = y1\n ox1 = event_x - x1; oy1 = event_y - y1\n held1 = 1\n}\non drag1.move {\n x1 = clamp(event_x - ox1, 20, 564)\n y1 = clamp(event_y - oy1, 90, 286)\n}\non drag1.up { held1 = 0 }\non drag1.cancel { x1 = sx1; y1 = sy1; held1 = 0 }\non drag2.down {\n selected = 2\n sx2 = x2; sy2 = y2\n ox2 = event_x - x2; oy2 = event_y - y2\n held2 = 1\n}\non drag2.move {\n x2 = clamp(event_x - ox2, 20, 564)\n y2 = clamp(event_y - oy2, 90, 286)\n}\non drag2.up { held2 = 0 }\non drag2.cancel { x2 = sx2; y2 = sy2; held2 = 0 }\non drag3.down {\n selected = 3\n sx3 = x3; sy3 = y3\n ox3 = event_x - x3; oy3 = event_y - y3\n held3 = 1\n}\non drag3.move {\n x3 = clamp(event_x - ox3, 20, 564)\n y3 = clamp(event_y - oy3, 90, 286)\n}\non drag3.up { held3 = 0 }\non drag3.cancel { x3 = sx3; y3 = sy3; held3 = 0 }\non left.down {\n if selected == 1 && held1 == 0 { x1 = clamp(x1 + -10, 20, 564) }\n if selected == 2 && held2 == 0 { x2 = clamp(x2 + -10, 20, 564) }\n if selected == 3 && held3 == 0 { x3 = clamp(x3 + -10, 20, 564) }\n}\non right.down {\n if selected == 1 && held1 == 0 { x1 = clamp(x1 + 10, 20, 564) }\n if selected == 2 && held2 == 0 { x2 = clamp(x2 + 10, 20, 564) }\n if selected == 3 && held3 == 0 { x3 = clamp(x3 + 10, 20, 564) }\n}\non up.down {\n if selected == 1 && held1 == 0 { y1 = clamp(y1 + -10, 90, 286) }\n if selected == 2 && held2 == 0 { y2 = clamp(y2 + -10, 90, 286) }\n if selected == 3 && held3 == 0 { y3 = clamp(y3 + -10, 90, 286) }\n}\non down.down {\n if selected == 1 && held1 == 0 { y1 = clamp(y1 + 10, 90, 286) }\n if selected == 2 && held2 == 0 { y2 = clamp(y2 + 10, 90, 286) }\n if selected == 3 && held3 == 0 { y3 = clamp(y3 + 10, 90, 286) }\n}\n",
+ "source_sha256": "cebd9c3485a2be4ec16ae665c1ed4ee200d51fc56ad0151306c8e9c31138f346",
+ "prepared_sha256": "95a9cb69007033e06f62e8cf7c7f6ea63d8afa85fe90b856666e1facbba7f2f4",
+ "native_binary_sha256": "63acfed8146f6b2421b82ca7fba44207873e309badfa15bed933a8802366c56b",
+ "native_binary_bytes": 594800
+ },
+ {
+ "name": "shared-layout",
+ "commands": 7,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 20064,
+ "fixed_profile_sha256": null,
+ "fresh_process_restore": true,
+ "source": "# 대상별 값은 item에, 공통 반응은 rule에 한 번만 작성합니다.\nstate selected = 0\nrecord Tile {\n x = 0; y = 0\n ox = 0; oy = 0\n sx = 0; sy = 0\n held = 0; id = 0\n}\nitem green = Tile(x=80, y=120, sx=80, sy=120, id=1)\nitem blue = Tile(x=280, y=160, sx=280, sy=160, id=2)\nitem amber = Tile(x=450, y=220, sx=450, sy=220, id=3)\ninput left = ArrowLeft\ninput right = ArrowRight\ninput up = ArrowUp\ninput down = ArrowDown\ninput drag_green = pointer(green_box)\ninput drag_blue = pointer(blue_box)\ninput drag_amber = pointer(amber_box)\nbox board(20,90,600,240,\"#233a31\")\ntext help(25,40,selected,\"선택한 도형: \")\nbox green_box(green.x,green.y,56,44,\"#c9f08b\")\nbox blue_box(blue.x,blue.y,56,44,\"#74c4d4\")\nbox amber_box(amber.x,amber.y,56,44,\"#dfb184\")\n\nrule grab(t: Tile) {\n selected = t.id\n t.sx = t.x; t.sy = t.y\n t.ox = event_x - t.x; t.oy = event_y - t.y\n t.held = 1\n}\nrule move(t: Tile) {\n t.x = clamp(event_x - t.ox, 20, 564)\n t.y = clamp(event_y - t.oy, 90, 286)\n}\nrule drop(t: Tile) { t.held = 0 }\nrule cancel(t: Tile) { t.x = t.sx; t.y = t.sy; t.held = 0 }\nrule go_left(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x - 10, 20, 564) }\n}\nrule go_right(t: Tile) {\n if selected == t.id && t.held == 0 { t.x = clamp(t.x + 10, 20, 564) }\n}\nrule go_up(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y - 10, 90, 286) }\n}\nrule go_down(t: Tile) {\n if selected == t.id && t.held == 0 { t.y = clamp(t.y + 10, 90, 286) }\n}\n\non drag_green.down use grab(green)\non drag_green.move use move(green)\non drag_green.up use drop(green)\non drag_green.cancel use cancel(green)\non left.down use go_left(green)\non right.down use go_right(green)\non up.down use go_up(green)\non down.down use go_down(green)\n\non drag_blue.down use grab(blue)\non drag_blue.move use move(blue)\non drag_blue.up use drop(blue)\non drag_blue.cancel use cancel(blue)\non left.down use go_left(blue)\non right.down use go_right(blue)\non up.down use go_up(blue)\non down.down use go_down(blue)\n\non drag_amber.down use grab(amber)\non drag_amber.move use move(amber)\non drag_amber.up use drop(amber)\non drag_amber.cancel use cancel(amber)\non left.down use go_left(amber)\non right.down use go_right(amber)\non up.down use go_up(amber)\non down.down use go_down(amber)\n",
+ "source_sha256": "be8296244b768f9adda99e7afc89249f34853fa27124a277aecb0841eb8176eb",
+ "prepared_sha256": "a0a3a7618e74a615cb2de5d66388a073e15015a1e9a737b8384c70e96fc77fd4",
+ "native_binary_sha256": "ad57a6507cb23f0a7a1429df8c47df4cd226e21d3f1f7ca4ce406d566f8440f2",
+ "native_binary_bytes": 594976
+ },
+ {
+ "name": "document",
+ "commands": 5,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 20064,
+ "fixed_profile_sha256": null,
+ "fresh_process_restore": true,
+ "source": "# 웹 컨트롤이 요청한 분할 비율을 받습니다. 레이아웃과 문서는 브라우저가 소유합니다.\nstate ratio = 50\ninput split = value\ninput reset = KeyR\non split.change { ratio = clamp(event_value, 20, 80) }\non reset.down { ratio = 50 }\n",
+ "source_sha256": "057d2e665d72dd3fce7ac59d232a65e9e0e1a6ca1f8c1e1b36ae766e480367d1",
+ "prepared_sha256": "905b28757ee3e93e69a1631cf88d9b2c63b70570f2484b9d8ca96367af15b5d6",
+ "native_binary_sha256": "3d31de6e4ef3c707f82165c1283edac64883e6487d898f0d3f76b0acced5ef67",
+ "native_binary_bytes": 592144
+ },
+ {
+ "name": "atomic-error",
+ "commands": 2,
+ "failed_events": 1,
+ "states_and_views_match": true,
+ "instance_bytes": 20064,
+ "fixed_profile_sha256": null,
+ "fresh_process_restore": true,
+ "source": "state x=7\nstate y=2\ninput n=value\non n.change {x=event_value; y=1/event_value}\ntext v(1,2,y,\"value: \")",
+ "source_sha256": "61a5478e7147ef281d617017910af36ad1bae3dabc97c03c8569c5d8b0e5fe19",
+ "prepared_sha256": "8fc04a4b7a6b9d6480374e5ec71379b8afba085c1c18ef7fc31c09eec29cede3",
+ "native_binary_sha256": "54b6a9b0c2f4d6814a6654be8d740ae95c1f4d941e0bb7720d9051e698f89544",
+ "native_binary_bytes": 592280
+ },
+ {
+ "name": "blank",
+ "commands": 0,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 20064,
+ "fixed_profile_sha256": null,
+ "fresh_process_restore": true,
+ "source": "",
+ "source_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "prepared_sha256": "0b788b46d577755be3dcd0b6f8126fd58de579079f048d57a240b830ef42446c",
+ "native_binary_sha256": "370e38fbb95d9fdcc34b302060321495669d068e4f674956b9c1ae6e6e57a9ca",
+ "native_binary_bytes": 591824
+ },
+ {
+ "name": "literal-boundary",
+ "commands": 1,
+ "failed_events": 0,
+ "states_and_views_match": true,
+ "instance_bytes": 20064,
+ "fixed_profile_sha256": null,
+ "fresh_process_restore": true,
+ "source": "state x=-0\ninput n=value\non n.change {x=event_value}\ntext v(1,2,x,\"한글 \\\" \\\\ \\n\")",
+ "source_sha256": "7f480fa50bbddaf4e3a42bbd2132701e2091ef12b499e30c1bdda1dcd947dcc1",
+ "prepared_sha256": "e07c5dfc5050f6ab50371d342a239deccc2eb392b804630a4ea0e07f24b378c3",
+ "native_binary_sha256": "bbe8cef640977f1d42315f1a545c49a5f07b599e2ada602cbcd6c148464b6708",
+ "native_binary_bytes": 592224
+ }
+ ],
+ "units": {
+ "native_instances": 3,
+ "checks": "independent-state-staging-inputs-errors-budget-snapshot-conflict-restore-plan-reload",
+ "scalar_instance_bytes": 20064,
+ "scalar_alignment": 8,
+ "app_controller_bytes": 928,
+ "action_bytes": 32,
+ "snapshot_bytes": 244,
+ "all_work_drained": true
+ },
+ "source_sha256": {
+ "prototypes/workbench/core.zig": "ea237848df0dbd2ce620c2874e64bc5ecbe569bf77f3737e9a4fe1e64fea40cc",
+ "prototypes/workbench/engine.zig": "a2ea7923975eeeb11d9222b91887489b374efd91f3f50b312b8d96deec076339",
+ "prototypes/workbench/evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "prototypes/workbench/native.zig": "5656e5e402359b5f1b4bd14c1e73366ed5e32a253a7469a16f58dd8e88104d43",
+ "prototypes/workbench/native-fixed.zig": "15418d44f2f697b65770ea96d8e896de84905dec98af1074784323a51cef5a2b",
+ "prototypes/workbench/manage.py": "6c3aba1b7a6d2b42ce967a0de8a1a74ae19a9751464607e2a349c76347cb62f2",
+ "prototypes/workbench/native/scalar.h": "4a95675e08c4a3bd30f0bef123aeb7327f0cae58297b9b32fc1e7d2a208f1d1a",
+ "prototypes/workbench/native/plan.h": "98825678cb4e9beba4699f4f9679bb6d6637968b513639cac4173c052de206d2",
+ "prototypes/workbench/native/plan.c": "aa484bfe9072addb00e6eb87edcc11b290b97bd4e362fe9c12105aca8b807f69",
+ "prototypes/workbench/native/app.h": "32c738e06f007894457b6b4369ff75c9c2286737885a04159e257c1dcdfa3729",
+ "prototypes/workbench/native/app.c": "96ab7faf4848b5eaba5a1fb672eec2ea7882b3e554a4cba1adfac9c202a2b4ca",
+ "prototypes/workbench/native/main.c": "dd28168741e79ffaa73cf74e60b974a1ca84b0473a43aafe089019f61e732aab",
+ "prototypes/workbench/native/prepare.mjs": "94b732b7cd7a1f8beaf6759bf484fb794449f9d93ed4ea1c3a3186501f8a519a",
+ "prototypes/workbench/native/test.c": "47488a2cfda2b378637f69b8f0d94f75e615d784e8418167b5b82c8ce141f7e8",
+ "prototypes/workbench/native/test-fixed.c": "f89cf711090ac5c152b6713d3afc15081d0d41d3bb78c54705ef400bde8b6131",
+ "prototypes/workbench/native/check.mjs": "4beac79764664bfa30ae5693a9063f60879221eafa2fb5b51329dc27bab7fec7",
+ "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1",
+ "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54",
+ "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35",
+ "prototypes/workbench/web/core.wasm": "e5187c44f8d2df952a85e4cf8102b71dbec6c43420893b3544a6f8514a1bff5c",
+ "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2",
+ "prototypes/connections/runtime.h": "ff03c0be92dcc482247a58a0be54d2dc5c270325b51ceeed37afc2ffa5480b1f",
+ "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04",
+ "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc",
+ "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421",
+ "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d",
+ "prototypes/connections/store.c": "44702e9414c26d1ee336b2e36e6713560013c2c8d0091a8900042e2468f88015",
+ "prototypes/connections/store.h": "e3cf5f169be02e40f1274105046f29a63a46cd153beaf204d33d6100b9c869b8",
+ "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f",
+ "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6"
+ },
+ "scalar_archive_sha256": "766e7a761e36aaa4f7d5cc38605d5fe2f625f8db3e221901fcfeffa654ed42a1",
+ "scalar_archive_size": " text\t data\t bss\t dec\t hex\tfilename\n 33084\t 0\t 0\t 33084\t 813c\tprototypes/workbench/build/cache/tmp/62dc1b2e933cdcfa/general_zcu.o (ex /tmp/pure-native-parity-RFDTHK/general.a)\n",
+ "scalar_archive_undefined": "\nprototypes/workbench/build/cache/tmp/62dc1b2e933cdcfa/general_zcu.o:\n U memcpy\n U memset\n"
+}
diff --git a/prototypes/workbench/results/2026-09-24-shared-program/instance-memory.json b/prototypes/workbench/results/2026-09-24-shared-program/instance-memory.json
new file mode 100644
index 0000000..91329ee
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-shared-program/instance-memory.json
@@ -0,0 +1,71 @@
+{
+ "host": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41",
+ "samples": {
+ "general": [
+ {
+ "instances": 1000,
+ "instance_bytes": 20064,
+ "rss_before_kib": 1248,
+ "rss_after_kib": 21056,
+ "rss_delta_kib": 19808,
+ "checksum": 1000
+ },
+ {
+ "instances": 1000,
+ "instance_bytes": 20064,
+ "rss_before_kib": 1216,
+ "rss_after_kib": 21024,
+ "rss_delta_kib": 19808,
+ "checksum": 1000
+ },
+ {
+ "instances": 1000,
+ "instance_bytes": 20064,
+ "rss_before_kib": 1232,
+ "rss_after_kib": 21040,
+ "rss_delta_kib": 19808,
+ "checksum": 1000
+ }
+ ],
+ "shared_fixed": [
+ {
+ "instances": 1000,
+ "instance_bytes": 280,
+ "rss_before_kib": 1232,
+ "rss_after_kib": 1712,
+ "rss_delta_kib": 480,
+ "checksum": 1000
+ },
+ {
+ "instances": 1000,
+ "instance_bytes": 280,
+ "rss_before_kib": 1200,
+ "rss_after_kib": 1680,
+ "rss_delta_kib": 480,
+ "checksum": 1000
+ },
+ {
+ "instances": 1000,
+ "instance_bytes": 280,
+ "rss_before_kib": 1216,
+ "rss_after_kib": 1696,
+ "rss_delta_kib": 480,
+ "checksum": 1000
+ }
+ ]
+ },
+ "binary_sha256": {
+ "general": "b25a49909a01510d38c0c4fe125c7542b2dc343d460203f3c446ba278696acfe",
+ "shared_fixed": "3dddca197b168c404c308bef618a71020b5dece98c43c24c3fad1881e5401c46"
+ },
+ "median_rss_delta_kib": {
+ "general": 19808,
+ "shared_fixed": 480
+ },
+ "zig_version": "0.15.2",
+ "build": {
+ "zig": "ReleaseSafe -fno-compiler-rt",
+ "c": "-std=c17 -O2 -g -Wall -Wextra -Wpedantic -Werror",
+ "fixed_c_define": "PURE_FIXED_DEFINITION=1"
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-24-shared-program/peer-build.json b/prototypes/workbench/results/2026-09-24-shared-program/peer-build.json
new file mode 100644
index 0000000..8499d96
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-shared-program/peer-build.json
@@ -0,0 +1,53 @@
+{
+ "source": "/home/eunhhu/work/pure/prototypes/workbench/examples/routed-layout.pure",
+ "sourceSha256": "656e81577c250df704ba744b1ea6ec9c987ab3b8ee90c758062ac4beaf195650",
+ "zigVersion": "0.15.2",
+ "zigFlags": [
+ "-O",
+ "ReleaseSafe",
+ "-fno-compiler-rt"
+ ],
+ "scalarProfile": "fixed-definition",
+ "zigRoot": "/home/eunhhu/work/pure/prototypes/workbench/native-fixed.zig",
+ "cFlags": [
+ "-std=c17",
+ "-O2",
+ "-g",
+ "-Wall",
+ "-Wextra",
+ "-Wpedantic",
+ "-Werror",
+ "-DPURE_FIXED_DEFINITION=1"
+ ],
+ "binary": "/home/eunhhu/work/pure/prototypes/workbench/build/native/pure-peer",
+ "binarySha256": "058eff07a94e0fefb655263acb804e07332fed0f685951d81be31b6b39642a22",
+ "librarySha256": "aeb0052b016704cd77e0f673c1e270df7a26816a0f03e4d778077db8c902423a",
+ "fixedProfileSha256": "4854304aae01c28b5e6fd43a7d16ff3b08ddd711108b4fdde6848eb5f5beaa92",
+ "preparedSha256": "b7524e53a5d90d1ce16d2e2fbaac67ceb242f1cd27f280afc3a80857ad8c21ba",
+ "desktop": false,
+ "desktopFlags": [],
+ "peer": true,
+ "peerFlags": [
+ "-lssl",
+ "-lcrypto"
+ ],
+ "implementationSha256": {
+ "prototypes/workbench/engine.zig": "a2ea7923975eeeb11d9222b91887489b374efd91f3f50b312b8d96deec076339",
+ "prototypes/workbench/evaluation.zig": "ce04f0785180aaf6d1dcddc4461817251ad61abf1625ccd04ce673c89774b165",
+ "prototypes/workbench/native.zig": "5656e5e402359b5f1b4bd14c1e73366ed5e32a253a7469a16f58dd8e88104d43",
+ "prototypes/workbench/native-fixed.zig": "15418d44f2f697b65770ea96d8e896de84905dec98af1074784323a51cef5a2b",
+ "prototypes/workbench/native/prepare.mjs": "94b732b7cd7a1f8beaf6759bf484fb794449f9d93ed4ea1c3a3186501f8a519a",
+ "prototypes/workbench/native/scalar.h": "4a95675e08c4a3bd30f0bef123aeb7327f0cae58297b9b32fc1e7d2a208f1d1a",
+ "prototypes/workbench/native/plan.h": "98825678cb4e9beba4699f4f9679bb6d6637968b513639cac4173c052de206d2",
+ "prototypes/workbench/native/app.h": "32c738e06f007894457b6b4369ff75c9c2286737885a04159e257c1dcdfa3729",
+ "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1",
+ "prototypes/workbench/native/peer.c": "7d5865d499a4ba51f1d14e0d286480f605be2476616d28e1150b35ca1f87bf56",
+ "prototypes/workbench/native/peer-wire.c": "8fc3694db7a00a4f4899ca5a42dc2c0d96e39cb7d927c759ee8881901241733a",
+ "prototypes/workbench/native/app.c": "96ab7faf4848b5eaba5a1fb672eec2ea7882b3e554a4cba1adfac9c202a2b4ca",
+ "prototypes/workbench/native/plan.c": "aa484bfe9072addb00e6eb87edcc11b290b97bd4e362fe9c12105aca8b807f69",
+ "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2",
+ "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04",
+ "prototypes/connections/store.c": "44702e9414c26d1ee336b2e36e6713560013c2c8d0091a8900042e2468f88015",
+ "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f"
+ }
+}
diff --git a/prototypes/workbench/results/2026-09-24-shared-program/peer-check.json b/prototypes/workbench/results/2026-09-24-shared-program/peer-check.json
new file mode 100644
index 0000000..4025aa6
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-shared-program/peer-check.json
@@ -0,0 +1 @@
+{"alpn": "pass", "disconnect_release": "pass", "idle_deadline": "pass", "malformed_frames": "pass", "mutual_authentication": "pass", "persist_restart": "pass", "python_client": "pass", "revision_conflict": "pass", "server_connected_rss_kib": 8448, "server_hostname": "pass", "server_idle_rss_kib": 7856}
diff --git a/prototypes/workbench/results/2026-09-24-shared-program/size.txt b/prototypes/workbench/results/2026-09-24-shared-program/size.txt
new file mode 100644
index 0000000..a815c80
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-shared-program/size.txt
@@ -0,0 +1,5 @@
+ text data bss dec hex filename
+ 10396 0 0 10396 289c prototypes/workbench/build/cache/tmp/0d9c963faf84813d/libpure-scalar-fixed_zcu.o (ex prototypes/workbench/build/native/libpure-scalar-fixed.a)
+ 37135 2040 16 39191 9917 prototypes/workbench/build/native/pure-native
+ 50277 2472 16 52765 ce1d prototypes/workbench/build/native/pure-peer
+ 33084 0 0 33084 813c prototypes/workbench/build/cache/tmp/0bfbc00652c46618/pure-shared-general-scalar-final_zcu.o (ex /tmp/pure-shared-general-scalar-final.a)
diff --git a/prototypes/workbench/results/2026-09-24-shared-program/stripped.txt b/prototypes/workbench/results/2026-09-24-shared-program/stripped.txt
new file mode 100644
index 0000000..a63dc8e
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-shared-program/stripped.txt
@@ -0,0 +1,2 @@
+pure-native original=196688 stripped=68000
+pure-peer original=241416 stripped=68400
diff --git a/prototypes/workbench/results/2026-09-24-shared-program/symbols.txt b/prototypes/workbench/results/2026-09-24-shared-program/symbols.txt
new file mode 100644
index 0000000..75dab69
--- /dev/null
+++ b/prototypes/workbench/results/2026-09-24-shared-program/symbols.txt
@@ -0,0 +1,4 @@
+pure-native
+0000000000007348 00000000000009b0 r engine.shared_program
+pure-peer
+000000000000a1e8 00000000000009b0 r engine.shared_program